stremio/apps/ios/VMC/Playback/PlayerScreen.swift
Jos Vooges | STH 85da6f59ad Ship F1 hub on Android TV and harden iOS live/ClearKey playback.
Add the WOW-style F1 hub navigation/rails and improve event playback paths on TV/mobile plus iOS AVLive/ClearKey proxy handling.
2026-09-22 21:52:23 +02:00

645 lines
24 KiB
Swift

import SwiftUI
struct PlayRequest: Identifiable {
let id = UUID()
let url: URL
let title: String
let mediaType: String
let mediaFileId: String?
let movieId: String?
let episodeId: String?
let seriesId: String?
let startMs: Int64
let isLive: Bool
let fallbackUrl: URL?
let subtitleFileURL: URL?
let format: String?
let drm: DrmInfo?
let watchSessionId: String?
let watchKind: String?
let watchId: String?
static func vod(
url: URL,
title: String,
mediaType: String,
mediaFileId: String,
movieId: String?,
episodeId: String?,
seriesId: String?,
startMs: Int64,
subtitleFileURL: URL? = nil
) -> PlayRequest {
PlayRequest(
url: url, title: title, mediaType: mediaType, mediaFileId: mediaFileId,
movieId: movieId, episodeId: episodeId, seriesId: seriesId,
startMs: startMs, isLive: false, fallbackUrl: nil, subtitleFileURL: subtitleFileURL,
format: nil, drm: nil, watchSessionId: nil, watchKind: nil, watchId: nil
)
}
static func live(
url: URL,
title: String,
fallback: URL?,
format: String? = nil,
drm: DrmInfo? = nil,
watchSessionId: String? = nil,
watchKind: String? = nil,
watchId: String? = nil
) -> PlayRequest {
PlayRequest(
url: url, title: title, mediaType: "live", mediaFileId: nil,
movieId: nil, episodeId: nil, seriesId: nil,
startMs: 0, isLive: true, fallbackUrl: fallback, subtitleFileURL: nil,
format: format, drm: drm, watchSessionId: watchSessionId,
watchKind: watchKind, watchId: watchId
)
}
var needsClearKey: Bool {
guard let drm, drm.type.lowercased() == "clearkey" else { return false }
return !drm.keys.isEmpty
}
}
struct PlayerScreen: View {
@EnvironmentObject private var app: AppModel
@Environment(\.dismiss) private var dismiss
let request: PlayRequest
/// ClearKey live → AVPlayer + lokale decrypt-HLS. FTA live + VOD → VLC (PiP intact).
@StateObject private var vlc = VLCPlayerController()
@StateObject private var av = AVLivePlayerController()
@State private var controlsVisible = true
@State private var loading = true
@State private var error: String?
@State private var progressTask: Task<Void, Never>?
@State private var hideTask: Task<Void, Never>?
@State private var heartbeatTask: Task<Void, Never>?
@State private var showTracks = false
@State private var trackTab = 0
@State private var subtitleHits: [SubtitleHit] = []
@State private var subsLoading = false
@State private var subsError: String?
@State private var pendingStop: Task<Void, Never>?
private var isLive: Bool { request.isLive }
private var useClearKeyAV: Bool { request.isLive && request.needsClearKey }
private var stateText: String { useClearKeyAV ? av.stateText : vlc.stateText }
private var isPlaying: Bool { useClearKeyAV ? av.isPlaying : vlc.isPlaying }
private var playerHasError: Bool { useClearKeyAV ? av.hasError : vlc.hasError }
private var playerErrorMessage: String? { useClearKeyAV ? av.errorMessage : vlc.errorMessage }
var body: some View {
ZStack {
Color.black.ignoresSafeArea()
if useClearKeyAV {
AVLivePlayerView(controller: av)
.ignoresSafeArea()
.allowsHitTesting(false)
} else if isLive {
VLCVideoView(controller: vlc)
.ignoresSafeArea()
.allowsHitTesting(false)
} else {
GeometryReader { geo in
let fitted = Self.fittedVideoSize(in: geo.size, aspect: 16.0 / 9.0)
VLCVideoView(controller: vlc)
.frame(width: fitted.width, height: fitted.height)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
.allowsHitTesting(false)
}
.ignoresSafeArea()
}
// Speler eet touches op — vang tikken altijd zelf af.
Color.clear
.contentShape(Rectangle())
.ignoresSafeArea()
.onTapGesture { toggleControls() }
.allowsHitTesting(!controlsVisible)
if loading && !isPlaying {
ProgressView(stateText)
.tint(.white)
.foregroundStyle(.white)
.allowsHitTesting(false)
}
if let error {
errorOverlay(error)
} else if playerHasError {
errorOverlay(playerErrorMessage ?? "Afspeelfout")
}
if controlsVisible {
controlsOverlay
.transition(.opacity)
}
}
.statusBarHidden(true)
.persistentSystemOverlays(.hidden)
.onAppear {
pendingStop?.cancel()
pendingStop = nil
app.enterPlayback()
}
.task {
await start()
scheduleHide()
startHeartbeatIfNeeded()
}
.onDisappear {
app.leavePlayback()
hideTask?.cancel()
progressTask?.cancel()
heartbeatTask?.cancel()
if !isLive {
let pos = vlc.currentPositionMs()
let dur = vlc.durationMs()
Task { await saveProgress(positionMs: pos, durationMs: dur) }
}
pendingStop?.cancel()
pendingStop = Task {
try? await Task.sleep(nanoseconds: 350_000_000)
guard !Task.isCancelled else { return }
await MainActor.run {
if useClearKeyAV { av.stop() } else { vlc.stop() }
}
}
}
.sheet(isPresented: $showTracks) {
tracksSheet
.presentationDetents([.medium, .large])
.presentationDragIndicator(.visible)
}
}
private var controlsOverlay: some View {
VStack(spacing: 0) {
HStack {
Button {
dismiss()
} label: {
Image(systemName: "chevron.down")
.font(.title3.weight(.semibold))
.foregroundStyle(.white)
.padding(12)
.background(.black.opacity(0.4), in: Circle())
}
VStack(alignment: .leading, spacing: 2) {
Text(request.title)
.font(McFonts.headline)
.foregroundStyle(.white)
.lineLimit(1)
Text(stateText)
.font(McFonts.caption)
.foregroundStyle(.white.opacity(0.7))
}
Spacer()
if !useClearKeyAV && vlc.pipPossible {
Button {
vlc.togglePictureInPicture()
scheduleHide()
} label: {
Image(systemName: vlc.pipActive
? "pip.exit"
: "pip.enter")
.font(.title3)
.foregroundStyle(.white)
.padding(12)
.background(.black.opacity(0.4), in: Circle())
}
}
if !request.isLive {
Button {
showTracks = true
scheduleHide(cancelOnly: true)
Task { await loadSubtitles() }
} label: {
Image(systemName: "captions.bubble.fill")
.font(.title3)
.foregroundStyle(.white)
.padding(12)
.background(.black.opacity(0.4), in: Circle())
}
}
}
.padding(.horizontal, 16)
.padding(.top, 12)
Spacer()
.contentShape(Rectangle())
.onTapGesture { hideControls() }
VStack(spacing: 14) {
if !request.isLive {
Slider(
value: Binding(
get: { Double(vlc.position) },
set: {
vlc.seek(ratio: Float($0))
scheduleHide()
}
),
in: 0...1
)
.tint(McColors.accent)
HStack {
Text(vlc.timeText).font(McFonts.caption).foregroundStyle(.white.opacity(0.85))
Spacer()
Text(vlc.lengthText).font(McFonts.caption).foregroundStyle(.white.opacity(0.85))
}
}
HStack(spacing: 36) {
if !request.isLive {
Button {
vlc.seek(toMs: max(vlc.currentPositionMs() - 10_000, 0))
scheduleHide()
} label: {
Image(systemName: "gobackward.10").font(.title).foregroundStyle(.white)
}
}
Button {
if useClearKeyAV { av.togglePlay() } else { vlc.togglePlay() }
scheduleHide()
} label: {
Image(systemName: isPlaying ? "pause.circle.fill" : "play.circle.fill")
.font(.system(size: 64))
.foregroundStyle(.white)
.shadow(radius: 8)
}
if !request.isLive {
Button {
vlc.seek(toMs: vlc.currentPositionMs() + 10_000)
scheduleHide()
} label: {
Image(systemName: "goforward.10").font(.title).foregroundStyle(.white)
}
}
}
}
.padding(20)
.background(
LinearGradient(colors: [.clear, .black.opacity(0.8)], startPoint: .top, endPoint: .bottom)
)
}
}
private var tracksSheet: some View {
NavigationStack {
VStack(spacing: 0) {
Picker("", selection: $trackTab) {
Text("In bestand").tag(0)
Text("Downloaden").tag(1)
Text("Audio").tag(2)
}
.pickerStyle(.segmented)
.padding()
if trackTab == 0 {
List {
ForEach(vlc.subtitleTracks) { track in
Button {
vlc.selectSubtitle(id: track.id)
showTracks = false
scheduleHide()
} label: {
HStack {
Text(track.name).foregroundStyle(McColors.text)
Spacer()
if track.id == vlc.currentSubtitleId {
Image(systemName: "checkmark").foregroundStyle(McColors.accent)
}
}
}
.listRowBackground(McColors.bgElevated)
}
}
.scrollContentBackground(.hidden)
} else if trackTab == 1 {
Group {
if subsLoading {
ProgressView("Zoeken…").padding(.top, 40)
} else if let subsError {
Text(subsError).foregroundStyle(McColors.danger).padding()
} else if subtitleHits.isEmpty {
ContentUnavailableView("Geen downloads", systemImage: "arrow.down.circle", description: Text("Geen OpenSubtitles-resultaten."))
} else {
List(subtitleHits) { hit in
Button {
Task { await applySubtitle(hit) }
} label: {
VStack(alignment: .leading, spacing: 4) {
Text(hit.release ?? hit.fileName ?? "Ondertitel")
.foregroundStyle(McColors.text)
.lineLimit(2)
HStack(spacing: 8) {
Text((hit.language ?? "?").uppercased())
if hit.hearingImpaired == true { Text("SDH") }
if let n = hit.downloadCount { Text("↓\(n)") }
}
.font(McFonts.caption)
.foregroundStyle(McColors.textDim)
}
}
.listRowBackground(McColors.bgElevated)
}
.scrollContentBackground(.hidden)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
List {
ForEach(vlc.audioTracks) { track in
Button {
vlc.selectAudio(id: track.id)
showTracks = false
scheduleHide()
} label: {
HStack {
Text(track.name).foregroundStyle(McColors.text)
Spacer()
if track.id == vlc.currentAudioId {
Image(systemName: "checkmark").foregroundStyle(McColors.accent)
}
}
}
.listRowBackground(McColors.bgElevated)
}
}
.scrollContentBackground(.hidden)
}
}
.background(McColors.bg)
.navigationTitle("Sporen")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Sluiten") { showTracks = false }
}
}
.onAppear { vlc.refreshTracks() }
}
}
private func errorOverlay(_ message: String) -> some View {
VStack(spacing: 14) {
Image(systemName: "exclamationmark.triangle.fill")
.font(.largeTitle)
.foregroundStyle(McColors.accent)
Text(message)
.multilineTextAlignment(.center)
.foregroundStyle(.white)
.padding(.horizontal)
Button("Opnieuw") { Task { await start() } }
.buttonStyle(McPrimaryButtonStyle())
Button("Sluiten") { dismiss() }
.buttonStyle(McSecondaryButtonStyle())
}
.padding(24)
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 20))
.padding(24)
}
private func toggleControls() {
if controlsVisible {
hideControls()
} else {
showControls()
}
}
private func showControls() {
withAnimation(.easeOut(duration: 0.2)) { controlsVisible = true }
scheduleHide()
}
private func hideControls() {
hideTask?.cancel()
withAnimation(.easeOut(duration: 0.2)) { controlsVisible = false }
}
private func scheduleHide(cancelOnly: Bool = false) {
hideTask?.cancel()
guard !cancelOnly else { return }
hideTask = Task {
try? await Task.sleep(nanoseconds: 3_000_000_000)
guard !Task.isCancelled else { return }
await MainActor.run {
withAnimation(.easeOut(duration: 0.25)) { controlsVisible = false }
}
}
}
private func start() async {
loading = true
error = nil
if useClearKeyAV {
let keys = request.drm?.keys ?? []
await av.play(
url: request.url,
fallback: request.fallbackUrl,
format: request.format,
clearKeys: keys
)
let maxWaitNs: UInt64 = 18_000_000_000
let stepNs: UInt64 = 200_000_000
var waited: UInt64 = 0
while waited < maxWaitNs && !av.hasError {
if av.isPlaying { break }
try? await Task.sleep(nanoseconds: stepNs)
waited += stepNs
}
if !av.isPlaying || av.hasError {
error = av.errorMessage
?? "Kon deze ClearKey-livestream niet starten."
}
} else if isLive {
await vlc.playLive(
url: request.url,
fallback: request.fallbackUrl,
format: request.format,
clearKeys: []
)
let maxWaitNs: UInt64 = 4_000_000_000
let stepNs: UInt64 = 200_000_000
var waited: UInt64 = 0
while waited < maxWaitNs && !vlc.isPlaying && !vlc.hasError {
try? await Task.sleep(nanoseconds: stepNs)
waited += stepNs
}
if vlc.hasError {
error = vlc.errorMessage ?? "Kon deze livestream niet starten."
}
} else {
vlc.play(url: request.url, startMs: request.startMs, subtitleFileURL: request.subtitleFileURL)
progressTask = Task { await progressLoop() }
}
try? await Task.sleep(nanoseconds: 250_000_000)
loading = false
if !useClearKeyAV && !vlc.hasError {
vlc.refreshTracks()
}
}
private func startHeartbeatIfNeeded() {
heartbeatTask?.cancel()
guard let session = request.watchSessionId, !session.isEmpty else { return }
heartbeatTask = Task {
while !Task.isCancelled {
await app.api.watchingHeartbeat(
watchSessionId: session,
kind: request.watchKind,
id: request.watchId
)
try? await Task.sleep(nanoseconds: 25_000_000_000)
}
}
}
private func progressLoop() async {
while !Task.isCancelled {
try? await Task.sleep(nanoseconds: 15_000_000_000)
await saveProgress()
}
}
private func saveProgress(positionMs: Int64? = nil, durationMs: Int64? = nil) async {
guard !request.isLive else { return }
let pos = positionMs ?? vlc.currentPositionMs()
let dur = durationMs ?? vlc.durationMs()
guard pos > 0 else { return }
try? await app.api.postProgress(
mediaType: request.mediaType,
movieId: request.movieId,
episodeId: request.episodeId,
seriesId: request.seriesId,
positionMs: pos,
durationMs: max(dur, 0)
)
}
private func loadSubtitles() async {
guard let mediaFileId = request.mediaFileId else { return }
subsLoading = true
subsError = nil
do {
let result = try await app.api.searchSubtitles(mediaFileId: mediaFileId)
subtitleHits = result.results
if result.configured == false {
subsError = "OpenSubtitles is niet geconfigureerd op de master."
}
subsLoading = false
} catch {
subsError = error.localizedDescription
subsLoading = false
}
}
private func applySubtitle(_ hit: SubtitleHit) async {
do {
let fetched = try await app.api.fetchSubtitle(fileId: hit.fileId, source: hit.source ?? "org")
guard let data = Data(base64Encoded: fetched.contentBase64) else {
throw ApiError.message("Ongeldige ondertiteldata")
}
let ext = (fetched.format?.isEmpty == false ? fetched.format! : "srt")
let url = FileManager.default.temporaryDirectory
.appendingPathComponent("vmc-sub-\(hit.fileId).\(ext)")
try data.write(to: url, options: .atomic)
vlc.addExternalSubtitle(url: url)
showTracks = false
scheduleHide()
} catch {
subsError = error.localizedDescription
}
}
private static func fittedVideoSize(in container: CGSize, aspect: CGFloat) -> CGSize {
guard container.width > 0, container.height > 0, aspect > 0 else { return container }
let containerAspect = container.width / container.height
if containerAspect > aspect {
let h = container.height
return CGSize(width: h * aspect, height: h)
} else {
let w = container.width
return CGSize(width: w, height: w / aspect)
}
}
}
struct PlayLauncher: View {
@EnvironmentObject private var app: AppModel
let mediaFileId: String
let title: String
let mediaType: String
let movieId: String?
let episodeId: String?
let seriesId: String?
let startMs: Int64
@State private var request: PlayRequest?
@State private var error: String?
@State private var loading = true
var body: some View {
ZStack {
Color.black.ignoresSafeArea()
if let request {
PlayerScreen(request: request)
} else if loading {
ProgressView("Stream voorbereiden…").tint(.white).foregroundStyle(.white)
} else if let error {
VStack(spacing: 12) {
Text(error).foregroundStyle(McColors.danger).multilineTextAlignment(.center).padding()
Button("Opnieuw") { Task { await prepare() } }.buttonStyle(McPrimaryButtonStyle())
}
}
}
.task { await prepare() }
.onAppear { app.enterPlayback() }
.onDisappear { app.leavePlayback() }
}
private func prepare() async {
loading = true
error = nil
do {
let play = try await app.api.play(mediaFileId: mediaFileId)
guard let url = URL(string: play.streamUrl) else {
throw ApiError.message("Ongeldige stream-URL")
}
var subtitleURL: URL?
if let sub = play.defaultSubtitle,
let data = Data(base64Encoded: sub.contentBase64),
!data.isEmpty {
let ext: String = {
let f = (sub.format ?? "").lowercased()
if f == "vtt" { return "vtt" }
if f == "ass" || f == "ssa" { return "ass" }
return "srt"
}()
let fileId = sub.fileId ?? 0
let path = FileManager.default.temporaryDirectory
.appendingPathComponent("vmc-auto-sub-\(fileId).\(ext)")
try data.write(to: path, options: .atomic)
subtitleURL = path
}
request = .vod(
url: url, title: title, mediaType: mediaType, mediaFileId: mediaFileId,
movieId: movieId, episodeId: episodeId, seriesId: seriesId, startMs: startMs,
subtitleFileURL: subtitleURL
)
loading = false
} catch {
self.error = error.localizedDescription
loading = false
}
}
}