import Foundation import SwiftSignalKit import UniversalMediaPlayer import Postbox import TelegramCore import WebKit import AsyncDisplayKit import AccountContext import TelegramAudio import Display import PhotoResources import TelegramVoip 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..(value: false) var disposable: Disposable? init(source: HLSServerSource, sourceTask: any WKURLSchemeTask) { self.sourceTask = sourceTask var requestRange: Range? if let rangeString = sourceTask.request.allHTTPHeaderFields?["Range"] { requestRange = parseRange(from: rangeString) } guard let url = sourceTask.request.url else { return } let filePath = (url.absoluteString as NSString).lastPathComponent if filePath == "master.m3u8" { self.disposable = source.masterPlaylistData().startStrict(next: { [weak self] data in guard let self else { return } self.sendResponseAndClose(data: data.data(using: .utf8)!) }) } 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 { self.sendErrorAndClose() return } self.disposable = source.playlistData(quality: levelIndex).startStrict(next: { [weak self] data in guard let self else { return } self.sendResponseAndClose(data: data.data(using: .utf8)!) }) } 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 { self.sendErrorAndClose() return } guard let requestRange else { self.sendErrorAndClose() return } self.disposable = (source.fileData(id: fileIdValue, range: requestRange.lowerBound ..< requestRange.upperBound + 1) |> take(1)).start(next: { [weak self] result in guard let self else { return } if let (file, range, totalSize) = result { guard let allData = try? Data(contentsOf: URL(fileURLWithPath: file.path), options: .mappedIfSafe) else { return } let data = allData.subdata(in: range) self.sendResponseAndClose(data: data, range: requestRange, totalSize: totalSize) } else { self.sendErrorAndClose() } }) } else { self.sendErrorAndClose() } } deinit { self.disposable?.dispose() } func cancel() { } func sendErrorAndClose() { self.sourceTask.didFailWithError(NSError(domain: "LocalVideoError", code: 500, userInfo: nil)) } private func sendResponseAndClose(data: Data, range: Range? = nil, totalSize: Int? = nil) { // Create the response with the appropriate content-type and content-length //let mimeType = "application/octet-stream" let responseLength = data.count // Construct URLResponse with optional range headers (for partial content responses) var headers: [String: String] = [ "Content-Length": "\(responseLength)", "Connection": "close", "Access-Control-Allow-Origin": "*" ] if let range = range, let totalSize = totalSize { headers["Content-Range"] = "bytes \(range.lowerBound)-\(range.upperBound)/\(totalSize)" } // Create the URLResponse object let response = HTTPURLResponse(url: self.sourceTask.request.url!, statusCode: 200, httpVersion: "HTTP/1.1", headerFields: headers) // Send the response headers self.sourceTask.didReceive(response!) // Send the response data self.sourceTask.didReceive(data) // Complete the task self.sourceTask.didFinish() } } private let source: HLSServerSource private var pendingTasks: [PendingTask] = [] init(source: HLSServerSource) { self.source = source } func webView(_ webView: WKWebView, start urlSchemeTask: any WKURLSchemeTask) { self.pendingTasks.append(PendingTask(source: self.source, sourceTask: urlSchemeTask)) } func webView(_ webView: WKWebView, stop urlSchemeTask: any WKURLSchemeTask) { if let index = self.pendingTasks.firstIndex(where: { $0.sourceTask === urlSchemeTask }) { let task = self.pendingTasks[index] self.pendingTasks.remove(at: index) task.cancel() } } } private class WeakScriptMessageHandler: NSObject, WKScriptMessageHandler { private let f: (WKScriptMessage) -> () init(_ f: @escaping (WKScriptMessage) -> ()) { self.f = f super.init() } func userContentController(_ controller: WKUserContentController, didReceive scriptMessage: WKScriptMessage) { self.f(scriptMessage) } } final class HLSVideoJSContentNode: ASDisplayNode, UniversalVideoContentNode { private 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 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 let audioSessionManager: ManagedAudioSession private let audioSessionDisposable = MetaDisposable() private var hasAudioSession = false private let playerSource: HLSServerSource? 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() } private let _isNativePictureInPictureActive = ValuePromise(false, ignoreRepeated: true) var isNativePictureInPictureActive: Signal { return self._isNativePictureInPictureActive.get() } private let _ready = Promise() var ready: Signal { return self._ready.get() } private let _preloadCompleted = ValuePromise() var preloadCompleted: Signal { return self._preloadCompleted.get() } private let imageNode: TransformImageNode private let webView: WKWebView 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 private var playerIsReady: Bool = false private var playerIsFirstFrameReady: Bool = false private var playerIsPlaying: Bool = false private var playerRate: Double = 0.0 private var playerDefaultRate: Double = 1.0 private var playerTime: Double = 0.0 private var playerTimeGenerationTimestamp: Double = 0.0 private var playerAvailableLevels: [Int: Level] = [:] private var playerCurrentLevelIndex: Int? private var hasRequestedPlayerLoad: Bool = false private var requestedPlaying: Bool = false private var requestedBaseRate: Double = 1.0 private var requestedLevelIndex: Int? init(accountId: AccountRecordId, postbox: Postbox, audioSessionManager: ManagedAudioSession, userLocation: MediaResourceUserLocation, fileReference: FileMediaReference, streamVideo: Bool, loopVideo: Bool, enableSound: Bool, baseRate: Double, fetchAutomatically: Bool) { self.postbox = postbox self.fileReference = fileReference self.approximateDuration = fileReference.media.duration ?? 0.0 self.audioSessionManager = audioSessionManager self.userLocation = userLocation self.requestedBaseRate = baseRate /*#if DEBUG if let minimizedQualityFile = HLSVideoContent.minimizedHLSQualityFile(file: self.fileReference) { let _ = fetchedMediaResource(mediaBox: postbox.mediaBox, userLocation: userLocation, userContentType: .video, reference: minimizedQualityFile.resourceReference(minimizedQualityFile.media.resource), range: (0 ..< 5 * 1024 * 1024, .default)).startStandalone() } #endif*/ 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() let config = WKWebViewConfiguration() config.allowsInlineMediaPlayback = true config.mediaTypesRequiringUserActionForPlayback = [] config.allowsPictureInPictureMediaPlayback = true var playerSource: HLSServerSource? if let qualitySet = HLSQualitySet(baseFile: fileReference) { let playerSourceValue = HLSServerSource(accountId: accountId.int64, fileId: fileReference.media.fileId.id, postbox: postbox, userLocation: userLocation, playlistFiles: qualitySet.playlistFiles, qualityFiles: qualitySet.qualityFiles) playerSource = playerSourceValue let schemeHandler = CustomVideoSchemeHandler(source: playerSourceValue) config.setURLSchemeHandler(schemeHandler, forURLScheme: "tghls") } self.playerSource = playerSource let userController = WKUserContentController() var handleScriptMessage: ((WKScriptMessage) -> Void)? userController.add(WeakScriptMessageHandler { message in handleScriptMessage?(message) }, name: "performAction") let isDebug: Bool #if DEBUG isDebug = true #else isDebug = false #endif let mediaDimensions = fileReference.media.dimensions?.cgSize ?? CGSize(width: 480.0, height: 320.0) var intrinsicDimensions = mediaDimensions.aspectFittedOrSmaller(CGSize(width: 1280.0, height: 1280.0)) let userScriptJs = """ playerInitialize({ 'debug': \(isDebug), 'width': \(Int(intrinsicDimensions.width)), 'height': \(Int(intrinsicDimensions.height)), 'bandwidthEstimate': \(HLSVideoJSContentNode.sharedBandwidthEstimate ?? 500000.0) }); """; let userScript = WKUserScript(source: userScriptJs, injectionTime: .atDocumentEnd, forMainFrameOnly: true) userController.addUserScript(userScript) config.userContentController = userController intrinsicDimensions.width = floor(intrinsicDimensions.width / UIScreenScale) intrinsicDimensions.height = floor(intrinsicDimensions.height / UIScreenScale) self.intrinsicDimensions = intrinsicDimensions self.webView = WKWebView(frame: CGRect(origin: CGPoint(), size: self.intrinsicDimensions), configuration: config) self.webView.scrollView.isScrollEnabled = false self.webView.allowsLinkPreview = false self.webView.allowsBackForwardNavigationGestures = false self.webView.accessibilityIgnoresInvertColors = true self.webView.scrollView.contentInsetAdjustmentBehavior = .never self.webView.alpha = 0.0 if #available(iOS 16.4, *) { #if DEBUG self.webView.isInspectable = true #endif } super.init() self.imageNode.setSignal(internalMediaGridMessageVideo(postbox: postbox, userLocation: self.userLocation, videoReference: fileReference) |> 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.view.addSubview(self.webView) self.imageNode.imageUpdated = { [weak self] _ in self?._ready.set(.single(Void())) } self._bufferingStatus.set(.single(nil)) handleScriptMessage = { [weak self] message in Queue.mainQueue().async { guard let self else { return } guard let body = message.body as? [String: Any] else { return } guard let eventName = body["event"] as? String else { return } switch eventName { case "playerStatus": guard let eventData = body["data"] as? [String: Any] else { return } if let isReady = eventData["isReady"] as? Bool { self.playerIsReady = isReady } else { self.playerIsReady = false } if let isFirstFrameReady = eventData["isFirstFrameReady"] as? Bool { self.playerIsFirstFrameReady = isFirstFrameReady } else { self.playerIsFirstFrameReady = 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] = Level( bitrate: levelBitrate, width: levelWidth, height: levelHeight ) } } else { self.playerAvailableLevels.removeAll() } self._isNativePictureInPictureActive.set(eventData["isPictureInPictureActive"] as? Bool ?? false) if let currentLevel = eventData["currentLevel"] as? Int { if self.playerAvailableLevels[currentLevel] != nil { self.playerCurrentLevelIndex = currentLevel } else { self.playerCurrentLevelIndex = nil } } else { self.playerCurrentLevelIndex = nil } self.webView.alpha = self.playerIsFirstFrameReady ? 1.0 : 0.0 if self.playerIsReady { if !self.hasRequestedPlayerLoad { if !self.playerAvailableLevels.isEmpty { var selectedLevelIndex: Int? if let minimizedQualityFile = HLSVideoContent.minimizedHLSQuality(file: self.fileReference)?.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 { self.hasRequestedPlayerLoad = true self.webView.evaluateJavaScript("playerLoad(\(selectedLevelIndex));", completionHandler: nil) } } } self.webView.evaluateJavaScript("playerSetBaseRate(\(self.requestedBaseRate));", completionHandler: nil) if self.requestedPlaying { self.requestPlay() } else { self.requestPause() } } self.updateStatus() case "playerCurrentTime": guard let eventData = body["data"] as? [String: Any] else { return } guard let value = eventData["value"] as? Double else { return } self.playerTime = value self.playerTimeGenerationTimestamp = CACurrentMediaTime() var bandwidthEstimate = eventData["bandwidthEstimate"] as? Double if let bandwidthEstimateValue = bandwidthEstimate, bandwidthEstimateValue.isNaN || bandwidthEstimateValue.isInfinite { bandwidthEstimate = nil } HLSVideoJSContentNode.sharedBandwidthEstimate = bandwidthEstimate self.updateStatus() default: break } } } if let playerSource = self.playerSource { self.serverDisposable = SharedHLSServer.shared.registerPlayer(source: playerSource, completion: { [weak self] in Queue.mainQueue().async { guard let self else { return } let htmlUrl = "http://127.0.0.1:\(SharedHLSServer.shared.port)/\(playerSource.id)/index.html" self.webView.load(URLRequest(url: URL(string: htmlUrl)!)) } }) } } deinit { self.serverDisposable?.dispose() self.audioSessionDisposable.dispose() self.statusTimer?.invalidate() } private func updateStatus() { let isPlaying = self.requestedPlaying && self.playerRate != 0.0 let status: MediaPlayerPlaybackStatus if self.requestedPlaying && !isPlaying { status = .buffering(initial: false, whilePlaying: self.requestedPlaying, progress: 0.0, display: true) } else { status = self.requestedPlaying ? .playing : .paused } var timestamp = self.playerTime if timestamp.isFinite && !timestamp.isNaN { } else { timestamp = 0.0 } self.statusValue = MediaPlayerStatus(generationTimestamp: self.playerTimeGenerationTimestamp, duration: Double(self.approximateDuration), dimensions: CGSize(), timestamp: timestamp, baseRate: self.requestedBaseRate, seekId: self.seekId, status: status, soundEnabled: true) self._status.set(self.statusValue) if case .playing = status { if self.statusTimer == nil { self.statusTimer = Foundation.Timer.scheduledTimer(withTimeInterval: 1.0 / 30.0, repeats: true, block: { [weak self] _ in guard let self else { return } self.updateStatus() }) } } else if let statusTimer = self.statusTimer { self.statusTimer = nil statusTimer.invalidate() } } private func performActionAtEnd() { for listener in self.playbackCompletedListeners.copyItems() { listener() } } func updateLayout(size: CGSize, actualSize: CGSize, transition: ContainedViewLayoutTransition) { transition.updatePosition(layer: self.webView.layer, position: CGPoint(x: size.width / 2.0, y: size.height / 2.0)) transition.updateTransformScale(layer: self.webView.layer, 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: true)) } /*if !self.hasAudioSession { self.audioSessionDisposable.set(self.audioSessionManager.push(audioSessionType: .play(mixWithOthers: false), activate: { [weak self] _ in Queue.mainQueue().async { guard let self else { return } self.hasAudioSession = true self.requestPlay() } }, deactivate: { [weak self] _ in return Signal { subscriber in if let self { self.hasAudioSession = false self.requestPause() } subscriber.putCompletion() return EmptyDisposable } |> runOn(.mainQueue()) })) } else*/ do { self.requestPlay() } } private func requestPlay() { self.requestedPlaying = true if self.playerIsReady { self.webView.evaluateJavaScript("playerPlay();", completionHandler: nil) } self.updateStatus() } private func requestPause() { self.requestedPlaying = false if self.playerIsReady { self.webView.evaluateJavaScript("playerPause();", completionHandler: nil) } self.updateStatus() } func pause() { assert(Queue.mainQueue().isCurrent()) self.requestPause() } func togglePlayPause() { assert(Queue.mainQueue().isCurrent()) if self.requestedPlaying { self.pause() } else { self.play() } } func setSoundEnabled(_ value: Bool) { assert(Queue.mainQueue().isCurrent()) /*if value { if !self.hasAudioSession { self.audioSessionDisposable.set(self.audioSessionManager.push(audioSessionType: .play(mixWithOthers: false), activate: { [weak self] _ in self?.hasAudioSession = true self?.player?.volume = 1.0 }, deactivate: { [weak self] _ in self?.hasAudioSession = false self?.player?.pause() return .complete() })) } } else { self.player?.volume = 0.0 self.hasAudioSession = false self.audioSessionDisposable.set(nil) }*/ } func seek(_ timestamp: Double) { assert(Queue.mainQueue().isCurrent()) self.seekId += 1 self.webView.evaluateJavaScript("playerSeek(\(timestamp));", completionHandler: nil) } func playOnceWithSound(playAndRecord: Bool, seek: MediaPlayerSeek, actionAtEnd: MediaPlayerPlayOnceWithSoundActionAtEnd) { self.webView.evaluateJavaScript("playerSetIsMuted(false);", completionHandler: nil) self.play() } func setSoundMuted(soundMuted: Bool) { self.webView.evaluateJavaScript("playerSetIsMuted(\(soundMuted));", completionHandler: nil) } func continueWithOverridingAmbientMode(isAmbient: Bool) { } func setForceAudioToSpeaker(_ forceAudioToSpeaker: Bool) { } func continuePlayingWithoutSound(actionAtEnd: MediaPlayerPlayOnceWithSoundActionAtEnd) { self.webView.evaluateJavaScript("playerSetIsMuted(true);", completionHandler: nil) self.hasAudioSession = false self.audioSessionDisposable.set(nil) } func setContinuePlayingWithoutSoundOnLostAudioSession(_ value: Bool) { } func setBaseRate(_ baseRate: Double) { self.requestedBaseRate = baseRate if self.playerIsReady { self.webView.evaluateJavaScript("playerSetBaseRate(\(self.requestedBaseRate));", completionHandler: nil) } self.updateStatus() } func setVideoQuality(_ videoQuality: UniversalVideoContentVideoQuality) { self.preferredVideoQuality = videoQuality switch videoQuality { case .auto: self.requestedLevelIndex = nil case let .quality(quality): if let level = self.playerAvailableLevels.first(where: { $0.value.height == quality }) { self.requestedLevelIndex = level.key } else { self.requestedLevelIndex = nil } } if self.playerIsReady { self.webView.evaluateJavaScript("playerSetLevel(\(self.requestedLevelIndex ?? -1));", completionHandler: nil) } } func videoQualityState() -> (current: Int, preferred: UniversalVideoContentVideoQuality, available: [Int])? { guard let playerCurrentLevelIndex = self.playerCurrentLevelIndex else { return nil } guard let currentLevel = self.playerAvailableLevels[playerCurrentLevelIndex] else { return nil } var available = self.playerAvailableLevels.values.map(\.height) available.sort(by: { $0 > $1 }) return (currentLevel.height, self.preferredVideoQuality, 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) { } func enterNativePictureInPicture() -> Bool { self.webView.evaluateJavaScript("playerRequestPictureInPicture();", completionHandler: nil) return true } func exitNativePictureInPicture() { self.webView.evaluateJavaScript("playerStopPictureInPicture();", completionHandler: nil) } }