import Foundation import UIKit import AsyncDisplayKit import Display import SwiftSignalKit import Postbox import TelegramCore import AVFoundation import UniversalMediaPlayer import TelegramAudio import AccountContext import PhotoResources import RangeSet import TelegramVoip import ManagedFile import AppBundle public struct HLSCodecConfiguration { public var isHardwareAv1Supported: Bool public var isSoftwareAv1Supported: Bool public init(isHardwareAv1Supported: Bool, isSoftwareAv1Supported: Bool) { self.isHardwareAv1Supported = isHardwareAv1Supported self.isSoftwareAv1Supported = isSoftwareAv1Supported } } public extension HLSCodecConfiguration { init(context: AccountContext) { /*var isSoftwareAv1Supported = false var isHardwareAv1Supported = false var length: Int = 4 var cpuCount: UInt32 = 0 sysctlbyname("hw.ncpu", &cpuCount, &length, nil, 0) if cpuCount >= 6 { isSoftwareAv1Supported = true } if let data = context.currentAppConfiguration.with({ $0 }).data, let value = data["ios_enable_hardware_av1"] as? Double { isHardwareAv1Supported = value != 0.0 } if let data = context.currentAppConfiguration.with({ $0 }).data, let value = data["ios_enable_software_av1"] as? Double { isSoftwareAv1Supported = value != 0.0 } self.init(isHardwareAv1Supported: isHardwareAv1Supported, isSoftwareAv1Supported: isSoftwareAv1Supported)*/ self.init(isHardwareAv1Supported: false, isSoftwareAv1Supported: false) } } public final class HLSQualitySet { public let qualityFiles: [Int: FileMediaReference] public let playlistFiles: [Int: FileMediaReference] public init?(baseFile: FileMediaReference, codecConfiguration: HLSCodecConfiguration) { var qualityFiles: [Int: FileMediaReference] = [:] for alternativeRepresentation in baseFile.media.alternativeRepresentations { if let alternativeFile = alternativeRepresentation as? TelegramMediaFile { for attribute in alternativeFile.attributes { if case let .Video(_, size, _, _, _, videoCodec) = attribute { if let videoCodec, NativeVideoContent.isVideoCodecSupported(videoCodec: videoCodec, isHardwareAv1Supported: codecConfiguration.isHardwareAv1Supported, isSoftwareAv1Supported: codecConfiguration.isSoftwareAv1Supported) { let key = Int(min(size.width, size.height)) if let currentFile = qualityFiles[key] { var currentCodec: String? for attribute in currentFile.media.attributes { if case let .Video(_, _, _, _, _, videoCodec) = attribute { currentCodec = videoCodec } } if let currentCodec, (currentCodec == "av1" || currentCodec == "av01") { } else { qualityFiles[key] = baseFile.withMedia(alternativeFile) } } else { qualityFiles[key] = baseFile.withMedia(alternativeFile) } } } } } } var playlistFiles: [Int: FileMediaReference] = [:] for alternativeRepresentation in baseFile.media.alternativeRepresentations { if let alternativeFile = alternativeRepresentation as? TelegramMediaFile { if alternativeFile.mimeType == "application/x-mpegurl" { if let fileName = alternativeFile.fileName { if fileName.hasPrefix("mtproto:") { let fileIdString = String(fileName[fileName.index(fileName.startIndex, offsetBy: "mtproto:".count)...]) if let fileId = Int64(fileIdString) { for (quality, file) in qualityFiles { if file.media.fileId.id == fileId { playlistFiles[quality] = baseFile.withMedia(alternativeFile) break } } } } } } } } if !playlistFiles.isEmpty && playlistFiles.keys == qualityFiles.keys { self.qualityFiles = qualityFiles self.playlistFiles = playlistFiles } else { return nil } } } public final class HLSVideoContent: UniversalVideoContent { public static func minimizedHLSQuality(file: FileMediaReference, codecConfiguration: HLSCodecConfiguration) -> (playlist: FileMediaReference, file: FileMediaReference)? { guard let qualitySet = HLSQualitySet(baseFile: file, codecConfiguration: codecConfiguration) else { return nil } let sortedQualities = qualitySet.qualityFiles.sorted(by: { $0.key < $1.key }) for (quality, qualityFile) in sortedQualities { if quality >= 600 { guard let playlistFile = qualitySet.playlistFiles[quality] else { return nil } return (playlistFile, qualityFile) } } if let (quality, qualityFile) = sortedQualities.first { guard let playlistFile = qualitySet.playlistFiles[quality] else { return nil } return (playlistFile, qualityFile) } return nil } public static func minimizedHLSQualityPreloadData(postbox: Postbox, file: FileMediaReference, userLocation: MediaResourceUserLocation, prefixSeconds: Int, autofetchPlaylist: Bool, codecConfiguration: HLSCodecConfiguration) -> Signal<(FileMediaReference, Range)?, NoError> { guard let fileSet = minimizedHLSQuality(file: file, codecConfiguration: codecConfiguration) else { return .single(nil) } let playlistData: Signal?, NoError> = Signal { subscriber in var fetchDisposable: Disposable? if autofetchPlaylist { fetchDisposable = freeMediaFileResourceInteractiveFetched(postbox: postbox, userLocation: userLocation, fileReference: fileSet.playlist, resource: fileSet.playlist.media.resource).start() } let dataDisposable = postbox.mediaBox.resourceData(fileSet.playlist.media.resource).start(next: { data in if !data.complete { return } guard let data = try? Data(contentsOf: URL(fileURLWithPath: data.path)) else { subscriber.putNext(nil) subscriber.putCompletion() return } guard let playlistString = String(data: data, encoding: .utf8) else { subscriber.putNext(nil) subscriber.putCompletion() return } 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)) } } } if durations.count != byteRanges.count { subscriber.putNext(nil) subscriber.putCompletion() return } var rangeUpperBound: Int64 = 0 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 { subscriber.putNext(0 ..< rangeUpperBound) subscriber.putCompletion() } else { subscriber.putNext(nil) subscriber.putCompletion() } return }) return ActionDisposable { fetchDisposable?.dispose() dataDisposable.dispose() } } return playlistData |> map { range -> (FileMediaReference, Range)? in guard let range else { return nil } return (fileSet.file, range) } } public let id: AnyHashable public let nativeId: NativeVideoContentId let userLocation: MediaResourceUserLocation public let fileReference: FileMediaReference public let dimensions: CGSize public let duration: Double let streamVideo: Bool let loopVideo: Bool let enableSound: Bool let baseRate: Double let fetchAutomatically: Bool let onlyFullSizeThumbnail: Bool let useLargeThumbnail: Bool let autoFetchFullSizeThumbnail: Bool let codecConfiguration: HLSCodecConfiguration public init(id: NativeVideoContentId, userLocation: MediaResourceUserLocation, fileReference: FileMediaReference, streamVideo: Bool = false, loopVideo: Bool = false, enableSound: Bool = true, baseRate: Double = 1.0, fetchAutomatically: Bool = true, onlyFullSizeThumbnail: Bool = false, useLargeThumbnail: Bool = false, autoFetchFullSizeThumbnail: Bool = false, codecConfiguration: HLSCodecConfiguration) { self.id = id self.userLocation = userLocation self.nativeId = id self.fileReference = fileReference self.dimensions = self.fileReference.media.dimensions?.cgSize ?? CGSize(width: 480, height: 320) self.duration = self.fileReference.media.duration ?? 0.0 self.streamVideo = streamVideo self.loopVideo = loopVideo self.enableSound = enableSound self.baseRate = baseRate self.fetchAutomatically = fetchAutomatically self.onlyFullSizeThumbnail = onlyFullSizeThumbnail self.useLargeThumbnail = useLargeThumbnail self.autoFetchFullSizeThumbnail = autoFetchFullSizeThumbnail self.codecConfiguration = codecConfiguration } public func makeContentNode(context: AccountContext, postbox: Postbox, audioSession: ManagedAudioSession) -> UniversalVideoContentNode & ASDisplayNode { return HLSVideoJSNativeContentNode(accountId: context.account.id, postbox: postbox, audioSessionManager: audioSession, userLocation: self.userLocation, fileReference: self.fileReference, streamVideo: self.streamVideo, loopVideo: self.loopVideo, enableSound: self.enableSound, baseRate: self.baseRate, fetchAutomatically: self.fetchAutomatically, onlyFullSizeThumbnail: self.onlyFullSizeThumbnail, useLargeThumbnail: self.useLargeThumbnail, autoFetchFullSizeThumbnail: self.autoFetchFullSizeThumbnail, codecConfiguration: self.codecConfiguration) } public func isEqual(to other: UniversalVideoContent) -> Bool { if let other = other as? NativeVideoContent { if case let .message(stableId, _) = self.nativeId { if case .message(stableId, _) = other.nativeId { if self.fileReference.media.isInstantVideo { return true } } } } return false } } final class HLSServerSource: 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 if path == "index.html" { if let path = getAppBundle().path(forResource: "HLSVideoPlayer", ofType: "html"), let data = try? Data(contentsOf: URL(fileURLWithPath: path)) { subscriber.putNext((data, "text/html")) } else { subscriber.putNext(nil) } } else if path == "hls.js" { if let path = getAppBundle().path(forResource: "hls", ofType: "js"), let data = try? Data(contentsOf: URL(fileURLWithPath: path)) { subscriber.putNext((data, "application/javascript")) } else { subscriber.putNext(nil) } } 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) } let _ = quality guard let size = file.media.size else { return .single(nil) } let postbox = self.postbox let userLocation = self.userLocation let mappedRange: Range = Int64(range.lowerBound) ..< Int64(range.upperBound) let queue = postbox.mediaBox.dataQueue let fetchFromRemote: Signal<(TempBoxFile, Range, Int)?, NoError> = 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 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 } }