diff --git a/apps/desktop/pubspec.lock b/apps/desktop/pubspec.lock index e0ef223..6cc91a6 100644 --- a/apps/desktop/pubspec.lock +++ b/apps/desktop/pubspec.lock @@ -538,10 +538,9 @@ packages: media_kit_video: dependency: "direct main" description: - name: media_kit_video - sha256: afaa509e7b7e0bf247557a3a740cde903a52c34ace9810f94500e127bd7b043d - url: "https://pub.dev" - source: hosted + path: "../../third_party/media_kit_video" + relative: true + source: path version: "2.0.1" meta: dependency: transitive diff --git a/apps/desktop/pubspec.yaml b/apps/desktop/pubspec.yaml index f2f2e74..3306c16 100644 --- a/apps/desktop/pubspec.yaml +++ b/apps/desktop/pubspec.yaml @@ -10,7 +10,9 @@ dependencies: sdk: flutter signals: ^7.0.0 media_kit: ^1.1.0 - media_kit_video: ^2.0.1 + # Vendored with Starsync's patches (third_party/media_kit_video/starsync). + media_kit_video: + path: ../../third_party/media_kit_video media_kit_libs_video: ^1.0.0 path_provider: ^2.1.0 web_socket_channel: ^3.0.0 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index c3f6883..14d6125 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -12,6 +12,7 @@ Plain overview first: [HOW-IT-WORKS.md](HOW-IT-WORKS.md). Wire format: | `apps/desktop` | Flutter app: UI, `AppGraph` (composition root), `SyncClient`, `Connection`, `SessionCoordinator`, `Navigation`, `YourServer` (`ServerIsolate`), `MediaPipeline` (opening media), `PlaybackController` (the session player's marks and commands), the player host (`PlayerHost`, `MediaKitHost`: the only media_kit user besides the `Video` widget glue), update UI (`UpdateController`) | ships with `starsync-server` | | `tool` | harnesses: `range_logger`, `shaper`, `fake_viewer`, `e2e_check` | dev only | | `deploy` | systemd unit for the dedicated server | | +| `third_party/media_kit_video` | upstream media_kit_video 2.0.1 with Starsync's patches (`starsync/README.md`: Flutter ≥ 3.38 EGL display on the platform thread, the X11 GLX step-aside) | vendored; re-apply patches on update | Guard: `packages/core/test/flutter_free_test.dart` fails if anything under `core/lib` imports Flutter, `dart:ui` or `signals_flutter`; CI runs diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 796699f..aaaec65 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -6,7 +6,10 @@ Layout and rules: [ARCHITECTURE.md](ARCHITECTURE.md). Needs the Flutter SDK (beta channel, Dart 3.13+) and, for the Linux desktop build, the usual GTK toolchain (clang, cmake, ninja, pkg-config, libgtk-3-dev). -mpv libraries come from `media_kit_libs_video`. `flutter_soloud` (the app's +mpv libraries come from `media_kit_libs_video`. `media_kit_video` is the patched copy in +`third_party/media_kit_video` (see its `starsync/README.md`); test video +output changes under X11 (`GDK_BACKEND=x11`, ideally a real X11 session) +and Wayland. `flutter_soloud` (the app's sounds) compiles SoLoud and miniaudio from source in its build hook with that C++ toolchain (MSVC on Windows); it needs no audio dev headers (miniaudio loads ALSA / PulseAudio / JACK at run time) and, with `no_xiph_libs` set in diff --git a/third_party/media_kit_video/CHANGELOG.md b/third_party/media_kit_video/CHANGELOG.md new file mode 100644 index 0000000..13f4832 --- /dev/null +++ b/third_party/media_kit_video/CHANGELOG.md @@ -0,0 +1,245 @@ +## 2.0.1 + +- fix: flutter 3.38.x crash +- fix(linux): flutter 3.38.x h/w render + +## 2.0.0 + +- feat: flutter 3.38.x support +- **BREAKING CHANGE**: remove `screen_brightness` and `volume_controller` dependencies + +## 1.3.1 + +- fix(windows): notify `VideoOutput.Resize` on platform thread +- fix(linux): notify `VideoOutput.Resize` on platform thread +- fix(android): `VideoController` implementation +- fix(android): remove deprecated API usage + +## 1.3.0 + + - **REFACTOR**: screen_brightness -> screen_brightness_platform_interface. + - **REFACTOR**(android): simplify AndroidVideoController implementation. + - **REFACTOR**(android): VideoOutput SurfaceTextureEntry -> SurfaceProducer migration. + - **REFACTOR**: use setProperty API in NativeVideoController. + - **FIX**: improve responsiveness of showing controls on mobile. + - **FIX**: bump web to 1.1.0. + - **FIX**: cast to JSObject. + - **FIX**: not call super.didChangeAppLifecycleState(state);. + - **FIX**: not call super.didChangeAppLifecycleState(state);. + - **FIX**: wakelock print. + - **FIX**: subtitles not shifting on controls show/hide. + - **FIX**: set width/height from VideoParams in NativeVideoController. + - **FIX**: seek inside onPointerMove. + - **FIX**(windows): automatic IDXGIAdapter selection on windows 10 or greater. + - **FIX**(android): waitUntilFirstFrameRenderedCompleter. + - **FIX**(android): --hwdec=auto-safe as default. + - **FIX**: dispose ValueNotifier(s) in PlatformVideoController. + - **FIX**: fullscreen. + - **FIX**: long press video speed reset issue. + - **FIX**: wrong value in brightness builder callback. + - **FIX**: Use a Scaffold as the outermost widget on fullscreen video pages. + - **FEAT**: upgrade volume_controller dependency and refactor related code. + - **FEAT**: seek on double tap custom duration support. + +## 1.2.5 +- fix: subtitleView not being updated +- fix: mobile double tap areas hidden but still mounted +- fix: mobile center click is delayed + +- feat: desktop video controls mouse hides with controls +- feat: desktop on tap pause and play +- feat: desktop on double tap fullscreen +- fix: windows crash by @Airyzz [#900](https://github.com/media-kit/media-kit/pull/900) +- feat: auto pass changes to fullscreen without having to call .update + +## 1.2.4 + +- fix: web compile error + +## 1.2.3 + +- feat: `VideoState.update` & `VideoViewParameters` + +## 1.2.2 + +- fix: override `setState` & check `mounted` in `MaterialVideoControls` & `MaterialDesktopVideoControls` + +## 1.2.1 + +- fix(android): clear `android.view.Surface` before playback +- feat: `MaterialVideoControlsThemeData.seekBarAlignment` + +## 1.2.0 + +- fix: `MaterialVideoControls` layout + +## 1.1.9 + +- fix: unmount `CircularProgressIndicator` buffering indicator if invisible +- fix: pass all video attributes to fullscreen route +- fix: prevent controls from hiding during seek +- fix: hide last video's frame upon `Player.open` +- fix: `ThemeData.copyWith` override +- fix(android): `hwdec=auto-safe` w/ `enableHardwareAcceleration=true` +- fix(windows): fullscreen for non-primary monitors +- fix(darwin): `mpv_render_context_free` call +- fix(darwin): memory leaks +- fix(ios): fix `disposeMPV` +- feat: `VideoControllerConfiguration.androidAttachSurfaceAfterVideoParameters` +- feat: center the seek-bar within its parent `Container` for improved tap area +- feat: `backdropColor` argument in `MaterialVideoControlsThemeData` + +## 1.1.8 + +- fix: pass all `*VideoControlsTheme`(s) to fullscreen `context` + +## 1.1.7 + +- fix: add `await` to `maybePop` when exiting fullscreen +- fix: `MaterialVideoControls`/`MaterialDesktopVideoControls` seekbar glitch +- fix(android): S/W rendering fallback +- fix(android): create fresh `android.view.Surface` for every video output + +## 1.1.6 + +- fix: programmatic fullscreen API +- fix(android): pause upon entering fullscreen +- fix(android): `waitUntilFirstFrameRenderedNotify` in `FlutterFragmentActivity` + +## 1.1.5 + +- fix(android): `waitUntilFirstFrameRenderedNotify` fallback & release-mode + +## 1.1.4 + +- feat: `Video` `resumeUponEnteringForegroundMode` +- feat(android): `waitUntilFirstFrameRenderedNotify` implementation + +## 1.1.3 + +- feat(android): `VideoControllerConfiguration.scale` +- fix(android): use `hwdec=auto` +- fix(android): `SurfaceTexture.setDefaultBufferSize` & render race + +## 1.1.2 + +- fix(windows): memory leak in `GetVideoWidth`/`GetVideoHeight` +- fix(linux): `GThread*` leak in S/W render & `video_output_get_(width|height)` +- fix(linux): H/W support for multiple videos +- build(darwin): bump `mpv` headers to `0.36.0` +- build(darwin): use symlinks for `FRAMEWORK_SEARCH_PATHS`, `media_kit_libs_*** >= 1.1.0` +- fix(darwin): remove black screen when switching videos ([#332](https://github.com/media-kit/media-kit/issues/332)) +- feat: `Video`: expose `onEnterFullscreen` & `onExitFullscreen` +- feat: feat: `visibleOnMount` `MaterialVideoControls`/`MaterialDesktopVideoControls` +- fix: display `bufferingIndicatorBuilder` even if controls are hidden + +## 1.1.1 + +- chore: `try`/`catch` native calls to hide stray logs +- fix: `MaterialDesktopVideoControls`: do not add `onTapUp` callback if `toggleFullscreenOnDoublePress` is disabled + +## 1.1.0 + +- feat: `SubtitleView`, `SubtitleViewConfiguration` +- feat: `shiftSubtitlesOnControlsVisibilityChange` in `MaterialVideoControls` & `MaterialDesktopVideoControls` +- feat: apply rotation from metadata to video output +- feat: improve wakelock behavior +- feat: `pauseUponEnteringBackgroundMode` +- fix: `bufferingIndicatorBuilder` padding in `MaterialVideoControls` & `MaterialDesktopVideoControls` +- fix(windows): maintain aspect ratio in s/w rendering pixel-buffer size clamping +- fix(linux): maintain aspect ratio in s/w rendering pixel-buffer size clamping +- perf(android): use `hwdec=mediacodec` w/ `enableHardwareAcceleration` +- deps: migrate [`package:wakelock_plus`](https://pub.dev/packages/wakelock_plus) + +## 1.0.2 + +- fix(video/macos): fix fullscreen support + +## 1.0.1 + +- fix: synchronize `VideoController` constructor +- fix: `fullscreen` video controls theme data not being applied + +## 1.0.0 + +- feat: web support +- feat: fullscreen API +- feat: acquire wakelock +- feat: support for AGP 8.0 +- feat: pre-built video controls +- feat: `controls` argument in `Video` widget +- feat: `AdaptiveVideoControls`, `MaterialVideoControls`, `MaterialDesktopVideoControls` & `NoVideoControls` + +## 0.0.12 + +- fix(android): improve `Texture` resize handling + +## 0.0.11 + +- fix(android): improve `Texture` resize handling + +## 0.0.10 + +- feat: `VideoControllerConfiguration` +- feat: `VideoController.waitUntilFirstFrameRendered` +- refactor: clean-up package structure +- refactor: remove `VideoController.dispose` +- refactor: `VideoController.create` -> `VideoController` constructor +- fix(android): add `av1` to `hwdec-codecs` +- fix(android): use `--vo=gpu` + `--hwdec=mediacodec-copy` /w `enableHardwareAcceleration` + +## 0.0.9 + +- fix(android): revert to `--vo=mediacodec_embed` in `enableHardwareAcceleration` + +## 0.0.8 + +- fix(android): subtitle rendering +- fix(android): video rendering inside emulators (#149) +- fix(android): video rendering with `enableHardwareAcceleration: false` + +## 0.0.7 + +- fix(linux): VAAPI hardware acceleration +- perf(windows): `VideoOutput::Resize`: delete texture objects in background + +## 0.0.6 + +- fix(windows): synchronize texture object deletion in on unregister _v.i.z_ `VideoOutput::Resize` or `VideoOutput::~VideoOutput` + +## 0.0.5 + +- Android support +- feat: `VideoController.setSize` +- fix: set `vo` to `libmpv` before creating render context +- refactor: `VideoController.create` takes `Player` reference instead of `handle` + +## 0.0.4 + +- fix: use `mkdir` instead of `.gitkeep` + +## 0.0.3 + +- fix: add `.framework` & `.xcframework` for all libs + +## 0.0.2 + +- macOS support: + - Hardware: MPV_RENDER_API_TYPE_OPENGL + pixel buffer + METAL + - Software: MPV_RENDER_API_TYPE_SW + pixel buffer +- iOS support: + - Hardware: MPV_RENDER_API_TYPE_OPENGL + pixel buffer + - Software: MPV_RENDER_API_TYPE_SW + pixel buffer +- fix(windows): use `TextureRegistrar::UnregisterTexture` release callback to free texture resources +- fix(windows): synchronize texture unregister & release on frame dimensions change +- feat: `aspectRatio` parameter for `Video` widget + +## 0.0.1 + +- Initial release +- Windows support: + - Hardware: MPV_RENDER_API_TYPE_OPENGL + ANGLE + DirectX 11 + - Software: MPV_RENDER_API_TYPE_SW + pixel buffer +- GNU/Linux support: + - Hardware: MPV_RENDER_API_TYPE_OPENGL + GDK/GL + - Software: MPV_RENDER_API_TYPE_SW + pixel buffer diff --git a/third_party/media_kit_video/LICENSE b/third_party/media_kit_video/LICENSE new file mode 100644 index 0000000..c4c95d9 --- /dev/null +++ b/third_party/media_kit_video/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021 & onwards Hitesh Kumar Saini + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/third_party/media_kit_video/README.md b/third_party/media_kit_video/README.md new file mode 100644 index 0000000..57f4225 --- /dev/null +++ b/third_party/media_kit_video/README.md @@ -0,0 +1,11 @@ +# [package:media_kit_video](https://github.com/media-kit/media-kit) + +[![](https://img.shields.io/discord/1079685977523617792?color=33cd57&label=Discord&logo=discord&logoColor=discord)](https://discord.gg/h7qf2R9n57) [![Github Actions](https://github.com/media-kit/media-kit/actions/workflows/ci.yml/badge.svg)](https://github.com/media-kit/media-kit/actions/workflows/ci.yml) + +Native implementation for video playback in [package:media_kit](https://pub.dev/packages/media_kit). + +## License + +Copyright © 2021 & onwards, Hitesh Kumar Saini <> + +This project & the work under this repository is governed by MIT license that can be found in the [LICENSE](./LICENSE) file. diff --git a/third_party/media_kit_video/analysis_options.yaml b/third_party/media_kit_video/analysis_options.yaml new file mode 100644 index 0000000..38a258d --- /dev/null +++ b/third_party/media_kit_video/analysis_options.yaml @@ -0,0 +1,8 @@ +include: package:flutter_lints/flutter.yaml +analyzer: + errors: + dangling_library_doc_comments: ignore + unintended_html_in_doc_comment: ignore + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/third_party/media_kit_video/android/build.gradle b/third_party/media_kit_video/android/build.gradle new file mode 100644 index 0000000..510be7d --- /dev/null +++ b/third_party/media_kit_video/android/build.gradle @@ -0,0 +1,38 @@ +group 'com.alexmercerind.media_kit_video' +version '1.0' + +buildscript { + repositories { + google() + mavenCentral() + } + + dependencies { + classpath 'com.android.tools.build:gradle:8.13.0' + } +} + +rootProject.allprojects { + repositories { + google() + mavenCentral() + } +} + +apply plugin: 'com.android.library' + +android { + namespace 'com.alexmercerind.media_kit_video' + + compileSdkVersion 36 + + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + + defaultConfig { + minSdkVersion 16 + consumerProguardFiles 'proguard-rules.pro' + } +} diff --git a/third_party/media_kit_video/android/proguard-rules.pro b/third_party/media_kit_video/android/proguard-rules.pro new file mode 100644 index 0000000..1d865bf --- /dev/null +++ b/third_party/media_kit_video/android/proguard-rules.pro @@ -0,0 +1,3 @@ +-keepclassmembers class io.flutter.embedding.engine.FlutterEngine { + private io.flutter.embedding.engine.FlutterJNI flutterJNI; +} diff --git a/third_party/media_kit_video/android/settings.gradle b/third_party/media_kit_video/android/settings.gradle new file mode 100644 index 0000000..3c09d7c --- /dev/null +++ b/third_party/media_kit_video/android/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'media_kit_video' diff --git a/third_party/media_kit_video/android/src/main/AndroidManifest.xml b/third_party/media_kit_video/android/src/main/AndroidManifest.xml new file mode 100644 index 0000000..d2b7859 --- /dev/null +++ b/third_party/media_kit_video/android/src/main/AndroidManifest.xml @@ -0,0 +1 @@ + diff --git a/third_party/media_kit_video/android/src/main/java/com/alexmercerind/media_kit_video/MediaKitVideoPlugin.java b/third_party/media_kit_video/android/src/main/java/com/alexmercerind/media_kit_video/MediaKitVideoPlugin.java new file mode 100644 index 0000000..b9277fe --- /dev/null +++ b/third_party/media_kit_video/android/src/main/java/com/alexmercerind/media_kit_video/MediaKitVideoPlugin.java @@ -0,0 +1,84 @@ +/** + * This file is a part of media_kit (https://github.com/media-kit/media-kit). + *

+ * Copyright © 2021 & onwards, Hitesh Kumar Saini . + * All rights reserved. + * Use of this source code is governed by MIT license that can be found in the LICENSE file. + */ +package com.alexmercerind.media_kit_video; + +import androidx.annotation.NonNull; + +import java.util.HashMap; + +import io.flutter.embedding.engine.plugins.FlutterPlugin; +import io.flutter.plugin.common.MethodCall; +import io.flutter.plugin.common.MethodChannel; +import io.flutter.plugin.common.MethodChannel.MethodCallHandler; +import io.flutter.plugin.common.MethodChannel.Result; + +/** + * MediaKitVideoPlugin + */ +public class MediaKitVideoPlugin implements FlutterPlugin, MethodCallHandler { + private MethodChannel channel; + private VideoOutputManager videoOutputManager; + + @Override + public void onAttachedToEngine(@NonNull FlutterPluginBinding flutterPluginBinding) { + channel = new MethodChannel(flutterPluginBinding.getBinaryMessenger(), "com.alexmercerind/media_kit_video"); + channel.setMethodCallHandler(this); + + videoOutputManager = new VideoOutputManager(flutterPluginBinding.getTextureRegistry()); + + } + + @Override + public void onMethodCall(@NonNull MethodCall call, @NonNull Result result) { + switch (call.method) { + case "VideoOutputManager.Create": { + final long handle = Long.parseLong(call.argument("handle")); + videoOutputManager.create(handle, (id, wid, width, height) -> channel.invokeMethod("VideoOutput.Resize", new HashMap() {{ + put("handle", handle); + put("id", id); + put("wid", wid); + put("rect", new HashMap() {{ + put("left", 0); + put("top", 0); + put("width", width); + put("height", height); + }}); + }})); + result.success(null); + break; + } + case "VideoOutputManager.SetSurfaceSize": { + final long handle = Long.parseLong(call.argument("handle")); + final int width = Integer.parseInt(call.argument("width")); + final int height = Integer.parseInt(call.argument("height")); + videoOutputManager.setSurfaceSize(handle, width, height); + result.success(null); + break; + } + case "VideoOutputManager.Dispose": { + final long handle = Long.parseLong(call.argument("handle")); + videoOutputManager.dispose(handle); + result.success(null); + break; + } + case "Utils.IsEmulator": { + result.success(Utils.isEmulator()); + break; + } + default: { + result.notImplemented(); + break; + } + } + } + + @Override + public void onDetachedFromEngine(@NonNull FlutterPluginBinding binding) { + channel.setMethodCallHandler(null); + } +} diff --git a/third_party/media_kit_video/android/src/main/java/com/alexmercerind/media_kit_video/TextureUpdateCallback.java b/third_party/media_kit_video/android/src/main/java/com/alexmercerind/media_kit_video/TextureUpdateCallback.java new file mode 100644 index 0000000..4a7497b --- /dev/null +++ b/third_party/media_kit_video/android/src/main/java/com/alexmercerind/media_kit_video/TextureUpdateCallback.java @@ -0,0 +1,5 @@ +package com.alexmercerind.media_kit_video; + +public interface TextureUpdateCallback { + void onTextureUpdate(long id, long wid, int width, int height); +} diff --git a/third_party/media_kit_video/android/src/main/java/com/alexmercerind/media_kit_video/Utils.java b/third_party/media_kit_video/android/src/main/java/com/alexmercerind/media_kit_video/Utils.java new file mode 100644 index 0000000..293801a --- /dev/null +++ b/third_party/media_kit_video/android/src/main/java/com/alexmercerind/media_kit_video/Utils.java @@ -0,0 +1,42 @@ +/** + * This file is a part of media_kit (https://github.com/media-kit/media-kit). + *

+ * Copyright © 2021 & onwards, Hitesh Kumar Saini . + * All rights reserved. + * Use of this source code is governed by MIT license that can be found in the LICENSE file. + */ +package com.alexmercerind.media_kit_video; + +import android.os.Build; + +import io.flutter.Log; + + +public abstract class Utils { + private static final String TAG = "Utils"; + + public static boolean isEmulator() { + try { + // https://github.com/fluttercommunity/plus_plugins/blob/ff54dc49230ee5f8b772a3326d4ff3758618df80/packages/device_info_plus/device_info_plus/android/src/main/kotlin/dev/fluttercommunity/plus/device_info/MethodCallHandlerImpl.kt#L110-L125 + return Build.BRAND.startsWith("generic") && Build.DEVICE.startsWith("generic") + || Build.FINGERPRINT.startsWith("generic") + || Build.FINGERPRINT.startsWith("unknown") + || Build.HARDWARE.contains("goldfish") + || Build.HARDWARE.contains("ranchu") + || Build.MODEL.contains("google_sdk") + || Build.MODEL.contains("Emulator") + || Build.MODEL.contains("Android SDK built for x86") + || Build.MANUFACTURER.contains("Genymotion") + || Build.PRODUCT.contains("sdk_google") + || Build.PRODUCT.contains("google_sdk") + || Build.PRODUCT.contains("sdk") + || Build.PRODUCT.contains("sdk_x86") + || Build.PRODUCT.contains("vbox86p") + || Build.PRODUCT.contains("emulator") + || Build.PRODUCT.contains("simulator"); + } catch (Throwable e) { + Log.e(TAG, "isEmulator", e); + } + return false; + } +} diff --git a/third_party/media_kit_video/android/src/main/java/com/alexmercerind/media_kit_video/VideoOutput.java b/third_party/media_kit_video/android/src/main/java/com/alexmercerind/media_kit_video/VideoOutput.java new file mode 100644 index 0000000..cc645f0 --- /dev/null +++ b/third_party/media_kit_video/android/src/main/java/com/alexmercerind/media_kit_video/VideoOutput.java @@ -0,0 +1,141 @@ +/** + * This file is a part of media_kit (https://github.com/media-kit/media-kit). + *

+ * Copyright © 2021 & onwards, Hitesh Kumar Saini . + * All rights reserved. + * Use of this source code is governed by MIT license that can be found in the LICENSE file. + */ +package com.alexmercerind.media_kit_video; + +import android.os.Handler; +import android.os.Looper; +import android.util.Log; + +import java.lang.reflect.Method; +import java.util.HashSet; +import java.util.Locale; +import java.util.Objects; + +import io.flutter.view.TextureRegistry; + +public class VideoOutput implements TextureRegistry.SurfaceProducer.Callback { + private static final String TAG = "VideoOutput"; + private static final Method newGlobalObjectRef; + private static final Method deleteGlobalObjectRef; + private static final HashSet deletedGlobalObjectRefs = new HashSet<>(); + private static final Handler handler = new Handler(Looper.getMainLooper()); + + static { + try { + // com.alexmercerind.mediakitandroidhelper.MediaKitAndroidHelper is part of package:media_kit_libs_android_video & package:media_kit_libs_android_audio packages. + // Use reflection to invoke methods of com.alexmercerind.mediakitandroidhelper.MediaKitAndroidHelper. + Class mediaKitAndroidHelperClass = Class.forName("com.alexmercerind.mediakitandroidhelper.MediaKitAndroidHelper"); + newGlobalObjectRef = mediaKitAndroidHelperClass.getDeclaredMethod("newGlobalObjectRef", Object.class); + deleteGlobalObjectRef = mediaKitAndroidHelperClass.getDeclaredMethod("deleteGlobalObjectRef", long.class); + newGlobalObjectRef.setAccessible(true); + deleteGlobalObjectRef.setAccessible(true); + } catch (Throwable e) { + Log.i("media_kit", "package:media_kit_libs_android_video missing. Make sure you have added it to pubspec.yaml."); + throw new RuntimeException("Failed to initialize com.alexmercerind.media_kit_video.VideoOutput."); + } + } + + private long id = 0; + private long wid = 0; + + private final TextureUpdateCallback textureUpdateCallback; + + private final TextureRegistry.SurfaceProducer surfaceProducer; + + private final Object lock = new Object(); + + VideoOutput(TextureRegistry textureRegistryReference, TextureUpdateCallback textureUpdateCallback) { + this.textureUpdateCallback = textureUpdateCallback; + + surfaceProducer = textureRegistryReference.createSurfaceProducer(); + surfaceProducer.setCallback(this); + } + + public void dispose() { + synchronized (lock) { + try { + surfaceProducer.getSurface().release(); + } catch (Throwable e) { + Log.e(TAG, "dispose", e); + } + try { + surfaceProducer.release(); + } catch (Throwable e) { + Log.e(TAG, "dispose", e); + } + onSurfaceCleanup(); + } + } + + public void setSurfaceSize(int width, int height) { + setSurfaceSize(width, height, false); + } + + private void setSurfaceSize(int width, int height, boolean force) { + synchronized (lock) { + try { + if (!force && surfaceProducer.getWidth() == width && surfaceProducer.getHeight() == height) { + return; + } + surfaceProducer.setSize(width, height); + onSurfaceAvailable(); + } catch (Throwable e) { + Log.e(TAG, "setSurfaceSize", e); + } + } + } + + @Override + public void onSurfaceAvailable() { + synchronized (lock) { + Log.i(TAG, "onSurfaceAvailable"); + id = surfaceProducer.id(); + wid = newGlobalObjectRef(surfaceProducer.getSurface()); + textureUpdateCallback.onTextureUpdate(id, wid, surfaceProducer.getWidth(), surfaceProducer.getHeight()); + } + } + + @Override + public void onSurfaceCleanup() { + synchronized (lock) { + Log.i(TAG, "onSurfaceCleanup"); + textureUpdateCallback.onTextureUpdate(id, 0, surfaceProducer.getWidth(), surfaceProducer.getHeight()); + if (wid != 0) { + final long widReference = wid; + handler.postDelayed(() -> deleteGlobalObjectRef(widReference), 5000); + } + } + } + + private static long newGlobalObjectRef(Object object) { + Log.i(TAG, String.format(Locale.ENGLISH, "newGlobalRef: object = %s", object)); + try { + return (long) Objects.requireNonNull(newGlobalObjectRef.invoke(null, object)); + } catch (Throwable e) { + Log.e(TAG, "newGlobalRef", e); + return 0; + } + } + + private static void deleteGlobalObjectRef(long ref) { + if (deletedGlobalObjectRefs.contains(ref)) { + Log.i(TAG, String.format(Locale.ENGLISH, "deleteGlobalObjectRef: ref = %d ALREADY DELETED", ref)); + return; + } + if (deletedGlobalObjectRefs.size() > 100) { + deletedGlobalObjectRefs.clear(); + } + deletedGlobalObjectRefs.add(ref); + Log.i(TAG, String.format(Locale.ENGLISH, "deleteGlobalObjectRef: ref = %d", ref)); + try { + deleteGlobalObjectRef.invoke(null, ref); + } catch (Throwable e) { + Log.e(TAG, "deleteGlobalObjectRef", e); + } + } +} diff --git a/third_party/media_kit_video/android/src/main/java/com/alexmercerind/media_kit_video/VideoOutputManager.java b/third_party/media_kit_video/android/src/main/java/com/alexmercerind/media_kit_video/VideoOutputManager.java new file mode 100644 index 0000000..8c5cca2 --- /dev/null +++ b/third_party/media_kit_video/android/src/main/java/com/alexmercerind/media_kit_video/VideoOutputManager.java @@ -0,0 +1,57 @@ +/** + * This file is a part of media_kit (https://github.com/media-kit/media-kit). + *

+ * Copyright © 2021 & onwards, Hitesh Kumar Saini . + * All rights reserved. + * Use of this source code is governed by MIT license that can be found in the LICENSE file. + */ +package com.alexmercerind.media_kit_video; + +import android.util.Log; + +import java.util.HashMap; +import java.util.Locale; +import java.util.Objects; + +import io.flutter.view.TextureRegistry; + +public class VideoOutputManager { + private static final String TAG = "VideoOutputManager"; + + private final HashMap videoOutputs = new HashMap<>(); + private final TextureRegistry textureRegistryReference; + private final Object lock = new Object(); + + VideoOutputManager(TextureRegistry textureRegistryReference) { + this.textureRegistryReference = textureRegistryReference; + } + + public void create(long handle, TextureUpdateCallback textureUpdateCallback) { + synchronized (lock) { + Log.i(TAG, String.format(Locale.ENGLISH, "com.alexmercerind.media_kit_video.VideoOutputManager.create: %d", handle)); + if (!videoOutputs.containsKey(handle)) { + final VideoOutput videoOutput = new VideoOutput(textureRegistryReference, textureUpdateCallback); + videoOutputs.put(handle, videoOutput); + } + } + } + + public void dispose(long handle) { + synchronized (lock) { + Log.i(TAG, String.format(Locale.ENGLISH, "com.alexmercerind.media_kit_video.VideoOutputManager.dispose: %d", handle)); + if (videoOutputs.containsKey(handle)) { + Objects.requireNonNull(videoOutputs.get(handle)).dispose(); + videoOutputs.remove(handle); + } + } + } + + public void setSurfaceSize(long handle, int width, int height) { + synchronized (lock) { + Log.i(TAG, String.format(Locale.ENGLISH, "com.alexmercerind.media_kit_video.VideoOutputManager.setSurfaceSize: %d %d %d", handle, width, height)); + if (videoOutputs.containsKey(handle)) { + Objects.requireNonNull(videoOutputs.get(handle)).setSurfaceSize(width, height); + } + } + } +} diff --git a/third_party/media_kit_video/common/darwin/Classes/plugin/MPVHelpers.swift b/third_party/media_kit_video/common/darwin/Classes/plugin/MPVHelpers.swift new file mode 100644 index 0000000..26b6d18 --- /dev/null +++ b/third_party/media_kit_video/common/darwin/Classes/plugin/MPVHelpers.swift @@ -0,0 +1,30 @@ +public enum MPVHelpers { + public static func checkError(_ status: CInt) { + if status < 0 { + NSLog("MPVHelpers: error: \(String(cString: mpv_error_string(status)))") + exit(1) + } + } + + public static func getVideoOutParams( + _ handle: OpaquePointer + ) -> MPVVideoOutParams { + var node = mpv_node() + defer { + mpv_free_node_contents(&node) + } + + mpv_get_property(handle, "video-out-params", MPV_FORMAT_NODE, &node) + + if node.format != MPV_FORMAT_NODE_MAP { + return MPVVideoOutParams.empty + } + + let map: mpv_node_list = node.u.list!.pointee + if map.num == 0 { + return MPVVideoOutParams.empty + } + + return MPVVideoOutParams.fromMPVNodeList(map) + } +} diff --git a/third_party/media_kit_video/common/darwin/Classes/plugin/MPVVideoOutParams.swift b/third_party/media_kit_video/common/darwin/Classes/plugin/MPVVideoOutParams.swift new file mode 100644 index 0000000..160ea57 --- /dev/null +++ b/third_party/media_kit_video/common/darwin/Classes/plugin/MPVVideoOutParams.swift @@ -0,0 +1,55 @@ +public class MPVVideoOutParams { + public let dw: Int64 + public let dh: Int64 + public let rotate: Int64 + + init( + dw: Int64, + dh: Int64, + rotate: Int64 + ) { + self.dw = dw + self.dh = dh + self.rotate = rotate + } + + public static let empty = MPVVideoOutParams( + dw: 0, + dh: 0, + rotate: 0 + ) + + public static func fromMPVNodeList( + _ map: mpv_node_list + ) -> MPVVideoOutParams { + var dw: Int64 = 0 + var dh: Int64 = 0 + var rotate: Int64 = 0 + + var kptr = map.keys! + var vptr = map.values! + for _ in 0 ..< map.num { + let key = String(cString: kptr.pointee!) + let value: mpv_node = vptr.pointee + + kptr = kptr.successor() + vptr = vptr.successor() + + if value.format == MPV_FORMAT_INT64 { + if key == "dw" { + dw = value.u.int64 + } else if key == "dh" { + dh = value.u.int64 + } else if key == "rotate" { + rotate = value.u.int64 + } + } + } + + return MPVVideoOutParams( + dw: dw, + dh: dh, + rotate: rotate + ) + } +} diff --git a/third_party/media_kit_video/common/darwin/Classes/plugin/MediaKitVideoPlugin.swift b/third_party/media_kit_video/common/darwin/Classes/plugin/MediaKitVideoPlugin.swift new file mode 100644 index 0000000..f5f9a29 --- /dev/null +++ b/third_party/media_kit_video/common/darwin/Classes/plugin/MediaKitVideoPlugin.swift @@ -0,0 +1,168 @@ +#if canImport(Flutter) + import Flutter +#elseif canImport(FlutterMacOS) + import FlutterMacOS +#endif + +public class MediaKitVideoPlugin: NSObject, FlutterPlugin { + private static let CHANNEL_NAME = "com.alexmercerind/media_kit_video" + + public static func register(with registrar: FlutterPluginRegistrar) { + #if canImport(Flutter) + let binaryMessenger = registrar.messenger() + let registry = registrar.textures() + let utils: UtilsProtocol? = nil + #elseif canImport(FlutterMacOS) + let binaryMessenger = registrar.messenger + let registry = registrar.textures + let utils: UtilsProtocol? = Utils(registrar) + #endif + + let channel = FlutterMethodChannel( + name: CHANNEL_NAME, + binaryMessenger: binaryMessenger + ) + let instance = MediaKitVideoPlugin( + registry: registry, + channel: channel, + utils: utils + ) + registrar.addMethodCallDelegate(instance, channel: channel) + } + + private let channel: FlutterMethodChannel + private let videoOutputManager: VideoOutputManager + private let utils: UtilsProtocol? + + init( + registry: FlutterTextureRegistry, + channel: FlutterMethodChannel, + utils: UtilsProtocol? + ) { + self.channel = channel + videoOutputManager = VideoOutputManager( + registry: registry + ) + self.utils = utils + } + + public func handle( + _ call: FlutterMethodCall, + result: @escaping FlutterResult + ) { + switch call.method { + case "VideoOutputManager.Create": + handleCreateMethodCall(call.arguments, result) + case "VideoOutputManager.SetSize": + handleSetSizeMethodCall(call.arguments, result) + case "VideoOutputManager.Dispose": + handleDisposeMethodCall(call.arguments, result) + case "Utils.EnterNativeFullscreen": + handleEnterNativeFullscreenMethodCall(call.arguments, result) + case "Utils.ExitNativeFullscreen": + handleExitNativeFullscreenMethodCall(call.arguments, result) + default: + result(FlutterMethodNotImplemented) + } + } + + private func handleCreateMethodCall( + _ arguments: Any?, + _ result: FlutterResult + ) { + let args = arguments as? [String: Any] + let handleStr = args?["handle"] as! String + let handle: Int64? = Int64(handleStr) + let configDict = args?["configuration"] as! [String: Any] + let configuration = VideoOutputConfiguration.fromDict(configDict) + + assert(handle != nil, "handle must be an Int64") + + videoOutputManager.create( + handle: handle!, + configuration: configuration, + textureUpdateCallback: { (_ textureId: Int64, _ size: CGSize) in + self.channel.invokeMethod( + "VideoOutput.Resize", + arguments: [ + "handle": handle!, + "id": textureId, + "rect": [ + "top": 0, + "left": 0, + "width": size.width, + "height": size.height, + ], + ] as [String: Any] + ) + } + ) + + result(nil) + } + + private func handleSetSizeMethodCall( + _ arguments: Any?, + _ result: FlutterResult + ) { + let args = arguments as? [String: Any] + let handleStr = args?["handle"] as! String + let widthStr = args?["width"] as! String + let heightStr = args?["height"] as! String + + let handle: Int64? = Int64(handleStr) + let width: Int64? = Int64(widthStr) + let height: Int64? = Int64(heightStr) + + assert(handle != nil, "handle must be an Int64") + + self.videoOutputManager.setSize( + handle: handle!, + width: width, + height: height + ) + + result(nil) + } + + private func handleDisposeMethodCall( + _ arguments: Any?, + _ result: FlutterResult + ) { + let args = arguments as? [String: Any] + let handleStr = args?["handle"] as! String + let handle: Int64? = Int64(handleStr) + + assert(handle != nil, "handle must be an Int64") + + videoOutputManager.destroy( + handle: handle! + ) + + result(nil) + } + + private func handleEnterNativeFullscreenMethodCall( + _: Any?, + _ result: FlutterResult + ) { + if utils == nil { + return result(FlutterMethodNotImplemented) + } + + utils?.enterNativeFullscreen() + result(nil) + } + + private func handleExitNativeFullscreenMethodCall( + _: Any?, + _ result: FlutterResult + ) { + if utils == nil { + return result(FlutterMethodNotImplemented) + } + + utils?.exitNativeFullscreen() + result(nil) + } +} diff --git a/third_party/media_kit_video/common/darwin/Classes/plugin/ResizableTextureProtocol.swift b/third_party/media_kit_video/common/darwin/Classes/plugin/ResizableTextureProtocol.swift new file mode 100644 index 0000000..eae5f7c --- /dev/null +++ b/third_party/media_kit_video/common/darwin/Classes/plugin/ResizableTextureProtocol.swift @@ -0,0 +1,10 @@ +#if canImport(Flutter) + import Flutter +#elseif canImport(FlutterMacOS) + import FlutterMacOS +#endif + +public protocol ResizableTextureProtocol: NSObject, FlutterTexture { + func resize(_ size: CGSize) + func render(_ size: CGSize) +} diff --git a/third_party/media_kit_video/common/darwin/Classes/plugin/SafeResizableTexture.swift b/third_party/media_kit_video/common/darwin/Classes/plugin/SafeResizableTexture.swift new file mode 100644 index 0000000..58fc4bf --- /dev/null +++ b/third_party/media_kit_video/common/darwin/Classes/plugin/SafeResizableTexture.swift @@ -0,0 +1,44 @@ +#if canImport(Flutter) + import Flutter +#elseif canImport(FlutterMacOS) + import FlutterMacOS +#endif + +// This class avoids data race when called from a thread +public class SafeResizableTexture: + NSObject, + FlutterTexture, + ResizableTextureProtocol +{ + private let lock = NSRecursiveLock() + private let child: ResizableTextureProtocol + + init(_ child: ResizableTextureProtocol) { + self.child = child + } + + public func resize(_ size: CGSize) { + return locked { + return child.resize(size) + } + } + + public func render(_ size: CGSize) { + return locked { + return child.render(size) + } + } + + public func copyPixelBuffer() -> Unmanaged? { + return child.copyPixelBuffer() + } + + private func locked(do block: () -> T) -> T { + lock.lock() + defer { + lock.unlock() + } + + return block() + } +} diff --git a/third_party/media_kit_video/common/darwin/Classes/plugin/SwappableObjectManager.swift b/third_party/media_kit_video/common/darwin/Classes/plugin/SwappableObjectManager.swift new file mode 100644 index 0000000..2bdb50a --- /dev/null +++ b/third_party/media_kit_video/common/darwin/Classes/plugin/SwappableObjectManager.swift @@ -0,0 +1,106 @@ +// This class was created to prevent a frameBuffer read by Flutter from being +// concurrently modified by a write method (mpv's renderer). +// +// To do this, two pools are set up, one for frameBuffers available for writing, +// the other for frameBuffers ready to be read by Flutter. +// +// When a frameBuffer has finished being modified and is "pushed", the oldest +// ready frameBuffer is marked as the current frameBuffer, and the oldest +// current frameBuffer is placed in the pool of frameBuffers available for +// writing. +// +// The use of at least three frameBuffers ensures that the read and write phases +// do not overlap, thus eliminating flicker. +public class SwappableObjectManager { + private let lock: NSRecursiveLock = NSRecursiveLock() + private var available: [T] + private var ready: [T] = [] + private var _current: T? + + init(objects: [T], skipCheckArgs: Bool = false) { + if !skipCheckArgs { + SwappableObjectManager.checkArgs(objects) + } + + available = objects + } + + public func reinit(objects: [T], skipCheckArgs: Bool = false) { + if !skipCheckArgs { + SwappableObjectManager.checkArgs(objects) + } + + lock.lock() + defer { + lock.unlock() + } + + available = objects + ready = [] + _current = nil + } + + public func nextAvailable() -> T? { + lock.lock() + defer { + lock.unlock() + } + + let object: T? = + available.count > 0 + ? available.removeFirst() + : nil + + return object + } + + public func pushAsReady(_ object: T) { + lock.lock() + defer { + lock.unlock() + } + + ready.append(object) + updateCurrent() + } + + public var current: T? { + lock.lock() + defer { + lock.unlock() + } + + return _current + } + + private func updateCurrent() { + lock.lock() + defer { + lock.unlock() + } + + let next: T? = + ready.count > 0 + ? ready.removeFirst() + : nil + + if next == nil { + return + } + + let old: T? = _current + _current = next + + if old == nil { + return + } + + available.append(old!) + } + + static private func checkArgs(_ objects: [T]) { + if objects.count < 2 { + NSLog("SwappableObjectManager: require at least two objects to work") + } + } +} diff --git a/third_party/media_kit_video/common/darwin/Classes/plugin/TextureSW.swift b/third_party/media_kit_video/common/darwin/Classes/plugin/TextureSW.swift new file mode 100644 index 0000000..7745f31 --- /dev/null +++ b/third_party/media_kit_video/common/darwin/Classes/plugin/TextureSW.swift @@ -0,0 +1,146 @@ +#if canImport(Flutter) + import Flutter +#elseif canImport(FlutterMacOS) + import FlutterMacOS +#endif + +public class TextureSW: NSObject, FlutterTexture, ResizableTextureProtocol { + public typealias UpdateCallback = () -> Void + + private let handle: OpaquePointer + private let updateCallback: UpdateCallback + private var renderContext: OpaquePointer? + private var textureContexts = SwappableObjectManager( + objects: [], + skipCheckArgs: true + ) + + init( + handle: OpaquePointer, + updateCallback: @escaping UpdateCallback + ) { + self.handle = handle + self.updateCallback = updateCallback + + super.init() + + DispatchQueue.main.async { + self.initMPV() + } + } + + deinit { + disposePixelBuffer() + disposeMPV() + } + + public func copyPixelBuffer() -> Unmanaged? { + let textureContext = textureContexts.current + if textureContext == nil { + return nil + } + + return Unmanaged.passRetained(textureContext!.pixelBuffer) + } + + private func initMPV() { + let api = UnsafeMutableRawPointer( + mutating: (MPV_RENDER_API_TYPE_SW as NSString).utf8String + ) + var params: [mpv_render_param] = [ + mpv_render_param(type: MPV_RENDER_PARAM_API_TYPE, data: api), + mpv_render_param(type: MPV_RENDER_PARAM_INVALID, data: nil), + ] + + MPVHelpers.checkError( + mpv_render_context_create(&renderContext, handle, ¶ms) + ) + + mpv_render_context_set_update_callback( + renderContext, + { (ctx) in + let that = unsafeBitCast(ctx, to: TextureSW.self) + DispatchQueue.main.async { + that.updateCallback() + } + }, + UnsafeMutableRawPointer(Unmanaged.passUnretained(self).toOpaque()) + ) + } + + private func disposeMPV() { + mpv_render_context_set_update_callback(renderContext, nil, nil) + mpv_render_context_free(renderContext) + } + + public func resize(_ size: CGSize) { + if size.width == 0 || size.height == 0 { + return + } + + NSLog("TextureSW: resize: \(size.width)x\(size.height)") + createPixelBuffer(size) + } + + private func createPixelBuffer(_ size: CGSize) { + disposePixelBuffer() + + textureContexts.reinit( + objects: [ + TextureSWContext(size: size), + TextureSWContext(size: size), + TextureSWContext(size: size), + ], + skipCheckArgs: true + ) + } + + private func disposePixelBuffer() { + textureContexts.reinit(objects: [], skipCheckArgs: true) + } + + public func render(_ size: CGSize) { + let textureContext = textureContexts.nextAvailable() + if textureContext == nil { + return + } + + CVPixelBufferLockBaseAddress( + textureContext!.pixelBuffer, + CVPixelBufferLockFlags(rawValue: 0) + ) + defer { + CVPixelBufferUnlockBaseAddress( + textureContext!.pixelBuffer, + CVPixelBufferLockFlags(rawValue: 0) + ) + } + + var ssize: [Int32] = [Int32(size.width), Int32(size.height)] + let format: String = "bgr0" + var pitch: Int = CVPixelBufferGetBytesPerRow(textureContext!.pixelBuffer) + let buffer = CVPixelBufferGetBaseAddress(textureContext!.pixelBuffer) + + // pointers + let ssizePtr = ssize.withUnsafeMutableBytes { + $0.baseAddress?.assumingMemoryBound(to: Int32.self) + } + let formatPtr = UnsafeMutablePointer( + mutating: (format as NSString).utf8String + ) + let pitchPtr = withUnsafeMutablePointer(to: &pitch) { $0 } + let bufferPtr = buffer!.assumingMemoryBound(to: UInt8.self) + + var params: [mpv_render_param] = [ + mpv_render_param(type: MPV_RENDER_PARAM_SW_SIZE, data: ssizePtr), + mpv_render_param(type: MPV_RENDER_PARAM_SW_FORMAT, data: formatPtr), + mpv_render_param(type: MPV_RENDER_PARAM_SW_STRIDE, data: pitchPtr), + mpv_render_param(type: MPV_RENDER_PARAM_SW_POINTER, data: bufferPtr), + mpv_render_param(type: MPV_RENDER_PARAM_INVALID, data: nil), + ] + + mpv_render_context_render(renderContext, ¶ms) + + textureContexts.pushAsReady(textureContext!) + } +} diff --git a/third_party/media_kit_video/common/darwin/Classes/plugin/TextureSWContext.swift b/third_party/media_kit_video/common/darwin/Classes/plugin/TextureSWContext.swift new file mode 100644 index 0000000..db329c4 --- /dev/null +++ b/third_party/media_kit_video/common/darwin/Classes/plugin/TextureSWContext.swift @@ -0,0 +1,36 @@ +public class TextureSWContext { + public let pixelBuffer: CVPixelBuffer + + init(size: CGSize) { + self.pixelBuffer = TextureSWContext.createPixelBuffer(size) + } + + deinit { + TextureSWContext.disposePixelBuffer(pixelBuffer) + } + + private static func createPixelBuffer(_ size: CGSize) -> CVPixelBuffer { + let attrs = + [ + kCVPixelBufferMetalCompatibilityKey: true + ] as CFDictionary + + var pixelBuffer: CVPixelBuffer? + let cvret = CVPixelBufferCreate( + kCFAllocatorDefault, + Int(size.width), + Int(size.height), + kCVPixelFormatType_32BGRA, + attrs, + &pixelBuffer + ) + assert(cvret == kCVReturnSuccess, "CVPixelBufferCreate") + + return pixelBuffer! + } + + private static func disposePixelBuffer(_ pixelBuffer: CVPixelBuffer) { + // 'CVPixelBufferRelease' is unavailable: Core Foundation objects are + // automatically memory managed + } +} diff --git a/third_party/media_kit_video/common/darwin/Classes/plugin/UtilsProtocol.swift b/third_party/media_kit_video/common/darwin/Classes/plugin/UtilsProtocol.swift new file mode 100644 index 0000000..6d6fda1 --- /dev/null +++ b/third_party/media_kit_video/common/darwin/Classes/plugin/UtilsProtocol.swift @@ -0,0 +1,4 @@ +public protocol UtilsProtocol: NSObject { + func enterNativeFullscreen() + func exitNativeFullscreen() +} diff --git a/third_party/media_kit_video/common/darwin/Classes/plugin/VideoOutput.swift b/third_party/media_kit_video/common/darwin/Classes/plugin/VideoOutput.swift new file mode 100644 index 0000000..9441c7e --- /dev/null +++ b/third_party/media_kit_video/common/darwin/Classes/plugin/VideoOutput.swift @@ -0,0 +1,199 @@ +#if canImport(Flutter) + import Flutter +#elseif canImport(FlutterMacOS) + import FlutterMacOS +#endif + +// This class creates and manipulates the different types of FlutterTexture, +// handles resizing, rendering calls, and notify Flutter when a new frame is +// available to render. +// +// To improve the user experience, a worker is used to execute heavy tasks on a +// dedicated thread. +public class VideoOutput: NSObject { + // Will be called on the main thread + public typealias TextureUpdateCallback = (Int64, CGSize) -> Void + + private static let isSimulator: Bool = { + let isSim: Bool + #if targetEnvironment(simulator) + isSim = true + #else + isSim = false + #endif + return isSim + }() + + private let handle: OpaquePointer + private let enableHardwareAcceleration: Bool + private let registry: FlutterTextureRegistry + private let textureUpdateCallback: TextureUpdateCallback + private let worker: Worker = .init() + private var width: Int64? + private var height: Int64? + private var texture: ResizableTextureProtocol! + private var textureId: Int64 = -1 + private var currentSize: CGSize = CGSize.zero + private var disposed: Bool = false + + init( + handle: Int64, + configuration: VideoOutputConfiguration, + registry: FlutterTextureRegistry, + textureUpdateCallback: @escaping TextureUpdateCallback + ) { + let handle = OpaquePointer(bitPattern: Int(handle)) + assert(handle != nil, "handle casting") + + self.handle = handle! + width = configuration.width + height = configuration.height + enableHardwareAcceleration = configuration.enableHardwareAcceleration + self.registry = registry + self.textureUpdateCallback = textureUpdateCallback + + super.init() + + worker.enqueue { + self._init() + } + } + + deinit { + worker.cancel() + + disposed = true + disposeTextureId() + } + + public func setSize(width: Int64?, height: Int64?) { + worker.enqueue { + self.width = width + self.height = height + } + } + + private func _init() { + let enableHardwareAcceleration = + VideoOutput.isSimulator ? false : enableHardwareAcceleration + + NSLog( + "VideoOutput: enableHardwareAcceleration: \(enableHardwareAcceleration)" + ) + + if VideoOutput.isSimulator { + NSLog( + "VideoOutput: warning: hardware rendering is disabled in the iOS simulator, due to an incompatibility with OpenGL ES" + ) + } + + if enableHardwareAcceleration { + texture = SafeResizableTexture( + TextureHW( + handle: handle, + // Use `weak self` to prevent memory leaks + updateCallback: { [weak self]() in + guard let that = self else { + return + } + that.updateCallback() + } + ) + ) + } else { + texture = SafeResizableTexture( + TextureSW( + handle: handle, + // Use `weak self` to prevent memory leaks + updateCallback: { [weak self]() in + guard let that = self else { + return + } + that.updateCallback() + } + ) + ) + } + + DispatchQueue.main.sync { [weak self]() in + guard let that = self else { + return + } + that.registerTextureId() + } + } + + // Must be run on the main thread + private func registerTextureId() { + // Textures must be registered on the platform thread. + textureId = registry.register(texture) + // textureUpdateCallback must run on the main thread + textureUpdateCallback(textureId, CGSize(width: 0, height: 0)) + } + + private func disposeTextureId() { + let registry_ = self.registry + let textureId_ = self.textureId + textureId = -1 + DispatchQueue.main.async { + // Textures must be unregistered on the platform thread + registry_.unregisterTexture(textureId_) + } + } + + public func updateCallback() { + worker.enqueue { + self._updateCallback() + } + } + + private func _updateCallback() { + let size = videoSize + + if size.width == 0 || size.height == 0 { + return + } + + if currentSize != size { + currentSize = size + + texture.resize(size) + DispatchQueue.main.sync { [weak self] in + guard let that = self else { return } + // textureUpdateCallback must run on the main thread + that.textureUpdateCallback(that.textureId, size) + } + } + + if disposed { + return + } + + texture.render(size) + DispatchQueue.main.sync { [weak self] in + guard let that = self else { return } + // Textures must be marked as available from the main thread + that.registry.textureFrameAvailable(that.textureId) + } + } + + private var videoSize: CGSize { + // fixed size + if width != nil && height != nil { + return CGSize( + width: Double(width!), + height: Double(height!) + ) + } + + let params = MPVHelpers.getVideoOutParams(handle) + return CGSize( + width: Double(width ?? (params.rotate == 0 || params.rotate == 180 + ? params.dw + : params.dh)), + height: Double(height ?? (params.rotate == 0 || params.rotate == 180 + ? params.dh + : params.dw)) + ) + } +} diff --git a/third_party/media_kit_video/common/darwin/Classes/plugin/VideoOutputConfiguration.swift b/third_party/media_kit_video/common/darwin/Classes/plugin/VideoOutputConfiguration.swift new file mode 100644 index 0000000..1437873 --- /dev/null +++ b/third_party/media_kit_video/common/darwin/Classes/plugin/VideoOutputConfiguration.swift @@ -0,0 +1,33 @@ +public class VideoOutputConfiguration { + public let width: Int64? + public let height: Int64? + public let enableHardwareAcceleration: Bool + + init( + width: Int64?, + height: Int64?, + enableHardwareAcceleration: Bool + ) { + self.width = width + self.height = height + self.enableHardwareAcceleration = enableHardwareAcceleration + } + + public static func fromDict(_ dict: [String: Any]) + -> VideoOutputConfiguration + { + let widthStr = dict["width"] as! String + let heightStr = dict["height"] as! String + let enableHardwareAcceleration = + dict["enableHardwareAcceleration"] as! Bool + + let width: Int64? = Int64(widthStr) + let height: Int64? = Int64(heightStr) + + return VideoOutputConfiguration( + width: width, + height: height, + enableHardwareAcceleration: enableHardwareAcceleration + ) + } +} diff --git a/third_party/media_kit_video/common/darwin/Classes/plugin/VideoOutputManager.swift b/third_party/media_kit_video/common/darwin/Classes/plugin/VideoOutputManager.swift new file mode 100644 index 0000000..0536a2b --- /dev/null +++ b/third_party/media_kit_video/common/darwin/Classes/plugin/VideoOutputManager.swift @@ -0,0 +1,56 @@ +#if canImport(Flutter) + import Flutter +#elseif canImport(FlutterMacOS) + import FlutterMacOS +#endif + +public class VideoOutputManager: NSObject { + private let registry: FlutterTextureRegistry + private var videoOutputs = [Int64: VideoOutput]() + + init(registry: FlutterTextureRegistry) { + self.registry = registry + } + + public func create( + handle: Int64, + configuration: VideoOutputConfiguration, + textureUpdateCallback: @escaping VideoOutput.TextureUpdateCallback + ) { + let videoOutput = VideoOutput( + handle: handle, + configuration: configuration, + registry: self.registry, + textureUpdateCallback: textureUpdateCallback + ) + + self.videoOutputs[handle] = videoOutput + } + + public func setSize( + handle: Int64, + width: Int64?, + height: Int64? + ) { + let videoOutput = self.videoOutputs[handle] + if videoOutput == nil { + return + } + + videoOutput!.setSize( + width: width, + height: height + ) + } + + public func destroy( + handle: Int64 + ) { + let videoOutput = self.videoOutputs[handle] + if videoOutput == nil { + return + } + + self.videoOutputs[handle] = nil + } +} diff --git a/third_party/media_kit_video/common/darwin/Classes/plugin/Worker.swift b/third_party/media_kit_video/common/darwin/Classes/plugin/Worker.swift new file mode 100644 index 0000000..dbb2c0b --- /dev/null +++ b/third_party/media_kit_video/common/darwin/Classes/plugin/Worker.swift @@ -0,0 +1,74 @@ +// https://stackoverflow.com/questions/49043257/how-to-ensure-to-run-some-code-on-same-background-thread/49075382#49075382 +class Worker { + public typealias Job = () -> Void + + private let semaphore = DispatchSemaphore(value: 0) + private let lock = NSRecursiveLock() + private var thread: Thread! + private var queue = [Job]() + private var canceled: Bool = false + + init() { + thread = Thread(block: loop) + thread.start() + } + + public func cancel() { + signalCancel() + thread.cancel() + } + + public func enqueue(_ job: @escaping Job) { + locked { + queue.append(job) + } + + semaphore.signal() + } + + private func loop() { + while true { + semaphore.wait() + + if isCanceled() { + return + } + + let job = getFirstJob() + job() + } + } + + private func signalCancel() { + locked { + canceled = true + } + + semaphore.signal() + } + + private func isCanceled() -> Bool { + let c = locked { + canceled + } + + return c + } + + private func getFirstJob() -> Job { + let job = locked { + queue.removeFirst() + } + + return job + } + + private func locked(do block: () -> T) -> T { + lock.lock() + defer { + lock.unlock() + } + + return block() + } +} diff --git a/third_party/media_kit_video/common/darwin/Classes/stub/MediaKitVideoPlugin.swift b/third_party/media_kit_video/common/darwin/Classes/stub/MediaKitVideoPlugin.swift new file mode 100644 index 0000000..6a4e706 --- /dev/null +++ b/third_party/media_kit_video/common/darwin/Classes/stub/MediaKitVideoPlugin.swift @@ -0,0 +1,9 @@ +#if canImport(Flutter) + import Flutter +#elseif canImport(FlutterMacOS) + import FlutterMacOS +#endif + +public class MediaKitVideoPlugin: NSObject, FlutterPlugin { + public static func register(with _: FlutterPluginRegistrar) {} +} diff --git a/third_party/media_kit_video/common/darwin/Makefile b/third_party/media_kit_video/common/darwin/Makefile new file mode 100644 index 0000000..d77cd39 --- /dev/null +++ b/third_party/media_kit_video/common/darwin/Makefile @@ -0,0 +1,29 @@ +SPACE := $(subst ,, ) +HEADERS_DESTDIR_ESCAPED=$(subst $(SPACE),\ ,${HEADERS_DESTDIR}) + +all: ${HEADERS_DESTDIR_ESCAPED}/mpv/*.h + +MPV_HEADERS_VERSION=v0.36.0 +MPV_HEADERS_SHA256SUM=29abc44f8ebee013bb2f9fe14d80b30db19b534c679056e4851ceadf5a5e8bf6 + +TAR=tar +TAR_WILDCARDS_FLAG := $(shell ${TAR} --version 2>&1 | grep -q 'GNU' && echo "--wildcards" || echo "") + +.cache/headers/mpv-${MPV_HEADERS_VERSION}.tar.gz: + mkdir -p .cache/headers + rm -f .cache/headers/*.tmp .cache/headers/*.tar.gz + curl -L \ + https://github.com/mpv-player/mpv/archive/refs/tags/${MPV_HEADERS_VERSION}.tar.gz \ + -o .cache/headers/mpv.tar.gz.tmp + shasum -a 256 -c <<< '${MPV_HEADERS_SHA256SUM} .cache/headers/mpv.tar.gz.tmp' + mv .cache/headers/mpv.tar.gz.tmp .cache/headers/mpv-${MPV_HEADERS_VERSION}.tar.gz + touch .cache/headers/mpv-${MPV_HEADERS_VERSION}.tar.gz + +.cache/headers/mpv.tar.gz: .cache/headers/mpv-${MPV_HEADERS_VERSION}.tar.gz + rm -f .cache/headers/mpv.tar.gz + ln -s mpv-${MPV_HEADERS_VERSION}.tar.gz .cache/headers/mpv.tar.gz + +${HEADERS_DESTDIR_ESCAPED}/mpv/*.h: .cache/headers/mpv.tar.gz + mkdir -p ${HEADERS_DESTDIR_ESCAPED}/mpv + ${TAR} ${TAR_WILDCARDS_FLAG} -xvf .cache/headers/mpv.tar.gz --strip-components 2 -C ${HEADERS_DESTDIR_ESCAPED}/mpv/ 'mpv-*/libmpv/*.h' + touch ${HEADERS_DESTDIR_ESCAPED}/mpv/*.h diff --git a/third_party/media_kit_video/common/darwin/Podspec/media_kit_utils.rb b/third_party/media_kit_video/common/darwin/Podspec/media_kit_utils.rb new file mode 100644 index 0000000..7025333 --- /dev/null +++ b/third_party/media_kit_video/common/darwin/Podspec/media_kit_utils.rb @@ -0,0 +1,95 @@ +class MediaKitUtils + attr_accessor :libs_found, :libs_package + + module Platform + IOS ||= 'ios' + MACOS ||= 'macos' + end + + module Type + AUDIO ||= 'audio' + VIDEO ||= 'video' + end + + def initialize(platform) + raise "Expecting '#{Platform::IOS}' or '#{Platform::MACOS}': platform = #{platform}" if + !(platform == Platform::IOS || platform == Platform::MACOS) + + # Find the nearest path to `pubspec.lock` using `PWD` env var set by + # `cocoapods` + current_dir = ENV['PWD'] || '/' + pubspec_lock_path = MediaKitUtils::find_nearest_pubspec_lock(current_dir) + + # If not found try again using `PWD_FALLBACK` env var manually set, + # `PWD_FALLBACK` must point to app folder or any child folder + if pubspec_lock_path == '' + current_dir = ENV['PWD_FALLBACK'] || '/' + pubspec_lock_path = MediaKitUtils::find_nearest_pubspec_lock(current_dir) + end + + # Abort if no `pubspec.lock` was found + if pubspec_lock_path == '' + abort( + sprintf( + 'media_kit: ERROR: No pubspec.lock was found: ENV["PWD"] = "%s"', + ENV["PWD"] + ) + ) + end + + # Load packages from `pubspec.lock` + pubspec_lock = YAML.load_file(pubspec_lock_path) + packages = pubspec_lock['packages'] + + # Checks for `media_kit_libs_***` in `pubspec.lock` + libs_count = 0 + @libs_package = '' + Type.constants.each do |constant| + type = Type.const_get(constant) + package = sprintf('media_kit_libs_%s_%s', platform, type) + if packages.keys.include?(package) + libs_count += 1 + @libs_package = package + + puts sprintf('media_kit: INFO: package:%s found', package) + end + end + + # Abort if multiple `media_kit_libs_***` was found + if libs_count > 1 + abort( + sprintf( + 'media_kit: ERROR: package:media_kit_libs_%s_*** must be uniq', + platform, + ) + ) + end + + @libs_found = libs_count > 0 + + # Warn if no `media_kit_libs_*` was found + if !@libs_found + warn( + sprintf( + 'media_kit: WARNING: package:media_kit_libs_%s_*** not found', + platform + ) + ) + end + end + + # Looks for the nearest `pubspec.lock` by recursively ascending through the + # parent folders + def self.find_nearest_pubspec_lock(current_dir) + while current_dir != '/' + path = File.join(current_dir, 'pubspec.lock') + if File.exist?(path) + return path + else + current_dir = File.expand_path('..', current_dir) + end + end + + return '' + end +end diff --git a/third_party/media_kit_video/ios/Classes/plugin/TextureHW.swift b/third_party/media_kit_video/ios/Classes/plugin/TextureHW.swift new file mode 100644 index 0000000..a3c3acd --- /dev/null +++ b/third_party/media_kit_video/ios/Classes/plugin/TextureHW.swift @@ -0,0 +1,205 @@ +import Flutter +import OpenGLES + +public class TextureHW: NSObject, FlutterTexture, ResizableTextureProtocol { + public typealias UpdateCallback = () -> Void + + private let handle: OpaquePointer + private let updateCallback: UpdateCallback + private let context: EAGLContext + private let textureCache: CVOpenGLESTextureCache + private var renderContext: OpaquePointer? + private var textureContexts = SwappableObjectManager( + objects: [], + skipCheckArgs: true + ) + + init( + handle: OpaquePointer, + updateCallback: @escaping UpdateCallback + ) { + self.handle = handle + self.updateCallback = updateCallback + self.context = OpenGLESHelpers.createContext() + self.textureCache = OpenGLESHelpers.createTextureCache(context) + + super.init() + + self.initMPV() + } + + deinit { + disposePixelBuffer() + disposeMPV() + OpenGLESHelpers.deleteTextureCache(textureCache) + + // Deleting the context may cause potential RAM or VRAM memory leaks, as it + // is used in the `deinit` method of the `TextureGLESContext`. + // Potential fix: use a counter, and delete it only when the counter reaches + // zero + OpenGLESHelpers.deleteContext(context) + } + + public func copyPixelBuffer() -> Unmanaged? { + let textureContext = textureContexts.current + if textureContext == nil { + return nil + } + + return Unmanaged.passRetained(textureContext!.pixelBuffer) + } + + private func initMPV() { + EAGLContext.setCurrent(context) + defer { + OpenGLESHelpers.checkError("initMPV") + EAGLContext.setCurrent(nil) + } + + let api = UnsafeMutableRawPointer( + mutating: (MPV_RENDER_API_TYPE_OPENGL as NSString).utf8String + ) + var procAddress = mpv_opengl_init_params( + get_proc_address: { + (ctx, name) in + return TextureHW.getProcAddress(ctx, name) + }, + get_proc_address_ctx: nil + ) + + var params: [mpv_render_param] = withUnsafeMutableBytes(of: &procAddress) { + procAddress in + return [ + mpv_render_param(type: MPV_RENDER_PARAM_API_TYPE, data: api), + mpv_render_param( + type: MPV_RENDER_PARAM_OPENGL_INIT_PARAMS, + data: procAddress.baseAddress.map { + UnsafeMutableRawPointer($0) + } + ), + mpv_render_param(), + ] + } + + MPVHelpers.checkError( + mpv_render_context_create(&renderContext, handle, ¶ms) + ) + + mpv_render_context_set_update_callback( + renderContext, + { (ctx) in + let that = unsafeBitCast(ctx, to: TextureHW.self) + DispatchQueue.main.async { + that.updateCallback() + } + }, + UnsafeMutableRawPointer(Unmanaged.passUnretained(self).toOpaque()) + ) + } + + private func disposeMPV() { + EAGLContext.setCurrent(context) + defer { + OpenGLESHelpers.checkError("disposeMPV") + EAGLContext.setCurrent(nil) + } + + mpv_render_context_set_update_callback(renderContext, nil, nil) + mpv_render_context_free(renderContext) + } + + public func resize(_ size: CGSize) { + if size.width == 0 || size.height == 0 { + return + } + + NSLog("TextureGL: resize: \(size.width)x\(size.height)") + createPixelBuffer(size) + } + + private func createPixelBuffer(_ size: CGSize) { + disposePixelBuffer() + + textureContexts.reinit( + objects: [ + TextureGLESContext( + context: context, + textureCache: textureCache, + size: size + ), + TextureGLESContext( + context: context, + textureCache: textureCache, + size: size + ), + TextureGLESContext( + context: context, + textureCache: textureCache, + size: size + ), + ], + skipCheckArgs: true + ) + } + + private func disposePixelBuffer() { + textureContexts.reinit(objects: [], skipCheckArgs: true) + } + + public func render(_ size: CGSize) { + let textureContext = textureContexts.nextAvailable() + if textureContext == nil { + return + } + + EAGLContext.setCurrent(context) + defer { + OpenGLESHelpers.checkError("render") + EAGLContext.setCurrent(nil) + } + + glBindFramebuffer(GLenum(GL_FRAMEBUFFER), textureContext!.frameBuffer) + defer { + glBindFramebuffer(GLenum(GL_FRAMEBUFFER), 0) + } + + var fbo = mpv_opengl_fbo( + fbo: Int32(textureContext!.frameBuffer), + w: Int32(size.width), + h: Int32(size.height), + internal_format: 0 + ) + let fboPtr = withUnsafeMutablePointer(to: &fbo) { $0 } + + var params: [mpv_render_param] = [ + mpv_render_param(type: MPV_RENDER_PARAM_OPENGL_FBO, data: fboPtr), + mpv_render_param(type: MPV_RENDER_PARAM_INVALID, data: nil), + ] + + mpv_render_context_render(renderContext, ¶ms) + + glFlush() + + textureContexts.pushAsReady(textureContext!) + } + + static private func getProcAddress( + _ ctx: UnsafeMutableRawPointer?, + _ name: UnsafePointer? + ) -> UnsafeMutableRawPointer? { + let symbol: CFString = CFStringCreateWithCString( + kCFAllocatorDefault, + name, + kCFStringEncodingASCII + ) + let indentifier = CFBundleGetBundleWithIdentifier( + "com.apple.opengles" as CFString + ) + let addr = CFBundleGetFunctionPointerForName(indentifier, symbol) + + if addr == nil { + NSLog("Cannot get OpenGLES function pointer!") + } + return addr + } +} diff --git a/third_party/media_kit_video/ios/Classes/plugin/common/MPVHelpers.swift b/third_party/media_kit_video/ios/Classes/plugin/common/MPVHelpers.swift new file mode 100644 index 0000000..26b6d18 --- /dev/null +++ b/third_party/media_kit_video/ios/Classes/plugin/common/MPVHelpers.swift @@ -0,0 +1,30 @@ +public enum MPVHelpers { + public static func checkError(_ status: CInt) { + if status < 0 { + NSLog("MPVHelpers: error: \(String(cString: mpv_error_string(status)))") + exit(1) + } + } + + public static func getVideoOutParams( + _ handle: OpaquePointer + ) -> MPVVideoOutParams { + var node = mpv_node() + defer { + mpv_free_node_contents(&node) + } + + mpv_get_property(handle, "video-out-params", MPV_FORMAT_NODE, &node) + + if node.format != MPV_FORMAT_NODE_MAP { + return MPVVideoOutParams.empty + } + + let map: mpv_node_list = node.u.list!.pointee + if map.num == 0 { + return MPVVideoOutParams.empty + } + + return MPVVideoOutParams.fromMPVNodeList(map) + } +} diff --git a/third_party/media_kit_video/ios/Classes/plugin/common/MPVVideoOutParams.swift b/third_party/media_kit_video/ios/Classes/plugin/common/MPVVideoOutParams.swift new file mode 100644 index 0000000..160ea57 --- /dev/null +++ b/third_party/media_kit_video/ios/Classes/plugin/common/MPVVideoOutParams.swift @@ -0,0 +1,55 @@ +public class MPVVideoOutParams { + public let dw: Int64 + public let dh: Int64 + public let rotate: Int64 + + init( + dw: Int64, + dh: Int64, + rotate: Int64 + ) { + self.dw = dw + self.dh = dh + self.rotate = rotate + } + + public static let empty = MPVVideoOutParams( + dw: 0, + dh: 0, + rotate: 0 + ) + + public static func fromMPVNodeList( + _ map: mpv_node_list + ) -> MPVVideoOutParams { + var dw: Int64 = 0 + var dh: Int64 = 0 + var rotate: Int64 = 0 + + var kptr = map.keys! + var vptr = map.values! + for _ in 0 ..< map.num { + let key = String(cString: kptr.pointee!) + let value: mpv_node = vptr.pointee + + kptr = kptr.successor() + vptr = vptr.successor() + + if value.format == MPV_FORMAT_INT64 { + if key == "dw" { + dw = value.u.int64 + } else if key == "dh" { + dh = value.u.int64 + } else if key == "rotate" { + rotate = value.u.int64 + } + } + } + + return MPVVideoOutParams( + dw: dw, + dh: dh, + rotate: rotate + ) + } +} diff --git a/third_party/media_kit_video/ios/Classes/plugin/common/MediaKitVideoPlugin.swift b/third_party/media_kit_video/ios/Classes/plugin/common/MediaKitVideoPlugin.swift new file mode 100644 index 0000000..f5f9a29 --- /dev/null +++ b/third_party/media_kit_video/ios/Classes/plugin/common/MediaKitVideoPlugin.swift @@ -0,0 +1,168 @@ +#if canImport(Flutter) + import Flutter +#elseif canImport(FlutterMacOS) + import FlutterMacOS +#endif + +public class MediaKitVideoPlugin: NSObject, FlutterPlugin { + private static let CHANNEL_NAME = "com.alexmercerind/media_kit_video" + + public static func register(with registrar: FlutterPluginRegistrar) { + #if canImport(Flutter) + let binaryMessenger = registrar.messenger() + let registry = registrar.textures() + let utils: UtilsProtocol? = nil + #elseif canImport(FlutterMacOS) + let binaryMessenger = registrar.messenger + let registry = registrar.textures + let utils: UtilsProtocol? = Utils(registrar) + #endif + + let channel = FlutterMethodChannel( + name: CHANNEL_NAME, + binaryMessenger: binaryMessenger + ) + let instance = MediaKitVideoPlugin( + registry: registry, + channel: channel, + utils: utils + ) + registrar.addMethodCallDelegate(instance, channel: channel) + } + + private let channel: FlutterMethodChannel + private let videoOutputManager: VideoOutputManager + private let utils: UtilsProtocol? + + init( + registry: FlutterTextureRegistry, + channel: FlutterMethodChannel, + utils: UtilsProtocol? + ) { + self.channel = channel + videoOutputManager = VideoOutputManager( + registry: registry + ) + self.utils = utils + } + + public func handle( + _ call: FlutterMethodCall, + result: @escaping FlutterResult + ) { + switch call.method { + case "VideoOutputManager.Create": + handleCreateMethodCall(call.arguments, result) + case "VideoOutputManager.SetSize": + handleSetSizeMethodCall(call.arguments, result) + case "VideoOutputManager.Dispose": + handleDisposeMethodCall(call.arguments, result) + case "Utils.EnterNativeFullscreen": + handleEnterNativeFullscreenMethodCall(call.arguments, result) + case "Utils.ExitNativeFullscreen": + handleExitNativeFullscreenMethodCall(call.arguments, result) + default: + result(FlutterMethodNotImplemented) + } + } + + private func handleCreateMethodCall( + _ arguments: Any?, + _ result: FlutterResult + ) { + let args = arguments as? [String: Any] + let handleStr = args?["handle"] as! String + let handle: Int64? = Int64(handleStr) + let configDict = args?["configuration"] as! [String: Any] + let configuration = VideoOutputConfiguration.fromDict(configDict) + + assert(handle != nil, "handle must be an Int64") + + videoOutputManager.create( + handle: handle!, + configuration: configuration, + textureUpdateCallback: { (_ textureId: Int64, _ size: CGSize) in + self.channel.invokeMethod( + "VideoOutput.Resize", + arguments: [ + "handle": handle!, + "id": textureId, + "rect": [ + "top": 0, + "left": 0, + "width": size.width, + "height": size.height, + ], + ] as [String: Any] + ) + } + ) + + result(nil) + } + + private func handleSetSizeMethodCall( + _ arguments: Any?, + _ result: FlutterResult + ) { + let args = arguments as? [String: Any] + let handleStr = args?["handle"] as! String + let widthStr = args?["width"] as! String + let heightStr = args?["height"] as! String + + let handle: Int64? = Int64(handleStr) + let width: Int64? = Int64(widthStr) + let height: Int64? = Int64(heightStr) + + assert(handle != nil, "handle must be an Int64") + + self.videoOutputManager.setSize( + handle: handle!, + width: width, + height: height + ) + + result(nil) + } + + private func handleDisposeMethodCall( + _ arguments: Any?, + _ result: FlutterResult + ) { + let args = arguments as? [String: Any] + let handleStr = args?["handle"] as! String + let handle: Int64? = Int64(handleStr) + + assert(handle != nil, "handle must be an Int64") + + videoOutputManager.destroy( + handle: handle! + ) + + result(nil) + } + + private func handleEnterNativeFullscreenMethodCall( + _: Any?, + _ result: FlutterResult + ) { + if utils == nil { + return result(FlutterMethodNotImplemented) + } + + utils?.enterNativeFullscreen() + result(nil) + } + + private func handleExitNativeFullscreenMethodCall( + _: Any?, + _ result: FlutterResult + ) { + if utils == nil { + return result(FlutterMethodNotImplemented) + } + + utils?.exitNativeFullscreen() + result(nil) + } +} diff --git a/third_party/media_kit_video/ios/Classes/plugin/common/ResizableTextureProtocol.swift b/third_party/media_kit_video/ios/Classes/plugin/common/ResizableTextureProtocol.swift new file mode 100644 index 0000000..eae5f7c --- /dev/null +++ b/third_party/media_kit_video/ios/Classes/plugin/common/ResizableTextureProtocol.swift @@ -0,0 +1,10 @@ +#if canImport(Flutter) + import Flutter +#elseif canImport(FlutterMacOS) + import FlutterMacOS +#endif + +public protocol ResizableTextureProtocol: NSObject, FlutterTexture { + func resize(_ size: CGSize) + func render(_ size: CGSize) +} diff --git a/third_party/media_kit_video/ios/Classes/plugin/common/SafeResizableTexture.swift b/third_party/media_kit_video/ios/Classes/plugin/common/SafeResizableTexture.swift new file mode 100644 index 0000000..58fc4bf --- /dev/null +++ b/third_party/media_kit_video/ios/Classes/plugin/common/SafeResizableTexture.swift @@ -0,0 +1,44 @@ +#if canImport(Flutter) + import Flutter +#elseif canImport(FlutterMacOS) + import FlutterMacOS +#endif + +// This class avoids data race when called from a thread +public class SafeResizableTexture: + NSObject, + FlutterTexture, + ResizableTextureProtocol +{ + private let lock = NSRecursiveLock() + private let child: ResizableTextureProtocol + + init(_ child: ResizableTextureProtocol) { + self.child = child + } + + public func resize(_ size: CGSize) { + return locked { + return child.resize(size) + } + } + + public func render(_ size: CGSize) { + return locked { + return child.render(size) + } + } + + public func copyPixelBuffer() -> Unmanaged? { + return child.copyPixelBuffer() + } + + private func locked(do block: () -> T) -> T { + lock.lock() + defer { + lock.unlock() + } + + return block() + } +} diff --git a/third_party/media_kit_video/ios/Classes/plugin/common/SwappableObjectManager.swift b/third_party/media_kit_video/ios/Classes/plugin/common/SwappableObjectManager.swift new file mode 100644 index 0000000..2bdb50a --- /dev/null +++ b/third_party/media_kit_video/ios/Classes/plugin/common/SwappableObjectManager.swift @@ -0,0 +1,106 @@ +// This class was created to prevent a frameBuffer read by Flutter from being +// concurrently modified by a write method (mpv's renderer). +// +// To do this, two pools are set up, one for frameBuffers available for writing, +// the other for frameBuffers ready to be read by Flutter. +// +// When a frameBuffer has finished being modified and is "pushed", the oldest +// ready frameBuffer is marked as the current frameBuffer, and the oldest +// current frameBuffer is placed in the pool of frameBuffers available for +// writing. +// +// The use of at least three frameBuffers ensures that the read and write phases +// do not overlap, thus eliminating flicker. +public class SwappableObjectManager { + private let lock: NSRecursiveLock = NSRecursiveLock() + private var available: [T] + private var ready: [T] = [] + private var _current: T? + + init(objects: [T], skipCheckArgs: Bool = false) { + if !skipCheckArgs { + SwappableObjectManager.checkArgs(objects) + } + + available = objects + } + + public func reinit(objects: [T], skipCheckArgs: Bool = false) { + if !skipCheckArgs { + SwappableObjectManager.checkArgs(objects) + } + + lock.lock() + defer { + lock.unlock() + } + + available = objects + ready = [] + _current = nil + } + + public func nextAvailable() -> T? { + lock.lock() + defer { + lock.unlock() + } + + let object: T? = + available.count > 0 + ? available.removeFirst() + : nil + + return object + } + + public func pushAsReady(_ object: T) { + lock.lock() + defer { + lock.unlock() + } + + ready.append(object) + updateCurrent() + } + + public var current: T? { + lock.lock() + defer { + lock.unlock() + } + + return _current + } + + private func updateCurrent() { + lock.lock() + defer { + lock.unlock() + } + + let next: T? = + ready.count > 0 + ? ready.removeFirst() + : nil + + if next == nil { + return + } + + let old: T? = _current + _current = next + + if old == nil { + return + } + + available.append(old!) + } + + static private func checkArgs(_ objects: [T]) { + if objects.count < 2 { + NSLog("SwappableObjectManager: require at least two objects to work") + } + } +} diff --git a/third_party/media_kit_video/ios/Classes/plugin/common/TextureSW.swift b/third_party/media_kit_video/ios/Classes/plugin/common/TextureSW.swift new file mode 100644 index 0000000..7745f31 --- /dev/null +++ b/third_party/media_kit_video/ios/Classes/plugin/common/TextureSW.swift @@ -0,0 +1,146 @@ +#if canImport(Flutter) + import Flutter +#elseif canImport(FlutterMacOS) + import FlutterMacOS +#endif + +public class TextureSW: NSObject, FlutterTexture, ResizableTextureProtocol { + public typealias UpdateCallback = () -> Void + + private let handle: OpaquePointer + private let updateCallback: UpdateCallback + private var renderContext: OpaquePointer? + private var textureContexts = SwappableObjectManager( + objects: [], + skipCheckArgs: true + ) + + init( + handle: OpaquePointer, + updateCallback: @escaping UpdateCallback + ) { + self.handle = handle + self.updateCallback = updateCallback + + super.init() + + DispatchQueue.main.async { + self.initMPV() + } + } + + deinit { + disposePixelBuffer() + disposeMPV() + } + + public func copyPixelBuffer() -> Unmanaged? { + let textureContext = textureContexts.current + if textureContext == nil { + return nil + } + + return Unmanaged.passRetained(textureContext!.pixelBuffer) + } + + private func initMPV() { + let api = UnsafeMutableRawPointer( + mutating: (MPV_RENDER_API_TYPE_SW as NSString).utf8String + ) + var params: [mpv_render_param] = [ + mpv_render_param(type: MPV_RENDER_PARAM_API_TYPE, data: api), + mpv_render_param(type: MPV_RENDER_PARAM_INVALID, data: nil), + ] + + MPVHelpers.checkError( + mpv_render_context_create(&renderContext, handle, ¶ms) + ) + + mpv_render_context_set_update_callback( + renderContext, + { (ctx) in + let that = unsafeBitCast(ctx, to: TextureSW.self) + DispatchQueue.main.async { + that.updateCallback() + } + }, + UnsafeMutableRawPointer(Unmanaged.passUnretained(self).toOpaque()) + ) + } + + private func disposeMPV() { + mpv_render_context_set_update_callback(renderContext, nil, nil) + mpv_render_context_free(renderContext) + } + + public func resize(_ size: CGSize) { + if size.width == 0 || size.height == 0 { + return + } + + NSLog("TextureSW: resize: \(size.width)x\(size.height)") + createPixelBuffer(size) + } + + private func createPixelBuffer(_ size: CGSize) { + disposePixelBuffer() + + textureContexts.reinit( + objects: [ + TextureSWContext(size: size), + TextureSWContext(size: size), + TextureSWContext(size: size), + ], + skipCheckArgs: true + ) + } + + private func disposePixelBuffer() { + textureContexts.reinit(objects: [], skipCheckArgs: true) + } + + public func render(_ size: CGSize) { + let textureContext = textureContexts.nextAvailable() + if textureContext == nil { + return + } + + CVPixelBufferLockBaseAddress( + textureContext!.pixelBuffer, + CVPixelBufferLockFlags(rawValue: 0) + ) + defer { + CVPixelBufferUnlockBaseAddress( + textureContext!.pixelBuffer, + CVPixelBufferLockFlags(rawValue: 0) + ) + } + + var ssize: [Int32] = [Int32(size.width), Int32(size.height)] + let format: String = "bgr0" + var pitch: Int = CVPixelBufferGetBytesPerRow(textureContext!.pixelBuffer) + let buffer = CVPixelBufferGetBaseAddress(textureContext!.pixelBuffer) + + // pointers + let ssizePtr = ssize.withUnsafeMutableBytes { + $0.baseAddress?.assumingMemoryBound(to: Int32.self) + } + let formatPtr = UnsafeMutablePointer( + mutating: (format as NSString).utf8String + ) + let pitchPtr = withUnsafeMutablePointer(to: &pitch) { $0 } + let bufferPtr = buffer!.assumingMemoryBound(to: UInt8.self) + + var params: [mpv_render_param] = [ + mpv_render_param(type: MPV_RENDER_PARAM_SW_SIZE, data: ssizePtr), + mpv_render_param(type: MPV_RENDER_PARAM_SW_FORMAT, data: formatPtr), + mpv_render_param(type: MPV_RENDER_PARAM_SW_STRIDE, data: pitchPtr), + mpv_render_param(type: MPV_RENDER_PARAM_SW_POINTER, data: bufferPtr), + mpv_render_param(type: MPV_RENDER_PARAM_INVALID, data: nil), + ] + + mpv_render_context_render(renderContext, ¶ms) + + textureContexts.pushAsReady(textureContext!) + } +} diff --git a/third_party/media_kit_video/ios/Classes/plugin/common/TextureSWContext.swift b/third_party/media_kit_video/ios/Classes/plugin/common/TextureSWContext.swift new file mode 100644 index 0000000..db329c4 --- /dev/null +++ b/third_party/media_kit_video/ios/Classes/plugin/common/TextureSWContext.swift @@ -0,0 +1,36 @@ +public class TextureSWContext { + public let pixelBuffer: CVPixelBuffer + + init(size: CGSize) { + self.pixelBuffer = TextureSWContext.createPixelBuffer(size) + } + + deinit { + TextureSWContext.disposePixelBuffer(pixelBuffer) + } + + private static func createPixelBuffer(_ size: CGSize) -> CVPixelBuffer { + let attrs = + [ + kCVPixelBufferMetalCompatibilityKey: true + ] as CFDictionary + + var pixelBuffer: CVPixelBuffer? + let cvret = CVPixelBufferCreate( + kCFAllocatorDefault, + Int(size.width), + Int(size.height), + kCVPixelFormatType_32BGRA, + attrs, + &pixelBuffer + ) + assert(cvret == kCVReturnSuccess, "CVPixelBufferCreate") + + return pixelBuffer! + } + + private static func disposePixelBuffer(_ pixelBuffer: CVPixelBuffer) { + // 'CVPixelBufferRelease' is unavailable: Core Foundation objects are + // automatically memory managed + } +} diff --git a/third_party/media_kit_video/ios/Classes/plugin/common/UtilsProtocol.swift b/third_party/media_kit_video/ios/Classes/plugin/common/UtilsProtocol.swift new file mode 100644 index 0000000..6d6fda1 --- /dev/null +++ b/third_party/media_kit_video/ios/Classes/plugin/common/UtilsProtocol.swift @@ -0,0 +1,4 @@ +public protocol UtilsProtocol: NSObject { + func enterNativeFullscreen() + func exitNativeFullscreen() +} diff --git a/third_party/media_kit_video/ios/Classes/plugin/common/VideoOutput.swift b/third_party/media_kit_video/ios/Classes/plugin/common/VideoOutput.swift new file mode 100644 index 0000000..9441c7e --- /dev/null +++ b/third_party/media_kit_video/ios/Classes/plugin/common/VideoOutput.swift @@ -0,0 +1,199 @@ +#if canImport(Flutter) + import Flutter +#elseif canImport(FlutterMacOS) + import FlutterMacOS +#endif + +// This class creates and manipulates the different types of FlutterTexture, +// handles resizing, rendering calls, and notify Flutter when a new frame is +// available to render. +// +// To improve the user experience, a worker is used to execute heavy tasks on a +// dedicated thread. +public class VideoOutput: NSObject { + // Will be called on the main thread + public typealias TextureUpdateCallback = (Int64, CGSize) -> Void + + private static let isSimulator: Bool = { + let isSim: Bool + #if targetEnvironment(simulator) + isSim = true + #else + isSim = false + #endif + return isSim + }() + + private let handle: OpaquePointer + private let enableHardwareAcceleration: Bool + private let registry: FlutterTextureRegistry + private let textureUpdateCallback: TextureUpdateCallback + private let worker: Worker = .init() + private var width: Int64? + private var height: Int64? + private var texture: ResizableTextureProtocol! + private var textureId: Int64 = -1 + private var currentSize: CGSize = CGSize.zero + private var disposed: Bool = false + + init( + handle: Int64, + configuration: VideoOutputConfiguration, + registry: FlutterTextureRegistry, + textureUpdateCallback: @escaping TextureUpdateCallback + ) { + let handle = OpaquePointer(bitPattern: Int(handle)) + assert(handle != nil, "handle casting") + + self.handle = handle! + width = configuration.width + height = configuration.height + enableHardwareAcceleration = configuration.enableHardwareAcceleration + self.registry = registry + self.textureUpdateCallback = textureUpdateCallback + + super.init() + + worker.enqueue { + self._init() + } + } + + deinit { + worker.cancel() + + disposed = true + disposeTextureId() + } + + public func setSize(width: Int64?, height: Int64?) { + worker.enqueue { + self.width = width + self.height = height + } + } + + private func _init() { + let enableHardwareAcceleration = + VideoOutput.isSimulator ? false : enableHardwareAcceleration + + NSLog( + "VideoOutput: enableHardwareAcceleration: \(enableHardwareAcceleration)" + ) + + if VideoOutput.isSimulator { + NSLog( + "VideoOutput: warning: hardware rendering is disabled in the iOS simulator, due to an incompatibility with OpenGL ES" + ) + } + + if enableHardwareAcceleration { + texture = SafeResizableTexture( + TextureHW( + handle: handle, + // Use `weak self` to prevent memory leaks + updateCallback: { [weak self]() in + guard let that = self else { + return + } + that.updateCallback() + } + ) + ) + } else { + texture = SafeResizableTexture( + TextureSW( + handle: handle, + // Use `weak self` to prevent memory leaks + updateCallback: { [weak self]() in + guard let that = self else { + return + } + that.updateCallback() + } + ) + ) + } + + DispatchQueue.main.sync { [weak self]() in + guard let that = self else { + return + } + that.registerTextureId() + } + } + + // Must be run on the main thread + private func registerTextureId() { + // Textures must be registered on the platform thread. + textureId = registry.register(texture) + // textureUpdateCallback must run on the main thread + textureUpdateCallback(textureId, CGSize(width: 0, height: 0)) + } + + private func disposeTextureId() { + let registry_ = self.registry + let textureId_ = self.textureId + textureId = -1 + DispatchQueue.main.async { + // Textures must be unregistered on the platform thread + registry_.unregisterTexture(textureId_) + } + } + + public func updateCallback() { + worker.enqueue { + self._updateCallback() + } + } + + private func _updateCallback() { + let size = videoSize + + if size.width == 0 || size.height == 0 { + return + } + + if currentSize != size { + currentSize = size + + texture.resize(size) + DispatchQueue.main.sync { [weak self] in + guard let that = self else { return } + // textureUpdateCallback must run on the main thread + that.textureUpdateCallback(that.textureId, size) + } + } + + if disposed { + return + } + + texture.render(size) + DispatchQueue.main.sync { [weak self] in + guard let that = self else { return } + // Textures must be marked as available from the main thread + that.registry.textureFrameAvailable(that.textureId) + } + } + + private var videoSize: CGSize { + // fixed size + if width != nil && height != nil { + return CGSize( + width: Double(width!), + height: Double(height!) + ) + } + + let params = MPVHelpers.getVideoOutParams(handle) + return CGSize( + width: Double(width ?? (params.rotate == 0 || params.rotate == 180 + ? params.dw + : params.dh)), + height: Double(height ?? (params.rotate == 0 || params.rotate == 180 + ? params.dh + : params.dw)) + ) + } +} diff --git a/third_party/media_kit_video/ios/Classes/plugin/common/VideoOutputConfiguration.swift b/third_party/media_kit_video/ios/Classes/plugin/common/VideoOutputConfiguration.swift new file mode 100644 index 0000000..1437873 --- /dev/null +++ b/third_party/media_kit_video/ios/Classes/plugin/common/VideoOutputConfiguration.swift @@ -0,0 +1,33 @@ +public class VideoOutputConfiguration { + public let width: Int64? + public let height: Int64? + public let enableHardwareAcceleration: Bool + + init( + width: Int64?, + height: Int64?, + enableHardwareAcceleration: Bool + ) { + self.width = width + self.height = height + self.enableHardwareAcceleration = enableHardwareAcceleration + } + + public static func fromDict(_ dict: [String: Any]) + -> VideoOutputConfiguration + { + let widthStr = dict["width"] as! String + let heightStr = dict["height"] as! String + let enableHardwareAcceleration = + dict["enableHardwareAcceleration"] as! Bool + + let width: Int64? = Int64(widthStr) + let height: Int64? = Int64(heightStr) + + return VideoOutputConfiguration( + width: width, + height: height, + enableHardwareAcceleration: enableHardwareAcceleration + ) + } +} diff --git a/third_party/media_kit_video/ios/Classes/plugin/common/VideoOutputManager.swift b/third_party/media_kit_video/ios/Classes/plugin/common/VideoOutputManager.swift new file mode 100644 index 0000000..0536a2b --- /dev/null +++ b/third_party/media_kit_video/ios/Classes/plugin/common/VideoOutputManager.swift @@ -0,0 +1,56 @@ +#if canImport(Flutter) + import Flutter +#elseif canImport(FlutterMacOS) + import FlutterMacOS +#endif + +public class VideoOutputManager: NSObject { + private let registry: FlutterTextureRegistry + private var videoOutputs = [Int64: VideoOutput]() + + init(registry: FlutterTextureRegistry) { + self.registry = registry + } + + public func create( + handle: Int64, + configuration: VideoOutputConfiguration, + textureUpdateCallback: @escaping VideoOutput.TextureUpdateCallback + ) { + let videoOutput = VideoOutput( + handle: handle, + configuration: configuration, + registry: self.registry, + textureUpdateCallback: textureUpdateCallback + ) + + self.videoOutputs[handle] = videoOutput + } + + public func setSize( + handle: Int64, + width: Int64?, + height: Int64? + ) { + let videoOutput = self.videoOutputs[handle] + if videoOutput == nil { + return + } + + videoOutput!.setSize( + width: width, + height: height + ) + } + + public func destroy( + handle: Int64 + ) { + let videoOutput = self.videoOutputs[handle] + if videoOutput == nil { + return + } + + self.videoOutputs[handle] = nil + } +} diff --git a/third_party/media_kit_video/ios/Classes/plugin/common/Worker.swift b/third_party/media_kit_video/ios/Classes/plugin/common/Worker.swift new file mode 100644 index 0000000..dbb2c0b --- /dev/null +++ b/third_party/media_kit_video/ios/Classes/plugin/common/Worker.swift @@ -0,0 +1,74 @@ +// https://stackoverflow.com/questions/49043257/how-to-ensure-to-run-some-code-on-same-background-thread/49075382#49075382 +class Worker { + public typealias Job = () -> Void + + private let semaphore = DispatchSemaphore(value: 0) + private let lock = NSRecursiveLock() + private var thread: Thread! + private var queue = [Job]() + private var canceled: Bool = false + + init() { + thread = Thread(block: loop) + thread.start() + } + + public func cancel() { + signalCancel() + thread.cancel() + } + + public func enqueue(_ job: @escaping Job) { + locked { + queue.append(job) + } + + semaphore.signal() + } + + private func loop() { + while true { + semaphore.wait() + + if isCanceled() { + return + } + + let job = getFirstJob() + job() + } + } + + private func signalCancel() { + locked { + canceled = true + } + + semaphore.signal() + } + + private func isCanceled() -> Bool { + let c = locked { + canceled + } + + return c + } + + private func getFirstJob() -> Job { + let job = locked { + queue.removeFirst() + } + + return job + } + + private func locked(do block: () -> T) -> T { + lock.lock() + defer { + lock.unlock() + } + + return block() + } +} diff --git a/third_party/media_kit_video/ios/Classes/plugin/gles/OpenGLESHelpers.swift b/third_party/media_kit_video/ios/Classes/plugin/gles/OpenGLESHelpers.swift new file mode 100644 index 0000000..40adef0 --- /dev/null +++ b/third_party/media_kit_video/ios/Classes/plugin/gles/OpenGLESHelpers.swift @@ -0,0 +1,181 @@ +import OpenGLES +import UIKit + +public class OpenGLESHelpers { + static public func createContext() -> EAGLContext { + let context = EAGLContext(api: .openGLES3) + return context! + } + + static public func createTextureCache( + _ context: EAGLContext + ) -> CVOpenGLESTextureCache { + var textureCache: CVOpenGLESTextureCache? + + let cvret: CVReturn = CVOpenGLESTextureCacheCreate( + kCFAllocatorDefault, + nil, + context, + nil, + &textureCache + ) + assert(cvret == kCVReturnSuccess, "CVOpenGLESTextureCacheCreate") + + return textureCache! + } + + static public func createPixelBuffer(_ size: CGSize) -> CVPixelBuffer { + var pixelBuffer: CVPixelBuffer? + + let attrs = + [ + kCVPixelBufferMetalCompatibilityKey: true + ] as CFDictionary + + let cvret: CVReturn = CVPixelBufferCreate( + kCFAllocatorDefault, + Int(size.width), + Int(size.height), + kCVPixelFormatType_32BGRA, + attrs, + &pixelBuffer + ) + assert(cvret == kCVReturnSuccess, "CVPixelBufferCreate") + + return pixelBuffer! + } + + static public func createTexture( + _ textureCache: CVOpenGLESTextureCache, + _ pixelBuffer: CVPixelBuffer, + _ size: CGSize + ) -> CVOpenGLESTexture { + var texture: CVOpenGLESTexture? + + let cvret: CVReturn = CVOpenGLESTextureCacheCreateTextureFromImage( + kCFAllocatorDefault, + textureCache, + pixelBuffer, + nil, + GLenum(GL_TEXTURE_2D), + GL_RGBA, + GLsizei(size.width), + GLsizei(size.height), + GLenum(GL_BGRA), + GLenum(GL_UNSIGNED_BYTE), + 0, + &texture + ) + assert( + cvret == kCVReturnSuccess, + "CVOpenGLESTextureCacheCreateTextureFromImage" + ) + + return texture! + } + + public enum FrameBufferError: Error { + case status(status: GLuint) + } + + static public func createFrameBuffer( + context: EAGLContext, + texture: CVOpenGLESTexture, + size: CGSize + ) throws -> GLuint { + EAGLContext.setCurrent(context) + defer { + OpenGLESHelpers.checkError("createFrameBuffer") + EAGLContext.setCurrent(nil) + } + + let textureName: GLuint = CVOpenGLESTextureGetName(texture) + glBindTexture(GLenum(GL_TEXTURE_2D), textureName) + defer { + glBindTexture(GLenum(GL_TEXTURE_2D), 0) + } + + var frameBuffer: GLuint = 0 + glGenFramebuffers(1, &frameBuffer) + glBindFramebuffer(GLenum(GL_FRAMEBUFFER), frameBuffer) + defer { + glBindFramebuffer(GLenum(GL_FRAMEBUFFER), 0) + } + + glFramebufferTexture2D( + GLenum(GL_FRAMEBUFFER), + GLenum(GL_COLOR_ATTACHMENT0), + GLenum(GL_TEXTURE_2D), + textureName, + 0 + ) + + let status = glCheckFramebufferStatus(GLenum(GL_FRAMEBUFFER)) + if status != GLenum(GL_FRAMEBUFFER_COMPLETE) { + glDeleteFramebuffers(1, &frameBuffer) + throw OpenGLESHelpers.FrameBufferError.status(status: status) + } + + return frameBuffer + } + + static public func deleteContext(_ context: EAGLContext) { + // Release function for EAGLContext not found + } + + static public func deleteTextureCache(_ textureCache: CVOpenGLESTextureCache) { + CVOpenGLESTextureCacheFlush(textureCache, 0) + + // 'CVOpenGLESTextureCacheRelease' is unavailable: Core Foundation objects are + // automatically memory managed + } + + static public func deletePixeBuffer( + _ context: EAGLContext, + _ pixelBuffer: CVPixelBuffer + ) { + // 'CVPixelBufferRelease' is unavailable: Core Foundation objects are + // automatically memory managed + } + + // BUG: `glDeleteTextures` does not release `CVOpenGLESTexture`. + // `CVOpenGLESTextureCache` retains a direct or indirect reference to + // `IOSurface`, which causes a memory leak until `CVOpenGLESTextureCache` is + // released. + static public func deleteTexture( + _ context: EAGLContext, + _ texture: CVOpenGLESTexture + ) { + EAGLContext.setCurrent(context) + defer { + OpenGLESHelpers.checkError("deleteTexture") + EAGLContext.setCurrent(nil) + } + + var textureName: GLuint = CVOpenGLESTextureGetName(texture) + glDeleteTextures(1, &textureName) + } + + static public func deleteFrameBuffer( + _ context: EAGLContext, + _ frameBuffer: GLuint + ) { + EAGLContext.setCurrent(context) + defer { + OpenGLESHelpers.checkError("deleteFrameBuffer") + EAGLContext.setCurrent(nil) + } + + var frameBuffer = frameBuffer + glDeleteFramebuffers(1, &frameBuffer) + } + + static public func checkError(_ message: String) { + let error = glGetError() + if error == GL_NO_ERROR { + return + } + + NSLog("OpenGLESHelpers: error: \(message): \(error)") + } +} diff --git a/third_party/media_kit_video/ios/Classes/plugin/gles/TextureGLESContext.swift b/third_party/media_kit_video/ios/Classes/plugin/gles/TextureGLESContext.swift new file mode 100644 index 0000000..c2952be --- /dev/null +++ b/third_party/media_kit_video/ios/Classes/plugin/gles/TextureGLESContext.swift @@ -0,0 +1,54 @@ +public class TextureGLESContext { + private let context: EAGLContext + public let frameBuffer: GLuint + public let texture: CVOpenGLESTexture + public let pixelBuffer: CVPixelBuffer + + init( + context: EAGLContext, + textureCache: CVOpenGLESTextureCache, + size: CGSize + ) { + self.context = context + + // Sometimes the frameBuffer created by `createFrameBuffer` has the status + // `GL_FRAMEBUFFER_INCOMPLETE_ATTACHMENT`, which makes it unusable. + // This seems to be because the pixelBuffer created by `createPixelBuffer` + // appears to be invalid, with no way to prevent or detect this. + // So we have to try several times to create a frameBuffer valid. + // This is usually the case after the second attempt. + for index in 0...3 { + let pixelBuffer = OpenGLESHelpers.createPixelBuffer(size) + + let texture = OpenGLESHelpers.createTexture( + textureCache, + pixelBuffer, + size + ) + + let frameBuffer = try? OpenGLESHelpers.createFrameBuffer( + context: context, + texture: texture, + size: size + ) + if frameBuffer != nil { + self.pixelBuffer = pixelBuffer + self.texture = texture + self.frameBuffer = frameBuffer! + return + } + + OpenGLESHelpers.deletePixeBuffer(context, pixelBuffer) + OpenGLESHelpers.deleteTexture(context, texture) + } + + NSLog("TextureGLESContext: init: unable to create a valid frameBuffer") + exit(1) + } + + deinit { + OpenGLESHelpers.deletePixeBuffer(context, pixelBuffer) + OpenGLESHelpers.deleteTexture(context, texture) + OpenGLESHelpers.deleteFrameBuffer(context, frameBuffer) + } +} diff --git a/third_party/media_kit_video/ios/Classes/stub/common/MediaKitVideoPlugin.swift b/third_party/media_kit_video/ios/Classes/stub/common/MediaKitVideoPlugin.swift new file mode 100644 index 0000000..6a4e706 --- /dev/null +++ b/third_party/media_kit_video/ios/Classes/stub/common/MediaKitVideoPlugin.swift @@ -0,0 +1,9 @@ +#if canImport(Flutter) + import Flutter +#elseif canImport(FlutterMacOS) + import FlutterMacOS +#endif + +public class MediaKitVideoPlugin: NSObject, FlutterPlugin { + public static func register(with _: FlutterPluginRegistrar) {} +} diff --git a/third_party/media_kit_video/ios/media_kit_video.podspec b/third_party/media_kit_video/ios/media_kit_video.podspec new file mode 100644 index 0000000..81b6e07 --- /dev/null +++ b/third_party/media_kit_video/ios/media_kit_video.podspec @@ -0,0 +1,50 @@ +# +# To learn more about a Podspec see http://guides.cocoapods.org/syntax/podspec.html. +# Run `pod lib lint media_kit_video.podspec` to validate before publishing. +# + +require_relative '../common/darwin/Podspec/media_kit_utils.rb' + +Pod::Spec.new do |s| + # Setup required files + system("make -C ../common/darwin HEADERS_DESTDIR=\"$(pwd)/Headers\"") + + # Initialize `MediaKitUtils` + mku = MediaKitUtils.new(MediaKitUtils::Platform::IOS) + + s.name = 'media_kit_video' + s.version = '0.0.1' + s.summary = 'Native implementation for video playback in package:media_kit' + s.description = <<-DESC + Native implementation for video playback in package:media_kit. + DESC + s.homepage = 'https://github.com/media-kit/media-kit.git' + s.license = { :file => '../LICENSE' } + s.author = { 'Hitesh Kumar Saini' => 'saini123hitesh@gmail.com' } + + s.source = { :path => '.' } + s.platform = :ios, '9.0' + s.swift_version = '5.0' + s.dependency 'Flutter' + + if mku.libs_found + # Define paths to frameworks dir + framework_search_paths_iphoneos = sprintf('$(PROJECT_DIR)/../.symlinks/plugins/%s/ios/Frameworks/.symlinks/mpv/ios', mku.libs_package) + framework_search_paths_iphonesimulator = sprintf('$(PROJECT_DIR)/../.symlinks/plugins/%s/ios/Frameworks/.symlinks/mpv/ios-simulator', mku.libs_package) + + s.source_files = 'Classes/plugin/**/*.swift', 'Headers/**/*.h' + s.pod_target_xcconfig = { + 'DEFINES_MODULE' => 'YES', + 'GCC_WARN_INHIBIT_ALL_WARNINGS' => 'YES', + 'GCC_PREPROCESSOR_DEFINITIONS' => '"$(inherited)" GL_SILENCE_DEPRECATION COREVIDEO_SILENCE_GL_DEPRECATION', + 'FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]' => sprintf('"$(inherited)" "%s"', framework_search_paths_iphoneos), + 'FRAMEWORK_SEARCH_PATHS[sdk=iphonesimulator*]' => sprintf('"$(inherited)" "%s"', framework_search_paths_iphonesimulator), + 'OTHER_LDFLAGS' => '"$(inherited)" -framework Mpv', + # Flutter.framework does not contain a i386 slice. + 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386', + } + else + s.source_files = 'Classes/stub/**/*.swift' + s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES' } + end +end diff --git a/third_party/media_kit_video/lib/media_kit_video.dart b/third_party/media_kit_video/lib/media_kit_video.dart new file mode 100644 index 0000000..48c7295 --- /dev/null +++ b/third_party/media_kit_video/lib/media_kit_video.dart @@ -0,0 +1,14 @@ +/// This file is a part of media_kit (https://github.com/media-kit/media-kit). +/// +/// Copyright © 2021 & onwards, Hitesh Kumar Saini . +/// All rights reserved. +/// Use of this source code is governed by MIT license that can be found in the LICENSE file. + +export 'package:media_kit_video/src/video_controller/platform_video_controller.dart'; +export 'package:media_kit_video/src/video_controller/video_controller.dart'; +export 'package:media_kit_video/src/video_view_parameters.dart'; +export 'package:media_kit_video/src/video/video.dart'; + +export 'package:media_kit_video/src/subtitle/subtitle_view.dart'; + +export 'package:media_kit_video/media_kit_video_controls/media_kit_video_controls.dart'; diff --git a/third_party/media_kit_video/lib/media_kit_video_controls/media_kit_video_controls.dart b/third_party/media_kit_video/lib/media_kit_video_controls/media_kit_video_controls.dart new file mode 100644 index 0000000..7db2c40 --- /dev/null +++ b/third_party/media_kit_video/lib/media_kit_video_controls/media_kit_video_controls.dart @@ -0,0 +1,13 @@ +/// This file is a part of media_kit (https://github.com/media-kit/media-kit). +/// +/// Copyright © 2021 & onwards, Hitesh Kumar Saini . +/// All rights reserved. +/// Use of this source code is governed by MIT license that can be found in the LICENSE file. + +export 'package:media_kit_video/media_kit_video_controls/src/controls/no.dart'; +export 'package:media_kit_video/media_kit_video_controls/src/controls/adaptive.dart'; +export 'package:media_kit_video/media_kit_video_controls/src/controls/material.dart'; +export 'package:media_kit_video/media_kit_video_controls/src/controls/material_desktop.dart'; +export 'package:media_kit_video/media_kit_video_controls/src/controls/methods/fullscreen.dart'; +export 'package:media_kit_video/media_kit_video_controls/src/controls/widgets/fullscreen_inherited_widget.dart'; +export 'package:media_kit_video/media_kit_video_controls/src/controls/widgets/video_state_inherited_widget.dart'; diff --git a/third_party/media_kit_video/lib/media_kit_video_controls/src/controls/adaptive.dart b/third_party/media_kit_video/lib/media_kit_video_controls/src/controls/adaptive.dart new file mode 100644 index 0000000..61c3172 --- /dev/null +++ b/third_party/media_kit_video/lib/media_kit_video_controls/src/controls/adaptive.dart @@ -0,0 +1,27 @@ +/// This file is a part of media_kit (https://github.com/media-kit/media-kit). +/// +/// Copyright © 2021 & onwards, Hitesh Kumar Saini . +/// All rights reserved. +/// Use of this source code is governed by MIT license that can be found in the LICENSE file. +// ignore_for_file: non_constant_identifier_names +import 'package:flutter/material.dart'; +import 'package:media_kit_video/media_kit_video.dart'; + +/// {@template adaptive_video_controls} +/// +/// [Video] controls based on the running platform. +/// +/// {@endtemplate} +Widget AdaptiveVideoControls(VideoState state) { + switch (Theme.of(state.context).platform) { + case TargetPlatform.android: + case TargetPlatform.iOS: + return MaterialVideoControls(state); + case TargetPlatform.macOS: + case TargetPlatform.windows: + case TargetPlatform.linux: + return MaterialDesktopVideoControls(state); + default: + return NoVideoControls(state); + } +} diff --git a/third_party/media_kit_video/lib/media_kit_video_controls/src/controls/extensions/duration.dart b/third_party/media_kit_video/lib/media_kit_video_controls/src/controls/extensions/duration.dart new file mode 100644 index 0000000..541db15 --- /dev/null +++ b/third_party/media_kit_video/lib/media_kit_video_controls/src/controls/extensions/duration.dart @@ -0,0 +1,42 @@ +/// This file is a part of media_kit (https://github.com/media-kit/media-kit). +/// +/// Copyright © 2021 & onwards, Hitesh Kumar Saini . +/// All rights reserved. +/// Use of this source code is governed by MIT license that can be found in the LICENSE file. + +/// Extension methods for [Duration]. +extension DurationExtension on Duration { + /// Returns clamp of [Duration] between [min] and [max]. + Duration clamp(Duration min, Duration max) { + if (this < min) return min; + if (this > max) return max; + return this; + } + + /// Returns a [String] representation of [Duration]. + String label({Duration? reference}) { + reference ??= this; + reference = reference.abs(); + + if (isNegative) { + return abs().label(reference: reference); + } + + if (reference > const Duration(days: 1)) { + final days = inDays.toString().padLeft(3, '0'); + final hours = (inHours - (inDays * 24)).toString().padLeft(2, '0'); + final minutes = (inMinutes - (inHours * 60)).toString().padLeft(2, '0'); + final seconds = (inSeconds - (inMinutes * 60)).toString().padLeft(2, '0'); + return '$days:$hours:$minutes:$seconds'; + } else if (reference > const Duration(hours: 1)) { + final hours = inHours.toString().padLeft(2, '0'); + final minutes = (inMinutes - (inHours * 60)).toString().padLeft(2, '0'); + final seconds = (inSeconds - (inMinutes * 60)).toString().padLeft(2, '0'); + return '$hours:$minutes:$seconds'; + } else { + final minutes = inMinutes.toString().padLeft(2, '0'); + final seconds = (inSeconds - (inMinutes * 60)).toString().padLeft(2, '0'); + return '$minutes:$seconds'; + } + } +} diff --git a/third_party/media_kit_video/lib/media_kit_video_controls/src/controls/material.dart b/third_party/media_kit_video/lib/media_kit_video_controls/src/controls/material.dart new file mode 100644 index 0000000..0d69e9b --- /dev/null +++ b/third_party/media_kit_video/lib/media_kit_video_controls/src/controls/material.dart @@ -0,0 +1,2198 @@ +/// This file is a part of media_kit (https://github.com/media-kit/media-kit). +/// +/// Copyright © 2021 & onwards, Hitesh Kumar Saini . +/// All rights reserved. +/// Use of this source code is governed by MIT license that can be found in the LICENSE file. +// ignore_for_file: non_constant_identifier_names +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:media_kit_video/media_kit_video.dart'; +import 'package:media_kit_video/media_kit_video_controls/src/controls/extensions/duration.dart'; +import 'package:media_kit_video/media_kit_video_controls/src/controls/methods/video_state.dart'; +import 'package:media_kit_video/media_kit_video_controls/src/controls/widgets/video_controls_theme_data_injector.dart'; + +/// {@template material_video_controls} +/// +/// [Video] controls which use Material design. +/// +/// {@endtemplate} +Widget MaterialVideoControls(VideoState state) { + return const VideoControlsThemeDataInjector( + child: _MaterialVideoControls(), + ); +} + +/// [MaterialVideoControlsThemeData] available in this [context]. +MaterialVideoControlsThemeData _theme(BuildContext context) => + FullscreenInheritedWidget.maybeOf(context) == null + ? MaterialVideoControlsTheme.maybeOf(context)?.normal ?? + kDefaultMaterialVideoControlsThemeData + : MaterialVideoControlsTheme.maybeOf(context)?.fullscreen ?? + kDefaultMaterialVideoControlsThemeDataFullscreen; + +/// Default [MaterialVideoControlsThemeData]. +const kDefaultMaterialVideoControlsThemeData = MaterialVideoControlsThemeData(); + +/// Default [MaterialVideoControlsThemeData] for fullscreen. +const kDefaultMaterialVideoControlsThemeDataFullscreen = + MaterialVideoControlsThemeData( + displaySeekBar: true, + automaticallyImplySkipNextButton: true, + automaticallyImplySkipPreviousButton: true, + volumeGesture: true, + brightnessGesture: true, + seekGesture: true, + gesturesEnabledWhileControlsVisible: true, + seekOnDoubleTap: true, + seekOnDoubleTapEnabledWhileControlsVisible: true, + visibleOnMount: false, + speedUpOnLongPress: false, + speedUpFactor: 2.0, + verticalGestureSensitivity: 100, + horizontalGestureSensitivity: 1000, + backdropColor: Color(0x66000000), + padding: null, + controlsHoverDuration: Duration(seconds: 3), + controlsTransitionDuration: Duration(milliseconds: 300), + bufferingIndicatorBuilder: null, + volumeIndicatorBuilder: null, + brightnessIndicatorBuilder: null, + seekIndicatorBuilder: null, + speedUpIndicatorBuilder: null, + primaryButtonBar: [ + Spacer(flex: 2), + MaterialSkipPreviousButton(), + Spacer(), + MaterialPlayOrPauseButton(iconSize: 56.0), + Spacer(), + MaterialSkipNextButton(), + Spacer(flex: 2), + ], + topButtonBar: [], + topButtonBarMargin: EdgeInsets.symmetric( + horizontal: 16.0, + ), + bottomButtonBar: [ + MaterialPositionIndicator(), + Spacer(), + MaterialFullscreenButton(), + ], + bottomButtonBarMargin: EdgeInsets.only( + left: 16.0, + right: 8.0, + bottom: 42.0, + ), + buttonBarHeight: 56.0, + buttonBarButtonSize: 24.0, + buttonBarButtonColor: Color(0xFFFFFFFF), + seekBarMargin: EdgeInsets.only( + left: 16.0, + right: 16.0, + bottom: 42.0, + ), + seekBarHeight: 2.4, + seekBarContainerHeight: 36.0, + seekBarColor: Color(0x3DFFFFFF), + seekBarPositionColor: Color(0xFFFF0000), + seekBarBufferColor: Color(0x3DFFFFFF), + seekBarThumbSize: 12.8, + seekBarThumbColor: Color(0xFFFF0000), + seekBarAlignment: Alignment.bottomCenter, + shiftSubtitlesOnControlsVisibilityChange: false, +); + +/// {@template material_video_controls_theme_data} +/// +/// Theming related data for [MaterialVideoControls]. These values are used to theme the descendant [MaterialVideoControls]. +/// +/// {@endtemplate} +class MaterialVideoControlsThemeData { + // BEHAVIOR + + /// Whether to display seek bar. + final bool displaySeekBar; + + /// Whether a skip next button should be displayed if there are more than one videos in the playlist. + final bool automaticallyImplySkipNextButton; + + /// Whether a skip previous button should be displayed if there are more than one videos in the playlist. + final bool automaticallyImplySkipPreviousButton; + + /// Whether to modify volume on vertical drag gesture on the right side of the screen. + final bool volumeGesture; + + /// Whether to modify screen brightness on vertical drag gesture on the left side of the screen. + final bool brightnessGesture; + + /// Whether to seek on horizontal drag gesture. + final bool seekGesture; + + /// Whether to allow gesture controls to work while controls are visible. + /// NOTE: This option is ignored when gestures are false. + final bool gesturesEnabledWhileControlsVisible; + + /// Whether to enable double tap to seek on left or right side of the screen. + final bool seekOnDoubleTap; + + /// Whether to allow double tap to seek on left or right side of the screen to work while controls are visible. + /// NOTE: This option is ignored when [seekOnDoubleTap] is false. + final bool seekOnDoubleTapEnabledWhileControlsVisible; + + /// `seekOnDoubleTapLayoutTapsRatios` defines the width proportions for the interactive areas + /// responsible for seek actions (backward seek, instant tap, forward seek) when a double tap + /// occurs on the video widget. This property divides the video widget into three segments + /// horizontally. Each integer in the list represents the relative width of each segment. + /// By default, the value `[1, 1, 1]` means that the video widget is equally divided into three + /// segments: the left segment for backward seek, the middle segment for instant tap (usually show and hide controls), + /// and the right segment for forward seek. Adjusting these values changes the width of the interactive areas + /// for each double tap action. + final List seekOnDoubleTapLayoutTapsRatios; + + /// `seekOnDoubleTapLayoutWidgetRatios` defines the width proportions for the visual indicators or + /// widgets that appear during the double tap actions (backward seek, instant tap, forward seek). + /// Similar to `seekOnDoubleTapLayoutTapsRatios`, it divides the area where these indicators are + /// displayed into three segments. Each integer in the list represents the relative width of each + /// segment where the corresponding indicators will be shown. The default `[1, 1, 1]` equally divides + /// the space for each indicator. Modifying these values can change the layout of the seek indicators, + /// giving more or less space to each one based on the specified ratios. + final List seekOnDoubleTapLayoutWidgetRatios; + + /// Duration of seek on double tap backward. + final Duration seekOnDoubleTapBackwardDuration; + + /// Duration of seek on double tap forward. + final Duration seekOnDoubleTapForwardDuration; + + /// Whether the controls are initially visible. + final bool visibleOnMount; + + /// Whether to speed up on long press. + final bool speedUpOnLongPress; + + /// Factor to speed up on long press. + final double speedUpFactor; + + /// Gesture sensitivity on vertical drag gestures, the higher the value is the less sensitive the gesture. + final double verticalGestureSensitivity; + + /// Gesture sensitivity on horizontal drag gestures, the higher the value is the less sensitive the gesture. + final double horizontalGestureSensitivity; + + /// Color of backdrop that comes up when controls are visible. + final Color? backdropColor; + + // GENERIC + + /// Padding around the controls. + /// + /// * Default: `EdgeInsets.zero` + /// * FullScreen: `MediaQuery.of(context).padding` + /// + /// NOTE: In fullscreen, this will be safe area (set [padding] to [EdgeInsets.zero] to disable safe area) + final EdgeInsets? padding; + + /// [Duration] after which the controls will be hidden when there is no mouse movement. + final Duration controlsHoverDuration; + + /// [Duration] for which the controls will be animated when shown or hidden. + final Duration controlsTransitionDuration; + + /// Builder for the buffering indicator. + final Widget Function(BuildContext)? bufferingIndicatorBuilder; + + /// Custom builder for volume indicator. + final Widget Function(BuildContext, double)? volumeIndicatorBuilder; + + /// Custom builder for brightness indicator. + final Widget Function(BuildContext, double)? brightnessIndicatorBuilder; + + /// Optional callback called when volume should be changed (0.0 to 1.0). + /// If not provided, volume gestures will be disabled. + final void Function(double)? onVolumeChanged; + + /// Optional initial volume value (0.0 to 1.0). Defaults to 0.5. + final double? initialVolume; + + /// Optional callback called when brightness should be changed (0.0 to 1.0). + /// If not provided, brightness gestures will be disabled. + final void Function(double)? onBrightnessChanged; + + /// Optional initial brightness value (0.0 to 1.0). Defaults to 0.5. + final double? initialBrightness; + + /// Optional callback called when brightness should be reset on dispose. + final void Function()? onBrightnessReset; + + /// Custom builder for seek indicator. + final Widget Function(BuildContext, Duration)? seekIndicatorBuilder; + + /// Custom builder for seek indicator. + final Widget Function(BuildContext, double)? speedUpIndicatorBuilder; + + // BUTTON BAR + + /// Buttons to be displayed in the primary button bar. + final List primaryButtonBar; + + /// Buttons to be displayed in the top button bar. + final List topButtonBar; + + /// Margin around the top button bar. + final EdgeInsets topButtonBarMargin; + + /// Buttons to be displayed in the bottom button bar. + final List bottomButtonBar; + + /// Margin around the button bar. + final EdgeInsets bottomButtonBarMargin; + + /// Height of the button bar. + final double buttonBarHeight; + + /// Size of the button bar buttons. + final double buttonBarButtonSize; + + /// Color of the button bar buttons. + final Color buttonBarButtonColor; + + // SEEK BAR + + /// Margin around the seek bar. + final EdgeInsets seekBarMargin; + + /// Height of the seek bar. + final double seekBarHeight; + + /// Height of the seek bar [Container]. + final double seekBarContainerHeight; + + /// [Color] of the seek bar. + final Color seekBarColor; + + /// [Color] of the playback position section in the seek bar. + final Color seekBarPositionColor; + + /// [Color] of the playback buffer section in the seek bar. + final Color seekBarBufferColor; + + /// Size of the seek bar thumb. + final double seekBarThumbSize; + + /// [Color] of the seek bar thumb. + final Color seekBarThumbColor; + + /// [Alignment] of seek bar inside the seek bar container. + final Alignment seekBarAlignment; + + // SUBTITLE + + /// Whether to shift the subtitles upwards when the controls are visible. + final bool shiftSubtitlesOnControlsVisibilityChange; + + /// {@macro material_video_controls_theme_data} + const MaterialVideoControlsThemeData({ + this.displaySeekBar = true, + this.automaticallyImplySkipNextButton = true, + this.automaticallyImplySkipPreviousButton = true, + this.volumeGesture = false, + this.brightnessGesture = false, + this.seekGesture = false, + this.gesturesEnabledWhileControlsVisible = true, + this.seekOnDoubleTap = false, + this.seekOnDoubleTapEnabledWhileControlsVisible = true, + this.seekOnDoubleTapLayoutTapsRatios = const [1, 1, 1], + this.seekOnDoubleTapLayoutWidgetRatios = const [1, 1, 1], + this.seekOnDoubleTapBackwardDuration = const Duration(seconds: 10), + this.seekOnDoubleTapForwardDuration = const Duration(seconds: 10), + this.visibleOnMount = false, + this.speedUpOnLongPress = false, + this.speedUpFactor = 2.0, + this.verticalGestureSensitivity = 100, + this.horizontalGestureSensitivity = 1000, + this.backdropColor = const Color(0x66000000), + this.padding, + this.controlsHoverDuration = const Duration(seconds: 3), + this.controlsTransitionDuration = const Duration(milliseconds: 300), + this.bufferingIndicatorBuilder, + this.volumeIndicatorBuilder, + this.brightnessIndicatorBuilder, + this.onVolumeChanged, + this.initialVolume, + this.onBrightnessChanged, + this.initialBrightness, + this.onBrightnessReset, + this.seekIndicatorBuilder, + this.speedUpIndicatorBuilder, + this.primaryButtonBar = const [ + Spacer(flex: 2), + MaterialSkipPreviousButton(), + Spacer(), + MaterialPlayOrPauseButton(iconSize: 48.0), + Spacer(), + MaterialSkipNextButton(), + Spacer(flex: 2), + ], + this.topButtonBar = const [], + this.topButtonBarMargin = const EdgeInsets.symmetric(horizontal: 16.0), + this.bottomButtonBar = const [ + MaterialPositionIndicator(), + Spacer(), + MaterialFullscreenButton(), + ], + this.bottomButtonBarMargin = const EdgeInsets.only(left: 16.0, right: 8.0), + this.buttonBarHeight = 56.0, + this.buttonBarButtonSize = 24.0, + this.buttonBarButtonColor = const Color(0xFFFFFFFF), + this.seekBarMargin = EdgeInsets.zero, + this.seekBarHeight = 2.4, + this.seekBarContainerHeight = 36.0, + this.seekBarColor = const Color(0x3DFFFFFF), + this.seekBarPositionColor = const Color(0xFFFF0000), + this.seekBarBufferColor = const Color(0x3DFFFFFF), + this.seekBarThumbSize = 12.8, + this.seekBarThumbColor = const Color(0xFFFF0000), + this.seekBarAlignment = Alignment.bottomCenter, + this.shiftSubtitlesOnControlsVisibilityChange = false, + }); + + /// Creates a copy of this [MaterialVideoControlsThemeData] with the given fields replaced by the non-null parameter values. + MaterialVideoControlsThemeData copyWith({ + bool? displaySeekBar, + bool? automaticallyImplySkipNextButton, + bool? automaticallyImplySkipPreviousButton, + bool? volumeGesture, + bool? brightnessGesture, + bool? seekGesture, + bool? gesturesEnabledWhileControlsVisible, + bool? seekOnDoubleTap, + bool? seekOnDoubleTapEnabledWhileControlsVisible, + List? seekOnDoubleTapLayoutTapsRatios, + List? seekOnDoubleTapLayoutWidgetRatios, + Duration? seekOnDoubleTapBackwardDuration, + Duration? seekOnDoubleTapForwardDuration, + bool? visibleOnMount, + bool? speedUpOnLongPress, + double? speedUpFactor, + double? verticalGestureSensitivity, + double? horizontalGestureSensitivity, + Color? backdropColor, + Duration? controlsHoverDuration, + Duration? controlsTransitionDuration, + Widget Function(BuildContext)? bufferingIndicatorBuilder, + Widget Function(BuildContext, double)? volumeIndicatorBuilder, + Widget Function(BuildContext, double)? brightnessIndicatorBuilder, + void Function(double)? onVolumeChanged, + double? initialVolume, + void Function(double)? onBrightnessChanged, + double? initialBrightness, + void Function()? onBrightnessReset, + Widget Function(BuildContext, Duration)? seekIndicatorBuilder, + Widget Function(BuildContext, double)? speedUpIndicatorBuilder, + List? primaryButtonBar, + List? topButtonBar, + EdgeInsets? topButtonBarMargin, + List? bottomButtonBar, + EdgeInsets? bottomButtonBarMargin, + double? buttonBarHeight, + double? buttonBarButtonSize, + Color? buttonBarButtonColor, + EdgeInsets? seekBarMargin, + double? seekBarHeight, + double? seekBarContainerHeight, + Color? seekBarColor, + Color? seekBarPositionColor, + Color? seekBarBufferColor, + double? seekBarThumbSize, + Color? seekBarThumbColor, + Alignment? seekBarAlignment, + bool? shiftSubtitlesOnControlsVisibilityChange, + }) { + return MaterialVideoControlsThemeData( + displaySeekBar: displaySeekBar ?? this.displaySeekBar, + automaticallyImplySkipNextButton: automaticallyImplySkipNextButton ?? + this.automaticallyImplySkipNextButton, + automaticallyImplySkipPreviousButton: + automaticallyImplySkipPreviousButton ?? + this.automaticallyImplySkipPreviousButton, + volumeGesture: volumeGesture ?? this.volumeGesture, + brightnessGesture: brightnessGesture ?? this.brightnessGesture, + seekGesture: seekGesture ?? this.seekGesture, + gesturesEnabledWhileControlsVisible: + gesturesEnabledWhileControlsVisible ?? + this.gesturesEnabledWhileControlsVisible, + seekOnDoubleTap: seekOnDoubleTap ?? this.seekOnDoubleTap, + seekOnDoubleTapEnabledWhileControlsVisible: + seekOnDoubleTapEnabledWhileControlsVisible ?? + this.seekOnDoubleTapEnabledWhileControlsVisible, + seekOnDoubleTapLayoutTapsRatios: seekOnDoubleTapLayoutTapsRatios ?? + this.seekOnDoubleTapLayoutTapsRatios, + seekOnDoubleTapLayoutWidgetRatios: seekOnDoubleTapLayoutWidgetRatios ?? + this.seekOnDoubleTapLayoutWidgetRatios, + seekOnDoubleTapBackwardDuration: seekOnDoubleTapBackwardDuration ?? + this.seekOnDoubleTapBackwardDuration, + seekOnDoubleTapForwardDuration: + seekOnDoubleTapForwardDuration ?? this.seekOnDoubleTapForwardDuration, + visibleOnMount: visibleOnMount ?? this.visibleOnMount, + speedUpOnLongPress: speedUpOnLongPress ?? this.speedUpOnLongPress, + speedUpFactor: speedUpFactor ?? this.speedUpFactor, + verticalGestureSensitivity: + verticalGestureSensitivity ?? this.verticalGestureSensitivity, + horizontalGestureSensitivity: + horizontalGestureSensitivity ?? this.horizontalGestureSensitivity, + backdropColor: backdropColor ?? this.backdropColor, + controlsHoverDuration: + controlsHoverDuration ?? this.controlsHoverDuration, + controlsTransitionDuration: + controlsTransitionDuration ?? this.controlsTransitionDuration, + bufferingIndicatorBuilder: + bufferingIndicatorBuilder ?? this.bufferingIndicatorBuilder, + volumeIndicatorBuilder: + volumeIndicatorBuilder ?? this.volumeIndicatorBuilder, + brightnessIndicatorBuilder: + brightnessIndicatorBuilder ?? this.brightnessIndicatorBuilder, + onVolumeChanged: onVolumeChanged ?? this.onVolumeChanged, + initialVolume: initialVolume ?? this.initialVolume, + onBrightnessChanged: onBrightnessChanged ?? this.onBrightnessChanged, + initialBrightness: initialBrightness ?? this.initialBrightness, + onBrightnessReset: onBrightnessReset ?? this.onBrightnessReset, + seekIndicatorBuilder: seekIndicatorBuilder ?? this.seekIndicatorBuilder, + speedUpIndicatorBuilder: + speedUpIndicatorBuilder ?? this.speedUpIndicatorBuilder, + primaryButtonBar: primaryButtonBar ?? this.primaryButtonBar, + topButtonBar: topButtonBar ?? this.topButtonBar, + topButtonBarMargin: topButtonBarMargin ?? this.topButtonBarMargin, + bottomButtonBar: bottomButtonBar ?? this.bottomButtonBar, + bottomButtonBarMargin: + bottomButtonBarMargin ?? this.bottomButtonBarMargin, + buttonBarHeight: buttonBarHeight ?? this.buttonBarHeight, + buttonBarButtonSize: buttonBarButtonSize ?? this.buttonBarButtonSize, + buttonBarButtonColor: buttonBarButtonColor ?? this.buttonBarButtonColor, + seekBarMargin: seekBarMargin ?? this.seekBarMargin, + seekBarHeight: seekBarHeight ?? this.seekBarHeight, + seekBarContainerHeight: + seekBarContainerHeight ?? this.seekBarContainerHeight, + seekBarColor: seekBarColor ?? this.seekBarColor, + seekBarPositionColor: seekBarPositionColor ?? this.seekBarPositionColor, + seekBarBufferColor: seekBarBufferColor ?? this.seekBarBufferColor, + seekBarThumbSize: seekBarThumbSize ?? this.seekBarThumbSize, + seekBarThumbColor: seekBarThumbColor ?? this.seekBarThumbColor, + seekBarAlignment: seekBarAlignment ?? this.seekBarAlignment, + shiftSubtitlesOnControlsVisibilityChange: + shiftSubtitlesOnControlsVisibilityChange ?? + this.shiftSubtitlesOnControlsVisibilityChange, + ); + } +} + +/// {@template material_video_controls_theme} +/// +/// Inherited widget which provides [MaterialVideoControlsThemeData] to descendant widgets. +/// +/// {@endtemplate} +class MaterialVideoControlsTheme extends InheritedWidget { + final MaterialVideoControlsThemeData normal; + final MaterialVideoControlsThemeData fullscreen; + const MaterialVideoControlsTheme({ + super.key, + required this.normal, + required this.fullscreen, + required super.child, + }); + + static MaterialVideoControlsTheme? maybeOf(BuildContext context) { + return context + .dependOnInheritedWidgetOfExactType(); + } + + static MaterialVideoControlsTheme of(BuildContext context) { + final MaterialVideoControlsTheme? result = maybeOf(context); + assert( + result != null, + 'No [MaterialVideoControlsTheme] found in [context]', + ); + return result!; + } + + @override + bool updateShouldNotify(MaterialVideoControlsTheme oldWidget) => + identical(normal, oldWidget.normal) && + identical(fullscreen, oldWidget.fullscreen); +} + +/// {@macro material_video_controls} +class _MaterialVideoControls extends StatefulWidget { + const _MaterialVideoControls(); + + @override + State<_MaterialVideoControls> createState() => _MaterialVideoControlsState(); +} + +/// {@macro material_video_controls} +class _MaterialVideoControlsState extends State<_MaterialVideoControls> { + late bool mount; + late bool visible; + Timer? _timer; + + double _brightnessValue = 0.0; + bool _brightnessIndicator = false; + Timer? _brightnessTimer; + double _currentRate = 1.0; + double _volumeValue = 0.0; + bool _volumeIndicator = false; + Timer? _volumeTimer; + + void Function()? _onBrightnessReset; + + Offset _dragInitialDelta = + Offset.zero; // Initial position for horizontal drag + int swipeDuration = 0; // Duration to seek in video + bool showSwipeDuration = false; // Whether to show the seek duration overlay + + bool _speedUpIndicator = false; + late /* private */ var playlist = controller(context).player.state.playlist; + late bool buffering = controller(context).player.state.buffering; + + bool _mountSeekBackwardButton = false; + bool _mountSeekForwardButton = false; + bool _hideSeekBackwardButton = false; + bool _hideSeekForwardButton = false; + Timer? _timerSeekBackwardButton; + Timer? _timerSeekForwardButton; + + final ValueNotifier _seekBarDeltaValueNotifier = + ValueNotifier(Duration.zero); + + final List subscriptions = []; + + double get subtitleVerticalShiftOffset => + (_theme(context).padding?.bottom ?? 0.0) + + (_theme(context).bottomButtonBarMargin.vertical) + + (_theme(context).bottomButtonBar.isNotEmpty + ? _theme(context).buttonBarHeight + : 0.0); + Offset? _tapPosition; + + void _handleDoubleTapDown(TapDownDetails details) { + setState(() { + _tapPosition = details.localPosition; + }); + } + + void _handleLongPress() { + setState(() { + _speedUpIndicator = true; + }); + _currentRate = controller(context).player.state.rate; + controller(context).player.setRate(_theme(context).speedUpFactor); + } + + void _handleLongPressEnd(LongPressEndDetails details) { + setState(() { + _speedUpIndicator = false; + }); + controller(context).player.setRate(_currentRate); + } + + @override + void setState(VoidCallback fn) { + if (mounted) { + super.setState(fn); + } + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (subscriptions.isEmpty) { + mount = _theme(context).visibleOnMount; + visible = _theme(context).visibleOnMount; + _volumeValue = _theme(context).initialVolume ?? 0.5; + _brightnessValue = _theme(context).initialBrightness ?? 0.5; + _onBrightnessReset = _theme(context).onBrightnessReset; + + subscriptions.addAll( + [ + controller(context).player.stream.playlist.listen( + (event) { + setState(() { + playlist = event; + }); + }, + ), + controller(context).player.stream.buffering.listen( + (event) { + setState(() { + buffering = event; + }); + }, + ), + ], + ); + + if (_theme(context).visibleOnMount) { + _timer = Timer( + _theme(context).controlsHoverDuration, + () { + if (mounted) { + setState(() { + visible = false; + }); + unshiftSubtitle(); + } + }, + ); + } + } + } + + @override + void dispose() { + for (final subscription in subscriptions) { + subscription.cancel(); + } + _onBrightnessReset?.call(); + _timerSeekBackwardButton?.cancel(); + _timerSeekForwardButton?.cancel(); + super.dispose(); + } + + void shiftSubtitle() { + if (_theme(context).shiftSubtitlesOnControlsVisibilityChange) { + state(context).setSubtitleViewPadding( + state(context).widget.subtitleViewConfiguration.padding + + EdgeInsets.fromLTRB( + 0.0, + 0.0, + 0.0, + subtitleVerticalShiftOffset, + ), + ); + } + } + + void unshiftSubtitle() { + if (_theme(context).shiftSubtitlesOnControlsVisibilityChange) { + state(context).setSubtitleViewPadding( + state(context).widget.subtitleViewConfiguration.padding, + ); + } + } + + void onTap() { + if (!visible) { + setState(() { + mount = true; + visible = true; + }); + shiftSubtitle(); + _timer?.cancel(); + _timer = Timer(_theme(context).controlsHoverDuration, () { + if (mounted) { + setState(() { + visible = false; + }); + unshiftSubtitle(); + } + }); + } else { + setState(() { + visible = false; + }); + unshiftSubtitle(); + _timer?.cancel(); + } + } + + void onDoubleTapSeekBackward() { + setState(() { + _mountSeekBackwardButton = true; + }); + } + + void onDoubleTapSeekForward() { + setState(() { + _mountSeekForwardButton = true; + }); + } + + void onHorizontalDragUpdate(DragUpdateDetails details) { + if (_dragInitialDelta == Offset.zero) { + _dragInitialDelta = details.localPosition; + return; + } + + final diff = _dragInitialDelta.dx - details.localPosition.dx; + final duration = controller(context).player.state.duration.inSeconds; + final position = controller(context).player.state.position.inSeconds; + + final seconds = + -(diff * duration / _theme(context).horizontalGestureSensitivity) + .round(); + final relativePosition = position + seconds; + + if (relativePosition <= duration && relativePosition >= 0) { + setState(() { + swipeDuration = seconds; + showSwipeDuration = true; + _seekBarDeltaValueNotifier.value = Duration(seconds: seconds); + }); + } + } + + void onHorizontalDragEnd() { + if (swipeDuration != 0) { + Duration newPosition = controller(context).player.state.position + + Duration(seconds: swipeDuration); + newPosition = newPosition.clamp( + Duration.zero, + controller(context).player.state.duration, + ); + controller(context).player.seek(newPosition); + } + + setState(() { + _dragInitialDelta = Offset.zero; + showSwipeDuration = false; + }); + } + + bool _isInSegment(double localX, int segmentIndex) { + // Local variable with the list of ratios + List segmentRatios = _theme(context).seekOnDoubleTapLayoutTapsRatios; + + int totalRatios = segmentRatios.reduce((a, b) => a + b); + + double segmentWidthMultiplier = widgetWidth(context) / totalRatios; + double start = 0; + double end; + + for (int i = 0; i < segmentRatios.length; i++) { + end = start + (segmentWidthMultiplier * segmentRatios[i]); + + // Check if the current index matches the segmentIndex and if localX falls within it + if (i == segmentIndex && localX >= start && localX <= end) { + return true; + } + + // Set the start of the next segment + start = end; + } + + // If localX does not fall within the specified segment + return false; + } + + bool _isInRightSegment(double localX) { + return _isInSegment(localX, 2); + } + + bool _isInLeftSegment(double localX) { + return _isInSegment(localX, 0); + } + + void _handlePointerDown(PointerDownEvent event) { + onTap(); + } + + void setVolume(double value) { + _theme(context).onVolumeChanged?.call(value); + setState(() { + _volumeValue = value; + _volumeIndicator = true; + }); + _volumeTimer?.cancel(); + _volumeTimer = Timer(const Duration(milliseconds: 200), () { + if (mounted) { + setState(() { + _volumeIndicator = false; + }); + } + }); + } + + void setBrightness(double value) { + _theme(context).onBrightnessChanged?.call(value); + setState(() { + _brightnessValue = value; + _brightnessIndicator = true; + }); + _brightnessTimer?.cancel(); + _brightnessTimer = Timer(const Duration(milliseconds: 200), () { + if (mounted) { + setState(() { + _brightnessIndicator = false; + }); + } + }); + } + + @override + Widget build(BuildContext context) { + var seekOnDoubleTapEnabledWhileControlsAreVisible = + (_theme(context).seekOnDoubleTap && + _theme(context).seekOnDoubleTapEnabledWhileControlsVisible); + assert(_theme(context).seekOnDoubleTapLayoutTapsRatios.length == 3, + "The number of seekOnDoubleTapLayoutTapsRatios must be 3, i.e. [1, 1, 1]"); + assert(_theme(context).seekOnDoubleTapLayoutWidgetRatios.length == 3, + "The number of seekOnDoubleTapLayoutWidgetRatios must be 3, i.e. [1, 1, 1]"); + return Theme( + data: Theme.of(context).copyWith( + focusColor: const Color(0x00000000), + hoverColor: const Color(0x00000000), + splashColor: const Color(0x00000000), + highlightColor: const Color(0x00000000), + ), + + /// Add [Directionality] to ltr to avoid wrong animation of sides. + child: Directionality( + textDirection: TextDirection.ltr, + child: Focus( + focusNode: videoViewParametersNotifier(context).value.focusNode, + autofocus: true, + child: Material( + elevation: 0.0, + borderOnForeground: false, + animationDuration: Duration.zero, + color: const Color(0x00000000), + shadowColor: const Color(0x00000000), + surfaceTintColor: const Color(0x00000000), + child: Stack( + clipBehavior: Clip.none, + alignment: Alignment.center, + children: [ + // Controls: + AnimatedOpacity( + curve: Curves.easeInOut, + opacity: visible ? 1.0 : 0.0, + duration: _theme(context).controlsTransitionDuration, + onEnd: () { + setState(() { + if (!visible) { + mount = false; + } + }); + }, + child: Stack( + clipBehavior: Clip.none, + alignment: Alignment.center, + children: [ + Positioned.fill( + child: Container( + color: _theme(context).backdropColor, + ), + ), + // We are adding 16.0 boundary around the actual controls (which contain the vertical drag gesture detectors). + // This will make the hit-test on edges (e.g. swiping to: show status-bar, show navigation-bar, go back in navigation) not activate the swipe gesture annoyingly. + Positioned.fill( + left: 16.0, + top: 16.0, + right: 16.0, + bottom: 16.0 + subtitleVerticalShiftOffset, + child: Listener( + onPointerDown: (event) => _handlePointerDown(event), + child: GestureDetector( + onDoubleTapDown: _handleDoubleTapDown, + onLongPress: _theme(context).speedUpOnLongPress + ? _handleLongPress + : null, + onLongPressEnd: _theme(context).speedUpOnLongPress + ? _handleLongPressEnd + : null, + onDoubleTap: () { + if (_tapPosition == null) { + return; + } + if (_isInRightSegment(_tapPosition!.dx)) { + if ((!mount && + _theme(context).seekOnDoubleTap) || + seekOnDoubleTapEnabledWhileControlsAreVisible) { + onDoubleTapSeekForward(); + } + } else { + if (_isInLeftSegment(_tapPosition!.dx)) { + if ((!mount && + _theme(context).seekOnDoubleTap) || + seekOnDoubleTapEnabledWhileControlsAreVisible) { + onDoubleTapSeekBackward(); + } + } + } + }, + onHorizontalDragUpdate: (details) { + if ((!mount && _theme(context).seekGesture) || + (_theme(context).seekGesture && + _theme(context) + .gesturesEnabledWhileControlsVisible)) { + onHorizontalDragUpdate(details); + } + }, + onHorizontalDragEnd: (details) { + onHorizontalDragEnd(); + }, + onVerticalDragUpdate: (e) async { + final delta = e.delta.dy; + final Offset position = e.localPosition; + + if (position.dx <= widgetWidth(context) / 2) { + // Left side of screen swiped + if ((!mount && + _theme(context).brightnessGesture && + _theme(context).onBrightnessChanged != + null) || + (_theme(context).brightnessGesture && + _theme(context) + .gesturesEnabledWhileControlsVisible && + _theme(context).onBrightnessChanged != + null)) { + final brightness = _brightnessValue - + delta / + _theme(context) + .verticalGestureSensitivity; + final result = brightness.clamp(0.0, 1.0); + setBrightness(result); + } + } else { + // Right side of screen swiped + + if ((!mount && + _theme(context).volumeGesture && + _theme(context).onVolumeChanged != + null) || + (_theme(context).volumeGesture && + _theme(context) + .gesturesEnabledWhileControlsVisible && + _theme(context).onVolumeChanged != + null)) { + final volume = _volumeValue - + delta / + _theme(context) + .verticalGestureSensitivity; + final result = volume.clamp(0.0, 1.0); + setVolume(result); + } + } + }, + child: Container( + color: const Color(0x00000000), + ), + ), + ), + ), + if (mount) + Padding( + padding: _theme(context).padding ?? + ( + // Add padding in fullscreen! + isFullscreen(context) + ? MediaQuery.of(context).padding + : EdgeInsets.zero), + child: Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Container( + height: _theme(context).buttonBarHeight, + margin: _theme(context).topButtonBarMargin, + child: Row( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.center, + children: _theme(context).topButtonBar, + ), + ), + // Only display [primaryButtonBar] if [buffering] is false. + Expanded( + child: AnimatedOpacity( + curve: Curves.easeInOut, + opacity: buffering ? 0.0 : 1.0, + duration: _theme(context) + .controlsTransitionDuration, + child: Center( + child: Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: + MainAxisAlignment.center, + crossAxisAlignment: + CrossAxisAlignment.center, + children: + _theme(context).primaryButtonBar, + ), + ), + ), + ), + Stack( + alignment: Alignment.bottomCenter, + children: [ + if (_theme(context).displaySeekBar) + MaterialSeekBar( + onSeekStart: () { + _timer?.cancel(); + }, + onSeekEnd: () { + _timer = Timer( + _theme(context).controlsHoverDuration, + () { + if (mounted) { + setState(() { + visible = false; + }); + unshiftSubtitle(); + } + }, + ); + }, + ), + Container( + height: _theme(context).buttonBarHeight, + margin: + _theme(context).bottomButtonBarMargin, + child: Row( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: + MainAxisAlignment.start, + crossAxisAlignment: + CrossAxisAlignment.center, + children: _theme(context).bottomButtonBar, + ), + ), + ], + ), + ], + ), + ), + ], + ), + ), + // Double-Tap Seek Seek-Bar: + if (!mount) + if (_mountSeekBackwardButton || + _mountSeekForwardButton || + showSwipeDuration) + Column( + children: [ + const Spacer(), + Stack( + alignment: Alignment.bottomCenter, + children: [ + if (_theme(context).displaySeekBar) + MaterialSeekBar( + delta: _seekBarDeltaValueNotifier, + ), + Container( + height: _theme(context).buttonBarHeight, + margin: _theme(context).bottomButtonBarMargin, + ), + ], + ), + ], + ), + // Buffering Indicator. + IgnorePointer( + child: Padding( + padding: _theme(context).padding ?? + ( + // Add padding in fullscreen! + isFullscreen(context) + ? MediaQuery.of(context).padding + : EdgeInsets.zero), + child: Column( + children: [ + Container( + height: _theme(context).buttonBarHeight, + margin: _theme(context).topButtonBarMargin, + ), + Expanded( + child: Center( + child: TweenAnimationBuilder( + tween: Tween( + begin: 0.0, + end: buffering ? 1.0 : 0.0, + ), + duration: + _theme(context).controlsTransitionDuration, + builder: (context, value, child) { + // Only mount the buffering indicator if the opacity is greater than 0.0. + // This has been done to prevent redundant resource usage in [CircularProgressIndicator]. + if (value > 0.0) { + return Opacity( + opacity: value, + child: _theme(context) + .bufferingIndicatorBuilder + ?.call(context) ?? + child!, + ); + } + return const SizedBox.shrink(); + }, + child: const CircularProgressIndicator( + color: Color(0xFFFFFFFF), + ), + ), + ), + ), + Container( + height: _theme(context).buttonBarHeight, + margin: _theme(context).bottomButtonBarMargin, + ), + ], + ), + ), + ), + // Volume Indicator. + IgnorePointer( + child: AnimatedOpacity( + curve: Curves.easeInOut, + opacity: (!mount || + _theme(context) + .gesturesEnabledWhileControlsVisible) && + _volumeIndicator + ? 1.0 + : 0.0, + duration: _theme(context).controlsTransitionDuration, + child: _theme(context) + .volumeIndicatorBuilder + ?.call(context, _volumeValue) ?? + Container( + alignment: Alignment.center, + decoration: BoxDecoration( + color: const Color(0x88000000), + borderRadius: BorderRadius.circular(64.0), + ), + height: 52.0, + width: 108.0, + child: Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Container( + height: 52.0, + width: 42.0, + alignment: Alignment.centerRight, + child: Icon( + _volumeValue == 0.0 + ? Icons.volume_off + : _volumeValue < 0.5 + ? Icons.volume_down + : Icons.volume_up, + color: const Color(0xFFFFFFFF), + size: 24.0, + ), + ), + const SizedBox(width: 8.0), + Expanded( + child: Text( + '${(_volumeValue * 100.0).round()}%', + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 14.0, + color: Color(0xFFFFFFFF), + ), + ), + ), + const SizedBox(width: 16.0), + ], + ), + ), + ), + ), + // Brightness Indicator. + IgnorePointer( + child: AnimatedOpacity( + curve: Curves.easeInOut, + opacity: (!mount || + _theme(context) + .gesturesEnabledWhileControlsVisible) && + _brightnessIndicator + ? 1.0 + : 0.0, + duration: _theme(context).controlsTransitionDuration, + child: _theme(context) + .brightnessIndicatorBuilder + ?.call(context, _brightnessValue) ?? + Container( + alignment: Alignment.center, + decoration: BoxDecoration( + color: const Color(0x88000000), + borderRadius: BorderRadius.circular(64.0), + ), + height: 52.0, + width: 108.0, + child: Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Container( + height: 52.0, + width: 42.0, + alignment: Alignment.centerRight, + child: Icon( + _brightnessValue < 1.0 / 3.0 + ? Icons.brightness_low + : _brightnessValue < 2.0 / 3.0 + ? Icons.brightness_medium + : Icons.brightness_high, + color: const Color(0xFFFFFFFF), + size: 24.0, + ), + ), + const SizedBox(width: 8.0), + Expanded( + child: Text( + '${(_brightnessValue * 100.0).round()}%', + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 14.0, + color: Color(0xFFFFFFFF), + ), + ), + ), + const SizedBox(width: 16.0), + ], + ), + ), + ), + ), + // Speedup Indicator. + IgnorePointer( + child: Padding( + padding: _theme(context).padding ?? + ( + // Add padding in fullscreen! + isFullscreen(context) + ? MediaQuery.of(context).padding + : EdgeInsets.zero), + child: Column( + children: [ + Container( + height: _theme(context).buttonBarHeight, + margin: _theme(context).topButtonBarMargin, + ), + Expanded( + child: AnimatedOpacity( + duration: + _theme(context).controlsTransitionDuration, + opacity: _speedUpIndicator ? 1 : 0, + child: _theme(context) + .speedUpIndicatorBuilder + ?.call(context, + _theme(context).speedUpFactor) ?? + Container( + alignment: Alignment.topCenter, + child: Container( + margin: const EdgeInsets.all(16.0), + alignment: Alignment.center, + decoration: BoxDecoration( + color: const Color(0x88000000), + borderRadius: BorderRadius.circular(64.0), + ), + height: 48.0, + width: 108.0, + child: Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: + MainAxisAlignment.center, + crossAxisAlignment: + CrossAxisAlignment.center, + children: [ + const SizedBox(width: 16.0), + Expanded( + child: Text( + '${_theme(context).speedUpFactor.toStringAsFixed(1)}x', + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 14.0, + color: Color(0xFFFFFFFF), + ), + ), + ), + Container( + height: 48.0, + width: 48.0 - 16.0, + alignment: Alignment.centerRight, + child: const Icon( + Icons.fast_forward, + color: Color(0xFFFFFFFF), + size: 24.0, + ), + ), + const SizedBox(width: 16.0), + ], + ), + ), + ), + ), + ), + Container( + height: _theme(context).buttonBarHeight, + margin: _theme(context).bottomButtonBarMargin, + ), + ], + ), + ), + ), + // Seek Indicator. + IgnorePointer( + child: AnimatedOpacity( + duration: _theme(context).controlsTransitionDuration, + opacity: showSwipeDuration ? 1 : 0, + child: _theme(context) + .seekIndicatorBuilder + ?.call(context, Duration(seconds: swipeDuration)) ?? + Container( + alignment: Alignment.center, + decoration: BoxDecoration( + color: const Color(0x88000000), + borderRadius: BorderRadius.circular(64.0), + ), + height: 52.0, + width: 108.0, + child: Text( + swipeDuration > 0 + ? "+ ${Duration(seconds: swipeDuration).label()}" + : "- ${Duration(seconds: swipeDuration).label()}", + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 14.0, + color: Color(0xFFFFFFFF), + ), + ), + ), + ), + ), + + // Double-Tap Seek Button(s): + if (!mount || seekOnDoubleTapEnabledWhileControlsAreVisible) + if (_mountSeekBackwardButton || _mountSeekForwardButton) + Positioned.fill( + child: Row( + children: [ + Expanded( + flex: _theme(context) + .seekOnDoubleTapLayoutWidgetRatios[0], + child: _mountSeekBackwardButton + ? AnimatedOpacity( + opacity: _hideSeekBackwardButton ? 0 : 1.0, + duration: const Duration(milliseconds: 200), + child: _BackwardSeekIndicator( + duration: _theme(context) + .seekOnDoubleTapBackwardDuration, + onChanged: (value) { + _seekBarDeltaValueNotifier.value = + -value; + }, + onSubmitted: (value) { + _timerSeekBackwardButton?.cancel(); + _timerSeekBackwardButton = Timer( + const Duration(milliseconds: 200), + () { + setState(() { + _hideSeekBackwardButton = false; + _mountSeekBackwardButton = false; + }); + }, + ); + + setState(() { + _hideSeekBackwardButton = true; + }); + var result = controller(context) + .player + .state + .position - + value; + result = result.clamp( + Duration.zero, + controller(context) + .player + .state + .duration, + ); + controller(context).player.seek(result); + }, + ), + ) + : const SizedBox(), + ), + //Area in the middle where the double-tap seek buttons are ignored in + if (_theme(context) + .seekOnDoubleTapLayoutWidgetRatios[1] > + 0) + Spacer( + flex: _theme(context) + .seekOnDoubleTapLayoutWidgetRatios[1], + ), + Expanded( + flex: _theme(context) + .seekOnDoubleTapLayoutWidgetRatios[2], + child: _mountSeekForwardButton + ? AnimatedOpacity( + opacity: _hideSeekForwardButton ? 0 : 1.0, + duration: const Duration(milliseconds: 200), + child: _ForwardSeekIndicator( + duration: _theme(context) + .seekOnDoubleTapForwardDuration, + onChanged: (value) { + _seekBarDeltaValueNotifier.value = + value; + }, + onSubmitted: (value) { + _timerSeekForwardButton?.cancel(); + _timerSeekForwardButton = Timer( + const Duration(milliseconds: 200), + () { + if (_hideSeekForwardButton) { + setState(() { + _hideSeekForwardButton = false; + _mountSeekForwardButton = false; + }); + } + }); + setState(() { + _hideSeekForwardButton = true; + }); + + var result = controller(context) + .player + .state + .position + + value; + result = result.clamp( + Duration.zero, + controller(context) + .player + .state + .duration, + ); + controller(context).player.seek(result); + }, + ), + ) + : const SizedBox(), + ), + ], + ), + ), + ], + ), + ), + ), + ), + ); + } + + double widgetWidth(BuildContext context) => + (context.findRenderObject() as RenderBox).paintBounds.width; +} + +// SEEK BAR + +/// Material design seek bar. +class MaterialSeekBar extends StatefulWidget { + final ValueNotifier? delta; + final VoidCallback? onSeekStart; + final VoidCallback? onSeekEnd; + + const MaterialSeekBar({ + super.key, + this.delta, + this.onSeekStart, + this.onSeekEnd, + }); + + @override + MaterialSeekBarState createState() => MaterialSeekBarState(); +} + +class MaterialSeekBarState extends State { + bool tapped = false; + double slider = 0.0; + + late bool playing = controller(context).player.state.playing; + late Duration position = controller(context).player.state.position; + late Duration duration = controller(context).player.state.duration; + late Duration buffer = controller(context).player.state.buffer; + + final List subscriptions = []; + + @override + void setState(VoidCallback fn) { + if (mounted) { + super.setState(fn); + } + } + + void listener() { + setState(() { + final delta = widget.delta?.value ?? Duration.zero; + position = controller(context).player.state.position + delta; + }); + } + + @override + void initState() { + super.initState(); + widget.delta?.addListener(listener); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (subscriptions.isEmpty && widget.delta == null) { + subscriptions.addAll( + [ + controller(context).player.stream.playing.listen((event) { + setState(() { + playing = event; + }); + }), + controller(context).player.stream.completed.listen((event) { + setState(() { + position = Duration.zero; + }); + }), + controller(context).player.stream.position.listen((event) { + setState(() { + if (!tapped) { + position = event; + } + }); + }), + controller(context).player.stream.duration.listen((event) { + setState(() { + duration = event; + }); + }), + controller(context).player.stream.buffer.listen((event) { + setState(() { + buffer = event; + }); + }), + ], + ); + } + } + + @override + void dispose() { + widget.delta?.removeListener(listener); + for (final subscription in subscriptions) { + subscription.cancel(); + } + super.dispose(); + } + + void onPointerMove(PointerMoveEvent e, BoxConstraints constraints) { + final percent = e.localPosition.dx / constraints.maxWidth; + setState(() { + tapped = true; + slider = percent.clamp(0.0, 1.0); + }); + controller(context).player.seek(duration * slider); + } + + void onPointerDown() { + widget.onSeekStart?.call(); + setState(() { + tapped = true; + }); + } + + void onPointerUp() { + widget.onSeekEnd?.call(); + setState(() { + // Explicitly set the position to prevent the slider from jumping. + tapped = false; + position = duration * slider; + }); + controller(context).player.seek(duration * slider); + } + + void onPanStart(DragStartDetails e, BoxConstraints constraints) { + final percent = e.localPosition.dx / constraints.maxWidth; + setState(() { + tapped = true; + slider = percent.clamp(0.0, 1.0); + }); + } + + void onPanDown(DragDownDetails e, BoxConstraints constraints) { + final percent = e.localPosition.dx / constraints.maxWidth; + setState(() { + tapped = true; + slider = percent.clamp(0.0, 1.0); + }); + } + + void onPanUpdate(DragUpdateDetails e, BoxConstraints constraints) { + final percent = e.localPosition.dx / constraints.maxWidth; + setState(() { + tapped = true; + slider = percent.clamp(0.0, 1.0); + }); + } + + /// Returns the current playback position in percentage. + double get positionPercent { + if (position == Duration.zero || duration == Duration.zero) { + return 0.0; + } else { + final value = position.inMilliseconds / duration.inMilliseconds; + return value.clamp(0.0, 1.0); + } + } + + /// Returns the current playback buffer position in percentage. + double get bufferPercent { + if (buffer == Duration.zero || duration == Duration.zero) { + return 0.0; + } else { + final value = buffer.inMilliseconds / duration.inMilliseconds; + return value.clamp(0.0, 1.0); + } + } + + @override + Widget build(BuildContext context) { + return Container( + clipBehavior: Clip.none, + margin: _theme(context).seekBarMargin, + child: LayoutBuilder( + builder: (context, constraints) => MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + onHorizontalDragUpdate: (_) {}, + onPanStart: (e) => onPanStart(e, constraints), + onPanDown: (e) => onPanDown(e, constraints), + onPanUpdate: (e) => onPanUpdate(e, constraints), + child: Listener( + onPointerMove: (e) => onPointerMove(e, constraints), + onPointerDown: (e) => onPointerDown(), + onPointerUp: (e) => onPointerUp(), + child: Container( + color: Colors.transparent, + width: constraints.maxWidth, + alignment: _theme(context).seekBarAlignment, + height: _theme(context).seekBarContainerHeight, + child: Stack( + clipBehavior: Clip.none, + alignment: Alignment.bottomCenter, + children: [ + Container( + width: constraints.maxWidth, + height: _theme(context).seekBarHeight, + alignment: Alignment.bottomLeft, + color: _theme(context).seekBarColor, + child: Stack( + clipBehavior: Clip.none, + alignment: Alignment.bottomLeft, + children: [ + Container( + width: constraints.maxWidth * bufferPercent, + color: _theme(context).seekBarBufferColor, + ), + Container( + width: tapped + ? constraints.maxWidth * slider + : constraints.maxWidth * positionPercent, + color: _theme(context).seekBarPositionColor, + ), + ], + ), + ), + Positioned( + left: tapped + ? (constraints.maxWidth - + _theme(context).seekBarThumbSize / 2) * + slider + : (constraints.maxWidth - + _theme(context).seekBarThumbSize / 2) * + positionPercent, + bottom: -1.0 * _theme(context).seekBarThumbSize / 2 + + _theme(context).seekBarHeight / 2, + child: Container( + width: _theme(context).seekBarThumbSize, + height: _theme(context).seekBarThumbSize, + decoration: BoxDecoration( + color: _theme(context).seekBarThumbColor, + borderRadius: BorderRadius.circular( + _theme(context).seekBarThumbSize / 2, + ), + ), + ), + ), + ], + ), + ), + ), + ), + ), + ), + ); + } +} + +// BUTTON: PLAY/PAUSE + +/// A material design play/pause button. +class MaterialPlayOrPauseButton extends StatefulWidget { + /// Overriden icon size for [MaterialSkipPreviousButton]. + final double? iconSize; + + /// Overriden icon color for [MaterialSkipPreviousButton]. + final Color? iconColor; + + const MaterialPlayOrPauseButton({ + super.key, + this.iconSize, + this.iconColor, + }); + + @override + MaterialPlayOrPauseButtonState createState() => + MaterialPlayOrPauseButtonState(); +} + +class MaterialPlayOrPauseButtonState extends State + with SingleTickerProviderStateMixin { + late final animation = AnimationController( + vsync: this, + value: controller(context).player.state.playing ? 1 : 0, + duration: const Duration(milliseconds: 200), + ); + + StreamSubscription? subscription; + + @override + void setState(VoidCallback fn) { + if (mounted) { + super.setState(fn); + } + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + subscription ??= controller(context).player.stream.playing.listen((event) { + if (event) { + animation.forward(); + } else { + animation.reverse(); + } + }); + } + + @override + void dispose() { + animation.dispose(); + subscription?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return IconButton( + onPressed: controller(context).player.playOrPause, + iconSize: widget.iconSize ?? _theme(context).buttonBarButtonSize, + color: widget.iconColor ?? _theme(context).buttonBarButtonColor, + icon: IgnorePointer( + child: AnimatedIcon( + progress: animation, + icon: AnimatedIcons.play_pause, + size: widget.iconSize ?? _theme(context).buttonBarButtonSize, + color: widget.iconColor ?? _theme(context).buttonBarButtonColor, + ), + ), + ); + } +} + +// BUTTON: SKIP NEXT + +/// Material design skip next button. +class MaterialSkipNextButton extends StatelessWidget { + /// Icon for [MaterialSkipNextButton]. + final Widget? icon; + + /// Overriden icon size for [MaterialSkipNextButton]. + final double? iconSize; + + /// Overriden icon color for [MaterialSkipNextButton]. + final Color? iconColor; + + const MaterialSkipNextButton({ + super.key, + this.icon, + this.iconSize, + this.iconColor, + }); + + @override + Widget build(BuildContext context) { + if (!_theme(context).automaticallyImplySkipNextButton || + (controller(context).player.state.playlist.medias.length > 1 && + _theme(context).automaticallyImplySkipNextButton)) { + return IconButton( + onPressed: controller(context).player.next, + icon: icon ?? const Icon(Icons.skip_next), + iconSize: iconSize ?? _theme(context).buttonBarButtonSize, + color: iconColor ?? _theme(context).buttonBarButtonColor, + ); + } + return const SizedBox.shrink(); + } +} + +// BUTTON: SKIP PREVIOUS + +/// Material design skip previous button. +class MaterialSkipPreviousButton extends StatelessWidget { + /// Icon for [MaterialSkipPreviousButton]. + final Widget? icon; + + /// Overriden icon size for [MaterialSkipPreviousButton]. + final double? iconSize; + + /// Overriden icon color for [MaterialSkipPreviousButton]. + final Color? iconColor; + + const MaterialSkipPreviousButton({ + super.key, + this.icon, + this.iconSize, + this.iconColor, + }); + + @override + Widget build(BuildContext context) { + if (!_theme(context).automaticallyImplySkipPreviousButton || + (controller(context).player.state.playlist.medias.length > 1 && + _theme(context).automaticallyImplySkipPreviousButton)) { + return IconButton( + onPressed: controller(context).player.previous, + icon: icon ?? const Icon(Icons.skip_previous), + iconSize: iconSize ?? _theme(context).buttonBarButtonSize, + color: iconColor ?? _theme(context).buttonBarButtonColor, + ); + } + return const SizedBox.shrink(); + } +} + +// BUTTON: FULL SCREEN + +/// Material design fullscreen button. +class MaterialFullscreenButton extends StatelessWidget { + /// Icon for [MaterialFullscreenButton]. + final Widget? icon; + + /// Overriden icon size for [MaterialFullscreenButton]. + final double? iconSize; + + /// Overriden icon color for [MaterialFullscreenButton]. + final Color? iconColor; + + const MaterialFullscreenButton({ + super.key, + this.icon, + this.iconSize, + this.iconColor, + }); + + @override + Widget build(BuildContext context) { + return IconButton( + onPressed: () => toggleFullscreen(context), + icon: icon ?? + (isFullscreen(context) + ? const Icon(Icons.fullscreen_exit) + : const Icon(Icons.fullscreen)), + iconSize: iconSize ?? _theme(context).buttonBarButtonSize, + color: iconColor ?? _theme(context).buttonBarButtonColor, + ); + } +} + +// BUTTON: CUSTOM + +/// Material design custom button. +class MaterialCustomButton extends StatelessWidget { + /// Icon for [MaterialCustomButton]. + final Widget? icon; + + /// Icon size for [MaterialCustomButton]. + final double? iconSize; + + /// Icon color for [MaterialCustomButton]. + final Color? iconColor; + + /// The callback that is called when the button is tapped or otherwise activated. + final VoidCallback onPressed; + + const MaterialCustomButton({ + super.key, + this.icon, + this.iconSize, + this.iconColor, + required this.onPressed, + }); + + @override + Widget build(BuildContext context) { + return IconButton( + onPressed: onPressed, + icon: icon ?? const Icon(Icons.settings), + padding: EdgeInsets.zero, + iconSize: iconSize ?? _theme(context).buttonBarButtonSize, + color: iconColor ?? _theme(context).buttonBarButtonColor, + ); + } +} + +// POSITION INDICATOR + +/// Material design position indicator. +class MaterialPositionIndicator extends StatefulWidget { + /// Overriden [TextStyle] for the [MaterialPositionIndicator]. + final TextStyle? style; + const MaterialPositionIndicator({super.key, this.style}); + + @override + MaterialPositionIndicatorState createState() => + MaterialPositionIndicatorState(); +} + +class MaterialPositionIndicatorState extends State { + late Duration position = controller(context).player.state.position; + late Duration duration = controller(context).player.state.duration; + + final List subscriptions = []; + + @override + void setState(VoidCallback fn) { + if (mounted) { + super.setState(fn); + } + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (subscriptions.isEmpty) { + subscriptions.addAll( + [ + controller(context).player.stream.position.listen((event) { + setState(() { + position = event; + }); + }), + controller(context).player.stream.duration.listen((event) { + setState(() { + duration = event; + }); + }), + ], + ); + } + } + + @override + void dispose() { + for (final subscription in subscriptions) { + subscription.cancel(); + } + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Text( + '${position.label(reference: duration)} / ${duration.label(reference: duration)}', + style: widget.style ?? + TextStyle( + height: 1.0, + fontSize: 12.0, + color: _theme(context).buttonBarButtonColor, + ), + ); + } +} + +class _BackwardSeekIndicator extends StatefulWidget { + final Duration duration; + final void Function(Duration) onChanged; + final void Function(Duration) onSubmitted; + const _BackwardSeekIndicator({ + required this.duration, + required this.onChanged, + required this.onSubmitted, + }); + + @override + State<_BackwardSeekIndicator> createState() => _BackwardSeekIndicatorState(); +} + +class _BackwardSeekIndicatorState extends State<_BackwardSeekIndicator> { + late Duration value = widget.duration; + + Timer? timer; + + @override + void setState(VoidCallback fn) { + if (mounted) { + super.setState(fn); + } + } + + @override + void initState() { + super.initState(); + timer = Timer(const Duration(milliseconds: 400), () { + widget.onSubmitted.call(value); + }); + } + + void increment() { + timer?.cancel(); + timer = Timer(const Duration(milliseconds: 400), () { + widget.onSubmitted.call(value); + }); + widget.onChanged.call(value); + setState(() { + value += const Duration(seconds: 10); + }); + } + + @override + Widget build(BuildContext context) { + return Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + colors: [ + Color(0x88767676), + Color(0x00767676), + ], + begin: Alignment.centerLeft, + end: Alignment.centerRight, + ), + ), + child: InkWell( + splashColor: const Color(0x44767676), + onTap: increment, + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const Icon( + Icons.fast_rewind, + size: 24.0, + color: Color(0xFFFFFFFF), + ), + const SizedBox(height: 8.0), + Text( + '${value.inSeconds} seconds', + style: const TextStyle( + fontSize: 12.0, + color: Color(0xFFFFFFFF), + ), + ), + ], + ), + ), + ), + ); + } +} + +class _ForwardSeekIndicator extends StatefulWidget { + final Duration duration; + final void Function(Duration) onChanged; + final void Function(Duration) onSubmitted; + const _ForwardSeekIndicator({ + required this.duration, + required this.onChanged, + required this.onSubmitted, + }); + + @override + State<_ForwardSeekIndicator> createState() => _ForwardSeekIndicatorState(); +} + +class _ForwardSeekIndicatorState extends State<_ForwardSeekIndicator> { + late Duration value = widget.duration; + + Timer? timer; + + @override + void setState(VoidCallback fn) { + if (mounted) { + super.setState(fn); + } + } + + @override + void initState() { + super.initState(); + timer = Timer(const Duration(milliseconds: 400), () { + widget.onSubmitted.call(value); + }); + } + + void increment() { + timer?.cancel(); + timer = Timer(const Duration(milliseconds: 400), () { + widget.onSubmitted.call(value); + }); + widget.onChanged.call(value); + setState(() { + value += const Duration(seconds: 10); + }); + } + + @override + Widget build(BuildContext context) { + return Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + colors: [ + Color(0x00767676), + Color(0x88767676), + ], + begin: Alignment.centerLeft, + end: Alignment.centerRight, + ), + ), + child: InkWell( + splashColor: const Color(0x44767676), + onTap: increment, + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const Icon( + Icons.fast_forward, + size: 24.0, + color: Color(0xFFFFFFFF), + ), + const SizedBox(height: 8.0), + Text( + '${value.inSeconds} seconds', + style: const TextStyle( + fontSize: 12.0, + color: Color(0xFFFFFFFF), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/third_party/media_kit_video/lib/media_kit_video_controls/src/controls/material_desktop.dart b/third_party/media_kit_video/lib/media_kit_video_controls/src/controls/material_desktop.dart new file mode 100644 index 0000000..73c851a --- /dev/null +++ b/third_party/media_kit_video/lib/media_kit_video_controls/src/controls/material_desktop.dart @@ -0,0 +1,1625 @@ +/// This file is a part of media_kit (https://github.com/media-kit/media-kit). +/// +/// Copyright © 2021 & onwards, Hitesh Kumar Saini . +/// All rights reserved. +/// Use of this source code is governed by MIT license that can be found in the LICENSE file. +// ignore_for_file: non_constant_identifier_names +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/services.dart'; +import 'package:media_kit_video/media_kit_video.dart'; + +import 'package:media_kit_video/media_kit_video_controls/src/controls/methods/video_state.dart'; +import 'package:media_kit_video/media_kit_video_controls/src/controls/extensions/duration.dart'; +import 'package:media_kit_video/media_kit_video_controls/src/controls/widgets/video_controls_theme_data_injector.dart'; + +/// {@template material_desktop_video_controls} +/// +/// [Video] controls which use Material design. +/// +/// {@endtemplate} +Widget MaterialDesktopVideoControls(VideoState state) { + return const VideoControlsThemeDataInjector( + child: _MaterialDesktopVideoControls(), + ); +} + +/// [MaterialDesktopVideoControlsThemeData] available in this [context]. +MaterialDesktopVideoControlsThemeData _theme(BuildContext context) => + FullscreenInheritedWidget.maybeOf(context) == null + ? MaterialDesktopVideoControlsTheme.maybeOf(context)?.normal ?? + kDefaultMaterialDesktopVideoControlsThemeData + : MaterialDesktopVideoControlsTheme.maybeOf(context)?.fullscreen ?? + kDefaultMaterialDesktopVideoControlsThemeDataFullscreen; + +/// Default [MaterialDesktopVideoControlsThemeData]. +const kDefaultMaterialDesktopVideoControlsThemeData = + MaterialDesktopVideoControlsThemeData(); + +/// Default [MaterialDesktopVideoControlsThemeData] for fullscreen. +const kDefaultMaterialDesktopVideoControlsThemeDataFullscreen = + MaterialDesktopVideoControlsThemeData(); + +/// {@template material_desktop_video_controls_theme_data} +/// +/// Theming related data for [MaterialDesktopVideoControls]. These values are used to theme the descendant [MaterialDesktopVideoControls]. +/// +/// {@endtemplate} +class MaterialDesktopVideoControlsThemeData { + // BEHAVIOR + + /// Whether to display seek bar. + final bool displaySeekBar; + + /// Whether a skip next button should be displayed if there are more than one videos in the playlist. + final bool automaticallyImplySkipNextButton; + + /// Whether a skip previous button should be displayed if there are more than one videos in the playlist. + final bool automaticallyImplySkipPreviousButton; + + /// Modify volume on mouse scroll. + final bool modifyVolumeOnScroll; + + /// Whether to toggle fullscreen on double press. + final bool toggleFullscreenOnDoublePress; + + /// Whether to hide mouse on controls removal.(will need to move the mouse to be hidden check issue: https://github.com/flutter/flutter/issues/76622) works on macos without moving the mouse + final bool hideMouseOnControlsRemoval; + + /// Whether to toggle play and pause on tap. + final bool playAndPauseOnTap; + + /// Keyboards shortcuts. + final Map? keyboardShortcuts; + + /// Whether the controls are initially visible. + final bool visibleOnMount; + + // GENERIC + + /// Padding around the controls. + /// + /// * Default: `EdgeInsets.zero` + /// * Fullscreen: `MediaQuery.of(context).padding` + final EdgeInsets? padding; + + /// [Duration] after which the controls will be hidden when there is no mouse movement. + final Duration controlsHoverDuration; + + /// [Duration] for which the controls will be animated when shown or hidden. + final Duration controlsTransitionDuration; + + /// Builder for the buffering indicator. + final Widget Function(BuildContext)? bufferingIndicatorBuilder; + + // BUTTON BAR + + /// Buttons to be displayed in the primary button bar. + final List primaryButtonBar; + + /// Buttons to be displayed in the top button bar. + final List topButtonBar; + + /// Margin around the top button bar. + final EdgeInsets topButtonBarMargin; + + /// Buttons to be displayed in the bottom button bar. + final List bottomButtonBar; + + /// Margin around the bottom button bar. + final EdgeInsets bottomButtonBarMargin; + + /// Height of the button bar. + final double buttonBarHeight; + + /// Size of the button bar buttons. + final double buttonBarButtonSize; + + /// Color of the button bar buttons. + final Color buttonBarButtonColor; + + // SEEK BAR + + /// [Duration] for which the seek bar will be animated when the user seeks. + final Duration seekBarTransitionDuration; + + /// [Duration] for which the seek bar thumb will be animated when the user seeks. + final Duration seekBarThumbTransitionDuration; + + /// Margin around the seek bar. + final EdgeInsets seekBarMargin; + + /// Height of the seek bar. + final double seekBarHeight; + + /// Height of the seek bar when hovered. + final double seekBarHoverHeight; + + /// Height of the seek bar [Container]. + final double seekBarContainerHeight; + + /// [Color] of the seek bar. + final Color seekBarColor; + + /// [Color] of the hovered section in the seek bar. + final Color seekBarHoverColor; + + /// [Color] of the playback position section in the seek bar. + final Color seekBarPositionColor; + + /// [Color] of the playback buffer section in the seek bar. + final Color seekBarBufferColor; + + /// Size of the seek bar thumb. + final double seekBarThumbSize; + + /// [Color] of the seek bar thumb. + final Color seekBarThumbColor; + + // VOLUME BAR + + /// [Color] of the volume bar. + final Color volumeBarColor; + + /// [Color] of the active region in the volume bar. + final Color volumeBarActiveColor; + + /// Size of the volume bar thumb. + final double volumeBarThumbSize; + + /// [Color] of the volume bar thumb. + final Color volumeBarThumbColor; + + /// [Duration] for which the volume bar will be animated when the user hovers. + final Duration volumeBarTransitionDuration; + + // SUBTITLE + + /// Whether to shift the subtitles upwards when the controls are visible. + final bool shiftSubtitlesOnControlsVisibilityChange; + + /// {@macro material_desktop_video_controls_theme_data} + const MaterialDesktopVideoControlsThemeData({ + this.displaySeekBar = true, + this.automaticallyImplySkipNextButton = true, + this.automaticallyImplySkipPreviousButton = true, + this.toggleFullscreenOnDoublePress = true, + this.playAndPauseOnTap = false, + this.modifyVolumeOnScroll = true, + this.keyboardShortcuts, + this.visibleOnMount = false, + this.hideMouseOnControlsRemoval = false, + this.padding, + this.controlsHoverDuration = const Duration(seconds: 3), + this.controlsTransitionDuration = const Duration(milliseconds: 150), + this.bufferingIndicatorBuilder, + this.primaryButtonBar = const [], + this.topButtonBar = const [], + this.topButtonBarMargin = const EdgeInsets.symmetric(horizontal: 16.0), + this.bottomButtonBar = const [ + MaterialDesktopSkipPreviousButton(), + MaterialDesktopPlayOrPauseButton(), + MaterialDesktopSkipNextButton(), + MaterialDesktopVolumeButton(), + MaterialDesktopPositionIndicator(), + Spacer(), + MaterialDesktopFullscreenButton(), + ], + this.bottomButtonBarMargin = const EdgeInsets.symmetric(horizontal: 16.0), + this.buttonBarHeight = 56.0, + this.buttonBarButtonSize = 28.0, + this.buttonBarButtonColor = const Color(0xFFFFFFFF), + this.seekBarTransitionDuration = const Duration(milliseconds: 300), + this.seekBarThumbTransitionDuration = const Duration(milliseconds: 150), + this.seekBarMargin = const EdgeInsets.symmetric(horizontal: 16.0), + this.seekBarHeight = 3.2, + this.seekBarHoverHeight = 5.6, + this.seekBarContainerHeight = 36.0, + this.seekBarColor = const Color(0x3DFFFFFF), + this.seekBarHoverColor = const Color(0x3DFFFFFF), + this.seekBarPositionColor = const Color(0xFFFF0000), + this.seekBarBufferColor = const Color(0x3DFFFFFF), + this.seekBarThumbSize = 12.0, + this.seekBarThumbColor = const Color(0xFFFF0000), + this.volumeBarColor = const Color(0x3DFFFFFF), + this.volumeBarActiveColor = const Color(0xFFFFFFFF), + this.volumeBarThumbSize = 12.0, + this.volumeBarThumbColor = const Color(0xFFFFFFFF), + this.volumeBarTransitionDuration = const Duration(milliseconds: 150), + this.shiftSubtitlesOnControlsVisibilityChange = true, + }); + + /// Creates a copy of this [MaterialDesktopVideoControlsThemeData] with the given fields replaced by the non-null parameter values. + MaterialDesktopVideoControlsThemeData copyWith({ + bool? displaySeekBar, + bool? automaticallyImplySkipNextButton, + bool? automaticallyImplySkipPreviousButton, + bool? toggleFullscreenOnDoublePress, + bool? playAndPauseOnTap, + bool? modifyVolumeOnScroll, + Map? keyboardShortcuts, + bool? visibleOnMount, + bool? hideMouseOnControlsRemoval, + Duration? controlsHoverDuration, + Duration? controlsTransitionDuration, + Widget Function(BuildContext)? bufferingIndicatorBuilder, + List? topButtonBar, + EdgeInsets? topButtonBarMargin, + List? bottomButtonBar, + EdgeInsets? bottomButtonBarMargin, + double? buttonBarHeight, + double? buttonBarButtonSize, + Color? buttonBarButtonColor, + Duration? seekBarTransitionDuration, + Duration? seekBarThumbTransitionDuration, + EdgeInsets? seekBarMargin, + double? seekBarHeight, + double? seekBarHoverHeight, + double? seekBarContainerHeight, + Color? seekBarColor, + Color? seekBarHoverColor, + Color? seekBarPositionColor, + Color? seekBarBufferColor, + double? seekBarThumbSize, + Color? seekBarThumbColor, + Color? volumeBarColor, + Color? volumeBarActiveColor, + double? volumeBarThumbSize, + Color? volumeBarThumbColor, + Duration? volumeBarTransitionDuration, + bool? shiftSubtitlesOnControlsVisibilityChange, + }) { + return MaterialDesktopVideoControlsThemeData( + displaySeekBar: displaySeekBar ?? this.displaySeekBar, + automaticallyImplySkipNextButton: automaticallyImplySkipNextButton ?? + this.automaticallyImplySkipNextButton, + automaticallyImplySkipPreviousButton: + automaticallyImplySkipPreviousButton ?? + this.automaticallyImplySkipPreviousButton, + toggleFullscreenOnDoublePress: + toggleFullscreenOnDoublePress ?? this.toggleFullscreenOnDoublePress, + playAndPauseOnTap: playAndPauseOnTap ?? this.playAndPauseOnTap, + modifyVolumeOnScroll: modifyVolumeOnScroll ?? this.modifyVolumeOnScroll, + keyboardShortcuts: keyboardShortcuts ?? this.keyboardShortcuts, + visibleOnMount: visibleOnMount ?? this.visibleOnMount, + hideMouseOnControlsRemoval: + hideMouseOnControlsRemoval ?? this.hideMouseOnControlsRemoval, + controlsHoverDuration: + controlsHoverDuration ?? this.controlsHoverDuration, + bufferingIndicatorBuilder: + bufferingIndicatorBuilder ?? this.bufferingIndicatorBuilder, + controlsTransitionDuration: + controlsTransitionDuration ?? this.controlsTransitionDuration, + topButtonBar: topButtonBar ?? this.topButtonBar, + topButtonBarMargin: topButtonBarMargin ?? this.topButtonBarMargin, + bottomButtonBar: bottomButtonBar ?? this.bottomButtonBar, + bottomButtonBarMargin: + bottomButtonBarMargin ?? this.bottomButtonBarMargin, + buttonBarHeight: buttonBarHeight ?? this.buttonBarHeight, + buttonBarButtonSize: buttonBarButtonSize ?? this.buttonBarButtonSize, + buttonBarButtonColor: buttonBarButtonColor ?? this.buttonBarButtonColor, + seekBarTransitionDuration: + seekBarTransitionDuration ?? this.seekBarTransitionDuration, + seekBarThumbTransitionDuration: + seekBarThumbTransitionDuration ?? this.seekBarThumbTransitionDuration, + seekBarMargin: seekBarMargin ?? this.seekBarMargin, + seekBarHeight: seekBarHeight ?? this.seekBarHeight, + seekBarHoverHeight: seekBarHoverHeight ?? this.seekBarHoverHeight, + seekBarContainerHeight: + seekBarContainerHeight ?? this.seekBarContainerHeight, + seekBarColor: seekBarColor ?? this.seekBarColor, + seekBarHoverColor: seekBarHoverColor ?? this.seekBarHoverColor, + seekBarPositionColor: seekBarPositionColor ?? this.seekBarPositionColor, + seekBarBufferColor: seekBarBufferColor ?? this.seekBarBufferColor, + seekBarThumbSize: seekBarThumbSize ?? this.seekBarThumbSize, + seekBarThumbColor: seekBarThumbColor ?? this.seekBarThumbColor, + volumeBarColor: volumeBarColor ?? this.volumeBarColor, + volumeBarActiveColor: volumeBarActiveColor ?? this.volumeBarActiveColor, + volumeBarThumbSize: volumeBarThumbSize ?? this.volumeBarThumbSize, + volumeBarThumbColor: volumeBarThumbColor ?? this.volumeBarThumbColor, + volumeBarTransitionDuration: + volumeBarTransitionDuration ?? this.volumeBarTransitionDuration, + shiftSubtitlesOnControlsVisibilityChange: + shiftSubtitlesOnControlsVisibilityChange ?? + this.shiftSubtitlesOnControlsVisibilityChange, + ); + } +} + +/// {@template material_desktop_video_controls_theme} +/// +/// Inherited widget which provides [MaterialDesktopVideoControlsThemeData] to descendant widgets. +/// +/// {@endtemplate} +class MaterialDesktopVideoControlsTheme extends InheritedWidget { + final MaterialDesktopVideoControlsThemeData normal; + final MaterialDesktopVideoControlsThemeData fullscreen; + const MaterialDesktopVideoControlsTheme({ + super.key, + required this.normal, + required this.fullscreen, + required super.child, + }); + + static MaterialDesktopVideoControlsTheme? maybeOf(BuildContext context) { + return context.dependOnInheritedWidgetOfExactType< + MaterialDesktopVideoControlsTheme>(); + } + + static MaterialDesktopVideoControlsTheme of(BuildContext context) { + final MaterialDesktopVideoControlsTheme? result = maybeOf(context); + assert( + result != null, + 'No [MaterialDesktopVideoControlsTheme] found in [context]', + ); + return result!; + } + + @override + bool updateShouldNotify(MaterialDesktopVideoControlsTheme oldWidget) => + identical(normal, oldWidget.normal) && + identical(fullscreen, oldWidget.fullscreen); +} + +/// {@macro material_desktop_video_controls} +class _MaterialDesktopVideoControls extends StatefulWidget { + const _MaterialDesktopVideoControls(); + + @override + State<_MaterialDesktopVideoControls> createState() => + _MaterialDesktopVideoControlsState(); +} + +/// {@macro material_desktop_video_controls} +class _MaterialDesktopVideoControlsState + extends State<_MaterialDesktopVideoControls> { + late bool mount; + late bool visible; + + Timer? _timer; + + late /* private */ var playlist = controller(context).player.state.playlist; + late bool buffering = controller(context).player.state.buffering; + + DateTime last = DateTime.now(); + + final List subscriptions = []; + + double get subtitleVerticalShiftOffset => + (_theme(context).padding?.bottom ?? 0.0) + + (_theme(context).bottomButtonBarMargin.vertical) + + (_theme(context).bottomButtonBar.isNotEmpty + ? _theme(context).buttonBarHeight + : 0.0); + + @override + void setState(VoidCallback fn) { + if (mounted) { + super.setState(fn); + } + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (subscriptions.isEmpty) { + mount = _theme(context).visibleOnMount; + visible = _theme(context).visibleOnMount; + + subscriptions.addAll( + [ + controller(context).player.stream.playlist.listen( + (event) { + setState(() { + playlist = event; + }); + }, + ), + controller(context).player.stream.buffering.listen( + (event) { + setState(() { + buffering = event; + }); + }, + ), + ], + ); + + if (_theme(context).visibleOnMount) { + _timer = Timer( + _theme(context).controlsHoverDuration, + () { + if (mounted) { + setState(() { + visible = false; + }); + unshiftSubtitle(); + } + }, + ); + } + } + } + + @override + void dispose() { + for (final subscription in subscriptions) { + subscription.cancel(); + } + super.dispose(); + } + + void shiftSubtitle() { + if (_theme(context).shiftSubtitlesOnControlsVisibilityChange) { + state(context).setSubtitleViewPadding( + state(context).widget.subtitleViewConfiguration.padding + + EdgeInsets.fromLTRB( + 0.0, + 0.0, + 0.0, + subtitleVerticalShiftOffset, + ), + ); + } + } + + void unshiftSubtitle() { + if (_theme(context).shiftSubtitlesOnControlsVisibilityChange) { + state(context).setSubtitleViewPadding( + state(context).widget.subtitleViewConfiguration.padding, + ); + } + } + + void onHover() { + setState(() { + mount = true; + visible = true; + }); + shiftSubtitle(); + _timer?.cancel(); + _timer = Timer(_theme(context).controlsHoverDuration, () { + if (mounted) { + setState(() { + visible = false; + }); + unshiftSubtitle(); + } + }); + } + + void onEnter() { + setState(() { + mount = true; + visible = true; + }); + shiftSubtitle(); + _timer?.cancel(); + _timer = Timer(_theme(context).controlsHoverDuration, () { + if (mounted) { + setState(() { + visible = false; + }); + unshiftSubtitle(); + } + }); + } + + void onExit() { + setState(() { + visible = false; + }); + unshiftSubtitle(); + _timer?.cancel(); + } + + @override + Widget build(BuildContext context) { + return Theme( + data: Theme.of(context).copyWith( + focusColor: const Color(0x00000000), + hoverColor: const Color(0x00000000), + splashColor: const Color(0x00000000), + highlightColor: const Color(0x00000000), + ), + child: CallbackShortcuts( + bindings: _theme(context).keyboardShortcuts ?? + { + // Default key-board shortcuts. + // https://support.google.com/youtube/answer/7631406 + const SingleActivator(LogicalKeyboardKey.mediaPlay): () => + controller(context).player.play(), + const SingleActivator(LogicalKeyboardKey.mediaPause): () => + controller(context).player.pause(), + const SingleActivator(LogicalKeyboardKey.mediaPlayPause): () => + controller(context).player.playOrPause(), + const SingleActivator(LogicalKeyboardKey.mediaTrackNext): () => + controller(context).player.next(), + const SingleActivator(LogicalKeyboardKey.mediaTrackPrevious): + () => controller(context).player.previous(), + const SingleActivator(LogicalKeyboardKey.space): () => + controller(context).player.playOrPause(), + const SingleActivator(LogicalKeyboardKey.keyJ): () { + final rate = controller(context).player.state.position - + const Duration(seconds: 10); + controller(context).player.seek(rate); + }, + const SingleActivator(LogicalKeyboardKey.keyI): () { + final rate = controller(context).player.state.position + + const Duration(seconds: 10); + controller(context).player.seek(rate); + }, + const SingleActivator(LogicalKeyboardKey.arrowLeft): () { + final rate = controller(context).player.state.position - + const Duration(seconds: 2); + controller(context).player.seek(rate); + }, + const SingleActivator(LogicalKeyboardKey.arrowRight): () { + final rate = controller(context).player.state.position + + const Duration(seconds: 2); + controller(context).player.seek(rate); + }, + const SingleActivator(LogicalKeyboardKey.arrowUp): () { + final volume = controller(context).player.state.volume + 5.0; + controller(context).player.setVolume(volume.clamp(0.0, 100.0)); + }, + const SingleActivator(LogicalKeyboardKey.arrowDown): () { + final volume = controller(context).player.state.volume - 5.0; + controller(context).player.setVolume(volume.clamp(0.0, 100.0)); + }, + const SingleActivator(LogicalKeyboardKey.keyF): () => + toggleFullscreen(context), + const SingleActivator(LogicalKeyboardKey.escape): () => + exitFullscreen(context), + }, + + /// Add [Directionality] to ltr to avoid wrong animation of sides. + child: Directionality( + textDirection: TextDirection.ltr, + child: Focus( + focusNode: videoViewParametersNotifier(context).value.focusNode, + autofocus: true, + child: Material( + elevation: 0.0, + borderOnForeground: false, + animationDuration: Duration.zero, + color: const Color(0x00000000), + shadowColor: const Color(0x00000000), + surfaceTintColor: const Color(0x00000000), + child: Listener( + onPointerSignal: _theme(context).modifyVolumeOnScroll + ? (e) { + if (e is PointerScrollEvent) { + if (e.delta.dy > 0) { + final volume = + controller(context).player.state.volume - 5.0; + controller(context) + .player + .setVolume(volume.clamp(0.0, 100.0)); + } + if (e.delta.dy < 0) { + final volume = + controller(context).player.state.volume + 5.0; + controller(context) + .player + .setVolume(volume.clamp(0.0, 100.0)); + } + } + } + : null, + child: GestureDetector( + onTapDown: !_theme(context).playAndPauseOnTap + ? null + : (TapDownDetails details) { + final RenderBox box = + context.findRenderObject() as RenderBox; + final Offset localPosition = + box.globalToLocal(details.globalPosition); + const double tapPadding = 10.0; + if (!mount || + localPosition.dy < + box.size.height - + subtitleVerticalShiftOffset - + tapPadding) { + // Only play and pause when the bottom seek bar is visible + // and when clicking outside of the bottom seek bar region + controller(context).player.playOrPause(); + } + }, + onTapUp: !_theme(context).toggleFullscreenOnDoublePress + ? null + : (e) { + final now = DateTime.now(); + final difference = now.difference(last); + last = now; + if (difference < const Duration(milliseconds: 400)) { + toggleFullscreen(context); + } + }, + onPanUpdate: _theme(context).modifyVolumeOnScroll + ? (e) { + if (e.delta.dy > 0) { + final volume = + controller(context).player.state.volume - 5.0; + controller(context) + .player + .setVolume(volume.clamp(0.0, 100.0)); + } + if (e.delta.dy < 0) { + final volume = + controller(context).player.state.volume + 5.0; + controller(context) + .player + .setVolume(volume.clamp(0.0, 100.0)); + } + } + : null, + child: MouseRegion( + cursor: + (_theme(context).hideMouseOnControlsRemoval && !mount) + ? SystemMouseCursors.none + : SystemMouseCursors.basic, + onHover: (_) => onHover(), + onEnter: (_) => onEnter(), + onExit: (_) => onExit(), + child: Stack( + children: [ + AnimatedOpacity( + curve: Curves.easeInOut, + opacity: visible ? 1.0 : 0.0, + duration: _theme(context).controlsTransitionDuration, + onEnd: () { + if (!visible) { + setState(() { + mount = false; + }); + } + }, + child: Stack( + clipBehavior: Clip.none, + alignment: Alignment.bottomCenter, + children: [ + // Top gradient. + if (_theme(context).topButtonBar.isNotEmpty) + Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + stops: [ + 0.0, + 0.2, + ], + colors: [ + Color(0x61000000), + Color(0x00000000), + ], + ), + ), + ), + // Bottom gradient. + if (_theme(context).bottomButtonBar.isNotEmpty) + Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + stops: [ + 0.5, + 1.0, + ], + colors: [ + Color(0x00000000), + Color(0x61000000), + ], + ), + ), + ), + if (mount) + Padding( + padding: _theme(context).padding ?? + ( + // Add padding in fullscreen! + isFullscreen(context) + ? MediaQuery.of(context).padding + : EdgeInsets.zero), + child: Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Container( + height: _theme(context).buttonBarHeight, + margin: + _theme(context).topButtonBarMargin, + child: Row( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: + MainAxisAlignment.start, + crossAxisAlignment: + CrossAxisAlignment.center, + children: + _theme(context).topButtonBar, + ), + ), + // Only display [primaryButtonBar] if [buffering] is false. + Expanded( + child: AnimatedOpacity( + curve: Curves.easeInOut, + opacity: buffering ? 0.0 : 1.0, + duration: _theme(context) + .controlsTransitionDuration, + child: Center( + child: Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: + MainAxisAlignment.center, + crossAxisAlignment: + CrossAxisAlignment.center, + children: _theme(context) + .primaryButtonBar, + ), + ), + ), + ), + if (_theme(context).displaySeekBar) + Transform.translate( + offset: _theme(context) + .bottomButtonBar + .isNotEmpty + ? const Offset(0.0, 16.0) + : Offset.zero, + child: MaterialDesktopSeekBar( + onSeekStart: () { + _timer?.cancel(); + }, + onSeekEnd: () { + _timer = Timer( + _theme(context) + .controlsHoverDuration, + () { + if (mounted) { + setState(() { + visible = false; + }); + unshiftSubtitle(); + } + }, + ); + }, + ), + ), + if (_theme(context) + .bottomButtonBar + .isNotEmpty) + Container( + height: + _theme(context).buttonBarHeight, + margin: _theme(context) + .bottomButtonBarMargin, + child: Row( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: + MainAxisAlignment.start, + crossAxisAlignment: + CrossAxisAlignment.center, + children: + _theme(context).bottomButtonBar, + ), + ), + ], + ), + ), + ], + ), + ), + // Buffering Indicator. + IgnorePointer( + child: Padding( + padding: _theme(context).padding ?? + ( + // Add padding in fullscreen! + isFullscreen(context) + ? MediaQuery.of(context).padding + : EdgeInsets.zero), + child: Column( + children: [ + Container( + height: _theme(context).buttonBarHeight, + margin: _theme(context).topButtonBarMargin, + ), + Expanded( + child: Center( + child: Center( + child: TweenAnimationBuilder( + tween: Tween( + begin: 0.0, + end: buffering ? 1.0 : 0.0, + ), + duration: _theme(context) + .controlsTransitionDuration, + builder: (context, value, child) { + // Only mount the buffering indicator if the opacity is greater than 0.0. + // This has been done to prevent redundant resource usage in [CircularProgressIndicator]. + if (value > 0.0) { + return Opacity( + opacity: value, + child: _theme(context) + .bufferingIndicatorBuilder + ?.call(context) ?? + child!, + ); + } + return const SizedBox.shrink(); + }, + child: const CircularProgressIndicator( + color: Color(0xFFFFFFFF), + ), + ), + ), + ), + ), + Container( + height: _theme(context).buttonBarHeight, + margin: _theme(context).bottomButtonBarMargin, + ), + ], + ), + ), + ), + ], + ), + ), + ), + ), + ), + ), + ), + ), + ); + } +} + +// SEEK BAR + +/// Material design seek bar. +class MaterialDesktopSeekBar extends StatefulWidget { + final VoidCallback? onSeekStart; + final VoidCallback? onSeekEnd; + + const MaterialDesktopSeekBar({ + super.key, + this.onSeekStart, + this.onSeekEnd, + }); + + @override + MaterialDesktopSeekBarState createState() => MaterialDesktopSeekBarState(); +} + +class MaterialDesktopSeekBarState extends State { + bool hover = false; + bool click = false; + double slider = 0.0; + + late bool playing = controller(context).player.state.playing; + late Duration position = controller(context).player.state.position; + late Duration duration = controller(context).player.state.duration; + late Duration buffer = controller(context).player.state.buffer; + + final List subscriptions = []; + + @override + void setState(VoidCallback fn) { + if (mounted) { + super.setState(fn); + } + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (subscriptions.isEmpty) { + subscriptions.addAll( + [ + controller(context).player.stream.playing.listen((event) { + setState(() { + playing = event; + }); + }), + controller(context).player.stream.completed.listen((event) { + setState(() { + position = Duration.zero; + }); + }), + controller(context).player.stream.position.listen((event) { + setState(() { + if (!click) position = event; + }); + }), + controller(context).player.stream.duration.listen((event) { + setState(() { + duration = event; + }); + }), + controller(context).player.stream.buffer.listen((event) { + setState(() { + buffer = event; + }); + }), + ], + ); + } + } + + @override + void dispose() { + for (final subscription in subscriptions) { + subscription.cancel(); + } + super.dispose(); + } + + void onPointerMove(PointerMoveEvent e, BoxConstraints constraints) { + final percent = e.localPosition.dx / constraints.maxWidth; + setState(() { + hover = true; + slider = percent.clamp(0.0, 1.0); + }); + controller(context).player.seek(duration * slider); + } + + void onPointerDown() { + widget.onSeekStart?.call(); + setState(() { + click = true; + }); + } + + void onPointerUp() { + widget.onSeekEnd?.call(); + setState(() { + // Explicitly set the position to prevent the slider from jumping. + click = false; + position = duration * slider; + }); + controller(context).player.seek(duration * slider); + } + + void onHover(PointerHoverEvent e, BoxConstraints constraints) { + final percent = e.localPosition.dx / constraints.maxWidth; + setState(() { + hover = true; + slider = percent.clamp(0.0, 1.0); + }); + } + + void onEnter(PointerEnterEvent e, BoxConstraints constraints) { + final percent = e.localPosition.dx / constraints.maxWidth; + setState(() { + hover = true; + slider = percent.clamp(0.0, 1.0); + }); + } + + void onExit(PointerExitEvent e, BoxConstraints constraints) { + setState(() { + hover = false; + slider = 0.0; + }); + } + + /// Returns the current playback position in percentage. + double get positionPercent { + if (position == Duration.zero || duration == Duration.zero) { + return 0.0; + } else { + final value = position.inMilliseconds / duration.inMilliseconds; + return value.clamp(0.0, 1.0); + } + } + + /// Returns the current playback buffer position in percentage. + double get bufferPercent { + if (buffer == Duration.zero || duration == Duration.zero) { + return 0.0; + } else { + final value = buffer.inMilliseconds / duration.inMilliseconds; + return value.clamp(0.0, 1.0); + } + } + + @override + Widget build(BuildContext context) { + return Container( + clipBehavior: Clip.none, + margin: _theme(context).seekBarMargin, + child: LayoutBuilder( + builder: (context, constraints) => MouseRegion( + cursor: SystemMouseCursors.click, + onHover: (e) => onHover(e, constraints), + onEnter: (e) => onEnter(e, constraints), + onExit: (e) => onExit(e, constraints), + child: Listener( + onPointerMove: (e) => onPointerMove(e, constraints), + onPointerDown: (e) => onPointerDown(), + onPointerUp: (e) => onPointerUp(), + child: Container( + color: const Color(0x00000000), + width: constraints.maxWidth, + height: _theme(context).seekBarContainerHeight, + child: Stack( + clipBehavior: Clip.none, + alignment: Alignment.centerLeft, + children: [ + AnimatedContainer( + width: constraints.maxWidth, + height: hover + ? _theme(context).seekBarHoverHeight + : _theme(context).seekBarHeight, + alignment: Alignment.centerLeft, + duration: _theme(context).seekBarThumbTransitionDuration, + color: _theme(context).seekBarColor, + child: Stack( + clipBehavior: Clip.none, + alignment: Alignment.centerLeft, + children: [ + Container( + width: constraints.maxWidth * slider, + color: _theme(context).seekBarHoverColor, + ), + Container( + width: constraints.maxWidth * bufferPercent, + color: _theme(context).seekBarBufferColor, + ), + Container( + width: click + ? constraints.maxWidth * slider + : constraints.maxWidth * positionPercent, + color: _theme(context).seekBarPositionColor, + ), + ], + ), + ), + Positioned( + left: click + ? (constraints.maxWidth - + _theme(context).seekBarThumbSize / 2) * + slider + : (constraints.maxWidth - + _theme(context).seekBarThumbSize / 2) * + positionPercent, + child: AnimatedContainer( + width: hover || click + ? _theme(context).seekBarThumbSize + : 0.0, + height: hover || click + ? _theme(context).seekBarThumbSize + : 0.0, + duration: _theme(context).seekBarThumbTransitionDuration, + decoration: BoxDecoration( + color: _theme(context).seekBarThumbColor, + borderRadius: BorderRadius.circular( + _theme(context).seekBarThumbSize / 2, + ), + ), + ), + ), + ], + ), + ), + ), + ), + ), + ); + } +} + +// BUTTON: PLAY/PAUSE + +/// A material design play/pause button. +class MaterialDesktopPlayOrPauseButton extends StatefulWidget { + /// Overriden icon size for [MaterialDesktopSkipPreviousButton]. + final double? iconSize; + + /// Overriden icon color for [MaterialDesktopSkipPreviousButton]. + final Color? iconColor; + + const MaterialDesktopPlayOrPauseButton({ + super.key, + this.iconSize, + this.iconColor, + }); + + @override + MaterialDesktopPlayOrPauseButtonState createState() => + MaterialDesktopPlayOrPauseButtonState(); +} + +class MaterialDesktopPlayOrPauseButtonState + extends State + with SingleTickerProviderStateMixin { + late final animation = AnimationController( + vsync: this, + value: controller(context).player.state.playing ? 1 : 0, + duration: const Duration(milliseconds: 200), + ); + + StreamSubscription? subscription; + + @override + void setState(VoidCallback fn) { + if (mounted) { + super.setState(fn); + } + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + subscription ??= controller(context).player.stream.playing.listen((event) { + if (event) { + animation.forward(); + } else { + animation.reverse(); + } + }); + } + + @override + void dispose() { + animation.dispose(); + subscription?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return IconButton( + onPressed: controller(context).player.playOrPause, + iconSize: widget.iconSize ?? _theme(context).buttonBarButtonSize, + color: widget.iconColor ?? _theme(context).buttonBarButtonColor, + icon: AnimatedIcon( + progress: animation, + icon: AnimatedIcons.play_pause, + size: widget.iconSize ?? _theme(context).buttonBarButtonSize, + color: widget.iconColor ?? _theme(context).buttonBarButtonColor, + ), + ); + } +} + +// BUTTON: SKIP NEXT + +/// MaterialDesktop design skip next button. +class MaterialDesktopSkipNextButton extends StatelessWidget { + /// Icon for [MaterialDesktopSkipNextButton]. + final Widget? icon; + + /// Overriden icon size for [MaterialDesktopSkipNextButton]. + final double? iconSize; + + /// Overriden icon color for [MaterialDesktopSkipNextButton]. + final Color? iconColor; + + const MaterialDesktopSkipNextButton({ + super.key, + this.icon, + this.iconSize, + this.iconColor, + }); + + @override + Widget build(BuildContext context) { + if (!_theme(context).automaticallyImplySkipNextButton || + (controller(context).player.state.playlist.medias.length > 1 && + _theme(context).automaticallyImplySkipNextButton)) { + return IconButton( + onPressed: controller(context).player.next, + icon: icon ?? const Icon(Icons.skip_next), + iconSize: iconSize ?? _theme(context).buttonBarButtonSize, + color: iconColor ?? _theme(context).buttonBarButtonColor, + ); + } + return const SizedBox.shrink(); + } +} + +// BUTTON: SKIP PREVIOUS + +/// MaterialDesktop design skip previous button. +class MaterialDesktopSkipPreviousButton extends StatelessWidget { + /// Icon for [MaterialDesktopSkipPreviousButton]. + final Widget? icon; + + /// Overriden icon size for [MaterialDesktopSkipPreviousButton]. + final double? iconSize; + + /// Overriden icon color for [MaterialDesktopSkipPreviousButton]. + final Color? iconColor; + + const MaterialDesktopSkipPreviousButton({ + super.key, + this.icon, + this.iconSize, + this.iconColor, + }); + + @override + Widget build(BuildContext context) { + if (!_theme(context).automaticallyImplySkipPreviousButton || + (controller(context).player.state.playlist.medias.length > 1 && + _theme(context).automaticallyImplySkipPreviousButton)) { + return IconButton( + onPressed: controller(context).player.previous, + icon: icon ?? const Icon(Icons.skip_previous), + iconSize: iconSize ?? _theme(context).buttonBarButtonSize, + color: iconColor ?? _theme(context).buttonBarButtonColor, + ); + } + return const SizedBox.shrink(); + } +} + +// BUTTON: FULL SCREEN + +/// MaterialDesktop design fullscreen button. +class MaterialDesktopFullscreenButton extends StatelessWidget { + /// Icon for [MaterialDesktopFullscreenButton]. + final Widget? icon; + + /// Overriden icon size for [MaterialDesktopFullscreenButton]. + final double? iconSize; + + /// Overriden icon color for [MaterialDesktopFullscreenButton]. + final Color? iconColor; + + const MaterialDesktopFullscreenButton({ + super.key, + this.icon, + this.iconSize, + this.iconColor, + }); + + @override + Widget build(BuildContext context) { + return IconButton( + onPressed: () => toggleFullscreen(context), + icon: icon ?? + (isFullscreen(context) + ? const Icon(Icons.fullscreen_exit) + : const Icon(Icons.fullscreen)), + iconSize: iconSize ?? _theme(context).buttonBarButtonSize, + color: iconColor ?? _theme(context).buttonBarButtonColor, + ); + } +} + +// BUTTON: CUSTOM + +/// MaterialDesktop design custom button. +class MaterialDesktopCustomButton extends StatelessWidget { + /// Icon for [MaterialDesktopCustomButton]. + final Widget? icon; + + /// Icon size for [MaterialDesktopCustomButton]. + final double? iconSize; + + /// Icon color for [MaterialDesktopCustomButton]. + final Color? iconColor; + + /// The callback that is called when the button is tapped or otherwise activated. + final VoidCallback onPressed; + + const MaterialDesktopCustomButton({ + super.key, + this.icon, + this.iconSize, + this.iconColor, + required this.onPressed, + }); + + @override + Widget build(BuildContext context) { + return IconButton( + onPressed: onPressed, + icon: icon ?? const Icon(Icons.settings), + iconSize: iconSize ?? _theme(context).buttonBarButtonSize, + color: iconColor ?? _theme(context).buttonBarButtonColor, + ); + } +} + +// BUTTON: VOLUME + +/// MaterialDesktop design volume button & slider. +class MaterialDesktopVolumeButton extends StatefulWidget { + /// Icon size for the volume button. + final double? iconSize; + + /// Icon color for the volume button. + final Color? iconColor; + + /// Mute icon. + final Widget? volumeMuteIcon; + + /// Low volume icon. + final Widget? volumeLowIcon; + + /// High volume icon. + final Widget? volumeHighIcon; + + /// Width for the volume slider. + final double? sliderWidth; + + const MaterialDesktopVolumeButton({ + super.key, + this.iconSize, + this.iconColor, + this.volumeMuteIcon, + this.volumeLowIcon, + this.volumeHighIcon, + this.sliderWidth, + }); + + @override + MaterialDesktopVolumeButtonState createState() => + MaterialDesktopVolumeButtonState(); +} + +class MaterialDesktopVolumeButtonState + extends State + with SingleTickerProviderStateMixin { + late double volume = controller(context).player.state.volume; + + StreamSubscription? subscription; + + bool hover = false; + + bool mute = false; + double _volume = 0.0; + + @override + void setState(VoidCallback fn) { + if (mounted) { + super.setState(fn); + } + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + subscription ??= controller(context).player.stream.volume.listen((event) { + setState(() { + volume = event; + }); + }); + } + + @override + void dispose() { + subscription?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return MouseRegion( + onEnter: (e) { + setState(() { + hover = true; + }); + }, + onExit: (e) { + setState(() { + hover = false; + }); + }, + child: Listener( + onPointerSignal: (event) { + if (event is PointerScrollEvent) { + if (event.scrollDelta.dy < 0) { + controller(context).player.setVolume( + (volume + 5.0).clamp(0.0, 100.0), + ); + } + if (event.scrollDelta.dy > 0) { + controller(context).player.setVolume( + (volume - 5.0).clamp(0.0, 100.0), + ); + } + } + }, + child: Row( + children: [ + const SizedBox(width: 4.0), + IconButton( + onPressed: () async { + if (mute) { + await controller(context).player.setVolume(_volume); + mute = !mute; + } + // https://github.com/media-kit/media-kit/pull/250#issuecomment-1605588306 + else if (volume == 0.0) { + _volume = 100.0; + await controller(context).player.setVolume(100.0); + mute = false; + } else { + _volume = volume; + await controller(context).player.setVolume(0.0); + mute = !mute; + } + + setState(() {}); + }, + iconSize: widget.iconSize ?? + (_theme(context).buttonBarButtonSize * 0.8), + color: widget.iconColor ?? _theme(context).buttonBarButtonColor, + icon: AnimatedSwitcher( + duration: _theme(context).volumeBarTransitionDuration, + child: volume == 0.0 + ? (widget.volumeMuteIcon ?? + const Icon( + Icons.volume_off, + key: ValueKey(Icons.volume_off), + )) + : volume < 50.0 + ? (widget.volumeLowIcon ?? + const Icon( + Icons.volume_down, + key: ValueKey(Icons.volume_down), + )) + : (widget.volumeHighIcon ?? + const Icon( + Icons.volume_up, + key: ValueKey(Icons.volume_up), + )), + ), + ), + AnimatedOpacity( + opacity: hover ? 1.0 : 0.0, + duration: _theme(context).volumeBarTransitionDuration, + child: AnimatedContainer( + width: + hover ? (12.0 + (widget.sliderWidth ?? 52.0) + 18.0) : 12.0, + duration: _theme(context).volumeBarTransitionDuration, + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + const SizedBox(width: 12.0), + SizedBox( + width: widget.sliderWidth ?? 52.0, + child: SliderTheme( + data: SliderThemeData( + trackHeight: 1.2, + inactiveTrackColor: _theme(context).volumeBarColor, + activeTrackColor: + _theme(context).volumeBarActiveColor, + thumbColor: _theme(context).volumeBarThumbColor, + thumbShape: RoundSliderThumbShape( + enabledThumbRadius: + _theme(context).volumeBarThumbSize / 2, + elevation: 0.0, + pressedElevation: 0.0, + ), + trackShape: _CustomTrackShape(), + overlayColor: const Color(0x00000000), + ), + child: Slider( + value: volume.clamp(0.0, 100.0), + min: 0.0, + max: 100.0, + onChanged: (value) async { + await controller(context).player.setVolume(value); + mute = false; + setState(() {}); + }, + ), + ), + ), + const SizedBox(width: 18.0), + ], + ), + ), + ), + ), + ], + ), + ), + ); + } +} + +// POSITION INDICATOR + +/// MaterialDesktop design position indicator. +class MaterialDesktopPositionIndicator extends StatefulWidget { + /// Overriden [TextStyle] for the [MaterialDesktopPositionIndicator]. + final TextStyle? style; + const MaterialDesktopPositionIndicator({super.key, this.style}); + + @override + MaterialDesktopPositionIndicatorState createState() => + MaterialDesktopPositionIndicatorState(); +} + +class MaterialDesktopPositionIndicatorState + extends State { + late Duration position = controller(context).player.state.position; + late Duration duration = controller(context).player.state.duration; + + final List subscriptions = []; + + @override + void setState(VoidCallback fn) { + if (mounted) { + super.setState(fn); + } + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (subscriptions.isEmpty) { + subscriptions.addAll( + [ + controller(context).player.stream.position.listen((event) { + setState(() { + position = event; + }); + }), + controller(context).player.stream.duration.listen((event) { + setState(() { + duration = event; + }); + }), + ], + ); + } + } + + @override + void dispose() { + for (final subscription in subscriptions) { + subscription.cancel(); + } + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Text( + '${position.label(reference: duration)} / ${duration.label(reference: duration)}', + style: widget.style ?? + TextStyle( + height: 1.0, + fontSize: 12.0, + color: _theme(context).buttonBarButtonColor, + ), + ); + } +} + +class _CustomTrackShape extends RoundedRectSliderTrackShape { + @override + Rect getPreferredRect({ + required RenderBox parentBox, + Offset offset = Offset.zero, + required SliderThemeData sliderTheme, + bool isEnabled = false, + bool isDiscrete = false, + }) { + final height = sliderTheme.trackHeight; + final left = offset.dx; + final top = offset.dy + (parentBox.size.height - height!) / 2; + final width = parentBox.size.width; + return Rect.fromLTWH( + left, + top, + width, + height, + ); + } +} diff --git a/third_party/media_kit_video/lib/media_kit_video_controls/src/controls/methods/fullscreen.dart b/third_party/media_kit_video/lib/media_kit_video_controls/src/controls/methods/fullscreen.dart new file mode 100644 index 0000000..bdd5d68 --- /dev/null +++ b/third_party/media_kit_video/lib/media_kit_video_controls/src/controls/methods/fullscreen.dart @@ -0,0 +1,119 @@ +/// This file is a part of media_kit (https://github.com/media-kit/media-kit). +/// +/// Copyright © 2021 & onwards, Hitesh Kumar Saini . +/// All rights reserved. +/// Use of this source code is governed by MIT license that can be found in the LICENSE file. +import 'package:flutter/material.dart'; +import 'package:synchronized/synchronized.dart'; +import 'package:media_kit_video/media_kit_video.dart'; + +import 'package:media_kit_video/media_kit_video_controls/src/controls/methods/video_state.dart'; + +import 'package:media_kit_video/media_kit_video_controls/src/controls/widgets/video_controls_theme_data_injector.dart'; + +/// Whether a [Video] present in the current [BuildContext] is in fullscreen or not. +bool isFullscreen(BuildContext context) => + FullscreenInheritedWidget.maybeOf(context) != null; + +/// Makes the [Video] present in the current [BuildContext] enter fullscreen. +Future enterFullscreen(BuildContext context) { + return lock.synchronized(() async { + if (!isFullscreen(context)) { + if (context.mounted) { + final stateValue = state(context); + final contextNotifierValue = contextNotifier(context); + final videoViewParametersNotifierValue = + videoViewParametersNotifier(context); + final controllerValue = controller(context); + Navigator.of(context, rootNavigator: true).push( + PageRouteBuilder( + pageBuilder: (_, __, ___) => Material( + child: VideoControlsThemeDataInjector( + // NOTE: Make various *VideoControlsThemeData from the parent context available in the fullscreen context. + context: context, + child: VideoStateInheritedWidget( + state: stateValue, + contextNotifier: contextNotifierValue, + videoViewParametersNotifier: videoViewParametersNotifierValue, + disposeNotifiers: false, + child: FullscreenInheritedWidget( + parent: stateValue, + // Another [VideoStateInheritedWidget] inside [FullscreenInheritedWidget] is important to notify about the fullscreen [BuildContext]. + child: VideoStateInheritedWidget( + state: stateValue, + contextNotifier: contextNotifierValue, + videoViewParametersNotifier: + videoViewParametersNotifierValue, + disposeNotifiers: false, + child: Video( + controller: controllerValue, + // Do not restrict the video's width & height in fullscreen mode: + width: null, + height: null, + fit: videoViewParametersNotifierValue.value.fit, + fill: videoViewParametersNotifierValue.value.fill, + alignment: + videoViewParametersNotifierValue.value.alignment, + aspectRatio: + videoViewParametersNotifierValue.value.aspectRatio, + filterQuality: videoViewParametersNotifierValue + .value.filterQuality, + controls: + videoViewParametersNotifierValue.value.controls, + // Do not acquire or modify existing wakelock in fullscreen mode: + wakelock: false, + pauseUponEnteringBackgroundMode: + stateValue.widget.pauseUponEnteringBackgroundMode, + resumeUponEnteringForegroundMode: + stateValue.widget.resumeUponEnteringForegroundMode, + subtitleViewConfiguration: + videoViewParametersNotifierValue + .value.subtitleViewConfiguration, + focusNode: + videoViewParametersNotifierValue.value.focusNode, + onEnterFullscreen: stateValue.widget.onEnterFullscreen, + onExitFullscreen: stateValue.widget.onExitFullscreen, + ), + ), + ), + ), + ), + ), + transitionDuration: Duration.zero, + reverseTransitionDuration: Duration.zero, + ), + ); + await onEnterFullscreen(context)?.call(); + } + } + }); +} + +/// Makes the [Video] present in the current [BuildContext] exit fullscreen. +Future exitFullscreen(BuildContext context) { + return lock.synchronized(() async { + if (isFullscreen(context)) { + if (context.mounted) { + await Navigator.of(context).maybePop(); + // It is known that this [context] will have a [FullscreenInheritedWidget] above it. + if (context.mounted) { + FullscreenInheritedWidget.of(context).parent.refreshView(); + } + } + // [exitNativeFullscreen] is moved to [WillPopScope] in [FullscreenInheritedWidget]. + // This is because [exitNativeFullscreen] needs to be called when the user presses the back button. + } + }); +} + +/// Toggles fullscreen for the [Video] present in the current [BuildContext]. +Future toggleFullscreen(BuildContext context) { + if (isFullscreen(context)) { + return exitFullscreen(context); + } else { + return enterFullscreen(context); + } +} + +/// For synchronizing [enterFullscreen] & [exitFullscreen] operations. +final Lock lock = Lock(); diff --git a/third_party/media_kit_video/lib/media_kit_video_controls/src/controls/methods/video_state.dart b/third_party/media_kit_video/lib/media_kit_video_controls/src/controls/methods/video_state.dart new file mode 100644 index 0000000..037ccb0 --- /dev/null +++ b/third_party/media_kit_video/lib/media_kit_video_controls/src/controls/methods/video_state.dart @@ -0,0 +1,33 @@ +/// This file is a part of media_kit (https://github.com/media-kit/media-kit). +/// +/// Copyright © 2021 & onwards, Hitesh Kumar Saini . +/// All rights reserved. +/// Use of this source code is governed by MIT license that can be found in the LICENSE file. +import 'package:flutter/widgets.dart'; +import 'package:media_kit_video/media_kit_video.dart'; +import 'package:media_kit_video/src/utils/dispose_safe_notifer.dart'; + +/// Returns the [VideoState] associated with the [Video] present in the current [BuildContext]. +VideoState state(BuildContext context) => + VideoStateInheritedWidget.of(context).state; + +/// Returns the [ValueNotifier] associated with the [Video] present in the current [BuildContext]. +DisposeSafeNotifier contextNotifier(BuildContext context) => + VideoStateInheritedWidget.of(context).contextNotifier; + +/// Returns the [ValueNotifier] associated with the [Video] present in the current [BuildContext]. +ValueNotifier videoViewParametersNotifier( + BuildContext context) => + VideoStateInheritedWidget.of(context).videoViewParametersNotifier; + +/// Returns the [VideoController] associated with the [Video] present in the current [BuildContext]. +VideoController controller(BuildContext context) => + VideoStateInheritedWidget.of(context).state.widget.controller; + +/// Returns the callback which must be invoked when the video enters fullscreen mode. +Future Function()? onEnterFullscreen(BuildContext context) => + VideoStateInheritedWidget.of(context).state.widget.onEnterFullscreen; + +/// Returns the callback which must be invoked when the video exits fullscreen mode. +Future Function()? onExitFullscreen(BuildContext context) => + VideoStateInheritedWidget.of(context).state.widget.onExitFullscreen; diff --git a/third_party/media_kit_video/lib/media_kit_video_controls/src/controls/no.dart b/third_party/media_kit_video/lib/media_kit_video_controls/src/controls/no.dart new file mode 100644 index 0000000..bf904b9 --- /dev/null +++ b/third_party/media_kit_video/lib/media_kit_video_controls/src/controls/no.dart @@ -0,0 +1,14 @@ +/// This file is a part of media_kit (https://github.com/media-kit/media-kit). +/// +/// Copyright © 2021 & onwards, Hitesh Kumar Saini . +/// All rights reserved. +/// Use of this source code is governed by MIT license that can be found in the LICENSE file. +// ignore_for_file: constant_identifier_names +import 'package:media_kit_video/media_kit_video.dart'; + +/// {@template no_video_controls} +/// +/// Disables [Video] controls. +/// +/// {@endtemplate} +const NoVideoControls = null; diff --git a/third_party/media_kit_video/lib/media_kit_video_controls/src/controls/widgets/fullscreen_inherited_widget.dart b/third_party/media_kit_video/lib/media_kit_video_controls/src/controls/widgets/fullscreen_inherited_widget.dart new file mode 100644 index 0000000..50dbfeb --- /dev/null +++ b/third_party/media_kit_video/lib/media_kit_video_controls/src/controls/widgets/fullscreen_inherited_widget.dart @@ -0,0 +1,72 @@ +/// This file is a part of media_kit (https://github.com/media-kit/media-kit). +/// +/// Copyright © 2021 & onwards, Hitesh Kumar Saini . +/// All rights reserved. +/// Use of this source code is governed by MIT license that can be found in the LICENSE file. +import 'package:flutter/widgets.dart'; +import 'package:media_kit_video/media_kit_video.dart'; + +import 'package:media_kit_video/media_kit_video_controls/src/controls/methods/video_state.dart'; + +/// {@template fullscreen_inherited_widget} +/// +/// Inherited widget used to identify whether parent [Video] is in fullscreen or not. +/// +/// {@endtemplate} +class FullscreenInheritedWidget extends InheritedWidget { + final VideoState parent; + + FullscreenInheritedWidget({ + super.key, + required this.parent, + required Widget child, + }) : super(child: _FullscreenInheritedWidgetPopScope(child: child)); + + static FullscreenInheritedWidget? maybeOf(BuildContext context) { + return context + .dependOnInheritedWidgetOfExactType(); + } + + static FullscreenInheritedWidget of(BuildContext context) { + final FullscreenInheritedWidget? result = maybeOf(context); + assert( + result != null, + 'No [FullscreenInheritedWidget] found in [context]', + ); + return result!; + } + + @override + bool updateShouldNotify(FullscreenInheritedWidget oldWidget) => + identical(parent, oldWidget.parent); +} + +/// {@template fullscreen_inherited_widget_pop_scope} +/// +/// This widget is used to exit native fullscreen when this route is popped from the navigator. +/// +/// {@endtemplate} +class _FullscreenInheritedWidgetPopScope extends StatefulWidget { + final Widget child; + const _FullscreenInheritedWidgetPopScope({ + required this.child, + }); + + @override + State<_FullscreenInheritedWidgetPopScope> createState() => + _FullscreenInheritedWidgetPopScopeState(); +} + +class _FullscreenInheritedWidgetPopScopeState + extends State<_FullscreenInheritedWidgetPopScope> { + @override + Widget build(BuildContext context) { + return PopScope( + onPopInvokedWithResult: (_, __) { + // Make sure to exit native fullscreen when this route is popped from the navigator. + onExitFullscreen(context)?.call(); + }, + child: widget.child, + ); + } +} diff --git a/third_party/media_kit_video/lib/media_kit_video_controls/src/controls/widgets/video_controls_theme_data_injector.dart b/third_party/media_kit_video/lib/media_kit_video_controls/src/controls/widgets/video_controls_theme_data_injector.dart new file mode 100644 index 0000000..b018429 --- /dev/null +++ b/third_party/media_kit_video/lib/media_kit_video_controls/src/controls/widgets/video_controls_theme_data_injector.dart @@ -0,0 +1,83 @@ +/// This file is a part of media_kit (https://github.com/media-kit/media-kit). +/// +/// Copyright © 2021 & onwards, Hitesh Kumar Saini . +/// All rights reserved. +/// Use of this source code is governed by MIT license that can be found in the LICENSE file. +import 'package:flutter/widgets.dart'; + +import 'package:media_kit_video/media_kit_video_controls/src/controls/material.dart'; +import 'package:media_kit_video/media_kit_video_controls/src/controls/material_desktop.dart'; + +/// {@template video_controls_theme_data_injector} +/// +/// VideoControlsThemeDataInjector +/// ------------------------------ +/// +/// Makes the various `*VideoControlsTheme` [InheritedWidget]s from provided [context] to current [BuildContext] and its descendants. +/// +/// The fallback to default values is done by [VideoControlsThemeDataInjector] itself. +/// +/// * [CupertinoVideoControlsTheme] +/// * [MaterialVideoControlsTheme] +/// * [MaterialDesktopVideoControlsTheme] +/// +/// {@endtemplate} +class VideoControlsThemeDataInjector extends StatefulWidget { + final Widget child; + final BuildContext? context; + + /// {@macro video_controls_theme_data_injector} + const VideoControlsThemeDataInjector({ + super.key, + required this.child, + this.context, + }); + + @override + State createState() => + _VideoControlsThemeDataInjectorState(); +} + +class _VideoControlsThemeDataInjectorState + extends State { + late final builders = [ + // MaterialVideoControlsTheme + (child) { + final theme = MaterialVideoControlsTheme.maybeOf( + widget.context ?? context, + ); + final normal = theme?.normal ?? kDefaultMaterialVideoControlsThemeData; + final fullscreen = + theme?.fullscreen ?? kDefaultMaterialVideoControlsThemeDataFullscreen; + return MaterialVideoControlsTheme( + normal: normal, + fullscreen: fullscreen, + child: child, + ); + }, + // MaterialDesktopVideoControlsTheme + (child) { + final theme = MaterialDesktopVideoControlsTheme.maybeOf( + widget.context ?? context, + ); + final normal = + theme?.normal ?? kDefaultMaterialDesktopVideoControlsThemeData; + final fullscreen = theme?.fullscreen ?? + kDefaultMaterialDesktopVideoControlsThemeDataFullscreen; + return MaterialDesktopVideoControlsTheme( + normal: normal, + fullscreen: fullscreen, + child: child, + ); + }, + // NOTE: Add more builders if more *VideoControlsTheme are implemented. + ]; + + @override + Widget build(BuildContext context) { + return builders.fold( + widget.child, + (child, builder) => builder(child), + ); + } +} diff --git a/third_party/media_kit_video/lib/media_kit_video_controls/src/controls/widgets/video_state_inherited_widget.dart b/third_party/media_kit_video/lib/media_kit_video_controls/src/controls/widgets/video_state_inherited_widget.dart new file mode 100644 index 0000000..b354b93 --- /dev/null +++ b/third_party/media_kit_video/lib/media_kit_video_controls/src/controls/widgets/video_state_inherited_widget.dart @@ -0,0 +1,110 @@ +/// This file is a part of media_kit (https://github.com/media-kit/media-kit). +/// +/// Copyright © 2021 & onwards, Hitesh Kumar Saini . +/// All rights reserved. +/// Use of this source code is governed by MIT license that can be found in the LICENSE file. +import 'dart:collection'; + +import 'package:flutter/widgets.dart'; +import 'package:media_kit_video/media_kit_video.dart'; +import 'package:media_kit_video/src/utils/dispose_safe_notifer.dart'; + +/// {@template video_state_inherited_widget} +/// +/// Inherited widget which provides [VideoState] associated with the parent [Video] widget to descendant widgets. +/// +/// {@endtemplate} +class VideoStateInheritedWidget extends InheritedWidget { + final VideoState state; + final DisposeSafeNotifier contextNotifier; + final ValueNotifier videoViewParametersNotifier; + + final bool disposeNotifiers; + VideoStateInheritedWidget({ + super.key, + required this.state, + required this.contextNotifier, + required this.videoViewParametersNotifier, + required Widget child, + this.disposeNotifiers = true, + }) : super( + child: VideoStateInheritedWidgetContextNotifier( + state: state, + contextNotifier: contextNotifier, + videoViewParametersNotifier: videoViewParametersNotifier, + disposeNotifiers: disposeNotifiers, + child: child, + ), + ); + + static VideoStateInheritedWidget? maybeOf(BuildContext context) { + return context + .dependOnInheritedWidgetOfExactType(); + } + + static VideoStateInheritedWidget of(BuildContext context) { + final VideoStateInheritedWidget? result = maybeOf(context); + assert( + result != null, + 'No [VideoStateInheritedWidget] found in [context]', + ); + return result!; + } + + @override + bool updateShouldNotify(VideoStateInheritedWidget oldWidget) => + identical(state, oldWidget.state) && + identical(contextNotifier, oldWidget.contextNotifier); +} + +/// {@template video_state_inherited_widget_context_notifier} +/// +/// This widget is used to notify the [VideoState._contextNotifier] about the most recent [BuildContext] associated with the [Video] widget. +/// +/// {@endtemplate} +class VideoStateInheritedWidgetContextNotifier extends StatefulWidget { + final VideoState state; + final DisposeSafeNotifier contextNotifier; + final ValueNotifier videoViewParametersNotifier; + final bool disposeNotifiers; + final Widget child; + + const VideoStateInheritedWidgetContextNotifier({ + super.key, + required this.state, + required this.contextNotifier, + required this.videoViewParametersNotifier, + required this.disposeNotifiers, + required this.child, + }); + + @override + State createState() => + VideoStateInheritedWidgetContextNotifierState(); +} + +class VideoStateInheritedWidgetContextNotifierState + extends State { + static final fallback = HashMap.identity(); + + @override + void dispose() { + if (!widget.contextNotifier.disposed) { + // Restore the original [BuildContext] associated with this [Video] widget. + widget.contextNotifier.value = fallback[widget.state]; + } + super.dispose(); + } + + @override + Widget build(BuildContext context) { + // Only update the [BuildContext] associated with this [Video] widget if it is not already set or if the [Video] widget is in fullscreen mode. + // This is being done because the [Video] widget is rebuilt when it enters/exits fullscreen mode... & things don't work properly if we let [BuildContext] update in every rebuild. + if (widget.contextNotifier.value == null || isFullscreen(context)) { + widget.contextNotifier.value = context; + fallback[widget.state] ??= context; + } + + return widget.child; + } +} diff --git a/third_party/media_kit_video/lib/src/subtitle/subtitle_view.dart b/third_party/media_kit_video/lib/src/subtitle/subtitle_view.dart new file mode 100644 index 0000000..bc4d5b1 --- /dev/null +++ b/third_party/media_kit_video/lib/src/subtitle/subtitle_view.dart @@ -0,0 +1,163 @@ +/// This file is a part of media_kit (https://github.com/media-kit/media-kit). +/// +/// Copyright © 2021 & onwards, Hitesh Kumar Saini . +/// All rights reserved. +/// Use of this source code is governed by MIT license that can be found in the LICENSE file. +import 'dart:async'; +import 'dart:math'; +import 'package:flutter/material.dart'; + +import 'package:media_kit_video/src/video_controller/video_controller.dart'; + +/// {@template subtitle_view} +/// +/// SubtitleView +/// ------------ +/// +/// [SubtitleView] widget is used to display the subtitles on top of the [Video]. +/// +/// {@endtemplate} +class SubtitleView extends StatefulWidget { + /// The [VideoController] reference to control this [SubtitleView] output. + final VideoController controller; + + /// The configuration to be used for the subtitles. + final SubtitleViewConfiguration configuration; + + /// {@macro subtitle_view} + const SubtitleView({ + super.key, + required this.controller, + required this.configuration, + }); + + @override + SubtitleViewState createState() => SubtitleViewState(); +} + +class SubtitleViewState extends State { + late List subtitle = widget.controller.player.state.subtitle; + late EdgeInsets padding = widget.configuration.padding; + late Duration duration = const Duration(milliseconds: 100); + + // The [StreamSubscription] to listen to the subtitle changes. + StreamSubscription>? subscription; + + // The reference width for calculating the visible text scale factor. + static const kTextScaleFactorReferenceWidth = 1920.0; + // The reference height for calculating the visible text scale factor. + static const kTextScaleFactorReferenceHeight = 1080.0; + + @override + void initState() { + subscription = widget.controller.player.stream.subtitle.listen((value) { + setState(() { + subtitle = value; + }); + }); + super.initState(); + } + + @override + void dispose() { + subscription?.cancel(); + super.dispose(); + } + + /// Sets the padding to be used for the subtitles. + /// + /// The [duration] argument may be specified to set the duration of the animation. + void setPadding( + EdgeInsets padding, { + Duration duration = const Duration(milliseconds: 100), + }) { + if (this.duration != duration) { + setState(() { + this.duration = duration; + }); + } + setState(() { + this.padding = padding; + }); + } + + /// {@macro subtitle_view} + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + // Calculate the visible text scale factor. + + final nr = (constraints.maxWidth * constraints.maxHeight); + const dr = + kTextScaleFactorReferenceWidth * kTextScaleFactorReferenceHeight; + final textScaleFactor = sqrt((nr / dr).clamp(0.0, 1.0)); + + final textScaler = widget.configuration.textScaler ?? + TextScaler.linear(textScaleFactor); + return Material( + color: Colors.transparent, + child: AnimatedContainer( + padding: padding, + duration: duration, + alignment: Alignment.bottomCenter, + child: Text( + [ + for (final line in subtitle) + if (line.trim().isNotEmpty) line.trim(), + ].join('\n'), + style: widget.configuration.style, + textAlign: widget.configuration.textAlign, + textScaler: textScaler, + ), + ), + ); + }, + ); + } +} + +/// {@template subtitle_view_configuration} +/// SubtitleViewConfiguration +/// ------------------------- +/// +/// Configurable options for customizing the [SubtitleView] behaviour. +/// {@endtemplate} +class SubtitleViewConfiguration { + /// Whether the subtitles should be visible or not. + final bool visible; + + /// The text style to be used for the subtitles. + final TextStyle style; + + /// The text alignment to be used for the subtitles. + final TextAlign textAlign; + + /// The text scaler to be used for the subtitles. + final TextScaler? textScaler; + + /// The padding to be used for the subtitles. + final EdgeInsets padding; + + /// {@macro subtitle_view_configuration} + const SubtitleViewConfiguration({ + this.visible = true, + this.style = const TextStyle( + height: 1.4, + fontSize: 32.0, + letterSpacing: 0.0, + wordSpacing: 0.0, + color: Color(0xffffffff), + fontWeight: FontWeight.normal, + backgroundColor: Color(0xaa000000), + ), + this.textAlign = TextAlign.center, + this.textScaler, + this.padding = const EdgeInsets.fromLTRB( + 16.0, + 0.0, + 16.0, + 24.0, + ), + }); +} diff --git a/third_party/media_kit_video/lib/src/utils/dispose_safe_notifer.dart b/third_party/media_kit_video/lib/src/utils/dispose_safe_notifer.dart new file mode 100644 index 0000000..cbc795b --- /dev/null +++ b/third_party/media_kit_video/lib/src/utils/dispose_safe_notifer.dart @@ -0,0 +1,12 @@ +import 'package:flutter/widgets.dart'; + +class DisposeSafeNotifier extends ValueNotifier { + bool disposed = false; + DisposeSafeNotifier(super.value); + + @override + void dispose() { + disposed = true; + super.dispose(); + } +} diff --git a/third_party/media_kit_video/lib/src/utils/query_decoders.dart b/third_party/media_kit_video/lib/src/utils/query_decoders.dart new file mode 100644 index 0000000..47bc089 --- /dev/null +++ b/third_party/media_kit_video/lib/src/utils/query_decoders.dart @@ -0,0 +1,68 @@ +/// This file is a part of media_kit (https://github.com/media-kit/media-kit). +/// +/// Copyright © 2021 & onwards, Hitesh Kumar Saini . +/// All rights reserved. +/// Use of this source code is governed by MIT license that can be found in the LICENSE file. +import 'dart:ffi'; +import 'dart:collection'; +import 'package:synchronized/synchronized.dart'; +// ignore_for_file: unused_import, implementation_imports +import 'package:media_kit/ffi/ffi.dart'; +import 'package:media_kit/src/player/native/core/native_library.dart'; + +import 'package:media_kit/generated/libmpv/bindings.dart'; + +/// Returns the list of available decoders available in libavcodec. +/// Takes raw address to `mpv_handle*` as [handle]. +/// +/// https://mpv.io/manual/stable/#command-interface-decoder-list +Future> queryDecoders(int handle) { + return _lock.synchronized(() { + if (_decoders.isNotEmpty) { + return _decoders; + } + + NativeLibrary.ensureInitialized(); + final mpv = MPV(DynamicLibrary.open(NativeLibrary.path)); + + if (_decoders.isEmpty) { + final name = 'decoder-list'.toNativeUtf8(); + final data = calloc(); + mpv.mpv_get_property( + Pointer.fromAddress(handle), + name.cast(), + mpv_format.MPV_FORMAT_NODE, + data.cast(), + ); + if (data.ref.format == mpv_format.MPV_FORMAT_NODE_ARRAY) { + for (int i = 0; i < data.ref.u.list.ref.num; i++) { + final decoder = data.ref.u.list.ref.values[i]; + if (decoder.format == mpv_format.MPV_FORMAT_NODE_MAP) { + String? name; + for (int j = 0; j < decoder.u.list.ref.num; j++) { + final k = decoder.u.list.ref.keys[j].cast().toDartString(); + final v = decoder.u.list.ref.values[j]; + if (k == 'codec' && v.format == mpv_format.MPV_FORMAT_STRING) { + name ??= v.u.string.cast().toDartString(); + } + if (k == 'driver' && v.format == mpv_format.MPV_FORMAT_STRING) { + name ??= v.u.string.cast().toDartString(); + } + } + if (name != null) { + _decoders.add(name); + } + } + } + } + mpv.mpv_free_node_contents(data); + calloc.free(name); + calloc.free(data); + } + + return _decoders; + }); +} + +final Lock _lock = Lock(); +final HashSet _decoders = HashSet(); diff --git a/third_party/media_kit_video/lib/src/utils/wakelock.dart b/third_party/media_kit_video/lib/src/utils/wakelock.dart new file mode 100644 index 0000000..896617c --- /dev/null +++ b/third_party/media_kit_video/lib/src/utils/wakelock.dart @@ -0,0 +1,51 @@ +/// This file is a part of media_kit (https://github.com/media-kit/media-kit). +/// +/// Copyright © 2021 & onwards, Julien Muret . +/// All rights reserved. +/// Use of this source code is governed by MIT license that can be found in the LICENSE file. +import 'package:wakelock_plus/wakelock_plus.dart'; + +/// {@template wakelock} +/// Wakelock +/// -------- +/// +/// This class acquires & releases the wakelock based on reference count. +/// +/// {@endtemplate} +class Wakelock { + /// {@macro wakelock} + Wakelock(); + + /// Marks the wakelock as enabled for this instance. + void enable() { + if (!_enabled) { + _enabled = true; + _count++; + _update(); + } + } + + /// Marks the wakelock as disabled for this instance. + void disable() { + if (_enabled) { + _enabled = false; + _count--; + _update(); + } + } + + /// Acquires the wakelock if enabled count is greater than 0. + void _update() { + if (_count > 0) { + WakelockPlus.enable().catchError((_) {}); + } else { + WakelockPlus.disable().catchError((_) {}); + } + } + + /// Whether the wakelock is enabled for this instance. + bool _enabled = false; + + /// The number of [Wakelock] instances in enabled state. + static int _count = 0; +} diff --git a/third_party/media_kit_video/lib/src/video/video.dart b/third_party/media_kit_video/lib/src/video/video.dart new file mode 100644 index 0000000..43ca477 --- /dev/null +++ b/third_party/media_kit_video/lib/src/video/video.dart @@ -0,0 +1,7 @@ +/// This file is a part of media_kit (https://github.com/media-kit/media-kit). +/// +/// Copyright © 2021 & onwards, Hitesh Kumar Saini . +/// All rights reserved. +/// Use of this source code is governed by MIT license that can be found in the LICENSE file. + +export 'video_texture.dart' if (dart.library.js_interop) 'video_web.dart'; diff --git a/third_party/media_kit_video/lib/src/video/video_texture.dart b/third_party/media_kit_video/lib/src/video/video_texture.dart new file mode 100644 index 0000000..14a92d7 --- /dev/null +++ b/third_party/media_kit_video/lib/src/video/video_texture.dart @@ -0,0 +1,534 @@ +/// This file is a part of media_kit (https://github.com/media-kit/media-kit). +/// +/// Copyright © 2021 & onwards, Hitesh Kumar Saini . +/// All rights reserved. +/// Use of this source code is governed by MIT license that can be found in the LICENSE file. +import 'dart:io'; +import 'dart:async'; +import 'package:flutter/widgets.dart'; +import 'package:flutter/services.dart'; +import 'package:media_kit_video/media_kit_video_controls/media_kit_video_controls.dart'; + +import 'package:media_kit_video/src/subtitle/subtitle_view.dart'; +import 'package:media_kit_video/media_kit_video_controls/media_kit_video_controls.dart' + as media_kit_video_controls; +import 'package:media_kit_video/src/utils/dispose_safe_notifer.dart'; + +import 'package:media_kit_video/src/utils/wakelock.dart'; +import 'package:media_kit_video/src/video_view_parameters.dart'; +import 'package:media_kit_video/src/video_controller/video_controller.dart'; +import 'package:media_kit_video/src/video_controller/platform_video_controller.dart'; + +/// {@template video} +/// +/// Video +/// ----- +/// [Video] widget is used to display video output. +/// +/// Use [VideoController] to initialize & handle the video rendering. +/// +/// **Example:** +/// +/// ```dart +/// class MyScreen extends StatefulWidget { +/// const MyScreen({Key? key}) : super(key: key); +/// @override +/// State createState() => MyScreenState(); +/// } +/// +/// class MyScreenState extends State { +/// late final player = Player(); +/// late final controller = VideoController(player); +/// +/// @override +/// void initState() { +/// super.initState(); +/// player.open(Media('https://user-images.githubusercontent.com/28951144/229373695-22f88f13-d18f-4288-9bf1-c3e078d83722.mp4')); +/// } +/// +/// @override +/// void dispose() { +/// player.dispose(); +/// super.dispose(); +/// } +/// +/// @override +/// Widget build(BuildContext context) { +/// return Scaffold( +/// body: Video( +/// controller: controller, +/// ), +/// ); +/// } +/// } +/// ``` +/// +/// {@endtemplate} +class Video extends StatefulWidget { + /// The [VideoController] reference to control this [Video] output. + final VideoController controller; + + /// Width of this viewport. + final double? width; + + /// Height of this viewport. + final double? height; + + /// Fit of the viewport. + final BoxFit fit; + + /// Background color to fill the video background. + final Color fill; + + /// Alignment of the viewport. + final Alignment alignment; + + /// Preferred aspect ratio of the viewport. + final double? aspectRatio; + + /// Filter quality of the [Texture] widget displaying the video output. + final FilterQuality filterQuality; + + /// Video controls builder. + final VideoControlsBuilder? controls; + + /// Whether to acquire wake lock while playing the video. + final bool wakelock; + + /// Whether to pause the video when application enters background mode. + final bool pauseUponEnteringBackgroundMode; + + /// Whether to resume the video when application enters foreground mode. + /// + /// This attribute is only applicable if [pauseUponEnteringBackgroundMode] is `true`. + /// + final bool resumeUponEnteringForegroundMode; + + /// The configuration for subtitles e.g. [TextStyle] & padding etc. + final SubtitleViewConfiguration subtitleViewConfiguration; + + /// The callback invoked when the [Video] enters fullscreen. + final Future Function() onEnterFullscreen; + + /// The callback invoked when the [Video] exits fullscreen. + final Future Function() onExitFullscreen; + + /// FocusNode for keyboard input. + final FocusNode? focusNode; + + /// {@macro video} + const Video({ + super.key, + required this.controller, + this.width, + this.height, + this.fit = BoxFit.contain, + this.fill = const Color(0xFF000000), + this.alignment = Alignment.center, + this.aspectRatio, + this.filterQuality = FilterQuality.low, + this.controls = media_kit_video_controls.AdaptiveVideoControls, + this.wakelock = true, + this.pauseUponEnteringBackgroundMode = true, + this.resumeUponEnteringForegroundMode = false, + this.subtitleViewConfiguration = const SubtitleViewConfiguration(), + this.onEnterFullscreen = defaultEnterNativeFullscreen, + this.onExitFullscreen = defaultExitNativeFullscreen, + this.focusNode, + }); + + @override + State