Archived
Redesign Hope iOS UI, harden playback, and document project handoff
This commit is contained in:
1 parent
40aaf4fea7
commit
24f95639dc
24 files changed
+1470
-594
No files matched your search
@@ -17,6 +17,7 @@ final class MediaPlayerController: ObservableObject {
|
||||
@Published private(set) var pendingJobs: [MediaPendingJob] = []
|
||||
@Published private(set) var choices: [MediaChoice] = []
|
||||
@Published private(set) var stateError: String?
|
||||
@Published private(set) var playbackError: String?
|
||||
|
||||
let player = AVPlayer()
|
||||
|
||||
@@ -224,6 +225,7 @@ final class MediaPlayerController: ObservableObject {
|
||||
pollGeneration += 1
|
||||
room = nil
|
||||
wasPlayingBeforeTalk = false
|
||||
clearMedia()
|
||||
}
|
||||
|
||||
private func startPolling() {
|
||||
@@ -278,6 +280,7 @@ final class MediaPlayerController: ObservableObject {
|
||||
desired = s.desired ?? "stopped"
|
||||
|
||||
let track = s.track
|
||||
self.track = track
|
||||
if let track, track.id != currentTrackID {
|
||||
loadTrack(track)
|
||||
} else if let track, let audioURL = track.audioURL, loadedAudioPath != audioURL {
|
||||
@@ -349,7 +352,17 @@ final class MediaPlayerController: ObservableObject {
|
||||
}
|
||||
|
||||
private func wireSource(_ path: String) {
|
||||
guard let url = URL(string: path) else { return }
|
||||
// A complete file is required for the server's cached AAC conversion.
|
||||
// Keep showing download progress; the next ready-state poll wires it.
|
||||
guard track?.kind != "youtube" || track?.status != "downloading" else { return }
|
||||
guard var components = URLComponents(string: path) else { return }
|
||||
if track?.kind == "youtube", components.path.hasPrefix("/media/api/stream/") {
|
||||
var items = components.queryItems ?? []
|
||||
items.removeAll { $0.name == "format" }
|
||||
items.append(URLQueryItem(name: "format", value: "m4a"))
|
||||
components.queryItems = items
|
||||
}
|
||||
guard let url = components.url else { return }
|
||||
loadedAudioPath = path
|
||||
let pinned = mediaLoader.rewrite(url)
|
||||
// Remove the previous asset's loader mapping before wiring a new one so
|
||||
@@ -439,14 +452,17 @@ final class MediaPlayerController: ObservableObject {
|
||||
self, selector: #selector(playerItemDidEnd(_:)),
|
||||
name: .AVPlayerItemDidPlayToEndTime, object: item
|
||||
)
|
||||
statusCancellable = item.publisher(for: \.status).sink { status in
|
||||
switch status {
|
||||
case .readyToPlay:
|
||||
logger.log("item readyToPlay")
|
||||
case .failed:
|
||||
logger.log("item failed: \(String(describing: item.error), privacy: .public)")
|
||||
default:
|
||||
break
|
||||
statusCancellable = item.publisher(for: \.status).sink { [weak self, weak item] status in
|
||||
Task { @MainActor in
|
||||
guard let self, let item, self.player.currentItem === item else { return }
|
||||
switch status {
|
||||
case .readyToPlay:
|
||||
self.playbackError = nil
|
||||
case .failed:
|
||||
self.playbackError = "This audio couldn’t play on your iPhone. Try another track or an MP3/AAC station."
|
||||
logger.log("item failed: \(String(describing: item.error), privacy: .public)")
|
||||
default: break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -456,15 +472,15 @@ final class MediaPlayerController: ObservableObject {
|
||||
report("stopped")
|
||||
publishMediaState("stopped")
|
||||
Task { [weak self] in
|
||||
guard let self, let room = self.room else { return }
|
||||
try? await self.api.control(room: room, action: "stop")
|
||||
guard let self else { return }
|
||||
try? await self.api.control(room: self.room ?? "default", action: "stop")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Reporting + publishing
|
||||
|
||||
private func report(_ state: String, trackID: String? = nil) {
|
||||
guard let room else { return }
|
||||
let room = room ?? "default"
|
||||
let position = currentTime.isFinite ? currentTime : 0
|
||||
var durationValue: Double?
|
||||
if duration.isFinite, duration > 0 {
|
||||
@@ -505,20 +521,28 @@ final class MediaPlayerController: ObservableObject {
|
||||
|
||||
func playVideo(videoID: String?, title: String?, channel: String?) async {
|
||||
let targetRoom = room ?? "default"
|
||||
let generation = pollGeneration
|
||||
playbackError = nil
|
||||
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 {
|
||||
guard generation == pollGeneration else { return }
|
||||
playbackError = "Couldn’t play this song. Check your connection and try again."
|
||||
logger.log("play failed: \(String(describing: error), privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
func playTrack(id: String) async {
|
||||
let targetRoom = room ?? "default"
|
||||
let generation = pollGeneration
|
||||
playbackError = nil
|
||||
do {
|
||||
try await api.play(room: targetRoom, trackID: id)
|
||||
logger.log("play requested track_id=\(id, privacy: .public) room=\(targetRoom, privacy: .public)")
|
||||
} catch {
|
||||
guard generation == pollGeneration else { return }
|
||||
playbackError = "Couldn’t play this track. Check your connection and try again."
|
||||
logger.log("play failed: \(String(describing: error), privacy: .public)")
|
||||
}
|
||||
}
|
||||
@@ -534,13 +558,13 @@ final class MediaPlayerController: ObservableObject {
|
||||
// The rate observer reports + publishes the actual transition;
|
||||
// the control call updates server desired state.
|
||||
player.play()
|
||||
if let room { try? await api.control(room: room, action: "resume") }
|
||||
await sendControl("resume")
|
||||
} else {
|
||||
lastReportDate = Date()
|
||||
report("paused")
|
||||
publishMediaState("paused")
|
||||
player.pause()
|
||||
if let room { try? await api.control(room: room, action: "pause") }
|
||||
await sendControl("pause")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -553,6 +577,7 @@ final class MediaPlayerController: ObservableObject {
|
||||
}
|
||||
|
||||
func seek(to position: Double) {
|
||||
guard position.isFinite, position >= 0 else { return }
|
||||
player.seek(to: CMTime(seconds: position, preferredTimescale: 600))
|
||||
currentTime = position
|
||||
report(player.rate.isZero ? "paused" : "playing")
|
||||
@@ -564,11 +589,12 @@ final class MediaPlayerController: ObservableObject {
|
||||
let trackID = currentTrackID
|
||||
report("stopped", trackID: trackID)
|
||||
clearMedia()
|
||||
if let room { try? await api.control(room: room, action: "stop") }
|
||||
await sendControl("stop")
|
||||
publishMediaState("stopped", trackID: trackID)
|
||||
}
|
||||
|
||||
private func clearMedia() {
|
||||
playbackError = nil
|
||||
currentTrackID = nil
|
||||
track = nil
|
||||
loadedAudioPath = nil
|
||||
@@ -582,6 +608,18 @@ final class MediaPlayerController: ObservableObject {
|
||||
stateError = nil
|
||||
}
|
||||
|
||||
private func sendControl(_ action: String) async {
|
||||
let generation = pollGeneration
|
||||
do {
|
||||
try await api.control(room: room ?? "default", action: action)
|
||||
guard generation == pollGeneration else { return }
|
||||
playbackError = nil
|
||||
} catch {
|
||||
guard generation == pollGeneration else { return }
|
||||
playbackError = "Couldn’t update playback on the server. Check your connection and try again."
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Volume
|
||||
|
||||
func applyVolume() {
|
||||
|
||||
Reference in new issue
Block a user