feat(ios): downloads (DownloadManager/DownloadsView), remote control view + bridge/player refinements

(cherry picked from commit a1f7e44e17bb41f48976b69f4dbe5278cbad06b2)
This commit is contained in:
Natalie 2026-06-09 06:38:45 -07:00
parent f0669f1ca8
commit 17cf518418
12 changed files with 1083 additions and 148 deletions

View file

@ -1,18 +1,20 @@
// HTTP client for the plum-control-bridge. Phase-1 surface: list shows, and
// build a raw-file stream URL for VLCKit. Auth (when the bridge has a token set)
// rides as a Bearer header for JSON and a ?token= query for the stream URL,
// because VLCKit is handed a bare URL with no header hook.
// HTTP client for the plum-control-bridge. Covers the full surface: library,
// streaming + artwork URLs, watch progress/continue, remote transport, and
// downloads. Auth (when the bridge sets a token) rides as a Bearer header for
// JSON and a ?token= query for media URLs handed to VLCKit.
import Foundation
enum BridgeError: LocalizedError {
case badStatus(Int)
case transport(String)
case decode(String)
var errorDescription: String? {
switch self {
case .badStatus(let code): return "Bridge returned HTTP \(code)."
case .transport(let msg): return msg
case .decode(let msg): return "Bad response: \(msg)."
}
}
}
@ -21,16 +23,120 @@ struct BridgeClient {
let baseURL: URL
let token: String?
func fetchShows(refresh: Bool = false) async throws -> [BridgeShow] {
var comps = URLComponents(
url: baseURL.appendingPathComponent("library").appendingPathComponent("shows"),
resolvingAgainstBaseURL: false
)!
if refresh { comps.queryItems = [URLQueryItem(name: "refresh", value: "1")] }
// MARK: - Library / media
func fetchShows(refresh: Bool = false) async throws -> [BridgeShow] {
try await get("library/shows", query: refresh ? ["refresh": "1"] : [:], as: ShowsResponse.self).shows
}
/// URL VLCKit (or a download task) reads the raw video from. base64url id is path-safe.
func streamURL(episodeId: String) -> URL {
mediaURL(["stream", episodeId])
}
func artworkURL(episodeId: String) -> URL {
mediaURL(["artwork", episodeId])
}
// MARK: - Watch progress
func fetchContinue() async throws -> [ContinueItem] {
try await get("watch/continue", as: ContinueResponse.self).items
}
func resumePosition(episodeId: String) async -> Double {
// Best-effort: a missing resume just starts from 0.
(try? await get("watch", "episode", episodeId, as: ResumeResponse.self).positionSeconds) ?? 0
}
func reportProgress(episodeId: String, positionSeconds: Double, durationSeconds: Double?, finished: Bool) async {
var body: [String: Any] = ["episodeId": episodeId, "positionSeconds": positionSeconds, "finished": finished]
if let durationSeconds, durationSeconds > 0 { body["durationSeconds"] = durationSeconds }
_ = try? await postRaw("watch/progress", body: body)
}
// MARK: - Remote transport (Black TV)
func remoteStatus() async throws -> RemoteStatus {
try await get("remote/status", as: RemoteStatus.self)
}
func remoteCommand(action: String, value: Double? = nil) async throws {
var body: [String: Any] = ["action": action]
if let value { body["value"] = value }
try await postExpectOK("remote/command", body: body)
}
func remotePlay(show: String, season: Int? = nil, episode: Int? = nil) async throws {
var body: [String: Any] = ["show": show]
if let season { body["season"] = season }
if let episode { body["episode"] = episode }
try await postExpectOK("remote/play", body: body)
}
// MARK: - Downloads
func fetchTorrents() async throws -> [Torrent] {
try await get("torrents", as: TorrentsResponse.self).torrents
}
func searchTorrents(query: String, limit: Int = 20) async throws -> [SearchResult] {
try await get("torrents/search", query: ["q": query, "limit": String(limit)], as: SearchResponse.self).results
}
func addTorrent(magnet: String, category: String?) async throws {
var body: [String: Any] = ["magnet": magnet]
if let category { body["category"] = category }
try await postExpectOK("torrents", body: body)
}
func removeTorrent(id: Int, deleteData: Bool) async throws {
try await send("torrents/\(id)", method: "DELETE", query: deleteData ? ["delete": "1"] : [:], body: nil)
}
// MARK: - Request plumbing
private func mediaURL(_ components: [String]) -> URL {
var url = baseURL
for c in components { url.appendPathComponent(c) }
var comps = URLComponents(url: url, resolvingAgainstBaseURL: false)!
if let token { comps.queryItems = [URLQueryItem(name: "token", value: token)] }
return comps.url!
}
private func get<T: Decodable>(_ components: String..., query: [String: String] = [:], as type: T.Type) async throws -> T {
let data = try await send(path(components), method: "GET", query: query, body: nil)
do {
return try JSONDecoder().decode(T.self, from: data)
} catch {
throw BridgeError.decode(String(describing: error))
}
}
private func postRaw(_ path: String, body: [String: Any]) async throws -> Data {
try await send(path, method: "POST", query: [:], body: JSONSerialization.data(withJSONObject: body))
}
private func postExpectOK(_ path: String, body: [String: Any]) async throws {
_ = try await postRaw(path, body: body)
}
private func path(_ components: [String]) -> String { components.joined(separator: "/") }
@discardableResult
private func send(_ path: String, method: String, query: [String: String], body: Data?) async throws -> Data {
var comps = URLComponents(url: baseURL.appendingPathComponent(path), resolvingAgainstBaseURL: false)!
if !query.isEmpty {
comps.queryItems = query.map { URLQueryItem(name: $0.key, value: $0.value) }
}
var request = URLRequest(url: comps.url!)
request.timeoutInterval = 30
request.httpMethod = method
request.timeoutInterval = 60 // black SSH commands can be slow
if let token { request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") }
if let body {
request.httpBody = body
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
}
let data: Data
let response: URLResponse
@ -39,27 +145,8 @@ struct BridgeClient {
} catch {
throw BridgeError.transport(error.localizedDescription)
}
guard let http = response as? HTTPURLResponse else {
throw BridgeError.transport("No HTTP response.")
}
guard http.statusCode == 200 else {
throw BridgeError.badStatus(http.statusCode)
}
return try JSONDecoder().decode(ShowsResponse.self, from: data).shows
}
/// URL VLCKit (or a download task) reads the raw video from. The episode id
/// is base64url, hence already path-safe no further escaping needed.
func streamURL(episodeId: String) -> URL {
var comps = URLComponents(
url: baseURL.appendingPathComponent("stream").appendingPathComponent(episodeId),
resolvingAgainstBaseURL: false
)!
if let token { comps.queryItems = [URLQueryItem(name: "token", value: token)] }
return comps.url!
}
func healthURL() -> URL {
baseURL.appendingPathComponent("healthz")
guard let http = response as? HTTPURLResponse else { throw BridgeError.transport("No HTTP response.") }
guard (200..<300).contains(http.statusCode) else { throw BridgeError.badStatus(http.statusCode) }
return data
}
}

View file

@ -30,3 +30,88 @@ struct BridgeEpisode: Codable, Identifiable, Hashable {
struct ShowsResponse: Codable {
let shows: [BridgeShow]
}
// MARK: - Continue watching / prefetch
struct ResumePoint: Codable, Hashable {
let episodeId: String
let season: Int
let episode: Int
let label: String
let positionSeconds: Double
let durationSeconds: Double?
var code: String { String(format: "S%02dE%02d", season, episode) }
}
struct NextEpisode: Codable, Hashable {
let episodeId: String
let season: Int
let episode: Int
let label: String
}
struct ContinueItem: Codable, Identifiable, Hashable {
let show: String
let showId: String
let resume: ResumePoint?
let next: NextEpisode?
let lastWatched: String
var id: String { showId }
}
struct ContinueResponse: Codable { let items: [ContinueItem] }
struct ResumeResponse: Codable { let positionSeconds: Double }
// MARK: - Remote transport (Black TV)
struct RemoteStatus: Codable, Hashable {
let playing: Bool
let paused: Bool?
let title: String?
let volume: Double?
let position: Double?
let duration: Double?
}
// MARK: - Downloads
struct Torrent: Codable, Identifiable, Hashable {
let id: Int
let name: String
let percentDone: Double
let status: Int
let rateDownload: Double
let rateUpload: Double
let eta: Double
let sizeWhenDone: Double
let haveValid: Double
var statusLabel: String {
switch status {
case 0: return "Stopped"
case 1, 2: return "Checking"
case 3, 5: return "Queued"
case 4: return "Downloading"
case 6: return "Seeding"
default: return ""
}
}
}
struct TorrentsResponse: Codable { let torrents: [Torrent] }
struct SearchResult: Codable, Identifiable, Hashable {
let filename: String
let source: String
let size: String
let seeders: Int
let leechers: Int
let magnet: String?
var id: String { (magnet ?? filename) + source }
}
struct SearchResponse: Codable { let results: [SearchResult] }

View file

@ -13,6 +13,8 @@ final class BridgeSettings: ObservableObject {
@Published var port: Int { didSet { store.set(port, forKey: Keys.port) } }
@Published var token: String { didSet { store.set(token, forKey: Keys.token) } }
@Published var networkCachingMs: Int { didSet { store.set(networkCachingMs, forKey: Keys.buffer) } }
@Published var prefetchEnabled: Bool { didSet { store.set(prefetchEnabled, forKey: Keys.prefetchOn) } }
@Published var prefetchCount: Int { didSet { store.set(prefetchCount, forKey: Keys.prefetchN) } }
init() {
host = store.string(forKey: Keys.host) ?? "127.0.0.1"
@ -21,6 +23,9 @@ final class BridgeSettings: ObservableObject {
token = store.string(forKey: Keys.token) ?? ""
let buf = store.integer(forKey: Keys.buffer)
networkCachingMs = buf == 0 ? 1500 : buf
prefetchEnabled = store.object(forKey: Keys.prefetchOn) as? Bool ?? true
let n = store.integer(forKey: Keys.prefetchN)
prefetchCount = n == 0 ? 3 : n
}
var baseURL: URL? {
@ -37,5 +42,7 @@ final class BridgeSettings: ObservableObject {
static let port = "bridge.port"
static let token = "bridge.token"
static let buffer = "bridge.networkCachingMs"
static let prefetchOn = "bridge.prefetchEnabled"
static let prefetchN = "bridge.prefetchCount"
}
}

View file

@ -0,0 +1,194 @@
// Offline downloads + prefetch-ahead. Raw files are pulled from the bridge's
// /stream endpoint (same bytes VLCKit streams) into app storage and played
// locally. The prefetch policy keeps the next N episodes after the user's
// position downloaded, so the next ones are always ready offline the app-side
// equivalent of the governor's buffer.
import Foundation
import CryptoKit
struct OfflineEntry: Codable, Identifiable, Hashable {
let episodeId: String
let filename: String
let show: String
let label: String
let season: Int
let episode: Int
var bytes: Int64
var id: String { episodeId }
var code: String { String(format: "S%02dE%02d", season, episode) }
}
/// Metadata needed to download one episode (so the offline index is self-describing).
struct DownloadRequest {
let episodeId: String
let ext: String
let show: String
let label: String
let season: Int
let episode: Int
let url: URL
}
@MainActor
final class DownloadManager: NSObject, ObservableObject {
enum State: Equatable {
case downloading(Double) // 0...1
case completed
case failed(String)
}
@Published private(set) var entries: [OfflineEntry] = []
@Published private(set) var states: [String: State] = [:]
private var taskToEpisode: [Int: String] = [:]
private var requestByEpisode: [String: DownloadRequest] = [:]
private lazy var session: URLSession = {
let cfg = URLSessionConfiguration.default
cfg.waitsForConnectivity = true
return URLSession(configuration: cfg, delegate: self, delegateQueue: nil)
}()
override init() {
super.init()
try? FileManager.default.createDirectory(at: Self.dir, withIntermediateDirectories: true)
loadIndex()
}
// MARK: - Storage layout
private static var dir: URL {
FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
.appendingPathComponent("offline", isDirectory: true)
}
private static var indexURL: URL { dir.appendingPathComponent("index.json") }
private static func filename(for episodeId: String, ext: String) -> String {
let digest = SHA256.hash(data: Data(episodeId.utf8))
let hex = digest.map { String(format: "%02x", $0) }.joined()
return "\(hex).\(ext.isEmpty ? "mkv" : ext)"
}
/// Local file for an episode, if fully downloaded.
func localURL(episodeId: String) -> URL? {
guard let entry = entries.first(where: { $0.episodeId == episodeId }) else { return nil }
let url = Self.dir.appendingPathComponent(entry.filename)
return FileManager.default.fileExists(atPath: url.path) ? url : nil
}
func isDownloaded(_ episodeId: String) -> Bool { localURL(episodeId: episodeId) != nil }
func isActive(_ episodeId: String) -> Bool {
if case .downloading = states[episodeId] { return true }
return false
}
var totalBytes: Int64 { entries.reduce(0) { $0 + $1.bytes } }
// MARK: - Operations
func download(_ req: DownloadRequest) {
guard !isDownloaded(req.episodeId), !isActive(req.episodeId) else { return }
requestByEpisode[req.episodeId] = req
states[req.episodeId] = .downloading(0)
let task = session.downloadTask(with: req.url)
taskToEpisode[task.taskIdentifier] = req.episodeId
task.resume()
}
func delete(episodeId: String) {
if let entry = entries.first(where: { $0.episodeId == episodeId }) {
try? FileManager.default.removeItem(at: Self.dir.appendingPathComponent(entry.filename))
}
entries.removeAll { $0.episodeId == episodeId }
states[episodeId] = nil
saveIndex()
}
/// Keep the next `count` upcoming episodes downloaded. `upcoming` is the
/// ordered list of episodes after the user's current position.
func prefetch(upcoming: [DownloadRequest], count: Int) {
for req in upcoming.prefix(count) {
download(req)
}
}
// MARK: - Index persistence
private func loadIndex() {
guard let data = try? Data(contentsOf: Self.indexURL),
let decoded = try? JSONDecoder().decode([OfflineEntry].self, from: data) else { return }
// Drop entries whose file vanished.
entries = decoded.filter { FileManager.default.fileExists(atPath: Self.dir.appendingPathComponent($0.filename).path) }
}
private func saveIndex() {
if let data = try? JSONEncoder().encode(entries) {
try? data.write(to: Self.indexURL)
}
}
fileprivate func finish(episodeId: String, tempURL: URL) {
guard let req = requestByEpisode[episodeId] else { return }
let filename = Self.filename(for: episodeId, ext: req.ext)
let dest = Self.dir.appendingPathComponent(filename)
try? FileManager.default.removeItem(at: dest)
do {
try FileManager.default.moveItem(at: tempURL, to: dest)
} catch {
states[episodeId] = .failed(error.localizedDescription)
return
}
let bytes = (try? FileManager.default.attributesOfItem(atPath: dest.path)[.size] as? Int64) ?? 0
entries.removeAll { $0.episodeId == episodeId }
entries.append(OfflineEntry(
episodeId: episodeId, filename: filename, show: req.show, label: req.label,
season: req.season, episode: req.episode, bytes: bytes
))
entries.sort { ($0.show, $0.season, $0.episode) < ($1.show, $1.season, $1.episode) }
states[episodeId] = .completed
requestByEpisode[episodeId] = nil
saveIndex()
}
}
// URLSession delegate runs off the main actor; hop back to update @Published state.
extension DownloadManager: URLSessionDownloadDelegate {
nonisolated func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask,
didWriteData bytesWritten: Int64, totalBytesWritten: Int64,
totalBytesExpectedToWrite: Int64) {
guard totalBytesExpectedToWrite > 0 else { return }
let progress = Double(totalBytesWritten) / Double(totalBytesExpectedToWrite)
let tid = downloadTask.taskIdentifier
Task { @MainActor in
if let ep = self.taskToEpisode[tid] { self.states[ep] = .downloading(progress) }
}
}
nonisolated func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask,
didFinishDownloadingTo location: URL) {
// The temp file is deleted when this returns, so move it synchronously here.
let tid = downloadTask.taskIdentifier
let fm = FileManager.default
let staged = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try? fm.moveItem(at: location, to: staged)
Task { @MainActor in
guard let ep = self.taskToEpisode[tid] else { return }
self.taskToEpisode[tid] = nil
self.finish(episodeId: ep, tempURL: staged)
}
}
nonisolated func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
guard let error else { return }
let tid = task.taskIdentifier
Task { @MainActor in
if let ep = self.taskToEpisode[tid] {
self.states[ep] = .failed(error.localizedDescription)
self.taskToEpisode[tid] = nil
}
}
}
}

View file

@ -0,0 +1,214 @@
// Downloads tab: two modes Offline (episodes saved on this device) and
// Torrents (search + transmission management on black).
import SwiftUI
import LilithDesignTokens
struct DownloadsView: View {
enum Mode: String, CaseIterable { case offline = "Offline", torrents = "Torrents" }
@State private var mode: Mode = .offline
var body: some View {
NavigationStack {
ZStack {
AppColors.background.ignoresSafeArea()
VStack(spacing: 0) {
Picker("Mode", selection: $mode) {
ForEach(Mode.allCases, id: \.self) { Text($0.rawValue).tag($0) }
}
.pickerStyle(.segmented)
.padding(AppSpacing.base)
switch mode {
case .offline: OfflineList()
case .torrents: TorrentsList()
}
}
}
.navigationTitle("Downloads")
.navigationDestination(for: PlaybackTarget.self) { PlayerScreen(show: $0.show, episode: $0.episode) }
}
}
}
// MARK: - Offline
private struct OfflineList: View {
@EnvironmentObject private var downloads: DownloadManager
var body: some View {
if downloads.entries.isEmpty && downloads.states.isEmpty {
ContentUnavailableView("Nothing downloaded", systemImage: "arrow.down.circle",
description: Text("Download episodes from the Library to watch offline."))
} else {
ScrollView {
LazyVStack(spacing: AppSpacing.sm) {
ForEach(downloads.entries) { entry in
NavigationLink(value: PlaybackTarget(show: nil, episode: entry.asEpisode)) {
OfflineRow(entry: entry) { downloads.delete(episodeId: entry.episodeId) }
}
.buttonStyle(.plain)
}
}
.padding(AppSpacing.base)
}
}
}
}
private struct OfflineRow: View {
let entry: OfflineEntry
let onDelete: () -> Void
var body: some View {
HStack(spacing: AppSpacing.md) {
Image(systemName: "play.circle.fill").font(.title2).foregroundStyle(AppColors.primary)
VStack(alignment: .leading, spacing: 2) {
Text("\(entry.show.isEmpty ? entry.label : entry.show)")
.font(AppTypography.bodySmall(weight: .medium))
.foregroundStyle(AppColors.textPrimary).lineLimit(1)
Text("\(entry.code) · \(ByteCountFormatter.string(fromByteCount: entry.bytes, countStyle: .file))")
.font(AppTypography.caption()).foregroundStyle(AppColors.textSecondary)
}
Spacer()
Button(role: .destructive, action: onDelete) {
Image(systemName: "trash").foregroundStyle(AppColors.Semantic.error)
}
.buttonStyle(.plain)
}
.padding(AppSpacing.md)
.background(AppColors.surface, in: RoundedRectangle(cornerRadius: 12))
}
}
// MARK: - Torrents
private struct TorrentsList: View {
@EnvironmentObject private var settings: BridgeSettings
@State private var query = ""
@State private var results: [SearchResult] = []
@State private var torrents: [Torrent] = []
@State private var searching = false
@State private var errorText: String?
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: AppSpacing.md) {
searchBar
if let errorText {
Text(errorText).font(AppTypography.caption()).foregroundStyle(AppColors.Semantic.error)
}
if !results.isEmpty {
Text("Results").font(AppTypography.h5()).foregroundStyle(AppColors.textPrimary)
ForEach(results) { ResultRow(result: $0, onAdd: { add($0) }) }
}
if !torrents.isEmpty {
Text("Active").font(AppTypography.h5()).foregroundStyle(AppColors.textPrimary)
ForEach(torrents) { t in
TorrentRow(torrent: t, onRemove: { remove(t) })
}
}
}
.padding(AppSpacing.base)
}
.task { await refreshActive() }
}
private var searchBar: some View {
HStack {
Image(systemName: "magnifyingglass").foregroundStyle(AppColors.textSecondary)
TextField("Search torrents…", text: $query)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.onSubmit { Task { await search() } }
if searching { ProgressView() }
}
.padding(AppSpacing.md)
.background(AppColors.surface, in: RoundedRectangle(cornerRadius: 10))
}
private func search() async {
guard let client = settings.client, !query.isEmpty else { return }
searching = true; defer { searching = false }
do { results = try await client.searchTorrents(query: query); errorText = nil }
catch { errorText = error.localizedDescription }
}
private func add(_ r: SearchResult) {
guard let client = settings.client, let magnet = r.magnet else { return }
Task {
do { try await client.addTorrent(magnet: magnet, category: nil); await refreshActive() }
catch { errorText = error.localizedDescription }
}
}
private func remove(_ t: Torrent) {
guard let client = settings.client else { return }
Task {
do { try await client.removeTorrent(id: t.id, deleteData: false); await refreshActive() }
catch { errorText = error.localizedDescription }
}
}
private func refreshActive() async {
guard let client = settings.client else { return }
torrents = (try? await client.fetchTorrents()) ?? []
}
}
private struct ResultRow: View {
let result: SearchResult
let onAdd: (SearchResult) -> Void
var body: some View {
HStack(spacing: AppSpacing.md) {
VStack(alignment: .leading, spacing: 2) {
Text(result.filename).font(AppTypography.caption()).foregroundStyle(AppColors.textPrimary)
.lineLimit(2)
Text("\(result.size) · ▲\(result.seeders)\(result.leechers) · \(result.source)")
.font(AppTypography.caption()).foregroundStyle(AppColors.textSecondary)
}
Spacer()
Button { onAdd(result) } label: {
Image(systemName: "plus.circle.fill").font(.title3).foregroundStyle(AppColors.primary)
}
.buttonStyle(.plain)
.disabled(result.magnet == nil)
}
.padding(AppSpacing.md)
.background(AppColors.surface, in: RoundedRectangle(cornerRadius: 12))
}
}
private struct TorrentRow: View {
let torrent: Torrent
let onRemove: () -> Void
var body: some View {
HStack(spacing: AppSpacing.md) {
VStack(alignment: .leading, spacing: 4) {
Text(torrent.name).font(AppTypography.caption()).foregroundStyle(AppColors.textPrimary).lineLimit(1)
ProgressView(value: torrent.percentDone).tint(AppColors.primary)
Text("\(torrent.statusLabel) · \(Int(torrent.percentDone * 100))%")
.font(AppTypography.caption()).foregroundStyle(AppColors.textSecondary)
}
Button(role: .destructive, action: onRemove) {
Image(systemName: "xmark.circle").foregroundStyle(AppColors.Semantic.error)
}
.buttonStyle(.plain)
}
.padding(AppSpacing.md)
.background(AppColors.surface, in: RoundedRectangle(cornerRadius: 12))
}
}
private extension OfflineEntry {
/// Synthesize a BridgeEpisode for the player from an offline entry.
var asEpisode: BridgeEpisode {
BridgeEpisode(
id: episodeId, season: season, episode: episode, label: label,
ext: (filename as NSString).pathExtension
)
}
}

View file

@ -1,17 +1,24 @@
// Browse the network library: shows episodes play. The whole screen is
// driven by one bridge call (fetchShows); episodes come embedded in each show.
// Styled with the shared Lilith dark-first design tokens.
// Library tab: a Continue-Watching rail (resume where you left off) over the
// full show list. Artwork is ffmpeg frame-grabs served by the bridge.
import SwiftUI
import LilithDesignTokens
/// A concrete (show, episode) pair to play one navigation destination for the
/// whole stack, so pushes from the rail and from the episode list both work.
struct PlaybackTarget: Hashable {
/// Present for library playback (enables prefetch-ahead); nil for offline-only.
let show: BridgeShow?
let episode: BridgeEpisode
}
struct LibraryView: View {
@EnvironmentObject private var settings: BridgeSettings
@State private var shows: [BridgeShow] = []
@State private var continueItems: [ContinueItem] = []
@State private var loading = false
@State private var errorText: String?
@State private var showingSettings = false
var body: some View {
NavigationStack {
@ -20,24 +27,11 @@ struct LibraryView: View {
content
}
.navigationTitle("Library")
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button { showingSettings = true } label: {
Image(systemName: "gearshape")
.foregroundStyle(AppColors.textSecondary)
}
}
}
.navigationDestination(for: BridgeShow.self) { show in
EpisodesView(show: show)
}
.sheet(isPresented: $showingSettings) {
SettingsView().environmentObject(settings)
}
.navigationDestination(for: BridgeShow.self) { EpisodesView(show: $0) }
.navigationDestination(for: PlaybackTarget.self) { PlayerScreen(show: $0.show, episode: $0.episode) }
.task { await load(refresh: false) }
.refreshable { await load(refresh: true) }
}
.tint(AppColors.primary)
}
@ViewBuilder
@ -48,30 +42,65 @@ struct LibraryView: View {
} description: {
Text(errorText)
} actions: {
Button("Settings") { showingSettings = true }
Button("Retry") { Task { await load(refresh: true) } }
}
} else if shows.isEmpty && loading {
ProgressView("Loading library…")
.tint(AppColors.primary)
.foregroundStyle(AppColors.textSecondary)
ProgressView("Loading library…").tint(AppColors.primary)
} else if shows.isEmpty {
ContentUnavailableView("No shows", systemImage: "tv")
} else {
ScrollView {
LazyVStack(spacing: AppSpacing.md) {
ForEach(shows) { show in
NavigationLink(value: show) {
ShowRow(show: show)
}
.buttonStyle(.plain)
LazyVStack(alignment: .leading, spacing: AppSpacing.lg) {
if !continueItems.isEmpty {
continueRail
}
Text("All Shows")
.font(AppTypography.h4())
.foregroundStyle(AppColors.textPrimary)
.padding(.horizontal, AppSpacing.base)
LazyVStack(spacing: AppSpacing.md) {
ForEach(shows) { show in
NavigationLink(value: show) { ShowRow(show: show, client: settings.client) }
.buttonStyle(.plain)
}
}
.padding(.horizontal, AppSpacing.base)
}
.padding(AppSpacing.base)
.padding(.vertical, AppSpacing.base)
}
}
}
private var continueRail: some View {
VStack(alignment: .leading, spacing: AppSpacing.sm) {
Text("Continue Watching")
.font(AppTypography.h4())
.foregroundStyle(AppColors.textPrimary)
.padding(.horizontal, AppSpacing.base)
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: AppSpacing.md) {
ForEach(continueItems) { item in
if let target = playTarget(for: item) {
NavigationLink(value: target) {
ContinueCard(item: item, client: settings.client)
}
.buttonStyle(.plain)
}
}
}
.padding(.horizontal, AppSpacing.base)
}
}
}
/// Resolve a continue item's resume episode back to concrete (show, episode).
private func playTarget(for item: ContinueItem) -> PlaybackTarget? {
guard let resume = item.resume,
let show = shows.first(where: { $0.id == item.showId }),
let episode = show.episodes.first(where: { $0.id == resume.episodeId }) else { return nil }
return PlaybackTarget(show: show, episode: episode)
}
private func load(refresh: Bool) async {
guard let client = settings.client else {
errorText = "Set a bridge host in Settings."
@ -81,6 +110,7 @@ struct LibraryView: View {
defer { loading = false }
do {
shows = try await client.fetchShows(refresh: refresh)
continueItems = (try? await client.fetchContinue()) ?? []
errorText = nil
} catch {
errorText = error.localizedDescription
@ -88,18 +118,33 @@ struct LibraryView: View {
}
}
// MARK: - Rows & cards
struct ArtworkThumb: View {
let url: URL?
var body: some View {
AsyncImage(url: url) { phase in
if case .success(let image) = phase {
image.resizable().aspectRatio(contentMode: .fill)
} else {
ZStack {
AppColors.surfaceElevated
Image(systemName: "play.tv").foregroundStyle(AppColors.textTertiary)
}
}
}
}
}
private struct ShowRow: View {
let show: BridgeShow
let client: BridgeClient?
var body: some View {
HStack(spacing: AppSpacing.md) {
RoundedRectangle(cornerRadius: 8)
.fill(AppColors.primary.opacity(0.18))
.frame(width: 44, height: 44)
.overlay {
Image(systemName: "play.tv.fill")
.foregroundStyle(AppColors.primary)
}
ArtworkThumb(url: show.episodes.first.flatMap { client?.artworkURL(episodeId: $0.id) })
.frame(width: 80, height: 45)
.clipShape(RoundedRectangle(cornerRadius: 6))
VStack(alignment: .leading, spacing: 2) {
Text(show.name)
.font(AppTypography.body(weight: .semibold))
@ -109,17 +154,49 @@ private struct ShowRow: View {
.foregroundStyle(AppColors.textSecondary)
}
Spacer()
Image(systemName: "chevron.right")
.font(.caption)
.foregroundStyle(AppColors.textTertiary)
Image(systemName: "chevron.right").font(.caption).foregroundStyle(AppColors.textTertiary)
}
.padding(AppSpacing.base)
.padding(AppSpacing.md)
.background(AppColors.surface, in: RoundedRectangle(cornerRadius: 14))
}
}
private struct ContinueCard: View {
let item: ContinueItem
let client: BridgeClient?
var body: some View {
VStack(alignment: .leading, spacing: 4) {
ZStack(alignment: .bottomLeading) {
ArtworkThumb(url: item.resume.flatMap { client?.artworkURL(episodeId: $0.episodeId) })
.frame(width: 200, height: 112)
.clipShape(RoundedRectangle(cornerRadius: 10))
if let resume = item.resume, let dur = resume.durationSeconds, dur > 0 {
GeometryReader { geo in
Rectangle()
.fill(AppColors.primary)
.frame(width: geo.size.width * min(1, resume.positionSeconds / dur), height: 3)
}
.frame(height: 3)
}
}
Text(item.show)
.font(AppTypography.bodySmall(weight: .medium))
.foregroundStyle(AppColors.textPrimary)
.lineLimit(1)
Text(item.resume.map { "\($0.code)" } ?? "")
.font(AppTypography.caption())
.foregroundStyle(AppColors.textSecondary)
}
.frame(width: 200)
}
}
// MARK: - Episodes
struct EpisodesView: View {
@EnvironmentObject private var settings: BridgeSettings
@EnvironmentObject private var downloads: DownloadManager
let show: BridgeShow
var body: some View {
@ -128,19 +205,15 @@ struct EpisodesView: View {
ScrollView {
LazyVStack(spacing: AppSpacing.sm) {
ForEach(show.episodes) { ep in
if let client = settings.client {
// Destination-based link: robust inside a pushed view
// (value-based destinations don't always register here).
NavigationLink {
PlayerScreen(
title: "\(show.name) · \(ep.code)",
url: client.streamURL(episodeId: ep.id),
networkCachingMs: settings.networkCachingMs
)
} label: {
// Download control is a *sibling* of the link, not a child
// a Button nested inside a NavigationLink swallows the row tap.
ZStack(alignment: .trailing) {
NavigationLink(value: PlaybackTarget(show: show, episode: ep)) {
EpisodeRow(episode: ep)
}
.buttonStyle(.plain)
DownloadControl(episode: ep, showName: show.name, downloads: downloads, client: settings.client)
.padding(.trailing, AppSpacing.base)
}
}
}
@ -149,7 +222,6 @@ struct EpisodesView: View {
}
.navigationTitle(show.name)
.navigationBarTitleDisplayMode(.inline)
.tint(AppColors.primary)
}
}
@ -161,18 +233,43 @@ private struct EpisodeRow: View {
Text(episode.code)
.font(AppTypography.mono(size: 13))
.foregroundStyle(AppColors.secondary)
.frame(width: 70, alignment: .leading)
.frame(width: 64, alignment: .leading)
Text(episode.label)
.font(AppTypography.bodySmall())
.foregroundStyle(AppColors.textPrimary)
.lineLimit(1)
.truncationMode(.middle)
.lineLimit(1).truncationMode(.middle)
Spacer()
Image(systemName: "play.circle.fill")
.foregroundStyle(AppColors.primary)
Image(systemName: "play.circle.fill").foregroundStyle(AppColors.primary)
Color.clear.frame(width: 28) // reserve space for the overlaid download control
}
.padding(.vertical, AppSpacing.md)
.padding(.horizontal, AppSpacing.base)
.background(AppColors.surface, in: RoundedRectangle(cornerRadius: 12))
}
}
private struct DownloadControl: View {
let episode: BridgeEpisode
let showName: String
@ObservedObject var downloads: DownloadManager
let client: BridgeClient?
var body: some View {
if downloads.isDownloaded(episode.id) {
Image(systemName: "arrow.down.circle.fill").foregroundStyle(AppColors.Semantic.success)
} else if case .downloading(let p) = downloads.states[episode.id] {
ProgressView(value: p).progressViewStyle(.circular).tint(AppColors.primary)
.frame(width: 20, height: 20)
} else if let client {
Button {
downloads.download(DownloadRequest(
episodeId: episode.id, ext: episode.ext, show: showName, label: episode.label,
season: episode.season, episode: episode.episode, url: client.streamURL(episodeId: episode.id)
))
} label: {
Image(systemName: "arrow.down.circle").foregroundStyle(AppColors.textSecondary)
}
.buttonStyle(.plain)
}
}
}

View file

@ -1,43 +1,60 @@
// Full-screen video with an auto-hiding control overlay. The VLCKit drawable is
// a plain UIView bridged via UIViewRepresentable; all transport goes through
// VLCPlayerModel.
// Full-screen video with auto-hiding controls. Prefers a local offline copy,
// otherwise streams. Restores the saved resume position, reports progress back
// to the bridge, and kicks off prefetch-ahead for upcoming episodes.
import SwiftUI
import MobileVLCKit
import LilithDesignTokens
struct PlayerScreen: View {
let title: String
let url: URL
let networkCachingMs: Int
let show: BridgeShow?
let episode: BridgeEpisode
@EnvironmentObject private var settings: BridgeSettings
@EnvironmentObject private var downloads: DownloadManager
@StateObject private var model = VLCPlayerModel()
@State private var controlsVisible = true
@State private var scrubValue: Double = 0
@State private var playingLocally = false
private var title: String {
if let show { return "\(show.name) · \(episode.code)" }
return episode.label
}
var body: some View {
ZStack {
Color.black.ignoresSafeArea()
VLCVideoView(player: model.player)
.ignoresSafeArea()
VLCVideoView(player: model.player).ignoresSafeArea()
if model.buffering {
ProgressView().tint(.white).scaleEffect(1.4)
}
if controlsVisible {
controls
.transition(.opacity)
controls.transition(.opacity)
}
if playingLocally {
VStack {
HStack {
Spacer()
Label("Offline", systemImage: "arrow.down.circle.fill")
.font(.caption2)
.padding(6)
.background(.ultraThinMaterial, in: Capsule())
.padding()
}
Spacer()
}
}
}
.contentShape(Rectangle())
.onTapGesture {
withAnimation { controlsVisible.toggle() }
}
.onTapGesture { withAnimation { controlsVisible.toggle() } }
.navigationTitle(title)
.navigationBarTitleDisplayMode(.inline)
.onAppear { model.start(url: url, networkCachingMs: networkCachingMs) }
.onDisappear { model.teardown() }
.task { await startPlayback() }
.task { await reportProgressLoop() }
.onDisappear { Task { await finish() }; model.teardown() }
}
private var controls: some View {
@ -62,18 +79,10 @@ struct PlayerScreen: View {
HStack(spacing: 10) {
Text(model.elapsed)
.font(.caption.monospacedDigit())
.accessibilityIdentifier("elapsed") // UI test asserts this advances
Slider(
value: $scrubValue,
in: 0...1,
onEditingChanged: { editing in
if editing {
model.beginScrub()
} else {
model.commitScrub(to: scrubValue)
}
}
)
.accessibilityIdentifier("elapsed")
Slider(value: $scrubValue, in: 0...1, onEditingChanged: { editing in
if editing { model.beginScrub() } else { model.commitScrub(to: scrubValue) }
})
.tint(AppColors.primary)
Text(model.remaining)
.font(.caption.monospacedDigit())
@ -83,22 +92,72 @@ struct PlayerScreen: View {
.padding(.bottom, 24)
}
.background(
LinearGradient(
colors: [.clear, .black.opacity(0.6)],
startPoint: .center,
endPoint: .bottom
)
.ignoresSafeArea()
LinearGradient(colors: [.clear, .black.opacity(0.6)], startPoint: .center, endPoint: .bottom)
.ignoresSafeArea()
)
// Keep the slider in sync with playback unless the user is dragging it.
.onReceive(model.$position) { p in
scrubValue = p
.onReceive(model.$position) { scrubValue = $0 }
}
// MARK: - Playback lifecycle
private func startPlayback() async {
guard let client = settings.client else { return }
let local = downloads.localURL(episodeId: episode.id)
playingLocally = local != nil
let url = local ?? client.streamURL(episodeId: episode.id)
// Start the first frame immediately never block it on the resume fetch,
// which is what made offline playback hang.
model.start(url: url, networkCachingMs: settings.networkCachingMs, startAt: 0)
if settings.prefetchEnabled, show != nil {
downloads.prefetch(upcoming: upcomingRequests(client), count: settings.prefetchCount)
}
// Resume opportunistically: applies when the bridge answers (online),
// silently skipped when it doesn't (offline).
let resume = await client.resumePosition(episodeId: episode.id)
model.requestResume(seconds: resume)
}
private func reportProgressLoop() async {
guard let client = settings.client else { return }
while !Task.isCancelled {
try? await Task.sleep(for: .seconds(15))
guard model.positionSeconds > 0 else { continue }
await client.reportProgress(
episodeId: episode.id,
positionSeconds: model.positionSeconds,
durationSeconds: model.durationSeconds,
finished: false
)
}
}
private func finish() async {
guard let client = settings.client, model.positionSeconds > 0 else { return }
let finished = model.durationSeconds > 0 && model.positionSeconds >= model.durationSeconds * 0.92
await client.reportProgress(
episodeId: episode.id,
positionSeconds: model.positionSeconds,
durationSeconds: model.durationSeconds,
finished: finished
)
}
/// Episodes after the current one the prefetch-ahead candidates.
private func upcomingRequests(_ client: BridgeClient) -> [DownloadRequest] {
guard let show, let idx = show.episodes.firstIndex(of: episode), idx + 1 < show.episodes.count else { return [] }
return show.episodes[(idx + 1)...].map { ep in
DownloadRequest(
episodeId: ep.id, ext: ep.ext, show: show.name, label: ep.label,
season: ep.season, episode: ep.episode, url: client.streamURL(episodeId: ep.id)
)
}
}
}
/// Hosts VLCKit's video output. The drawable must be set on a live UIView, so we
/// hand the player's drawable to the view we create here.
/// Hosts VLCKit's video output.
struct VLCVideoView: UIViewRepresentable {
let player: VLCMediaPlayer

View file

@ -0,0 +1,105 @@
// Remote tab: control the Black TV (mpv on black's HDMI). Polls status and
// issues transport commands through the bridge's /remote endpoints.
import SwiftUI
import LilithDesignTokens
struct RemoteView: View {
@EnvironmentObject private var settings: BridgeSettings
@State private var status: RemoteStatus?
@State private var errorText: String?
@State private var volume: Double = 100
var body: some View {
NavigationStack {
ZStack {
AppColors.background.ignoresSafeArea()
VStack(spacing: AppSpacing.xl) {
nowPlaying
transport
volumeControl
if let errorText {
Text(errorText).font(AppTypography.caption()).foregroundStyle(AppColors.Semantic.error)
}
Spacer()
}
.padding(AppSpacing.lg)
}
.navigationTitle("Black TV")
.task { await pollLoop() }
}
}
private var nowPlaying: some View {
VStack(spacing: AppSpacing.sm) {
Image(systemName: "tv")
.font(.system(size: 56))
.foregroundStyle(status?.playing == true ? AppColors.primary : AppColors.textTertiary)
.padding(.top, AppSpacing.xl)
Text(status?.title ?? "Nothing playing")
.font(AppTypography.h5())
.foregroundStyle(AppColors.textPrimary)
.multilineTextAlignment(.center)
.lineLimit(2)
if let s = status, let pos = s.position, let dur = s.duration, dur > 0 {
ProgressView(value: min(1, pos / dur)).tint(AppColors.primary)
}
}
}
private var transport: some View {
HStack(spacing: AppSpacing.xl) {
transportButton("backward.end.fill") { await send { try await $0.remoteCommand(action: "prev") } }
transportButton("gobackward.30") { await send { try await $0.remoteCommand(action: "seek", value: -30) } }
transportButton(status?.paused == true ? "play.fill" : "pause.fill", big: true) {
await send { try await $0.remoteCommand(action: "playpause") }
}
transportButton("goforward.30") { await send { try await $0.remoteCommand(action: "seek", value: 30) } }
transportButton("forward.end.fill") { await send { try await $0.remoteCommand(action: "next") } }
}
.foregroundStyle(AppColors.textPrimary)
}
private var volumeControl: some View {
HStack(spacing: AppSpacing.md) {
Image(systemName: "speaker.fill").foregroundStyle(AppColors.textSecondary)
Slider(value: $volume, in: 0...130, step: 1, onEditingChanged: { editing in
if !editing { Task { await send { try await $0.remoteCommand(action: "volume", value: volume) } } }
})
.tint(AppColors.primary)
Image(systemName: "speaker.wave.3.fill").foregroundStyle(AppColors.textSecondary)
}
}
private func transportButton(_ symbol: String, big: Bool = false, _ action: @escaping () async -> Void) -> some View {
Button { Task { await action() } } label: {
Image(systemName: symbol).font(.system(size: big ? 44 : 26))
}
.buttonStyle(.plain)
}
private func send(_ op: (BridgeClient) async throws -> Void) async {
guard let client = settings.client else { return }
do {
try await op(client)
errorText = nil
status = try? await client.remoteStatus()
} catch {
errorText = error.localizedDescription
}
}
private func pollLoop() async {
while !Task.isCancelled {
if let client = settings.client {
if let s = try? await client.remoteStatus() {
status = s
if let v = s.volume { volume = v }
errorText = nil
}
}
try? await Task.sleep(for: .seconds(3))
}
}
}

View file

@ -1,12 +1,12 @@
// Connection + playback settings. Host/port point at the bridge (plum now, black
// later); the buffer slider maps to VLCKit --network-caching.
// Settings tab: bridge connection, playback buffer, prefetch-ahead policy, and
// offline storage management.
import SwiftUI
import LilithDesignTokens
struct SettingsView: View {
struct SettingsScreen: View {
@EnvironmentObject private var settings: BridgeSettings
@Environment(\.dismiss) private var dismiss
@EnvironmentObject private var downloads: DownloadManager
@State private var portText = ""
@ -42,8 +42,7 @@ struct SettingsView: View {
get: { Double(settings.networkCachingMs) },
set: { settings.networkCachingMs = Int($0) }
),
in: 300...8000,
step: 100
in: 300...8000, step: 100
)
}
} header: {
@ -51,17 +50,31 @@ struct SettingsView: View {
} footer: {
Text("Higher buffer absorbs more network jitter but makes seeking slower. 1500 ms is a good default over the mesh.")
}
}
.navigationTitle("Settings")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("Done") { dismiss() }
Section {
Toggle("Prefetch ahead", isOn: $settings.prefetchEnabled)
if settings.prefetchEnabled {
Stepper("Keep next \(settings.prefetchCount) episode\(settings.prefetchCount == 1 ? "" : "s")",
value: $settings.prefetchCount, in: 1...10)
}
} header: {
Text("Offline")
} footer: {
Text("While you watch a series, the next episodes download automatically so they're ready offline.")
}
Section("Storage") {
LabeledContent("Downloaded", value: "\(downloads.entries.count) episodes")
LabeledContent("On disk", value: ByteCountFormatter.string(fromByteCount: downloads.totalBytes, countStyle: .file))
if !downloads.entries.isEmpty {
Button("Delete all offline", role: .destructive) {
for e in downloads.entries { downloads.delete(episodeId: e.episodeId) }
}
}
}
}
.navigationTitle("Settings")
.onAppear { portText = String(settings.port) }
}
.preferredColorScheme(.dark)
.tint(AppColors.primary)
}
}

View file

@ -1,18 +1,37 @@
// TVAnarchy iOS a thin bridge client: browse the network library, stream/play
// in-app via VLCKit. All heavy logic (scan, transport, downloads, metadata)
// lives behind the plum-control-bridge; this app speaks only HTTP to it.
// in-app via VLCKit, download for offline (with prefetch-ahead), and remote-
// control the Black TV. All heavy logic lives behind the plum-control-bridge.
import SwiftUI
import LilithDesignTokens
@main
struct TVAnarchyiOSApp: App {
@StateObject private var settings = BridgeSettings()
@StateObject private var downloads = DownloadManager()
var body: some Scene {
WindowGroup {
LibraryView()
RootTabView()
.environmentObject(settings)
.preferredColorScheme(.dark) // dark-first, matches LilithDesignTokens palette
.environmentObject(downloads)
.preferredColorScheme(.dark)
.tint(AppColors.primary)
}
}
}
struct RootTabView: View {
var body: some View {
TabView {
LibraryView()
.tabItem { Label("Library", systemImage: "play.tv") }
DownloadsView()
.tabItem { Label("Downloads", systemImage: "arrow.down.circle") }
RemoteView()
.tabItem { Label("Remote", systemImage: "appletvremote.gen4") }
SettingsScreen()
.tabItem { Label("Settings", systemImage: "gearshape") }
}
}
}

View file

@ -18,14 +18,20 @@ final class VLCPlayerModel: ObservableObject {
@Published var elapsed = "00:00"
@Published var remaining = "00:00"
@Published var buffering = true
@Published var positionSeconds: Double = 0 // for progress reporting
@Published var durationSeconds: Double = 0
private var timer: Timer?
private var scrubbing = false
private var pendingSeekSeconds: Double = 0
private var didSeek = true
func start(url: URL, networkCachingMs: Int) {
func start(url: URL, networkCachingMs: Int, startAt: Double = 0) {
let media = VLCMedia(url: url)
media.addOption("--network-caching=\(networkCachingMs)")
player.media = media
pendingSeekSeconds = startAt
didSeek = startAt <= 1 // nothing to restore
player.play()
timer?.invalidate()
timer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] _ in
@ -43,6 +49,28 @@ final class VLCPlayerModel: ObservableObject {
elapsed = player.time.stringValue
// remainingTime is negative ("-12:34"); show it as-is, it reads naturally.
remaining = player.remainingTime?.stringValue ?? ""
let elapsedMs = Double(player.time.intValue)
positionSeconds = elapsedMs / 1000
if let length = player.media?.length.intValue, length > 0 {
durationSeconds = Double(length) / 1000
} else if let rem = player.remainingTime?.intValue {
durationSeconds = (elapsedMs - Double(rem)) / 1000 // rem is negative
}
// Restore the saved resume position once the media is actually seekable.
if !didSeek, player.isSeekable, durationSeconds > 0 {
player.time = VLCTime(int: Int32(pendingSeekSeconds * 1000))
didSeek = true
}
}
/// Apply a resume position fetched after playback already started, so the
/// first frame is never blocked on a network round-trip (offline-safe).
func requestResume(seconds: Double) {
guard seconds > 1 else { return }
pendingSeekSeconds = seconds
didSeek = false
}
func togglePlay() {

View file

@ -6,6 +6,33 @@
import XCTest
final class PlaybackUITests: XCTestCase {
/// Visit each tab and capture a screenshot visual proof of the dark theme
/// and the full feature surface (Library rail, Downloads, Remote, Settings).
func testTabTour() {
let app = XCUIApplication()
app.launch()
XCTAssertTrue(app.buttons.matching(NSPredicate(format: "label CONTAINS %@", "Test Show")).firstMatch
.waitForExistence(timeout: 15), "library never loaded")
snapshot(app, "01-library")
let titles = ["Downloads": "Downloads", "Remote": "Black TV", "Settings": "Settings"]
for tab in ["Downloads", "Remote", "Settings"] {
app.tabBars.buttons[tab].tap()
let title = app.navigationBars[titles[tab]!]
XCTAssertTrue(title.waitForExistence(timeout: 5), "\(tab) tab did not appear")
sleep(1)
snapshot(app, "tab-\(tab)")
}
}
private func snapshot(_ app: XCUIApplication, _ name: String) {
let shot = XCUIScreen.main.screenshot()
let att = XCTAttachment(screenshot: shot)
att.name = name
att.lifetime = .keepAlways
add(att)
}
func testBrowseToPlayer() {
let app = XCUIApplication()
app.launch()