jellyflood/Shared/Objects/MediaPlayerManager/NowPlayable/NowPlayableObserver.swift

306 lines
10 KiB
Swift

//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
//
import Combine
import Foundation
import Logging
import MediaPlayer
import Nuke
// TODO: ensure proper state handling
// - manager states
// - playback request states
// TODO: have MediaPlayerItem report supported commands
@MainActor
class NowPlayableObserver: ViewModel, MediaPlayerObserver {
private var defaultRegisteredCommands: [NowPlayableCommand] {
[
.play,
.pause,
.togglePausePlay,
.skipBackward,
.skipForward,
.changePlaybackPosition,
// TODO: only register next/previous if there is a queue
// .nextTrack,
// .previousTrack,
]
}
private var itemImageCancellable: AnyCancellable?
private var playbackRequestStateBeforeInterruption: MediaPlayerManager.PlaybackRequestStatus = .playing
weak var manager: MediaPlayerManager? {
willSet {
guard let newValue else { return }
setup(with: newValue)
}
}
private func setup(with manager: MediaPlayerManager) {
do {
try startSession()
} catch {
logger.critical("Unable to activate audio session: \(error.localizedDescription)")
}
cancellables = []
manager.actions
.sink { [weak self] newValue in self?.actionDidChange(newValue) }
.store(in: &cancellables)
manager.$playbackItem
.sink { [weak self] newValue in self?.playbackItemDidChange(newValue) }
.store(in: &cancellables)
manager.$playbackRequestStatus
.sink { [weak self] newValue in self?.playbackRequestStatusDidChange(newValue) }
.store(in: &cancellables)
manager.secondsBox.$value
.sink { [weak self] newValue in self?.secondsDidChange(newValue) }
.store(in: &cancellables)
Notifications[.avAudioSessionInterruption]
.publisher
.sink { i in
Task { @MainActor in
self.handleInterruption(type: i.0, options: i.1)
}
}
.store(in: &cancellables)
Task { @MainActor in
configureRemoteCommands(
defaultRegisteredCommands,
commandHandler: handleCommand
)
}
}
private func playbackRequestStatusDidChange(_ newStatus: MediaPlayerManager.PlaybackRequestStatus) {
handleNowPlayablePlaybackChange(
playing: newStatus == .playing,
metadata: .init(
position: manager?.seconds ?? .zero,
duration: manager?.item.runtime ?? .zero
)
)
}
private func secondsDidChange(_ newSeconds: Duration) {
handleNowPlayablePlaybackChange(
playing: true,
metadata: .init(
position: newSeconds,
duration: manager?.item.runtime ?? .zero
)
)
}
private func actionDidChange(_ newAction: MediaPlayerManager._Action) {
switch newAction {
case .stop, .error:
handleStopAction()
default: ()
}
}
// TODO: remove and respond to manager action publisher instead
// TODO: register different commands based on item capabilities
private func playbackItemDidChange(_ newItem: MediaPlayerItem?) {
itemImageCancellable?.cancel()
itemImageCancellable = nil
guard let newItem else { return }
setNowPlayingMetadata(newItem.baseItem.nowPlayableStaticMetadata())
itemImageCancellable = Task {
let currentBaseItem = newItem.baseItem
guard let image = await newItem.thumbnailProvider?() else { return }
guard manager?.item.id == currentBaseItem.id else { return }
await MainActor.run {
setNowPlayingMetadata(
currentBaseItem.nowPlayableStaticMetadata(image)
)
}
}
.asAnyCancellable()
handleNowPlayablePlaybackChange(
playing: true,
metadata: .init(
position: manager?.seconds ?? .zero,
duration: manager?.item.runtime ?? .zero
)
)
}
private func handleStopAction() {
cancellables = []
for command in defaultRegisteredCommands {
command.removeHandler()
}
Task(priority: .userInitiated) {
// TODO: figure out way to not need delay
// Delay to wait for io to stop
try? await Task.sleep(for: .seconds(0.3))
do {
try stopSession()
} catch {
logger.critical("Unable to stop audio session: \(error.localizedDescription)")
}
}
}
// TODO: complete by referencing apple code
// - restart
@MainActor
private func handleInterruption(
type: AVAudioSession.InterruptionType,
options: AVAudioSession.InterruptionOptions
) {
switch type {
case .began:
playbackRequestStateBeforeInterruption = manager?.playbackRequestStatus ?? .playing
manager?.setPlaybackRequestStatus(status: .paused)
case .ended:
do {
try startSession()
if playbackRequestStateBeforeInterruption == .playing {
if options.contains(.shouldResume) {
manager?.setPlaybackRequestStatus(status: .playing)
} else {
manager?.setPlaybackRequestStatus(status: .paused)
}
}
} catch {
logger.critical("Unable to reactivate audio session after interruption: \(error.localizedDescription)")
manager?.stop()
}
@unknown default: ()
}
}
@MainActor
private func handleCommand(
command: NowPlayableCommand,
event: MPRemoteCommandEvent
) -> MPRemoteCommandHandlerStatus {
switch command {
case .pause:
manager?.setPlaybackRequestStatus(status: .paused)
case .play:
manager?.setPlaybackRequestStatus(status: .playing)
case .togglePausePlay:
manager?.togglePlayPause()
case .skipBackward:
guard let event = event as? MPSkipIntervalCommandEvent else { return .commandFailed }
manager?.proxy?.jumpBackward(.seconds(event.interval))
case .skipForward:
guard let event = event as? MPSkipIntervalCommandEvent else { return .commandFailed }
manager?.proxy?.jumpForward(.seconds(event.interval))
case .changePlaybackPosition:
guard let event = event as? MPChangePlaybackPositionCommandEvent else { return .commandFailed }
manager?.proxy?.setSeconds(Duration.seconds(event.positionTime))
case .nextTrack:
guard let nextItem = manager?.queue?.nextItem else { return .commandFailed }
manager?.playNewItem(provider: nextItem)
case .previousTrack:
guard let previousItem = manager?.queue?.previousItem else { return .commandFailed }
manager?.playNewItem(provider: previousItem)
default: ()
}
return .success
}
private func handleNowPlayablePlaybackChange(
playing: Bool,
metadata: NowPlayableDynamicMetadata
) {
setNowPlayingPlaybackInfo(metadata)
MPNowPlayingInfoCenter.default().playbackState = playing ? .playing : .paused
}
private func configureRemoteCommands(
_ commands: [NowPlayableCommand],
commandHandler: @escaping (NowPlayableCommand, MPRemoteCommandEvent) -> MPRemoteCommandHandlerStatus
) {
guard commands.isNotEmpty else { return }
for command in commands {
command.addHandler(commandHandler)
command.isEnabled(true)
}
}
private func setNowPlayingMetadata(_ metadata: NowPlayableStaticMetadata) {
let nowPlayingInfoCenter = MPNowPlayingInfoCenter.default()
var nowPlayingInfo: [String: Any] = [:]
nowPlayingInfo[MPNowPlayingInfoPropertyMediaType] = metadata.mediaType.rawValue
nowPlayingInfo[MPNowPlayingInfoPropertyIsLiveStream] = metadata.isLiveStream
nowPlayingInfo[MPMediaItemPropertyTitle] = metadata.title
nowPlayingInfo[MPMediaItemPropertyArtist] = metadata.artist
nowPlayingInfo[MPMediaItemPropertyArtwork] = metadata.artwork
nowPlayingInfo[MPMediaItemPropertyAlbumArtist] = metadata.albumArtist
nowPlayingInfo[MPMediaItemPropertyAlbumTitle] = metadata.albumTitle
nowPlayingInfoCenter.nowPlayingInfo = nowPlayingInfo
}
private func setNowPlayingPlaybackInfo(_ metadata: NowPlayableDynamicMetadata) {
let nowPlayingInfoCenter = MPNowPlayingInfoCenter.default()
var nowPlayingInfo: [String: Any] = nowPlayingInfoCenter.nowPlayingInfo ?? [:]
nowPlayingInfo[MPMediaItemPropertyPlaybackDuration] = Float(metadata.duration.seconds)
nowPlayingInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = Float(metadata.position.seconds)
nowPlayingInfo[MPNowPlayingInfoPropertyPlaybackRate] = metadata.rate
nowPlayingInfo[MPNowPlayingInfoPropertyDefaultPlaybackRate] = 1.0
nowPlayingInfo[MPNowPlayingInfoPropertyCurrentLanguageOptions] = metadata.currentLanguageOptions
nowPlayingInfo[MPNowPlayingInfoPropertyAvailableLanguageOptions] = metadata.availableLanguageOptionGroups
nowPlayingInfoCenter.nowPlayingInfo = nowPlayingInfo
}
private func startSession() throws {
let audioSession = AVAudioSession.sharedInstance()
do {
try audioSession.setCategory(.playback, mode: .default)
try audioSession.setActive(true)
logger.trace("Started AVAudioSession")
} catch {
logger.critical("Unable to activate AVAudioSession instance: \(error.localizedDescription)")
throw error
}
}
private func stopSession() throws {
do {
try AVAudioSession.sharedInstance().setActive(false)
logger.trace("Stopped AVAudioSession")
} catch {
logger.critical("Unable to deactivate AVAudioSession instance: \(error.localizedDescription)")
throw error
}
}
}