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
@@ -127,10 +127,10 @@ final class MediaAPI {
|
||||
private let baseURL: URL
|
||||
private let session: URLSession
|
||||
|
||||
init(config: ServerConfig) {
|
||||
init(config: ServerConfig, session: URLSession? = nil) {
|
||||
self.serverBase = config.baseURL
|
||||
self.baseURL = config.baseURL.appendingPathComponent("media/api")
|
||||
self.session = TLSPinning.session()
|
||||
self.session = session ?? TLSPinning.session()
|
||||
}
|
||||
|
||||
func state(room: String) async throws -> MediaState {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import AVFoundation
|
||||
import Foundation
|
||||
import UniformTypeIdentifiers
|
||||
import os
|
||||
|
||||
private let logger = Logger(subsystem: "dev.shane.voice", category: "media")
|
||||
@@ -114,21 +115,29 @@ final class PinnedMediaResourceLoader: NSObject, AVAssetResourceLoaderDelegate {
|
||||
shouldWaitForLoadingOfRequestedResource loadingRequest: AVAssetResourceLoadingRequest
|
||||
) -> Bool {
|
||||
// All state access is on the main queue (the delegate queue).
|
||||
guard let dataRequest = loadingRequest.dataRequest else { return false }
|
||||
guard loadingRequest.dataRequest != nil else { return false }
|
||||
guard let url = loaderURLs[ObjectIdentifier(resourceLoader)] else {
|
||||
logger.log("no resolved URL for resource loader")
|
||||
loadingRequest.finishLoading(with: URLError(.badURL))
|
||||
return false
|
||||
}
|
||||
|
||||
fetchChunk(loadingRequest, url: url)
|
||||
return true
|
||||
}
|
||||
|
||||
/// Return bounded chunks promptly instead of buffering an entire song before
|
||||
/// AVPlayer receives its first usable bytes. The same loading request stays
|
||||
/// open until its requested range (or EOF) has been delivered.
|
||||
private func fetchChunk(_ loadingRequest: AVAssetResourceLoadingRequest, url: URL) {
|
||||
guard !loadingRequest.isCancelled, let dataRequest = loadingRequest.dataRequest else { return }
|
||||
var upstream = URLRequest(url: url)
|
||||
let offset = dataRequest.requestedOffset
|
||||
let offset = max(dataRequest.requestedOffset, dataRequest.currentOffset)
|
||||
let length = Int64(dataRequest.requestedLength)
|
||||
if length > 0 {
|
||||
upstream.setValue("bytes=\(offset)-\(offset + length - 1)", forHTTPHeaderField: "Range")
|
||||
} else {
|
||||
upstream.setValue("bytes=\(offset)-", forHTTPHeaderField: "Range")
|
||||
}
|
||||
let requestedEnd = dataRequest.requestedOffset + length
|
||||
let end = dataRequest.requestsAllDataToEndOfResource || length == 0
|
||||
? offset + 262_143 : min(requestedEnd - 1, offset + 262_143)
|
||||
upstream.setValue("bytes=\(offset)-\(end)", forHTTPHeaderField: "Range")
|
||||
logger.log("fetching \(url.absoluteString, privacy: .public) range=\(upstream.value(forHTTPHeaderField: "Range") ?? "-", privacy: .public)")
|
||||
|
||||
// Key the task by the loading request's identity directly — no hash
|
||||
@@ -140,6 +149,7 @@ final class PinnedMediaResourceLoader: NSObject, AVAssetResourceLoaderDelegate {
|
||||
// Runs on .main (the session's delegateQueue), same domain as all
|
||||
// other map mutations.
|
||||
self.activeTasks[requestKey] = nil
|
||||
guard !loadingRequest.isCancelled else { return }
|
||||
|
||||
if let error {
|
||||
logger.log("fetch failed \(url.absoluteString, privacy: .public): \(error.localizedDescription, privacy: .public)")
|
||||
@@ -158,7 +168,8 @@ final class PinnedMediaResourceLoader: NSObject, AVAssetResourceLoaderDelegate {
|
||||
return
|
||||
}
|
||||
if let info = loadingRequest.contentInformationRequest {
|
||||
info.contentType = http.mimeType ?? "application/octet-stream"
|
||||
// AVFoundation requires a UTI, not the HTTP MIME string.
|
||||
info.contentType = http.mimeType.flatMap { UTType(mimeType: $0)?.identifier }
|
||||
// Content-length handling (Iteration 05 Item 7):
|
||||
// - For 206: set full content length ONLY when Content-Range
|
||||
// provides a known total. Never fall back to the range's
|
||||
@@ -180,14 +191,21 @@ final class PinnedMediaResourceLoader: NSObject, AVAssetResourceLoaderDelegate {
|
||||
}
|
||||
info.isByteRangeAccessSupported = true
|
||||
}
|
||||
if let data {
|
||||
if let data, !data.isEmpty {
|
||||
dataRequest.respond(with: data)
|
||||
let nextOffset = offset + Int64(data.count)
|
||||
let total = Self.parseTotalLength(http)
|
||||
let reachedEOF = total.map { nextOffset >= $0 } ?? (nextOffset <= end)
|
||||
let needsMore = dataRequest.requestsAllDataToEndOfResource || nextOffset < requestedEnd
|
||||
if http.statusCode == 206, !reachedEOF, needsMore {
|
||||
self.fetchChunk(loadingRequest, url: url)
|
||||
return
|
||||
}
|
||||
}
|
||||
loadingRequest.finishLoading()
|
||||
}
|
||||
activeTasks[requestKey] = task
|
||||
task.resume()
|
||||
return true
|
||||
}
|
||||
|
||||
func resourceLoader(
|
||||
|
||||
Reference in new issue
Block a user