208 lines
6.2 KiB
Swift
208 lines
6.2 KiB
Swift
import AVFoundation
|
|
import Combine
|
|
import Foundation
|
|
|
|
extension Notification.Name {
|
|
static let lumaExclusiveMediaPlayback = Notification.Name("app.luma.exclusiveMediaPlayback")
|
|
}
|
|
|
|
@MainActor
|
|
final class MediaPlaybackCoordinator: ObservableObject {
|
|
@Published private(set) var currentMessageID: String?
|
|
@Published private(set) var isPlaying = false
|
|
@Published private(set) var currentTime: TimeInterval = 0
|
|
@Published private(set) var duration: TimeInterval = 0
|
|
@Published private(set) var voiceRate: Double = 1
|
|
|
|
private var player: AVPlayer?
|
|
private var timeObserver: Any?
|
|
private var endObserver: NSObjectProtocol?
|
|
private var currentIsVoice = false
|
|
private var durationTask: Task<Void, Never>?
|
|
|
|
deinit {
|
|
durationTask?.cancel()
|
|
if let timeObserver, let player {
|
|
player.removeTimeObserver(timeObserver)
|
|
}
|
|
if let endObserver {
|
|
NotificationCenter.default.removeObserver(endObserver)
|
|
}
|
|
}
|
|
|
|
func toggle(
|
|
messageID: String,
|
|
url: URL,
|
|
durationHint: TimeInterval?,
|
|
isVoice: Bool
|
|
) {
|
|
if currentMessageID != messageID {
|
|
prepare(
|
|
messageID: messageID,
|
|
url: url,
|
|
durationHint: durationHint,
|
|
isVoice: isVoice
|
|
)
|
|
}
|
|
guard player != nil else { return }
|
|
isPlaying ? pause() : play()
|
|
}
|
|
|
|
func seek(
|
|
messageID: String,
|
|
url: URL,
|
|
durationHint: TimeInterval?,
|
|
isVoice: Bool,
|
|
fraction: Double
|
|
) {
|
|
if currentMessageID != messageID {
|
|
prepare(
|
|
messageID: messageID,
|
|
url: url,
|
|
durationHint: durationHint,
|
|
isVoice: isVoice
|
|
)
|
|
}
|
|
guard let player else { return }
|
|
let targetDuration = max(duration, durationHint ?? 0)
|
|
guard targetDuration > 0 else { return }
|
|
let seconds = max(0, min(1, fraction)) * targetDuration
|
|
currentTime = seconds
|
|
player.seek(
|
|
to: CMTime(seconds: seconds, preferredTimescale: 600),
|
|
toleranceBefore: .zero,
|
|
toleranceAfter: .zero
|
|
)
|
|
}
|
|
|
|
func cycleVoiceRate() {
|
|
if voiceRate < 1.25 {
|
|
voiceRate = 1.5
|
|
} else if voiceRate < 1.75 {
|
|
voiceRate = 2
|
|
} else {
|
|
voiceRate = 1
|
|
}
|
|
guard currentIsVoice, isPlaying else { return }
|
|
player?.playImmediately(atRate: Float(voiceRate))
|
|
}
|
|
|
|
func stop() {
|
|
durationTask?.cancel()
|
|
durationTask = nil
|
|
player?.pause()
|
|
removeObservers()
|
|
player = nil
|
|
currentMessageID = nil
|
|
isPlaying = false
|
|
currentTime = 0
|
|
duration = 0
|
|
currentIsVoice = false
|
|
}
|
|
|
|
private func prepare(
|
|
messageID: String,
|
|
url: URL,
|
|
durationHint: TimeInterval?,
|
|
isVoice: Bool
|
|
) {
|
|
stop()
|
|
let player = AVPlayer(url: url)
|
|
player.actionAtItemEnd = .pause
|
|
self.player = player
|
|
currentMessageID = messageID
|
|
currentIsVoice = isVoice
|
|
currentTime = 0
|
|
duration = Self.validDuration(durationHint) ?? 0
|
|
installObservers(for: player)
|
|
|
|
if duration == 0 {
|
|
durationTask = Task { [weak self] in
|
|
let asset = AVURLAsset(url: url)
|
|
guard let time = try? await asset.load(.duration),
|
|
!Task.isCancelled else { return }
|
|
let seconds = time.seconds
|
|
guard seconds.isFinite, seconds > 0,
|
|
self?.currentMessageID == messageID else { return }
|
|
self?.duration = seconds
|
|
}
|
|
}
|
|
}
|
|
|
|
private func play() {
|
|
guard let player, let currentMessageID else { return }
|
|
configureAudioSession()
|
|
NotificationCenter.default.post(
|
|
name: .lumaExclusiveMediaPlayback,
|
|
object: currentMessageID
|
|
)
|
|
if currentIsVoice {
|
|
player.playImmediately(atRate: Float(voiceRate))
|
|
} else {
|
|
player.play()
|
|
}
|
|
isPlaying = true
|
|
}
|
|
|
|
private func pause() {
|
|
player?.pause()
|
|
isPlaying = false
|
|
}
|
|
|
|
private func installObservers(for player: AVPlayer) {
|
|
timeObserver = player.addPeriodicTimeObserver(
|
|
forInterval: CMTime(seconds: 0.1, preferredTimescale: 600),
|
|
queue: .main
|
|
) { [weak self, weak player] time in
|
|
Task { @MainActor in
|
|
guard let self, let player else { return }
|
|
let seconds = time.seconds
|
|
if seconds.isFinite {
|
|
self.currentTime = max(0, seconds)
|
|
}
|
|
if let itemDuration = player.currentItem?.duration.seconds,
|
|
itemDuration.isFinite,
|
|
itemDuration > 0 {
|
|
self.duration = itemDuration
|
|
}
|
|
}
|
|
}
|
|
endObserver = NotificationCenter.default.addObserver(
|
|
forName: .AVPlayerItemDidPlayToEndTime,
|
|
object: player.currentItem,
|
|
queue: .main
|
|
) { [weak self, weak player] _ in
|
|
Task { @MainActor in
|
|
player?.seek(to: .zero)
|
|
self?.currentTime = 0
|
|
self?.isPlaying = false
|
|
}
|
|
}
|
|
}
|
|
|
|
private func removeObservers() {
|
|
if let timeObserver, let player {
|
|
player.removeTimeObserver(timeObserver)
|
|
}
|
|
timeObserver = nil
|
|
if let endObserver {
|
|
NotificationCenter.default.removeObserver(endObserver)
|
|
}
|
|
endObserver = nil
|
|
}
|
|
|
|
private func configureAudioSession() {
|
|
#if os(iOS)
|
|
let session = AVAudioSession.sharedInstance()
|
|
let mode: AVAudioSession.Mode = currentIsVoice ? .spokenAudio : .default
|
|
try? session.setCategory(.playback, mode: mode, options: [.allowAirPlay, .allowBluetoothA2DP])
|
|
try? session.setActive(true)
|
|
#endif
|
|
}
|
|
|
|
private static func validDuration(_ value: TimeInterval?) -> TimeInterval? {
|
|
guard let value, value.isFinite, value > 0 else { return nil }
|
|
return value
|
|
}
|
|
}
|