From 4d92b8668fbaad3e96b63cc3e7f7076109d683d6 Mon Sep 17 00:00:00 2001 From: mertalev <101130780+mertalev@users.noreply.github.com> Date: Thu, 5 Feb 2026 16:40:06 -0500 Subject: [PATCH 1/6] handle client cert --- .../NativeVideoPlayerViewController.swift | 163 ++++++++------- .../NativeVideoPlayerApi.swift | 5 +- ios/Classes/VideoResourceLoader.swift | 192 ++++++++++++++++++ 3 files changed, 289 insertions(+), 71 deletions(-) create mode 100644 ios/Classes/VideoResourceLoader.swift diff --git a/ios/Classes/NativeVideoPlayerViewController.swift b/ios/Classes/NativeVideoPlayerViewController.swift index 127321e..29d97a7 100644 --- a/ios/Classes/NativeVideoPlayerViewController.swift +++ b/ios/Classes/NativeVideoPlayerViewController.swift @@ -9,11 +9,12 @@ public class NativeVideoPlayerViewController: NSObject, FlutterPlatformView { private var loop = false private var lastPosition: Int64 = -1 private var timeObserver: Any? + private var timeControlObserver: NSKeyValueObservation? init( messenger: FlutterBinaryMessenger, viewId: Int64, - frame: CGRect + frame: CGRect, ) { api = NativeVideoPlayerApi( messenger: messenger, @@ -28,9 +29,7 @@ public class NativeVideoPlayerViewController: NSObject, FlutterPlatformView { // Play audio even when the device is in silent mode do { - try AVAudioSession.sharedInstance().setCategory( - .playback, - mode: .default) + try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default) try AVAudioSession.sharedInstance().setActive(true) } catch { print("Failed to set playback audio session. Error: \(error)") @@ -39,9 +38,10 @@ public class NativeVideoPlayerViewController: NSObject, FlutterPlatformView { deinit { player.removeObserver(self, forKeyPath: "status") + timeControlObserver?.invalidate() removeOnVideoCompletedObserver() removePeriodicTimeObserver() - + player.replaceCurrentItem(with: nil) } @@ -55,34 +55,87 @@ extension NativeVideoPlayerViewController: NativeVideoPlayerApiDelegate { func loadVideoSource(videoSource: VideoSource) { let isUrl = videoSource.type == .network let sourcePath = videoSource.path - guard - let uri = isUrl - ? URL(string: sourcePath) - : URL(fileURLWithPath: sourcePath) - else { - return + guard let uri = isUrl ? URL(string: sourcePath) : URL(fileURLWithPath: sourcePath) else { return } + + let videoAsset: AVAsset + if isUrl, let asset = VideoResourceLoader.shared.prepareAsset(url: uri, headers: videoSource.headers) { + videoAsset = asset + } else if isUrl { + videoAsset = AVURLAsset(url: uri, options: ["AVURLAssetHTTPHeaderFieldsKey": videoSource.headers]) + } else { + videoAsset = AVAsset(url: uri) } - let videoAsset = - isUrl - ? AVURLAsset(url: uri, options: ["AVURLAssetHTTPHeaderFieldsKey": videoSource.headers]) - : AVAsset(url: uri) + let playerItem = AVPlayerItem(asset: videoAsset) - removeOnVideoCompletedObserver() player.replaceCurrentItem(with: playerItem) addOnVideoCompletedObserver() - + timeControlObserver = addTimeControlObserver() api.onPlaybackReady() addPeriodicTimeObserver() } + + func getVideoInfo(completion: @escaping (VideoInfo) -> Void) { + if #available(iOS 15, *) { + getVideoInfoAsync(completion: completion) + } else { + getVideoInfoLegacy(completion: completion) + } + } + + @available(iOS 15, *) + private func getVideoInfoAsync(completion: @escaping (VideoInfo) -> Void) { + guard let asset = player.currentItem?.asset else { + return completion(VideoInfo(height: 0, width: 0, duration: 0)) + } + Task { + do { + async let d = asset.load(.duration) + async let t = asset.loadTracks(withMediaType: .video) + + let duration = try await d + let size = try await t.first?.load(.naturalSize) ?? .zero + + let info = VideoInfo( + height: Int(size.height), + width: Int(size.width), + duration: Int64(duration.seconds * 1000) + ) + completion(info) + } catch { + completion(VideoInfo(height: 0, width: 0, duration: 0)) + } + } + } - func getVideoInfo() -> VideoInfo { - let videoInfo = VideoInfo( - height: getVideoHeight(), - width: getVideoWidth(), - duration: getVideoDuration() - ) - return videoInfo + private func getVideoInfoLegacy(completion: @escaping (VideoInfo) -> Void) { + guard let asset = player.currentItem?.asset else { + return completion(VideoInfo(height: 0, width: 0, duration: 0)) + } + + asset.loadValuesAsynchronously(forKeys: ["duration", "tracks"]) { + let duration: Int64 + if asset.statusOfValue(forKey: "duration", error: nil) == .loaded { + duration = Int64(asset.duration.seconds * 1000) + } else { + duration = 0 + } + + var width = 0, height = 0 + if asset.statusOfValue(forKey: "tracks", error: nil) == .loaded, + let track = asset.tracks(withMediaType: .video).first { + track.loadValuesAsynchronously(forKeys: ["naturalSize"]) { + if track.statusOfValue(forKey: "naturalSize", error: nil) == .loaded { + width = Int(track.naturalSize.width) + height = Int(track.naturalSize.height) + } + completion(VideoInfo(height: height, width: width, duration: duration)) + } + return + } + + completion(VideoInfo(height: height, width: width, duration: duration)) + } } func play() { @@ -140,36 +193,6 @@ extension NativeVideoPlayerViewController: NativeVideoPlayerApiDelegate { } } -extension NativeVideoPlayerViewController { - private func getVideoDuration() -> Int64 { - guard let currentItem = player.currentItem else { return 0 } - return Int64(currentItem.asset.duration.seconds * 1000) - } - - private func getVideoHeight() -> Int { - if let videoTrack = getVideoTrack() { - return Int(videoTrack.naturalSize.height) - } - return 0 - } - - private func getVideoWidth() -> Int { - if let videoTrack = getVideoTrack() { - return Int(videoTrack.naturalSize.width) - } - return 0 - } - - private func getVideoTrack() -> AVAssetTrack? { - if let tracks = player.currentItem?.asset.tracks(withMediaType: .video), - let track = tracks.first - { - return track - } - return nil - } -} - extension NativeVideoPlayerViewController { override public func observeValue( forKeyPath keyPath: String?, @@ -177,20 +200,8 @@ extension NativeVideoPlayerViewController { change: [NSKeyValueChangeKey: Any]?, context: UnsafeMutableRawPointer? ) { - if keyPath == "status" { - switch player.status { - case .unknown: - break - case .readyToPlay: - break - case .failed: - if let error = player.error { - api.onError(error) - } - default: - break - } - } + guard keyPath == "status", player.status == .failed, let error = player.error else { return } + api.onError(error) } } @@ -222,6 +233,22 @@ extension NativeVideoPlayerViewController { ) } + private func addTimeControlObserver() -> NSKeyValueObservation { + return player.observe(\.timeControlStatus, options: [.new]) { [weak self] player, _ in + guard let self = self else { return } + + if player.timeControlStatus == .waitingToPlayAtSpecifiedRate, + player.reasonForWaitingToPlay == .toMinimizeStalls, + VideoResourceLoader.shared.clientCredential != nil, + !VideoResourceLoader.shared.hasPendingRequests { + // AVPlayer can get stuck in waitingToMinimizeStalls with no pending requests. + // Seek slightly ahead to force it to reset its request state. + self.player.seek(to: self.player.currentTime() + CMTime(value: 5, timescale: 100)) + self.player.play() + } + } + } + private func addPeriodicTimeObserver() { removePeriodicTimeObserver() timeObserver = player.addPeriodicTimeObserver( diff --git a/ios/Classes/PlatformInterface/NativeVideoPlayerApi.swift b/ios/Classes/PlatformInterface/NativeVideoPlayerApi.swift index 8607cb2..db86ac5 100644 --- a/ios/Classes/PlatformInterface/NativeVideoPlayerApi.swift +++ b/ios/Classes/PlatformInterface/NativeVideoPlayerApi.swift @@ -1,6 +1,6 @@ protocol NativeVideoPlayerApiDelegate: AnyObject { func loadVideoSource(videoSource: VideoSource) - func getVideoInfo() -> VideoInfo + func getVideoInfo(completion: @escaping (VideoInfo) -> Void) func getPlaybackPosition() -> Int64 func play() func pause() @@ -66,8 +66,7 @@ class NativeVideoPlayerApi { delegate?.loadVideoSource(videoSource: videoSource) result(nil) case "getVideoInfo": - let videoInfo = delegate?.getVideoInfo().toMap() - result(videoInfo) + delegate?.getVideoInfo { info in result(info.toMap()) } case "getPlaybackPosition": let playbackPosition = delegate?.getPlaybackPosition() result(playbackPosition) diff --git a/ios/Classes/VideoResourceLoader.swift b/ios/Classes/VideoResourceLoader.swift new file mode 100644 index 0000000..9a8fa15 --- /dev/null +++ b/ios/Classes/VideoResourceLoader.swift @@ -0,0 +1,192 @@ +import AVFoundation +import Foundation +import UniformTypeIdentifiers + +/// Fulfils AVPlayer resource requests through a URLSession configured for mTLS. +public final class VideoResourceLoader: NSObject, AVAssetResourceLoaderDelegate, URLSessionDataDelegate { + public static let shared = VideoResourceLoader() + private static let schemePrefix = "mtls-" + private static let maxRetries = 3 + + public var clientCredential: URLCredential? + private let loaderQueue = DispatchQueue(label: "video.loader") + private var session: URLSession! + private var pending = [Int: PendingRequest]() + private var taskByLoadingRequest = [ObjectIdentifier: Int]() + private var currentHeaders = [String: String]() + + private struct PendingRequest { + let loadingRequest: AVAssetResourceLoadingRequest + let task: URLSessionDataTask + let request: URLRequest + let retryCount: Int + } + + private override init() { + super.init() + let config = URLSessionConfiguration.ephemeral + config.httpMaximumConnectionsPerHost = 64 + let delegateQueue = OperationQueue() + delegateQueue.underlyingQueue = loaderQueue + delegateQueue.maxConcurrentOperationCount = 1 + session = URLSession(configuration: config, delegate: self, delegateQueue: delegateQueue) + } + + /// Creates an AVURLAsset configured to load through this resource loader. + /// Returns nil if the loader is not active. + public func prepareAsset(url: URL, headers: [String: String]) -> AVURLAsset? { + guard clientCredential != nil, + var components = URLComponents(url: url, resolvingAgainstBaseURL: false), + let scheme = components.scheme else { return nil } + currentHeaders = headers + components.scheme = Self.schemePrefix + scheme + guard let customURL = components.url else { return nil } + let asset = AVURLAsset(url: customURL) + asset.resourceLoader.setDelegate(self, queue: loaderQueue) + return asset + } + + var hasPendingRequests: Bool { + loaderQueue.sync { !pending.isEmpty } + } + + private func addPending(_ entry: PendingRequest) { + pending[entry.task.taskIdentifier] = entry + taskByLoadingRequest[ObjectIdentifier(entry.loadingRequest)] = entry.task.taskIdentifier + } + + @discardableResult private func removePending(for taskId: Int) -> PendingRequest? { + guard let entry = pending.removeValue(forKey: taskId) else { return nil } + taskByLoadingRequest.removeValue(forKey: ObjectIdentifier(entry.loadingRequest)) + return entry + } + + @discardableResult private func removePending(for loadingRequest: AVAssetResourceLoadingRequest) -> PendingRequest? { + guard let taskId = taskByLoadingRequest.removeValue(forKey: ObjectIdentifier(loadingRequest)) else { return nil } + return pending.removeValue(forKey: taskId) + } + + private func originalURL(from url: URL) -> URL? { + guard let scheme = url.scheme, scheme.hasPrefix(Self.schemePrefix) else { return nil } + var components = URLComponents(url: url, resolvingAgainstBaseURL: false) + components?.scheme = String(scheme.dropFirst(Self.schemePrefix.count)) + return components?.url + } + + public func resourceLoader(_ resourceLoader: AVAssetResourceLoader, + shouldWaitForLoadingOfRequestedResource loadingRequest: AVAssetResourceLoadingRequest) -> Bool { + guard let url = loadingRequest.request.url, let originalURL = originalURL(from: url) else { return false } + + var request = URLRequest(url: originalURL) + request.setValue("identity", forHTTPHeaderField: "Accept-Encoding") + + for (key, value) in currentHeaders { + request.setValue(value, forHTTPHeaderField: key) + } + + if let dataRequest = loadingRequest.dataRequest { + let start = dataRequest.requestedOffset + if dataRequest.requestsAllDataToEndOfResource { + request.setValue("bytes=\(start)-", forHTTPHeaderField: "Range") + } else { + let end = start + Int64(dataRequest.requestedLength) - 1 + request.setValue("bytes=\(start)-\(end)", forHTTPHeaderField: "Range") + } + } + + let task = session.dataTask(with: request) + addPending(PendingRequest(loadingRequest: loadingRequest, task: task, request: request, retryCount: 0)) + task.resume() + return true + } + + public func resourceLoader(_ resourceLoader: AVAssetResourceLoader, + didCancel loadingRequest: AVAssetResourceLoadingRequest) { + guard let entry = removePending(for: loadingRequest) else { return } + entry.task.cancel() + } + + public func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, + didReceive response: URLResponse, + completionHandler: @escaping (URLSession.ResponseDisposition) -> Void) { + defer { completionHandler(.allow) } + + guard let entry = pending[dataTask.taskIdentifier], + let http = response as? HTTPURLResponse, + let contentInfo = entry.loadingRequest.contentInformationRequest else { return } + + if let mimeType = http.value(forHTTPHeaderField: "Content-Type") { + contentInfo.contentType = UTType(mimeType: mimeType)?.identifier + } + + contentInfo.isByteRangeAccessSupported = + http.statusCode == 206 || http.value(forHTTPHeaderField: "Accept-Ranges")?.contains("bytes") == true + + if let rangeHeader = http.value(forHTTPHeaderField: "Content-Range"), + let slashIndex = rangeHeader.lastIndex(of: "/"), + let total = Int64(rangeHeader[rangeHeader.index(after: slashIndex)...]) { + contentInfo.contentLength = total + } else { + contentInfo.contentLength = http.expectedContentLength + } + + // Per https://jaredsinclair.com/2016/09/03/implementing-avassetresourceload.html, we need to + // finish the content info request immediately without responding to the 2-byte data request. + removePending(for: dataTask.taskIdentifier) + entry.loadingRequest.finishLoading() + dataTask.cancel() + } + + public func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) { + guard let entry = pending[dataTask.taskIdentifier] else { return } + entry.loadingRequest.dataRequest?.respond(with: data) + } + + public func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { + guard let entry = removePending(for: task.taskIdentifier) else { return } + guard let error = error else { + return entry.loadingRequest.finishLoading() + } + + if entry.retryCount >= Self.maxRetries || entry.loadingRequest.isCancelled { + return entry.loadingRequest.finishLoading(with: error) + } + + var retryRequest = entry.request + if let dataRequest = entry.loadingRequest.dataRequest, + dataRequest.currentOffset > dataRequest.requestedOffset { + let offset = dataRequest.currentOffset + if dataRequest.requestsAllDataToEndOfResource { + retryRequest.setValue("bytes=\(offset)-", forHTTPHeaderField: "Range") + } else { + let end = dataRequest.requestedOffset + Int64(dataRequest.requestedLength) - 1 + retryRequest.setValue("bytes=\(offset)-\(end)", forHTTPHeaderField: "Range") + } + } + let newTask = session.dataTask(with: retryRequest) + addPending(PendingRequest(loadingRequest: entry.loadingRequest, task: newTask, + request: retryRequest, retryCount: entry.retryCount + 1)) + newTask.resume() + } + + public func urlSession(_ session: URLSession, didReceive challenge: URLAuthenticationChallenge, + completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) { + handleChallenge(challenge, completionHandler: completionHandler) + } + + public func urlSession(_ session: URLSession, task: URLSessionTask, didReceive challenge: URLAuthenticationChallenge, + completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) { + handleChallenge(challenge, completionHandler: completionHandler) + } + + private func handleChallenge( + _ challenge: URLAuthenticationChallenge, + completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void + ) { + guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodClientCertificate, + let clientCredential = clientCredential else { + return completionHandler(.performDefaultHandling, nil) + } + completionHandler(.useCredential, clientCredential) + } +} From 6f5d78cddd30a4a55251b12cf564be680b6fe68d Mon Sep 17 00:00:00 2001 From: mertalev <101130780+mertalev@users.noreply.github.com> Date: Wed, 18 Feb 2026 16:54:11 -0500 Subject: [PATCH 2/6] use per-task delegate --- .../NativeVideoPlayerViewController.swift | 2 +- ios/Classes/VideoResourceLoader.swift | 197 +++++++++--------- 2 files changed, 94 insertions(+), 105 deletions(-) diff --git a/ios/Classes/NativeVideoPlayerViewController.swift b/ios/Classes/NativeVideoPlayerViewController.swift index 29d97a7..103e946 100644 --- a/ios/Classes/NativeVideoPlayerViewController.swift +++ b/ios/Classes/NativeVideoPlayerViewController.swift @@ -239,7 +239,7 @@ extension NativeVideoPlayerViewController { if player.timeControlStatus == .waitingToPlayAtSpecifiedRate, player.reasonForWaitingToPlay == .toMinimizeStalls, - VideoResourceLoader.shared.clientCredential != nil, + VideoResourceLoader.shared.isActive, !VideoResourceLoader.shared.hasPendingRequests { // AVPlayer can get stuck in waitingToMinimizeStalls with no pending requests. // Seek slightly ahead to force it to reset its request state. diff --git a/ios/Classes/VideoResourceLoader.swift b/ios/Classes/VideoResourceLoader.swift index 9a8fa15..035d2da 100644 --- a/ios/Classes/VideoResourceLoader.swift +++ b/ios/Classes/VideoResourceLoader.swift @@ -2,42 +2,35 @@ import AVFoundation import Foundation import UniformTypeIdentifiers -/// Fulfils AVPlayer resource requests through a URLSession configured for mTLS. -public final class VideoResourceLoader: NSObject, AVAssetResourceLoaderDelegate, URLSessionDataDelegate { +/// Fulfils AVPlayer resource requests through a shared URLSession. +/// On iOS 15+, uses per-task delegates so auth challenges fall through to the session delegate. +/// On iOS < 15, `prepareAsset` returns nil so callers fall back to AVURLAsset with headers. +public final class VideoResourceLoader: NSObject, AVAssetResourceLoaderDelegate { public static let shared = VideoResourceLoader() private static let schemePrefix = "mtls-" - private static let maxRetries = 3 + static let maxRetries = 3 - public var clientCredential: URLCredential? + public var sharedSession: URLSession? private let loaderQueue = DispatchQueue(label: "video.loader") - private var session: URLSession! - private var pending = [Int: PendingRequest]() - private var taskByLoadingRequest = [ObjectIdentifier: Int]() private var currentHeaders = [String: String]() + private var activeTasks = [ObjectIdentifier: URLSessionDataTask]() + private let taskLock = NSLock() - private struct PendingRequest { - let loadingRequest: AVAssetResourceLoadingRequest - let task: URLSessionDataTask - let request: URLRequest - let retryCount: Int + public var isActive: Bool { + if #available(iOS 15.0, *) { return sharedSession != nil } + return false } - private override init() { - super.init() - let config = URLSessionConfiguration.ephemeral - config.httpMaximumConnectionsPerHost = 64 - let delegateQueue = OperationQueue() - delegateQueue.underlyingQueue = loaderQueue - delegateQueue.maxConcurrentOperationCount = 1 - session = URLSession(configuration: config, delegate: self, delegateQueue: delegateQueue) + public var hasPendingRequests: Bool { + taskLock.lock() + defer { taskLock.unlock() } + return !activeTasks.isEmpty } - /// Creates an AVURLAsset configured to load through this resource loader. - /// Returns nil if the loader is not active. public func prepareAsset(url: URL, headers: [String: String]) -> AVURLAsset? { - guard clientCredential != nil, - var components = URLComponents(url: url, resolvingAgainstBaseURL: false), - let scheme = components.scheme else { return nil } + guard #available(iOS 15.0, *), sharedSession != nil, + var components = URLComponents(url: url, resolvingAgainstBaseURL: false), + let scheme = components.scheme else { return nil } currentHeaders = headers components.scheme = Self.schemePrefix + scheme guard let customURL = components.url else { return nil } @@ -46,26 +39,6 @@ public final class VideoResourceLoader: NSObject, AVAssetResourceLoaderDelegate, return asset } - var hasPendingRequests: Bool { - loaderQueue.sync { !pending.isEmpty } - } - - private func addPending(_ entry: PendingRequest) { - pending[entry.task.taskIdentifier] = entry - taskByLoadingRequest[ObjectIdentifier(entry.loadingRequest)] = entry.task.taskIdentifier - } - - @discardableResult private func removePending(for taskId: Int) -> PendingRequest? { - guard let entry = pending.removeValue(forKey: taskId) else { return nil } - taskByLoadingRequest.removeValue(forKey: ObjectIdentifier(entry.loadingRequest)) - return entry - } - - @discardableResult private func removePending(for loadingRequest: AVAssetResourceLoadingRequest) -> PendingRequest? { - guard let taskId = taskByLoadingRequest.removeValue(forKey: ObjectIdentifier(loadingRequest)) else { return nil } - return pending.removeValue(forKey: taskId) - } - private func originalURL(from url: URL) -> URL? { guard let scheme = url.scheme, scheme.hasPrefix(Self.schemePrefix) else { return nil } var components = URLComponents(url: url, resolvingAgainstBaseURL: false) @@ -75,9 +48,11 @@ public final class VideoResourceLoader: NSObject, AVAssetResourceLoaderDelegate, public func resourceLoader(_ resourceLoader: AVAssetResourceLoader, shouldWaitForLoadingOfRequestedResource loadingRequest: AVAssetResourceLoadingRequest) -> Bool { - guard let url = loadingRequest.request.url, let originalURL = originalURL(from: url) else { return false } + guard #available(iOS 15.0, *), let session = sharedSession, + let url = loadingRequest.request.url, let originalURL = originalURL(from: url) else { return false } var request = URLRequest(url: originalURL) + request.cachePolicy = .reloadIgnoringLocalCacheData request.setValue("identity", forHTTPHeaderField: "Accept-Encoding") for (key, value) in currentHeaders { @@ -94,99 +69,113 @@ public final class VideoResourceLoader: NSObject, AVAssetResourceLoaderDelegate, } } - let task = session.dataTask(with: request) - addPending(PendingRequest(loadingRequest: loadingRequest, task: task, request: request, retryCount: 0)) - task.resume() + startTask(on: session, request: request, loadingRequest: loadingRequest) return true } public func resourceLoader(_ resourceLoader: AVAssetResourceLoader, didCancel loadingRequest: AVAssetResourceLoadingRequest) { - guard let entry = removePending(for: loadingRequest) else { return } - entry.task.cancel() + taskLock.lock() + let task = activeTasks.removeValue(forKey: ObjectIdentifier(loadingRequest)) + taskLock.unlock() + task?.cancel() } - public func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, - didReceive response: URLResponse, - completionHandler: @escaping (URLSession.ResponseDisposition) -> Void) { - defer { completionHandler(.allow) } + @available(iOS 15.0, *) + fileprivate func startTask(on session: URLSession, request: URLRequest, + loadingRequest: AVAssetResourceLoadingRequest, retryCount: Int = 0) { + let delegate = ResourceTaskDelegate(loadingRequest: loadingRequest, request: request, + retryCount: retryCount, loader: self) + let task = session.dataTask(with: request) + task.delegate = delegate + taskLock.lock() + activeTasks[ObjectIdentifier(loadingRequest)] = task + taskLock.unlock() + task.resume() + } + + fileprivate func removeTask(for loadingRequest: AVAssetResourceLoadingRequest) { + taskLock.lock() + activeTasks.removeValue(forKey: ObjectIdentifier(loadingRequest)) + taskLock.unlock() + } +} + +// MARK: - Per-Task Delegate (auth challenges intentionally omitted → session delegate handles them) - guard let entry = pending[dataTask.taskIdentifier], - let http = response as? HTTPURLResponse, - let contentInfo = entry.loadingRequest.contentInformationRequest else { return } +@available(iOS 15.0, *) +private class ResourceTaskDelegate: NSObject, URLSessionDataDelegate { + let loadingRequest: AVAssetResourceLoadingRequest + var request: URLRequest + var retryCount: Int + private weak var loader: VideoResourceLoader? + + init(loadingRequest: AVAssetResourceLoadingRequest, request: URLRequest, + retryCount: Int, loader: VideoResourceLoader) { + self.loadingRequest = loadingRequest + self.request = request + self.retryCount = retryCount + self.loader = loader + } + + func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, + didReceive response: URLResponse, + completionHandler: @escaping (URLSession.ResponseDisposition) -> Void) { + defer { completionHandler(.allow) } + guard let http = response as? HTTPURLResponse, + let contentInfo = loadingRequest.contentInformationRequest else { return } if let mimeType = http.value(forHTTPHeaderField: "Content-Type") { contentInfo.contentType = UTType(mimeType: mimeType)?.identifier } - contentInfo.isByteRangeAccessSupported = http.statusCode == 206 || http.value(forHTTPHeaderField: "Accept-Ranges")?.contains("bytes") == true - if let rangeHeader = http.value(forHTTPHeaderField: "Content-Range"), - let slashIndex = rangeHeader.lastIndex(of: "/"), - let total = Int64(rangeHeader[rangeHeader.index(after: slashIndex)...]) { + let slashIndex = rangeHeader.lastIndex(of: "/"), + let total = Int64(rangeHeader[rangeHeader.index(after: slashIndex)...]) { contentInfo.contentLength = total } else { contentInfo.contentLength = http.expectedContentLength } - // Per https://jaredsinclair.com/2016/09/03/implementing-avassetresourceload.html, we need to - // finish the content info request immediately without responding to the 2-byte data request. - removePending(for: dataTask.taskIdentifier) - entry.loadingRequest.finishLoading() + // Per https://jaredsinclair.com/2016/09/03/implementing-avassetresourceload.html, finish + // the content info request immediately without responding to the 2-byte data request. + finish() dataTask.cancel() } - public func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) { - guard let entry = pending[dataTask.taskIdentifier] else { return } - entry.loadingRequest.dataRequest?.respond(with: data) + func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) { + loadingRequest.dataRequest?.respond(with: data) } - public func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { - guard let entry = removePending(for: task.taskIdentifier) else { return } - guard let error = error else { - return entry.loadingRequest.finishLoading() - } - - if entry.retryCount >= Self.maxRetries || entry.loadingRequest.isCancelled { - return entry.loadingRequest.finishLoading(with: error) + func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { + guard let error = error else { return finish() } + if (error as NSError).code == NSURLErrorCancelled { return } + guard retryCount < VideoResourceLoader.maxRetries, !loadingRequest.isCancelled else { + return finish(error: error) } - var retryRequest = entry.request - if let dataRequest = entry.loadingRequest.dataRequest, - dataRequest.currentOffset > dataRequest.requestedOffset { + if let dataRequest = loadingRequest.dataRequest, + dataRequest.currentOffset > dataRequest.requestedOffset { let offset = dataRequest.currentOffset if dataRequest.requestsAllDataToEndOfResource { - retryRequest.setValue("bytes=\(offset)-", forHTTPHeaderField: "Range") + request.setValue("bytes=\(offset)-", forHTTPHeaderField: "Range") } else { let end = dataRequest.requestedOffset + Int64(dataRequest.requestedLength) - 1 - retryRequest.setValue("bytes=\(offset)-\(end)", forHTTPHeaderField: "Range") + request.setValue("bytes=\(offset)-\(end)", forHTTPHeaderField: "Range") } } - let newTask = session.dataTask(with: retryRequest) - addPending(PendingRequest(loadingRequest: entry.loadingRequest, task: newTask, - request: retryRequest, retryCount: entry.retryCount + 1)) - newTask.resume() - } - public func urlSession(_ session: URLSession, didReceive challenge: URLAuthenticationChallenge, - completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) { - handleChallenge(challenge, completionHandler: completionHandler) + retryCount += 1 + guard let session = loader?.sharedSession else { return finish(error: error) } + loader?.startTask(on: session, request: request, + loadingRequest: loadingRequest, retryCount: retryCount) } - public func urlSession(_ session: URLSession, task: URLSessionTask, didReceive challenge: URLAuthenticationChallenge, - completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) { - handleChallenge(challenge, completionHandler: completionHandler) - } - - private func handleChallenge( - _ challenge: URLAuthenticationChallenge, - completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void - ) { - guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodClientCertificate, - let clientCredential = clientCredential else { - return completionHandler(.performDefaultHandling, nil) - } - completionHandler(.useCredential, clientCredential) + private func finish(error: Error? = nil) { + loader?.removeTask(for: loadingRequest) + guard !loadingRequest.isCancelled else { return } + if let error = error { loadingRequest.finishLoading(with: error) } + else { loadingRequest.finishLoading() } } } From 0290152b843cacf01743d4e564bc57c68f662cbb Mon Sep 17 00:00:00 2001 From: mertalev <101130780+mertalev@users.noreply.github.com> Date: Sat, 21 Feb 2026 17:30:57 -0500 Subject: [PATCH 3/6] datasource factory --- .../albemala/native_video_player/NativeVideoPlayerPlugin.kt | 4 ++++ .../native_video_player/NativeVideoPlayerViewController.kt | 5 ++++- .../native_video_player/NativeVideoPlayerViewFactory.kt | 3 ++- 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/android/src/main/kotlin/me/albemala/native_video_player/NativeVideoPlayerPlugin.kt b/android/src/main/kotlin/me/albemala/native_video_player/NativeVideoPlayerPlugin.kt index 4d07098..c655425 100644 --- a/android/src/main/kotlin/me/albemala/native_video_player/NativeVideoPlayerPlugin.kt +++ b/android/src/main/kotlin/me/albemala/native_video_player/NativeVideoPlayerPlugin.kt @@ -1,10 +1,14 @@ package me.albemala.native_video_player import androidx.annotation.NonNull +import androidx.media3.datasource.DataSource import io.flutter.embedding.engine.plugins.FlutterPlugin class NativeVideoPlayerPlugin : FlutterPlugin { + companion object { + var dataSourceFactory: ((Map) -> DataSource.Factory)? = null + } override fun onAttachedToEngine(@NonNull binding: FlutterPlugin.FlutterPluginBinding) { val factory = NativeVideoPlayerViewFactory(binding.binaryMessenger) diff --git a/android/src/main/kotlin/me/albemala/native_video_player/NativeVideoPlayerViewController.kt b/android/src/main/kotlin/me/albemala/native_video_player/NativeVideoPlayerViewController.kt index b63a3d9..03d7b43 100644 --- a/android/src/main/kotlin/me/albemala/native_video_player/NativeVideoPlayerViewController.kt +++ b/android/src/main/kotlin/me/albemala/native_video_player/NativeVideoPlayerViewController.kt @@ -11,6 +11,7 @@ import androidx.media3.common.MediaItem import androidx.media3.common.PlaybackException import androidx.media3.common.Player import androidx.media3.common.util.UnstableApi +import androidx.media3.datasource.DataSource import androidx.media3.datasource.DefaultHttpDataSource import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.source.ProgressiveMediaSource @@ -23,6 +24,7 @@ class NativeVideoPlayerViewController( viewId: Int, context: Context, private val api: NativeVideoPlayerApi = NativeVideoPlayerApi(messenger, viewId), + private val dataSourceFactory: ((Map) -> DataSource.Factory)? = null, ) : PlatformView, NativeVideoPlayerApiDelegate, Player.Listener { @@ -78,7 +80,8 @@ class NativeVideoPlayerViewController( when (videoSource.type) { VideoSourceType.Asset, VideoSourceType.File -> player.setMediaItem(mediaItem) VideoSourceType.Network -> { - val dataSource = DefaultHttpDataSource.Factory().setDefaultRequestProperties(videoSource.headers) + val dataSource = dataSourceFactory?.invoke(videoSource.headers) + ?: DefaultHttpDataSource.Factory().setDefaultRequestProperties(videoSource.headers) val mediaSource = ProgressiveMediaSource.Factory(dataSource).createMediaSource(mediaItem) player.setMediaSource(mediaSource) } diff --git a/android/src/main/kotlin/me/albemala/native_video_player/NativeVideoPlayerViewFactory.kt b/android/src/main/kotlin/me/albemala/native_video_player/NativeVideoPlayerViewFactory.kt index 995528a..0d93f70 100644 --- a/android/src/main/kotlin/me/albemala/native_video_player/NativeVideoPlayerViewFactory.kt +++ b/android/src/main/kotlin/me/albemala/native_video_player/NativeVideoPlayerViewFactory.kt @@ -23,7 +23,8 @@ class NativeVideoPlayerViewFactory( return NativeVideoPlayerViewController( messenger, viewId, - context + context, + dataSourceFactory = NativeVideoPlayerPlugin.dataSourceFactory, ) } } From c3b043120aca4d71553f8a9bc5393bf817a6c947 Mon Sep 17 00:00:00 2001 From: mertalev <101130780+mertalev@users.noreply.github.com> Date: Sat, 21 Feb 2026 21:07:27 -0500 Subject: [PATCH 4/6] proxy impl --- .../NativeVideoPlayerViewController.swift | 10 +- ios/Classes/VideoProxyServer.swift | 201 ++++++++++++++++++ ios/Classes/VideoResourceLoader.swift | 181 ---------------- 3 files changed, 204 insertions(+), 188 deletions(-) create mode 100644 ios/Classes/VideoProxyServer.swift delete mode 100644 ios/Classes/VideoResourceLoader.swift diff --git a/ios/Classes/NativeVideoPlayerViewController.swift b/ios/Classes/NativeVideoPlayerViewController.swift index 103e946..fb498f7 100644 --- a/ios/Classes/NativeVideoPlayerViewController.swift +++ b/ios/Classes/NativeVideoPlayerViewController.swift @@ -58,8 +58,8 @@ extension NativeVideoPlayerViewController: NativeVideoPlayerApiDelegate { guard let uri = isUrl ? URL(string: sourcePath) : URL(fileURLWithPath: sourcePath) else { return } let videoAsset: AVAsset - if isUrl, let asset = VideoResourceLoader.shared.prepareAsset(url: uri, headers: videoSource.headers) { - videoAsset = asset + if isUrl, #available(iOS 15.0, *), let proxyURL = VideoProxyServer.shared.proxyURL(for: uri) { + videoAsset = AVURLAsset(url: proxyURL) } else if isUrl { videoAsset = AVURLAsset(url: uri, options: ["AVURLAssetHTTPHeaderFieldsKey": videoSource.headers]) } else { @@ -238,12 +238,8 @@ extension NativeVideoPlayerViewController { guard let self = self else { return } if player.timeControlStatus == .waitingToPlayAtSpecifiedRate, - player.reasonForWaitingToPlay == .toMinimizeStalls, - VideoResourceLoader.shared.isActive, - !VideoResourceLoader.shared.hasPendingRequests { + player.reasonForWaitingToPlay == .toMinimizeStalls { // AVPlayer can get stuck in waitingToMinimizeStalls with no pending requests. - // Seek slightly ahead to force it to reset its request state. - self.player.seek(to: self.player.currentTime() + CMTime(value: 5, timescale: 100)) self.player.play() } } diff --git a/ios/Classes/VideoProxyServer.swift b/ios/Classes/VideoProxyServer.swift new file mode 100644 index 0000000..1081e95 --- /dev/null +++ b/ios/Classes/VideoProxyServer.swift @@ -0,0 +1,201 @@ +import Foundation +import Network + +@available(iOS 15.0, *) +public final class VideoProxyServer: @unchecked Sendable { + public static let shared = VideoProxyServer() + public var session: URLSession? + + private var listener: NWListener? + private let queue = DispatchQueue(label: "video.proxy", qos: .userInitiated) + private var port: UInt16 = 0 + private var activeConnections = Set() + + private init() {} + + public var isRunning: Bool { listener?.state == .ready } + + public func proxyURL(for originalURL: URL) -> URL? { + guard session != nil, let _ = try? start() else { return nil } + guard let scheme = originalURL.scheme, let host = originalURL.host else { return nil } + let hostPort = originalURL.port.map { "\(host):\($0)" } ?? host + var components = URLComponents() + components.scheme = "http" + components.host = "127.0.0.1" + components.port = Int(port) + components.path = "/\(scheme)/\(hostPort)\(originalURL.path.isEmpty ? "/" : originalURL.path)" + components.percentEncodedQuery = originalURL.query + return components.url + } + + private func start() throws { + guard !isRunning else { return } + listener?.cancel() + listener = nil + let nwListener = try NWListener(using: .tcp, on: .any) + let semaphore = DispatchSemaphore(value: 0) + var startError: Error? + nwListener.stateUpdateHandler = { [weak self] state in + switch state { + case .ready: self?.port = nwListener.port?.rawValue ?? 0; semaphore.signal() + case .failed(let e): startError = e; semaphore.signal() + case .cancelled: self?.port = 0 + default: break + } + } + nwListener.newConnectionHandler = { [weak self] conn in + guard let self = self else { return } + let pc = ProxyConnection(connection: conn, server: self, queue: self.queue) + self.activeConnections.insert(pc) + pc.start() + } + listener = nwListener + nwListener.start(queue: queue) + semaphore.wait() + if let error = startError { listener = nil; throw error } + } + + fileprivate func remove(_ conn: ProxyConnection) { activeConnections.remove(conn) } + + /// Reconstructs the original URL from a proxy request target (e.g. `/https/host:port/path?q=1`). + fileprivate func originalURL(fromRequestTarget target: String) -> URL? { + let pathQuery = target.split(separator: "?", maxSplits: 1) + let segments = pathQuery[0].dropFirst().split(separator: "/", maxSplits: 2) + guard segments.count >= 2 else { return nil } + let hostParts = segments[1].split(separator: ":", maxSplits: 1) + var components = URLComponents() + components.scheme = String(segments[0]) + components.host = String(hostParts[0]) + components.port = hostParts.count > 1 ? Int(hostParts[1]) : nil + components.percentEncodedPath = segments.count > 2 ? "/\(segments[2])" : "/" + components.percentEncodedQuery = pathQuery.count > 1 ? String(pathQuery[1]) : nil + return components.url + } +} + +@available(iOS 15.0, *) +private final class ProxyConnection: NSObject, URLSessionDataDelegate { + let connection: NWConnection + private weak var server: VideoProxyServer? + private let queue: DispatchQueue + private var buffer = Data() + private var currentTask: URLSessionDataTask? + private var headersSent = false + private static let headerEnd = Data("\r\n\r\n".utf8) + private static let skipHeaders: Set = ["host", "connection", "proxy-connection", "keep-alive"] + + init(connection: NWConnection, server: VideoProxyServer, queue: DispatchQueue) { + self.connection = connection + self.server = server + self.queue = queue + } + + func start() { + connection.stateUpdateHandler = { [weak self] state in + switch state { + case .cancelled, .failed: self?.cleanup() + default: break + } + } + connection.start(queue: queue) + readRequest() + } + + private func cleanup() { + currentTask?.cancel() + currentTask = nil + server?.remove(self) + } + + private func readRequest() { + connection.receive(minimumIncompleteLength: 1, maximumLength: 8192) { [weak self] data, _, isComplete, error in + guard let self = self else { return } + guard error == nil, let data = data, !data.isEmpty else { + return self.connection.cancel() + } + self.buffer.append(data) + if self.buffer.range(of: Self.headerEnd) != nil { + self.forwardRequest() + } else { + self.readRequest() + } + } + } + + private func forwardRequest() { + guard let session = server?.session else { return sendError(502) } + guard let request = parseRequest() else { return sendError(400) } + let task = session.dataTask(with: request) + task.delegate = self + currentTask = task + headersSent = false + task.resume() + } + + private func parseRequest() -> URLRequest? { + let headerBlock = String(decoding: buffer, as: UTF8.self) + buffer.removeAll(keepingCapacity: true) + let lines = headerBlock.split(separator: "\r\n", omittingEmptySubsequences: false) + let requestParts = lines.first?.split(separator: " ", maxSplits: 2) + guard let requestParts, requestParts.count >= 2, + let url = server?.originalURL(fromRequestTarget: String(requestParts[1])) + else { return nil } + + var request = URLRequest(url: url) + request.cachePolicy = .reloadIgnoringLocalCacheData + request.httpMethod = String(requestParts[0]) + for line in lines.dropFirst() { + if line.isEmpty { break } + let parts = line.split(separator: ":", maxSplits: 1) + guard parts.count == 2 else { continue } + let key = parts[0].trimmingCharacters(in: .whitespaces) + guard !Self.skipHeaders.contains(key.lowercased()) else { continue } + request.setValue(parts[1].trimmingCharacters(in: .whitespaces), forHTTPHeaderField: key) + } + return request + } + + private func sendError(_ code: Int) { + let reason = HTTPURLResponse.localizedString(forStatusCode: code) + let body = "\(code) \(reason)" + let resp = "HTTP/1.1 \(code) \(reason)\r\nContent-Length: \(body.utf8.count)\r\nConnection: close\r\n\r\n\(body)" + connection.send(content: Data(resp.utf8), contentContext: .finalMessage, completion: .contentProcessed { [weak self] _ in + self?.connection.cancel() + }) + } + + func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, + didReceive response: URLResponse, + completionHandler: @escaping (URLSession.ResponseDisposition) -> Void) { + guard let http = response as? HTTPURLResponse else { return completionHandler(.cancel) } + var head = Data(capacity: 1024) + head.append(contentsOf: "HTTP/1.1 \(http.statusCode) \(HTTPURLResponse.localizedString(forStatusCode: http.statusCode))\r\n".utf8) + for (key, value) in http.allHeaderFields { + head.append(contentsOf: "\(key): \(value)\r\n".utf8) + } + head.append(contentsOf: "\r\n".utf8) + headersSent = true + connection.send(content: head, completion: .contentProcessed { _ in }) + completionHandler(.allow) + } + + func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) { + connection.send(content: data, completion: .contentProcessed { _ in }) + } + + func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { + if let error = error { + if (error as NSError).code == NSURLErrorCancelled { return } + if !headersSent { return sendError(502) } + return connection.cancel() + } + currentTask = nil + self.readRequest() + } + + func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, + willCacheResponse proposedResponse: CachedURLResponse, + completionHandler: @escaping (CachedURLResponse?) -> Void) { + completionHandler(nil) + } +} diff --git a/ios/Classes/VideoResourceLoader.swift b/ios/Classes/VideoResourceLoader.swift deleted file mode 100644 index 035d2da..0000000 --- a/ios/Classes/VideoResourceLoader.swift +++ /dev/null @@ -1,181 +0,0 @@ -import AVFoundation -import Foundation -import UniformTypeIdentifiers - -/// Fulfils AVPlayer resource requests through a shared URLSession. -/// On iOS 15+, uses per-task delegates so auth challenges fall through to the session delegate. -/// On iOS < 15, `prepareAsset` returns nil so callers fall back to AVURLAsset with headers. -public final class VideoResourceLoader: NSObject, AVAssetResourceLoaderDelegate { - public static let shared = VideoResourceLoader() - private static let schemePrefix = "mtls-" - static let maxRetries = 3 - - public var sharedSession: URLSession? - private let loaderQueue = DispatchQueue(label: "video.loader") - private var currentHeaders = [String: String]() - private var activeTasks = [ObjectIdentifier: URLSessionDataTask]() - private let taskLock = NSLock() - - public var isActive: Bool { - if #available(iOS 15.0, *) { return sharedSession != nil } - return false - } - - public var hasPendingRequests: Bool { - taskLock.lock() - defer { taskLock.unlock() } - return !activeTasks.isEmpty - } - - public func prepareAsset(url: URL, headers: [String: String]) -> AVURLAsset? { - guard #available(iOS 15.0, *), sharedSession != nil, - var components = URLComponents(url: url, resolvingAgainstBaseURL: false), - let scheme = components.scheme else { return nil } - currentHeaders = headers - components.scheme = Self.schemePrefix + scheme - guard let customURL = components.url else { return nil } - let asset = AVURLAsset(url: customURL) - asset.resourceLoader.setDelegate(self, queue: loaderQueue) - return asset - } - - private func originalURL(from url: URL) -> URL? { - guard let scheme = url.scheme, scheme.hasPrefix(Self.schemePrefix) else { return nil } - var components = URLComponents(url: url, resolvingAgainstBaseURL: false) - components?.scheme = String(scheme.dropFirst(Self.schemePrefix.count)) - return components?.url - } - - public func resourceLoader(_ resourceLoader: AVAssetResourceLoader, - shouldWaitForLoadingOfRequestedResource loadingRequest: AVAssetResourceLoadingRequest) -> Bool { - guard #available(iOS 15.0, *), let session = sharedSession, - let url = loadingRequest.request.url, let originalURL = originalURL(from: url) else { return false } - - var request = URLRequest(url: originalURL) - request.cachePolicy = .reloadIgnoringLocalCacheData - request.setValue("identity", forHTTPHeaderField: "Accept-Encoding") - - for (key, value) in currentHeaders { - request.setValue(value, forHTTPHeaderField: key) - } - - if let dataRequest = loadingRequest.dataRequest { - let start = dataRequest.requestedOffset - if dataRequest.requestsAllDataToEndOfResource { - request.setValue("bytes=\(start)-", forHTTPHeaderField: "Range") - } else { - let end = start + Int64(dataRequest.requestedLength) - 1 - request.setValue("bytes=\(start)-\(end)", forHTTPHeaderField: "Range") - } - } - - startTask(on: session, request: request, loadingRequest: loadingRequest) - return true - } - - public func resourceLoader(_ resourceLoader: AVAssetResourceLoader, - didCancel loadingRequest: AVAssetResourceLoadingRequest) { - taskLock.lock() - let task = activeTasks.removeValue(forKey: ObjectIdentifier(loadingRequest)) - taskLock.unlock() - task?.cancel() - } - - @available(iOS 15.0, *) - fileprivate func startTask(on session: URLSession, request: URLRequest, - loadingRequest: AVAssetResourceLoadingRequest, retryCount: Int = 0) { - let delegate = ResourceTaskDelegate(loadingRequest: loadingRequest, request: request, - retryCount: retryCount, loader: self) - let task = session.dataTask(with: request) - task.delegate = delegate - taskLock.lock() - activeTasks[ObjectIdentifier(loadingRequest)] = task - taskLock.unlock() - task.resume() - } - - fileprivate func removeTask(for loadingRequest: AVAssetResourceLoadingRequest) { - taskLock.lock() - activeTasks.removeValue(forKey: ObjectIdentifier(loadingRequest)) - taskLock.unlock() - } -} - -// MARK: - Per-Task Delegate (auth challenges intentionally omitted → session delegate handles them) - -@available(iOS 15.0, *) -private class ResourceTaskDelegate: NSObject, URLSessionDataDelegate { - let loadingRequest: AVAssetResourceLoadingRequest - var request: URLRequest - var retryCount: Int - private weak var loader: VideoResourceLoader? - - init(loadingRequest: AVAssetResourceLoadingRequest, request: URLRequest, - retryCount: Int, loader: VideoResourceLoader) { - self.loadingRequest = loadingRequest - self.request = request - self.retryCount = retryCount - self.loader = loader - } - - func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, - didReceive response: URLResponse, - completionHandler: @escaping (URLSession.ResponseDisposition) -> Void) { - defer { completionHandler(.allow) } - guard let http = response as? HTTPURLResponse, - let contentInfo = loadingRequest.contentInformationRequest else { return } - - if let mimeType = http.value(forHTTPHeaderField: "Content-Type") { - contentInfo.contentType = UTType(mimeType: mimeType)?.identifier - } - contentInfo.isByteRangeAccessSupported = - http.statusCode == 206 || http.value(forHTTPHeaderField: "Accept-Ranges")?.contains("bytes") == true - if let rangeHeader = http.value(forHTTPHeaderField: "Content-Range"), - let slashIndex = rangeHeader.lastIndex(of: "/"), - let total = Int64(rangeHeader[rangeHeader.index(after: slashIndex)...]) { - contentInfo.contentLength = total - } else { - contentInfo.contentLength = http.expectedContentLength - } - - // Per https://jaredsinclair.com/2016/09/03/implementing-avassetresourceload.html, finish - // the content info request immediately without responding to the 2-byte data request. - finish() - dataTask.cancel() - } - - func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) { - loadingRequest.dataRequest?.respond(with: data) - } - - func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { - guard let error = error else { return finish() } - if (error as NSError).code == NSURLErrorCancelled { return } - guard retryCount < VideoResourceLoader.maxRetries, !loadingRequest.isCancelled else { - return finish(error: error) - } - - if let dataRequest = loadingRequest.dataRequest, - dataRequest.currentOffset > dataRequest.requestedOffset { - let offset = dataRequest.currentOffset - if dataRequest.requestsAllDataToEndOfResource { - request.setValue("bytes=\(offset)-", forHTTPHeaderField: "Range") - } else { - let end = dataRequest.requestedOffset + Int64(dataRequest.requestedLength) - 1 - request.setValue("bytes=\(offset)-\(end)", forHTTPHeaderField: "Range") - } - } - - retryCount += 1 - guard let session = loader?.sharedSession else { return finish(error: error) } - loader?.startTask(on: session, request: request, - loadingRequest: loadingRequest, retryCount: retryCount) - } - - private func finish(error: Error? = nil) { - loader?.removeTask(for: loadingRequest) - guard !loadingRequest.isCancelled else { return } - if let error = error { loadingRequest.finishLoading(with: error) } - else { loadingRequest.finishLoading() } - } -} From 374e321cfdc4bf2be77086816a529b88fcb8ae0a Mon Sep 17 00:00:00 2001 From: mertalev <101130780+mertalev@users.noreply.github.com> Date: Sun, 22 Feb 2026 01:30:57 -0500 Subject: [PATCH 5/6] improved observer check --- .../NativeVideoPlayerViewController.swift | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/ios/Classes/NativeVideoPlayerViewController.swift b/ios/Classes/NativeVideoPlayerViewController.swift index fb498f7..58cc001 100644 --- a/ios/Classes/NativeVideoPlayerViewController.swift +++ b/ios/Classes/NativeVideoPlayerViewController.swift @@ -70,7 +70,7 @@ extension NativeVideoPlayerViewController: NativeVideoPlayerApiDelegate { removeOnVideoCompletedObserver() player.replaceCurrentItem(with: playerItem) addOnVideoCompletedObserver() - timeControlObserver = addTimeControlObserver() + timeControlObserver = addTimeControlObserver(currentItem: playerItem) api.onPlaybackReady() addPeriodicTimeObserver() } @@ -233,14 +233,12 @@ extension NativeVideoPlayerViewController { ) } - private func addTimeControlObserver() -> NSKeyValueObservation { - return player.observe(\.timeControlStatus, options: [.new]) { [weak self] player, _ in - guard let self = self else { return } - - if player.timeControlStatus == .waitingToPlayAtSpecifiedRate, - player.reasonForWaitingToPlay == .toMinimizeStalls { - // AVPlayer can get stuck in waitingToMinimizeStalls with no pending requests. - self.player.play() + private func addTimeControlObserver(currentItem: AVPlayerItem) -> NSKeyValueObservation { + return currentItem.observe(\.isPlaybackLikelyToKeepUp, options: [.new]) { [weak self] item, _ in + if item.isPlaybackLikelyToKeepUp, + self?.player.timeControlStatus == .waitingToPlayAtSpecifiedRate { + // AVPlayer can sometimes get stuck + DispatchQueue.main.async { [weak self] in self?.player.play() } } } } From 0a80cd0bd3ff61790d1e05ef15baa7cbe26264d2 Mon Sep 17 00:00:00 2001 From: mertalev <101130780+mertalev@users.noreply.github.com> Date: Sun, 22 Feb 2026 02:45:16 -0500 Subject: [PATCH 6/6] handle suspend --- ios/Classes/VideoProxyServer.swift | 29 +++++++++++++++++++++-------- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/ios/Classes/VideoProxyServer.swift b/ios/Classes/VideoProxyServer.swift index 1081e95..53a0ec1 100644 --- a/ios/Classes/VideoProxyServer.swift +++ b/ios/Classes/VideoProxyServer.swift @@ -1,5 +1,6 @@ import Foundation import Network +import UIKit @available(iOS 15.0, *) public final class VideoProxyServer: @unchecked Sendable { @@ -11,12 +12,24 @@ public final class VideoProxyServer: @unchecked Sendable { private var port: UInt16 = 0 private var activeConnections = Set() - private init() {} + private init() { + NotificationCenter.default.addObserver( + self, + selector: #selector(onForeground), + name: UIApplication.willEnterForegroundNotification, + object: nil + ) + } public var isRunning: Bool { listener?.state == .ready } + @objc private func onForeground() { + guard session != nil else { return } + try? start() + } + public func proxyURL(for originalURL: URL) -> URL? { - guard session != nil, let _ = try? start() else { return nil } + guard session != nil, (isRunning || (try? start()) != nil) else { return nil } guard let scheme = originalURL.scheme, let host = originalURL.host else { return nil } let hostPort = originalURL.port.map { "\(host):\($0)" } ?? host var components = URLComponents() @@ -29,17 +42,17 @@ public final class VideoProxyServer: @unchecked Sendable { } private func start() throws { - guard !isRunning else { return } listener?.cancel() listener = nil - let nwListener = try NWListener(using: .tcp, on: .any) + + let port = port > 0 ? NWEndpoint.Port(rawValue: port) ?? .any : .any + let nwListener = try NWListener(using: .tcp, on: port) let semaphore = DispatchSemaphore(value: 0) var startError: Error? - nwListener.stateUpdateHandler = { [weak self] state in + nwListener.stateUpdateHandler = { [weak self, weak nwListener] state in switch state { - case .ready: self?.port = nwListener.port?.rawValue ?? 0; semaphore.signal() - case .failed(let e): startError = e; semaphore.signal() - case .cancelled: self?.port = 0 + case .ready: self?.port = nwListener?.port?.rawValue ?? 0; semaphore.signal() + case .failed(let e), .waiting(let e): startError = e; semaphore.signal() default: break } }