// // PlayerView.swift // Mobile Music Assistant // // Created by Sven Hanold on 26.03.26. // import SwiftUI struct PlayerView: View { @Environment(MAService.self) private var service let playerId: String @State private var player: MAPlayer? @State private var queueItems: [MAQueueItem] = [] @State private var isLoading = true @State private var errorMessage: String? var body: some View { ScrollView { VStack(spacing: 24) { if let player { // Now Playing Section nowPlayingSection(player: player) // Transport Controls transportControls(player: player) // Volume Control volumeControl(player: player) Divider() .padding(.vertical, 8) // Queue Section queueSection } else if isLoading { ProgressView() .padding() } else if let errorMessage { ContentUnavailableView( "Error", systemImage: "exclamationmark.triangle", description: Text(errorMessage) ) } } .padding() } .navigationTitle(player?.name ?? "Player") .navigationBarTitleDisplayMode(.inline) .task { await loadPlayerData() observePlayerUpdates() } .refreshable { await loadPlayerData() } } // MARK: - Now Playing Section @ViewBuilder private func nowPlayingSection(player: MAPlayer) -> some View { VStack(spacing: 16) { // Album Art if let currentItem = player.currentItem, let mediaItem = currentItem.mediaItem, let imageUrl = mediaItem.imageUrl { let coverURL = service.imageProxyURL(path: imageUrl, size: 512) CachedAsyncImage(url: coverURL) { image in image .resizable() .aspectRatio(contentMode: .fill) } placeholder: { Rectangle() .fill(Color.gray.opacity(0.2)) .overlay { Image(systemName: "music.note") .font(.system(size: 60)) .foregroundStyle(.secondary) } } .frame(width: 300, height: 300) .clipShape(RoundedRectangle(cornerRadius: 12)) .shadow(radius: 10) } else { Rectangle() .fill(Color.gray.opacity(0.2)) .frame(width: 300, height: 300) .clipShape(RoundedRectangle(cornerRadius: 12)) .overlay { Image(systemName: "music.note") .font(.system(size: 60)) .foregroundStyle(.secondary) } } // Track Info VStack(spacing: 8) { if let currentItem = player.currentItem { Text(currentItem.name) .font(.title2) .fontWeight(.semibold) .multilineTextAlignment(.center) if let mediaItem = currentItem.mediaItem { if let artists = mediaItem.artists, !artists.isEmpty { Text(artists.map { $0.name }.joined(separator: ", ")) .font(.title3) .foregroundStyle(.secondary) .multilineTextAlignment(.center) } if let album = mediaItem.album { Text(album.name) .font(.subheadline) .foregroundStyle(.tertiary) .multilineTextAlignment(.center) } } } else { Text("No Track Playing") .font(.title3) .foregroundStyle(.secondary) } } .padding(.horizontal) } } // MARK: - Transport Controls @ViewBuilder private func transportControls(player: MAPlayer) -> some View { HStack(spacing: 40) { // Previous Button { Task { try? await service.playerManager.previousTrack(playerId: playerId) } } label: { Image(systemName: "backward.fill") .font(.system(size: 32)) .foregroundStyle(.primary) } .disabled(!player.available) // Play/Pause Button { Task { if player.state == .playing { try? await service.playerManager.pause(playerId: playerId) } else { try? await service.playerManager.play(playerId: playerId) } } } label: { Image(systemName: player.state == .playing ? "pause.circle.fill" : "play.circle.fill") .font(.system(size: 64)) .foregroundStyle(.primary) } .disabled(!player.available) // Next Button { Task { try? await service.playerManager.nextTrack(playerId: playerId) } } label: { Image(systemName: "forward.fill") .font(.system(size: 32)) .foregroundStyle(.primary) } .disabled(!player.available) } .padding() } // MARK: - Volume Control @ViewBuilder private func volumeControl(player: MAPlayer) -> some View { VStack(spacing: 12) { HStack { Image(systemName: "speaker.fill") .foregroundStyle(.secondary) Slider( value: Binding( get: { Double(player.volume) }, set: { newValue in Task { try? await service.playerManager.setVolume( playerId: playerId, level: Int(newValue) ) } } ), in: 0...100, step: 1 ) Image(systemName: "speaker.wave.3.fill") .foregroundStyle(.secondary) } Text("\(player.volume)%") .font(.caption) .foregroundStyle(.secondary) } .padding(.horizontal) .disabled(!player.available) } // MARK: - Queue Section @ViewBuilder private var queueSection: some View { VStack(alignment: .leading, spacing: 12) { Text("Queue") .font(.headline) .padding(.horizontal) if queueItems.isEmpty { Text("Queue is empty") .foregroundStyle(.secondary) .frame(maxWidth: .infinity) .padding() } else { LazyVStack(spacing: 0) { ForEach(Array(queueItems.enumerated()), id: \.element.id) { index, item in QueueItemRow(item: item, index: index) .contentShape(Rectangle()) .onTapGesture { Task { try? await service.playerManager.playIndex( playerId: playerId, index: index ) } } if index < queueItems.count - 1 { Divider() .padding(.leading, 60) } } } .background(Color(.systemBackground)) .clipShape(RoundedRectangle(cornerRadius: 12)) .padding(.horizontal) } } } // MARK: - Data Loading private func loadPlayerData() async { isLoading = true errorMessage = nil do { // Load player info let players = try await service.getPlayers() player = players.first { $0.playerId == playerId } // Load queue let items = try await service.getQueue(playerId: playerId) queueItems = items isLoading = false } catch { errorMessage = error.localizedDescription isLoading = false } } private func observePlayerUpdates() { // Observe player updates from PlayerManager Task { while !Task.isCancelled { try? await Task.sleep(for: .milliseconds(100)) // Update from PlayerManager cache if let updatedPlayer = service.playerManager.players[playerId] { await MainActor.run { player = updatedPlayer } } if let updatedQueue = service.playerManager.queues[playerId] { await MainActor.run { queueItems = updatedQueue } } } } } } // MARK: - Queue Item Row struct QueueItemRow: View { @Environment(MAService.self) private var service let item: MAQueueItem let index: Int var body: some View { HStack(spacing: 12) { // Thumbnail if let mediaItem = item.mediaItem, let imageUrl = mediaItem.imageUrl { let coverURL = service.imageProxyURL(path: imageUrl, size: 64) CachedAsyncImage(url: coverURL) { image in image .resizable() .aspectRatio(contentMode: .fill) } placeholder: { Rectangle() .fill(Color.gray.opacity(0.2)) } .frame(width: 48, height: 48) .clipShape(RoundedRectangle(cornerRadius: 6)) } else { RoundedRectangle(cornerRadius: 6) .fill(Color.gray.opacity(0.2)) .frame(width: 48, height: 48) .overlay { Image(systemName: "music.note") .foregroundStyle(.secondary) } } // Track Info VStack(alignment: .leading, spacing: 4) { Text(item.name) .font(.body) .lineLimit(1) if let mediaItem = item.mediaItem, let artists = mediaItem.artists, !artists.isEmpty { Text(artists.map { $0.name }.joined(separator: ", ")) .font(.caption) .foregroundStyle(.secondary) .lineLimit(1) } } Spacer() // Duration if let duration = item.duration { Text(formatDuration(duration)) .font(.caption) .foregroundStyle(.secondary) } } .padding(.vertical, 8) .padding(.horizontal) } private func formatDuration(_ seconds: Int) -> String { let minutes = seconds / 60 let remainingSeconds = seconds % 60 return String(format: "%d:%02d", minutes, remainingSeconds) } } #Preview { NavigationStack { PlayerView(playerId: "test_player") .environment(MAService()) } }