diff --git a/packages/video_player/video_player/CHANGELOG.md b/packages/video_player/video_player/CHANGELOG.md index 3f70c390c861..67b4d95e25c4 100644 --- a/packages/video_player/video_player/CHANGELOG.md +++ b/packages/video_player/video_player/CHANGELOG.md @@ -1,5 +1,6 @@ ## 2.11.0 +* Adds background playback with system media notification support. * Adds `getAudioTracks()` and `selectAudioTrack()` methods to retrieve and select available audio tracks. * Updates minimum supported SDK version to Flutter 3.38/Dart 3.10. * Updates README to reflect currently supported OS versions for the latest diff --git a/packages/video_player/video_player/example/pubspec.yaml b/packages/video_player/video_player/example/pubspec.yaml index 09dc13ba5baa..cdeca06de5b0 100644 --- a/packages/video_player/video_player/example/pubspec.yaml +++ b/packages/video_player/video_player/example/pubspec.yaml @@ -35,3 +35,10 @@ flutter: - assets/bumble_bee_captions.srt - assets/bumble_bee_captions.vtt - assets/Audio.mp3 +# FOR TESTING AND INITIAL REVIEW ONLY. DO NOT MERGE. +# See https://github.com/flutter/flutter/blob/master/docs/ecosystem/contributing/README.md#changing-federated-plugins +dependency_overrides: + video_player_android: {path: ../../video_player_android} + video_player_avfoundation: {path: ../../video_player_avfoundation} + video_player_platform_interface: {path: ../../video_player_platform_interface} + video_player_web: {path: ../../video_player_web} diff --git a/packages/video_player/video_player/lib/video_player.dart b/packages/video_player/video_player/lib/video_player.dart index 232dda0858b7..bace7b3514ae 100644 --- a/packages/video_player/video_player/lib/video_player.dart +++ b/packages/video_player/video_player/lib/video_player.dart @@ -9,8 +9,7 @@ import 'dart:math' as math show max; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:video_player_platform_interface/video_player_platform_interface.dart' - as platform_interface; +import 'package:video_player_platform_interface/video_player_platform_interface.dart'; import 'src/closed_caption_file.dart'; @@ -18,6 +17,7 @@ export 'package:video_player_platform_interface/video_player_platform_interface. show DataSourceType, DurationRange, + NotificationMetadata, VideoFormat, VideoPlayerOptions, VideoPlayerWebOptions, @@ -26,121 +26,10 @@ export 'package:video_player_platform_interface/video_player_platform_interface. export 'src/closed_caption_file.dart'; -/// Represents an audio track in a video with its metadata. -@immutable -class VideoAudioTrack { - /// Constructs an instance of [VideoAudioTrack]. - const VideoAudioTrack({ - required this.id, - required this.isSelected, - this.label, - this.language, - this.bitrate, - this.sampleRate, - this.channelCount, - this.codec, - }); - - /// Unique identifier for the audio track. - final String id; - - /// Human-readable label for the track. - /// - /// May be null if not available from the platform. - final String? label; - - /// Language code of the audio track (e.g., 'en', 'es', 'und'). - /// - /// May be null if not available from the platform. - final String? language; - - /// Whether this track is currently selected. - final bool isSelected; - - /// Bitrate of the audio track in bits per second. - /// - /// May be null if not available from the platform. - final int? bitrate; - - /// Sample rate of the audio track in Hz. - /// - /// May be null if not available from the platform. - final int? sampleRate; - - /// Number of audio channels. - /// - /// May be null if not available from the platform. - final int? channelCount; - - /// Audio codec used (e.g., 'aac', 'mp3', 'ac3'). - /// - /// May be null if not available from the platform. - final String? codec; - - @override - bool operator ==(Object other) { - return identical(this, other) || - other is VideoAudioTrack && - runtimeType == other.runtimeType && - id == other.id && - label == other.label && - language == other.language && - isSelected == other.isSelected && - bitrate == other.bitrate && - sampleRate == other.sampleRate && - channelCount == other.channelCount && - codec == other.codec; - } - - @override - int get hashCode => Object.hash( - id, - label, - language, - isSelected, - bitrate, - sampleRate, - channelCount, - codec, - ); - - @override - String toString() => - 'VideoAudioTrack(' - 'id: $id, ' - 'label: $label, ' - 'language: $language, ' - 'isSelected: $isSelected, ' - 'bitrate: $bitrate, ' - 'sampleRate: $sampleRate, ' - 'channelCount: $channelCount, ' - 'codec: $codec)'; -} +VideoPlayerPlatform? _lastVideoPlayerPlatform; -/// Converts a platform interface [VideoAudioTrack] to the public API type. -/// -/// This internal method is used to decouple the public API from the -/// platform interface implementation. -VideoAudioTrack _convertPlatformAudioTrack( - platform_interface.VideoAudioTrack platformTrack, -) { - return VideoAudioTrack( - id: platformTrack.id, - label: platformTrack.label, - language: platformTrack.language, - isSelected: platformTrack.isSelected, - bitrate: platformTrack.bitrate, - sampleRate: platformTrack.sampleRate, - channelCount: platformTrack.channelCount, - codec: platformTrack.codec, - ); -} - -platform_interface.VideoPlayerPlatform? _lastVideoPlayerPlatform; - -platform_interface.VideoPlayerPlatform get _videoPlayerPlatform { - final platform_interface.VideoPlayerPlatform currentInstance = - platform_interface.VideoPlayerPlatform.instance; +VideoPlayerPlatform get _videoPlayerPlatform { + final VideoPlayerPlatform currentInstance = VideoPlayerPlatform.instance; if (_lastVideoPlayerPlatform != currentInstance) { // This will clear all open videos on the platform when a full restart is // performed. @@ -162,7 +51,7 @@ class VideoPlayerValue { this.position = Duration.zero, this.caption = Caption.none, this.captionOffset = Duration.zero, - this.buffered = const [], + this.buffered = const [], this.isInitialized = false, this.isPlaying = false, this.isLooping = false, @@ -210,7 +99,7 @@ class VideoPlayerValue { final Duration captionOffset; /// The currently buffered ranges. - final List buffered; + final List buffered; /// True if the video is playing. False if it's paused. final bool isPlaying; @@ -276,7 +165,7 @@ class VideoPlayerValue { Duration? position, Caption? caption, Duration? captionOffset, - List? buffered, + List? buffered, bool? isInitialized, bool? isPlaying, bool? isLooping, @@ -393,9 +282,9 @@ class VideoPlayerController extends ValueNotifier { this.package, Future? closedCaptionFile, this.videoPlayerOptions, - this.viewType = platform_interface.VideoViewType.textureView, + this.viewType = VideoViewType.textureView, }) : _closedCaptionFileFuture = closedCaptionFile, - dataSourceType = platform_interface.DataSourceType.asset, + dataSourceType = DataSourceType.asset, formatHint = null, httpHeaders = const {}, super(const VideoPlayerValue(duration: Duration.zero)); @@ -420,9 +309,9 @@ class VideoPlayerController extends ValueNotifier { Future? closedCaptionFile, this.videoPlayerOptions, this.httpHeaders = const {}, - this.viewType = platform_interface.VideoViewType.textureView, + this.viewType = VideoViewType.textureView, }) : _closedCaptionFileFuture = closedCaptionFile, - dataSourceType = platform_interface.DataSourceType.network, + dataSourceType = DataSourceType.network, package = null, super(const VideoPlayerValue(duration: Duration.zero)); @@ -441,10 +330,10 @@ class VideoPlayerController extends ValueNotifier { Future? closedCaptionFile, this.videoPlayerOptions, this.httpHeaders = const {}, - this.viewType = platform_interface.VideoViewType.textureView, + this.viewType = VideoViewType.textureView, }) : _closedCaptionFileFuture = closedCaptionFile, dataSource = url.toString(), - dataSourceType = platform_interface.DataSourceType.network, + dataSourceType = DataSourceType.network, package = null, super(const VideoPlayerValue(duration: Duration.zero)); @@ -457,10 +346,10 @@ class VideoPlayerController extends ValueNotifier { Future? closedCaptionFile, this.videoPlayerOptions, this.httpHeaders = const {}, - this.viewType = platform_interface.VideoViewType.textureView, + this.viewType = VideoViewType.textureView, }) : _closedCaptionFileFuture = closedCaptionFile, dataSource = Uri.file(file.absolute.path).toString(), - dataSourceType = platform_interface.DataSourceType.file, + dataSourceType = DataSourceType.file, package = null, formatHint = null, super(const VideoPlayerValue(duration: Duration.zero)); @@ -473,14 +362,14 @@ class VideoPlayerController extends ValueNotifier { Uri contentUri, { Future? closedCaptionFile, this.videoPlayerOptions, - this.viewType = platform_interface.VideoViewType.textureView, + this.viewType = VideoViewType.textureView, }) : assert( defaultTargetPlatform == TargetPlatform.android, 'VideoPlayerController.contentUri is only supported on Android.', ), _closedCaptionFileFuture = closedCaptionFile, dataSource = contentUri.toString(), - dataSourceType = platform_interface.DataSourceType.contentUri, + dataSourceType = DataSourceType.contentUri, package = null, formatHint = null, httpHeaders = const {}, @@ -497,14 +386,14 @@ class VideoPlayerController extends ValueNotifier { /// **Android only**. Will override the platform's generic file format /// detection with whatever is set here. - final platform_interface.VideoFormat? formatHint; + final VideoFormat? formatHint; /// Describes the type of data source this [VideoPlayerController] /// is constructed with. - final platform_interface.DataSourceType dataSourceType; + final DataSourceType dataSourceType; /// Provide additional configuration options (optional). Like setting the audio mode to mix - final platform_interface.VideoPlayerOptions? videoPlayerOptions; + final VideoPlayerOptions? videoPlayerOptions; /// Only set for [asset] videos. The package that the asset was loaded from. final String? package; @@ -512,7 +401,7 @@ class VideoPlayerController extends ValueNotifier { /// The requested display mode for the video. /// /// Platforms that do not support the request view type will ignore this. - final platform_interface.VideoViewType viewType; + final VideoViewType viewType; Future? _closedCaptionFileFuture; ClosedCaptionFile? _closedCaptionFile; @@ -542,37 +431,40 @@ class VideoPlayerController extends ValueNotifier { _lifeCycleObserver?.initialize(); _creatingCompleter = Completer(); - final platform_interface.DataSource dataSourceDescription; + final DataSource dataSourceDescription; switch (dataSourceType) { - case platform_interface.DataSourceType.asset: - dataSourceDescription = platform_interface.DataSource( - sourceType: platform_interface.DataSourceType.asset, + case DataSourceType.asset: + dataSourceDescription = DataSource( + sourceType: DataSourceType.asset, asset: dataSource, package: package, ); - case platform_interface.DataSourceType.network: - dataSourceDescription = platform_interface.DataSource( - sourceType: platform_interface.DataSourceType.network, + case DataSourceType.network: + dataSourceDescription = DataSource( + sourceType: DataSourceType.network, uri: dataSource, formatHint: formatHint, httpHeaders: httpHeaders, ); - case platform_interface.DataSourceType.file: - dataSourceDescription = platform_interface.DataSource( - sourceType: platform_interface.DataSourceType.file, + case DataSourceType.file: + dataSourceDescription = DataSource( + sourceType: DataSourceType.file, uri: dataSource, httpHeaders: httpHeaders, ); - case platform_interface.DataSourceType.contentUri: - dataSourceDescription = platform_interface.DataSource( - sourceType: platform_interface.DataSourceType.contentUri, + case DataSourceType.contentUri: + dataSourceDescription = DataSource( + sourceType: DataSourceType.contentUri, uri: dataSource, ); } - final creationOptions = platform_interface.VideoCreationOptions( + final creationOptions = VideoCreationOptions( dataSource: dataSourceDescription, viewType: viewType, + allowBackgroundPlayback: + videoPlayerOptions?.allowBackgroundPlayback ?? false, + notificationMetadata: videoPlayerOptions?.notificationMetadata, ); if (videoPlayerOptions?.mixWithOthers != null) { @@ -585,6 +477,7 @@ class VideoPlayerController extends ValueNotifier { (await _videoPlayerPlatform.createWithOptions(creationOptions)) ?? kUninitializedPlayerId; _creatingCompleter!.complete(null); + final initializingCompleter = Completer(); // Apply the web-specific options @@ -595,13 +488,13 @@ class VideoPlayerController extends ValueNotifier { ); } - void eventListener(platform_interface.VideoEvent event) { + void eventListener(VideoEvent event) { if (_isDisposed) { return; } switch (event.eventType) { - case platform_interface.VideoEventType.initialized: + case VideoEventType.initialized: value = value.copyWith( duration: event.duration, size: event.size, @@ -624,20 +517,20 @@ class VideoPlayerController extends ValueNotifier { _applyLooping(); _applyVolume(); _applyPlayPause(); - case platform_interface.VideoEventType.completed: + case VideoEventType.completed: // In this case we need to stop _timer, set isPlaying=false, and // position=value.duration. Instead of setting the values directly, // we use pause() and seekTo() to ensure the platform stops playing // and seeks to the last frame of the video. pause().then((void pauseResult) => seekTo(value.duration)); value = value.copyWith(isCompleted: true); - case platform_interface.VideoEventType.bufferingUpdate: + case VideoEventType.bufferingUpdate: value = value.copyWith(buffered: event.buffered); - case platform_interface.VideoEventType.bufferingStart: + case VideoEventType.bufferingStart: value = value.copyWith(isBuffering: true); - case platform_interface.VideoEventType.bufferingEnd: + case VideoEventType.bufferingEnd: value = value.copyWith(isBuffering: false); - case platform_interface.VideoEventType.isPlayingStateUpdate: + case VideoEventType.isPlayingStateUpdate: if (event.isPlaying ?? false) { value = value.copyWith( isPlaying: event.isPlaying, @@ -646,7 +539,7 @@ class VideoPlayerController extends ValueNotifier { } else { value = value.copyWith(isPlaying: event.isPlaying); } - case platform_interface.VideoEventType.unknown: + case VideoEventType.unknown: break; } } @@ -931,63 +824,6 @@ class VideoPlayerController extends ValueNotifier { } } - /// Gets the available audio tracks for the video. - /// - /// Returns a list of [VideoAudioTrack] objects containing metadata about - /// each available audio track. The list may be empty if no audio tracks - /// are available or if the video is not initialized. - /// - /// Throws an error if the video player is disposed. - Future> getAudioTracks() async { - if (_isDisposed) { - throw StateError('VideoPlayerController is disposed'); - } - if (!value.isInitialized) { - return []; - } - final List platformTracks = - await _videoPlayerPlatform.getAudioTracks(_playerId); - return platformTracks.map(_convertPlatformAudioTrack).toList(); - } - - /// Selects which audio track is chosen for playback from its [trackId] - /// - /// The [trackId] should match the ID of one of the tracks returned by - /// [getAudioTracks]. If the track ID is not found or invalid, the - /// platform may ignore the request or throw an exception. - /// - /// Throws an error if the video player is disposed or not initialized. - Future selectAudioTrack(String trackId) async { - if (_isDisposedOrNotInitialized) { - throw StateError('VideoPlayerController is disposed or not initialized'); - } - // The platform implementation (e.g., Android) will wait for the track - // selection to complete by listening to platform-specific events - await _videoPlayerPlatform.selectAudioTrack(_playerId, trackId); - } - - /// Returns whether audio track selection is supported on this platform. - /// - /// This method allows developers to query at runtime whether the current - /// platform supports audio track selection functionality. This is useful - /// for platforms like web where audio track selection may not be available. - /// - /// Returns `true` if [getAudioTracks] and [selectAudioTrack] are supported, - /// `false` otherwise. - /// - /// Example usage: - /// ```dart - /// if (controller.isAudioTrackSupportAvailable()) { - /// final tracks = await controller.getAudioTracks(); - /// // Show audio track selection UI - /// } else { - /// // Hide audio track selection UI or show unsupported message - /// } - /// ``` - bool isAudioTrackSupportAvailable() { - return _videoPlayerPlatform.isAudioTrackSupportAvailable(); - } - bool get _isDisposedOrNotInitialized => _isDisposed || !value.isInitialized; } @@ -1072,7 +908,7 @@ class _VideoPlayerState extends State { : _VideoPlayerWithRotation( rotation: widget.controller.value.rotationCorrection, child: _videoPlayerPlatform.buildViewWithOptions( - platform_interface.VideoViewOptions(playerId: _playerId), + VideoViewOptions(playerId: _playerId), ), ); } @@ -1285,10 +1121,7 @@ class _VideoProgressIndicatorState extends State { final double maxBuffering = duration == 0.0 ? 0.0 : controller.value.buffered - .map( - (platform_interface.DurationRange range) => - range.end.inMilliseconds, - ) + .map((DurationRange range) => range.end.inMilliseconds) .fold(0, math.max) / duration; progressIndicator = Stack( diff --git a/packages/video_player/video_player/pubspec.yaml b/packages/video_player/video_player/pubspec.yaml index f48636e14cde..e308d168bd8b 100644 --- a/packages/video_player/video_player/pubspec.yaml +++ b/packages/video_player/video_player/pubspec.yaml @@ -38,3 +38,10 @@ dev_dependencies: topics: - video - video-player +# FOR TESTING AND INITIAL REVIEW ONLY. DO NOT MERGE. +# See https://github.com/flutter/flutter/blob/master/docs/ecosystem/contributing/README.md#changing-federated-plugins +dependency_overrides: + video_player_android: {path: ../video_player_android} + video_player_avfoundation: {path: ../video_player_avfoundation} + video_player_platform_interface: {path: ../video_player_platform_interface} + video_player_web: {path: ../video_player_web} diff --git a/packages/video_player/video_player/test/video_player_test.dart b/packages/video_player/video_player/test/video_player_test.dart index 70bbf643675d..888901680082 100644 --- a/packages/video_player/video_player/test/video_player_test.dart +++ b/packages/video_player/video_player/test/video_player_test.dart @@ -1904,4 +1904,18 @@ class FakeVideoPlayerPlatform extends VideoPlayerPlatform { } final Map selectedAudioTrackIds = {}; + + @override + bool isBackgroundPlaybackSupportAvailable() { + return true; + } + + @override + Future setBackgroundPlayback( + int playerId, { + required bool enableBackground, + NotificationMetadata? notificationMetadata, + }) async { + calls.add('setBackgroundPlayback'); + } } diff --git a/packages/video_player/video_player_android/CHANGELOG.md b/packages/video_player/video_player_android/CHANGELOG.md index cf70c13aa770..38dffed747a6 100644 --- a/packages/video_player/video_player_android/CHANGELOG.md +++ b/packages/video_player/video_player_android/CHANGELOG.md @@ -1,3 +1,7 @@ +## 2.10.0 + +* Adds background playback with system media notification support. + ## 2.9.3 * Updates `androidx.media3` to 1.9.1. diff --git a/packages/video_player/video_player_android/android/build.gradle b/packages/video_player/video_player_android/android/build.gradle index 714bb410f871..cf1195815d30 100644 --- a/packages/video_player/video_player_android/android/build.gradle +++ b/packages/video_player/video_player_android/android/build.gradle @@ -57,6 +57,7 @@ android { implementation("androidx.media3:media3-exoplayer-dash:${exoplayer_version}") implementation("androidx.media3:media3-exoplayer-rtsp:${exoplayer_version}") implementation("androidx.media3:media3-exoplayer-smoothstreaming:${exoplayer_version}") + implementation("androidx.media3:media3-session:${exoplayer_version}") testImplementation("junit:junit:4.13.2") testImplementation("androidx.test:core:1.7.0") testImplementation("org.mockito:mockito-core:5.21.0") diff --git a/packages/video_player/video_player_android/android/src/main/AndroidManifest.xml b/packages/video_player/video_player_android/android/src/main/AndroidManifest.xml index e6e98bfe5a31..368b6125490e 100644 --- a/packages/video_player/video_player_android/android/src/main/AndroidManifest.xml +++ b/packages/video_player/video_player_android/android/src/main/AndroidManifest.xml @@ -1,3 +1,17 @@ + + + + + + + + + + + diff --git a/packages/video_player/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/VideoMedia3SessionService.java b/packages/video_player/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/VideoMedia3SessionService.java new file mode 100644 index 000000000000..d0858f443bd3 --- /dev/null +++ b/packages/video_player/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/VideoMedia3SessionService.java @@ -0,0 +1,572 @@ +// Copyright 2013 The Flutter Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +package io.flutter.plugins.videoplayer; + +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.content.Intent; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.net.Uri; +import android.os.Build; +import android.os.Bundle; +import android.os.Handler; +import android.os.Looper; +import android.util.Log; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.media3.common.MediaItem; +import androidx.media3.common.MediaMetadata; +import androidx.media3.common.Player; +import androidx.media3.exoplayer.ExoPlayer; +import androidx.media3.session.CommandButton; +import androidx.media3.session.DefaultMediaNotificationProvider; +import androidx.media3.session.MediaSession; +import androidx.media3.session.MediaSessionService; +import androidx.media3.session.SessionCommand; +import androidx.media3.session.SessionResult; +import com.google.common.collect.ImmutableList; +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; +import java.io.ByteArrayOutputStream; +import java.io.InputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +/** + * Media3 MediaSessionService for background video playback. Implements the modern Media3 + * MediaSession API for automatic notification management and better system integration. + * + *

Based on: https://developer.android.com/media/media3/session/background-playback + */ +public class VideoMedia3SessionService extends MediaSessionService { + private static final String TAG = "VideoMedia3SessionService"; + private static final String CHANNEL_ID = "video_player_channel"; + + // Map of texture IDs to media sessions + private final Map mediaSessions = new HashMap<>(); + + // Track the primary session for the service + @Nullable private MediaSession primarySession = null; + + // Executor for async operations like artwork loading + private final ExecutorService executorService = Executors.newCachedThreadPool(); + + // Handler for main thread operations + private final Handler mainHandler = new Handler(Looper.getMainLooper()); + + // Binder for local service connection + private final android.os.IBinder binder = new LocalBinder(); + + /** Local binder for binding to this service from VideoPlayerPlugin. */ + public class LocalBinder extends android.os.Binder { + public VideoMedia3SessionService getService() { + return VideoMedia3SessionService.this; + } + } + + @Override + public void onCreate() { + super.onCreate(); + + // Create notification channel FIRST (required for Android O+) + createNotificationChannel(); + + // Configure Media3 to automatically handle notifications + setMediaNotificationProvider( + new DefaultMediaNotificationProvider.Builder(this).setChannelId(CHANNEL_ID).build()); + + Log.d(TAG, "VideoMedia3SessionService created with notification provider"); + } + + private void createNotificationChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + NotificationChannel channel = + new NotificationChannel(CHANNEL_ID, "Video Playback", NotificationManager.IMPORTANCE_LOW); + channel.setDescription("Media playback controls"); + channel.setShowBadge(false); + + NotificationManager manager = getSystemService(NotificationManager.class); + if (manager != null) { + manager.createNotificationChannel(channel); + Log.d(TAG, "Notification channel created: " + CHANNEL_ID); + } + } + } + + @Override + @Nullable + public android.os.IBinder onBind(@NonNull Intent intent) { + // If this is a MediaSession connection, use the superclass binding + if (intent.getAction() != null + && intent.getAction().equals(MediaSessionService.SERVICE_INTERFACE)) { + return super.onBind(intent); + } + // Otherwise, return our local binder for plugin access + return binder; + } + + /** + * Called when a controller (like MediaController or notification) requests the session. Returns + * the primary MediaSession or null if none exists. + */ + @Override + @Nullable + public MediaSession onGetSession(@NonNull MediaSession.ControllerInfo controllerInfo) { + Log.d(TAG, "onGetSession called for controller: " + controllerInfo.getPackageName()); + return primarySession; + } + + /** + * Creates a MediaSession for a video player. This is the main entry point for enabling background + * playback. + * + * @param textureId Unique identifier for the video player + * @param player The ExoPlayer instance + * @param notificationMetadata Metadata about the media being played + * @return The created MediaSession, or null if creation fails + */ + @Nullable + public MediaSession createMediaSession( + int textureId, + @NonNull ExoPlayer player, + @Nullable NotificationMetadataMessage notificationMetadata) { + + try { + Log.d(TAG, "Creating Media3 MediaSession for texture: " + textureId); + + // Check if session already exists for this texture + if (mediaSessions.containsKey(textureId)) { + Log.w(TAG, "MediaSession already exists for texture: " + textureId); + return mediaSessions.get(textureId); + } + + // Validate player + if (player == null) { + Log.e(TAG, "Cannot create MediaSession with null player"); + return null; + } + + // Update player metadata if provided + if (notificationMetadata != null) { + updatePlayerMetadata(player, notificationMetadata); + } + + // Create session activity (opens the app when notification is tapped) + Intent openAppIntent = getPackageManager().getLaunchIntentForPackage(getPackageName()); + if (openAppIntent != null) { + openAppIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_SINGLE_TOP); + } + PendingIntent sessionActivity = + PendingIntent.getActivity( + this, + textureId, + openAppIntent, + PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT); + + // Build MediaSession with callback + MediaSession session = + new MediaSession.Builder(this, player) + .setId("video_player_" + textureId) + .setSessionActivity(sessionActivity) + .setCallback(new MediaSessionCallback()) + .build(); + + // Store the session + mediaSessions.put(textureId, session); + + // Set as primary session if first one + if (primarySession == null) { + primarySession = session; + Log.d(TAG, "Set primary MediaSession for texture: " + textureId); + } + + // CRITICAL: Register the session with Media3's notification system + // This tells the MediaSessionService about the active session + try { + addSession(session); + Log.d(TAG, "Called addSession() to register with Media3 notification system"); + } catch (Exception e) { + Log.e(TAG, "Error calling addSession()", e); + } + + // Add listener to log playback state changes for debugging + player.addListener( + new Player.Listener() { + @Override + public void onIsPlayingChanged(boolean isPlaying) { + Log.d(TAG, "Player isPlaying changed: " + isPlaying); + } + + @Override + public void onPlaybackStateChanged(int playbackState) { + String stateStr = + playbackState == Player.STATE_IDLE + ? "IDLE" + : playbackState == Player.STATE_BUFFERING + ? "BUFFERING" + : playbackState == Player.STATE_READY + ? "READY" + : playbackState == Player.STATE_ENDED ? "ENDED" : "UNKNOWN"; + Log.d( + TAG, + "Player playbackState changed: " + + stateStr + + " (isPlaying=" + + player.isPlaying() + + ")"); + } + }); + + Log.d( + TAG, + "MediaSession created successfully for texture: " + + textureId + + ", player.isPlaying=" + + player.isPlaying() + + ", playbackState=" + + player.getPlaybackState()); + return session; + + } catch (Exception e) { + Log.e(TAG, "Error creating MediaSession for texture: " + textureId, e); + return null; + } + } + + /** + * Updates the ExoPlayer's current MediaItem with new metadata. This will automatically update the + * notification. + */ + private void updatePlayerMetadata( + @NonNull ExoPlayer player, @NonNull NotificationMetadataMessage metadata) { + + MediaMetadata.Builder metadataBuilder = new MediaMetadata.Builder(); + + if (metadata.getTitle() != null) { + metadataBuilder.setTitle(metadata.getTitle()); + metadataBuilder.setDisplayTitle(metadata.getTitle()); + } + if (metadata.getArtist() != null) { + metadataBuilder.setArtist(metadata.getArtist()); + } + if (metadata.getAlbum() != null) { + metadataBuilder.setAlbumTitle(metadata.getAlbum()); + } + + // Load artwork asynchronously if provided + if (metadata.getArtUri() != null) { + loadArtworkAsync( + metadata.getArtUri(), + bitmap -> { + if (bitmap != null) { + mainHandler.post( + () -> { + try { + MediaMetadata currentMetadata = player.getMediaMetadata(); + MediaMetadata.Builder updatedBuilder = currentMetadata.buildUpon(); + + byte[] artworkData = bitmapToByteArray(bitmap); + updatedBuilder.setArtworkData( + artworkData, MediaMetadata.PICTURE_TYPE_FRONT_COVER); + + // Update the current media item with artwork + MediaItem currentItem = player.getCurrentMediaItem(); + if (currentItem != null) { + MediaItem updatedItem = + currentItem + .buildUpon() + .setMediaMetadata(updatedBuilder.build()) + .build(); + // Use replaceMediaItem to avoid disrupting playback + player.replaceMediaItem(0, updatedItem); + Log.d(TAG, "Artwork loaded and updated"); + } + } catch (Exception e) { + Log.e(TAG, "Error updating artwork", e); + } + }); + } + }); + } + + // Update the current media item with metadata (without artwork initially) + MediaItem currentItem = player.getCurrentMediaItem(); + if (currentItem != null) { + MediaItem updatedItem = + currentItem.buildUpon().setMediaMetadata(metadataBuilder.build()).build(); + // Use replaceMediaItem to avoid disrupting playback + player.replaceMediaItem(0, updatedItem); + Log.d(TAG, "Player metadata updated"); + } else { + Log.w(TAG, "Player has no current MediaItem to update with metadata"); + } + } + + /** Callback interface for async artwork loading. */ + private interface ArtworkCallback { + void onArtworkLoaded(@Nullable Bitmap bitmap); + } + + /** Asynchronously loads artwork from a URI. */ + private void loadArtworkAsync(@NonNull String artUri, @NonNull ArtworkCallback callback) { + executorService.execute( + () -> { + Bitmap bitmap = null; + try { + Uri uri = Uri.parse(artUri); + String scheme = uri.getScheme(); + + if ("http".equals(scheme) || "https".equals(scheme)) { + bitmap = loadArtworkFromNetwork(artUri); + } else if ("file".equals(scheme) || "content".equals(scheme)) { + bitmap = loadArtworkFromContentUri(uri); + } + + if (bitmap != null) { + bitmap = scaleBitmap(bitmap, 512); // Max 512x512 for notification + } + } catch (Exception e) { + Log.e(TAG, "Error loading artwork", e); + } + + final Bitmap finalBitmap = bitmap; + mainHandler.post(() -> callback.onArtworkLoaded(finalBitmap)); + }); + } + + private Bitmap loadArtworkFromNetwork(String urlString) throws Exception { + HttpURLConnection connection = null; + try { + URL url = new URL(urlString); + connection = (HttpURLConnection) url.openConnection(); + connection.setDoInput(true); + connection.setConnectTimeout(5000); + connection.setReadTimeout(5000); + connection.connect(); + + int responseCode = connection.getResponseCode(); + if (responseCode == HttpURLConnection.HTTP_OK) { + InputStream input = connection.getInputStream(); + return BitmapFactory.decodeStream(input); + } + return null; + } finally { + if (connection != null) { + connection.disconnect(); + } + } + } + + private Bitmap loadArtworkFromContentUri(Uri uri) throws Exception { + InputStream input = getContentResolver().openInputStream(uri); + if (input != null) { + try { + return BitmapFactory.decodeStream(input); + } finally { + input.close(); + } + } + return null; + } + + private Bitmap scaleBitmap(Bitmap bitmap, int maxSize) { + int width = bitmap.getWidth(); + int height = bitmap.getHeight(); + + if (width <= maxSize && height <= maxSize) { + return bitmap; + } + + float scale = Math.min((float) maxSize / width, (float) maxSize / height); + int newWidth = Math.round(width * scale); + int newHeight = Math.round(height * scale); + + Bitmap scaled = Bitmap.createScaledBitmap(bitmap, newWidth, newHeight, true); + if (scaled != bitmap) { + bitmap.recycle(); + } + return scaled; + } + + private byte[] bitmapToByteArray(Bitmap bitmap) { + ByteArrayOutputStream stream = new ByteArrayOutputStream(); + bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream); + return stream.toByteArray(); + } + + /** Removes a media session when player is disposed. */ + public void removeMediaSession(int textureId) { + try { + MediaSession session = mediaSessions.remove(textureId); + + if (session != null) { + Log.d(TAG, "Removing MediaSession for texture: " + textureId); + + if (session == primarySession) { + primarySession = null; + + // If there are other sessions, promote one to primary + if (!mediaSessions.isEmpty()) { + primarySession = mediaSessions.values().iterator().next(); + Log.d(TAG, "Promoted new primary MediaSession"); + } + } + + // Unregister from Media3 notification system + try { + removeSession(session); + } catch (Exception e) { + Log.e(TAG, "Error calling removeSession", e); + } + + // Release the session + try { + session.release(); + } catch (Exception e) { + Log.e(TAG, "Error releasing MediaSession", e); + } + } else { + Log.w(TAG, "No MediaSession found for texture: " + textureId); + } + + // Stop service if no more sessions + if (mediaSessions.isEmpty()) { + Log.d(TAG, "No more sessions, stopping service"); + stopSelf(); + } else { + Log.d(TAG, "Active sessions remaining: " + mediaSessions.size()); + } + + } catch (Exception e) { + Log.e(TAG, "Error in removeMediaSession for texture: " + textureId, e); + } + } + + /** MediaSession.Callback to handle playback commands and controller connections. */ + private class MediaSessionCallback implements MediaSession.Callback { + + @Override + public MediaSession.ConnectionResult onConnect( + @NonNull MediaSession session, @NonNull MediaSession.ControllerInfo controller) { + + Log.d(TAG, "Controller connecting: " + controller.getPackageName()); + + // Configure available commands for the controller + MediaSession.ConnectionResult.AcceptedResultBuilder resultBuilder = + new MediaSession.ConnectionResult.AcceptedResultBuilder(session); + + // For media notification controller, configure button preferences + if (session.isMediaNotificationController(controller)) { + Log.d(TAG, "Configuring media notification controller"); + + // Set available player commands + Player.Commands availableCommands = + new Player.Commands.Builder() + .addAll( + Player.COMMAND_PLAY_PAUSE, + Player.COMMAND_PREPARE, + Player.COMMAND_STOP, + Player.COMMAND_SEEK_TO_DEFAULT_POSITION, + Player.COMMAND_SEEK_IN_CURRENT_MEDIA_ITEM, + Player.COMMAND_SEEK_BACK, + Player.COMMAND_SEEK_FORWARD, + Player.COMMAND_SET_SPEED_AND_PITCH, + Player.COMMAND_GET_CURRENT_MEDIA_ITEM, + Player.COMMAND_GET_TIMELINE, + Player.COMMAND_GET_METADATA) + .build(); + + resultBuilder.setAvailablePlayerCommands(availableCommands); + + // Configure media button layout for notification + ImmutableList customLayout = ImmutableList.of( + new CommandButton.Builder() + .setPlayerCommand(Player.COMMAND_SEEK_BACK) + .setDisplayName("Rewind") + .setIconResId(android.R.drawable.ic_media_rew) + .build(), + new CommandButton.Builder() + .setPlayerCommand(Player.COMMAND_PLAY_PAUSE) + .setDisplayName("Play/Pause") + .setIconResId(android.R.drawable.ic_media_play) + .build(), + new CommandButton.Builder() + .setPlayerCommand(Player.COMMAND_SEEK_FORWARD) + .setDisplayName("Fast Forward") + .setIconResId(android.R.drawable.ic_media_ff) + .build()); + + resultBuilder.setMediaButtonPreferences(customLayout); + } + + return resultBuilder.build(); + } + + @Override + public ListenableFuture onCustomCommand( + @NonNull MediaSession session, + @NonNull MediaSession.ControllerInfo controller, + @NonNull SessionCommand customCommand, + @NonNull Bundle args) { + + Log.d(TAG, "Custom command received: " + customCommand.customAction); + return Futures.immediateFuture(new SessionResult(SessionResult.RESULT_SUCCESS)); + } + + @Override + public void onDisconnected( + @NonNull MediaSession session, @NonNull MediaSession.ControllerInfo controller) { + Log.d(TAG, "Controller disconnected: " + controller.getPackageName()); + } + } + + @Override + public void onTaskRemoved(@Nullable Intent rootIntent) { + Log.d(TAG, "Task removed - app swiped away"); + + // Stop all playback when app is removed from recents + for (MediaSession session : mediaSessions.values()) { + Player player = session.getPlayer(); + if (player != null && player.isPlaying()) { + player.pause(); + } + } + + // Stop the service + stopSelf(); + + super.onTaskRemoved(rootIntent); + } + + @Override + public void onDestroy() { + Log.d(TAG, "VideoMedia3SessionService destroyed"); + + try { + // Release all sessions + for (MediaSession session : mediaSessions.values()) { + try { + session.release(); + } catch (Exception e) { + Log.e(TAG, "Error releasing session in onDestroy", e); + } + } + mediaSessions.clear(); + primarySession = null; + + executorService.shutdown(); + } catch (Exception e) { + Log.e(TAG, "Error in onDestroy", e); + } + + super.onDestroy(); + } +} diff --git a/packages/video_player/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/VideoPlayer.java b/packages/video_player/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/VideoPlayer.java index 7cfb5c1c13be..5ef9dd01c9ac 100644 --- a/packages/video_player/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/VideoPlayer.java +++ b/packages/video_player/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/VideoPlayer.java @@ -7,6 +7,12 @@ import static androidx.media3.common.Player.REPEAT_MODE_ALL; import static androidx.media3.common.Player.REPEAT_MODE_OFF; +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.ServiceConnection; +import android.os.IBinder; +import android.util.Log; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.media3.common.AudioAttributes; @@ -20,6 +26,7 @@ import androidx.media3.common.util.UnstableApi; import androidx.media3.exoplayer.ExoPlayer; import androidx.media3.exoplayer.trackselection.DefaultTrackSelector; +import androidx.media3.session.MediaSession; import io.flutter.view.TextureRegistry.SurfaceProducer; import java.util.ArrayList; import java.util.List; @@ -30,6 +37,8 @@ *

It provides methods to control playback, adjust volume, and handle seeking. */ public abstract class VideoPlayer implements VideoPlayerInstanceApi { + private static final String TAG = "VideoPlayer"; + @NonNull protected final VideoPlayerCallbacks videoPlayerEvents; @Nullable protected final SurfaceProducer surfaceProducer; @Nullable private DisposeHandler disposeHandler; @@ -37,6 +46,41 @@ public abstract class VideoPlayer implements VideoPlayerInstanceApi { // TODO: Migrate to stable API, see https://github.com/flutter/flutter/issues/147039. @UnstableApi @Nullable protected DefaultTrackSelector trackSelector; + // Background playback support + @Nullable protected Context applicationContext; + @Nullable protected VideoMedia3SessionService mediaSessionService; + @Nullable protected MediaSession mediaSession; + protected boolean backgroundPlaybackEnabled = false; + protected int textureId = -1; + private boolean serviceBound = false; + + private final ServiceConnection serviceConnection = + new ServiceConnection() { + @Override + public void onServiceConnected(ComponentName name, IBinder service) { + VideoMedia3SessionService.LocalBinder binder = + (VideoMedia3SessionService.LocalBinder) service; + mediaSessionService = binder.getService(); + serviceBound = true; + Log.d(TAG, "MediaSessionService connected"); + + // If background playback was requested before service was connected, set it up now + if (backgroundPlaybackEnabled && pendingNotificationMetadata != null) { + createMediaSessionInternal(pendingNotificationMetadata); + pendingNotificationMetadata = null; + } + } + + @Override + public void onServiceDisconnected(ComponentName name) { + mediaSessionService = null; + serviceBound = false; + Log.d(TAG, "MediaSessionService disconnected"); + } + }; + + @Nullable private NotificationMetadataMessage pendingNotificationMetadata; + /** A closure-compatible signature since {@link java.util.function.Supplier} is API level 24. */ public interface ExoPlayerProvider { /** @@ -233,10 +277,105 @@ public void selectAudioTrack(long groupIndex, long trackIndex) { trackSelector.buildUponParameters().setOverrideForType(override).build()); } + /** + * Sets the application context and texture ID for background playback support. This should be + * called by the plugin when creating a player. + */ + public void setBackgroundPlaybackContext(@NonNull Context context, int textureId) { + this.applicationContext = context.getApplicationContext(); + this.textureId = textureId; + } + + /** + * Configures background playback and media notification. Called during player creation if + * background playback is requested. + */ + public void configureBackgroundPlayback(@NonNull BackgroundPlaybackMessage msg) { + backgroundPlaybackEnabled = msg.getEnableBackground(); + + if (!backgroundPlaybackEnabled) { + // Disable background playback - remove media session + removeMediaSession(); + return; + } + + NotificationMetadataMessage metadata = msg.getNotificationMetadata(); + if (metadata == null) { + // Background playback without notification - just keep the flag + Log.d(TAG, "Background playback enabled without notification metadata"); + return; + } + + // Check if service is already connected + if (mediaSessionService != null && serviceBound) { + createMediaSessionInternal(metadata); + } else { + // Store metadata and bind to service + pendingNotificationMetadata = metadata; + bindMediaSessionService(); + } + } + + private void bindMediaSessionService() { + if (applicationContext == null) { + Log.e(TAG, "Cannot bind to MediaSessionService: no application context"); + return; + } + + if (serviceBound) { + return; + } + + Intent intent = new Intent(applicationContext, VideoMedia3SessionService.class); + try { + applicationContext.startService(intent); + applicationContext.bindService(intent, serviceConnection, Context.BIND_AUTO_CREATE); + Log.d(TAG, "Binding to MediaSessionService"); + } catch (Exception e) { + Log.e(TAG, "Failed to bind to MediaSessionService", e); + } + } + + private void createMediaSessionInternal(@NonNull NotificationMetadataMessage metadata) { + if (mediaSessionService == null || textureId < 0) { + Log.w(TAG, "Cannot create media session: service not available or invalid texture ID"); + return; + } + + mediaSession = mediaSessionService.createMediaSession(textureId, exoPlayer, metadata); + if (mediaSession != null) { + Log.d(TAG, "Media session created for texture: " + textureId); + } + } + + private void removeMediaSession() { + if (mediaSessionService != null && textureId >= 0) { + mediaSessionService.removeMediaSession(textureId); + mediaSession = null; + } + } + + private void unbindMediaSessionService() { + if (applicationContext != null && serviceBound) { + try { + applicationContext.unbindService(serviceConnection); + serviceBound = false; + Log.d(TAG, "Unbound from MediaSessionService"); + } catch (Exception e) { + Log.e(TAG, "Error unbinding from MediaSessionService", e); + } + } + } + public void dispose() { if (disposeHandler != null) { disposeHandler.onDispose(); } + + // Clean up background playback resources + removeMediaSession(); + unbindMediaSessionService(); + exoPlayer.release(); } } diff --git a/packages/video_player/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/VideoPlayerPlugin.java b/packages/video_player/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/VideoPlayerPlugin.java index 49adaf4b7b33..4a179eb2e231 100644 --- a/packages/video_player/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/VideoPlayerPlugin.java +++ b/packages/video_player/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/VideoPlayerPlugin.java @@ -94,7 +94,7 @@ public long createForPlatformView(@NonNull CreationOptions options) { videoAsset, sharedOptions); - registerPlayerInstance(videoPlayer, id); + registerPlayerInstance(videoPlayer, id, options.getBackgroundPlayback()); return id; } @@ -114,7 +114,7 @@ public long createForPlatformView(@NonNull CreationOptions options) { videoAsset, sharedOptions); - registerPlayerInstance(videoPlayer, id); + registerPlayerInstance(videoPlayer, id, options.getBackgroundPlayback()); return new TexturePlayerIds(id, handle.id()); } @@ -145,7 +145,16 @@ public long createForPlatformView(@NonNull CreationOptions options) { } } - private void registerPlayerInstance(VideoPlayer player, long id) { + private void registerPlayerInstance( + VideoPlayer player, long id, @Nullable BackgroundPlaybackMessage backgroundPlayback) { + // Set up background playback context + player.setBackgroundPlaybackContext(flutterState.applicationContext, (int) id); + + // Configure background playback if requested + if (backgroundPlayback != null) { + player.configureBackgroundPlayback(backgroundPlayback); + } + // Set up the instance-specific API handler, and make sure it is removed when the player is // disposed. BinaryMessenger messenger = flutterState.binaryMessenger; diff --git a/packages/video_player/video_player_android/android/src/main/kotlin/io/flutter/plugins/videoplayer/Messages.kt b/packages/video_player/video_player_android/android/src/main/kotlin/io/flutter/plugins/videoplayer/Messages.kt index e546c744e561..9ceba4d3948d 100644 --- a/packages/video_player/video_player_android/android/src/main/kotlin/io/flutter/plugins/videoplayer/Messages.kt +++ b/packages/video_player/video_player_android/android/src/main/kotlin/io/flutter/plugins/videoplayer/Messages.kt @@ -1,7 +1,7 @@ // Copyright 2013 The Flutter Authors // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -// Autogenerated from Pigeon (v26.1.5), do not edit directly. +// Autogenerated from Pigeon (v26.1.7), do not edit directly. // See also: https://pub.dev/packages/pigeon @file:Suppress("UNCHECKED_CAST", "ArrayInDataClass") @@ -300,7 +300,9 @@ data class CreationOptions( val uri: String, val formatHint: PlatformVideoFormat? = null, val httpHeaders: Map, - val userAgent: String? = null + val userAgent: String? = null, + /** Background playback configuration (optional). */ + val backgroundPlayback: BackgroundPlaybackMessage? = null ) { companion object { fun fromList(pigeonVar_list: List): CreationOptions { @@ -308,7 +310,8 @@ data class CreationOptions( val formatHint = pigeonVar_list[1] as PlatformVideoFormat? val httpHeaders = pigeonVar_list[2] as Map val userAgent = pigeonVar_list[3] as String? - return CreationOptions(uri, formatHint, httpHeaders, userAgent) + val backgroundPlayback = pigeonVar_list[4] as BackgroundPlaybackMessage? + return CreationOptions(uri, formatHint, httpHeaders, userAgent, backgroundPlayback) } } @@ -318,6 +321,7 @@ data class CreationOptions( formatHint, httpHeaders, userAgent, + backgroundPlayback, ) } @@ -557,6 +561,92 @@ data class NativeAudioTrackData( override fun hashCode(): Int = toList().hashCode() } +/** + * Metadata for the system media notification when playing in background. + * + * Generated class from Pigeon that represents data sent in messages. + */ +data class NotificationMetadataMessage( + val id: String, + val title: String? = null, + val album: String? = null, + val artist: String? = null, + val durationMs: Long? = null, + val artUri: String? = null +) { + companion object { + fun fromList(pigeonVar_list: List): NotificationMetadataMessage { + val id = pigeonVar_list[0] as String + val title = pigeonVar_list[1] as String? + val album = pigeonVar_list[2] as String? + val artist = pigeonVar_list[3] as String? + val durationMs = pigeonVar_list[4] as Long? + val artUri = pigeonVar_list[5] as String? + return NotificationMetadataMessage(id, title, album, artist, durationMs, artUri) + } + } + + fun toList(): List { + return listOf( + id, + title, + album, + artist, + durationMs, + artUri, + ) + } + + override fun equals(other: Any?): Boolean { + if (other !is NotificationMetadataMessage) { + return false + } + if (this === other) { + return true + } + return MessagesPigeonUtils.deepEquals(toList(), other.toList()) + } + + override fun hashCode(): Int = toList().hashCode() +} + +/** + * Message for configuring background playback with media notification. + * + * Generated class from Pigeon that represents data sent in messages. + */ +data class BackgroundPlaybackMessage( + val enableBackground: Boolean, + val notificationMetadata: NotificationMetadataMessage? = null +) { + companion object { + fun fromList(pigeonVar_list: List): BackgroundPlaybackMessage { + val enableBackground = pigeonVar_list[0] as Boolean + val notificationMetadata = pigeonVar_list[1] as NotificationMetadataMessage? + return BackgroundPlaybackMessage(enableBackground, notificationMetadata) + } + } + + fun toList(): List { + return listOf( + enableBackground, + notificationMetadata, + ) + } + + override fun equals(other: Any?): Boolean { + if (other !is BackgroundPlaybackMessage) { + return false + } + if (this === other) { + return true + } + return MessagesPigeonUtils.deepEquals(toList(), other.toList()) + } + + override fun hashCode(): Int = toList().hashCode() +} + private open class MessagesPigeonCodec : StandardMessageCodec() { override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? { return when (type) { @@ -601,6 +691,12 @@ private open class MessagesPigeonCodec : StandardMessageCodec() { 141.toByte() -> { return (readValue(buffer) as? List)?.let { NativeAudioTrackData.fromList(it) } } + 142.toByte() -> { + return (readValue(buffer) as? List)?.let { NotificationMetadataMessage.fromList(it) } + } + 143.toByte() -> { + return (readValue(buffer) as? List)?.let { BackgroundPlaybackMessage.fromList(it) } + } else -> super.readValueOfType(type, buffer) } } @@ -659,6 +755,14 @@ private open class MessagesPigeonCodec : StandardMessageCodec() { stream.write(141) writeValue(stream, value.toList()) } + is NotificationMetadataMessage -> { + stream.write(142) + writeValue(stream, value.toList()) + } + is BackgroundPlaybackMessage -> { + stream.write(143) + writeValue(stream, value.toList()) + } else -> super.writeValue(stream, value) } } diff --git a/packages/video_player/video_player_android/example/pubspec.yaml b/packages/video_player/video_player_android/example/pubspec.yaml index 07c5b497d5d2..64b8c2cfd5a1 100644 --- a/packages/video_player/video_player_android/example/pubspec.yaml +++ b/packages/video_player/video_player_android/example/pubspec.yaml @@ -34,3 +34,7 @@ flutter: assets: - assets/flutter-mark-square-64.png - assets/Butterfly-209.mp4 +# FOR TESTING AND INITIAL REVIEW ONLY. DO NOT MERGE. +# See https://github.com/flutter/flutter/blob/master/docs/ecosystem/contributing/README.md#changing-federated-plugins +dependency_overrides: + video_player_platform_interface: {path: ../../../../packages/video_player/video_player_platform_interface} diff --git a/packages/video_player/video_player_android/lib/src/android_video_player.dart b/packages/video_player/video_player_android/lib/src/android_video_player.dart index 84249bd41afd..ca290d9b832e 100644 --- a/packages/video_player/video_player_android/lib/src/android_video_player.dart +++ b/packages/video_player/video_player_android/lib/src/android_video_player.dart @@ -109,11 +109,33 @@ class AndroidVideoPlayer extends VideoPlayerPlatform { if (uri == null) { throw ArgumentError('Unable to construct a video asset from $options'); } + // Set up background playback configuration if provided + BackgroundPlaybackMessage? backgroundPlayback; + if (options.allowBackgroundPlayback) { + NotificationMetadataMessage? metadataMessage; + if (options.notificationMetadata != null) { + final NotificationMetadata metadata = options.notificationMetadata!; + metadataMessage = NotificationMetadataMessage( + id: metadata.id, + title: metadata.title, + artist: metadata.artist, + album: metadata.album, + durationMs: metadata.duration?.inMilliseconds, + artUri: metadata.artUri?.toString(), + ); + } + backgroundPlayback = BackgroundPlaybackMessage( + enableBackground: true, + notificationMetadata: metadataMessage, + ); + } + final pigeonCreationOptions = CreationOptions( uri: uri, httpHeaders: httpHeaders, userAgent: userAgent, formatHint: formatHint, + backgroundPlayback: backgroundPlayback, ); final int playerId; diff --git a/packages/video_player/video_player_android/lib/src/messages.g.dart b/packages/video_player/video_player_android/lib/src/messages.g.dart index 1aca7dc531dd..8f91b917a49e 100644 --- a/packages/video_player/video_player_android/lib/src/messages.g.dart +++ b/packages/video_player/video_player_android/lib/src/messages.g.dart @@ -1,7 +1,7 @@ // Copyright 2013 The Flutter Authors // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -// Autogenerated from Pigeon (v26.1.5), do not edit directly. +// Autogenerated from Pigeon (v26.1.7), do not edit directly. // See also: https://pub.dev/packages/pigeon // ignore_for_file: public_member_api_docs, non_constant_identifier_names, avoid_as, unused_import, unnecessary_parenthesis, prefer_null_aware_operators, omit_local_variable_types, omit_obvious_local_variable_types, unused_shown_name, unnecessary_import, no_leading_underscores_for_local_identifiers @@ -261,6 +261,7 @@ class CreationOptions { this.formatHint, required this.httpHeaders, this.userAgent, + this.backgroundPlayback, }); String uri; @@ -271,8 +272,17 @@ class CreationOptions { String? userAgent; + /// Background playback configuration (optional). + BackgroundPlaybackMessage? backgroundPlayback; + List _toList() { - return [uri, formatHint, httpHeaders, userAgent]; + return [ + uri, + formatHint, + httpHeaders, + userAgent, + backgroundPlayback, + ]; } Object encode() { @@ -287,6 +297,7 @@ class CreationOptions { httpHeaders: (result[2] as Map?)! .cast(), userAgent: result[3] as String?, + backgroundPlayback: result[4] as BackgroundPlaybackMessage?, ); } @@ -588,6 +599,112 @@ class NativeAudioTrackData { int get hashCode => Object.hashAll(_toList()); } +/// Metadata for the system media notification when playing in background. +class NotificationMetadataMessage { + NotificationMetadataMessage({ + required this.id, + this.title, + this.album, + this.artist, + this.durationMs, + this.artUri, + }); + + String id; + + String? title; + + String? album; + + String? artist; + + int? durationMs; + + String? artUri; + + List _toList() { + return [id, title, album, artist, durationMs, artUri]; + } + + Object encode() { + return _toList(); + } + + static NotificationMetadataMessage decode(Object result) { + result as List; + return NotificationMetadataMessage( + id: result[0]! as String, + title: result[1] as String?, + album: result[2] as String?, + artist: result[3] as String?, + durationMs: result[4] as int?, + artUri: result[5] as String?, + ); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + bool operator ==(Object other) { + if (other is! NotificationMetadataMessage || + other.runtimeType != runtimeType) { + return false; + } + if (identical(this, other)) { + return true; + } + return _deepEquals(encode(), other.encode()); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + int get hashCode => Object.hashAll(_toList()); +} + +/// Message for configuring background playback with media notification. +class BackgroundPlaybackMessage { + BackgroundPlaybackMessage({ + required this.enableBackground, + this.notificationMetadata, + }); + + bool enableBackground; + + NotificationMetadataMessage? notificationMetadata; + + List _toList() { + return [enableBackground, notificationMetadata]; + } + + Object encode() { + return _toList(); + } + + static BackgroundPlaybackMessage decode(Object result) { + result as List; + return BackgroundPlaybackMessage( + enableBackground: result[0]! as bool, + notificationMetadata: result[1] as NotificationMetadataMessage?, + ); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + bool operator ==(Object other) { + if (other is! BackgroundPlaybackMessage || + other.runtimeType != runtimeType) { + return false; + } + if (identical(this, other)) { + return true; + } + return _deepEquals(encode(), other.encode()); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + int get hashCode => Object.hashAll(_toList()); +} + class _PigeonCodec extends StandardMessageCodec { const _PigeonCodec(); @override @@ -634,6 +751,12 @@ class _PigeonCodec extends StandardMessageCodec { } else if (value is NativeAudioTrackData) { buffer.putUint8(141); writeValue(buffer, value.encode()); + } else if (value is NotificationMetadataMessage) { + buffer.putUint8(142); + writeValue(buffer, value.encode()); + } else if (value is BackgroundPlaybackMessage) { + buffer.putUint8(143); + writeValue(buffer, value.encode()); } else { super.writeValue(buffer, value); } @@ -670,6 +793,10 @@ class _PigeonCodec extends StandardMessageCodec { return ExoPlayerAudioTrackData.decode(readValue(buffer)!); case 141: return NativeAudioTrackData.decode(readValue(buffer)!); + case 142: + return NotificationMetadataMessage.decode(readValue(buffer)!); + case 143: + return BackgroundPlaybackMessage.decode(readValue(buffer)!); default: return super.readValueOfType(type, buffer); } diff --git a/packages/video_player/video_player_android/pigeons/messages.dart b/packages/video_player/video_player_android/pigeons/messages.dart index 8666b074969a..08bda2fe40ba 100644 --- a/packages/video_player/video_player_android/pigeons/messages.dart +++ b/packages/video_player/video_player_android/pigeons/messages.dart @@ -73,6 +73,9 @@ class CreationOptions { PlatformVideoFormat? formatHint; Map httpHeaders; String? userAgent; + + /// Background playback configuration (optional). + BackgroundPlaybackMessage? backgroundPlayback; } class TexturePlayerIds { @@ -148,6 +151,36 @@ class NativeAudioTrackData { List? exoPlayerTracks; } +/// Metadata for the system media notification when playing in background. +class NotificationMetadataMessage { + NotificationMetadataMessage({ + required this.id, + this.title, + this.album, + this.artist, + this.durationMs, + this.artUri, + }); + + String id; + String? title; + String? album; + String? artist; + int? durationMs; + String? artUri; +} + +/// Message for configuring background playback with media notification. +class BackgroundPlaybackMessage { + BackgroundPlaybackMessage({ + required this.enableBackground, + this.notificationMetadata, + }); + + bool enableBackground; + NotificationMetadataMessage? notificationMetadata; +} + @HostApi() abstract class AndroidVideoPlayerApi { void initialize(); diff --git a/packages/video_player/video_player_android/pubspec.yaml b/packages/video_player/video_player_android/pubspec.yaml index 359903f05de1..edaecf5f1a6d 100644 --- a/packages/video_player/video_player_android/pubspec.yaml +++ b/packages/video_player/video_player_android/pubspec.yaml @@ -2,7 +2,7 @@ name: video_player_android description: Android implementation of the video_player plugin. repository: https://github.com/flutter/packages/tree/main/packages/video_player/video_player_android issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+video_player%22 -version: 2.9.3 +version: 2.10.0 environment: sdk: ^3.9.0 @@ -32,3 +32,7 @@ dev_dependencies: topics: - video - video-player +# FOR TESTING AND INITIAL REVIEW ONLY. DO NOT MERGE. +# See https://github.com/flutter/flutter/blob/master/docs/ecosystem/contributing/README.md#changing-federated-plugins +dependency_overrides: + video_player_platform_interface: {path: ../video_player_platform_interface} diff --git a/packages/video_player/video_player_android/test/android_video_player_test.dart b/packages/video_player/video_player_android/test/android_video_player_test.dart index 810a815fddf5..47bb0274cff7 100644 --- a/packages/video_player/video_player_android/test/android_video_player_test.dart +++ b/packages/video_player/video_player_android/test/android_video_player_test.dart @@ -33,6 +33,14 @@ void main() { ); provideDummy>(Future.value()); + // Provide dummy values for background playback types + provideDummy( + BackgroundPlaybackMessage(enableBackground: false), + ); + provideDummy( + NotificationMetadataMessage(id: 'dummy'), + ); + (AndroidVideoPlayer, MockAndroidVideoPlayerApi, MockVideoPlayerInstanceApi) setUpMockPlayer({required int playerId, int? textureId}) { final pluginApi = MockAndroidVideoPlayerApi(); diff --git a/packages/video_player/video_player_android/test/android_video_player_test.mocks.dart b/packages/video_player/video_player_android/test/android_video_player_test.mocks.dart index 212c9bde40c1..7232580654bc 100644 --- a/packages/video_player/video_player_android/test/android_video_player_test.mocks.dart +++ b/packages/video_player/video_player_android/test/android_video_player_test.mocks.dart @@ -22,6 +22,7 @@ import 'package:video_player_android/src/messages.g.dart' as _i2; // ignore_for_file: unnecessary_parenthesis // ignore_for_file: camel_case_types // ignore_for_file: subtype_of_sealed_class +// ignore_for_file: invalid_use_of_internal_member class _FakeTexturePlayerIds_0 extends _i1.SmartFake implements _i2.TexturePlayerIds { diff --git a/packages/video_player/video_player_avfoundation/CHANGELOG.md b/packages/video_player/video_player_avfoundation/CHANGELOG.md index b22e2b1231e9..6bec31391b95 100644 --- a/packages/video_player/video_player_avfoundation/CHANGELOG.md +++ b/packages/video_player/video_player_avfoundation/CHANGELOG.md @@ -1,3 +1,11 @@ +## 2.10.1 + +* Fixes CarPlay Now Playing play/pause button not updating when playback is controlled from the Flutter app. + +## 2.10.0 + +* Adds background playback with system media notification support. + ## 2.9.3 * Fixes a regression where HTTP headers were ignored. diff --git a/packages/video_player/video_player_avfoundation/darwin/video_player_avfoundation/Sources/video_player_avfoundation/FVPVideoPlayer.m b/packages/video_player/video_player_avfoundation/darwin/video_player_avfoundation/Sources/video_player_avfoundation/FVPVideoPlayer.m index 2270120378d5..6992dc25fe23 100644 --- a/packages/video_player/video_player_avfoundation/darwin/video_player_avfoundation/Sources/video_player_avfoundation/FVPVideoPlayer.m +++ b/packages/video_player/video_player_avfoundation/darwin/video_player_avfoundation/Sources/video_player_avfoundation/FVPVideoPlayer.m @@ -6,6 +6,9 @@ #import "./include/video_player_avfoundation/FVPVideoPlayer_Internal.h" #import +#if TARGET_OS_IOS +#import +#endif #import "./include/video_player_avfoundation/AVAssetTrackUtils.h" @@ -69,6 +72,13 @@ static void FVPRemoveKeyValueObservers(NSObject *observer, @implementation FVPVideoPlayer { // Whether or not player and player item listeners have ever been registered. BOOL _listenersRegistered; + + // Background playback support + BOOL _enableBackgroundPlayback; + FVPNotificationMetadataMessage *_notificationMetadata; +#if TARGET_OS_IOS + id _timeObserver; +#endif } - (instancetype)initWithPlayerItem:(NSObject *)item @@ -165,6 +175,15 @@ - (void)disposeWithError:(FlutterError *_Nullable *_Nonnull)error { } _disposed = YES; + // Clean up background playback resources +#if TARGET_OS_IOS + if (_enableBackgroundPlayback) { + [self teardownRemoteCommandCenter]; + [self clearNowPlayingInfo]; + [self removeTimeObserver]; + } +#endif + if (_listenersRegistered) { [[NSNotificationCenter defaultCenter] removeObserver:self]; FVPRemoveKeyValueObservers(self, FVPGetPlayerItemObservations(), self.player.currentItem); @@ -401,11 +420,21 @@ - (void)reportInitialized { - (void)playWithError:(FlutterError *_Nullable *_Nonnull)error { _isPlaying = YES; [self updatePlayingState]; +#if TARGET_OS_IOS + if (_enableBackgroundPlayback) { + [self updateNowPlayingPlaybackState]; + } +#endif } - (void)pauseWithError:(FlutterError *_Nullable *_Nonnull)error { _isPlaying = NO; [self updatePlayingState]; +#if TARGET_OS_IOS + if (_enableBackgroundPlayback) { + [self updateNowPlayingPlaybackState]; + } +#endif } - (nullable NSNumber *)position:(FlutterError *_Nullable *_Nonnull)error { @@ -508,6 +537,368 @@ - (void)selectAudioTrackAtIndex:(NSInteger)trackIndex } } +/// Configures background playback and media notification. +/// Called during player creation if background playback is requested. +- (void)configureBackgroundPlayback:(FVPBackgroundPlaybackMessage *)msg { + _enableBackgroundPlayback = msg.enableBackground; + _notificationMetadata = msg.notificationMetadata; + +#if TARGET_OS_IOS + if (_enableBackgroundPlayback) { + // Configure audio session for background playback + NSError *audioError = nil; + AVAudioSession *audioSession = [AVAudioSession sharedInstance]; + [audioSession setCategory:AVAudioSessionCategoryPlayback + mode:AVAudioSessionModeMoviePlayback + options:0 + error:&audioError]; + if (audioError) { + NSLog(@"Error setting audio session category: %@", audioError); + } + + [audioSession setActive:YES error:&audioError]; + if (audioError) { + NSLog(@"Error activating audio session: %@", audioError); + } + + // iOS 15+: Enable seamless background playback without layer disconnection + if (@available(iOS 15.0, macOS 12.0, *)) { + _player.audiovisualBackgroundPlaybackPolicy = + AVPlayerAudiovisualBackgroundPlaybackPolicyContinuesIfPossible; + } + + // Only set up remote commands and Now Playing if metadata is provided + if (_notificationMetadata) { + [self setupRemoteCommandCenter]; + [self updateNowPlayingInfo]; + [self setupTimeObserver]; + } else { + // Ensure no stale notification from previous player + [self clearNowPlayingInfo]; + } + } else { + // Disable background playback + [self teardownRemoteCommandCenter]; + [self clearNowPlayingInfo]; + [self removeTimeObserver]; + + // iOS 15+: Reset background playback policy to automatic (default) + if (@available(iOS 15.0, macOS 12.0, *)) { + _player.audiovisualBackgroundPlaybackPolicy = + AVPlayerAudiovisualBackgroundPlaybackPolicyAutomatic; + } + + // Deactivate audio session to release audio hardware + NSError *audioError = nil; + [[AVAudioSession sharedInstance] + setActive:NO + withOptions:AVAudioSessionSetActiveOptionNotifyOthersOnDeactivation + error:&audioError]; + if (audioError) { + NSLog(@"Error deactivating audio session: %@", audioError); + } + } +#else + // macOS doesn't support MPNowPlayingInfoCenter in the same way + // Background audio works differently on macOS + if (_enableBackgroundPlayback) { + NSLog(@"Background playback enabled (macOS - limited support)"); + } +#endif +} + +#if TARGET_OS_IOS +- (void)setupRemoteCommandCenter { + MPRemoteCommandCenter *commandCenter = [MPRemoteCommandCenter sharedCommandCenter]; + + // Play command + [commandCenter.playCommand setEnabled:YES]; + [commandCenter.playCommand addTarget:self action:@selector(handlePlayCommand:)]; + + // Pause command + [commandCenter.pauseCommand setEnabled:YES]; + [commandCenter.pauseCommand addTarget:self action:@selector(handlePauseCommand:)]; + + // Toggle play/pause command + [commandCenter.togglePlayPauseCommand setEnabled:YES]; + [commandCenter.togglePlayPauseCommand addTarget:self + action:@selector(handleTogglePlayPauseCommand:)]; + + // Change playback position command (seeking) + [commandCenter.changePlaybackPositionCommand setEnabled:YES]; + [commandCenter.changePlaybackPositionCommand + addTarget:self + action:@selector(handleChangePlaybackPositionCommand:)]; + + // Skip forward command + [commandCenter.skipForwardCommand setEnabled:YES]; + commandCenter.skipForwardCommand.preferredIntervals = @[ @15 ]; + [commandCenter.skipForwardCommand addTarget:self action:@selector(handleSkipForwardCommand:)]; + + // Skip backward command + [commandCenter.skipBackwardCommand setEnabled:YES]; + commandCenter.skipBackwardCommand.preferredIntervals = @[ @15 ]; + [commandCenter.skipBackwardCommand addTarget:self action:@selector(handleSkipBackwardCommand:)]; +} + +- (MPRemoteCommandHandlerStatus)handlePlayCommand:(MPRemoteCommandEvent *)event { + _isPlaying = YES; + [self updatePlayingState]; + [self updateNowPlayingPlaybackState]; + return MPRemoteCommandHandlerStatusSuccess; +} + +- (MPRemoteCommandHandlerStatus)handlePauseCommand:(MPRemoteCommandEvent *)event { + _isPlaying = NO; + [self updatePlayingState]; + [self updateNowPlayingPlaybackState]; + return MPRemoteCommandHandlerStatusSuccess; +} + +- (MPRemoteCommandHandlerStatus)handleTogglePlayPauseCommand:(MPRemoteCommandEvent *)event { + _isPlaying = !_isPlaying; + [self updatePlayingState]; + [self updateNowPlayingPlaybackState]; + return MPRemoteCommandHandlerStatusSuccess; +} + +- (MPRemoteCommandHandlerStatus)handleChangePlaybackPositionCommand:(MPRemoteCommandEvent *)event { + MPChangePlaybackPositionCommandEvent *positionEvent = + (MPChangePlaybackPositionCommandEvent *)event; + CMTime targetTime = CMTimeMakeWithSeconds(positionEvent.positionTime, NSEC_PER_SEC); + [_player seekToTime:targetTime + completionHandler:^(BOOL finished) { + if (finished) { + [self updateNowPlayingPlaybackState]; + } + }]; + return MPRemoteCommandHandlerStatusSuccess; +} + +- (MPRemoteCommandHandlerStatus)handleSkipForwardCommand:(MPRemoteCommandEvent *)event { + MPSkipIntervalCommandEvent *skipEvent = (MPSkipIntervalCommandEvent *)event; + CMTime currentTime = _player.currentTime; + CMTime skipTime = CMTimeMakeWithSeconds(skipEvent.interval, NSEC_PER_SEC); + CMTime newTime = CMTimeAdd(currentTime, skipTime); + [_player seekToTime:newTime + completionHandler:^(BOOL finished) { + if (finished) { + [self updateNowPlayingPlaybackState]; + } + }]; + return MPRemoteCommandHandlerStatusSuccess; +} + +- (MPRemoteCommandHandlerStatus)handleSkipBackwardCommand:(MPRemoteCommandEvent *)event { + MPSkipIntervalCommandEvent *skipEvent = (MPSkipIntervalCommandEvent *)event; + CMTime currentTime = _player.currentTime; + CMTime skipTime = CMTimeMakeWithSeconds(skipEvent.interval, NSEC_PER_SEC); + CMTime newTime = CMTimeSubtract(currentTime, skipTime); + [_player seekToTime:newTime + completionHandler:^(BOOL finished) { + if (finished) { + [self updateNowPlayingPlaybackState]; + } + }]; + return MPRemoteCommandHandlerStatusSuccess; +} + +- (void)teardownRemoteCommandCenter { + MPRemoteCommandCenter *commandCenter = [MPRemoteCommandCenter sharedCommandCenter]; + + [commandCenter.playCommand removeTarget:self action:@selector(handlePlayCommand:)]; + [commandCenter.pauseCommand removeTarget:self action:@selector(handlePauseCommand:)]; + [commandCenter.togglePlayPauseCommand removeTarget:self + action:@selector(handleTogglePlayPauseCommand:)]; + [commandCenter.changePlaybackPositionCommand + removeTarget:self + action:@selector(handleChangePlaybackPositionCommand:)]; + [commandCenter.skipForwardCommand removeTarget:self action:@selector(handleSkipForwardCommand:)]; + [commandCenter.skipBackwardCommand removeTarget:self + action:@selector(handleSkipBackwardCommand:)]; + + [commandCenter.playCommand setEnabled:NO]; + [commandCenter.pauseCommand setEnabled:NO]; + [commandCenter.togglePlayPauseCommand setEnabled:NO]; + [commandCenter.changePlaybackPositionCommand setEnabled:NO]; + [commandCenter.skipForwardCommand setEnabled:NO]; + [commandCenter.skipBackwardCommand setEnabled:NO]; +} + +- (void)updateNowPlayingInfo { + if (!_notificationMetadata) { + return; + } + + NSMutableDictionary *nowPlayingInfo = [[NSMutableDictionary alloc] init]; + + // Set title + if (_notificationMetadata.title) { + nowPlayingInfo[MPMediaItemPropertyTitle] = _notificationMetadata.title; + } + + // Set artist + if (_notificationMetadata.artist) { + nowPlayingInfo[MPMediaItemPropertyArtist] = _notificationMetadata.artist; + } + + // Set album + if (_notificationMetadata.album) { + nowPlayingInfo[MPMediaItemPropertyAlbumTitle] = _notificationMetadata.album; + } + + // Set duration + NSNumber *durationMs = _notificationMetadata.durationMs; + if (durationMs) { + nowPlayingInfo[MPMediaItemPropertyPlaybackDuration] = @(durationMs.doubleValue / 1000.0); + } else { + // Use the actual video duration + CMTime duration = _player.currentItem.asset.duration; + if (CMTIME_IS_VALID(duration) && !CMTIME_IS_INDEFINITE(duration)) { + nowPlayingInfo[MPMediaItemPropertyPlaybackDuration] = @(CMTimeGetSeconds(duration)); + } + } + + // Set current playback position + CMTime currentTime = _player.currentTime; + if (CMTIME_IS_VALID(currentTime)) { + nowPlayingInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = @(CMTimeGetSeconds(currentTime)); + } + + // Set playback rate + nowPlayingInfo[MPNowPlayingInfoPropertyPlaybackRate] = @(_isPlaying ? _player.rate : 0.0); + + // Load artwork asynchronously if provided + if (_notificationMetadata.artUri) { + [self loadArtworkFromUri:_notificationMetadata.artUri + completion:^(MPMediaItemArtwork *artwork) { + if (artwork) { + NSMutableDictionary *info = + [[MPNowPlayingInfoCenter defaultCenter].nowPlayingInfo mutableCopy]; + if (info) { + info[MPMediaItemPropertyArtwork] = artwork; + [MPNowPlayingInfoCenter defaultCenter].nowPlayingInfo = info; + } + } + }]; + } + + [MPNowPlayingInfoCenter defaultCenter].nowPlayingInfo = nowPlayingInfo; + + // Set initial playbackState for CarPlay CPNowPlayingTemplate. + if (@available(iOS 14.0, *)) { + [MPNowPlayingInfoCenter defaultCenter].playbackState = + _isPlaying ? MPNowPlayingPlaybackStatePlaying : MPNowPlayingPlaybackStatePaused; + } +} + +- (void)updateNowPlayingPlaybackState { + NSMutableDictionary *nowPlayingInfo = + [[MPNowPlayingInfoCenter defaultCenter].nowPlayingInfo mutableCopy]; + if (!nowPlayingInfo) { + return; + } + + // Update elapsed time + CMTime currentTime = _player.currentTime; + if (CMTIME_IS_VALID(currentTime)) { + nowPlayingInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = @(CMTimeGetSeconds(currentTime)); + } + + // Update playback rate + nowPlayingInfo[MPNowPlayingInfoPropertyPlaybackRate] = @(_isPlaying ? _player.rate : 0.0); + + [MPNowPlayingInfoCenter defaultCenter].nowPlayingInfo = nowPlayingInfo; + + // Update playbackState explicitly for CarPlay CPNowPlayingTemplate. + // The lock screen / Control Center infers play/pause from playbackRate in the + // nowPlayingInfo dictionary, but CarPlay's CPNowPlayingTemplate reads the + // playbackState property instead. Although Apple documents this as macOS-only, + // it works on iOS 14+ and is required for CarPlay to reflect the correct + // play/pause button state. + if (@available(iOS 14.0, *)) { + [MPNowPlayingInfoCenter defaultCenter].playbackState = + _isPlaying ? MPNowPlayingPlaybackStatePlaying : MPNowPlayingPlaybackStatePaused; + } +} + +- (void)clearNowPlayingInfo { + [MPNowPlayingInfoCenter defaultCenter].nowPlayingInfo = nil; + if (@available(iOS 14.0, *)) { + [MPNowPlayingInfoCenter defaultCenter].playbackState = MPNowPlayingPlaybackStateStopped; + } +} + +- (void)setupTimeObserver { + // Remove existing observer if any + [self removeTimeObserver]; + + // Add periodic time observer to update Now Playing info + __weak typeof(self) weakSelf = self; + _timeObserver = + [_player addPeriodicTimeObserverForInterval:CMTimeMakeWithSeconds(1.0, NSEC_PER_SEC) + queue:dispatch_get_main_queue() + usingBlock:^(CMTime time) { + [weakSelf updateNowPlayingPlaybackState]; + }]; +} + +- (void)removeTimeObserver { + if (_timeObserver) { + [_player removeTimeObserver:_timeObserver]; + _timeObserver = nil; + } +} + +- (void)loadArtworkFromUri:(NSString *)uriString + completion:(void (^)(MPMediaItemArtwork *_Nullable artwork))completion { + if (!uriString || uriString.length == 0) { + completion(nil); + return; + } + + NSURL *url = [NSURL URLWithString:uriString]; + if (!url) { + completion(nil); + return; + } + + // Check if it's a local file or network URL + if ([url.scheme isEqualToString:@"file"]) { + UIImage *image = [UIImage imageWithContentsOfFile:url.path]; + if (image) { + MPMediaItemArtwork *artwork = + [[MPMediaItemArtwork alloc] initWithBoundsSize:image.size + requestHandler:^UIImage *_Nonnull(CGSize size) { + return image; + }]; + completion(artwork); + } else { + completion(nil); + } + } else { + // Network URL - load asynchronously + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ + NSData *imageData = [NSData dataWithContentsOfURL:url]; + UIImage *image = imageData ? [UIImage imageWithData:imageData] : nil; + + dispatch_async(dispatch_get_main_queue(), ^{ + if (image) { + MPMediaItemArtwork *artwork = + [[MPMediaItemArtwork alloc] initWithBoundsSize:image.size + requestHandler:^UIImage *_Nonnull(CGSize size) { + return image; + }]; + completion(artwork); + } else { + completion(nil); + } + }); + }); + } +} +#endif + #pragma mark - Private - (int64_t)duration { diff --git a/packages/video_player/video_player_avfoundation/darwin/video_player_avfoundation/Sources/video_player_avfoundation/FVPVideoPlayerPlugin.m b/packages/video_player/video_player_avfoundation/darwin/video_player_avfoundation/Sources/video_player_avfoundation/FVPVideoPlayerPlugin.m index a420e8397401..d471472544ac 100644 --- a/packages/video_player/video_player_avfoundation/darwin/video_player_avfoundation/Sources/video_player_avfoundation/FVPVideoPlayerPlugin.m +++ b/packages/video_player/video_player_avfoundation/darwin/video_player_avfoundation/Sources/video_player_avfoundation/FVPVideoPlayerPlugin.m @@ -136,10 +136,16 @@ - (void)detachFromEngineForRegistrar:(NSObject *)registr } - (int64_t)configurePlayer:(FVPVideoPlayer *)player - withExtraDisposeHandler:(nullable void (^)(void))extraDisposeHandler { + withExtraDisposeHandler:(nullable void (^)(void))extraDisposeHandler + backgroundPlayback:(nullable FVPBackgroundPlaybackMessage *)backgroundPlayback { int64_t playerIdentifier = self.nextPlayerIdentifier++; self.playersByIdentifier[@(playerIdentifier)] = player; + // Configure background playback if requested + if (backgroundPlayback != nil) { + [player configureBackgroundPlayback:backgroundPlayback]; + } + NSObject *messenger = self.binaryMessenger; NSString *channelSuffix = [NSString stringWithFormat:@"%lld", playerIdentifier]; // Set up the player-specific API handler, and its onDispose unregistration. @@ -224,7 +230,9 @@ - (nullable NSNumber *)createPlatformViewPlayerWithOptions:(nonnull FVPCreationO avFactory:self.avFactory viewProvider:self.viewProvider]; - return @([self configurePlayer:player withExtraDisposeHandler:nil]); + return @([self configurePlayer:player + withExtraDisposeHandler:nil + backgroundPlayback:options.backgroundPlayback]); } @catch (NSException *exception) { *error = [FlutterError errorWithCode:@"video_player" message:exception.reason details:nil]; return nil; @@ -256,7 +264,8 @@ - (nullable FVPTexturePlayerIds *)createTexturePlayerWithOptions: int64_t playerIdentifier = [self configurePlayer:player withExtraDisposeHandler:^() { [weakSelf.textureRegistry unregisterTexture:textureIdentifier]; - }]; + } + backgroundPlayback:options.backgroundPlayback]; return [FVPTexturePlayerIds makeWithPlayerId:playerIdentifier textureId:textureIdentifier]; } @catch (NSException *exception) { *error = [FlutterError errorWithCode:@"video_player" message:exception.reason details:nil]; diff --git a/packages/video_player/video_player_avfoundation/darwin/video_player_avfoundation/Sources/video_player_avfoundation/include/video_player_avfoundation/FVPVideoPlayer.h b/packages/video_player/video_player_avfoundation/darwin/video_player_avfoundation/Sources/video_player_avfoundation/include/video_player_avfoundation/FVPVideoPlayer.h index 02954d7a3680..5b1a0aad83a7 100644 --- a/packages/video_player/video_player_avfoundation/darwin/video_player_avfoundation/Sources/video_player_avfoundation/include/video_player_avfoundation/FVPVideoPlayer.h +++ b/packages/video_player/video_player_avfoundation/darwin/video_player_avfoundation/Sources/video_player_avfoundation/include/video_player_avfoundation/FVPVideoPlayer.h @@ -42,6 +42,10 @@ NS_ASSUME_NONNULL_BEGIN avFactory:(id)avFactory viewProvider:(NSObject *)viewProvider; +/// Configures background playback and media notification. +/// Called during player creation if background playback is requested. +- (void)configureBackgroundPlayback:(FVPBackgroundPlaybackMessage *)msg; + @end NS_ASSUME_NONNULL_END diff --git a/packages/video_player/video_player_avfoundation/darwin/video_player_avfoundation/Sources/video_player_avfoundation/include/video_player_avfoundation/messages.g.h b/packages/video_player/video_player_avfoundation/darwin/video_player_avfoundation/Sources/video_player_avfoundation/include/video_player_avfoundation/messages.g.h index 3b2dd3952245..0754734aa8ef 100644 --- a/packages/video_player/video_player_avfoundation/darwin/video_player_avfoundation/Sources/video_player_avfoundation/include/video_player_avfoundation/messages.g.h +++ b/packages/video_player/video_player_avfoundation/darwin/video_player_avfoundation/Sources/video_player_avfoundation/include/video_player_avfoundation/messages.g.h @@ -17,6 +17,8 @@ NS_ASSUME_NONNULL_BEGIN @class FVPCreationOptions; @class FVPTexturePlayerIds; @class FVPMediaSelectionAudioTrackData; +@class FVPNotificationMetadataMessage; +@class FVPBackgroundPlaybackMessage; /// Information passed to the platform view creation. @interface FVPPlatformVideoViewCreationParams : NSObject @@ -30,9 +32,12 @@ NS_ASSUME_NONNULL_BEGIN /// `init` unavailable to enforce nonnull fields, see the `make` class method. - (instancetype)init NS_UNAVAILABLE; + (instancetype)makeWithUri:(NSString *)uri - httpHeaders:(NSDictionary *)httpHeaders; + httpHeaders:(NSDictionary *)httpHeaders + backgroundPlayback:(nullable FVPBackgroundPlaybackMessage *)backgroundPlayback; @property(nonatomic, copy) NSString *uri; @property(nonatomic, copy) NSDictionary *httpHeaders; +/// Background playback configuration (optional). +@property(nonatomic, strong, nullable) FVPBackgroundPlaybackMessage *backgroundPlayback; @end @interface FVPTexturePlayerIds : NSObject @@ -59,6 +64,35 @@ NS_ASSUME_NONNULL_BEGIN @property(nonatomic, copy, nullable) NSString *commonMetadataTitle; @end +/// Metadata for the system media notification when playing in background. +@interface FVPNotificationMetadataMessage : NSObject +/// `init` unavailable to enforce nonnull fields, see the `make` class method. +- (instancetype)init NS_UNAVAILABLE; ++ (instancetype)makeWithId:(NSString *)id + title:(nullable NSString *)title + album:(nullable NSString *)album + artist:(nullable NSString *)artist + durationMs:(nullable NSNumber *)durationMs + artUri:(nullable NSString *)artUri; +@property(nonatomic, copy) NSString *id; +@property(nonatomic, copy, nullable) NSString *title; +@property(nonatomic, copy, nullable) NSString *album; +@property(nonatomic, copy, nullable) NSString *artist; +@property(nonatomic, strong, nullable) NSNumber *durationMs; +@property(nonatomic, copy, nullable) NSString *artUri; +@end + +/// Message for configuring background playback with media notification. +@interface FVPBackgroundPlaybackMessage : NSObject +/// `init` unavailable to enforce nonnull fields, see the `make` class method. +- (instancetype)init NS_UNAVAILABLE; ++ (instancetype)makeWithEnableBackground:(BOOL)enableBackground + notificationMetadata: + (nullable FVPNotificationMetadataMessage *)notificationMetadata; +@property(nonatomic, assign) BOOL enableBackground; +@property(nonatomic, strong, nullable) FVPNotificationMetadataMessage *notificationMetadata; +@end + /// The codec used by all APIs. NSObject *FVPGetMessagesCodec(void); diff --git a/packages/video_player/video_player_avfoundation/darwin/video_player_avfoundation/Sources/video_player_avfoundation/messages.g.m b/packages/video_player/video_player_avfoundation/darwin/video_player_avfoundation/Sources/video_player_avfoundation/messages.g.m index abb8efbad50d..7ccfbcc728ae 100644 --- a/packages/video_player/video_player_avfoundation/darwin/video_player_avfoundation/Sources/video_player_avfoundation/messages.g.m +++ b/packages/video_player/video_player_avfoundation/darwin/video_player_avfoundation/Sources/video_player_avfoundation/messages.g.m @@ -50,6 +50,18 @@ + (nullable FVPMediaSelectionAudioTrackData *)nullableFromList:(NSArray *)li - (NSArray *)toList; @end +@interface FVPNotificationMetadataMessage () ++ (FVPNotificationMetadataMessage *)fromList:(NSArray *)list; ++ (nullable FVPNotificationMetadataMessage *)nullableFromList:(NSArray *)list; +- (NSArray *)toList; +@end + +@interface FVPBackgroundPlaybackMessage () ++ (FVPBackgroundPlaybackMessage *)fromList:(NSArray *)list; ++ (nullable FVPBackgroundPlaybackMessage *)nullableFromList:(NSArray *)list; +- (NSArray *)toList; +@end + @implementation FVPPlatformVideoViewCreationParams + (instancetype)makeWithPlayerId:(NSInteger)playerId { FVPPlatformVideoViewCreationParams *pigeonResult = @@ -75,16 +87,19 @@ + (nullable FVPPlatformVideoViewCreationParams *)nullableFromList:(NSArray * @implementation FVPCreationOptions + (instancetype)makeWithUri:(NSString *)uri - httpHeaders:(NSDictionary *)httpHeaders { + httpHeaders:(NSDictionary *)httpHeaders + backgroundPlayback:(nullable FVPBackgroundPlaybackMessage *)backgroundPlayback { FVPCreationOptions *pigeonResult = [[FVPCreationOptions alloc] init]; pigeonResult.uri = uri; pigeonResult.httpHeaders = httpHeaders; + pigeonResult.backgroundPlayback = backgroundPlayback; return pigeonResult; } + (FVPCreationOptions *)fromList:(NSArray *)list { FVPCreationOptions *pigeonResult = [[FVPCreationOptions alloc] init]; pigeonResult.uri = GetNullableObjectAtIndex(list, 0); pigeonResult.httpHeaders = GetNullableObjectAtIndex(list, 1); + pigeonResult.backgroundPlayback = GetNullableObjectAtIndex(list, 2); return pigeonResult; } + (nullable FVPCreationOptions *)nullableFromList:(NSArray *)list { @@ -94,6 +109,7 @@ + (nullable FVPCreationOptions *)nullableFromList:(NSArray *)list { return @[ self.uri ?: [NSNull null], self.httpHeaders ?: [NSNull null], + self.backgroundPlayback ?: [NSNull null], ]; } @end @@ -159,6 +175,73 @@ + (nullable FVPMediaSelectionAudioTrackData *)nullableFromList:(NSArray *)li } @end +@implementation FVPNotificationMetadataMessage ++ (instancetype)makeWithId:(NSString *)id + title:(nullable NSString *)title + album:(nullable NSString *)album + artist:(nullable NSString *)artist + durationMs:(nullable NSNumber *)durationMs + artUri:(nullable NSString *)artUri { + FVPNotificationMetadataMessage *pigeonResult = [[FVPNotificationMetadataMessage alloc] init]; + pigeonResult.id = id; + pigeonResult.title = title; + pigeonResult.album = album; + pigeonResult.artist = artist; + pigeonResult.durationMs = durationMs; + pigeonResult.artUri = artUri; + return pigeonResult; +} ++ (FVPNotificationMetadataMessage *)fromList:(NSArray *)list { + FVPNotificationMetadataMessage *pigeonResult = [[FVPNotificationMetadataMessage alloc] init]; + pigeonResult.id = GetNullableObjectAtIndex(list, 0); + pigeonResult.title = GetNullableObjectAtIndex(list, 1); + pigeonResult.album = GetNullableObjectAtIndex(list, 2); + pigeonResult.artist = GetNullableObjectAtIndex(list, 3); + pigeonResult.durationMs = GetNullableObjectAtIndex(list, 4); + pigeonResult.artUri = GetNullableObjectAtIndex(list, 5); + return pigeonResult; +} ++ (nullable FVPNotificationMetadataMessage *)nullableFromList:(NSArray *)list { + return (list) ? [FVPNotificationMetadataMessage fromList:list] : nil; +} +- (NSArray *)toList { + return @[ + self.id ?: [NSNull null], + self.title ?: [NSNull null], + self.album ?: [NSNull null], + self.artist ?: [NSNull null], + self.durationMs ?: [NSNull null], + self.artUri ?: [NSNull null], + ]; +} +@end + +@implementation FVPBackgroundPlaybackMessage ++ (instancetype)makeWithEnableBackground:(BOOL)enableBackground + notificationMetadata: + (nullable FVPNotificationMetadataMessage *)notificationMetadata { + FVPBackgroundPlaybackMessage *pigeonResult = [[FVPBackgroundPlaybackMessage alloc] init]; + pigeonResult.enableBackground = enableBackground; + pigeonResult.notificationMetadata = notificationMetadata; + return pigeonResult; +} ++ (FVPBackgroundPlaybackMessage *)fromList:(NSArray *)list { + FVPBackgroundPlaybackMessage *pigeonResult = [[FVPBackgroundPlaybackMessage alloc] init]; + pigeonResult.enableBackground = [GetNullableObjectAtIndex(list, 0) boolValue]; + pigeonResult.notificationMetadata = GetNullableObjectAtIndex(list, 1); + return pigeonResult; +} ++ (nullable FVPBackgroundPlaybackMessage *)nullableFromList:(NSArray *)list { + return (list) ? [FVPBackgroundPlaybackMessage fromList:list] : nil; +} +- (NSArray *)toList { + return @[ + @(self.enableBackground), + self.notificationMetadata ?: [NSNull null], + ]; +} +@end + @interface FVPMessagesPigeonCodecReader : FlutterStandardReader @end @implementation FVPMessagesPigeonCodecReader @@ -172,6 +255,10 @@ - (nullable id)readValueOfType:(UInt8)type { return [FVPTexturePlayerIds fromList:[self readValue]]; case 132: return [FVPMediaSelectionAudioTrackData fromList:[self readValue]]; + case 133: + return [FVPNotificationMetadataMessage fromList:[self readValue]]; + case 134: + return [FVPBackgroundPlaybackMessage fromList:[self readValue]]; default: return [super readValueOfType:type]; } @@ -194,6 +281,12 @@ - (void)writeValue:(id)value { } else if ([value isKindOfClass:[FVPMediaSelectionAudioTrackData class]]) { [self writeByte:132]; [self writeValue:[value toList]]; + } else if ([value isKindOfClass:[FVPNotificationMetadataMessage class]]) { + [self writeByte:133]; + [self writeValue:[value toList]]; + } else if ([value isKindOfClass:[FVPBackgroundPlaybackMessage class]]) { + [self writeByte:134]; + [self writeValue:[value toList]]; } else { [super writeValue:value]; } diff --git a/packages/video_player/video_player_avfoundation/example/pubspec.yaml b/packages/video_player/video_player_avfoundation/example/pubspec.yaml index 4a9153c7d267..c033c06a13dc 100644 --- a/packages/video_player/video_player_avfoundation/example/pubspec.yaml +++ b/packages/video_player/video_player_avfoundation/example/pubspec.yaml @@ -31,3 +31,7 @@ flutter: assets: - assets/flutter-mark-square-64.png - assets/Butterfly-209.mp4 +# FOR TESTING AND INITIAL REVIEW ONLY. DO NOT MERGE. +# See https://github.com/flutter/flutter/blob/master/docs/ecosystem/contributing/README.md#changing-federated-plugins +dependency_overrides: + video_player_platform_interface: {path: ../../video_player_platform_interface} diff --git a/packages/video_player/video_player_avfoundation/lib/src/avfoundation_video_player.dart b/packages/video_player/video_player_avfoundation/lib/src/avfoundation_video_player.dart index 6684d9c4c658..040a7d46e8ab 100644 --- a/packages/video_player/video_player_avfoundation/lib/src/avfoundation_video_player.dart +++ b/packages/video_player/video_player_avfoundation/lib/src/avfoundation_video_player.dart @@ -92,9 +92,31 @@ class AVFoundationVideoPlayer extends VideoPlayerPlatform { if (uri == null) { throw ArgumentError('Unable to construct a video asset from $options'); } + // Set up background playback configuration if provided + BackgroundPlaybackMessage? backgroundPlayback; + if (options.allowBackgroundPlayback) { + NotificationMetadataMessage? metadataMessage; + if (options.notificationMetadata != null) { + final NotificationMetadata metadata = options.notificationMetadata!; + metadataMessage = NotificationMetadataMessage( + id: metadata.id, + title: metadata.title, + artist: metadata.artist, + album: metadata.album, + durationMs: metadata.duration?.inMilliseconds, + artUri: metadata.artUri?.toString(), + ); + } + backgroundPlayback = BackgroundPlaybackMessage( + enableBackground: true, + notificationMetadata: metadataMessage, + ); + } + final pigeonCreationOptions = CreationOptions( uri: uri, httpHeaders: dataSource.httpHeaders, + backgroundPlayback: backgroundPlayback, ); final int playerId; diff --git a/packages/video_player/video_player_avfoundation/lib/src/messages.g.dart b/packages/video_player/video_player_avfoundation/lib/src/messages.g.dart index 24644d8f42d0..3422b21eb021 100644 --- a/packages/video_player/video_player_avfoundation/lib/src/messages.g.dart +++ b/packages/video_player/video_player_avfoundation/lib/src/messages.g.dart @@ -74,14 +74,21 @@ class PlatformVideoViewCreationParams { } class CreationOptions { - CreationOptions({required this.uri, required this.httpHeaders}); + CreationOptions({ + required this.uri, + required this.httpHeaders, + this.backgroundPlayback, + }); String uri; Map httpHeaders; + /// Background playback configuration (optional). + BackgroundPlaybackMessage? backgroundPlayback; + List _toList() { - return [uri, httpHeaders]; + return [uri, httpHeaders, backgroundPlayback]; } Object encode() { @@ -94,6 +101,7 @@ class CreationOptions { uri: result[0]! as String, httpHeaders: (result[1] as Map?)! .cast(), + backgroundPlayback: result[2] as BackgroundPlaybackMessage?, ); } @@ -217,6 +225,112 @@ class MediaSelectionAudioTrackData { int get hashCode => Object.hashAll(_toList()); } +/// Metadata for the system media notification when playing in background. +class NotificationMetadataMessage { + NotificationMetadataMessage({ + required this.id, + this.title, + this.album, + this.artist, + this.durationMs, + this.artUri, + }); + + String id; + + String? title; + + String? album; + + String? artist; + + int? durationMs; + + String? artUri; + + List _toList() { + return [id, title, album, artist, durationMs, artUri]; + } + + Object encode() { + return _toList(); + } + + static NotificationMetadataMessage decode(Object result) { + result as List; + return NotificationMetadataMessage( + id: result[0]! as String, + title: result[1] as String?, + album: result[2] as String?, + artist: result[3] as String?, + durationMs: result[4] as int?, + artUri: result[5] as String?, + ); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + bool operator ==(Object other) { + if (other is! NotificationMetadataMessage || + other.runtimeType != runtimeType) { + return false; + } + if (identical(this, other)) { + return true; + } + return _deepEquals(encode(), other.encode()); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + int get hashCode => Object.hashAll(_toList()); +} + +/// Message for configuring background playback with media notification. +class BackgroundPlaybackMessage { + BackgroundPlaybackMessage({ + required this.enableBackground, + this.notificationMetadata, + }); + + bool enableBackground; + + NotificationMetadataMessage? notificationMetadata; + + List _toList() { + return [enableBackground, notificationMetadata]; + } + + Object encode() { + return _toList(); + } + + static BackgroundPlaybackMessage decode(Object result) { + result as List; + return BackgroundPlaybackMessage( + enableBackground: result[0]! as bool, + notificationMetadata: result[1] as NotificationMetadataMessage?, + ); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + bool operator ==(Object other) { + if (other is! BackgroundPlaybackMessage || + other.runtimeType != runtimeType) { + return false; + } + if (identical(this, other)) { + return true; + } + return _deepEquals(encode(), other.encode()); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + int get hashCode => Object.hashAll(_toList()); +} + class _PigeonCodec extends StandardMessageCodec { const _PigeonCodec(); @override @@ -236,6 +350,12 @@ class _PigeonCodec extends StandardMessageCodec { } else if (value is MediaSelectionAudioTrackData) { buffer.putUint8(132); writeValue(buffer, value.encode()); + } else if (value is NotificationMetadataMessage) { + buffer.putUint8(133); + writeValue(buffer, value.encode()); + } else if (value is BackgroundPlaybackMessage) { + buffer.putUint8(134); + writeValue(buffer, value.encode()); } else { super.writeValue(buffer, value); } @@ -252,6 +372,10 @@ class _PigeonCodec extends StandardMessageCodec { return TexturePlayerIds.decode(readValue(buffer)!); case 132: return MediaSelectionAudioTrackData.decode(readValue(buffer)!); + case 133: + return NotificationMetadataMessage.decode(readValue(buffer)!); + case 134: + return BackgroundPlaybackMessage.decode(readValue(buffer)!); default: return super.readValueOfType(type, buffer); } @@ -625,6 +749,31 @@ class VideoPlayerInstanceApi { } } + Future setBackgroundPlayback(BackgroundPlaybackMessage msg) async { + final pigeonVar_channelName = + 'dev.flutter.pigeon.video_player_avfoundation.VideoPlayerInstanceApi.setBackgroundPlayback$pigeonVar_messageChannelSuffix'; + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send( + [msg], + ); + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { + throw PlatformException( + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], + ); + } else { + return; + } + } + Future> getAudioTracks() async { final pigeonVar_channelName = 'dev.flutter.pigeon.video_player_avfoundation.VideoPlayerInstanceApi.getAudioTracks$pigeonVar_messageChannelSuffix'; diff --git a/packages/video_player/video_player_avfoundation/pigeons/messages.dart b/packages/video_player/video_player_avfoundation/pigeons/messages.dart index f49b46005307..52755d544dc5 100644 --- a/packages/video_player/video_player_avfoundation/pigeons/messages.dart +++ b/packages/video_player/video_player_avfoundation/pigeons/messages.dart @@ -30,6 +30,9 @@ class CreationOptions { String uri; Map httpHeaders; + + /// Background playback configuration (optional). + BackgroundPlaybackMessage? backgroundPlayback; } class TexturePlayerIds { @@ -56,6 +59,36 @@ class MediaSelectionAudioTrackData { String? commonMetadataTitle; } +/// Metadata for the system media notification when playing in background. +class NotificationMetadataMessage { + NotificationMetadataMessage({ + required this.id, + this.title, + this.album, + this.artist, + this.durationMs, + this.artUri, + }); + + String id; + String? title; + String? album; + String? artist; + int? durationMs; + String? artUri; +} + +/// Message for configuring background playback with media notification. +class BackgroundPlaybackMessage { + BackgroundPlaybackMessage({ + required this.enableBackground, + this.notificationMetadata, + }); + + bool enableBackground; + NotificationMetadataMessage? notificationMetadata; +} + @HostApi() abstract class AVFoundationVideoPlayerApi { @ObjCSelector('initialize') diff --git a/packages/video_player/video_player_avfoundation/pubspec.yaml b/packages/video_player/video_player_avfoundation/pubspec.yaml index 853ce2760ae8..22dfad398aa7 100644 --- a/packages/video_player/video_player_avfoundation/pubspec.yaml +++ b/packages/video_player/video_player_avfoundation/pubspec.yaml @@ -2,7 +2,7 @@ name: video_player_avfoundation description: iOS and macOS implementation of the video_player plugin. repository: https://github.com/flutter/packages/tree/main/packages/video_player/video_player_avfoundation issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+video_player%22 -version: 2.9.3 +version: 2.10.1 environment: sdk: ^3.10.0 @@ -36,3 +36,7 @@ dev_dependencies: topics: - video - video-player +# FOR TESTING AND INITIAL REVIEW ONLY. DO NOT MERGE. +# See https://github.com/flutter/flutter/blob/master/docs/ecosystem/contributing/README.md#changing-federated-plugins +dependency_overrides: + video_player_platform_interface: {path: ../video_player_platform_interface} diff --git a/packages/video_player/video_player_avfoundation/test/avfoundation_video_player_test.dart b/packages/video_player/video_player_avfoundation/test/avfoundation_video_player_test.dart index c16d5bcb08e9..b4d81ab2939e 100644 --- a/packages/video_player/video_player_avfoundation/test/avfoundation_video_player_test.dart +++ b/packages/video_player/video_player_avfoundation/test/avfoundation_video_player_test.dart @@ -20,6 +20,14 @@ import 'avfoundation_video_player_test.mocks.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); + // Provide dummy values for background playback types + provideDummy( + BackgroundPlaybackMessage(enableBackground: false), + ); + provideDummy( + NotificationMetadataMessage(id: 'dummy'), + ); + ( AVFoundationVideoPlayer, MockAVFoundationVideoPlayerApi, diff --git a/packages/video_player/video_player_avfoundation/test/avfoundation_video_player_test.mocks.dart b/packages/video_player/video_player_avfoundation/test/avfoundation_video_player_test.mocks.dart index 8caf6ad8dc43..1f1b6d66bec1 100644 --- a/packages/video_player/video_player_avfoundation/test/avfoundation_video_player_test.mocks.dart +++ b/packages/video_player/video_player_avfoundation/test/avfoundation_video_player_test.mocks.dart @@ -22,6 +22,7 @@ import 'package:video_player_avfoundation/src/messages.g.dart' as _i2; // ignore_for_file: unnecessary_parenthesis // ignore_for_file: camel_case_types // ignore_for_file: subtype_of_sealed_class +// ignore_for_file: invalid_use_of_internal_member class _FakeTexturePlayerIds_0 extends _i1.SmartFake implements _i2.TexturePlayerIds { @@ -198,4 +199,28 @@ class MockVideoPlayerInstanceApi extends _i1.Mock returnValueForMissingStub: _i4.Future.value(), ) as _i4.Future); + + @override + _i4.Future> getAudioTracks() => + (super.noSuchMethod( + Invocation.method(#getAudioTracks, []), + returnValue: + _i4.Future>.value( + <_i2.MediaSelectionAudioTrackData>[], + ), + returnValueForMissingStub: + _i4.Future>.value( + <_i2.MediaSelectionAudioTrackData>[], + ), + ) + as _i4.Future>); + + @override + _i4.Future selectAudioTrack(int? trackIndex) => + (super.noSuchMethod( + Invocation.method(#selectAudioTrack, [trackIndex]), + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), + ) + as _i4.Future); } diff --git a/packages/video_player/video_player_platform_interface/CHANGELOG.md b/packages/video_player/video_player_platform_interface/CHANGELOG.md index 4b44b050047a..54c3adb62111 100644 --- a/packages/video_player/video_player_platform_interface/CHANGELOG.md +++ b/packages/video_player/video_player_platform_interface/CHANGELOG.md @@ -1,5 +1,6 @@ -## NEXT +## 6.7.0 +* Adds background playback with system media notification support. * Updates minimum supported SDK version to Flutter 3.35/Dart 3.9. ## 6.6.0 diff --git a/packages/video_player/video_player_platform_interface/lib/video_player_platform_interface.dart b/packages/video_player/video_player_platform_interface/lib/video_player_platform_interface.dart index 1cec5f42c218..8bb9aa23d1ef 100644 --- a/packages/video_player/video_player_platform_interface/lib/video_player_platform_interface.dart +++ b/packages/video_player/video_player_platform_interface/lib/video_player_platform_interface.dart @@ -424,6 +424,68 @@ class DurationRange { int get hashCode => Object.hash(start, end); } +/// Metadata for the system media notification when playing in background. +/// +/// Used to display information in the system's media notification, +/// lock screen, and control center. Providing this metadata to +/// [VideoPlayerOptions.notificationMetadata] enables the notification. +@immutable +class NotificationMetadata { + /// Creates a [NotificationMetadata] with the given metadata. + const NotificationMetadata({ + required this.id, + this.title, + this.album, + this.artist, + this.duration, + this.artUri, + }); + + /// A unique identifier for this media item. + final String id; + + /// The title of this media item. + final String? title; + + /// The album this media item belongs to. + final String? album; + + /// The artist of this media item. + final String? artist; + + /// The duration of this media item. + /// + /// If not specified, the duration will be automatically detected from the + /// video source after initialization. Manual specification is useful for + /// live streams or when you want to display an estimated duration before + /// the video loads. + final Duration? duration; + + /// The URI of the artwork for this media item. + /// + /// Can be a network URL, asset path, or file path. + final Uri? artUri; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is NotificationMetadata && + runtimeType == other.runtimeType && + id == other.id && + title == other.title && + album == other.album && + artist == other.artist && + duration == other.duration && + artUri == other.artUri; + + @override + int get hashCode => Object.hash(id, title, album, artist, duration, artUri); + + @override + String toString() => + '${objectRuntimeType(this, 'NotificationMetadata')}(id: $id, title: $title, album: $album, artist: $artist, duration: $duration, artUri: $artUri)'; +} + /// [VideoPlayerOptions] can be optionally used to set additional player settings @immutable class VideoPlayerOptions { @@ -435,6 +497,7 @@ class VideoPlayerOptions { VideoPlayerOptions({ this.mixWithOthers = false, this.allowBackgroundPlayback = false, + this.notificationMetadata, this.webOptions, }); @@ -449,6 +512,17 @@ class VideoPlayerOptions { /// currently no way to implement this feature in this platform). final bool mixWithOthers; + /// Metadata for the system media notification. + /// + /// When provided along with [allowBackgroundPlayback], a system notification + /// will be shown with media controls (play/pause, seek) and the provided + /// metadata (title, artist, album art, etc.). + /// + /// Note: On Android, this requires setting up foreground service permissions. + /// On iOS, this requires enabling background audio capability. + /// This feature is not available on web. + final NotificationMetadata? notificationMetadata; + /// Additional web controls final VideoPlayerWebOptions? webOptions; } @@ -553,6 +627,8 @@ class VideoCreationOptions { const VideoCreationOptions({ required this.dataSource, required this.viewType, + this.allowBackgroundPlayback = false, + this.notificationMetadata, }); /// The data source used to create the player. @@ -560,6 +636,17 @@ class VideoCreationOptions { /// The type of view to be used for displaying the video player final VideoViewType viewType; + + /// Whether to allow background playback. + /// + /// When true, video audio will continue playing when the app is backgrounded. + final bool allowBackgroundPlayback; + + /// Metadata for the system media notification. + /// + /// When provided along with [allowBackgroundPlayback], a system notification + /// will be shown with media controls and the provided metadata. + final NotificationMetadata? notificationMetadata; } /// Represents an audio track in a video with its metadata. diff --git a/packages/video_player/video_player_platform_interface/pubspec.yaml b/packages/video_player/video_player_platform_interface/pubspec.yaml index b39acce19665..373ddb91f1fb 100644 --- a/packages/video_player/video_player_platform_interface/pubspec.yaml +++ b/packages/video_player/video_player_platform_interface/pubspec.yaml @@ -4,7 +4,7 @@ repository: https://github.com/flutter/packages/tree/main/packages/video_player/ issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+video_player%22 # NOTE: We strongly prefer non-breaking changes, even at the expense of a # less-clean API. See https://flutter.dev/go/platform-interface-breaking-changes -version: 6.6.0 +version: 6.7.0 environment: sdk: ^3.9.0 diff --git a/packages/video_player/video_player_platform_interface/test/video_player_options_test.dart b/packages/video_player/video_player_platform_interface/test/video_player_options_test.dart index 6af3e57f6c0f..71b857d72de6 100644 --- a/packages/video_player/video_player_platform_interface/test/video_player_options_test.dart +++ b/packages/video_player/video_player_platform_interface/test/video_player_options_test.dart @@ -10,8 +10,33 @@ void main() { final options = VideoPlayerOptions(); expect(options.allowBackgroundPlayback, false); }); + test('VideoPlayerOptions mixWithOthers defaults to false', () { final options = VideoPlayerOptions(); expect(options.mixWithOthers, false); }); + + test('VideoPlayerOptions notificationMetadata defaults to null', () { + final options = VideoPlayerOptions(); + expect(options.notificationMetadata, isNull); + }); + + test('VideoPlayerOptions accepts notificationMetadata', () { + const metadata = NotificationMetadata( + id: 'test_id', + title: 'Test Title', + artist: 'Test Artist', + ); + final options = VideoPlayerOptions(notificationMetadata: metadata); + + expect(options.notificationMetadata, isNotNull); + expect(options.notificationMetadata!.id, 'test_id'); + expect(options.notificationMetadata!.title, 'Test Title'); + expect(options.notificationMetadata!.artist, 'Test Artist'); + }); + + test('VideoPlayerOptions webOptions defaults to null', () { + final options = VideoPlayerOptions(); + expect(options.webOptions, isNull); + }); } diff --git a/packages/video_player/video_player_platform_interface/test/video_player_platform_interface_test.dart b/packages/video_player/video_player_platform_interface/test/video_player_platform_interface_test.dart index 2d920161ec9e..e791b77e422a 100644 --- a/packages/video_player/video_player_platform_interface/test/video_player_platform_interface_test.dart +++ b/packages/video_player/video_player_platform_interface/test/video_player_platform_interface_test.dart @@ -40,4 +40,80 @@ void main() { test('default implementation isAudioTrackSupportAvailable returns false', () { expect(initialInstance.isAudioTrackSupportAvailable(), false); }); + + group('NotificationMetadata', () { + test('constructs with required id', () { + const metadata = NotificationMetadata(id: 'test_id'); + + expect(metadata.id, 'test_id'); + expect(metadata.title, isNull); + expect(metadata.album, isNull); + expect(metadata.artist, isNull); + expect(metadata.duration, isNull); + expect(metadata.artUri, isNull); + }); + + test('constructs with all properties', () { + final metadata = NotificationMetadata( + id: 'test_id', + title: 'Test Title', + album: 'Test Album', + artist: 'Test Artist', + duration: const Duration(minutes: 5), + artUri: Uri.parse('https://example.com/art.jpg'), + ); + + expect(metadata.id, 'test_id'); + expect(metadata.title, 'Test Title'); + expect(metadata.album, 'Test Album'); + expect(metadata.artist, 'Test Artist'); + expect(metadata.duration, const Duration(minutes: 5)); + expect(metadata.artUri, Uri.parse('https://example.com/art.jpg')); + }); + + test('equality works correctly', () { + final metadata1 = NotificationMetadata( + id: 'test_id', + title: 'Test Title', + artUri: Uri.parse('https://example.com/art.jpg'), + ); + final metadata2 = NotificationMetadata( + id: 'test_id', + title: 'Test Title', + artUri: Uri.parse('https://example.com/art.jpg'), + ); + final metadata3 = NotificationMetadata( + id: 'different_id', + title: 'Test Title', + artUri: Uri.parse('https://example.com/art.jpg'), + ); + + expect(metadata1, equals(metadata2)); + expect(metadata1, isNot(equals(metadata3))); + }); + + test('hashCode is consistent with equality', () { + final metadata1 = NotificationMetadata( + id: 'test_id', + title: 'Test Title', + artUri: Uri.parse('https://example.com/art.jpg'), + ); + final metadata2 = NotificationMetadata( + id: 'test_id', + title: 'Test Title', + artUri: Uri.parse('https://example.com/art.jpg'), + ); + + expect(metadata1.hashCode, equals(metadata2.hashCode)); + }); + + test('toString returns readable representation', () { + const metadata = NotificationMetadata(id: 'test_id', title: 'Test Title'); + + final string = metadata.toString(); + expect(string, contains('NotificationMetadata')); + expect(string, contains('test_id')); + expect(string, contains('Test Title')); + }); + }); } diff --git a/packages/video_player/video_player_web/CHANGELOG.md b/packages/video_player/video_player_web/CHANGELOG.md index f0bd392fd683..d7fb8487061b 100644 --- a/packages/video_player/video_player_web/CHANGELOG.md +++ b/packages/video_player/video_player_web/CHANGELOG.md @@ -1,5 +1,6 @@ -## NEXT +## 2.5.0 +* Adds background playback with system media notification support. * Updates minimum supported SDK version to Flutter 3.35/Dart 3.9. ## 2.4.0 diff --git a/packages/video_player/video_player_web/example/pubspec.yaml b/packages/video_player/video_player_web/example/pubspec.yaml index ae7ab08cb17d..2b77898c37a8 100644 --- a/packages/video_player/video_player_web/example/pubspec.yaml +++ b/packages/video_player/video_player_web/example/pubspec.yaml @@ -18,3 +18,7 @@ dev_dependencies: sdk: flutter integration_test: sdk: flutter +# FOR TESTING AND INITIAL REVIEW ONLY. DO NOT MERGE. +# See https://github.com/flutter/flutter/blob/master/docs/ecosystem/contributing/README.md#changing-federated-plugins +dependency_overrides: + video_player_platform_interface: {path: ../../../../packages/video_player/video_player_platform_interface} diff --git a/packages/video_player/video_player_web/pubspec.yaml b/packages/video_player/video_player_web/pubspec.yaml index b3b4739896d7..819493953170 100644 --- a/packages/video_player/video_player_web/pubspec.yaml +++ b/packages/video_player/video_player_web/pubspec.yaml @@ -2,7 +2,7 @@ name: video_player_web description: Web platform implementation of video_player. repository: https://github.com/flutter/packages/tree/main/packages/video_player/video_player_web issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+video_player%22 -version: 2.4.0 +version: 2.5.0 environment: sdk: ^3.9.0 @@ -31,3 +31,7 @@ dev_dependencies: topics: - video - video-player +# FOR TESTING AND INITIAL REVIEW ONLY. DO NOT MERGE. +# See https://github.com/flutter/flutter/blob/master/docs/ecosystem/contributing/README.md#changing-federated-plugins +dependency_overrides: + video_player_platform_interface: {path: ../video_player_platform_interface}