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? @State private var hideTask: Task? @State private var heartbeatTask: Task? @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? 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 } } }