diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEngine.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEngine.kt index 3a93f7de6..846c10200 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEngine.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEngine.kt @@ -21,6 +21,8 @@ interface PlayerEngineController { fun applySubtitleStyle(style: SubtitleStyleState) {} fun setSubtitleDelayMs(delayMs: Int) {} fun configureIosVideoOutput(settings: PlayerSettingsUiState) {} + fun updateNowPlayingMetadata(info: PlayerNowPlayingInfo) {} + fun clearNowPlayingInfo() {} } internal fun sanitizePlaybackHeaders(headers: Map?): Map { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerModels.kt index 7f5efb6f0..e1d3b7b2a 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerModels.kt @@ -224,3 +224,9 @@ data class PlayerPlaybackSnapshot( val bufferedPositionMs: Long = 0L, val playbackSpeed: Float = 1f, ) + +data class PlayerNowPlayingInfo( + val title: String, + val subtitle: String? = null, + val artworkUrl: String? = null, +) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeEffects.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeEffects.kt index 0b2d5f3e2..81e1cf1bb 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeEffects.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeEffects.kt @@ -164,6 +164,23 @@ internal fun PlayerScreenRuntime.BindPlayerRuntimeEffects() { playerController?.applySubtitleStyle(subtitleStyle) } + LaunchedEffect( + playerController, + playerControllerSourceUrl, + activeSourceUrl, + title, + activeStreamTitle, + activeSeasonNumber, + activeEpisodeNumber, + activeEpisodeTitle, + poster, + background, + ) { + val controller = playerController ?: return@LaunchedEffect + if (playerControllerSourceUrl != activeSourceUrl) return@LaunchedEffect + controller.updateNowPlayingMetadata(buildNowPlayingInfo()) + } + LaunchedEffect(activeSourceUrl, addonSubtitleFetchKey, playerSettingsUiState.addonSubtitleStartupMode) { val fetchKey = addonSubtitleFetchKey ?: return@LaunchedEffect if (playerSettingsUiState.addonSubtitleStartupMode == AddonSubtitleStartupMode.FAST_STARTUP) { @@ -255,6 +272,7 @@ internal fun PlayerScreenRuntime.BindPlayerRuntimeEffects() { DisposableEffect(Unit) { onDispose { + playerController?.clearNowPlayingInfo() P2pStreamingEngine.shutdown() PlayerStreamsRepository.clearAll() } @@ -469,6 +487,45 @@ private fun PlayerScreenRuntime.BindPlayerMetadataAndSkipEffects() { } } +private fun PlayerScreenRuntime.buildNowPlayingInfo(): PlayerNowPlayingInfo { + val isEpisode = activeSeasonNumber != null && activeEpisodeNumber != null + return PlayerNowPlayingInfo( + title = title.ifBlank { activeStreamTitle }, + subtitle = buildNowPlayingSubtitle( + isEpisode = isEpisode, + seasonNumber = activeSeasonNumber, + episodeNumber = activeEpisodeNumber, + episodeTitle = activeEpisodeTitle, + ), + artworkUrl = firstNonBlankUrl(poster, background), + ) +} + +private fun buildNowPlayingSubtitle( + isEpisode: Boolean, + seasonNumber: Int?, + episodeNumber: Int?, + episodeTitle: String?, +): String? { + if (!isEpisode) return null + + val episodeParts = buildList { + if (seasonNumber != null && episodeNumber != null) { + add("S${seasonNumber}E${episodeNumber}") + } + episodeTitle?.takeIf { it.isNotBlank() }?.let { add(it) } + } + + return when (episodeParts.size) { + 0 -> null + 1 -> episodeParts.first() + else -> "${episodeParts[0]} - ${episodeParts[1]}" + } +} + +private fun firstNonBlankUrl(vararg values: String?): String? = + values.firstOrNull { !it.isNullOrBlank() }?.trim() + internal fun PlayerScreenRuntime.removeFailedStreamFromCache() { val currentVideoId = activeVideoId ?: return val cacheKey = StreamLinkCacheRepository.contentKey( diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerBridge.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerBridge.kt index 5cb9cba2e..9715283e2 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerBridge.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerBridge.kt @@ -20,6 +20,12 @@ interface NuvioPlayerBridge { fun seekTo(positionMs: Long) fun seekBy(offsetMs: Long) fun retry() + fun updateNowPlayingMetadata( + title: String, + subtitle: String?, + artworkUrl: String?, + ) + fun clearNowPlayingInfo() fun configureVideoOutput( hardwareDecoder: String, targetColorspaceHint: Boolean, diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerEngine.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerEngine.ios.kt index 418e21f64..83eb29464 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerEngine.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerEngine.ios.kt @@ -93,6 +93,26 @@ actual fun PlatformPlayerSurface( bridge.retry() } + override fun updateNowPlayingMetadata(info: PlayerNowPlayingInfo) { + runCatching { + bridge.updateNowPlayingMetadata( + title = info.title, + subtitle = info.subtitle, + artworkUrl = info.artworkUrl, + ) + }.onFailure { error -> + Logger.w(TAG, error) { "Failed to update iOS Now Playing metadata" } + } + } + + override fun clearNowPlayingInfo() { + runCatching { + bridge.clearNowPlayingInfo() + }.onFailure { error -> + Logger.w(TAG, error) { "Failed to clear iOS Now Playing metadata" } + } + } + override fun configureIosVideoOutput(settings: PlayerSettingsUiState) { bridge.applyIosVideoOutputSettings(settings) } diff --git a/iosApp/iosApp/Info.plist b/iosApp/iosApp/Info.plist index 3701d36a4..8ad977fa2 100644 --- a/iosApp/iosApp/Info.plist +++ b/iosApp/iosApp/Info.plist @@ -29,6 +29,10 @@ outplayer open-vidhub + UIBackgroundModes + + audio + NSSupportsLiveActivities UIApplicationSupportsIndirectInputEvents diff --git a/iosApp/iosApp/Player/MPVPlayerBridge.swift b/iosApp/iosApp/Player/MPVPlayerBridge.swift index 870583f7a..6e3e81414 100644 --- a/iosApp/iosApp/Player/MPVPlayerBridge.swift +++ b/iosApp/iosApp/Player/MPVPlayerBridge.swift @@ -1,5 +1,6 @@ import Foundation import UIKit +import AVFoundation import Libmpv import ComposeApp @@ -10,14 +11,19 @@ final class MPVPlayerBridgeImpl: NSObject, NuvioPlayerBridge { private var playerVC: MPVPlayerViewController? func createPlayerViewController() -> UIViewController { + return ensurePlayerViewController() + } + + private func ensurePlayerViewController() -> MPVPlayerViewController { + if let playerVC { return playerVC } let vc = MPVPlayerViewController() self.playerVC = vc return vc } - func loadFile(url: String) { playerVC?.loadFile(url) } + func loadFile(url: String) { ensurePlayerViewController().loadFile(url) } func loadFileWithAudio(videoUrl: String, audioUrl: String?, headersJson: String?, subtitlesJson: String?) { - playerVC?.loadFile( + ensurePlayerViewController().loadFile( videoUrl, audioUrl: audioUrl, requestHeaders: parseRequestHeaders(headersJson), @@ -49,6 +55,18 @@ final class MPVPlayerBridgeImpl: NSObject, NuvioPlayerBridge { func seekTo(positionMs: Int64) { playerVC?.seekToMs(positionMs) } func seekBy(offsetMs: Int64) { playerVC?.seekByMs(offsetMs) } func retry() { playerVC?.retryPlayback() } + func updateNowPlayingMetadata( + title: String, + subtitle: String?, + artworkUrl: String? + ) { + ensurePlayerViewController().updateNowPlayingMetadata( + title: title, + subtitle: subtitle, + artworkUrl: artworkUrl + ) + } + func clearNowPlayingInfo() { playerVC?.clearNowPlayingInfo() } func configureVideoOutput( hardwareDecoder: String, targetColorspaceHint: Bool, @@ -226,12 +244,20 @@ final class MPVPlayerViewController: UIViewController { private static let defaultAudioOutput = "audiounit" + private struct CachedNowPlayingMetadata { + let title: String + let subtitle: String? + let artworkUrl: String? + } + private let errorStateLock = NSLock() private var metalLayer = MetalLayer() private var lastAppliedDrawableSize: CGSize = .zero private var pendingLoadRequest: PendingLoadRequest? private var pendingLoadRetryWorkItem: DispatchWorkItem? private var mpv: OpaquePointer? + private var cachedNowPlayingMetadata: CachedNowPlayingMetadata? + private lazy var nowPlayingController = PlayerNowPlayingController(owner: self) private lazy var eventQueue = DispatchQueue(label: "mpv-events", qos: .userInitiated) private var recentPlaybackLogs: [String] = [] private var activeRequestHeaders: [String: String] = [:] @@ -255,6 +281,10 @@ final class MPVPlayerViewController: UIViewController { } private var _currentErrorMessage: String? + override var canBecomeFirstResponder: Bool { + true + } + override var prefersHomeIndicatorAutoHidden: Bool { true } @@ -287,6 +317,7 @@ final class MPVPlayerViewController: UIViewController { layoutMetalLayer() setupMpv() + activateAudioSessionForPlayback() setupNotifications() refreshImmersiveSystemUI() } @@ -305,6 +336,10 @@ final class MPVPlayerViewController: UIViewController { override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) refreshImmersiveSystemUI() + becomeFirstResponder() + UIApplication.shared.beginReceivingRemoteControlEvents() + publishCachedNowPlayingInfoIfNeeded() + layoutMetalLayer() attemptStartPendingLoad() } @@ -489,12 +524,17 @@ final class MPVPlayerViewController: UIViewController { func playPlayback() { guard mpv != nil else { return } + publishNowPlayingForPlaybackSession() setFlag("pause", false) + isPlayerPlaying = true + syncNowPlayingPlaybackState(isPlaying: true) } func pausePlayback() { guard mpv != nil else { return } setFlag("pause", true) + isPlayerPlaying = false + syncNowPlayingPlaybackState(isPlaying: false) } func seekToMs(_ ms: Int64) { @@ -503,10 +543,11 @@ final class MPVPlayerViewController: UIViewController { command("seek", args: [String(format: "%.3f", seconds), "absolute"]) } - func seekByMs(_ ms: Int64) { + func seekByMs(_ ms: Int64, exact: Bool = false) { guard mpv != nil else { return } let seconds = Double(ms) / 1000.0 - command("seek", args: [String(format: "%.3f", seconds), "relative"]) + let seekMode = exact ? "relative+exact" : "relative" + command("seek", args: [String(format: "%.3f", seconds), seekMode]) } func retryPlayback() { @@ -702,15 +743,37 @@ final class MPVPlayerViewController: UIViewController { func destroyPlayer() { NotificationCenter.default.removeObserver(self) + UIApplication.shared.endReceivingRemoteControlEvents() + resignFirstResponder() pendingLoadRetryWorkItem?.cancel() pendingLoadRetryWorkItem = nil pendingLoadRequest = nil + nowPlayingController.invalidate() clearPlaybackError() + deactivateAudioSession() guard let ctx = mpv else { return } mpv = nil // nil first so event loop stops reading mpv_terminate_destroy(ctx) } + private func activateAudioSessionForPlayback() { + do { + let session = AVAudioSession.sharedInstance() + try session.setCategory(.playback, mode: .moviePlayback) + try session.setActive(true) + } catch { + print("[NowPlaying] Failed to activate audio session: \(error)") + } + } + + private func deactivateAudioSession() { + do { + try AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation) + } catch { + print("[NowPlaying] Failed to deactivate audio session: \(error)") + } + } + // MARK: - State Update /// Lightweight state refresh — called by Kotlin polling (every 250ms). @@ -734,6 +797,20 @@ final class MPVPlayerViewController: UIViewController { positionMs = Int64(max(position, 0) * 1000) bufferedMs = Int64(max(position + cached, 0) * 1000) currentSpeed = Float(speed > 0 ? speed : 1.0) + + let shouldPublishNowPlayingState = !isPlayerLoading || isPlayerPlaying || durationMs > 0 || positionMs > 0 + if shouldPublishNowPlayingState { + syncNowPlayingPlaybackState(isPlaying: isPlayerPlaying) + } + } + + private func syncNowPlayingPlaybackState(isPlaying: Bool) { + nowPlayingController.syncPlayback( + positionMs: positionMs, + durationMs: durationMs, + isPlaying: isPlaying, + playbackSpeed: currentSpeed + ) } /// Full state + track refresh — called from MPV event loop on property changes. @@ -783,6 +860,48 @@ final class MPVPlayerViewController: UIViewController { subtitleTracks = subs } + func updateNowPlayingMetadata( + title: String, + subtitle: String?, + artworkUrl: String? + ) { + cachedNowPlayingMetadata = CachedNowPlayingMetadata( + title: title, + subtitle: subtitle, + artworkUrl: artworkUrl + ) + nowPlayingController.updateMetadata( + title: title, + subtitle: subtitle, + artworkUrl: artworkUrl + ) + publishNowPlayingForPlaybackSession() + } + + func clearNowPlayingInfo() { + cachedNowPlayingMetadata = nil + nowPlayingController.clear() + } + + private func publishCachedNowPlayingInfoIfNeeded() { + guard let metadata = cachedNowPlayingMetadata else { return } + nowPlayingController.updateMetadata( + title: metadata.title, + subtitle: metadata.subtitle, + artworkUrl: metadata.artworkUrl + ) + } + + private func publishNowPlayingForPlaybackSession() { + activateAudioSessionForPlayback() + if isViewLoaded, view.window != nil { + becomeFirstResponder() + } + UIApplication.shared.beginReceivingRemoteControlEvents() + publishCachedNowPlayingInfoIfNeeded() + syncNowPlayingPlaybackState(isPlaying: isPlayerPlaying) + } + private func getTrackString(_ index: Int, _ field: String) -> String { (getString("track-list/\(index)/\(field)") ?? "") .trimmingCharacters(in: .whitespacesAndNewlines) @@ -926,8 +1045,14 @@ final class MPVPlayerViewController: UIViewController { self.clearPlaybackError() self.isPlayerLoading = false self.updateState() + self.publishNowPlayingForPlaybackSession() self.logCurrentAudioOutput() } + case MPV_EVENT_PLAYBACK_RESTART: + DispatchQueue.main.async { + self.updateState() + self.publishNowPlayingForPlaybackSession() + } case MPV_EVENT_END_FILE: if let data = eventPtr.pointee.data { let endFile = UnsafePointer(OpaquePointer(data)).pointee diff --git a/iosApp/iosApp/Player/NowPlayingController.swift b/iosApp/iosApp/Player/NowPlayingController.swift new file mode 100644 index 000000000..2c9faa6e4 --- /dev/null +++ b/iosApp/iosApp/Player/NowPlayingController.swift @@ -0,0 +1,274 @@ +import Foundation +import UIKit +import MediaPlayer + +final class PlayerNowPlayingController { + private struct Metadata { + var title: String? + var subtitle: String? + var artworkUrl: String? + } + + private struct PlaybackState { + var isPlaying: Bool = false + var positionMs: Int64 = 0 + var durationMs: Int64 = 0 + var playbackSpeed: Float = 1.0 + } + + private struct RemoteCommandTarget { + let command: MPRemoteCommand + let token: Any + } + + private weak var owner: MPVPlayerViewController? + private var metadata = Metadata() + private var playbackState = PlaybackState() + private var currentArtworkImage: UIImage? + private var currentArtworkURL: String? + private var artworkTask: URLSessionDataTask? + private var remoteTargets: [RemoteCommandTarget] = [] + private let artworkCache = NSCache() + + init(owner: MPVPlayerViewController) { + self.owner = owner + configureRemoteCommands() + } + + deinit { + invalidate() + } + + func updateMetadata( + title: String, + subtitle: String?, + artworkUrl: String? + ) { + let normalizedTitle = title.trimmingCharacters(in: .whitespacesAndNewlines) + let normalizedSubtitle = subtitle?.trimmingCharacters(in: .whitespacesAndNewlines) + let normalizedArtworkUrl = artworkUrl?.trimmingCharacters(in: .whitespacesAndNewlines) + + if metadata.title == normalizedTitle, + metadata.subtitle == normalizedSubtitle, + currentArtworkURL == normalizedArtworkUrl { + applyNowPlayingInfo() + return + } + + metadata.title = normalizedTitle + metadata.subtitle = normalizedSubtitle + + if normalizedArtworkUrl != currentArtworkURL { + currentArtworkURL = normalizedArtworkUrl + currentArtworkImage = nil + artworkTask?.cancel() + artworkTask = nil + + guard let urlString = normalizedArtworkUrl, !urlString.isEmpty else { + applyNowPlayingInfo() + return + } + + if let cached = artworkCache.object(forKey: urlString as NSString) { + currentArtworkImage = cached + applyNowPlayingInfo() + return + } + + guard let url = URL(string: urlString) else { + applyNowPlayingInfo() + return + } + + let task = URLSession.shared.dataTask(with: url) { [weak self] data, _, _ in + guard let self else { return } + guard let data, let image = UIImage(data: data) else { return } + self.artworkCache.setObject(image, forKey: urlString as NSString) + DispatchQueue.main.async { [weak self] in + guard let self, self.currentArtworkURL == urlString else { return } + self.currentArtworkImage = image + self.applyNowPlayingInfo() + } + } + artworkTask = task + task.resume() + } + + applyNowPlayingInfo() + } + + func syncPlayback( + positionMs: Int64, + durationMs: Int64, + isPlaying: Bool, + playbackSpeed: Float + ) { + let nextState = PlaybackState( + isPlaying: isPlaying, + positionMs: max(0, positionMs), + durationMs: max(0, durationMs), + playbackSpeed: playbackSpeed > 0 ? playbackSpeed : 1.0 + ) + let positionChanged = abs(nextState.positionMs - playbackState.positionMs) >= 1_000 + guard playbackState.isPlaying != nextState.isPlaying || + playbackState.durationMs != nextState.durationMs || + playbackState.playbackSpeed != nextState.playbackSpeed || + positionChanged else { + return + } + + playbackState = nextState + applyNowPlayingInfo() + } + + func clear() { + artworkTask?.cancel() + artworkTask = nil + currentArtworkImage = nil + currentArtworkURL = nil + metadata = Metadata() + playbackState = PlaybackState() + DispatchQueue.main.async { + MPNowPlayingInfoCenter.default().nowPlayingInfo = nil + } + } + + func invalidate() { + clear() + removeRemoteCommandTargets() + } + + private func applyNowPlayingInfo() { + guard let title = metadata.title, !title.isEmpty else { return } + + let buildInfo = { + var info: [String: Any] = [:] + info[MPMediaItemPropertyTitle] = title + info[MPNowPlayingInfoPropertyMediaType] = MPNowPlayingInfoMediaType.video.rawValue + if let subtitle = self.metadata.subtitle, !subtitle.isEmpty { + info[MPMediaItemPropertyArtist] = subtitle + } + if self.playbackState.durationMs > 0 { + info[MPMediaItemPropertyPlaybackDuration] = Double(self.playbackState.durationMs) / 1000.0 + } + info[MPNowPlayingInfoPropertyElapsedPlaybackTime] = Double(self.playbackState.positionMs) / 1000.0 + info[MPNowPlayingInfoPropertyPlaybackRate] = Double( + self.playbackState.isPlaying ? self.playbackState.playbackSpeed : 0.0 + ) + info[MPNowPlayingInfoPropertyIsLiveStream] = self.playbackState.durationMs <= 0 + if let artwork = self.currentArtworkImage { + info[MPMediaItemPropertyArtwork] = MPMediaItemArtwork(boundsSize: artwork.size) { _ in artwork } + } + + MPNowPlayingInfoCenter.default().nowPlayingInfo = info + } + + if Thread.isMainThread { + buildInfo() + } else { + DispatchQueue.main.async(execute: buildInfo) + } + } + + private func configureRemoteCommands() { + guard remoteTargets.isEmpty else { return } + + let center = MPRemoteCommandCenter.shared() + center.playCommand.isEnabled = true + center.pauseCommand.isEnabled = true + center.togglePlayPauseCommand.isEnabled = true + center.skipForwardCommand.isEnabled = true + center.skipBackwardCommand.isEnabled = true + center.changePlaybackPositionCommand.isEnabled = true + center.skipForwardCommand.preferredIntervals = [10] + center.skipBackwardCommand.preferredIntervals = [10] + + remoteTargets.append( + RemoteCommandTarget( + command: center.playCommand, + token: center.playCommand.addTarget { [weak self] _ in + DispatchQueue.main.async { self?.owner?.playPlayback() } + return .success + } + ) + ) + remoteTargets.append( + RemoteCommandTarget( + command: center.pauseCommand, + token: center.pauseCommand.addTarget { [weak self] _ in + DispatchQueue.main.async { self?.owner?.pausePlayback() } + return .success + } + ) + ) + remoteTargets.append( + RemoteCommandTarget( + command: center.togglePlayPauseCommand, + token: center.togglePlayPauseCommand.addTarget { [weak self] _ in + DispatchQueue.main.async { + guard let owner = self?.owner else { return } + if owner.isPlayerPlaying { + owner.pausePlayback() + } else { + owner.playPlayback() + } + } + return .success + } + ) + ) + remoteTargets.append( + RemoteCommandTarget( + command: center.skipForwardCommand, + token: center.skipForwardCommand.addTarget { [weak self] event in + guard let event = event as? MPSkipIntervalCommandEvent else { return .commandFailed } + DispatchQueue.main.async { + self?.owner?.seekByMs(Int64(event.interval * 1000.0), exact: true) + } + return .success + } + ) + ) + remoteTargets.append( + RemoteCommandTarget( + command: center.skipBackwardCommand, + token: center.skipBackwardCommand.addTarget { [weak self] event in + guard let event = event as? MPSkipIntervalCommandEvent else { return .commandFailed } + DispatchQueue.main.async { + self?.owner?.seekByMs(-Int64(event.interval * 1000.0), exact: true) + } + return .success + } + ) + ) + remoteTargets.append( + RemoteCommandTarget( + command: center.changePlaybackPositionCommand, + token: center.changePlaybackPositionCommand.addTarget { [weak self] event in + guard let event = event as? MPChangePlaybackPositionCommandEvent else { return .commandFailed } + DispatchQueue.main.async { + self?.owner?.seekToMs(Int64(event.positionTime * 1000.0)) + } + return .success + } + ) + ) + } + + private func removeRemoteCommandTargets() { + guard !remoteTargets.isEmpty else { return } + + remoteTargets.forEach { target in + target.command.removeTarget(target.token) + } + remoteTargets.removeAll(keepingCapacity: false) + + let center = MPRemoteCommandCenter.shared() + center.playCommand.isEnabled = false + center.pauseCommand.isEnabled = false + center.togglePlayPauseCommand.isEnabled = false + center.skipForwardCommand.isEnabled = false + center.skipBackwardCommand.isEnabled = false + center.changePlaybackPositionCommand.isEnabled = false + } +}