tv-anarchy/Sources/TVAnarchyiOS/PlayerScreen.swift
Natalie 17cf518418 feat(ios): downloads (DownloadManager/DownloadsView), remote control view + bridge/player refinements
(cherry picked from commit a1f7e44e17bb41f48976b69f4dbe5278cbad06b2)
2026-06-09 06:38:45 -07:00

172 lines
6.3 KiB
Swift

// 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 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()
if model.buffering {
ProgressView().tint(.white).scaleEffect(1.4)
}
if controlsVisible {
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() } }
.navigationTitle(title)
.navigationBarTitleDisplayMode(.inline)
.task { await startPlayback() }
.task { await reportProgressLoop() }
.onDisappear { Task { await finish() }; model.teardown() }
}
private var controls: some View {
VStack {
Spacer()
HStack(spacing: 40) {
Button { model.skip(seconds: -10) } label: {
Image(systemName: "gobackward.10").font(.title)
}
Button { model.togglePlay() } label: {
Image(systemName: model.isPlaying ? "pause.fill" : "play.fill")
.font(.system(size: 48))
}
.accessibilityIdentifier("playPauseButton")
Button { model.skip(seconds: 30) } label: {
Image(systemName: "goforward.30").font(.title)
}
}
.foregroundStyle(.white)
.padding(.bottom, 12)
HStack(spacing: 10) {
Text(model.elapsed)
.font(.caption.monospacedDigit())
.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())
}
.foregroundStyle(.white)
.padding(.horizontal)
.padding(.bottom, 24)
}
.background(
LinearGradient(colors: [.clear, .black.opacity(0.6)], startPoint: .center, endPoint: .bottom)
.ignoresSafeArea()
)
.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.
struct VLCVideoView: UIViewRepresentable {
let player: VLCMediaPlayer
func makeUIView(context: Context) -> UIView {
let view = UIView()
view.backgroundColor = .black
player.drawable = view
return view
}
func updateUIView(_ uiView: UIView, context: Context) {}
}