DisklavierLink/DisklavierLink/Views/ContentView.swift
Maxence Socheleau 1a8aa6e0b3 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>
2026-07-03 12:59:10 +02:00

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