stremio/apps/ios/VMC/Playback/VLCPlayerController.swift
Jos Vooges | STH 89ceccb557 Restore working iOS 0.5.35 player and fix Odido redirected DASH on iOS (0.5.41).
Recovered the ClearKey AVPlayer/proxy stack from the Mac stash, keep later UI fixes, resolve DASH segments against the redirected manifest URL and XML-unescape MPD values.
2026-09-27 23:34:12 +02:00

1021 lines
38 KiB
Swift
Raw 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
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
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
}
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 mediaPlayer.drawable == nil {
applyDrawable(force: true)
}
}
/// 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 = !liveMode && !clearKeyProxyActive
usePipDrawable = wantPipHost
if wantPipHost {
drawableHost.attach(to: view)
if force || mediaPlayer.drawable as AnyObject? !== drawableHost {
mediaPlayer.drawable = drawableHost
}
if pipWindow != nil {
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) {
// Annuleer pending finishStop van eerdere/spurious onDisappear.
stopGeneration &+= 1
stopping = false
hasError = false
errorMessage = nil
stateText = "Verbinden…"
subtitleTracks = []
audioTracks = []
liveMode = false
clearKeyProxyActive = false
plainVideoView?.layoutIfNeeded()
applyDrawable(force: true)
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")
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
) 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()
liveMode = true
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
)
playURL = started.url
proxySession = started.session
// 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)
// 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()
}
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
) {
// Stabiele live-start: te lage caching + drop-late-frames liet streams meteen stoppen.
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)")
// IPTV/CDN's weigeren soms exotic UA's.
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) {
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) {
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
}
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.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 = 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) {
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)
}
}