diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..436268e --- /dev/null +++ b/.claude/settings.json @@ -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..." + } + ] + } + ] + } +} diff --git a/.claude/skills/run/SKILL.md b/.claude/skills/run/SKILL.md new file mode 100644 index 0000000..c1016ce --- /dev/null +++ b/.claude/skills/run/SKILL.md @@ -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 `. + +5. Report whether the app launched successfully. Note any build warnings that look relevant to recent changes. diff --git a/.gitignore b/.gitignore index 1669786..7f6f6d5 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,10 @@ xcuserdata/ # CocoaPods / Carthage (if you ever add them) Pods/ Carthage/Build/ + +# Personal Claude Code preferences +CLAUDE.local.md + +# Claude Code — local-only files (personal permissions, worktree sandboxes) +.claude/settings.local.json +.claude/worktrees/ diff --git a/DisklavierLink.xcodeproj/project.pbxproj b/DisklavierLink.xcodeproj/project.pbxproj index c34bf5b..67881e2 100644 --- a/DisklavierLink.xcodeproj/project.pbxproj +++ b/DisklavierLink.xcodeproj/project.pbxproj @@ -179,7 +179,7 @@ GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MACOSX_DEPLOYMENT_TARGET = 15.6; + MACOSX_DEPLOYMENT_TARGET = 14.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; @@ -237,7 +237,7 @@ GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MACOSX_DEPLOYMENT_TARGET = 15.6; + MACOSX_DEPLOYMENT_TARGET = 14.0; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; SDKROOT = macosx; @@ -250,6 +250,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = DisklavierLink/DisklavierLink.entitlements; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 1; @@ -282,6 +283,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = DisklavierLink/DisklavierLink.entitlements; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 1; diff --git a/DisklavierLink/App/DisklavierLinkApp.swift b/DisklavierLink/App/DisklavierLinkApp.swift new file mode 100644 index 0000000..4e5d02b --- /dev/null +++ b/DisklavierLink/App/DisklavierLinkApp.swift @@ -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) + } + } +} diff --git a/DisklavierLink/ContentView.swift b/DisklavierLink/ContentView.swift deleted file mode 100644 index b0fcb1d..0000000 --- a/DisklavierLink/ContentView.swift +++ /dev/null @@ -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() -} diff --git a/DisklavierLink/DisklavierLink.entitlements b/DisklavierLink/DisklavierLink.entitlements new file mode 100644 index 0000000..8c60721 --- /dev/null +++ b/DisklavierLink/DisklavierLink.entitlements @@ -0,0 +1,8 @@ + + + + + com.apple.security.network.client + + + diff --git a/DisklavierLink/DisklavierLinkApp.swift b/DisklavierLink/DisklavierLinkApp.swift deleted file mode 100644 index 33079c6..0000000 --- a/DisklavierLink/DisklavierLinkApp.swift +++ /dev/null @@ -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() - } - } -} diff --git a/DisklavierLink/Models/Song.swift b/DisklavierLink/Models/Song.swift new file mode 100644 index 0000000..267bcd4 --- /dev/null +++ b/DisklavierLink/Models/Song.swift @@ -0,0 +1,8 @@ +import Foundation + +struct Song: Identifiable, Codable, Hashable { + let id: String + var title: String + var durationSeconds: Int? + var artist: String? +} diff --git a/DisklavierLink/Networking/PianoClient.swift b/DisklavierLink/Networking/PianoClient.swift new file mode 100644 index 0000000..e9da53a --- /dev/null +++ b/DisklavierLink/Networking/PianoClient.swift @@ -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"), + ] + } +} diff --git a/DisklavierLink/Networking/PianoClientProtocol.swift b/DisklavierLink/Networking/PianoClientProtocol.swift new file mode 100644 index 0000000..2c90efc --- /dev/null +++ b/DisklavierLink/Networking/PianoClientProtocol.swift @@ -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] +} diff --git a/DisklavierLink/Networking/PianoConnectionError.swift b/DisklavierLink/Networking/PianoConnectionError.swift new file mode 100644 index 0000000..f44e62c --- /dev/null +++ b/DisklavierLink/Networking/PianoConnectionError.swift @@ -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))." + } + } +} diff --git a/DisklavierLink/ViewModels/PianoViewModel.swift b/DisklavierLink/ViewModels/PianoViewModel.swift new file mode 100644 index 0000000..e6550c2 --- /dev/null +++ b/DisklavierLink/ViewModels/PianoViewModel.swift @@ -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() + + 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 + } + } +} diff --git a/DisklavierLink/ViewModels/SettingsStore.swift b/DisklavierLink/ViewModels/SettingsStore.swift new file mode 100644 index 0000000..9ab90de --- /dev/null +++ b/DisklavierLink/ViewModels/SettingsStore.swift @@ -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") + } +} diff --git a/DisklavierLink/Views/ContentView.swift b/DisklavierLink/Views/ContentView.swift new file mode 100644 index 0000000..98e78dd --- /dev/null +++ b/DisklavierLink/Views/ContentView.swift @@ -0,0 +1,348 @@ +import SwiftUI + +struct ContentView: View { + @ObservedObject var viewModel: PianoViewModel + @Environment(\.openSettings) private var openSettings + + private var volumeBinding: Binding { + 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()) +} diff --git a/DisklavierLink/Views/SettingsView.swift b/DisklavierLink/Views/SettingsView.swift new file mode 100644 index 0000000..d5ae70d --- /dev/null +++ b/DisklavierLink/Views/SettingsView.swift @@ -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()) +}