import Combine import MobileVLCKit import SwiftUI import UIKit 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: Int32 = -1 @Published var currentAudioId: Int32 = -1 let mediaPlayer = VLCMediaPlayer() 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 override init() { super.init() mediaPlayer.delegate = self } func attach(to view: UIView) { mediaPlayer.drawable = view } 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") // VLC: hoger = kleiner (hoogte ≈ scherm/N). Default ~16 is te groot op iPhone. media.addOption(":freetype-rel-fontsize=\(Self.subtitleRelFontSize)") media.addOption(":freetype-bold=1") mediaPlayer.media = media // Async parse zodat tracksInformation language/title beschikbaar wordt. // MobileVLCKit (deze binary) exposeert parseWithOptions: niet netjes naar Swift. media.parse() 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 { // MobileVLCKit kan CENC/ClearKey niet zelf; lokale proxy decrypt segmenten. // start() wacht tot listener + MPD-warmup klaar zijn. 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 // Geen parse() op live/DASH — blokkeert/stopt soms meteen op dynamic MPD. 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)" } /// Na vroege Gestopt/error: één keer opnieuw afspelen (proxy blijft actief). 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() } } func seek(toMs: Int64) { mediaPlayer.time = VLCTime(number: NSNumber(value: toMs)) } func seek(ratio: Float) { mediaPlayer.position = ratio } 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: Int32) { mediaPlayer.currentVideoSubTitleIndex = id currentSubtitleId = id } func selectAudio(id: Int32) { mediaPlayer.currentAudioTrackIndex = id currentAudioId = id } 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() } } /// VLC relative fontsize: groter getal = kleinere tekst (~1/N van schermhoogte). 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() { let meta = trackMetaById() var textOrderHints: [String?] = [] var audioOrderHints: [String?] = [] for info in (mediaPlayer.media?.tracksInformation as? [Any]) ?? [] { guard let dict = info as? [AnyHashable: Any] else { continue } let type = ((dict[VLCMediaTracksInformationType] as? String) ?? (dict["type"] as? String) ?? "").lowercased() let lang = (dict[VLCMediaTracksInformationLanguage] as? String) ?? (dict["language"] as? String) let desc = (dict[VLCMediaTracksInformationDescription] as? String) ?? (dict["description"] as? String) let hint = lang ?? desc if type.contains("text") { textOrderHints.append(hint) } else if type.contains("audio") { audioOrderHints.append(hint) } } var subs: [PlayerTrack] = [PlayerTrack(id: -1, name: "Uit")] var textOrd = 0 if let names = mediaPlayer.videoSubTitlesNames as? [Any], let indexes = mediaPlayer.videoSubTitlesIndexes as? [Any] { for (i, raw) in indexes.enumerated() { let idx = (raw as? NSNumber)?.int32Value ?? Int32(truncating: raw as? NSNumber ?? -1) if idx < 0 { continue } let rawName: String = { if i < names.count, let n = names[i] as? String, !n.isEmpty { return n } return "" }() let fromId = meta[idx] let fromOrder = textOrd < textOrderHints.count ? textOrderHints[textOrd] : nil textOrd += 1 let label = Self.displayTrackLabel( rawName: rawName, fallbackIndex: subs.count, kind: .subtitle, languageHint: fromId?.language ?? fromOrder, descriptionHint: fromId?.description ) subs.append(PlayerTrack(id: idx, name: label)) } } subtitleTracks = subs currentSubtitleId = mediaPlayer.currentVideoSubTitleIndex var audios: [PlayerTrack] = [] var audioOrd = 0 if let names = mediaPlayer.audioTrackNames as? [Any], let indexes = mediaPlayer.audioTrackIndexes as? [Any] { for (i, raw) in indexes.enumerated() { let idx = (raw as? NSNumber)?.int32Value ?? -1 if idx < 0 { continue } let rawName: String = { if i < names.count, let n = names[i] as? String, !n.isEmpty { return n } return "" }() let fromId = meta[idx] let fromOrder = audioOrd < audioOrderHints.count ? audioOrderHints[audioOrd] : nil audioOrd += 1 let label = Self.displayTrackLabel( rawName: rawName, fallbackIndex: audios.count + 1, kind: .audio, languageHint: fromId?.language ?? fromOrder, descriptionHint: fromId?.description ) audios.append(PlayerTrack(id: idx, name: label)) } } audioTracks = audios currentAudioId = mediaPlayer.currentAudioTrackIndex } private struct TrackMeta { var language: String? var description: String? } private func trackMetaById() -> [Int32: TrackMeta] { guard let media = mediaPlayer.media else { return [:] } var out: [Int32: TrackMeta] = [:] for info in (media.tracksInformation as? [Any]) ?? [] { guard let dict = info as? [AnyHashable: Any] else { continue } let tid = (dict[VLCMediaTracksInformationId] as? NSNumber)?.int32Value ?? (dict["id"] as? NSNumber)?.int32Value guard let tid else { continue } let lang = (dict[VLCMediaTracksInformationLanguage] as? String) ?? (dict["language"] as? String) let desc = (dict[VLCMediaTracksInformationDescription] as? String) ?? (dict["description"] as? String) out[tid] = TrackMeta(language: lang, description: desc) } return out } 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 mediaPlayer.stop() mediaPlayer.media = nil livePlayURL = nil livePlayFormat = nil liveStartedAt = nil liveRetryCount = 0 everPlayed = false if clearKeyProxyActive { ClearKeyDashProxy.shared.stop() clearKeyProxyActive = false } } func mediaPlayerStateChanged(_ aNotification: Notification) { DispatchQueue.main.async { [weak self] in guard let self else { return } self.isPlaying = self.mediaPlayer.isPlaying switch self.mediaPlayer.state { 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: // Vroege "Gestopt" bij ClearKey is vaak tijdelijk (init/segment race) — niet meteen opgeven. if self.clearKeyProxyActive && !self.everPlayed { self.stateText = "Verbinden…" self.retryLiveIfNeeded() } else { self.stateText = "Gestopt" } case .ended: self.stateText = "Einde" 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" } case .esAdded: self.refreshTracks() @unknown default: break } } } 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 = 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) } } 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) } }