diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index cab4df7e..08485c91 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -44,6 +44,22 @@ jobs: - run: make android-test + web: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: subosito/flutter-action@v2 + with: + flutter-version: 3.47.2 + channel: stable + cache: true + + - run: flutter pub get + + - run: flutter build web + working-directory: example + ios: runs-on: macos-26 env: diff --git a/CHANGELOG.md b/CHANGELOG.md index 44ccfaad..a6c2f92a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,30 @@ +## 2.2.0 + +- Feature: web support. The player is embedded as a platform view and plays back through + [`bccm-video-player`](https://www.npmjs.com/package/bccm-video-player), which the host + page must load as `window.bccmVideoPlayer` — see the Web docs. + - The web player draws its own controls and owns fullscreen, so `controlsConfig` and + custom control builders do not apply there. + - Downloads and Chromecast are unavailable on web and degrade rather than throwing + channel errors. + - Known limitation: the video is painted from the root `Overlay` so that it is never + re-parented, which means it is not clipped by ancestor scroll views. +- Feature: `BccmPlayerView.native`, a factory the docs already referenced but which was + never added. Uses the platform's own controls and fullscreen. +- Fix: the web implementation no longer uses `dart:html`, so `flutter build web --wasm` + works and the package has no analyzer suppressions left. + +## 2.1.0 + +- Chore: `pigeon` 22 -> 28. Swift `@async` host APIs moved from completion handlers to + `async throws`; `ios/Classes/DownloaderApiImpl.swift` was updated to match. +- Feature: native test suites for iOS (`example/ios/RunnerTests`) and Android + (`android/src/test`), covering pigeon codec round-trips, enum ordinals, the downloader + state schema, and track/media-item mapping. +- Fix: `CastMediaItemConverter` wrote `KEY_ALBUM_TITLE` into `setArtist`, so a cast item + carrying both lost its artist. +- CI: separate `android`, `ios` and `web` jobs; nothing compiled the native code before. + ## 2.0.0 - Breaking: requires Dart 3.13 / Flutter 3.47. diff --git a/README.md b/README.md index 69651ee1..3ef81d0f 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,20 @@ This means the video is rendered in the native view hierarchy without any interm - [x] iOS - [x] Android -- [ ] ~~Web~~. Some groundwork is there, but it's not complete and it's not supported. +- [x] Web — with caveats, see below. + +### Web + +Web plays back through [`bccm-video-player`](https://www.npmjs.com/package/bccm-video-player), +which the host page has to load (see the "Web" section of the docs). Differences +from iOS and Android: + +- **The player draws its own controls**, so `controlsConfig` and custom control + builders do not apply. Fullscreen is the player's, not the Flutter route. +- **No downloads and no Chromecast.** Neither has a web equivalent. +- **A single player in a page**, not feed-style playback. The video is not + clipped by ancestor scroll views, so layouts that crop it with + `ClipRect`/`OverflowBox` — short-form feeds in particular — do not work. ## Features diff --git a/doc/advanced-usage/custom-controls.md b/doc/advanced-usage/custom-controls.md index b45c9c23..67d84b73 100644 --- a/doc/advanced-usage/custom-controls.md +++ b/doc/advanced-usage/custom-controls.md @@ -1,5 +1,8 @@ ### Custom controls +> **Not applicable on web.** The web player draws its own controls, so +> `controlsConfig` and `customBuilder` are ignored there. See [Web](../web.md). + #### Controls customization For colors, etc, see "Styling". diff --git a/doc/advanced-usage/offline.md b/doc/advanced-usage/offline.md index e3c7d3dc..252edb95 100644 --- a/doc/advanced-usage/offline.md +++ b/doc/advanced-usage/offline.md @@ -1,5 +1,7 @@ # Offline +> **Not available on web.** See [Web](../web.md). + To support offlining, you need to add the DATA_SYNC permission to your AndroidManifest: ```xml diff --git a/doc/contributing/todo.md b/doc/contributing/todo.md index 08c2b5ad..838e6d40 100644 --- a/doc/contributing/todo.md +++ b/doc/contributing/todo.md @@ -4,27 +4,26 @@ Known gaps, roughly in order of value. Each is self-contained — none blocks th Queue and audio work is tracked separately in [audio-support-plan.md](audio-support-plan.md); this list is everything outside that. -## Migrate the web player off `dart:html` - -[`lib/src/web/video_js_player.dart`](../../lib/src/web/video_js_player.dart) is ~120 lines of DOM code on the deprecated `dart:html`. Moving to `package:web` + `dart:js_interop` needs a new dependency, `ui_web.platformViewRegistry` in place of `dart:ui`'s, and a replacement for `NodeTreeSanitizer.trusted`. - -Only a real web build can verify it — no Dart test reaches this file. It carries the single inline `// ignore: deprecated_member_use` in the package, so this is the last thing between us and an unqualified strict `flutter analyze`. - ## Make `tv_controls.dart` DVR-aware [`lib/src/widgets/controls/tv/tv_controls.dart`](../../lib/src/widgets/controls/tv/tv_controls.dart) reimplements `useTimeline` inline and ignores `seekableRangeStartMs` / `seekableRangeEndMs` entirely, so seeking a live DVR window is wrong on TV. Unlike the bug fixed in `default_controls`, it is at least self-consistent — its thumb and its drag agree with each other — so this is a missing feature rather than a mismatch. The fix is to make it call `useTimeline` and `positionFromFraction`, which also removes the duplication. -## Native tests - -The six Robolectric tests in `android/src/test/` still aren't in CI. They need a JDK and the Android SDK, and run through the Flutter-generated Gradle project rather than from `android/` directly: +## Web -```sh -cd example/android && ./gradlew :bccm_player:testDebugUnitTest -``` +The player is embedded as a platform view hosted in the root overlay +([`web_player_overlay.dart`](../../lib/src/widgets/video/web_player_overlay.dart)), +which is what stops the element being re-parented — re-parenting costs the media +element its source, so the video reloads. Remaining gaps: -iOS has no test target at all — `ios/bccm_player.podspec` has no `test_spec`. +- The video is not clipped by ancestor scroll views, so a player scrolled past + its viewport paints over whatever is beside it. +- `getPlayerTracks` reports `isSelected: false` for every track: the JS package + exposes track lists but not which one is current. Only matters if an app reads + tracks from Dart rather than using the player's own picker. +- Web uses the player's own skin rather than the Flutter controls, deliberately — + see the comment in `controlled_player_view.dart`. ## Minor diff --git a/doc/index.md b/doc/index.md index b67a6fd1..c8e39381 100644 --- a/doc/index.md +++ b/doc/index.md @@ -47,4 +47,19 @@ ... ``` -4. For chromecast support, you need to do a few more things, check out the "Chromecast" docs. +4. (Web) Load the player bundle in `web/index.html`, before Flutter starts. The + plugin calls `window.bccmVideoPlayer`, and nothing plays without it: + + ```html + + + + ``` + + The UMD build registers itself as `window['btv-video']`, hence the alias. A + real app should bundle [`bccm-video-player`](https://www.npmjs.com/package/bccm-video-player) + rather than load it from a CDN. + + Read the "Web" page for what differs from iOS and Android. + +5. For chromecast support, you need to do a few more things, check out the "Chromecast" docs. diff --git a/doc/web.md b/doc/web.md new file mode 100644 index 00000000..934223c7 --- /dev/null +++ b/doc/web.md @@ -0,0 +1,74 @@ +# Web + +Web support works, but it is not the same player as iOS and Android, and the +differences are worth knowing before you build against it. + +## Setup + +The host page must load [`bccm-video-player`](https://www.npmjs.com/package/bccm-video-player) +and expose it as `window.bccmVideoPlayer` — see step 4 of [Installation](index.md). +Without it, players are created but never play. + +## The player draws its own controls + +On iOS and Android the plugin draws Flutter controls over the video. On web the +underlying player draws its own, and **`BccmPlayerViewConfig.controlsConfig` — +including `customBuilder` — does not apply**. + +This is deliberate. The video is a real DOM element, and it can only live in one +place at a time: drawing Flutter controls above it means hoisting them out of the +normal widget tree, which costs `Material` ancestry, layout and z-ordering. The +player's own skin is web-optimised and localised, so the trade is worth it. + +Practically: `BccmPlayerView` and `BccmPlayerView.native` render the same thing +on web. + +## Fullscreen + +Fullscreen is the browser's, requested on the player element, rather than the +Flutter route used elsewhere. `BccmPlayerViewController.enterFullscreen()` still +works and still resolves when fullscreen ends, so calling code does not need to +change — but the fullscreen UI is the player's. + +Pushing a Flutter route for it would rebuild the platform view and detach the +video from its source. + +## Not available + +- **Downloads.** No web equivalent, so `DownloaderInterface` reads empty and + `startDownload` throws `UnsupportedError`. +- **Chromecast.** The cast APIs are no-ops. +- **Picture in picture**, video textures, and `setMixWithOthers`. + +## Known limitation: clipping + +The video is painted from Flutter's root `Overlay` rather than inline, which is +what keeps it from being re-parented — re-parenting costs the media element its +source and the video reloads. The cost is that it is **not clipped by ancestor +scroll views**: a player scrolled past its viewport paints over whatever is +beside it. + +If that matters more than reload-free scrolling for your layout, open an issue — +the trade is a small, contained change. + +### What this rules out + +Layouts that crop the video by clipping it. The common shape is an `OverflowBox` +sized past the frame inside a `ClipRect`, used to cover-crop a landscape stream +into a vertical one — a short-form/"shorts" feed being the usual case. On web the +clip does not apply, so the video paints at its overflowed size across the page. + +The same layouts usually draw their own UI over the video, which the overlay also +paints above, and run several preloaded players at once, all of which would be +visible rather than hidden off-screen. + +Feed-style playback is therefore not supported on web. A single player in a page +is what this is built for. + +## Track selection + +`getPlayerTracks` returns the available audio and subtitle tracks, and +`setSelectedTrack` switches them, but tracks are reported with +`isSelected: false` because the underlying API exposes the lists without saying +which is current. Use the player's own picker unless you are tracking selection +yourself. diff --git a/example/lib/generated_plugin_registrant.dart b/example/lib/generated_plugin_registrant.dart deleted file mode 100644 index 2be05929..00000000 --- a/example/lib/generated_plugin_registrant.dart +++ /dev/null @@ -1,17 +0,0 @@ -// -// Generated file. Do not edit. -// - -// ignore_for_file: directives_ordering -// ignore_for_file: lines_longer_than_80_chars -// ignore_for_file: depend_on_referenced_packages - -import 'package:bccm_player/bccm_player_web.dart'; - -import 'package:flutter_web_plugins/flutter_web_plugins.dart'; - -// ignore: public_member_api_docs -void registerPlugins(Registrar registrar) { - BccmPlayerWeb.registerWith(registrar); - registrar.registerMessageHandler(); -} diff --git a/example/web/index.html b/example/web/index.html index e39c4986..713bd429 100644 --- a/example/web/index.html +++ b/example/web/index.html @@ -1,5 +1,6 @@ + - + - + bccm_player_example - - - + + + + + - + - + + \ No newline at end of file diff --git a/lib/bccm_player_web.dart b/lib/bccm_player_web.dart index f14fdccb..ace72332 100644 --- a/lib/bccm_player_web.dart +++ b/lib/bccm_player_web.dart @@ -2,7 +2,15 @@ // of your plugin as a separate package, instead of inlining it in the same // package as the core of your plugin. +import 'dart:async'; +import 'dart:js_interop'; + import 'package:bccm_player/src/native/root_pigeon_playback_listener.dart'; +import 'package:bccm_player/src/state/state_playback_listener.dart'; +import 'package:bccm_player/src/web/downloader_web.dart'; +import 'package:bccm_player/src/widgets/video/web_player_overlay.dart'; +import 'package:web/web.dart' as web; +import 'package:bccm_player/src/web/js/bccm_video_player.dart' as js; import 'package:bccm_player/src/pigeon/playback_platform_pigeon.g.dart' as pigeon; import 'package:flutter_web_plugins/flutter_web_plugins.dart'; import 'bccm_player.dart'; @@ -14,15 +22,52 @@ class BccmPlayerWeb extends BccmPlayerInterface { NpawConfig? npawConfig; Map webVideoPlayers = {}; final RootPigeonPlaybackListener _rootPlaybackListener = RootPigeonPlaybackListener(); + BccmPlayerController? _primaryController; + /// Mirrors the native implementations: one long-lived controller that follows + /// whichever player is currently primary, rather than a controller per player. @override - BccmPlayerController get primaryController => throw UnimplementedError('primaryController has not been implemented for web.'); + BccmPlayerController get primaryController { + final existing = _primaryController; + if (existing != null) { + return existing; + } + final controller = BccmPlayerController.empty(); + stateNotifier.addListener( + (state) { + final id = state.primaryPlayerId; + if (id == null || id == controller.value.playerId) return; + final notifier = stateNotifier.getPlayerNotifier(id); + if (notifier != null) { + controller.swapPlayerNotifier(notifier); + } + }, + fireImmediately: true, + ); + return _primaryController = controller; + } + + Future? _setupFuture; @override - Future setup() async {} + Future setup() async => _setupFuture ??= _setup(); + + Future _setup() async { + // Without this the pigeon events emitted by VideoJsPlayer go nowhere: it is + // what turns them into PlayerState. The native implementation does the same. + _rootPlaybackListener.addListener(StatePlaybackListener(stateNotifier)); + // The native side creates a primary player while attaching; on web there is + // nothing to attach to, so we create it here. Without it `primaryController` + // has no notifier and throws on first use. + final playerId = await newPlayer(); + await setPrimary(playerId); + } static void registerWith(Registrar registrar) { BccmPlayerInterface.instance = BccmPlayerWeb(); + // Otherwise this stays DownloaderNative and every call fails against a + // pigeon channel that does not exist on web. + DownloaderInterface.instance = DownloaderWeb(); } @override @@ -76,13 +121,92 @@ class BccmPlayerWeb extends BccmPlayerInterface { } @override - void play(String playerId) {} + void play(String playerId) { + // play() rejects with AbortError if a pause() lands before it resolves, + // which is routine when toggling quickly. Swallow it rather than letting it + // surface as an unhandled rejection. + webVideoPlayers[playerId]?.mediaElement?.play().toDart.catchError((_) => null); + } @override - void pause(String playerId) {} + void pause(String playerId) { + webVideoPlayers[playerId]?.mediaElement?.pause(); + } @override - void stop(String playerId, bool reset) {} + void stop(String playerId, bool reset) { + final media = webVideoPlayers[playerId]?.mediaElement; + if (media == null) return; + media.pause(); + if (reset) media.currentTime = 0; + } + + @override + Future seekTo(String playerId, double positionMs) async { + webVideoPlayers[playerId]?.mediaElement?.currentTime = positionMs / 1000; + } + + /// Jumps to the live edge, i.e. the end of the DVR window the browser reports. + @override + Future seekToLive(String playerId) async { + final media = webVideoPlayers[playerId]?.mediaElement; + if (media == null) return; + final seekable = media.seekable; + if (seekable.length == 0) return; + media.currentTime = seekable.end(seekable.length - 1); + } + + @override + Future setVolume(String playerId, double volume) async { + webVideoPlayers[playerId]?.mediaElement?.volume = volume; + } + + @override + Future setPlaybackSpeed(String playerId, double speed) async { + webVideoPlayers[playerId]?.mediaElement?.playbackRate = speed; + } + + @override + Future disposePlayer(String playerId) async { + webVideoPlayers.remove(playerId)?.dispose(); + WebPlayerOverlay.disposeFor(playerId); + // Disposing the notifier is what unregisters it from the plugin state. + stateNotifier.getPlayerNotifier(playerId)?.dispose(force: true); + } + + @override + Future getPlayerTracks({String? playerId}) async { + final id = playerId ?? stateNotifier.getPrimaryPlayerId(); + final player = id != null ? webVideoPlayers[id]?.jsPlayer : null; + if (id == null || player == null) return null; + return PlayerTracksSnapshot( + playerId: id, + audioTracks: _toTracks(player.getAudioLanguages().toDart), + textTracks: _toTracks(player.getSubtitleLanguages().toDart), + // Quality is selected by height rather than enumerated as tracks. + videoTracks: const [], + ); + } + + @override + Future setSelectedTrack(String playerId, pigeon.TrackType type, String? trackId) async { + final player = webVideoPlayers[playerId]?.jsPlayer; + if (player == null) return; + switch (type) { + case pigeon.TrackType.audio: + player.setAudioTrackToLanguage(trackId); + case pigeon.TrackType.text: + player.setSubtitleTrackToLanguage(trackId); + case pigeon.TrackType.video: + final height = int.tryParse(trackId ?? ''); + if (height != null) player.setVideoQuality(height); + } + } + + /// The JS package keys tracks by language, so the language doubles as the id. + List _toTracks(List options) => options + .map((o) => pigeon.Track(id: o.language, language: o.language, label: o.label, isSelected: false)) + .toList(); @override Future setNpawConfig(pigeon.NpawConfig? config) async { @@ -90,7 +214,79 @@ class BccmPlayerWeb extends BccmPlayerInterface { } @override - Future startNpawView(String playerId, pigeon.MediaMetadata? metadata) async {} + Future startNpawView(String playerId, pigeon.MediaMetadata? metadata) async { + final player = webVideoPlayers[playerId]?.jsPlayer; + final config = npawConfig; + if (player == null || config?.accountCode == null) return; + js.restartNPAWView( + player, + js.NpawOptions( + enabled: true, + accountCode: config!.accountCode, + appName: config.appName ?? '', + tracking: js.NpawTrackingOptions( + userId: appConfig?.analyticsId, + sessionId: appConfig?.sessionId?.toString(), + metadata: js.NpawMetadataOptions(title: metadata?.title), + ), + ), + ); + } + + @override + Future removePlaybackListener(pigeon.PlaybackListenerPigeon listener) async { + _rootPlaybackListener.removeListener(listener); + } + + @override + Future getPlayerState({String? playerId}) async { + final id = playerId ?? stateNotifier.getPrimaryPlayerId(); + return id != null ? webVideoPlayers[id]?.currentSnapshot : null; + } + + /// No audio-session concept on the web; every player mixes by default. + @override + Future setMixWithOthers(String playerId, bool mixWithOthers) async {} + + /// Fullscreened in place via the browser API. A Flutter fullscreen route + /// would re-parent the platform view's element and detach the media, so the + /// element must never move. + @override + Future enterFullscreen(String playerId) async { + final container = webVideoPlayers[playerId]?.container; + if (container == null) return; + await container.requestFullscreen().toDart; + + // Resolve only once fullscreen ends, so callers can await it the same way + // they await the fullscreen route on other platforms. The user can leave + // via Esc or the player's own button, so the document is the source of + // truth rather than our own exitFullscreen call. + final exited = Completer(); + late final JSFunction onChange; + onChange = ((web.Event _) { + if (web.document.fullscreenElement == container) return; + web.document.removeEventListener('fullscreenchange', onChange); + if (!exited.isCompleted) exited.complete(); + }).toJS; + web.document.addEventListener('fullscreenchange', onChange); + return exited.future; + } + + @override + void exitFullscreen(String playerId) { + if (web.document.fullscreenElement != null) { + web.document.exitFullscreen(); + } + } + + /// Looping is not wired through to the media element yet; ignored rather than + /// throwing so it cannot break an otherwise working player. + @override + Future setRepeatMode(String playerId, pigeon.RepeatMode repeatMode) async {} + + /// Android-only concept. + @override + Future getAndroidPerformanceClass() async => 0; @override void setAppConfig(pigeon.AppConfig? config) { @@ -100,18 +296,20 @@ class BccmPlayerWeb extends BccmPlayerInterface { @override void setPlayerViewVisibility(int viewId, bool visible) {} + /// Would need a throwaway player to probe the manifest; not supported yet. @override Future fetchMediaInfo({required String url, String? mimeType}) { - throw UnimplementedError(); + throw UnsupportedError('fetchMediaInfo is not supported on web.'); } + /// The web player composes as a DOM element, so there is no texture to share. @override - Future setRepeatMode(String playerId, pigeon.RepeatMode repeatMode) { - throw UnimplementedError(); - } + Future createVideoTexture() => throw UnsupportedError('Video textures are not supported on web.'); @override - Future getAndroidPerformanceClass() { - throw UnimplementedError(); - } + Future disposeVideoTexture(int textureId) => throw UnsupportedError('Video textures are not supported on web.'); + + @override + Future switchToVideoTexture(String playerId, int textureId) => + throw UnsupportedError('Video textures are not supported on web.'); } diff --git a/lib/src/state/player_view_controller.dart b/lib/src/state/player_view_controller.dart index 43022cda..bcc72e58 100644 --- a/lib/src/state/player_view_controller.dart +++ b/lib/src/state/player_view_controller.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:wakelock_plus/wakelock_plus.dart'; @@ -54,6 +55,19 @@ class BccmPlayerViewController extends ChangeNotifier { debugPrint("bccm: Already in fullscreen, ignoring enterFullscreen() call."); return; } + if (kIsWeb) { + // Pushing the fullscreen route would build a second platform view for the + // same player, which moves the element in the DOM and detaches the media. + // The web player fullscreens itself in place instead. + _isFullscreen = true; + notifyListeners(); + // Resolves when the browser leaves fullscreen, however that happened — + // the player's own exit button, Esc, or exitFullscreen() below. + await BccmPlayerInterface.instance.enterFullscreen(playerController.value.playerId); + _isFullscreen = false; + if (!_isDisposed) notifyListeners(); + return; + } WakelockPlus.enable(); // Kept for iOS and for Android devices running API < 35, where it still works. Ignored by // Android when the app targets SDK 36, which is why AndroidImmersiveMode is also needed. @@ -136,6 +150,10 @@ class BccmPlayerViewController extends ChangeNotifier { Future exitFullscreen() async { _isFullscreen = false; notifyListeners(); + if (kIsWeb) { + BccmPlayerInterface.instance.exitFullscreen(playerController.value.playerId); + return; + } _currentFullscreenNavigator?.maybePop(); _currentFullscreenNavigator = null; } diff --git a/lib/src/web/downloader_web.dart b/lib/src/web/downloader_web.dart new file mode 100644 index 00000000..6fa14d53 --- /dev/null +++ b/lib/src/web/downloader_web.dart @@ -0,0 +1,34 @@ +import 'package:bccm_player/src/downloader_platform_interface.dart'; +import 'package:bccm_player/src/pigeon/downloader_pigeon.g.dart'; + +/// Offline downloads have no web equivalent — there is no background download +/// service and no local storage for HLS segments. +/// +/// Without this, [DownloaderInterface.instance] stays [DownloaderNative] and +/// every call fails with a channel error against a pigeon channel that does not +/// exist. Reads answer emptily so a shared "my downloads" UI renders as +/// nothing-downloaded rather than blowing up; anything that would actually +/// start a download fails loudly, because silently doing nothing would be worse. +class DownloaderWeb extends DownloaderInterface { + @override + final DownloaderListener events = DownloaderListener(); + + @override + Future> getDownloads() async => const []; + + @override + Future getDownload(String downloadKey) async => null; + + @override + Future getDownloadStatus(String downloadKey) async => 0; + + @override + Future removeDownload(String downloadKey) async {} + + @override + Future getFreeDiskSpace() async => 0; + + @override + Future startDownload(DownloadConfig config) => + throw UnsupportedError('Downloading is not supported on web.'); +} diff --git a/lib/src/web/js/bccm_video_player.dart b/lib/src/web/js/bccm_video_player.dart index c2b1bd93..023f687d 100644 --- a/lib/src/web/js/bccm_video_player.dart +++ b/lib/src/web/js/bccm_video_player.dart @@ -1,65 +1,94 @@ @JS('window.bccmVideoPlayer') -library bccm_video_player; +library; import 'dart:js_interop'; +import 'package:web/web.dart' as web; + +/// Bindings for the `bccm-video-player` npm package, which the host page is +/// expected to load before Flutter starts (exposed as `window.bccmVideoPlayer`). +/// +/// Modelled on the package's own TypeScript definitions — see +/// `build/types/video-player/index.d.ts` in the published package. +/// +/// These use extension types rather than the older `@JS`/`@anonymous` classes: +/// `dart:js_interop` only accepts static interop, so the previous form no longer +/// compiles under dart2js at all. +@JS() +external JSPromise createPlayer(String containerId, Options options); + @JS() -external void createPlayer(String? elementId, Options options); +external void setNPAWOptions(Player player, NpawOptions options); @JS() -@anonymous -class Options { +external void restartNPAWView(Player player, NpawOptions options); + +/// The handle returned by [createPlayer]. +/// +/// [mediaEl] is the real `