diff --git a/packages/video_player/video_player_web/CHANGELOG.md b/packages/video_player/video_player_web/CHANGELOG.md index cac6a397996f..e79a0ba19484 100644 --- a/packages/video_player/video_player_web/CHANGELOG.md +++ b/packages/video_player/video_player_web/CHANGELOG.md @@ -1,3 +1,7 @@ +## 2.4.0 + +* Adds VideoPlugin.renderVideoAsTexture + ## 2.3.0 * Migrates package and tests to `package:web``. diff --git a/packages/video_player/video_player_web/example/integration_test/video_player_web_test.dart b/packages/video_player/video_player_web/example/integration_test/video_player_web_test.dart index 7c8f1c9acefc..ff2e5090cda8 100644 --- a/packages/video_player/video_player_web/example/integration_test/video_player_web_test.dart +++ b/packages/video_player/video_player_web/example/integration_test/video_player_web_test.dart @@ -23,7 +23,8 @@ void main() { late Future textureId; setUp(() { - VideoPlayerPlatform.instance = VideoPlayerPlugin(); + VideoPlayerPlatform.instance = VideoPlayerPlugin() + ..mode = VideoRenderMode.html; textureId = VideoPlayerPlatform.instance .create( DataSource( diff --git a/packages/video_player/video_player_web/lib/src/browser_detection.dart b/packages/video_player/video_player_web/lib/src/browser_detection.dart new file mode 100644 index 000000000000..f25d162bc77d --- /dev/null +++ b/packages/video_player/video_player_web/lib/src/browser_detection.dart @@ -0,0 +1,83 @@ +import 'package:web/web.dart' as web; + +/// Browser detection copied from engine/browser_detection.dart + +/// The HTML engine used by the current browser. +enum BrowserEngine { + /// The engine that powers Chrome, Samsung Internet Browser, UC Browser, + /// Microsoft Edge, Opera, and others. + /// + /// Blink is assumed in case when a more precise browser engine wasn't + /// detected. + blink, + + /// The engine that powers Safari. + webkit, + + /// The engine that powers Firefox. + firefox, +} + +/// html webgl version qualifier constants. +abstract class WebGLVersion { + /// WebGL 1.0 is based on OpenGL ES 2.0 / GLSL 1.00 + static const int webgl1 = 1; + + /// WebGL 2.0 is based on OpenGL ES 3.0 / GLSL 3.00 + static const int webgl2 = 2; +} + +/// Lazily initialized current browser engine. +final BrowserEngine _browserEngine = _detectBrowserEngine(); + +/// Override the value of [browserEngine]. +/// +/// Setting this to `null` lets [browserEngine] detect the browser that the +/// app is running on. +/// +/// This is intended to be used for testing and debugging only. +BrowserEngine? debugBrowserEngineOverride; + +/// Returns the [BrowserEngine] used by the current browser. +/// +/// This is used to implement browser-specific behavior. +BrowserEngine get browserEngine { + return debugBrowserEngineOverride ?? _browserEngine; +} + +BrowserEngine _detectBrowserEngine() { + final String vendor = web.window.navigator.vendor; + final String agent = web.window.navigator.userAgent.toLowerCase(); + return detectBrowserEngineByVendorAgent(vendor, agent); +} + +/// Detects browser engine for a given vendor and agent string. +/// +/// Used for testing this library. +BrowserEngine detectBrowserEngineByVendorAgent(String vendor, String agent) { + if (vendor == 'Google Inc.') { + return BrowserEngine.blink; + } else if (vendor == 'Apple Computer, Inc.') { + return BrowserEngine.webkit; + } else if (agent.contains('Edg/')) { + // Chromium based Microsoft Edge has `Edg` in the user-agent. + // https://docs.microsoft.com/en-us/microsoft-edge/web-platform/user-agent-string + return BrowserEngine.blink; + } else if (vendor == '' && agent.contains('firefox')) { + // An empty string means firefox: + // https://developer.mozilla.org/en-US/docs/Web/API/Navigator/vendor + return BrowserEngine.firefox; + } + + // Assume Blink otherwise, but issue a warning. + // ignore: avoid_print + print( + 'WARNING: failed to detect current browser engine. Assuming this is a Chromium-compatible browser.'); + return BrowserEngine.blink; +} + +/// Whether the current browser is Safari. +bool get isSafari => browserEngine == BrowserEngine.webkit; + +/// Whether the current browser is Firefox. +bool get isFirefox => browserEngine == BrowserEngine.firefox; 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 012463fc780d..e58ca413b4bc 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 @@ -349,4 +349,9 @@ class VideoPlayer { } return durationRange; } + + /// Returns the [web.HTMLVideoElement] associated with this player + web.HTMLVideoElement get videoElement { + return _videoElement; + } } 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 8f5c0265e965..7c5ec61c2cff 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 @@ -3,6 +3,10 @@ // found in the LICENSE file. import 'dart:async'; +import 'dart:js_interop'; +import 'dart:js_util'; +import 'dart:math'; +import 'dart:ui' as ui; import 'dart:ui_web' as ui_web; import 'package:flutter/material.dart'; @@ -10,6 +14,7 @@ import 'package:flutter_web_plugins/flutter_web_plugins.dart'; import 'package:video_player_platform_interface/video_player_platform_interface.dart'; import 'package:web/web.dart' as web; +import 'src/browser_detection.dart'; import 'src/video_player.dart'; /// The web implementation of [VideoPlayerPlatform]. @@ -35,6 +40,7 @@ class VideoPlayerPlugin extends VideoPlayerPlatform { @override Future dispose(int textureId) async { _player(textureId).dispose(); + _videoPlayers.remove(textureId); return; } @@ -71,15 +77,7 @@ class VideoPlayerPlugin extends VideoPlayerPlatform { 'web implementation of video_player cannot play content uri')); } - final web.HTMLVideoElement videoElement = web.HTMLVideoElement() - ..id = 'videoElement-$textureId' - ..style.border = 'none' - ..style.height = '100%' - ..style.width = '100%'; - - // TODO(hterkelsen): Use initialization parameters once they are available - ui_web.platformViewRegistry.registerViewFactory( - 'videoPlayer-$textureId', (int viewId) => videoElement); + final web.HTMLVideoElement videoElement = _createElement(textureId); final VideoPlayer player = VideoPlayer(videoElement: videoElement) ..initialize( @@ -91,6 +89,21 @@ class VideoPlayerPlugin extends VideoPlayerPlatform { return textureId; } + web.VideoElement _createElement(int textureId) { + final web.VideoElement videoElement = web.HTMLVideoElement() + ..id = 'videoElement-$textureId' + ..style.border = 'none' + ..style.height = '100%' + ..style.width = '100%' + ..style.pointerEvents = 'none'; + + if (mode != VideoRenderMode.html) { + videoElement.crossOrigin = 'anonymous'; + } + + return videoElement; + } + @override Future setLooping(int textureId, bool looping) async { return _player(textureId).setLooping(looping); @@ -144,10 +157,453 @@ class VideoPlayerPlugin extends VideoPlayerPlatform { @override Widget buildView(int textureId) { - return HtmlElementView(viewType: 'videoPlayer-$textureId'); + return _AdapterVideoPlayerRenderer( + key: Key(textureId.toString()), + textureId: textureId, + player: _player(textureId), + mode: mode); } /// Sets the audio mode to mix with other sources (ignored) @override Future setMixWithOthers(bool mixWithOthers) => Future.value(); + + /// The mode to render the video with. + VideoRenderMode mode = VideoRenderMode.auto; +} + +class _AdapterVideoPlayerRenderer extends StatefulWidget { + const _AdapterVideoPlayerRenderer( + {required this.textureId, + required this.player, + required this.mode, + super.key}); + + final int textureId; + final VideoPlayer player; + final VideoRenderMode mode; + + @override + State createState() => _AdapterVideoPlayerRendererState(); +} + +class _AdapterVideoPlayerRendererState + extends State<_AdapterVideoPlayerRenderer> { + _AdapterVideoPlayerRendererState() { + setDesiredModeRef = setDesiredMode.toJS; + } + + late JSFunction setDesiredModeRef; + + VideoRenderMode _mode = VideoRenderMode.html; + + VideoRenderMode get mode { + return _mode; + } + + set mode(VideoRenderMode mode) { + if (_mode != mode) { + _mode = mode; + updateVideoElementStyle(); + if (mounted) { + if (widget.player.videoElement.parentElement == null && + mode != VideoRenderMode.html) { + web.document.body!.appendChild(widget.player.videoElement); + } + } + } + } + + bool get rendererCanvasKit { + return hasProperty(web.window, 'flutterCanvasKit'); + } + + @override + void initState() { + super.initState(); + + // if auto rendering mode, and in canvaskit renderer, always start in + // texture mode to reduce the number of platform layers used for videos + // that are not playing + mode = widget.mode == VideoRenderMode.auto + ? (rendererCanvasKit ? VideoRenderMode.texture : VideoRenderMode.html) + : widget.mode; + + if (widget.mode == VideoRenderMode.auto) { + widget.player.videoElement + .addEventListener('play', setDesiredModeRef, false.toJS); + widget.player.videoElement + .addEventListener('pause', setDesiredModeRef, false.toJS); + } + } + + // when in auto mode, will optimize based off of the browser + void setDesiredMode(web.Event event) { + VideoRenderMode desiredMode; + final web.HTMLVideoElement element = widget.player.videoElement; + isPlaying = !!(!element.paused && !element.ended && element.readyState > 2); + + if (isPlaying && (isSafari || isFirefox)) { + desiredMode = VideoRenderMode.canvas; + } else { + desiredMode = VideoRenderMode.texture; + } + if (desiredMode != mode) { + setState(() { + mode = desiredMode; + }); + } + } + + bool isPlaying = true; + + @override + void dispose() { + super.dispose(); + events?.cancel(); + + if (widget.mode == VideoRenderMode.auto) { + widget.player.videoElement.removeEventListener('play', setDesiredModeRef); + widget.player.videoElement + .removeEventListener('pause', setDesiredModeRef); + } + + if (widget.player.videoElement.parentElement != null) { + widget.player.videoElement.parentElement! + .removeChild(widget.player.videoElement); + } + } + + StreamSubscription? events; + + void updateVideoElementStyle() { + switch (mode) { + case VideoRenderMode.html: + widget.player.videoElement + ..style.width = '100%' + ..style.height = '100%' + ..style.opacity = '1' + ..style.position = 'relative'; + case VideoRenderMode.auto: + case VideoRenderMode.texture: + case VideoRenderMode.canvas: + widget.player.videoElement + ..style.top = '0px' + ..style.left = '0px' + ..style.width = '0px' + ..style.height = '0px' + ..style.opacity = '0' + ..style.zIndex = '-1' + ..style.position = 'absolute'; + } + } + + /// Builds a view that will render the video player using an HtmlElementView + Widget buildHtmlView(int textureId, VideoPlayer player) { + return HtmlElementView.fromTagName( + tagName: 'div', + onElementCreated: (Object? element) { + final web.HTMLElement tag = element! as web.HTMLElement; + tag.appendChild(player.videoElement); + }); + } + + Widget buildTextureView(int textureId, VideoPlayer player) { + player.videoElement.style.top = '0px'; + player.videoElement.style.left = '0px'; + player.videoElement.style.width = '0px'; + player.videoElement.style.height = '0px'; + player.videoElement.style.opacity = '0'; + player.videoElement.style.zIndex = '-1'; + player.videoElement.style.position = 'absolute'; + return _TextureVideoPlayerRenderer( + element: player.videoElement, paused: !isPlaying); + } + + Widget buildCanvasView(int textureId, VideoPlayer player) { + player.videoElement.style.top = '0px'; + player.videoElement.style.left = '0px'; + player.videoElement.style.width = '0px'; + player.videoElement.style.height = '0px'; + player.videoElement.style.opacity = '0'; + player.videoElement.style.zIndex = '-1'; + player.videoElement.style.position = 'absolute'; + return _CanvasVideoPlayerRenderer( + element: player.videoElement, paused: !isPlaying); + } + + @override + Widget build(BuildContext context) { + return switch (mode) { + VideoRenderMode.html => buildHtmlView(widget.textureId, widget.player), + VideoRenderMode.texture => + buildTextureView(widget.textureId, widget.player), + VideoRenderMode.canvas => + buildCanvasView(widget.textureId, widget.player), + VideoRenderMode.auto => + Container(color: Colors.red) // This case should never happen + }; + } +} + +/// Determines whether the video uses a platform layer or renders into the canvas +enum VideoRenderMode { + /// render the video with a a native layer so that it can interact with + /// shaders and other flutter features, this will eliminate the need for + /// platform layers, but may reduce performance in some browsers. + texture, + + /// render the video into an html canvas element layer, this will reduce + /// memory usage in browsers without zero copy bitmap operations. + canvas, + + /// render the video with a platform layer to enable support for DRM + html, + + /// automatically pick an appropriate rendering mode based on the active + /// rendering engine and whether the video is playing, if using CanvasKit + /// renderer, will attempt to minimize the number of platform layers used + /// while optimizing performance. If using HTML renderer, will use a platform + /// view. + auto +} + +abstract class _VideoPlayerRenderer extends StatefulWidget { + const _VideoPlayerRenderer({required this.element, this.paused = true}); + + final web.HTMLVideoElement element; + final bool paused; +} + +abstract class _VideoPlayerRendererState + extends State { + _VideoPlayerRendererState() { + onSeekRef = onSeek.toJS; + } + + @override + void initState() { + super.initState(); + + frameCallback(0.toJS, 0.toJS); + widget.element.addEventListener('seeked', onSeekRef); + } + + late JSFunction onSeekRef; + + int? callbackID; + + void getFrame(web.HTMLVideoElement element) { + callbackID = + element.requestVideoFrameCallbackWithFallback(frameCallback.toJS); + } + + void cancelFrame(web.HTMLVideoElement element) { + if (callbackID != null) { + element.cancelVideoFrameCallbackWithFallback(callbackID!); + } + } + + void onSeek(web.Event event) { + capture(); + } + + void frameCallback(JSAny now, JSAny metadata) { + final web.HTMLVideoElement element = widget.element; + final bool isPlaying = !!(element.currentTime > 0 && + !element.paused && + !element.ended && + element.readyState > 2); + + // only capture frames if video is playing (optimization for RAF) + if (isPlaying || element.readyState > 2 && lastFrameTime == null) { + capture().then((_) async { + getFrame(widget.element); + }); + } else { + getFrame(widget.element); + } + } + + web.ImageBitmap? source; + num? lastFrameTime; + + Future capture(); + + @override + void dispose() { + cancelFrame(widget.element); + super.dispose(); + source?.close(); + widget.element.removeEventListener('seeked', onSeekRef); + } + + @override + void didUpdateWidget(T oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.element != widget.element) { + oldWidget.element.addEventListener('seeked', onSeekRef); + widget.element.addEventListener('seeked', onSeekRef); + cancelFrame(oldWidget.element); + getFrame(widget.element); + } + } +} + +class _TextureVideoPlayerRenderer extends _VideoPlayerRenderer { + const _TextureVideoPlayerRenderer({required super.element, super.paused}); + + @override + State createState() => _TextureVideoPlayerRendererState(); +} + +class _TextureVideoPlayerRendererState + extends _VideoPlayerRendererState<_TextureVideoPlayerRenderer> { + @override + Future capture() async { + if (!widget.paused || lastFrameTime != widget.element.currentTime) { + lastFrameTime = widget.element.currentTime; + try { + final web.ImageBitmap newSource = + await promiseToFuture( + web.window.createImageBitmap(widget.element)); + final ui.Image img = await ui_web.createImageFromImageBitmap(newSource); + + if (mounted) { + setState(() { + image?.dispose(); + source?.close(); + image = img; + source = newSource; + }); + } + } on web.DOMException catch (err) { + lastFrameTime = null; + if (err.name == 'InvalidStateError') { + // We don't have enough data yet, continue on + } else { + rethrow; + } + } + } + } + + ui.Image? image; + + @override + void dispose() { + super.dispose(); + image?.dispose(); + } + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + // adjust video element size to match dimensions of frame so we capture the correct sized bitmap + final double dpr = MediaQuery.of(context).devicePixelRatio; + + final double maxWidth = constraints.maxWidth * dpr; + final double maxHeight = constraints.maxHeight * dpr; + final double videoWidth = widget.element.videoWidth.toDouble(); + final double videoHeight = widget.element.videoHeight.toDouble(); + + widget.element.width = + (videoWidth == 0 ? maxWidth : min(videoWidth, maxWidth)).ceil(); + widget.element.height = + (videoHeight == 0 ? maxHeight : min(videoHeight, maxHeight)).ceil(); + if (image != null) { + return RawImage(image: image); + } else { + return const ColoredBox(color: Colors.black); + } + }); + } +} + +class _CanvasVideoPlayerRenderer extends _VideoPlayerRenderer { + const _CanvasVideoPlayerRenderer({required super.element, super.paused}); + + @override + State createState() => _CanvasVideoPlayerRendererState(); +} + +class _CanvasVideoPlayerRendererState + extends _VideoPlayerRendererState<_CanvasVideoPlayerRenderer> { + @override + Future capture() async { + lastFrameTime = widget.element.currentTime; + if (canvas != null) { + final web.CanvasRenderingContext2D context = + canvas!.getContext('2d')! as web.CanvasRenderingContext2D; + + context.drawImage(widget.element, 0, 0, canvas!.width, canvas!.height); + } + } + + web.HTMLCanvasElement? canvas; + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + // adjust video element size to match dimensions of frame so we capture the correct sized bitmap + final double dpr = MediaQuery.of(context).devicePixelRatio; + + final double maxWidth = constraints.maxWidth * dpr; + final double maxHeight = constraints.maxHeight * dpr; + final double videoWidth = widget.element.videoWidth.toDouble(); + final double videoHeight = widget.element.videoHeight.toDouble(); + + widget.element.width = + (videoWidth == 0 ? maxWidth : min(videoWidth, maxWidth)).ceil(); + widget.element.height = + (videoHeight == 0 ? maxHeight : min(videoHeight, maxHeight)).ceil(); + + if (canvas != null) { + if (canvas!.width != widget.element.width || + canvas!.height != widget.element.height) { + canvas!.width = widget.element.width; + canvas!.height = widget.element.height; + capture(); + } + } + + return HtmlElementView.fromTagName( + tagName: 'canvas', + onElementCreated: (Object? element) { + canvas = element! as web.HTMLCanvasElement; + canvas!.width = widget.element.width; + canvas!.height = widget.element.height; + capture(); + getFrame(widget.element); + }); + }); + } +} + +typedef _VideoFrameRequestCallback = JSFunction; + +extension _HTMLVideoElementRequestAnimationFrame on web.HTMLVideoElement { + int requestVideoFrameCallbackWithFallback( + _VideoFrameRequestCallback callback) { + if (hasProperty(this, 'requestVideoFrameCallback')) { + return requestVideoFrameCallback(callback); + } else { + return web.window.requestAnimationFrame((double num) { + callback.callAsFunction(this, 0.toJS, 0.toJS); + }.toJS); + } + } + + void cancelVideoFrameCallbackWithFallback(int callbackID) { + if (hasProperty(this, 'requestVideoFrameCallback')) { + cancelVideoFrameCallback(callbackID); + } else { + web.window.cancelAnimationFrame(callbackID); + } + } + + external int requestVideoFrameCallback(_VideoFrameRequestCallback callback); + external void cancelVideoFrameCallback(int callbackID); } diff --git a/packages/video_player/video_player_web/pubspec.yaml b/packages/video_player/video_player_web/pubspec.yaml index dd876328b014..004dd4a0dc23 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.3.0 +version: 2.4.0 environment: sdk: ^3.3.0