stremio/apps/ios/VMC/Playback/VLCPlayerController.swift

1218 lines
47 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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)
}
}