diff --git a/Sources/TVAnarchyiOS/BridgeClient.swift b/Sources/TVAnarchyiOS/BridgeClient.swift index 1a115c2..b996101 100644 --- a/Sources/TVAnarchyiOS/BridgeClient.swift +++ b/Sources/TVAnarchyiOS/BridgeClient.swift @@ -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(_ 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 } } diff --git a/Sources/TVAnarchyiOS/BridgeModels.swift b/Sources/TVAnarchyiOS/BridgeModels.swift index a489234..c6bbcaa 100644 --- a/Sources/TVAnarchyiOS/BridgeModels.swift +++ b/Sources/TVAnarchyiOS/BridgeModels.swift @@ -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] } diff --git a/Sources/TVAnarchyiOS/BridgeSettings.swift b/Sources/TVAnarchyiOS/BridgeSettings.swift index 5431eba..4143839 100644 --- a/Sources/TVAnarchyiOS/BridgeSettings.swift +++ b/Sources/TVAnarchyiOS/BridgeSettings.swift @@ -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" } } diff --git a/Sources/TVAnarchyiOS/DownloadManager.swift b/Sources/TVAnarchyiOS/DownloadManager.swift new file mode 100644 index 0000000..9e6c4b3 --- /dev/null +++ b/Sources/TVAnarchyiOS/DownloadManager.swift @@ -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 + } + } + } +} diff --git a/Sources/TVAnarchyiOS/DownloadsView.swift b/Sources/TVAnarchyiOS/DownloadsView.swift new file mode 100644 index 0000000..41f99df --- /dev/null +++ b/Sources/TVAnarchyiOS/DownloadsView.swift @@ -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 + ) + } +} diff --git a/Sources/TVAnarchyiOS/LibraryView.swift b/Sources/TVAnarchyiOS/LibraryView.swift index f015e0e..81d043e 100644 --- a/Sources/TVAnarchyiOS/LibraryView.swift +++ b/Sources/TVAnarchyiOS/LibraryView.swift @@ -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) + } + } +} diff --git a/Sources/TVAnarchyiOS/PlayerScreen.swift b/Sources/TVAnarchyiOS/PlayerScreen.swift index caec917..4952a1d 100644 --- a/Sources/TVAnarchyiOS/PlayerScreen.swift +++ b/Sources/TVAnarchyiOS/PlayerScreen.swift @@ -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 diff --git a/Sources/TVAnarchyiOS/RemoteView.swift b/Sources/TVAnarchyiOS/RemoteView.swift new file mode 100644 index 0000000..868113b --- /dev/null +++ b/Sources/TVAnarchyiOS/RemoteView.swift @@ -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)) + } + } +} diff --git a/Sources/TVAnarchyiOS/SettingsView.swift b/Sources/TVAnarchyiOS/SettingsView.swift index bacc83d..9ce4d00 100644 --- a/Sources/TVAnarchyiOS/SettingsView.swift +++ b/Sources/TVAnarchyiOS/SettingsView.swift @@ -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) } } diff --git a/Sources/TVAnarchyiOS/TVAnarchyiOSApp.swift b/Sources/TVAnarchyiOS/TVAnarchyiOSApp.swift index 355d5dc..819e664 100644 --- a/Sources/TVAnarchyiOS/TVAnarchyiOSApp.swift +++ b/Sources/TVAnarchyiOS/TVAnarchyiOSApp.swift @@ -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") } } } } diff --git a/Sources/TVAnarchyiOS/VLCPlayerModel.swift b/Sources/TVAnarchyiOS/VLCPlayerModel.swift index a49823f..b4d316a 100644 --- a/Sources/TVAnarchyiOS/VLCPlayerModel.swift +++ b/Sources/TVAnarchyiOS/VLCPlayerModel.swift @@ -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() { diff --git a/Tests/TVAnarchyiOSUITests/PlaybackUITests.swift b/Tests/TVAnarchyiOSUITests/PlaybackUITests.swift index 333aeff..f422318 100644 --- a/Tests/TVAnarchyiOSUITests/PlaybackUITests.swift +++ b/Tests/TVAnarchyiOSUITests/PlaybackUITests.swift @@ -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()