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>
493 lines
20 KiB
Swift
493 lines
20 KiB
Swift
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
|
|
|
|
public enum PlatformVideoContentId: Hashable {
|
|
case message(MessageId, UInt32, MediaId)
|
|
case instantPage(MediaId, MediaId)
|
|
|
|
public static func ==(lhs: PlatformVideoContentId, rhs: PlatformVideoContentId) -> Bool {
|
|
switch lhs {
|
|
case let .message(messageId, stableId, mediaId):
|
|
if case .message(messageId, stableId, mediaId) = rhs {
|
|
return true
|
|
} else {
|
|
return false
|
|
}
|
|
case let .instantPage(pageId, mediaId):
|
|
if case .instantPage(pageId, mediaId) = rhs {
|
|
return true
|
|
} else {
|
|
return false
|
|
}
|
|
}
|
|
}
|
|
|
|
public func hash(into hasher: inout Hasher) {
|
|
switch self {
|
|
case let .message(messageId, _, mediaId):
|
|
hasher.combine(messageId)
|
|
hasher.combine(mediaId)
|
|
case let .instantPage(pageId, mediaId):
|
|
hasher.combine(pageId)
|
|
hasher.combine(mediaId)
|
|
}
|
|
}
|
|
}
|
|
|
|
public final class PlatformVideoContent: UniversalVideoContent {
|
|
public enum Content {
|
|
case file(FileMediaReference)
|
|
case url(String)
|
|
|
|
var duration: Double? {
|
|
switch self {
|
|
case let .file(file):
|
|
return file.media.duration
|
|
case .url:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
var dimensions: PixelDimensions? {
|
|
switch self {
|
|
case let .file(file):
|
|
return file.media.dimensions
|
|
case .url:
|
|
return PixelDimensions(width: 480, height: 300)
|
|
}
|
|
}
|
|
}
|
|
|
|
public let id: AnyHashable
|
|
public let nativeId: PlatformVideoContentId
|
|
let userLocation: MediaResourceUserLocation
|
|
let content: Content
|
|
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, content: Content, 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.content = content
|
|
self.dimensions = self.content.dimensions?.cgSize ?? CGSize(width: 480, height: 320)
|
|
self.duration = self.content.duration ?? 0.0
|
|
self.streamVideo = streamVideo
|
|
self.loopVideo = loopVideo
|
|
self.enableSound = enableSound
|
|
self.baseRate = baseRate
|
|
self.fetchAutomatically = fetchAutomatically
|
|
}
|
|
|
|
public func makeContentNode(context: AccountContext, postbox: Postbox, audioSession: ManagedAudioSession) -> UniversalVideoContentNode & ASDisplayNode {
|
|
return PlatformVideoContentNode(postbox: postbox, audioSessionManager: audioSession, userLocation: self.userLocation, content: self.content, 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? PlatformVideoContent {
|
|
if case let .message(_, stableId, _) = self.nativeId {
|
|
if case .message(_, stableId, _) = other.nativeId {
|
|
if case let .file(file) = self.content {
|
|
if file.media.isInstantVideo {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
}
|
|
|
|
private final class PlatformVideoContentNode: ASDisplayNode, UniversalVideoContentNode {
|
|
private let postbox: Postbox
|
|
private let userLocation: MediaResourceUserLocation
|
|
private let content: PlatformVideoContent.Content
|
|
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 isBuffering = false
|
|
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 var playerItem: AVPlayerItem?
|
|
private let 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 playerItemFailedToPlayToEndTimeObserver: NSObjectProtocol?
|
|
|
|
private let fetchDisposable = MetaDisposable()
|
|
|
|
private var dimensions: CGSize?
|
|
private let dimensionsPromise = ValuePromise<CGSize>(CGSize())
|
|
|
|
private var validLayout: (size: CGSize, actualSize: CGSize)?
|
|
|
|
init(postbox: Postbox, audioSessionManager: ManagedAudioSession, userLocation: MediaResourceUserLocation, content: PlatformVideoContent.Content, streamVideo: Bool, loopVideo: Bool, enableSound: Bool, baseRate: Double, fetchAutomatically: Bool) {
|
|
self.postbox = postbox
|
|
self.content = content
|
|
self.approximateDuration = Double(content.duration ?? 1)
|
|
self.audioSessionManager = audioSessionManager
|
|
self.userLocation = userLocation
|
|
|
|
self.imageNode = TransformImageNode()
|
|
|
|
let player = AVPlayer(playerItem: nil)
|
|
player.allowsExternalPlayback = true
|
|
self.player = player
|
|
|
|
self.playerNode = ASDisplayNode()
|
|
self.playerNode.setLayerBlock({
|
|
return AVPlayerLayer(player: player)
|
|
})
|
|
|
|
self.intrinsicDimensions = content.dimensions?.cgSize ?? CGSize()
|
|
|
|
self.playerNode.frame = CGRect(origin: CGPoint(), size: self.intrinsicDimensions)
|
|
|
|
super.init()
|
|
|
|
switch content {
|
|
case let .file(file):
|
|
self.imageNode.setSignal(internalMediaGridMessageVideo(postbox: postbox, userLocation: self.userLocation, videoReference: file) |> 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
|
|
})
|
|
case .url:
|
|
break
|
|
}
|
|
|
|
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
|
|
self?.performActionAtEnd()
|
|
})
|
|
|
|
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))
|
|
|
|
let playerItem: AVPlayerItem
|
|
switch content {
|
|
case let .file(file):
|
|
playerItem = AVPlayerItem(url: URL(string: postbox.mediaBox.completedResourcePath(file.media.resource, pathExtension: "mov") ?? "")!)
|
|
case let .url(url):
|
|
playerItem = AVPlayerItem(url: URL(string: url)!)
|
|
}
|
|
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 didPlayToEndTimeObserver = self.didPlayToEndTimeObserver {
|
|
NotificationCenter.default.removeObserver(didPlayToEndTimeObserver)
|
|
}
|
|
if let didBecomeActiveObserver = self.didBecomeActiveObserver {
|
|
NotificationCenter.default.removeObserver(didBecomeActiveObserver)
|
|
}
|
|
if let willResignActiveObserver = self.willResignActiveObserver {
|
|
NotificationCenter.default.removeObserver(willResignActiveObserver)
|
|
}
|
|
}
|
|
|
|
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")
|
|
if let playerItemFailedToPlayToEndTimeObserver = self.playerItemFailedToPlayToEndTimeObserver {
|
|
NotificationCenter.default.removeObserver(playerItemFailedToPlayToEndTimeObserver)
|
|
self.playerItemFailedToPlayToEndTimeObserver = nil
|
|
}
|
|
}
|
|
|
|
self.playerItem = item
|
|
|
|
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 strongSelf = self else {
|
|
return
|
|
}
|
|
switch strongSelf.content {
|
|
case .file:
|
|
break
|
|
case let .url(url):
|
|
let updatedPlayerItem = AVPlayerItem(url: URL(string: url)!)
|
|
strongSelf.setPlayerItem(updatedPlayerItem)
|
|
}
|
|
})
|
|
}
|
|
|
|
self.player.replaceCurrentItem(with: self.playerItem)
|
|
}
|
|
|
|
override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
|
|
if keyPath == "rate" {
|
|
let isPlaying = !self.player.rate.isZero
|
|
let status: MediaPlayerPlaybackStatus
|
|
if isPlaying {
|
|
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: Double(self.approximateDuration), dimensions: CGSize(), timestamp: 0.0, baseRate: 1.0, seekId: 0, 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: Double(self.approximateDuration), dimensions: CGSize(), timestamp: 0.0, baseRate: 1.0, seekId: 0, 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: Double(self.approximateDuration), dimensions: CGSize(), timestamp: 0.0, baseRate: 1.0, seekId: 0, status: status, soundEnabled: true)
|
|
self._status.set(self.statusValue)
|
|
} else if keyPath == "status" {
|
|
/*if let playerItem = self.playerItem, false {
|
|
switch playerItem.status {
|
|
case .failed:
|
|
switch self.content {
|
|
case .file:
|
|
break
|
|
case let .url(url):
|
|
let updatedPlayerItem = AVPlayerItem(url: URL(string: url)!)
|
|
self.setPlayerItem(updatedPlayerItem)
|
|
}
|
|
default:
|
|
break
|
|
}
|
|
}*/
|
|
}
|
|
}
|
|
|
|
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))
|
|
|
|
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: Double(self.approximateDuration), dimensions: CGSize(), timestamp: 0.0, baseRate: 1.0, seekId: 0, 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: Double(self.approximateDuration), dimensions: CGSize(), timestamp: 0.0, baseRate: 1.0, seekId: 0, 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.player.seek(to: CMTime(seconds: timestamp, preferredTimescale: 30))
|
|
}
|
|
|
|
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) {
|
|
}
|
|
}
|