Remove embedded music player
The music feature is retired: delete the Media tab, MediaAPI, MediaPlayerController, PinnedMediaResourceLoader, MediaPanelView, the media_state control publish, media scope handling in VoiceSession, the volume setting, and all media tests. App is now Talk + Activity tabs. Backend media API in hope-voice-api is untouched (still serves the web UI).
This commit is contained in:
1 parent
3620a2387b
commit
ed329213de
18 files changed
+55
-2393
No files matched your search
@@ -40,15 +40,8 @@ struct ConversationView: View {
|
||||
.tabItem { Label("Talk", systemImage: "waveform") }
|
||||
.tag(0)
|
||||
|
||||
NavigationStack {
|
||||
MediaPanelView(playerController: session.mediaController, settings: settings)
|
||||
.toolbar { settingsLink }
|
||||
}
|
||||
.tabItem { Label("Music", systemImage: "music.note") }
|
||||
.tag(1)
|
||||
|
||||
ActivityTab(taskStore: session.taskStore, settings: settings, session: session)
|
||||
.tag(2)
|
||||
.tag(1)
|
||||
}
|
||||
.id(sessionID)
|
||||
.tint(HopeStyle.tint)
|
||||
@@ -79,9 +72,7 @@ struct ConversationView: View {
|
||||
// Coalesce rapid Apply actions; construct from the captured URL.
|
||||
while let next = pendingServerURL {
|
||||
pendingServerURL = nil
|
||||
guard next != activeServerURL else { continue }
|
||||
await session.disconnect()
|
||||
session.mediaController.resetScope()
|
||||
activeServerURL = next
|
||||
session = Self.makeSession(next, settings: settings)
|
||||
sessionID = UUID()
|
||||
@@ -157,13 +148,12 @@ private struct ConversationContent: View {
|
||||
Text("A little help.\nA good conversation.")
|
||||
.font(.largeTitle.weight(.semibold))
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
Text("Talk things through, find something out, or put on a favorite song.")
|
||||
Text("Talk things through, find something out, or get something done.")
|
||||
.font(.body).foregroundStyle(.secondary)
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Label("Think out loud", systemImage: "bubble.left.and.bubble.right")
|
||||
Label("Find an answer", systemImage: "sparkle.magnifyingglass")
|
||||
Label("Make time for music", systemImage: "music.note")
|
||||
}
|
||||
.font(.subheadline).foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
@@ -1,610 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user