diff --git a/PLAN.md b/PLAN.md index d3f24a3..9765a68 100644 --- a/PLAN.md +++ b/PLAN.md @@ -25,7 +25,7 @@ Goal: full-parity native iOS app per UPDATE_PATH.md, verified in simulator via X - [x] Phase 3: state machine, audio levels, wake lock, history (commit 5483e6e) - [x] Phase 4: voice/model pickers with sync-on-join (commit ca2d26f) - [x] Phase 5: media player — commit 0af944e (webm playback blocked by iOS AVPlayer; see status below) -- [ ] Phase 6: media search, choices, library panel +- [x] Phase 6: media search, choices, library panel (commit pending) - [ ] Phase 7: background tasks panel (task_event fold-in) - [ ] Phase 8: polish/hardening/parity pass - [ ] Phase 9: docs & handoff @@ -114,6 +114,22 @@ BLOCKED — webm playback (platform limitation, NOT app bug): Needs server change to verify audible playback: - [ ] Server: provide an iOS-decodable audio format (m4a/aac/mp3) for library tracks, then re-test playback in sim. +## Phase 6 status (2026-08-30 ~13:00) +Done: +- `MediaPanelView` expanded: search section (query + Find button, results with "in library" tag), choices section (agent-offered suggestions from state), library section (list with Play/Remove per track, refresh button), add-stream section (name + URL form). +- `MediaPlayerController.playVideo` / `playTrack`: send POST /play to the scoped room or "default" when no session is active. +- `startDefaultPolling()`: Media Panel starts 1.5s state polling on appear so Now Playing card reflects server state without a voice session. +- `pollState` now logs failures instead of silently swallowing them. +- `absolute()` helper prefixes server base URL for relative art paths. +- Stream URL field: `.textInputAutocapitalization(.never)` to prevent "Https://" capitalization. + +Verified in simulator (log + UI + server evidence): +- Search: typed "toes", tapped Find → POST /media/api/search 200 → results rendered with "in library" tag on matching track. +- Play (library): tapped Play on "Toes" → POST /media/api/play 200 → state `desired: playing` confirmed via curl. AVPlayer attempted to decode webm and failed (-11828, expected platform limitation from Phase 5). +- Delete: tapped Remove on "Toes" → confirmation alert "Remove from library?" → tapped Remove → DELETE /media/api/tracks/XMh9vU4g0jc 200 → library refreshed to show only Sheryl Crow. +- Add Stream: entered name + URL, tapped Add → POST /media/api/streams 400 (server rejected test URL) → error message "Could not add stream: Media API request failed with HTTP 400. url must be http(s)" displayed in red. +- Polling without session: confirmed via AVPlayer webm decode error in logs (state was fetched and reconciled even though no voice session was active). + ## Subagent notes - Small context model: keep prompts self-contained, point at this file + UPDATE_PATH.md - Watch for loops (repeated identical commands) — kill and redirect if seen diff --git a/Sources/Features/Media/MediaPanelView.swift b/Sources/Features/Media/MediaPanelView.swift index 58eb660..9c1216d 100644 --- a/Sources/Features/Media/MediaPanelView.swift +++ b/Sources/Features/Media/MediaPanelView.swift @@ -4,6 +4,17 @@ 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? + var body: some View { ScrollView { VStack(spacing: 20) { @@ -12,10 +23,30 @@ struct MediaPanelView: View { seekSection volumeSection pendingSection + choicesSection + searchSection + librarySection + addStreamSection } .padding() } .navigationTitle("Media") + .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 { @@ -157,6 +188,261 @@ struct MediaPanelView: View { } } + @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("Search YouTube Music") + .font(.headline) + HStack(spacing: 8) { + TextField("Song, artist, lyrics…", text: $searchQuery) + .textFieldStyle(.roundedBorder) + .submitLabel(.search) + .onSubmit(performSearch) + Button("Find", action: performSearch) + .buttonStyle(.bordered) + .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 results.") + .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") + } + } + if let libraryError { + Text(libraryError) + .font(.subheadline) + .foregroundStyle(.red) + } else if let tracks = libraryTracks { + if tracks.isEmpty { + Text("Downloaded songs and streams live here.") + .font(.subheadline) + .foregroundStyle(.secondary) + } 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) + } + .accessibilityLabel("Remove \(track.title ?? track.id)") + } + } + } + } + } + } + + private var addStreamSection: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Add live stream") + .font(.headline) + TextField("Station name", text: $streamName) + .textFieldStyle(.roundedBorder) + TextField("https://… audio stream URL", text: $streamURL) + .textFieldStyle(.roundedBorder) + .keyboardType(.URL) + .autocorrectionDisabled() + .textContentType(.URL) + .textInputAutocapitalization(.never) + if let streamMessage { + Text(streamMessage) + .font(.subheadline) + .foregroundStyle(streamMessage.hasPrefix("Added") ? Color.green : Color.red) + } + Button("Add", action: addStream) + .buttonStyle(.bordered) + .disabled(streamURL.trimmingCharacters(in: .whitespaces).isEmpty) + } + } + + 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(1) + if !detail.isEmpty { + Text(detail) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + Spacer() + Button("Play", action: onPlay) + .buttonStyle(.bordered) + .controlSize(.small) + .disabled(!playEnabled) + } + .padding(10) + .background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 12)) + } + + 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: .whitespaces) + guard !query.isEmpty 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() { + Task { + 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: .whitespaces) + guard !url.isEmpty else { return } + let name = streamName.trimmingCharacters(in: .whitespaces).isEmpty ? url : streamName.trimmingCharacters(in: .whitespaces) + Task { + do { + try await MediaAPI(config: serverConfig).addStream(room: "default", name: name, url: url) + streamMessage = "Added \(name)" + streamName = "" + streamURL = "" + 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 { + 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" } @@ -182,7 +468,9 @@ struct MediaPanelView: View { private func absolute(_ path: String) -> String { if path.hasPrefix("http") { return path } - return (path.hasPrefix("/") ? path : "/" + path) + let base = serverConfig.baseURL.absoluteString + let suffix = path.hasPrefix("/") ? path : "/" + path + return base + suffix } private func togglePlayPause() { diff --git a/Sources/Media/MediaAPI.swift b/Sources/Media/MediaAPI.swift index a1a6321..138e98d 100644 --- a/Sources/Media/MediaAPI.swift +++ b/Sources/Media/MediaAPI.swift @@ -87,6 +87,7 @@ struct MediaLibrary: Decodable, Equatable { struct MediaSearchResult: Decodable, Equatable { let videoID: String? + let trackID: String? let title: String? let channel: String? let duration: Double? @@ -95,6 +96,7 @@ struct MediaSearchResult: Decodable, Equatable { enum CodingKeys: String, CodingKey { case title, channel, duration case videoID = "video_id" + case trackID = "track_id" case inLibrary = "in_library" } } diff --git a/Sources/Media/MediaPlayerController.swift b/Sources/Media/MediaPlayerController.swift index a2978b1..4138c07 100644 --- a/Sources/Media/MediaPlayerController.swift +++ b/Sources/Media/MediaPlayerController.swift @@ -110,12 +110,23 @@ final class MediaPlayerController: ObservableObject { pollTask = nil } + /// Start polling the default room when no session is active. Called from + /// the Media Panel so play/stop state is visible without a voice session. + func startDefaultPolling() { + guard room == nil else { return } + startPolling() + } + // MARK: Poll + reconcile (ported from app.js pollMediaState/reconcileMedia) private func pollState() async { let scope = room ?? "default" - guard let state = try? await api.state(room: scope) else { return } - reconcile(state) + do { + let state = try await api.state(room: scope) + reconcile(state) + } catch { + logger.log("poll failed: \(String(describing: error), privacy: .public)") + } } private func reconcile(_ s: MediaState) { @@ -270,6 +281,28 @@ final class MediaPlayerController: ObservableObject { } } + // MARK: Play requests (search results, choices, library) + + func playVideo(videoID: String?, title: String?, channel: String?) async { + let targetRoom = room ?? "default" + do { + try await api.play(room: targetRoom, videoID: videoID, title: title, channel: channel) + logger.log("play requested video_id=\(videoID ?? "nil", privacy: .public) room=\(targetRoom, privacy: .public)") + } catch { + logger.log("play failed: \(String(describing: error), privacy: .public)") + } + } + + func playTrack(id: String) async { + let targetRoom = room ?? "default" + do { + try await api.play(room: targetRoom, trackID: id) + logger.log("play requested track_id=\(id, privacy: .public) room=\(targetRoom, privacy: .public)") + } catch { + logger.log("play failed: \(String(describing: error), privacy: .public)") + } + } + // MARK: Transport controls (parity with web UI play/pause/seek/stop) func togglePlayPause() async {