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>
349 lines
12 KiB
Swift
349 lines
12 KiB
Swift
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())
|
|
}
|