Set up app skeleton: folder structure, networking abstraction, and UI
Establishes the foundational architecture for DisklavierLink: Networking layer - PianoClientProtocol defines the full async API (play, pause, stop, record, volume, skip, connect, fetchSongList) - PianoClient is a stub implementation that logs calls and simulates short async delays; fetchSongList returns hardcoded sample songs - PianoConnectionError carries a French-language error description for when the piano is unreachable (used by the simulation toggle and, later, by real HTTP failures) State and persistence - Song model (Identifiable, Codable, Hashable) with optional artist and duration fields, ready to be mapped from the future JSON API response - SettingsStore persists host, port, and autoConnectOnLaunch to UserDefaults via @Published + didSet (replacing @AppStorage, which does not work in non-View ObservableObject classes) - PianoViewModel (@MainActor ObservableObject) owns the client and settings, wraps every protocol call with error handling, and exposes isSimulatedPianoOn so the unreachable-piano path can be exercised without a physical device UI - Connection settings moved to a dedicated Settings window (Cmd+,) using SwiftUI's Settings scene and a grouped Form - Auto-connect on launch option in Settings (off by default) - Main window redesigned as a music-player layout: Now Playing card with gradient artwork tile and progress track placeholder, large circular play/pause button, sidebar song library with row selection - Connection banner appears only when disconnected, showing the French error message with a shortcut to Settings - Toolbar holds a status badge, Connect/Disconnect button, and a simulation power toggle (green = piano on, red = piano off) for testing the error path without real hardware Project - Source files reorganised into App/, Models/, Networking/, ViewModels/, Views/ subdirectories (PBXFileSystemSynchronizedRootGroup picks them up automatically — no project.pbxproj file-reference edits needed) - com.apple.security.network.client entitlement added - Deployment target updated to macOS 14.0 - SwiftFormat hook and /run skill added to .claude/ Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
9e99921859
commit
1a8aa6e0b3
17
.claude/settings.json
Normal file
17
.claude/settings.json
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
{
|
||||||
|
"hooks": {
|
||||||
|
"PostToolUse": [
|
||||||
|
{
|
||||||
|
"matcher": "Write|Edit",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "jq -r '.tool_input.file_path // .tool_response.filePath' | { read -r f; case \"$f\" in *.swift) /opt/homebrew/bin/swiftformat --swift-version 5 \"$f\" 2>/dev/null || true ;; esac; }",
|
||||||
|
"timeout": 30,
|
||||||
|
"statusMessage": "Formatting Swift file..."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
16
.claude/skills/run/SKILL.md
Normal file
16
.claude/skills/run/SKILL.md
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
---
|
||||||
|
name: run
|
||||||
|
description: Build the DisklavierLink macOS app with xcodebuild and open it so the developer can see changes live. Use when asked to run, launch, or test the app visually.
|
||||||
|
---
|
||||||
|
|
||||||
|
Build and launch the DisklavierLink macOS app:
|
||||||
|
|
||||||
|
1. Run `xcodebuild -project DisklavierLink.xcodeproj -scheme DisklavierLink -configuration Debug build` from the project root. Capture stdout and stderr.
|
||||||
|
|
||||||
|
2. If the build fails, show the relevant error lines (not the full log — filter for `error:` lines) and stop. Do not try to open a non-existent binary.
|
||||||
|
|
||||||
|
3. If the build succeeds, find the built app. The output path is typically inside `~/Library/Developer/Xcode/DerivedData/DisklavierLink-*/Build/Products/Debug/DisklavierLink.app`. Use `find ~/Library/Developer/Xcode/DerivedData -name "DisklavierLink.app" -maxdepth 6` if needed.
|
||||||
|
|
||||||
|
4. Open it with `open <path-to-DisklavierLink.app>`.
|
||||||
|
|
||||||
|
5. Report whether the app launched successfully. Note any build warnings that look relevant to recent changes.
|
||||||
7
.gitignore
vendored
7
.gitignore
vendored
|
|
@ -15,3 +15,10 @@ xcuserdata/
|
||||||
# CocoaPods / Carthage (if you ever add them)
|
# CocoaPods / Carthage (if you ever add them)
|
||||||
Pods/
|
Pods/
|
||||||
Carthage/Build/
|
Carthage/Build/
|
||||||
|
|
||||||
|
# Personal Claude Code preferences
|
||||||
|
CLAUDE.local.md
|
||||||
|
|
||||||
|
# Claude Code — local-only files (personal permissions, worktree sandboxes)
|
||||||
|
.claude/settings.local.json
|
||||||
|
.claude/worktrees/
|
||||||
|
|
|
||||||
|
|
@ -179,7 +179,7 @@
|
||||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||||
MACOSX_DEPLOYMENT_TARGET = 15.6;
|
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
||||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||||
MTL_FAST_MATH = YES;
|
MTL_FAST_MATH = YES;
|
||||||
ONLY_ACTIVE_ARCH = YES;
|
ONLY_ACTIVE_ARCH = YES;
|
||||||
|
|
@ -237,7 +237,7 @@
|
||||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||||
MACOSX_DEPLOYMENT_TARGET = 15.6;
|
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
||||||
MTL_ENABLE_DEBUG_INFO = NO;
|
MTL_ENABLE_DEBUG_INFO = NO;
|
||||||
MTL_FAST_MATH = YES;
|
MTL_FAST_MATH = YES;
|
||||||
SDKROOT = macosx;
|
SDKROOT = macosx;
|
||||||
|
|
@ -250,6 +250,7 @@
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
|
CODE_SIGN_ENTITLEMENTS = DisklavierLink/DisklavierLink.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
COMBINE_HIDPI_IMAGES = YES;
|
COMBINE_HIDPI_IMAGES = YES;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
|
|
@ -282,6 +283,7 @@
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
|
CODE_SIGN_ENTITLEMENTS = DisklavierLink/DisklavierLink.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
COMBINE_HIDPI_IMAGES = YES;
|
COMBINE_HIDPI_IMAGES = YES;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
|
|
|
||||||
22
DisklavierLink/App/DisklavierLinkApp.swift
Normal file
22
DisklavierLink/App/DisklavierLinkApp.swift
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
@main
|
||||||
|
struct DisklavierLinkApp: App {
|
||||||
|
@StateObject private var viewModel = PianoViewModel()
|
||||||
|
|
||||||
|
var body: some Scene {
|
||||||
|
WindowGroup {
|
||||||
|
ContentView(viewModel: viewModel)
|
||||||
|
.task {
|
||||||
|
if viewModel.settings.autoConnectOnLaunch {
|
||||||
|
await viewModel.connect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Settings {
|
||||||
|
SettingsView()
|
||||||
|
.environmentObject(viewModel.settings)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
//
|
|
||||||
// ContentView.swift
|
|
||||||
// DisklavierLink
|
|
||||||
//
|
|
||||||
// Created by Maxence Socheleau on 02.07.2026.
|
|
||||||
//
|
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
struct ContentView: View {
|
|
||||||
var body: some View {
|
|
||||||
VStack {
|
|
||||||
Image(systemName: "globe")
|
|
||||||
.imageScale(.large)
|
|
||||||
.foregroundStyle(.tint)
|
|
||||||
Text("Hello, world!")
|
|
||||||
}
|
|
||||||
.padding()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#Preview {
|
|
||||||
ContentView()
|
|
||||||
}
|
|
||||||
8
DisklavierLink/DisklavierLink.entitlements
Normal file
8
DisklavierLink/DisklavierLink.entitlements
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>com.apple.security.network.client</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
//
|
|
||||||
// DisklavierLinkApp.swift
|
|
||||||
// DisklavierLink
|
|
||||||
//
|
|
||||||
// Created by Maxence Socheleau on 02.07.2026.
|
|
||||||
//
|
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
@main
|
|
||||||
struct DisklavierLinkApp: App {
|
|
||||||
var body: some Scene {
|
|
||||||
WindowGroup {
|
|
||||||
ContentView()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
8
DisklavierLink/Models/Song.swift
Normal file
8
DisklavierLink/Models/Song.swift
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct Song: Identifiable, Codable, Hashable {
|
||||||
|
let id: String
|
||||||
|
var title: String
|
||||||
|
var durationSeconds: Int?
|
||||||
|
var artist: String?
|
||||||
|
}
|
||||||
57
DisklavierLink/Networking/PianoClient.swift
Normal file
57
DisklavierLink/Networking/PianoClient.swift
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct PianoClient: PianoClientProtocol {
|
||||||
|
nonisolated init() {}
|
||||||
|
|
||||||
|
func connect(host: String, port: Int) async throws {
|
||||||
|
print("PianoClient.connect(host: \(host), port: \(port)) called")
|
||||||
|
try await Task.sleep(for: .milliseconds(300))
|
||||||
|
}
|
||||||
|
|
||||||
|
func play() async throws {
|
||||||
|
print("PianoClient.play() called")
|
||||||
|
try await Task.sleep(for: .milliseconds(150))
|
||||||
|
}
|
||||||
|
|
||||||
|
func pause() async throws {
|
||||||
|
print("PianoClient.pause() called")
|
||||||
|
try await Task.sleep(for: .milliseconds(150))
|
||||||
|
}
|
||||||
|
|
||||||
|
func stop() async throws {
|
||||||
|
print("PianoClient.stop() called")
|
||||||
|
try await Task.sleep(for: .milliseconds(150))
|
||||||
|
}
|
||||||
|
|
||||||
|
func record() async throws {
|
||||||
|
print("PianoClient.record() called")
|
||||||
|
try await Task.sleep(for: .milliseconds(150))
|
||||||
|
}
|
||||||
|
|
||||||
|
func setVolume(_ level: Double) async throws {
|
||||||
|
print("PianoClient.setVolume(\(String(format: "%.2f", level))) called")
|
||||||
|
try await Task.sleep(for: .milliseconds(100))
|
||||||
|
}
|
||||||
|
|
||||||
|
func skipForward() async throws {
|
||||||
|
print("PianoClient.skipForward() called")
|
||||||
|
try await Task.sleep(for: .milliseconds(150))
|
||||||
|
}
|
||||||
|
|
||||||
|
func skipBackward() async throws {
|
||||||
|
print("PianoClient.skipBackward() called")
|
||||||
|
try await Task.sleep(for: .milliseconds(150))
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchSongList() async throws -> [Song] {
|
||||||
|
print("PianoClient.fetchSongList() called")
|
||||||
|
try await Task.sleep(for: .milliseconds(500))
|
||||||
|
return [
|
||||||
|
Song(id: "1", title: "Moonlight Sonata", durationSeconds: 1220, artist: "Beethoven"),
|
||||||
|
Song(id: "2", title: "Für Elise", durationSeconds: 177, artist: "Beethoven"),
|
||||||
|
Song(id: "3", title: "Clair de Lune", durationSeconds: 290, artist: "Debussy"),
|
||||||
|
Song(id: "4", title: "Nocturne in E-flat major, Op. 9 No. 2", durationSeconds: 268, artist: "Chopin"),
|
||||||
|
Song(id: "5", title: "Gymnopédie No. 1", durationSeconds: 205, artist: "Satie"),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
13
DisklavierLink/Networking/PianoClientProtocol.swift
Normal file
13
DisklavierLink/Networking/PianoClientProtocol.swift
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
protocol PianoClientProtocol {
|
||||||
|
func connect(host: String, port: Int) async throws
|
||||||
|
func play() async throws
|
||||||
|
func pause() async throws
|
||||||
|
func stop() async throws
|
||||||
|
func record() async throws
|
||||||
|
func setVolume(_ level: Double) async throws
|
||||||
|
func skipForward() async throws
|
||||||
|
func skipBackward() async throws
|
||||||
|
func fetchSongList() async throws -> [Song]
|
||||||
|
}
|
||||||
12
DisklavierLink/Networking/PianoConnectionError.swift
Normal file
12
DisklavierLink/Networking/PianoConnectionError.swift
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum PianoConnectionError: LocalizedError {
|
||||||
|
case pianoUnreachable(host: String, port: Int)
|
||||||
|
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case let .pianoUnreachable(host, port):
|
||||||
|
return "Impossible de joindre le piano. Vérifiez qu'il est allumé et que la configuration est correcte (\(host):\(port))."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
116
DisklavierLink/ViewModels/PianoViewModel.swift
Normal file
116
DisklavierLink/ViewModels/PianoViewModel.swift
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
import Combine
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class PianoViewModel: ObservableObject {
|
||||||
|
@Published var isPlaying = false
|
||||||
|
@Published var isConnected = false
|
||||||
|
@Published var volume: Double = 0.5
|
||||||
|
@Published var songs: [Song] = []
|
||||||
|
@Published var lastError: Error?
|
||||||
|
@Published var selectedSong: Song?
|
||||||
|
|
||||||
|
/// Simulation flag: when false, connect() fails with a French error message,
|
||||||
|
/// mirroring what will happen when the real piano is physically off.
|
||||||
|
@Published var isSimulatedPianoOn = true
|
||||||
|
|
||||||
|
let settings = SettingsStore()
|
||||||
|
|
||||||
|
private let client: PianoClientProtocol
|
||||||
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
|
init(client: PianoClientProtocol = PianoClient()) {
|
||||||
|
self.client = client
|
||||||
|
settings.objectWillChange
|
||||||
|
.sink { [weak self] in self?.objectWillChange.send() }
|
||||||
|
.store(in: &cancellables)
|
||||||
|
}
|
||||||
|
|
||||||
|
func connect() async {
|
||||||
|
guard isSimulatedPianoOn else {
|
||||||
|
lastError = PianoConnectionError.pianoUnreachable(host: settings.host, port: settings.port)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
try await client.connect(host: settings.host, port: settings.port)
|
||||||
|
isConnected = true
|
||||||
|
lastError = nil
|
||||||
|
} catch {
|
||||||
|
lastError = error
|
||||||
|
isConnected = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func disconnect() {
|
||||||
|
isConnected = false
|
||||||
|
isPlaying = false
|
||||||
|
}
|
||||||
|
|
||||||
|
func play() async {
|
||||||
|
do {
|
||||||
|
try await client.play()
|
||||||
|
isPlaying = true
|
||||||
|
} catch {
|
||||||
|
lastError = error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func pause() async {
|
||||||
|
do {
|
||||||
|
try await client.pause()
|
||||||
|
isPlaying = false
|
||||||
|
} catch {
|
||||||
|
lastError = error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func stop() async {
|
||||||
|
do {
|
||||||
|
try await client.stop()
|
||||||
|
isPlaying = false
|
||||||
|
} catch {
|
||||||
|
lastError = error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func record() async {
|
||||||
|
do {
|
||||||
|
try await client.record()
|
||||||
|
} catch {
|
||||||
|
lastError = error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func setVolume(_ level: Double) async {
|
||||||
|
volume = level
|
||||||
|
do {
|
||||||
|
try await client.setVolume(level)
|
||||||
|
} catch {
|
||||||
|
lastError = error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func skipForward() async {
|
||||||
|
do {
|
||||||
|
try await client.skipForward()
|
||||||
|
} catch {
|
||||||
|
lastError = error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func skipBackward() async {
|
||||||
|
do {
|
||||||
|
try await client.skipBackward()
|
||||||
|
} catch {
|
||||||
|
lastError = error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchSongList() async {
|
||||||
|
do {
|
||||||
|
songs = try await client.fetchSongList()
|
||||||
|
} catch {
|
||||||
|
lastError = error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
25
DisklavierLink/ViewModels/SettingsStore.swift
Normal file
25
DisklavierLink/ViewModels/SettingsStore.swift
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
import Combine
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// @AppStorage only works inside SwiftUI Views. Outside that context, @Published
|
||||||
|
/// + UserDefaults gives the same automatic persistence with proper objectWillChange.
|
||||||
|
final class SettingsStore: ObservableObject {
|
||||||
|
@Published var host: String {
|
||||||
|
didSet { UserDefaults.standard.set(host, forKey: "disklavier.host") }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Published var port: Int {
|
||||||
|
didSet { UserDefaults.standard.set(port, forKey: "disklavier.port") }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Published var autoConnectOnLaunch: Bool {
|
||||||
|
didSet { UserDefaults.standard.set(autoConnectOnLaunch, forKey: "disklavier.autoConnectOnLaunch") }
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {
|
||||||
|
host = UserDefaults.standard.string(forKey: "disklavier.host") ?? "192.168.1.100"
|
||||||
|
let saved = UserDefaults.standard.integer(forKey: "disklavier.port")
|
||||||
|
port = saved > 0 ? saved : 80
|
||||||
|
autoConnectOnLaunch = UserDefaults.standard.bool(forKey: "disklavier.autoConnectOnLaunch")
|
||||||
|
}
|
||||||
|
}
|
||||||
348
DisklavierLink/Views/ContentView.swift
Normal file
348
DisklavierLink/Views/ContentView.swift
Normal file
|
|
@ -0,0 +1,348 @@
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct ContentView: View {
|
||||||
|
@ObservedObject var viewModel: PianoViewModel
|
||||||
|
@Environment(\.openSettings) private var openSettings
|
||||||
|
|
||||||
|
private var volumeBinding: Binding<Double> {
|
||||||
|
Binding(
|
||||||
|
get: { viewModel.volume },
|
||||||
|
set: { level in Task { await viewModel.setVolume(level) } }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
connectionBanner
|
||||||
|
|
||||||
|
VStack(spacing: 20) {
|
||||||
|
nowPlayingCard
|
||||||
|
transportControls
|
||||||
|
volumeRow
|
||||||
|
}
|
||||||
|
.padding(20)
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
songLibrary
|
||||||
|
}
|
||||||
|
.frame(minWidth: 460, minHeight: 620)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .navigation) {
|
||||||
|
statusBadge
|
||||||
|
}
|
||||||
|
ToolbarItem(placement: .navigation) {
|
||||||
|
connectButton
|
||||||
|
}
|
||||||
|
ToolbarItem(placement: .primaryAction) {
|
||||||
|
simulationToggle
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Connection banner
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var connectionBanner: some View {
|
||||||
|
if let error = viewModel.lastError {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
Image(systemName: "exclamationmark.triangle.fill")
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
Text(error.localizedDescription)
|
||||||
|
.font(.callout)
|
||||||
|
.lineLimit(2)
|
||||||
|
Spacer()
|
||||||
|
Button("Réglages…") { openSettings() }
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
.controlSize(.small)
|
||||||
|
Button { viewModel.lastError = nil } label: {
|
||||||
|
Image(systemName: "xmark")
|
||||||
|
.font(.caption.weight(.medium))
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
.background(.orange.opacity(0.1))
|
||||||
|
.overlay(alignment: .bottom) { Divider() }
|
||||||
|
} else if !viewModel.isConnected {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
Image(systemName: "wifi.slash")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text("Non connecté — configurez l'adresse dans les Réglages.")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.font(.callout)
|
||||||
|
Spacer()
|
||||||
|
Button("Réglages…") { openSettings() }
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
.controlSize(.small)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
.background(.secondary.opacity(0.04))
|
||||||
|
.overlay(alignment: .bottom) { Divider() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Now Playing card
|
||||||
|
|
||||||
|
private var nowPlayingCard: some View {
|
||||||
|
ZStack {
|
||||||
|
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||||||
|
.fill(.thinMaterial)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
HStack(alignment: .center, spacing: 16) {
|
||||||
|
artworkTile
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text(viewModel.selectedSong?.title ?? "Aucun morceau")
|
||||||
|
.font(.title3.weight(.semibold))
|
||||||
|
.lineLimit(2)
|
||||||
|
.minimumScaleFactor(0.85)
|
||||||
|
Text(viewModel.selectedSong?.artist ?? "—")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
|
||||||
|
progressTrack
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var artworkTile: some View {
|
||||||
|
ZStack {
|
||||||
|
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||||
|
.fill(
|
||||||
|
LinearGradient(
|
||||||
|
colors: [.accentColor.opacity(0.75), .accentColor.opacity(0.3)],
|
||||||
|
startPoint: .topLeading,
|
||||||
|
endPoint: .bottomTrailing
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.frame(width: 72, height: 72)
|
||||||
|
Image(systemName: "pianokeys")
|
||||||
|
.font(.system(size: 28, weight: .light))
|
||||||
|
.foregroundStyle(.white.opacity(0.9))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var progressTrack: some View {
|
||||||
|
VStack(spacing: 4) {
|
||||||
|
GeometryReader { geo in
|
||||||
|
ZStack(alignment: .leading) {
|
||||||
|
RoundedRectangle(cornerRadius: 2, style: .continuous)
|
||||||
|
.fill(.secondary.opacity(0.2))
|
||||||
|
.frame(height: 4)
|
||||||
|
// Width will represent playback position once the real client is wired up.
|
||||||
|
RoundedRectangle(cornerRadius: 2, style: .continuous)
|
||||||
|
.fill(Color.accentColor)
|
||||||
|
.frame(width: viewModel.isPlaying ? geo.size.width * 0.0 : 0, height: 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(height: 4)
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
Text("0:00")
|
||||||
|
.font(.caption.monospacedDigit())
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
Spacer()
|
||||||
|
if let d = viewModel.selectedSong?.durationSeconds {
|
||||||
|
Text(formatDuration(d))
|
||||||
|
.font(.caption.monospacedDigit())
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Transport
|
||||||
|
|
||||||
|
private var transportControls: some View {
|
||||||
|
HStack(spacing: 0) {
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
iconButton("backward.fill", size: 20) { Task { await viewModel.skipBackward() } }
|
||||||
|
|
||||||
|
Spacer().frame(width: 28)
|
||||||
|
|
||||||
|
playPauseButton
|
||||||
|
|
||||||
|
Spacer().frame(width: 28)
|
||||||
|
|
||||||
|
iconButton("forward.fill", size: 20) { Task { await viewModel.skipForward() } }
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
.frame(height: 32)
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
|
||||||
|
iconButton("stop.fill", size: 16) { Task { await viewModel.stop() } }
|
||||||
|
.padding(.trailing, 12)
|
||||||
|
|
||||||
|
Button { Task { await viewModel.record() } } label: {
|
||||||
|
Image(systemName: "record.circle")
|
||||||
|
.font(.system(size: 22))
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
.disabled(!viewModel.isConnected)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var playPauseButton: some View {
|
||||||
|
Button {
|
||||||
|
Task {
|
||||||
|
if viewModel.isPlaying { await viewModel.pause() }
|
||||||
|
else { await viewModel.play() }
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
ZStack {
|
||||||
|
Circle()
|
||||||
|
.fill(Color.accentColor)
|
||||||
|
.frame(width: 56, height: 56)
|
||||||
|
.shadow(color: .accentColor.opacity(0.35), radius: 8, y: 4)
|
||||||
|
Image(systemName: viewModel.isPlaying ? "pause.fill" : "play.fill")
|
||||||
|
.font(.system(size: 22, weight: .semibold))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.offset(x: viewModel.isPlaying ? 0 : 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func iconButton(_ symbol: String, size: CGFloat, action: @escaping () -> Void) -> some View {
|
||||||
|
Button(action: action) {
|
||||||
|
Image(systemName: symbol)
|
||||||
|
.font(.system(size: size, weight: .medium))
|
||||||
|
.foregroundStyle(.primary)
|
||||||
|
.frame(width: 36, height: 36)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Volume
|
||||||
|
|
||||||
|
private var volumeRow: some View {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
Image(systemName: "speaker.fill")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.font(.footnote)
|
||||||
|
.frame(width: 16, alignment: .center)
|
||||||
|
Slider(value: volumeBinding, in: 0 ... 1)
|
||||||
|
Image(systemName: "speaker.wave.3.fill")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.font(.footnote)
|
||||||
|
.frame(width: 20, alignment: .center)
|
||||||
|
}
|
||||||
|
.disabled(!viewModel.isConnected)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Song library
|
||||||
|
|
||||||
|
private var songLibrary: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
HStack {
|
||||||
|
Text("Bibliothèque")
|
||||||
|
.font(.headline)
|
||||||
|
Spacer()
|
||||||
|
Button {
|
||||||
|
Task { await viewModel.fetchSongList() }
|
||||||
|
} label: {
|
||||||
|
Label("Actualiser", systemImage: "arrow.clockwise")
|
||||||
|
.labelStyle(.iconOnly)
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderless)
|
||||||
|
.disabled(!viewModel.isConnected)
|
||||||
|
.help("Actualiser la liste")
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
if viewModel.songs.isEmpty {
|
||||||
|
Text("Aucun morceau — connectez-vous et actualisez.")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
.padding()
|
||||||
|
} else {
|
||||||
|
List(viewModel.songs, selection: $viewModel.selectedSong) { song in
|
||||||
|
HStack {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(song.title)
|
||||||
|
.lineLimit(1)
|
||||||
|
if let artist = song.artist {
|
||||||
|
Text(artist)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
if let d = song.durationSeconds {
|
||||||
|
Text(formatDuration(d))
|
||||||
|
.font(.caption.monospacedDigit())
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
.tag(song)
|
||||||
|
}
|
||||||
|
.listStyle(.sidebar)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(minHeight: 160)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Toolbar items
|
||||||
|
|
||||||
|
private var statusBadge: some View {
|
||||||
|
HStack(spacing: 5) {
|
||||||
|
Circle()
|
||||||
|
.fill(viewModel.isConnected ? Color.green : Color.secondary.opacity(0.4))
|
||||||
|
.frame(width: 7, height: 7)
|
||||||
|
Text(viewModel.isConnected ? "Connecté" : "Non connecté")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.font(.callout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var connectButton: some View {
|
||||||
|
Button(viewModel.isConnected ? "Déconnecter" : "Connecter") {
|
||||||
|
if viewModel.isConnected {
|
||||||
|
viewModel.disconnect()
|
||||||
|
} else {
|
||||||
|
Task { await viewModel.connect() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
.controlSize(.small)
|
||||||
|
.tint(viewModel.isConnected ? .secondary : .accentColor)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var simulationToggle: some View {
|
||||||
|
Toggle(isOn: $viewModel.isSimulatedPianoOn) {
|
||||||
|
Label(
|
||||||
|
viewModel.isSimulatedPianoOn ? "Piano allumé" : "Piano éteint",
|
||||||
|
systemImage: viewModel.isSimulatedPianoOn ? "power.circle.fill" : "power.circle"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.toggleStyle(.button)
|
||||||
|
.tint(viewModel.isSimulatedPianoOn ? .green : .red)
|
||||||
|
.help("Simuler l'état physique du piano (développement)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
private func formatDuration(_ seconds: Int) -> String {
|
||||||
|
String(format: "%d:%02d", seconds / 60, seconds % 60)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
ContentView(viewModel: PianoViewModel())
|
||||||
|
}
|
||||||
35
DisklavierLink/Views/SettingsView.swift
Normal file
35
DisklavierLink/Views/SettingsView.swift
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct SettingsView: View {
|
||||||
|
@EnvironmentObject var settings: SettingsStore
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Form {
|
||||||
|
Section("Connexion") {
|
||||||
|
LabeledContent("Adresse IP") {
|
||||||
|
TextField("192.168.1.100", text: $settings.host)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.frame(width: 180)
|
||||||
|
}
|
||||||
|
|
||||||
|
LabeledContent("Port") {
|
||||||
|
TextField("80", value: $settings.port, format: .number)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.frame(width: 80)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Section("Démarrage") {
|
||||||
|
Toggle("Connexion automatique au démarrage", isOn: $settings.autoConnectOnLaunch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.formStyle(.grouped)
|
||||||
|
.frame(width: 400)
|
||||||
|
.padding(.vertical)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
SettingsView()
|
||||||
|
.environmentObject(SettingsStore())
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue