Preserve BaseURL/Location, warm up MPD, clear sinf for VLC, and retry early stops so custom live DRM plays instead of Gestopt.
556 lines
20 KiB
Swift
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
|
|
}
|
|
}
|
|
}
|