Archived
feat: media search, choices, library management (Phase 6)
This commit is contained in:
1 parent
c10ea72cd6
commit
cbf18306ff
4 files changed
+342
-3
No files matched your search
@@ -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 3: state machine, audio levels, wake lock, history (commit 5483e6e)
|
||||||
- [x] Phase 4: voice/model pickers with sync-on-join (commit ca2d26f)
|
- [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)
|
- [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 7: background tasks panel (task_event fold-in)
|
||||||
- [ ] Phase 8: polish/hardening/parity pass
|
- [ ] Phase 8: polish/hardening/parity pass
|
||||||
- [ ] Phase 9: docs & handoff
|
- [ ] Phase 9: docs & handoff
|
||||||
@@ -114,6 +114,22 @@ BLOCKED — webm playback (platform limitation, NOT app bug):
|
|||||||
Needs server change to verify audible playback:
|
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.
|
- [ ] 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
|
## Subagent notes
|
||||||
- Small context model: keep prompts self-contained, point at this file + UPDATE_PATH.md
|
- 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
|
- Watch for loops (repeated identical commands) — kill and redirect if seen
|
||||||
|
|||||||
@@ -4,6 +4,17 @@ struct MediaPanelView: View {
|
|||||||
@ObservedObject var playerController: MediaPlayerController
|
@ObservedObject var playerController: MediaPlayerController
|
||||||
@ObservedObject var settings: SettingsStore
|
@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 {
|
var body: some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(spacing: 20) {
|
VStack(spacing: 20) {
|
||||||
@@ -12,10 +23,30 @@ struct MediaPanelView: View {
|
|||||||
seekSection
|
seekSection
|
||||||
volumeSection
|
volumeSection
|
||||||
pendingSection
|
pendingSection
|
||||||
|
choicesSection
|
||||||
|
searchSection
|
||||||
|
librarySection
|
||||||
|
addStreamSection
|
||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
}
|
}
|
||||||
.navigationTitle("Media")
|
.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 {
|
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 {
|
private var title: String {
|
||||||
playerController.track?.title ?? "Nothing loaded"
|
playerController.track?.title ?? "Nothing loaded"
|
||||||
}
|
}
|
||||||
@@ -182,7 +468,9 @@ struct MediaPanelView: View {
|
|||||||
|
|
||||||
private func absolute(_ path: String) -> String {
|
private func absolute(_ path: String) -> String {
|
||||||
if path.hasPrefix("http") { return path }
|
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() {
|
private func togglePlayPause() {
|
||||||
|
|||||||
@@ -87,6 +87,7 @@ struct MediaLibrary: Decodable, Equatable {
|
|||||||
|
|
||||||
struct MediaSearchResult: Decodable, Equatable {
|
struct MediaSearchResult: Decodable, Equatable {
|
||||||
let videoID: String?
|
let videoID: String?
|
||||||
|
let trackID: String?
|
||||||
let title: String?
|
let title: String?
|
||||||
let channel: String?
|
let channel: String?
|
||||||
let duration: Double?
|
let duration: Double?
|
||||||
@@ -95,6 +96,7 @@ struct MediaSearchResult: Decodable, Equatable {
|
|||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case title, channel, duration
|
case title, channel, duration
|
||||||
case videoID = "video_id"
|
case videoID = "video_id"
|
||||||
|
case trackID = "track_id"
|
||||||
case inLibrary = "in_library"
|
case inLibrary = "in_library"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -110,12 +110,23 @@ final class MediaPlayerController: ObservableObject {
|
|||||||
pollTask = nil
|
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)
|
// MARK: Poll + reconcile (ported from app.js pollMediaState/reconcileMedia)
|
||||||
|
|
||||||
private func pollState() async {
|
private func pollState() async {
|
||||||
let scope = room ?? "default"
|
let scope = room ?? "default"
|
||||||
guard let state = try? await api.state(room: scope) else { return }
|
do {
|
||||||
|
let state = try await api.state(room: scope)
|
||||||
reconcile(state)
|
reconcile(state)
|
||||||
|
} catch {
|
||||||
|
logger.log("poll failed: \(String(describing: error), privacy: .public)")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func reconcile(_ s: MediaState) {
|
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)
|
// MARK: Transport controls (parity with web UI play/pause/seek/stop)
|
||||||
|
|
||||||
func togglePlayPause() async {
|
func togglePlayPause() async {
|
||||||
|
|||||||
Reference in new issue
Block a user