Archived
feat: media search, choices, library management (Phase 6)
This commit is contained in:
1 parent
c10ea72cd6
commit
cbf18306ff
4 files changed
+343
-4
No files matched your search
@@ -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() {
|
||||
|
||||
Reference in new issue
Block a user