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 public final class HLSVideoContent: UniversalVideoContent { public let id: AnyHashable public let nativeId: PlatformVideoContentId 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 public init(id: PlatformVideoContentId, userLocation: MediaResourceUserLocation, fileReference: FileMediaReference, streamVideo: Bool = false, loopVideo: Bool = false, enableSound: Bool = true, baseRate: Double = 1.0, fetchAutomatically: Bool = true) { 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 } public func makeContentNode(accountId: AccountRecordId, postbox: Postbox, audioSession: ManagedAudioSession) -> UniversalVideoContentNode & ASDisplayNode { return HLSVideoContentNode(accountId: accountId, 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) } public func isEqual(to other: UniversalVideoContent) -> Bool { if let other = other as? HLSVideoContent { if case let .message(_, stableId, _) = self.nativeId { if case .message(_, stableId, _) = other.nativeId { if self.fileReference.media.isInstantVideo { return true } } } } return false } } private final class HLSVideoContentNode: ASDisplayNode, UniversalVideoContentNode { private 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 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 return Signal<(TempBoxFile, Range, Int)?, NoError> { subscriber in 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 partialFile = 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) } } 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 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 baseRate: Double = 1.0 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 _ready = Promise() var ready: Signal { return self._ready.get() } private let _preloadCompleted = ValuePromise() var preloadCompleted: Signal { return self._preloadCompleted.get() } private var playerSource: HLSServerSource? private var serverDisposable: Disposable? private let imageNode: TransformImageNode private var playerItem: AVPlayerItem? private var player: AVPlayer? private let playerNode: ASDisplayNode private var loadProgressDisposable: Disposable? private var statusDisposable: Disposable? private var didPlayToEndTimeObserver: NSObjectProtocol? private var didBecomeActiveObserver: NSObjectProtocol? private var willResignActiveObserver: NSObjectProtocol? private var failureObserverId: NSObjectProtocol? private var errorObserverId: NSObjectProtocol? private var playerItemFailedToPlayToEndTimeObserver: NSObjectProtocol? private let fetchDisposable = MetaDisposable() private var dimensions: CGSize? private let dimensionsPromise = ValuePromise(CGSize()) private var validLayout: CGSize? private var statusTimer: Foundation.Timer? private var preferredVideoQuality: UniversalVideoContentVideoQuality = .auto 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.baseRate = baseRate 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 player: AVPlayer? player = AVPlayer(playerItem: nil) self.player = player if #available(iOS 16.0, *) { player?.defaultRate = Float(baseRate) } if !enableSound { player?.volume = 0.0 } self.playerNode = ASDisplayNode() self.playerNode.setLayerBlock({ return AVPlayerLayer(player: player) }) self.intrinsicDimensions = fileReference.media.dimensions?.cgSize ?? CGSize(width: 480.0, height: 320.0) self.playerNode.frame = CGRect(origin: CGPoint(), size: self.intrinsicDimensions) var qualityFiles: [Int: FileMediaReference] = [:] for alternativeRepresentation in fileReference.media.alternativeRepresentations { if let alternativeFile = alternativeRepresentation as? TelegramMediaFile { for attribute in alternativeFile.attributes { if case let .Video(_, size, _, _, _, videoCodec) = attribute { let _ = size if let videoCodec, NativeVideoContent.isVideoCodecSupported(videoCodec: videoCodec) { qualityFiles[Int(size.height)] = fileReference.withMedia(alternativeFile) } } } } } /*for key in Array(qualityFiles.keys) { if key != 144 && key != 720 { qualityFiles.removeValue(forKey: key) } }*/ var playlistFiles: [Int: FileMediaReference] = [:] for alternativeRepresentation in fileReference.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] = fileReference.withMedia(alternativeFile) break } } } } } } } } if !playlistFiles.isEmpty && playlistFiles.keys == qualityFiles.keys { self.playerSource = HLSServerSource(accountId: accountId.int64, fileId: fileReference.media.fileId.id, postbox: postbox, userLocation: userLocation, playlistFiles: playlistFiles, qualityFiles: qualityFiles) } 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 size = strongSelf.validLayout { strongSelf.updateLayout(size: size, transition: .immediate) } } } } return getData }) self.addSubnode(self.imageNode) self.addSubnode(self.playerNode) self.player?.actionAtItemEnd = .pause self.imageNode.imageUpdated = { [weak self] _ in self?._ready.set(.single(Void())) } self.player?.addObserver(self, forKeyPath: "rate", options: [], context: nil) self._bufferingStatus.set(.single(nil)) 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 playerItem: AVPlayerItem let assetUrl = "http://127.0.0.1:\(SharedHLSServer.shared.port)/\(playerSource.id)/master.m3u8" #if DEBUG print("HLSVideoContentNode: playing \(assetUrl)") #endif playerItem = AVPlayerItem(url: URL(string: assetUrl)!) if #available(iOS 14.0, *) { playerItem.startsOnFirstEligibleVariant = true } self.setPlayerItem(playerItem) } }) } self.didBecomeActiveObserver = NotificationCenter.default.addObserver(forName: UIApplication.willEnterForegroundNotification, object: nil, queue: nil, using: { [weak self] _ in guard let strongSelf = self, let layer = strongSelf.playerNode.layer as? AVPlayerLayer else { return } layer.player = strongSelf.player }) self.willResignActiveObserver = NotificationCenter.default.addObserver(forName: UIApplication.didEnterBackgroundNotification, object: nil, queue: nil, using: { [weak self] _ in guard let strongSelf = self, let layer = strongSelf.playerNode.layer as? AVPlayerLayer else { return } layer.player = nil }) } deinit { self.player?.removeObserver(self, forKeyPath: "rate") self.setPlayerItem(nil) self.audioSessionDisposable.dispose() self.loadProgressDisposable?.dispose() self.statusDisposable?.dispose() if let didBecomeActiveObserver = self.didBecomeActiveObserver { NotificationCenter.default.removeObserver(didBecomeActiveObserver) } if let willResignActiveObserver = self.willResignActiveObserver { NotificationCenter.default.removeObserver(willResignActiveObserver) } if let didPlayToEndTimeObserver = self.didPlayToEndTimeObserver { NotificationCenter.default.removeObserver(didPlayToEndTimeObserver) } if let failureObserverId = self.failureObserverId { NotificationCenter.default.removeObserver(failureObserverId) } if let errorObserverId = self.errorObserverId { NotificationCenter.default.removeObserver(errorObserverId) } self.serverDisposable?.dispose() self.statusTimer?.invalidate() } private func setPlayerItem(_ item: AVPlayerItem?) { if let playerItem = self.playerItem { playerItem.removeObserver(self, forKeyPath: "playbackBufferEmpty") playerItem.removeObserver(self, forKeyPath: "playbackLikelyToKeepUp") playerItem.removeObserver(self, forKeyPath: "playbackBufferFull") playerItem.removeObserver(self, forKeyPath: "status") playerItem.removeObserver(self, forKeyPath: "presentationSize") } if let playerItemFailedToPlayToEndTimeObserver = self.playerItemFailedToPlayToEndTimeObserver { self.playerItemFailedToPlayToEndTimeObserver = nil NotificationCenter.default.removeObserver(playerItemFailedToPlayToEndTimeObserver) } if let didPlayToEndTimeObserver = self.didPlayToEndTimeObserver { self.didPlayToEndTimeObserver = nil NotificationCenter.default.removeObserver(didPlayToEndTimeObserver) } if let failureObserverId = self.failureObserverId { self.failureObserverId = nil NotificationCenter.default.removeObserver(failureObserverId) } if let errorObserverId = self.errorObserverId { self.errorObserverId = nil NotificationCenter.default.removeObserver(errorObserverId) } self.playerItem = item if let item { self.didPlayToEndTimeObserver = NotificationCenter.default.addObserver(forName: NSNotification.Name.AVPlayerItemDidPlayToEndTime, object: item, queue: nil, using: { [weak self] notification in self?.performActionAtEnd() }) self.failureObserverId = NotificationCenter.default.addObserver(forName: AVPlayerItem.failedToPlayToEndTimeNotification, object: item, queue: .main, using: { notification in #if DEBUG print("Player Error: \(notification.description)") #endif }) self.errorObserverId = NotificationCenter.default.addObserver(forName: AVPlayerItem.newErrorLogEntryNotification, object: item, queue: .main, using: { [weak item] notification in if let item { let event = item.errorLog()?.events.last if let event { let _ = event #if DEBUG print("Player Error: \(event.errorComment ?? "")") #endif } } }) item.addObserver(self, forKeyPath: "presentationSize", options: [], context: nil) } if let playerItem = self.playerItem { playerItem.addObserver(self, forKeyPath: "playbackBufferEmpty", options: .new, context: nil) playerItem.addObserver(self, forKeyPath: "playbackLikelyToKeepUp", options: .new, context: nil) playerItem.addObserver(self, forKeyPath: "playbackBufferFull", options: .new, context: nil) playerItem.addObserver(self, forKeyPath: "status", options: .new, context: nil) self.playerItemFailedToPlayToEndTimeObserver = NotificationCenter.default.addObserver(forName: NSNotification.Name.AVPlayerItemFailedToPlayToEndTime, object: playerItem, queue: OperationQueue.main, using: { [weak self] _ in guard let self else { return } let _ = self }) } self.player?.replaceCurrentItem(with: self.playerItem) } private func updateStatus() { guard let player = self.player else { return } let isPlaying = !player.rate.isZero let status: MediaPlayerPlaybackStatus if self.isBuffering { status = .buffering(initial: false, whilePlaying: isPlaying, progress: 0.0, display: true) } else { status = isPlaying ? .playing : .paused } var timestamp = player.currentTime().seconds if timestamp.isFinite && !timestamp.isNaN { } else { timestamp = 0.0 } self.statusValue = MediaPlayerStatus(generationTimestamp: CACurrentMediaTime(), duration: Double(self.approximateDuration), dimensions: CGSize(), timestamp: timestamp, baseRate: self.baseRate, 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() } } override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) { if keyPath == "rate" { if let player = self.player { let isPlaying = !player.rate.isZero if isPlaying { self.isBuffering = false } } self.updateStatus() } else if keyPath == "playbackBufferEmpty" { self.isBuffering = true self.updateStatus() } else if keyPath == "playbackLikelyToKeepUp" || keyPath == "playbackBufferFull" { self.isBuffering = false self.updateStatus() } else if keyPath == "presentationSize" { if let currentItem = self.player?.currentItem { print("Presentation size: \(Int(currentItem.presentationSize.height))") } } } private func performActionAtEnd() { for listener in self.playbackCompletedListeners.copyItems() { listener() } } func updateLayout(size: 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.baseRate, seekId: self.seekId, status: .buffering(initial: true, whilePlaying: true, progress: 0.0, display: true), soundEnabled: true)) } if !self.hasAudioSession { if self.player?.volume != 0.0 { self.audioSessionDisposable.set(self.audioSessionManager.push(audioSessionType: .play(mixWithOthers: false), activate: { [weak self] _ in guard let self else { return } self.hasAudioSession = true self.player?.play() }, deactivate: { [weak self] _ in guard let self else { return .complete() } self.hasAudioSession = false self.player?.pause() return .complete() })) } else { self.player?.play() } } else { self.player?.play() } } func pause() { assert(Queue.mainQueue().isCurrent()) self.player?.pause() } func togglePlayPause() { assert(Queue.mainQueue().isCurrent()) guard let player = self.player else { return } if player.rate.isZero { self.play() } else { self.pause() } } 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.player?.seek(to: CMTime(seconds: timestamp, preferredTimescale: 30)) } func playOnceWithSound(playAndRecord: Bool, seek: MediaPlayerSeek, actionAtEnd: MediaPlayerPlayOnceWithSoundActionAtEnd) { self.player?.volume = 1.0 self.play() } func setSoundMuted(soundMuted: Bool) { self.player?.volume = soundMuted ? 0.0 : 1.0 } func continueWithOverridingAmbientMode(isAmbient: Bool) { } func setForceAudioToSpeaker(_ forceAudioToSpeaker: Bool) { } func continuePlayingWithoutSound(actionAtEnd: MediaPlayerPlayOnceWithSoundActionAtEnd) { self.player?.volume = 0.0 self.hasAudioSession = false self.audioSessionDisposable.set(nil) } func setContinuePlayingWithoutSoundOnLostAudioSession(_ value: Bool) { } func setBaseRate(_ baseRate: Double) { guard let player = self.player else { return } self.baseRate = baseRate if #available(iOS 16.0, *) { player.defaultRate = Float(baseRate) } if player.rate != 0.0 { player.rate = Float(baseRate) } self.updateStatus() } func setVideoQuality(_ videoQuality: UniversalVideoContentVideoQuality) { self.preferredVideoQuality = videoQuality guard let currentItem = self.player?.currentItem else { return } guard let playerSource = self.playerSource else { return } switch videoQuality { case .auto: currentItem.preferredPeakBitRate = 0.0 case let .quality(qualityValue): if let file = playerSource.qualityFiles[qualityValue] { if let size = file.media.size, let duration = file.media.duration, duration != 0.0 { let bandwidth = Int(Double(size) / duration) * 8 currentItem.preferredPeakBitRate = Double(bandwidth) } } } } func videoQualityState() -> (current: Int, preferred: UniversalVideoContentVideoQuality, available: [Int])? { guard let currentItem = self.player?.currentItem else { return nil } guard let playerSource = self.playerSource else { return nil } let current = Int(currentItem.presentationSize.height) var available: [Int] = Array(playerSource.qualityFiles.keys) available.sort(by: { $0 > $1 }) return (current, 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) { } }