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 static let offSubtitleId = PlayerTrack.offId private let library: VLCLibrary let mediaPlayer: VLCMediaPlayer private let drawableHost = VLCPipDrawableHost() private var lengthMs: Int32 = 0 private var tick: AnyCancellable? private var clearKeyProxyActive = 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? override init() { // Eigen library-instance zodat opties/PiP-vout stabiel blijven. library = VLCLibrary(options: []) mediaPlayer = VLCMediaPlayer(library: library) super.init() mediaPlayer.delegate = self drawableHost.controller = self mediaPlayer.drawable = drawableHost configureAudioSession() observeSceneForAutoPip() } deinit { if let sceneObserver { NotificationCenter.default.removeObserver(sceneObserver) } } func attach(to view: UIView) { drawableHost.attach(to: view) mediaPlayer.drawable = drawableHost } func startPictureInPicture() { pipWindow?.startPictureInPicture() } func stopPictureInPicture() { pipWindow?.stopPictureInPicture() } func togglePictureInPicture() { if pipActive { stopPictureInPicture() } else { startPictureInPicture() } } func play(url: URL, startMs: Int64 = 0, subtitleFileURL: URL? = nil) { hasError = false errorMessage = nil stateText = "Verbinden…" subtitleTracks = [] audioTracks = [] let media = VLCMedia(url: url) media.delegate = self media.addOption(":network-caching=2500") media.addOption(":http-reconnect") media.addOption(":avcodec-hw=any") media.addOption(":sout-mux-caching=1500") media.addOption(":sub-track=0") media.addOption(":freetype-rel-fontsize=\(Self.subtitleRelFontSize)") media.addOption(":freetype-bold=1") mediaPlayer.media = media _ = media.parse(options: VLCMediaParsingOptions(rawValue: 0x02 | 0x08)) // network + fetchLocal mediaPlayer.play() applySubtitleStyle() if startMs > 2000 { DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) { [weak self] in self?.seek(toMs: startMs) } } if let subtitleFileURL { DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) { [weak self] in self?.addExternalSubtitle(url: subtitleFileURL) } } startTicker() DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { [weak self] in self?.refreshTracks() self?.applySubtitleStyle() } } func playLive( url: URL, fallback: URL? = nil, format: String? = nil, clearKeys: [ClearKeyEntry] = [] ) { hasError = false errorMessage = nil stateText = "Live laden…" liveRetryCount = 0 everPlayed = false liveStartedAt = Date() var playURL = url var playFormat = format clearKeyProxyActive = false if !clearKeys.isEmpty { do { // VLCKit speelt geen CENC/ClearKey zelf; lokale proxy decrypt segmenten. playURL = try ClearKeyDashProxy.shared.start(manifestURL: url, keys: clearKeys) playFormat = "dash" clearKeyProxyActive = true stateText = "ClearKey laden…" } catch { hasError = true errorMessage = dutchClearKeyError(error) stateText = "Fout" return } } livePlayURL = playURL livePlayFormat = playFormat startLiveMedia(url: playURL, format: playFormat) if let fallback, clearKeys.isEmpty { DispatchQueue.main.asyncAfter(deadline: .now() + 8) { [weak self] in guard let self, !self.isPlaying || self.hasError else { return } self.startLiveMedia(url: fallback, format: nil) } } startTicker() } private func startLiveMedia(url: URL, format: String?) { let media = VLCMedia(url: url) media.delegate = self Self.applyLiveOptions(to: media, url: url, format: format) mediaPlayer.media = media mediaPlayer.play() applySubtitleStyle() } 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 "DASH-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 clearKeyProxyActive, !everPlayed, liveRetryCount < 2 else { return } guard let url = livePlayURL else { return } let elapsed = Date().timeIntervalSince(liveStartedAt ?? Date()) guard elapsed < 12 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.clearKeyProxyActive else { return } self.startLiveMedia(url: url, format: self.livePlayFormat) } } private static func applyLiveOptions( to media: VLCMedia, url: URL, format: String? ) { media.addOption(":network-caching=1200") media.addOption(":http-reconnect") media.addOption(":live-caching=1000") media.addOption(":freetype-rel-fontsize=\(subtitleRelFontSize)") let fmt = (format ?? "").lowercased() let path = url.absoluteString.lowercased() let isDash = fmt == "dash" || path.contains(".mpd") let isHls = fmt == "hls" || path.contains(".m3u8") || path.contains("m3u8") if isDash { media.addOption(":demux=dash") media.addOption(":adaptive-logic=highest") media.addOption(":adaptive-use-access") } else if isHls { media.addOption(":demux=hls") } } func togglePlay() { if mediaPlayer.isPlaying { mediaPlayer.pause() } else { mediaPlayer.play() } invalidatePipPlaybackState() } func seek(toMs: Int64) { mediaPlayer.time = VLCTime(number: NSNumber(value: toMs)) invalidatePipPlaybackState() } func seek(ratio: Float) { mediaPlayer.position = Double(ratio) invalidatePipPlaybackState() } func currentPositionMs() -> Int64 { Int64(mediaPlayer.time.intValue) } func durationMs() -> Int64 { 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 } invalidatePipPlaybackState() } func addExternalSubtitle(url: URL) { _ = mediaPlayer.addPlaybackSlave(url, type: .subtitle, enforce: true) DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { [weak self] in self?.refreshTracks() self?.applySubtitleStyle() } } 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() { var subs: [PlayerTrack] = [PlayerTrack(id: PlayerTrack.offId, name: "Uit")] for (index, track) in mediaPlayer.textTracks.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 { currentSubtitleId = track.trackId } } if !mediaPlayer.textTracks.contains(where: \.isSelected) { currentSubtitleId = PlayerTrack.offId } subtitleTracks = subs var audios: [PlayerTrack] = [] for (index, track) in mediaPlayer.audioTracks.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 { currentAudioId = track.trackId } } if currentAudioId.isEmpty, let first = audios.first { currentAudioId = first.id } audioTracks = audios } 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() { tick?.cancel() tick = nil stopPictureInPicture() mediaPlayer.stop() mediaPlayer.media = nil livePlayURL = nil livePlayFormat = nil liveStartedAt = nil liveRetryCount = 0 everPlayed = false if clearKeyProxyActive { ClearKeyDashProxy.shared.stop() clearKeyProxyActive = false } } // MARK: - VLCMediaPlayerDelegate (VLCKit 4) func mediaPlayerStateChanged(_ newState: VLCMediaPlayerState) { DispatchQueue.main.async { [weak self] in guard let self else { return } self.isPlaying = self.mediaPlayer.isPlaying self.invalidatePipPlaybackState() switch newState { case .buffering: self.stateText = "Bufferen…" case .opening: self.stateText = "Openen…" case .playing: self.stateText = "Afspelen" self.hasError = false self.errorMessage = nil self.everPlayed = true self.refreshTracks() self.applySubtitleStyle() case .paused: self.stateText = "Pauze" case .stopped, .stopping: if self.clearKeyProxyActive && !self.everPlayed { self.stateText = "Verbinden…" self.retryLiveIfNeeded() } else { self.stateText = "Gestopt" } case .error: if self.clearKeyProxyActive && !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." : "Kon dit bestand niet afspelen" self.stateText = "Fout" } @unknown default: break } } } func mediaPlayerTrackAdded(_ trackId: String, with type: VLCMedia.TrackType) { DispatchQueue.main.async { [weak self] in self?.refreshTracks() } } func mediaPlayerTrackUpdated(_ trackId: String, with type: VLCMedia.TrackType) { DispatchQueue.main.async { [weak self] in self?.refreshTracks() } } func mediaPlayerLengthChanged(_ length: Int64) { DispatchQueue.main.async { [weak self] in self?.invalidatePipPlaybackState() } } func mediaDidFinishParsing(_ aMedia: VLCMedia) { DispatchQueue.main.async { [weak self] in 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() { let t = mediaPlayer.time.intValue let l = mediaPlayer.media?.length.intValue ?? 0 if l > 0 { lengthMs = l } position = Float(mediaPlayer.position) timeText = Self.format(ms: t) lengthText = Self.format(ms: lengthMs) isPlaying = mediaPlayer.isPlaying } 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) { pipWindow = controller pipPossible = true controller.stateChangeEventHandler = { [weak self] isStarted in DispatchQueue.main.async { self?.pipActive = isStarted } } // 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, self.pipPossible 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? 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] } } // VLCDrawable 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] } var bounds: CGRect { container?.bounds ?? .zero } // VLCPictureInPictureDrawable var mediaController: VLCPictureInPictureMediaControlling { self } func pictureInPictureReady() -> (VLCPictureInPictureWindowControlling) -> Void { { [weak self] pip in self?.controller?.onPictureInPictureReady(pip) } } // VLCPictureInPictureMediaControlling func play() { controller?.mediaPlayer.play() } func pause() { controller?.mediaPlayer.pause() } @objc(seekBy:completion:) func seekBy(_ offset: Int64, completion: @escaping () -> Void) { guard let player = controller?.mediaPlayer else { completion() return } let ok = player.jump(withOffset: Int(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 } } struct VLCVideoView: UIViewRepresentable { let controller: VLCPlayerController func makeUIView(context: Context) -> UIView { let view = UIView() view.backgroundColor = .black controller.attach(to: view) return view } func updateUIView(_ uiView: UIView, context: Context) { controller.attach(to: uiView) } }