Play-diagnose shows bedoeld vs verzonden; Don't go MPD fails with that UA on screen instead of playing the promo.
887 lines
35 KiB
Swift
887 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 {
|
|
if let diag = ClearKeyDashProxy.lastCdnDiag {
|
|
await app.api.reportViaplayCdnDiag(diag)
|
|
}
|
|
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 request.usesViaplayCdn, let diag = ClearKeyDashProxy.lastCdnDiag {
|
|
await app.api.reportViaplayCdnDiag(diag)
|
|
}
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
}
|