import SwiftUI struct MediaPanelView: View { @ObservedObject var playerController: MediaPlayerController @ObservedObject var settings: SettingsStore @State private var searchQuery = "" @State private var searchResults: [MediaSearchResult]? @State private var isSearching = false @State private var searchError: String? @State private var libraryTracks: [MediaTrack]? @State private var libraryError: String? @State private var streamName = "" @State private var streamURL = "" @State private var streamMessage: String? @State private var trackToDelete: MediaTrack? @State private var browseMode = 0 @State private var showAddStream = false @State private var isAddingStream = false @State private var isLoadingLibrary = false @State private var seekPosition: Double = 0 @State private var isSeeking = false var body: some View { ScrollView { LazyVStack(alignment: .leading, spacing: 24) { if playerController.stateError != nil { VStack(alignment: .leading, spacing: 8) { Label("Music server unavailable", systemImage: "wifi.exclamationmark") .font(.headline) Text("Check that your Hope server is running and you’re connected to its network. We’ll reconnect automatically.") .font(.subheadline).foregroundStyle(.secondary) } .frame(maxWidth: .infinity, alignment: .leading) } if playerController.track != nil { VStack(spacing: 20) { nowPlayingCard seekSection transportControls volumeSection } .padding(16) .background(Color(.secondarySystemGroupedBackground), in: RoundedRectangle(cornerRadius: 16)) } if let error = playerController.playbackError { Label(error, systemImage: "exclamationmark.circle") .font(.subheadline).foregroundStyle(.red) } pendingSection choicesSection Picker("Browse music", selection: $browseMode) { Text("Library").tag(0) Text("Search").tag(1) }.pickerStyle(.segmented) if browseMode == 0 { librarySection } else { searchSection } } .padding() } .background(Color(.systemGroupedBackground)) .navigationTitle("Music") .toolbar { ToolbarItem(placement: .topBarTrailing) { Button { streamMessage = nil showAddStream = true } label: { Image(systemName: "plus").frame(minWidth: 44, minHeight: 44) } .accessibilityLabel("Add radio station") } } .sheet(isPresented: $showAddStream) { NavigationStack { Form { addStreamSection } .navigationTitle("Add a station") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { showAddStream = false }.disabled(isAddingStream) } } } .interactiveDismissDisabled(isAddingStream) } .onAppear { playerController.startDefaultPolling() refreshLibrary() } .alert("Remove from library?", isPresented: Binding( get: { trackToDelete != nil }, set: { if !$0 { trackToDelete = nil } } )) { Button("Remove", role: .destructive) { guard let track = trackToDelete else { return } Task { await deleteTrack(track) } } Button("Cancel", role: .cancel) { trackToDelete = nil } } message: { Text(trackToDelete?.title ?? "") } } private var nowPlayingCard: some View { HStack(spacing: 12) { if let artURL = playerController.track?.artURL, let url = URL(string: absolute(artURL)) { PinnedArtwork(url: url) .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(2) if playerController.track != nil { Text(statusText) .font(.caption) .foregroundStyle(playerController.desired == "playing" ? Color.green : Color.secondary) } } Spacer() } } private var transportControls: some View { HStack(spacing: 24) { Button(action: seekBackward) { Image(systemName: "gobackward.10") .font(.title3) .frame(width: 44, height: 44) } .accessibilityLabel("Seek back 10 seconds") .disabled(playerController.duration <= 0 || playerController.track?.kind == "stream") Button(action: togglePlayPause) { Image(systemName: playerController.desired == "playing" && playerController.track != nil ? "pause.circle.fill" : "play.circle.fill") .font(.largeTitle) .frame(width: 56, height: 56) .foregroundStyle(playerController.track == nil ? Color.secondary : Color.accentColor) } .accessibilityLabel(playerController.desired == "playing" && playerController.track != nil ? "Pause" : "Play") .disabled(playerController.track == nil) Button(action: seekForward) { Image(systemName: "goforward.10") .font(.title3) .frame(width: 44, height: 44) } .accessibilityLabel("Seek forward 10 seconds") .disabled(playerController.duration <= 0 || playerController.track?.kind == "stream") Button(role: .destructive, action: stopPlayback) { Image(systemName: "stop.circle") .font(.title2) .frame(width: 44, height: 44) } .accessibilityLabel("Stop playback") .disabled(playerController.track == nil) } } private var seekSection: some View { VStack(spacing: 4) { if playerController.track?.kind == "stream" { Label("Live radio", systemImage: "dot.radiowaves.left.and.right") .font(.subheadline).foregroundStyle(.secondary) } else if playerController.track != nil { Slider( value: Binding( get: { min(isSeeking ? seekPosition : playerController.currentTime, max(playerController.duration, 1)) }, set: { seekPosition = $0 } ), in: 0...max(playerController.duration, 1), onEditingChanged: { editing in isSeeking = editing if !editing { playerController.seek(to: seekPosition) } } ) .disabled(playerController.duration <= 0) .accessibilityLabel("Playback position") 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) .accessibilityLabel("Music volume") .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)) } } @ViewBuilder private var choicesSection: some View { if !playerController.choices.isEmpty { VStack(alignment: .leading, spacing: 8) { Text("Hope's suggestions") .font(.headline) ForEach(playerController.choices, id: \.videoID) { choice in mediaRow( title: choice.title ?? choice.videoID ?? "Unknown", detail: rowDetail(channel: choice.channel, duration: choice.duration), playEnabled: true ) { Task { await playerController.playVideo( videoID: choice.videoID, title: choice.title, channel: choice.channel ) } } } } } } private var searchSection: some View { VStack(alignment: .leading, spacing: 8) { Text("Find your next favorite") .font(.headline) Text("Search songs and artists on YouTube Music.") .font(.subheadline).foregroundStyle(.secondary) HStack(spacing: 8) { TextField("Song, artist, lyrics…", text: $searchQuery) .textFieldStyle(.roundedBorder) .submitLabel(.search) .onSubmit(performSearch) Button("Search", action: performSearch) .accessibilityIdentifier("music.search") .buttonStyle(.bordered) .frame(minHeight: 44) .disabled(searchQuery.trimmingCharacters(in: .whitespaces).isEmpty || isSearching) } if isSearching { HStack(spacing: 8) { ProgressView() Text("Searching YouTube Music…") .font(.subheadline) .foregroundStyle(.secondary) } } else if let searchError { Text(searchError) .font(.subheadline) .foregroundStyle(.red) } else if let results = searchResults { if results.isEmpty { Text("No matches. Try a different song or artist.") .font(.subheadline) .foregroundStyle(.secondary) } else { ForEach(results, id: \.videoID) { result in mediaRow( title: result.title ?? result.videoID ?? "Unknown", detail: rowDetail(channel: result.channel, duration: result.duration, inLibrary: result.inLibrary), playEnabled: true ) { Task { if let trackID = result.trackID { await playerController.playTrack(id: trackID) } else { await playerController.playVideo( videoID: result.videoID, title: result.title, channel: result.channel ) } } } } } } } } private var librarySection: some View { VStack(alignment: .leading, spacing: 8) { HStack { Text("Library") .font(.headline) Spacer() Button(action: refreshLibrary) { Image(systemName: "arrow.clockwise").frame(width: 44, height: 44) } .disabled(isLoadingLibrary) .accessibilityLabel("Refresh library") } if let libraryError, playerController.stateError == nil { Text(libraryError) .font(.subheadline) .foregroundStyle(.red) } else if let tracks = libraryTracks { if tracks.isEmpty { ContentUnavailableView { Label("A home for your favorites", systemImage: "music.note.list") } description: { Text("Search for a song to save it here, or add a radio station using the + button.") } actions: { Button("Find music") { browseMode = 1 }.buttonStyle(.bordered) } } else { ForEach(tracks, id: \.id) { track in HStack(spacing: 8) { mediaRow( title: track.title ?? track.id, detail: rowDetail( channel: track.channel ?? (track.kind == "stream" ? "Live stream" : ""), duration: track.duration, status: track.status ), playEnabled: track.status == "ready" ) { Task { await playerController.playTrack(id: track.id) } } Button(role: .destructive) { trackToDelete = track } label: { Image(systemName: "xmark") .font(.subheadline.weight(.semibold)) .foregroundStyle(.red) .frame(width: 44, height: 44) } .accessibilityLabel("Remove \(track.title ?? track.id)") } } } } else if isLoadingLibrary { ProgressView("Loading your library…").frame(maxWidth: .infinity).padding(.vertical, 24) } } } private var addStreamSection: some View { Section { TextField("Station name", text: $streamName) .accessibilityLabel("Station name") TextField("https://… audio stream URL", text: $streamURL) .accessibilityLabel("Audio stream URL") .keyboardType(.URL) .autocorrectionDisabled() .textContentType(.URL) .textInputAutocapitalization(.never) if let streamMessage { Text(streamMessage) .font(.subheadline) .foregroundStyle(streamMessage.hasPrefix("Added") ? Color.green : Color.red) } Button(isAddingStream ? "Adding station…" : "Add station", action: addStream) .disabled(streamURL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || isAddingStream) } footer: { Text("Use a direct MP3 or AAC audio stream address, rather than the station’s website.") } } private func mediaRow( title: String, detail: String, playEnabled: Bool, onPlay: @escaping () -> Void ) -> some View { HStack(spacing: 10) { VStack(alignment: .leading, spacing: 2) { Text(title) .font(.subheadline.weight(.semibold)) .lineLimit(2) if !detail.isEmpty { Text(detail) .font(.caption) .foregroundStyle(.secondary) .lineLimit(2) } } Spacer() Button(action: onPlay) { Image(systemName: "play.fill").frame(width: 44, height: 44) } .accessibilityLabel("Play \(title)") .disabled(!playEnabled) } .padding(.vertical, 10) } private func rowDetail(channel: String?, duration: Double?, status: String? = nil, inLibrary: Bool? = nil) -> String { var parts: [String] = [] if let channel, !channel.isEmpty { parts.append(channel) } if let duration, duration > 0 { parts.append(fmtTime(duration)) } if let status, status != "ready" { parts.append(status) } if inLibrary == true { parts.append("in library") } return parts.joined(separator: " · ") } private func performSearch() { let query = searchQuery.trimmingCharacters(in: .whitespacesAndNewlines) guard !query.isEmpty, !isSearching else { return } isSearching = true searchError = nil Task { do { let response = try await MediaAPI(config: serverConfig).search(room: "default", query: query, limit: 6) searchResults = response.results } catch { searchError = error.localizedDescription } isSearching = false } } private func refreshLibrary() { guard !isLoadingLibrary else { return } isLoadingLibrary = true Task { defer { isLoadingLibrary = false } do { let library = try await MediaAPI(config: serverConfig).library() libraryTracks = library.tracks libraryError = nil } catch { libraryError = error.localizedDescription } } } private func addStream() { let url = streamURL.trimmingCharacters(in: .whitespacesAndNewlines) guard !isAddingStream else { return } guard let parsed = URL(string: url), ["https", "http"].contains(parsed.scheme?.lowercased() ?? ""), parsed.host != nil else { streamMessage = "Enter a complete http:// or https:// audio stream address." return } isAddingStream = true let name = streamName.trimmingCharacters(in: .whitespaces).isEmpty ? url : streamName.trimmingCharacters(in: .whitespaces) Task { defer { isAddingStream = false } do { try await MediaAPI(config: serverConfig).addStream(room: "default", name: name, url: url) streamMessage = "Added \(name)" streamName = "" streamURL = "" showAddStream = false refreshLibrary() } catch { streamMessage = "Could not add stream: \(error.localizedDescription)" } } } private func deleteTrack(_ track: MediaTrack) async { do { try await MediaAPI(config: serverConfig).deleteTrack(id: track.id) if playerController.track?.id == track.id { Task { await playerController.stop() } } refreshLibrary() } catch { libraryError = error.localizedDescription } } private var serverConfig: ServerConfig { // Use the configured URL directly. If it's invalid, fall back to the // documented default (media operations are less critical than the main // session, and the Settings view will show a validation error). if case .success(let config) = ServerConfig.parse(settings.trimmedServerURL) { return config } let fallback = ServerConfig.parse("https://192.168.86.2:9178") if case .success(let config) = fallback { return config } fatalError("Unreachable: default server URL is valid") } 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 } let base = serverConfig.baseURL.absoluteString let suffix = path.hasPrefix("/") ? path : "/" + path return base + suffix } 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))" } } /// Artwork uses the same trust policy as token and media requests. private struct PinnedArtwork: View { let url: URL @State private var artwork: UIImage? var body: some View { Group { if let artwork { Image(uiImage: artwork).resizable().scaledToFill() } else { Image(systemName: "music.note").font(.title2).foregroundStyle(.secondary) .frame(maxWidth: .infinity, maxHeight: .infinity) .background(Color(.tertiarySystemFill)) } } .accessibilityHidden(true) .task(id: url) { artwork = nil let session = TLSPinning.session() defer { session.invalidateAndCancel() } guard let (data, response) = try? await session.data(from: url), (response as? HTTPURLResponse)?.statusCode == 200, !Task.isCancelled else { return } artwork = UIImage(data: data) } } }