1218 lines
47 KiB
Swift
1218 lines
47 KiB
Swift
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)
|
||
}
|
||
}
|