diff --git a/apps/ios/VMC.xcodeproj/project.pbxproj b/apps/ios/VMC.xcodeproj/project.pbxproj index caa9d6d..d52f4f5 100644 --- a/apps/ios/VMC.xcodeproj/project.pbxproj +++ b/apps/ios/VMC.xcodeproj/project.pbxproj @@ -337,7 +337,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 15; + CURRENT_PROJECT_VERSION = 16; DEVELOPMENT_TEAM = X25NYX3K49; ENABLE_BITCODE = NO; ENABLE_PREVIEWS = YES; @@ -352,7 +352,7 @@ "$(inherited)", "@executable_path/Frameworks" ); - MARKETING_VERSION = 0.5.3; + MARKETING_VERSION = 0.5.4; OTHER_LDFLAGS = ("$(inherited)", "-ObjC"); PRODUCT_BUNDLE_IDENTIFIER = nl.vonas.mediacluster.ios; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -370,7 +370,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 15; + CURRENT_PROJECT_VERSION = 16; DEVELOPMENT_TEAM = X25NYX3K49; ENABLE_BITCODE = NO; ENABLE_PREVIEWS = YES; @@ -385,7 +385,7 @@ "$(inherited)", "@executable_path/Frameworks" ); - MARKETING_VERSION = 0.5.3; + MARKETING_VERSION = 0.5.4; OTHER_LDFLAGS = ("$(inherited)", "-ObjC"); PRODUCT_BUNDLE_IDENTIFIER = nl.vonas.mediacluster.ios; PRODUCT_NAME = "$(TARGET_NAME)"; diff --git a/apps/ios/VMC/Playback/ClearKeyDashProxy.swift b/apps/ios/VMC/Playback/ClearKeyDashProxy.swift index 820c06c..4a98a94 100644 --- a/apps/ios/VMC/Playback/ClearKeyDashProxy.swift +++ b/apps/ios/VMC/Playback/ClearKeyDashProxy.swift @@ -31,77 +31,96 @@ final class ClearKeyDashProxy { /// 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. - func start(manifestURL: URL, keys: [ClearKeyEntry]) throws -> URL { + func start(manifestURL: URL, keys: [ClearKeyEntry]) async throws -> URL { let map = ClearKeyKeyMap(entries: keys) guard !map.isEmpty else { throw ProxyError.noKeys } - stop() - remoteManifest = manifestURL - keyMap = map - cryptoState = CencMp4Decryptor.SessionState() - generation += 1 + + 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 + params.allowLocalEndpointReuse = true + let listener = try NWListener(using: params, on: .any) + self.listener = listener + + listener.stateUpdateHandler = { [weak self] state in + guard let self else { return } + switch state { + case .ready: + let p = listener.port?.rawValue ?? 0 + 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) + } + } + case .failed(let err): + proxyLog.error("listener failed: \(String(describing: err), privacy: .public)") + once.run { cont.resume(throwing: err) } + default: + break + } + } + + listener.newConnectionHandler = { [weak self] conn in + self?.handle(connection: conn) + } + listener.start(queue: self.queue) + + self.queue.asyncAfter(deadline: .now() + 5) { + once.run { cont.resume(throwing: ProxyError.bindFailed) } + } + } catch { + once.run { cont.resume(throwing: error) } + } + } + } + + guard boundPort > 0 else { throw ProxyError.bindFailed } let gen = generation - let params = NWParameters.tcp - params.allowLocalEndpointReuse = true - let listener = try NWListener(using: params, on: .any) - self.listener = listener - - let ready = DispatchSemaphore(value: 0) - var readyError: Error? - - listener.stateUpdateHandler = { [weak self] state in - guard let self else { return } - switch state { - case .ready: - if let p = listener.port?.rawValue { - self.port = p - } - proxyLog.info("listener ready on port \(self.port, privacy: .public)") - ready.signal() - case .failed(let err): - proxyLog.error("listener failed: \(String(describing: err), privacy: .public)") - readyError = err - ready.signal() - default: - break - } + let (body, _) = try await payload(for: "/manifest.mpd") + guard !body.isEmpty, gen == generation else { + throw ProxyError.emptyManifest } + let preview = String(data: body.prefix(200), encoding: .utf8) ?? "" + proxyLog.info("warmup MPD ok bytes=\(body.count, privacy: .public) head=\(preview, privacy: .public)") - listener.newConnectionHandler = { [weak self] conn in - self?.handle(connection: conn) + guard let local = URL(string: "http://127.0.0.1:\(boundPort)/manifest.mpd") else { + throw ProxyError.bindFailed } - - listener.start(queue: queue) - _ = ready.wait(timeout: .now() + 5) - if let readyError { throw readyError } - guard port > 0 else { throw ProxyError.bindFailed } - - // Warm-up: MPD ophalen + herschrijven vóór VLC start (live dynamic MPD's falen anders meteen). - let warm = DispatchSemaphore(value: 0) - var warmError: Error? - Task { - do { - let (body, _) = try await self.payload(for: "/manifest.mpd") - guard !body.isEmpty, gen == self.generation else { - throw ProxyError.emptyManifest - } - let preview = String(data: body.prefix(200), encoding: .utf8) ?? "" - 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")! + return local } func stop() { + queue.sync { stopLocked() } + } + + private func stopLocked() { generation += 1 listener?.cancel() listener = nil @@ -303,7 +322,7 @@ final class ClearKeyDashProxy { var req = URLRequest(url: url) req.timeoutInterval = 25 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") let (data, resp) = try await urlSession.data(for: req) if let http = resp as? HTTPURLResponse, !(200...299).contains(http.statusCode) { diff --git a/apps/ios/VMC/Playback/PlayerScreen.swift b/apps/ios/VMC/Playback/PlayerScreen.swift index 8b5d2cd..6d9207c 100644 --- a/apps/ios/VMC/Playback/PlayerScreen.swift +++ b/apps/ios/VMC/Playback/PlayerScreen.swift @@ -401,14 +401,14 @@ struct PlayerScreen: View { error = nil if request.isLive { let keys = request.needsClearKey ? (request.drm?.keys ?? []) : [] - player.playLive( + await player.playLive( url: request.url, fallback: request.fallbackUrl, format: request.format, clearKeys: keys ) // 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) if request.needsClearKey && (player.hasError || !player.isPlaying) { try? await Task.sleep(nanoseconds: 6_000_000_000) @@ -423,7 +423,9 @@ struct PlayerScreen: View { } try? await Task.sleep(nanoseconds: 400_000_000) loading = false - player.refreshTracks() + if !player.hasError { + player.refreshTracks() + } } private func startHeartbeatIfNeeded() { diff --git a/apps/ios/VMC/Playback/VLCPlayerController.swift b/apps/ios/VMC/Playback/VLCPlayerController.swift index c137c71..8dbbe16 100644 --- a/apps/ios/VMC/Playback/VLCPlayerController.swift +++ b/apps/ios/VMC/Playback/VLCPlayerController.swift @@ -30,9 +30,12 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg 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? private var lengthMs: Int32 = 0 private var tick: AnyCancellable? private var clearKeyProxyActive = false + private var liveMode = false private var livePlayURL: URL? private var livePlayFormat: String? private var liveStartedAt: Date? @@ -42,13 +45,12 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg private var sceneObserver: NSObjectProtocol? override init() { - // Eigen library-instance zodat opties/PiP-vout stabiel blijven. - library = VLCLibrary(options: []) + // Shared library = alle demux/access plugins (DASH adaptive in VLC 4). + library = VLCLibrary.sharedLibrary() mediaPlayer = VLCMediaPlayer(library: library) super.init() mediaPlayer.delegate = self drawableHost.controller = self - mediaPlayer.drawable = drawableHost configureAudioSession() observeSceneForAutoPip() } @@ -60,8 +62,22 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg } func attach(to view: UIView) { - drawableHost.attach(to: view) - mediaPlayer.drawable = drawableHost + 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) + mediaPlayer.drawable = drawableHost + } } func startPictureInPicture() { @@ -83,7 +99,15 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg stateText = "Verbinden…" subtitleTracks = [] 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.addOption(":network-caching=2500") media.addOption(":http-reconnect") @@ -118,13 +142,14 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg fallback: URL? = nil, format: String? = nil, clearKeys: [ClearKeyEntry] = [] - ) { + ) async { hasError = false errorMessage = nil stateText = "Live laden…" liveRetryCount = 0 everPlayed = false liveStartedAt = Date() + liveMode = true var playURL = url var playFormat = format @@ -132,11 +157,11 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg if !clearKeys.isEmpty { do { - // VLCKit speelt geen CENC/ClearKey zelf; lokale proxy decrypt segmenten. - playURL = try ClearKeyDashProxy.shared.start(manifestURL: url, keys: clearKeys) + stateText = "ClearKey laden…" + // Async: blokkeer de main thread niet (watchdog-crash). + playURL = try await ClearKeyDashProxy.shared.start(manifestURL: url, keys: clearKeys) playFormat = "dash" clearKeyProxyActive = true - stateText = "ClearKey laden…" } catch { hasError = true errorMessage = dutchClearKeyError(error) @@ -145,6 +170,7 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg } } + applyDrawable() livePlayURL = playURL livePlayFormat = playFormat startLiveMedia(url: playURL, format: playFormat) @@ -159,7 +185,12 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg } 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 Self.applyLiveOptions(to: media, url: url, format: format) mediaPlayer.media = media @@ -204,22 +235,19 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg url: URL, format: String? ) { - media.addOption(":network-caching=1200") + media.addOption(":network-caching=1800") media.addOption(":http-reconnect") - media.addOption(":live-caching=1000") + media.addOption(":live-caching=1500") 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 path = url.absoluteString.lowercased() - let isDash = fmt == "dash" || path.contains(".mpd") let isHls = fmt == "hls" || path.contains(".m3u8") || path.contains("m3u8") - - if isDash { - media.addOption(":demux=dash") - media.addOption(":adaptive-logic=highest") - media.addOption(":adaptive-use-access") - } else if isHls { - media.addOption(":demux=hls") + if isHls { + // HLS mag nog steeds via adaptive; geen aparte demux forceren. } } @@ -284,8 +312,18 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg } 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")] - for (index, track) in mediaPlayer.textTracks.enumerated() { + var selectedSub = PlayerTrack.offId + for (index, track) in textList.enumerated() { let label = Self.displayTrackLabel( rawName: track.trackName, fallbackIndex: index + 1, @@ -294,15 +332,14 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg descriptionHint: track.trackDescription ) subs.append(PlayerTrack(id: track.trackId, name: label)) - if track.isSelected { currentSubtitleId = track.trackId } - } - if !mediaPlayer.textTracks.contains(where: \.isSelected) { - currentSubtitleId = PlayerTrack.offId + if track.isSelected { selectedSub = track.trackId } } subtitleTracks = subs + currentSubtitleId = selectedSub var audios: [PlayerTrack] = [] - for (index, track) in mediaPlayer.audioTracks.enumerated() { + var selectedAudio = currentAudioId + for (index, track) in audioList.enumerated() { let label = Self.displayTrackLabel( rawName: track.trackName, fallbackIndex: index + 1, @@ -311,12 +348,13 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg descriptionHint: track.trackDescription ) 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 { - currentAudioId = first.id + if selectedAudio.isEmpty, let first = audios.first { + selectedAudio = first.id } audioTracks = audios + currentAudioId = selectedAudio } private enum TrackKind { case subtitle, audio } @@ -432,7 +470,9 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg func stop() { tick?.cancel() tick = nil - stopPictureInPicture() + if pipActive { + stopPictureInPicture() + } mediaPlayer.stop() mediaPlayer.media = nil livePlayURL = nil @@ -440,6 +480,10 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg liveStartedAt = nil liveRetryCount = 0 everPlayed = false + liveMode = false + pipWindow = nil + pipPossible = false + pipActive = false if clearKeyProxyActive { ClearKeyDashProxy.shared.stop() clearKeyProxyActive = false @@ -452,7 +496,9 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg DispatchQueue.main.async { [weak self] in guard let self else { return } self.isPlaying = self.mediaPlayer.isPlaying - self.invalidatePipPlaybackState() + if self.pipPossible { + self.invalidatePipPlaybackState() + } switch newState { case .buffering: self.stateText = "Bufferen…" case .opening: self.stateText = "Openen…" @@ -468,7 +514,7 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg if self.clearKeyProxyActive && !self.everPlayed { self.stateText = "Verbinden…" self.retryLiveIfNeeded() - } else { + } else if !self.hasError { self.stateText = "Gestopt" } 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) { videoSubview = view guard let container else { return } @@ -618,15 +669,16 @@ final class VLCPipDrawableHost: NSObject, VLCDrawable, VLCPictureInPictureDrawab view.autoresizingMask = [.flexibleWidth, .flexibleHeight] } - var bounds: CGRect { + func bounds() -> CGRect { container?.bounds ?? .zero } // VLCPictureInPictureDrawable - var mediaController: VLCPictureInPictureMediaControlling { self } + func mediaController() -> (any VLCPictureInPictureMediaControlling)? { self } - func pictureInPictureReady() -> (VLCPictureInPictureWindowControlling) -> Void { + func pictureInPictureReady() -> (((any VLCPictureInPictureWindowControlling)?) -> Void)? { { [weak self] pip in + guard let pip else { return } self?.controller?.onPictureInPictureReady(pip) } } @@ -640,13 +692,12 @@ final class VLCPipDrawableHost: NSObject, VLCDrawable, VLCPictureInPictureDrawab controller?.mediaPlayer.pause() } - @objc(seekBy:completion:) - func seekBy(_ offset: Int64, completion: @escaping () -> Void) { + func seek(by offset: Int64, completion: @escaping () -> Void) { guard let player = controller?.mediaPlayer else { completion() return } - let ok = player.jump(withOffset: Int(offset), completion: completion) + let ok = player.jump(withOffset: Int32(clamping: offset), completion: completion) if !ok { completion() } }