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 29cc1018785..04dc5c8d25d 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
@@ -10,7 +10,6 @@
android:exported="true">
-
diff --git a/packages/video_player/video_player_web/lib/src/pkg_web_tweaks.dart b/packages/video_player/video_player_web/lib/src/pkg_web_tweaks.dart
index 32e2eb559b2..e3c23566f0f 100644
--- a/packages/video_player/video_player_web/lib/src/pkg_web_tweaks.dart
+++ b/packages/video_player/video_player_web/lib/src/pkg_web_tweaks.dart
@@ -2,19 +2,32 @@
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
-import 'package:web/web.dart' as web;
+import 'dart:js_interop';
-/// Adds a "disablePictureInPicture" setter to [web.HTMLVideoElement]s.
-extension NonStandardSettersOnVideoElement on web.HTMLVideoElement {
- // TODO(srujzs): This will be added in `package:web` 0.6.0. Remove this helper
- // once it's available.
- external set disablePictureInPicture(bool disabled);
-}
+import 'package:web/web.dart' as web;
-/// Adds a "disableRemotePlayback" and "controlsList" setters to [web.HTMLMediaElement]s.
+/// Adds a "controlsList" setter to [web.HTMLMediaElement]s.
+///
+/// `disablePictureInPicture` and `disableRemotePlayback` are now available
+/// directly in `package:web`, but `controlsList` is not yet included.
extension NonStandardSettersOnMediaElement on web.HTMLMediaElement {
- // TODO(srujzs): This will be added in `package:web` 0.6.0. Remove this helper
- // once it's available.
- external set disableRemotePlayback(bool disabled);
external set controlsList(String? controlsList);
}
+
+/// Interop for [MediaSessionActionDetails] which is not yet in `package:web`.
+///
+/// This is the details object passed to MediaSession action handlers.
+/// See: https://developer.mozilla.org/en-US/docs/Web/API/MediaSessionActionDetails
+extension type MediaSessionActionDetails._(JSObject _) implements JSObject {
+ /// The media session action that was triggered (e.g. 'play', 'seekto').
+ external String get action;
+
+ /// The offset in seconds for seek actions (seekbackward/seekforward).
+ external double? get seekOffset;
+
+ /// The absolute time in seconds to seek to (for 'seekto' action).
+ external double? get seekTime;
+
+ /// Whether this is a rapid sequential seek (for 'seekto' action).
+ external bool? get fastSeek;
+}
diff --git a/packages/video_player/video_player_web/lib/src/video_player.dart b/packages/video_player/video_player_web/lib/src/video_player.dart
index 45aa558508d..50417812cbe 100644
--- a/packages/video_player/video_player_web/lib/src/video_player.dart
+++ b/packages/video_player/video_player_web/lib/src/video_player.dart
@@ -52,6 +52,14 @@ class VideoPlayer {
bool _isInitialized = false;
bool _isBuffering = false;
+ // -- PiP state --
+ bool _isPipActive = false;
+ web.EventHandler? _onEnterPip;
+ web.EventHandler? _onLeavePip;
+
+ // -- MediaSession state --
+ bool _mediaSessionEnabled = false;
+
/// Returns the [Stream] of [VideoEvent]s from the inner [web.HTMLVideoElement].
Stream get events => _eventController.stream;
@@ -112,6 +120,7 @@ class VideoPlayer {
isPlaying: true,
),
);
+ _updateMediaSessionPlaybackState();
});
_videoElement.onPause.listen((dynamic _) {
@@ -121,6 +130,7 @@ class VideoPlayer {
isPlaying: false,
),
);
+ _updateMediaSessionPlaybackState();
});
_videoElement.onEnded.listen((dynamic _) {
@@ -128,6 +138,44 @@ class VideoPlayer {
_eventController.add(VideoEvent(eventType: VideoEventType.completed));
});
+ // Listen for timeupdate to keep MediaSession position in sync.
+ _videoElement.addEventListener(
+ 'timeupdate',
+ ((web.Event _) {
+ _updateMediaSessionPositionState();
+ }).toJS,
+ );
+
+ // PiP event listeners.
+ _onEnterPip = ((web.Event event) {
+ _isPipActive = true;
+ final pipEvent = event as web.PictureInPictureEvent;
+ final web.PictureInPictureWindow pipWindow =
+ pipEvent.pictureInPictureWindow;
+ _eventController.add(
+ VideoEvent(
+ eventType: VideoEventType.pipStateChanged,
+ isPipActive: true,
+ pipWindowSize: Size(
+ pipWindow.width.toDouble(),
+ pipWindow.height.toDouble(),
+ ),
+ ),
+ );
+ }).toJS;
+ _videoElement.addEventListener('enterpictureinpicture', _onEnterPip);
+
+ _onLeavePip = ((web.Event _) {
+ _isPipActive = false;
+ _eventController.add(
+ VideoEvent(
+ eventType: VideoEventType.pipStateChanged,
+ isPipActive: false,
+ ),
+ );
+ }).toJS;
+ _videoElement.addEventListener('leavepictureinpicture', _onLeavePip);
+
// The `src` of the _videoElement is the last property that is set, so all
// the listeners for the events that the plugin cares about are attached.
if (src != null) {
@@ -139,6 +187,10 @@ class VideoPlayer {
_videoElement.load();
}
+ // ---------------------------------------------------------------------------
+ // Playback controls
+ // ---------------------------------------------------------------------------
+
/// Attempts to play the video.
///
/// If this method is called programmatically (without user interaction), it
@@ -240,6 +292,10 @@ class VideoPlayer {
return Duration(milliseconds: (_videoElement.currentTime * 1000).round());
}
+ // ---------------------------------------------------------------------------
+ // Web options
+ // ---------------------------------------------------------------------------
+
/// Sets options
Future setOptions(VideoPlayerWebOptions options) async {
// In case this method is called multiple times, reset options.
@@ -283,8 +339,285 @@ class VideoPlayer {
_videoElement.removeAttribute('poster');
}
+ // ---------------------------------------------------------------------------
+ // Picture-in-Picture
+ // ---------------------------------------------------------------------------
+
+ /// Whether the browser supports the Picture-in-Picture API.
+ ///
+ /// Returns `false` on Firefox (non-standard PiP, not programmable) and
+ /// browsers that don't implement the W3C PiP spec.
+ bool get isPipSupported {
+ try {
+ return web.document.pictureInPictureEnabled;
+ } catch (_) {
+ return false;
+ }
+ }
+
+ /// Enters Picture-in-Picture mode.
+ ///
+ /// Throws [PlatformException] if PiP is not supported, the video element
+ /// has `disablePictureInPicture` set, or a user gesture is required.
+ Future enterPip() async {
+ try {
+ await _videoElement.requestPictureInPicture().toDart;
+ } catch (e) {
+ if (e is web.DOMException) {
+ throw PlatformException(
+ code: e.name,
+ message: e.message,
+ );
+ }
+ rethrow;
+ }
+ }
+
+ /// Exits Picture-in-Picture mode.
+ ///
+ /// No-op if PiP is not currently active.
+ Future exitPip() async {
+ if (web.document.pictureInPictureElement == null) {
+ return;
+ }
+ try {
+ await web.document.exitPictureInPicture().toDart;
+ } catch (e) {
+ if (e is web.DOMException) {
+ throw PlatformException(
+ code: e.name,
+ message: e.message,
+ );
+ }
+ rethrow;
+ }
+ }
+
+ /// Whether PiP is currently active for this video element.
+ bool get isPipActive => _isPipActive;
+
+ // ---------------------------------------------------------------------------
+ // MediaSession (background playback metadata & controls)
+ // ---------------------------------------------------------------------------
+
+ /// Sets up the browser's MediaSession with metadata and action handlers.
+ ///
+ /// On the web, "background playback" works natively (audio continues when
+ /// the tab is in the background). This method configures the browser's
+ /// MediaSession API so that OS-level media controls (lock screen, media
+ /// overlay, notification) display the correct metadata and respond to
+ /// play/pause/seek actions.
+ void enableMediaSession(MediaInfo? mediaInfo) {
+ _mediaSessionEnabled = true;
+ _setupMediaSession(mediaInfo);
+ }
+
+ /// Tears down the MediaSession configuration.
+ void disableMediaSession() {
+ _mediaSessionEnabled = false;
+ setAutoEnterPip(false);
+ _teardownMediaSession();
+ }
+
+ void _setupMediaSession(MediaInfo? mediaInfo) {
+ final web.MediaSession mediaSession;
+ try {
+ mediaSession = web.window.navigator.mediaSession;
+ } catch (_) {
+ // MediaSession not supported in this browser.
+ return;
+ }
+
+ // Set metadata.
+ if (mediaInfo != null) {
+ final artwork = [];
+ if (mediaInfo.artworkUrl != null) {
+ artwork.add(
+ web.MediaImage(src: mediaInfo.artworkUrl!, sizes: '512x512'),
+ );
+ }
+ mediaSession.metadata = web.MediaMetadata(
+ web.MediaMetadataInit(
+ title: mediaInfo.title,
+ artist: mediaInfo.artist ?? '',
+ artwork: artwork.toJS,
+ ),
+ );
+ }
+
+ // Action handlers.
+ mediaSession.setActionHandler(
+ 'play',
+ ((MediaSessionActionDetails _) {
+ play();
+ }).toJS,
+ );
+
+ mediaSession.setActionHandler(
+ 'pause',
+ ((MediaSessionActionDetails _) {
+ pause();
+ }).toJS,
+ );
+
+ mediaSession.setActionHandler(
+ 'seekto',
+ ((MediaSessionActionDetails details) {
+ final double? seekTime = details.seekTime;
+ if (seekTime != null) {
+ seekTo(Duration(milliseconds: (seekTime * 1000).round()));
+ }
+ }).toJS,
+ );
+
+ mediaSession.setActionHandler(
+ 'seekbackward',
+ ((MediaSessionActionDetails details) {
+ final double offset = details.seekOffset ?? 10;
+ final Duration current = getPosition();
+ final int target = current.inMilliseconds - (offset * 1000).round();
+ seekTo(Duration(milliseconds: target < 0 ? 0 : target));
+ }).toJS,
+ );
+
+ mediaSession.setActionHandler(
+ 'seekforward',
+ ((MediaSessionActionDetails details) {
+ final double offset = details.seekOffset ?? 10;
+ final Duration current = getPosition();
+ seekTo(
+ Duration(
+ milliseconds: current.inMilliseconds + (offset * 1000).round(),
+ ),
+ );
+ }).toJS,
+ );
+
+ // PiP handler — invoked by Chrome when the user taps the PiP button in
+ // media controls ("useraction") or, on eligible pages, when the user
+ // switches tabs ("contentoccluded"). Chrome provides user activation so
+ // requestPictureInPicture() works without a prior gesture.
+ try {
+ mediaSession.setActionHandler(
+ 'enterpictureinpicture',
+ ((MediaSessionActionDetails _) {
+ _videoElement.requestPictureInPicture().toDart.ignore();
+ }).toJS,
+ );
+ } catch (_) {
+ // enterpictureinpicture action not supported in this browser.
+ }
+
+ _updateMediaSessionPlaybackState();
+ _updateMediaSessionPositionState();
+ }
+
+ void _teardownMediaSession() {
+ final web.MediaSession mediaSession;
+ try {
+ mediaSession = web.window.navigator.mediaSession;
+ } catch (_) {
+ return;
+ }
+
+ mediaSession.metadata = null;
+ mediaSession.playbackState = 'none';
+ const actions = [
+ 'play',
+ 'pause',
+ 'seekto',
+ 'seekbackward',
+ 'seekforward',
+ 'enterpictureinpicture',
+ ];
+ for (final action in actions) {
+ try {
+ mediaSession.setActionHandler(action, null);
+ } catch (_) {
+ // Some actions may not be supported in all browsers.
+ }
+ }
+ }
+
+ void _updateMediaSessionPlaybackState() {
+ if (!_mediaSessionEnabled) {
+ return;
+ }
+ try {
+ final web.MediaSession mediaSession = web.window.navigator.mediaSession;
+ mediaSession.playbackState =
+ _videoElement.paused ? 'paused' : 'playing';
+ } catch (_) {
+ // MediaSession not available.
+ }
+ }
+
+ void _updateMediaSessionPositionState() {
+ if (!_mediaSessionEnabled) {
+ return;
+ }
+ try {
+ final web.MediaSession mediaSession = web.window.navigator.mediaSession;
+ final double duration = _videoElement.duration;
+ if (duration.isFinite && duration > 0) {
+ mediaSession.setPositionState(
+ web.MediaPositionState(
+ duration: duration,
+ playbackRate: _videoElement.playbackRate,
+ position: _videoElement.currentTime,
+ ),
+ );
+ }
+ } catch (_) {
+ // MediaSession not available.
+ }
+ }
+
+ // ---------------------------------------------------------------------------
+ // Auto-PiP
+ // ---------------------------------------------------------------------------
+
+ /// No-op on web. Auto-PiP on tab switch is not supported because Flutter
+ /// web renders the video element inside a platform view (not the top frame),
+ /// which Chrome requires for automatic PiP invocation.
+ ///
+ /// PiP is still available on web via:
+ /// - Chrome's media controls PiP button (when MediaSession is enabled)
+ /// - Programmatic [enterPip] / [exitPip] calls (require user gesture)
+ void setAutoEnterPip(bool enabled) {
+ // No-op on web — auto-PiP requires media in the top frame.
+ }
+
+ // ---------------------------------------------------------------------------
+ // Dispose
+ // ---------------------------------------------------------------------------
+
/// Disposes of the current [web.HTMLVideoElement].
void dispose() {
+ // Exit PiP if active.
+ if (_isPipActive) {
+ try {
+ web.document.exitPictureInPicture();
+ } catch (_) {
+ // Ignore errors during disposal.
+ }
+ }
+
+ // Clean up PiP listeners.
+ if (_onEnterPip != null) {
+ _videoElement.removeEventListener('enterpictureinpicture', _onEnterPip);
+ _onEnterPip = null;
+ }
+ if (_onLeavePip != null) {
+ _videoElement.removeEventListener('leavepictureinpicture', _onLeavePip);
+ _onLeavePip = null;
+ }
+
+ // Tear down MediaSession (includes enterpictureinpicture handler).
+ if (_mediaSessionEnabled) {
+ _teardownMediaSession();
+ }
+
_videoElement.removeAttribute('src');
if (_onContextMenu != null) {
_videoElement.removeEventListener('contextmenu', _onContextMenu);
@@ -293,6 +626,10 @@ class VideoPlayer {
_videoElement.load();
}
+ // ---------------------------------------------------------------------------
+ // Internal helpers
+ // ---------------------------------------------------------------------------
+
// Handler to mark (and broadcast) when this player [_isInitialized].
//
// (Used as a JS event handler for "canplay" and "loadedmetadata")
diff --git a/packages/video_player/video_player_web/lib/video_player_web.dart b/packages/video_player/video_player_web/lib/video_player_web.dart
index ecc8e427d2d..63784b2eb53 100644
--- a/packages/video_player/video_player_web/lib/video_player_web.dart
+++ b/packages/video_player/video_player_web/lib/video_player_web.dart
@@ -166,6 +166,61 @@ class VideoPlayerPlugin extends VideoPlayerPlatform {
return HtmlElementView(viewType: 'videoPlayer-$playerId');
}
+ // ---------------------------------------------------------------------------
+ // Picture-in-Picture
+ // ---------------------------------------------------------------------------
+
+ @override
+ Future isPipSupported() async {
+ // Check via any existing player, or fall back to document-level check.
+ final VideoPlayer? player = _videoPlayers.values.firstOrNull;
+ if (player != null) {
+ return player.isPipSupported;
+ }
+ try {
+ return web.document.pictureInPictureEnabled;
+ } catch (_) {
+ return false;
+ }
+ }
+
+ @override
+ Future enterPip(int playerId) async {
+ return _player(playerId).enterPip();
+ }
+
+ @override
+ Future exitPip(int playerId) async {
+ return _player(playerId).exitPip();
+ }
+
+ @override
+ Future isPipActive(int playerId) async {
+ return _player(playerId).isPipActive;
+ }
+
+ @override
+ Future setAutoEnterPip(int playerId, bool enabled) async {
+ _player(playerId).setAutoEnterPip(enabled);
+ }
+
+ // ---------------------------------------------------------------------------
+ // MediaSession / Background playback
+ // ---------------------------------------------------------------------------
+
+ @override
+ Future enableBackgroundPlayback(
+ int playerId, {
+ MediaInfo? mediaInfo,
+ }) async {
+ _player(playerId).enableMediaSession(mediaInfo);
+ }
+
+ @override
+ Future disableBackgroundPlayback(int playerId) async {
+ _player(playerId).disableMediaSession();
+ }
+
/// Sets the audio mode to mix with other sources (ignored).
@override
Future setMixWithOthers(bool mixWithOthers) => Future.value();