Redesign Hope iOS UI, harden playback, and document project handoff

This commit is contained in:
snowspeeder committed 2026-09-09 20:23:44 -04:00
1 parent 40aaf4fea7
commit 24f95639dc
24 files changed
+1470 -594

No files matched your search

+2 -2
View File
@@ -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 {
+53 -15
View File
@@ -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() {
+28 -10
View File
@@ -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(