import AVKit import Combine import SwiftUI import UIKit import VLCKit struct PlayerTrack: Identifiable, Hashable { /// VLCKit4 trackId, of `"__off__"` voor ondertitels uit. let id: String let name: String } final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDelegate, VLCMediaDelegate { @Published var isPlaying = false @Published var position: Float = 0 @Published var timeText = "0:00" @Published var lengthText = "0:00" @Published var stateText = "Laden…" @Published var hasError = false @Published var errorMessage: String? @Published var subtitleTracks: [PlayerTrack] = [] @Published var audioTracks: [PlayerTrack] = [] @Published var currentSubtitleId: String = PlayerTrack.offId @Published var currentAudioId: String = "" @Published var pipPossible = false @Published var pipActive = false /// VOD / F1-replay via playLive: finite duration + VLC seekable. @Published var canSeek = false static let offSubtitleId = PlayerTrack.offId private let library: VLCLibrary let mediaPlayer: VLCMediaPlayer private let drawableHost = VLCPipDrawableHost() /// Plain UIView-target voor live/ClearKey (PiP sample-buffer-vout breekt DASH/proxy). private weak var plainVideoView: UIView? /// Sterke ref zolang er gespeeld wordt — voorkomt crash als SwiftUI de surface al freeggeeft. private var retainedSurface: UIView? private var lengthMs: Int32 = 0 private var tick: AnyCancellable? private var clearKeyProxyActive = false private var clearKeyProxySession = 0 private var liveMode = false private var livePlayURL: URL? private var livePlayFormat: String? private var liveStartedAt: Date? private var liveRetryCount = 0 private var everPlayed = false private var pipWindow: VLCPictureInPictureWindowControlling? private var sceneObserver: NSObjectProtocol? /// Voorkomt herhaald `drawable=` bij SwiftUI-ticks (audio-zonder-beeld). private weak var attachedView: UIView? /// VOD: altijd PiP-capable drawable (officieel VLCKit-patroon). Live/ClearKey: plain UIView. private var usePipDrawable = false private var stopping = false private var stopGeneration = 0 private var didApplyPreferredAudio = false private var knownVodDurationMs: Int64 = 0 /// Playlist is geknipt vanaf dit punt (VLC-tijd 0 = deze offset). private var seekOriginMs: Int64 = 0 /// Taal/naam van het gekozen audiospoor — na seek-reload terugzetten (VLC pakt anders weer Spaans). private var restoreAudioHint: String = "" override init() { // Shared library = alle demux/access plugins (DASH adaptive in VLC 4). library = VLCLibrary.shared() mediaPlayer = VLCMediaPlayer(library: library) super.init() mediaPlayer.delegate = self drawableHost.controller = self configureAudioSession() observeSceneForAutoPip() } deinit { if let sceneObserver { NotificationCenter.default.removeObserver(sceneObserver) } mediaPlayer.stop() mediaPlayer.drawable = nil if clearKeyProxyActive { ClearKeyDashProxy.shared.stop(session: clearKeyProxySession) } } func attach(to view: UIView) { guard !stopping else { return } attachedView = view plainVideoView = view retainedSurface = view view.backgroundColor = .black // PiP-restore: container mag wisselen zonder mediaPlayer.drawable te resetten. if usePipDrawable, mediaPlayer.drawable as AnyObject? === drawableHost { drawableHost.attach(to: view) return } // VOD: PiP-host zetten vóór VLC een gewone vout opent (anders komt pictureInPictureReady nooit). if wantsPipDrawable, mediaPlayer.media == nil, view.bounds.width > 1 { applyDrawable(force: true) return } if mediaPlayer.drawable as AnyObject? === view { return } // Nooit mid-play herbinden als er al een geldige drawable hangt. if mediaPlayer.drawable != nil, mediaPlayer.media != nil { if usePipDrawable { drawableHost.attach(to: view) } return } applyDrawable(force: mediaPlayer.drawable == nil) } /// Layout-callback: bind alleen als size OK; sta herstel toe als eerder op 0×0 gebonden. func surfaceDidLayout(_ view: UIView) { guard !stopping else { return } plainVideoView = view attachedView = view retainedSurface = view guard view.bounds.width > 1, view.bounds.height > 1 else { return } if usePipDrawable, mediaPlayer.drawable as AnyObject? === drawableHost { drawableHost.attach(to: view) return } if wantsPipDrawable, mediaPlayer.media == nil { applyDrawable(force: true) return } if mediaPlayer.drawable == nil { applyDrawable(force: true) } } private var wantsPipDrawable: Bool { !liveMode && !clearKeyProxyActive && !stopping } /// Live/ClearKey → plain UIView. VOD → VLCPipDrawableHost (PiP vanaf start). private func applyDrawable(force: Bool = false) { guard !stopping else { return } guard let view = plainVideoView else { return } // Nooit binden op 0×0 — VLC opent dan vout zonder beeld (audio-only). guard view.bounds.width > 1, view.bounds.height > 1 else { return } let wantPipHost = wantsPipDrawable usePipDrawable = wantPipHost if wantPipHost { drawableHost.attach(to: view) if force || mediaPlayer.drawable as AnyObject? !== drawableHost { mediaPlayer.drawable = drawableHost } pipPossible = AVPictureInPictureController.isPictureInPictureSupported() } else { // ClearKey/live: plain UIView. Nooit detach/rebind als al gezet tijdens play. if mediaPlayer.drawable as AnyObject? === view, !force { return } if mediaPlayer.media != nil, mediaPlayer.drawable != nil, !force { return } drawableHost.detachVideo() mediaPlayer.drawable = view pipPossible = false } } func startPictureInPicture() { guard !liveMode, !clearKeyProxyActive, !stopping else { return } // FreeType-subs zitten niet in VLCKit sample-buffer PiP (Apple/VLC-beperking). if currentSubtitleId != PlayerTrack.offId { stateText = "PiP zonder ondertiteling" DispatchQueue.main.asyncAfter(deadline: .now() + 2.5) { [weak self] in guard let self, !self.stopping, self.stateText == "PiP zonder ondertiteling" else { return } self.stateText = self.isPlaying ? "Afspelen" : self.stateText } } if let pipWindow { pipWindow.startPictureInPicture() return } applyDrawable(force: true) let gen = stopGeneration var attempts = 0 func tryStart() { guard self.stopGeneration == gen, !self.stopping else { return } guard !self.liveMode, !self.clearKeyProxyActive else { return } attempts += 1 if let win = self.pipWindow { win.startPictureInPicture() } else if attempts < 20 { DispatchQueue.main.asyncAfter(deadline: .now() + 0.1, execute: tryStart) } } DispatchQueue.main.asyncAfter(deadline: .now() + 0.2, execute: tryStart) } func stopPictureInPicture() { pipWindow?.stopPictureInPicture() } func togglePictureInPicture() { if pipActive { stopPictureInPicture() } else { startPictureInPicture() } } func play(url: URL, startMs: Int64 = 0, subtitleFileURL: URL? = nil) async { // Annuleer pending finishStop van eerdere/spurious onDisappear. stopGeneration &+= 1 let playGen = stopGeneration stopping = false hasError = false errorMessage = nil stateText = "Verbinden…" subtitleTracks = [] audioTracks = [] canSeek = false didApplyPreferredAudio = false restoreAudioHint = "" knownVodDurationMs = 0 seekOriginMs = 0 liveMode = false clearKeyProxyActive = false await waitForSurface(timeout: 2.0) guard stopGeneration == playGen, !stopping else { return } await MainActor.run { plainVideoView?.layoutIfNeeded() applyDrawable(force: true) } guard stopGeneration == playGen, !stopping else { return } pipPossible = AVPictureInPictureController.isPictureInPictureSupported() guard let media = VLCMedia(url: url) else { hasError = true errorMessage = "Kon media niet openen." stateText = "Fout" return } media.delegate = self media.addOption(":network-caching=2000") media.addOption(":http-reconnect") media.addOption(":avcodec-hw=any") media.addOption(":sout-mux-caching=1200") media.addOption(":sub-track=0") media.addOption(":freetype-rel-fontsize=\(Self.subtitleRelFontSize)") media.addOption(":freetype-bold=1") media.addOption(":sub-margin=0") if ViaplayPlaybackHeaders.matches(url) { media.addOption(":http-user-agent=\(ViaplayPlaybackHeaders.userAgent)") media.addOption(":http-referrer=\(ViaplayPlaybackHeaders.referer)") } if mediaPlayer.drawable as AnyObject? !== drawableHost { applyDrawable(force: true) } mediaPlayer.media = media _ = VLCMediaParser.shared().queue( media, options: VLCMediaParsingOptions(rawValue: 0x02 | 0x08) ) mediaPlayer.play() applySubtitleStyle() if startMs > 2000 { DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) { [weak self] in guard let self, !self.stopping else { return } self.seek(toMs: startMs) } } if let subtitleFileURL { DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) { [weak self] in guard let self, !self.stopping else { return } self.addExternalSubtitle(url: subtitleFileURL) } } startTicker() DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { [weak self] in guard let self, !self.stopping else { return } self.refreshTracks() self.applySubtitleStyle() } } func playLive( url: URL, fallback: URL? = nil, format: String? = nil, clearKeys: [ClearKeyEntry] = [], preferHls: Bool = false, browserCdnHeaders: Bool = false, forceVod: Bool = false ) async { // Annuleer pending finishStop / delayed proxy-stop van vorige sessie. stopGeneration &+= 1 let playGen = stopGeneration stopping = false hasError = false errorMessage = nil stateText = "Live laden…" liveRetryCount = 0 everPlayed = false liveStartedAt = Date() canSeek = false didApplyPreferredAudio = false restoreAudioHint = "" knownVodDurationMs = 0 seekOriginMs = 0 liveMode = !forceVod usePipDrawable = false pipPossible = false var playURL = url var playFormat = format clearKeyProxyActive = false var proxySession = 0 if !clearKeys.isEmpty { do { stateText = "ClearKey laden…" let started = try await ClearKeyDashProxy.shared.start( manifestURL: url, keys: clearKeys, preferHlsForAVPlayer: preferHls, browserCdnHeaders: browserCdnHeaders, forceVod: forceVod ) playURL = started.url proxySession = started.session if started.vodDurationMs > 3000 { knownVodDurationMs = started.vodDurationMs let clamped = Int32(min(started.vodDurationMs, Int64(Int32.max))) lengthMs = clamped lengthText = Self.format(ms: clamped) canSeek = true liveMode = false } // Lokale clear-HLS of remote HLS. if preferHls || ClearKeyDashProxy.isHlsURL(started.url) || ClearKeyDashProxy.isHlsURL(url) { playFormat = "hls" } else { playFormat = "dash" } clearKeyProxyActive = true } catch { hasError = true errorMessage = dutchClearKeyError(error) stateText = "Fout" return } } guard stopGeneration == playGen, !stopping else { return } await waitForSurface(timeout: 2.0) await MainActor.run { plainVideoView?.layoutIfNeeded() applyDrawable(force: true) } guard stopGeneration == playGen, !stopping else { return } livePlayURL = playURL livePlayFormat = playFormat clearKeyProxySession = proxySession startLiveMedia(url: playURL, format: playFormat) if let fallback, clearKeys.isEmpty { let gen = stopGeneration // Snellere fallback: IPTV-HLS faalt vaak stil; probeer binnen 3.5s de fallback. DispatchQueue.main.asyncAfter(deadline: .now() + 3.5) { [weak self] in guard let self, self.stopGeneration == gen, !self.stopping else { return } guard !self.everPlayed else { return } guard !self.isPlaying || self.hasError else { return } self.stateText = "Fallback…" self.startLiveMedia(url: fallback, format: nil) } } startTicker() } private func waitForSurface(timeout: TimeInterval) async { let deadline = Date().addingTimeInterval(timeout) while Date() < deadline { let ready = await MainActor.run { () -> Bool in guard let view = plainVideoView else { return false } return view.bounds.width > 1 && view.bounds.height > 1 } if ready { return } try? await Task.sleep(nanoseconds: 40_000_000) } } private func startLiveMedia(url: URL, format: String?) { guard !stopping else { return } guard let media = VLCMedia(url: url) else { hasError = true errorMessage = "Kon live-stream niet openen." stateText = "Fout" return } media.delegate = self Self.applyLiveOptions( to: media, url: url, format: format, viaLocalProxy: clearKeyProxyActive, asVod: knownVodDurationMs > 3000 ) // Drawable één keer vóór play — nooit opnieuw mid-stream (audio-only / crash). if mediaPlayer.drawable == nil { applyDrawable(force: true) } mediaPlayer.media = media mediaPlayer.play() let gen = stopGeneration DispatchQueue.main.asyncAfter(deadline: .now() + 1.6) { [weak self] in guard let self, self.stopGeneration == gen, !self.stopping else { return } self.refreshTracks() } DispatchQueue.main.asyncAfter(deadline: .now() + 4.0) { [weak self] in guard let self, self.stopGeneration == gen, !self.stopping else { return } self.refreshTracks() } } private func dutchClearKeyError(_ error: Error) -> String { let msg = error.localizedDescription if msg.localizedCaseInsensitiveContains("upstream") { return "Stream-server gaf een fout (\(msg)). Probeer opnieuw." } if msg.localizedCaseInsensitiveContains("leeg") || msg.localizedCaseInsensitiveContains("ongeldig") { return "Stream-manifest kon niet worden geladen. Controleer de zender-URL." } if msg.localizedCaseInsensitiveContains("proxy") || msg.localizedCaseInsensitiveContains("bind") { return "Lokale ClearKey-proxy startte niet. Herstart de app en probeer opnieuw." } if msg.localizedCaseInsensitiveContains("sleutel") { return "Geen geldige ClearKey-sleutels voor deze zender." } return "ClearKey-stream mislukt: \(msg)" } private func retryLiveIfNeeded() { guard liveMode, !everPlayed, liveRetryCount < 2 else { return } guard let url = livePlayURL else { return } let elapsed = Date().timeIntervalSince(liveStartedAt ?? Date()) guard elapsed < 14 else { return } liveRetryCount += 1 stateText = "Opnieuw verbinden…" hasError = false errorMessage = nil DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) { [weak self] in guard let self, self.liveMode, !self.stopping else { return } self.startLiveMedia(url: url, format: self.livePlayFormat) } } private static func applyLiveOptions( to media: VLCMedia, url: URL, format: String?, viaLocalProxy: Bool, asVod: Bool = false ) { // Stabiele live-start: te lage caching + drop-late-frames liet streams meteen stoppen. // VOD (F1-replay): géén live-caching, anders blijft VLC isSeekable=false / duur=0. if asVod { media.addOption(":network-caching=1500") } else if viaLocalProxy { media.addOption(":network-caching=1200") media.addOption(":live-caching=1000") } else { media.addOption(":network-caching=1000") media.addOption(":live-caching=800") } media.addOption(":http-reconnect") media.addOption(":avcodec-hw=any") media.addOption(":freetype-rel-fontsize=\(subtitleRelFontSize)") if viaLocalProxy { media.addOption(":http-user-agent=VLC/3.0.21 LibVLC/3.0.21") } else if ViaplayPlaybackHeaders.matches(url) { media.addOption(":http-user-agent=\(ViaplayPlaybackHeaders.userAgent)") media.addOption(":http-referrer=\(ViaplayPlaybackHeaders.referer)") } else { media.addOption(":http-user-agent=VLC/3.0.21 LibVLC/3.0.21") } // VLC 4: unified adaptive demux (DASH+HLS). Forceer GEEN :demux=dash (VLC3-module). media.addOption(":adaptive-logic=highest") if !viaLocalProxy { media.addOption(":adaptive-use-access") } let fmt = (format ?? "").lowercased() let path = url.absoluteString.lowercased() let isHls = fmt == "hls" || path.contains(".m3u8") || path.contains("m3u8") if isHls { // HLS mag nog steeds via adaptive; geen aparte demux forceren. } } func togglePlay() { if mediaPlayer.isPlaying { mediaPlayer.pause() } else { mediaPlayer.play() } invalidatePipPlaybackState() } func seek(toMs: Int64) { let target = max(0, toMs) if knownVodDurationMs > 3000, clearKeyProxyActive, let base = livePlayURL { let endPad = max(knownVodDurationMs - 1500, 0) let clamped = min(target, endPad) seekOriginMs = clamped ClearKeyDashProxy.shared.setPlaybackStartOffset(ms: clamped) var comps = URLComponents(url: base, resolvingAgainstBaseURL: false) var items = (comps?.queryItems ?? []).filter { $0.name != "vmcSeek" } items.append(URLQueryItem(name: "vmcSeek", value: String(clamped))) comps?.queryItems = items if let url = comps?.url { position = knownVodDurationMs > 0 ? Float(Double(clamped) / Double(knownVodDurationMs)) : 0 timeText = Self.format(ms: Int32(min(clamped, Int64(Int32.max)))) captureAudioRestoreHint() didApplyPreferredAudio = false startLiveMedia(url: url, format: livePlayFormat) invalidatePipPlaybackState() return } } mediaPlayer.time = VLCTime(number: NSNumber(value: target)) invalidatePipPlaybackState() } func seek(ratio: Float) { let dur = durationMs() if dur > 3000 { seek(toMs: Int64(Double(dur) * Double(min(max(ratio, 0), 1)))) return } mediaPlayer.position = Double(ratio) invalidatePipPlaybackState() } func currentPositionMs() -> Int64 { Int64(mediaPlayer.time.intValue) + seekOriginMs } func durationMs() -> Int64 { if knownVodDurationMs > 3000 { return knownVodDurationMs } let v = mediaPlayer.media?.length.intValue ?? lengthMs return Int64(max(v, 0)) } func selectSubtitle(id: String) { if id == PlayerTrack.offId { mediaPlayer.deselectAllTextTracks() currentSubtitleId = PlayerTrack.offId } else if let track = mediaPlayer.textTracks.first(where: { $0.trackId == id }) { track.isSelectedExclusively = true currentSubtitleId = id } invalidatePipPlaybackState() } func selectAudio(id: String) { if let track = mediaPlayer.audioTracks.first(where: { $0.trackId == id }) { track.isSelectedExclusively = true currentAudioId = id restoreAudioHint = Self.audioHint(language: track.language, name: track.trackName, description: track.trackDescription) } invalidatePipPlaybackState() } func addExternalSubtitle(url: URL) { if pipActive { stopPictureInPicture() } _ = mediaPlayer.addPlaybackSlave(url, type: .subtitle, enforce: true) DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in guard let self, !self.stopping else { return } self.refreshTracks() self.applySubtitleStyle() if let last = self.subtitleTracks.last(where: { $0.id != PlayerTrack.offId }) { self.selectSubtitle(id: last.id) } } } private static let subtitleRelFontSize = 32 private func applySubtitleStyle() { let size = NSNumber(value: Self.subtitleRelFontSize) let sel = NSSelectorFromString("setTextRendererFontSize:") if mediaPlayer.responds(to: sel) { mediaPlayer.perform(sel, with: size) } } func refreshTracks() { // VLCKit 4 kan crashen op track-API tijdens error/opening — altijd defensief. guard !stopping, mediaPlayer.media != nil else { subtitleTracks = [PlayerTrack(id: PlayerTrack.offId, name: "Uit")] audioTracks = [] return } let textList = mediaPlayer.textTracks let audioList = mediaPlayer.audioTracks var subs: [PlayerTrack] = [PlayerTrack(id: PlayerTrack.offId, name: "Uit")] var selectedSub = PlayerTrack.offId for (index, track) in textList.enumerated() { let label = Self.displayTrackLabel( rawName: track.trackName, fallbackIndex: index + 1, kind: .subtitle, languageHint: track.language, descriptionHint: track.trackDescription ) subs.append(PlayerTrack(id: track.trackId, name: label)) if track.isSelected { selectedSub = track.trackId } } subtitleTracks = subs currentSubtitleId = selectedSub var audios: [PlayerTrack] = [] var selectedAudio = currentAudioId for (index, track) in audioList.enumerated() { let label = Self.displayTrackLabel( rawName: track.trackName, fallbackIndex: index + 1, kind: .audio, languageHint: track.language, descriptionHint: track.trackDescription ) audios.append(PlayerTrack(id: track.trackId, name: label)) if track.isSelected { selectedAudio = track.trackId } } if selectedAudio.isEmpty, let first = audios.first { selectedAudio = first.id } audioTracks = audios currentAudioId = selectedAudio applyPreferredAudioIfNeeded() } private func captureAudioRestoreHint() { if let track = mediaPlayer.audioTracks.first(where: { $0.isSelected || $0.trackId == currentAudioId }) { restoreAudioHint = Self.audioHint(language: track.language, name: track.trackName, description: track.trackDescription) return } if restoreAudioHint.isEmpty, let name = audioTracks.first(where: { $0.id == currentAudioId })?.name { restoreAudioHint = name.lowercased() } } private func applyPreferredAudioIfNeeded() { let list = mediaPlayer.audioTracks guard !list.isEmpty else { return } if !restoreAudioHint.isEmpty { let hint = restoreAudioHint if let match = list.max(by: { Self.audioHintScore(language: $0.language, name: $0.trackName, description: $0.trackDescription, hint: hint) < Self.audioHintScore(language: $1.language, name: $1.trackName, description: $1.trackDescription, hint: hint) }), Self.audioHintScore(language: match.language, name: match.trackName, description: match.trackDescription, hint: hint) > 0 { if !match.isSelected { match.isSelectedExclusively = true } currentAudioId = match.trackId didApplyPreferredAudio = true return } } guard !didApplyPreferredAudio else { return } didApplyPreferredAudio = true let ranked = list.enumerated().map { i, t in (track: t, score: Self.audioPreferenceScore(language: t.language, name: t.trackName, description: t.trackDescription, index: i)) } guard let best = ranked.max(by: { $0.score < $1.score }), best.score > 0 else { return } if !best.track.isSelected { best.track.isSelectedExclusively = true } currentAudioId = best.track.trackId restoreAudioHint = Self.audioHint(language: best.track.language, name: best.track.trackName, description: best.track.trackDescription) } private static func audioHint(language: String?, name: String?, description: String?) -> String { [language, name, description] .compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() } .filter { !$0.isEmpty } .joined(separator: " ") } private static func audioHintScore(language: String?, name: String?, description: String?, hint: String) -> Int { let blob = audioHint(language: language, name: name, description: description) if blob.isEmpty || hint.isEmpty { return 0 } if blob == hint { return 100 } let lang = (language ?? "").lowercased() if !lang.isEmpty, hint.contains(lang) { return 80 } let trackName = (name ?? "").lowercased() if !trackName.isEmpty, hint.contains(trackName) { return 70 } let tokens = hint.split(whereSeparator: { !$0.isLetter }).map(String.init).filter { $0.count >= 2 } return tokens.contains(where: { blob.contains($0) }) ? 40 : 0 } /// NL eerst, anders Engels. Geen match → 0 (niet forceren). static func audioPreferenceScore(language: String?, name: String?, description: String?, index: Int) -> Int { let blob = [language, name, description] .compactMap { $0?.lowercased() } .joined(separator: " ") if blob.isEmpty { return 0 } let nl = ["nederlands", "dutch", " holland", "nl ", "dut", "nld"] if nl.contains(where: { blob.contains($0) }) || blob == "nl" || blob.hasPrefix("nl-") || blob.hasPrefix("nl_") { return 100 - index } let en = ["engels", "english", "international", "eng", "en "] if en.contains(where: { blob.contains($0) }) || blob == "en" || blob.hasPrefix("en-") || blob.hasPrefix("en_") || blob == "eng" { return 50 - index } return 0 } private enum TrackKind { case subtitle, audio } private static func displayTrackLabel( rawName: String, fallbackIndex: Int, kind: TrackKind, languageHint: String?, descriptionHint: String? ) -> String { let prettyLang = prettyLanguage(languageHint) ?? prettyLanguage(extractLanguageToken(from: descriptionHint)) ?? prettyLanguage(extractLanguageToken(from: rawName)) if isGenericTrackLabel(rawName) || rawName.uppercased().contains("TEXT/") || rawName.uppercased().hasPrefix("HTTP") { if let prettyLang { if let descriptionHint, !descriptionHint.isEmpty, !isGenericTrackLabel(descriptionHint), !descriptionHint.localizedCaseInsensitiveContains(prettyLang) { return "\(prettyLang) · \(descriptionHint)" } return prettyLang } return kind == .subtitle ? "Ondertitel \(fallbackIndex)" : "Audio \(fallbackIndex)" } if let prettyLang, !rawName.localizedCaseInsensitiveContains(prettyLang) { return "\(prettyLang) · \(rawName)" } return rawName } private static func isGenericTrackLabel(_ raw: String?) -> Bool { let s = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines) if s.isEmpty { return true } let lower = s.lowercased() if lower == "und" || lower == "unknown" || lower == "null" || lower == "sub" || lower == "subtitle" { return true } if lower.range(of: #"^track\s*\d+$"#, options: .regularExpression) != nil { return true } if lower.range(of: #"^subtitle\s*\d+$"#, options: .regularExpression) != nil { return true } if lower.range(of: #"^ondertitel\s*\d+$"#, options: .regularExpression) != nil { return true } if lower.range(of: #"^audio\s*\d+$"#, options: .regularExpression) != nil { return true } return false } private static func extractLanguageToken(from raw: String?) -> String? { guard let raw, !raw.isEmpty else { return nil } let lower = raw.lowercased() let known = [ "nederlands", "dutch", "nl", "dut", "nld", "engels", "english", "en", "eng", "duits", "german", "de", "ger", "deu", "frans", "french", "fr", "fre", "fra", "spaans", "spanish", "es", "spa", "italiaans", "italian", "it", "ita", "portugees", "portuguese", "pt", "por", "pools", "polish", "pl", "pol", "russisch", "russian", "ru", "rus", "japans", "japanese", "ja", "jpn", "koreaans", "korean", "ko", "kor", "chinees", "chinese", "zh", "chi", "zho", ] let tokens = lower.split(whereSeparator: { !$0.isLetter }).map(String.init) for token in known where tokens.contains(token) || lower == token { return token } if let match = lower.range(of: #"\b[a-z]{2,3}\b"#, options: .regularExpression) { return String(lower[match]) } return nil } private static func prettyLanguage(_ code: String?) -> String? { let c = (code ?? "").trimmingCharacters(in: .whitespacesAndNewlines).lowercased() guard !c.isEmpty, c != "und", c != "null", c != "unknown" else { return nil } switch c { case "nl", "dut", "nld", "dutch", "nederlands": return "Nederlands" case "en", "eng", "english", "engels": return "Engels" case "de", "ger", "deu", "german", "duits": return "Duits" case "fr", "fre", "fra", "french", "frans": return "Frans" case "es", "spa", "spanish", "spaans": return "Spaans" case "it", "ita", "italian", "italiaans": return "Italiaans" case "pt", "por", "portuguese", "portugees": return "Portugees" case "pl", "pol", "polish", "pools": return "Pools" case "ru", "rus", "russian", "russisch": return "Russisch" case "ja", "jpn", "japanese", "japans": return "Japans" case "ko", "kor", "korean", "koreaans": return "Koreaans" case "zh", "chi", "zho", "cmn", "chinese", "chinees": return "Chinees" case "ar", "ara", "arabic", "arabisch": return "Arabisch" case "tr", "tur", "turkish", "turks": return "Turks" case "sv", "swe", "swedish", "zweeds": return "Zweeds" case "no", "nor", "nb", "nn", "norwegian", "noors": return "Noors" case "da", "dan", "danish", "deens": return "Deens" case "fi", "fin", "finnish", "fins": return "Fins" default: if c.count <= 3 { let name = Locale(identifier: "nl_NL").localizedString(forLanguageCode: c) if let name, !name.isEmpty, name.lowercased() != c { return name.capitalized } return c.uppercased() } return c.capitalized } } func stop() { guard !stopping else { return } stopping = true stopGeneration &+= 1 let gen = stopGeneration let proxySession = clearKeyProxySession tick?.cancel() tick = nil let proxyWasActive = clearKeyProxyActive let wasPip = pipActive clearKeyProxyActive = false clearKeyProxySession = 0 pipPossible = false // PiP eerst netjes stoppen en wachten — sync nil + media stop = EXC_BAD_ACCESS (SwiftVLC #20). if wasPip { pipWindow?.stopPictureInPicture() pipActive = false finishStop(gen: gen, proxyWasActive: proxyWasActive, proxySession: proxySession, delay: 0.85) return } finishStop(gen: gen, proxyWasActive: proxyWasActive, proxySession: proxySession, delay: 0.05) } private func finishStop(gen: Int, proxyWasActive: Bool, proxySession: Int, delay: TimeInterval) { let work = { [weak self] in guard let self else { return } // Nieuwe play() heeft generation gebumpt of stopping gereset → niet meer tear-down'en. guard self.stopGeneration == gen, self.stopping else { return } self.pipWindow = nil self.pipActive = false self.usePipDrawable = false self.mediaPlayer.stop() self.mediaPlayer.media = nil self.mediaPlayer.drawable = nil self.drawableHost.detachVideo() self.attachedView = nil self.plainVideoView = nil DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in guard let self, self.stopGeneration == gen else { return } self.retainedSurface = nil } self.livePlayURL = nil self.livePlayFormat = nil self.liveStartedAt = nil self.liveRetryCount = 0 self.everPlayed = false self.liveMode = false self.canSeek = false self.didApplyPreferredAudio = false self.knownVodDurationMs = 0 self.seekOriginMs = 0 self.isPlaying = false self.hasError = false self.errorMessage = nil self.stateText = "Gestopt" self.subtitleTracks = [PlayerTrack(id: PlayerTrack.offId, name: "Uit")] self.audioTracks = [] if proxyWasActive, proxySession > 0 { DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { ClearKeyDashProxy.shared.stop(session: proxySession) } } } if delay > 0 { DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: work) } else { work() } } // MARK: - VLCMediaPlayerDelegate (VLCKit 4) func mediaPlayerStateChanged(_ newState: VLCMediaPlayerState) { DispatchQueue.main.async { [weak self] in guard let self, !self.stopping else { return } self.isPlaying = self.mediaPlayer.isPlaying if self.pipPossible { self.invalidatePipPlaybackState() } switch newState { case .opening: self.stateText = "Openen…" case .playing: self.stateText = "Afspelen" self.hasError = false self.errorMessage = nil self.everPlayed = true // Geen drawable-rebind hier — dat maakte ClearKey audio-only + crash. if !self.liveMode { self.refreshTracks() self.applySubtitleStyle() } case .paused: self.stateText = "Pauze" case .stopped, .stopping: if self.liveMode && !self.everPlayed { self.stateText = "Verbinden…" self.retryLiveIfNeeded() } else if !self.hasError { self.stateText = "Gestopt" } case .error: if self.liveMode && !self.everPlayed && self.liveRetryCount < 2 { self.stateText = "Opnieuw…" self.retryLiveIfNeeded() } else { self.hasError = true self.errorMessage = self.clearKeyProxyActive ? "Kon deze ClearKey-livezender niet afspelen. Controleer netwerk of probeer opnieuw." : (self.liveMode ? "Kon deze livestream niet openen." : "Kon dit bestand niet afspelen") self.stateText = "Fout" } case .nothingSpecial: break @unknown default: break } } } func mediaPlayerTrackAdded(_ trackId: String, with type: VLCMedia.TrackType) { DispatchQueue.main.async { [weak self] in guard let self, !self.stopping, !self.liveMode else { return } self.refreshTracks() } } func mediaPlayerTrackUpdated(_ trackId: String, with type: VLCMedia.TrackType) { DispatchQueue.main.async { [weak self] in guard let self, !self.stopping, !self.liveMode else { return } self.refreshTracks() } } func mediaPlayerLengthChanged(_ length: Int64) { DispatchQueue.main.async { [weak self] in guard let self, !self.stopping else { return } self.invalidatePipPlaybackState() } } func mediaDidFinishParsing(_ aMedia: VLCMedia) { DispatchQueue.main.async { [weak self] in guard let self, !self.stopping, !self.liveMode else { return } self.refreshTracks() } } private func startTicker() { tick?.cancel() tick = Timer.publish(every: 0.4, on: .main, in: .common) .autoconnect() .sink { [weak self] _ in self?.refreshTime() } } private func refreshTime() { guard !stopping else { return } let t = Int64(mediaPlayer.time.intValue) + seekOriginMs let l = mediaPlayer.media?.length.intValue ?? 0 if knownVodDurationMs > 3000 { lengthMs = Int32(min(knownVodDurationMs, Int64(Int32.max))) position = Float(Double(t) / Double(knownVodDurationMs)) } else { if l > 0 { lengthMs = l } position = Float(mediaPlayer.position) } timeText = Self.format(ms: Int32(min(max(t, 0), Int64(Int32.max)))) lengthText = Self.format(ms: lengthMs) isPlaying = mediaPlayer.isPlaying let seekable = (knownVodDurationMs > 3000 || mediaPlayer.isSeekable) && lengthMs > 3000 if seekable != canSeek { canSeek = seekable } } private static func format(ms: Int32) -> String { let total = max(Int(ms) / 1000, 0) let h = total / 3600 let m = (total % 3600) / 60 let s = total % 60 if h > 0 { return String(format: "%d:%02d:%02d", h, m, s) } return String(format: "%d:%02d", m, s) } // MARK: - PiP / audio fileprivate func onPictureInPictureReady(_ controller: VLCPictureInPictureWindowControlling) { guard !stopping, !liveMode, !clearKeyProxyActive else { return } pipWindow = controller pipPossible = AVPictureInPictureController.isPictureInPictureSupported() controller.stateChangeEventHandler = { [weak self] isStarted in DispatchQueue.main.async { guard let self else { return } if self.stopping { self.pipActive = false return } let wasActive = self.pipActive self.pipActive = isStarted if wasActive && !isStarted { // PiP-X gesloten: herkoppel video-subview aan inline surface (geen drawable-reset). if let view = self.plainVideoView ?? self.retainedSurface { self.drawableHost.attach(to: view) } // Opnieuw FreeType/subs activeren na terugkeer uit PiP. if self.currentSubtitleId != PlayerTrack.offId { self.selectSubtitle(id: self.currentSubtitleId) } self.applySubtitleStyle() self.invalidatePipPlaybackState() } if !wasActive && isStarted { // Net PiP gestart: forceer spu opnieuw (sample-buffer kan spu laten vallen). DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) { [weak self] in guard let self, self.pipActive, !self.stopping else { return } if self.currentSubtitleId != PlayerTrack.offId { self.selectSubtitle(id: self.currentSubtitleId) } self.applySubtitleStyle() } } } } // Automatisch PiP bij app → achtergrond (Netflix-gedrag). if let obj = controller as? NSObject { for key in ["avPipController", "avPictureInPictureController", "_avPipController"] { if let av = obj.value(forKey: key) as? AVPictureInPictureController { if #available(iOS 14.2, *) { av.canStartPictureInPictureAutomaticallyFromInline = true } break } } } } fileprivate func invalidatePipPlaybackState() { pipWindow?.invalidatePlaybackState() } private func configureAudioSession() { let session = AVAudioSession.sharedInstance() do { try session.setCategory(.playback, mode: .moviePlayback, options: []) try session.setActive(true) } catch { // Speelt vaak nog steeds; PiP vereist wel background audio mode in Info.plist. } } private func observeSceneForAutoPip() { sceneObserver = NotificationCenter.default.addObserver( forName: UIApplication.didEnterBackgroundNotification, object: nil, queue: .main ) { [weak self] _ in guard let self, self.isPlaying, !self.pipActive else { return } guard !self.liveMode, !self.clearKeyProxyActive else { return } self.startPictureInPicture() } } } extension PlayerTrack { static let offId = "__off__" } // MARK: - VLC drawable + Apple PiP bridge final class VLCPipDrawableHost: NSObject, VLCDrawable, VLCPictureInPictureDrawable, VLCPictureInPictureMediaControlling { weak var controller: VLCPlayerController? private weak var container: UIView? private var videoSubview: UIView? var hasVideoSubview: Bool { guard let videoSubview else { return false } return videoSubview.superview != nil && videoSubview.bounds.width > 1 } func attach(to view: UIView) { container = view view.backgroundColor = .black if let videoSubview, videoSubview.superview !== view { videoSubview.removeFromSuperview() view.addSubview(videoSubview) videoSubview.frame = view.bounds videoSubview.autoresizingMask = [.flexibleWidth, .flexibleHeight] } } func detachVideo() { videoSubview?.removeFromSuperview() // Houd referentie; VLC kan hem opnieuw addSubview'en. } // VLCDrawable (VLCKit 4: methods, not properties) func addSubview(_ view: UIView) { videoSubview = view guard let container else { return } container.subviews.filter { $0 !== view }.forEach { $0.removeFromSuperview() } if view.superview !== container { container.addSubview(view) } view.frame = container.bounds view.autoresizingMask = [.flexibleWidth, .flexibleHeight] } func bounds() -> CGRect { container?.bounds ?? .zero } // VLCPictureInPictureDrawable func mediaController() -> (any VLCPictureInPictureMediaControlling)? { self } func pictureInPictureReady() -> (((any VLCPictureInPictureWindowControlling)?) -> Void)? { { [weak self] pip in guard let pip else { return } self?.controller?.onPictureInPictureReady(pip) } } // VLCPictureInPictureMediaControlling func play() { controller?.mediaPlayer.play() } func pause() { controller?.mediaPlayer.pause() } func seek(by offset: Int64, completion: @escaping () -> Void) { guard let player = controller?.mediaPlayer else { completion() return } let ok = player.jump(withOffset: Int32(clamping: offset), completion: completion) if !ok { completion() } } func mediaLength() -> Int64 { Int64(controller?.mediaPlayer.media?.length.intValue ?? 0) } func mediaTime() -> Int64 { Int64(controller?.mediaPlayer.time.intValue ?? 0) } func isMediaSeekable() -> Bool { controller?.mediaPlayer.isSeekable ?? false } func isMediaPlaying() -> Bool { controller?.mediaPlayer.isPlaying ?? false } } /// UIKit surface met layout-callback — voorkomt zero-bounds drawable (audio-only). final class VLCSurfaceView: UIView { var onLayout: ((UIView) -> Void)? override func layoutSubviews() { super.layoutSubviews() onLayout?(self) } } struct VLCVideoView: UIViewRepresentable { let controller: VLCPlayerController func makeUIView(context: Context) -> VLCSurfaceView { let view = VLCSurfaceView() view.backgroundColor = .black view.clipsToBounds = true view.onLayout = { [weak controller] surface in controller?.surfaceDidLayout(surface) } controller.attach(to: view) return view } func updateUIView(_ uiView: VLCSurfaceView, context: Context) { uiView.onLayout = { [weak controller] surface in controller?.surfaceDidLayout(surface) } // Geen herhaalde drawable-bind bij SwiftUI ticks (breekt video-vout). controller.attach(to: uiView) } }