import Foundation import SwiftUI import VelodyDomain struct MacLibraryView: View { @State private var viewModel = MacLibraryViewModel() @State private var scrubbedPlaybackTime: Double? var body: some View { VStack(alignment: .leading, spacing: 16) { Text("Private Library Foundation") .font(.largeTitle) Text("Backend Connection") .font(.title2) TextField("http://localhost:3000", text: $viewModel.serverURLString) .textFieldStyle(.roundedBorder) .onSubmit { viewModel.persistServerURLSelection() } VStack(alignment: .leading, spacing: 8) { statusRow(title: "Server URL", value: viewModel.serverURLString) statusRow(title: "Registration", value: viewModel.deviceRegistrationStatus) statusRow( title: "Device ID", value: viewModel.registeredDeviceId ?? "Not registered yet." ) statusRow(title: "Last heartbeat", value: viewModel.lastHeartbeatStatus) statusRow(title: "Last bootstrap", value: viewModel.lastSyncBootstrapStatus) statusRow(title: "Last upload", value: viewModel.lastUploadStatus) if let lastBootstrapTrackCount = viewModel.lastBootstrapTrackCount { statusRow(title: "Bootstrap tracks", value: "\(lastBootstrapTrackCount)") } if let lastBootstrapCursor = viewModel.lastBootstrapCursor { statusRow(title: "Next cursor", value: lastBootstrapCursor) } } HStack(spacing: 12) { Button("Register this Mac") { Task { await viewModel.registerThisMac() } } .disabled(viewModel.isRegisteringDevice) Button("Send Heartbeat") { Task { await viewModel.sendHeartbeat() } } .disabled(viewModel.registeredDeviceId == nil || viewModel.isSendingHeartbeat) Button("Sync Bootstrap") { Task { await viewModel.syncBootstrap() } } .disabled(viewModel.isRunningSyncBootstrap) } Text("Selected folder") .font(.headline) Text(viewModel.selectedFolderPath) .textSelection(.enabled) .foregroundStyle(.secondary) HStack(spacing: 12) { Button("Choose Music Folder") { viewModel.chooseFolder() } Button("Scan MP3 Files") { Task { await viewModel.scanMP3Files() } } .disabled(viewModel.selectedFolderPath == "No folder selected" || viewModel.isScanning) } HStack(spacing: 12) { Text("Discovered tracks: \(viewModel.discoveredTrackCount)") .font(.headline) if viewModel.isScanning { ProgressView() .controlSize(.small) } } Text(viewModel.scanStatus) .foregroundStyle(.secondary) HStack(spacing: 12) { Button("Upload Selected Track") { Task { await viewModel.uploadSelectedTrack() } } .disabled( viewModel.selectedTrackID == nil || viewModel.registeredDeviceId == nil || viewModel.isUploadingAnyTrack || viewModel.isUploadingAllTracks ) Button("Upload All Local Tracks") { Task { await viewModel.uploadAllLocalTracks() } } .disabled( viewModel.tracks.isEmpty || viewModel.registeredDeviceId == nil || viewModel.isUploadingAnyTrack || viewModel.isUploadingAllTracks ) if viewModel.isUploadingAnyTrack || viewModel.isUploadingAllTracks { ProgressView() .controlSize(.small) } } List(selection: $viewModel.selectedTrackID) { ForEach(viewModel.tracks, id: \.id) { track in HStack(alignment: .top, spacing: 12) { Button { viewModel.togglePlayback(for: track) } label: { Image(systemName: viewModel.playbackButtonSymbol(for: track)) .font(.title2) } .buttonStyle(.borderless) VStack(alignment: .leading, spacing: 6) { HStack(alignment: .center, spacing: 8) { Text(track.title) .font(.headline) Text(viewModel.uploadStatusLabel(for: track)) .font(.caption) .foregroundStyle(uploadBadgeColor(for: track)) .padding(.horizontal, 8) .padding(.vertical, 4) .background(uploadBadgeColor(for: track).opacity(0.12), in: Capsule()) } Text(track.artist) .foregroundStyle(.secondary) if let album = track.album { Text(album) .font(.caption) .foregroundStyle(.secondary) } if let durationSeconds = track.durationSeconds { Text("Duration: \(format(durationSeconds: durationSeconds))") .font(.caption2) .foregroundStyle(.tertiary) } if let remoteTrackId = track.remoteTrackId, !remoteTrackId.isEmpty { Text("Remote track ID: \(remoteTrackId)") .font(.caption2) .foregroundStyle(.secondary) .textSelection(.enabled) } if let uploadProgress = viewModel.uploadProgress(for: track) { ProgressView(value: uploadProgress) .frame(maxWidth: 220) } else if [.preparing, .uploading].contains(viewModel.uploadStatus(for: track)) { ProgressView() .frame(maxWidth: 220, alignment: .leading) } if let lastUploadError = track.lastUploadError, !lastUploadError.isEmpty { Text(lastUploadError) .font(.caption2) .foregroundStyle(.red) } Text(track.localFilePath) .font(.caption2) .foregroundStyle(.tertiary) .lineLimit(1) } Spacer(minLength: 12) if viewModel.isCurrentTrack(track) { Text(viewModel.nowPlayingState.isPlaying ? "Playing" : "Paused") .font(.caption) .foregroundStyle(Color.accentColor) .padding(.horizontal, 8) .padding(.vertical, 4) .background(.quaternary, in: Capsule()) } } .padding(.vertical, 4) .tag(track.id) } } .overlay { if viewModel.tracks.isEmpty && !viewModel.isScanning { ContentUnavailableView( "No MP3 Files Discovered", systemImage: "music.note.list", description: Text("Choose a folder, then run a manual scan.") ) } } miniPlayer } .padding(24) .task { await viewModel.loadIfNeeded() } } private func format(durationSeconds: Double) -> String { let totalSeconds = Int(durationSeconds.rounded()) let minutes = totalSeconds / 60 let seconds = totalSeconds % 60 return String(format: "%d:%02d", minutes, seconds) } private func uploadBadgeColor(for track: LibraryTrack) -> Color { switch viewModel.uploadStatus(for: track) { case .localOnly: return .secondary case .preparing: return .orange case .uploading: return .blue case .uploaded: return .green case .failed: return .red } } private var displayedPlaybackTime: Double { scrubbedPlaybackTime ?? viewModel.nowPlayingState.currentTime } private var playbackDuration: Double { max( viewModel.nowPlayingState.duration, viewModel.nowPlayingState.currentTrack?.durationSeconds ?? 0 ) } private var miniPlayer: some View { VStack(alignment: .leading, spacing: 12) { HStack(alignment: .top) { VStack(alignment: .leading, spacing: 4) { Text(viewModel.nowPlayingState.currentTrack?.title ?? "No track selected") .font(.headline) Text( viewModel.nowPlayingState.currentTrack?.artist ?? "Select a scanned MP3 to begin local playback." ) .foregroundStyle(.secondary) } Spacer(minLength: 16) if let playbackErrorMessage = viewModel.playbackErrorMessage { Text(playbackErrorMessage) .font(.caption) .foregroundStyle(.red) .multilineTextAlignment(.trailing) } } HStack(spacing: 12) { Button { viewModel.toggleShuffle() } label: { Image(systemName: "shuffle") } .foregroundStyle( viewModel.nowPlayingState.isShuffleEnabled ? Color.accentColor : Color.secondary ) .disabled(viewModel.tracks.isEmpty) Button { viewModel.playPreviousTrack() } label: { Image(systemName: "backward.fill") } .disabled(viewModel.tracks.isEmpty) Button { viewModel.togglePlayPause() } label: { Image(systemName: viewModel.nowPlayingState.isPlaying ? "pause.fill" : "play.fill") } .buttonStyle(.borderedProminent) .disabled(viewModel.tracks.isEmpty) Button { viewModel.stopPlayback() } label: { Image(systemName: "stop.fill") } .disabled(viewModel.nowPlayingState.currentTrack == nil) Button { viewModel.playNextTrack() } label: { Image(systemName: "forward.fill") } .disabled(viewModel.tracks.isEmpty) Button { viewModel.cycleRepeatMode() } label: { Image(systemName: viewModel.repeatButtonSymbol) } .foregroundStyle( viewModel.nowPlayingState.repeatMode == .off ? .secondary : Color.accentColor ) .disabled(viewModel.tracks.isEmpty) } .buttonStyle(.borderless) HStack(spacing: 12) { Text(format(durationSeconds: displayedPlaybackTime)) .font(.caption) .monospacedDigit() .foregroundStyle(.secondary) Slider( value: Binding( get: { displayedPlaybackTime }, set: { newValue in scrubbedPlaybackTime = newValue } ), in: 0...max(playbackDuration, 1), onEditingChanged: { isEditing in if !isEditing { let targetTime = scrubbedPlaybackTime ?? viewModel.nowPlayingState.currentTime scrubbedPlaybackTime = nil viewModel.seekPlayback(to: targetTime) } } ) .disabled(viewModel.nowPlayingState.currentTrack == nil || playbackDuration <= 0) Text(format(durationSeconds: playbackDuration)) .font(.caption) .monospacedDigit() .foregroundStyle(.secondary) } } .padding(16) .background(.quaternary.opacity(0.4), in: RoundedRectangle(cornerRadius: 16)) } @ViewBuilder private func statusRow(title: String, value: String) -> some View { VStack(alignment: .leading, spacing: 2) { Text(title) .font(.headline) Text(value) .textSelection(.enabled) .foregroundStyle(.secondary) } } }