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..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 @@ -1,94 +1,202 @@ 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.Bundle +import android.content.pm.ServiceInfo +import android.os.Build import android.util.Log -import androidx.media3.common.Player -import androidx.media3.session.CommandButton +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 -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 + * Foreground MediaSessionService for background audio playback. * - * 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 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. + * + * 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() { companion object { private const val TAG = "VideoPlayerMSS" + private const val NOTIFICATION_ID = 1001 + internal const val CHANNEL_ID = "video_player_channel" + private const val LEGACY_CHANNEL_ID = "video_player" + + private var activeSession: MediaSession? = null - // The MediaSession is stored here so it can be accessed by the service - private var mediaSession: MediaSession? = null + fun getActiveSession(): MediaSession? = activeSession /** - * Gets the current media session + * Registers the session that should drive the foreground notification. + * If another handler's session is already active, it is replaced. */ - fun getMediaSession(): MediaSession? = mediaSession + fun setActiveSession(session: MediaSession?) { + activeSession = session + } /** - * Sets the media session (called by VideoPlayerNotificationHandler) - * This must be called before starting the service + * 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 setMediaSession(session: MediaSession?) { - Log.d(TAG, "MediaSession ${if (session != null) "set" else "cleared"}, hasPlayer=${session?.player != null}") - mediaSession = session + fun clearActiveSessionIfMatches(session: MediaSession) { + if (activeSession === session) { + activeSession = null + } } } + private var registeredSession: MediaSession? = null + private var isForegroundStarted = false + + // ── lifecycle ──────────────────────────────────────────────────────────── + override fun onCreate() { super.onCreate() - Log.d(TAG, "VideoPlayerMediaSessionService onCreate, mediaSession=${mediaSession != null}") + + val provider = DefaultMediaNotificationProvider.Builder(this) + .setChannelId(CHANNEL_ID) + .setNotificationId(NOTIFICATION_ID) + .build() + provider.setSmallIcon(resolveNotificationIcon()) + setMediaNotificationProvider(provider) + + setListener(ServiceListener()) + } + + override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? { + return activeSession } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - Log.d(TAG, "onStartCommand called, mediaSession=${mediaSession != null}, player=${mediaSession?.player != null}") + // Satisfy Android's 5s startForeground() deadline with a placeholder; + // Media3 will replace it via onUpdateNotification moments later. + startForegroundIfNeeded() - // Important: Call super to trigger the Media3 notification framework - val result = super.onStartCommand(intent, flags, startId) + val session = activeSession + if (session == null) { + // Nothing to play — let the service die rather than sit foregrounded. + stopSelf() + return 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}") + // 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 result + return super.onStartCommand(intent, flags, startId) } - override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? { - Log.d(TAG, "onGetSession called for ${controllerInfo.packageName}, returning session=${mediaSession != null}") - - // Return the MediaSession - this triggers the notification to appear - return mediaSession + override fun onUpdateNotification(session: MediaSession, startInForegroundRequired: Boolean) { + super.onUpdateNotification(session, startInForegroundRequired) } 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 { + 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, "VideoPlayerMediaSessionService onDestroy") - // Don't release the player or session here - they're managed by the notification handler + isForegroundStarted = false + registeredSession = null + clearListener() super.onDestroy() } -} \ No newline at end of file + + // ── foreground promotion ──────────────────────────────────────────────── + + 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()) + .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 + } + + // ── notification channel ──────────────────────────────────────────────── + + private fun ensureNotificationChannel(nmc: NotificationManagerCompat) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return + + 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 { + 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/VideoPlayerView.kt b/android/src/main/kotlin/com/huddlecommunity/better_native_video_player/VideoPlayerView.kt index 79ab3c3..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 @@ -15,9 +15,11 @@ 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 +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 @@ -43,6 +45,7 @@ class VideoPlayerView( companion object { private const val TAG = "VideoPlayerView" + private const val SEEK_INCREMENT_MS = 15_000L } private val playerView: PlayerView @@ -83,6 +86,7 @@ class VideoPlayerView( // HDR setting private var enableHDR: Boolean = false + private var useAspectFill: Boolean = false init { @@ -97,6 +101,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 @@ -129,7 +134,15 @@ 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() } @@ -146,6 +159,7 @@ class VideoPlayerView( playerView = PlayerView(context).apply { this.player = this@VideoPlayerView.player useController = showNativeControls + resizeMode = resolveResizeMode(useAspectFill) controllerShowTimeoutMs = 5000 controllerHideOnTouch = true @@ -297,6 +311,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}") @@ -349,6 +372,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() @@ -644,6 +673,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 @@ -654,17 +704,30 @@ 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 - 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 +741,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 +842,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..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 @@ -2,10 +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.os.Build +import android.net.Uri import android.util.Log import androidx.media3.common.C import androidx.media3.common.MediaItem @@ -44,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 @@ -72,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 */ @@ -129,6 +68,8 @@ 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) "isAirPlayAvailable" -> handleIsAirPlayAvailable(result) @@ -141,6 +82,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 */ @@ -158,6 +113,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 { @@ -214,6 +173,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()) } @@ -326,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 } @@ -347,7 +309,6 @@ class VideoPlayerMethodHandler( * Starts playback */ private fun handlePlay(result: MethodChannel.Result) { - requestAudioFocusForPlayback() player.play() result.success(null) } @@ -357,7 +318,6 @@ class VideoPlayerMethodHandler( */ private fun handlePause(result: MethodChannel.Result) { player.pause() - abandonAudioFocusForPlayback() result.success(null) } @@ -560,14 +520,18 @@ 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 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") @@ -846,4 +810,58 @@ 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") + + 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) + .build() + + 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 { + notificationHandler.stopForegroundPlayback() + } + + 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 7eb209d..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,34 +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.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 kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import java.net.URL +import androidx.media3.session.MediaSession.ConnectionResult +import com.huddlecommunity.better_native_video_player.VideoPlayerMediaSessionService /** - * 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, @@ -36,63 +25,160 @@ 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 } 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) { + 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() + } + } - init { - createNotificationChannel() + 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, + ): 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) + } + return base + } } 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 -> { + // Stop the foreground service when playback ends; Media3 handles + // regular notification updates in all other states. + stopForegroundPlayback() + } + else -> { /* no-op */ } } } } /** - * Creates notification channel for Android O+ + * 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. */ - 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) - } - notificationManager.createNotificationChannel(channel) - Log.d(TAG, "Notification channel created") - } + fun updateTrackNavFlags(mediaInfo: Map?) { + val newShowPrev = (mediaInfo?.get("showSystemPreviousTrackControl") as? Boolean) ?: false + val newShowNext = (mediaInfo?.get("showSystemNextTrackControl") as? Boolean) ?: false + showSystemPreviousTrackControl = newShowPrev + showSystemNextTrackControl = newShowNext } /** @@ -100,82 +186,71 @@ class VideoPlayerNotificationHandler( */ fun updateEventHandler(newEventHandler: VideoPlayerEventHandler) { eventHandler = newEventHandler - Log.d(TAG, "Event handler updated for shared notification handler") } /** - * 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) } (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() .setMediaMetadata(metadataBuilder.build()) .build() - // Replace the MediaItem without interrupting playback val wasPlaying = player.isPlaying val position = player.currentPosition 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"]}") } /** - * 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 currentTitle = newTitle currentSubtitle = newSubtitle - Log.d(TAG, "📱 Media info - title: $currentTitle, subtitle: $currentSubtitle, changed: $mediaInfoChanged") + showSkipControls = newShowSkipControls + showSystemPreviousTrackControl = newShowSystemPreviousTrackControl + showSystemNextTrackControl = newShowSystemNextTrackControl + + // 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) { - Log.d(TAG, "📱 MediaSession exists - media info changed, updating metadata") - 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() - Log.d(TAG, "✅ Notification updated with new media info") - } - } - } else { - Log.d(TAG, "📱 MediaSession exists - media info unchanged, skipping update to avoid interruption") } return } @@ -192,230 +267,84 @@ 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, player) + mediaSession = MediaSession.Builder(context, wrappedPlayer) .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") - - // 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 - mediaInfo?.let { info -> - updateMediaMetadata(info) - } - - // Start periodic position updates - startPositionUpdates() - } - - /** - * 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) - } - } - - /** - * Updates the existing notification - */ - private fun updateNotification() { - showNotification() + mediaInfo?.let { updatePlayerMediaItemMetadata(it) } } /** - * Hides the notification + * 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. */ - private fun hideNotification() { - notificationManager.cancel(NOTIFICATION_ID) - Log.d(TAG, "Notification hidden") - } - - /** - * 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 - ) + fun startForegroundPlayback() { + if (foregroundRequested) return + val session = mediaSession ?: return - Log.d(TAG, "Building notification - title: $title, subtitle: $artist (from player: ${mediaMetadata != null})") + // 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) - // Get notification icon from the app's resources - val appInfo = context.applicationInfo - val iconResId = appInfo.icon - - // 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 (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") - 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) - ) - } 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() - } - - /** - * 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) { - Log.d(TAG, "Ignoring outdated artwork for $artworkUrl") - 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() - 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") - } - } - } - } - - Log.d(TAG, "Media metadata setup complete") - } - - /** - * 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 (e: Exception) { - Log.e(TAG, "Error loading artwork: ${e.message}", e) - withContext(Dispatchers.Main) { - callback(null) - } + val serviceIntent = Intent(context, VideoPlayerMediaSessionService::class.java) + 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) } } } /** - * Converts Bitmap to ByteArray + * Stops the foreground service and removes the notification. + * Idempotent: safe to call repeatedly. */ - private fun bitmapToByteArray(bitmap: Bitmap): ByteArray { - val stream = java.io.ByteArrayOutputStream() - bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream) - return stream.toByteArray() - } + fun stopForegroundPlayback() { + if (!foregroundRequested) return + foregroundRequested = false - /** - * 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!!) - } + mediaSession?.let { VideoPlayerMediaSessionService.clearActiveSessionIfMatches(it) } - /** - * Stops periodic position updates - */ - private fun stopPositionUpdates() { - positionUpdateRunnable?.let { handler.removeCallbacks(it) } - positionUpdateRunnable = null + try { + val serviceIntent = Intent(context, VideoPlayerMediaSessionService::class.java) + context.stopService(serviceIntent) + } catch (_: Exception) { } } /** - * 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) - hideNotification() + stopForegroundPlayback() + mediaSession?.let { VideoPlayerMediaSessionService.clearActiveSessionIfMatches(it) } mediaSession?.release() mediaSession = null - currentArtwork = null - currentArtworkUrl = null + currentTitle = "Video" currentSubtitle = "" - Log.d(TAG, "MediaSession released") } } 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..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 @@ -31,6 +32,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 +75,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,8 +104,50 @@ 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 + } + } + + 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 @@ -209,6 +258,22 @@ class VideoPlayerObserver( ) } + override fun onVideoSizeChanged(videoSize: androidx.media3.common.VideoSize) { + 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) { // Check if playing to a remote device (Cast) val isExternalPlaybackActive = deviceInfo.playbackType == androidx.media3.common.DeviceInfo.PLAYBACK_TYPE_REMOTE 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..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 @@ -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 @@ -16,6 +17,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() @@ -36,7 +38,15 @@ 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() } return Pair(player, alreadyExisted) @@ -172,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/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..70affea 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)) @@ -709,6 +726,19 @@ extension VideoPlayerView { func handleDispose(result: @escaping FlutterResult) { print("🗑️ [VideoPlayerMethodHandler] handleDispose called for controllerId: \(String(describing: controllerId))") + 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() @@ -734,7 +764,6 @@ extension VideoPlayerView { player = nil print("🧹 [VideoPlayerMethodHandler] Local player reference cleared") - sendEvent("stopped") result(nil) } @@ -1128,12 +1157,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) } } } @@ -1282,4 +1313,84 @@ 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 { + result(nil) + return + } + + 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( + forMediaCharacteristic: .visual + ) { + playerItem.select(nil, in: videoGroup) + } + + // Strategy 2: Restrict bitrate to audio-only threshold (fallback) + playerItem.preferredPeakBitRate = 1.0 + } 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) + } + } + + // 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 f31f443..844263a 100644 --- a/ios/Classes/Handlers/VideoPlayerNowPlayingHandler.swift +++ b/ios/Classes/Handlers/VideoPlayerNowPlayingHandler.swift @@ -52,8 +52,11 @@ 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) print("🎛️ Removed all remote command targets") } @@ -66,8 +69,11 @@ 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) print("🎛️ Atomically transferred ownership to view \(viewId) and cleared targets") } } @@ -202,6 +208,10 @@ 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 + 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 @@ -227,6 +237,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 +258,7 @@ extension VideoPlayerView { } // --- Pause --- + commandCenter.pauseCommand.isEnabled = true commandCenter.pauseCommand.addTarget { [weak self] _ in guard let self = self else { return .commandFailed } @@ -262,10 +274,41 @@ extension VideoPlayerView { return .success } - // --- Skip forward/backward --- + // --- 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] + // 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 } + + 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 + } + + 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 + } + + self.sendEvent("nextTrack") + return .success + } + commandCenter.skipForwardCommand.addTarget { [weak self] event in guard let self = self, let skipEvent = event as? MPSkipIntervalCommandEvent, @@ -274,7 +317,6 @@ extension VideoPlayerView { 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 @@ -295,7 +337,6 @@ extension VideoPlayerView { 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 @@ -308,11 +349,39 @@ extension VideoPlayerView { 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") // 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/ios/Classes/Handlers/VideoPlayerObserver.swift b/ios/Classes/Handlers/VideoPlayerObserver.swift index 275682b..31c3011 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) @@ -27,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( @@ -76,6 +87,8 @@ extension VideoPlayerView { } } } + case "presentationSize": + emitVideoDimensionsIfAvailable(from: item) default: break } } @@ -269,21 +282,58 @@ 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 + // 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") } } @@ -491,4 +541,4 @@ extension VideoPlayerView { print(" - No AirPlay-related changes (device=nil, playerActive=false)") } } -} \ No newline at end of file +} 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 c8ec0fc..e680281 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 @@ -88,9 +90,24 @@ 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 + var nativeLayoutConstraints: [NSLayoutConstraint] = [] + weak var flutterParentView: UIView? + var flutterFrame: CGRect = .zero + var portraitPlayerRectInRoot: CGRect? + // 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 +192,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 @@ -184,13 +203,27 @@ 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 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 @@ -229,6 +262,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] { @@ -342,6 +379,101 @@ import QuartzCore return playerViewController.view } + // MARK: - Native Layout Overlay + + /// 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) + 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 + } + + flutterParentView = playerView.superview + flutterFrame = playerView.frame + + // Get player's exact screen position before reparenting. + let currentRectInRoot = playerView.convert(playerView.bounds, to: rootView) + + // Remember portrait position for the reverse rotation. + let isCurrentlyPortrait = rootView.bounds.height > rootView.bounds.width + if isCurrentlyPortrait { + portraitPlayerRectInRoot = currentRectInRoot + } + + // 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) + + 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) + + isUsingNativeLayout = true + print("✅ [NativeLayout] Player view reparented to root view") + result(nil) + } + + /// 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) + + // 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 + flutterParentView = nil + print("✅ [NativeLayout] Player view returned to Flutter layout") + result(nil) + } + // MARK: - Audio Session Management /// Prepares and activates the audio session for video playback @@ -395,6 +527,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": @@ -411,6 +545,14 @@ 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 "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) @@ -431,18 +573,99 @@ 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) { + 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() { @@ -501,10 +724,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. } } } @@ -537,12 +765,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") } @@ -577,6 +807,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 @@ -592,7 +824,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 @@ -686,39 +920,27 @@ 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() + + // 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 - // 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, *) { @@ -776,6 +998,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 @@ -999,3 +1222,59 @@ import QuartzCore } } +// 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(childView: UIView, initialRect: CGRect, portraitRect: CGRect?) { + self.initialRect = initialRect + self.portraitRect = portraitRect + super.init(frame: .zero) + + backgroundColor = .black + clipsToBounds = true + + 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 { + child.frame = initialRect + } else if bounds.size != previousBoundsSize { + hasRotated = true + let isRotatingToPortrait = bounds.height > bounds.width + if isRotatingToPortrait, let pRect = portraitRect { + child.frame = pRect + } else { + child.frame = bounds + } + } + } else { + let isPortrait = bounds.height > bounds.width + if isPortrait, let pRect = portraitRect { + child.frame = pRect + } else { + child.frame = bounds + } + } + + previousBoundsSize = bounds.size + } +} 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 5733152..0955997 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'; @@ -48,10 +49,12 @@ class NativeVideoPlayerController { this.mediaInfo, this.allowsPictureInPicture = true, this.canStartPictureInPictureAutomatically = true, + this.allowsVideoFrameAnalysis = true, this.lockToLandscape = true, this.enableHDR = true, this.enableLooping = false, this.showNativeControls = true, + this.useAspectFill = false, List? preferredOrientations, }) { // Set preferred orientations if provided @@ -63,9 +66,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 @@ -130,6 +130,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; @@ -142,6 +148,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 { @@ -807,12 +817,14 @@ class NativeVideoPlayerController { 'allowsPictureInPicture': allowsPictureInPicture, 'canStartPictureInPictureAutomatically': canStartPictureInPictureAutomatically, + 'allowsVideoFrameAnalysis': allowsVideoFrameAnalysis, 'showNativeControls': _hasCustomOverlay ? false : showNativeControls, // Hide native controls if we have custom overlay, otherwise use parameter 'isFullScreen': _state.isFullScreen, 'enableHDR': enableHDR, 'enableLooping': enableLooping, + 'useAspectFill': useAspectFill, if (mediaInfo != null) 'mediaInfo': mediaInfo!.toMap(), }; @@ -885,6 +897,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 @@ -1024,19 +1038,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 @@ -1558,10 +1608,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'); @@ -1569,6 +1620,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. @@ -1801,6 +1879,31 @@ class NativeVideoPlayerController { await _methodChannel?.setSubtitleTrack(track); } + /// 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 /// Checks the actual device capabilities rather than just the platform /// PiP is available on iOS 14+ and Android 8+ (if the device supports it) @@ -2152,6 +2255,29 @@ 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(); + } + + /// 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/enums/native_video_player_event.dart b/lib/src/enums/native_video_player_event.dart index 718851b..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, @@ -28,6 +30,7 @@ enum PlayerControlState { fullscreenEntered, fullscreenExited, timeUpdated, + videoDimensionsUpdated, } /// Activity state event for playback changes @@ -103,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': @@ -126,6 +133,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..ba40601 100644 --- a/lib/src/models/native_video_player_media_info.dart +++ b/lib/src/models/native_video_player_media_info.dart @@ -4,17 +4,26 @@ class NativeVideoPlayerMediaInfo { this.subtitle, this.album, this.artworkUrl, + this.showSkipControls, + this.showSystemNextTrackControl, + this.showSystemPreviousTrackControl, }); final String? title; final String? subtitle; final String? album; final String? artworkUrl; + final bool? showSkipControls; + final bool? showSystemNextTrackControl; + final bool? showSystemPreviousTrackControl; 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, + if (showSystemNextTrackControl != null) 'showSystemNextTrackControl': showSystemNextTrackControl, + if (showSystemPreviousTrackControl != null) 'showSystemPreviousTrackControl': showSystemPreviousTrackControl, }; } 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 bcd2c73..92a8a5f 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 { @@ -188,6 +189,40 @@ 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. + /// + /// 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 Future isPictureInPictureAvailable() async { try { @@ -294,6 +329,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 { @@ -374,6 +442,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 {