This repository has been archived on 2026-10-04. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
hope-voice-ios/Sources/Features/Media/MediaPanelView.swift
T

611 lines
24 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}
}