import SwiftUI struct MediaPanelView: View { @ObservedObject var playerController: MediaPlayerController @ObservedObject var settings: SettingsStore var body: some View { ScrollView { VStack(spacing: 20) { nowPlayingCard transportControls seekSection volumeSection pendingSection } .padding() } .navigationTitle("Media") } private var nowPlayingCard: some View { HStack(spacing: 12) { if let artURL = playerController.track?.artURL, let url = URL(string: absolute(artURL)) { AsyncImage(url: url) { phase in switch phase { case .success(let image): image.resizable().scaledToFill() default: Image(systemName: "music.note") .font(.title2) .foregroundStyle(.secondary) .frame(maxWidth: .infinity, maxHeight: .infinity) .background(Color(.systemGray6)) } } .frame(width: 72, height: 72) .clipShape(RoundedRectangle(cornerRadius: 10)) } else { RoundedRectangle(cornerRadius: 10) .fill(Color(.systemGray6)) .frame(width: 72, height: 72) .overlay( Image(systemName: "music.note") .font(.title2) .foregroundStyle(.secondary) ) } VStack(alignment: .leading, spacing: 4) { Text(title) .font(.headline) .lineLimit(2) Text(subtitle) .font(.subheadline) .foregroundStyle(.secondary) .lineLimit(1) if playerController.track != nil { Text(statusText) .font(.caption) .foregroundStyle(playerController.desired == "playing" ? Color.green : Color.secondary) } } Spacer() } .padding(12) .background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 14)) } private var transportControls: some View { HStack(spacing: 24) { Button(action: seekBackward) { Image(systemName: "gobackward.10") .font(.title3) } .disabled(playerController.track == nil) Button(action: togglePlayPause) { Image(systemName: playerController.desired == "playing" && playerController.track != nil ? "pause.circle.fill" : "play.circle.fill") .font(.system(size: 44)) .foregroundStyle(playerController.track == nil ? Color.secondary : Color.accentColor) } .disabled(playerController.track == nil) Button(action: seekForward) { Image(systemName: "goforward.10") .font(.title3) } .disabled(playerController.track == nil) Button(role: .destructive, action: stopPlayback) { Image(systemName: "stop.circle") .font(.title2) } .disabled(playerController.track == nil) } } private var seekSection: some View { VStack(spacing: 4) { if playerController.track != nil { Slider( value: Binding( get: { playerController.currentTime }, set: { newValue in Task { playerController.seek(to: newValue) } } ), in: 0...max(playerController.duration, 1) ) HStack { Text(fmtTime(playerController.currentTime)) .font(.caption.monospacedDigit()) .foregroundStyle(.secondary) Spacer() Text(fmtTime(playerController.duration)) .font(.caption.monospacedDigit()) .foregroundStyle(.secondary) } } else { Text("Nothing loaded") .font(.subheadline) .foregroundStyle(.secondary) .frame(maxWidth: .infinity, alignment: .center) .padding(.vertical, 8) } } } private var volumeSection: some View { HStack(spacing: 12) { Image(systemName: "speaker.fill") .foregroundStyle(.secondary) Slider(value: $settings.volume, in: 0...1) .onChange(of: settings.volume) { _, newValue in playerController.setVolume(newValue) } Image(systemName: "speaker.wave.3.fill") .foregroundStyle(.secondary) } } @ViewBuilder private var pendingSection: some View { if !playerController.pendingJobs.isEmpty { VStack(alignment: .leading, spacing: 8) { ForEach(playerController.pendingJobs, id: \.jobID) { job in HStack { Text(job.stage == "queued" ? "Queued…" : "Downloading…") .font(.subheadline) Spacer() Text("\(Int((job.progress ?? 0)))%") .font(.caption.monospacedDigit()) .foregroundStyle(.secondary) } } } .padding(12) .background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 14)) } } private var title: String { playerController.track?.title ?? "Nothing loaded" } private var subtitle: String { guard let track = playerController.track else { return "" } if track.status == "failed" { return "Download failed — pick another result or try again." } if track.status == "downloading" { return track.channel.map { "\($0) · downloading" } ?? "Downloading…" } return track.channel ?? (track.kind == "stream" ? "Live stream" : "") } private var statusText: String { switch playerController.desired { case "playing": return "Playing" case "paused": return "Paused" default: return "Stopped" } } private func absolute(_ path: String) -> String { if path.hasPrefix("http") { return path } return (path.hasPrefix("/") ? path : "/" + path) } private func togglePlayPause() { Task { await playerController.togglePlayPause() } } private func seekBackward() { Task { await playerController.seek(by: -10) } } private func seekForward() { Task { await playerController.seek(by: 10) } } private func stopPlayback() { Task { await playerController.stop() } } private func fmtTime(_ sec: Double) -> String { guard sec.isFinite, sec >= 0 else { return "0:00" } let total = Int(sec) let s = total % 60 let m = (total / 60) % 60 let h = total / 3600 return h > 0 ? "\(h):\(String(format: "%02d", m)):\(String(format: "%02d", s))" : "\(m):\(String(format: "%02d", s))" } }