172 lines
6.3 KiB
Swift
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) {}
|
|
}
|