From a16def88787ed60ef18fbf51d1fc689be821f1a2 Mon Sep 17 00:00:00 2001 From: Daniel Williamson Date: Thu, 5 Mar 2026 19:42:49 +1100 Subject: [PATCH 01/28] Add aspect-fill, dimensions events, and lockscreen controls fixes --- .../VideoPlayerView.kt | 35 +++++++- .../handlers/VideoPlayerMethodHandler.kt | 15 ++++ .../handlers/VideoPlayerObserver.kt | 30 ++++++- .../VideoPlayerViewController+Delegate.swift | 4 +- .../Handlers/VideoPlayerMethodHandler.swift | 6 +- .../VideoPlayerNowPlayingHandler.swift | 75 +++++++++-------- .../Handlers/VideoPlayerObserver.swift | 16 +++- ios/Classes/View/VideoPlayerView.swift | 81 ++++++++++++++++++- .../native_video_player_controller.dart | 16 ++++ lib/src/enums/native_video_player_event.dart | 3 + .../native_video_player_media_info.dart | 3 + .../platform/video_player_method_channel.dart | 33 ++++++++ 12 files changed, 269 insertions(+), 48 deletions(-) diff --git a/android/src/main/kotlin/com/huddlecommunity/better_native_video_player/VideoPlayerView.kt b/android/src/main/kotlin/com/huddlecommunity/better_native_video_player/VideoPlayerView.kt index 79ab3c3..681e922 100644 --- a/android/src/main/kotlin/com/huddlecommunity/better_native_video_player/VideoPlayerView.kt +++ b/android/src/main/kotlin/com/huddlecommunity/better_native_video_player/VideoPlayerView.kt @@ -18,6 +18,7 @@ import androidx.media3.common.AudioAttributes import androidx.media3.common.Player import androidx.media3.common.util.UnstableApi import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.ui.AspectRatioFrameLayout import androidx.media3.ui.PlayerView import com.huddlecommunity.better_native_video_player.handlers.VideoPlayerEventHandler import com.huddlecommunity.better_native_video_player.handlers.VideoPlayerMethodHandler @@ -83,6 +84,7 @@ class VideoPlayerView( // HDR setting private var enableHDR: Boolean = false + private var useAspectFill: Boolean = false init { @@ -97,6 +99,7 @@ class VideoPlayerView( // Extract native controls setting from args showNativeControlsOriginal = args?.get("showNativeControls") as? Boolean ?: true + useAspectFill = args?.get("useAspectFill") as? Boolean ?: false // Extract HDR setting from args enableHDR = args?.get("enableHDR") as? Boolean ?: false @@ -146,6 +149,7 @@ class VideoPlayerView( playerView = PlayerView(context).apply { this.player = this@VideoPlayerView.player useController = showNativeControls + resizeMode = resolveResizeMode(useAspectFill) controllerShowTimeoutMs = 5000 controllerHideOnTouch = true @@ -349,6 +353,12 @@ class VideoPlayerView( playerView.useController = show result.success(null) } + "setUseAspectFill" -> { + val enabled = call.argument("enabled") ?: false + useAspectFill = enabled + playerView.resizeMode = resolveResizeMode(enabled) + result.success(null) + } "ensureSurfaceConnected" -> { // Called when reconnecting after all platform views were disposed (list→detail→back). reconnectSurface() @@ -658,13 +668,18 @@ class VideoPlayerView( if (duration > 0) { // Get buffered position val bufferedPosition = player.bufferedPosition + val videoWidth = player.videoSize.width + val videoHeight = player.videoSize.height - eventHandler.sendEvent("timeUpdate", mapOf( + val payload = mutableMapOf( "position" to currentPosition.toInt(), "duration" to duration.toInt(), "bufferedPosition" to bufferedPosition.toInt(), "isBuffering" to (player.playbackState == ExoPlayer.STATE_BUFFERING) - )) + ) + addVideoDimensionsToPayload(payload, videoWidth, videoHeight) + + eventHandler.sendEvent("timeUpdate", payload) Log.d(TAG, "Emitted timeUpdate with duration: ${duration}ms") } @@ -678,6 +693,21 @@ class VideoPlayerView( } } + private fun resolveResizeMode(useAspectFill: Boolean): Int { + return if (useAspectFill) { + AspectRatioFrameLayout.RESIZE_MODE_ZOOM + } else { + AspectRatioFrameLayout.RESIZE_MODE_FIT + } + } + + private fun addVideoDimensionsToPayload(payload: MutableMap, width: Int, height: Int) { + if (width > 0 && height > 0) { + payload["videoWidth"] = width + payload["videoHeight"] = height + } + } + /** * Reconnects the player's surface to the PlayerView * This is called when another platform view using the same shared player is disposed @@ -764,4 +794,3 @@ class VideoPlayerView( } } } - diff --git a/android/src/main/kotlin/com/huddlecommunity/better_native_video_player/handlers/VideoPlayerMethodHandler.kt b/android/src/main/kotlin/com/huddlecommunity/better_native_video_player/handlers/VideoPlayerMethodHandler.kt index 86a9385..c7d36c8 100644 --- a/android/src/main/kotlin/com/huddlecommunity/better_native_video_player/handlers/VideoPlayerMethodHandler.kt +++ b/android/src/main/kotlin/com/huddlecommunity/better_native_video_player/handlers/VideoPlayerMethodHandler.kt @@ -129,6 +129,7 @@ class VideoPlayerMethodHandler( "getAvailableQualities" -> handleGetAvailableQualities(result) "getAvailableSubtitleTracks" -> handleGetAvailableSubtitleTracks(result) "setSubtitleTrack" -> handleSetSubtitleTrack(call, result) + "getVideoDimensions" -> handleGetVideoDimensions(result) "enterFullScreen" -> handleEnterFullScreen(result) "exitFullScreen" -> handleExitFullScreen(result) "isAirPlayAvailable" -> handleIsAirPlayAvailable(result) @@ -141,6 +142,20 @@ class VideoPlayerMethodHandler( } } + /** + * Returns current decoded video dimensions if available. + */ + private fun handleGetVideoDimensions(result: MethodChannel.Result) { + val videoSize = player.videoSize + val width = videoSize.width + val height = videoSize.height + if (width > 0 && height > 0) { + result.success(mapOf("width" to width, "height" to height)) + return + } + result.success(null) + } + /** * Loads a video URL into the player */ diff --git a/android/src/main/kotlin/com/huddlecommunity/better_native_video_player/handlers/VideoPlayerObserver.kt b/android/src/main/kotlin/com/huddlecommunity/better_native_video_player/handlers/VideoPlayerObserver.kt index 57a77f7..2e5a16c 100644 --- a/android/src/main/kotlin/com/huddlecommunity/better_native_video_player/handlers/VideoPlayerObserver.kt +++ b/android/src/main/kotlin/com/huddlecommunity/better_native_video_player/handlers/VideoPlayerObserver.kt @@ -31,6 +31,8 @@ class VideoPlayerObserver( // Track Cast/external playback connection state private var wasExternalPlaybackActive = false + private var lastVideoWidth = 0 + private var lastVideoHeight = 0 private val handler = Handler(Looper.getMainLooper()) private val timeUpdateRunnable = object : Runnable { @@ -72,14 +74,18 @@ class VideoPlayerObserver( // Check if currently buffering val isBuffering = player.playbackState == Player.STATE_BUFFERING + val videoWidth = player.videoSize.width + val videoHeight = player.videoSize.height if (duration > 0) { - eventHandler.sendEvent("timeUpdate", mapOf( + val payload = mutableMapOf( "position" to position.toInt(), "duration" to duration.toInt(), "bufferedPosition" to bufferedPosition, "isBuffering" to isBuffering - )) + ) + addVideoDimensionsToPayload(payload, videoWidth, videoHeight) + eventHandler.sendEvent("timeUpdate", payload) } // Schedule next update @@ -97,6 +103,13 @@ class VideoPlayerObserver( handler.removeCallbacks(timeUpdateRunnable) } + private fun addVideoDimensionsToPayload(payload: MutableMap, width: Int, height: Int) { + if (width > 0 && height > 0) { + payload["videoWidth"] = width + payload["videoHeight"] = height + } + } + override fun onPlaybackStateChanged(playbackState: Int) { Log.d(TAG, "Playback state changed: $playbackState, isLoading: ${player.isLoading}") when (playbackState) { @@ -209,6 +222,19 @@ class VideoPlayerObserver( ) } + override fun onVideoSizeChanged(videoSize: androidx.media3.common.VideoSize) { + val width = videoSize.width + val height = videoSize.height + if (width <= 0 || height <= 0) return + if (width == lastVideoWidth && height == lastVideoHeight) return + lastVideoWidth = width + lastVideoHeight = height + eventHandler.sendEvent( + "videoDimensions", + mapOf("videoWidth" to width, "videoHeight" to height) + ) + } + override fun onDeviceInfoChanged(deviceInfo: androidx.media3.common.DeviceInfo) { // Check if playing to a remote device (Cast) val isExternalPlaybackActive = deviceInfo.playbackType == androidx.media3.common.DeviceInfo.PLAYBACK_TYPE_REMOTE diff --git a/ios/Classes/Extensions/VideoPlayerViewController+Delegate.swift b/ios/Classes/Extensions/VideoPlayerViewController+Delegate.swift index f9754a4..df80f8a 100644 --- a/ios/Classes/Extensions/VideoPlayerViewController+Delegate.swift +++ b/ios/Classes/Extensions/VideoPlayerViewController+Delegate.swift @@ -196,7 +196,7 @@ extension VideoPlayerView: AVPlayerViewControllerDelegate { } // Handle when the user dismisses fullscreen by swiping down or tapping Done - @available(iOS 13.0, *) + @available(iOS 12.0, *) public func playerViewController(_ playerViewController: AVPlayerViewController, willEndFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) { // Store the playback state before dismissing let wasPlaying = self.player?.rate != 0 @@ -547,4 +547,4 @@ extension VideoPlayerView: AVPictureInPictureControllerDelegate { completionHandler(false) } } -} \ No newline at end of file +} diff --git a/ios/Classes/Handlers/VideoPlayerMethodHandler.swift b/ios/Classes/Handlers/VideoPlayerMethodHandler.swift index 1c0423c..4783001 100644 --- a/ios/Classes/Handlers/VideoPlayerMethodHandler.swift +++ b/ios/Classes/Handlers/VideoPlayerMethodHandler.swift @@ -1128,12 +1128,14 @@ extension VideoPlayerView { let totalDuration = Int(durationSeconds * 1000) // milliseconds let bufferedPosition = Int(bufferedSeconds * 1000) // milliseconds - self.sendEvent("timeUpdate", data: [ + var payload: [String: Any] = [ "position": position, "duration": totalDuration, "bufferedPosition": bufferedPosition, "isBuffering": isBuffering - ]) + ] + self.appendVideoDimensions(to: &payload) + self.sendEvent("timeUpdate", data: payload) } } } diff --git a/ios/Classes/Handlers/VideoPlayerNowPlayingHandler.swift b/ios/Classes/Handlers/VideoPlayerNowPlayingHandler.swift index f31f443..e0e55c6 100644 --- a/ios/Classes/Handlers/VideoPlayerNowPlayingHandler.swift +++ b/ios/Classes/Handlers/VideoPlayerNowPlayingHandler.swift @@ -202,6 +202,7 @@ extension VideoPlayerView { /// Only registers if this view should be the owner private func setupRemoteCommandCenter() { let commandCenter = MPRemoteCommandCenter.shared() + let showSkipControls = (currentMediaInfo?["showSkipControls"] as? Bool) ?? true // Check if we've already registered handlers for this view // If so, skip the registration to avoid clearing and re-adding targets @@ -227,6 +228,7 @@ extension VideoPlayerView { hasRegisteredRemoteCommands = true // --- Play --- + commandCenter.playCommand.isEnabled = true commandCenter.playCommand.addTarget { [weak self] _ in guard let self = self else { return .commandFailed } @@ -247,6 +249,7 @@ extension VideoPlayerView { } // --- Pause --- + commandCenter.pauseCommand.isEnabled = true commandCenter.pauseCommand.addTarget { [weak self] _ in guard let self = self else { return .commandFailed } @@ -263,49 +266,53 @@ extension VideoPlayerView { } // --- Skip forward/backward --- + commandCenter.skipForwardCommand.isEnabled = showSkipControls + commandCenter.skipBackwardCommand.isEnabled = showSkipControls commandCenter.skipForwardCommand.preferredIntervals = [15] commandCenter.skipBackwardCommand.preferredIntervals = [15] - commandCenter.skipForwardCommand.addTarget { [weak self] event in - guard let self = self, - let skipEvent = event as? MPSkipIntervalCommandEvent, - let player = self.player - else { - return .commandFailed - } + if showSkipControls { + commandCenter.skipForwardCommand.addTarget { [weak self] event in + guard let self = self, + let skipEvent = event as? MPSkipIntervalCommandEvent, + let player = self.player + else { + return .commandFailed + } - // Only handle if we still own the remote commands - guard RemoteCommandManager.shared.isOwner(self.viewId) else { - print("⚠️ View \(self.viewId) received skip forward command but is not owner") - return .commandFailed + // Only handle if we still own the remote commands + guard RemoteCommandManager.shared.isOwner(self.viewId) else { + print("⚠️ View \(self.viewId) received skip forward command but is not owner") + return .commandFailed + } + + let currentTime = player.currentTime() + let newTime = CMTimeAdd(currentTime, CMTime(seconds: skipEvent.interval, preferredTimescale: 600)) + player.seek(to: newTime) + self.updateNowPlayingPlaybackTime() + return .success } - let currentTime = player.currentTime() - let newTime = CMTimeAdd(currentTime, CMTime(seconds: skipEvent.interval, preferredTimescale: 600)) - player.seek(to: newTime) - self.updateNowPlayingPlaybackTime() - return .success - } + commandCenter.skipBackwardCommand.addTarget { [weak self] event in + guard let self = self, + let skipEvent = event as? MPSkipIntervalCommandEvent, + let player = self.player + else { + return .commandFailed + } - commandCenter.skipBackwardCommand.addTarget { [weak self] event in - guard let self = self, - let skipEvent = event as? MPSkipIntervalCommandEvent, - let player = self.player - else { - return .commandFailed - } + // Only handle if we still own the remote commands + guard RemoteCommandManager.shared.isOwner(self.viewId) else { + print("⚠️ View \(self.viewId) received skip backward command but is not owner") + return .commandFailed + } - // Only handle if we still own the remote commands - guard RemoteCommandManager.shared.isOwner(self.viewId) else { - print("⚠️ View \(self.viewId) received skip backward command but is not owner") - return .commandFailed + let currentTime = player.currentTime() + let newTime = CMTimeSubtract(currentTime, CMTime(seconds: skipEvent.interval, preferredTimescale: 600)) + player.seek(to: max(newTime, .zero)) + self.updateNowPlayingPlaybackTime() + return .success } - - let currentTime = player.currentTime() - let newTime = CMTimeSubtract(currentTime, CMTime(seconds: skipEvent.interval, preferredTimescale: 600)) - player.seek(to: max(newTime, .zero)) - self.updateNowPlayingPlaybackTime() - return .success } print("🎛️ View \(viewId) registered remote command handlers") diff --git a/ios/Classes/Handlers/VideoPlayerObserver.swift b/ios/Classes/Handlers/VideoPlayerObserver.swift index 275682b..df00660 100644 --- a/ios/Classes/Handlers/VideoPlayerObserver.swift +++ b/ios/Classes/Handlers/VideoPlayerObserver.swift @@ -6,6 +6,7 @@ extension VideoPlayerView { item.addObserver(self, forKeyPath: "status", options: [.new, .old], context: nil) item.addObserver(self, forKeyPath: "playbackBufferEmpty", options: [.new], context: nil) item.addObserver(self, forKeyPath: "playbackLikelyToKeepUp", options: [.new], context: nil) + item.addObserver(self, forKeyPath: "presentationSize", options: [.new, .initial], context: nil) // Observe player's timeControlStatus to track play/pause state changes player?.addObserver(self, forKeyPath: "timeControlStatus", options: [.new, .old], context: nil) @@ -76,6 +77,8 @@ extension VideoPlayerView { } } } + case "presentationSize": + emitVideoDimensionsIfAvailable(from: item) default: break } } @@ -269,6 +272,17 @@ extension VideoPlayerView { } } + private func emitVideoDimensionsIfAvailable(from item: AVPlayerItem) { + let size = item.presentationSize + let width = Int(size.width.rounded()) + let height = Int(size.height.rounded()) + if width <= 0 || height <= 0 { return } + if width == lastEmittedVideoWidth && height == lastEmittedVideoHeight { return } + lastEmittedVideoWidth = width + lastEmittedVideoHeight = height + sendEvent("videoDimensions", data: ["videoWidth": width, "videoHeight": height]) + } + @objc func videoDidEnd() { if enableLooping { // For smooth looping, seek to beginning and continue playing @@ -491,4 +505,4 @@ extension VideoPlayerView { print(" - No AirPlay-related changes (device=nil, playerActive=false)") } } -} \ No newline at end of file +} diff --git a/ios/Classes/View/VideoPlayerView.swift b/ios/Classes/View/VideoPlayerView.swift index c8ec0fc..266b8b4 100644 --- a/ios/Classes/View/VideoPlayerView.swift +++ b/ios/Classes/View/VideoPlayerView.swift @@ -91,6 +91,11 @@ import QuartzCore // Store looping setting var enableLooping: Bool = false + // Store preferred render mode + var useAspectFill: Bool = false + var lastEmittedVideoWidth: Int = 0 + var lastEmittedVideoHeight: Int = 0 + // Track if app is in background to keep audio playing on screen lock var isInBackground: Bool = false var lastKnownRate: Float = 0.0 @@ -175,6 +180,8 @@ import QuartzCore let showControls = (args as? [String: Any])?["showNativeControls"] as? Bool ?? true playerViewController.showsPlaybackControls = showControls playerViewController.delegate = self + useAspectFill = (args as? [String: Any])?["useAspectFill"] as? Bool ?? false + applyVideoGravity(useAspectFill) // Disable automatic Now Playing updates - we'll handle it manually playerViewController.updatesNowPlayingInfoCenter = false @@ -411,6 +418,10 @@ import QuartzCore handleDisableAutomaticInlinePip(result: result) case "setShowNativeControls": handleSetShowNativeControls(call: call, result: result) + case "setUseAspectFill": + handleSetUseAspectFill(call: call, result: result) + case "getVideoDimensions": + handleGetVideoDimensions(result: result) case "ensureSurfaceConnected": // No-op on iOS; each platform view uses its own AVPlayerViewController when shared. result(nil) @@ -443,6 +454,64 @@ import QuartzCore } } + private func handleSetUseAspectFill(call: FlutterMethodCall, result: @escaping FlutterResult) { + let args = call.arguments as? [String: Any] + let enabled = args?["enabled"] as? Bool ?? false + if useAspectFill == enabled { + result(nil) + return + } + useAspectFill = enabled + applyVideoGravity(enabled) + result(nil) + } + + private func applyVideoGravity(_ enabled: Bool) { + if Thread.isMainThread { + CATransaction.begin() + CATransaction.setDisableActions(true) + UIView.performWithoutAnimation { + playerViewController.videoGravity = enabled ? .resizeAspectFill : .resizeAspect + playerViewController.view.setNeedsLayout() + playerViewController.view.layoutIfNeeded() + } + CATransaction.commit() + return + } + DispatchQueue.main.async { [weak self] in + self?.applyVideoGravity(enabled) + } + } + + func getCurrentVideoDimensions() -> [String: Int]? { + guard let currentItem = player?.currentItem else { + return nil + } + let presentationSize = currentItem.presentationSize + let width = Int(presentationSize.width.rounded()) + let height = Int(presentationSize.height.rounded()) + if width > 0 && height > 0 { + return ["width": width, "height": height] + } + return nil + } + + func appendVideoDimensions(to payload: inout [String: Any]) { + guard let dimensions = getCurrentVideoDimensions() else { + return + } + payload["videoWidth"] = dimensions["width"] + payload["videoHeight"] = dimensions["height"] + } + + private func handleGetVideoDimensions(result: @escaping FlutterResult) { + if let dimensions = getCurrentVideoDimensions() { + result(dimensions) + return + } + result(nil) + } + /// Cleans up remote command ownership, attempting to transfer to another view if possible /// This is called from both deinit and handleDispose to avoid duplication func cleanupRemoteCommandOwnership() { @@ -537,12 +606,14 @@ import QuartzCore } let bufferedPosition = Int(bufferedSeconds * 1000) - sendEvent("timeUpdate", data: [ + var payload: [String: Any] = [ "position": position, "duration": duration, "bufferedPosition": bufferedPosition, "isBuffering": player.timeControlStatus == .waitingToPlayAtSpecifiedRate - ]) + ] + appendVideoDimensions(to: &payload) + sendEvent("timeUpdate", data: payload) print("[\(channelName)] Emitted timeUpdate with duration: \(duration)ms") } @@ -592,7 +663,9 @@ import QuartzCore } else { let duration = Int(durationSeconds * 1000) let position = Int(currentTimeSeconds * 1000) - sendEvent("timeUpdated", data: ["position": position, "duration": duration]) + var payload: [String: Any] = ["position": position, "duration": duration] + appendVideoDimensions(to: &payload) + sendEvent("timeUpdated", data: payload) } // Send current playback state @@ -776,6 +849,7 @@ import QuartzCore item.removeObserver(self, forKeyPath: "status") item.removeObserver(self, forKeyPath: "playbackBufferEmpty") item.removeObserver(self, forKeyPath: "playbackLikelyToKeepUp") + item.removeObserver(self, forKeyPath: "presentationSize") } // Remove player observer for timeControlStatus @@ -998,4 +1072,3 @@ import QuartzCore } } } - diff --git a/lib/src/controllers/native_video_player_controller.dart b/lib/src/controllers/native_video_player_controller.dart index 5733152..78f8eee 100644 --- a/lib/src/controllers/native_video_player_controller.dart +++ b/lib/src/controllers/native_video_player_controller.dart @@ -52,6 +52,7 @@ class NativeVideoPlayerController { this.enableHDR = true, this.enableLooping = false, this.showNativeControls = true, + this.useAspectFill = false, List? preferredOrientations, }) { // Set preferred orientations if provided @@ -142,6 +143,10 @@ class NativeVideoPlayerController { /// When set to false, native controls are hidden. Custom overlays automatically hide native controls regardless of this setting. final bool showNativeControls; + /// Whether to render video in aspect-fill mode (zoom/crop to fill). + /// When false, uses aspect-fit. + final bool useAspectFill; + /// BuildContext getter for showing Dart fullscreen dialog /// Returns a mounted context from any registered platform view BuildContext? get _fullscreenContext { @@ -813,6 +818,7 @@ class NativeVideoPlayerController { 'isFullScreen': _state.isFullScreen, 'enableHDR': enableHDR, 'enableLooping': enableLooping, + 'useAspectFill': useAspectFill, if (mediaInfo != null) 'mediaInfo': mediaInfo!.toMap(), }; @@ -2152,6 +2158,16 @@ class NativeVideoPlayerController { await _methodChannel?.setShowNativeControls(show); } + /// Sets native render mode to aspect-fill (true) or aspect-fit (false). + Future setUseAspectFill(bool enabled) async { + await _methodChannel?.setUseAspectFill(enabled); + } + + /// Returns native video dimensions if available. + Future?> getVideoDimensions() async { + return await _methodChannel?.getVideoDimensions(); + } + /// Checks if AirPlay is available on the device /// /// This is only available on iOS. On Android, this always returns false. diff --git a/lib/src/enums/native_video_player_event.dart b/lib/src/enums/native_video_player_event.dart index 718851b..3074ef5 100644 --- a/lib/src/enums/native_video_player_event.dart +++ b/lib/src/enums/native_video_player_event.dart @@ -28,6 +28,7 @@ enum PlayerControlState { fullscreenEntered, fullscreenExited, timeUpdated, + videoDimensionsUpdated, } /// Activity state event for playback changes @@ -126,6 +127,8 @@ class PlayerControlEvent { case 'timeUpdate': case 'timeUpdated': // Native side sends 'timeUpdated' in some cases return PlayerControlState.timeUpdated; + case 'videoDimensions': + return PlayerControlState.videoDimensionsUpdated; default: return PlayerControlState.none; } diff --git a/lib/src/models/native_video_player_media_info.dart b/lib/src/models/native_video_player_media_info.dart index 25a9466..a7ba5ce 100644 --- a/lib/src/models/native_video_player_media_info.dart +++ b/lib/src/models/native_video_player_media_info.dart @@ -4,17 +4,20 @@ class NativeVideoPlayerMediaInfo { this.subtitle, this.album, this.artworkUrl, + this.showSkipControls, }); final String? title; final String? subtitle; final String? album; final String? artworkUrl; + final bool? showSkipControls; Map toMap() => { if (title != null) 'title': title, if (subtitle != null) 'subtitle': subtitle, if (album != null) 'album': album, if (artworkUrl != null) 'artworkUrl': artworkUrl, + if (showSkipControls != null) 'showSkipControls': showSkipControls, }; } diff --git a/lib/src/platform/video_player_method_channel.dart b/lib/src/platform/video_player_method_channel.dart index bcd2c73..37261e2 100644 --- a/lib/src/platform/video_player_method_channel.dart +++ b/lib/src/platform/video_player_method_channel.dart @@ -294,6 +294,39 @@ class VideoPlayerMethodChannel { } } + /// Sets whether video should use aspect-fill (zoom/crop) instead of aspect-fit. + Future setUseAspectFill(bool enabled) async { + try { + await _methodChannel.invokeMethod( + 'setUseAspectFill', + {'viewId': primaryPlatformViewId, 'enabled': enabled}, + ); + } catch (e) { + debugPrint('Error calling setUseAspectFill: $e'); + } + } + + /// Gets current video dimensions if available. + Future?> getVideoDimensions() async { + try { + final dynamic result = await _methodChannel.invokeMethod( + 'getVideoDimensions', + {'viewId': primaryPlatformViewId}, + ); + if (result is Map) { + final width = (result['width'] as num?)?.toInt(); + final height = (result['height'] as num?)?.toInt(); + if (width != null && height != null && width > 0 && height > 0) { + return {'width': width, 'height': height}; + } + } + return null; + } catch (e) { + debugPrint('Error calling getVideoDimensions: $e'); + return null; + } + } + /// Checks if AirPlay is available (iOS only) Future isAirPlayAvailable() async { try { From 4c535061d0e9cd8e2703676f124cd08ee2f59226 Mon Sep 17 00:00:00 2001 From: Daniel Williamson Date: Fri, 6 Mar 2026 13:02:48 +1100 Subject: [PATCH 02/28] Fix lockscreen seek and metadata parity on iOS/Android --- .../handlers/VideoPlayerMethodHandler.kt | 5 +++ .../VideoPlayerNotificationHandler.kt | 44 +++++++++++++++++++ .../VideoPlayerNowPlayingHandler.swift | 30 +++++++++++++ 3 files changed, 79 insertions(+) diff --git a/android/src/main/kotlin/com/huddlecommunity/better_native_video_player/handlers/VideoPlayerMethodHandler.kt b/android/src/main/kotlin/com/huddlecommunity/better_native_video_player/handlers/VideoPlayerMethodHandler.kt index c7d36c8..0aa7150 100644 --- a/android/src/main/kotlin/com/huddlecommunity/better_native_video_player/handlers/VideoPlayerMethodHandler.kt +++ b/android/src/main/kotlin/com/huddlecommunity/better_native_video_player/handlers/VideoPlayerMethodHandler.kt @@ -5,6 +5,7 @@ import android.content.Context import android.media.AudioAttributes import android.media.AudioFocusRequest import android.media.AudioManager +import android.net.Uri import android.os.Build import android.util.Log import androidx.media3.common.C @@ -229,6 +230,10 @@ class VideoPlayerMethodHandler( (mediaInfo["title"] as? String)?.let { metadataBuilder.setTitle(it) } (mediaInfo["subtitle"] as? String)?.let { metadataBuilder.setArtist(it) } (mediaInfo["album"] as? String)?.let { metadataBuilder.setAlbumTitle(it) } + (mediaInfo["artworkUrl"] as? String)?.let { artworkUrl -> + runCatching { Uri.parse(artworkUrl) } + .onSuccess { metadataBuilder.setArtworkUri(it) } + } mediaItemBuilder.setMediaMetadata(metadataBuilder.build()) } diff --git a/android/src/main/kotlin/com/huddlecommunity/better_native_video_player/handlers/VideoPlayerNotificationHandler.kt b/android/src/main/kotlin/com/huddlecommunity/better_native_video_player/handlers/VideoPlayerNotificationHandler.kt index 7eb209d..1ca138d 100644 --- a/android/src/main/kotlin/com/huddlecommunity/better_native_video_player/handlers/VideoPlayerNotificationHandler.kt +++ b/android/src/main/kotlin/com/huddlecommunity/better_native_video_player/handlers/VideoPlayerNotificationHandler.kt @@ -8,6 +8,7 @@ import android.content.Context import android.content.Intent import android.graphics.Bitmap import android.graphics.BitmapFactory +import android.net.Uri import android.os.Build import android.os.Handler import android.os.Looper @@ -53,6 +54,30 @@ class VideoPlayerNotificationHandler( // Store current metadata separately to avoid reading stale data from player private var currentTitle: String = "Video" private var currentSubtitle: String = "" + private var showSkipControls: Boolean = true + + private val mediaSessionCallback = object : MediaSession.Callback { + override fun onConnect( + session: MediaSession, + controller: MediaSession.ControllerInfo + ): MediaSession.ConnectionResult { + val connectionResult = super.onConnect(session, controller) + if (showSkipControls) { + return connectionResult + } + + val filteredPlayerCommands = connectionResult.availablePlayerCommands.buildUpon() + .remove(Player.COMMAND_SEEK_IN_CURRENT_MEDIA_ITEM) + .remove(Player.COMMAND_SEEK_BACK) + .remove(Player.COMMAND_SEEK_FORWARD) + .build() + + return MediaSession.ConnectionResult.accept( + connectionResult.availableSessionCommands, + filteredPlayerCommands + ) + } + } init { createNotificationChannel() @@ -117,6 +142,10 @@ class VideoPlayerNotificationHandler( (mediaInfo["title"] as? String)?.let { metadataBuilder.setTitle(it) } (mediaInfo["subtitle"] as? String)?.let { metadataBuilder.setArtist(it) } (mediaInfo["album"] as? String)?.let { metadataBuilder.setAlbumTitle(it) } + (mediaInfo["artworkUrl"] as? String)?.let { artworkUrl -> + runCatching { Uri.parse(artworkUrl) } + .onSuccess { metadataBuilder.setArtworkUri(it) } + } // Create updated MediaItem with new metadata val updatedItem = currentItem.buildUpon() @@ -142,14 +171,27 @@ class VideoPlayerNotificationHandler( // Extract metadata from the provided info val newTitle = (mediaInfo?.get("title") as? String) ?: "Video" val newSubtitle = (mediaInfo?.get("subtitle") as? String) ?: "" + val newShowSkipControls = (mediaInfo?.get("showSkipControls") as? Boolean) ?: true // Check if media info has actually changed to avoid unnecessary updates val mediaInfoChanged = (newTitle != currentTitle || newSubtitle != currentSubtitle) + val seekPermissionChanged = newShowSkipControls != showSkipControls // Store the new metadata currentTitle = newTitle currentSubtitle = newSubtitle + showSkipControls = newShowSkipControls Log.d(TAG, "📱 Media info - title: $currentTitle, subtitle: $currentSubtitle, changed: $mediaInfoChanged") + Log.d(TAG, "📱 showSkipControls: $showSkipControls, seekPermissionChanged: $seekPermissionChanged") + + // Recreate MediaSession when seek permissions change so connected system controllers + // receive the new command set (seek/scrub disabled for non-premium). + if (seekPermissionChanged && mediaSession != null) { + mediaSession?.release() + mediaSession = null + player.removeListener(playerListener) + Log.d(TAG, "📱 Recreating MediaSession due to seek permission change") + } // If MediaSession already exists, only update if media info changed if (mediaSession != null) { @@ -197,9 +239,11 @@ class VideoPlayerNotificationHandler( mediaSession = MediaSession.Builder(context, player) .setId(sessionId) .setSessionActivity(pendingIntent) + .setCallback(mediaSessionCallback) .build() // Add listener to track play/pause events + player.removeListener(playerListener) player.addListener(playerListener) Log.d(TAG, "MediaSession created - lock screen and notification controls active") diff --git a/ios/Classes/Handlers/VideoPlayerNowPlayingHandler.swift b/ios/Classes/Handlers/VideoPlayerNowPlayingHandler.swift index e0e55c6..203dd67 100644 --- a/ios/Classes/Handlers/VideoPlayerNowPlayingHandler.swift +++ b/ios/Classes/Handlers/VideoPlayerNowPlayingHandler.swift @@ -54,6 +54,7 @@ class RemoteCommandManager { commandCenter.pauseCommand.removeTarget(nil) commandCenter.skipForwardCommand.removeTarget(nil) commandCenter.skipBackwardCommand.removeTarget(nil) + commandCenter.changePlaybackPositionCommand.removeTarget(nil) print("🎛️ Removed all remote command targets") } @@ -68,6 +69,7 @@ class RemoteCommandManager { commandCenter.pauseCommand.removeTarget(nil) commandCenter.skipForwardCommand.removeTarget(nil) commandCenter.skipBackwardCommand.removeTarget(nil) + commandCenter.changePlaybackPositionCommand.removeTarget(nil) print("🎛️ Atomically transferred ownership to view \(viewId) and cleared targets") } } @@ -268,6 +270,7 @@ extension VideoPlayerView { // --- Skip forward/backward --- commandCenter.skipForwardCommand.isEnabled = showSkipControls commandCenter.skipBackwardCommand.isEnabled = showSkipControls + commandCenter.changePlaybackPositionCommand.isEnabled = showSkipControls commandCenter.skipForwardCommand.preferredIntervals = [15] commandCenter.skipBackwardCommand.preferredIntervals = [15] @@ -313,6 +316,33 @@ extension VideoPlayerView { self.updateNowPlayingPlaybackTime() return .success } + + commandCenter.changePlaybackPositionCommand.addTarget { [weak self] event in + guard let self = self, + let seekEvent = event as? MPChangePlaybackPositionCommandEvent, + let player = self.player + else { + return .commandFailed + } + + // Only handle if we still own the remote commands + guard RemoteCommandManager.shared.isOwner(self.viewId) else { + print("⚠️ View \(self.viewId) received change position command but is not owner") + return .commandFailed + } + + let durationSeconds = CMTimeGetSeconds(player.currentItem?.duration ?? .zero) + let boundedPosition = max(0, seekEvent.positionTime) + + if durationSeconds.isFinite { + player.seek(to: CMTime(seconds: min(boundedPosition, durationSeconds), preferredTimescale: 600)) + } else { + player.seek(to: CMTime(seconds: boundedPosition, preferredTimescale: 600)) + } + + self.updateNowPlayingPlaybackTime() + return .success + } } print("🎛️ View \(viewId) registered remote command handlers") From d70afeaa18bb3037d37da4c21ef57c15cc1b6d64 Mon Sep 17 00:00:00 2001 From: Daniel Williamson Date: Fri, 6 Mar 2026 13:43:00 +1100 Subject: [PATCH 03/28] Disable previous/restart transport seek for non-premium --- .../handlers/VideoPlayerNotificationHandler.kt | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/android/src/main/kotlin/com/huddlecommunity/better_native_video_player/handlers/VideoPlayerNotificationHandler.kt b/android/src/main/kotlin/com/huddlecommunity/better_native_video_player/handlers/VideoPlayerNotificationHandler.kt index 1ca138d..11275ef 100644 --- a/android/src/main/kotlin/com/huddlecommunity/better_native_video_player/handlers/VideoPlayerNotificationHandler.kt +++ b/android/src/main/kotlin/com/huddlecommunity/better_native_video_player/handlers/VideoPlayerNotificationHandler.kt @@ -70,6 +70,12 @@ class VideoPlayerNotificationHandler( .remove(Player.COMMAND_SEEK_IN_CURRENT_MEDIA_ITEM) .remove(Player.COMMAND_SEEK_BACK) .remove(Player.COMMAND_SEEK_FORWARD) + .remove(Player.COMMAND_SEEK_TO_DEFAULT_POSITION) + .remove(Player.COMMAND_SEEK_TO_MEDIA_ITEM) + .remove(Player.COMMAND_SEEK_TO_PREVIOUS) + .remove(Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM) + .remove(Player.COMMAND_SEEK_TO_NEXT) + .remove(Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM) .build() return MediaSession.ConnectionResult.accept( From dcafcde0360d867da6364754943e360627c18e8e Mon Sep 17 00:00:00 2001 From: Daniel Williamson Date: Fri, 6 Mar 2026 13:46:33 +1100 Subject: [PATCH 04/28] Set Android seek back/forward increment to 15 seconds --- .../better_native_video_player/VideoPlayerView.kt | 3 +++ .../better_native_video_player/manager/SharedPlayerManager.kt | 3 +++ 2 files changed, 6 insertions(+) diff --git a/android/src/main/kotlin/com/huddlecommunity/better_native_video_player/VideoPlayerView.kt b/android/src/main/kotlin/com/huddlecommunity/better_native_video_player/VideoPlayerView.kt index 681e922..3e6b78f 100644 --- a/android/src/main/kotlin/com/huddlecommunity/better_native_video_player/VideoPlayerView.kt +++ b/android/src/main/kotlin/com/huddlecommunity/better_native_video_player/VideoPlayerView.kt @@ -44,6 +44,7 @@ class VideoPlayerView( companion object { private const val TAG = "VideoPlayerView" + private const val SEEK_INCREMENT_MS = 15_000L } private val playerView: PlayerView @@ -133,6 +134,8 @@ class VideoPlayerView( isSharedPlayer = false ExoPlayer.Builder(context) .setAudioAttributes(AudioAttributes.DEFAULT, false) + .setSeekBackIncrementMs(SEEK_INCREMENT_MS) + .setSeekForwardIncrementMs(SEEK_INCREMENT_MS) .build() } diff --git a/android/src/main/kotlin/com/huddlecommunity/better_native_video_player/manager/SharedPlayerManager.kt b/android/src/main/kotlin/com/huddlecommunity/better_native_video_player/manager/SharedPlayerManager.kt index 7a8ff05..895f841 100644 --- a/android/src/main/kotlin/com/huddlecommunity/better_native_video_player/manager/SharedPlayerManager.kt +++ b/android/src/main/kotlin/com/huddlecommunity/better_native_video_player/manager/SharedPlayerManager.kt @@ -16,6 +16,7 @@ import com.huddlecommunity.better_native_video_player.handlers.VideoPlayerEventH */ object SharedPlayerManager { private const val TAG = "SharedPlayerManager" + private const val SEEK_INCREMENT_MS = 15_000L private val players = mutableMapOf() private val notificationHandlers = mutableMapOf() @@ -37,6 +38,8 @@ object SharedPlayerManager { val player = players.getOrPut(controllerId) { ExoPlayer.Builder(context) .setAudioAttributes(AudioAttributes.DEFAULT, false) + .setSeekBackIncrementMs(SEEK_INCREMENT_MS) + .setSeekForwardIncrementMs(SEEK_INCREMENT_MS) .build() } return Pair(player, alreadyExisted) From 34e9e03be3a877cedae5eb6d6d7b44898a936f02 Mon Sep 17 00:00:00 2001 From: Daniel Williamson Date: Tue, 17 Mar 2026 18:02:00 +1100 Subject: [PATCH 05/28] FLTR-19614: Add video frame analysis toggle --- ios/Classes/View/VideoPlayerView.swift | 5 +++++ lib/src/controllers/native_video_player_controller.dart | 8 ++++++++ 2 files changed, 13 insertions(+) diff --git a/ios/Classes/View/VideoPlayerView.swift b/ios/Classes/View/VideoPlayerView.swift index 266b8b4..569def9 100644 --- a/ios/Classes/View/VideoPlayerView.swift +++ b/ios/Classes/View/VideoPlayerView.swift @@ -191,6 +191,7 @@ import QuartzCore // PiP configuration from args let argsAllowsPiP = args["allowsPictureInPicture"] as? Bool ?? true let argsCanStartAutomatically = args["canStartPictureInPictureAutomatically"] as? Bool ?? true + let argsAllowsVideoFrameAnalysis = args["allowsVideoFrameAnalysis"] as? Bool ?? true let argsShowNativeControls = args["showNativeControls"] as? Bool ?? true // HDR configuration from args @@ -236,6 +237,10 @@ import QuartzCore print("⚠️ Automatic PiP requires iOS 14.2+, current device doesn't support it") } + if #available(iOS 16.0, *) { + playerViewController.allowsVideoFrameAnalysis = argsAllowsVideoFrameAnalysis + } + // Store media info if provided during initialization // This ensures we have the correct media info even for shared players if let mediaInfo = args["mediaInfo"] as? [String: Any] { diff --git a/lib/src/controllers/native_video_player_controller.dart b/lib/src/controllers/native_video_player_controller.dart index 78f8eee..8a3cb79 100644 --- a/lib/src/controllers/native_video_player_controller.dart +++ b/lib/src/controllers/native_video_player_controller.dart @@ -48,6 +48,7 @@ class NativeVideoPlayerController { this.mediaInfo, this.allowsPictureInPicture = true, this.canStartPictureInPictureAutomatically = true, + this.allowsVideoFrameAnalysis = true, this.lockToLandscape = true, this.enableHDR = true, this.enableLooping = false, @@ -131,6 +132,12 @@ class NativeVideoPlayerController { /// Whether PiP can start automatically when app goes to background (iOS 14.2+) final bool canStartPictureInPictureAutomatically; + /// Whether iOS video frame analysis features such as Live Text are allowed. + /// + /// On iOS 16+, `AVPlayerViewController` can show a system analysis button over + /// video content when this is enabled. + final bool allowsVideoFrameAnalysis; + /// Whether to enable HDR playback (default: false) /// When set to false, HDR is disabled to prevent washed-out/too-white video appearance final bool enableHDR; @@ -812,6 +819,7 @@ class NativeVideoPlayerController { 'allowsPictureInPicture': allowsPictureInPicture, 'canStartPictureInPictureAutomatically': canStartPictureInPictureAutomatically, + 'allowsVideoFrameAnalysis': allowsVideoFrameAnalysis, 'showNativeControls': _hasCustomOverlay ? false : showNativeControls, // Hide native controls if we have custom overlay, otherwise use parameter From f43964d4f7a6f60cd32cb53ee741026a7bdc709b Mon Sep 17 00:00:00 2001 From: Daniel Williamson Date: Wed, 18 Mar 2026 13:58:06 +1100 Subject: [PATCH 06/28] FLTR-19614: Harden iOS video event teardown --- .../Handlers/VideoPlayerMethodHandler.swift | 3 +- ios/Classes/View/VideoPlayerView.swift | 58 ++++++++++--------- 2 files changed, 34 insertions(+), 27 deletions(-) diff --git a/ios/Classes/Handlers/VideoPlayerMethodHandler.swift b/ios/Classes/Handlers/VideoPlayerMethodHandler.swift index 4783001..92e66fc 100644 --- a/ios/Classes/Handlers/VideoPlayerMethodHandler.swift +++ b/ios/Classes/Handlers/VideoPlayerMethodHandler.swift @@ -709,6 +709,8 @@ extension VideoPlayerView { func handleDispose(result: @escaping FlutterResult) { print("🗑️ [VideoPlayerMethodHandler] handleDispose called for controllerId: \(String(describing: controllerId))") + isDisposed = true + invalidateEventChannel() // Pause the player first player?.pause() @@ -734,7 +736,6 @@ extension VideoPlayerView { player = nil print("🧹 [VideoPlayerMethodHandler] Local player reference cleared") - sendEvent("stopped") result(nil) } diff --git a/ios/Classes/View/VideoPlayerView.swift b/ios/Classes/View/VideoPlayerView.swift index 569def9..dfa156a 100644 --- a/ios/Classes/View/VideoPlayerView.swift +++ b/ios/Classes/View/VideoPlayerView.swift @@ -13,6 +13,8 @@ import QuartzCore private var methodChannel: FlutterMethodChannel private var channelName: String var eventSink: FlutterEventSink? + var isEventChannelActive: Bool = false + var isDisposed: Bool = false var availableQualities: [[String: Any]] = [] var qualityLevels: [VideoPlayer.QualityLevel] = [] var isAutoQuality = false @@ -447,16 +449,39 @@ import QuartzCore } } + func invalidateEventChannel() { + isEventChannelActive = false + eventSink = nil + } + public func sendEvent(_ name: String, data: [String: Any]? = nil) { + guard isEventChannelActive, !isDisposed else { + return + } + var event: [String: Any] = ["event": name] if let data = data { event.merge(data) { (_, new) in new } } - DispatchQueue.main.async { - self.eventSink?(event) + + let emitEvent = { [weak self] in + guard let self = self, + self.isEventChannelActive, + !self.isDisposed, + let eventSink = self.eventSink else { + return + } + eventSink(event) } + + if Thread.isMainThread { + emitEvent() + return + } + + DispatchQueue.main.async(execute: emitEvent) } private func handleSetUseAspectFill(call: FlutterMethodCall, result: @escaping FlutterResult) { @@ -653,6 +678,8 @@ import QuartzCore // MARK: - FlutterStreamHandler public func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? { print("[\(channelName)] Event channel listener attached") + isDisposed = false + isEventChannelActive = true self.eventSink = events // Send initial state event when listener is attached @@ -764,39 +791,18 @@ import QuartzCore public func onCancel(withArguments arguments: Any?) -> FlutterError? { print("[\(channelName)] Event channel listener detached") - self.eventSink = nil + invalidateEventChannel() return nil } deinit { print("VideoPlayerView deinit for channel: \(channelName), viewId: \(viewId)") + isDisposed = true + invalidateEventChannel() // Use the isPipCurrentlyActive flag to check if PiP is active let isPipActiveNow = isPipCurrentlyActive - // ALWAYS emit PiP state on disposal to ensure Flutter side is synchronized - // This is important for state management even if PiP is not active - if isPipActiveNow { - print("⚠️ View being disposed while PiP is active - sending pipStop event") - } else { - print("ℹ️ View being disposed while PiP is inactive - sending pipStop event for state sync") - } - - // Always send pipStop event - either from this view or an alternative - if eventSink != nil { - // This view still has a listener, send from here - sendEvent("pipStop", data: ["isPictureInPicture": false]) - print("✅ Sent pipStop event from disposing view \(viewId)") - } else if let controllerIdValue = controllerId, - let alternativeView = SharedPlayerManager.shared.findAnotherViewForController(controllerIdValue, excluding: viewId), - alternativeView.eventSink != nil { - // Send from alternative view if it exists and has a listener - alternativeView.sendEvent("pipStop", data: ["isPictureInPicture": false]) - print("✅ Sent pipStop event from alternative view \(alternativeView.viewId)") - } else { - print("⚠️ No active view with listener found - pipStop event cannot be sent") - } - // Try to stop PiP gracefully if it was active if isPipActiveNow { if #available(iOS 14.0, *) { From bb77fffa8d604162d7728b860e8cab4df96ac966 Mon Sep 17 00:00:00 2001 From: Daniel Williamson Date: Thu, 19 Mar 2026 19:10:42 +1100 Subject: [PATCH 07/28] FLTR-19615: Emit Android video dimensions more reliably --- .../VideoPlayerView.kt | 43 +++++++++++++- .../handlers/VideoPlayerObserver.kt | 59 +++++++++++++++---- 2 files changed, 90 insertions(+), 12 deletions(-) diff --git a/android/src/main/kotlin/com/huddlecommunity/better_native_video_player/VideoPlayerView.kt b/android/src/main/kotlin/com/huddlecommunity/better_native_video_player/VideoPlayerView.kt index 3e6b78f..005ff99 100644 --- a/android/src/main/kotlin/com/huddlecommunity/better_native_video_player/VideoPlayerView.kt +++ b/android/src/main/kotlin/com/huddlecommunity/better_native_video_player/VideoPlayerView.kt @@ -15,6 +15,7 @@ import androidx.core.view.WindowCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsControllerCompat import androidx.media3.common.AudioAttributes +import androidx.media3.common.C import androidx.media3.common.Player import androidx.media3.common.util.UnstableApi import androidx.media3.exoplayer.ExoPlayer @@ -304,6 +305,15 @@ class VideoPlayerView( eventHandler.setInitialStateCallback { Log.d(TAG, "Sending initial state - isPlaying: ${player.isPlaying}, playbackState: ${player.playbackState}, duration: ${player.duration}") + resolveCurrentVideoDimensions()?.let { (initialVideoWidth, initialVideoHeight) -> + Log.d(TAG, "Sending initial videoDimensions event: ${initialVideoWidth}x${initialVideoHeight}") + eventHandler.sendEvent( + "videoDimensions", + mapOf("videoWidth" to initialVideoWidth, "videoHeight" to initialVideoHeight), + synchronous = true + ) + } + // For shared players or players with media already loaded, send loaded event first if (player.playbackState != ExoPlayer.STATE_IDLE && player.duration >= 0) { Log.d(TAG, "Sending loaded event with duration: ${player.duration}") @@ -657,6 +667,27 @@ class VideoPlayerView( // PiP is now handled by the floating package on the Dart side // All PiP-related methods have been removed + private fun resolveCurrentVideoDimensions(): Pair? { + val currentVideoSize = player.videoSize + if (currentVideoSize.width > 0 && currentVideoSize.height > 0) { + return currentVideoSize.width to currentVideoSize.height + } + + val currentTracks = player.currentTracks + for (group in currentTracks.groups) { + if (group.type != C.TRACK_TYPE_VIDEO || !group.isSelected) continue + for (index in 0 until group.length) { + if (!group.isTrackSelected(index)) continue + val format = group.getTrackFormat(index) + if (format.width > 0 && format.height > 0) { + return format.width to format.height + } + } + } + + return null + } + /** * Emits all current player states to ensure UI is in sync * This is useful after events like exiting PiP where the UI needs to refresh @@ -667,12 +698,20 @@ class VideoPlayerView( // Emit current time and duration val currentPosition = player.currentPosition val duration = player.duration + val currentVideoDimensions = resolveCurrentVideoDimensions() + val videoWidth = currentVideoDimensions?.first ?: 0 + val videoHeight = currentVideoDimensions?.second ?: 0 + + if (videoWidth > 0 && videoHeight > 0) { + eventHandler.sendEvent( + "videoDimensions", + mapOf("videoWidth" to videoWidth, "videoHeight" to videoHeight) + ) + } if (duration > 0) { // Get buffered position val bufferedPosition = player.bufferedPosition - val videoWidth = player.videoSize.width - val videoHeight = player.videoSize.height val payload = mutableMapOf( "position" to currentPosition.toInt(), diff --git a/android/src/main/kotlin/com/huddlecommunity/better_native_video_player/handlers/VideoPlayerObserver.kt b/android/src/main/kotlin/com/huddlecommunity/better_native_video_player/handlers/VideoPlayerObserver.kt index 2e5a16c..48c8d00 100644 --- a/android/src/main/kotlin/com/huddlecommunity/better_native_video_player/handlers/VideoPlayerObserver.kt +++ b/android/src/main/kotlin/com/huddlecommunity/better_native_video_player/handlers/VideoPlayerObserver.kt @@ -7,6 +7,7 @@ import androidx.media3.common.PlaybackException import androidx.media3.common.Player import androidx.media3.common.Timeline import androidx.media3.common.C +import androidx.media3.common.Tracks /** * Observes ExoPlayer state changes and reports them via EventHandler @@ -110,8 +111,43 @@ class VideoPlayerObserver( } } + private fun maybeEmitVideoDimensions(width: Int, height: Int) { + if (width <= 0 || height <= 0) return + if (width == lastVideoWidth && height == lastVideoHeight) return + lastVideoWidth = width + lastVideoHeight = height + eventHandler.sendEvent( + "videoDimensions", + mapOf("videoWidth" to width, "videoHeight" to height) + ) + } + + private fun resolveCurrentVideoDimensions(): Pair? { + val currentVideoSize = player.videoSize + if (currentVideoSize.width > 0 && currentVideoSize.height > 0) { + return currentVideoSize.width to currentVideoSize.height + } + + val currentTracks = player.currentTracks + for (group in currentTracks.groups) { + if (group.type != C.TRACK_TYPE_VIDEO || !group.isSelected) continue + for (index in 0 until group.length) { + if (!group.isTrackSelected(index)) continue + val format = group.getTrackFormat(index) + if (format.width > 0 && format.height > 0) { + return format.width to format.height + } + } + } + + return null + } + override fun onPlaybackStateChanged(playbackState: Int) { Log.d(TAG, "Playback state changed: $playbackState, isLoading: ${player.isLoading}") + resolveCurrentVideoDimensions()?.let { (width, height) -> + maybeEmitVideoDimensions(width, height) + } when (playbackState) { Player.STATE_IDLE -> { // Player is idle @@ -223,16 +259,19 @@ class VideoPlayerObserver( } override fun onVideoSizeChanged(videoSize: androidx.media3.common.VideoSize) { - val width = videoSize.width - val height = videoSize.height - if (width <= 0 || height <= 0) return - if (width == lastVideoWidth && height == lastVideoHeight) return - lastVideoWidth = width - lastVideoHeight = height - eventHandler.sendEvent( - "videoDimensions", - mapOf("videoWidth" to width, "videoHeight" to height) - ) + maybeEmitVideoDimensions(videoSize.width, videoSize.height) + } + + override fun onTracksChanged(tracks: Tracks) { + resolveCurrentVideoDimensions()?.let { (width, height) -> + maybeEmitVideoDimensions(width, height) + } + } + + override fun onRenderedFirstFrame() { + resolveCurrentVideoDimensions()?.let { (width, height) -> + maybeEmitVideoDimensions(width, height) + } } override fun onDeviceInfoChanged(deviceInfo: androidx.media3.common.DeviceInfo) { From c2102617f7a40b7542a5cc48661357ff20f5fb02 Mon Sep 17 00:00:00 2001 From: Daniel Williamson Date: Wed, 25 Mar 2026 15:18:11 +1100 Subject: [PATCH 08/28] FLTR-19589: Handle controller event channel teardown races --- .../native_video_player_controller.dart | 97 +++++++++++++++---- 1 file changed, 80 insertions(+), 17 deletions(-) diff --git a/lib/src/controllers/native_video_player_controller.dart b/lib/src/controllers/native_video_player_controller.dart index 8a3cb79..892ccae 100644 --- a/lib/src/controllers/native_video_player_controller.dart +++ b/lib/src/controllers/native_video_player_controller.dart @@ -65,9 +65,6 @@ class NativeVideoPlayerController { if (!kIsWeb && Platform.isAndroid) { WidgetsBinding.instance.addObserver(_AppLifecycleObserver(this)); } - - // Set up controller-level event channel for persistent events (PiP, AirPlay) - _setupControllerEventChannel(); } /// Initialize the controller and wait for the platform view to be created @@ -899,6 +896,8 @@ class NativeVideoPlayerController { _emitCurrentState(); + unawaited(_setupControllerEventChannelWithRetry()); + // ALWAYS notify all event handler listeners about the current state // This ensures listeners added via add*Listener methods receive the current state @@ -1038,19 +1037,55 @@ class NativeVideoPlayerController { /// This channel receives PiP and AirPlay events independently of platform views. /// It persists even when all platform views are disposed, allowing events to /// flow after calling releaseResources(). Only disposed when controller.dispose() is called. - void _setupControllerEventChannel() { - _controllerEventChannel = EventChannel( + Future _setupControllerEventChannelWithRetry() async { + if (kIsWeb || !Platform.isIOS || _isDisposed) { + return; + } + + if (_controllerEventSubscription != null) { + return; + } + + _controllerEventChannel ??= EventChannel( 'native_video_player_controller_$id', ); - _controllerEventSubscription = _controllerEventChannel! - .receiveBroadcastStream() - .listen( - _handleControllerEvent, - onError: (dynamic error) { - debugPrint('Controller event channel error: $error'); - }, - cancelOnError: false, - ); + + const List delays = [0, 50, 100, 200, 400]; + + for (final delay in delays) { + if (_isDisposed || _controllerEventSubscription != null) { + return; + } + + if (delay > 0) { + await Future.delayed(Duration(milliseconds: delay)); + } + + try { + _controllerEventSubscription = _controllerEventChannel! + .receiveBroadcastStream() + .listen( + _handleControllerEvent, + onError: (dynamic error) { + if (kDebugMode && + !_isIgnorableControllerChannelSetupError(error)) { + debugPrint('Controller event channel error: $error'); + } + }, + cancelOnError: false, + ); + return; + } catch (e) { + if (_isIgnorableControllerChannelSetupError(e)) { + continue; + } + + if (kDebugMode) { + debugPrint('Controller event channel setup error: $e'); + } + return; + } + } } /// Handles events from the controller-level event channel @@ -1572,10 +1607,11 @@ class NativeVideoPlayerController { } try { await subscription.cancel(); - } on MissingPluginException { - // Native side has already disposed the EventChannel StreamHandler - // This is harmless and safe to ignore } catch (e) { + if (_isIgnorableStreamCancellationError(e)) { + return; + } + // Log other exceptions in debug mode for debugging purposes if (kDebugMode) { debugPrint('Error cancelling subscription: $e'); @@ -1583,6 +1619,33 @@ class NativeVideoPlayerController { } } + bool _isIgnorableStreamCancellationError(Object error) { + if (error is MissingPluginException) { + return true; + } + + if (error is! PlatformException) { + return false; + } + + return error.code == 'error' && + (error.message?.contains('No active stream to cancel') ?? false); + } + + bool _isIgnorableControllerChannelSetupError(Object error) { + if (error is MissingPluginException) { + return true; + } + + if (error is! PlatformException) { + return false; + } + + return error.code == 'channel-error' && + (error.message?.contains('Unable to establish connection on channel') ?? + false); + } + /// Called when a platform view is disposed /// /// Unregisters the platform view from this controller. From aa6e940a015cf4f01f89232e65f0a1350fb8ffee Mon Sep 17 00:00:00 2001 From: Daniel Williamson Date: Thu, 26 Mar 2026 18:52:48 +1100 Subject: [PATCH 09/28] FLTR-19589: Add video playlist track navigation controls for Android and iOS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Android: ForwardingPlayer intercepts seekTo{Previous,Next}() to fire previousTrack/nextTrack events when track nav flags are set; overrides getAvailableCommands() so the system notification shows ⏮/⏭ for single-item playlists; flags are updated eagerly in handleLoad so onAvailableCommandsChanged reflects them immediately - iOS: Register previousTrackCommand/nextTrackCommand remote command handlers; toggle enabled state based on showSystemPreviousTrackControl / showSystemNextTrackControl flags; disabled direction falls back to the corresponding skip button - Dart: Add previousTrackRequested/nextTrackRequested to PlayerControlState and NativeVideoPlayerMediaInfo Co-Authored-By: Claude Sonnet 4.6 --- .../handlers/VideoPlayerMethodHandler.kt | 4 + .../VideoPlayerNotificationHandler.kt | 154 +++++++++++++++--- .../VideoPlayerNowPlayingHandler.swift | 150 ++++++++++------- lib/src/enums/native_video_player_event.dart | 6 + .../native_video_player_media_info.dart | 6 + 5 files changed, 235 insertions(+), 85 deletions(-) diff --git a/android/src/main/kotlin/com/huddlecommunity/better_native_video_player/handlers/VideoPlayerMethodHandler.kt b/android/src/main/kotlin/com/huddlecommunity/better_native_video_player/handlers/VideoPlayerMethodHandler.kt index 0aa7150..b0080a0 100644 --- a/android/src/main/kotlin/com/huddlecommunity/better_native_video_player/handlers/VideoPlayerMethodHandler.kt +++ b/android/src/main/kotlin/com/huddlecommunity/better_native_video_player/handlers/VideoPlayerMethodHandler.kt @@ -174,6 +174,10 @@ class VideoPlayerMethodHandler( val mediaInfo = args["mediaInfo"] as? Map val drmConfig = args["drmConfig"] as? Map<*, *> + // Update track nav flags early so getAvailableCommands() reflects them when + // setMediaSource() triggers onAvailableCommandsChanged below. + notificationHandler.updateTrackNavFlags(mediaInfo) + // Store media info in the VideoPlayerView updateMediaInfo?.invoke(mediaInfo) mediaInfo?.let { diff --git a/android/src/main/kotlin/com/huddlecommunity/better_native_video_player/handlers/VideoPlayerNotificationHandler.kt b/android/src/main/kotlin/com/huddlecommunity/better_native_video_player/handlers/VideoPlayerNotificationHandler.kt index 11275ef..e257f65 100644 --- a/android/src/main/kotlin/com/huddlecommunity/better_native_video_player/handlers/VideoPlayerNotificationHandler.kt +++ b/android/src/main/kotlin/com/huddlecommunity/better_native_video_player/handlers/VideoPlayerNotificationHandler.kt @@ -16,11 +16,12 @@ import android.util.Log import android.support.v4.media.session.MediaSessionCompat import androidx.core.app.NotificationCompat import androidx.media.app.NotificationCompat as MediaNotificationCompat +import androidx.media3.common.ForwardingPlayer import androidx.media3.common.MediaMetadata import androidx.media3.common.Player import androidx.media3.exoplayer.ExoPlayer import androidx.media3.session.MediaSession -import androidx.media3.session.SessionToken +import androidx.media3.session.MediaSession.ConnectionResult import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -55,33 +56,116 @@ class VideoPlayerNotificationHandler( private var currentTitle: String = "Video" private var currentSubtitle: String = "" private var showSkipControls: Boolean = true + private var showSystemPreviousTrackControl: Boolean = false + private var showSystemNextTrackControl: Boolean = false + + /** + * Wraps the ExoPlayer so that seekBack/seekForward can be intercepted when track-navigation + * buttons are active. The system notification always calls seekBack()/seekForward() on the + * player regardless of custom session commands, so interception must happen here. + */ + private val wrappedPlayer = object : ForwardingPlayer(player) { + /** + * Dynamically include/exclude the seek-to-previous and seek-to-next player commands based + * on the track navigation flags. ExoPlayer never adds these commands for a single-item + * playlist, so the system notification would never show ⏮/⏭ without this override. + * The MediaSession calls getAvailableCommands() when pushing updates to controllers, so + * updating the flags before setMediaSource() fires onAvailableCommandsChanged is enough + * to make the buttons appear/disappear without recreating the session. + */ + override fun getAvailableCommands(): Player.Commands { + val builder = super.getAvailableCommands().buildUpon() + if (showSystemPreviousTrackControl) { + builder.add(Player.COMMAND_SEEK_TO_PREVIOUS) + builder.add(Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM) + } else { + builder.remove(Player.COMMAND_SEEK_TO_PREVIOUS) + builder.remove(Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM) + } + if (showSystemNextTrackControl) { + builder.add(Player.COMMAND_SEEK_TO_NEXT) + builder.add(Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM) + } else { + builder.remove(Player.COMMAND_SEEK_TO_NEXT) + builder.remove(Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM) + } + return builder.build() + } + + override fun seekBack() { + if (showSystemPreviousTrackControl) { + eventHandler.sendEvent("previousTrack") + } else { + super.seekBack() + } + } + + override fun seekForward() { + if (showSystemNextTrackControl) { + eventHandler.sendEvent("nextTrack") + } else { + super.seekForward() + } + } + + // The system notification uses COMMAND_SEEK_TO_PREVIOUS / COMMAND_SEEK_TO_NEXT + // (not COMMAND_SEEK_BACK / COMMAND_SEEK_FORWARD) for the ⏮ / ⏭ buttons. + // On a single-item ExoPlayer playlist these would seek to position 0 / end of track, + // so we must intercept them here as well. + override fun seekToPrevious() { + if (showSystemPreviousTrackControl) { + eventHandler.sendEvent("previousTrack") + } else { + super.seekToPrevious() + } + } + + override fun seekToPreviousMediaItem() { + if (showSystemPreviousTrackControl) { + eventHandler.sendEvent("previousTrack") + } else { + super.seekToPreviousMediaItem() + } + } + + override fun seekToNext() { + if (showSystemNextTrackControl) { + eventHandler.sendEvent("nextTrack") + } else { + super.seekToNext() + } + } + + override fun seekToNextMediaItem() { + if (showSystemNextTrackControl) { + eventHandler.sendEvent("nextTrack") + } else { + super.seekToNextMediaItem() + } + } + } private val mediaSessionCallback = object : MediaSession.Callback { override fun onConnect( session: MediaSession, - controller: MediaSession.ControllerInfo - ): MediaSession.ConnectionResult { - val connectionResult = super.onConnect(session, controller) - if (showSkipControls) { - return connectionResult + controller: MediaSession.ControllerInfo, + ): ConnectionResult { + val base = super.onConnect(session, controller) + if (!showSkipControls) { + val playerCommands = base.availablePlayerCommands.buildUpon() + .remove(Player.COMMAND_SEEK_IN_CURRENT_MEDIA_ITEM) + .remove(Player.COMMAND_SEEK_BACK) + .remove(Player.COMMAND_SEEK_FORWARD) + .remove(Player.COMMAND_SEEK_TO_DEFAULT_POSITION) + .remove(Player.COMMAND_SEEK_TO_MEDIA_ITEM) + .remove(Player.COMMAND_SEEK_TO_PREVIOUS) + .remove(Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM) + .remove(Player.COMMAND_SEEK_TO_NEXT) + .remove(Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM) + .build() + return ConnectionResult.accept(base.availableSessionCommands, playerCommands) } - - val filteredPlayerCommands = connectionResult.availablePlayerCommands.buildUpon() - .remove(Player.COMMAND_SEEK_IN_CURRENT_MEDIA_ITEM) - .remove(Player.COMMAND_SEEK_BACK) - .remove(Player.COMMAND_SEEK_FORWARD) - .remove(Player.COMMAND_SEEK_TO_DEFAULT_POSITION) - .remove(Player.COMMAND_SEEK_TO_MEDIA_ITEM) - .remove(Player.COMMAND_SEEK_TO_PREVIOUS) - .remove(Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM) - .remove(Player.COMMAND_SEEK_TO_NEXT) - .remove(Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM) - .build() - - return MediaSession.ConnectionResult.accept( - connectionResult.availableSessionCommands, - filteredPlayerCommands - ) + return base } } @@ -126,6 +210,19 @@ class VideoPlayerNotificationHandler( } } + /** + * Updates track navigation flags early (called from handleLoad before setMediaSource). + * This ensures getAvailableCommands() returns the correct result when ExoPlayer fires + * onAvailableCommandsChanged during media source preparation, so the system notification + * shows ⏮/⏭ without requiring a session recreation. + */ + fun updateTrackNavFlags(mediaInfo: Map?) { + val newShowPrev = (mediaInfo?.get("showSystemPreviousTrackControl") as? Boolean) ?: false + val newShowNext = (mediaInfo?.get("showSystemNextTrackControl") as? Boolean) ?: false + showSystemPreviousTrackControl = newShowPrev + showSystemNextTrackControl = newShowNext + } + /** * Updates the event handler (needed when shared NotificationHandler is reused by new VideoPlayerView) */ @@ -178,20 +275,25 @@ class VideoPlayerNotificationHandler( val newTitle = (mediaInfo?.get("title") as? String) ?: "Video" val newSubtitle = (mediaInfo?.get("subtitle") as? String) ?: "" val newShowSkipControls = (mediaInfo?.get("showSkipControls") as? Boolean) ?: true + val newShowSystemPreviousTrackControl = (mediaInfo?.get("showSystemPreviousTrackControl") as? Boolean) ?: false + val newShowSystemNextTrackControl = (mediaInfo?.get("showSystemNextTrackControl") as? Boolean) ?: false // Check if media info has actually changed to avoid unnecessary updates val mediaInfoChanged = (newTitle != currentTitle || newSubtitle != currentSubtitle) val seekPermissionChanged = newShowSkipControls != showSkipControls - // Store the new metadata + // Store the new metadata (wrappedPlayer reads these fields live, so no session restart needed) currentTitle = newTitle currentSubtitle = newSubtitle showSkipControls = newShowSkipControls + showSystemPreviousTrackControl = newShowSystemPreviousTrackControl + showSystemNextTrackControl = newShowSystemNextTrackControl Log.d(TAG, "📱 Media info - title: $currentTitle, subtitle: $currentSubtitle, changed: $mediaInfoChanged") Log.d(TAG, "📱 showSkipControls: $showSkipControls, seekPermissionChanged: $seekPermissionChanged") + Log.d(TAG, "📱 showPrevTrack: $showSystemPreviousTrackControl, showNextTrack: $showSystemNextTrackControl") // Recreate MediaSession when seek permissions change so connected system controllers - // receive the new command set (seek/scrub disabled for non-premium). + // receive the new command set via onConnect. if (seekPermissionChanged && mediaSession != null) { mediaSession?.release() mediaSession = null @@ -242,7 +344,7 @@ class VideoPlayerNotificationHandler( // Create MediaSession with unique session ID and activity (opens app when notification is tapped) val sessionId = "huddle_video_player_${++sessionCounter}" - mediaSession = MediaSession.Builder(context, player) + mediaSession = MediaSession.Builder(context, wrappedPlayer) .setId(sessionId) .setSessionActivity(pendingIntent) .setCallback(mediaSessionCallback) diff --git a/ios/Classes/Handlers/VideoPlayerNowPlayingHandler.swift b/ios/Classes/Handlers/VideoPlayerNowPlayingHandler.swift index 203dd67..844263a 100644 --- a/ios/Classes/Handlers/VideoPlayerNowPlayingHandler.swift +++ b/ios/Classes/Handlers/VideoPlayerNowPlayingHandler.swift @@ -52,6 +52,8 @@ class RemoteCommandManager { let commandCenter = MPRemoteCommandCenter.shared() commandCenter.playCommand.removeTarget(nil) commandCenter.pauseCommand.removeTarget(nil) + commandCenter.previousTrackCommand.removeTarget(nil) + commandCenter.nextTrackCommand.removeTarget(nil) commandCenter.skipForwardCommand.removeTarget(nil) commandCenter.skipBackwardCommand.removeTarget(nil) commandCenter.changePlaybackPositionCommand.removeTarget(nil) @@ -67,6 +69,8 @@ class RemoteCommandManager { let commandCenter = MPRemoteCommandCenter.shared() commandCenter.playCommand.removeTarget(nil) commandCenter.pauseCommand.removeTarget(nil) + commandCenter.previousTrackCommand.removeTarget(nil) + commandCenter.nextTrackCommand.removeTarget(nil) commandCenter.skipForwardCommand.removeTarget(nil) commandCenter.skipBackwardCommand.removeTarget(nil) commandCenter.changePlaybackPositionCommand.removeTarget(nil) @@ -205,6 +209,9 @@ extension VideoPlayerView { private func setupRemoteCommandCenter() { let commandCenter = MPRemoteCommandCenter.shared() let showSkipControls = (currentMediaInfo?["showSkipControls"] as? Bool) ?? true + let showSystemNextTrackControl = (currentMediaInfo?["showSystemNextTrackControl"] as? Bool) ?? false + let showSystemPreviousTrackControl = (currentMediaInfo?["showSystemPreviousTrackControl"] as? Bool) ?? false + let shouldShowTrackNavigation = showSystemNextTrackControl || showSystemPreviousTrackControl // Check if we've already registered handlers for this view // If so, skip the registration to avoid clearing and re-adding targets @@ -267,82 +274,105 @@ extension VideoPlayerView { return .success } - // --- Skip forward/backward --- - commandCenter.skipForwardCommand.isEnabled = showSkipControls - commandCenter.skipBackwardCommand.isEnabled = showSkipControls + // --- Track navigation and seek controls --- + // When track navigation is active, a disabled direction falls back to the corresponding seek button + commandCenter.previousTrackCommand.isEnabled = shouldShowTrackNavigation && showSystemPreviousTrackControl + commandCenter.nextTrackCommand.isEnabled = shouldShowTrackNavigation && showSystemNextTrackControl + commandCenter.skipBackwardCommand.isEnabled = shouldShowTrackNavigation ? (!showSystemPreviousTrackControl && showSkipControls) : showSkipControls + commandCenter.skipForwardCommand.isEnabled = shouldShowTrackNavigation ? (!showSystemNextTrackControl && showSkipControls) : showSkipControls commandCenter.changePlaybackPositionCommand.isEnabled = showSkipControls commandCenter.skipForwardCommand.preferredIntervals = [15] commandCenter.skipBackwardCommand.preferredIntervals = [15] - if showSkipControls { - commandCenter.skipForwardCommand.addTarget { [weak self] event in - guard let self = self, - let skipEvent = event as? MPSkipIntervalCommandEvent, - let player = self.player - else { - return .commandFailed - } + // Always register all handlers so they're available when controls are toggled via isEnabled + commandCenter.previousTrackCommand.addTarget { [weak self] _ in + guard let self = self else { return .commandFailed } - // Only handle if we still own the remote commands - guard RemoteCommandManager.shared.isOwner(self.viewId) else { - print("⚠️ View \(self.viewId) received skip forward command but is not owner") - return .commandFailed - } + guard RemoteCommandManager.shared.isOwner(self.viewId) else { + print("⚠️ View \(self.viewId) received previous track command but is not owner") + return .commandFailed + } + + self.sendEvent("previousTrack") + return .success + } - let currentTime = player.currentTime() - let newTime = CMTimeAdd(currentTime, CMTime(seconds: skipEvent.interval, preferredTimescale: 600)) - player.seek(to: newTime) - self.updateNowPlayingPlaybackTime() - return .success + commandCenter.nextTrackCommand.addTarget { [weak self] _ in + guard let self = self else { return .commandFailed } + + guard RemoteCommandManager.shared.isOwner(self.viewId) else { + print("⚠️ View \(self.viewId) received next track command but is not owner") + return .commandFailed } - commandCenter.skipBackwardCommand.addTarget { [weak self] event in - guard let self = self, - let skipEvent = event as? MPSkipIntervalCommandEvent, - let player = self.player - else { - return .commandFailed - } + self.sendEvent("nextTrack") + return .success + } - // Only handle if we still own the remote commands - guard RemoteCommandManager.shared.isOwner(self.viewId) else { - print("⚠️ View \(self.viewId) received skip backward command but is not owner") - return .commandFailed - } + commandCenter.skipForwardCommand.addTarget { [weak self] event in + guard let self = self, + let skipEvent = event as? MPSkipIntervalCommandEvent, + let player = self.player + else { + return .commandFailed + } - let currentTime = player.currentTime() - let newTime = CMTimeSubtract(currentTime, CMTime(seconds: skipEvent.interval, preferredTimescale: 600)) - player.seek(to: max(newTime, .zero)) - self.updateNowPlayingPlaybackTime() - return .success + guard RemoteCommandManager.shared.isOwner(self.viewId) else { + print("⚠️ View \(self.viewId) received skip forward command but is not owner") + return .commandFailed } - commandCenter.changePlaybackPositionCommand.addTarget { [weak self] event in - guard let self = self, - let seekEvent = event as? MPChangePlaybackPositionCommandEvent, - let player = self.player - else { - return .commandFailed - } + let currentTime = player.currentTime() + let newTime = CMTimeAdd(currentTime, CMTime(seconds: skipEvent.interval, preferredTimescale: 600)) + player.seek(to: newTime) + self.updateNowPlayingPlaybackTime() + return .success + } - // Only handle if we still own the remote commands - guard RemoteCommandManager.shared.isOwner(self.viewId) else { - print("⚠️ View \(self.viewId) received change position command but is not owner") - return .commandFailed - } + commandCenter.skipBackwardCommand.addTarget { [weak self] event in + guard let self = self, + let skipEvent = event as? MPSkipIntervalCommandEvent, + let player = self.player + else { + return .commandFailed + } - let durationSeconds = CMTimeGetSeconds(player.currentItem?.duration ?? .zero) - let boundedPosition = max(0, seekEvent.positionTime) + guard RemoteCommandManager.shared.isOwner(self.viewId) else { + print("⚠️ View \(self.viewId) received skip backward command but is not owner") + return .commandFailed + } - if durationSeconds.isFinite { - player.seek(to: CMTime(seconds: min(boundedPosition, durationSeconds), preferredTimescale: 600)) - } else { - player.seek(to: CMTime(seconds: boundedPosition, preferredTimescale: 600)) - } + let currentTime = player.currentTime() + let newTime = CMTimeSubtract(currentTime, CMTime(seconds: skipEvent.interval, preferredTimescale: 600)) + player.seek(to: max(newTime, .zero)) + self.updateNowPlayingPlaybackTime() + return .success + } - self.updateNowPlayingPlaybackTime() - return .success + commandCenter.changePlaybackPositionCommand.addTarget { [weak self] event in + guard let self = self, + let seekEvent = event as? MPChangePlaybackPositionCommandEvent, + let player = self.player + else { + return .commandFailed } + + guard RemoteCommandManager.shared.isOwner(self.viewId) else { + print("⚠️ View \(self.viewId) received change position command but is not owner") + return .commandFailed + } + + let durationSeconds = CMTimeGetSeconds(player.currentItem?.duration ?? .zero) + let boundedPosition = max(0, seekEvent.positionTime) + + if durationSeconds.isFinite { + player.seek(to: CMTime(seconds: min(boundedPosition, durationSeconds), preferredTimescale: 600)) + } else { + player.seek(to: CMTime(seconds: boundedPosition, preferredTimescale: 600)) + } + + self.updateNowPlayingPlaybackTime() + return .success } print("🎛️ View \(viewId) registered remote command handlers") @@ -350,6 +380,8 @@ extension VideoPlayerView { // Verify remote commands are enabled print(" → Play command enabled: \(commandCenter.playCommand.isEnabled)") print(" → Pause command enabled: \(commandCenter.pauseCommand.isEnabled)") + print(" → Previous track enabled: \(commandCenter.previousTrackCommand.isEnabled)") + print(" → Next track enabled: \(commandCenter.nextTrackCommand.isEnabled)") print(" → Skip forward enabled: \(commandCenter.skipForwardCommand.isEnabled)") print(" → Skip backward enabled: \(commandCenter.skipBackwardCommand.isEnabled)") } diff --git a/lib/src/enums/native_video_player_event.dart b/lib/src/enums/native_video_player_event.dart index 3074ef5..9511c9b 100644 --- a/lib/src/enums/native_video_player_event.dart +++ b/lib/src/enums/native_video_player_event.dart @@ -19,6 +19,8 @@ enum PlayerControlState { qualityChanged, speedChanged, seeked, + previousTrackRequested, + nextTrackRequested, pipStarted, pipStopped, pipAvailabilityChanged, @@ -104,6 +106,10 @@ class PlayerControlEvent { return PlayerControlState.speedChanged; case 'seek': return PlayerControlState.seeked; + case 'previousTrack': + return PlayerControlState.previousTrackRequested; + case 'nextTrack': + return PlayerControlState.nextTrackRequested; case 'pipStart': return PlayerControlState.pipStarted; case 'pipStop': diff --git a/lib/src/models/native_video_player_media_info.dart b/lib/src/models/native_video_player_media_info.dart index a7ba5ce..ba40601 100644 --- a/lib/src/models/native_video_player_media_info.dart +++ b/lib/src/models/native_video_player_media_info.dart @@ -5,6 +5,8 @@ class NativeVideoPlayerMediaInfo { this.album, this.artworkUrl, this.showSkipControls, + this.showSystemNextTrackControl, + this.showSystemPreviousTrackControl, }); final String? title; @@ -12,6 +14,8 @@ class NativeVideoPlayerMediaInfo { final String? album; final String? artworkUrl; final bool? showSkipControls; + final bool? showSystemNextTrackControl; + final bool? showSystemPreviousTrackControl; Map toMap() => { if (title != null) 'title': title, @@ -19,5 +23,7 @@ class NativeVideoPlayerMediaInfo { if (album != null) 'album': album, if (artworkUrl != null) 'artworkUrl': artworkUrl, if (showSkipControls != null) 'showSkipControls': showSkipControls, + if (showSystemNextTrackControl != null) 'showSystemNextTrackControl': showSystemNextTrackControl, + if (showSystemPreviousTrackControl != null) 'showSystemPreviousTrackControl': showSystemPreviousTrackControl, }; } From eb56283979f7a38068e6baefd076278e48878627 Mon Sep 17 00:00:00 2001 From: Daniel Williamson Date: Tue, 7 Apr 2026 18:40:15 +1000 Subject: [PATCH 10/28] FLTR-19796: Fix mixed video/audio playlist media center showing Not Playing after video track Co-Authored-By: Claude Sonnet 4.6 --- ios/Classes/View/VideoPlayerView.swift | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/ios/Classes/View/VideoPlayerView.swift b/ios/Classes/View/VideoPlayerView.swift index dfa156a..5118e2d 100644 --- a/ios/Classes/View/VideoPlayerView.swift +++ b/ios/Classes/View/VideoPlayerView.swift @@ -600,10 +600,15 @@ import QuartzCore RemoteCommandManager.shared.clearOwner(viewId) // Do NOT clear nowPlayingInfo or remove targets while PiP is active or restoring } else { - print("🗑️ No transfer possible and PiP is not active - clearing ownership and Now Playing info") + print("🗑️ No transfer possible and PiP is not active - clearing ownership only") RemoteCommandManager.shared.clearOwner(viewId) - RemoteCommandManager.shared.removeAllTargets() - MPNowPlayingInfoCenter.default().nowPlayingInfo = nil + // Do NOT remove targets or clear nowPlayingInfo here. + // In a mixed video/audio playlist, the audio player may have already re-registered + // its command handlers and set its nowPlayingInfo. Clearing them here creates a race + // condition where the audio player's setup gets wiped, resulting in "Not Playing". + // The stale video handlers are harmless: they check RemoteCommandManager.isOwner() + // (now cleared) and hold a [weak self] that becomes nil after deallocation — both + // guards cause them to return .commandFailed without side effects. } } } From 9fd31d5ede565a3c05f1abf8e1cd7e4aa809f399 Mon Sep 17 00:00:00 2001 From: Vishnu Date: Thu, 9 Apr 2026 16:43:31 +0530 Subject: [PATCH 11/28] Add feature to disable video and background play --- .../handlers/VideoPlayerMethodHandler.kt | 33 ++ .../VideoPlayerNotificationHandler.kt | 14 + background_audio_fork_implementation.md | 437 ++++++++++++++++++ .../Handlers/VideoPlayerMethodHandler.swift | 62 +++ ios/Classes/View/VideoPlayerView.swift | 2 + .../native_video_player_controller.dart | 12 + .../platform/video_player_method_channel.dart | 21 + 7 files changed, 581 insertions(+) create mode 100644 background_audio_fork_implementation.md diff --git a/android/src/main/kotlin/com/huddlecommunity/better_native_video_player/handlers/VideoPlayerMethodHandler.kt b/android/src/main/kotlin/com/huddlecommunity/better_native_video_player/handlers/VideoPlayerMethodHandler.kt index b0080a0..14b66b5 100644 --- a/android/src/main/kotlin/com/huddlecommunity/better_native_video_player/handlers/VideoPlayerMethodHandler.kt +++ b/android/src/main/kotlin/com/huddlecommunity/better_native_video_player/handlers/VideoPlayerMethodHandler.kt @@ -130,6 +130,7 @@ class VideoPlayerMethodHandler( "getAvailableQualities" -> handleGetAvailableQualities(result) "getAvailableSubtitleTracks" -> handleGetAvailableSubtitleTracks(result) "setSubtitleTrack" -> handleSetSubtitleTrack(call, result) + "setVideoTrackDisabled" -> handleSetVideoTrackDisabled(call, result) "getVideoDimensions" -> handleGetVideoDimensions(result) "enterFullScreen" -> handleEnterFullScreen(result) "exitFullScreen" -> handleExitFullScreen(result) @@ -870,4 +871,36 @@ class VideoPlayerMethodHandler( result.error("ERROR", "Failed to set subtitle track: ${e.message}", null) } } + + /** + * Disables or enables the video track. + * + * When disabled on a demuxed HLS stream, ExoPlayer stops selecting video renditions + * and only fetches audio segments — saving bandwidth during background playback. + * + * When re-enabled, ExoPlayer resumes video segment downloads from the current position. + * + * Uses the same trackSelectionParameters API as subtitle disabling. + */ + private fun handleSetVideoTrackDisabled(call: MethodCall, result: MethodChannel.Result) { + try { + val args = call.arguments as? Map<*, *> + val disabled = args?.get("disabled") as? Boolean ?: false + + Log.d(TAG, "Setting video track disabled: $disabled") + + val newParameters = player.trackSelectionParameters + .buildUpon() + .setTrackTypeDisabled(C.TRACK_TYPE_VIDEO, disabled) + .build() + + player.trackSelectionParameters = newParameters + + Log.d(TAG, "Video track ${if (disabled) "disabled" else "enabled"}") + result.success(null) + } catch (e: Exception) { + Log.e(TAG, "Error setting video track disabled: ${e.message}", e) + result.error("ERROR", "Failed to set video track disabled: ${e.message}", null) + } + } } diff --git a/android/src/main/kotlin/com/huddlecommunity/better_native_video_player/handlers/VideoPlayerNotificationHandler.kt b/android/src/main/kotlin/com/huddlecommunity/better_native_video_player/handlers/VideoPlayerNotificationHandler.kt index e257f65..c2e2145 100644 --- a/android/src/main/kotlin/com/huddlecommunity/better_native_video_player/handlers/VideoPlayerNotificationHandler.kt +++ b/android/src/main/kotlin/com/huddlecommunity/better_native_video_player/handlers/VideoPlayerNotificationHandler.kt @@ -26,6 +26,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import com.huddlecommunity.better_native_video_player.VideoPlayerMediaSessionService import java.net.URL /** @@ -367,6 +368,19 @@ class VideoPlayerNotificationHandler( updateMediaMetadata(info) } + // Store the session for the service to access and start as foreground service. + // When MediaSessionService receives onStartCommand() and onGetSession() returns + // a non-null MediaSession with active media, Media3 internally calls startForeground() + // with the notification it constructs. + VideoPlayerMediaSessionService.setMediaSession(mediaSession) + val serviceIntent = Intent(context, VideoPlayerMediaSessionService::class.java) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + context.startForegroundService(serviceIntent) + } else { + context.startService(serviceIntent) + } + Log.d(TAG, "Started VideoPlayerMediaSessionService as foreground service") + // Start periodic position updates startPositionUpdates() } diff --git a/background_audio_fork_implementation.md b/background_audio_fork_implementation.md new file mode 100644 index 0000000..2bfa9d5 --- /dev/null +++ b/background_audio_fork_implementation.md @@ -0,0 +1,437 @@ +# Background Audio-Only HLS Streaming — Fork Implementation Guide + +> **Repo:** `https://github.com/Insight-Timer/flutter-native-video-player.git` +> **Branch:** Create `feature/background-audio-only` from latest main +> **Scope:** Native Android (Kotlin), Native iOS (Swift), Flutter package (Dart) + +--- + +## 1. Goal + +Add a new `setVideoTrackDisabled(bool)` API to the `better_native_video_player` package. When called with `true`, the native player stops downloading video segments from HLS demuxed streams while audio continues uninterrupted. When called with `false`, video resumes. + +Additionally, ensure the Android `VideoPlayerMediaSessionService` is properly started as a foreground service so the process survives background execution limits. + +--- + +## 2. Existing Pattern to Follow + +The **subtitle track disabling** already demonstrates the exact pattern for all three layers. Follow it as a template: + +### Android — `VideoPlayerMethodHandler.kt` + +```kotlin +// Existing subtitle disabling (lines 788-872) +val parametersBuilder = player.trackSelectionParameters.buildUpon() +parametersBuilder.setTrackTypeDisabled(C.TRACK_TYPE_TEXT, true) +player.trackSelectionParameters = parametersBuilder.build() +``` + +### Flutter — `video_player_method_channel.dart` + +```dart +// Existing subtitle track method +Future setSubtitleTrack(NativeVideoPlayerSubtitleTrack track) async { + await _methodChannel.invokeMethod('setSubtitleTrack', { + 'viewId': primaryPlatformViewId, + 'track': track.toMap(), + }); +} +``` + +### Flutter — `native_video_player_controller.dart` + +```dart +// Existing subtitle method +Future setSubtitleTrack(NativeVideoPlayerSubtitleTrack track) async { + await _methodChannel?.setSubtitleTrack(track); +} +``` + +--- + +## 3. Android — Add `handleSetVideoTrackDisabled` + +### 3.1 File: `android/src/main/kotlin/com/huddlecommunity/better_native_video_player/handlers/VideoPlayerMethodHandler.kt` + +**Step 1:** Add method case in the `handleMethodCall` switch statement. Find the `"setSubtitleTrack"` case and add after it: + +```kotlin +"setVideoTrackDisabled" -> handleSetVideoTrackDisabled(call, result) +``` + +**Step 2:** Add the handler method. Place it after the subtitle methods section (after line ~872): + +```kotlin +/** + * Disables or enables the video track. + * + * When disabled on a demuxed HLS stream, ExoPlayer stops selecting video renditions + * and only fetches audio segments — saving bandwidth during background playback. + * + * When re-enabled, ExoPlayer resumes video segment downloads from the current position. + * + * On muxed content (progressive MP4), disabling stops video decoding but the full + * muxed file is still downloaded — no bandwidth savings. + * + * Uses the same trackSelectionParameters API as subtitle disabling. + */ +private fun handleSetVideoTrackDisabled(call: MethodCall, result: MethodChannel.Result) { + try { + val args = call.arguments as? Map<*, *> + val disabled = args?.get("disabled") as? Boolean ?: false + + Log.d(TAG, "Setting video track disabled: $disabled") + + val newParameters = player.trackSelectionParameters + .buildUpon() + .setTrackTypeDisabled(C.TRACK_TYPE_VIDEO, disabled) + .build() + + player.trackSelectionParameters = newParameters + + Log.d(TAG, "Video track ${if (disabled) "disabled" else "enabled"}") + result.success(null) + } catch (e: Exception) { + Log.e(TAG, "Error setting video track disabled: ${e.message}", e) + result.error("ERROR", "Failed to set video track disabled: ${e.message}", null) + } +} +``` + +### 3.2 Why No Other Android Files Need Changes + +| File | Why no change needed | +|------|---------------------| +| `SharedPlayerManager.kt` | ExoPlayer is created without a custom `DefaultTrackSelector`. The `trackSelectionParameters` API works on the implicit default track selector — same as subtitle disabling. | +| `VideoPlayerView.kt` | `handleMethodCall` at line 382 already delegates unrecognized methods to `methodHandler.handleMethodCall(call, result)`. The new method is automatically routed. | +| `VideoPlayerObserver.kt` | No new events to emit. Track disabling is fire-and-forget. | +| `VideoPlayerEventHandler.kt` | No new events needed. | +| `AndroidManifest.xml` | Already declares `foregroundServiceType="mediaPlayback"`, `FOREGROUND_SERVICE`, and `FOREGROUND_SERVICE_MEDIA_PLAYBACK`. | + +--- + +## 4. Android — Start Foreground Service + +### 4.1 Problem + +The `VideoPlayerMediaSessionService` is declared in AndroidManifest with all required attributes: + +```xml + + + + + + +``` + +And permissions are declared: + +```xml + + + +``` + +**But the service is never started via `startForegroundService()`.** Without this, Android's background execution limits (API 26+) will kill the process within ~1 minute of backgrounding. Audio stops. + +**Reference:** The audio player's `InsightMediaSessionService` (in the main app's `packages/insight_timer_player`) explicitly calls `startForeground(NOTIFICATION_ID, notification)` at line 519 — that's what keeps audio content alive in the background. + +### 4.2 File: `android/src/main/kotlin/com/huddlecommunity/better_native_video_player/handlers/VideoPlayerNotificationHandler.kt` + +**In the `setupMediaSession()` method**, after the MediaSession is created and stored (around line 352 where `VideoPlayerMediaSessionService.setMediaSession(mediaSession)` is called), add: + +```kotlin +// Store the session for the service to access +VideoPlayerMediaSessionService.setMediaSession(mediaSession) + +// Start the MediaSessionService as a foreground service. +// When MediaSessionService receives onStartCommand() and onGetSession() returns +// a non-null MediaSession with active media, Media3 internally calls startForeground() +// with the notification it constructs. We just need to start the service. +val serviceIntent = Intent(context, VideoPlayerMediaSessionService::class.java) +if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + context.startForegroundService(serviceIntent) +} else { + context.startService(serviceIntent) +} +Log.d(TAG, "Started VideoPlayerMediaSessionService as foreground service") +``` + +**Add imports if not already present:** + +```kotlin +import android.content.Intent +import android.os.Build +``` + +### 4.3 How It Works End-to-End + +``` +1. setupMediaSession() creates MediaSession with player + metadata +2. VideoPlayerMediaSessionService.setMediaSession(session) stores it statically +3. context.startForegroundService(intent) starts the service +4. Android calls VideoPlayerMediaSessionService.onStartCommand() +5. Media3 calls onGetSession() → returns the stored MediaSession +6. Media3 internally calls startForeground() with a notification +7. The notification shows title, artwork, and media controls +8. Process stays alive in background ✓ +``` + +### 4.4 Notification Controls — Already Working + +The existing `VideoPlayerNotificationHandler` already provides: + +| Control | Implementation | +|---------|---------------| +| Play/Pause | Default MediaSession play/pause command | +| Skip Forward (15s) | `NotificationPlayerCustomCommandButton.FORWARD` | +| Skip Backward (15s) | `NotificationPlayerCustomCommandButton.REWIND` | +| Previous Track | `NotificationPlayerCustomCommandButton.PREVIOUS` | +| Next Track | `NotificationPlayerCustomCommandButton.NEXT` | +| Artwork | Async loaded from URL via `loadArtwork()` | +| Title/Subtitle | From `mediaInfo` map passed to `setupMediaSession()` | + +**No additional notification changes needed.** All controls continue working during background audio because they communicate with the ExoPlayer instance which stays alive via the foreground service. + +--- + +## 5. iOS — Add `handleSetVideoTrackDisabled` + +### 5.1 File: `ios/Classes/Handlers/VideoPlayerMethodHandler.swift` + +**Step 1:** Add method case in the `handleMethodCall` switch (before `default:`): + +```swift +case "setVideoTrackDisabled": + handleSetVideoTrackDisabled(call: call, result: result) +``` + +**Step 2:** Add the handler method: + +```swift +// MARK: - Video Track Disabling (Background Audio-Only) + +/// Disables or enables the video track for HLS background audio-only streaming. +/// +/// Uses a two-strategy approach: +/// +/// **Strategy 1 — AVMediaSelectionGroup** (primary, for demuxed HLS): +/// Deselects the visual media selection group entirely. With demuxed HLS +/// (`EXT-X-MEDIA:TYPE=AUDIO`), AVPlayer stops downloading video segments +/// and only fetches audio segments. +/// +/// **Strategy 2 — preferredPeakBitRate** (fallback): +/// Sets the peak bitrate to 1 bps, which effectively excludes all video variants +/// (typically 500kbps+) and only allows audio-quality streams through. +/// Handles cases where the media selection group is not yet available. +/// +/// When re-enabling: +/// - Restores the default video rendition via AVMediaSelectionGroup +/// - Clears the bitrate restriction (0 = no limit) +func handleSetVideoTrackDisabled(call: FlutterMethodCall, result: @escaping FlutterResult) { + guard let args = call.arguments as? [String: Any], + let disabled = args["disabled"] as? Bool else { + result(FlutterError( + code: "INVALID_ARGS", + message: "Missing 'disabled' parameter", + details: nil + )) + return + } + + guard let player = player, let playerItem = player.currentItem else { + print("[VideoPlayer] No current player item for video track disable") + result(nil) + return + } + + if disabled { + // Strategy 1: Deselect the visual media selection group (demuxed HLS) + if let asset = playerItem.asset as? AVURLAsset, + let videoGroup = asset.mediaSelectionGroup( + forMediaCharacteristic: .visual + ) { + playerItem.select(nil, in: videoGroup) + print("[VideoPlayer] Video track disabled via AVMediaSelectionGroup") + } + + // Strategy 2: Restrict bitrate to audio-only threshold (fallback) + playerItem.preferredPeakBitRate = 1.0 + print("[VideoPlayer] preferredPeakBitRate set to 1.0 (audio-only)") + } else { + // Re-enable: restore video rendition selection + if let asset = playerItem.asset as? AVURLAsset, + let videoGroup = asset.mediaSelectionGroup( + forMediaCharacteristic: .visual + ) { + if let defaultOption = videoGroup.defaultOption { + playerItem.select(defaultOption, in: videoGroup) + } else if let firstOption = videoGroup.options.first { + playerItem.select(firstOption, in: videoGroup) + } + print("[VideoPlayer] Video track re-enabled via AVMediaSelectionGroup") + } + + // Clear bitrate restriction (0 = no limit) + playerItem.preferredPeakBitRate = 0 + print("[VideoPlayer] preferredPeakBitRate cleared (no limit)") + } + + result(nil) +} +``` + +### 5.2 Where to Add in VideoPlayerView.swift + +If the method call routing goes through `VideoPlayerView.swift` instead of `VideoPlayerMethodHandler.swift`, add the case in the appropriate `handleMethodCall` switch in `VideoPlayerView.swift`: + +```swift +case "setVideoTrackDisabled": + handleSetVideoTrackDisabled(call: call, result: result) +``` + +Check how other methods like `"setSubtitleTrack"` are routed and follow the same pattern. + +### 5.3 Why No Other iOS Files Need Changes + +| File | Why no change needed | +|------|---------------------| +| `SharedPlayerManager.swift` | Already sets `audiovisualBackgroundPlaybackPolicy = .continuesIfPossible` (iOS 15+). Background audio works. | +| `VideoPlayerNowPlayingHandler.swift` | Already manages `MPNowPlayingInfoCenter` + `MPRemoteCommandCenter`. Lock screen controls work. | +| `VideoPlayerObserver.swift` | No new events to observe. | +| `Info.plist` (main app) | Already declares `UIBackgroundModes: ["audio"]`. | + +### 5.4 iOS Background Audio Flow (Already Working) + +``` +1. App goes to background +2. iOS checks UIBackgroundModes → "audio" is declared ✓ +3. AVAudioSession category is .playback ✓ +4. audiovisualBackgroundPlaybackPolicy = .continuesIfPossible ✓ +5. AVPlayer continues playing audio +6. MPNowPlayingInfoCenter shows on lock screen ✓ +7. MPRemoteCommandCenter handles play/pause/skip ✓ +8. setVideoTrackDisabled(true) → AVPlayer stops fetching video segments +9. Only audio segments downloaded → bandwidth saved ✓ +``` + +--- + +## 6. Flutter Package — MethodChannel Bridge + +### 6.1 File: `lib/src/platform/video_player_method_channel.dart` + +Add after the `setSubtitleTrack` method: + +```dart +/// Disables or enables the video track in the native player. +/// +/// When [disabled] is true, the native player stops downloading video segments +/// from HLS demuxed streams, saving bandwidth during background playback. +/// Audio continues uninterrupted. +/// +/// When [disabled] is false, video segment downloads resume from the current position. +Future setVideoTrackDisabled(bool disabled) async { + try { + await _methodChannel.invokeMethod( + 'setVideoTrackDisabled', + { + 'viewId': primaryPlatformViewId, + 'disabled': disabled, + }, + ); + } catch (e) { + debugPrint('Error calling setVideoTrackDisabled: $e'); + } +} +``` + +### 6.2 File: `lib/src/controllers/native_video_player_controller.dart` + +Add after the `setSubtitleTrack` method: + +```dart +/// Disables or enables the video track in the native player. +/// +/// When [disabled] is true, only audio segments are downloaded from HLS +/// demuxed streams. This is designed for background audio-only playback +/// to save bandwidth. +/// +/// Call with `true` when the app goes to background, +/// `false` when returning to foreground. +Future setVideoTrackDisabled(bool disabled) async { + await _methodChannel?.setVideoTrackDisabled(disabled); +} +``` + +--- + +## 7. Files Changed Summary + +| File | Change | ~LOC | +|------|--------|------| +| `android/.../handlers/VideoPlayerMethodHandler.kt` | Add `handleSetVideoTrackDisabled` + switch case | 25 | +| `android/.../handlers/VideoPlayerNotificationHandler.kt` | Start foreground service in `setupMediaSession()` | 10 | +| `ios/Classes/Handlers/VideoPlayerMethodHandler.swift` | Add `handleSetVideoTrackDisabled` + switch case | 50 | +| `lib/src/platform/video_player_method_channel.dart` | Add `setVideoTrackDisabled` MethodChannel call | 12 | +| `lib/src/controllers/native_video_player_controller.dart` | Add `setVideoTrackDisabled` public method | 8 | + +**Total: ~105 lines across 5 files** + +--- + +## 8. Testing the Fork Changes + +### 8.1 Local Testing with the Main App + +Use `pubspec_overrides.yaml` in the main app to point to your local fork clone: + +```yaml +# apps/insight_timer/pubspec_overrides.yaml (gitignored, don't commit) +dependency_overrides: + better_native_video_player: + path: /path/to/your/local/flutter-native-video-player +``` + +Then run `flutter pub get` and test changes immediately without pushing. + +### 8.2 Verification Steps + +**Android:** +1. Play an HLS video with demuxed audio tracks +2. Call `setVideoTrackDisabled(true)` via the Flutter controller +3. Verify in logcat: `"Video track disabled"` log appears +4. Use Android Studio Network Profiler or Charles Proxy: only audio segment requests visible +5. Call `setVideoTrackDisabled(false)` — verify video segments resume +6. Background the app — verify foreground notification appears and audio continues +7. Verify app is NOT killed after 1+ minutes in background + +**iOS:** +1. Play an HLS video with demuxed audio tracks +2. Call `setVideoTrackDisabled(true)` via the Flutter controller +3. Verify in Xcode console: `"Video track disabled via AVMediaSelectionGroup"` log appears +4. Use Charles Proxy: only audio segment requests visible +5. Call `setVideoTrackDisabled(false)` — verify video segments resume +6. Background the app — verify Now Playing info on lock screen, audio continues +7. Verify Control Center shows correct artwork and controls + +--- + +## 9. PR Checklist for the Fork + +- [ ] Feature branch created from latest main +- [ ] Android: `handleSetVideoTrackDisabled` added to `VideoPlayerMethodHandler.kt` +- [ ] Android: Foreground service started in `VideoPlayerNotificationHandler.setupMediaSession()` +- [ ] iOS: `handleSetVideoTrackDisabled` added to `VideoPlayerMethodHandler.swift` +- [ ] Dart: `setVideoTrackDisabled` added to `video_player_method_channel.dart` +- [ ] Dart: `setVideoTrackDisabled` added to `native_video_player_controller.dart` +- [ ] Tested on Android physical device with HLS demuxed stream +- [ ] Tested on iOS physical device with HLS demuxed stream +- [ ] Verified foreground notification works on Android background +- [ ] Verified Now Playing / lock screen controls work on iOS background +- [ ] No regressions in existing video playback, PiP, AirPlay, subtitles diff --git a/ios/Classes/Handlers/VideoPlayerMethodHandler.swift b/ios/Classes/Handlers/VideoPlayerMethodHandler.swift index 92e66fc..247ab30 100644 --- a/ios/Classes/Handlers/VideoPlayerMethodHandler.swift +++ b/ios/Classes/Handlers/VideoPlayerMethodHandler.swift @@ -1285,4 +1285,66 @@ extension VideoPlayerView { result(nil) } + + // MARK: - Video Track Disabling (Background Audio-Only) + + /// Disables or enables the video track for HLS background audio-only streaming. + /// + /// Uses a two-strategy approach: + /// - Strategy 1 (AVMediaSelectionGroup): Deselects the visual media selection group. + /// With demuxed HLS, AVPlayer stops downloading video segments. + /// - Strategy 2 (preferredPeakBitRate): Fallback that restricts bitrate to exclude video variants. + /// + /// When re-enabling, restores default video rendition and clears bitrate restriction. + func handleSetVideoTrackDisabled(call: FlutterMethodCall, result: @escaping FlutterResult) { + guard let args = call.arguments as? [String: Any], + let disabled = args["disabled"] as? Bool else { + result(FlutterError( + code: "INVALID_ARGS", + message: "Missing 'disabled' parameter", + details: nil + )) + return + } + + guard let player = player, let playerItem = player.currentItem else { + print("[VideoPlayer] No current player item for video track disable") + result(nil) + return + } + + if disabled { + // Strategy 1: Deselect the visual media selection group (demuxed HLS) + if let asset = playerItem.asset as? AVURLAsset, + let videoGroup = asset.mediaSelectionGroup( + forMediaCharacteristic: .visual + ) { + playerItem.select(nil, in: videoGroup) + print("[VideoPlayer] Video track disabled via AVMediaSelectionGroup") + } + + // Strategy 2: Restrict bitrate to audio-only threshold (fallback) + playerItem.preferredPeakBitRate = 1.0 + print("[VideoPlayer] preferredPeakBitRate set to 1.0 (audio-only)") + } else { + // Re-enable: restore video rendition selection + if let asset = playerItem.asset as? AVURLAsset, + let videoGroup = asset.mediaSelectionGroup( + forMediaCharacteristic: .visual + ) { + if let defaultOption = videoGroup.defaultOption { + playerItem.select(defaultOption, in: videoGroup) + } else if let firstOption = videoGroup.options.first { + playerItem.select(firstOption, in: videoGroup) + } + print("[VideoPlayer] Video track re-enabled via AVMediaSelectionGroup") + } + + // Clear bitrate restriction (0 = no limit) + playerItem.preferredPeakBitRate = 0 + print("[VideoPlayer] preferredPeakBitRate cleared (no limit)") + } + + result(nil) + } } diff --git a/ios/Classes/View/VideoPlayerView.swift b/ios/Classes/View/VideoPlayerView.swift index 5118e2d..7e3d26e 100644 --- a/ios/Classes/View/VideoPlayerView.swift +++ b/ios/Classes/View/VideoPlayerView.swift @@ -409,6 +409,8 @@ import QuartzCore handleGetAvailableSubtitleTracks(result: result) case "setSubtitleTrack": handleSetSubtitleTrack(call: call, result: result) + case "setVideoTrackDisabled": + handleSetVideoTrackDisabled(call: call, result: result) case "enterFullScreen": handleEnterFullScreen(result: result) case "exitFullScreen": diff --git a/lib/src/controllers/native_video_player_controller.dart b/lib/src/controllers/native_video_player_controller.dart index 892ccae..9a40510 100644 --- a/lib/src/controllers/native_video_player_controller.dart +++ b/lib/src/controllers/native_video_player_controller.dart @@ -1878,6 +1878,18 @@ class NativeVideoPlayerController { await _methodChannel?.setSubtitleTrack(track); } + /// Disables or enables the video track in the native player. + /// + /// When [disabled] is true, only audio segments are downloaded from HLS + /// demuxed streams. This is designed for background audio-only playback + /// to save bandwidth. + /// + /// Call with `true` when the app goes to background, + /// `false` when returning to foreground. + Future setVideoTrackDisabled(bool disabled) async { + await _methodChannel?.setVideoTrackDisabled(disabled); + } + /// Returns whether Picture-in-Picture is available on this device /// Checks the actual device capabilities rather than just the platform /// PiP is available on iOS 14+ and Android 8+ (if the device supports it) diff --git a/lib/src/platform/video_player_method_channel.dart b/lib/src/platform/video_player_method_channel.dart index 37261e2..f9dce46 100644 --- a/lib/src/platform/video_player_method_channel.dart +++ b/lib/src/platform/video_player_method_channel.dart @@ -188,6 +188,27 @@ class VideoPlayerMethodChannel { } } + /// Disables or enables the video track in the native player. + /// + /// When [disabled] is true, the native player stops downloading video segments + /// from HLS demuxed streams, saving bandwidth during background playback. + /// Audio continues uninterrupted. + /// + /// When [disabled] is false, video segment downloads resume from the current position. + Future setVideoTrackDisabled(bool disabled) async { + try { + await _methodChannel.invokeMethod( + 'setVideoTrackDisabled', + { + 'viewId': primaryPlatformViewId, + 'disabled': disabled, + }, + ); + } catch (e) { + debugPrint('Error calling setVideoTrackDisabled: $e'); + } + } + /// Checks if Picture-in-Picture is available Future isPictureInPictureAvailable() async { try { From a5db2e368670d480b05414aeb7d31367fa997762 Mon Sep 17 00:00:00 2001 From: Vishnu Date: Thu, 9 Apr 2026 17:35:08 +0530 Subject: [PATCH 12/28] Fix android crash --- .../VideoPlayerMediaSessionService.kt | 45 ++++++++++++++++++- 1 file changed, 44 insertions(+), 1 deletion(-) diff --git a/android/src/main/kotlin/com/huddlecommunity/better_native_video_player/VideoPlayerMediaSessionService.kt b/android/src/main/kotlin/com/huddlecommunity/better_native_video_player/VideoPlayerMediaSessionService.kt index afa05b4..e645b1c 100644 --- a/android/src/main/kotlin/com/huddlecommunity/better_native_video_player/VideoPlayerMediaSessionService.kt +++ b/android/src/main/kotlin/com/huddlecommunity/better_native_video_player/VideoPlayerMediaSessionService.kt @@ -1,9 +1,13 @@ package com.huddlecommunity.better_native_video_player +import android.app.NotificationChannel +import android.app.NotificationManager import android.app.PendingIntent import android.content.Intent +import android.os.Build import android.os.Bundle import android.util.Log +import androidx.core.app.NotificationCompat import androidx.media3.common.Player import androidx.media3.session.CommandButton import androidx.media3.session.MediaSession @@ -27,6 +31,8 @@ class VideoPlayerMediaSessionService : MediaSessionService() { companion object { private const val TAG = "VideoPlayerMSS" + private const val NOTIFICATION_ID = 1001 + private const val CHANNEL_ID = "video_player_channel" // The MediaSession is stored here so it can be accessed by the service private var mediaSession: MediaSession? = null @@ -54,7 +60,12 @@ class VideoPlayerMediaSessionService : MediaSessionService() { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { Log.d(TAG, "onStartCommand called, mediaSession=${mediaSession != null}, player=${mediaSession?.player != null}") - // Important: Call super to trigger the Media3 notification framework + // Immediately promote to foreground with a placeholder notification to satisfy + // Android's 5-second startForeground() deadline. Media3's super.onStartCommand() + // will replace this with the real media notification once it processes the session. + startForegroundWithPlaceholder() + + // Now let Media3 do its work — it will replace the placeholder notification val result = super.onStartCommand(intent, flags, startId) // Log player state for debugging @@ -65,6 +76,38 @@ class VideoPlayerMediaSessionService : MediaSessionService() { return result } + /** + * Posts a minimal foreground notification so the service satisfies Android's + * startForeground() contract before Media3 builds the real media notification. + */ + private fun startForegroundWithPlaceholder() { + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = NotificationChannel( + CHANNEL_ID, + "Video Playback", + NotificationManager.IMPORTANCE_LOW + ).apply { + description = "Controls for video playback" + } + val manager = getSystemService(NotificationManager::class.java) + manager.createNotificationChannel(channel) + } + + val notification = NotificationCompat.Builder(this, CHANNEL_ID) + .setContentTitle("Video Player") + .setSmallIcon(android.R.drawable.ic_media_play) + .setPriority(NotificationCompat.PRIORITY_LOW) + .setSilent(true) + .build() + + startForeground(NOTIFICATION_ID, notification) + Log.d(TAG, "Placeholder foreground notification posted") + } catch (e: Exception) { + Log.e(TAG, "Failed to start foreground: ${e.message}", e) + } + } + override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? { Log.d(TAG, "onGetSession called for ${controllerInfo.packageName}, returning session=${mediaSession != null}") From 6e7a40bd2824262b7b2263da358c85333c3e520f Mon Sep 17 00:00:00 2001 From: Vishnu Date: Thu, 9 Apr 2026 17:42:57 +0530 Subject: [PATCH 13/28] Fix notification for Android --- .../VideoPlayerMediaSessionService.kt | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/android/src/main/kotlin/com/huddlecommunity/better_native_video_player/VideoPlayerMediaSessionService.kt b/android/src/main/kotlin/com/huddlecommunity/better_native_video_player/VideoPlayerMediaSessionService.kt index e645b1c..c18659a 100644 --- a/android/src/main/kotlin/com/huddlecommunity/better_native_video_player/VideoPlayerMediaSessionService.kt +++ b/android/src/main/kotlin/com/huddlecommunity/better_native_video_player/VideoPlayerMediaSessionService.kt @@ -10,6 +10,7 @@ import android.util.Log import androidx.core.app.NotificationCompat import androidx.media3.common.Player import androidx.media3.session.CommandButton +import androidx.media3.session.DefaultMediaNotificationProvider import androidx.media3.session.MediaSession import androidx.media3.session.MediaSessionService import androidx.media3.session.SessionCommand @@ -54,6 +55,17 @@ class VideoPlayerMediaSessionService : MediaSessionService() { override fun onCreate() { super.onCreate() + + // Configure Media3's notification provider so it knows which channel and + // notification ID to use when building the real media notification. + // Without this, Media3 never replaces the placeholder posted in onStartCommand(). + setMediaNotificationProvider( + DefaultMediaNotificationProvider.Builder(this) + .setChannelId(CHANNEL_ID) + .setNotificationId(NOTIFICATION_ID) + .build() + ) + Log.d(TAG, "VideoPlayerMediaSessionService onCreate, mediaSession=${mediaSession != null}") } From ce9148911f1dda2cb3a75e3b3410ae23b9464502 Mon Sep 17 00:00:00 2001 From: Vishnu Date: Thu, 9 Apr 2026 17:50:09 +0530 Subject: [PATCH 14/28] One more fix for Android --- .../VideoPlayerMediaSessionService.kt | 37 ++++++++++++++----- 1 file changed, 27 insertions(+), 10 deletions(-) diff --git a/android/src/main/kotlin/com/huddlecommunity/better_native_video_player/VideoPlayerMediaSessionService.kt b/android/src/main/kotlin/com/huddlecommunity/better_native_video_player/VideoPlayerMediaSessionService.kt index c18659a..a7e205a 100644 --- a/android/src/main/kotlin/com/huddlecommunity/better_native_video_player/VideoPlayerMediaSessionService.kt +++ b/android/src/main/kotlin/com/huddlecommunity/better_native_video_player/VideoPlayerMediaSessionService.kt @@ -59,12 +59,19 @@ class VideoPlayerMediaSessionService : MediaSessionService() { // Configure Media3's notification provider so it knows which channel and // notification ID to use when building the real media notification. // Without this, Media3 never replaces the placeholder posted in onStartCommand(). - setMediaNotificationProvider( - DefaultMediaNotificationProvider.Builder(this) - .setChannelId(CHANNEL_ID) - .setNotificationId(NOTIFICATION_ID) - .build() - ) + val notificationProvider = DefaultMediaNotificationProvider.Builder(this) + .setChannelId(CHANNEL_ID) + .setNotificationId(NOTIFICATION_ID) + .build() + notificationProvider.setSmallIcon(applicationInfo.icon) + setMediaNotificationProvider(notificationProvider) + + // Handle Android 12+ background start restrictions gracefully + setListener(object : Listener { + override fun onForegroundServiceStartNotAllowedException() { + Log.w(TAG, "Foreground service start not allowed (Android 12+ background restriction)") + } + }) Log.d(TAG, "VideoPlayerMediaSessionService onCreate, mediaSession=${mediaSession != null}") } @@ -101,15 +108,25 @@ class VideoPlayerMediaSessionService : MediaSessionService() { NotificationManager.IMPORTANCE_LOW ).apply { description = "Controls for video playback" + setSound(null, null) } - val manager = getSystemService(NotificationManager::class.java) - manager.createNotificationChannel(channel) + (getSystemService(NOTIFICATION_SERVICE) as NotificationManager) + .createNotificationChannel(channel) } + // Build a launch intent so tapping the placeholder opens the app + val launchIntent = packageManager.getLaunchIntentForPackage(packageName) + ?: Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LAUNCHER).setPackage(packageName) + val pendingIntent = PendingIntent.getActivity( + this, 0, launchIntent, PendingIntent.FLAG_IMMUTABLE + ) + val notification = NotificationCompat.Builder(this, CHANNEL_ID) - .setContentTitle("Video Player") - .setSmallIcon(android.R.drawable.ic_media_play) + .setContentTitle("Playing") + .setSmallIcon(applicationInfo.icon) + .setContentIntent(pendingIntent) .setPriority(NotificationCompat.PRIORITY_LOW) + .setOngoing(true) .setSilent(true) .build() From c4bab0b7ea6b9c6d600b6fc2bf638c7535229b4d Mon Sep 17 00:00:00 2001 From: Vishnu Date: Thu, 9 Apr 2026 17:59:27 +0530 Subject: [PATCH 15/28] One more fix --- .../VideoPlayerMediaSessionService.kt | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/android/src/main/kotlin/com/huddlecommunity/better_native_video_player/VideoPlayerMediaSessionService.kt b/android/src/main/kotlin/com/huddlecommunity/better_native_video_player/VideoPlayerMediaSessionService.kt index a7e205a..6500adc 100644 --- a/android/src/main/kotlin/com/huddlecommunity/better_native_video_player/VideoPlayerMediaSessionService.kt +++ b/android/src/main/kotlin/com/huddlecommunity/better_native_video_player/VideoPlayerMediaSessionService.kt @@ -10,7 +10,6 @@ import android.util.Log import androidx.core.app.NotificationCompat import androidx.media3.common.Player import androidx.media3.session.CommandButton -import androidx.media3.session.DefaultMediaNotificationProvider import androidx.media3.session.MediaSession import androidx.media3.session.MediaSessionService import androidx.media3.session.SessionCommand @@ -55,24 +54,6 @@ class VideoPlayerMediaSessionService : MediaSessionService() { override fun onCreate() { super.onCreate() - - // Configure Media3's notification provider so it knows which channel and - // notification ID to use when building the real media notification. - // Without this, Media3 never replaces the placeholder posted in onStartCommand(). - val notificationProvider = DefaultMediaNotificationProvider.Builder(this) - .setChannelId(CHANNEL_ID) - .setNotificationId(NOTIFICATION_ID) - .build() - notificationProvider.setSmallIcon(applicationInfo.icon) - setMediaNotificationProvider(notificationProvider) - - // Handle Android 12+ background start restrictions gracefully - setListener(object : Listener { - override fun onForegroundServiceStartNotAllowedException() { - Log.w(TAG, "Foreground service start not allowed (Android 12+ background restriction)") - } - }) - Log.d(TAG, "VideoPlayerMediaSessionService onCreate, mediaSession=${mediaSession != null}") } From 39b4be0c5a6aebd15cf3ffe731ae2a5c9bd28f89 Mon Sep 17 00:00:00 2001 From: Vishnu Date: Fri, 10 Apr 2026 11:36:20 +0530 Subject: [PATCH 16/28] Fixed Android Notification issue --- .../VideoPlayerMediaSessionService.kt | 110 +++++++++--------- .../handlers/VideoPlayerMethodHandler.kt | 7 ++ .../VideoPlayerNotificationHandler.kt | 57 ++++++--- 3 files changed, 103 insertions(+), 71 deletions(-) diff --git a/android/src/main/kotlin/com/huddlecommunity/better_native_video_player/VideoPlayerMediaSessionService.kt b/android/src/main/kotlin/com/huddlecommunity/better_native_video_player/VideoPlayerMediaSessionService.kt index 6500adc..725a422 100644 --- a/android/src/main/kotlin/com/huddlecommunity/better_native_video_player/VideoPlayerMediaSessionService.kt +++ b/android/src/main/kotlin/com/huddlecommunity/better_native_video_player/VideoPlayerMediaSessionService.kt @@ -4,81 +4,72 @@ import android.app.NotificationChannel import android.app.NotificationManager import android.app.PendingIntent import android.content.Intent +import android.content.pm.ServiceInfo import android.os.Build -import android.os.Bundle -import android.util.Log import androidx.core.app.NotificationCompat -import androidx.media3.common.Player -import androidx.media3.session.CommandButton +import androidx.media3.session.DefaultMediaNotificationProvider import androidx.media3.session.MediaSession import androidx.media3.session.MediaSessionService -import androidx.media3.session.SessionCommand -import androidx.media3.session.SessionResult -import com.google.common.collect.ImmutableList -import com.google.common.util.concurrent.Futures -import com.google.common.util.concurrent.ListenableFuture /** - * MediaSessionService for native video player - * Provides automatic media notification controls with play/pause buttons - * Based on: https://developer.android.com/media/implement/surfaces/mobile + * MediaSessionService for native video player. * - * IMPORTANT: MediaSessionService automatically creates and manages the notification - * when there's an active MediaSession and the system calls onGetSession() - * The notification appears automatically when media is playing + * The MediaSession is created externally by VideoPlayerNotificationHandler and passed in + * via the static [setMediaSession] / [addSession] methods. This service owns the foreground + * lifecycle and delegates notification rendering to Media3's DefaultMediaNotificationProvider. + * + * Foreground service flow: + * 1. NotificationHandler calls setMediaSession(session) + startForegroundService() + * 2. onStartCommand() immediately posts a placeholder notification to satisfy Android's + * 5-second startForeground() deadline. + * 3. super.onStartCommand() triggers Media3's notification pipeline which replaces the + * placeholder with a rich media notification (artwork, play/pause, skip controls). */ class VideoPlayerMediaSessionService : MediaSessionService() { companion object { - private const val TAG = "VideoPlayerMSS" private const val NOTIFICATION_ID = 1001 private const val CHANNEL_ID = "video_player_channel" - // The MediaSession is stored here so it can be accessed by the service private var mediaSession: MediaSession? = null - /** - * Gets the current media session - */ fun getMediaSession(): MediaSession? = mediaSession /** - * Sets the media session (called by VideoPlayerNotificationHandler) - * This must be called before starting the service + * Sets the media session (called by VideoPlayerNotificationHandler). + * Must be called before starting the service. */ fun setMediaSession(session: MediaSession?) { - Log.d(TAG, "MediaSession ${if (session != null) "set" else "cleared"}, hasPlayer=${session?.player != null}") mediaSession = session } } override fun onCreate() { super.onCreate() - Log.d(TAG, "VideoPlayerMediaSessionService onCreate, mediaSession=${mediaSession != null}") + + val notificationProvider = DefaultMediaNotificationProvider.Builder(this) + .setChannelId(CHANNEL_ID) + .setNotificationId(NOTIFICATION_ID) + .build() + notificationProvider.setSmallIcon(resolveNotificationIcon()) + setMediaNotificationProvider(notificationProvider) + + setListener(object : Listener { + override fun onForegroundServiceStartNotAllowedException() { + // Android 12+ background start restriction — nothing to do + } + }) } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - Log.d(TAG, "onStartCommand called, mediaSession=${mediaSession != null}, player=${mediaSession?.player != null}") - - // Immediately promote to foreground with a placeholder notification to satisfy - // Android's 5-second startForeground() deadline. Media3's super.onStartCommand() - // will replace this with the real media notification once it processes the session. startForegroundWithPlaceholder() - - // Now let Media3 do its work — it will replace the placeholder notification - val result = super.onStartCommand(intent, flags, startId) - - // Log player state for debugging - mediaSession?.player?.let { player -> - Log.d(TAG, "Player state: playWhenReady=${player.playWhenReady}, playbackState=${player.playbackState}, mediaItemCount=${player.mediaItemCount}") - } - - return result + mediaSession?.let { addSession(it) } + return super.onStartCommand(intent, flags, startId) } /** - * Posts a minimal foreground notification so the service satisfies Android's - * startForeground() contract before Media3 builds the real media notification. + * Posts a minimal foreground notification to satisfy Android's 5-second deadline. + * Media3 replaces this with the real media notification shortly after. */ private fun startForegroundWithPlaceholder() { try { @@ -95,7 +86,6 @@ class VideoPlayerMediaSessionService : MediaSessionService() { .createNotificationChannel(channel) } - // Build a launch intent so tapping the placeholder opens the app val launchIntent = packageManager.getLaunchIntentForPackage(packageName) ?: Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LAUNCHER).setPackage(packageName) val pendingIntent = PendingIntent.getActivity( @@ -104,34 +94,42 @@ class VideoPlayerMediaSessionService : MediaSessionService() { val notification = NotificationCompat.Builder(this, CHANNEL_ID) .setContentTitle("Playing") - .setSmallIcon(applicationInfo.icon) + .setSmallIcon(resolveNotificationIcon()) .setContentIntent(pendingIntent) .setPriority(NotificationCompat.PRIORITY_LOW) .setOngoing(true) .setSilent(true) .build() - startForeground(NOTIFICATION_ID, notification) - Log.d(TAG, "Placeholder foreground notification posted") - } catch (e: Exception) { - Log.e(TAG, "Failed to start foreground: ${e.message}", e) - } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + startForeground( + NOTIFICATION_ID, + notification, + ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK + ) + } else { + startForeground(NOTIFICATION_ID, notification) + } + } catch (_: Exception) { } } - override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? { - Log.d(TAG, "onGetSession called for ${controllerInfo.packageName}, returning session=${mediaSession != null}") + /** + * Resolves the best available notification icon. + * Prefers a dedicated ic_notification drawable, falls back to the app launcher icon. + */ + private fun resolveNotificationIcon(): Int { + val iconRes = resources.getIdentifier("ic_notification", "drawable", packageName) + return if (iconRes != 0) iconRes else applicationInfo.icon + } - // Return the MediaSession - this triggers the notification to appear + override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? { return mediaSession } override fun onTaskRemoved(rootIntent: Intent?) { - Log.d(TAG, "Task removed") val session = mediaSession if (session != null) { if (!session.player.playWhenReady || session.player.mediaItemCount == 0) { - // Stop the service if not playing - Log.d(TAG, "Stopping service - not playing") stopSelf() } } else { @@ -140,8 +138,6 @@ class VideoPlayerMediaSessionService : MediaSessionService() { } override fun onDestroy() { - Log.d(TAG, "VideoPlayerMediaSessionService onDestroy") - // Don't release the player or session here - they're managed by the notification handler super.onDestroy() } -} \ No newline at end of file +} diff --git a/android/src/main/kotlin/com/huddlecommunity/better_native_video_player/handlers/VideoPlayerMethodHandler.kt b/android/src/main/kotlin/com/huddlecommunity/better_native_video_player/handlers/VideoPlayerMethodHandler.kt index 14b66b5..077a797 100644 --- a/android/src/main/kotlin/com/huddlecommunity/better_native_video_player/handlers/VideoPlayerMethodHandler.kt +++ b/android/src/main/kotlin/com/huddlecommunity/better_native_video_player/handlers/VideoPlayerMethodHandler.kt @@ -896,6 +896,13 @@ class VideoPlayerMethodHandler( player.trackSelectionParameters = newParameters + // Start/stop the foreground service based on audio-only mode. + if (disabled) { + notificationHandler.startForegroundPlayback() + } else { + notificationHandler.stopForegroundPlayback() + } + Log.d(TAG, "Video track ${if (disabled) "disabled" else "enabled"}") result.success(null) } catch (e: Exception) { diff --git a/android/src/main/kotlin/com/huddlecommunity/better_native_video_player/handlers/VideoPlayerNotificationHandler.kt b/android/src/main/kotlin/com/huddlecommunity/better_native_video_player/handlers/VideoPlayerNotificationHandler.kt index c2e2145..f49c50d 100644 --- a/android/src/main/kotlin/com/huddlecommunity/better_native_video_player/handlers/VideoPlayerNotificationHandler.kt +++ b/android/src/main/kotlin/com/huddlecommunity/better_native_video_player/handlers/VideoPlayerNotificationHandler.kt @@ -177,18 +177,23 @@ class VideoPlayerNotificationHandler( private val playerListener = object : Player.Listener { override fun onPlayWhenReadyChanged(playWhenReady: Boolean, reason: Int) { if (playWhenReady) { - showNotification() eventHandler.sendEvent("play") } else { - updateNotification() eventHandler.sendEvent("pause") } } override fun onPlaybackStateChanged(playbackState: Int) { when (playbackState) { - Player.STATE_ENDED, Player.STATE_IDLE -> hideNotification() - Player.STATE_READY -> if (player.playWhenReady) showNotification() + Player.STATE_ENDED, Player.STATE_IDLE -> { + try { + val serviceIntent = Intent(context, VideoPlayerMediaSessionService::class.java) + context.stopService(serviceIntent) + } catch (e: Exception) { + Log.e(TAG, "Error stopping service: ${e.message}") + } + } + else -> { /* Media3 handles notification updates */ } } } } @@ -368,21 +373,44 @@ class VideoPlayerNotificationHandler( updateMediaMetadata(info) } - // Store the session for the service to access and start as foreground service. - // When MediaSessionService receives onStartCommand() and onGetSession() returns - // a non-null MediaSession with active media, Media3 internally calls startForeground() - // with the notification it constructs. VideoPlayerMediaSessionService.setMediaSession(mediaSession) + Log.d(TAG, "===== setupMediaSession: MediaSession created (foreground service NOT started)") + + // Start periodic position updates + startPositionUpdates() + } + + /** + * Starts the foreground service with media notification. + * Call this ONLY when switching to audio-only playback (background or manual audio mode). + * NOT when video is playing in the foreground. + */ + fun startForegroundPlayback() { + if (mediaSession == null) { + Log.w(TAG, "===== startForegroundPlayback: no MediaSession, skipping") + return + } + Log.d(TAG, "===== startForegroundPlayback: starting foreground service") val serviceIntent = Intent(context, VideoPlayerMediaSessionService::class.java) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { context.startForegroundService(serviceIntent) } else { context.startService(serviceIntent) } - Log.d(TAG, "Started VideoPlayerMediaSessionService as foreground service") + } - // Start periodic position updates - startPositionUpdates() + /** + * Stops the foreground service and removes the notification. + * Call when switching back to video mode from audio mode. + */ + fun stopForegroundPlayback() { + Log.d(TAG, "===== stopForegroundPlayback: stopping foreground service") + try { + val serviceIntent = Intent(context, VideoPlayerMediaSessionService::class.java) + context.stopService(serviceIntent) + } catch (e: Exception) { + Log.e(TAG, "Error stopping service: ${e.message}") + } } /** @@ -409,8 +437,7 @@ class VideoPlayerNotificationHandler( * Hides the notification */ private fun hideNotification() { - notificationManager.cancel(NOTIFICATION_ID) - Log.d(TAG, "Notification hidden") + stopForegroundPlayback() } /** @@ -574,7 +601,9 @@ class VideoPlayerNotificationHandler( fun release() { stopPositionUpdates() player.removeListener(playerListener) - hideNotification() + + stopForegroundPlayback() + VideoPlayerMediaSessionService.setMediaSession(null) mediaSession?.release() mediaSession = null From 707b39a59a1d1e1ac18ff146017d8c7deca2460f Mon Sep 17 00:00:00 2001 From: Vishnu Date: Fri, 10 Apr 2026 11:58:16 +0530 Subject: [PATCH 17/28] Fixed the issue --- .../VideoPlayerMediaSessionService.kt | 222 +++++++++++------- .../handlers/VideoPlayerMethodHandler.kt | 2 + .../VideoPlayerNotificationHandler.kt | 92 ++------ 3 files changed, 158 insertions(+), 158 deletions(-) diff --git a/android/src/main/kotlin/com/huddlecommunity/better_native_video_player/VideoPlayerMediaSessionService.kt b/android/src/main/kotlin/com/huddlecommunity/better_native_video_player/VideoPlayerMediaSessionService.kt index 725a422..bee00e3 100644 --- a/android/src/main/kotlin/com/huddlecommunity/better_native_video_player/VideoPlayerMediaSessionService.kt +++ b/android/src/main/kotlin/com/huddlecommunity/better_native_video_player/VideoPlayerMediaSessionService.kt @@ -6,138 +6,192 @@ import android.app.PendingIntent import android.content.Intent import android.content.pm.ServiceInfo import android.os.Build +import android.util.Log import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.media3.common.util.UnstableApi import androidx.media3.session.DefaultMediaNotificationProvider import androidx.media3.session.MediaSession import androidx.media3.session.MediaSessionService /** - * MediaSessionService for native video player. + * Foreground MediaSessionService for background video/audio playback. * - * The MediaSession is created externally by VideoPlayerNotificationHandler and passed in - * via the static [setMediaSession] / [addSession] methods. This service owns the foreground - * lifecycle and delegates notification rendering to Media3's DefaultMediaNotificationProvider. + * Modelled after InsightMediaSessionService in the insight_timer_player package. + * Key difference: the ExoPlayer and MediaSession are created EXTERNALLY in + * VideoPlayerNotificationHandler and handed in via the static setMediaSession(). * - * Foreground service flow: - * 1. NotificationHandler calls setMediaSession(session) + startForegroundService() - * 2. onStartCommand() immediately posts a placeholder notification to satisfy Android's - * 5-second startForeground() deadline. - * 3. super.onStartCommand() triggers Media3's notification pipeline which replaces the - * placeholder with a rich media notification (artwork, play/pause, skip controls). + * Media3's DefaultMediaNotificationProvider (configured via a CustomMediaNotificationProvider + * subclass) builds the notification through startForeground(), which is exempt from + * the POST_NOTIFICATIONS runtime permission on Android 13+. */ +@androidx.annotation.OptIn(UnstableApi::class) class VideoPlayerMediaSessionService : MediaSessionService() { companion object { + private const val TAG = "VideoPlayerMSS" private const val NOTIFICATION_ID = 1001 private const val CHANNEL_ID = "video_player_channel" + private const val LEGACY_CHANNEL_ID = "video_player" private var mediaSession: MediaSession? = null + private var isForegroundStarted = false fun getMediaSession(): MediaSession? = mediaSession /** - * Sets the media session (called by VideoPlayerNotificationHandler). - * Must be called before starting the service. + * Called by VideoPlayerNotificationHandler after it creates the MediaSession. */ fun setMediaSession(session: MediaSession?) { + Log.d(TAG, "===== setMediaSession: session=${session != null}, player=${session?.player != null}") mediaSession = session } } + // ── lifecycle ──────────────────────────────────────────────────────────── + override fun onCreate() { super.onCreate() - val notificationProvider = DefaultMediaNotificationProvider.Builder(this) + // Wire up the notification provider exactly like InsightMediaSessionService. + // This subclass controls which buttons appear and in what order. + val provider = DefaultMediaNotificationProvider.Builder(this) .setChannelId(CHANNEL_ID) .setNotificationId(NOTIFICATION_ID) .build() - notificationProvider.setSmallIcon(resolveNotificationIcon()) - setMediaNotificationProvider(notificationProvider) - - setListener(object : Listener { - override fun onForegroundServiceStartNotAllowedException() { - // Android 12+ background start restriction — nothing to do - } - }) + // Use the app's dedicated notification icon (monochrome drawable). + // android.R.drawable.ic_media_play is a safe fallback if ic_notification doesn't exist. + val iconRes = resolveNotificationIcon() + provider.setSmallIcon(iconRes) + setMediaNotificationProvider(provider) + + setListener(ServiceListener()) + Log.d(TAG, "===== SERVICE onCreate – provider & listener set") } - override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - startForegroundWithPlaceholder() - mediaSession?.let { addSession(it) } - return super.onStartCommand(intent, flags, startId) + override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? { + Log.d(TAG, "===== SERVICE onGetSession, session=${mediaSession != null}") + return mediaSession } - /** - * Posts a minimal foreground notification to satisfy Android's 5-second deadline. - * Media3 replaces this with the real media notification shortly after. - */ - private fun startForegroundWithPlaceholder() { - try { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - val channel = NotificationChannel( - CHANNEL_ID, - "Video Playback", - NotificationManager.IMPORTANCE_LOW - ).apply { - description = "Controls for video playback" - setSound(null, null) - } - (getSystemService(NOTIFICATION_SERVICE) as NotificationManager) - .createNotificationChannel(channel) - } - - val launchIntent = packageManager.getLaunchIntentForPackage(packageName) - ?: Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LAUNCHER).setPackage(packageName) - val pendingIntent = PendingIntent.getActivity( - this, 0, launchIntent, PendingIntent.FLAG_IMMUTABLE - ) - - val notification = NotificationCompat.Builder(this, CHANNEL_ID) - .setContentTitle("Playing") - .setSmallIcon(resolveNotificationIcon()) - .setContentIntent(pendingIntent) - .setPriority(NotificationCompat.PRIORITY_LOW) - .setOngoing(true) - .setSilent(true) - .build() - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - startForeground( - NOTIFICATION_ID, - notification, - ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK - ) - } else { - startForeground(NOTIFICATION_ID, notification) - } - } catch (_: Exception) { } - } + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + Log.d(TAG, "===== SERVICE onStartCommand, session=${mediaSession != null}") - /** - * Resolves the best available notification icon. - * Prefers a dedicated ic_notification drawable, falls back to the app launcher icon. - */ - private fun resolveNotificationIcon(): Int { - val iconRes = resources.getIdentifier("ic_notification", "drawable", packageName) - return if (iconRes != 0) iconRes else applicationInfo.icon + // Immediately satisfy Android's 5-second startForeground() deadline with a placeholder. + startForegroundIfNeeded() + + // Explicitly register the external MediaSession with the service. + // MediaSessionService's notification pipeline only activates when it knows about a session. + // onGetSession() is never called because no MediaController binds via startForegroundService(). + // addSession() is the Media3 API for registering externally-created sessions — + // it triggers the internal connection + notification pipeline. + val session = mediaSession + if (session != null) { + Log.d(TAG, "===== SERVICE calling addSession()") + addSession(session) + } + + return super.onStartCommand(intent, flags, startId) } - override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? { - return mediaSession + override fun onUpdateNotification(session: MediaSession, startInForegroundRequired: Boolean) { + // Let Media3 + CustomMediaNotificationProvider handle everything. + super.onUpdateNotification(session, startInForegroundRequired) } override fun onTaskRemoved(rootIntent: Intent?) { + Log.d(TAG, "===== SERVICE onTaskRemoved") val session = mediaSession - if (session != null) { - if (!session.player.playWhenReady || session.player.mediaItemCount == 0) { - stopSelf() - } - } else { + if (session == null || !session.player.playWhenReady || session.player.mediaItemCount == 0) { stopSelf() } } override fun onDestroy() { + Log.d(TAG, "===== SERVICE onDestroy") + isForegroundStarted = false + clearListener() super.onDestroy() } + + // ── foreground promotion ──────────────────────────────────────────────── + + /** + * Posts a minimal foreground notification so the service satisfies Android's + * startForeground() contract. Media3 will replace it with the real media + * notification moments later via [onUpdateNotification]. + * + * Copied from InsightMediaSessionService.startForegroundIfNeeded(). + */ + private fun startForegroundIfNeeded() { + if (isForegroundStarted) return + + val launchIntent = packageManager.getLaunchIntentForPackage(packageName) + ?: Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LAUNCHER).setPackage(packageName) + val pendingIntent = PendingIntent.getActivity(this, 0, launchIntent, PendingIntent.FLAG_IMMUTABLE) + val notificationManagerCompat = NotificationManagerCompat.from(this) + ensureNotificationChannel(notificationManagerCompat) + + val notification = NotificationCompat.Builder(this, CHANNEL_ID) + .setSmallIcon(resolveNotificationIcon()) + .setContentTitle("Playing") + .setContentIntent(pendingIntent) + .setCategory(NotificationCompat.CATEGORY_SERVICE) + .setPriority(NotificationCompat.PRIORITY_LOW) + .setDefaults(0) + .setSound(null) + .setVibrate(longArrayOf(0L)) + .setOnlyAlertOnce(true) + .setOngoing(true) + .build() + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK) + } else { + startForeground(NOTIFICATION_ID, notification) + } + isForegroundStarted = true + Log.d(TAG, "===== SERVICE startForeground done (MEDIA_PLAYBACK)") + } + + // ── notification channel ──────────────────────────────────────────────── + + private fun ensureNotificationChannel(nmc: NotificationManagerCompat) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return + + // Remove legacy channel if it exists + if (nmc.getNotificationChannel(LEGACY_CHANNEL_ID) != null) { + nmc.deleteNotificationChannel(LEGACY_CHANNEL_ID) + } + if (nmc.getNotificationChannel(CHANNEL_ID) != null) return + + val channel = NotificationChannel( + CHANNEL_ID, + "Video Playback", + NotificationManager.IMPORTANCE_LOW + ).apply { + setSound(null, null) + enableVibration(false) + vibrationPattern = longArrayOf(0L) + setShowBadge(false) + } + nmc.createNotificationChannel(channel) + } + + // ── icon helper ───────────────────────────────────────────────────────── + + private fun resolveNotificationIcon(): Int { + // Try the app's dedicated notification icon first (same as audio player). + val resId = resources.getIdentifier("ic_notification", "drawable", packageName) + return if (resId != 0) resId else android.R.drawable.ic_media_play + } + + // ── Media3 listener for Android 12+ background-start restriction ──────── + + private inner class ServiceListener : Listener { + override fun onForegroundServiceStartNotAllowedException() { + Log.w(TAG, "Foreground service start not allowed (Android 12+ restriction)") + } + } + } diff --git a/android/src/main/kotlin/com/huddlecommunity/better_native_video_player/handlers/VideoPlayerMethodHandler.kt b/android/src/main/kotlin/com/huddlecommunity/better_native_video_player/handlers/VideoPlayerMethodHandler.kt index 077a797..1f0e0fd 100644 --- a/android/src/main/kotlin/com/huddlecommunity/better_native_video_player/handlers/VideoPlayerMethodHandler.kt +++ b/android/src/main/kotlin/com/huddlecommunity/better_native_video_player/handlers/VideoPlayerMethodHandler.kt @@ -897,6 +897,8 @@ class VideoPlayerMethodHandler( player.trackSelectionParameters = newParameters // Start/stop the foreground service based on audio-only mode. + // When video track is disabled → audio-only → show notification. + // When video track is re-enabled → video mode → remove notification. if (disabled) { notificationHandler.startForegroundPlayback() } else { diff --git a/android/src/main/kotlin/com/huddlecommunity/better_native_video_player/handlers/VideoPlayerNotificationHandler.kt b/android/src/main/kotlin/com/huddlecommunity/better_native_video_player/handlers/VideoPlayerNotificationHandler.kt index f49c50d..fc80bfd 100644 --- a/android/src/main/kotlin/com/huddlecommunity/better_native_video_player/handlers/VideoPlayerNotificationHandler.kt +++ b/android/src/main/kotlin/com/huddlecommunity/better_native_video_player/handlers/VideoPlayerNotificationHandler.kt @@ -12,7 +12,6 @@ import android.net.Uri import android.os.Build import android.os.Handler import android.os.Looper -import android.util.Log import android.support.v4.media.session.MediaSessionCompat import androidx.core.app.NotificationCompat import androidx.media.app.NotificationCompat as MediaNotificationCompat @@ -39,7 +38,6 @@ class VideoPlayerNotificationHandler( private var eventHandler: VideoPlayerEventHandler ) { companion object { - private const val TAG = "VideoPlayerNotification" private const val NOTIFICATION_ID = 1001 private const val CHANNEL_ID = "video_player_channel" private var sessionCounter = 0 @@ -181,17 +179,17 @@ class VideoPlayerNotificationHandler( } else { eventHandler.sendEvent("pause") } + // Media3's MediaSessionService handles notification updates automatically. } override fun onPlaybackStateChanged(playbackState: Int) { when (playbackState) { Player.STATE_ENDED, Player.STATE_IDLE -> { + // Stop the foreground service when playback ends try { val serviceIntent = Intent(context, VideoPlayerMediaSessionService::class.java) context.stopService(serviceIntent) - } catch (e: Exception) { - Log.e(TAG, "Error stopping service: ${e.message}") - } + } catch (_: Exception) { } } else -> { /* Media3 handles notification updates */ } } @@ -212,7 +210,6 @@ class VideoPlayerNotificationHandler( setShowBadge(false) } notificationManager.createNotificationChannel(channel) - Log.d(TAG, "Notification channel created") } } @@ -234,7 +231,6 @@ class VideoPlayerNotificationHandler( */ fun updateEventHandler(newEventHandler: VideoPlayerEventHandler) { eventHandler = newEventHandler - Log.d(TAG, "Event handler updated for shared notification handler") } /** @@ -267,8 +263,6 @@ class VideoPlayerNotificationHandler( player.replaceMediaItem(player.currentMediaItemIndex, updatedItem) player.seekTo(position) if (wasPlaying) player.play() - - Log.d(TAG, "Updated player MediaItem metadata - title: ${mediaInfo["title"]}, subtitle: ${mediaInfo["subtitle"]}") } /** @@ -294,9 +288,6 @@ class VideoPlayerNotificationHandler( showSkipControls = newShowSkipControls showSystemPreviousTrackControl = newShowSystemPreviousTrackControl showSystemNextTrackControl = newShowSystemNextTrackControl - Log.d(TAG, "📱 Media info - title: $currentTitle, subtitle: $currentSubtitle, changed: $mediaInfoChanged") - Log.d(TAG, "📱 showSkipControls: $showSkipControls, seekPermissionChanged: $seekPermissionChanged") - Log.d(TAG, "📱 showPrevTrack: $showSystemPreviousTrackControl, showNextTrack: $showSystemNextTrackControl") // Recreate MediaSession when seek permissions change so connected system controllers // receive the new command set via onConnect. @@ -304,14 +295,12 @@ class VideoPlayerNotificationHandler( mediaSession?.release() mediaSession = null player.removeListener(playerListener) - Log.d(TAG, "📱 Recreating MediaSession due to seek permission change") } // If MediaSession already exists, only update if media info changed if (mediaSession != null) { // Only update MediaItem if the info actually changed to avoid playback interruptions if (mediaInfoChanged) { - Log.d(TAG, "📱 MediaSession exists - media info changed, updating metadata") currentArtwork = null // Clear old artwork currentArtworkUrl = null // Clear artwork URL to ignore pending loads @@ -327,11 +316,8 @@ class VideoPlayerNotificationHandler( handler.post { if (player.playWhenReady) { updateNotification() - Log.d(TAG, "✅ Notification updated with new media info") } } - } else { - Log.d(TAG, "📱 MediaSession exists - media info unchanged, skipping update to avoid interruption") } return } @@ -360,12 +346,9 @@ class VideoPlayerNotificationHandler( player.removeListener(playerListener) player.addListener(playerListener) - Log.d(TAG, "MediaSession created - lock screen and notification controls active") - // Set metadata on the player's MediaItem first (for MediaSession to use) mediaInfo?.let { info -> updatePlayerMediaItemMetadata(info) - Log.d(TAG, "Initial MediaItem metadata set for new MediaSession") } // Load artwork asynchronously if provided @@ -373,8 +356,11 @@ class VideoPlayerNotificationHandler( updateMediaMetadata(info) } + // Store the session so it's available when the foreground service is started later. + // The service is NOT started here — it's started only when: + // 1. setVideoTrackDisabled(true) is called (audio mode or background) + // 2. Via startForegroundPlayback() below VideoPlayerMediaSessionService.setMediaSession(mediaSession) - Log.d(TAG, "===== setupMediaSession: MediaSession created (foreground service NOT started)") // Start periodic position updates startPositionUpdates() @@ -386,11 +372,7 @@ class VideoPlayerNotificationHandler( * NOT when video is playing in the foreground. */ fun startForegroundPlayback() { - if (mediaSession == null) { - Log.w(TAG, "===== startForegroundPlayback: no MediaSession, skipping") - return - } - Log.d(TAG, "===== startForegroundPlayback: starting foreground service") + if (mediaSession == null) return val serviceIntent = Intent(context, VideoPlayerMediaSessionService::class.java) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { context.startForegroundService(serviceIntent) @@ -404,38 +386,20 @@ class VideoPlayerNotificationHandler( * Call when switching back to video mode from audio mode. */ fun stopForegroundPlayback() { - Log.d(TAG, "===== stopForegroundPlayback: stopping foreground service") try { val serviceIntent = Intent(context, VideoPlayerMediaSessionService::class.java) context.stopService(serviceIntent) - } catch (e: Exception) { - Log.e(TAG, "Error stopping service: ${e.message}") - } + } catch (_: Exception) { } } - /** - * Shows or updates the media notification - */ private fun showNotification() { - try { - val notification = buildNotification() - notificationManager.notify(NOTIFICATION_ID, notification) - Log.d(TAG, "Notification shown/updated") - } catch (e: Exception) { - Log.e(TAG, "Error showing notification: ${e.message}", e) - } + // No-op: Media3 handles notification via the foreground service. } - /** - * Updates the existing notification - */ private fun updateNotification() { - showNotification() + // No-op: Media3 handles notification updates. } - /** - * Hides the notification - */ private fun hideNotification() { stopForegroundPlayback() } @@ -464,11 +428,9 @@ class VideoPlayerNotificationHandler( PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT ) - Log.d(TAG, "Building notification - title: $title, subtitle: $artist (from player: ${mediaMetadata != null})") - - // Get notification icon from the app's resources - val appInfo = context.applicationInfo - val iconResId = appInfo.icon + // Use a system drawable for the small icon — adaptive/mipmap launcher icons + // are silently suppressed by Android's notification system. + val iconResId = android.R.drawable.ic_media_play // Convert Media3 SessionToken to MediaSessionCompat.Token for notification // Media3 1.4.0+ requires us to extract the token differently @@ -476,9 +438,7 @@ class VideoPlayerNotificationHandler( // Use reflection to access the session compat token val method = session.javaClass.getMethod("getSessionCompatToken") method.invoke(session) as? MediaSessionCompat.Token - } catch (e: Exception) { - // If reflection fails (Media3 1.4.0+), create a token from the session's underlying binder - Log.w(TAG, "getSessionCompatToken not available, using alternative method") + } catch (_: Exception) { null } @@ -498,10 +458,6 @@ class VideoPlayerNotificationHandler( MediaNotificationCompat.MediaStyle() .setMediaSession(token) ) - } else { - // Fallback: create notification without media session integration - // Controls will still work through MediaSession, just not integrated in notification - Log.w(TAG, "Creating notification without MediaSession token integration") } return builder.build() @@ -519,10 +475,7 @@ class VideoPlayerNotificationHandler( currentArtworkUrl = artworkUrl // Track the current artwork URL loadArtwork(artworkUrl) { bitmap -> // Only use this artwork if it's still the current one (prevent race conditions) - if (artworkUrl != currentArtworkUrl) { - Log.d(TAG, "Ignoring outdated artwork for $artworkUrl") - return@loadArtwork - } + if (artworkUrl != currentArtworkUrl) return@loadArtwork bitmap?.let { currentArtwork = it @@ -531,18 +484,12 @@ class VideoPlayerNotificationHandler( // DO NOT call replaceMediaItem here as it can interrupt playback // The notification will use currentArtwork automatically if (player.playWhenReady) { - handler.post { - updateNotification() - Log.d(TAG, "Artwork loaded and notification updated for $artworkUrl") - } - } else { - Log.d(TAG, "Artwork loaded but player not ready, will show on next play") + handler.post { updateNotification() } } } } } - Log.d(TAG, "Media metadata setup complete") } /** @@ -556,8 +503,7 @@ class VideoPlayerNotificationHandler( withContext(Dispatchers.Main) { callback(bitmap) } - } catch (e: Exception) { - Log.e(TAG, "Error loading artwork: ${e.message}", e) + } catch (_: Exception) { withContext(Dispatchers.Main) { callback(null) } @@ -604,13 +550,11 @@ class VideoPlayerNotificationHandler( stopForegroundPlayback() VideoPlayerMediaSessionService.setMediaSession(null) - mediaSession?.release() mediaSession = null currentArtwork = null currentArtworkUrl = null currentTitle = "Video" currentSubtitle = "" - Log.d(TAG, "MediaSession released") } } From 57749830cf6e9da92c5a7e5ab7f432864257fc7c Mon Sep 17 00:00:00 2001 From: Vishnu Date: Fri, 10 Apr 2026 13:32:15 +0530 Subject: [PATCH 18/28] Update for iOS --- ios/Classes/Handlers/VideoPlayerMethodHandler.swift | 12 ++++++++++++ .../Handlers/VideoPlayerNowPlayingHandler.swift | 5 +++++ ios/Classes/View/VideoPlayerView.swift | 3 +++ 3 files changed, 20 insertions(+) diff --git a/ios/Classes/Handlers/VideoPlayerMethodHandler.swift b/ios/Classes/Handlers/VideoPlayerMethodHandler.swift index 247ab30..699cb3e 100644 --- a/ios/Classes/Handlers/VideoPlayerMethodHandler.swift +++ b/ios/Classes/Handlers/VideoPlayerMethodHandler.swift @@ -1326,6 +1326,12 @@ extension VideoPlayerView { // Strategy 2: Restrict bitrate to audio-only threshold (fallback) playerItem.preferredPeakBitRate = 1.0 print("[VideoPlayer] preferredPeakBitRate set to 1.0 (audio-only)") + + // Enable Now Playing info for lock screen / Control Center + isAudioOnlyMode = true + if let mediaInfo = currentMediaInfo { + setupNowPlayingInfo(mediaInfo: mediaInfo) + } } else { // Re-enable: restore video rendition selection if let asset = playerItem.asset as? AVURLAsset, @@ -1343,6 +1349,12 @@ extension VideoPlayerView { // Clear bitrate restriction (0 = no limit) playerItem.preferredPeakBitRate = 0 print("[VideoPlayer] preferredPeakBitRate cleared (no limit)") + + // Clear Now Playing info when returning to video mode + isAudioOnlyMode = false + MPNowPlayingInfoCenter.default().nowPlayingInfo = nil + hasRegisteredRemoteCommands = false + print("[VideoPlayer] Now Playing info cleared") } result(nil) diff --git a/ios/Classes/Handlers/VideoPlayerNowPlayingHandler.swift b/ios/Classes/Handlers/VideoPlayerNowPlayingHandler.swift index 844263a..efb6665 100644 --- a/ios/Classes/Handlers/VideoPlayerNowPlayingHandler.swift +++ b/ios/Classes/Handlers/VideoPlayerNowPlayingHandler.swift @@ -81,6 +81,11 @@ class RemoteCommandManager { extension VideoPlayerView { /// Sets up the Now Playing info for the Control Center and Lock Screen func setupNowPlayingInfo(mediaInfo: [String: Any]) { + // Only show Now Playing info in audio-only mode (background/manual audio switch). + guard isAudioOnlyMode else { + print("🎵 setupNowPlayingInfo skipped – not in audio-only mode") + return + } print("🎵 setupNowPlayingInfo called for view \(viewId)") print(" → Media title: \(mediaInfo["title"] ?? "Unknown")") print(" → Current Now Playing info before update: \(MPNowPlayingInfoCenter.default().nowPlayingInfo?[MPMediaItemPropertyTitle] as? String ?? "nil")") diff --git a/ios/Classes/View/VideoPlayerView.swift b/ios/Classes/View/VideoPlayerView.swift index 7e3d26e..778c147 100644 --- a/ios/Classes/View/VideoPlayerView.swift +++ b/ios/Classes/View/VideoPlayerView.swift @@ -35,6 +35,9 @@ import QuartzCore // This prevents re-registering and clearing targets unnecessarily var hasRegisteredRemoteCommands: Bool = false + /// When true, Now Playing info and remote commands are active (audio-only/background mode). + var isAudioOnlyMode: Bool = false + /// Force re-registration of remote commands /// Call this when you know the targets might have been removed externally func forceReregisterRemoteCommands() { From 73a7f8ac4577468221a459dc3ac9079986af59de Mon Sep 17 00:00:00 2001 From: Vishnu Date: Thu, 16 Apr 2026 10:29:21 +0530 Subject: [PATCH 19/28] Implement handling the audio --- .../handlers/VideoPlayerMethodHandler.kt | 13 + background_audio_fork_implementation.md | 437 ------------------ .../Handlers/VideoPlayerMethodHandler.swift | 23 + 3 files changed, 36 insertions(+), 437 deletions(-) delete mode 100644 background_audio_fork_implementation.md diff --git a/android/src/main/kotlin/com/huddlecommunity/better_native_video_player/handlers/VideoPlayerMethodHandler.kt b/android/src/main/kotlin/com/huddlecommunity/better_native_video_player/handlers/VideoPlayerMethodHandler.kt index 1f0e0fd..4010c52 100644 --- a/android/src/main/kotlin/com/huddlecommunity/better_native_video_player/handlers/VideoPlayerMethodHandler.kt +++ b/android/src/main/kotlin/com/huddlecommunity/better_native_video_player/handlers/VideoPlayerMethodHandler.kt @@ -889,6 +889,19 @@ class VideoPlayerMethodHandler( Log.d(TAG, "Setting video track disabled: $disabled") + if (disabled) { + // Check if HLS has demuxed (separate) audio tracks. + // If audio is muxed inside video segments, disabling video + // will not save bandwidth, so skip. + val hasDemuxedAudio = player.currentTracks.groups.any { + it.type == C.TRACK_TYPE_AUDIO + } + if (!hasDemuxedAudio) { + result.success(mapOf("skipped" to true, "reason" to "no_demuxed_audio")) + return + } + } + val newParameters = player.trackSelectionParameters .buildUpon() .setTrackTypeDisabled(C.TRACK_TYPE_VIDEO, disabled) diff --git a/background_audio_fork_implementation.md b/background_audio_fork_implementation.md deleted file mode 100644 index 2bfa9d5..0000000 --- a/background_audio_fork_implementation.md +++ /dev/null @@ -1,437 +0,0 @@ -# Background Audio-Only HLS Streaming — Fork Implementation Guide - -> **Repo:** `https://github.com/Insight-Timer/flutter-native-video-player.git` -> **Branch:** Create `feature/background-audio-only` from latest main -> **Scope:** Native Android (Kotlin), Native iOS (Swift), Flutter package (Dart) - ---- - -## 1. Goal - -Add a new `setVideoTrackDisabled(bool)` API to the `better_native_video_player` package. When called with `true`, the native player stops downloading video segments from HLS demuxed streams while audio continues uninterrupted. When called with `false`, video resumes. - -Additionally, ensure the Android `VideoPlayerMediaSessionService` is properly started as a foreground service so the process survives background execution limits. - ---- - -## 2. Existing Pattern to Follow - -The **subtitle track disabling** already demonstrates the exact pattern for all three layers. Follow it as a template: - -### Android — `VideoPlayerMethodHandler.kt` - -```kotlin -// Existing subtitle disabling (lines 788-872) -val parametersBuilder = player.trackSelectionParameters.buildUpon() -parametersBuilder.setTrackTypeDisabled(C.TRACK_TYPE_TEXT, true) -player.trackSelectionParameters = parametersBuilder.build() -``` - -### Flutter — `video_player_method_channel.dart` - -```dart -// Existing subtitle track method -Future setSubtitleTrack(NativeVideoPlayerSubtitleTrack track) async { - await _methodChannel.invokeMethod('setSubtitleTrack', { - 'viewId': primaryPlatformViewId, - 'track': track.toMap(), - }); -} -``` - -### Flutter — `native_video_player_controller.dart` - -```dart -// Existing subtitle method -Future setSubtitleTrack(NativeVideoPlayerSubtitleTrack track) async { - await _methodChannel?.setSubtitleTrack(track); -} -``` - ---- - -## 3. Android — Add `handleSetVideoTrackDisabled` - -### 3.1 File: `android/src/main/kotlin/com/huddlecommunity/better_native_video_player/handlers/VideoPlayerMethodHandler.kt` - -**Step 1:** Add method case in the `handleMethodCall` switch statement. Find the `"setSubtitleTrack"` case and add after it: - -```kotlin -"setVideoTrackDisabled" -> handleSetVideoTrackDisabled(call, result) -``` - -**Step 2:** Add the handler method. Place it after the subtitle methods section (after line ~872): - -```kotlin -/** - * Disables or enables the video track. - * - * When disabled on a demuxed HLS stream, ExoPlayer stops selecting video renditions - * and only fetches audio segments — saving bandwidth during background playback. - * - * When re-enabled, ExoPlayer resumes video segment downloads from the current position. - * - * On muxed content (progressive MP4), disabling stops video decoding but the full - * muxed file is still downloaded — no bandwidth savings. - * - * Uses the same trackSelectionParameters API as subtitle disabling. - */ -private fun handleSetVideoTrackDisabled(call: MethodCall, result: MethodChannel.Result) { - try { - val args = call.arguments as? Map<*, *> - val disabled = args?.get("disabled") as? Boolean ?: false - - Log.d(TAG, "Setting video track disabled: $disabled") - - val newParameters = player.trackSelectionParameters - .buildUpon() - .setTrackTypeDisabled(C.TRACK_TYPE_VIDEO, disabled) - .build() - - player.trackSelectionParameters = newParameters - - Log.d(TAG, "Video track ${if (disabled) "disabled" else "enabled"}") - result.success(null) - } catch (e: Exception) { - Log.e(TAG, "Error setting video track disabled: ${e.message}", e) - result.error("ERROR", "Failed to set video track disabled: ${e.message}", null) - } -} -``` - -### 3.2 Why No Other Android Files Need Changes - -| File | Why no change needed | -|------|---------------------| -| `SharedPlayerManager.kt` | ExoPlayer is created without a custom `DefaultTrackSelector`. The `trackSelectionParameters` API works on the implicit default track selector — same as subtitle disabling. | -| `VideoPlayerView.kt` | `handleMethodCall` at line 382 already delegates unrecognized methods to `methodHandler.handleMethodCall(call, result)`. The new method is automatically routed. | -| `VideoPlayerObserver.kt` | No new events to emit. Track disabling is fire-and-forget. | -| `VideoPlayerEventHandler.kt` | No new events needed. | -| `AndroidManifest.xml` | Already declares `foregroundServiceType="mediaPlayback"`, `FOREGROUND_SERVICE`, and `FOREGROUND_SERVICE_MEDIA_PLAYBACK`. | - ---- - -## 4. Android — Start Foreground Service - -### 4.1 Problem - -The `VideoPlayerMediaSessionService` is declared in AndroidManifest with all required attributes: - -```xml - - - - - - -``` - -And permissions are declared: - -```xml - - - -``` - -**But the service is never started via `startForegroundService()`.** Without this, Android's background execution limits (API 26+) will kill the process within ~1 minute of backgrounding. Audio stops. - -**Reference:** The audio player's `InsightMediaSessionService` (in the main app's `packages/insight_timer_player`) explicitly calls `startForeground(NOTIFICATION_ID, notification)` at line 519 — that's what keeps audio content alive in the background. - -### 4.2 File: `android/src/main/kotlin/com/huddlecommunity/better_native_video_player/handlers/VideoPlayerNotificationHandler.kt` - -**In the `setupMediaSession()` method**, after the MediaSession is created and stored (around line 352 where `VideoPlayerMediaSessionService.setMediaSession(mediaSession)` is called), add: - -```kotlin -// Store the session for the service to access -VideoPlayerMediaSessionService.setMediaSession(mediaSession) - -// Start the MediaSessionService as a foreground service. -// When MediaSessionService receives onStartCommand() and onGetSession() returns -// a non-null MediaSession with active media, Media3 internally calls startForeground() -// with the notification it constructs. We just need to start the service. -val serviceIntent = Intent(context, VideoPlayerMediaSessionService::class.java) -if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - context.startForegroundService(serviceIntent) -} else { - context.startService(serviceIntent) -} -Log.d(TAG, "Started VideoPlayerMediaSessionService as foreground service") -``` - -**Add imports if not already present:** - -```kotlin -import android.content.Intent -import android.os.Build -``` - -### 4.3 How It Works End-to-End - -``` -1. setupMediaSession() creates MediaSession with player + metadata -2. VideoPlayerMediaSessionService.setMediaSession(session) stores it statically -3. context.startForegroundService(intent) starts the service -4. Android calls VideoPlayerMediaSessionService.onStartCommand() -5. Media3 calls onGetSession() → returns the stored MediaSession -6. Media3 internally calls startForeground() with a notification -7. The notification shows title, artwork, and media controls -8. Process stays alive in background ✓ -``` - -### 4.4 Notification Controls — Already Working - -The existing `VideoPlayerNotificationHandler` already provides: - -| Control | Implementation | -|---------|---------------| -| Play/Pause | Default MediaSession play/pause command | -| Skip Forward (15s) | `NotificationPlayerCustomCommandButton.FORWARD` | -| Skip Backward (15s) | `NotificationPlayerCustomCommandButton.REWIND` | -| Previous Track | `NotificationPlayerCustomCommandButton.PREVIOUS` | -| Next Track | `NotificationPlayerCustomCommandButton.NEXT` | -| Artwork | Async loaded from URL via `loadArtwork()` | -| Title/Subtitle | From `mediaInfo` map passed to `setupMediaSession()` | - -**No additional notification changes needed.** All controls continue working during background audio because they communicate with the ExoPlayer instance which stays alive via the foreground service. - ---- - -## 5. iOS — Add `handleSetVideoTrackDisabled` - -### 5.1 File: `ios/Classes/Handlers/VideoPlayerMethodHandler.swift` - -**Step 1:** Add method case in the `handleMethodCall` switch (before `default:`): - -```swift -case "setVideoTrackDisabled": - handleSetVideoTrackDisabled(call: call, result: result) -``` - -**Step 2:** Add the handler method: - -```swift -// MARK: - Video Track Disabling (Background Audio-Only) - -/// Disables or enables the video track for HLS background audio-only streaming. -/// -/// Uses a two-strategy approach: -/// -/// **Strategy 1 — AVMediaSelectionGroup** (primary, for demuxed HLS): -/// Deselects the visual media selection group entirely. With demuxed HLS -/// (`EXT-X-MEDIA:TYPE=AUDIO`), AVPlayer stops downloading video segments -/// and only fetches audio segments. -/// -/// **Strategy 2 — preferredPeakBitRate** (fallback): -/// Sets the peak bitrate to 1 bps, which effectively excludes all video variants -/// (typically 500kbps+) and only allows audio-quality streams through. -/// Handles cases where the media selection group is not yet available. -/// -/// When re-enabling: -/// - Restores the default video rendition via AVMediaSelectionGroup -/// - Clears the bitrate restriction (0 = no limit) -func handleSetVideoTrackDisabled(call: FlutterMethodCall, result: @escaping FlutterResult) { - guard let args = call.arguments as? [String: Any], - let disabled = args["disabled"] as? Bool else { - result(FlutterError( - code: "INVALID_ARGS", - message: "Missing 'disabled' parameter", - details: nil - )) - return - } - - guard let player = player, let playerItem = player.currentItem else { - print("[VideoPlayer] No current player item for video track disable") - result(nil) - return - } - - if disabled { - // Strategy 1: Deselect the visual media selection group (demuxed HLS) - if let asset = playerItem.asset as? AVURLAsset, - let videoGroup = asset.mediaSelectionGroup( - forMediaCharacteristic: .visual - ) { - playerItem.select(nil, in: videoGroup) - print("[VideoPlayer] Video track disabled via AVMediaSelectionGroup") - } - - // Strategy 2: Restrict bitrate to audio-only threshold (fallback) - playerItem.preferredPeakBitRate = 1.0 - print("[VideoPlayer] preferredPeakBitRate set to 1.0 (audio-only)") - } else { - // Re-enable: restore video rendition selection - if let asset = playerItem.asset as? AVURLAsset, - let videoGroup = asset.mediaSelectionGroup( - forMediaCharacteristic: .visual - ) { - if let defaultOption = videoGroup.defaultOption { - playerItem.select(defaultOption, in: videoGroup) - } else if let firstOption = videoGroup.options.first { - playerItem.select(firstOption, in: videoGroup) - } - print("[VideoPlayer] Video track re-enabled via AVMediaSelectionGroup") - } - - // Clear bitrate restriction (0 = no limit) - playerItem.preferredPeakBitRate = 0 - print("[VideoPlayer] preferredPeakBitRate cleared (no limit)") - } - - result(nil) -} -``` - -### 5.2 Where to Add in VideoPlayerView.swift - -If the method call routing goes through `VideoPlayerView.swift` instead of `VideoPlayerMethodHandler.swift`, add the case in the appropriate `handleMethodCall` switch in `VideoPlayerView.swift`: - -```swift -case "setVideoTrackDisabled": - handleSetVideoTrackDisabled(call: call, result: result) -``` - -Check how other methods like `"setSubtitleTrack"` are routed and follow the same pattern. - -### 5.3 Why No Other iOS Files Need Changes - -| File | Why no change needed | -|------|---------------------| -| `SharedPlayerManager.swift` | Already sets `audiovisualBackgroundPlaybackPolicy = .continuesIfPossible` (iOS 15+). Background audio works. | -| `VideoPlayerNowPlayingHandler.swift` | Already manages `MPNowPlayingInfoCenter` + `MPRemoteCommandCenter`. Lock screen controls work. | -| `VideoPlayerObserver.swift` | No new events to observe. | -| `Info.plist` (main app) | Already declares `UIBackgroundModes: ["audio"]`. | - -### 5.4 iOS Background Audio Flow (Already Working) - -``` -1. App goes to background -2. iOS checks UIBackgroundModes → "audio" is declared ✓ -3. AVAudioSession category is .playback ✓ -4. audiovisualBackgroundPlaybackPolicy = .continuesIfPossible ✓ -5. AVPlayer continues playing audio -6. MPNowPlayingInfoCenter shows on lock screen ✓ -7. MPRemoteCommandCenter handles play/pause/skip ✓ -8. setVideoTrackDisabled(true) → AVPlayer stops fetching video segments -9. Only audio segments downloaded → bandwidth saved ✓ -``` - ---- - -## 6. Flutter Package — MethodChannel Bridge - -### 6.1 File: `lib/src/platform/video_player_method_channel.dart` - -Add after the `setSubtitleTrack` method: - -```dart -/// Disables or enables the video track in the native player. -/// -/// When [disabled] is true, the native player stops downloading video segments -/// from HLS demuxed streams, saving bandwidth during background playback. -/// Audio continues uninterrupted. -/// -/// When [disabled] is false, video segment downloads resume from the current position. -Future setVideoTrackDisabled(bool disabled) async { - try { - await _methodChannel.invokeMethod( - 'setVideoTrackDisabled', - { - 'viewId': primaryPlatformViewId, - 'disabled': disabled, - }, - ); - } catch (e) { - debugPrint('Error calling setVideoTrackDisabled: $e'); - } -} -``` - -### 6.2 File: `lib/src/controllers/native_video_player_controller.dart` - -Add after the `setSubtitleTrack` method: - -```dart -/// Disables or enables the video track in the native player. -/// -/// When [disabled] is true, only audio segments are downloaded from HLS -/// demuxed streams. This is designed for background audio-only playback -/// to save bandwidth. -/// -/// Call with `true` when the app goes to background, -/// `false` when returning to foreground. -Future setVideoTrackDisabled(bool disabled) async { - await _methodChannel?.setVideoTrackDisabled(disabled); -} -``` - ---- - -## 7. Files Changed Summary - -| File | Change | ~LOC | -|------|--------|------| -| `android/.../handlers/VideoPlayerMethodHandler.kt` | Add `handleSetVideoTrackDisabled` + switch case | 25 | -| `android/.../handlers/VideoPlayerNotificationHandler.kt` | Start foreground service in `setupMediaSession()` | 10 | -| `ios/Classes/Handlers/VideoPlayerMethodHandler.swift` | Add `handleSetVideoTrackDisabled` + switch case | 50 | -| `lib/src/platform/video_player_method_channel.dart` | Add `setVideoTrackDisabled` MethodChannel call | 12 | -| `lib/src/controllers/native_video_player_controller.dart` | Add `setVideoTrackDisabled` public method | 8 | - -**Total: ~105 lines across 5 files** - ---- - -## 8. Testing the Fork Changes - -### 8.1 Local Testing with the Main App - -Use `pubspec_overrides.yaml` in the main app to point to your local fork clone: - -```yaml -# apps/insight_timer/pubspec_overrides.yaml (gitignored, don't commit) -dependency_overrides: - better_native_video_player: - path: /path/to/your/local/flutter-native-video-player -``` - -Then run `flutter pub get` and test changes immediately without pushing. - -### 8.2 Verification Steps - -**Android:** -1. Play an HLS video with demuxed audio tracks -2. Call `setVideoTrackDisabled(true)` via the Flutter controller -3. Verify in logcat: `"Video track disabled"` log appears -4. Use Android Studio Network Profiler or Charles Proxy: only audio segment requests visible -5. Call `setVideoTrackDisabled(false)` — verify video segments resume -6. Background the app — verify foreground notification appears and audio continues -7. Verify app is NOT killed after 1+ minutes in background - -**iOS:** -1. Play an HLS video with demuxed audio tracks -2. Call `setVideoTrackDisabled(true)` via the Flutter controller -3. Verify in Xcode console: `"Video track disabled via AVMediaSelectionGroup"` log appears -4. Use Charles Proxy: only audio segment requests visible -5. Call `setVideoTrackDisabled(false)` — verify video segments resume -6. Background the app — verify Now Playing info on lock screen, audio continues -7. Verify Control Center shows correct artwork and controls - ---- - -## 9. PR Checklist for the Fork - -- [ ] Feature branch created from latest main -- [ ] Android: `handleSetVideoTrackDisabled` added to `VideoPlayerMethodHandler.kt` -- [ ] Android: Foreground service started in `VideoPlayerNotificationHandler.setupMediaSession()` -- [ ] iOS: `handleSetVideoTrackDisabled` added to `VideoPlayerMethodHandler.swift` -- [ ] Dart: `setVideoTrackDisabled` added to `video_player_method_channel.dart` -- [ ] Dart: `setVideoTrackDisabled` added to `native_video_player_controller.dart` -- [ ] Tested on Android physical device with HLS demuxed stream -- [ ] Tested on iOS physical device with HLS demuxed stream -- [ ] Verified foreground notification works on Android background -- [ ] Verified Now Playing / lock screen controls work on iOS background -- [ ] No regressions in existing video playback, PiP, AirPlay, subtitles diff --git a/ios/Classes/Handlers/VideoPlayerMethodHandler.swift b/ios/Classes/Handlers/VideoPlayerMethodHandler.swift index 699cb3e..2e0bc82 100644 --- a/ios/Classes/Handlers/VideoPlayerMethodHandler.swift +++ b/ios/Classes/Handlers/VideoPlayerMethodHandler.swift @@ -1314,6 +1314,29 @@ extension VideoPlayerView { } if disabled { + // Check if HLS has demuxed (separate) audio tracks. + // AVMediaSelectionGroup for .audible is non-nil only when + // #EXT-X-MEDIA:TYPE=AUDIO is present with separate audio renditions. + // If nil/empty, audio is muxed inside video segments, so skip. + let hasDemuxedAudio: Bool + if let asset = playerItem.asset as? AVURLAsset, + let audioGroup = asset.mediaSelectionGroup( + forMediaCharacteristic: .audible + ), + !audioGroup.options.isEmpty { + hasDemuxedAudio = true + } else { + hasDemuxedAudio = false + } + + if !hasDemuxedAudio { + result([ + "skipped": true, + "reason": "no_demuxed_audio" + ]) + return + } + // Strategy 1: Deselect the visual media selection group (demuxed HLS) if let asset = playerItem.asset as? AVURLAsset, let videoGroup = asset.mediaSelectionGroup( From 2e7b41aa8707dcb92c88f041f290676e708f746e Mon Sep 17 00:00:00 2001 From: Vishnu Date: Thu, 16 Apr 2026 11:30:47 +0530 Subject: [PATCH 20/28] Gap when we switch the background to foreground --- .../handlers/VideoPlayerMethodHandler.kt | 2 +- .../VideoPlayerNotificationHandler.kt | 47 +++++++++++++++++++ .../Handlers/VideoPlayerMethodHandler.swift | 26 +++++----- 3 files changed, 61 insertions(+), 14 deletions(-) diff --git a/android/src/main/kotlin/com/huddlecommunity/better_native_video_player/handlers/VideoPlayerMethodHandler.kt b/android/src/main/kotlin/com/huddlecommunity/better_native_video_player/handlers/VideoPlayerMethodHandler.kt index 4010c52..df13d6a 100644 --- a/android/src/main/kotlin/com/huddlecommunity/better_native_video_player/handlers/VideoPlayerMethodHandler.kt +++ b/android/src/main/kotlin/com/huddlecommunity/better_native_video_player/handlers/VideoPlayerMethodHandler.kt @@ -915,7 +915,7 @@ class VideoPlayerMethodHandler( if (disabled) { notificationHandler.startForegroundPlayback() } else { - notificationHandler.stopForegroundPlayback() + notificationHandler.stopForegroundPlaybackWhenReady() } Log.d(TAG, "Video track ${if (disabled) "disabled" else "enabled"}") diff --git a/android/src/main/kotlin/com/huddlecommunity/better_native_video_player/handlers/VideoPlayerNotificationHandler.kt b/android/src/main/kotlin/com/huddlecommunity/better_native_video_player/handlers/VideoPlayerNotificationHandler.kt index fc80bfd..e99b753 100644 --- a/android/src/main/kotlin/com/huddlecommunity/better_native_video_player/handlers/VideoPlayerNotificationHandler.kt +++ b/android/src/main/kotlin/com/huddlecommunity/better_native_video_player/handlers/VideoPlayerNotificationHandler.kt @@ -46,6 +46,8 @@ class VideoPlayerNotificationHandler( private var mediaSession: MediaSession? = null private val handler = Handler(Looper.getMainLooper()) private var positionUpdateRunnable: Runnable? = null + private var pendingStopWhenReadyListener: Player.Listener? = null + private var pendingStopWhenReadyTimeout: Runnable? = null private val notificationManager: NotificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager private var currentArtwork: Bitmap? = null @@ -386,12 +388,57 @@ class VideoPlayerNotificationHandler( * Call when switching back to video mode from audio mode. */ fun stopForegroundPlayback() { + pendingStopWhenReadyListener?.let { player.removeListener(it) } + pendingStopWhenReadyTimeout?.let { handler.removeCallbacks(it) } + pendingStopWhenReadyListener = null + pendingStopWhenReadyTimeout = null + try { val serviceIntent = Intent(context, VideoPlayerMediaSessionService::class.java) context.stopService(serviceIntent) } catch (_: Exception) { } } + /** + * Stops the foreground service only after playback returns to STATE_READY. + * This keeps process priority elevated while video is being re-enabled. + */ + fun stopForegroundPlaybackWhenReady() { + if (player.playbackState == Player.STATE_READY && player.playWhenReady) { + stopForegroundPlayback() + return + } + + pendingStopWhenReadyListener?.let { player.removeListener(it) } + pendingStopWhenReadyTimeout?.let { handler.removeCallbacks(it) } + pendingStopWhenReadyListener = null + pendingStopWhenReadyTimeout = null + + val readyListener = object : Player.Listener { + override fun onPlaybackStateChanged(playbackState: Int) { + if (playbackState == Player.STATE_READY) { + pendingStopWhenReadyListener?.let { player.removeListener(it) } + pendingStopWhenReadyTimeout?.let { handler.removeCallbacks(it) } + pendingStopWhenReadyListener = null + pendingStopWhenReadyTimeout = null + stopForegroundPlayback() + } + } + } + + val timeoutRunnable = Runnable { + pendingStopWhenReadyListener?.let { player.removeListener(it) } + pendingStopWhenReadyListener = null + pendingStopWhenReadyTimeout = null + stopForegroundPlayback() + } + + pendingStopWhenReadyListener = readyListener + pendingStopWhenReadyTimeout = timeoutRunnable + player.addListener(readyListener) + handler.postDelayed(timeoutRunnable, 5000) + } + private fun showNotification() { // No-op: Media3 handles notification via the foreground service. } diff --git a/ios/Classes/Handlers/VideoPlayerMethodHandler.swift b/ios/Classes/Handlers/VideoPlayerMethodHandler.swift index 2e0bc82..8221188 100644 --- a/ios/Classes/Handlers/VideoPlayerMethodHandler.swift +++ b/ios/Classes/Handlers/VideoPlayerMethodHandler.swift @@ -1308,7 +1308,6 @@ extension VideoPlayerView { } guard let player = player, let playerItem = player.currentItem else { - print("[VideoPlayer] No current player item for video track disable") result(nil) return } @@ -1343,12 +1342,10 @@ extension VideoPlayerView { forMediaCharacteristic: .visual ) { playerItem.select(nil, in: videoGroup) - print("[VideoPlayer] Video track disabled via AVMediaSelectionGroup") } // Strategy 2: Restrict bitrate to audio-only threshold (fallback) playerItem.preferredPeakBitRate = 1.0 - print("[VideoPlayer] preferredPeakBitRate set to 1.0 (audio-only)") // Enable Now Playing info for lock screen / Control Center isAudioOnlyMode = true @@ -1366,18 +1363,21 @@ extension VideoPlayerView { } else if let firstOption = videoGroup.options.first { playerItem.select(firstOption, in: videoGroup) } - print("[VideoPlayer] Video track re-enabled via AVMediaSelectionGroup") } - // Clear bitrate restriction (0 = no limit) - playerItem.preferredPeakBitRate = 0 - print("[VideoPlayer] preferredPeakBitRate cleared (no limit)") - - // Clear Now Playing info when returning to video mode - isAudioOnlyMode = false - MPNowPlayingInfoCenter.default().nowPlayingInfo = nil - hasRegisteredRemoteCommands = false - print("[VideoPlayer] Now Playing info cleared") + // Use a high bitrate first for smoother transition while AVPlayer + // starts selecting video variants again. + playerItem.preferredPeakBitRate = 10_000_000 + + // Defer cleanup/removal of the cap slightly to reduce audible gaps + // while the video pipeline is being re-established. + DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { [weak self, weak playerItem] in + guard let self = self, let playerItem = playerItem else { return } + self.isAudioOnlyMode = false + MPNowPlayingInfoCenter.default().nowPlayingInfo = nil + self.hasRegisteredRemoteCommands = false + playerItem.preferredPeakBitRate = 0 + } } result(nil) From bea3344719f8266ff79cb980e3cc506720505de0 Mon Sep 17 00:00:00 2001 From: Vishnu Date: Thu, 16 Apr 2026 13:17:05 +0530 Subject: [PATCH 21/28] Fix the player when switching --- .../handlers/VideoPlayerMethodHandler.kt | 2 +- .../handlers/VideoPlayerNotificationHandler.kt | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/android/src/main/kotlin/com/huddlecommunity/better_native_video_player/handlers/VideoPlayerMethodHandler.kt b/android/src/main/kotlin/com/huddlecommunity/better_native_video_player/handlers/VideoPlayerMethodHandler.kt index df13d6a..4010c52 100644 --- a/android/src/main/kotlin/com/huddlecommunity/better_native_video_player/handlers/VideoPlayerMethodHandler.kt +++ b/android/src/main/kotlin/com/huddlecommunity/better_native_video_player/handlers/VideoPlayerMethodHandler.kt @@ -915,7 +915,7 @@ class VideoPlayerMethodHandler( if (disabled) { notificationHandler.startForegroundPlayback() } else { - notificationHandler.stopForegroundPlaybackWhenReady() + notificationHandler.stopForegroundPlayback() } Log.d(TAG, "Video track ${if (disabled) "disabled" else "enabled"}") diff --git a/android/src/main/kotlin/com/huddlecommunity/better_native_video_player/handlers/VideoPlayerNotificationHandler.kt b/android/src/main/kotlin/com/huddlecommunity/better_native_video_player/handlers/VideoPlayerNotificationHandler.kt index e99b753..8ed8acb 100644 --- a/android/src/main/kotlin/com/huddlecommunity/better_native_video_player/handlers/VideoPlayerNotificationHandler.kt +++ b/android/src/main/kotlin/com/huddlecommunity/better_native_video_player/handlers/VideoPlayerNotificationHandler.kt @@ -375,6 +375,14 @@ class VideoPlayerNotificationHandler( */ fun startForegroundPlayback() { if (mediaSession == null) return + + // Cancel any pending deferred stop — user switched back to audio mode + // before the previous stopWhenReady completed. + pendingStopWhenReadyListener?.let { player.removeListener(it) } + pendingStopWhenReadyTimeout?.let { handler.removeCallbacks(it) } + pendingStopWhenReadyListener = null + pendingStopWhenReadyTimeout = null + val serviceIntent = Intent(context, VideoPlayerMediaSessionService::class.java) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { context.startForegroundService(serviceIntent) From 4457d679d44f89fe49cef40e6f6b584f67655f2d Mon Sep 17 00:00:00 2001 From: Vishnu Date: Thu, 16 Apr 2026 15:53:31 +0530 Subject: [PATCH 22/28] Revert the changes for iOS --- .../Handlers/VideoPlayerMethodHandler.swift | 21 ++----------------- .../VideoPlayerNowPlayingHandler.swift | 5 ----- ios/Classes/View/VideoPlayerView.swift | 3 --- 3 files changed, 2 insertions(+), 27 deletions(-) diff --git a/ios/Classes/Handlers/VideoPlayerMethodHandler.swift b/ios/Classes/Handlers/VideoPlayerMethodHandler.swift index 8221188..72300ed 100644 --- a/ios/Classes/Handlers/VideoPlayerMethodHandler.swift +++ b/ios/Classes/Handlers/VideoPlayerMethodHandler.swift @@ -1346,12 +1346,6 @@ extension VideoPlayerView { // Strategy 2: Restrict bitrate to audio-only threshold (fallback) playerItem.preferredPeakBitRate = 1.0 - - // Enable Now Playing info for lock screen / Control Center - isAudioOnlyMode = true - if let mediaInfo = currentMediaInfo { - setupNowPlayingInfo(mediaInfo: mediaInfo) - } } else { // Re-enable: restore video rendition selection if let asset = playerItem.asset as? AVURLAsset, @@ -1365,19 +1359,8 @@ extension VideoPlayerView { } } - // Use a high bitrate first for smoother transition while AVPlayer - // starts selecting video variants again. - playerItem.preferredPeakBitRate = 10_000_000 - - // Defer cleanup/removal of the cap slightly to reduce audible gaps - // while the video pipeline is being re-established. - DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { [weak self, weak playerItem] in - guard let self = self, let playerItem = playerItem else { return } - self.isAudioOnlyMode = false - MPNowPlayingInfoCenter.default().nowPlayingInfo = nil - self.hasRegisteredRemoteCommands = false - playerItem.preferredPeakBitRate = 0 - } + // Clear bitrate restriction (0 = no limit) + playerItem.preferredPeakBitRate = 0 } result(nil) diff --git a/ios/Classes/Handlers/VideoPlayerNowPlayingHandler.swift b/ios/Classes/Handlers/VideoPlayerNowPlayingHandler.swift index efb6665..844263a 100644 --- a/ios/Classes/Handlers/VideoPlayerNowPlayingHandler.swift +++ b/ios/Classes/Handlers/VideoPlayerNowPlayingHandler.swift @@ -81,11 +81,6 @@ class RemoteCommandManager { extension VideoPlayerView { /// Sets up the Now Playing info for the Control Center and Lock Screen func setupNowPlayingInfo(mediaInfo: [String: Any]) { - // Only show Now Playing info in audio-only mode (background/manual audio switch). - guard isAudioOnlyMode else { - print("🎵 setupNowPlayingInfo skipped – not in audio-only mode") - return - } print("🎵 setupNowPlayingInfo called for view \(viewId)") print(" → Media title: \(mediaInfo["title"] ?? "Unknown")") print(" → Current Now Playing info before update: \(MPNowPlayingInfoCenter.default().nowPlayingInfo?[MPMediaItemPropertyTitle] as? String ?? "nil")") diff --git a/ios/Classes/View/VideoPlayerView.swift b/ios/Classes/View/VideoPlayerView.swift index 778c147..7e3d26e 100644 --- a/ios/Classes/View/VideoPlayerView.swift +++ b/ios/Classes/View/VideoPlayerView.swift @@ -35,9 +35,6 @@ import QuartzCore // This prevents re-registering and clearing targets unnecessarily var hasRegisteredRemoteCommands: Bool = false - /// When true, Now Playing info and remote commands are active (audio-only/background mode). - var isAudioOnlyMode: Bool = false - /// Force re-registration of remote commands /// Call this when you know the targets might have been removed externally func forceReregisterRemoteCommands() { From 14f86519ef5a11482b625b0cafa65105dcfa4034 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20Pu=CC=88hringer?= Date: Thu, 16 Apr 2026 13:51:04 +0200 Subject: [PATCH 23/28] Add native layout snapshot overlay for smooth iOS rotation transitions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit During orientation changes, Flutter's layout engine produces broken frames for platform views. This adds useNativeLayout/useFlutterLayout method channel calls that place a snapshot of the video on the root view with Auto Layout constraints. The snapshot animates smoothly with iOS rotation while Flutter re-layouts underneath, then is removed after the transition. - RotationSnapshotContainer handles portrait→landscape and landscape→portrait - Video content is cropped from AVPlayerLayer.videoRect (no letterbox bars) - Portrait video position is remembered for reverse rotation animation Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Handlers/VideoPlayerMethodHandler.swift | 4 +- ios/Classes/View/VideoPlayerView.swift | 164 ++++++++++++++++++ .../native_video_player_controller.dart | 13 ++ .../platform/video_player_method_channel.dart | 25 +++ 4 files changed, 204 insertions(+), 2 deletions(-) diff --git a/ios/Classes/Handlers/VideoPlayerMethodHandler.swift b/ios/Classes/Handlers/VideoPlayerMethodHandler.swift index 92e66fc..a60538d 100644 --- a/ios/Classes/Handlers/VideoPlayerMethodHandler.swift +++ b/ios/Classes/Handlers/VideoPlayerMethodHandler.swift @@ -922,7 +922,7 @@ extension VideoPlayerView { } /// Finds the AVPlayerLayer in the view hierarchy - private func findPlayerLayer() -> AVPlayerLayer? { + func findPlayerLayer() -> AVPlayerLayer? { // Get the player layer from the AVPlayerViewController's view if let playerView = playerViewController.view { return findPlayerLayerInView(playerView) @@ -931,7 +931,7 @@ extension VideoPlayerView { } /// Recursively searches for AVPlayerLayer in view hierarchy - private func findPlayerLayerInView(_ view: UIView) -> AVPlayerLayer? { + func findPlayerLayerInView(_ view: UIView) -> AVPlayerLayer? { // Check if this view's layer is an AVPlayerLayer if let playerLayer = view.layer as? AVPlayerLayer { return playerLayer diff --git a/ios/Classes/View/VideoPlayerView.swift b/ios/Classes/View/VideoPlayerView.swift index 5118e2d..2f2f1bb 100644 --- a/ios/Classes/View/VideoPlayerView.swift +++ b/ios/Classes/View/VideoPlayerView.swift @@ -90,6 +90,17 @@ import QuartzCore // Store HDR setting var enableHDR: Bool = false + // MARK: - Native Layout (orientation transition workaround) + // When true, the player view has been reparented to the root view with Auto Layout + // constraints so it stays centered during iOS orientation animations, bypassing + // Flutter's layout which looks broken during transitions. + var isUsingNativeLayout: Bool = false + // Snapshot overlay placed on the root view during rotation to cover + // Flutter's broken layout. The actual platform view stays untouched. + var nativeLayoutSnapshotView: UIView? + // Remembered portrait video rect so landscape→portrait can animate back. + var portraitVideoRectInRoot: CGRect? + // Store looping setting var enableLooping: Bool = false @@ -356,6 +367,91 @@ import QuartzCore return playerViewController.view } + // MARK: - Native Layout Overlay + + /// Places a snapshot of the current video frame on the root view, pinned + /// edge-to-edge with Auto Layout. The snapshot rotates smoothly with iOS + /// orientation animations while Flutter re-layouts underneath. + /// The actual platform view is never moved — no reparenting, no flicker. + func handleUseNativeLayout(result: @escaping FlutterResult) { + guard !isUsingNativeLayout else { + result(nil) + return + } + + let playerView = playerViewController.view! + guard let rootView = UIApplication.shared.delegate?.window??.rootViewController?.view else { + print("⚠️ [NativeLayout] Could not find root view — skipping") + result(FlutterError(code: "NO_ROOT_VIEW", message: "Could not find root view controller", details: nil)) + return + } + + // Get the actual video rect (excludes letterbox bars) from the player layer. + let videoRect: CGRect + if let playerLayer = findPlayerLayer(), playerLayer.videoRect != .zero { + videoRect = playerLayer.videoRect + } else { + videoRect = playerView.bounds + } + + // Render only the video content area into an image (no letterbox bars). + let renderer = UIGraphicsImageRenderer(size: videoRect.size) + let image = renderer.image { ctx in + ctx.cgContext.translateBy(x: -videoRect.origin.x, y: -videoRect.origin.y) + playerView.drawHierarchy(in: playerView.bounds, afterScreenUpdates: false) + } + + // Video's exact position in root-view coordinates. + let videoRectInRoot = playerView.convert(videoRect, to: rootView) + + // Remember the portrait position for the reverse rotation. + let isCurrentlyPortrait = rootView.bounds.height > rootView.bounds.width + if isCurrentlyPortrait { + portraitVideoRectInRoot = videoRectInRoot + } + + // Custom container that starts the image at the exact video position + // and animates to the appropriate target when iOS rotates. + let container = RotationSnapshotContainer( + image: image, + initialVideoRect: videoRectInRoot, + portraitVideoRect: portraitVideoRectInRoot + ) + container.translatesAutoresizingMaskIntoConstraints = false + + rootView.addSubview(container) + NSLayoutConstraint.activate([ + container.leadingAnchor.constraint(equalTo: rootView.leadingAnchor), + container.trailingAnchor.constraint(equalTo: rootView.trailingAnchor), + container.topAnchor.constraint(equalTo: rootView.topAnchor), + container.bottomAnchor.constraint(equalTo: rootView.bottomAnchor), + ]) + + nativeLayoutSnapshotView = container + isUsingNativeLayout = true + print("✅ [NativeLayout] Snapshot overlay added to root view") + result(nil) + } + + /// Removes the snapshot overlay. The real platform view was never moved, + /// so Flutter's layout is already correct underneath — no flicker. + func handleUseFlutterLayout(result: @escaping FlutterResult) { + guard isUsingNativeLayout else { + result(nil) + return + } + + CATransaction.begin() + CATransaction.setDisableActions(true) + nativeLayoutSnapshotView?.removeFromSuperview() + nativeLayoutSnapshotView = nil + CATransaction.commit() + + isUsingNativeLayout = false + print("✅ [NativeLayout] Snapshot overlay removed") + result(nil) + } + // MARK: - Audio Session Management /// Prepares and activates the audio session for video playback @@ -429,6 +525,10 @@ import QuartzCore handleSetUseAspectFill(call: call, result: result) case "getVideoDimensions": handleGetVideoDimensions(result: result) + case "useNativeLayout": + handleUseNativeLayout(result: result) + case "useFlutterLayout": + handleUseFlutterLayout(result: result) case "ensureSurfaceConnected": // No-op on iOS; each platform view uses its own AVPlayerViewController when shared. result(nil) @@ -1088,3 +1188,67 @@ import QuartzCore } } } + +// MARK: - Rotation Snapshot Container + +/// A full-screen black container that holds a video snapshot image. +/// The image starts at the video's exact screen position and animates to +/// the correct target when iOS rotation changes the container's bounds. +/// Because `layoutSubviews` is called inside iOS's rotation animation block, +/// the frame change is automatically animated. +/// +/// - Portrait → Landscape: animates from portrait video rect to fullscreen. +/// - Landscape → Portrait: animates from fullscreen to portrait video rect. +private class RotationSnapshotContainer: UIView { + private let imageView: UIImageView + private let initialVideoRect: CGRect + private let portraitVideoRect: CGRect? + private var previousBoundsSize: CGSize = .zero + private var hasRotated = false + + init(image: UIImage, initialVideoRect: CGRect, portraitVideoRect: CGRect?) { + self.imageView = UIImageView(image: image) + self.initialVideoRect = initialVideoRect + self.portraitVideoRect = portraitVideoRect + super.init(frame: .zero) + + backgroundColor = .black + clipsToBounds = true + imageView.contentMode = .scaleAspectFit + addSubview(imageView) + } + + required init?(coder: NSCoder) { fatalError() } + + override func layoutSubviews() { + super.layoutSubviews() + + if !hasRotated { + if previousBoundsSize == .zero { + // First layout pass — place image at exact video position. + imageView.frame = initialVideoRect + } else if bounds.size != previousBoundsSize { + // Bounds changed → rotation is happening inside the animation + // block. Pick the right target based on new orientation. + hasRotated = true + let isRotatingToPortrait = bounds.height > bounds.width + if isRotatingToPortrait, let portraitRect = portraitVideoRect { + imageView.frame = portraitRect + } else { + // Landscape — fill the screen. + imageView.frame = bounds + } + } + } else { + // Already rotated — maintain the target. + let isPortrait = bounds.height > bounds.width + if isPortrait, let portraitRect = portraitVideoRect { + imageView.frame = portraitRect + } else { + imageView.frame = bounds + } + } + + previousBoundsSize = bounds.size + } +} diff --git a/lib/src/controllers/native_video_player_controller.dart b/lib/src/controllers/native_video_player_controller.dart index 892ccae..e40ed04 100644 --- a/lib/src/controllers/native_video_player_controller.dart +++ b/lib/src/controllers/native_video_player_controller.dart @@ -2239,6 +2239,19 @@ class NativeVideoPlayerController { return await _methodChannel?.getVideoDimensions(); } + /// Reparents the native player view to the root UIViewController with + /// edge-pinned Auto Layout constraints so iOS orientation animations + /// keep the video centered. Call before an orientation transition. + Future useNativeLayout() async { + await _methodChannel?.useNativeLayout(); + } + + /// Returns the native player view to Flutter's layout control. + /// Call after the orientation transition has settled. + Future useFlutterLayout() async { + await _methodChannel?.useFlutterLayout(); + } + /// Checks if AirPlay is available on the device /// /// This is only available on iOS. On Android, this always returns false. diff --git a/lib/src/platform/video_player_method_channel.dart b/lib/src/platform/video_player_method_channel.dart index 37261e2..17989f2 100644 --- a/lib/src/platform/video_player_method_channel.dart +++ b/lib/src/platform/video_player_method_channel.dart @@ -407,6 +407,31 @@ class VideoPlayerMethodChannel { } } + /// Reparents the native player view from Flutter's container to the root + /// UIViewController's view with Auto Layout constraints (edge-pinned). + /// Use this before an orientation change so iOS animates the view smoothly. + Future useNativeLayout() async { + try { + await _methodChannel.invokeMethod('useNativeLayout', { + 'viewId': primaryPlatformViewId, + }); + } catch (e) { + debugPrint('Error calling useNativeLayout: $e'); + } + } + + /// Returns the native player view to Flutter's layout control. + /// Call this after the orientation transition settles. + Future useFlutterLayout() async { + try { + await _methodChannel.invokeMethod('useFlutterLayout', { + 'viewId': primaryPlatformViewId, + }); + } catch (e) { + debugPrint('Error calling useFlutterLayout: $e'); + } + } + /// Asks the native side to ensure the player surface is connected to this view. /// Called when reconnecting after all platform views were disposed (e.g. list→detail→back). Future ensureSurfaceConnected() async { From 18a2ce00222176b09edbbb487ec1ce5cccdea684 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20Pu=CC=88hringer?= Date: Thu, 16 Apr 2026 16:20:12 +0200 Subject: [PATCH 24/28] Switch to reparenting approach with RotationReparentContainer Reparent the live player view to the root view during rotation instead of using snapshots (which fail with DRM/HLS content on real devices). RotationReparentContainer positions the view at its exact Flutter location initially and animates to fullscreen during rotation. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Handlers/VideoPlayerMethodHandler.swift | 4 +- ios/Classes/View/VideoPlayerView.swift | 152 +++++++++--------- 2 files changed, 79 insertions(+), 77 deletions(-) diff --git a/ios/Classes/Handlers/VideoPlayerMethodHandler.swift b/ios/Classes/Handlers/VideoPlayerMethodHandler.swift index a60538d..92e66fc 100644 --- a/ios/Classes/Handlers/VideoPlayerMethodHandler.swift +++ b/ios/Classes/Handlers/VideoPlayerMethodHandler.swift @@ -922,7 +922,7 @@ extension VideoPlayerView { } /// Finds the AVPlayerLayer in the view hierarchy - func findPlayerLayer() -> AVPlayerLayer? { + private func findPlayerLayer() -> AVPlayerLayer? { // Get the player layer from the AVPlayerViewController's view if let playerView = playerViewController.view { return findPlayerLayerInView(playerView) @@ -931,7 +931,7 @@ extension VideoPlayerView { } /// Recursively searches for AVPlayerLayer in view hierarchy - func findPlayerLayerInView(_ view: UIView) -> AVPlayerLayer? { + private func findPlayerLayerInView(_ view: UIView) -> AVPlayerLayer? { // Check if this view's layer is an AVPlayerLayer if let playerLayer = view.layer as? AVPlayerLayer { return playerLayer diff --git a/ios/Classes/View/VideoPlayerView.swift b/ios/Classes/View/VideoPlayerView.swift index 2f2f1bb..76a2127 100644 --- a/ios/Classes/View/VideoPlayerView.swift +++ b/ios/Classes/View/VideoPlayerView.swift @@ -95,11 +95,10 @@ import QuartzCore // constraints so it stays centered during iOS orientation animations, bypassing // Flutter's layout which looks broken during transitions. var isUsingNativeLayout: Bool = false - // Snapshot overlay placed on the root view during rotation to cover - // Flutter's broken layout. The actual platform view stays untouched. - var nativeLayoutSnapshotView: UIView? - // Remembered portrait video rect so landscape→portrait can animate back. - var portraitVideoRectInRoot: CGRect? + var nativeLayoutConstraints: [NSLayoutConstraint] = [] + weak var flutterParentView: UIView? + var flutterFrame: CGRect = .zero + var portraitPlayerRectInRoot: CGRect? // Store looping setting var enableLooping: Bool = false @@ -369,10 +368,9 @@ import QuartzCore // MARK: - Native Layout Overlay - /// Places a snapshot of the current video frame on the root view, pinned - /// edge-to-edge with Auto Layout. The snapshot rotates smoothly with iOS - /// orientation animations while Flutter re-layouts underneath. - /// The actual platform view is never moved — no reparenting, no flicker. + /// Reparents the player view from Flutter's container to the root view + /// with edge-pinned Auto Layout constraints. The live video rotates + /// smoothly with iOS while Flutter re-layouts underneath. func handleUseNativeLayout(result: @escaping FlutterResult) { guard !isUsingNativeLayout else { result(nil) @@ -386,69 +384,80 @@ import QuartzCore return } - // Get the actual video rect (excludes letterbox bars) from the player layer. - let videoRect: CGRect - if let playerLayer = findPlayerLayer(), playerLayer.videoRect != .zero { - videoRect = playerLayer.videoRect - } else { - videoRect = playerView.bounds - } + flutterParentView = playerView.superview + flutterFrame = playerView.frame - // Render only the video content area into an image (no letterbox bars). - let renderer = UIGraphicsImageRenderer(size: videoRect.size) - let image = renderer.image { ctx in - ctx.cgContext.translateBy(x: -videoRect.origin.x, y: -videoRect.origin.y) - playerView.drawHierarchy(in: playerView.bounds, afterScreenUpdates: false) - } + // Get player's exact screen position before reparenting. + let currentRectInRoot = playerView.convert(playerView.bounds, to: rootView) - // Video's exact position in root-view coordinates. - let videoRectInRoot = playerView.convert(videoRect, to: rootView) - - // Remember the portrait position for the reverse rotation. + // Remember portrait position for the reverse rotation. let isCurrentlyPortrait = rootView.bounds.height > rootView.bounds.width if isCurrentlyPortrait { - portraitVideoRectInRoot = videoRectInRoot + portraitPlayerRectInRoot = currentRectInRoot } - // Custom container that starts the image at the exact video position - // and animates to the appropriate target when iOS rotates. - let container = RotationSnapshotContainer( - image: image, - initialVideoRect: videoRectInRoot, - portraitVideoRect: portraitVideoRectInRoot + // Reparent into a container on the root view. The container is + // edge-pinned; the player view starts at its exact screen position + // and animates to fullscreen (or back to portrait rect) when iOS + // rotation changes the bounds. + let container = RotationReparentContainer( + childView: playerView, + initialRect: currentRectInRoot, + portraitRect: portraitPlayerRectInRoot ) container.translatesAutoresizingMaskIntoConstraints = false - rootView.addSubview(container) - NSLayoutConstraint.activate([ + + nativeLayoutConstraints = [ container.leadingAnchor.constraint(equalTo: rootView.leadingAnchor), container.trailingAnchor.constraint(equalTo: rootView.trailingAnchor), container.topAnchor.constraint(equalTo: rootView.topAnchor), container.bottomAnchor.constraint(equalTo: rootView.bottomAnchor), - ]) + ] + NSLayoutConstraint.activate(nativeLayoutConstraints) - nativeLayoutSnapshotView = container isUsingNativeLayout = true - print("✅ [NativeLayout] Snapshot overlay added to root view") + print("✅ [NativeLayout] Player view reparented to root view") result(nil) } - /// Removes the snapshot overlay. The real platform view was never moved, - /// so Flutter's layout is already correct underneath — no flicker. + /// Returns the player view to Flutter's container. func handleUseFlutterLayout(result: @escaping FlutterResult) { guard isUsingNativeLayout else { result(nil) return } + let playerView = playerViewController.view! + CATransaction.begin() CATransaction.setDisableActions(true) - nativeLayoutSnapshotView?.removeFromSuperview() - nativeLayoutSnapshotView = nil + + // Remove the container (which holds the player view) from root. + let container = playerView.superview + NSLayoutConstraint.deactivate(nativeLayoutConstraints) + nativeLayoutConstraints = [] + + // Move player view back to Flutter's container. + playerView.removeFromSuperview() + playerView.translatesAutoresizingMaskIntoConstraints = true + + if let parent = flutterParentView { + parent.addSubview(playerView) + playerView.frame = parent.bounds + playerView.layoutIfNeeded() + } else { + print("⚠️ [NativeLayout] Flutter parent was deallocated") + } + + // Clean up the container. + container?.removeFromSuperview() + CATransaction.commit() isUsingNativeLayout = false - print("✅ [NativeLayout] Snapshot overlay removed") + flutterParentView = nil + print("✅ [NativeLayout] Player view returned to Flutter layout") result(nil) } @@ -1189,63 +1198,56 @@ import QuartzCore } } -// MARK: - Rotation Snapshot Container - -/// A full-screen black container that holds a video snapshot image. -/// The image starts at the video's exact screen position and animates to -/// the correct target when iOS rotation changes the container's bounds. -/// Because `layoutSubviews` is called inside iOS's rotation animation block, -/// the frame change is automatically animated. -/// -/// - Portrait → Landscape: animates from portrait video rect to fullscreen. -/// - Landscape → Portrait: animates from fullscreen to portrait video rect. -private class RotationSnapshotContainer: UIView { - private let imageView: UIImageView - private let initialVideoRect: CGRect - private let portraitVideoRect: CGRect? +// MARK: - Rotation Reparent Container + +/// Edge-pinned container on the root view that holds the reparented player view. +/// Starts the child at its exact screen position (matching Flutter's layout) +/// and animates to fullscreen when iOS rotation changes the bounds. +/// `layoutSubviews` is called inside iOS's rotation animation block, +/// so the frame change is automatically animated. +private class RotationReparentContainer: UIView { + private let initialRect: CGRect + private let portraitRect: CGRect? private var previousBoundsSize: CGSize = .zero private var hasRotated = false - init(image: UIImage, initialVideoRect: CGRect, portraitVideoRect: CGRect?) { - self.imageView = UIImageView(image: image) - self.initialVideoRect = initialVideoRect - self.portraitVideoRect = portraitVideoRect + init(childView: UIView, initialRect: CGRect, portraitRect: CGRect?) { + self.initialRect = initialRect + self.portraitRect = portraitRect super.init(frame: .zero) backgroundColor = .black clipsToBounds = true - imageView.contentMode = .scaleAspectFit - addSubview(imageView) + + childView.removeFromSuperview() + childView.translatesAutoresizingMaskIntoConstraints = true + addSubview(childView) } required init?(coder: NSCoder) { fatalError() } override func layoutSubviews() { super.layoutSubviews() + guard let child = subviews.first else { return } if !hasRotated { if previousBoundsSize == .zero { - // First layout pass — place image at exact video position. - imageView.frame = initialVideoRect + child.frame = initialRect } else if bounds.size != previousBoundsSize { - // Bounds changed → rotation is happening inside the animation - // block. Pick the right target based on new orientation. hasRotated = true let isRotatingToPortrait = bounds.height > bounds.width - if isRotatingToPortrait, let portraitRect = portraitVideoRect { - imageView.frame = portraitRect + if isRotatingToPortrait, let pRect = portraitRect { + child.frame = pRect } else { - // Landscape — fill the screen. - imageView.frame = bounds + child.frame = bounds } } } else { - // Already rotated — maintain the target. let isPortrait = bounds.height > bounds.width - if isPortrait, let portraitRect = portraitVideoRect { - imageView.frame = portraitRect + if isPortrait, let pRect = portraitRect { + child.frame = pRect } else { - imageView.frame = bounds + child.frame = bounds } } From ee63f45f7ca4e7593fcfe0b4dee4c7a5bb25af42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20Pu=CC=88hringer?= Date: Thu, 16 Apr 2026 16:42:10 +0200 Subject: [PATCH 25/28] Clean up rotation container on dispose to prevent black screen When the player page is exited while in landscape, the RotationReparentContainer stays on the root view blocking the app. Add cleanup in both handleDispose and deinit to remove the container. Co-Authored-By: Claude Opus 4.6 (1M context) --- ios/Classes/Handlers/VideoPlayerMethodHandler.swift | 11 +++++++++++ ios/Classes/View/VideoPlayerView.swift | 9 +++++++++ 2 files changed, 20 insertions(+) diff --git a/ios/Classes/Handlers/VideoPlayerMethodHandler.swift b/ios/Classes/Handlers/VideoPlayerMethodHandler.swift index 92e66fc..ea8651c 100644 --- a/ios/Classes/Handlers/VideoPlayerMethodHandler.swift +++ b/ios/Classes/Handlers/VideoPlayerMethodHandler.swift @@ -712,6 +712,17 @@ extension VideoPlayerView { isDisposed = true invalidateEventChannel() + // Clean up rotation container if still on root view. + if isUsingNativeLayout { + let playerView = playerViewController.view! + let container = playerView.superview + playerView.removeFromSuperview() + container?.removeFromSuperview() + isUsingNativeLayout = false + flutterParentView = nil + print("🧹 [VideoPlayerMethodHandler] Cleaned up rotation container") + } + // Pause the player first player?.pause() print("⏸️ [VideoPlayerMethodHandler] Player paused") diff --git a/ios/Classes/View/VideoPlayerView.swift b/ios/Classes/View/VideoPlayerView.swift index 76a2127..f9a14c7 100644 --- a/ios/Classes/View/VideoPlayerView.swift +++ b/ios/Classes/View/VideoPlayerView.swift @@ -914,6 +914,15 @@ import QuartzCore isDisposed = true invalidateEventChannel() + // Clean up rotation container if still on root view. + if isUsingNativeLayout { + let playerView = playerViewController.view! + let container = playerView.superview + playerView.removeFromSuperview() + container?.removeFromSuperview() + isUsingNativeLayout = false + } + // Use the isPipCurrentlyActive flag to check if PiP is active let isPipActiveNow = isPipCurrentlyActive From 6cf1d9630a0e54096063a0ca4c1aa6ce4bd409b4 Mon Sep 17 00:00:00 2001 From: Vishnu Date: Fri, 17 Apr 2026 08:34:31 +0530 Subject: [PATCH 26/28] Fix Audio Focus and crash issue --- .../handlers/VideoPlayerMethodHandler.kt | 67 ------------------- .../VideoPlayerNotificationHandler.kt | 54 --------------- .../manager/SharedPlayerManager.kt | 9 ++- 3 files changed, 8 insertions(+), 122 deletions(-) diff --git a/android/src/main/kotlin/com/huddlecommunity/better_native_video_player/handlers/VideoPlayerMethodHandler.kt b/android/src/main/kotlin/com/huddlecommunity/better_native_video_player/handlers/VideoPlayerMethodHandler.kt index 4010c52..3c1bb32 100644 --- a/android/src/main/kotlin/com/huddlecommunity/better_native_video_player/handlers/VideoPlayerMethodHandler.kt +++ b/android/src/main/kotlin/com/huddlecommunity/better_native_video_player/handlers/VideoPlayerMethodHandler.kt @@ -2,11 +2,7 @@ package com.huddlecommunity.better_native_video_player.handlers import android.app.Activity import android.content.Context -import android.media.AudioAttributes -import android.media.AudioFocusRequest -import android.media.AudioManager import android.net.Uri -import android.os.Build import android.util.Log import androidx.media3.common.C import androidx.media3.common.MediaItem @@ -45,25 +41,6 @@ class VideoPlayerMethodHandler( private const val TAG = "VideoPlayerMethod" } - private val audioManager: AudioManager = - context.getSystemService(Context.AUDIO_SERVICE) as AudioManager - private var audioFocusRequest: AudioFocusRequest? = null - private val legacyAudioFocusListener = AudioManager.OnAudioFocusChangeListener { } - - private val audioFocusPlaybackListener = object : Player.Listener { - override fun onIsPlayingChanged(isPlaying: Boolean) { - if (isPlaying) { - requestAudioFocusForPlayback() - } else { - abandonAudioFocusForPlayback() - } - } - } - - init { - player.addListener(audioFocusPlaybackListener) - } - private var availableQualities: List> = emptyList() private var isAutoQuality = false private var lastBitrateCheck = 0L @@ -73,45 +50,6 @@ class VideoPlayerMethodHandler( // Callback to handle fullscreen requests from Flutter var onFullscreenRequest: ((Boolean) -> Unit)? = null - private fun requestAudioFocusForPlayback() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - if (audioFocusRequest == null) { - val attrs = AudioAttributes.Builder() - .setUsage(AudioAttributes.USAGE_MEDIA) - .setContentType(AudioAttributes.CONTENT_TYPE_MOVIE) - .build() - audioFocusRequest = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN) - .setAudioAttributes(attrs) - .build() - } - audioFocusRequest?.let { request -> - val result = audioManager.requestAudioFocus(request) - if (result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED) { - Log.d(TAG, "Audio focus requested and granted") - } - } - } else { - @Suppress("DEPRECATION") - audioManager.requestAudioFocus( - legacyAudioFocusListener, - AudioManager.STREAM_MUSIC, - AudioManager.AUDIOFOCUS_GAIN - ) - } - } - - private fun abandonAudioFocusForPlayback() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - audioFocusRequest?.let { request -> - audioManager.abandonAudioFocusRequest(request) - audioFocusRequest = null - } - } else { - @Suppress("DEPRECATION") - audioManager.abandonAudioFocus(legacyAudioFocusListener) - } - } - /** * Handles incoming method calls from Flutter */ @@ -351,7 +289,6 @@ class VideoPlayerMethodHandler( // Auto play if requested - MUST be done after player is ready if (autoPlay) { Log.d(TAG, "Auto-playing video after ready") - requestAudioFocusForPlayback() player.play() // Play event will be sent automatically by VideoPlayerObserver } @@ -372,7 +309,6 @@ class VideoPlayerMethodHandler( * Starts playback */ private fun handlePlay(result: MethodChannel.Result) { - requestAudioFocusForPlayback() player.play() result.success(null) } @@ -382,7 +318,6 @@ class VideoPlayerMethodHandler( */ private fun handlePause(result: MethodChannel.Result) { player.pause() - abandonAudioFocusForPlayback() result.success(null) } @@ -585,8 +520,6 @@ class VideoPlayerMethodHandler( * Disposes the player */ private fun handleDispose(result: MethodChannel.Result) { - player.removeListener(audioFocusPlaybackListener) - abandonAudioFocusForPlayback() player.stop() // Remove from shared manager if this is a shared player diff --git a/android/src/main/kotlin/com/huddlecommunity/better_native_video_player/handlers/VideoPlayerNotificationHandler.kt b/android/src/main/kotlin/com/huddlecommunity/better_native_video_player/handlers/VideoPlayerNotificationHandler.kt index 8ed8acb..a3eb1cf 100644 --- a/android/src/main/kotlin/com/huddlecommunity/better_native_video_player/handlers/VideoPlayerNotificationHandler.kt +++ b/android/src/main/kotlin/com/huddlecommunity/better_native_video_player/handlers/VideoPlayerNotificationHandler.kt @@ -46,8 +46,6 @@ class VideoPlayerNotificationHandler( private var mediaSession: MediaSession? = null private val handler = Handler(Looper.getMainLooper()) private var positionUpdateRunnable: Runnable? = null - private var pendingStopWhenReadyListener: Player.Listener? = null - private var pendingStopWhenReadyTimeout: Runnable? = null private val notificationManager: NotificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager private var currentArtwork: Bitmap? = null @@ -376,13 +374,6 @@ class VideoPlayerNotificationHandler( fun startForegroundPlayback() { if (mediaSession == null) return - // Cancel any pending deferred stop — user switched back to audio mode - // before the previous stopWhenReady completed. - pendingStopWhenReadyListener?.let { player.removeListener(it) } - pendingStopWhenReadyTimeout?.let { handler.removeCallbacks(it) } - pendingStopWhenReadyListener = null - pendingStopWhenReadyTimeout = null - val serviceIntent = Intent(context, VideoPlayerMediaSessionService::class.java) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { context.startForegroundService(serviceIntent) @@ -396,57 +387,12 @@ class VideoPlayerNotificationHandler( * Call when switching back to video mode from audio mode. */ fun stopForegroundPlayback() { - pendingStopWhenReadyListener?.let { player.removeListener(it) } - pendingStopWhenReadyTimeout?.let { handler.removeCallbacks(it) } - pendingStopWhenReadyListener = null - pendingStopWhenReadyTimeout = null - try { val serviceIntent = Intent(context, VideoPlayerMediaSessionService::class.java) context.stopService(serviceIntent) } catch (_: Exception) { } } - /** - * Stops the foreground service only after playback returns to STATE_READY. - * This keeps process priority elevated while video is being re-enabled. - */ - fun stopForegroundPlaybackWhenReady() { - if (player.playbackState == Player.STATE_READY && player.playWhenReady) { - stopForegroundPlayback() - return - } - - pendingStopWhenReadyListener?.let { player.removeListener(it) } - pendingStopWhenReadyTimeout?.let { handler.removeCallbacks(it) } - pendingStopWhenReadyListener = null - pendingStopWhenReadyTimeout = null - - val readyListener = object : Player.Listener { - override fun onPlaybackStateChanged(playbackState: Int) { - if (playbackState == Player.STATE_READY) { - pendingStopWhenReadyListener?.let { player.removeListener(it) } - pendingStopWhenReadyTimeout?.let { handler.removeCallbacks(it) } - pendingStopWhenReadyListener = null - pendingStopWhenReadyTimeout = null - stopForegroundPlayback() - } - } - } - - val timeoutRunnable = Runnable { - pendingStopWhenReadyListener?.let { player.removeListener(it) } - pendingStopWhenReadyListener = null - pendingStopWhenReadyTimeout = null - stopForegroundPlayback() - } - - pendingStopWhenReadyListener = readyListener - pendingStopWhenReadyTimeout = timeoutRunnable - player.addListener(readyListener) - handler.postDelayed(timeoutRunnable, 5000) - } - private fun showNotification() { // No-op: Media3 handles notification via the foreground service. } diff --git a/android/src/main/kotlin/com/huddlecommunity/better_native_video_player/manager/SharedPlayerManager.kt b/android/src/main/kotlin/com/huddlecommunity/better_native_video_player/manager/SharedPlayerManager.kt index 895f841..20af45a 100644 --- a/android/src/main/kotlin/com/huddlecommunity/better_native_video_player/manager/SharedPlayerManager.kt +++ b/android/src/main/kotlin/com/huddlecommunity/better_native_video_player/manager/SharedPlayerManager.kt @@ -3,6 +3,7 @@ package com.huddlecommunity.better_native_video_player.manager import android.content.Context import android.content.Intent import android.util.Log +import androidx.media3.common.C import androidx.media3.common.AudioAttributes import androidx.media3.exoplayer.ExoPlayer import com.huddlecommunity.better_native_video_player.VideoPlayerMediaSessionService @@ -37,7 +38,13 @@ object SharedPlayerManager { val alreadyExisted = players.containsKey(controllerId) val player = players.getOrPut(controllerId) { ExoPlayer.Builder(context) - .setAudioAttributes(AudioAttributes.DEFAULT, false) + .setAudioAttributes( + AudioAttributes.Builder() + .setUsage(C.USAGE_MEDIA) + .setContentType(C.AUDIO_CONTENT_TYPE_MOVIE) + .build(), + true + ) .setSeekBackIncrementMs(SEEK_INCREMENT_MS) .setSeekForwardIncrementMs(SEEK_INCREMENT_MS) .build() From 80962f103ecbd48e151a57db7c60fd737ea1a39e Mon Sep 17 00:00:00 2001 From: Vishnu Date: Fri, 17 Apr 2026 13:25:20 +0530 Subject: [PATCH 27/28] Fix the issues with code --- .../VideoPlayerMediaSessionService.kt | 109 +++--- .../VideoPlayerView.kt | 8 +- .../handlers/VideoPlayerMethodHandler.kt | 8 +- .../VideoPlayerNotificationHandler.kt | 339 ++++-------------- .../manager/SharedPlayerManager.kt | 5 +- lib/better_native_video_player.dart | 1 + .../native_video_player_controller.dart | 34 +- ...ive_video_player_track_disable_result.dart | 23 ++ .../platform/video_player_method_channel.dart | 36 +- 9 files changed, 212 insertions(+), 351 deletions(-) create mode 100644 lib/src/models/native_video_player_track_disable_result.dart diff --git a/android/src/main/kotlin/com/huddlecommunity/better_native_video_player/VideoPlayerMediaSessionService.kt b/android/src/main/kotlin/com/huddlecommunity/better_native_video_player/VideoPlayerMediaSessionService.kt index bee00e3..f1f1f00 100644 --- a/android/src/main/kotlin/com/huddlecommunity/better_native_video_player/VideoPlayerMediaSessionService.kt +++ b/android/src/main/kotlin/com/huddlecommunity/better_native_video_player/VideoPlayerMediaSessionService.kt @@ -15,15 +15,18 @@ import androidx.media3.session.MediaSession import androidx.media3.session.MediaSessionService /** - * Foreground MediaSessionService for background video/audio playback. + * Foreground MediaSessionService for background audio playback. * - * Modelled after InsightMediaSessionService in the insight_timer_player package. - * Key difference: the ExoPlayer and MediaSession are created EXTERNALLY in - * VideoPlayerNotificationHandler and handed in via the static setMediaSession(). + * The ExoPlayer and MediaSession are created externally in + * [com.huddlecommunity.better_native_video_player.handlers.VideoPlayerNotificationHandler] + * and registered with this service via [setActiveSession] before a handler starts + * foreground playback. * - * Media3's DefaultMediaNotificationProvider (configured via a CustomMediaNotificationProvider - * subclass) builds the notification through startForeground(), which is exempt from - * the POST_NOTIFICATIONS runtime permission on Android 13+. + * Only one active session is tracked at a time. When a second handler requests + * foreground playback, its session replaces the previous one (single-owner model). + * The system notification is built by Media3's [DefaultMediaNotificationProvider]; + * it runs through [startForeground] and is therefore exempt from the + * POST_NOTIFICATIONS runtime permission on Android 13+. */ @androidx.annotation.OptIn(UnstableApi::class) class VideoPlayerMediaSessionService : MediaSessionService() { @@ -31,98 +34,104 @@ class VideoPlayerMediaSessionService : MediaSessionService() { companion object { private const val TAG = "VideoPlayerMSS" private const val NOTIFICATION_ID = 1001 - private const val CHANNEL_ID = "video_player_channel" + internal const val CHANNEL_ID = "video_player_channel" private const val LEGACY_CHANNEL_ID = "video_player" - private var mediaSession: MediaSession? = null - private var isForegroundStarted = false + private var activeSession: MediaSession? = null - fun getMediaSession(): MediaSession? = mediaSession + fun getActiveSession(): MediaSession? = activeSession /** - * Called by VideoPlayerNotificationHandler after it creates the MediaSession. + * Registers the session that should drive the foreground notification. + * If another handler's session is already active, it is replaced. */ - fun setMediaSession(session: MediaSession?) { - Log.d(TAG, "===== setMediaSession: session=${session != null}, player=${session?.player != null}") - mediaSession = session + fun setActiveSession(session: MediaSession?) { + activeSession = session + } + + /** + * Clears the active session only if it still matches [session]. A handler + * must call this on release so a later handler's session is not wiped. + */ + fun clearActiveSessionIfMatches(session: MediaSession) { + if (activeSession === session) { + activeSession = null + } } } + private var registeredSession: MediaSession? = null + private var isForegroundStarted = false + // ── lifecycle ──────────────────────────────────────────────────────────── override fun onCreate() { super.onCreate() - // Wire up the notification provider exactly like InsightMediaSessionService. - // This subclass controls which buttons appear and in what order. val provider = DefaultMediaNotificationProvider.Builder(this) .setChannelId(CHANNEL_ID) .setNotificationId(NOTIFICATION_ID) .build() - // Use the app's dedicated notification icon (monochrome drawable). - // android.R.drawable.ic_media_play is a safe fallback if ic_notification doesn't exist. - val iconRes = resolveNotificationIcon() - provider.setSmallIcon(iconRes) + provider.setSmallIcon(resolveNotificationIcon()) setMediaNotificationProvider(provider) setListener(ServiceListener()) - Log.d(TAG, "===== SERVICE onCreate – provider & listener set") } override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? { - Log.d(TAG, "===== SERVICE onGetSession, session=${mediaSession != null}") - return mediaSession + return activeSession } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - Log.d(TAG, "===== SERVICE onStartCommand, session=${mediaSession != null}") - - // Immediately satisfy Android's 5-second startForeground() deadline with a placeholder. + // Satisfy Android's 5s startForeground() deadline with a placeholder; + // Media3 will replace it via onUpdateNotification moments later. startForegroundIfNeeded() - // Explicitly register the external MediaSession with the service. - // MediaSessionService's notification pipeline only activates when it knows about a session. - // onGetSession() is never called because no MediaController binds via startForegroundService(). - // addSession() is the Media3 API for registering externally-created sessions — - // it triggers the internal connection + notification pipeline. - val session = mediaSession - if (session != null) { - Log.d(TAG, "===== SERVICE calling addSession()") - addSession(session) + val session = activeSession + if (session == null) { + // Nothing to play — let the service die rather than sit foregrounded. + stopSelf() + return super.onStartCommand(intent, flags, startId) + } + + // addSession can throw IllegalArgumentException if the same session is added twice + // on some Media3 versions, so swap registered sessions only on change. + if (session !== registeredSession) { + registeredSession?.let { prev -> + runCatching { removeSession(prev) } + .onFailure { Log.w(TAG, "removeSession failed: ${it.message}") } + } + runCatching { addSession(session) } + .onFailure { Log.w(TAG, "addSession failed: ${it.message}") } + registeredSession = session } return super.onStartCommand(intent, flags, startId) } override fun onUpdateNotification(session: MediaSession, startInForegroundRequired: Boolean) { - // Let Media3 + CustomMediaNotificationProvider handle everything. super.onUpdateNotification(session, startInForegroundRequired) } override fun onTaskRemoved(rootIntent: Intent?) { - Log.d(TAG, "===== SERVICE onTaskRemoved") - val session = mediaSession - if (session == null || !session.player.playWhenReady || session.player.mediaItemCount == 0) { + val session = activeSession + // Stop only when there is no session or no media loaded. Keep the service + // alive while media is loaded — even when paused — so the user can + // resume from the notification after swiping the task away. + if (session == null || session.player.mediaItemCount == 0) { stopSelf() } } override fun onDestroy() { - Log.d(TAG, "===== SERVICE onDestroy") isForegroundStarted = false + registeredSession = null clearListener() super.onDestroy() } // ── foreground promotion ──────────────────────────────────────────────── - /** - * Posts a minimal foreground notification so the service satisfies Android's - * startForeground() contract. Media3 will replace it with the real media - * notification moments later via [onUpdateNotification]. - * - * Copied from InsightMediaSessionService.startForegroundIfNeeded(). - */ private fun startForegroundIfNeeded() { if (isForegroundStarted) return @@ -134,7 +143,6 @@ class VideoPlayerMediaSessionService : MediaSessionService() { val notification = NotificationCompat.Builder(this, CHANNEL_ID) .setSmallIcon(resolveNotificationIcon()) - .setContentTitle("Playing") .setContentIntent(pendingIntent) .setCategory(NotificationCompat.CATEGORY_SERVICE) .setPriority(NotificationCompat.PRIORITY_LOW) @@ -151,7 +159,6 @@ class VideoPlayerMediaSessionService : MediaSessionService() { startForeground(NOTIFICATION_ID, notification) } isForegroundStarted = true - Log.d(TAG, "===== SERVICE startForeground done (MEDIA_PLAYBACK)") } // ── notification channel ──────────────────────────────────────────────── @@ -159,7 +166,6 @@ class VideoPlayerMediaSessionService : MediaSessionService() { private fun ensureNotificationChannel(nmc: NotificationManagerCompat) { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return - // Remove legacy channel if it exists if (nmc.getNotificationChannel(LEGACY_CHANNEL_ID) != null) { nmc.deleteNotificationChannel(LEGACY_CHANNEL_ID) } @@ -181,7 +187,6 @@ class VideoPlayerMediaSessionService : MediaSessionService() { // ── icon helper ───────────────────────────────────────────────────────── private fun resolveNotificationIcon(): Int { - // Try the app's dedicated notification icon first (same as audio player). val resId = resources.getIdentifier("ic_notification", "drawable", packageName) return if (resId != 0) resId else android.R.drawable.ic_media_play } diff --git a/android/src/main/kotlin/com/huddlecommunity/better_native_video_player/VideoPlayerView.kt b/android/src/main/kotlin/com/huddlecommunity/better_native_video_player/VideoPlayerView.kt index 005ff99..3b6fe9d 100644 --- a/android/src/main/kotlin/com/huddlecommunity/better_native_video_player/VideoPlayerView.kt +++ b/android/src/main/kotlin/com/huddlecommunity/better_native_video_player/VideoPlayerView.kt @@ -134,7 +134,13 @@ class VideoPlayerView( Log.d(TAG, "No controller ID provided, creating new player") isSharedPlayer = false ExoPlayer.Builder(context) - .setAudioAttributes(AudioAttributes.DEFAULT, false) + .setAudioAttributes( + AudioAttributes.Builder() + .setUsage(C.USAGE_MEDIA) + .setContentType(C.AUDIO_CONTENT_TYPE_MOVIE) + .build(), + true + ) .setSeekBackIncrementMs(SEEK_INCREMENT_MS) .setSeekForwardIncrementMs(SEEK_INCREMENT_MS) .build() diff --git a/android/src/main/kotlin/com/huddlecommunity/better_native_video_player/handlers/VideoPlayerMethodHandler.kt b/android/src/main/kotlin/com/huddlecommunity/better_native_video_player/handlers/VideoPlayerMethodHandler.kt index 3c1bb32..787b9db 100644 --- a/android/src/main/kotlin/com/huddlecommunity/better_native_video_player/handlers/VideoPlayerMethodHandler.kt +++ b/android/src/main/kotlin/com/huddlecommunity/better_native_video_player/handlers/VideoPlayerMethodHandler.kt @@ -522,10 +522,16 @@ class VideoPlayerMethodHandler( private fun handleDispose(result: MethodChannel.Result) { player.stop() - // Remove from shared manager if this is a shared player if (controllerId != null) { + // Shared player: SharedPlayerManager.removePlayer() releases the + // notification handler, stops the service, and releases the player. SharedPlayerManager.removePlayer(context, controllerId) Log.d(TAG, "Removed shared player for controller ID: $controllerId") + } else { + // Non-shared player: tear down the foreground service and MediaSession + // here rather than relying on PlatformView.dispose() running first. + // release() is idempotent, so a later dispose call is safe. + notificationHandler.release() } eventHandler.sendEvent("stopped") diff --git a/android/src/main/kotlin/com/huddlecommunity/better_native_video_player/handlers/VideoPlayerNotificationHandler.kt b/android/src/main/kotlin/com/huddlecommunity/better_native_video_player/handlers/VideoPlayerNotificationHandler.kt index a3eb1cf..e4a1a52 100644 --- a/android/src/main/kotlin/com/huddlecommunity/better_native_video_player/handlers/VideoPlayerNotificationHandler.kt +++ b/android/src/main/kotlin/com/huddlecommunity/better_native_video_player/handlers/VideoPlayerNotificationHandler.kt @@ -1,36 +1,23 @@ package com.huddlecommunity.better_native_video_player.handlers -import android.app.Notification -import android.app.NotificationChannel -import android.app.NotificationManager import android.app.PendingIntent import android.content.Context import android.content.Intent -import android.graphics.Bitmap -import android.graphics.BitmapFactory import android.net.Uri import android.os.Build -import android.os.Handler -import android.os.Looper -import android.support.v4.media.session.MediaSessionCompat -import androidx.core.app.NotificationCompat -import androidx.media.app.NotificationCompat as MediaNotificationCompat import androidx.media3.common.ForwardingPlayer import androidx.media3.common.MediaMetadata import androidx.media3.common.Player import androidx.media3.exoplayer.ExoPlayer import androidx.media3.session.MediaSession import androidx.media3.session.MediaSession.ConnectionResult -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext import com.huddlecommunity.better_native_video_player.VideoPlayerMediaSessionService -import java.net.URL /** - * Handles MediaSession and notification controls for lock screen and notification area - * Equivalent to iOS VideoPlayerNowPlayingHandler + * Owns the per-controller [MediaSession] and coordinates foreground-service + * lifecycle for audio-only / background playback. The system notification + * itself is produced by Media3's DefaultMediaNotificationProvider inside the + * service; this class only creates the session and toggles the service on/off. */ class VideoPlayerNotificationHandler( private val context: Context, @@ -38,40 +25,32 @@ class VideoPlayerNotificationHandler( private var eventHandler: VideoPlayerEventHandler ) { companion object { - private const val NOTIFICATION_ID = 1001 - private const val CHANNEL_ID = "video_player_channel" private var sessionCounter = 0 } private var mediaSession: MediaSession? = null - private val handler = Handler(Looper.getMainLooper()) - private var positionUpdateRunnable: Runnable? = null - private val notificationManager: NotificationManager = - context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - private var currentArtwork: Bitmap? = null - private var currentArtworkUrl: String? = null // Track which artwork we're currently loading - - // Store current metadata separately to avoid reading stale data from player + + // Current metadata (wrappedPlayer reads these live, no session restart needed) private var currentTitle: String = "Video" private var currentSubtitle: String = "" private var showSkipControls: Boolean = true private var showSystemPreviousTrackControl: Boolean = false private var showSystemNextTrackControl: Boolean = false + // Tracks whether we've asked the service to host a foreground notification + // for this handler. Acts as a state-transition guard against rapid + // setVideoTrackDisabled toggles requesting duplicate startForegroundService calls. + private var foregroundRequested: Boolean = false + + // Guards release() so both handleDispose and PlatformView.dispose can call it. + private var isReleased: Boolean = false + /** * Wraps the ExoPlayer so that seekBack/seekForward can be intercepted when track-navigation * buttons are active. The system notification always calls seekBack()/seekForward() on the * player regardless of custom session commands, so interception must happen here. */ private val wrappedPlayer = object : ForwardingPlayer(player) { - /** - * Dynamically include/exclude the seek-to-previous and seek-to-next player commands based - * on the track navigation flags. ExoPlayer never adds these commands for a single-item - * playlist, so the system notification would never show ⏮/⏭ without this override. - * The MediaSession calls getAvailableCommands() when pushing updates to controllers, so - * updating the flags before setMediaSource() fires onAvailableCommandsChanged is enough - * to make the buttons appear/disappear without recreating the session. - */ override fun getAvailableCommands(): Player.Commands { val builder = super.getAvailableCommands().buildUpon() if (showSystemPreviousTrackControl) { @@ -168,10 +147,6 @@ class VideoPlayerNotificationHandler( } } - init { - createNotificationChannel() - } - private val playerListener = object : Player.Listener { override fun onPlayWhenReadyChanged(playWhenReady: Boolean, reason: Int) { if (playWhenReady) { @@ -179,37 +154,17 @@ class VideoPlayerNotificationHandler( } else { eventHandler.sendEvent("pause") } - // Media3's MediaSessionService handles notification updates automatically. } override fun onPlaybackStateChanged(playbackState: Int) { when (playbackState) { Player.STATE_ENDED, Player.STATE_IDLE -> { - // Stop the foreground service when playback ends - try { - val serviceIntent = Intent(context, VideoPlayerMediaSessionService::class.java) - context.stopService(serviceIntent) - } catch (_: Exception) { } + // Stop the foreground service when playback ends; Media3 handles + // regular notification updates in all other states. + stopForegroundPlayback() } - else -> { /* Media3 handles notification updates */ } - } - } - } - - /** - * Creates notification channel for Android O+ - */ - private fun createNotificationChannel() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - val channel = NotificationChannel( - CHANNEL_ID, - "Video Player", - NotificationManager.IMPORTANCE_LOW - ).apply { - description = "Media playback controls" - setShowBadge(false) + else -> { /* no-op */ } } - notificationManager.createNotificationChannel(channel) } } @@ -234,15 +189,14 @@ class VideoPlayerNotificationHandler( } /** - * Updates the player's current MediaItem metadata (title, artist, album) - * This is essential for MediaSession to display correct info in notification + * Updates the player's current MediaItem metadata (title, artist, album, artwork URI). + * Media3's DefaultMediaNotificationProvider reads these values to build the notification. */ private fun updatePlayerMediaItemMetadata(mediaInfo: Map?) { if (mediaInfo == null) return val currentItem = player.currentMediaItem ?: return - // Build new metadata from mediaInfo val metadataBuilder = MediaMetadata.Builder() (mediaInfo["title"] as? String)?.let { metadataBuilder.setTitle(it) } (mediaInfo["subtitle"] as? String)?.let { metadataBuilder.setArtist(it) } @@ -252,12 +206,10 @@ class VideoPlayerNotificationHandler( .onSuccess { metadataBuilder.setArtworkUri(it) } } - // Create updated MediaItem with new metadata val updatedItem = currentItem.buildUpon() .setMediaMetadata(metadataBuilder.build()) .build() - // Replace the MediaItem without interrupting playback val wasPlaying = player.isPlaying val position = player.currentPosition player.replaceMediaItem(player.currentMediaItemIndex, updatedItem) @@ -266,23 +218,21 @@ class VideoPlayerNotificationHandler( } /** - * Sets up MediaSession with metadata (title, subtitle, artwork) - * Similar to iOS MPNowPlayingInfoCenter - shows on lock screen when playing - * MediaSession automatically provides lock screen controls and system media notification + * Sets up MediaSession with metadata (title, subtitle, artwork). + * Similar to iOS MPNowPlayingInfoCenter — provides lock screen controls and system + * media notification. The foreground service is NOT started here; call + * [startForegroundPlayback] when switching to audio-only or background mode. */ fun setupMediaSession(mediaInfo: Map?) { - // Extract metadata from the provided info val newTitle = (mediaInfo?.get("title") as? String) ?: "Video" val newSubtitle = (mediaInfo?.get("subtitle") as? String) ?: "" val newShowSkipControls = (mediaInfo?.get("showSkipControls") as? Boolean) ?: true val newShowSystemPreviousTrackControl = (mediaInfo?.get("showSystemPreviousTrackControl") as? Boolean) ?: false val newShowSystemNextTrackControl = (mediaInfo?.get("showSystemNextTrackControl") as? Boolean) ?: false - // Check if media info has actually changed to avoid unnecessary updates val mediaInfoChanged = (newTitle != currentTitle || newSubtitle != currentSubtitle) val seekPermissionChanged = newShowSkipControls != showSkipControls - // Store the new metadata (wrappedPlayer reads these fields live, so no session restart needed) currentTitle = newTitle currentSubtitle = newSubtitle showSkipControls = newShowSkipControls @@ -292,32 +242,15 @@ class VideoPlayerNotificationHandler( // Recreate MediaSession when seek permissions change so connected system controllers // receive the new command set via onConnect. if (seekPermissionChanged && mediaSession != null) { + mediaSession?.let { VideoPlayerMediaSessionService.clearActiveSessionIfMatches(it) } mediaSession?.release() mediaSession = null player.removeListener(playerListener) } - // If MediaSession already exists, only update if media info changed if (mediaSession != null) { - // Only update MediaItem if the info actually changed to avoid playback interruptions if (mediaInfoChanged) { - currentArtwork = null // Clear old artwork - currentArtworkUrl = null // Clear artwork URL to ignore pending loads - - // Update the player's MediaItem with the new metadata updatePlayerMediaItemMetadata(mediaInfo) - - // Load new artwork asynchronously - mediaInfo?.let { info -> - updateMediaMetadata(info) - } - - // Update notification with new info - handler.post { - if (player.playWhenReady) { - updateNotification() - } - } } return } @@ -334,7 +267,6 @@ class VideoPlayerNotificationHandler( PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT ) - // Create MediaSession with unique session ID and activity (opens app when notification is tapped) val sessionId = "huddle_video_player_${++sessionCounter}" mediaSession = MediaSession.Builder(context, wrappedPlayer) .setId(sessionId) @@ -342,219 +274,76 @@ class VideoPlayerNotificationHandler( .setCallback(mediaSessionCallback) .build() - // Add listener to track play/pause events player.removeListener(playerListener) player.addListener(playerListener) - // Set metadata on the player's MediaItem first (for MediaSession to use) - mediaInfo?.let { info -> - updatePlayerMediaItemMetadata(info) - } - - // Load artwork asynchronously if provided - mediaInfo?.let { info -> - updateMediaMetadata(info) - } - - // Store the session so it's available when the foreground service is started later. - // The service is NOT started here — it's started only when: - // 1. setVideoTrackDisabled(true) is called (audio mode or background) - // 2. Via startForegroundPlayback() below - VideoPlayerMediaSessionService.setMediaSession(mediaSession) - - // Start periodic position updates - startPositionUpdates() + mediaInfo?.let { updatePlayerMediaItemMetadata(it) } } /** - * Starts the foreground service with media notification. - * Call this ONLY when switching to audio-only playback (background or manual audio mode). - * NOT when video is playing in the foreground. + * Starts the foreground service with media notification for audio-only / background + * playback. Idempotent: repeated calls while already active are no-ops. + * + * Only one handler at a time can drive the foreground notification. If another + * handler is active, calling this replaces it — by design, because the feature's + * contract is "audio-only playback in the background" with a single visible player. */ fun startForegroundPlayback() { - if (mediaSession == null) return + if (foregroundRequested) return + val session = mediaSession ?: return + + // Publish our session to the service BEFORE starting it so onStartCommand + // always finds a valid session to register — even if another handler's + // release() ran concurrently and cleared theirs. + VideoPlayerMediaSessionService.setActiveSession(session) val serviceIntent = Intent(context, VideoPlayerMediaSessionService::class.java) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - context.startForegroundService(serviceIntent) - } else { - context.startService(serviceIntent) + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + context.startForegroundService(serviceIntent) + } else { + context.startService(serviceIntent) + } + foregroundRequested = true + } catch (e: Exception) { + // On Android 12+ background-initiated foreground starts can be blocked + // (ForegroundServiceStartNotAllowedException). Revert the active-session + // pointer so we don't leave a dangling reference. + mediaSession?.let { VideoPlayerMediaSessionService.clearActiveSessionIfMatches(it) } } } /** * Stops the foreground service and removes the notification. - * Call when switching back to video mode from audio mode. + * Idempotent: safe to call repeatedly. */ fun stopForegroundPlayback() { + if (!foregroundRequested) return + foregroundRequested = false + + mediaSession?.let { VideoPlayerMediaSessionService.clearActiveSessionIfMatches(it) } + try { val serviceIntent = Intent(context, VideoPlayerMediaSessionService::class.java) context.stopService(serviceIntent) } catch (_: Exception) { } } - private fun showNotification() { - // No-op: Media3 handles notification via the foreground service. - } - - private fun updateNotification() { - // No-op: Media3 handles notification updates. - } - - private fun hideNotification() { - stopForegroundPlayback() - } - - /** - * Builds the media notification - */ - private fun buildNotification(): Notification { - val session = mediaSession ?: throw IllegalStateException("MediaSession not initialized") - - // Read metadata from the player's current MediaItem (source of truth for MediaSession) - // This ensures the notification always shows what the MediaSession is actually playing - val mediaMetadata = player.currentMediaItem?.mediaMetadata - val title = mediaMetadata?.title?.toString() ?: currentTitle - val artist = mediaMetadata?.artist?.toString() ?: currentSubtitle - - // Create pending intent for the notification - val packageManager = context.packageManager - val intent = packageManager.getLaunchIntentForPackage(context.packageName)?.apply { - flags = Intent.FLAG_ACTIVITY_SINGLE_TOP - } ?: Intent() - val contentIntent = PendingIntent.getActivity( - context, - 0, - intent, - PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT - ) - - // Use a system drawable for the small icon — adaptive/mipmap launcher icons - // are silently suppressed by Android's notification system. - val iconResId = android.R.drawable.ic_media_play - - // Convert Media3 SessionToken to MediaSessionCompat.Token for notification - // Media3 1.4.0+ requires us to extract the token differently - val token = try { - // Use reflection to access the session compat token - val method = session.javaClass.getMethod("getSessionCompatToken") - method.invoke(session) as? MediaSessionCompat.Token - } catch (_: Exception) { - null - } - - val builder = NotificationCompat.Builder(context, CHANNEL_ID) - .setContentTitle(title) - .setContentText(artist) - .setSmallIcon(iconResId) - .setLargeIcon(currentArtwork) - .setContentIntent(contentIntent) - .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) - .setOnlyAlertOnce(true) - .setShowWhen(false) - - // Only set media session token if we successfully obtained it - if (token != null) { - builder.setStyle( - MediaNotificationCompat.MediaStyle() - .setMediaSession(token) - ) - } - - return builder.build() - } - /** - * Updates media metadata (title, artist, artwork) - * This is called after the MediaItem is already set, so we just load artwork - * The base metadata was already set when creating the MediaItem - */ - fun updateMediaMetadata(mediaInfo: Map) { - // Load artwork asynchronously if present and update the notification - val artworkUrl = mediaInfo["artworkUrl"] as? String - if (artworkUrl != null) { - currentArtworkUrl = artworkUrl // Track the current artwork URL - loadArtwork(artworkUrl) { bitmap -> - // Only use this artwork if it's still the current one (prevent race conditions) - if (artworkUrl != currentArtworkUrl) return@loadArtwork - - bitmap?.let { - currentArtwork = it - - // Update notification directly with the new artwork - // DO NOT call replaceMediaItem here as it can interrupt playback - // The notification will use currentArtwork automatically - if (player.playWhenReady) { - handler.post { updateNotification() } - } - } - } - } - - } - - /** - * Loads artwork from URL - */ - private fun loadArtwork(url: String, callback: (Bitmap?) -> Unit) { - CoroutineScope(Dispatchers.IO).launch { - try { - val connection = URL(url).openConnection() - val bitmap = BitmapFactory.decodeStream(connection.getInputStream()) - withContext(Dispatchers.Main) { - callback(bitmap) - } - } catch (_: Exception) { - withContext(Dispatchers.Main) { - callback(null) - } - } - } - } - - /** - * Converts Bitmap to ByteArray - */ - private fun bitmapToByteArray(bitmap: Bitmap): ByteArray { - val stream = java.io.ByteArrayOutputStream() - bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream) - return stream.toByteArray() - } - - /** - * Starts periodic position updates (every second) - */ - private fun startPositionUpdates() { - positionUpdateRunnable = object : Runnable { - override fun run() { - // Position is automatically updated by ExoPlayer/MediaSession - handler.postDelayed(this, 1000) - } - } - handler.post(positionUpdateRunnable!!) - } - - /** - * Stops periodic position updates - */ - private fun stopPositionUpdates() { - positionUpdateRunnable?.let { handler.removeCallbacks(it) } - positionUpdateRunnable = null - } - - /** - * Releases MediaSession and hides notification + * Releases MediaSession and tears down the foreground service if we own it. + * Safe to call multiple times. */ fun release() { - stopPositionUpdates() + if (isReleased) return + isReleased = true + player.removeListener(playerListener) stopForegroundPlayback() - VideoPlayerMediaSessionService.setMediaSession(null) + mediaSession?.let { VideoPlayerMediaSessionService.clearActiveSessionIfMatches(it) } mediaSession?.release() mediaSession = null - currentArtwork = null - currentArtworkUrl = null + currentTitle = "Video" currentSubtitle = "" } diff --git a/android/src/main/kotlin/com/huddlecommunity/better_native_video_player/manager/SharedPlayerManager.kt b/android/src/main/kotlin/com/huddlecommunity/better_native_video_player/manager/SharedPlayerManager.kt index 20af45a..1c146bb 100644 --- a/android/src/main/kotlin/com/huddlecommunity/better_native_video_player/manager/SharedPlayerManager.kt +++ b/android/src/main/kotlin/com/huddlecommunity/better_native_video_player/manager/SharedPlayerManager.kt @@ -182,7 +182,10 @@ object SharedPlayerManager { * Stops the MediaSessionService */ private fun stopMediaSessionService(context: Context) { - VideoPlayerMediaSessionService.setMediaSession(null) + // Nuclear reset — clearAll() / last-player-removed code paths only. + // Per-handler cleanup is handled by VideoPlayerNotificationHandler.release() + // via clearActiveSessionIfMatches(). + VideoPlayerMediaSessionService.setActiveSession(null) val serviceIntent = Intent(context, VideoPlayerMediaSessionService::class.java) context.stopService(serviceIntent) } diff --git a/lib/better_native_video_player.dart b/lib/better_native_video_player.dart index 49df54b..3fe8810 100644 --- a/lib/better_native_video_player.dart +++ b/lib/better_native_video_player.dart @@ -19,6 +19,7 @@ export 'src/models/native_video_player_media_info.dart'; export 'src/models/native_video_player_quality.dart'; export 'src/models/native_video_player_state.dart'; export 'src/models/native_video_player_subtitle_track.dart'; +export 'src/models/native_video_player_track_disable_result.dart'; export 'src/native_video_player_widget.dart'; export 'src/platform/platform_utils.dart'; export 'src/services/airplay_state_manager.dart'; diff --git a/lib/src/controllers/native_video_player_controller.dart b/lib/src/controllers/native_video_player_controller.dart index 9a40510..c888df8 100644 --- a/lib/src/controllers/native_video_player_controller.dart +++ b/lib/src/controllers/native_video_player_controller.dart @@ -13,6 +13,7 @@ import '../models/native_video_player_media_info.dart'; import '../models/native_video_player_quality.dart'; import '../models/native_video_player_state.dart'; import '../models/native_video_player_subtitle_track.dart'; +import '../models/native_video_player_track_disable_result.dart'; import '../platform/platform_utils.dart'; import '../platform/video_player_method_channel.dart'; import '../services/airplay_state_manager.dart'; @@ -1878,16 +1879,29 @@ class NativeVideoPlayerController { await _methodChannel?.setSubtitleTrack(track); } - /// Disables or enables the video track in the native player. - /// - /// When [disabled] is true, only audio segments are downloaded from HLS - /// demuxed streams. This is designed for background audio-only playback - /// to save bandwidth. - /// - /// Call with `true` when the app goes to background, - /// `false` when returning to foreground. - Future setVideoTrackDisabled(bool disabled) async { - await _methodChannel?.setVideoTrackDisabled(disabled); + /// Disables or enables the video track for background audio-only playback. + /// + /// When [disabled] is true, the native player stops downloading video + /// segments from demuxed HLS streams while audio continues uninterrupted. + /// On Android this also promotes a foreground MediaSessionService so audio + /// keeps playing with a lock-screen notification when the app is backgrounded. + /// + /// Contract: + /// - The caller owns lifecycle: toggle from a `WidgetsBindingObserver` / + /// `AppLifecycleState` listener — this plugin does not watch app state. + /// - On iOS, the host app must include `audio` in `UIBackgroundModes` for + /// playback to survive backgrounding. + /// - If the stream has no demuxed audio rendition (audio is muxed into video + /// segments), disabling video would kill audio too, so the call is a + /// no-op and returns [VideoTrackDisableStatus.skippedNoDemuxedAudio]. + /// + /// Throws [PlatformException] on native errors so callers can react. + Future setVideoTrackDisabled(bool disabled) async { + final channel = _methodChannel; + if (channel == null) { + return const VideoTrackDisableResult(VideoTrackDisableStatus.skipped); + } + return channel.setVideoTrackDisabled(disabled); } /// Returns whether Picture-in-Picture is available on this device diff --git a/lib/src/models/native_video_player_track_disable_result.dart b/lib/src/models/native_video_player_track_disable_result.dart new file mode 100644 index 0000000..18ee4e1 --- /dev/null +++ b/lib/src/models/native_video_player_track_disable_result.dart @@ -0,0 +1,23 @@ +/// Outcome of a [NativeVideoPlayerController.setVideoTrackDisabled] call. +enum VideoTrackDisableStatus { + /// The video track was successfully disabled or re-enabled. + ok, + + /// The stream has no demuxed audio rendition, so disabling the video track + /// would have killed audio too. No change was applied — the caller should + /// pick a different stream (e.g. a muxed audio-only variant) or leave video + /// enabled. + skippedNoDemuxedAudio, + + /// The native side declined the toggle for an unspecified reason. + skipped, +} + +/// Result of a [NativeVideoPlayerController.setVideoTrackDisabled] call. +class VideoTrackDisableResult { + const VideoTrackDisableResult(this.status); + + final VideoTrackDisableStatus status; + + bool get wasApplied => status == VideoTrackDisableStatus.ok; +} diff --git a/lib/src/platform/video_player_method_channel.dart b/lib/src/platform/video_player_method_channel.dart index f9dce46..52fcd50 100644 --- a/lib/src/platform/video_player_method_channel.dart +++ b/lib/src/platform/video_player_method_channel.dart @@ -3,6 +3,7 @@ import 'package:flutter/services.dart'; import '../models/native_video_player_quality.dart'; import '../models/native_video_player_subtitle_track.dart'; +import '../models/native_video_player_track_disable_result.dart'; /// Handles all method channel communication with the native platform class VideoPlayerMethodChannel { @@ -195,18 +196,31 @@ class VideoPlayerMethodChannel { /// Audio continues uninterrupted. /// /// When [disabled] is false, video segment downloads resume from the current position. - Future setVideoTrackDisabled(bool disabled) async { - try { - await _methodChannel.invokeMethod( - 'setVideoTrackDisabled', - { - 'viewId': primaryPlatformViewId, - 'disabled': disabled, - }, - ); - } catch (e) { - debugPrint('Error calling setVideoTrackDisabled: $e'); + /// + /// Returns a [VideoTrackDisableResult] describing what happened: + /// - [VideoTrackDisableStatus.ok] — the toggle was applied. + /// - [VideoTrackDisableStatus.skippedNoDemuxedAudio] — the stream has no + /// separate audio rendition, so disabling video would kill audio too. + /// The caller should either pick a different stream or not disable. + /// Propagates [PlatformException] on native errors so callers can react. + Future setVideoTrackDisabled(bool disabled) async { + final dynamic result = await _methodChannel.invokeMethod( + 'setVideoTrackDisabled', + { + 'viewId': primaryPlatformViewId, + 'disabled': disabled, + }, + ); + if (result is Map && result['skipped'] == true) { + final reason = result['reason']; + if (reason == 'no_demuxed_audio') { + return const VideoTrackDisableResult( + VideoTrackDisableStatus.skippedNoDemuxedAudio, + ); + } + return const VideoTrackDisableResult(VideoTrackDisableStatus.skipped); } + return const VideoTrackDisableResult(VideoTrackDisableStatus.ok); } /// Checks if Picture-in-Picture is available From 454ac583cc0010ffa5de98faf278409d55762135 Mon Sep 17 00:00:00 2001 From: VishalKevadiya Date: Fri, 17 Apr 2026 14:03:45 +0530 Subject: [PATCH 28/28] Fixed: ios-video-player-end-of-media-loop-resume --- .../Handlers/VideoPlayerMethodHandler.swift | 33 +++++++++--- .../Handlers/VideoPlayerObserver.swift | 44 ++++++++++++++-- ios/Classes/Manager/SharedPlayerManager.swift | 51 +++++++++++++++++++ ios/Classes/View/VideoPlayerView.swift | 15 +++++- 4 files changed, 130 insertions(+), 13 deletions(-) diff --git a/ios/Classes/Handlers/VideoPlayerMethodHandler.swift b/ios/Classes/Handlers/VideoPlayerMethodHandler.swift index 92e66fc..471aba7 100644 --- a/ios/Classes/Handlers/VideoPlayerMethodHandler.swift +++ b/ios/Classes/Handlers/VideoPlayerMethodHandler.swift @@ -214,18 +214,17 @@ extension VideoPlayerView { } // --- Set up observers for buffer status and player state --- + // Note: addObservers also registers the AVPlayerItemDidPlayToEndTime + // notification so shared/Dart-fullscreen views receive end-of-media too. addObservers(to: playerItem) // --- Set up periodic time observer for Now Playing elapsed time updates --- setupPeriodicTimeObserver() - // --- Listen for end of playback --- - NotificationCenter.default.addObserver( - self, - selector: #selector(videoDidEnd), - name: .AVPlayerItemDidPlayToEndTime, - object: playerItem - ) + // New playback session – clear any stale completion claim from a prior item. + if let controllerIdValue = controllerId { + SharedPlayerManager.shared.resetCompletionClaim(for: controllerIdValue) + } // --- Observe status (wait for ready) --- var statusObserver: NSKeyValueObservation? @@ -364,6 +363,12 @@ extension VideoPlayerView { // Prepare audio session, Now Playing info, and PiP before playback prepareForPlayback() + // User-initiated play starts a new window where `completed` should be + // able to fire again when the item reaches end-of-media. + if let controllerIdValue = controllerId { + SharedPlayerManager.shared.resetCompletionClaim(for: controllerIdValue) + } + print("Playing with speed: \(desiredPlaybackSpeed)") player?.play() // Apply the desired playback speed @@ -395,6 +400,11 @@ extension VideoPlayerView { func handleSeekTo(call: FlutterMethodCall, result: @escaping FlutterResult) { if let args = call.arguments as? [String: Any], let milliseconds = args["milliseconds"] as? Int { + // A user-initiated seek (typically away from end-of-media) re-opens + // the window for a future `completed` emission. + if let controllerIdValue = controllerId { + SharedPlayerManager.shared.resetCompletionClaim(for: controllerIdValue) + } let seconds = Double(milliseconds) / 1000.0 player?.seek(to: CMTime(seconds: seconds, preferredTimescale: 1000)) { _ in self.sendEvent("seek", data: ["position": milliseconds]) @@ -442,9 +452,16 @@ extension VideoPlayerView { let looping = args["looping"] as? Bool { print("Setting looping to: \(looping)") - // Update the enableLooping property + // Update the per-view flag for backward compatibility. enableLooping = looping + // Mirror into shared storage so whichever view actually handles the + // end-of-media notification sees the live value, even if setLooping + // was called on a different view for the same controller. + if let controllerIdValue = controllerId { + SharedPlayerManager.shared.setLoopingEnabled(for: controllerIdValue, enabled: looping) + } + result(nil) } else { result(FlutterError(code: "INVALID_LOOPING", message: "Invalid looping value", details: nil)) diff --git a/ios/Classes/Handlers/VideoPlayerObserver.swift b/ios/Classes/Handlers/VideoPlayerObserver.swift index df00660..31c3011 100644 --- a/ios/Classes/Handlers/VideoPlayerObserver.swift +++ b/ios/Classes/Handlers/VideoPlayerObserver.swift @@ -28,6 +28,16 @@ extension VideoPlayerView { name: .AVPlayerItemFailedToPlayToEndTime, object: item ) + + // Register end-of-media observer on every view (including shared/Dart-fullscreen views) + // so the notification is handled even when the view that originally called handleLoad + // has been disposed or no longer has an active Flutter listener. + NotificationCenter.default.addObserver( + self, + selector: #selector(videoDidEnd), + name: .AVPlayerItemDidPlayToEndTime, + object: item + ) } public override func observeValue( @@ -284,20 +294,46 @@ extension VideoPlayerView { } @objc func videoDidEnd() { - if enableLooping { - // For smooth looping, seek to beginning and continue playing + // Read looping state from shared storage so it stays consistent across + // the inline and Dart-fullscreen views (setLooping on one view must + // affect whichever view actually handles end-of-media). + let isLooping: Bool + if let controllerIdValue = controllerId { + isLooping = SharedPlayerManager.shared.isLoopingEnabled(for: controllerIdValue) + } else { + isLooping = enableLooping + } + + if isLooping { + // For smooth looping, seek to beginning and continue playing. + // iOS AVPlayer does not auto-resume after seeking, so play() + // must be called explicitly from the seek completion handler. + // Calls from multiple views for the same shared player are idempotent. player?.seek(to: .zero) { [weak self] finished in if finished { - // Continue playing for seamless loop self?.player?.play() } } // Don't send completed event when looping to match Android behavior // (Android with REPEAT_MODE_ONE doesn't reach STATE_ENDED) } else { - // Reset video to the beginning and pause + // Reset video to the beginning and pause. These are idempotent on a + // shared player so letting every registered view run them is safe. player?.seek(to: .zero) player?.pause() + + // Emit `completed` from a view whose event channel is live. Views + // whose channel has been torn down (e.g. inline view whose widget + // is currently hidden behind a Dart fullscreen route) skip emission + // so the event lands on the view Dart is actually subscribed to. + guard isEventChannelActive, !isDisposed else { return } + + // Dedupe in the rare case that multiple views for the same controller + // both have active listeners; only the first claimer emits. + if let controllerIdValue = controllerId, + !SharedPlayerManager.shared.claimCompletionEmission(for: controllerIdValue) { + return + } sendEvent("completed") } } diff --git a/ios/Classes/Manager/SharedPlayerManager.swift b/ios/Classes/Manager/SharedPlayerManager.swift index 0ff0d23..2529200 100644 --- a/ios/Classes/Manager/SharedPlayerManager.swift +++ b/ios/Classes/Manager/SharedPlayerManager.swift @@ -57,6 +57,16 @@ class SharedPlayerManager: NSObject { /// These persist to send PiP and AirPlay events even when all views are disposed private var controllerEventSinks: [Int: FlutterEventSink] = [:] + /// Track looping state per controller so all views (inline + fullscreen) agree. + /// Without this, setLooping(true) on one view would not affect the other view + /// that actually receives the end-of-media notification. + private var loopingByController: [Int: Bool] = [:] + + /// Track whether the end-of-media `completed` event has already been emitted + /// for the current playback session. Used to dedupe when multiple views + /// (e.g. inline + Dart fullscreen) observe the same AVPlayerItem. + private var completionClaimed: [Int: Bool] = [:] + struct PipSettings { let allowsPictureInPicture: Bool let canStartPictureInPictureAutomatically: Bool @@ -169,6 +179,41 @@ class SharedPlayerManager: NSObject { return mediaInfoCache[controllerId] } + // MARK: - Looping State + + /// Sets the looping flag for a controller so all views share the same value. + func setLoopingEnabled(for controllerId: Int, enabled: Bool) { + loopingByController[controllerId] = enabled + } + + /// Returns the looping flag for a controller (defaults to false). + func isLoopingEnabled(for controllerId: Int) -> Bool { + return loopingByController[controllerId] ?? false + } + + /// Returns the stored looping flag, or nil if nothing has been stored yet. + /// Lets callers distinguish "explicitly set to false" from "never seeded". + func storedLoopingValue(for controllerId: Int) -> Bool? { + return loopingByController[controllerId] + } + + // MARK: - End-of-Media Completion Claim + + /// Atomically claims the right to emit `completed` for this controller's + /// current playback session. Returns true the first time it is called + /// per session; subsequent calls return false until the flag is reset. + func claimCompletionEmission(for controllerId: Int) -> Bool { + if completionClaimed[controllerId] == true { return false } + completionClaimed[controllerId] = true + return true + } + + /// Clears the completion claim so the next end-of-media can emit again. + /// Called when a new item is loaded, the user resumes playback, or seeks. + func resetCompletionClaim(for controllerId: Int) { + completionClaimed.removeValue(forKey: controllerId) + } + // MARK: - Controller Event Channel Methods /// Registers a controller-level event sink for persistent events @@ -303,6 +348,10 @@ class SharedPlayerManager: NSObject { // Clear manual PiP flag controllersWithManualPiP.remove(controllerId) + // Clear looping and completion-claim state + loopingByController.removeValue(forKey: controllerId) + completionClaimed.removeValue(forKey: controllerId) + print("✅ [SharedPlayerManager] Fully removed player for controller ID: \(controllerId)") } @@ -324,6 +373,8 @@ class SharedPlayerManager: NSObject { mediaInfoCache.removeAll() controllerWithAutomaticPiP = nil controllersWithManualPiP.removeAll() + loopingByController.removeAll() + completionClaimed.removeAll() } // MARK: - AirPlay Route Detection diff --git a/ios/Classes/View/VideoPlayerView.swift b/ios/Classes/View/VideoPlayerView.swift index 5118e2d..b847cbb 100644 --- a/ios/Classes/View/VideoPlayerView.swift +++ b/ios/Classes/View/VideoPlayerView.swift @@ -200,7 +200,20 @@ import QuartzCore enableHDR = args["enableHDR"] as? Bool ?? false // Looping configuration from args - enableLooping = args["enableLooping"] as? Bool ?? false + let argsEnableLooping = args["enableLooping"] as? Bool ?? false + // Prefer any live value another view already stored for this controller; + // otherwise seed the shared state from this view's args so both inline + // and Dart-fullscreen views agree. + if let controllerIdValue = controllerId { + if let shared = SharedPlayerManager.shared.storedLoopingValue(for: controllerIdValue) { + enableLooping = shared + } else { + enableLooping = argsEnableLooping + SharedPlayerManager.shared.setLoopingEnabled(for: controllerIdValue, enabled: argsEnableLooping) + } + } else { + enableLooping = argsEnableLooping + } // For shared players, try to get PiP settings from SharedPlayerManager // This ensures PiP settings persist across all views using the same controller