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:
snowspeeder committed 2026-09-12 06:26:58 -04:00
1 parent 3620a2387b
commit ed329213de
18 files changed
+55 -2393

No files matched your search

-125
View File
@@ -1,125 +0,0 @@
import Foundation
import Testing
@testable import Voice
/// Minimal HTTP/1.1 stub built on BSD sockets that answers every request with a
/// successful JSON object body (mirroring the real media server's
/// `{"ok": true, ...}` shape). Used to prove MediaAPI no-response POSTs succeed
/// against an object body instead of throwing decodeFailed.
@MainActor
final class MediaAPIStubServer {
private(set) var url: URL!
nonisolated(unsafe) private var serverFD: Int32 = -1
nonisolated(unsafe) private var acceptThread: Thread?
nonisolated(unsafe) private var running = false
nonisolated(unsafe) private var lastPath: String?
func start() throws {
let fd = socket(AF_INET, SOCK_STREAM, 0)
guard fd >= 0 else { throw MediaAPIError.decodeFailed }
var yes: Int32 = 1
setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &yes, socklen_t(MemoryLayout<Int32>.size))
var addr = sockaddr_in()
addr.sin_family = sa_family_t(AF_INET)
addr.sin_port = in_port_t(0).bigEndian
addr.sin_addr = in_addr(s_addr: inet_addr("127.0.0.1"))
let bindResult = withUnsafePointer(to: &addr) { ptr in
ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sa in
bind(fd, sa, socklen_t(MemoryLayout<sockaddr_in>.size))
}
}
guard bindResult == 0 else { close(fd); throw MediaAPIError.decodeFailed }
guard listen(fd, 8) == 0 else { close(fd); throw MediaAPIError.decodeFailed }
var bound = sockaddr_in()
var len = socklen_t(MemoryLayout<sockaddr_in>.size)
withUnsafeMutablePointer(to: &bound) { ptr in
_ = ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sa in
getsockname(fd, sa, &len)
}
}
let port = Int(UInt16(bigEndian: bound.sin_port))
serverFD = fd
running = true
url = URL(string: "http://127.0.0.1:\(port)")!
let thread = Thread { [weak self] in
self?.acceptLoop()
}
acceptThread = thread
thread.start()
}
func stop() {
running = false
if serverFD >= 0 { close(serverFD) }
serverFD = -1
acceptThread?.cancel()
}
var lastRequestPath: String? { lastPath }
nonisolated private func acceptLoop() {
while running {
var clientAddr = sockaddr_in()
var len = socklen_t(MemoryLayout<sockaddr_in>.size)
let clientFD = withUnsafeMutablePointer(to: &clientAddr) { ptr in
ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sa in
accept(serverFD, sa, &len)
}
}
guard clientFD >= 0 else { continue }
handleClient(clientFD)
}
}
nonisolated private func handleClient(_ clientFD: Int32) {
defer { close(clientFD) }
var buffer = [UInt8](repeating: 0, count: 4096)
let bytesRead = read(clientFD, &buffer, buffer.count)
guard bytesRead > 0 else { return }
guard let request = String(bytes: buffer[0..<bytesRead], encoding: .utf8) else { return }
var path = ""
if let firstLine = request.split(separator: "\r\n").first {
let parts = firstLine.split(separator: " ")
if parts.count >= 2 { path = String(parts[1]) }
}
lastPath = path
let body = #"{"ok": true, "room": "test", "desired": "stopped"}"#
let response = """
HTTP/1.1 200 OK\r
Content-Type: application/json\r
Content-Length: \(body.utf8.count)\r
Connection: close\r
\(body)
"""
_ = response.withCString { cstr in
write(clientFD, cstr, response.utf8.count)
}
}
}
@MainActor
struct MediaAPITests {
@Test("successful JSON object response does not throw decodeFailed")
func successfulObjectResponse() async throws {
let stub = MediaAPIStubServer()
try stub.start()
defer { stub.stop() }
// Bypass the https-only parse path: build the config directly.
let base = stub.url!
let config = ServerConfig(baseURL: base, livekitURL: base)
let api = MediaAPI(config: config)
try await api.control(room: "test", action: "pause")
try await api.report(room: "test", state: "paused", trackID: nil, position: 0, duration: nil)
try await api.play(room: "test", videoID: nil, title: nil, channel: nil, trackID: nil, startPaused: nil)
#expect(stub.lastRequestPath == "/media/api/play")
}
}
-89
View File
@@ -1,89 +0,0 @@
import Foundation
import Testing
@testable import Voice
private final class PlaybackProtocol: URLProtocol, @unchecked Sendable {
static let lock = NSLock()
nonisolated(unsafe) static var status = 200
nonisolated(unsafe) static var requests: [URLRequest] = []
override class func canInit(with request: URLRequest) -> Bool { true }
override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
override func startLoading() {
Self.lock.lock()
Self.requests.append(request)
let code = Self.status
Self.lock.unlock()
let response = HTTPURLResponse(url: request.url!, statusCode: code, httpVersion: nil, headerFields: ["Content-Type": "application/json"])!
client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
client?.urlProtocol(self, didLoad: Data("{\"ok\":true}".utf8))
client?.urlProtocolDidFinishLoading(self)
}
override func stopLoading() {}
}
@Suite(.serialized)
@MainActor struct MediaPlaybackTests {
private func controller(status: Int = 200) -> MediaPlayerController {
PlaybackProtocol.lock.withLock {
PlaybackProtocol.status = status
PlaybackProtocol.requests = []
}
let configuration = URLSessionConfiguration.ephemeral
configuration.protocolClasses = [PlaybackProtocol.self]
let url = URL(string: "https://media.test")!
let api = MediaAPI(config: ServerConfig(baseURL: url, livekitURL: url), session: URLSession(configuration: configuration))
return MediaPlayerController(api: api, settings: SettingsStore())
}
@Test("standalone playback sends stop to the default server room")
func standaloneStop() async throws {
let player = controller()
await player.stop()
let calls = PlaybackProtocol.lock.withLock { PlaybackProtocol.requests }
let stop = try #require(calls.first { $0.url?.path == "/media/api/control" })
// URLSession may expose the encoded body as an InputStream to URLProtocol.
let body: Data
if let data = stop.httpBody { body = data }
else {
let stream = try #require(stop.httpBodyStream)
stream.open()
defer { stream.close() }
var data = Data()
var buffer = [UInt8](repeating: 0, count: 1024)
while stream.hasBytesAvailable {
let count = stream.read(&buffer, maxLength: buffer.count)
if count <= 0 { break }
data.append(contentsOf: buffer.prefix(count))
}
body = data
}
let json = try #require(JSONSerialization.jsonObject(with: body) as? [String: Any])
#expect(json["room"] as? String == "default")
#expect(json["action"] as? String == "stop")
}
@Test("failed play requests are visible and a successful retry clears the error")
func failureAndRetry() async {
let player = controller(status: 503)
await player.playTrack(id: "song")
#expect(player.playbackError != nil)
PlaybackProtocol.lock.withLock { PlaybackProtocol.status = 200 }
await player.playTrack(id: "song")
#expect(player.playbackError == nil)
}
@Test("metadata refreshes when a download becomes ready")
func readyMetadata() {
let player = controller()
func state(_ status: String) -> MediaState {
MediaState(room: "default", track: MediaTrack(id: "song", kind: "youtube", videoID: nil, title: status, channel: nil, duration: 180, status: status, error: nil, audioURL: nil, artURL: nil), desired: "paused", reported: nil, seekHint: nil, choices: [], pending: [], version: 1)
}
player.reconcile(state("downloading"))
player.reconcile(state("ready"))
#expect(player.track?.status == "ready")
#expect(player.track?.title == "ready")
player.resetScope()
#expect(player.track == nil)
#expect(player.player.currentItem == nil)
}
}
-124
View File
@@ -1,124 +0,0 @@
import Foundation
import Testing
@testable import Voice
@MainActor
struct MediaReconcileTests {
private func makeController() -> MediaPlayerController {
let config = ServerConfig(
baseURL: URL(string: "http://127.0.0.1:9")!,
livekitURL: URL(string: "ws://127.0.0.1:9")!
)
let api = MediaAPI(config: config)
return MediaPlayerController(api: api, settings: SettingsStore())
}
private func makeState(
desired: String? = "stopped",
trackID: String? = nil,
audioURL: String? = nil
) -> MediaState {
let track: MediaTrack? = trackID != nil ? MediaTrack(
id: trackID!,
kind: nil,
videoID: nil,
title: nil,
channel: nil,
duration: nil,
status: nil,
error: nil,
audioURL: audioURL,
artURL: nil
) : nil
return MediaState(
room: "default",
track: track,
desired: desired,
reported: nil,
seekHint: nil,
choices: [],
pending: [],
version: 1
)
}
@Test("reconcile updates published desired to playing")
func reconcileDesiredPlaying() {
let controller = makeController()
#expect(controller.desired == "stopped")
controller.reconcile(makeState(desired: "playing"))
#expect(controller.desired == "playing")
}
@Test("reconcile updates published desired to paused")
func reconcileDesiredPaused() {
let controller = makeController()
controller.reconcile(makeState(desired: "playing"))
#expect(controller.desired == "playing")
controller.reconcile(makeState(desired: "paused"))
#expect(controller.desired == "paused")
}
@Test("reconcile updates published desired to stopped")
func reconcileDesiredStopped() {
let controller = makeController()
controller.reconcile(makeState(desired: "playing"))
#expect(controller.desired == "playing")
controller.reconcile(makeState(desired: "stopped"))
#expect(controller.desired == "stopped")
}
@Test("reconcile with nil desired defaults to stopped")
func reconcileNilDesiredDefaultsToStopped() {
let controller = makeController()
controller.reconcile(makeState(desired: "playing"))
#expect(controller.desired == "playing")
controller.reconcile(makeState(desired: nil))
#expect(controller.desired == "stopped")
}
@Test("late audio: desired=playing before audio_url, then audio_url arrives")
func lateAudioReconciliation() {
let controller = makeController()
// Phase 1: server says playing but the track has no audio_url yet
// (still downloading). The source is not wired, so play cannot start.
let stateNoURL = makeState(desired: "playing", trackID: "track-1", audioURL: nil)
controller.reconcile(stateNoURL)
#expect(controller.desired == "playing")
#expect(controller.track?.id == "track-1")
// Player rate should still be zero because no source was wired.
#expect(controller.player.rate.isZero)
// Phase 2: the same track now has an audio_url. The reconcile path
// wires the source (wireSource) and then applyDesiredState retries
// play because desired is still "playing" and rate is still zero.
let stateWithURL = makeState(desired: "playing", trackID: "track-1", audioURL: "/stream/track-1.mp3")
controller.reconcile(stateWithURL)
#expect(controller.desired == "playing")
// The source should now be wired (loadedAudioPath is set internally).
// We can't directly observe loadedAudioPath, but we can verify the
// player has a current item after wireSource ran.
#expect(controller.player.currentItem != nil)
}
@Test("startDefaultPolling restarts after endConversation keeps scope")
func pollingRestartsAfterEndConversation() async {
let controller = makeController()
controller.startDefaultPolling()
await controller.endConversation()
controller.startDefaultPolling()
try? await Task.sleep(for: .milliseconds(50))
}
@Test("togglePlayPause without a loaded track is a no-op")
func toggleWithoutTrack() async {
let controller = makeController()
await controller.togglePlayPause()
#expect(controller.track == nil)
}
}
-126
View File
@@ -1,126 +0,0 @@
import Foundation
import Testing
@testable import Voice
/// Focused tests for PinnedMediaResourceLoader.parseTotalLength, covering
/// 200, 206 with known total, 206 with unknown total (*), and malformed
/// Content-Range headers (Finding 1 / 11).
struct PinnedMediaResourceLoaderTests {
private func makeResponse(status: Int, contentRange: String? = nil, expectedLength: Int64 = -1) -> HTTPURLResponse {
var headers: [String: String] = [:]
if let contentRange {
headers["Content-Range"] = contentRange
}
return HTTPURLResponse(
url: URL(string: "https://example.com/media/test.mp3")!,
statusCode: status,
httpVersion: "HTTP/1.1",
headerFields: headers
)!
}
@Test("206 with known total parses Content-Range total")
func parse206KnownTotal() {
let response = makeResponse(status: 206, contentRange: "bytes 0-1023/4096", expectedLength: 1024)
#expect(PinnedMediaResourceLoader.parseTotalLength(response) == 4096)
}
@Test("206 with unknown total (*) returns nil")
func parse206UnknownTotal() {
let response = makeResponse(status: 206, contentRange: "bytes 0-1023/*", expectedLength: 1024)
#expect(PinnedMediaResourceLoader.parseTotalLength(response) == nil)
}
@Test("200 response returns nil (expectedContentLength is already the full size)")
func parse200ReturnsNil() {
let response = makeResponse(status: 200, expectedLength: 8192)
#expect(PinnedMediaResourceLoader.parseTotalLength(response) == nil)
}
@Test("206 without Content-Range header returns nil")
func parse206NoContentRange() {
let response = makeResponse(status: 206, expectedLength: 512)
#expect(PinnedMediaResourceLoader.parseTotalLength(response) == nil)
}
@Test("malformed Content-Range returns nil")
func parseMalformedContentRange() {
let response = makeResponse(status: 206, contentRange: "garbage", expectedLength: 100)
#expect(PinnedMediaResourceLoader.parseTotalLength(response) == nil)
}
@Test("206 with large total parses correctly")
func parseLargeTotal() {
let response = makeResponse(status: 206, contentRange: "bytes 1048576-2097151/1073741824", expectedLength: 1048576)
#expect(PinnedMediaResourceLoader.parseTotalLength(response) == 1_073_741_824)
}
@Test("streamURL constructs endpoint without downloading")
func streamURLConstruction() {
let config = ServerConfig(
baseURL: URL(string: "https://192.168.86.2:9178")!,
livekitURL: URL(string: "ws://192.168.86.2:7880")!
)
let api = MediaAPI(config: config)
let url = api.streamURL(trackID: "track-123")
// streamURL builds under the /media/api base (MediaAPI.baseURL).
#expect(url.absoluteString == "https://192.168.86.2:9178/media/api/stream/track-123")
}
// MARK: Rewrite / Resolve round-trip (Item 7)
@Test("rewrite maps https URL to pinned-media scheme and resolve inverts it")
func rewriteResolveRoundTrip() {
let loader = PinnedMediaResourceLoader(base: URL(string: "https://192.168.86.2:9178")!)
let original = URL(string: "https://192.168.86.2:9178/media/track-123.mp3")!
let pinned = loader.rewrite(original)
#expect(pinned.scheme == "pinned-media")
#expect(pinned.absoluteString == "pinned-media://192.168.86.2:9178/media/track-123.mp3")
let resolved = loader.resolve(pinned)
#expect(resolved.absoluteString == original.absoluteString)
}
@Test("rewrite maps path-only URL using base host and port")
func rewritePathOnly() {
let loader = PinnedMediaResourceLoader(base: URL(string: "https://192.168.86.2:9178")!)
let pathURL = URL(string: "/media/track-456.mp3")!
let pinned = loader.rewrite(pathURL)
#expect(pinned.scheme == "pinned-media")
#expect(pinned.absoluteString == "pinned-media://192.168.86.2:9178/media/track-456.mp3")
}
@Test("resolve leaves non-pinned URLs unchanged")
func resolveNonPinned() {
let loader = PinnedMediaResourceLoader(base: URL(string: "https://192.168.86.2:9178")!)
let https = URL(string: "https://example.com/foo.mp3")!
#expect(loader.resolve(https) == https)
}
// MARK: Asset lifecycle (Item 7)
@Test("assetWithPinnedURL creates asset and removeLoader clears mapping")
func assetLifecycle() {
let loader = PinnedMediaResourceLoader(base: URL(string: "https://192.168.86.2:9178")!)
let pinned = loader.rewrite(URL(string: "https://192.168.86.2:9178/media/test.mp3")!)
let asset = loader.assetWithPinnedURL(pinned)
// The asset should have a valid URL matching the pinned scheme.
#expect(asset.url.scheme == PinnedMediaResourceLoader.scheme)
// Remove the loader mapping — subsequent calls should not crash.
loader.removeLoader(asset)
}
@Test("removeLoader is idempotent (safe to call twice)")
func removeLoaderIdempotent() {
let loader = PinnedMediaResourceLoader(base: URL(string: "https://192.168.86.2:9178")!)
let pinned = loader.rewrite(URL(string: "https://192.168.86.2:9178/media/test.mp3")!)
let asset = loader.assetWithPinnedURL(pinned)
loader.removeLoader(asset)
// Second call should be a no-op, not a crash.
loader.removeLoader(asset)
}
}