Fix ClearKey live/events on VLCKit 4 (adaptive demux, async proxy, no PiP vout).
This commit is contained in:
parent
878aa86121
commit
77ddc8b6a3
4 changed files with 179 additions and 107 deletions
|
|
@ -337,7 +337,7 @@
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 15;
|
CURRENT_PROJECT_VERSION = 16;
|
||||||
DEVELOPMENT_TEAM = X25NYX3K49;
|
DEVELOPMENT_TEAM = X25NYX3K49;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
|
|
@ -352,7 +352,7 @@
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks"
|
"@executable_path/Frameworks"
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 0.5.3;
|
MARKETING_VERSION = 0.5.4;
|
||||||
OTHER_LDFLAGS = ("$(inherited)", "-ObjC");
|
OTHER_LDFLAGS = ("$(inherited)", "-ObjC");
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = nl.vonas.mediacluster.ios;
|
PRODUCT_BUNDLE_IDENTIFIER = nl.vonas.mediacluster.ios;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
|
@ -370,7 +370,7 @@
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 15;
|
CURRENT_PROJECT_VERSION = 16;
|
||||||
DEVELOPMENT_TEAM = X25NYX3K49;
|
DEVELOPMENT_TEAM = X25NYX3K49;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
|
|
@ -385,7 +385,7 @@
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks"
|
"@executable_path/Frameworks"
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 0.5.3;
|
MARKETING_VERSION = 0.5.4;
|
||||||
OTHER_LDFLAGS = ("$(inherited)", "-ObjC");
|
OTHER_LDFLAGS = ("$(inherited)", "-ObjC");
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = nl.vonas.mediacluster.ios;
|
PRODUCT_BUNDLE_IDENTIFIER = nl.vonas.mediacluster.ios;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
|
|
||||||
|
|
@ -31,39 +31,56 @@ final class ClearKeyDashProxy {
|
||||||
|
|
||||||
/// Start (of herstart) proxy voor dit manifest + keys. Geeft lokale MPD-URL terug.
|
/// Start (of herstart) proxy voor dit manifest + keys. Geeft lokale MPD-URL terug.
|
||||||
/// Warmt de MPD-fetch op zodat VLC niet meteen een lege/fout-response krijgt.
|
/// Warmt de MPD-fetch op zodat VLC niet meteen een lege/fout-response krijgt.
|
||||||
func start(manifestURL: URL, keys: [ClearKeyEntry]) throws -> URL {
|
func start(manifestURL: URL, keys: [ClearKeyEntry]) async throws -> URL {
|
||||||
let map = ClearKeyKeyMap(entries: keys)
|
let map = ClearKeyKeyMap(entries: keys)
|
||||||
guard !map.isEmpty else {
|
guard !map.isEmpty else {
|
||||||
throw ProxyError.noKeys
|
throw ProxyError.noKeys
|
||||||
}
|
}
|
||||||
stop()
|
|
||||||
remoteManifest = manifestURL
|
|
||||||
keyMap = map
|
|
||||||
cryptoState = CencMp4Decryptor.SessionState()
|
|
||||||
generation += 1
|
|
||||||
let gen = generation
|
|
||||||
|
|
||||||
|
let boundPort: UInt16 = try await withCheckedThrowingContinuation { cont in
|
||||||
|
queue.async {
|
||||||
|
self.stopLocked()
|
||||||
|
self.remoteManifest = manifestURL
|
||||||
|
self.keyMap = map
|
||||||
|
self.cryptoState = CencMp4Decryptor.SessionState()
|
||||||
|
self.generation += 1
|
||||||
|
|
||||||
|
final class ResumeOnce: @unchecked Sendable {
|
||||||
|
private var done = false
|
||||||
|
private let lock = NSLock()
|
||||||
|
func run(_ body: () -> Void) {
|
||||||
|
lock.lock()
|
||||||
|
defer { lock.unlock() }
|
||||||
|
guard !done else { return }
|
||||||
|
done = true
|
||||||
|
body()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let once = ResumeOnce()
|
||||||
|
|
||||||
|
do {
|
||||||
let params = NWParameters.tcp
|
let params = NWParameters.tcp
|
||||||
params.allowLocalEndpointReuse = true
|
params.allowLocalEndpointReuse = true
|
||||||
let listener = try NWListener(using: params, on: .any)
|
let listener = try NWListener(using: params, on: .any)
|
||||||
self.listener = listener
|
self.listener = listener
|
||||||
|
|
||||||
let ready = DispatchSemaphore(value: 0)
|
|
||||||
var readyError: Error?
|
|
||||||
|
|
||||||
listener.stateUpdateHandler = { [weak self] state in
|
listener.stateUpdateHandler = { [weak self] state in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
switch state {
|
switch state {
|
||||||
case .ready:
|
case .ready:
|
||||||
if let p = listener.port?.rawValue {
|
let p = listener.port?.rawValue ?? 0
|
||||||
self.port = p
|
self.port = p
|
||||||
|
proxyLog.info("listener ready on port \(p, privacy: .public)")
|
||||||
|
once.run {
|
||||||
|
if p > 0 {
|
||||||
|
cont.resume(returning: p)
|
||||||
|
} else {
|
||||||
|
cont.resume(throwing: ProxyError.bindFailed)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
proxyLog.info("listener ready on port \(self.port, privacy: .public)")
|
|
||||||
ready.signal()
|
|
||||||
case .failed(let err):
|
case .failed(let err):
|
||||||
proxyLog.error("listener failed: \(String(describing: err), privacy: .public)")
|
proxyLog.error("listener failed: \(String(describing: err), privacy: .public)")
|
||||||
readyError = err
|
once.run { cont.resume(throwing: err) }
|
||||||
ready.signal()
|
|
||||||
default:
|
default:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
@ -72,36 +89,38 @@ final class ClearKeyDashProxy {
|
||||||
listener.newConnectionHandler = { [weak self] conn in
|
listener.newConnectionHandler = { [weak self] conn in
|
||||||
self?.handle(connection: conn)
|
self?.handle(connection: conn)
|
||||||
}
|
}
|
||||||
|
listener.start(queue: self.queue)
|
||||||
|
|
||||||
listener.start(queue: queue)
|
self.queue.asyncAfter(deadline: .now() + 5) {
|
||||||
_ = ready.wait(timeout: .now() + 5)
|
once.run { cont.resume(throwing: ProxyError.bindFailed) }
|
||||||
if let readyError { throw readyError }
|
}
|
||||||
guard port > 0 else { throw ProxyError.bindFailed }
|
} catch {
|
||||||
|
once.run { cont.resume(throwing: error) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Warm-up: MPD ophalen + herschrijven vóór VLC start (live dynamic MPD's falen anders meteen).
|
guard boundPort > 0 else { throw ProxyError.bindFailed }
|
||||||
let warm = DispatchSemaphore(value: 0)
|
let gen = generation
|
||||||
var warmError: Error?
|
|
||||||
Task {
|
let (body, _) = try await payload(for: "/manifest.mpd")
|
||||||
do {
|
guard !body.isEmpty, gen == generation else {
|
||||||
let (body, _) = try await self.payload(for: "/manifest.mpd")
|
|
||||||
guard !body.isEmpty, gen == self.generation else {
|
|
||||||
throw ProxyError.emptyManifest
|
throw ProxyError.emptyManifest
|
||||||
}
|
}
|
||||||
let preview = String(data: body.prefix(200), encoding: .utf8) ?? ""
|
let preview = String(data: body.prefix(200), encoding: .utf8) ?? ""
|
||||||
proxyLog.info("warmup MPD ok bytes=\(body.count, privacy: .public) head=\(preview, privacy: .public)")
|
proxyLog.info("warmup MPD ok bytes=\(body.count, privacy: .public) head=\(preview, privacy: .public)")
|
||||||
} catch {
|
|
||||||
warmError = error
|
|
||||||
proxyLog.error("warmup failed: \(error.localizedDescription, privacy: .public)")
|
|
||||||
}
|
|
||||||
warm.signal()
|
|
||||||
}
|
|
||||||
_ = warm.wait(timeout: .now() + 20)
|
|
||||||
if let warmError { throw warmError }
|
|
||||||
|
|
||||||
return URL(string: "http://127.0.0.1:\(port)/manifest.mpd")!
|
guard let local = URL(string: "http://127.0.0.1:\(boundPort)/manifest.mpd") else {
|
||||||
|
throw ProxyError.bindFailed
|
||||||
|
}
|
||||||
|
return local
|
||||||
}
|
}
|
||||||
|
|
||||||
func stop() {
|
func stop() {
|
||||||
|
queue.sync { stopLocked() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func stopLocked() {
|
||||||
generation += 1
|
generation += 1
|
||||||
listener?.cancel()
|
listener?.cancel()
|
||||||
listener = nil
|
listener = nil
|
||||||
|
|
@ -303,7 +322,7 @@ final class ClearKeyDashProxy {
|
||||||
var req = URLRequest(url: url)
|
var req = URLRequest(url: url)
|
||||||
req.timeoutInterval = 25
|
req.timeoutInterval = 25
|
||||||
req.cachePolicy = .reloadIgnoringLocalCacheData
|
req.cachePolicy = .reloadIgnoringLocalCacheData
|
||||||
req.setValue("VMC-iOS-ClearKey/0.5.2", forHTTPHeaderField: "User-Agent")
|
req.setValue("VMC-iOS-ClearKey/0.5.4", forHTTPHeaderField: "User-Agent")
|
||||||
req.setValue("bytes", forHTTPHeaderField: "Accept-Ranges")
|
req.setValue("bytes", forHTTPHeaderField: "Accept-Ranges")
|
||||||
let (data, resp) = try await urlSession.data(for: req)
|
let (data, resp) = try await urlSession.data(for: req)
|
||||||
if let http = resp as? HTTPURLResponse, !(200...299).contains(http.statusCode) {
|
if let http = resp as? HTTPURLResponse, !(200...299).contains(http.statusCode) {
|
||||||
|
|
|
||||||
|
|
@ -401,14 +401,14 @@ struct PlayerScreen: View {
|
||||||
error = nil
|
error = nil
|
||||||
if request.isLive {
|
if request.isLive {
|
||||||
let keys = request.needsClearKey ? (request.drm?.keys ?? []) : []
|
let keys = request.needsClearKey ? (request.drm?.keys ?? []) : []
|
||||||
player.playLive(
|
await player.playLive(
|
||||||
url: request.url,
|
url: request.url,
|
||||||
fallback: request.fallbackUrl,
|
fallback: request.fallbackUrl,
|
||||||
format: request.format,
|
format: request.format,
|
||||||
clearKeys: keys
|
clearKeys: keys
|
||||||
)
|
)
|
||||||
// ClearKey/DASH: proxy-warmup + init/media + eventuele retry.
|
// ClearKey/DASH: proxy-warmup + init/media + eventuele retry.
|
||||||
let waitNs: UInt64 = request.needsClearKey ? 10_000_000_000 : 2_500_000_000
|
let waitNs: UInt64 = request.needsClearKey ? 12_000_000_000 : 2_500_000_000
|
||||||
try? await Task.sleep(nanoseconds: waitNs)
|
try? await Task.sleep(nanoseconds: waitNs)
|
||||||
if request.needsClearKey && (player.hasError || !player.isPlaying) {
|
if request.needsClearKey && (player.hasError || !player.isPlaying) {
|
||||||
try? await Task.sleep(nanoseconds: 6_000_000_000)
|
try? await Task.sleep(nanoseconds: 6_000_000_000)
|
||||||
|
|
@ -423,8 +423,10 @@ struct PlayerScreen: View {
|
||||||
}
|
}
|
||||||
try? await Task.sleep(nanoseconds: 400_000_000)
|
try? await Task.sleep(nanoseconds: 400_000_000)
|
||||||
loading = false
|
loading = false
|
||||||
|
if !player.hasError {
|
||||||
player.refreshTracks()
|
player.refreshTracks()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func startHeartbeatIfNeeded() {
|
private func startHeartbeatIfNeeded() {
|
||||||
heartbeatTask?.cancel()
|
heartbeatTask?.cancel()
|
||||||
|
|
|
||||||
|
|
@ -30,9 +30,12 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
|
||||||
private let library: VLCLibrary
|
private let library: VLCLibrary
|
||||||
let mediaPlayer: VLCMediaPlayer
|
let mediaPlayer: VLCMediaPlayer
|
||||||
private let drawableHost = VLCPipDrawableHost()
|
private let drawableHost = VLCPipDrawableHost()
|
||||||
|
/// Plain UIView-target voor live/ClearKey (PiP sample-buffer-vout breekt DASH/proxy).
|
||||||
|
private weak var plainVideoView: UIView?
|
||||||
private var lengthMs: Int32 = 0
|
private var lengthMs: Int32 = 0
|
||||||
private var tick: AnyCancellable?
|
private var tick: AnyCancellable?
|
||||||
private var clearKeyProxyActive = false
|
private var clearKeyProxyActive = false
|
||||||
|
private var liveMode = false
|
||||||
private var livePlayURL: URL?
|
private var livePlayURL: URL?
|
||||||
private var livePlayFormat: String?
|
private var livePlayFormat: String?
|
||||||
private var liveStartedAt: Date?
|
private var liveStartedAt: Date?
|
||||||
|
|
@ -42,13 +45,12 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
|
||||||
private var sceneObserver: NSObjectProtocol?
|
private var sceneObserver: NSObjectProtocol?
|
||||||
|
|
||||||
override init() {
|
override init() {
|
||||||
// Eigen library-instance zodat opties/PiP-vout stabiel blijven.
|
// Shared library = alle demux/access plugins (DASH adaptive in VLC 4).
|
||||||
library = VLCLibrary(options: [])
|
library = VLCLibrary.sharedLibrary()
|
||||||
mediaPlayer = VLCMediaPlayer(library: library)
|
mediaPlayer = VLCMediaPlayer(library: library)
|
||||||
super.init()
|
super.init()
|
||||||
mediaPlayer.delegate = self
|
mediaPlayer.delegate = self
|
||||||
drawableHost.controller = self
|
drawableHost.controller = self
|
||||||
mediaPlayer.drawable = drawableHost
|
|
||||||
configureAudioSession()
|
configureAudioSession()
|
||||||
observeSceneForAutoPip()
|
observeSceneForAutoPip()
|
||||||
}
|
}
|
||||||
|
|
@ -60,9 +62,23 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
|
||||||
}
|
}
|
||||||
|
|
||||||
func attach(to view: UIView) {
|
func attach(to view: UIView) {
|
||||||
|
plainVideoView = view
|
||||||
|
view.backgroundColor = .black
|
||||||
|
applyDrawable()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Live/ClearKey → plain UIView. VOD → PiP-drawable (AVSampleBuffer-pad).
|
||||||
|
private func applyDrawable() {
|
||||||
|
guard let view = plainVideoView else { return }
|
||||||
|
if liveMode || clearKeyProxyActive {
|
||||||
|
pipPossible = false
|
||||||
|
drawableHost.detachVideo()
|
||||||
|
mediaPlayer.drawable = view
|
||||||
|
} else {
|
||||||
drawableHost.attach(to: view)
|
drawableHost.attach(to: view)
|
||||||
mediaPlayer.drawable = drawableHost
|
mediaPlayer.drawable = drawableHost
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func startPictureInPicture() {
|
func startPictureInPicture() {
|
||||||
pipWindow?.startPictureInPicture()
|
pipWindow?.startPictureInPicture()
|
||||||
|
|
@ -83,7 +99,15 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
|
||||||
stateText = "Verbinden…"
|
stateText = "Verbinden…"
|
||||||
subtitleTracks = []
|
subtitleTracks = []
|
||||||
audioTracks = []
|
audioTracks = []
|
||||||
let media = VLCMedia(url: url)
|
liveMode = false
|
||||||
|
clearKeyProxyActive = false
|
||||||
|
applyDrawable()
|
||||||
|
guard let media = VLCMedia(url: url) else {
|
||||||
|
hasError = true
|
||||||
|
errorMessage = "Kon media niet openen."
|
||||||
|
stateText = "Fout"
|
||||||
|
return
|
||||||
|
}
|
||||||
media.delegate = self
|
media.delegate = self
|
||||||
media.addOption(":network-caching=2500")
|
media.addOption(":network-caching=2500")
|
||||||
media.addOption(":http-reconnect")
|
media.addOption(":http-reconnect")
|
||||||
|
|
@ -118,13 +142,14 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
|
||||||
fallback: URL? = nil,
|
fallback: URL? = nil,
|
||||||
format: String? = nil,
|
format: String? = nil,
|
||||||
clearKeys: [ClearKeyEntry] = []
|
clearKeys: [ClearKeyEntry] = []
|
||||||
) {
|
) async {
|
||||||
hasError = false
|
hasError = false
|
||||||
errorMessage = nil
|
errorMessage = nil
|
||||||
stateText = "Live laden…"
|
stateText = "Live laden…"
|
||||||
liveRetryCount = 0
|
liveRetryCount = 0
|
||||||
everPlayed = false
|
everPlayed = false
|
||||||
liveStartedAt = Date()
|
liveStartedAt = Date()
|
||||||
|
liveMode = true
|
||||||
|
|
||||||
var playURL = url
|
var playURL = url
|
||||||
var playFormat = format
|
var playFormat = format
|
||||||
|
|
@ -132,11 +157,11 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
|
||||||
|
|
||||||
if !clearKeys.isEmpty {
|
if !clearKeys.isEmpty {
|
||||||
do {
|
do {
|
||||||
// VLCKit speelt geen CENC/ClearKey zelf; lokale proxy decrypt segmenten.
|
stateText = "ClearKey laden…"
|
||||||
playURL = try ClearKeyDashProxy.shared.start(manifestURL: url, keys: clearKeys)
|
// Async: blokkeer de main thread niet (watchdog-crash).
|
||||||
|
playURL = try await ClearKeyDashProxy.shared.start(manifestURL: url, keys: clearKeys)
|
||||||
playFormat = "dash"
|
playFormat = "dash"
|
||||||
clearKeyProxyActive = true
|
clearKeyProxyActive = true
|
||||||
stateText = "ClearKey laden…"
|
|
||||||
} catch {
|
} catch {
|
||||||
hasError = true
|
hasError = true
|
||||||
errorMessage = dutchClearKeyError(error)
|
errorMessage = dutchClearKeyError(error)
|
||||||
|
|
@ -145,6 +170,7 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
applyDrawable()
|
||||||
livePlayURL = playURL
|
livePlayURL = playURL
|
||||||
livePlayFormat = playFormat
|
livePlayFormat = playFormat
|
||||||
startLiveMedia(url: playURL, format: playFormat)
|
startLiveMedia(url: playURL, format: playFormat)
|
||||||
|
|
@ -159,7 +185,12 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
|
||||||
}
|
}
|
||||||
|
|
||||||
private func startLiveMedia(url: URL, format: String?) {
|
private func startLiveMedia(url: URL, format: String?) {
|
||||||
let media = VLCMedia(url: url)
|
guard let media = VLCMedia(url: url) else {
|
||||||
|
hasError = true
|
||||||
|
errorMessage = "Kon live-stream niet openen."
|
||||||
|
stateText = "Fout"
|
||||||
|
return
|
||||||
|
}
|
||||||
media.delegate = self
|
media.delegate = self
|
||||||
Self.applyLiveOptions(to: media, url: url, format: format)
|
Self.applyLiveOptions(to: media, url: url, format: format)
|
||||||
mediaPlayer.media = media
|
mediaPlayer.media = media
|
||||||
|
|
@ -204,22 +235,19 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
|
||||||
url: URL,
|
url: URL,
|
||||||
format: String?
|
format: String?
|
||||||
) {
|
) {
|
||||||
media.addOption(":network-caching=1200")
|
media.addOption(":network-caching=1800")
|
||||||
media.addOption(":http-reconnect")
|
media.addOption(":http-reconnect")
|
||||||
media.addOption(":live-caching=1000")
|
media.addOption(":live-caching=1500")
|
||||||
media.addOption(":freetype-rel-fontsize=\(subtitleRelFontSize)")
|
media.addOption(":freetype-rel-fontsize=\(subtitleRelFontSize)")
|
||||||
|
// VLC 4: unified adaptive demux (DASH+HLS). Forceer GEEN :demux=dash (VLC3-module).
|
||||||
|
media.addOption(":adaptive-logic=highest")
|
||||||
|
media.addOption(":adaptive-use-access")
|
||||||
|
|
||||||
let fmt = (format ?? "").lowercased()
|
let fmt = (format ?? "").lowercased()
|
||||||
let path = url.absoluteString.lowercased()
|
let path = url.absoluteString.lowercased()
|
||||||
let isDash = fmt == "dash" || path.contains(".mpd")
|
|
||||||
let isHls = fmt == "hls" || path.contains(".m3u8") || path.contains("m3u8")
|
let isHls = fmt == "hls" || path.contains(".m3u8") || path.contains("m3u8")
|
||||||
|
if isHls {
|
||||||
if isDash {
|
// HLS mag nog steeds via adaptive; geen aparte demux forceren.
|
||||||
media.addOption(":demux=dash")
|
|
||||||
media.addOption(":adaptive-logic=highest")
|
|
||||||
media.addOption(":adaptive-use-access")
|
|
||||||
} else if isHls {
|
|
||||||
media.addOption(":demux=hls")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -284,8 +312,18 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
|
||||||
}
|
}
|
||||||
|
|
||||||
func refreshTracks() {
|
func refreshTracks() {
|
||||||
|
// VLCKit 4 kan crashen op track-API tijdens error/opening — altijd defensief.
|
||||||
|
guard 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 subs: [PlayerTrack] = [PlayerTrack(id: PlayerTrack.offId, name: "Uit")]
|
||||||
for (index, track) in mediaPlayer.textTracks.enumerated() {
|
var selectedSub = PlayerTrack.offId
|
||||||
|
for (index, track) in textList.enumerated() {
|
||||||
let label = Self.displayTrackLabel(
|
let label = Self.displayTrackLabel(
|
||||||
rawName: track.trackName,
|
rawName: track.trackName,
|
||||||
fallbackIndex: index + 1,
|
fallbackIndex: index + 1,
|
||||||
|
|
@ -294,15 +332,14 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
|
||||||
descriptionHint: track.trackDescription
|
descriptionHint: track.trackDescription
|
||||||
)
|
)
|
||||||
subs.append(PlayerTrack(id: track.trackId, name: label))
|
subs.append(PlayerTrack(id: track.trackId, name: label))
|
||||||
if track.isSelected { currentSubtitleId = track.trackId }
|
if track.isSelected { selectedSub = track.trackId }
|
||||||
}
|
|
||||||
if !mediaPlayer.textTracks.contains(where: \.isSelected) {
|
|
||||||
currentSubtitleId = PlayerTrack.offId
|
|
||||||
}
|
}
|
||||||
subtitleTracks = subs
|
subtitleTracks = subs
|
||||||
|
currentSubtitleId = selectedSub
|
||||||
|
|
||||||
var audios: [PlayerTrack] = []
|
var audios: [PlayerTrack] = []
|
||||||
for (index, track) in mediaPlayer.audioTracks.enumerated() {
|
var selectedAudio = currentAudioId
|
||||||
|
for (index, track) in audioList.enumerated() {
|
||||||
let label = Self.displayTrackLabel(
|
let label = Self.displayTrackLabel(
|
||||||
rawName: track.trackName,
|
rawName: track.trackName,
|
||||||
fallbackIndex: index + 1,
|
fallbackIndex: index + 1,
|
||||||
|
|
@ -311,12 +348,13 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
|
||||||
descriptionHint: track.trackDescription
|
descriptionHint: track.trackDescription
|
||||||
)
|
)
|
||||||
audios.append(PlayerTrack(id: track.trackId, name: label))
|
audios.append(PlayerTrack(id: track.trackId, name: label))
|
||||||
if track.isSelected { currentAudioId = track.trackId }
|
if track.isSelected { selectedAudio = track.trackId }
|
||||||
}
|
}
|
||||||
if currentAudioId.isEmpty, let first = audios.first {
|
if selectedAudio.isEmpty, let first = audios.first {
|
||||||
currentAudioId = first.id
|
selectedAudio = first.id
|
||||||
}
|
}
|
||||||
audioTracks = audios
|
audioTracks = audios
|
||||||
|
currentAudioId = selectedAudio
|
||||||
}
|
}
|
||||||
|
|
||||||
private enum TrackKind { case subtitle, audio }
|
private enum TrackKind { case subtitle, audio }
|
||||||
|
|
@ -432,7 +470,9 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
|
||||||
func stop() {
|
func stop() {
|
||||||
tick?.cancel()
|
tick?.cancel()
|
||||||
tick = nil
|
tick = nil
|
||||||
|
if pipActive {
|
||||||
stopPictureInPicture()
|
stopPictureInPicture()
|
||||||
|
}
|
||||||
mediaPlayer.stop()
|
mediaPlayer.stop()
|
||||||
mediaPlayer.media = nil
|
mediaPlayer.media = nil
|
||||||
livePlayURL = nil
|
livePlayURL = nil
|
||||||
|
|
@ -440,6 +480,10 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
|
||||||
liveStartedAt = nil
|
liveStartedAt = nil
|
||||||
liveRetryCount = 0
|
liveRetryCount = 0
|
||||||
everPlayed = false
|
everPlayed = false
|
||||||
|
liveMode = false
|
||||||
|
pipWindow = nil
|
||||||
|
pipPossible = false
|
||||||
|
pipActive = false
|
||||||
if clearKeyProxyActive {
|
if clearKeyProxyActive {
|
||||||
ClearKeyDashProxy.shared.stop()
|
ClearKeyDashProxy.shared.stop()
|
||||||
clearKeyProxyActive = false
|
clearKeyProxyActive = false
|
||||||
|
|
@ -452,7 +496,9 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
|
||||||
DispatchQueue.main.async { [weak self] in
|
DispatchQueue.main.async { [weak self] in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
self.isPlaying = self.mediaPlayer.isPlaying
|
self.isPlaying = self.mediaPlayer.isPlaying
|
||||||
|
if self.pipPossible {
|
||||||
self.invalidatePipPlaybackState()
|
self.invalidatePipPlaybackState()
|
||||||
|
}
|
||||||
switch newState {
|
switch newState {
|
||||||
case .buffering: self.stateText = "Bufferen…"
|
case .buffering: self.stateText = "Bufferen…"
|
||||||
case .opening: self.stateText = "Openen…"
|
case .opening: self.stateText = "Openen…"
|
||||||
|
|
@ -468,7 +514,7 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
|
||||||
if self.clearKeyProxyActive && !self.everPlayed {
|
if self.clearKeyProxyActive && !self.everPlayed {
|
||||||
self.stateText = "Verbinden…"
|
self.stateText = "Verbinden…"
|
||||||
self.retryLiveIfNeeded()
|
self.retryLiveIfNeeded()
|
||||||
} else {
|
} else if !self.hasError {
|
||||||
self.stateText = "Gestopt"
|
self.stateText = "Gestopt"
|
||||||
}
|
}
|
||||||
case .error:
|
case .error:
|
||||||
|
|
@ -606,7 +652,12 @@ final class VLCPipDrawableHost: NSObject, VLCDrawable, VLCPictureInPictureDrawab
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// VLCDrawable
|
func detachVideo() {
|
||||||
|
videoSubview?.removeFromSuperview()
|
||||||
|
// Houd referentie; VLC kan hem opnieuw addSubview'en.
|
||||||
|
}
|
||||||
|
|
||||||
|
// VLCDrawable (VLCKit 4: methods, not properties)
|
||||||
func addSubview(_ view: UIView) {
|
func addSubview(_ view: UIView) {
|
||||||
videoSubview = view
|
videoSubview = view
|
||||||
guard let container else { return }
|
guard let container else { return }
|
||||||
|
|
@ -618,15 +669,16 @@ final class VLCPipDrawableHost: NSObject, VLCDrawable, VLCPictureInPictureDrawab
|
||||||
view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||||||
}
|
}
|
||||||
|
|
||||||
var bounds: CGRect {
|
func bounds() -> CGRect {
|
||||||
container?.bounds ?? .zero
|
container?.bounds ?? .zero
|
||||||
}
|
}
|
||||||
|
|
||||||
// VLCPictureInPictureDrawable
|
// VLCPictureInPictureDrawable
|
||||||
var mediaController: VLCPictureInPictureMediaControlling { self }
|
func mediaController() -> (any VLCPictureInPictureMediaControlling)? { self }
|
||||||
|
|
||||||
func pictureInPictureReady() -> (VLCPictureInPictureWindowControlling) -> Void {
|
func pictureInPictureReady() -> (((any VLCPictureInPictureWindowControlling)?) -> Void)? {
|
||||||
{ [weak self] pip in
|
{ [weak self] pip in
|
||||||
|
guard let pip else { return }
|
||||||
self?.controller?.onPictureInPictureReady(pip)
|
self?.controller?.onPictureInPictureReady(pip)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -640,13 +692,12 @@ final class VLCPipDrawableHost: NSObject, VLCDrawable, VLCPictureInPictureDrawab
|
||||||
controller?.mediaPlayer.pause()
|
controller?.mediaPlayer.pause()
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc(seekBy:completion:)
|
func seek(by offset: Int64, completion: @escaping () -> Void) {
|
||||||
func seekBy(_ offset: Int64, completion: @escaping () -> Void) {
|
|
||||||
guard let player = controller?.mediaPlayer else {
|
guard let player = controller?.mediaPlayer else {
|
||||||
completion()
|
completion()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
let ok = player.jump(withOffset: Int(offset), completion: completion)
|
let ok = player.jump(withOffset: Int32(clamping: offset), completion: completion)
|
||||||
if !ok { completion() }
|
if !ok { completion() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue