import Foundation import AVFoundation import SwiftSignalKit import UniversalMediaPlayer import Postbox import TelegramCore import AsyncDisplayKit import AccountContext import TelegramAudio import Display import PhotoResources import TelegramVoip import RangeSet import AppBundle import ManagedFile import FFMpegBinding import RangeSet private func parseRange(from rangeString: String) -> Range? { guard rangeString.hasPrefix("bytes=") else { return nil } let rangeValues = rangeString.dropFirst("bytes=".count).split(separator: "-") guard rangeValues.count == 2, let start = Int(rangeValues[0]), let end = Int(rangeValues[1]) else { return nil } return start ..< end } final class HLSJSServerSource: SharedHLSServer.Source { let id: String let postbox: Postbox let userLocation: MediaResourceUserLocation let playlistFiles: [Int: FileMediaReference] let qualityFiles: [Int: FileMediaReference] private var playlistFetchDisposables: [Int: Disposable] = [:] init(accountId: Int64, fileId: Int64, postbox: Postbox, userLocation: MediaResourceUserLocation, playlistFiles: [Int: FileMediaReference], qualityFiles: [Int: FileMediaReference]) { self.id = "\(UInt64(bitPattern: accountId))_\(fileId)" self.postbox = postbox self.userLocation = userLocation self.playlistFiles = playlistFiles self.qualityFiles = qualityFiles } deinit { for (_, disposable) in self.playlistFetchDisposables { disposable.dispose() } } func arbitraryFileData(path: String) -> Signal<(data: Data, contentType: String)?, NoError> { return Signal { subscriber in let bundle = Bundle(for: HLSJSServerSource.self) let bundlePath = bundle.bundlePath + "/HlsBundle.bundle" if let data = try? Data(contentsOf: URL(fileURLWithPath: bundlePath + "/" + path)) { let mimeType: String let pathExtension = (path as NSString).pathExtension if pathExtension == "html" { mimeType = "text/html" } else if pathExtension == "html" { mimeType = "application/javascript" } else { mimeType = "application/octet-stream" } subscriber.putNext((data, mimeType)) } else { subscriber.putNext(nil) } subscriber.putCompletion() return EmptyDisposable } } func masterPlaylistData() -> Signal { var playlistString: String = "" playlistString.append("#EXTM3U\n") for (quality, file) in self.qualityFiles.sorted(by: { $0.key > $1.key }) { let width = file.media.dimensions?.width ?? 1280 let height = file.media.dimensions?.height ?? 720 let bandwidth: Int if let size = file.media.size, let duration = file.media.duration, duration != 0.0 { bandwidth = Int(Double(size) / duration) * 8 } else { bandwidth = 1000000 } playlistString.append("#EXT-X-STREAM-INF:BANDWIDTH=\(bandwidth),RESOLUTION=\(width)x\(height)\n") playlistString.append("hls_level_\(quality).m3u8\n") } return .single(playlistString) } func playlistData(quality: Int) -> Signal { guard let playlistFile = self.playlistFiles[quality] else { return .never() } if self.playlistFetchDisposables[quality] == nil { self.playlistFetchDisposables[quality] = freeMediaFileResourceInteractiveFetched(postbox: self.postbox, userLocation: self.userLocation, fileReference: playlistFile, resource: playlistFile.media.resource).startStrict() } return self.postbox.mediaBox.resourceData(playlistFile.media.resource) |> filter { data in return data.complete } |> map { data -> String in guard data.complete else { return "" } guard let data = try? Data(contentsOf: URL(fileURLWithPath: data.path)) else { return "" } guard var playlistString = String(data: data, encoding: .utf8) else { return "" } let partRegex = try! NSRegularExpression(pattern: "mtproto:([\\d]+)", options: []) let results = partRegex.matches(in: playlistString, range: NSRange(playlistString.startIndex..., in: playlistString)) for result in results.reversed() { if let range = Range(result.range, in: playlistString) { if let fileIdRange = Range(result.range(at: 1), in: playlistString) { let fileId = String(playlistString[fileIdRange]) playlistString.replaceSubrange(range, with: "partfile\(fileId).mp4") } } } return playlistString } } func partData(index: Int, quality: Int) -> Signal { return .never() } func fileData(id: Int64, range: Range) -> Signal<(TempBoxFile, Range, Int)?, NoError> { guard let (quality, file) = self.qualityFiles.first(where: { $0.value.media.fileId.id == id }) else { return .single(nil) } guard let size = file.media.size else { return .single(nil) } let postbox = self.postbox let userLocation = self.userLocation let playlistPreloadRange = self.playlistData(quality: quality) |> mapToSignal { playlistString -> Signal?, NoError> in var durations: [Int] = [] var byteRanges: [Range] = [] let extinfRegex = try! NSRegularExpression(pattern: "EXTINF:(\\d+)", options: []) let byteRangeRegex = try! NSRegularExpression(pattern: "EXT-X-BYTERANGE:(\\d+)@(\\d+)", options: []) let extinfResults = extinfRegex.matches(in: playlistString, range: NSRange(playlistString.startIndex..., in: playlistString)) for result in extinfResults { if let durationRange = Range(result.range(at: 1), in: playlistString) { if let duration = Int(String(playlistString[durationRange])) { durations.append(duration) } } } let byteRangeResults = byteRangeRegex.matches(in: playlistString, range: NSRange(playlistString.startIndex..., in: playlistString)) for result in byteRangeResults { if let lengthRange = Range(result.range(at: 1), in: playlistString), let upperBoundRange = Range(result.range(at: 2), in: playlistString) { if let length = Int(String(playlistString[lengthRange])), let lowerBound = Int(String(playlistString[upperBoundRange])) { byteRanges.append(lowerBound ..< (lowerBound + length)) } } } let prefixSeconds = 10 var rangeUpperBound: Int64 = 0 if durations.count == byteRanges.count { var remainingSeconds = prefixSeconds for i in 0 ..< durations.count { if remainingSeconds <= 0 { break } let duration = durations[i] let byteRange = byteRanges[i] remainingSeconds -= duration rangeUpperBound = max(rangeUpperBound, Int64(byteRange.upperBound)) } } if rangeUpperBound != 0 { return .single(0 ..< rangeUpperBound) } else { return .single(nil) } } let mappedRange: Range = Int64(range.lowerBound) ..< Int64(range.upperBound) let queue = postbox.mediaBox.dataQueue let fetchFromRemote: Signal<(TempBoxFile, Range, Int)?, NoError> = playlistPreloadRange |> mapToSignal { preloadRange -> Signal<(TempBoxFile, Range, Int)?, NoError> in return Signal { subscriber in let partialFile = TempBox.shared.tempFile(fileName: "data") if let cachedData = postbox.mediaBox.internal_resourceData(id: file.media.resource.id, size: size, in: Int64(range.lowerBound) ..< Int64(range.upperBound)) { #if DEBUG print("Fetched \(quality)p part from cache") #endif let outputFile = ManagedFile(queue: nil, path: partialFile.path, mode: .readwrite) if let outputFile { let blockSize = 128 * 1024 var tempBuffer = Data(count: blockSize) var blockOffset = 0 while blockOffset < cachedData.length { let currentBlockSize = min(cachedData.length - blockOffset, blockSize) tempBuffer.withUnsafeMutableBytes { bytes -> Void in let _ = cachedData.file.read(bytes.baseAddress!, currentBlockSize) let _ = outputFile.write(bytes.baseAddress!, count: currentBlockSize) } blockOffset += blockSize } outputFile._unsafeClose() subscriber.putNext((partialFile, 0 ..< cachedData.length, Int(size))) subscriber.putCompletion() } else { #if DEBUG print("Error writing cached file to disk") #endif } return EmptyDisposable } guard let fetchResource = postbox.mediaBox.fetchResource else { return EmptyDisposable } let location = MediaResourceStorageLocation(userLocation: userLocation, reference: file.resourceReference(file.media.resource)) let params = MediaResourceFetchParameters( tag: TelegramMediaResourceFetchTag(statsCategory: .video, userContentType: .video), info: TelegramCloudMediaResourceFetchInfo(reference: file.resourceReference(file.media.resource), preferBackgroundReferenceRevalidation: true, continueInBackground: true), location: location, contentType: .video, isRandomAccessAllowed: true ) let completeFile = TempBox.shared.tempFile(fileName: "data") let metaFile = TempBox.shared.tempFile(fileName: "data") guard let fileContext = MediaBoxFileContextV2Impl( queue: queue, manager: postbox.mediaBox.dataFileManager, storageBox: nil, resourceId: file.media.resource.id.stringRepresentation.data(using: .utf8)!, path: completeFile.path, partialPath: partialFile.path, metaPath: metaFile.path ) else { return EmptyDisposable } let fetchDisposable = fileContext.fetched( range: mappedRange, priority: .default, fetch: { intervals in return fetchResource(file.media.resource, intervals, params) }, error: { _ in }, completed: { } ) #if DEBUG let startTime = CFAbsoluteTimeGetCurrent() #endif let dataDisposable = fileContext.data( range: mappedRange, waitUntilAfterInitialFetch: true, next: { result in if result.complete { #if DEBUG let fetchTime = CFAbsoluteTimeGetCurrent() - startTime print("Fetching \(quality)p part took \(fetchTime * 1000.0) ms") #endif if let data = try? Data(contentsOf: URL(fileURLWithPath: partialFile.path), options: .alwaysMapped) { let subData = data.subdata(in: Int(result.offset) ..< Int(result.offset + result.size)) postbox.mediaBox.storeResourceData(file.media.resource.id, range: Int64(range.lowerBound) ..< Int64(range.upperBound), data: subData) } subscriber.putNext((partialFile, Int(result.offset) ..< Int(result.offset + result.size), Int(size))) subscriber.putCompletion() } } ) return ActionDisposable { queue.async { fetchDisposable.dispose() dataDisposable.dispose() fileContext.cancelFullRangeFetches() TempBox.shared.dispose(completeFile) TempBox.shared.dispose(metaFile) } } } |> runOn(queue) } return fetchFromRemote } } protocol HLSJSContext: AnyObject { func evaluateJavaScript(_ string: String) } private final class SharedHLSVideoJSContext: NSObject { private final class ContextReference { weak var contentNode: HLSVideoJSNativeContentNode? init(contentNode: HLSVideoJSNativeContentNode?) { self.contentNode = contentNode } } private enum ResponseError { case badRequest case notFound case internalServerError var httpStatus: (Int, String) { switch self { case .badRequest: return (400, "Bad Request") case .notFound: return (404, "Not Found") case .internalServerError: return (500, "Internal Server Error") } } } static let shared: SharedHLSVideoJSContext = SharedHLSVideoJSContext() private var contextReferences: [Int: ContextReference] = [:] var jsContext: HLSJSContext? var videoElements: [Int: VideoElement] = [:] var mediaSources: [Int: MediaSource] = [:] var sourceBuffers: [Int: SourceBuffer] = [:] private var isJsContextReady: Bool = false private var pendingInitializeInstanceIds: [(id: Int, urlPrefix: String)] = [] private var tempTasks: [Int: URLSessionTask] = [:] private var emptyTimer: Foundation.Timer? override init() { super.init() } deinit { self.emptyTimer?.invalidate() } private func createJsContext() { let handleScriptMessage: ([String: Any]) -> Void = { [weak self] message in Queue.mainQueue().async { guard let self else { return } guard let eventName = message["event"] as? String else { return } switch eventName { case "windowOnLoad": self.isJsContextReady = true self.initializePendingInstances() case "bridgeInvoke": guard let eventData = message["data"] as? [String: Any] else { return } guard let bridgeId = eventData["bridgeId"] as? Int else { return } guard let callbackId = eventData["callbackId"] as? Int else { return } guard let className = eventData["className"] as? String else { return } guard let methodName = eventData["methodName"] as? String else { return } guard let params = eventData["params"] as? [String: Any] else { return } self.bridgeInvoke( bridgeId: bridgeId, className: className, methodName: methodName, params: params, completion: { [weak self] result in guard let self else { return } let jsonResult = try! JSONSerialization.data(withJSONObject: result) let jsonResultString = String(data: jsonResult, encoding: .utf8)! self.jsContext?.evaluateJavaScript("window.bridgeInvokeCallback(\(callbackId), \(jsonResultString));") } ) case "playerStatus": guard let instanceId = message["instanceId"] as? Int else { return } guard let instance = self.contextReferences[instanceId]?.contentNode else { self.contextReferences.removeValue(forKey: instanceId) return } guard let eventData = message["data"] as? [String: Any] else { return } instance.onPlayerStatusUpdated(eventData: eventData) case "playerCurrentTime": guard let instanceId = message["instanceId"] as? Int else { return } guard let instance = self.contextReferences[instanceId]?.contentNode else { self.contextReferences.removeValue(forKey: instanceId) self.cleanupContextsIfEmpty() return } guard let eventData = message["data"] as? [String: Any] else { return } guard let value = eventData["value"] as? Double else { return } instance.onPlayerUpdatedCurrentTime(currentTime: value) var bandwidthEstimate = eventData["bandwidthEstimate"] as? Double if let bandwidthEstimateValue = bandwidthEstimate, bandwidthEstimateValue.isNaN || bandwidthEstimateValue.isInfinite { bandwidthEstimate = nil } HLSVideoJSNativeContentNode.sharedBandwidthEstimate = bandwidthEstimate default: break } } } self.isJsContextReady = false //#if DEBUG self.jsContext = WebViewNativeJSContextImpl(handleScriptMessage: handleScriptMessage) /*#else self.jsContext = WebViewHLSJSContextImpl(handleScriptMessage: handleScriptMessage) #endif*/ } private func disposeJsContext() { if let _ = self.jsContext { self.jsContext = nil } self.isJsContextReady = false self.videoElements.removeAll() self.mediaSources.removeAll() self.sourceBuffers.removeAll() } private func bridgeInvoke( bridgeId: Int, className: String, methodName: String, params: [String: Any], completion: @escaping ([String: Any]) -> Void ) { if (className == "VideoElement") { if (methodName == "constructor") { guard let instanceId = params["instanceId"] as? Int else { assertionFailure() return } let videoElement = VideoElement(instanceId: instanceId) SharedHLSVideoJSContext.shared.videoElements[bridgeId] = videoElement completion([:]) } else if (methodName == "setMediaSource") { guard let instanceId = params["instanceId"] as? Int else { assertionFailure() return } guard let mediaSourceId = params["mediaSourceId"] as? Int else { assertionFailure() return } guard let (_, videoElement) = SharedHLSVideoJSContext.shared.videoElements.first(where: { $0.value.instanceId == instanceId }) else { return } videoElement.mediaSourceId = mediaSourceId } else if (methodName == "setCurrentTime") { guard let instanceId = params["instanceId"] as? Int else { assertionFailure() return } guard let currentTime = params["currentTime"] as? Double else { assertionFailure() return } if let instance = self.contextReferences[instanceId]?.contentNode { instance.onSetCurrentTime(timestamp: currentTime) } completion([:]) } else if (methodName == "setPlaybackRate") { guard let instanceId = params["instanceId"] as? Int else { assertionFailure() return } guard let playbackRate = params["playbackRate"] as? Double else { assertionFailure() return } if let instance = self.contextReferences[instanceId]?.contentNode { instance.onSetPlaybackRate(playbackRate: playbackRate) } completion([:]) } else if (methodName == "play") { guard let instanceId = params["instanceId"] as? Int else { assertionFailure() return } if let instance = self.contextReferences[instanceId]?.contentNode { instance.onPlay() } completion([:]) } else if (methodName == "pause") { guard let instanceId = params["instanceId"] as? Int else { assertionFailure() return } if let instance = self.contextReferences[instanceId]?.contentNode { instance.onPause() } completion([:]) } } else if (className == "MediaSource") { if (methodName == "constructor") { let mediaSource = MediaSource() SharedHLSVideoJSContext.shared.mediaSources[bridgeId] = mediaSource completion([:]) } else if (methodName == "setDuration") { guard let duration = params["duration"] as? Double else { assertionFailure() return } guard let mediaSource = SharedHLSVideoJSContext.shared.mediaSources[bridgeId] else { assertionFailure() return } var durationUpdated = false if mediaSource.duration != duration { mediaSource.duration = duration durationUpdated = true } guard let (_, videoElement) = SharedHLSVideoJSContext.shared.videoElements.first(where: { $0.value.mediaSourceId == bridgeId }) else { return } if let instance = self.contextReferences[videoElement.instanceId]?.contentNode { if durationUpdated { instance.onMediaSourceDurationUpdated() } } completion([:]) } else if (methodName == "updateSourceBuffers") { guard let ids = params["ids"] as? [Int] else { assertionFailure() return } guard let mediaSource = SharedHLSVideoJSContext.shared.mediaSources[bridgeId] else { assertionFailure() return } mediaSource.sourceBufferIds = ids guard let (_, videoElement) = SharedHLSVideoJSContext.shared.videoElements.first(where: { $0.value.mediaSourceId == bridgeId }) else { return } if let instance = self.contextReferences[videoElement.instanceId]?.contentNode { instance.onMediaSourceBuffersUpdated() } } } else if (className == "SourceBuffer") { if (methodName == "constructor") { guard let mediaSourceId = params["mediaSourceId"] as? Int else { assertionFailure() return } guard let mimeType = params["mimeType"] as? String else { assertionFailure() return } let sourceBuffer = SourceBuffer(mediaSourceId: mediaSourceId, mimeType: mimeType) SharedHLSVideoJSContext.shared.sourceBuffers[bridgeId] = sourceBuffer completion([:]) } else if (methodName == "appendBuffer") { guard let base64Data = params["data"] as? String else { assertionFailure() return } guard let data = Data(base64Encoded: base64Data.data(using: .utf8)!) else { assertionFailure() return } guard let sourceBuffer = SharedHLSVideoJSContext.shared.sourceBuffers[bridgeId] else { assertionFailure() return } sourceBuffer.appendBuffer(data: data, completion: { bufferedRanges in completion(["ranges": serializeRanges(bufferedRanges)]) }) } else if methodName == "remove" { guard let start = params["start"] as? Double, let end = params["end"] as? Double else { assertionFailure() return } guard let sourceBuffer = SharedHLSVideoJSContext.shared.sourceBuffers[bridgeId] else { assertionFailure() return } sourceBuffer.remove(start: start, end: end, completion: { bufferedRanges in completion(["ranges": serializeRanges(bufferedRanges)]) }) } else if methodName == "abort" { guard let sourceBuffer = SharedHLSVideoJSContext.shared.sourceBuffers[bridgeId] else { assertionFailure() return } sourceBuffer.abortOperation() completion([:]) } } else if className == "XMLHttpRequest" { if methodName == "load" { guard let id = params["id"] as? Int else { assertionFailure() return } guard let url = params["url"] as? String else { assertionFailure() return } guard let requestHeaders = params["requestHeaders"] as? [String: String] else { assertionFailure() return } guard let parsedUrl = URL(string: url) else { assertionFailure() return } guard let host = parsedUrl.host, host == "server" else { completion(["error": 1]) return } var requestPath = parsedUrl.path if requestPath.hasPrefix("/") { requestPath = String(requestPath[requestPath.index(after: requestPath.startIndex) ..< requestPath.endIndex]) } guard let firstSlash = requestPath.range(of: "/") else { completion(["error": 1]) return } var requestRange: Range? if let rangeString = requestHeaders["Range"] { requestRange = parseRange(from: rangeString) } let streamId = String(requestPath[requestPath.startIndex ..< firstSlash.lowerBound]) var handlerFound = false for (_, contextReference) in self.contextReferences { if let context = contextReference.contentNode, let source = context.playerSource, source.id == streamId { handlerFound = true let filePath = String(requestPath[firstSlash.upperBound...]) if filePath == "master.m3u8" { let _ = (source.masterPlaylistData() |> take(1)).start(next: { result in SharedHLSVideoJSContext.sendResponseAndClose(id: id, data: result.data(using: .utf8)!, completion: completion) }) } else if filePath.hasPrefix("hls_level_") && filePath.hasSuffix(".m3u8") { guard let levelIndex = Int(String(filePath[filePath.index(filePath.startIndex, offsetBy: "hls_level_".count) ..< filePath.index(filePath.endIndex, offsetBy: -".m3u8".count)])) else { SharedHLSVideoJSContext.sendErrorAndClose(id: id, error: .notFound, completion: completion) return } let _ = (source.playlistData(quality: levelIndex) |> deliverOn(.mainQueue()) |> take(1)).start(next: { result in SharedHLSVideoJSContext.sendResponseAndClose(id: id, data: result.data(using: .utf8)!, completion: completion) }) } else if filePath.hasPrefix("partfile") && filePath.hasSuffix(".mp4") { let fileId = String(filePath[filePath.index(filePath.startIndex, offsetBy: "partfile".count) ..< filePath.index(filePath.endIndex, offsetBy: -".mp4".count)]) guard let fileIdValue = Int64(fileId) else { SharedHLSVideoJSContext.sendErrorAndClose(id: id, error: .notFound, completion: completion) return } guard let requestRange else { SharedHLSVideoJSContext.sendErrorAndClose(id: id, error: .badRequest, completion: completion) return } let _ = (source.fileData(id: fileIdValue, range: requestRange.lowerBound ..< requestRange.upperBound + 1) |> deliverOn(.mainQueue()) //|> timeout(5.0, queue: self.queue, alternate: .single(nil)) |> take(1)).start(next: { result in if let (tempFile, tempFileRange, totalSize) = result { SharedHLSVideoJSContext.sendResponseFileAndClose(id: id, file: tempFile, fileRange: tempFileRange, range: requestRange, totalSize: totalSize, completion: completion) } else { SharedHLSVideoJSContext.sendErrorAndClose(id: id, error: .internalServerError, completion: completion) } }) } break } } if (!handlerFound) { completion(["error": 1]) } } else if methodName == "abort" { guard let id = params["id"] as? Int else { assertionFailure() return } if let task = self.tempTasks.removeValue(forKey: id) { task.cancel() } completion([:]) } } } private static func sendErrorAndClose(id: Int, error: ResponseError, completion: @escaping ([String: Any]) -> Void) { let (code, status) = error.httpStatus completion([ "status": code, "statusText": status, "responseData": "", "responseHeaders": [ "Content-Type": "text/html" ] as [String: String] ]) } private static func sendResponseAndClose(id: Int, data: Data, contentType: String = "application/octet-stream", completion: @escaping ([String: Any]) -> Void) { completion([ "status": 200, "statusText": "OK", "responseData": data.base64EncodedString(), "responseHeaders": [ "Content-Type": contentType, "Content-Length": "\(data.count)" ] as [String: String] ]) } private static func sendResponseFileAndClose(id: Int, file: TempBoxFile, fileRange: Range, range: Range, totalSize: Int, completion: @escaping ([String: Any]) -> Void) { Queue.concurrentDefaultQueue().async { if let data = try? Data(contentsOf: URL(fileURLWithPath: file.path), options: .mappedIfSafe).subdata(in: fileRange) { completion([ "status": 200, "statusText": "OK", "responseData": data.base64EncodedString(), "responseHeaders": [ "Content-Type": "application/octet-stream", "Content-Range": "bytes \(range.lowerBound)-\(range.upperBound)/\(totalSize)", "Content-Length": "\(fileRange.upperBound - fileRange.lowerBound)" ] as [String: String] ]) } else { SharedHLSVideoJSContext.sendErrorAndClose(id: id, error: .internalServerError, completion: completion) } } } func register(context: HLSVideoJSNativeContentNode) -> Disposable { let contextInstanceId = context.instanceId self.contextReferences[contextInstanceId] = ContextReference(contentNode: context) if self.jsContext == nil { self.createJsContext() } if let emptyTimer = self.emptyTimer { self.emptyTimer = nil emptyTimer.invalidate() } return ActionDisposable { [weak self, weak context] in Queue.mainQueue().async { guard let self else { return } self.pendingInitializeInstanceIds.removeAll(where: { $0.id == contextInstanceId }) if let current = self.contextReferences[contextInstanceId] { if let value = current.contentNode { if let context, context === value { self.contextReferences.removeValue(forKey: contextInstanceId) } } else { self.contextReferences.removeValue(forKey: contextInstanceId) } } self.jsContext?.evaluateJavaScript("window.hlsPlayer_destroyInstance(\(contextInstanceId));") self.cleanupContextsIfEmpty() } } } private func cleanupContextsIfEmpty() { if self.contextReferences.isEmpty { if self.emptyTimer == nil { let disposeTimeout: Double #if DEBUG disposeTimeout = 0.5 #else disposeTimeout = 10.0 #endif self.emptyTimer = Foundation.Timer.scheduledTimer(withTimeInterval: disposeTimeout, repeats: false, block: { [weak self] timer in guard let self else { return } if self.emptyTimer === timer { self.emptyTimer = nil } if self.contextReferences.isEmpty { self.disposeJsContext() } }) } } } func initializeWhenReady(context: HLSVideoJSNativeContentNode, urlPrefix: String) { self.pendingInitializeInstanceIds.append((context.instanceId, urlPrefix)) if self.isJsContextReady { self.initializePendingInstances() } } private func initializePendingInstances() { let pendingInitializeInstanceIds = self.pendingInitializeInstanceIds self.pendingInitializeInstanceIds.removeAll() if pendingInitializeInstanceIds.isEmpty { return } let isDebug: Bool #if DEBUG isDebug = true #else isDebug = false #endif var userScriptJs = "" for (instanceId, urlPrefix) in pendingInitializeInstanceIds { guard let _ = self.contextReferences[instanceId]?.contentNode else { self.contextReferences.removeValue(forKey: instanceId) self.cleanupContextsIfEmpty() continue } userScriptJs.append("window.hlsPlayer_makeInstance(\(instanceId));\n") userScriptJs.append(""" window.hlsPlayer_instances[\(instanceId)].playerInitialize({ 'debug': \(isDebug), 'bandwidthEstimate': \(HLSVideoJSNativeContentNode.sharedBandwidthEstimate ?? 500000.0), 'urlPrefix': '\(urlPrefix)' });\n """) } self.jsContext?.evaluateJavaScript(userScriptJs) } } final class HLSVideoJSNativeContentNode: ASDisplayNode, UniversalVideoContentNode { fileprivate struct Level { let bitrate: Int let width: Int let height: Int init(bitrate: Int, width: Int, height: Int) { self.bitrate = bitrate self.width = width self.height = height } } private struct VideoQualityState: Equatable { var current: Int var preferred: UniversalVideoContentVideoQuality var available: [Int] init(current: Int, preferred: UniversalVideoContentVideoQuality, available: [Int]) { self.current = current self.preferred = preferred self.available = available } } fileprivate static var sharedBandwidthEstimate: Double? private let postbox: Postbox private let userLocation: MediaResourceUserLocation private let fileReference: FileMediaReference private let approximateDuration: Double private let intrinsicDimensions: CGSize private var enableSound: Bool private let codecConfiguration: HLSCodecConfiguration private let audioSessionManager: ManagedAudioSession private let audioSessionDisposable = MetaDisposable() private var hasAudioSession = false fileprivate let playerSource: HLSJSServerSource? private var serverDisposable: Disposable? private let playbackCompletedListeners = Bag<() -> Void>() private var initializedStatus = false private var statusValue = MediaPlayerStatus(generationTimestamp: 0.0, duration: 0.0, dimensions: CGSize(), timestamp: 0.0, baseRate: 1.0, seekId: 0, status: .paused, soundEnabled: true) private var isBuffering = false private var seekId: Int = 0 private let _status = ValuePromise() var status: Signal { return self._status.get() } private let _bufferingStatus = Promise<(RangeSet, Int64)?>() var bufferingStatus: Signal<(RangeSet, Int64)?, NoError> { return self._bufferingStatus.get() } var isNativePictureInPictureActive: Signal { return .single(false) } private let _ready = Promise() var ready: Signal { return self._ready.get() } private let _preloadCompleted = ValuePromise() var preloadCompleted: Signal { return self._preloadCompleted.get() } private static var nextInstanceId: Int = 0 fileprivate let instanceId: Int private let imageNode: TransformImageNode private let player: ChunkMediaPlayer private let playerNode: MediaPlayerNode private let fetchDisposable = MetaDisposable() private var dimensions: CGSize? private let dimensionsPromise = ValuePromise(CGSize()) private var validLayout: (size: CGSize, actualSize: CGSize)? private var statusTimer: Foundation.Timer? private var preferredVideoQuality: UniversalVideoContentVideoQuality = .auto fileprivate var playerIsReady: Bool = false fileprivate var playerIsPlaying: Bool = false fileprivate var playerRate: Double = 0.0 fileprivate var playerDefaultRate: Double = 1.0 fileprivate var playerTime: Double = 0.0 fileprivate var playerAvailableLevels: [Int: Level] = [:] fileprivate var playerCurrentLevelIndex: Int? private var videoQualityStateValue: VideoQualityState? private let videoQualityStatePromise = Promise(nil) private var hasRequestedPlayerLoad: Bool = false private var requestedBaseRate: Double = 1.0 private var requestedLevelIndex: Int? private var didBecomeActiveObserver: NSObjectProtocol? private var willResignActiveObserver: NSObjectProtocol? private let chunkPlayerPartsState = Promise(ChunkMediaPlayerPartsState(duration: nil, content: .parts([]))) private var sourceBufferStateDisposable: Disposable? private var playerStatusDisposable: Disposable? private var contextDisposable: Disposable? init(context: AccountContext, postbox: Postbox, audioSessionManager: ManagedAudioSession, userLocation: MediaResourceUserLocation, fileReference: FileMediaReference, streamVideo: Bool, loopVideo: Bool, enableSound: Bool, baseRate: Double, fetchAutomatically: Bool, onlyFullSizeThumbnail: Bool, useLargeThumbnail: Bool, autoFetchFullSizeThumbnail: Bool, codecConfiguration: HLSCodecConfiguration) { self.instanceId = HLSVideoJSNativeContentNode.nextInstanceId HLSVideoJSNativeContentNode.nextInstanceId += 1 self.postbox = postbox self.fileReference = fileReference self.approximateDuration = fileReference.media.duration ?? 0.0 self.audioSessionManager = audioSessionManager self.userLocation = userLocation self.requestedBaseRate = baseRate self.enableSound = enableSound self.codecConfiguration = codecConfiguration if var dimensions = fileReference.media.dimensions { if let thumbnail = fileReference.media.previewRepresentations.first { let dimensionsVertical = dimensions.width < dimensions.height let thumbnailVertical = thumbnail.dimensions.width < thumbnail.dimensions.height if dimensionsVertical != thumbnailVertical { dimensions = PixelDimensions(width: dimensions.height, height: dimensions.width) } } self.dimensions = dimensions.cgSize } else { self.dimensions = CGSize(width: 128.0, height: 128.0) } self.imageNode = TransformImageNode() var playerSource: HLSJSServerSource? if let qualitySet = HLSQualitySet(baseFile: fileReference, codecConfiguration: codecConfiguration) { let playerSourceValue = HLSJSServerSource(accountId: context.account.id.int64, fileId: fileReference.media.fileId.id, postbox: postbox, userLocation: userLocation, playlistFiles: qualitySet.playlistFiles, qualityFiles: qualitySet.qualityFiles) playerSource = playerSourceValue } self.playerSource = playerSource let mediaDimensions = fileReference.media.dimensions?.cgSize ?? CGSize(width: 480.0, height: 320.0) var intrinsicDimensions = mediaDimensions.aspectFittedOrSmaller(CGSize(width: 1280.0, height: 1280.0)) intrinsicDimensions.width = floor(intrinsicDimensions.width / UIScreenScale) intrinsicDimensions.height = floor(intrinsicDimensions.height / UIScreenScale) self.intrinsicDimensions = intrinsicDimensions self.playerNode = MediaPlayerNode() var onSeeked: (() -> Void)? self.player = ChunkMediaPlayerV2( params: ChunkMediaPlayerV2.MediaDataReaderParams(context: context), audioContext: ChunkMediaPlayerV2.AudioContext(audioSessionManager: audioSessionManager), source: .externalParts(self.chunkPlayerPartsState.get()), video: true, enableSound: self.enableSound, baseRate: baseRate, onSeeked: { onSeeked?() }, playerNode: self.playerNode ) super.init() self.contextDisposable = SharedHLSVideoJSContext.shared.register(context: self) self.playerNode.frame = CGRect(origin: CGPoint(), size: self.intrinsicDimensions) var didProcessFramesToDisplay = false self.playerNode.isHidden = true self.playerNode.hasSentFramesToDisplay = { [weak self] in guard let self, !didProcessFramesToDisplay else { return } didProcessFramesToDisplay = true self.playerNode.isHidden = false } let thumbnailVideoReference = HLSVideoContent.minimizedHLSQuality(file: fileReference, codecConfiguration: self.codecConfiguration)?.file ?? fileReference self.imageNode.setSignal(internalMediaGridMessageVideo(postbox: postbox, userLocation: userLocation, videoReference: thumbnailVideoReference, previewSourceFileReference: fileReference, imageReference: nil, onlyFullSize: onlyFullSizeThumbnail, useLargeThumbnail: useLargeThumbnail, autoFetchFullSizeThumbnail: autoFetchFullSizeThumbnail || fileReference.media.isInstantVideo) |> map { [weak self] getSize, getData in Queue.mainQueue().async { if let strongSelf = self, strongSelf.dimensions == nil { if let dimensions = getSize() { strongSelf.dimensions = dimensions strongSelf.dimensionsPromise.set(dimensions) if let validLayout = strongSelf.validLayout { strongSelf.updateLayout(size: validLayout.size, actualSize: validLayout.actualSize, transition: .immediate) } } } } return getData }) self.addSubnode(self.imageNode) self.addSubnode(self.playerNode) self.imageNode.imageUpdated = { [weak self] _ in self?._ready.set(.single(Void())) } self._bufferingStatus.set(.single(nil)) self.didBecomeActiveObserver = NotificationCenter.default.addObserver(forName: UIApplication.willEnterForegroundNotification, object: nil, queue: nil, using: { [weak self] _ in let _ = self }) self.willResignActiveObserver = NotificationCenter.default.addObserver(forName: UIApplication.didEnterBackgroundNotification, object: nil, queue: nil, using: { [weak self] _ in let _ = self }) self.playerStatusDisposable = (self.player.status |> deliverOnMainQueue).startStrict(next: { [weak self] status in guard let self else { return } self.updatePlayerStatus(status: status) }) self.statusTimer = Foundation.Timer.scheduledTimer(withTimeInterval: 1.0 / 25.0, repeats: true, block: { [weak self] _ in guard let self else { return } self.updateStatus() }) onSeeked = { [weak self] in Queue.mainQueue().async { guard let self else { return } SharedHLSVideoJSContext.shared.jsContext?.evaluateJavaScript("window.hlsPlayer_instances[\(self.instanceId)].playerNotifySeekedOnNextStatusUpdate();") } } if let playerSource { SharedHLSVideoJSContext.shared.initializeWhenReady(context: self, urlPrefix: "http://server/\(playerSource.id)/") } } deinit { if let didBecomeActiveObserver = self.didBecomeActiveObserver { NotificationCenter.default.removeObserver(didBecomeActiveObserver) } if let willResignActiveObserver = self.willResignActiveObserver { NotificationCenter.default.removeObserver(willResignActiveObserver) } self.serverDisposable?.dispose() self.audioSessionDisposable.dispose() self.statusTimer?.invalidate() self.sourceBufferStateDisposable?.dispose() self.playerStatusDisposable?.dispose() self.contextDisposable?.dispose() } fileprivate func onPlayerStatusUpdated(eventData: [String: Any]) { if let isReady = eventData["isReady"] as? Bool { self.playerIsReady = isReady } else { self.playerIsReady = false } if let isPlaying = eventData["isPlaying"] as? Bool { self.playerIsPlaying = isPlaying } else { self.playerIsPlaying = false } if let rate = eventData["rate"] as? Double { self.playerRate = rate } else { self.playerRate = 0.0 } if let defaultRate = eventData["defaultRate"] as? Double { self.playerDefaultRate = defaultRate } else { self.playerDefaultRate = 0.0 } if let levels = eventData["levels"] as? [[String: Any]] { self.playerAvailableLevels.removeAll() for level in levels { guard let levelIndex = level["index"] as? Int else { continue } guard let levelBitrate = level["bitrate"] as? Int else { continue } guard let levelWidth = level["width"] as? Int else { continue } guard let levelHeight = level["height"] as? Int else { continue } self.playerAvailableLevels[levelIndex] = HLSVideoJSNativeContentNode.Level( bitrate: levelBitrate, width: levelWidth, height: levelHeight ) } } else { self.playerAvailableLevels.removeAll() } if let currentLevel = eventData["currentLevel"] as? Int { if self.playerAvailableLevels[currentLevel] != nil { self.playerCurrentLevelIndex = currentLevel } else { self.playerCurrentLevelIndex = nil } } else { self.playerCurrentLevelIndex = nil } self.updateVideoQualityState() if self.playerIsReady { if !self.hasRequestedPlayerLoad { if !self.playerAvailableLevels.isEmpty { var selectedLevelIndex: Int? if let qualityFiles = HLSQualitySet(baseFile: self.fileReference, codecConfiguration: self.codecConfiguration)?.qualityFiles.values, let maxQualityFile = qualityFiles.max(by: { lhs, rhs in if let lhsDimensions = lhs.media.dimensions, let rhsDimensions = rhs.media.dimensions { return lhsDimensions.width < rhsDimensions.width } else { return lhs.media.fileId.id < rhs.media.fileId.id } }), let dimensions = maxQualityFile.media.dimensions { if self.postbox.mediaBox.completedResourcePath(maxQualityFile.media.resource) != nil { for (index, level) in self.playerAvailableLevels { if level.height == Int(dimensions.height) { selectedLevelIndex = index break } } } } if selectedLevelIndex == nil { if let minimizedQualityFile = HLSVideoContent.minimizedHLSQuality(file: self.fileReference, codecConfiguration: self.codecConfiguration)?.file { if let dimensions = minimizedQualityFile.media.dimensions { for (index, level) in self.playerAvailableLevels { if level.height == Int(dimensions.height) { selectedLevelIndex = index break } } } } } if selectedLevelIndex == nil { selectedLevelIndex = self.playerAvailableLevels.sorted(by: { $0.value.height > $1.value.height }).first?.key } if let selectedLevelIndex { var effectiveSelectedLevelIndex = selectedLevelIndex if !self.enableSound { effectiveSelectedLevelIndex = self.resolveCurrentLevelIndex() ?? -1 } self.hasRequestedPlayerLoad = true SharedHLSVideoJSContext.shared.jsContext?.evaluateJavaScript(""" window.hlsPlayer_instances[\(self.instanceId)].playerSetCapAutoLevel(\(self.resolveCurrentLevelIndex() ?? -1)); window.hlsPlayer_instances[\(self.instanceId)].playerLoad(\(effectiveSelectedLevelIndex)); """) } } } SharedHLSVideoJSContext.shared.jsContext?.evaluateJavaScript("window.hlsPlayer_instances[\(self.instanceId)].playerSetBaseRate(\(self.requestedBaseRate));") } self.updateStatus() } fileprivate func onPlayerUpdatedCurrentTime(currentTime: Double) { self.playerTime = currentTime self.updateStatus() } fileprivate func onSetCurrentTime(timestamp: Double) { self.player.seek(timestamp: timestamp, play: nil) } fileprivate func onSetPlaybackRate(playbackRate: Double) { self.player.setBaseRate(playbackRate) } fileprivate func onPlay() { self.player.play() } fileprivate func onPause() { self.player.pause() } fileprivate func onMediaSourceDurationUpdated() { guard let (_, videoElement) = SharedHLSVideoJSContext.shared.videoElements.first(where: { $0.value.instanceId == self.instanceId }) else { return } guard let mediaSourceId = videoElement.mediaSourceId, let mediaSource = SharedHLSVideoJSContext.shared.mediaSources[mediaSourceId] else { return } guard let sourceBufferId = mediaSource.sourceBufferIds.first, let sourceBuffer = SharedHLSVideoJSContext.shared.sourceBuffers[sourceBufferId] else { return } self.chunkPlayerPartsState.set(.single(ChunkMediaPlayerPartsState(duration: mediaSource.duration, content: .parts(sourceBuffer.items)))) } fileprivate func onMediaSourceBuffersUpdated() { guard let (_, videoElement) = SharedHLSVideoJSContext.shared.videoElements.first(where: { $0.value.instanceId == self.instanceId }) else { return } guard let mediaSourceId = videoElement.mediaSourceId, let mediaSource = SharedHLSVideoJSContext.shared.mediaSources[mediaSourceId] else { return } guard let sourceBufferId = mediaSource.sourceBufferIds.first, let sourceBuffer = SharedHLSVideoJSContext.shared.sourceBuffers[sourceBufferId] else { return } self.chunkPlayerPartsState.set(.single(ChunkMediaPlayerPartsState(duration: mediaSource.duration, content: .parts(sourceBuffer.items)))) if self.sourceBufferStateDisposable == nil { self.sourceBufferStateDisposable = (sourceBuffer.updated.signal() |> deliverOnMainQueue).startStrict(next: { [weak self, weak sourceBuffer] _ in guard let self, let sourceBuffer else { return } guard let mediaSource = SharedHLSVideoJSContext.shared.mediaSources[sourceBuffer.mediaSourceId] else { return } self.chunkPlayerPartsState.set(.single(ChunkMediaPlayerPartsState(duration: mediaSource.duration, content: .parts(sourceBuffer.items)))) self.updateBuffered() }) } } private func updatePlayerStatus(status: MediaPlayerStatus) { self._status.set(status) if let (bridgeId, _) = SharedHLSVideoJSContext.shared.videoElements.first(where: { $0.value.instanceId == self.instanceId }) { var isPlaying: Bool = false var isBuffering = false switch status.status { case .playing: isPlaying = true case .paused: break case let .buffering(_, whilePlaying, _, _): isPlaying = whilePlaying isBuffering = true } let result: [String: Any] = [ "isPlaying": isPlaying, "isWaiting": isBuffering, "currentTime": status.timestamp ] let jsonResult = try! JSONSerialization.data(withJSONObject: result) let jsonResultString = String(data: jsonResult, encoding: .utf8)! SharedHLSVideoJSContext.shared.jsContext?.evaluateJavaScript("window.bridgeObjectMap[\(bridgeId)].bridgeUpdateStatus(\(jsonResultString));") } } private func updateBuffered() { guard let (_, videoElement) = SharedHLSVideoJSContext.shared.videoElements.first(where: { $0.value.instanceId == self.instanceId }) else { return } guard let mediaSourceId = videoElement.mediaSourceId, let mediaSource = SharedHLSVideoJSContext.shared.mediaSources[mediaSourceId] else { return } guard let sourceBufferId = mediaSource.sourceBufferIds.first, let sourceBuffer = SharedHLSVideoJSContext.shared.sourceBuffers[sourceBufferId] else { return } let bufferedRanges = sourceBuffer.ranges if let (_, videoElement) = SharedHLSVideoJSContext.shared.videoElements.first(where: { $0.value.instanceId == self.instanceId }) { if let mediaSourceId = videoElement.mediaSourceId, let mediaSource = SharedHLSVideoJSContext.shared.mediaSources[mediaSourceId] { if let duration = mediaSource.duration { var mappedRanges = RangeSet() for range in bufferedRanges.ranges { let rangeLower = max(0.0, range.lowerBound - 0.2) let rangeUpper = min(duration, range.upperBound + 0.2) mappedRanges.formUnion(RangeSet(Int64(rangeLower * 1000.0) ..< Int64(rangeUpper * 1000.0))) } self._bufferingStatus.set(.single((mappedRanges, Int64(duration * 1000.0)))) } } } } private func updateStatus() { } private func performActionAtEnd() { for listener in self.playbackCompletedListeners.copyItems() { listener() } } func updateLayout(size: CGSize, actualSize: CGSize, transition: ContainedViewLayoutTransition) { transition.updatePosition(node: self.playerNode, position: CGPoint(x: size.width / 2.0, y: size.height / 2.0)) transition.updateTransformScale(node: self.playerNode, scale: size.width / self.intrinsicDimensions.width) transition.updateFrame(node: self.imageNode, frame: CGRect(origin: CGPoint(), size: size)) if let dimensions = self.dimensions { let imageSize = CGSize(width: floor(dimensions.width / 2.0), height: floor(dimensions.height / 2.0)) let makeLayout = self.imageNode.asyncLayout() let applyLayout = makeLayout(TransformImageArguments(corners: ImageCorners(), imageSize: imageSize, boundingSize: imageSize, intrinsicInsets: UIEdgeInsets(), emptyColor: .clear)) applyLayout() } } func play() { assert(Queue.mainQueue().isCurrent()) if !self.initializedStatus { self._status.set(MediaPlayerStatus(generationTimestamp: 0.0, duration: Double(self.approximateDuration), dimensions: CGSize(), timestamp: 0.0, baseRate: self.requestedBaseRate, seekId: self.seekId, status: .buffering(initial: true, whilePlaying: true, progress: 0.0, display: true), soundEnabled: self.enableSound)) } self.player.play() } func pause() { assert(Queue.mainQueue().isCurrent()) self.player.pause() } func togglePlayPause() { assert(Queue.mainQueue().isCurrent()) self.player.togglePlayPause(faded: false) } func setSoundEnabled(_ value: Bool) { assert(Queue.mainQueue().isCurrent()) if self.enableSound != value { self.enableSound = value if value { self.player.playOnceWithSound(playAndRecord: false, seek: .none) } else { self.player.continuePlayingWithoutSound(seek: .none) } self.updateInternalQualityLevel() } } func seek(_ timestamp: Double) { assert(Queue.mainQueue().isCurrent()) self.seekId += 1 SharedHLSVideoJSContext.shared.jsContext?.evaluateJavaScript("window.hlsPlayer_instances[\(self.instanceId)].playerSeek(\(timestamp));") } func playOnceWithSound(playAndRecord: Bool, seek: MediaPlayerSeek, actionAtEnd: MediaPlayerPlayOnceWithSoundActionAtEnd) { assert(Queue.mainQueue().isCurrent()) let action = { [weak self] in Queue.mainQueue().async { self?.performActionAtEnd() } } self.enableSound = true switch actionAtEnd { case .loop: self.player.actionAtEnd = .loop({}) case .loopDisablingSound: self.player.actionAtEnd = .loopDisablingSound(action) case .stop: self.player.actionAtEnd = .action(action) case .repeatIfNeeded: let _ = (self.player.status |> deliverOnMainQueue |> take(1)).start(next: { [weak self] status in guard let strongSelf = self else { return } if status.timestamp > status.duration * 0.1 { strongSelf.player.actionAtEnd = .loop({ [weak self] in guard let strongSelf = self else { return } strongSelf.player.actionAtEnd = .loopDisablingSound(action) }) } else { strongSelf.player.actionAtEnd = .loopDisablingSound(action) } }) } self.player.playOnceWithSound(playAndRecord: playAndRecord, seek: seek) self.updateInternalQualityLevel() } func setSoundMuted(soundMuted: Bool) { self.player.setSoundMuted(soundMuted: soundMuted) } func continueWithOverridingAmbientMode(isAmbient: Bool) { self.player.continueWithOverridingAmbientMode(isAmbient: isAmbient) } func setForceAudioToSpeaker(_ forceAudioToSpeaker: Bool) { assert(Queue.mainQueue().isCurrent()) self.player.setForceAudioToSpeaker(forceAudioToSpeaker) } func continuePlayingWithoutSound(actionAtEnd: MediaPlayerPlayOnceWithSoundActionAtEnd) { assert(Queue.mainQueue().isCurrent()) let action = { [weak self] in Queue.mainQueue().async { self?.performActionAtEnd() } } self.enableSound = false switch actionAtEnd { case .loop: self.player.actionAtEnd = .loop({}) case .loopDisablingSound, .repeatIfNeeded: self.player.actionAtEnd = .loopDisablingSound(action) case .stop: self.player.actionAtEnd = .action(action) } self.player.continuePlayingWithoutSound(seek: .none) self.updateInternalQualityLevel() } func setContinuePlayingWithoutSoundOnLostAudioSession(_ value: Bool) { self.player.setContinuePlayingWithoutSoundOnLostAudioSession(value) } func setBaseRate(_ baseRate: Double) { self.requestedBaseRate = baseRate if self.playerIsReady { SharedHLSVideoJSContext.shared.jsContext?.evaluateJavaScript("window.hlsPlayer_instances[\(self.instanceId)].playerSetBaseRate(\(self.requestedBaseRate));") } self.updateStatus() } private func resolveCurrentLevelIndex() -> Int? { if self.enableSound { return self.requestedLevelIndex } else { var foundIndex: Int? if let minQualityFile = HLSVideoContent.minimizedHLSQuality(file: self.fileReference, codecConfiguration: self.codecConfiguration)?.file, let dimensions = minQualityFile.media.dimensions { for (index, level) in self.playerAvailableLevels { if level.width == Int(dimensions.width) && level.height == Int(dimensions.height) { foundIndex = index break } } } return foundIndex } } private func updateInternalQualityLevel() { if self.playerIsReady { SharedHLSVideoJSContext.shared.jsContext?.evaluateJavaScript(""" window.hlsPlayer_instances[\(self.instanceId)].playerSetCapAutoLevel(\(self.resolveCurrentLevelIndex() ?? -1)); """) } } func setVideoQuality(_ videoQuality: UniversalVideoContentVideoQuality) { self.preferredVideoQuality = videoQuality let resolvedVideoQuality = self.preferredVideoQuality switch resolvedVideoQuality { case .auto: self.requestedLevelIndex = nil case let .quality(quality): if let level = self.playerAvailableLevels.first(where: { min($0.value.width, $0.value.height) == quality }) { self.requestedLevelIndex = level.key } else { self.requestedLevelIndex = nil } } self.updateVideoQualityState() if self.playerIsReady { SharedHLSVideoJSContext.shared.jsContext?.evaluateJavaScript(""" window.hlsPlayer_instances[\(self.instanceId)].playerSetLevel(\(self.requestedLevelIndex ?? -1)); """) } } private func updateVideoQualityState() { var videoQualityState: VideoQualityState? if let value = self.videoQualityState() { videoQualityState = VideoQualityState(current: value.current, preferred: value.preferred, available: value.available) } if self.videoQualityStateValue != videoQualityState { self.videoQualityStateValue = videoQualityState self.videoQualityStatePromise.set(.single(videoQualityState)) } } func videoQualityState() -> (current: Int, preferred: UniversalVideoContentVideoQuality, available: [Int])? { if self.playerAvailableLevels.isEmpty { if let qualitySet = HLSQualitySet(baseFile: self.fileReference, codecConfiguration: self.codecConfiguration), let minQualityFile = HLSVideoContent.minimizedHLSQuality(file: self.fileReference, codecConfiguration: self.codecConfiguration)?.file { let sortedFiles = qualitySet.qualityFiles.sorted(by: { $0.key > $1.key }) if let minQuality = sortedFiles.first(where: { $0.value.media.fileId == minQualityFile.media.fileId }) { return (minQuality.key, .auto, sortedFiles.map(\.key)) } } } let currentLevelIndex: Int if let playerCurrentLevelIndex = self.playerCurrentLevelIndex { currentLevelIndex = playerCurrentLevelIndex } else { if let minQualityFile = HLSVideoContent.minimizedHLSQuality(file: self.fileReference, codecConfiguration: self.codecConfiguration)?.file, let dimensions = minQualityFile.media.dimensions { var foundIndex: Int? for (index, level) in self.playerAvailableLevels { if level.width == Int(dimensions.width) && level.height == Int(dimensions.height) { foundIndex = index break } } if let foundIndex { currentLevelIndex = foundIndex } else { return nil } } else { return nil } } guard let currentLevel = self.playerAvailableLevels[currentLevelIndex] else { return nil } var available = self.playerAvailableLevels.values.map { min($0.width, $0.height) } available.sort(by: { $0 > $1 }) return (min(currentLevel.width, currentLevel.height), self.preferredVideoQuality, available) } public func videoQualityStateSignal() -> Signal<(current: Int, preferred: UniversalVideoContentVideoQuality, available: [Int])?, NoError> { return self.videoQualityStatePromise.get() |> map { value -> (current: Int, preferred: UniversalVideoContentVideoQuality, available: [Int])? in guard let value else { return nil } return (value.current, value.preferred, value.available) } } func addPlaybackCompleted(_ f: @escaping () -> Void) -> Int { return self.playbackCompletedListeners.add(f) } func removePlaybackCompleted(_ index: Int) { self.playbackCompletedListeners.remove(index) } func fetchControl(_ control: UniversalVideoNodeFetchControl) { } func notifyPlaybackControlsHidden(_ hidden: Bool) { } func setCanPlaybackWithoutHierarchy(_ canPlaybackWithoutHierarchy: Bool) { self.playerNode.setCanPlaybackWithoutHierarchy(canPlaybackWithoutHierarchy) } func enterNativePictureInPicture() -> Bool { return false } func exitNativePictureInPicture() { } func setNativePictureInPictureIsActive(_ value: Bool) { self.imageNode.isHidden = !value } } private func serializeRanges(_ ranges: RangeSet) -> [Double] { var result: [Double] = [] for range in ranges.ranges { result.append(range.lowerBound) result.append(range.upperBound) } return result } private final class VideoElement { let instanceId: Int var mediaSourceId: Int? init(instanceId: Int) { self.instanceId = instanceId } } private final class MediaSource { var duration: Double? var sourceBufferIds: [Int] = [] init() { } } private final class SourceBuffer { private static let sharedQueue = Queue(name: "SourceBuffer") final class Item { let tempFile: TempBoxFile let asset: AVURLAsset let startTime: Double let endTime: Double let rawData: Data var clippedStartTime: Double var clippedEndTime: Double init(tempFile: TempBoxFile, asset: AVURLAsset, startTime: Double, endTime: Double, rawData: Data) { self.tempFile = tempFile self.asset = asset self.startTime = startTime self.endTime = endTime self.rawData = rawData self.clippedStartTime = startTime self.clippedEndTime = endTime } func removeRange(start: Double, end: Double) { //TODO } } let mediaSourceId: Int let mimeType: String var initializationData: Data? var items: [ChunkMediaPlayerPart] = [] var ranges = RangeSet() let updated = ValuePipe() private var currentUpdateId: Int = 0 init(mediaSourceId: Int, mimeType: String) { self.mediaSourceId = mediaSourceId self.mimeType = mimeType } func abortOperation() { self.currentUpdateId += 1 } func appendBuffer(data: Data, completion: @escaping (RangeSet) -> Void) { let initializationData = self.initializationData self.currentUpdateId += 1 let updateId = self.currentUpdateId SourceBuffer.sharedQueue.async { [weak self] in let tempFile = TempBox.shared.tempFile(fileName: "data.mp4") var combinedData = Data() if let initializationData { combinedData.append(initializationData) } combinedData.append(data) guard let _ = try? combinedData.write(to: URL(fileURLWithPath: tempFile.path), options: .atomic) else { Queue.mainQueue().async { guard let self else { completion(RangeSet()) return } if self.currentUpdateId != updateId { return } completion(self.ranges) } return } if let fragmentInfoSet = extractFFMpegMediaInfo(path: tempFile.path), let fragmentInfo = fragmentInfoSet.audio ?? fragmentInfoSet.video { Queue.mainQueue().async { guard let self else { completion(RangeSet()) return } if self.currentUpdateId != updateId { return } if fragmentInfo.duration.value == 0 { self.initializationData = data completion(self.ranges) } else { let videoCodecName: String? = fragmentInfoSet.video?.codecName let item = ChunkMediaPlayerPart( startTime: fragmentInfo.startTime.seconds, endTime: fragmentInfo.startTime.seconds + fragmentInfo.duration.seconds, content: ChunkMediaPlayerPart.TempFile(file: tempFile), codecName: videoCodecName, offsetTime: 0.0 ) self.items.append(item) self.updateRanges() completion(self.ranges) self.updated.putNext(Void()) } } } else { assertionFailure() Queue.mainQueue().async { guard let self else { completion(RangeSet()) return } if self.currentUpdateId != updateId { return } completion(self.ranges) } return } } } func remove(start: Double, end: Double, completion: @escaping (RangeSet) -> Void) { self.items.removeAll(where: { item in if item.startTime + 0.5 >= start && item.endTime - 0.5 <= end { return true } else { return false } }) self.updateRanges() completion(self.ranges) self.updated.putNext(Void()) } private func updateRanges() { self.ranges = RangeSet() for item in self.items { let itemStartTime = round(item.startTime * 1000.0) / 1000.0 let itemEndTime = round(item.endTime * 1000.0) / 1000.0 self.ranges.formUnion(RangeSet(itemStartTime ..< itemEndTime)) } } } private func parseFragment(filePath: String) -> (offset: CMTime, duration: CMTime)? { let source = SoftwareVideoSource(path: filePath, hintVP9: false, unpremultiplyAlpha: false) return source.readTrackInfo() }