From 43b5a42fb28e96d6aa56b0775b2534997719a02b Mon Sep 17 00:00:00 2001 From: WhiteGiso Date: Tue, 16 Jun 2026 09:37:06 +0200 Subject: [PATCH 1/3] feat(ios): add Now Playing support --- .../nuvio/app/features/player/PlayerEngine.kt | 2 + .../nuvio/app/features/player/PlayerModels.kt | 6 + .../player/PlayerScreenRuntimeEffects.kt | 57 ++++ .../nuvio/app/features/player/PlayerBridge.kt | 6 + .../app/features/player/PlayerEngine.ios.kt | 20 ++ iosApp/iosApp/Player/MPVPlayerBridge.swift | 103 ++++++- .../iosApp/Player/NowPlayingController.swift | 273 ++++++++++++++++++ 7 files changed, 465 insertions(+), 2 deletions(-) create mode 100644 iosApp/iosApp/Player/NowPlayingController.swift 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/Player/MPVPlayerBridge.swift b/iosApp/iosApp/Player/MPVPlayerBridge.swift index 870583f7a..497bb40a6 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,6 +524,7 @@ final class MPVPlayerViewController: UIViewController { func playPlayback() { guard mpv != nil else { return } + activateAudioSessionForPlayback() setFlag("pause", false) } @@ -702,15 +738,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 +792,16 @@ 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 { + nowPlayingController.syncPlayback( + positionMs: positionMs, + durationMs: durationMs, + isPlaying: isPlayerPlaying, + playbackSpeed: currentSpeed + ) + } } /// Full state + track refresh — called from MPV event loop on property changes. @@ -783,6 +851,37 @@ 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 + ) + } + + 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 getTrackString(_ index: Int, _ field: String) -> String { (getString("track-list/\(index)/\(field)") ?? "") .trimmingCharacters(in: .whitespacesAndNewlines) diff --git a/iosApp/iosApp/Player/NowPlayingController.swift b/iosApp/iosApp/Player/NowPlayingController.swift new file mode 100644 index 000000000..68c5f650e --- /dev/null +++ b/iosApp/iosApp/Player/NowPlayingController.swift @@ -0,0 +1,273 @@ +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 { + 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)) + } + 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)) + } + 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 + } +} From 9a2c34ad3636e26ffe7e1ab9a9723472d37f3814 Mon Sep 17 00:00:00 2001 From: WhiteGiso Date: Fri, 3 Jul 2026 15:45:44 +0200 Subject: [PATCH 2/3] Add iOS Now Playing support --- iosApp/iosApp/Info.plist | 4 ++ iosApp/iosApp/Player/MPVPlayerBridge.swift | 39 +++++++++++++++---- .../iosApp/Player/NowPlayingController.swift | 1 + 3 files changed, 37 insertions(+), 7 deletions(-) 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 497bb40a6..6eb0dfffe 100644 --- a/iosApp/iosApp/Player/MPVPlayerBridge.swift +++ b/iosApp/iosApp/Player/MPVPlayerBridge.swift @@ -524,13 +524,17 @@ final class MPVPlayerViewController: UIViewController { func playPlayback() { guard mpv != nil else { return } - activateAudioSessionForPlayback() + 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) { @@ -795,15 +799,19 @@ final class MPVPlayerViewController: UIViewController { let shouldPublishNowPlayingState = !isPlayerLoading || isPlayerPlaying || durationMs > 0 || positionMs > 0 if shouldPublishNowPlayingState { - nowPlayingController.syncPlayback( - positionMs: positionMs, - durationMs: durationMs, - isPlaying: isPlayerPlaying, - playbackSpeed: currentSpeed - ) + 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. func updateState() { refreshPlaybackState() @@ -866,6 +874,7 @@ final class MPVPlayerViewController: UIViewController { subtitle: subtitle, artworkUrl: artworkUrl ) + publishNowPlayingForPlaybackSession() } func clearNowPlayingInfo() { @@ -882,6 +891,16 @@ final class MPVPlayerViewController: UIViewController { ) } + 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) @@ -1025,8 +1044,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 index 68c5f650e..dc8685b17 100644 --- a/iosApp/iosApp/Player/NowPlayingController.swift +++ b/iosApp/iosApp/Player/NowPlayingController.swift @@ -51,6 +51,7 @@ final class PlayerNowPlayingController { if metadata.title == normalizedTitle, metadata.subtitle == normalizedSubtitle, currentArtworkURL == normalizedArtworkUrl { + applyNowPlayingInfo() return } From 120c653c2c1844ab30431b9900282aa4faa881ee Mon Sep 17 00:00:00 2001 From: WhiteGiso Date: Fri, 3 Jul 2026 16:00:55 +0200 Subject: [PATCH 3/3] Fix iOS Now Playing skip precision --- iosApp/iosApp/Player/MPVPlayerBridge.swift | 5 +++-- iosApp/iosApp/Player/NowPlayingController.swift | 4 ++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/iosApp/iosApp/Player/MPVPlayerBridge.swift b/iosApp/iosApp/Player/MPVPlayerBridge.swift index 6eb0dfffe..6e3e81414 100644 --- a/iosApp/iosApp/Player/MPVPlayerBridge.swift +++ b/iosApp/iosApp/Player/MPVPlayerBridge.swift @@ -543,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() { diff --git a/iosApp/iosApp/Player/NowPlayingController.swift b/iosApp/iosApp/Player/NowPlayingController.swift index dc8685b17..2c9faa6e4 100644 --- a/iosApp/iosApp/Player/NowPlayingController.swift +++ b/iosApp/iosApp/Player/NowPlayingController.swift @@ -223,7 +223,7 @@ final class PlayerNowPlayingController { 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)) + self?.owner?.seekByMs(Int64(event.interval * 1000.0), exact: true) } return .success } @@ -235,7 +235,7 @@ final class PlayerNowPlayingController { 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)) + self?.owner?.seekByMs(-Int64(event.interval * 1000.0), exact: true) } return .success }