mirror of
https://github.com/TelegramMessenger/Telegram-iOS.git
synced 2026-07-06 03:33:41 +02:00
The audio session category options include .allowBluetoothA2DP for certain playback types but omit .allowAirPlay, preventing proper AirPlay audio routing during media playback. This adds .allowAirPlay to the .play and .playWithPossiblePortOverride session types for consistency with other wireless audio routing options. Also explicitly sets allowsExternalPlayback = true on AVPlayer instances in PlatformVideoContent and SystemVideoContent to document external playback intent and prevent potential regressions. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
333 lines
14 KiB
Swift
333 lines
14 KiB
Swift
import Foundation
|
|
import UIKit
|
|
import AsyncDisplayKit
|
|
import Display
|
|
import SwiftSignalKit
|
|
import Postbox
|
|
import TelegramCore
|
|
import TelegramAudio
|
|
import LegacyComponents
|
|
import UniversalMediaPlayer
|
|
import AccountContext
|
|
import PhotoResources
|
|
import RangeSet
|
|
import CoreMedia
|
|
import AVFoundation
|
|
|
|
public final class SystemVideoContent: UniversalVideoContent {
|
|
public let id: AnyHashable
|
|
let userLocation: MediaResourceUserLocation
|
|
let url: String
|
|
let imageReference: ImageMediaReference
|
|
public let dimensions: CGSize
|
|
public let duration: Double
|
|
|
|
public init(userLocation: MediaResourceUserLocation, url: String, imageReference: ImageMediaReference, dimensions: CGSize, duration: Double) {
|
|
self.id = AnyHashable(url)
|
|
self.url = url
|
|
self.userLocation = userLocation
|
|
self.imageReference = imageReference
|
|
self.dimensions = dimensions
|
|
self.duration = duration
|
|
}
|
|
|
|
public func makeContentNode(context: AccountContext, postbox: Postbox, audioSession: ManagedAudioSession) -> UniversalVideoContentNode & ASDisplayNode {
|
|
return SystemVideoContentNode(postbox: postbox, audioSessionManager: audioSession, userLocation: self.userLocation, url: self.url, imageReference: self.imageReference, intrinsicDimensions: self.dimensions, approximateDuration: self.duration)
|
|
}
|
|
}
|
|
|
|
private final class SystemVideoContentNode: ASDisplayNode, UniversalVideoContentNode {
|
|
private let url: String
|
|
private let intrinsicDimensions: CGSize
|
|
private let approximateDuration: Double
|
|
|
|
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: .buffering(initial: true, whilePlaying: false, progress: 0.0, display: true), soundEnabled: true)
|
|
private var isBuffering = true
|
|
private let _status = ValuePromise<MediaPlayerStatus>()
|
|
var status: Signal<MediaPlayerStatus, NoError> {
|
|
return self._status.get()
|
|
}
|
|
|
|
private let _bufferingStatus = Promise<(RangeSet<Int64>, Int64)?>()
|
|
var bufferingStatus: Signal<(RangeSet<Int64>, Int64)?, NoError> {
|
|
return self._bufferingStatus.get()
|
|
}
|
|
|
|
var isNativePictureInPictureActive: Signal<Bool, NoError> {
|
|
return .single(false)
|
|
}
|
|
|
|
private let _ready = Promise<Void>()
|
|
var ready: Signal<Void, NoError> {
|
|
return self._ready.get()
|
|
}
|
|
|
|
private let _preloadCompleted = ValuePromise<Bool>()
|
|
var preloadCompleted: Signal<Bool, NoError> {
|
|
return self._preloadCompleted.get()
|
|
}
|
|
|
|
private let imageNode: TransformImageNode
|
|
private let playerItem: AVPlayerItem
|
|
private let player: AVPlayer
|
|
private let playerNode: ASDisplayNode
|
|
|
|
private var loadProgressDisposable: Disposable?
|
|
private var statusDisposable: Disposable?
|
|
|
|
private var didBeginPlaying = false
|
|
|
|
private var didPlayToEndTimeObserver: NSObjectProtocol?
|
|
private var timeObserver: Any?
|
|
|
|
private var seekId: Int = 0
|
|
|
|
init(postbox: Postbox, audioSessionManager: ManagedAudioSession, userLocation: MediaResourceUserLocation, url: String, imageReference: ImageMediaReference, intrinsicDimensions: CGSize, approximateDuration: Double) {
|
|
self.audioSessionManager = audioSessionManager
|
|
|
|
self.url = url
|
|
self.intrinsicDimensions = intrinsicDimensions
|
|
self.approximateDuration = approximateDuration
|
|
|
|
self.imageNode = TransformImageNode()
|
|
|
|
self.playerItem = AVPlayerItem(url: URL(string: url)!)
|
|
let player = AVPlayer(playerItem: self.playerItem)
|
|
player.allowsExternalPlayback = true
|
|
self.player = player
|
|
|
|
self.playerNode = ASDisplayNode()
|
|
self.playerNode.setLayerBlock({
|
|
return AVPlayerLayer(player: player)
|
|
})
|
|
|
|
self.playerNode.frame = CGRect(origin: CGPoint(), size: intrinsicDimensions)
|
|
self.isBuffering = true
|
|
|
|
super.init()
|
|
|
|
self.imageNode.setSignal(chatMessagePhoto(postbox: postbox, userLocation: userLocation, photoReference: imageReference))
|
|
|
|
self.addSubnode(self.imageNode)
|
|
self.addSubnode(self.playerNode)
|
|
self.player.actionAtItemEnd = .pause
|
|
|
|
self.didPlayToEndTimeObserver = NotificationCenter.default.addObserver(forName: NSNotification.Name.AVPlayerItemDidPlayToEndTime, object: self.player.currentItem, queue: nil, using: { [weak self] notification in
|
|
if let strongSelf = self {
|
|
strongSelf.player.seek(to: CMTime(seconds: 0.0, preferredTimescale: 30))
|
|
strongSelf.play()
|
|
}
|
|
})
|
|
|
|
self.imageNode.imageUpdated = { [weak self] _ in
|
|
self?._ready.set(.single(Void()))
|
|
}
|
|
|
|
self.player.addObserver(self, forKeyPath: "rate", options: [], context: nil)
|
|
self.playerItem.addObserver(self, forKeyPath: "playbackBufferEmpty", options: .new, context: nil)
|
|
self.playerItem.addObserver(self, forKeyPath: "playbackLikelyToKeepUp", options: .new, context: nil)
|
|
self.playerItem.addObserver(self, forKeyPath: "playbackBufferFull", options: .new, context: nil)
|
|
|
|
self._bufferingStatus.set(.single(nil))
|
|
self._status.set(self.statusValue)
|
|
|
|
self.timeObserver = self.player.addPeriodicTimeObserver(forInterval: CMTimeMake(value: 1, timescale: 10), queue: DispatchQueue.main) { [weak self] time in
|
|
guard let strongSelf = self else {
|
|
return
|
|
}
|
|
strongSelf.statusValue = MediaPlayerStatus(generationTimestamp: 0.0, duration: strongSelf.statusValue.duration, dimensions: CGSize(), timestamp: CMTimeGetSeconds(time), baseRate: 1.0, seekId: strongSelf.seekId, status: strongSelf.statusValue.status, soundEnabled: true)
|
|
strongSelf._status.set(strongSelf.statusValue)
|
|
}
|
|
}
|
|
|
|
deinit {
|
|
if let timeObserver = self.timeObserver {
|
|
self.player.removeTimeObserver(timeObserver)
|
|
}
|
|
self.player.removeObserver(self, forKeyPath: "rate")
|
|
self.playerItem.removeObserver(self, forKeyPath: "playbackBufferEmpty")
|
|
self.playerItem.removeObserver(self, forKeyPath: "playbackLikelyToKeepUp")
|
|
self.playerItem.removeObserver(self, forKeyPath: "playbackBufferFull")
|
|
|
|
self.audioSessionDisposable.dispose()
|
|
|
|
self.loadProgressDisposable?.dispose()
|
|
self.statusDisposable?.dispose()
|
|
|
|
if let didPlayToEndTimeObserver = self.didPlayToEndTimeObserver {
|
|
NotificationCenter.default.removeObserver(didPlayToEndTimeObserver)
|
|
}
|
|
}
|
|
|
|
override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
|
|
let duration: Double
|
|
if let currentItem = self.player.currentItem {
|
|
duration = CMTimeGetSeconds(currentItem.duration)
|
|
} else {
|
|
duration = self.approximateDuration
|
|
}
|
|
|
|
if keyPath == "rate" {
|
|
let isPlaying = !self.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
|
|
}
|
|
self.statusValue = MediaPlayerStatus(generationTimestamp: 0.0, duration: duration, dimensions: CGSize(), timestamp: self.statusValue.timestamp, baseRate: 1.0, seekId: self.seekId, status: status, soundEnabled: true)
|
|
self._status.set(self.statusValue)
|
|
} else if keyPath == "playbackBufferEmpty" {
|
|
let isPlaying = !self.player.rate.isZero
|
|
let status: MediaPlayerPlaybackStatus
|
|
self.isBuffering = true
|
|
if self.isBuffering {
|
|
status = .buffering(initial: false, whilePlaying: isPlaying, progress: 0.0, display: true)
|
|
} else {
|
|
status = isPlaying ? .playing : .paused
|
|
}
|
|
self.statusValue = MediaPlayerStatus(generationTimestamp: 0.0, duration: duration, dimensions: CGSize(), timestamp: self.statusValue.timestamp, baseRate: 1.0, seekId: self.seekId, status: status, soundEnabled: true)
|
|
self._status.set(self.statusValue)
|
|
} else if keyPath == "playbackLikelyToKeepUp" || keyPath == "playbackBufferFull" {
|
|
let isPlaying = !self.player.rate.isZero
|
|
let status: MediaPlayerPlaybackStatus
|
|
self.isBuffering = false
|
|
if self.isBuffering {
|
|
status = .buffering(initial: false, whilePlaying: isPlaying, progress: 0.0, display: true)
|
|
} else {
|
|
status = isPlaying ? .playing : .paused
|
|
}
|
|
self.statusValue = MediaPlayerStatus(generationTimestamp: 0.0, duration: duration, dimensions: CGSize(), timestamp: self.statusValue.timestamp, baseRate: 1.0, seekId: self.seekId, status: status, soundEnabled: true)
|
|
self._status.set(self.statusValue)
|
|
|
|
if !self.didBeginPlaying {
|
|
self.didBeginPlaying = true
|
|
self.play()
|
|
}
|
|
}
|
|
}
|
|
|
|
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))
|
|
|
|
let makeImageLayout = self.imageNode.asyncLayout()
|
|
let applyImageLayout = makeImageLayout(TransformImageArguments(corners: ImageCorners(), imageSize: size, boundingSize: size, intrinsicInsets: UIEdgeInsets()))
|
|
applyImageLayout()
|
|
}
|
|
|
|
func play() {
|
|
assert(Queue.mainQueue().isCurrent())
|
|
if !self.initializedStatus {
|
|
self._status.set(MediaPlayerStatus(generationTimestamp: 0.0, duration: self.approximateDuration, dimensions: CGSize(), timestamp: 0.0, baseRate: 1.0, 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
|
|
self?.hasAudioSession = true
|
|
self?.player.play()
|
|
}, deactivate: { [weak self] _ in
|
|
self?.hasAudioSession = false
|
|
self?.player.pause()
|
|
return .complete()
|
|
}))
|
|
} else {
|
|
self.player.play()
|
|
}
|
|
}
|
|
|
|
func pause() {
|
|
assert(Queue.mainQueue().isCurrent())
|
|
if !self.initializedStatus {
|
|
self._status.set(MediaPlayerStatus(generationTimestamp: 0.0, duration: self.approximateDuration, dimensions: CGSize(), timestamp: 0.0, baseRate: 1.0, seekId: self.seekId, status: .paused, soundEnabled: true))
|
|
}
|
|
self.player.pause()
|
|
}
|
|
|
|
func togglePlayPause() {
|
|
assert(Queue.mainQueue().isCurrent())
|
|
if self.player.rate.isZero {
|
|
self.play()
|
|
} else {
|
|
self.pause()
|
|
}
|
|
}
|
|
|
|
func setSoundEnabled(_ value: Bool) {
|
|
assert(Queue.mainQueue().isCurrent())
|
|
}
|
|
|
|
func seek(_ timestamp: Double) {
|
|
assert(Queue.mainQueue().isCurrent())
|
|
self.seekId += 1
|
|
self.playerItem.seek(to: CMTimeMake(value: Int64(timestamp) * 1000, timescale: 1000), completionHandler: nil)
|
|
}
|
|
|
|
func playOnceWithSound(playAndRecord: Bool, seek: MediaPlayerSeek, actionAtEnd: MediaPlayerPlayOnceWithSoundActionAtEnd) {
|
|
}
|
|
|
|
func setSoundMuted(soundMuted: Bool) {
|
|
}
|
|
|
|
func continueWithOverridingAmbientMode(isAmbient: Bool) {
|
|
}
|
|
|
|
func setForceAudioToSpeaker(_ forceAudioToSpeaker: Bool) {
|
|
}
|
|
|
|
func continuePlayingWithoutSound(actionAtEnd: MediaPlayerPlayOnceWithSoundActionAtEnd) {
|
|
}
|
|
|
|
func setContinuePlayingWithoutSoundOnLostAudioSession(_ value: Bool) {
|
|
}
|
|
|
|
func setBaseRate(_ baseRate: Double) {
|
|
}
|
|
|
|
func setVideoQuality(_ videoQuality: UniversalVideoContentVideoQuality) {
|
|
}
|
|
|
|
func videoQualityState() -> (current: Int, preferred: UniversalVideoContentVideoQuality, available: [Int])? {
|
|
return nil
|
|
}
|
|
|
|
func videoQualityStateSignal() -> Signal<(current: Int, preferred: UniversalVideoContentVideoQuality, available: [Int])?, NoError> {
|
|
return .single(nil)
|
|
}
|
|
|
|
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 {
|
|
return false
|
|
}
|
|
|
|
func exitNativePictureInPicture() {
|
|
}
|
|
|
|
func setNativePictureInPictureIsActive(_ value: Bool) {
|
|
}
|
|
}
|
|
|