Files
Luma/Sources/Shared/Services/MediaPlaybackCoordinator.swift
T
2026-07-30 20:07:51 +07:00

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
}
}