iOS only sent that UA when the hostname looked like viaplay; stored MPDs often do not. Event plays now pin the browser session for the whole fetch chain, still direct to the CDN.
1216 lines
47 KiB
Swift
1216 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
|
||
) 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 = 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,
|
||
browserCdnHeaders: browserCdnHeaders
|
||
)
|
||
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)
|
||
}
|
||
}
|