import Combine import MobileVLCKit import SwiftUI import UIKit final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDelegate { @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? 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.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 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) { hasError = false errorMessage = nil stateText = "Live laden…" let media = VLCMedia(url: url) media.addOption(":network-caching=1200") media.addOption(":http-reconnect") media.addOption(":live-caching=1000") media.addOption(":freetype-rel-fontsize=\(Self.subtitleRelFontSize)") mediaPlayer.media = media mediaPlayer.play() applySubtitleStyle() if let fallback { DispatchQueue.main.asyncAfter(deadline: .now() + 8) { [weak self] in guard let self, !self.isPlaying || self.hasError else { return } let fb = VLCMedia(url: fallback) fb.addOption(":network-caching=1200") fb.addOption(":http-reconnect") fb.addOption(":freetype-rel-fontsize=\(Self.subtitleRelFontSize)") self.mediaPlayer.media = fb self.mediaPlayer.play() self.applySubtitleStyle() } } startTicker() } 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() { var subs: [PlayerTrack] = [PlayerTrack(id: -1, name: "Uit")] 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 label: String if i < names.count, let n = names[i] as? String, !n.isEmpty { label = n } else { label = "Ondertitel \(subs.count)" } // Skip junk mime-like labels if label.uppercased().contains("TEXT/") || label.uppercased().hasPrefix("HTTP") { subs.append(PlayerTrack(id: idx, name: "Ondertitel \(subs.count)")) } else { subs.append(PlayerTrack(id: idx, name: label)) } } } subtitleTracks = subs currentSubtitleId = mediaPlayer.currentVideoSubTitleIndex var audios: [PlayerTrack] = [] 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 label: String if i < names.count, let n = names[i] as? String, !n.isEmpty { label = n } else { label = "Audio \(audios.count + 1)" } audios.append(PlayerTrack(id: idx, name: label)) } } audioTracks = audios currentAudioId = mediaPlayer.currentAudioTrackIndex } func stop() { tick?.cancel() tick = nil mediaPlayer.stop() mediaPlayer.media = nil } 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.refreshTracks() self.applySubtitleStyle() case .paused: self.stateText = "Pauze" case .stopped: self.stateText = "Gestopt" case .ended: self.stateText = "Einde" case .error: self.hasError = true self.errorMessage = "Kon dit bestand niet afspelen" self.stateText = "Fout" case .esAdded: self.refreshTracks() @unknown default: break } } } 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) } }