From 8b16a58b9e415c0af9924c6b11c4a5b4632e644c Mon Sep 17 00:00:00 2001 From: Sigve Hansen Date: Fri, 11 Sep 2026 13:53:49 +0200 Subject: [PATCH 1/4] feat(web): make wasm build work --- .github/workflows/test.yml | 16 + example/lib/generated_plugin_registrant.dart | 17 - example/web/index.html | 36 +- lib/bccm_player_web.dart | 207 ++++++++++- lib/src/state/player_view_controller.dart | 14 + lib/src/web/downloader_web.dart | 34 ++ lib/src/web/js/bccm_video_player.dart | 84 +++-- lib/src/web/video_js_player.dart | 329 ++++++++++++++---- .../widgets/video/controlled_player_view.dart | 14 + pubspec.yaml | 5 + 10 files changed, 601 insertions(+), 155 deletions(-) delete mode 100644 example/lib/generated_plugin_registrant.dart create mode 100644 lib/src/web/downloader_web.dart 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/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..39d77407 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..5d21f658 100644 --- a/lib/bccm_player_web.dart +++ b/lib/bccm_player_web.dart @@ -2,7 +2,13 @@ // of your plugin as a separate package, instead of inlining it in the same // package as the core of your plugin. +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: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 +20,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 +119,91 @@ 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) { + webVideoPlayers[playerId]?.mediaElement?.pause(); + } + + @override + 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(); + // Disposing the notifier is what unregisters it from the plugin state. + stateNotifier.getPlayerNotifier(playerId)?.dispose(force: true); + } @override - void pause(String playerId) {} + 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 - void stop(String playerId, bool reset) {} + 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 +211,65 @@ 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; + } + + @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 +279,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..c6d3d6eb 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,15 @@ 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(); + await BccmPlayerInterface.instance.enterFullscreen(playerController.value.playerId); + 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 +146,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 `