stremio/apps/ios/VMC/Playback/PlayerScreen.swift
Jos Vooges | STH d1b627752c Fix iOS ClearKey live-list DASH proxy immediate stop (0.5.2).
Preserve BaseURL/Location, warm up MPD, clear sinf for VLC, and retry early stops so custom live DRM plays instead of Gestopt.
2026-09-19 06:53:13 +02:00

556 lines
20 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
@StateObject private var player = VLCPlayerController()
@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?
var body: some View {
ZStack {
Color.black.ignoresSafeArea()
VLCVideoView(controller: player)
.ignoresSafeArea()
.allowsHitTesting(false)
// VLC eet touches op — vang tikken altijd zelf af.
Color.clear
.contentShape(Rectangle())
.ignoresSafeArea()
.onTapGesture { toggleControls() }
.allowsHitTesting(!controlsVisible)
if loading && !player.isPlaying {
ProgressView(player.stateText)
.tint(.white)
.foregroundStyle(.white)
.allowsHitTesting(false)
}
if let error {
errorOverlay(error)
} else if player.hasError {
errorOverlay(player.errorMessage ?? "Afspeelfout")
}
if controlsVisible {
controlsOverlay
.transition(.opacity)
}
}
.statusBarHidden(true)
.persistentSystemOverlays(.hidden)
.onAppear { app.enterPlayback() }
.task {
await start()
scheduleHide()
startHeartbeatIfNeeded()
}
.onDisappear {
app.leavePlayback()
hideTask?.cancel()
progressTask?.cancel()
heartbeatTask?.cancel()
Task { await saveProgress() }
player.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(player.stateText)
.font(McFonts.caption)
.foregroundStyle(.white.opacity(0.7))
}
Spacer()
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(player.position) },
set: {
player.seek(ratio: Float($0))
scheduleHide()
}
),
in: 0...1
)
.tint(McColors.accent)
HStack {
Text(player.timeText).font(McFonts.caption).foregroundStyle(.white.opacity(0.85))
Spacer()
Text(player.lengthText).font(McFonts.caption).foregroundStyle(.white.opacity(0.85))
}
}
HStack(spacing: 36) {
if !request.isLive {
Button {
player.seek(toMs: max(player.currentPositionMs() - 10_000, 0))
scheduleHide()
} label: {
Image(systemName: "gobackward.10").font(.title).foregroundStyle(.white)
}
}
Button {
player.togglePlay()
scheduleHide()
} label: {
Image(systemName: player.isPlaying ? "pause.circle.fill" : "play.circle.fill")
.font(.system(size: 64))
.foregroundStyle(.white)
.shadow(radius: 8)
}
if !request.isLive {
Button {
player.seek(toMs: player.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(player.subtitleTracks) { track in
Button {
player.selectSubtitle(id: track.id)
showTracks = false
scheduleHide()
} label: {
HStack {
Text(track.name).foregroundStyle(McColors.text)
Spacer()
if track.id == player.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(player.audioTracks) { track in
Button {
player.selectAudio(id: track.id)
showTracks = false
scheduleHide()
} label: {
HStack {
Text(track.name).foregroundStyle(McColors.text)
Spacer()
if track.id == player.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 { player.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 request.isLive {
let keys = request.needsClearKey ? (request.drm?.keys ?? []) : []
player.playLive(
url: request.url,
fallback: request.fallbackUrl,
format: request.format,
clearKeys: keys
)
// ClearKey/DASH: proxy-warmup + init/media + eventuele retry.
let waitNs: UInt64 = request.needsClearKey ? 10_000_000_000 : 2_500_000_000
try? await Task.sleep(nanoseconds: waitNs)
if request.needsClearKey && (player.hasError || !player.isPlaying) {
try? await Task.sleep(nanoseconds: 6_000_000_000)
if player.hasError || !player.isPlaying {
error = player.errorMessage
?? "Kon deze ClearKey-livezender niet starten. Het DASH-manifest of de sleutels zijn mogelijk ongeldig."
}
}
} else {
player.play(url: request.url, startMs: request.startMs, subtitleFileURL: request.subtitleFileURL)
progressTask = Task { await progressLoop() }
}
try? await Task.sleep(nanoseconds: 400_000_000)
loading = false
player.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() async {
guard !request.isLive else { return }
let pos = player.currentPositionMs()
let dur = player.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)
player.addExternalSubtitle(url: url)
showTracks = false
scheduleHide()
} catch {
subsError = error.localizedDescription
}
}
}
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
}
}
}