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();