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:
Maxence Socheleau 2026-07-03 12:59:10 +02:00
parent 9e99921859
commit 1a8aa6e0b3
16 changed files with 688 additions and 43 deletions

17
.claude/settings.json Normal file
View 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..."
}
]
}
]
}
}

View 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
View file

@ -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/

View file

@ -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;

View 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)
}
}
}

View file

@ -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()
}

View 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>

View file

@ -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()
}
}
}

View file

@ -0,0 +1,8 @@
import Foundation
struct Song: Identifiable, Codable, Hashable {
let id: String
var title: String
var durationSeconds: Int?
var artist: String?
}

View 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"),
]
}
}

View 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]
}

View 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))."
}
}
}

View 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
}
}
}

View 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")
}
}

View 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())
}

View 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())
}