Files
hope-voice-api/Sources/Features/Media/MediaPanelView.swift
T

514 lines
19 KiB
Swift

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?
var body: some View {
ScrollView {
VStack(spacing: 20) {
if let stateError = playerController.stateError {
Label(stateError, systemImage: "wifi.exclamationmark")
.font(.subheadline)
.foregroundStyle(.red)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(12)
.background(Color.red.opacity(0.1), in: RoundedRectangle(cornerRadius: 14))
}
nowPlayingCard
transportControls
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 {
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)
}
.accessibilityLabel("Seek back 10 seconds")
.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)
}
.accessibilityLabel(playerController.desired == "playing" && playerController.track != nil ? "Pause" : "Play")
.disabled(playerController.track == nil)
Button(action: seekForward) {
Image(systemName: "goforward.10")
.font(.title3)
}
.accessibilityLabel("Seek forward 10 seconds")
.disabled(playerController.track == nil)
Button(role: .destructive, action: stopPlayback) {
Image(systemName: "stop.circle")
.font(.title2)
}
.accessibilityLabel("Stop playback")
.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))
}
}
@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")
}
.accessibilityLabel("Refresh library")
}
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"
}
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))"
}
}