import Foundation import MediaPlayer import UIKit /// Lock screen, Control Center, headphone and car controls: publishes the current track to /// MPNowPlayingInfoCenter and routes MPRemoteCommandCenter back into [PlaybackManager]. /// Stands in for the Media3 session on Android. final class NowPlaying: @unchecked Sendable { private let pm: PlaybackManager private let plex: PlexClient private let radio: WebRadio private let settings: Settings private let center = MPNowPlayingInfoCenter.default() private var artKey: String? private var artwork: MPMediaItemArtwork? init(pm: PlaybackManager, plex: PlexClient, radio: WebRadio, settings: Settings) { self.pm = pm self.plex = plex self.radio = radio self.settings = settings pm.addListener { [weak self] in self?.publish() } wireCommands() publish() } // ---- shared with CarPlay --------------------------------------------------------- /// The mix shown next to the artist: nil unless stems are playing or the mix is Full. var mixLabel: String? { guard pm.source == .Stems else { return nil } guard let p = MixPresets.current(settings, pm.stemNames, pm.mix) else { return "Custom mix" } return p == MixPresets.full ? nil : p.label } var vocalsAvailable: Bool { pm.source == .Stems && pm.stemNames.contains("vocals") } var vocalsSilent: Bool { MixPresets.vocalsSilent(pm.stemNames, pm.mix) } /// Follows the Stem Editor: deleted mixes are skipped and the user's own are in the rotation. var nextMix: MixPreset { MixPresets.next(settings, pm.stemNames, pm.mix) } /// One tap: hear the vocals again, or take them out. func toggleVocals() { let names = pm.stemNames, mix = pm.mix let anySolo = names.contains { mix[$0]?.solo == true } if MixPresets.vocalsSilent(names, mix) { if mix["vocals"]?.muted == true && !anySolo { pm.toggleMute("vocals") } else { pm.applyMix([:]) } } else if anySolo { pm.applyMix(MixPresets.karaoke.build(names) ?? [:]) } else { pm.toggleMute("vocals") } } func cycleMix() { pm.applyMix(nextMix.build(pm.stemNames) ?? [:]) } // ---- info -------------------------------------------------------------------------- /// Puts the queue back on the lock screen (after the web radio stops). func refresh() { publish() } private func publish() { if radio.active { return } guard let t = pm.current?.track else { center.nowPlayingInfo = nil center.playbackState = .stopped return } var info: [String: Any] = [ MPMediaItemPropertyTitle: t.title, MPMediaItemPropertyArtist: mixLabel.map { "\(t.artist) ยท \($0)" } ?? t.artist, MPMediaItemPropertyAlbumTitle: t.album, MPMediaItemPropertyPlaybackDuration: Double(max(pm.durationMs, t.durationMs)) / 1000, MPNowPlayingInfoPropertyElapsedPlaybackTime: Double(pm.positionMs()) / 1000, MPNowPlayingInfoPropertyPlaybackRate: pm.isPlaying && !pm.buffering ? pm.speed : 0.0, MPNowPlayingInfoPropertyDefaultPlaybackRate: 1.0, MPNowPlayingInfoPropertyMediaType: MPNowPlayingInfoMediaType.audio.rawValue, MPNowPlayingInfoPropertyPlaybackQueueIndex: max(pm.index, 0), MPNowPlayingInfoPropertyPlaybackQueueCount: pm.queue.count, MPNowPlayingInfoPropertyExternalContentIdentifier: t.ratingKey, ] if let n = t.index { info[MPMediaItemPropertyAlbumTrackNumber] = n } let key = t.thumb if key == artKey, let artwork { info[MPMediaItemPropertyArtwork] = artwork } center.nowPlayingInfo = info center.playbackState = pm.isPlaying ? .playing : .paused updateCommands() if key != artKey { artKey = key artwork = nil if let url = plex.artUrl(key, px: 600) { Task { @MainActor [weak self] in guard let img = await ArtLoader.shared.image(url), let self, self.artKey == key else { return } self.artwork = MPMediaItemArtwork(boundsSize: img.size) { _ in img } self.publish() } } } } // ---- commands ---------------------------------------------------------------------- private func wireCommands() { let c = MPRemoteCommandCenter.shared() func on(_ cmd: MPRemoteCommand, _ f: @escaping @MainActor (MPRemoteCommandEvent) -> MPRemoteCommandHandlerStatus) { cmd.isEnabled = true cmd.addTarget { e in MainActor.assumeIsolated { f(e) } } } // While a web radio station is loaded, play/pause drive it and skipping does nothing. on(c.playCommand) { [pm, radio] _ in if radio.active { radio.resume() } else { pm.play() }; return .success } on(c.pauseCommand) { [pm, radio] _ in if radio.active { radio.pause() } else { pm.pause() }; return .success } on(c.stopCommand) { [pm, radio] _ in if radio.active { radio.stop() } else { pm.pause() }; return .success } on(c.togglePlayPauseCommand) { [pm, radio] _ in if radio.active { radio.toggle() } else { pm.togglePlay() }; return .success } on(c.nextTrackCommand) { [pm, radio] _ in if !radio.active { pm.next() }; return .success } on(c.previousTrackCommand) { [weak self, pm, radio] _ in guard !radio.active else { return .success } if self?.settings.lockScreenMix == true && pm.source == .Stems { self?.cycleMix() } else { pm.previous() } return .success } on(c.changePlaybackPositionCommand) { [pm] e in guard let e = e as? MPChangePlaybackPositionCommandEvent else { return .commandFailed } pm.seekTo(Int64(e.positionTime * 1000)) return .success } on(c.changeShuffleModeCommand) { [pm] e in guard let e = e as? MPChangeShuffleModeCommandEvent else { return .commandFailed } pm.setShuffle(e.shuffleType != .off) return .success } on(c.changeRepeatModeCommand) { [pm] e in guard let e = e as? MPChangeRepeatModeCommandEvent else { return .commandFailed } pm.setRepeat(e.repeatType == .one ? .One : e.repeatType == .all ? .All : .Off) return .success } c.skipForwardCommand.isEnabled = false c.skipBackwardCommand.isEnabled = false c.seekForwardCommand.isEnabled = false c.seekBackwardCommand.isEnabled = false } private func updateCommands() { let c = MPRemoteCommandCenter.shared() c.changeShuffleModeCommand.currentShuffleType = pm.shuffle ? .items : .off c.changeRepeatModeCommand.currentRepeatType = pm.repeatMode == .One ? .one : pm.repeatMode == .All ? .all : .off c.nextTrackCommand.isEnabled = pm.index + 1 < pm.queue.count || pm.repeatMode != .Off c.previousTrackCommand.isEnabled = pm.current != nil } }