feat(ios): downloads (DownloadManager/DownloadsView), remote control view + bridge/player refinements
(cherry picked from commit a1f7e44e17bb41f48976b69f4dbe5278cbad06b2)
This commit is contained in:
parent
f0669f1ca8
commit
17cf518418
12 changed files with 1083 additions and 148 deletions
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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] }
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
194
Sources/TVAnarchyiOS/DownloadManager.swift
Normal file
194
Sources/TVAnarchyiOS/DownloadManager.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
214
Sources/TVAnarchyiOS/DownloadsView.swift
Normal file
214
Sources/TVAnarchyiOS/DownloadsView.swift
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
105
Sources/TVAnarchyiOS/RemoteView.swift
Normal file
105
Sources/TVAnarchyiOS/RemoteView.swift
Normal 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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue