diff --git a/.github/labeler.yml b/.github/labeler.yml
index 3e0a4af1d..3e56bda45 100644
--- a/.github/labeler.yml
+++ b/.github/labeler.yml
@@ -65,6 +65,8 @@
- packages/url_launcher/**/*
"p: video_player":
- packages/video_player/**/*
+"p: video_player_videohole":
+ - packages/video_player_videohole/**/*
"p: wakelock":
- packages/wakelock/**/*
"p: wearable_rotary":
diff --git a/.github/recipe.yaml b/.github/recipe.yaml
index fa4e20807..bae29be6d 100644
--- a/.github/recipe.yaml
+++ b/.github/recipe.yaml
@@ -35,6 +35,7 @@ plugins:
flutter_webrtc: []
geolocator: []
network_info_plus: []
+ video_player_videohole: []
# Only testable with the drive command: https://github.com/flutter-tizen/plugins/issues/272
tizen_app_control: []
diff --git a/README.md b/README.md
index c99cf8f18..1cd177833 100644
--- a/README.md
+++ b/README.md
@@ -47,6 +47,7 @@ The _"non-endorsed"_ status means that the plugin is not endorsed by the origina
| [**tizen_rpc_port**](packages/tizen_rpc_port) | (Tizen-only) | [](https://pub.dev/packages/tizen_rpc_port) | N/A |
| [**url_launcher_tizen**](packages/url_launcher) | [url_launcher](https://pub.dev/packages/url_launcher) (1st-party) | [](https://pub.dev/packages/url_launcher_tizen) | No |
| [**video_player_tizen**](packages/video_player) | [video_player](https://pub.dev/packages/video_player) (1st-party) | [](https://pub.dev/packages/video_player_tizen) | No |
+| [**video_player_videohole**](packages/video_player_videohole) | (Tizen-only) | [](https://pub.dev/packages/video_player_videohole) | N/A |
| [**wakelock_tizen**](packages/wakelock) | [wakelock](https://pub.dev/packages/wakelock) (3rd-party) | [](https://pub.dev/packages/wakelock_tizen) | No |
| [**wearable_rotary**](packages/wearable_rotary) | (Tizen-only) | [](https://pub.dev/packages/wearable_rotary) | N/A |
| [**webview_flutter_lwe**](packages/webview_flutter_lwe) | [webview_flutter](https://pub.dev/packages/webview_flutter) (1st-party) | [](https://pub.dev/packages/webview_flutter_lwe) | No |
@@ -56,7 +57,7 @@ The _"non-endorsed"_ status means that the plugin is not endorsed by the origina
| Package name | API level | Watch | Watch emulator | TV | TV emulator | Remarks |
|-|:-:|:-:|:-:|:-:|:-:|-|
-| [**audioplayers_tizen**](packages/audioplayers) | 4.0 | ✔️ | ✔️ | ⚠️ | ⚠️ | Functional limitations |
+| [**audioplayers_tizen**](packages/audioplayers) | 4.0 | ✔️ | ✔️ | ✔️ | ✔️ |
| [**battery_plus_tizen**](packages/battery_plus) | 4.0 | ✔️ | ✔️ | ❌ | ❌ | No battery |
| [**camera_tizen**](packages/camera) | 4.0 | ❌ | ❌ | ❌ | ❌ | No camera |
| [**connectivity_plus_tizen**](packages/connectivity_plus) | 4.0 | ✔️ | ⚠️ | ✔️ | ✔️ | Returns incorrect connection status |
@@ -88,9 +89,9 @@ The _"non-endorsed"_ status means that the plugin is not endorsed by the origina
| [**tizen_package_manager**](packages/tizen_package_manager) | 4.0 | ✔️ | ✔️ | ✔️ | ✔️ |
| [**tizen_rpc_port**](packages/tizen_rpc_port) | 6.5 | ✔️ | ✔️ | ✔️ | ✔️ |
| [**url_launcher_tizen**](packages/url_launcher) | 4.0 | ✔️ | ❌ | ✔️ | ❌ | No browser app |
-| [**video_player_tizen**](packages/video_player) | 4.0 | ✔️ | ✔️ | ⚠️ | ❌ | Functional limitations, TV emulator issue |
+| [**video_player_tizen**](packages/video_player) | 4.0 | ✔️ | ✔️ | ✔️ | ❌ | TV emulator issue |
+| [**video_player_videohole**](packages/video_player_videohole) | 4.0 | ❌ | ❌ | ✔️ | ❌ | Only for TV devices |
| [**wakelock_tizen**](packages/wakelock) | 4.0 | ✔️ | ✔️ | ❌ | ❌ | Cannot override system settings |
| [**wearable_rotary**](packages/wearable_rotary) | 4.0 | ✔️ | ✔️ | ❌ | ❌ | Not applicable for TV |
| [**webview_flutter_lwe**](packages/webview_flutter_lwe) | 5.5 | ✔️ | ✔️ | ✔️ | ✔️ | Not for production use |
| [**webview_flutter_tizen**](packages/webview_flutter) | 5.5 | ❌ | ❌ | ✔️ | ✔️ | API not supported |
-
diff --git a/packages/video_player_videohole/.gitignore b/packages/video_player_videohole/.gitignore
new file mode 100644
index 000000000..e9dc58d3d
--- /dev/null
+++ b/packages/video_player_videohole/.gitignore
@@ -0,0 +1,7 @@
+.DS_Store
+.dart_tool/
+
+.packages
+.pub/
+
+build/
diff --git a/packages/video_player_videohole/CHANGELOG.md b/packages/video_player_videohole/CHANGELOG.md
new file mode 100644
index 000000000..607323422
--- /dev/null
+++ b/packages/video_player_videohole/CHANGELOG.md
@@ -0,0 +1,3 @@
+## 0.1.0
+
+* Initial release.
diff --git a/packages/video_player_videohole/LICENSE b/packages/video_player_videohole/LICENSE
new file mode 100644
index 000000000..036fb575e
--- /dev/null
+++ b/packages/video_player_videohole/LICENSE
@@ -0,0 +1,26 @@
+Copyright (c) 2023 Samsung Electronics Co., Ltd. All rights reserved.
+Copyright (c) 2013 The Flutter Authors. All rights reserved.
+
+Redistribution and use in source and binary forms, with or without modification,
+are permitted provided that the following conditions are met:
+
+ * Redistributions of source code must retain the above copyright
+ notice, this list of conditions and the following disclaimer.
+ * Redistributions in binary form must reproduce the above
+ copyright notice, this list of conditions and the following
+ disclaimer in the documentation and/or other materials provided
+ with the distribution.
+ * Neither the names of the copyright holders nor the names of the
+ contributors may be used to endorse or promote products derived
+ from this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
+ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
+ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
+ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
diff --git a/packages/video_player_videohole/README.md b/packages/video_player_videohole/README.md
new file mode 100644
index 000000000..b123ec123
--- /dev/null
+++ b/packages/video_player_videohole/README.md
@@ -0,0 +1,124 @@
+# video_player_videohole
+
+[](https://pub.dev/packages/video_player_videohole)
+
+A fork of the [`video_player`](https://pub.dev/packages/video_player) plugin to support playback of DRM streams (Widevine and PlayReady) on Tizen TV devices.
+
+This plugin is only supported on Tizen TV devices. If you are targeting other device types or don't plan to play DRM content in your app, use [`video_player`](https://pub.dev/packages/video_player) and [`video_player_tizen`](https://pub.dev/packages/video_player_tizen) instead.
+
+## Usage
+
+To use this package, add `video_player_videohole` as a dependency in your `pubspec.yaml` file.
+
+```yaml
+dependencies:
+ video_player_videohole: ^0.1.0
+```
+
+Then you can import `video_player_videohole` in your Dart code:
+
+```dart
+import 'package:video_player_videohole/video_player.dart';
+```
+
+Note that `video_player_videohole` is not compatible with the original `video_player` plugin. If you're writing a cross-platform app for Tizen and other platforms, it is recommended to create two separate source files and import `video_player` and `video_player_videohole` in the files respectively.
+
+### Example
+
+```dart
+import 'package:flutter/material.dart';
+import 'package:video_player_videohole/video_player.dart';
+
+class RemoteVideo extends StatefulWidget {
+ const RemoteVideo({Key? key}) : super(key: key);
+
+ @override
+ State createState() => _RemoteVideoState();
+}
+
+class _RemoteVideoState extends State {
+ late VideoPlayerController _controller;
+
+ @override
+ void initState() {
+ super.initState();
+ _controller = VideoPlayerController.network(
+ 'https://media.w3.org/2010/05/bunny/trailer.mp4',
+ drmConfigs: const DrmConfigs(
+ type: DrmType.playready,
+ licenseServerUrl:
+ 'http://test.playready.microsoft.com/service/rightsmanager.asmx',
+ ),
+ );
+ _controller.addListener(() => setState(() {}));
+ _controller.initialize().then((_) => setState(() {}));
+ }
+
+ @override
+ void dispose() {
+ _controller.dispose();
+ super.dispose();
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return Center(
+ child: AspectRatio(
+ aspectRatio: _controller.value.aspectRatio,
+ child: Stack(
+ alignment: Alignment.bottomCenter,
+ children: [
+ VideoPlayer(_controller),
+ ClosedCaption(text: _controller.value.caption.text),
+ GestureDetector(
+ onTap: () {
+ _controller.value.isPlaying
+ ? _controller.pause()
+ : _controller.play();
+ },
+ ),
+ VideoProgressIndicator(_controller, allowScrubbing: true),
+ ],
+ ),
+ ),
+ );
+ }
+}
+```
+
+## Required privileges
+
+To use this plugin, you may need to declare the following privileges in your `tizen-manifest.xml` file.
+
+```xml
+
+ http://tizen.org/privilege/mediastorage
+ http://tizen.org/privilege/externalstorage
+ http://tizen.org/privilege/internet
+ http://developer.samsung.com/privilege/drmplay
+
+```
+
+- The mediastorage privilege (`http://tizen.org/privilege/mediastorage`) is required to play video files located in the internal storage.
+- The externalstorage privilege (`http://tizen.org/privilege/externalstorage`) is required to play video files located in the external storage.
+- The internet privilege (`http://tizen.org/privilege/internet`) is required to play any URL from the network.
+- The drmplay privilege (`http://developer.samsung.com/privilege/drmplay`) is required to play DRM content. The app must be signed with a [partner-level certificate](https://docs.tizen.org/application/dotnet/get-started/certificates/creating-certificates) to use this privilege.
+
+For detailed information on Tizen privileges, see [Tizen Docs: API Privileges](https://docs.tizen.org/application/dotnet/get-started/api-privileges).
+
+## Limitations
+
+This plugin is not supported on TV emulators.
+
+The following options are not currently supported.
+
+- The `httpHeaders` option of `VideoPlayerController.network`
+- `VideoPlayerOptions.allowBackgroundPlayback`
+- `VideoPlayerOptions.mixWithOthers`
+
+This plugin has the following limitations.
+
+- The `setPlaybackSpeed` method will fail if triggered within the last 3 seconds of the video.
+- The playback speed will reset to 1.0 when the video is replayed in loop mode.
+- The `seekTo` method works only when the playback speed is 1.0, and it sets the video position to the nearest keyframe, not the exact value passed.
+- Dash sidecar subtitles are only supported on Tizen 7.0 and later.
diff --git a/packages/video_player_videohole/example/.gitignore b/packages/video_player_videohole/example/.gitignore
new file mode 100644
index 000000000..1adcc2fb3
--- /dev/null
+++ b/packages/video_player_videohole/example/.gitignore
@@ -0,0 +1,44 @@
+# Miscellaneous
+*.class
+*.log
+*.pyc
+*.swp
+.DS_Store
+.atom/
+.buildlog/
+.history
+.svn/
+
+# IntelliJ related
+*.iml
+*.ipr
+*.iws
+.idea/
+
+# The .vscode folder contains launch configuration and tasks you configure in
+# VS Code which you may wish to be included in version control, so this line
+# is commented out by default.
+#.vscode/
+
+# Flutter/Dart/Pub related
+**/doc/api/
+**/ios/Flutter/.last_build_id
+.dart_tool/
+.flutter-plugins
+.flutter-plugins-dependencies
+.packages
+.pub-cache/
+.pub/
+/build/
+
+# Web related
+lib/generated_plugin_registrant.dart
+
+# Symbolication related
+app.*.symbols
+
+# Obfuscation related
+app.*.map.json
+
+#vscode
+/.vscode/
diff --git a/packages/video_player_videohole/example/README.md b/packages/video_player_videohole/example/README.md
new file mode 100644
index 000000000..524e2e207
--- /dev/null
+++ b/packages/video_player_videohole/example/README.md
@@ -0,0 +1,7 @@
+# video_player_videohole_example
+
+Demonstrates how to use the video_player_videohole plugin.
+
+## Getting Started
+
+To run this app on your Tizen device, use [flutter-tizen](https://github.com/flutter-tizen/flutter-tizen).
diff --git a/packages/video_player_videohole/example/assets/Audio.mp3 b/packages/video_player_videohole/example/assets/Audio.mp3
new file mode 100644
index 000000000..355eb9b2e
Binary files /dev/null and b/packages/video_player_videohole/example/assets/Audio.mp3 differ
diff --git a/packages/video_player_videohole/example/assets/Butterfly-209.mp4 b/packages/video_player_videohole/example/assets/Butterfly-209.mp4
new file mode 100644
index 000000000..c8489799f
Binary files /dev/null and b/packages/video_player_videohole/example/assets/Butterfly-209.mp4 differ
diff --git a/packages/video_player_videohole/example/integration_test/video_player_test.dart b/packages/video_player_videohole/example/integration_test/video_player_test.dart
new file mode 100644
index 000000000..cf2fcd259
--- /dev/null
+++ b/packages/video_player_videohole/example/integration_test/video_player_test.dart
@@ -0,0 +1,333 @@
+// 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:io';
+
+import 'package:flutter/foundation.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter/services.dart' show rootBundle;
+import 'package:flutter_test/flutter_test.dart';
+import 'package:integration_test/integration_test.dart';
+import 'package:path_provider/path_provider.dart';
+import 'package:video_player_videohole/video_player.dart';
+
+const Duration _playDuration = Duration(seconds: 1);
+
+// Use WebM for web to allow CI to use Chromium.
+const String _videoAssetKey =
+ kIsWeb ? 'assets/Butterfly-209.webm' : 'assets/Butterfly-209.mp4';
+
+// Returns the URL to load an asset from this example app as a network source.
+String getUrlForAssetAsNetworkSource(String assetKey) {
+ return 'https://github.com/flutter/plugins/blob/'
+ // This hash can be rolled forward to pick up newly-added assets.
+ 'cba393233e559c925a4daf71b06b4bb01c606762'
+ '/packages/video_player/video_player/example/'
+ '$assetKey'
+ '?raw=true';
+}
+
+void main() {
+ IntegrationTestWidgetsFlutterBinding.ensureInitialized();
+ late VideoPlayerController controller;
+ tearDown(() async => controller.dispose());
+
+ group('asset videos', () {
+ setUp(() {
+ controller = VideoPlayerController.asset(_videoAssetKey);
+ });
+
+ testWidgets('can be initialized', (WidgetTester tester) async {
+ await controller.initialize();
+
+ expect(controller.value.isInitialized, true);
+ expect(controller.value.position, Duration.zero);
+ expect(controller.value.isPlaying, false);
+ // The WebM version has a slightly different duration than the MP4.
+ expect(controller.value.duration,
+ const Duration(seconds: 7, milliseconds: kIsWeb ? 544 : 540));
+ });
+
+ testWidgets(
+ 'live stream duration != 0',
+ (WidgetTester tester) async {
+ final VideoPlayerController networkController =
+ VideoPlayerController.network(
+ 'https://flutter.github.io/assets-for-api-docs/assets/videos/hls/bee.m3u8',
+ );
+ await networkController.initialize();
+
+ expect(networkController.value.isInitialized, true);
+ // Live streams should have either a positive duration or C.TIME_UNSET if the duration is unknown
+ // See https://exoplayer.dev/doc/reference/com/google/android/exoplayer2/Player.html#getDuration--
+ expect(networkController.value.duration,
+ (Duration duration) => duration != Duration.zero);
+ },
+ skip: kIsWeb,
+ );
+
+ testWidgets(
+ 'can be played',
+ (WidgetTester tester) async {
+ await controller.initialize();
+ // Mute to allow playing without DOM interaction on Web.
+ // See https://developers.google.com/web/updates/2017/09/autoplay-policy-changes
+ await controller.setVolume(0);
+
+ await controller.play();
+ await tester.pumpAndSettle(_playDuration);
+
+ expect(controller.value.isPlaying, true);
+ expect(controller.value.position,
+ (Duration position) => position > Duration.zero);
+ },
+ );
+
+ testWidgets(
+ 'can seek',
+ (WidgetTester tester) async {
+ await controller.initialize();
+
+ await controller.seekTo(const Duration(seconds: 3));
+
+ expect(controller.value.position, const Duration(seconds: 3));
+ },
+ );
+
+ testWidgets(
+ 'can be paused',
+ (WidgetTester tester) async {
+ await controller.initialize();
+ // Mute to allow playing without DOM interaction on Web.
+ // See https://developers.google.com/web/updates/2017/09/autoplay-policy-changes
+ await controller.setVolume(0);
+
+ // Play for a second, then pause, and then wait a second.
+ await controller.play();
+ await tester.pumpAndSettle(_playDuration);
+ await controller.pause();
+ final Duration pausedPosition = controller.value.position;
+ await tester.pumpAndSettle(_playDuration);
+
+ // Verify that we stopped playing after the pause.
+ expect(controller.value.isPlaying, false);
+ expect(controller.value.position, pausedPosition);
+ },
+ );
+
+ testWidgets(
+ 'stay paused when seeking after video completed',
+ (WidgetTester tester) async {
+ await controller.initialize();
+ // Mute to allow playing without DOM interaction on Web.
+ // See https://developers.google.com/web/updates/2017/09/autoplay-policy-changes
+ await controller.setVolume(0);
+ final Duration timeBeforeEnd =
+ controller.value.duration - const Duration(milliseconds: 500);
+ await controller.seekTo(timeBeforeEnd);
+ await controller.play();
+ await tester.pumpAndSettle(_playDuration);
+ expect(controller.value.isPlaying, false);
+ expect(controller.value.position, controller.value.duration);
+
+ await controller.seekTo(timeBeforeEnd);
+ await tester.pumpAndSettle(_playDuration);
+
+ expect(controller.value.isPlaying, false);
+ expect(controller.value.position, timeBeforeEnd);
+ },
+ );
+
+ testWidgets(
+ 'do not exceed duration on play after video completed',
+ (WidgetTester tester) async {
+ await controller.initialize();
+ // Mute to allow playing without DOM interaction on Web.
+ // See https://developers.google.com/web/updates/2017/09/autoplay-policy-changes
+ await controller.setVolume(0);
+ await controller.seekTo(
+ controller.value.duration - const Duration(milliseconds: 500));
+ await controller.play();
+ await tester.pumpAndSettle(_playDuration);
+ expect(controller.value.isPlaying, false);
+ expect(controller.value.position, controller.value.duration);
+
+ await controller.play();
+ await tester.pumpAndSettle(_playDuration);
+
+ expect(controller.value.position,
+ lessThanOrEqualTo(controller.value.duration));
+ },
+ );
+
+ testWidgets('test video player view with local asset',
+ (WidgetTester tester) async {
+ Future started() async {
+ await controller.initialize();
+ await controller.play();
+ return true;
+ }
+
+ await tester.pumpWidget(Material(
+ child: Directionality(
+ textDirection: TextDirection.ltr,
+ child: Center(
+ child: FutureBuilder(
+ future: started(),
+ builder: (BuildContext context, AsyncSnapshot snapshot) {
+ if (snapshot.data ?? false) {
+ return AspectRatio(
+ aspectRatio: controller.value.aspectRatio,
+ child: VideoPlayer(controller),
+ );
+ } else {
+ return const Text('waiting for video to load');
+ }
+ },
+ ),
+ ),
+ ),
+ ));
+
+ await tester.pumpAndSettle();
+ expect(controller.value.isPlaying, true);
+ },
+ skip: kIsWeb || // Web does not support local assets.
+ // Extremely flaky on iOS: https://github.com/flutter/flutter/issues/86915
+ defaultTargetPlatform == TargetPlatform.iOS);
+ });
+
+ group('file-based videos', () {
+ setUp(() async {
+ // Load the data from the asset.
+ final String tempDir = (await getTemporaryDirectory()).path;
+ final ByteData bytes = await rootBundle.load(_videoAssetKey);
+
+ // Write it to a file to use as a source.
+ final String filename = _videoAssetKey.split('/').last;
+ final File file = File('$tempDir/$filename');
+ await file.writeAsBytes(bytes.buffer.asInt8List());
+
+ controller = VideoPlayerController.file(file);
+ });
+
+ testWidgets('test video player using static file() method as constructor',
+ (WidgetTester tester) async {
+ await controller.initialize();
+
+ await controller.play();
+ expect(controller.value.isPlaying, true);
+
+ await controller.pause();
+ expect(controller.value.isPlaying, false);
+ }, skip: kIsWeb);
+ });
+
+ group('network videos', () {
+ setUp(() {
+ controller = VideoPlayerController.network(
+ getUrlForAssetAsNetworkSource(_videoAssetKey));
+ });
+
+ testWidgets(
+ 'reports buffering status',
+ (WidgetTester tester) async {
+ await controller.initialize();
+ // Mute to allow playing without DOM interaction on Web.
+ // See https://developers.google.com/web/updates/2017/09/autoplay-policy-changes
+ await controller.setVolume(0);
+ final Completer started = Completer();
+ final Completer ended = Completer();
+ controller.addListener(() {
+ if (!started.isCompleted && controller.value.isBuffering) {
+ started.complete();
+ }
+ if (started.isCompleted &&
+ !controller.value.isBuffering &&
+ !ended.isCompleted) {
+ ended.complete();
+ }
+ });
+
+ await controller.play();
+ await controller.seekTo(const Duration(seconds: 5));
+ await tester.pumpAndSettle(_playDuration);
+ await controller.pause();
+
+ expect(controller.value.isPlaying, false);
+ expect(controller.value.position,
+ (Duration position) => position > Duration.zero);
+
+ await expectLater(started.future, completes);
+ await expectLater(ended.future, completes);
+ },
+ skip: !(kIsWeb || defaultTargetPlatform == TargetPlatform.android),
+ );
+ });
+
+ // Audio playback is tested to prevent accidental regression,
+ // but could be removed in the future.
+ group('asset audios', () {
+ setUp(() {
+ controller = VideoPlayerController.asset('assets/Audio.mp3');
+ });
+
+ testWidgets('can be initialized', (WidgetTester tester) async {
+ await controller.initialize();
+
+ expect(controller.value.isInitialized, true);
+ expect(controller.value.position, Duration.zero);
+ expect(controller.value.isPlaying, false);
+ // Due to the duration calculation accurancy between platforms,
+ // the milliseconds on Web will be a slightly different from natives.
+ // The audio was made with 44100 Hz, 192 Kbps CBR, and 32 bits.
+ expect(
+ controller.value.duration,
+ const Duration(seconds: 5, milliseconds: kIsWeb ? 42 : 41),
+ );
+ });
+
+ testWidgets('can be played', (WidgetTester tester) async {
+ await controller.initialize();
+ // Mute to allow playing without DOM interaction on Web.
+ // See https://developers.google.com/web/updates/2017/09/autoplay-policy-changes
+ await controller.setVolume(0);
+
+ await controller.play();
+ await tester.pumpAndSettle(_playDuration);
+
+ expect(controller.value.isPlaying, true);
+ expect(
+ controller.value.position,
+ (Duration position) => position > Duration.zero,
+ );
+ });
+
+ testWidgets('can seek', (WidgetTester tester) async {
+ await controller.initialize();
+ await controller.seekTo(const Duration(seconds: 3));
+
+ expect(controller.value.position, const Duration(seconds: 3));
+ });
+
+ testWidgets('can be paused', (WidgetTester tester) async {
+ await controller.initialize();
+ // Mute to allow playing without DOM interaction on Web.
+ // See https://developers.google.com/web/updates/2017/09/autoplay-policy-changes
+ await controller.setVolume(0);
+
+ // Play for a second, then pause, and then wait a second.
+ await controller.play();
+ await tester.pumpAndSettle(_playDuration);
+ await controller.pause();
+ final Duration pausedPosition = controller.value.position;
+ await tester.pumpAndSettle(_playDuration);
+
+ // Verify that we stopped playing after the pause.
+ expect(controller.value.isPlaying, false);
+ expect(controller.value.position, pausedPosition);
+ });
+ });
+}
diff --git a/packages/video_player_videohole/example/lib/main.dart b/packages/video_player_videohole/example/lib/main.dart
new file mode 100644
index 000000000..48c775374
--- /dev/null
+++ b/packages/video_player_videohole/example/lib/main.dart
@@ -0,0 +1,488 @@
+// 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.
+
+// ignore_for_file: public_member_api_docs, avoid_print
+
+/// An example of using the plugin, controlling lifecycle and playback of the
+/// video.
+
+import 'package:flutter/material.dart';
+import 'package:flutter/services.dart';
+import 'package:http/http.dart' as http;
+import 'package:video_player_videohole/video_player.dart';
+
+void main() {
+ runApp(
+ MaterialApp(
+ home: _App(),
+ ),
+ );
+}
+
+class _App extends StatelessWidget {
+ @override
+ Widget build(BuildContext context) {
+ return DefaultTabController(
+ length: 5,
+ child: Scaffold(
+ key: const ValueKey('home_page'),
+ appBar: AppBar(
+ title: const Text('Video player example'),
+ bottom: const TabBar(
+ isScrollable: true,
+ tabs: [
+ Tab(icon: Icon(Icons.cloud), text: 'MP4'),
+ Tab(icon: Icon(Icons.cloud), text: 'HLS'),
+ Tab(icon: Icon(Icons.cloud), text: 'Dash'),
+ Tab(icon: Icon(Icons.cloud), text: 'DRM Widevine'),
+ Tab(icon: Icon(Icons.cloud), text: 'DRM PlayReady'),
+ ],
+ ),
+ ),
+ body: TabBarView(
+ children: [
+ _Mp4RemoteVideo(),
+ _HlsRomoteVideo(),
+ _DashRomoteVideo(),
+ _DrmRemoteVideo(),
+ _DrmRemoteVideo2(),
+ ],
+ ),
+ ),
+ );
+ }
+}
+
+class _HlsRomoteVideo extends StatefulWidget {
+ @override
+ State<_HlsRomoteVideo> createState() => _HlsRomoteVideoState();
+}
+
+class _HlsRomoteVideoState extends State<_HlsRomoteVideo> {
+ late VideoPlayerController _controller;
+
+ @override
+ void initState() {
+ super.initState();
+ _controller = VideoPlayerController.network(
+ 'https://bitdash-a.akamaihd.net/content/sintel/hls/playlist.m3u8');
+
+ _controller.addListener(() {
+ if (_controller.value.hasError) {
+ print(_controller.value.errorDescription);
+ }
+ setState(() {});
+ });
+ _controller.setLooping(true);
+ _controller.initialize().then((_) => setState(() {}));
+ _controller.play();
+ }
+
+ @override
+ void dispose() {
+ _controller.dispose();
+ super.dispose();
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return SingleChildScrollView(
+ child: Column(
+ children: [
+ Container(
+ padding: const EdgeInsets.only(top: 20.0),
+ ),
+ const Text('With Hls'),
+ Container(
+ padding: const EdgeInsets.all(20),
+ child: AspectRatio(
+ aspectRatio: _controller.value.aspectRatio,
+ child: Stack(
+ alignment: Alignment.bottomCenter,
+ children: [
+ VideoPlayer(_controller),
+ ClosedCaption(text: _controller.value.caption.text),
+ _ControlsOverlay(controller: _controller),
+ VideoProgressIndicator(_controller, allowScrubbing: true),
+ ],
+ ),
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+}
+
+class _DashRomoteVideo extends StatefulWidget {
+ @override
+ State<_DashRomoteVideo> createState() => _DashRomoteVideoState();
+}
+
+class _DashRomoteVideoState extends State<_DashRomoteVideo> {
+ late VideoPlayerController _controller;
+
+ @override
+ void initState() {
+ super.initState();
+ _controller = VideoPlayerController.network(
+ 'https://dash.akamaized.net/dash264/TestCasesUHD/2b/11/MultiRate.mpd');
+
+ _controller.addListener(() {
+ if (_controller.value.hasError) {
+ print(_controller.value.errorDescription);
+ }
+ setState(() {});
+ });
+ _controller.setLooping(true);
+ _controller.initialize().then((_) => setState(() {}));
+ _controller.play();
+ }
+
+ @override
+ void dispose() {
+ _controller.dispose();
+ super.dispose();
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return SingleChildScrollView(
+ child: Column(
+ children: [
+ Container(padding: const EdgeInsets.only(top: 20.0)),
+ const Text('With Dash'),
+ Container(
+ padding: const EdgeInsets.all(20),
+ child: AspectRatio(
+ aspectRatio: _controller.value.aspectRatio,
+ child: Stack(
+ alignment: Alignment.bottomCenter,
+ children: [
+ VideoPlayer(_controller),
+ ClosedCaption(text: _controller.value.caption.text),
+ _ControlsOverlay(controller: _controller),
+ VideoProgressIndicator(_controller, allowScrubbing: true),
+ ],
+ ),
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+}
+
+class _Mp4RemoteVideo extends StatefulWidget {
+ @override
+ State<_Mp4RemoteVideo> createState() => _Mp4RemoteVideoState();
+}
+
+class _Mp4RemoteVideoState extends State<_Mp4RemoteVideo> {
+ late VideoPlayerController _controller;
+
+ @override
+ void initState() {
+ super.initState();
+ _controller = VideoPlayerController.network(
+ 'https://media.w3.org/2010/05/bunny/trailer.mp4');
+
+ _controller.addListener(() {
+ if (_controller.value.hasError) {
+ print(_controller.value.errorDescription);
+ }
+ setState(() {});
+ });
+ _controller.setLooping(true);
+ _controller.initialize().then((_) => setState(() {}));
+ _controller.play();
+ }
+
+ @override
+ void dispose() {
+ _controller.dispose();
+ super.dispose();
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return SingleChildScrollView(
+ child: Column(
+ children: [
+ Container(padding: const EdgeInsets.only(top: 20.0)),
+ const Text('With remote mp4'),
+ Container(
+ padding: const EdgeInsets.all(20),
+ child: AspectRatio(
+ aspectRatio: _controller.value.aspectRatio,
+ child: Stack(
+ alignment: Alignment.bottomCenter,
+ children: [
+ VideoPlayer(_controller),
+ ClosedCaption(text: _controller.value.caption.text),
+ _ControlsOverlay(controller: _controller),
+ VideoProgressIndicator(_controller, allowScrubbing: true),
+ ],
+ ),
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+}
+
+class _DrmRemoteVideo extends StatefulWidget {
+ @override
+ State<_DrmRemoteVideo> createState() => _DrmRemoteVideoState();
+}
+
+class _DrmRemoteVideoState extends State<_DrmRemoteVideo> {
+ late VideoPlayerController _controller;
+
+ @override
+ void initState() {
+ super.initState();
+
+ _controller = VideoPlayerController.network(
+ 'https://storage.googleapis.com/wvmedia/cenc/hevc/tears/tears.mpd',
+ drmConfigs: DrmConfigs(
+ type: DrmType.widevine,
+ licenseCallback: (Uint8List challenge) async {
+ final http.Response response = await http.post(
+ Uri.parse('https://proxy.uat.widevine.com/proxy'),
+ body: challenge,
+ );
+ return response.bodyBytes;
+ },
+ ),
+ );
+
+ _controller.addListener(() {
+ if (_controller.value.hasError) {
+ print(_controller.value.errorDescription);
+ }
+ setState(() {});
+ });
+ _controller.setLooping(true);
+ _controller.initialize().then((_) => setState(() {}));
+ _controller.play();
+ }
+
+ @override
+ void dispose() {
+ _controller.dispose();
+ super.dispose();
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return SingleChildScrollView(
+ child: Column(
+ children: [
+ Container(padding: const EdgeInsets.only(top: 20.0)),
+ const Text('Play DRM Widevine'),
+ Container(
+ padding: const EdgeInsets.all(20),
+ child: AspectRatio(
+ aspectRatio: _controller.value.aspectRatio,
+ child: Stack(
+ alignment: Alignment.bottomCenter,
+ children: [
+ VideoPlayer(_controller),
+ ClosedCaption(text: _controller.value.caption.text),
+ _ControlsOverlay(controller: _controller),
+ VideoProgressIndicator(_controller, allowScrubbing: true),
+ ],
+ ),
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+}
+
+class _DrmRemoteVideo2 extends StatefulWidget {
+ @override
+ State<_DrmRemoteVideo2> createState() => _DrmRemoteVideoState2();
+}
+
+class _DrmRemoteVideoState2 extends State<_DrmRemoteVideo2> {
+ late VideoPlayerController _controller;
+
+ @override
+ void initState() {
+ super.initState();
+
+ _controller = VideoPlayerController.network(
+ 'https://test.playready.microsoft.com/smoothstreaming/SSWSS720H264PR/SuperSpeedway_720.ism/Manifest',
+ drmConfigs: const DrmConfigs(
+ type: DrmType.playready,
+ licenseServerUrl:
+ 'http://test.playready.microsoft.com/service/rightsmanager.asmx',
+ ),
+ );
+
+ _controller.addListener(() {
+ if (_controller.value.hasError) {
+ print(_controller.value.errorDescription);
+ }
+ setState(() {});
+ });
+ _controller.setLooping(true);
+ _controller.initialize().then((_) => setState(() {}));
+ _controller.play();
+ }
+
+ @override
+ void dispose() {
+ _controller.dispose();
+ super.dispose();
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return SingleChildScrollView(
+ child: Column(
+ children: [
+ Container(padding: const EdgeInsets.only(top: 20.0)),
+ const Text('Play DRM PlayReady'),
+ Container(
+ padding: const EdgeInsets.all(20),
+ child: AspectRatio(
+ aspectRatio: _controller.value.aspectRatio,
+ child: Stack(
+ alignment: Alignment.bottomCenter,
+ children: [
+ VideoPlayer(_controller),
+ ClosedCaption(text: _controller.value.caption.text),
+ _ControlsOverlay(controller: _controller),
+ VideoProgressIndicator(_controller, allowScrubbing: true),
+ ],
+ ),
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+}
+
+class _ControlsOverlay extends StatelessWidget {
+ const _ControlsOverlay({Key? key, required this.controller})
+ : super(key: key);
+
+ static const List _exampleCaptionOffsets = [
+ Duration(seconds: -10),
+ Duration(seconds: -3),
+ Duration(seconds: -1, milliseconds: -500),
+ Duration(milliseconds: -250),
+ Duration.zero,
+ Duration(milliseconds: 250),
+ Duration(seconds: 1, milliseconds: 500),
+ Duration(seconds: 3),
+ Duration(seconds: 10),
+ ];
+ static const List _examplePlaybackRates = [
+ 0.25,
+ 0.5,
+ 1.0,
+ 1.5,
+ 2.0,
+ 3.0,
+ 5.0,
+ 10.0,
+ ];
+
+ final VideoPlayerController controller;
+
+ @override
+ Widget build(BuildContext context) {
+ return Stack(
+ children: [
+ AnimatedSwitcher(
+ duration: const Duration(milliseconds: 50),
+ reverseDuration: const Duration(milliseconds: 200),
+ child: controller.value.isPlaying
+ ? const SizedBox.shrink()
+ : Container(
+ color: Colors.black26,
+ child: const Center(
+ child: Icon(
+ Icons.play_arrow,
+ color: Colors.white,
+ size: 100.0,
+ semanticLabel: 'Play',
+ ),
+ ),
+ ),
+ ),
+ GestureDetector(
+ onTap: () {
+ controller.value.isPlaying ? controller.pause() : controller.play();
+ },
+ ),
+ Align(
+ alignment: Alignment.topLeft,
+ child: PopupMenuButton(
+ initialValue: controller.value.captionOffset,
+ tooltip: 'Caption Offset',
+ onSelected: (Duration delay) {
+ controller.setCaptionOffset(delay);
+ },
+ itemBuilder: (BuildContext context) {
+ return >[
+ for (final Duration offsetDuration in _exampleCaptionOffsets)
+ PopupMenuItem(
+ value: offsetDuration,
+ child: Text('${offsetDuration.inMilliseconds}ms'),
+ )
+ ];
+ },
+ child: Padding(
+ padding: const EdgeInsets.symmetric(
+ // Using less vertical padding as the text is also longer
+ // horizontally, so it feels like it would need more spacing
+ // horizontally (matching the aspect ratio of the video).
+ vertical: 12,
+ horizontal: 16,
+ ),
+ child: Text('${controller.value.captionOffset.inMilliseconds}ms'),
+ ),
+ ),
+ ),
+ Align(
+ alignment: Alignment.topRight,
+ child: PopupMenuButton(
+ initialValue: controller.value.playbackSpeed,
+ tooltip: 'Playback speed',
+ onSelected: (double speed) {
+ controller.setPlaybackSpeed(speed);
+ },
+ itemBuilder: (BuildContext context) {
+ return >[
+ for (final double speed in _examplePlaybackRates)
+ PopupMenuItem(
+ value: speed,
+ child: Text('${speed}x'),
+ )
+ ];
+ },
+ child: Padding(
+ padding: const EdgeInsets.symmetric(
+ // Using less vertical padding as the text is also longer
+ // horizontally, so it feels like it would need more spacing
+ // horizontally (matching the aspect ratio of the video).
+ vertical: 12,
+ horizontal: 16,
+ ),
+ child: Text('${controller.value.playbackSpeed}x'),
+ ),
+ ),
+ ),
+ ],
+ );
+ }
+}
diff --git a/packages/video_player_videohole/example/pubspec.yaml b/packages/video_player_videohole/example/pubspec.yaml
new file mode 100644
index 000000000..36f406b03
--- /dev/null
+++ b/packages/video_player_videohole/example/pubspec.yaml
@@ -0,0 +1,34 @@
+name: video_player_videohole_example
+description: Demonstrates how to use the video_player_videohole plugin.
+publish_to: "none"
+
+dependencies:
+ flutter:
+ sdk: flutter
+ http: ^0.13.0
+ video_player_videohole:
+ path: ../
+
+dev_dependencies:
+ flutter_driver:
+ sdk: flutter
+ flutter_test:
+ sdk: flutter
+ integration_test:
+ sdk: flutter
+ integration_test_tizen:
+ path: ../../integration_test/
+ path_provider: ^2.0.6
+ path_provider_tizen:
+ path: ../../path_provider/
+ test: any
+
+flutter:
+ assets:
+ - assets/Butterfly-209.mp4
+ - assets/Audio.mp3
+ uses-material-design: true
+
+environment:
+ flutter: ">=3.0.0"
+ sdk: ">=2.12.0 <3.0.0"
diff --git a/packages/video_player_videohole/example/test_driver/integration_test.dart b/packages/video_player_videohole/example/test_driver/integration_test.dart
new file mode 100644
index 000000000..b38629cca
--- /dev/null
+++ b/packages/video_player_videohole/example/test_driver/integration_test.dart
@@ -0,0 +1,3 @@
+import 'package:integration_test/integration_test_driver.dart';
+
+Future main() => integrationDriver();
diff --git a/packages/video_player_videohole/example/tizen/.gitignore b/packages/video_player_videohole/example/tizen/.gitignore
new file mode 100644
index 000000000..750f3af1b
--- /dev/null
+++ b/packages/video_player_videohole/example/tizen/.gitignore
@@ -0,0 +1,5 @@
+flutter/
+.vs/
+*.user
+bin/
+obj/
diff --git a/packages/video_player_videohole/example/tizen/App.cs b/packages/video_player_videohole/example/tizen/App.cs
new file mode 100644
index 000000000..6dd4a6356
--- /dev/null
+++ b/packages/video_player_videohole/example/tizen/App.cs
@@ -0,0 +1,20 @@
+using Tizen.Flutter.Embedding;
+
+namespace Runner
+{
+ public class App : FlutterApplication
+ {
+ protected override void OnCreate()
+ {
+ base.OnCreate();
+
+ GeneratedPluginRegistrant.RegisterPlugins(this);
+ }
+
+ static void Main(string[] args)
+ {
+ var app = new App();
+ app.Run(args);
+ }
+ }
+}
diff --git a/packages/video_player_videohole/example/tizen/Runner.csproj b/packages/video_player_videohole/example/tizen/Runner.csproj
new file mode 100644
index 000000000..351a83987
--- /dev/null
+++ b/packages/video_player_videohole/example/tizen/Runner.csproj
@@ -0,0 +1,26 @@
+
+
+
+ Exe
+ tizen40
+
+
+
+ portable
+
+
+ none
+
+
+
+
+
+
+
+
+
+ %(RecursiveDir)
+
+
+
+
diff --git a/packages/video_player_videohole/example/tizen/shared/res/ic_launcher.png b/packages/video_player_videohole/example/tizen/shared/res/ic_launcher.png
new file mode 100644
index 000000000..4d6372eeb
Binary files /dev/null and b/packages/video_player_videohole/example/tizen/shared/res/ic_launcher.png differ
diff --git a/packages/video_player_videohole/example/tizen/tizen-manifest.xml b/packages/video_player_videohole/example/tizen/tizen-manifest.xml
new file mode 100644
index 000000000..7ff19bbd3
--- /dev/null
+++ b/packages/video_player_videohole/example/tizen/tizen-manifest.xml
@@ -0,0 +1,16 @@
+
+
+
+
+
+ ic_launcher.png
+
+
+
+
+ http://tizen.org/privilege/mediastorage
+ http://tizen.org/privilege/externalstorage
+ http://tizen.org/privilege/internet
+ http://developer.samsung.com/privilege/drmplay
+
+
diff --git a/packages/video_player_videohole/lib/src/closed_caption_file.dart b/packages/video_player_videohole/lib/src/closed_caption_file.dart
new file mode 100644
index 000000000..324ffc471
--- /dev/null
+++ b/packages/video_player_videohole/lib/src/closed_caption_file.dart
@@ -0,0 +1,77 @@
+// 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 'package:flutter/foundation.dart' show objectRuntimeType;
+
+import 'sub_rip.dart';
+import 'web_vtt.dart';
+
+export 'sub_rip.dart' show SubRipCaptionFile;
+export 'web_vtt.dart' show WebVTTCaptionFile;
+
+/// A structured representation of a parsed closed caption file.
+///
+/// A closed caption file includes a list of captions, each with a start and end
+/// time for when the given closed caption should be displayed.
+///
+/// The [captions] are a list of all captions in a file, in the order that they
+/// appeared in the file.
+///
+/// See:
+/// * [SubRipCaptionFile].
+/// * [WebVTTCaptionFile].
+abstract class ClosedCaptionFile {
+ /// The full list of captions from a given file.
+ ///
+ /// The [captions] will be in the order that they appear in the given file.
+ List
get captions;
+}
+
+/// A representation of a single caption.
+///
+/// A typical closed captioning file will include several [Caption]s, each
+/// linked to a start and end time.
+class Caption {
+ /// Creates a new [Caption] object.
+ ///
+ /// This is not recommended for direct use unless you are writing a parser for
+ /// a new closed captioning file type.
+ const Caption({
+ required this.number,
+ required this.start,
+ required this.end,
+ required this.text,
+ });
+
+ /// The number that this caption was assigned.
+ final int number;
+
+ /// When in the given video should this [Caption] begin displaying.
+ final Duration start;
+
+ /// When in the given video should this [Caption] be dismissed.
+ final Duration end;
+
+ /// The actual text that should appear on screen to be read between [start]
+ /// and [end].
+ final String text;
+
+ /// A no caption object. This is a caption with [start] and [end] durations of zero,
+ /// and an empty [text] string.
+ static const Caption none = Caption(
+ number: 0,
+ start: Duration.zero,
+ end: Duration.zero,
+ text: '',
+ );
+
+ @override
+ String toString() {
+ return '${objectRuntimeType(this, 'Caption')}('
+ 'number: $number, '
+ 'start: $start, '
+ 'end: $end, '
+ 'text: $text)';
+ }
+}
diff --git a/packages/video_player_videohole/lib/src/drm_configs.dart b/packages/video_player_videohole/lib/src/drm_configs.dart
new file mode 100644
index 000000000..025237ffb
--- /dev/null
+++ b/packages/video_player_videohole/lib/src/drm_configs.dart
@@ -0,0 +1,58 @@
+// Copyright 2023 Samsung Electronics Co., Ltd. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import 'package:flutter/foundation.dart';
+
+/// The DRM scheme for the video.
+enum DrmType {
+ /// None.
+ none,
+
+ /// PlayReady.
+ playready,
+
+ /// Widevine CDM.
+ widevine,
+}
+
+/// Callback that returns a DRM license from the given [challenge] data.
+typedef LicenseCallback = Future Function(Uint8List challenge);
+
+/// Configurations for playing DRM content.
+class DrmConfigs {
+ /// Creates a new [DrmConfigs].
+ const DrmConfigs({
+ this.type = DrmType.none,
+ this.licenseServerUrl,
+ this.licenseCallback,
+ });
+
+ /// The DRM type.
+ final DrmType type;
+
+ /// The URL of the DRM license server.
+ ///
+ /// This is optional. Either [licenseServerUrl] or [licenseCallback] can be
+ /// specified.
+ final String? licenseServerUrl;
+
+ /// A callback to retrieve a DRM license.
+ ///
+ /// This is optional. Either [licenseServerUrl] or [licenseCallback] can be
+ /// specified.
+ ///
+ /// This callback is called multiple times while the video is playing. Note
+ /// that the platform thread (main thread) is blocked while this callback is
+ /// running. If the execution of this callback is delayed, the program may
+ /// hang or fail to process user input.
+ final LicenseCallback? licenseCallback;
+
+ /// Converts to a map.
+ Map toMap() {
+ return {
+ 'drmType': type.index,
+ 'licenseServerUrl': licenseServerUrl,
+ };
+ }
+}
diff --git a/packages/video_player_videohole/lib/src/hole.dart b/packages/video_player_videohole/lib/src/hole.dart
new file mode 100644
index 000000000..63922b7b3
--- /dev/null
+++ b/packages/video_player_videohole/lib/src/hole.dart
@@ -0,0 +1,66 @@
+// Copyright 2023 Samsung Electronics Co., Ltd. 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:ui';
+
+import 'package:flutter/material.dart';
+import 'package:flutter/rendering.dart';
+
+/// A widget that creates a transparent hole in the Flutter UI.
+class Hole extends LeafRenderObjectWidget {
+ /// Creates a [Hole].
+ const Hole({Key? key}) : super(key: key);
+
+ @override
+ RenderBox createRenderObject(BuildContext context) => _HoleBox();
+}
+
+/// A render object of the [Hole] widget.
+class _HoleBox extends RenderBox {
+ @override
+ bool get sizedByParent => true;
+
+ @override
+ bool get alwaysNeedsCompositing => true;
+
+ @override
+ bool get isRepaintBoundary => true;
+
+ @override
+ void performResize() {
+ size = constraints.biggest;
+ }
+
+ @override
+ bool hitTestSelf(Offset position) {
+ return true;
+ }
+
+ @override
+ void paint(PaintingContext context, Offset offset) {
+ context.addLayer(_HoleLayer(rect: offset & size));
+ }
+}
+
+/// A composite layer that draws a rect with blend mode.
+class _HoleLayer extends Layer {
+ _HoleLayer({required this.rect});
+
+ final Rect rect;
+
+ @override
+ void addToScene(SceneBuilder builder, [Offset layerOffset = Offset.zero]) {
+ builder.addPicture(layerOffset, _createHolePicture(rect));
+ }
+
+ Picture _createHolePicture(Rect holeRect) {
+ final PictureRecorder recorder = PictureRecorder();
+ final Canvas canvas = Canvas(recorder);
+ final Paint paint = Paint();
+ paint.color = Colors.transparent;
+ paint.blendMode = BlendMode.src;
+ canvas.drawRect(rect, paint);
+ return recorder.endRecording();
+ }
+}
diff --git a/packages/video_player_videohole/lib/src/messages.g.dart b/packages/video_player_videohole/lib/src/messages.g.dart
new file mode 100644
index 000000000..05f8f52bd
--- /dev/null
+++ b/packages/video_player_videohole/lib/src/messages.g.dart
@@ -0,0 +1,594 @@
+// Autogenerated from Pigeon (v6.0.3), 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, unused_shown_name, unnecessary_import
+
+import 'dart:async';
+import 'dart:typed_data' show Float64List, Int32List, Int64List, Uint8List;
+
+import 'package:flutter/foundation.dart' show ReadBuffer, WriteBuffer;
+import 'package:flutter/services.dart';
+
+class PlayerMessage {
+ PlayerMessage({
+ required this.playerId,
+ });
+
+ int playerId;
+
+ Object encode() {
+ return