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