diff --git a/packages/video_player/video_player_web/CHANGELOG.md b/packages/video_player/video_player_web/CHANGELOG.md index 1cd428c4deea..3310660137a2 100644 --- a/packages/video_player/video_player_web/CHANGELOG.md +++ b/packages/video_player/video_player_web/CHANGELOG.md @@ -1,3 +1,11 @@ +## 2.0.8 + +* Ensures `buffering` state is only removed when the browser reports enough data + has been buffered so that the video can likely play through without stopping + (`onCanPlayThrough`). Issue [#94630](https://github.com/flutter/flutter/issues/94630). +* Improves testability of the `_VideoPlayer` private class. +* Ensures that tests that listen to a Stream fail "fast" (1 second max timeout). + ## 2.0.7 * Internal code cleanup for stricter analysis options. diff --git a/packages/video_player/video_player_web/example/integration_test/utils.dart b/packages/video_player/video_player_web/example/integration_test/utils.dart new file mode 100644 index 000000000000..b0118514053a --- /dev/null +++ b/packages/video_player/video_player_web/example/integration_test/utils.dart @@ -0,0 +1,16 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +// Returns the URL to load an asset from this example app as a network source. +// +// TODO(stuartmorgan): Convert this to a local `HttpServer` that vends the +// assets directly, https://github.com/flutter/flutter/issues/95420 +String getUrlForAssetAsNetworkSource(String assetKey) { + return 'https://github.com/flutter/plugins/blob/' + // This hash can be rolled forward to pick up newly-added assets. + 'cb381ced070d356799dddf24aca38ce0579d3d7b' + '/packages/video_player/video_player/example/' + '$assetKey' + '?raw=true'; +} diff --git a/packages/video_player/video_player_web/example/integration_test/video_player_test.dart b/packages/video_player/video_player_web/example/integration_test/video_player_test.dart new file mode 100644 index 000000000000..41aba9792e23 --- /dev/null +++ b/packages/video_player/video_player_web/example/integration_test/video_player_test.dart @@ -0,0 +1,195 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:async'; +import 'dart:html' as html; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:video_player_platform_interface/video_player_platform_interface.dart'; +import 'package:video_player_web/src/video_player.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + group('VideoPlayer', () { + late html.VideoElement video; + + setUp(() { + // Never set "src" on the video, so this test doesn't hit the network! + video = html.VideoElement() + ..controls = true + ..setAttribute('playsinline', 'false'); + }); + + testWidgets('fixes critical video element config', (WidgetTester _) async { + VideoPlayer(videoElement: video).initialize(); + + expect(video.controls, isFalse, + reason: 'Video is controlled through code'); + expect(video.getAttribute('autoplay'), 'false', + reason: 'Cannot autoplay on the web'); + expect(video.getAttribute('playsinline'), 'true', + reason: 'Needed by safari iOS'); + }); + + testWidgets('setVolume', (WidgetTester tester) async { + final VideoPlayer player = VideoPlayer(videoElement: video)..initialize(); + + player.setVolume(0); + + expect(video.volume, isZero, reason: 'Volume should be zero'); + expect(video.muted, isTrue, reason: 'muted attribute should be true'); + + expect(() { + player.setVolume(-0.0001); + }, throwsAssertionError, reason: 'Volume cannot be < 0'); + + expect(() { + player.setVolume(1.0001); + }, throwsAssertionError, reason: 'Volume cannot be > 1'); + }); + + testWidgets('setPlaybackSpeed', (WidgetTester tester) async { + final VideoPlayer player = VideoPlayer(videoElement: video)..initialize(); + + expect(() { + player.setPlaybackSpeed(-1); + }, throwsAssertionError, reason: 'Playback speed cannot be < 0'); + + expect(() { + player.setPlaybackSpeed(0); + }, throwsAssertionError, reason: 'Playback speed cannot be == 0'); + }); + + testWidgets('seekTo', (WidgetTester tester) async { + final VideoPlayer player = VideoPlayer(videoElement: video)..initialize(); + + expect(() { + player.seekTo(const Duration(seconds: -1)); + }, throwsAssertionError, reason: 'Cannot seek into negative numbers'); + }); + + // The events tested in this group do *not* represent the actual sequence + // of events from a real "video" element. They're crafted to test the + // behavior of the VideoPlayer in different states with different events. + group('events', () { + late StreamController streamController; + late VideoPlayer player; + late Stream timedStream; + + final Set bufferingEvents = { + VideoEventType.bufferingStart, + VideoEventType.bufferingEnd, + }; + + setUp(() { + streamController = StreamController(); + player = + VideoPlayer(videoElement: video, eventController: streamController) + ..initialize(); + + // This stream will automatically close after 100 ms without seeing any events + timedStream = streamController.stream.timeout( + const Duration(milliseconds: 100), + onTimeout: (EventSink sink) { + sink.close(); + }, + ); + }); + + testWidgets('buffering dispatches only when it changes', + (WidgetTester tester) async { + // Take all the "buffering" events that we see during the next few seconds + final Future> stream = timedStream + .where( + (VideoEvent event) => bufferingEvents.contains(event.eventType)) + .map((VideoEvent event) => + event.eventType == VideoEventType.bufferingStart) + .toList(); + + // Simulate some events coming from the player... + player.setBuffering(true); + player.setBuffering(true); + player.setBuffering(true); + player.setBuffering(false); + player.setBuffering(false); + player.setBuffering(true); + player.setBuffering(false); + player.setBuffering(true); + player.setBuffering(false); + + final List events = await stream; + + expect(events, hasLength(6)); + expect(events, [true, false, true, false, true, false]); + }); + + testWidgets('canplay event does not change buffering state', + (WidgetTester tester) async { + // Take all the "buffering" events that we see during the next few seconds + final Future> stream = timedStream + .where( + (VideoEvent event) => bufferingEvents.contains(event.eventType)) + .map((VideoEvent event) => + event.eventType == VideoEventType.bufferingStart) + .toList(); + + player.setBuffering(true); + + // Simulate "canplay" event... + video.dispatchEvent(html.Event('canplay')); + + final List events = await stream; + + expect(events, hasLength(1)); + expect(events, [true]); + }); + + testWidgets('canplaythrough event does change buffering state', + (WidgetTester tester) async { + // Take all the "buffering" events that we see during the next few seconds + final Future> stream = timedStream + .where( + (VideoEvent event) => bufferingEvents.contains(event.eventType)) + .map((VideoEvent event) => + event.eventType == VideoEventType.bufferingStart) + .toList(); + + player.setBuffering(true); + + // Simulate "canplaythrough" event... + video.dispatchEvent(html.Event('canplaythrough')); + + final List events = await stream; + + expect(events, hasLength(2)); + expect(events, [true, false]); + }); + + testWidgets('initialized dispatches only once', + (WidgetTester tester) async { + // Dispatch some bogus "canplay" events from the video object + video.dispatchEvent(html.Event('canplay')); + video.dispatchEvent(html.Event('canplay')); + video.dispatchEvent(html.Event('canplay')); + + // Take all the "initialized" events that we see during the next few seconds + final Future> stream = timedStream + .where((VideoEvent event) => + event.eventType == VideoEventType.initialized) + .toList(); + + video.dispatchEvent(html.Event('canplay')); + video.dispatchEvent(html.Event('canplay')); + video.dispatchEvent(html.Event('canplay')); + + final List events = await stream; + + expect(events, hasLength(1)); + expect(events[0].eventType, VideoEventType.initialized); + }); + }); + }); +} 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 97b03642cd07..5053ea6e5b04 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 @@ -11,10 +11,15 @@ import 'package:integration_test/integration_test.dart'; import 'package:video_player_platform_interface/video_player_platform_interface.dart'; import 'package:video_player_web/video_player_web.dart'; +import 'utils.dart'; + +// Use WebM to allow CI to run tests in Chromium. +const String _videoAssetKey = 'assets/Butterfly-209.webm'; + void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); - group('VideoPlayer for Web', () { + group('VideoPlayerWeb plugin (hits network)', () { late Future textureId; setUp(() { @@ -23,8 +28,7 @@ void main() { .create( DataSource( sourceType: DataSourceType.network, - uri: - 'https://flutter.github.io/assets-for-api-docs/assets/videos/bee.mp4', + uri: getUrlForAssetAsNetworkSource(_videoAssetKey), ), ) .then((int? textureId) => textureId!); @@ -38,9 +42,9 @@ void main() { expect( VideoPlayerPlatform.instance.create( DataSource( - sourceType: DataSourceType.network, - uri: - 'https://flutter.github.io/assets-for-api-docs/assets/videos/bee.mp4'), + sourceType: DataSourceType.network, + uri: getUrlForAssetAsNetworkSource(_videoAssetKey), + ), ), completion(isNonZero)); }); @@ -100,9 +104,9 @@ void main() { (WidgetTester tester) async { final int videoPlayerId = (await VideoPlayerPlatform.instance.create( DataSource( - sourceType: DataSourceType.network, - uri: - 'https://flutter.github.io/assets-for-api-docs/assets/videos/_non_existent_video.mp4'), + sourceType: DataSourceType.network, + uri: getUrlForAssetAsNetworkSource('assets/__non_existent.webm'), + ), ))!; final Stream eventStream = @@ -113,7 +117,7 @@ void main() { await VideoPlayerPlatform.instance.play(videoPlayerId); expect(() async { - await eventStream.last; + await eventStream.timeout(const Duration(seconds: 5)).last; }, throwsA(isA())); }); @@ -164,5 +168,40 @@ void main() { expect(VideoPlayerPlatform.instance.setMixWithOthers(true), completes); expect(VideoPlayerPlatform.instance.setMixWithOthers(false), completes); }); + + testWidgets('video playback lifecycle', (WidgetTester tester) async { + final int videoPlayerId = await textureId; + final Stream eventStream = + VideoPlayerPlatform.instance.videoEventsFor(videoPlayerId); + + final Future> stream = eventStream.timeout( + const Duration(seconds: 1), + onTimeout: (EventSink sink) { + sink.close(); + }, + ).toList(); + + await VideoPlayerPlatform.instance.setVolume(videoPlayerId, 0); + await VideoPlayerPlatform.instance.play(videoPlayerId); + + // Let the video play, until we stop seeing events for a second + final List events = await stream; + + await VideoPlayerPlatform.instance.pause(videoPlayerId); + + // The expected list of event types should look like this: + // 1. bufferingStart, + // 2. bufferingUpdate (videoElement.onWaiting), + // 3. initialized (videoElement.onCanPlay), + // 4. bufferingEnd (videoElement.onCanPlayThrough), + expect( + events.map((VideoEvent e) => e.eventType), + equals([ + VideoEventType.bufferingStart, + VideoEventType.bufferingUpdate, + VideoEventType.initialized, + VideoEventType.bufferingEnd + ])); + }); }); } 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 new file mode 100644 index 000000000000..eda188cb1b9f --- /dev/null +++ b/packages/video_player/video_player_web/lib/src/video_player.dart @@ -0,0 +1,254 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:async'; +import 'dart:html' as html; + +import 'package:flutter/foundation.dart' show visibleForTesting; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:video_player_platform_interface/video_player_platform_interface.dart'; + +// An error code value to error name Map. +// See: https://developer.mozilla.org/en-US/docs/Web/API/MediaError/code +const Map _kErrorValueToErrorName = { + 1: 'MEDIA_ERR_ABORTED', + 2: 'MEDIA_ERR_NETWORK', + 3: 'MEDIA_ERR_DECODE', + 4: 'MEDIA_ERR_SRC_NOT_SUPPORTED', +}; + +// An error code value to description Map. +// See: https://developer.mozilla.org/en-US/docs/Web/API/MediaError/code +const Map _kErrorValueToErrorDescription = { + 1: 'The user canceled the fetching of the video.', + 2: 'A network error occurred while fetching the video, despite having previously been available.', + 3: 'An error occurred while trying to decode the video, despite having previously been determined to be usable.', + 4: 'The video has been found to be unsuitable (missing or in a format not supported by your browser).', +}; + +// The default error message, when the error is an empty string +// See: https://developer.mozilla.org/en-US/docs/Web/API/MediaError/message +const String _kDefaultErrorMessage = + 'No further diagnostic information can be determined or provided.'; + +/// Wraps a [html.VideoElement] so its API complies with what is expected by the plugin. +class VideoPlayer { + /// Create a [VideoPlayer] from a [html.VideoElement] instance. + VideoPlayer({ + required html.VideoElement videoElement, + @visibleForTesting StreamController? eventController, + }) : _videoElement = videoElement, + _eventController = eventController ?? StreamController(); + + final StreamController _eventController; + final html.VideoElement _videoElement; + + bool _isInitialized = false; + bool _isBuffering = false; + + /// Returns the [Stream] of [VideoEvent]s from the inner [html.VideoElement]. + Stream get events => _eventController.stream; + + /// Initializes the wrapped [html.VideoElement]. + /// + /// This method sets the required DOM attributes so videos can [play] programmatically, + /// and attaches listeners to the internal events from the [html.VideoElement] + /// to react to them / expose them through the [VideoPlayer.events] stream. + void initialize() { + _videoElement + ..autoplay = false + ..controls = false; + + // Allows Safari iOS to play the video inline + _videoElement.setAttribute('playsinline', 'true'); + + // Set autoplay to false since most browsers won't autoplay a video unless it is muted + _videoElement.setAttribute('autoplay', 'false'); + + _videoElement.onCanPlay.listen((dynamic _) { + if (!_isInitialized) { + _isInitialized = true; + _sendInitialized(); + } + }); + + _videoElement.onCanPlayThrough.listen((dynamic _) { + setBuffering(false); + }); + + _videoElement.onPlaying.listen((dynamic _) { + setBuffering(false); + }); + + _videoElement.onWaiting.listen((dynamic _) { + setBuffering(true); + _sendBufferingRangesUpdate(); + }); + + // The error event fires when some form of error occurs while attempting to load or perform the media. + _videoElement.onError.listen((html.Event _) { + setBuffering(false); + // The Event itself (_) doesn't contain info about the actual error. + // We need to look at the HTMLMediaElement.error. + // See: https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/error + final html.MediaError error = _videoElement.error!; + _eventController.addError(PlatformException( + code: _kErrorValueToErrorName[error.code]!, + message: error.message != '' ? error.message : _kDefaultErrorMessage, + details: _kErrorValueToErrorDescription[error.code], + )); + }); + + _videoElement.onEnded.listen((dynamic _) { + setBuffering(false); + _eventController.add(VideoEvent(eventType: VideoEventType.completed)); + }); + } + + /// Attempts to play the video. + /// + /// If this method is called programmatically (without user interaction), it + /// might fail unless the video is completely muted (or it has no Audio tracks). + /// + /// When called from some user interaction (a tap on a button), the above + /// limitation should disappear. + Future play() { + return _videoElement.play().catchError((Object e) { + // play() attempts to begin playback of the media. It returns + // a Promise which can get rejected in case of failure to begin + // playback for any reason, such as permission issues. + // The rejection handler is called with a DomException. + // See: https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/play + final html.DomException exception = e as html.DomException; + _eventController.addError(PlatformException( + code: exception.name, + message: exception.message, + )); + }, test: (Object e) => e is html.DomException); + } + + /// Pauses the video in the current position. + void pause() { + _videoElement.pause(); + } + + /// Controls whether the video should start again after it finishes. + void setLooping(bool value) { + _videoElement.loop = value; + } + + /// Sets the volume at which the media will be played. + /// + /// Values must fall between 0 and 1, where 0 is muted and 1 is the loudest. + /// + /// When volume is set to 0, the `muted` property is also applied to the + /// [html.VideoElement]. This is required for auto-play on the web. + void setVolume(double volume) { + assert(volume >= 0 && volume <= 1); + + // TODO(ditman): Do we need to expose a "muted" API? + // https://github.com/flutter/flutter/issues/60721 + _videoElement.muted = !(volume > 0.0); + _videoElement.volume = volume; + } + + /// Sets the playback `speed`. + /// + /// A `speed` of 1.0 is "normal speed," values lower than 1.0 make the media + /// play slower than normal, higher values make it play faster. + /// + /// `speed` cannot be negative. + /// + /// The audio is muted when the fast forward or slow motion is outside a useful + /// range (for example, Gecko mutes the sound outside the range 0.25 to 4.0). + /// + /// The pitch of the audio is corrected by default. + void setPlaybackSpeed(double speed) { + assert(speed > 0); + + _videoElement.playbackRate = speed; + } + + /// Moves the playback head to a new `position`. + /// + /// `position` cannot be negative. + void seekTo(Duration position) { + assert(!position.isNegative); + + _videoElement.currentTime = position.inMilliseconds.toDouble() / 1000; + } + + /// Returns the current playback head position as a [Duration]. + Duration getPosition() { + _sendBufferingRangesUpdate(); + return Duration(milliseconds: (_videoElement.currentTime * 1000).round()); + } + + /// Disposes of the current [html.VideoElement]. + void dispose() { + _videoElement.removeAttribute('src'); + _videoElement.load(); + } + + // Sends an [VideoEventType.initialized] [VideoEvent] with info about the wrapped video. + void _sendInitialized() { + final Duration? duration = !_videoElement.duration.isNaN + ? Duration( + milliseconds: (_videoElement.duration * 1000).round(), + ) + : null; + + final Size? size = !_videoElement.videoHeight.isNaN + ? Size( + _videoElement.videoWidth.toDouble(), + _videoElement.videoHeight.toDouble(), + ) + : null; + + _eventController.add( + VideoEvent( + eventType: VideoEventType.initialized, + duration: duration, + size: size, + ), + ); + } + + /// Caches the current "buffering" state of the video. + /// + /// If the current buffering state is different from the previous one + /// ([_isBuffering]), this dispatches a [VideoEvent]. + @visibleForTesting + void setBuffering(bool buffering) { + if (_isBuffering != buffering) { + _isBuffering = buffering; + _eventController.add(VideoEvent( + eventType: _isBuffering + ? VideoEventType.bufferingStart + : VideoEventType.bufferingEnd, + )); + } + } + + // Broadcasts the [html.VideoElement.buffered] status through the [events] stream. + void _sendBufferingRangesUpdate() { + _eventController.add(VideoEvent( + buffered: _toDurationRange(_videoElement.buffered), + eventType: VideoEventType.bufferingUpdate, + )); + } + + // Converts from [html.TimeRanges] to our own List. + List _toDurationRange(html.TimeRanges buffered) { + final List durationRange = []; + for (int i = 0; i < buffered.length; i++) { + durationRange.add(DurationRange( + Duration(milliseconds: (buffered.start(i) * 1000).round()), + Duration(milliseconds: (buffered.end(i) * 1000).round()), + )); + } + return durationRange; + } +} 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 a676850f3488..e52fd83de79e 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 @@ -6,34 +6,11 @@ import 'dart:async'; import 'dart:html'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:flutter_web_plugins/flutter_web_plugins.dart'; import 'package:video_player_platform_interface/video_player_platform_interface.dart'; import 'src/shims/dart_ui.dart' as ui; - -// An error code value to error name Map. -// See: https://developer.mozilla.org/en-US/docs/Web/API/MediaError/code -const Map _kErrorValueToErrorName = { - 1: 'MEDIA_ERR_ABORTED', - 2: 'MEDIA_ERR_NETWORK', - 3: 'MEDIA_ERR_DECODE', - 4: 'MEDIA_ERR_SRC_NOT_SUPPORTED', -}; - -// An error code value to description Map. -// See: https://developer.mozilla.org/en-US/docs/Web/API/MediaError/code -const Map _kErrorValueToErrorDescription = { - 1: 'The user canceled the fetching of the video.', - 2: 'A network error occurred while fetching the video, despite having previously been available.', - 3: 'An error occurred while trying to decode the video, despite having previously been determined to be usable.', - 4: 'The video has been found to be unsuitable (missing or in a format not supported by your browser).', -}; - -// The default error message, when the error is an empty string -// See: https://developer.mozilla.org/en-US/docs/Web/API/MediaError/message -const String _kDefaultErrorMessage = - 'No further diagnostic information can be determined or provided.'; +import 'src/video_player.dart'; /// The web implementation of [VideoPlayerPlatform]. /// @@ -44,8 +21,10 @@ class VideoPlayerPlugin extends VideoPlayerPlatform { VideoPlayerPlatform.instance = VideoPlayerPlugin(); } - final Map _videoPlayers = {}; + // Map of textureId -> VideoPlayer instances + final Map _videoPlayers = {}; + // Simulate the native "textureId". int _textureCounter = 1; @override @@ -55,13 +34,13 @@ class VideoPlayerPlugin extends VideoPlayerPlatform { @override Future dispose(int textureId) async { - _videoPlayers[textureId]!.dispose(); + _player(textureId).dispose(); _videoPlayers.remove(textureId); return; } void _disposeAllPlayers() { - for (final _VideoPlayer videoPlayer in _videoPlayers.values) { + for (final VideoPlayer videoPlayer in _videoPlayers.values) { videoPlayer.dispose(); } _videoPlayers.clear(); @@ -69,8 +48,7 @@ class VideoPlayerPlugin extends VideoPlayerPlatform { @override Future create(DataSource dataSource) async { - final int textureId = _textureCounter; - _textureCounter++; + final int textureId = _textureCounter++; late String uri; switch (dataSource.sourceType) { @@ -95,58 +73,69 @@ class VideoPlayerPlugin extends VideoPlayerPlatform { 'web implementation of video_player cannot play content uri')); } - final _VideoPlayer player = _VideoPlayer( - uri: uri, - textureId: textureId, - ); + final VideoElement videoElement = VideoElement() + ..id = 'videoElement-$textureId' + ..src = uri + ..style.border = 'none' + ..style.height = '100%' + ..style.width = '100%'; - player.initialize(); + // TODO(hterkelsen): Use initialization parameters once they are available + ui.platformViewRegistry.registerViewFactory( + 'videoPlayer-$textureId', (int viewId) => videoElement); + + final VideoPlayer player = VideoPlayer(videoElement: videoElement) + ..initialize(); _videoPlayers[textureId] = player; + return textureId; } @override Future setLooping(int textureId, bool looping) async { - return _videoPlayers[textureId]!.setLooping(looping); + return _player(textureId).setLooping(looping); } @override Future play(int textureId) async { - return _videoPlayers[textureId]!.play(); + return _player(textureId).play(); } @override Future pause(int textureId) async { - return _videoPlayers[textureId]!.pause(); + return _player(textureId).pause(); } @override Future setVolume(int textureId, double volume) async { - return _videoPlayers[textureId]!.setVolume(volume); + return _player(textureId).setVolume(volume); } @override Future setPlaybackSpeed(int textureId, double speed) async { - assert(speed > 0); - - return _videoPlayers[textureId]!.setPlaybackSpeed(speed); + return _player(textureId).setPlaybackSpeed(speed); } @override Future seekTo(int textureId, Duration position) async { - return _videoPlayers[textureId]!.seekTo(position); + return _player(textureId).seekTo(position); } @override Future getPosition(int textureId) async { - _videoPlayers[textureId]!.sendBufferingUpdate(); - return _videoPlayers[textureId]!.getPosition(); + return _player(textureId).getPosition(); } @override Stream videoEventsFor(int textureId) { - return _videoPlayers[textureId]!.eventController.stream; + return _player(textureId).events; + } + + // Retrieves a [VideoPlayer] by its internal `id`. + // It must have been created earlier from the [create] method. + VideoPlayer _player(int id) { + return _videoPlayers[id]!; } @override @@ -158,171 +147,3 @@ class VideoPlayerPlugin extends VideoPlayerPlatform { @override Future setMixWithOthers(bool mixWithOthers) => Future.value(); } - -class _VideoPlayer { - _VideoPlayer({required this.uri, required this.textureId}); - - final StreamController eventController = - StreamController(); - - final String uri; - final int textureId; - late VideoElement videoElement; - bool isInitialized = false; - bool isBuffering = false; - - void setBuffering(bool buffering) { - if (isBuffering != buffering) { - isBuffering = buffering; - eventController.add(VideoEvent( - eventType: isBuffering - ? VideoEventType.bufferingStart - : VideoEventType.bufferingEnd)); - } - } - - void initialize() { - videoElement = VideoElement() - ..src = uri - ..autoplay = false - ..controls = false - ..style.border = 'none' - ..style.height = '100%' - ..style.width = '100%'; - - // Allows Safari iOS to play the video inline - videoElement.setAttribute('playsinline', 'true'); - - // Set autoplay to false since most browsers won't autoplay a video unless it is muted - videoElement.setAttribute('autoplay', 'false'); - - // TODO(hterkelsen): Use initialization parameters once they are available - ui.platformViewRegistry.registerViewFactory( - 'videoPlayer-$textureId', (int viewId) => videoElement); - - videoElement.onCanPlay.listen((dynamic _) { - if (!isInitialized) { - isInitialized = true; - sendInitialized(); - } - setBuffering(false); - }); - - videoElement.onCanPlayThrough.listen((dynamic _) { - setBuffering(false); - }); - - videoElement.onPlaying.listen((dynamic _) { - setBuffering(false); - }); - - videoElement.onWaiting.listen((dynamic _) { - setBuffering(true); - sendBufferingUpdate(); - }); - - // The error event fires when some form of error occurs while attempting to load or perform the media. - videoElement.onError.listen((Event _) { - setBuffering(false); - // The Event itself (_) doesn't contain info about the actual error. - // We need to look at the HTMLMediaElement.error. - // See: https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/error - final MediaError error = videoElement.error!; - eventController.addError(PlatformException( - code: _kErrorValueToErrorName[error.code]!, - message: error.message != '' ? error.message : _kDefaultErrorMessage, - details: _kErrorValueToErrorDescription[error.code], - )); - }); - - videoElement.onEnded.listen((dynamic _) { - setBuffering(false); - eventController.add(VideoEvent(eventType: VideoEventType.completed)); - }); - } - - void sendBufferingUpdate() { - eventController.add(VideoEvent( - buffered: _toDurationRange(videoElement.buffered), - eventType: VideoEventType.bufferingUpdate, - )); - } - - Future play() { - return videoElement.play().catchError((Object e) { - // play() attempts to begin playback of the media. It returns - // a Promise which can get rejected in case of failure to begin - // playback for any reason, such as permission issues. - // The rejection handler is called with a DomException. - // See: https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/play - final DomException exception = e as DomException; - eventController.addError(PlatformException( - code: exception.name, - message: exception.message, - )); - }, test: (Object e) => e is DomException); - } - - void pause() { - videoElement.pause(); - } - - void setLooping(bool value) { - videoElement.loop = value; - } - - void setVolume(double value) { - // TODO(ditman): Do we need to expose a "muted" API? https://github.com/flutter/flutter/issues/60721 - if (value > 0.0) { - videoElement.muted = false; - } else { - videoElement.muted = true; - } - videoElement.volume = value; - } - - void setPlaybackSpeed(double speed) { - assert(speed > 0); - - videoElement.playbackRate = speed; - } - - void seekTo(Duration position) { - videoElement.currentTime = position.inMilliseconds.toDouble() / 1000; - } - - Duration getPosition() { - return Duration(milliseconds: (videoElement.currentTime * 1000).round()); - } - - void sendInitialized() { - eventController.add( - VideoEvent( - eventType: VideoEventType.initialized, - duration: Duration( - milliseconds: (videoElement.duration * 1000).round(), - ), - size: Size( - videoElement.videoWidth.toDouble(), - videoElement.videoHeight.toDouble(), - ), - ), - ); - } - - void dispose() { - videoElement.removeAttribute('src'); - videoElement.load(); - } - - List _toDurationRange(TimeRanges buffered) { - final List durationRange = []; - for (int i = 0; i < buffered.length; i++) { - durationRange.add(DurationRange( - Duration(milliseconds: (buffered.start(i) * 1000).round()), - Duration(milliseconds: (buffered.end(i) * 1000).round()), - )); - } - return durationRange; - } -} diff --git a/packages/video_player/video_player_web/pubspec.yaml b/packages/video_player/video_player_web/pubspec.yaml index 69a2df4e99e4..064517e1f264 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/plugins/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.0.7 +version: 2.0.8 environment: sdk: ">=2.12.0 <3.0.0"