iOS only sent that UA when the hostname looked like viaplay; stored MPDs often do not. Event plays now pin the browser session for the whole fetch chain, still direct to the CDN.
881 lines
35 KiB
Swift
881 lines
35 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?
|
|
/// Provider-/titel-logo voor laad-animatie (events, films, series).
|
|
let logoUrl: String?
|
|
|
|
/// Zelfde Chrome/122-UA als de werkende losse MPD-speler, voor de hele CDN-sessie.
|
|
var usesViaplayCdn: Bool {
|
|
ViaplayPlaybackHeaders.matches(url)
|
|
|| (watchId ?? "").localizedCaseInsensitiveContains("viaplay")
|
|
}
|
|
|
|
static func vod(
|
|
url: URL,
|
|
title: String,
|
|
mediaType: String,
|
|
mediaFileId: String,
|
|
movieId: String?,
|
|
episodeId: String?,
|
|
seriesId: String?,
|
|
startMs: Int64,
|
|
subtitleFileURL: URL? = nil,
|
|
logoUrl: String? = 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,
|
|
logoUrl: logoUrl
|
|
)
|
|
}
|
|
|
|
static func live(
|
|
url: URL,
|
|
title: String,
|
|
fallback: URL?,
|
|
format: String? = nil,
|
|
drm: DrmInfo? = nil,
|
|
watchSessionId: String? = nil,
|
|
watchKind: String? = nil,
|
|
watchId: String? = nil,
|
|
logoUrl: String? = nil,
|
|
isLive: Bool = true
|
|
) -> PlayRequest {
|
|
PlayRequest(
|
|
url: url, title: title, mediaType: isLive ? "live" : "vod", mediaFileId: nil,
|
|
movieId: nil, episodeId: nil, seriesId: nil,
|
|
startMs: 0, isLive: isLive, fallbackUrl: fallback, subtitleFileURL: nil,
|
|
format: format, drm: drm, watchSessionId: watchSessionId,
|
|
watchKind: watchKind, watchId: watchId, logoUrl: logoUrl
|
|
)
|
|
}
|
|
|
|
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: DASH→AVPlayer+proxy; native HLS (ESPN)→VLC+proxy. FTA live + VOD → VLC.
|
|
@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 sliderDraft: Double = 0
|
|
@State private var isScrubbing = 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>?
|
|
|
|
/// ClearKey live: DASH→AVPlayer; native HLS (ESPN)→VLC (AV bevriest bij rotatie).
|
|
@State private var clearKeyPreferAV = true
|
|
|
|
private var isLive: Bool { request.isLive }
|
|
private var useClearKeyAV: Bool { request.needsClearKey && clearKeyPreferAV }
|
|
/// F1-replay e.d. komen via live-request binnen, maar hebben een echte duur.
|
|
private var showSeekChrome: Bool { !isLive || (!useClearKeyAV && vlc.canSeek) }
|
|
|
|
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 }
|
|
|
|
/// Alleen start/buffer vóór first frame — overlay mag playback niet blokkeren.
|
|
private var showLoadingChrome: Bool {
|
|
guard !playerHasError else { return false }
|
|
if loading && !isPlaying { return true }
|
|
if useClearKeyAV, !av.hasVideo, !av.isPlaying { return true }
|
|
return false
|
|
}
|
|
|
|
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()
|
|
}
|
|
|
|
Color.clear
|
|
.contentShape(Rectangle())
|
|
.ignoresSafeArea()
|
|
.onTapGesture { toggleControls() }
|
|
.allowsHitTesting(!controlsVisible)
|
|
|
|
if showLoadingChrome {
|
|
PlayerLoadingChrome(logoUrl: request.logoUrl, status: stateText)
|
|
.allowsHitTesting(false)
|
|
.transition(.opacity)
|
|
}
|
|
|
|
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, onDismiss: { scheduleHide() }) {
|
|
tracksSheet
|
|
.presentationDetents([.medium, .large])
|
|
.presentationDragIndicator(.visible)
|
|
}
|
|
}
|
|
|
|
private static func formatClock(ratio: Double, durationMs: Int64) -> String {
|
|
let ms = Int(Double(max(durationMs, 0)) * min(max(ratio, 0), 1) / 1000)
|
|
let h = ms / 3600
|
|
let m = (ms % 3600) / 60
|
|
let s = ms % 60
|
|
if h > 0 { return String(format: "%d:%02d:%02d", h, m, s) }
|
|
return String(format: "%d:%02d", m, s)
|
|
}
|
|
|
|
/// Letterbox/pillarbox buiten de VLC-surface zodat FreeType onderaan het beeld zit.
|
|
private static func fittedVideoSize(in container: CGSize, aspect: CGFloat) -> CGSize {
|
|
guard container.width > 1, container.height > 1, aspect > 0 else { return container }
|
|
let viewAspect = container.width / container.height
|
|
if viewAspect > aspect {
|
|
let h = container.height
|
|
return CGSize(width: h * aspect, height: h)
|
|
} else {
|
|
let w = container.width
|
|
return CGSize(width: w, height: w / aspect)
|
|
}
|
|
}
|
|
|
|
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 !isLive, 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())
|
|
}
|
|
}
|
|
Button {
|
|
showTracks = true
|
|
scheduleHide(cancelOnly: true)
|
|
if useClearKeyAV {
|
|
av.refreshMediaOptions()
|
|
} else {
|
|
vlc.refreshTracks()
|
|
}
|
|
if !isLive {
|
|
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 showSeekChrome {
|
|
Slider(
|
|
value: Binding(
|
|
get: { isScrubbing ? sliderDraft : Double(vlc.position) },
|
|
set: { sliderDraft = $0 }
|
|
),
|
|
in: 0...1,
|
|
onEditingChanged: { editing in
|
|
if editing {
|
|
isScrubbing = true
|
|
sliderDraft = Double(vlc.position)
|
|
scheduleHide(cancelOnly: true)
|
|
} else {
|
|
isScrubbing = false
|
|
vlc.seek(ratio: Float(sliderDraft))
|
|
scheduleHide()
|
|
}
|
|
}
|
|
)
|
|
.tint(McColors.accent)
|
|
|
|
HStack {
|
|
Text(isScrubbing ? Self.formatClock(ratio: sliderDraft, durationMs: vlc.durationMs()) : 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 showSeekChrome {
|
|
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 showSeekChrome {
|
|
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) {
|
|
if useClearKeyAV {
|
|
Picker("", selection: $trackTab) {
|
|
Text("Audio").tag(0)
|
|
Text("Ondertitel").tag(1)
|
|
}
|
|
.pickerStyle(.segmented)
|
|
.padding()
|
|
|
|
if trackTab == 0 {
|
|
List {
|
|
if av.audioOptions.isEmpty {
|
|
Text("Geen audio-sporen gevonden")
|
|
.foregroundStyle(McColors.textDim)
|
|
.listRowBackground(McColors.bgElevated)
|
|
}
|
|
ForEach(av.audioOptions) { track in
|
|
Button {
|
|
av.selectAudio(id: track.id)
|
|
showTracks = false
|
|
scheduleHide()
|
|
} label: {
|
|
HStack {
|
|
Text(track.name).foregroundStyle(McColors.text)
|
|
Spacer()
|
|
if track.id == av.currentAudioId {
|
|
Image(systemName: "checkmark").foregroundStyle(McColors.accent)
|
|
}
|
|
}
|
|
}
|
|
.listRowBackground(McColors.bgElevated)
|
|
}
|
|
}
|
|
.scrollContentBackground(.hidden)
|
|
} else {
|
|
List {
|
|
ForEach(av.subtitleOptions) { track in
|
|
Button {
|
|
av.selectSubtitle(id: track.id == "off" ? nil : track.id)
|
|
showTracks = false
|
|
scheduleHide()
|
|
} label: {
|
|
HStack {
|
|
Text(track.name).foregroundStyle(McColors.text)
|
|
Spacer()
|
|
if track.id == (av.currentSubtitleId ?? "off") {
|
|
Image(systemName: "checkmark").foregroundStyle(McColors.accent)
|
|
}
|
|
}
|
|
}
|
|
.listRowBackground(McColors.bgElevated)
|
|
}
|
|
}
|
|
.scrollContentBackground(.hidden)
|
|
}
|
|
} else {
|
|
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 isLive {
|
|
ContentUnavailableView(
|
|
"Alleen bij VOD",
|
|
systemImage: "arrow.down.circle",
|
|
description: Text("OpenSubtitles is beschikbaar bij films/series.")
|
|
)
|
|
} else 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 {
|
|
if useClearKeyAV {
|
|
av.refreshMediaOptions()
|
|
trackTab = 0
|
|
} else {
|
|
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 request.needsClearKey {
|
|
let keys = request.drm?.keys ?? []
|
|
let fmt = (request.format ?? "").lowercased()
|
|
// ESPN e.d.: native HLS + aparte AUDIO-rendition → AVPlayer bevriest beeld bij rotatie.
|
|
// DASH→HLS (Prime/HBO/Viaplay/Ziggo) blijft op AVPlayer (rotatie is daar vloeiend).
|
|
let nativeHls = fmt == "hls" || ClearKeyDashProxy.isHlsURL(request.url)
|
|
if nativeHls {
|
|
clearKeyPreferAV = false
|
|
await vlc.playLive(
|
|
url: request.url,
|
|
fallback: request.fallbackUrl,
|
|
format: request.format,
|
|
clearKeys: keys,
|
|
preferHls: true,
|
|
browserCdnHeaders: request.usesViaplayCdn
|
|
)
|
|
let maxWaitNs: UInt64 = 18_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 || !vlc.isPlaying {
|
|
error = vlc.errorMessage ?? "Kon deze ClearKey-livestream niet starten."
|
|
}
|
|
} else {
|
|
clearKeyPreferAV = true
|
|
await av.play(
|
|
url: request.url,
|
|
fallback: request.fallbackUrl,
|
|
format: request.format,
|
|
clearKeys: keys,
|
|
browserCdnHeaders: request.usesViaplayCdn
|
|
)
|
|
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 {
|
|
loading = false
|
|
return
|
|
}
|
|
let avFailHint = av.errorMessage
|
|
av.stop()
|
|
clearKeyPreferAV = false
|
|
try? await Task.sleep(nanoseconds: 250_000_000)
|
|
await vlc.playLive(
|
|
url: request.url,
|
|
fallback: request.fallbackUrl,
|
|
format: request.format,
|
|
clearKeys: keys,
|
|
preferHls: true,
|
|
browserCdnHeaders: request.usesViaplayCdn
|
|
)
|
|
waited = 0
|
|
while waited < 8_000_000_000 && !vlc.isPlaying && !vlc.hasError {
|
|
try? await Task.sleep(nanoseconds: stepNs)
|
|
waited += stepNs
|
|
}
|
|
if vlc.hasError || !vlc.isPlaying {
|
|
error = vlc.errorMessage
|
|
?? avFailHint
|
|
?? "Kon deze ClearKey-livestream niet starten."
|
|
}
|
|
}
|
|
} else if isLive {
|
|
await vlc.playLive(
|
|
url: request.url,
|
|
fallback: request.fallbackUrl,
|
|
format: request.format,
|
|
clearKeys: [],
|
|
browserCdnHeaders: request.usesViaplayCdn
|
|
)
|
|
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 if !vlc.isPlaying {
|
|
error = vlc.errorMessage ?? "Livestream startte niet (geen beeld/geluid)."
|
|
}
|
|
} else {
|
|
await 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
|
|
}
|
|
}
|
|
}
|
|
|
|
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
|
|
var logoUrl: String? = nil
|
|
|
|
@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 {
|
|
PlayerLoadingChrome(logoUrl: logoUrl, status: "Stream voorbereiden…")
|
|
} 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,
|
|
logoUrl: logoUrl
|
|
)
|
|
loading = false
|
|
} catch {
|
|
self.error = error.localizedDescription
|
|
loading = false
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Centraal logo (provider / film / serie) met zachte pulse i.p.v. kale ProgressView.
|
|
struct PlayerLoadingChrome: View {
|
|
let logoUrl: String?
|
|
let status: String
|
|
@State private var pulse = false
|
|
@State private var glow = false
|
|
|
|
private var hasLogo: Bool {
|
|
guard let logoUrl, !logoUrl.isEmpty, URL(string: logoUrl) != nil else { return false }
|
|
return true
|
|
}
|
|
|
|
var body: some View {
|
|
ZStack {
|
|
Color.black.opacity(0.55).ignoresSafeArea()
|
|
|
|
VStack(spacing: 22) {
|
|
if hasLogo {
|
|
ZStack {
|
|
Circle()
|
|
.fill(
|
|
RadialGradient(
|
|
colors: [
|
|
Color.white.opacity(glow ? 0.18 : 0.06),
|
|
Color.clear,
|
|
],
|
|
center: .center,
|
|
startRadius: 8,
|
|
endRadius: 140
|
|
)
|
|
)
|
|
.frame(width: 280, height: 280)
|
|
.scaleEffect(glow ? 1.08 : 0.92)
|
|
|
|
RemoteImage(url: logoUrl, contentMode: .fit, opaqueBackground: false)
|
|
.frame(maxWidth: 240, maxHeight: 96)
|
|
.scaleEffect(pulse ? 1.04 : 0.96)
|
|
.opacity(pulse ? 1.0 : 0.72)
|
|
.shadow(color: .black.opacity(0.55), radius: 18, y: 8)
|
|
}
|
|
.onAppear {
|
|
withAnimation(.easeInOut(duration: 1.25).repeatForever(autoreverses: true)) {
|
|
pulse = true
|
|
glow = true
|
|
}
|
|
}
|
|
} else {
|
|
ProgressView()
|
|
.tint(.white)
|
|
.scaleEffect(1.2)
|
|
}
|
|
|
|
Text(status)
|
|
.font(McFonts.caption)
|
|
.foregroundStyle(.white.opacity(0.85))
|
|
.multilineTextAlignment(.center)
|
|
.padding(.horizontal, 28)
|
|
}
|
|
}
|
|
}
|
|
}
|