diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 00000000..902f0e17 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,29 @@ +name: test + +# Consuming apps run their own test suites, not this package's, so without this +# workflow nothing enforces them. bcc-media-app's Semaphore `Test` block does +# fire when `/submodules/` changes, but it only runs that app's own `test/` — +# a regression in here reaches every consuming app unchallenged. +on: + push: + branches: [main] + pull_request: + +jobs: + bccm_player: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: subosito/flutter-action@v2 + with: + # Matches the `flutter: ">=3.44.0"` constraint in pubspec.yaml. + flutter-version: 3.44.0 + channel: stable + cache: true + + - run: flutter pub get + + - run: flutter analyze + + - run: flutter test diff --git a/analysis_options.yaml b/analysis_options.yaml index 88f2f551..d4b0f47a 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -6,4 +6,9 @@ include: package:flutter_lints/flutter.yaml analyzer: errors: body_might_complete_normally_nullable: ignore - exclude: [lib/**.freezed.dart, lib/**.g.dart] + exclude: + - lib/**.freezed.dart + - lib/**.g.dart + - build/** + - android/** + - ios/** diff --git a/doc/contributing/basics.md b/doc/contributing/basics.md index a285b9c8..7cf0618e 100644 --- a/doc/contributing/basics.md +++ b/doc/contributing/basics.md @@ -27,3 +27,33 @@ dart run pigeon --input pigeons/chromecast_pigeon.dart ``` You will likely need to add things to the pigeons if you are building new features that require writing native code in swift/kotlin. + +#### Running tests + +The Dart suite lives in `test/` and runs on every PR via `.github/workflows/test.yml`: + +```sh +flutter test + +# Warnings and errors are fatal; the package still carries some pre-existing +# deprecation infos, hence the flag. This is what CI runs. +flutter analyze --no-fatal-infos +``` + +There are also Kotlin unit tests (Robolectric) under `android/src/test/`. They need the +Flutter-generated Gradle project, so they run from the example app rather than from +`android/` directly: + +```sh +cd example/android && ./gradlew :bccm_player:testDebugUnitTest +``` + +These are not in CI yet — run them by hand if you touch `ExoPlayerController` or the +player-view lifecycle. iOS has no test target. + +When adding tests, note two things that will bite you otherwise: + +- `MediaItem` and `Track` are generated pigeon classes with no `==`/`hashCode`, so they + compare by identity. Assert on `id`/`url`, not on whole objects. +- `PlayerStateNotifier`'s constructor starts a periodic timer. Call `dispose(force: true)` + or run inside `fakeAsync`, or the test fails on a pending timer. diff --git a/doc/contributing/todo.md b/doc/contributing/todo.md new file mode 100644 index 00000000..b57a658e --- /dev/null +++ b/doc/contributing/todo.md @@ -0,0 +1,45 @@ +# TODO + +Known gaps, roughly in order of value. Each is self-contained — none blocks the others. + +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`. + +## Extract track selection out of `_SettingsBottomSheet` + +[`lib/src/widgets/controls/default/settings.dart`](../../lib/src/widgets/controls/default/settings.dart) hides real logic inside a widget build method: offline filtering (`downloaded == null || downloaded == true`), unique-height video-track dedupe, the selected-track lookup, re-adding a selected-but-filtered audio track, and `autoTrackId` handling. + +All of it is pure and none of it is testable where it sits. Pulling it into a function is the biggest remaining coverage win in the package. + +## 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. + +## Stop the riverpod providers leaking notifiers + +[`lib/src/plugins/riverpod/providers/player_provider.dart`](../../lib/src/plugins/riverpod/providers/player_provider.dart) — both `playerProviderFor` and `primaryPlayerProvider` fall back to `PlayerStateNotifier(keepAlive: false)` when the player is absent. That constructor starts a periodic 1 s timer, and a fresh notifier is built on every rebuild, so each one leaks. + +## 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: + +```sh +cd example/android && ./gradlew :bccm_player:testDebugUnitTest +``` + +iOS has no test target at all — `ios/bccm_player.podspec` has no `test_spec`. + +## Minor + +- `lib/src/widgets/utils/bccm_player_plugin_state_builder.dart` is dead code returning `Placeholder()` and isn't exported. Delete it. +- `PlayerPluginStateNotifier._removePlayer` calls `debugPrint` unconditionally — noisy in test output and in release logs. +- `StateNotifierSelectBuilder` compares selections with `!identical` rather than `!=`. Fine for enums, bools and small ints; a `select` that builds a `String` rebuilds on every notification regardless. Pinned by a test today, not a correctness bug. +- `useWakelockWhilePlaying` holds the wakelock in every state except `paused` — including `stopped` and `error`. Needs a product decision, not just a code change. +- The filename `lib/src/utils/use_wakelock_while_palying.dart` is misspelled. diff --git a/example/analysis_options.yaml b/example/analysis_options.yaml index 61b6c4de..c6bc5a9d 100644 --- a/example/analysis_options.yaml +++ b/example/analysis_options.yaml @@ -7,6 +7,12 @@ # The following line activates a set of recommended lints for Flutter apps, # packages, and plugins designed to encourage good coding practices. +analyzer: + exclude: + - build/** + - android/** + - ios/** + - web/** include: package:flutter_lints/flutter.yaml linter: diff --git a/example/lib/examples/downloader.dart b/example/lib/examples/downloader.dart index c4b3140e..d85931fb 100644 --- a/example/lib/examples/downloader.dart +++ b/example/lib/examples/downloader.dart @@ -5,6 +5,7 @@ import 'package:bccm_player/bccm_player.dart'; import 'package:bccm_player/controls.dart'; import 'package:bccm_player_example/example_videos.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; class Downloader extends StatefulWidget { @@ -162,7 +163,7 @@ class _TrackSelection extends HookWidget { final selectedAudioTracks = useState>([]); final selectedVideoTracks = useState>([]); return ListView( - cacheExtent: 10000, + scrollCacheExtent: const ScrollCacheExtent.pixels(10000), shrinkWrap: true, children: [ const Text("Media info"), diff --git a/example/lib/examples/queue.dart b/example/lib/examples/queue.dart index 773f7c8b..4f9a7305 100644 --- a/example/lib/examples/queue.dart +++ b/example/lib/examples/queue.dart @@ -155,7 +155,10 @@ class QueueExample extends HookWidget { }), Text('Queue items', style: Theme.of(context).textTheme.titleLarge), ReorderableListView.builder( - onReorder: (oldIndex, newIndex) { + // onReorderItem rather than the deprecated onReorder: it hands + // us a newIndex already adjusted for the removed item, which is + // what the body below (and QueueManager.moveQueueItem) assumes. + onReorderItem: (oldIndex, newIndex) { tempQueueItems.value.insert(newIndex, tempQueueItems.value.removeAt(oldIndex)); controller.queue.moveQueueItem(oldIndex, newIndex); }, diff --git a/example/pubspec.lock b/example/pubspec.lock index 1d2cf7f8..657f984c 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -39,7 +39,7 @@ packages: path: ".." relative: true source: path - version: "1.2.1" + version: "1.2.2" boolean_selector: dependency: transitive description: @@ -60,10 +60,10 @@ packages: dependency: transitive description: name: build_config - sha256: "94eaf6708fe64408c632ef2689ca3777b112f9421306ccf4f8c84d7c5c9f83f8" + sha256: d466ed2dc9c6cd1d169948879b84ee061eb5e22c64a7c6089879c6296d272a8d url: "https://pub.dev" source: hosted - version: "1.3.2" + version: "1.3.3" characters: dependency: transitive description: @@ -84,10 +84,10 @@ packages: dependency: transitive description: name: clock - sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + sha256: e51d50bca3217c9a9fa2b41a30e4a38971133f5f9ec7a3d57bae095007f1d28e url: "https://pub.dev" source: hosted - version: "1.1.2" + version: "1.1.3" collection: dependency: transitive description: @@ -283,10 +283,10 @@ packages: dependency: transitive description: name: glob - sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de + sha256: "218aeb56050c714f62a3182775320dfa04602b55074873e24e31bbd39bda96fb" url: "https://pub.dev" source: hosted - version: "2.1.3" + version: "2.2.0" hooks_riverpod: dependency: "direct main" description: @@ -371,10 +371,10 @@ packages: dependency: transitive description: name: matcher - sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd" url: "https://pub.dev" source: hosted - version: "0.12.19" + version: "0.12.20" material_color_utilities: dependency: transitive description: @@ -387,10 +387,10 @@ packages: dependency: transitive description: name: meta - sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" + sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9" url: "https://pub.dev" source: hosted - version: "1.18.0" + version: "1.19.0" nested: dependency: transitive description: @@ -475,18 +475,18 @@ packages: dependency: transitive description: name: pub_semver - sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + sha256: "261236774e8b1d69cfc6b9eabbc96c40f25e7a2d6b171f3385d4f65d5734fb24" url: "https://pub.dev" source: hosted - version: "2.2.0" + version: "2.2.1" pubspec_parse: dependency: transitive description: name: pubspec_parse - sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082" + sha256: c38b81cbf34450b67e0265d73433569d12e34782e30ed769c9cc99c9d5f2e796 url: "https://pub.dev" source: hosted - version: "1.5.0" + version: "1.6.0" riverpod: dependency: "direct main" description: @@ -520,10 +520,10 @@ packages: dependency: transitive description: name: stack_trace - sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + sha256: "277654b3034d17ac6f9f1cb5595db011b1d5d41e8806866db28e0abaa101c490" url: "https://pub.dev" source: hosted - version: "1.12.1" + version: "1.12.2" state_notifier: dependency: transitive description: @@ -560,10 +560,10 @@ packages: dependency: transitive description: name: test_api - sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" + sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11" url: "https://pub.dev" source: hosted - version: "0.7.11" + version: "0.7.12" typed_data: dependency: transitive description: @@ -616,10 +616,10 @@ packages: dependency: transitive description: name: vector_math - sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + sha256: f36f9f3be64c6198714492bb455c11056e33e2f85d9a0b676a48301e44fdcf47 url: "https://pub.dev" source: hosted - version: "2.2.0" + version: "2.4.2" vm_service: dependency: transitive description: @@ -632,18 +632,18 @@ packages: dependency: transitive description: name: wakelock_plus - sha256: "7253bca0fcf40d8413ddfcf4d2a1fa0a82475e79be25a4f2c564b695c9351486" + sha256: "22b3e7e937721de70e63c85e7139f4ac781dc22863b9262431a53ac030eb074b" url: "https://pub.dev" source: hosted - version: "1.7.0" + version: "1.8.0" wakelock_plus_platform_interface: dependency: transitive description: name: wakelock_plus_platform_interface - sha256: "0618d1799f0b28bcf98255b4ee8313e6fc4d38589dc4ee5fe5840d57d1aff6da" + sha256: "764c25504562abc8ac3406f5d175f126b276e6ba916c7c2675690b73368ca384" url: "https://pub.dev" source: hosted - version: "1.6.0" + version: "1.7.0" watcher: dependency: transitive description: @@ -680,10 +680,10 @@ packages: dependency: transitive description: name: yaml - sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + sha256: f67cdd8e07d3c6329146aaef1ba043542b3134c12489f553ca9a7435d1068aea url: "https://pub.dev" source: hosted - version: "3.1.3" + version: "3.1.4" sdks: dart: ">=3.12.0 <4.0.0" flutter: ">=3.44.0" diff --git a/lib/bccm_player_web.dart b/lib/bccm_player_web.dart index 2e7fbc9c..f14fdccb 100644 --- a/lib/bccm_player_web.dart +++ b/lib/bccm_player_web.dart @@ -4,7 +4,6 @@ import 'package:bccm_player/src/native/root_pigeon_playback_listener.dart'; import 'package:bccm_player/src/pigeon/playback_platform_pigeon.g.dart' as pigeon; -import 'package:bccm_player/src/pigeon/playback_platform_pigeon.g.dart'; import 'package:flutter_web_plugins/flutter_web_plugins.dart'; import 'bccm_player.dart'; import 'src/web/video_js_player.dart'; diff --git a/lib/src/downloader_platform_interface.dart b/lib/src/downloader_platform_interface.dart index 319c5ca8..1458210d 100644 --- a/lib/src/downloader_platform_interface.dart +++ b/lib/src/downloader_platform_interface.dart @@ -1,6 +1,5 @@ import 'dart:async'; import 'package:bccm_player/src/pigeon/downloader_pigeon.g.dart'; -import 'package:collection/collection.dart'; import 'package:plugin_platform_interface/plugin_platform_interface.dart'; class DownloaderListener implements DownloaderListenerPigeon { @@ -56,7 +55,7 @@ class DownloaderNative extends DownloaderInterface { @override Future> getDownloads() async { - return (await _pigeon.getDownloads()).whereNotNull().toList(); + return (await _pigeon.getDownloads()).nonNulls.toList(); } @override diff --git a/lib/src/model/player_view_config.dart b/lib/src/model/player_view_config.dart index 606eb2ba..f7a08f0e 100644 --- a/lib/src/model/player_view_config.dart +++ b/lib/src/model/player_view_config.dart @@ -68,7 +68,7 @@ class BccmPlayerViewConfig { /// * [deviceOrientationsFullscreen] is a callback used upon **entering** fullscreen to get the orientations to set. Return null for defaults. /// * [castPlayerBuilder] is a builder that will be used to build the cast player. const BccmPlayerViewConfig({ - BccmPlayerControlsConfig? controlsConfig, + this._controlsConfig, this.useSurfaceView = false, this.allowSystemGestures = false, this.castPlayerBuilder, @@ -80,7 +80,7 @@ class BccmPlayerViewConfig { this.pipOnLeave, this.videoFit, this.allowsVideoFrameAnalysis, - }) : _controlsConfig = controlsConfig; + }); BccmPlayerViewConfig copyWith({ BccmPlayerControlsConfig? controlsConfig, diff --git a/lib/src/native/root_pigeon_playback_listener.dart b/lib/src/native/root_pigeon_playback_listener.dart index 21f63a3b..fb979321 100644 --- a/lib/src/native/root_pigeon_playback_listener.dart +++ b/lib/src/native/root_pigeon_playback_listener.dart @@ -69,9 +69,10 @@ class RootPigeonPlaybackListener implements PlaybackListenerPigeon { } @override - void onPrimaryPlayerChanged(playerId) { + void onPrimaryPlayerChanged(event) { + _streamController.add(event); for (var listener in _listeners) { - listener.onPrimaryPlayerChanged(playerId); + listener.onPrimaryPlayerChanged(event); } } } diff --git a/lib/src/plugins/bcc_media/bccm_playback_listener.dart b/lib/src/plugins/bcc_media/bccm_playback_listener.dart index 00fc6346..938f81f9 100644 --- a/lib/src/plugins/bcc_media/bccm_playback_listener.dart +++ b/lib/src/plugins/bcc_media/bccm_playback_listener.dart @@ -15,23 +15,18 @@ class BccmPlaybackListener { BccmPlaybackListener({required this.ref, required this.updateProgress, this.onMediaItemTransition, this.onPlaybackEnded}) { final stream = BccmPlayerInterface.instance.playerEventStream; final listener = stream.listen((event) { - switch (event.runtimeType) { - case PositionDiscontinuityEvent: - onPositionDiscontinuity(event as PositionDiscontinuityEvent); - break; - case PlayerStateUpdateEvent: - onPlayerStateUpdate(event as PlayerStateUpdateEvent); - break; - case MediaItemTransitionEvent: - if (onMediaItemTransition != null) { - onMediaItemTransition!(event); - } - break; - case PlaybackEndedEvent: - if (onPlaybackEnded != null) { - onPlaybackEnded!(event); - } - break; + // Object patterns rather than `switch (event.runtimeType)` with type + // literals: the old form compared Type objects, matched only the exact + // runtime type, and left `event` dynamic at every call site. + switch (event) { + case PositionDiscontinuityEvent(): + onPositionDiscontinuity(event); + case PlayerStateUpdateEvent(): + onPlayerStateUpdate(event); + case MediaItemTransitionEvent(): + onMediaItemTransition?.call(event); + case PlaybackEndedEvent(): + onPlaybackEnded?.call(event); } }); ref.onDispose(() { diff --git a/lib/src/queue/default_queue_controller.dart b/lib/src/queue/default_queue_controller.dart index e3843a8c..0b276630 100644 --- a/lib/src/queue/default_queue_controller.dart +++ b/lib/src/queue/default_queue_controller.dart @@ -81,9 +81,15 @@ class DefaultQueueManager implements QueueManager { @override Future handlePlaybackEnded(MediaItem? mediaItem) async { - if (_playerNotifier == null) return; + final player = _playerNotifier; + if (player == null) return; + final ended = mediaItem ?? player.getState().currentMediaItem; final next = _queue.consumeNext() ?? _nextUp.consumeNext(); if (next != null) { + // Same as [skipToNext]: only record history when we actually move on. If + // there is nothing next, the ended item stays current and does not belong + // in history. + if (ended != null) _history.addToStart(ended); await _playMediaItem(next); } } @@ -95,22 +101,24 @@ class DefaultQueueManager implements QueueManager { @override Future setNextUp(List mediaItems) async { - for (var i = 0; i < mediaItems.length; i++) { - if (mediaItems[i].id == null) { - mediaItems[i] = MediaItem.decode(mediaItems[i].encode()); - mediaItems[i].id = const Uuid().v4(); - } - } - _nextUp.setItems(mediaItems); + // Build a new list rather than writing back into the caller's — MediaItem is + // a mutable pigeon class, so assigning into `mediaItems[i]` mutated the list + // the caller still holds. + _nextUp.setItems(mediaItems.map(_withId).toList()); + } + + /// Returns [item] unchanged if it already has an id, otherwise a copy with a + /// generated one. Ids are what the queue addresses items by. + MediaItem _withId(MediaItem item) { + if (item.id != null) return item; + final copy = MediaItem.decode(item.encode()); + copy.id = const Uuid().v4(); + return copy; } @override Future addQueueItem(MediaItem mediaItem) async { - if (mediaItem.id == null) { - mediaItem = MediaItem.decode(mediaItem.encode()); - mediaItem.id = const Uuid().v4(); - } - _queue.add(mediaItem); + _queue.add(_withId(mediaItem)); } @override @@ -165,10 +173,16 @@ class QueueList { itemsNotifier.value = itemsNotifier.value.where((item) => item.id != id).toList(); } + /// Moves the item at [fromIndex] to [toIndex]. + /// + /// Out-of-range indices are tolerated rather than thrown: a reorderable list + /// racing a queue update hands us stale indices routinely, and dropping the + /// move is far better than a RangeError out of a gesture handler. void move(int fromIndex, int toIndex) { final list = [...itemsNotifier.value]; + if (fromIndex < 0 || fromIndex >= list.length) return; final item = list.removeAt(fromIndex); - list.insert(toIndex, item); + list.insert(toIndex.clamp(0, list.length), item); itemsNotifier.value = list; } @@ -190,9 +204,18 @@ class QueueList { } } +/// A [QueueList] that can present its items in a shuffled order while +/// remembering the order they were given in, so toggling shuffle off restores +/// it. +/// +/// [_orderedItems] is the unshuffled backing list and must track every +/// mutation of [itemsNotifier] — otherwise consuming an item leaves it in the +/// backing list and toggling shuffle brings it back from the dead. Mutations +/// deliberately do *not* re-run [_maybeShuffle]: reshuffling on every consumed +/// track would reorder the visible queue under the user. class ShuffleQueueList extends QueueList { List _orderedItems = []; - ValueNotifier shuffleNotifier = ValueNotifier(false); + final ValueNotifier shuffleNotifier = ValueNotifier(false); ShuffleQueueList() { shuffleNotifier.addListener(_maybeShuffle); @@ -221,4 +244,51 @@ class ShuffleQueueList extends QueueList { _orderedItems = [...items]; _maybeShuffle(); } + + /// Drops [item] from the backing list. Matches on `id` when there is one and + /// falls back to identity, so an id-less item can't take every other id-less + /// item with it. + void _forget(MediaItem item) { + _orderedItems = _orderedItems + .where((i) => item.id != null ? i.id != item.id : !identical(i, item)) + .toList(); + } + + @override + void add(MediaItem item) { + _orderedItems = [..._orderedItems, item]; + super.add(item); + } + + @override + void addToStart(MediaItem item) { + _orderedItems = [item, ..._orderedItems]; + super.addToStart(item); + } + + @override + void clear() { + _orderedItems = []; + super.clear(); + } + + @override + void remove(String id) { + _orderedItems = _orderedItems.where((item) => item.id != id).toList(); + super.remove(id); + } + + @override + MediaItem? consumeNext() { + final item = super.consumeNext(); + if (item != null) _forget(item); + return item; + } + + @override + MediaItem? consumeSpecific(String id) { + final item = super.consumeSpecific(id); + if (item != null) _forget(item); + return item; + } } diff --git a/lib/src/state/player_controller.dart b/lib/src/state/player_controller.dart index 866f3fae..46a31239 100644 --- a/lib/src/state/player_controller.dart +++ b/lib/src/state/player_controller.dart @@ -77,10 +77,8 @@ class BccmPlayerController extends ValueNotifier { /// See also: /// /// * [BccmPlayerController.networkUrl] for a convenience constructor to create a [BccmPlayerController] with a network url. - BccmPlayerController(MediaItem mediaItem, {BufferMode? bufferMode, bool? disableNpaw}) + BccmPlayerController(MediaItem mediaItem, {this._bufferMode, this._disableNpaw}) : _intialMediaItem = mediaItem, - _bufferMode = bufferMode, - _disableNpaw = disableNpaw, super(const PlayerState( playerId: 'unknown', isInitialized: false, @@ -90,10 +88,8 @@ class BccmPlayerController extends ValueNotifier { /// /// Intended for internal use only. @protected - BccmPlayerController.empty({BufferMode? bufferMode, bool? disableNpaw}) + BccmPlayerController.empty({this._bufferMode, this._disableNpaw}) : _intialMediaItem = null, - _bufferMode = bufferMode, - _disableNpaw = disableNpaw, super(const PlayerState(playerId: 'unknown', isInitialized: false)); /// Convenience constructor to create a [BccmPlayerController] with a network url. @@ -104,14 +100,12 @@ class BccmPlayerController extends ValueNotifier { BccmPlayerController.networkUrl( Uri url, { String? mimeType, - BufferMode? bufferMode, - bool? disableNpaw, + this._bufferMode, + this._disableNpaw, }) : _intialMediaItem = MediaItem( url: url.toString(), mimeType: mimeType, ), - _bufferMode = bufferMode, - _disableNpaw = disableNpaw, super(const PlayerState(playerId: 'unknown', isInitialized: false)); /// Checks if this player is the current primary player. @@ -172,7 +166,7 @@ class BccmPlayerController extends ValueNotifier { Future _initialize() async { final playerId = await BccmPlayerInterface.instance.newPlayer(bufferMode: _bufferMode, disableNpaw: _disableNpaw); if (_intialMediaItem != null) { - await BccmPlayerInterface.instance.replaceCurrentMediaItem(playerId, _intialMediaItem!); + await BccmPlayerInterface.instance.replaceCurrentMediaItem(playerId, _intialMediaItem); } if (_isDisposed) { return; diff --git a/lib/src/state/player_state_notifier.dart b/lib/src/state/player_state_notifier.dart index 66e01332..614a6c59 100644 --- a/lib/src/state/player_state_notifier.dart +++ b/lib/src/state/player_state_notifier.dart @@ -50,11 +50,19 @@ class PlayerStateNotifier extends StateNotifier { return BccmPlayerInterface.instance.stateNotifier.getPlayerNotifier(playerId); } + bool _isDisposed = false; + @override // ignore: must_call_super void dispose({bool? force}) { // prevents riverpods StateNotifierProvider from disposing it if (!keepAlive || force == true) { + // Idempotent because [onDispose] re-enters: for a notifier created by + // [PlayerPluginStateNotifier], it runs `_removePlayer`, which calls + // `dispose(force: true)` right back. Without the guard the second pass + // disposes `queueManager` a second time and asserts. + if (_isDisposed) return; + _isDisposed = true; onDispose?.call(); positionUpdateTimer.cancel(); queueManager.dispose(); diff --git a/lib/src/theme/controls_theme_data.dart b/lib/src/theme/controls_theme_data.dart index f295d1ed..813f9607 100644 --- a/lib/src/theme/controls_theme_data.dart +++ b/lib/src/theme/controls_theme_data.dart @@ -37,10 +37,10 @@ class BccmControlsThemeData { thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 6), overlayShape: const RoundSliderOverlayShape(overlayRadius: 14), activeTrackColor: theme.colorScheme.primary, - inactiveTrackColor: theme.colorScheme.onSurface.withOpacity(0.2), + inactiveTrackColor: theme.colorScheme.onSurface.withValues(alpha: 0.2), thumbColor: theme.colorScheme.primary, ), - playNextButtonBackgroundColor: Colors.blue.withOpacity(0.75), + playNextButtonBackgroundColor: Colors.blue.withValues(alpha: 0.75), playNextButtonProgressColor: Colors.blue, ); } @@ -48,7 +48,11 @@ class BccmControlsThemeData { BccmControlsThemeData fillWithDefaults(BccmControlsThemeData defaults) { return BccmControlsThemeData( primaryColor: primaryColor ?? defaults.primaryColor, - iconColor: primaryColor ?? iconColor ?? defaults.iconColor, + // primaryColor acts as a shorthand that tints the icons, the same way it + // tints progressBarTheme below — but an explicitly set iconColor has to + // win over the shorthand. It used to be the other way round, which made + // iconColor unreachable for anyone who also set primaryColor. + iconColor: iconColor ?? primaryColor ?? defaults.iconColor, durationTextStyle: durationTextStyle ?? defaults.durationTextStyle, settingsListBackgroundColor: settingsListBackgroundColor ?? defaults.settingsListBackgroundColor, settingsListTextStyle: settingsListTextStyle ?? defaults.settingsListTextStyle, diff --git a/lib/src/theme/mini_player_theme_data.dart b/lib/src/theme/mini_player_theme_data.dart index a7c7b876..2bd0299d 100644 --- a/lib/src/theme/mini_player_theme_data.dart +++ b/lib/src/theme/mini_player_theme_data.dart @@ -24,8 +24,8 @@ class BccmMiniPlayerThemeData { return BccmMiniPlayerThemeData( iconColor: theme.colorScheme.onSurface, backgroundColor: theme.colorScheme.surface, - thumbnailBorderColor: Colors.white.withOpacity(0.01), - topBorderColor: theme.colorScheme.onSurface.withOpacity(0.1), + thumbnailBorderColor: Colors.white.withValues(alpha: 0.01), + topBorderColor: theme.colorScheme.onSurface.withValues(alpha: 0.1), progressColor: theme.colorScheme.onSurface, titleStyle: theme.textTheme.labelMedium!.copyWith(color: theme.colorScheme.onSurface), secondaryTitleStyle: theme.textTheme.labelSmall?.copyWith(color: theme.colorScheme.primary), diff --git a/lib/src/utils/debouncer.dart b/lib/src/utils/debouncer.dart index 47f99fae..f4c8b664 100644 --- a/lib/src/utils/debouncer.dart +++ b/lib/src/utils/debouncer.dart @@ -32,41 +32,52 @@ class Debouncer { } } -/// A class which upon calling run() replaces the current pending action with a new one, -/// and executes the pending action when the current future is done. -/// It differes from a debouncer in that it doesnt use any timers. +/// A class which upon calling [runWhenCurrentIsDone] replaces the current +/// pending action with a new one, and executes the pending action when the +/// current future is done. +/// +/// It differs from a debouncer in that it doesn't use any timers. Used for +/// scrubbing: intermediate seek targets are dropped rather than queued, so the +/// player only ever chases the latest one. class OneAsyncAtATime { - Completer? _currentCompleter; + bool _isRunning = false; Future Function()? _nextAction; OneAsyncAtATime(); Future runWhenCurrentIsDone(Future Function() action) async { _nextAction = action; - if (_currentCompleter == null) { + if (!_isRunning) { await _goNext(); } } Future _goNext() async { if (_nextAction == null) return; - _currentCompleter = Completer(); + _isRunning = true; try { final action = _nextAction; _nextAction = null; await action!(); - _currentCompleter?.complete(); - } catch (e) { - _currentCompleter?.completeError(e); + } catch (e, stack) { + // Swallowed deliberately: callers fire this off without awaiting (see + // `scrubTo`), so rethrowing surfaces as an unhandled async error, and a + // failed seek must not stop the queued one from running. + // + // This used to be routed into a Completer that nothing ever awaited, + // which raised an unhandled error regardless of the catch. + debugPrint('bccm: queued action failed: $e\n$stack'); + } finally { + _isRunning = false; } - _currentCompleter = null; + // Not awaited on purpose: awaiting would extend the await chain by a frame + // per queued action, for as long as the user keeps scrubbing. _goNext(); } + /// Drops the pending action. An action already in flight runs to completion. void reset() { _nextAction = null; - _currentCompleter?.completeError('disposed'); - _currentCompleter = null; } bool get hasPending => _nextAction != null; diff --git a/lib/src/utils/time.dart b/lib/src/utils/time.dart index ff43e328..37af39a3 100644 --- a/lib/src/utils/time.dart +++ b/lib/src/utils/time.dart @@ -2,7 +2,13 @@ import 'dart:math'; /// Convert milliseconds to 'hh:mm:ss' String getFormattedDuration(num durationMs) { - final duration = Duration(milliseconds: durationMs.toInt()); + // Player state supplies these, and AVPlayer reports NaN/infinite times before + // a manifest loads. `toInt()` throws on those, which would take the controls + // down, and a negative duration formats as nonsense ("00:59" for -1000ms). + // Callers currently sanitise via safeDouble/safeInt upstream; this makes the + // formatter total so that guard stopping being load-bearing is not a crash. + final safeMs = durationMs.isFinite ? max(0, durationMs.toInt()) : 0; + final duration = Duration(milliseconds: safeMs); return [ if (duration.inHours != 0) duration.inHours.toString().padLeft(2, '0'), (duration.inMinutes % 60).toString().padLeft(2, '0'), diff --git a/lib/src/utils/timeline.dart b/lib/src/utils/timeline.dart index dd96771c..732f49e2 100644 --- a/lib/src/utils/timeline.dart +++ b/lib/src/utils/timeline.dart @@ -35,6 +35,16 @@ class TimelineHelper { final void Function(double targetMs) scrubTo; final void Function(double milliseconds) scrubToRelative; + /// Converts a `[0,1]` slider fraction into an absolute position in + /// milliseconds. The inverse of [timeFraction], and the only correct way to + /// turn a seekbar value back into something to hand [scrubTo]. + /// + /// For VOD ([rangeStartMs] `== 0`) this is just `fraction * rangeEndMs`. For a + /// live DVR window it is not: multiplying by the range *end* lands the seek + /// somewhere the thumb was never pointing. + double positionFromFraction(double fraction) => + rangeStartMs + clampDouble(fraction, 0, 1) * (rangeEndMs - rangeStartMs); + TimelineHelper({ required this.seeking, required this.currentScrub, @@ -156,7 +166,10 @@ TimelineHelper useTimeline(BccmPlayerController playerController) { useListenableSelector( playerController, () => [ - (playerController.value.playbackPositionMs ?? 0 / 500).round(), + // `??` binds looser than `/`, so the parens matter: without them this + // read as `positionMs ?? (0 / 500)` and the 500ms bucketing never + // happened, rebuilding the controls on every millisecond. + ((playerController.value.playbackPositionMs ?? 0) / 500).round(), playerController.value.currentMediaItem?.metadata?.durationMs, playerController.seekableRangeStartMs, playerController.seekableRangeEndMs, diff --git a/lib/src/web/video_js_player.dart b/lib/src/web/video_js_player.dart index 6b43e2bc..5201d5c2 100644 --- a/lib/src/web/video_js_player.dart +++ b/lib/src/web/video_js_player.dart @@ -1,7 +1,13 @@ // ignore_for_file: avoid_web_libraries_in_flutter, sdk_version_since +// dart:html is deprecated in favour of package:web + dart:js_interop. Migrating +// this file is a real piece of work — new dependency, ui_web instead of +// dart:ui's platformViewRegistry, and a replacement for the trusted-HTML +// sanitizer below — and none of it is verifiable from the Dart test suite, +// only from an actual web build. Suppressed narrowly here so `flutter analyze` +// can stay strict everywhere else. Tracked as its own follow-up. +// ignore: deprecated_member_use import 'dart:html' as html; -import 'dart:html'; import 'package:bccm_player/src/pigeon/playback_platform_pigeon.g.dart'; import 'dart:ui' as ui; @@ -55,7 +61,7 @@ class VideoJsPlayer { v.style.backgroundColor = "#000000"; v.style.position = "fixed"; v.style.zIndex = "50"; - v.appendHtml('''''', treeSanitizer: NodeTreeSanitizer.trusted); + v.appendHtml('''''', treeSanitizer: html.NodeTreeSanitizer.trusted); html.window.document.getElementById('primary-player-wrapper')?.append(v); final topLeftWrapper = html.document.createElement("div"); topLeftWrapper.style diff --git a/lib/src/widgets/cast/cast_button.dart b/lib/src/widgets/cast/cast_button.dart index 4cdec970..97b972d2 100644 --- a/lib/src/widgets/cast/cast_button.dart +++ b/lib/src/widgets/cast/cast_button.dart @@ -15,7 +15,7 @@ class CastButton extends StatelessWidget { @override Widget build(BuildContext context) { final creationParams = { - if (color != null) 'color': color!.value, + if (color != null) 'color': color!.toARGB32(), }; if (Platform.isAndroid) { return SizedBox( diff --git a/lib/src/widgets/controls/default/settings_option_list.dart b/lib/src/widgets/controls/default/settings_option_list.dart index a4d4bc9b..3b570877 100644 --- a/lib/src/widgets/controls/default/settings_option_list.dart +++ b/lib/src/widgets/controls/default/settings_option_list.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; import '../../../theme/player_theme.dart'; @@ -46,7 +47,7 @@ class SettingsOptionList extends StatelessWidget { color: controlsTheme?.settingsListBackgroundColor, child: ListView( shrinkWrap: true, - cacheExtent: 1000, + scrollCacheExtent: const ScrollCacheExtent.pixels(1000), children: [ for (final option in options) ListTile( diff --git a/lib/src/widgets/controls/default_controls.dart b/lib/src/widgets/controls/default_controls.dart index afa7aece..e9df7695 100644 --- a/lib/src/widgets/controls/default_controls.dart +++ b/lib/src/widgets/controls/default_controls.dart @@ -150,7 +150,7 @@ class DefaultControls extends HookWidget { child: Slider( value: timeline.timeFraction, onChanged: (double value) { - timeline.scrubTo(value * timeline.duration); + timeline.scrubTo(timeline.positionFromFraction(value)); }, onChangeEnd: (double value) { //seekDebouncer.forceEarly(); diff --git a/lib/src/widgets/controls/play_next_button.dart b/lib/src/widgets/controls/play_next_button.dart index 09e2a0de..3e4e750c 100644 --- a/lib/src/widgets/controls/play_next_button.dart +++ b/lib/src/widgets/controls/play_next_button.dart @@ -122,7 +122,7 @@ class PlayNextButton extends HookWidget { Positioned.fill( child: Container( decoration: BoxDecoration( - border: Border.all(color: Colors.white.withOpacity(0.2), width: 1), + border: Border.all(color: Colors.white.withValues(alpha: 0.2), width: 1), borderRadius: BorderRadius.circular(20), ), ), diff --git a/pubspec.yaml b/pubspec.yaml index 55ba9439..4c37818b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: bccm_player description: ExoPlayer/AVPlayer via platform views, with cast, PiP, background audio, audio selection, etc. -version: 1.2.2 +version: 1.2.3 documentation: https://bcc-code.github.io/bccm-player/ repository: https://github.com/bcc-code/bccm-player @@ -36,6 +36,9 @@ dev_dependencies: pigeon: ^22.3.0 build_runner: ^2.3.3 mockito: ^5.4.2 + # Explicit rather than transitive via flutter_test: the timer-based tests + # (Debouncer, OneAsyncAtATime, the position ticker) depend on it directly. + fake_async: ^1.3.3 # The following section is specific to Flutter packages. flutter: diff --git a/test/controller_test.dart b/test/controller_test.dart index dfa651e9..85ba8ec9 100644 --- a/test/controller_test.dart +++ b/test/controller_test.dart @@ -1,38 +1,436 @@ import 'package:bccm_player/bccm_player.dart'; +import 'package:bccm_player/bccm_player_native.dart'; +import 'package:flutter/widgets.dart' hide RepeatMode; import 'package:flutter_test/flutter_test.dart'; import 'package:mockito/mockito.dart'; +import 'utils/fake_platform.dart'; +import 'utils/fixtures.dart'; import 'utils/mocks.mocks.dart'; void main() { - late MockBccmPlayerInterface mockPlayerInterface; + group('initialize', () { + test('creates a native player and loads the initial media item', () async { + // Kept on the mockito mocks: the point here is verifying the interaction, + // which is what mockito is good at. + final mockPlayerInterface = MockBccmPlayerInterface(); + BccmPlayerInterface.instance = mockPlayerInterface; - setUp(() { - mockPlayerInterface = MockBccmPlayerInterface(); - BccmPlayerInterface.instance = mockPlayerInterface; + const fakePlayerId = '12345678-1234-1234-1234-123456789012'; + const fakeUrl = 'url.mp4'; + + final stateNotifier = MockPlayerPluginStateNotifier(); + final playerStateNotifier = + PlayerStateNotifier(keepAlive: false, player: const PlayerState(playerId: fakePlayerId)); + + when(mockPlayerInterface.stateNotifier).thenAnswer((_) => stateNotifier); + when(stateNotifier.getOrAddPlayerNotifier(any)).thenReturn(playerStateNotifier); + when(mockPlayerInterface.newPlayer()).thenAnswer((_) async => fakePlayerId); + + final BccmPlayerController controller = BccmPlayerController.networkUrl(Uri.parse(fakeUrl)); + await controller.initialize(); + + verify(mockPlayerInterface.newPlayer()).called(1); + final replaceCurrentMediaItemCall = + verify(mockPlayerInterface.replaceCurrentMediaItem(fakePlayerId, captureAny)); + expect((replaceCurrentMediaItemCall.captured[0] as MediaItem).url, fakeUrl); + + playerStateNotifier.dispose(); + }); + + group('with a fake platform', () { + late FakeBccmPlayerInterface fake; + + setUp(() => fake = FakeBccmPlayerInterface.install()); + tearDown(() => fake.restore()); + + test('adopts the player id from the platform', () async { + final controller = BccmPlayerController(mediaItem(id: 'a')); + await controller.initialize(); + + expect(controller.value.playerId, 'fake-player-1'); + expect(controller.value.isInitialized, isTrue); + expect(controller.stateNotifier, isNotNull); + }); + + test('is memoized, so concurrent calls create one native player', () async { + final controller = BccmPlayerController(mediaItem(id: 'a')); + + await Future.wait([controller.initialize(), controller.initialize()]); + + expect(fake.newPlayerCalls, hasLength(1)); + }); + + test('a second initialize after the first completes is a no-op', () async { + final controller = BccmPlayerController(mediaItem(id: 'a')); + await controller.initialize(); + + await controller.initialize(); + + expect(fake.newPlayerCalls, hasLength(1)); + }); + + test('does nothing on an already-disposed controller', () async { + final controller = BccmPlayerController(mediaItem(id: 'a')); + await controller.dispose(); + + await controller.initialize(); + + expect(fake.newPlayerCalls, isEmpty); + }); + + test('does not attach when the controller is disposed mid-flight', () async { + // The window between `newPlayer` returning and the notifier being wired + // up is real: a widget can be torn down inside it. + final controller = BccmPlayerController(mediaItem(id: 'a')); + final pending = controller.initialize(); + await controller.dispose(); + await pending; + + expect(controller.stateNotifier, isNull); + expect(controller.value.isInitialized, isFalse); + }); + + test('networkUrl builds a media item from the url and mime type', () async { + final controller = BccmPlayerController.networkUrl( + Uri.parse('https://example.test/a.m3u8'), + mimeType: 'application/x-mpegURL', + ); + await controller.initialize(); + + final item = fake.replaceCurrentMediaItemCalls.single.mediaItem; + expect(item.url, 'https://example.test/a.m3u8'); + expect(item.mimeType, 'application/x-mpegURL'); + }); + + test('an empty controller loads no media item', () async { + final controller = BccmPlayerController.empty(); + await controller.initialize(); + + expect(fake.newPlayerCalls, hasLength(1)); + expect(fake.replaceCurrentMediaItemCalls, isEmpty); + }); + }); + }); + + group('an initialized controller', () { + late FakeBccmPlayerInterface fake; + late BccmPlayerController controller; + + setUp(() async { + fake = FakeBccmPlayerInterface.install(); + controller = BccmPlayerController(mediaItem(id: 'a')); + await controller.initialize(); + }); + + tearDown(() => fake.restore()); + + test('forwards seekTo as milliseconds', () async { + await controller.seekTo(const Duration(seconds: 90)); + + expect(fake.seekToCalls.single.playerId, 'fake-player-1'); + expect(fake.seekToCalls.single.positionMs, 90000.0); + }); + + test('forwards seekToLive', () async { + await controller.seekToLive(); + + expect(fake.seekToLiveCalls, ['fake-player-1']); + }); + + test('forwards the simple transport controls', () async { + await controller.play(); + await controller.pause(); + await controller.stop(reset: true); + await controller.setPlaybackSpeed(1.5); + await controller.setRepeatMode(RepeatMode.one); + await controller.setSelectedTrack(TrackType.audio, 'nor'); + + expect(fake.playCalls, ['fake-player-1']); + expect(fake.pauseCalls, ['fake-player-1']); + expect(fake.stopCalls, ['fake-player-1']); + expect(fake.setPlaybackSpeedCalls, [1.5]); + expect(fake.setRepeatModeCalls, [RepeatMode.one]); + expect(fake.setSelectedTrackCalls.single.trackId, 'nor'); + expect(fake.setSelectedTrackCalls.single.type, TrackType.audio); + }); + + test('mirrors state pushed through its notifier', () { + final notifier = fake.stateNotifier.getPlayerNotifier('fake-player-1')!; + + notifier.setPlaybackState(PlaybackState.playing); + + expect(controller.value.playbackState, PlaybackState.playing); + }); + + test('proxies the seekable range from its notifier', () { + final notifier = fake.stateNotifier.getPlayerNotifier('fake-player-1')!; + + notifier.setStateFromSnapshot(snapshot( + playerId: 'fake-player-1', + seekableRangeStartMs: 1000, + seekableRangeEndMs: 9000, + )); + + expect(controller.seekableRangeStartMs, 1000); + expect(controller.seekableRangeEndMs, 9000); + }); + + test('tracks whether it is the primary player', () { + expect(controller.isPrimary, isFalse); + + fake.stateNotifier.setPrimaryPlayer('fake-player-1'); + + expect(controller.isPrimary, isTrue); + }); + + test('disposing the primary player is refused', () { + fake.stateNotifier.setPrimaryPlayer('fake-player-1'); + + // The assert fires in debug; release falls through to an early return + // plus a debugPrint. Either way the primary player survives. + expect(() => controller.dispose(), throwsAssertionError); + expect(fake.disposePlayerCalls, isEmpty); + }); + + test('dispose tears down the native player', () async { + await controller.dispose(); + + expect(fake.disposePlayerCalls, ['fake-player-1']); + }); + + test('getTracks passes through the platform snapshot', () async { + fake.tracks = PlayerTracksSnapshot( + playerId: 'fake-player-1', + audioTracks: [track(id: 'nor', language: 'nor', isSelected: true)], + textTracks: [], + videoTracks: [], + ); + + final tracks = await controller.getTracks(); + + expect(tracks?.audioTracks.safe.single.id, 'nor'); + }); + }); + + group('seek guards', () { + late FakeBccmPlayerInterface fake; + + setUp(() => fake = FakeBccmPlayerInterface.install()); + tearDown(() => fake.restore()); + + test('seekTo before initialize throws rather than seeking a nonexistent player', () { + final controller = BccmPlayerController(mediaItem(id: 'a')); + + expect(() => controller.seekTo(Duration.zero), throwsException); + expect(fake.seekToCalls, isEmpty); + }); + + test('seekToLive before initialize throws', () { + final controller = BccmPlayerController(mediaItem(id: 'a')); + + expect(() => controller.seekToLive(), throwsException); + expect(fake.seekToLiveCalls, isEmpty); + }); + + test('the seekable range reads as unknown before initialize', () { + final controller = BccmPlayerController(mediaItem(id: 'a')); + + expect(controller.seekableRangeStartMs, isNull); + expect(controller.seekableRangeEndMs, isNull); + }); + + test('the queue is unavailable before initialize', () { + final controller = BccmPlayerController(mediaItem(id: 'a')); + + expect(() => controller.queue, throwsException); + }); + }); + + group('isChromecast', () { + late FakeBccmPlayerInterface fake; + + setUp(() => fake = FakeBccmPlayerInterface.install()); + tearDown(() => fake.restore()); + + test('is true only for the chromecast player id', () { + final controller = BccmPlayerController.empty(); + expect(controller.isChromecast, isFalse); + + final cast = fake.stateNotifier.getOrAddPlayerNotifier('chromecast'); + controller.swapPlayerNotifier(cast); + + expect(controller.isChromecast, isTrue); + }); }); - test('intialize', () async { - // Arrange - const fakePlayerId = '12345678-1234-1234-1234-123456789012'; - const fakeUrl = 'url.mp4'; + group('attach / detach', () { + late FakeBccmPlayerInterface fake; + late BccmPlayerController controller; - final stateNotifier = MockPlayerPluginStateNotifier(); - final playerStateNotifier = PlayerStateNotifier(keepAlive: false, player: const PlayerState(playerId: fakePlayerId)); + setUp(() async { + fake = FakeBccmPlayerInterface.install(); + controller = BccmPlayerController(mediaItem(id: 'a')); + await controller.initialize(); + }); - when(mockPlayerInterface.stateNotifier).thenAnswer((_) => stateNotifier); - when(stateNotifier.getOrAddPlayerNotifier(any)).thenReturn(playerStateNotifier); - when(mockPlayerInterface.newPlayer()).thenAnswer((_) async => fakePlayerId); + tearDown(() => fake.restore()); - // Act - final BccmPlayerController controller = BccmPlayerController.networkUrl(Uri.parse(fakeUrl)); - await controller.initialize(); + test('currentPlayerView is the most recently attached view', () { + final first = _FakePlayerView(); + final second = _FakePlayerView(); - // Assert - verify(mockPlayerInterface.newPlayer()).called(1); - final replaceCurrentMediaItemCall = verify(mockPlayerInterface.replaceCurrentMediaItem(fakePlayerId, captureAny)); - expect((replaceCurrentMediaItemCall.captured[0] as MediaItem).url, fakeUrl); + controller.attach(first); + expect(controller.currentPlayerView, same(first)); - playerStateNotifier.dispose(); + controller.attach(second); + expect(controller.currentPlayerView, same(second), + reason: 'a newly attached view takes over rendering'); + }); + + test('detaching falls back to the remaining view', () { + final first = _FakePlayerView(); + final second = _FakePlayerView(); + controller.attach(first); + controller.attach(second); + + controller.detach(second); + + expect(controller.currentPlayerView, same(first)); + }); + + test('detaching the last view leaves none', () { + final view = _FakePlayerView(); + controller.attach(view); + + controller.detach(view); + + expect(controller.currentPlayerView, isNull); + }); + + test('attach and detach both notify listeners', () { + var notifications = 0; + controller.addListener(() => notifications++); + final view = _FakePlayerView(); + + controller.attach(view); + controller.detach(view); + + expect(notifications, 2); + }); + + test('attaching to a disposed controller is ignored', () async { + await controller.dispose(); + + controller.attach(_FakePlayerView()); + + expect(controller.currentPlayerView, isNull); + }); }); + + group('events', () { + late FakeBccmPlayerInterface fake; + late BccmPlayerController controller; + + setUp(() async { + fake = FakeBccmPlayerInterface.install(); + controller = BccmPlayerController(mediaItem(id: 'a')); + await controller.initialize(); + }); + + tearDown(() => fake.restore()); + + test('yields only events for this player, and survives events without a playerId', () async { + final received = []; + final sub = controller.events.listen(received.add); + addTearDown(sub.cancel); + + fake.emitPlayerEvent(PositionDiscontinuityEvent(playerId: 'fake-player-1')); + fake.emitPlayerEvent(PositionDiscontinuityEvent(playerId: 'some-other-player')); + // Pigeon has no inheritance, so the filter reads `playerId` off a dynamic + // and has to tolerate values that simply don't have one. + fake.emitPlayerEvent(Object()); + + await Future.delayed(Duration.zero); + + expect(received, hasLength(1)); + expect((received.single as PositionDiscontinuityEvent).playerId, 'fake-player-1'); + }); + + test('stops yielding once the controller is disposed', () async { + final received = []; + final sub = controller.events.listen(received.add); + addTearDown(sub.cancel); + + await controller.dispose(); + fake.emitPlayerEvent(PositionDiscontinuityEvent(playerId: 'fake-player-1')); + await Future.delayed(Duration.zero); + + expect(received, isEmpty); + }); + }); + + group('swapPlayerNotifier', () { + late FakeBccmPlayerInterface fake; + + setUp(() => fake = FakeBccmPlayerInterface.install()); + tearDown(() => fake.restore()); + + test('stops following the old notifier', () { + // This is the cast-handover path. A leaked listener means two notifiers + // both writing `value`, and the local player clobbering cast state. + final controller = BccmPlayerController.empty(); + final local = fake.stateNotifier.getOrAddPlayerNotifier('local'); + final cast = fake.stateNotifier.getOrAddPlayerNotifier('chromecast'); + + controller.swapPlayerNotifier(local); + controller.swapPlayerNotifier(cast); + + local.setPlaybackState(PlaybackState.playing); + expect(controller.value.playerId, 'chromecast'); + expect(controller.value.playbackState, isNot(PlaybackState.playing)); + + cast.setPlaybackState(PlaybackState.paused); + expect(controller.value.playbackState, PlaybackState.paused); + }); + }); + + group('BccmPlayerNative.primaryController', () { + test('follows the primary player as it changes', () { + // Installed as the real instance so the getter's + // `BccmPlayerInterface.instance.stateNotifier` is its own. No channel is + // touched until a method is actually called. + final native = BccmPlayerNative(); + BccmPlayerInterface.instance = native; + addTearDown(() { + for (final n in [...native.stateNotifier.state.players.values]) { + n.dispose(force: true); + } + }); + + final controller = native.primaryController; + expect(controller.value.playerId, 'unknown'); + + native.stateNotifier.setPrimaryPlayer('p1'); + expect(controller.value.playerId, 'p1'); + + native.stateNotifier.setPrimaryPlayer('chromecast'); + expect(controller.value.playerId, 'chromecast'); + expect(controller.isChromecast, isTrue); + }); + + test('is the same controller across reads', () { + final native = BccmPlayerNative(); + BccmPlayerInterface.instance = native; + + expect(native.primaryController, same(native.primaryController)); + }); + }); +} + +/// A [State] stand-in for attach/detach bookkeeping. Never mounted — the +/// controller only ever holds it in a set and hands it back. +class _FakePlayerView extends State { + @override + Widget build(BuildContext context) => const SizedBox.shrink(); } diff --git a/test/queue/queue_manager_test.dart b/test/queue/queue_manager_test.dart new file mode 100644 index 00000000..1d0da148 --- /dev/null +++ b/test/queue/queue_manager_test.dart @@ -0,0 +1,317 @@ +import 'package:bccm_player/bccm_player.dart'; +import 'package:bccm_player/src/queue/queue_controller.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../utils/fake_platform.dart'; +import '../utils/fixtures.dart'; + +/// Tests are scoped to the [QueueManager] *contract*, not to the +/// `QueueList` / `ShuffleQueueList` internals. Per +/// `doc/contributing/audio-support-plan.md` §1, those internals move into the +/// native players; the contract survives the move and is what makes it safe. +void main() { + late FakeBccmPlayerInterface fake; + late PlayerStateNotifier player; + late QueueManager queue; + + setUp(() { + fake = FakeBccmPlayerInterface.install(); + player = PlayerStateNotifier(keepAlive: false, player: const PlayerState(playerId: 'p1')); + queue = player.queueManager; + }); + + tearDown(() { + player.dispose(); + fake.restore(); + }); + + /// The media item the fake platform was last asked to play. + MediaItem? lastPlayed() => + fake.replaceCurrentMediaItemCalls.isEmpty ? null : fake.replaceCurrentMediaItemCalls.last.mediaItem; + + group('skipToNext', () { + test('drains queue before nextUp', () async { + await queue.addQueueItem(mediaItem(id: 'q1')); + await queue.setNextUp([mediaItem(id: 'n1')]); + + await queue.skipToNext(); + expect(lastPlayed()?.id, 'q1'); + expect(idsOf(queue.queue.value), isEmpty); + expect(idsOf(queue.nextUp.value), ['n1']); + + await queue.skipToNext(); + expect(lastPlayed()?.id, 'n1'); + expect(idsOf(queue.nextUp.value), isEmpty); + }); + + test('pushes the outgoing item onto history', () async { + player.setMediaItem(mediaItem(id: 'current')); + await queue.addQueueItem(mediaItem(id: 'q1')); + + await queue.skipToNext(); + + expect(idsOf(queue.history.value), ['current']); + }); + + test('no-ops when both queue and nextUp are empty', () async { + player.setMediaItem(mediaItem(id: 'current')); + + await queue.skipToNext(); + + expect(fake.replaceCurrentMediaItemCalls, isEmpty); + expect(idsOf(queue.history.value), isEmpty); + }); + + test('plays with autoplay and without inheriting the primary position', () async { + await queue.addQueueItem(mediaItem(id: 'q1')); + + await queue.skipToNext(); + + final call = fake.replaceCurrentMediaItemCalls.single; + expect(call.playerId, 'p1'); + expect(call.autoplay, isTrue); + expect(call.playbackPositionFromPrimary, isFalse); + }); + }); + + group('skipToPrevious', () { + test('pops history and returns the current item to the front of the queue', () async { + player.setMediaItem(mediaItem(id: 'a')); + await queue.addQueueItem(mediaItem(id: 'q1')); + await queue.skipToNext(); // a -> history, q1 plays + player.setMediaItem(mediaItem(id: 'q1')); + await queue.addQueueItem(mediaItem(id: 'q2')); + + await queue.skipToPrevious(); + + expect(lastPlayed()?.id, 'a'); + expect(idsOf(queue.history.value), isEmpty); + expect(idsOf(queue.queue.value), ['q1', 'q2']); + }); + + test('returns the current item to nextUp when the queue is empty', () async { + player.setMediaItem(mediaItem(id: 'a')); + await queue.setNextUp([mediaItem(id: 'n1')]); + await queue.skipToNext(); // a -> history, n1 consumed + player.setMediaItem(mediaItem(id: 'n1')); + + await queue.skipToPrevious(); + + expect(lastPlayed()?.id, 'a'); + expect(idsOf(queue.nextUp.value), ['n1']); + expect(idsOf(queue.queue.value), isEmpty); + }); + + test('no-ops with empty history', () async { + player.setMediaItem(mediaItem(id: 'current')); + + await queue.skipToPrevious(); + + expect(fake.replaceCurrentMediaItemCalls, isEmpty); + }); + }); + + group('handlePlaybackEnded', () { + test('advances to the next item', () async { + await queue.addQueueItem(mediaItem(id: 'q1')); + + await queue.handlePlaybackEnded(mediaItem(id: 'ended')); + + expect(lastPlayed()?.id, 'q1'); + }); + + test('records the ended item in history, like skipToNext does', () async { + // Regression: handlePlaybackEnded used to ignore its argument entirely, so + // after a track finished naturally you could not skip back to it, even + // though skipping forward manually did record history. + final ended = mediaItem(id: 'ended'); + player.setMediaItem(ended); + await queue.addQueueItem(mediaItem(id: 'q1')); + + await queue.handlePlaybackEnded(ended); + + expect(idsOf(queue.history.value), ['ended']); + }); + + test('falls back to the current media item when passed null', () async { + player.setMediaItem(mediaItem(id: 'current')); + await queue.addQueueItem(mediaItem(id: 'q1')); + + await queue.handlePlaybackEnded(null); + + expect(idsOf(queue.history.value), ['current']); + }); + + test('does not record history when there is nothing to advance to', () async { + final ended = mediaItem(id: 'ended'); + player.setMediaItem(ended); + + await queue.handlePlaybackEnded(ended); + + expect(idsOf(queue.history.value), isEmpty); + expect(fake.replaceCurrentMediaItemCalls, isEmpty); + }); + }); + + group('id backfill', () { + test('addQueueItem assigns an id when the caller supplies none', () async { + await queue.addQueueItem(mediaItem(url: 'https://example.test/x.m3u8')); + + expect(queue.queue.value.single.id, isNotNull); + expect(queue.queue.value.single.url, 'https://example.test/x.m3u8'); + }); + + test('addQueueItem preserves a caller-supplied id', () async { + await queue.addQueueItem(mediaItem(id: 'mine')); + + expect(queue.queue.value.single.id, 'mine'); + }); + + test('setNextUp assigns ids and gives distinct ones per item', () async { + await queue.setNextUp([mediaItem(), mediaItem()]); + + final ids = idsOf(queue.nextUp.value); + expect(ids, everyElement(isNotNull)); + expect(ids.toSet(), hasLength(2)); + }); + + test('setNextUp does not mutate the list it was given', () async { + // Regression: setNextUp wrote the backfilled items straight back into + // `mediaItems[i]`, mutating the caller's list under them. + final caller = [mediaItem(), mediaItem()]; + + await queue.setNextUp(caller); + + expect(idsOf(caller), everyElement(isNull)); + expect(idsOf(queue.nextUp.value), everyElement(isNotNull)); + }); + }); + + group('queue mutation', () { + test('removeQueueItem removes by id', () async { + await queue.addQueueItem(mediaItem(id: 'a')); + await queue.addQueueItem(mediaItem(id: 'b')); + + await queue.removeQueueItem('a'); + + expect(idsOf(queue.queue.value), ['b']); + }); + + test('clearQueue empties the queue but leaves nextUp alone', () async { + await queue.addQueueItem(mediaItem(id: 'a')); + await queue.setNextUp([mediaItem(id: 'n1')]); + + await queue.clearQueue(); + + expect(idsOf(queue.queue.value), isEmpty); + expect(idsOf(queue.nextUp.value), ['n1']); + }); + + test('moveQueueItem reorders', () async { + await queue.addQueueItem(mediaItem(id: 'a')); + await queue.addQueueItem(mediaItem(id: 'b')); + await queue.addQueueItem(mediaItem(id: 'c')); + + await queue.moveQueueItem(0, 2); + + expect(idsOf(queue.queue.value), ['b', 'c', 'a']); + }); + + test('moveQueueItem tolerates stale indices instead of throwing', () async { + // Regression: bare removeAt/insert threw RangeError. A ReorderableListView + // racing a queue update hits this trivially. + await queue.addQueueItem(mediaItem(id: 'a')); + + await expectLater(queue.moveQueueItem(5, 0), completes); + await expectLater(queue.moveQueueItem(0, 9), completes); + await expectLater(queue.moveQueueItem(-1, 0), completes); + + expect(idsOf(queue.queue.value), ['a']); + }); + }); + + group('shuffle', () { + test('toggling off restores the original order', () async { + await queue.setNextUp(mediaItems(5)); + final original = idsOf(queue.nextUp.value); + + await queue.setShuffleEnabled(true); + await queue.setShuffleEnabled(false); + + expect(idsOf(queue.nextUp.value), original); + expect(queue.shuffleEnabled.value, isFalse); + }); + + test('shuffling keeps the same set of items', () async { + await queue.setNextUp(mediaItems(5)); + + await queue.setShuffleEnabled(true); + + expect(idsOf(queue.nextUp.value).toSet(), idsOf(mediaItems(5)).toSet()); + expect(queue.shuffleEnabled.value, isTrue); + }); + + test('toggling shuffle does not resurrect already-played items', () async { + // Regression: `_orderedItems` was only ever written by setItems, so + // consuming an item left it in the backing list and toggling shuffle + // replayed the whole original list from the top. + await queue.setNextUp(mediaItems(3)); // id-1, id-2, id-3 + await queue.skipToNext(); // consumes id-1 + + await queue.setShuffleEnabled(true); + await queue.setShuffleEnabled(false); + + expect(idsOf(queue.nextUp.value), ['id-2', 'id-3']); + }); + + test('an item returned to nextUp survives unshuffling', () async { + // skipToPrevious is the only caller that pushes onto nextUp, so it is the + // only path that exercises the backing list's add side. + player.setMediaItem(mediaItem(id: 'a')); + await queue.setNextUp(mediaItems(2)); // id-1, id-2 + await queue.setShuffleEnabled(true); + await queue.skipToNext(); // a -> history, one of id-1/id-2 consumed + player.setMediaItem(lastPlayed()); + + await queue.skipToPrevious(); // plays 'a', returns the consumed item + + await queue.setShuffleEnabled(false); + expect(idsOf(queue.nextUp.value), hasLength(2)); + expect(idsOf(queue.nextUp.value).toSet(), {'id-1', 'id-2'}); + }); + + test('removeQueueItem does not reach into nextUp, shuffled or not', () async { + // The contract has no way to remove a specific nextUp item — removeQueueItem + // only addresses `queue`. Worth pinning so a future `removeNextUpItem` + // does not quietly change this one's scope. + await queue.setNextUp(mediaItems(3)); + await queue.setShuffleEnabled(true); + + await queue.removeQueueItem('id-2'); + expect(idsOf(queue.nextUp.value), hasLength(3)); + + await queue.setShuffleEnabled(false); + expect(idsOf(queue.nextUp.value), ['id-1', 'id-2', 'id-3']); + }); + }); + + group('player state listener', () { + test('removes an item from queue and nextUp once it becomes current', () async { + await queue.addQueueItem(mediaItem(id: 'a')); + await queue.setNextUp([mediaItem(id: 'a'), mediaItem(id: 'b')]); + + player.setMediaItem(mediaItem(id: 'a')); + + expect(idsOf(queue.queue.value), isEmpty); + expect(idsOf(queue.nextUp.value), ['b']); + }); + + test('leaves the lists alone for an item that is not queued', () async { + await queue.addQueueItem(mediaItem(id: 'a')); + + player.setMediaItem(mediaItem(id: 'unrelated')); + + expect(idsOf(queue.queue.value), ['a']); + }); + }); +} diff --git a/test/state/playback_listener_test.dart b/test/state/playback_listener_test.dart new file mode 100644 index 00000000..35391ab6 --- /dev/null +++ b/test/state/playback_listener_test.dart @@ -0,0 +1,263 @@ +import 'package:bccm_player/bccm_player.dart'; +import 'package:bccm_player/src/native/root_pigeon_playback_listener.dart'; +import 'package:bccm_player/src/pigeon/playback_platform_pigeon.g.dart'; +import 'package:bccm_player/src/state/state_playback_listener.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../utils/fixtures.dart'; + +/// [StatePlaybackListener] is where the native players' events land in Dart. +/// The native side is not unit-tested, so this is the closest testable boundary +/// to it and every playback state the UI reads passes through here. +void main() { + group('StatePlaybackListener', () { + late PlayerPluginStateNotifier plugin; + late StatePlaybackListener listener; + + setUp(() { + plugin = PlayerPluginStateNotifier(keepAlive: false); + listener = StatePlaybackListener(plugin); + }); + + tearDown(() { + for (final notifier in plugin.state.players.values) { + notifier.dispose(force: true); + } + plugin.dispose(force: true); + }); + + PlayerState stateOf(String playerId) => plugin.getPlayerNotifier(playerId)!.state; + + test('onPlaybackStateChanged sets playbackState and isBuffering', () { + listener.onPlaybackStateChanged(PlaybackStateChangedEvent( + playerId: 'p1', + playbackState: PlaybackState.playing, + isBuffering: true, + )); + + expect(stateOf('p1').playbackState, PlaybackState.playing); + expect(stateOf('p1').isBuffering, isTrue); + }); + + test('onMediaItemTransition sets the current media item', () { + listener.onMediaItemTransition(MediaItemTransitionEvent( + playerId: 'p1', + mediaItem: mediaItem(id: 'ep-1'), + )); + + expect(stateOf('p1').currentMediaItem?.id, 'ep-1'); + }); + + test('onPictureInPictureModeChanged sets isInPipMode', () { + listener.onPictureInPictureModeChanged( + PictureInPictureModeChangedEvent(playerId: 'p1', isInPipMode: true), + ); + + expect(stateOf('p1').isInPipMode, isTrue); + }); + + test('onPositionDiscontinuity sets a rounded position', () { + listener.onPositionDiscontinuity( + PositionDiscontinuityEvent(playerId: 'p1', playbackPositionMs: 1500.6), + ); + + expect(stateOf('p1').playbackPositionMs, 1501); + }); + + test('onPositionDiscontinuity coerces a non-finite position to null', () { + // AVPlayer reports NaN/infinite times routinely before a manifest loads. + listener.onPositionDiscontinuity( + PositionDiscontinuityEvent(playerId: 'p1', playbackPositionMs: double.nan), + ); + expect(stateOf('p1').playbackPositionMs, isNull); + + listener.onPositionDiscontinuity( + PositionDiscontinuityEvent(playerId: 'p1', playbackPositionMs: double.infinity), + ); + expect(stateOf('p1').playbackPositionMs, isNull); + }); + + test('onPlayerStateUpdate applies the whole snapshot', () { + listener.onPlayerStateUpdate(PlayerStateUpdateEvent( + playerId: 'p1', + snapshot: snapshot( + playerId: 'p1', + playbackState: PlaybackState.paused, + isBuffering: true, + isFullscreen: true, + playbackSpeed: 1.5, + currentMediaItem: mediaItem(id: 'ep-1'), + playbackPositionMs: 4200.0, + videoSize: VideoSize(width: 1920, height: 1080), + textureId: 7, + volume: 0.5, + error: PlayerError(code: 'x', message: 'boom'), + ), + )); + + final state = stateOf('p1'); + expect(state.playbackState, PlaybackState.paused); + expect(state.isBuffering, isTrue); + expect(state.isNativeFullscreen, isTrue); + expect(state.playbackSpeed, 1.5); + expect(state.currentMediaItem?.id, 'ep-1'); + expect(state.playbackPositionMs, 4200); + expect(state.videoSize?.aspectRatio, 1920 / 1080); + expect(state.textureId, 7); + expect(state.volume, 0.5); + expect(state.error?.message, 'boom'); + expect(state.isInitialized, isTrue); + }); + + test('onPlayerStateUpdate carries the seekable range onto the notifier', () { + // These two live on the notifier rather than on PlayerState — see the + // comment at the top of player_state_notifier.dart for why. + listener.onPlayerStateUpdate(PlayerStateUpdateEvent( + playerId: 'p1', + snapshot: snapshot(playerId: 'p1', seekableRangeStartMs: 100.4, seekableRangeEndMs: 5000.5), + )); + + final notifier = plugin.getPlayerNotifier('p1')!; + expect(notifier.seekableRangeStartMs, 100); + expect(notifier.seekableRangeEndMs, 5001); + }); + + test('onPlayerStateUpdate nulls a non-finite seekable range', () { + listener.onPlayerStateUpdate(PlayerStateUpdateEvent( + playerId: 'p1', + snapshot: snapshot( + playerId: 'p1', + seekableRangeStartMs: double.nan, + seekableRangeEndMs: double.infinity, + ), + )); + + final notifier = plugin.getPlayerNotifier('p1')!; + expect(notifier.seekableRangeStartMs, isNull); + expect(notifier.seekableRangeEndMs, isNull); + }); + + test('onPlayerStateUpdate preserves isInPipMode, which is not in the snapshot', () { + listener.onPictureInPictureModeChanged( + PictureInPictureModeChangedEvent(playerId: 'p1', isInPipMode: true), + ); + + listener.onPlayerStateUpdate(PlayerStateUpdateEvent( + playerId: 'p1', + snapshot: snapshot(playerId: 'p1'), + )); + + expect(stateOf('p1').isInPipMode, isTrue); + }); + + test('onPrimaryPlayerChanged sets the primary id and creates the notifier', () { + listener.onPrimaryPlayerChanged(PrimaryPlayerChangedEvent(playerId: 'p9')); + + expect(plugin.getPrimaryPlayerId(), 'p9'); + expect(plugin.getPlayerNotifier('p9'), isNotNull); + }); + + test('events for an unknown player create that player', () { + expect(plugin.getPlayerNotifier('new'), isNull); + + listener.onPlaybackStateChanged(PlaybackStateChangedEvent( + playerId: 'new', + playbackState: PlaybackState.playing, + isBuffering: false, + )); + + expect(plugin.getPlayerNotifier('new'), isNotNull); + }); + + test('players are kept separate', () { + listener.onMediaItemTransition( + MediaItemTransitionEvent(playerId: 'p1', mediaItem: mediaItem(id: 'a')), + ); + listener.onMediaItemTransition( + MediaItemTransitionEvent(playerId: 'p2', mediaItem: mediaItem(id: 'b')), + ); + + expect(stateOf('p1').currentMediaItem?.id, 'a'); + expect(stateOf('p2').currentMediaItem?.id, 'b'); + }); + }); + + group('RootPigeonPlaybackListener', () { + late RootPigeonPlaybackListener root; + + setUp(() => root = RootPigeonPlaybackListener()); + + test('forwards every callback to added listeners', () { + final spy = _SpyListener(); + root.addListener(spy); + + root.onPlaybackStateChanged(PlaybackStateChangedEvent( + playerId: 'p1', playbackState: PlaybackState.playing, isBuffering: false)); + root.onPlaybackEnded(PlaybackEndedEvent(playerId: 'p1')); + root.onMediaItemTransition(MediaItemTransitionEvent(playerId: 'p1')); + root.onPictureInPictureModeChanged( + PictureInPictureModeChangedEvent(playerId: 'p1', isInPipMode: false)); + root.onPositionDiscontinuity(PositionDiscontinuityEvent(playerId: 'p1')); + root.onPlayerStateUpdate( + PlayerStateUpdateEvent(playerId: 'p1', snapshot: snapshot(playerId: 'p1'))); + root.onPrimaryPlayerChanged(PrimaryPlayerChangedEvent(playerId: 'p1')); + + expect(spy.received, hasLength(7)); + }); + + test('stops forwarding to a removed listener', () { + final spy = _SpyListener(); + root.addListener(spy); + root.removeListener(spy); + + root.onPlaybackEnded(PlaybackEndedEvent(playerId: 'p1')); + + expect(spy.received, isEmpty); + }); + + test('publishes every event onto the stream, including primary-player changes', () async { + // Regression: onPrimaryPlayerChanged was the only one of the seven + // callbacks that never reached the stream controller, so playerEventStream + // — and therefore BccmPlayerController.events and + // playerEventStreamProvider — never saw a primary-player change. + final received = []; + final sub = root.stream.listen(received.add); + addTearDown(sub.cancel); + + root.onPlaybackStateChanged(PlaybackStateChangedEvent( + playerId: 'p1', playbackState: PlaybackState.playing, isBuffering: false)); + root.onPlaybackEnded(PlaybackEndedEvent(playerId: 'p1')); + root.onMediaItemTransition(MediaItemTransitionEvent(playerId: 'p1')); + root.onPictureInPictureModeChanged( + PictureInPictureModeChangedEvent(playerId: 'p1', isInPipMode: false)); + root.onPositionDiscontinuity(PositionDiscontinuityEvent(playerId: 'p1')); + root.onPlayerStateUpdate( + PlayerStateUpdateEvent(playerId: 'p1', snapshot: snapshot(playerId: 'p1'))); + root.onPrimaryPlayerChanged(PrimaryPlayerChangedEvent(playerId: 'p1')); + + await Future.delayed(Duration.zero); + + expect(received, hasLength(7)); + expect(received.whereType(), hasLength(1)); + }); + }); +} + +class _SpyListener implements PlaybackListenerPigeon { + final List received = []; + + @override + void onPlaybackStateChanged(PlaybackStateChangedEvent event) => received.add(event); + @override + void onPlaybackEnded(PlaybackEndedEvent event) => received.add(event); + @override + void onMediaItemTransition(MediaItemTransitionEvent event) => received.add(event); + @override + void onPictureInPictureModeChanged(PictureInPictureModeChangedEvent event) => received.add(event); + @override + void onPositionDiscontinuity(PositionDiscontinuityEvent event) => received.add(event); + @override + void onPlayerStateUpdate(PlayerStateUpdateEvent event) => received.add(event); + @override + void onPrimaryPlayerChanged(PrimaryPlayerChangedEvent event) => received.add(event); +} diff --git a/test/state/player_state_notifier_test.dart b/test/state/player_state_notifier_test.dart new file mode 100644 index 00000000..b9687662 --- /dev/null +++ b/test/state/player_state_notifier_test.dart @@ -0,0 +1,282 @@ +import 'package:bccm_player/bccm_player.dart'; +import 'package:fake_async/fake_async.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../utils/fixtures.dart'; + +void main() { + group('PlayerState.fromPlayerStateSnapshot', () { + test('maps isFullscreen onto isNativeFullscreen', () { + // The field names differ on purpose but are trivially inverted in a + // refactor, and the symptom (fullscreen state stuck) is far from the cause. + final state = PlayerState.fromPlayerStateSnapshot(snapshot(isFullscreen: true)); + + expect(state.isNativeFullscreen, isTrue); + }); + + test('always marks the player initialized', () { + expect(PlayerState.fromPlayerStateSnapshot(snapshot()).isInitialized, isTrue); + }); + + test('rounds the playback position and nulls non-finite values', () { + expect( + PlayerState.fromPlayerStateSnapshot(snapshot(playbackPositionMs: 999.5)).playbackPositionMs, + 1000, + ); + expect( + PlayerState.fromPlayerStateSnapshot(snapshot(playbackPositionMs: double.nan)).playbackPositionMs, + isNull, + ); + expect( + PlayerState.fromPlayerStateSnapshot(snapshot(playbackPositionMs: double.negativeInfinity)) + .playbackPositionMs, + isNull, + ); + }); + + test('a fresh PlayerState has sane defaults', () { + const state = PlayerState(playerId: 'p1'); + + expect(state.playbackSpeed, 1.0); + expect(state.playbackState, PlaybackState.stopped); + expect(state.isBuffering, isFalse); + expect(state.isInPipMode, isFalse); + expect(state.isNativeFullscreen, isFalse); + expect(state.isInitialized, isFalse); + }); + }); + + group('PlayerStateNotifier position ticker', () { + /// Runs [body] with a notifier in the given state, inside a fake clock. + void withNotifier(PlayerState initial, void Function(PlayerStateNotifier, FakeAsync) body) { + fakeAsync((async) { + final notifier = PlayerStateNotifier(keepAlive: false, player: initial); + addTearDown(notifier.dispose); + body(notifier, async); + notifier.dispose(); + }); + } + + test('advances one second per tick while playing', () { + withNotifier( + const PlayerState( + playerId: 'p1', + playbackState: PlaybackState.playing, + playbackPositionMs: 0, + ), + (notifier, async) { + async.elapse(const Duration(seconds: 3)); + expect(notifier.state.playbackPositionMs, 3000); + }, + ); + }); + + test('scales the tick by playback speed', () { + withNotifier( + const PlayerState( + playerId: 'p1', + playbackState: PlaybackState.playing, + playbackPositionMs: 0, + playbackSpeed: 1.5, + ), + (notifier, async) { + async.elapse(const Duration(seconds: 2)); + expect(notifier.state.playbackPositionMs, 3000); + }, + ); + }); + + test('does not advance while buffering', () { + withNotifier( + const PlayerState( + playerId: 'p1', + playbackState: PlaybackState.playing, + playbackPositionMs: 5000, + isBuffering: true, + ), + (notifier, async) { + async.elapse(const Duration(seconds: 3)); + expect(notifier.state.playbackPositionMs, 5000); + }, + ); + }); + + test('does not advance while paused', () { + withNotifier( + const PlayerState( + playerId: 'p1', + playbackState: PlaybackState.paused, + playbackPositionMs: 5000, + ), + (notifier, async) { + async.elapse(const Duration(seconds: 3)); + expect(notifier.state.playbackPositionMs, 5000); + }, + ); + }); + + test('does not invent a position when there is none', () { + withNotifier( + const PlayerState(playerId: 'p1', playbackState: PlaybackState.playing), + (notifier, async) { + async.elapse(const Duration(seconds: 3)); + expect(notifier.state.playbackPositionMs, isNull); + }, + ); + }); + + test('resync restarts the interval so the next tick is a full second away', () { + withNotifier( + const PlayerState( + playerId: 'p1', + playbackState: PlaybackState.playing, + playbackPositionMs: 0, + ), + (notifier, async) { + async.elapse(const Duration(milliseconds: 900)); + expect(notifier.state.playbackPositionMs, 0, reason: 'not a full second yet'); + + notifier.resyncPlaybackPositionTimer(); + + // Would have ticked at 1000ms without the resync; now the next tick is + // at 1900ms. This is what keeps the interpolated position from + // double-counting right after a real position arrives from native. + async.elapse(const Duration(milliseconds: 600)); + expect(notifier.state.playbackPositionMs, 0); + + async.elapse(const Duration(milliseconds: 400)); + expect(notifier.state.playbackPositionMs, 1000); + }, + ); + }); + + test('stops ticking once disposed', () { + fakeAsync((async) { + final notifier = PlayerStateNotifier( + keepAlive: false, + player: const PlayerState( + playerId: 'p1', + playbackState: PlaybackState.playing, + playbackPositionMs: 0, + ), + ); + // Observed through a listener rather than `state`, which throws once + // disposed. + final positions = []; + notifier.addListener((s) => positions.add(s.playbackPositionMs)); + + async.elapse(const Duration(seconds: 1)); + expect(positions.last, 1000); + + notifier.dispose(); + final ticksAtDispose = positions.length; + async.elapse(const Duration(seconds: 5)); + + expect(positions, hasLength(ticksAtDispose), reason: 'timer kept firing after dispose'); + }); + }); + }); + + group('PlayerStateNotifier.dispose', () { + test('a keepAlive notifier ignores a plain dispose', () { + final notifier = PlayerStateNotifier(keepAlive: true); + addTearDown(() => notifier.dispose(force: true)); + + notifier.dispose(); + + expect(notifier.mounted, isTrue); + }); + + test('a keepAlive notifier honours a forced dispose', () { + final notifier = PlayerStateNotifier(keepAlive: true); + + notifier.dispose(force: true); + + expect(notifier.mounted, isFalse); + }); + + test('a non-keepAlive notifier disposes normally', () { + final notifier = PlayerStateNotifier(keepAlive: false); + + notifier.dispose(); + + expect(notifier.mounted, isFalse); + }); + + test('is idempotent even when onDispose re-enters it', () { + // Regression: for a notifier owned by PlayerPluginStateNotifier, onDispose + // runs `_removePlayer`, which calls dispose(force: true) straight back. The + // second pass used to dispose `queueManager` twice and assert. + final plugin = PlayerPluginStateNotifier(keepAlive: false); + addTearDown(() => plugin.dispose(force: true)); + final notifier = plugin.getOrAddPlayerNotifier('p1'); + + expect(() => notifier.dispose(force: true), returnsNormally); + expect(() => notifier.dispose(force: true), returnsNormally); + }); + }); + + group('PlayerPluginStateNotifier', () { + late PlayerPluginStateNotifier plugin; + + setUp(() => plugin = PlayerPluginStateNotifier(keepAlive: false)); + + tearDown(() { + for (final notifier in [...plugin.state.players.values]) { + notifier.dispose(force: true); + } + plugin.dispose(force: true); + }); + + test('getPlayerNotifier returns null for an unknown player', () { + expect(plugin.getPlayerNotifier('nope'), isNull); + }); + + test('getOrAddPlayerNotifier creates, stores and then reuses', () { + final first = plugin.getOrAddPlayerNotifier('p1'); + + expect(plugin.state.players.keys, ['p1']); + expect(plugin.getOrAddPlayerNotifier('p1'), same(first)); + }); + + test('getOrAddPlayerNotifier replaces an unmounted notifier', () { + final first = plugin.getOrAddPlayerNotifier('p1'); + first.dispose(force: true); + + final second = plugin.getOrAddPlayerNotifier('p1'); + + expect(second, isNot(same(first))); + expect(second.mounted, isTrue); + }); + + test('disposing a notifier removes it from the map', () { + final notifier = plugin.getOrAddPlayerNotifier('p1'); + + notifier.dispose(force: true); + + expect(plugin.state.players, isEmpty); + }); + + test('setPrimaryPlayer records the id and creates the notifier', () { + plugin.setPrimaryPlayer('p1'); + + expect(plugin.getPrimaryPlayerId(), 'p1'); + expect(plugin.getPlayerNotifier('p1'), isNotNull); + }); + + test('setPrimaryPlayer(null) clears the primary without touching players', () { + plugin.setPrimaryPlayer('p1'); + + plugin.setPrimaryPlayer(null); + + expect(plugin.getPrimaryPlayerId(), isNull); + expect(plugin.getPlayerNotifier('p1'), isNotNull); + }); + + test('a new player notifier starts out initialized', () { + // _createPlayerNotifier seeds isInitialized: true, because by the time + // native tells us about a player it exists over there. + expect(plugin.getOrAddPlayerNotifier('p1').state.isInitialized, isTrue); + }); + }); +} diff --git a/test/theme/controls_theme_test.dart b/test/theme/controls_theme_test.dart new file mode 100644 index 00000000..7e8deca9 --- /dev/null +++ b/test/theme/controls_theme_test.dart @@ -0,0 +1,129 @@ +import 'package:bccm_player/bccm_player.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + /// Builds the package defaults against a real Theme, which is what + /// `BccmPlayerTheme.safeOf` does at runtime. + Future defaults(WidgetTester tester) async { + late BccmControlsThemeData result; + await tester.pumpWidget(MaterialApp( + theme: ThemeData(colorScheme: ColorScheme.fromSeed(seedColor: Colors.green)), + home: Builder(builder: (context) { + result = BccmControlsThemeData.defaultTheme(context); + return const SizedBox.shrink(); + }), + )); + return result; + } + + group('BccmControlsThemeData.fillWithDefaults', () { + testWidgets('an explicitly set iconColor wins over primaryColor', (tester) async { + // Regression: this read `primaryColor ?? iconColor ?? defaults.iconColor`, + // so setting primaryColor made iconColor unreachable. It is an exported + // theming API that all the consuming apps configure. + final filled = BccmControlsThemeData( + primaryColor: Colors.red, + iconColor: Colors.blue, + ).fillWithDefaults(await defaults(tester)); + + expect(filled.iconColor, Colors.blue); + }); + + testWidgets('primaryColor still tints the icons when iconColor is unset', (tester) async { + // Deliberate shorthand, matching what primaryColor does to the progress + // bar below. + final filled = BccmControlsThemeData(primaryColor: Colors.red) + .fillWithDefaults(await defaults(tester)); + + expect(filled.iconColor, Colors.red); + }); + + testWidgets('falls back to the default iconColor when neither is set', (tester) async { + final base = await defaults(tester); + final filled = BccmControlsThemeData().fillWithDefaults(base); + + expect(filled.iconColor, base.iconColor); + }); + + testWidgets('primaryColor recolors the progress bar track and thumb', (tester) async { + final filled = BccmControlsThemeData(primaryColor: Colors.red) + .fillWithDefaults(await defaults(tester)); + + expect(filled.progressBarTheme?.activeTrackColor, Colors.red); + expect(filled.progressBarTheme?.thumbColor, Colors.red); + }); + + testWidgets('an explicit progressBarTheme is left alone by primaryColor', (tester) async { + const explicit = SliderThemeData(activeTrackColor: Colors.purple); + final filled = BccmControlsThemeData(primaryColor: Colors.red, progressBarTheme: explicit) + .fillWithDefaults(await defaults(tester)); + + expect(filled.progressBarTheme?.activeTrackColor, Colors.purple); + }); + + testWidgets('unset text styles come from the defaults', (tester) async { + final base = await defaults(tester); + final filled = BccmControlsThemeData().fillWithDefaults(base); + + expect(filled.durationTextStyle, base.durationTextStyle); + expect(filled.settingsListTextStyle, base.settingsListTextStyle); + expect(filled.fullscreenTitleStyle, base.fullscreenTitleStyle); + expect(filled.settingsListBackgroundColor, base.settingsListBackgroundColor); + }); + }); + + group('BccmPlayerTheme.safeOf', () { + testWidgets('yields the package defaults with no BccmPlayerTheme ancestor', (tester) async { + late BccmPlayerThemeData theme; + await tester.pumpWidget(MaterialApp( + home: Builder(builder: (context) { + theme = BccmPlayerTheme.safeOf(context); + return const SizedBox.shrink(); + }), + )); + + // Both DefaultControls and PlayPauseButton do `.controls!`, so these must + // never be null. + expect(theme.controls, isNotNull); + expect(theme.miniPlayer, isNotNull); + }); + + testWidgets('merges a partial theme over the defaults', (tester) async { + late BccmPlayerThemeData theme; + await tester.pumpWidget(MaterialApp( + home: BccmPlayerTheme( + playerTheme: BccmPlayerThemeData( + controls: BccmControlsThemeData(iconColor: Colors.orange), + ), + builder: (context) { + theme = BccmPlayerTheme.safeOf(context); + return const SizedBox.shrink(); + }, + ), + )); + + expect(theme.controls?.iconColor, Colors.orange); + expect(theme.controls?.durationTextStyle, isNotNull, reason: 'unset fields still get defaults'); + expect(theme.miniPlayer, isNotNull, reason: 'the whole miniPlayer section defaults'); + }); + + testWidgets('rejects setting both child and builder', (tester) async { + expect( + () => BccmPlayerTheme( + playerTheme: BccmPlayerThemeData(), + child: const SizedBox.shrink(), + builder: (_) => const SizedBox.shrink(), + ), + throwsAssertionError, + ); + }); + + testWidgets('requires one of child or builder', (tester) async { + expect( + () => BccmPlayerTheme(playerTheme: BccmPlayerThemeData()), + throwsAssertionError, + ); + }); + }); +} diff --git a/test/utils/debouncer_test.dart b/test/utils/debouncer_test.dart new file mode 100644 index 00000000..c4b04762 --- /dev/null +++ b/test/utils/debouncer_test.dart @@ -0,0 +1,145 @@ +import 'dart:async'; + +import 'package:bccm_player/src/utils/debouncer.dart'; +import 'package:fake_async/fake_async.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('Debouncer', () { + test('collapses rapid calls into the last one', () { + fakeAsync((async) { + final calls = []; + final debouncer = Debouncer(milliseconds: 100); + + debouncer.run(() => calls.add(1)); + async.elapse(const Duration(milliseconds: 50)); + debouncer.run(() => calls.add(2)); + async.elapse(const Duration(milliseconds: 50)); + debouncer.run(() => calls.add(3)); + async.elapse(const Duration(milliseconds: 100)); + + expect(calls, [3]); + }); + }); + + test('debounces the first call too, by default', () { + // The doc comment above the class claims the default is false. It is not — + // pinned here because the default is what every call site relies on. + fakeAsync((async) { + final calls = []; + final debouncer = Debouncer(milliseconds: 100); + + debouncer.run(() => calls.add(1)); + + expect(calls, isEmpty, reason: 'default debounceInitial: true delays the first call'); + async.elapse(const Duration(milliseconds: 100)); + expect(calls, [1]); + }); + }); + + test('runs the first call immediately when debounceInitial is false', () { + fakeAsync((async) { + final calls = []; + final debouncer = Debouncer(milliseconds: 100, debounceInitial: false); + + debouncer.run(() => calls.add(1)); + expect(calls, [1]); + + // The leading call already fired, so the trailing timer must not repeat it. + async.elapse(const Duration(milliseconds: 100)); + expect(calls, [1]); + }); + }); + + test('forceEarly runs the pending action now', () { + fakeAsync((async) { + final calls = []; + final debouncer = Debouncer(milliseconds: 1000); + debouncer.run(() => calls.add(1)); + + debouncer.forceEarly(); + + expect(calls, [1]); + async.elapse(const Duration(seconds: 2)); + expect(calls, [1], reason: 'the cancelled timer must not fire as well'); + }); + }); + }); + + group('OneAsyncAtATime', () { + test('runs a single action straight away', () async { + final scheduler = OneAsyncAtATime(); + final calls = []; + + await scheduler.runWhenCurrentIsDone(() async => calls.add(1)); + + expect(calls, [1]); + expect(scheduler.hasPending, isFalse); + }); + + test('keeps only the newest pending action while one is in flight', () async { + // This is what makes scrubbing feel responsive: intermediate seek targets + // are dropped rather than queued, so the player only chases the latest one. + final scheduler = OneAsyncAtATime(); + final calls = []; + final gate = Completer(); + + final first = scheduler.runWhenCurrentIsDone(() async { + calls.add(1); + await gate.future; + }); + + scheduler.runWhenCurrentIsDone(() async => calls.add(2)); + scheduler.runWhenCurrentIsDone(() async => calls.add(3)); + expect(scheduler.hasPending, isTrue); + expect(calls, [1], reason: 'nothing else runs until the first completes'); + + gate.complete(); + await first; + await Future.delayed(Duration.zero); + + expect(calls, [1, 3], reason: '2 was superseded by 3 before it ever ran'); + expect(scheduler.hasPending, isFalse); + }); + + test('a failing action is swallowed and does not block the next one', () async { + // Regression: the error was pushed into a Completer nothing awaited, which + // raised an unhandled async error. Callers fire these off unawaited, so a + // throw must not escape. + final scheduler = OneAsyncAtATime(); + final calls = []; + + await expectLater( + scheduler.runWhenCurrentIsDone(() async => throw Exception('boom')), + completes, + ); + await scheduler.runWhenCurrentIsDone(() async => calls.add(1)); + + expect(calls, [1]); + }); + + test('reset drops the pending action', () async { + final scheduler = OneAsyncAtATime(); + final calls = []; + final gate = Completer(); + + final first = scheduler.runWhenCurrentIsDone(() async { + calls.add(1); + await gate.future; + }); + scheduler.runWhenCurrentIsDone(() async => calls.add(2)); + + // Regression: reset() used to completeError('disposed') on that same + // unawaited Completer. useTimeline calls reset() on dispose, so navigating + // away mid-scrub raised it every time. + scheduler.reset(); + expect(scheduler.hasPending, isFalse); + + gate.complete(); + await first; + await Future.delayed(Duration.zero); + + expect(calls, [1], reason: 'the pending action was discarded by reset'); + }); + }); +} diff --git a/test/utils/fake_platform.dart b/test/utils/fake_platform.dart new file mode 100644 index 00000000..56f9a392 --- /dev/null +++ b/test/utils/fake_platform.dart @@ -0,0 +1,211 @@ +import 'dart:async'; + +import 'package:bccm_player/bccm_player.dart'; +import 'package:bccm_player/src/pigeon/playback_platform_pigeon.g.dart'; +import 'package:plugin_platform_interface/plugin_platform_interface.dart'; + +/// A hand-written stand-in for the native platform. +/// +/// [BccmPlayerInterface.instance] is a settable static guarded by +/// [PlatformInterface.verifyToken], and `_instance` is lazily initialised — so +/// assigning this fake before anything reads `instance` means [BccmPlayerNative] +/// (and therefore the pigeon channels) is never constructed at all. +/// +/// Prefer this over the mockito mocks in `mocks.dart` whenever a test needs +/// *real* state management: `stateNotifier` is a field on the abstract class, so +/// a fake gets a working [PlayerPluginStateNotifier] for free. Use the mocks +/// instead when the point of the test is to verify an interaction. +/// +/// Every call is recorded so tests can assert on what reached the platform. +class FakeBccmPlayerInterface extends BccmPlayerInterface with MockPlatformInterfaceMixin { + FakeBccmPlayerInterface(); + + /// Installs the fake and returns it. Call [restore] in `tearDown`. + /// + /// In a widget test, create players from `setUp` rather than from inside the + /// `testWidgets` body: [PlayerStateNotifier] starts a periodic timer, and one + /// created inside the body belongs to that test's fake-async zone, which trips + /// the pending-timer assertion before `tearDown` gets a chance to dispose it. + static FakeBccmPlayerInterface install() { + final fake = FakeBccmPlayerInterface(); + BccmPlayerInterface.instance = fake; + return fake; + } + + /// Drops references held by the fake. The `instance` static itself cannot be + /// reset to the real native implementation (and must not be, in tests), so + /// each test installs a fresh fake over the previous one. + void restore() { + _playerEvents.close(); + _chromecastEvents.close(); + for (final notifier in stateNotifier.state.players.values) { + notifier.dispose(force: true); + } + } + + // --- recorded calls ------------------------------------------------------- + + final List newPlayerCalls = []; + final List replaceCurrentMediaItemCalls = []; + final List seekToCalls = []; + final List seekToLiveCalls = []; + final List playCalls = []; + final List pauseCalls = []; + final List stopCalls = []; + final List disposePlayerCalls = []; + final List setPrimaryCalls = []; + final List setSelectedTrackCalls = []; + final List setPlaybackSpeedCalls = []; + final List setRepeatModeCalls = []; + int openExpandedCastControllerCalls = 0; + + /// Value handed back by [newPlayer]. Incremented per call so successive + /// players get distinct ids. + int _nextPlayerId = 1; + + /// Value handed back by [getPlayerTracks]. + PlayerTracksSnapshot? tracks; + + /// Value handed back by [getPlayerState]. + PlayerStateSnapshot? playerStateSnapshot; + + final StreamController _playerEvents = StreamController.broadcast(); + final StreamController _chromecastEvents = StreamController.broadcast(); + + /// Pushes an event onto [playerEventStream], as the native side would. + void emitPlayerEvent(Object? event) => _playerEvents.add(event); + + /// Pushes an event onto [chromecastEventStream]. + void emitChromecastEvent(ChromecastEvent event) => _chromecastEvents.add(event); + + // --- BccmPlayerInterface -------------------------------------------------- + + @override + Stream get playerEventStream => _playerEvents.stream; + + @override + Stream get chromecastEventStream => _chromecastEvents.stream; + + BccmPlayerController? _primaryController; + + @override + BccmPlayerController get primaryController => _primaryController ??= BccmPlayerController.empty(); + + @override + Future setup() async {} + + @override + Future newPlayer({BufferMode? bufferMode, bool? disableNpaw}) async { + final id = 'fake-player-${_nextPlayerId++}'; + newPlayerCalls.add(id); + stateNotifier.getOrAddPlayerNotifier(id); + return id; + } + + @override + Future disposePlayer(String playerId) async { + disposePlayerCalls.add(playerId); + } + + @override + Future setPrimary(String id) async { + setPrimaryCalls.add(id); + stateNotifier.setPrimaryPlayer(id); + return true; + } + + @override + Future replaceCurrentMediaItem( + String playerId, + MediaItem mediaItem, { + bool? playbackPositionFromPrimary, + bool? autoplay = true, + }) async { + replaceCurrentMediaItemCalls.add(ReplaceMediaItemCall( + playerId: playerId, + mediaItem: mediaItem, + playbackPositionFromPrimary: playbackPositionFromPrimary, + autoplay: autoplay, + )); + } + + @override + Future seekTo(String playerId, double positionMs) async { + seekToCalls.add(SeekCall(playerId: playerId, positionMs: positionMs)); + } + + @override + Future seekToLive(String playerId) async { + seekToLiveCalls.add(playerId); + } + + @override + void play(String playerId) => playCalls.add(playerId); + + @override + void pause(String playerId) => pauseCalls.add(playerId); + + @override + void stop(String playerId, bool reset) => stopCalls.add(playerId); + + @override + Future setSelectedTrack(String playerId, TrackType type, String? trackId) async { + setSelectedTrackCalls.add(SelectedTrackCall(playerId: playerId, type: type, trackId: trackId)); + } + + @override + Future setPlaybackSpeed(String playerId, double speed) async { + setPlaybackSpeedCalls.add(speed); + } + + @override + Future setRepeatMode(String playerId, RepeatMode repeatMode) async { + setRepeatModeCalls.add(repeatMode); + } + + @override + Future getPlayerTracks({String? playerId}) async => tracks; + + @override + Future getPlayerState({String? playerId}) async => playerStateSnapshot; + + @override + void openExpandedCastController() => openExpandedCastControllerCalls++; + + @override + Future fetchMediaInfo({required String url, String? mimeType}) async { + return MediaInfo(audioTracks: [], textTracks: [], videoTracks: []); + } + + @override + Future getAndroidPerformanceClass() async => 0; +} + +class ReplaceMediaItemCall { + ReplaceMediaItemCall({ + required this.playerId, + required this.mediaItem, + required this.playbackPositionFromPrimary, + required this.autoplay, + }); + + final String playerId; + final MediaItem mediaItem; + final bool? playbackPositionFromPrimary; + final bool? autoplay; +} + +class SeekCall { + SeekCall({required this.playerId, required this.positionMs}); + + final String playerId; + final double positionMs; +} + +class SelectedTrackCall { + SelectedTrackCall({required this.playerId, required this.type, required this.trackId}); + + final String playerId; + final TrackType type; + final String? trackId; +} diff --git a/test/utils/fixtures.dart b/test/utils/fixtures.dart new file mode 100644 index 00000000..1e69e140 --- /dev/null +++ b/test/utils/fixtures.dart @@ -0,0 +1,99 @@ +import 'package:bccm_player/src/pigeon/playback_platform_pigeon.g.dart'; + +/// Builders for the generated pigeon models. +/// +/// **`MediaItem` and `Track` have no `==`/`hashCode`.** They are plain mutable +/// pigeon classes, so they compare by identity. `expect(item, equals(other))` +/// therefore fails for two structurally identical items — assert on `id` / `url` +/// instead, or compare `.map((i) => i.id)` over a list. +/// +/// `PlayerState` *is* freezed and has value equality, but it holds a `MediaItem`, +/// so `copyWith(currentMediaItem: equalButDistinctItem)` still counts as a change +/// and still notifies listeners. + +MediaItem mediaItem({ + String? id, + String? url, + String? title, + double? durationMs, + bool? isLive, + bool? isOffline, + Map? extras, +}) { + return MediaItem( + id: id, + url: url ?? 'https://example.test/${id ?? 'item'}.m3u8', + mimeType: 'application/x-mpegURL', + isLive: isLive, + isOffline: isOffline, + metadata: MediaMetadata( + title: title ?? id, + durationMs: durationMs, + extras: extras, + ), + ); +} + +/// A list of items with sequential ids: `id-1`, `id-2`, ... +List mediaItems(int count, {String prefix = 'id'}) { + return List.generate(count, (i) => mediaItem(id: '$prefix-${i + 1}')); +} + +PlayerStateSnapshot snapshot({ + String playerId = 'fake-player-1', + PlaybackState playbackState = PlaybackState.playing, + bool isBuffering = false, + bool isFullscreen = false, + double playbackSpeed = 1.0, + MediaItem? currentMediaItem, + double? playbackPositionMs, + VideoSize? videoSize, + int? textureId, + double? volume, + PlayerError? error, + double? seekableRangeStartMs, + double? seekableRangeEndMs, +}) { + return PlayerStateSnapshot( + playerId: playerId, + playbackState: playbackState, + isBuffering: isBuffering, + isFullscreen: isFullscreen, + playbackSpeed: playbackSpeed, + currentMediaItem: currentMediaItem, + playbackPositionMs: playbackPositionMs, + videoSize: videoSize, + textureId: textureId, + volume: volume, + error: error, + seekableRangeStartMs: seekableRangeStartMs, + seekableRangeEndMs: seekableRangeEndMs, + ); +} + +Track track({ + required String id, + String? label, + String? language, + double? frameRate, + int? bitrate, + int? width, + int? height, + bool? downloaded, + bool isSelected = false, +}) { + return Track( + id: id, + label: label, + language: language, + frameRate: frameRate, + bitrate: bitrate, + width: width, + height: height, + downloaded: downloaded, + isSelected: isSelected, + ); +} + +/// Ids of a queue-ish list, for order assertions. +List idsOf(List items) => items.map((i) => i.id).toList(); diff --git a/test/utils/time_test.dart b/test/utils/time_test.dart new file mode 100644 index 00000000..8d186e78 --- /dev/null +++ b/test/utils/time_test.dart @@ -0,0 +1,144 @@ +import 'package:bccm_player/bccm_player.dart'; +import 'package:bccm_player/src/utils/extensions.dart'; +import 'package:bccm_player/src/utils/num.dart'; +import 'package:bccm_player/src/utils/time.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('getFormattedDuration', () { + test('omits the hours segment below one hour', () { + expect(getFormattedDuration(0), '00:00'); + expect(getFormattedDuration(999), '00:00'); + expect(getFormattedDuration(1000), '00:01'); + expect(getFormattedDuration(59000), '00:59'); + expect(getFormattedDuration(60000), '01:00'); + expect(getFormattedDuration(3599000), '59:59'); + }); + + test('includes hours from one hour up', () { + expect(getFormattedDuration(3600000), '01:00:00'); + expect(getFormattedDuration(3661000), '01:01:01'); + expect(getFormattedDuration(36000000), '10:00:00'); + }); + + test('treats non-finite input as zero rather than throwing', () { + // Regression: `Duration(milliseconds: nan.toInt())` throws + // UnsupportedError, which would take the whole controls overlay down. + // Reachable whenever the safeDouble/safeInt guards upstream are bypassed. + expect(getFormattedDuration(double.nan), '00:00'); + expect(getFormattedDuration(double.infinity), '00:00'); + expect(getFormattedDuration(double.negativeInfinity), '00:00'); + }); + + test('clamps negative input to zero', () { + // Regression: -1000ms used to format as "00:59", because Dart's % is + // always non-negative. + expect(getFormattedDuration(-1000), '00:00'); + expect(getFormattedDuration(-61000), '00:00'); + }); + }); + + group('calcTimeLeftMs', () { + test('is the remaining duration', () { + expect(calcTimeLeftMs(duration: 5000, currentMs: 1000), 4000); + }); + + test('never goes negative when the position overshoots the duration', () { + expect(calcTimeLeftMs(duration: 1000, currentMs: 2000), 0); + }); + + test('treats nulls as zero', () { + expect(calcTimeLeftMs(duration: null, currentMs: null), 0); + expect(calcTimeLeftMs(duration: 5000, currentMs: null), 5000); + expect(calcTimeLeftMs(duration: null, currentMs: 1000), 0); + }); + + test('treats a non-finite duration as zero', () { + expect(calcTimeLeftMs(duration: double.nan, currentMs: 5), 0); + expect(calcTimeLeftMs(duration: double.infinity, currentMs: 5), 0); + }); + + test('treats a non-finite position as zero, leaving the full duration', () { + expect(calcTimeLeftMs(duration: 5000, currentMs: double.nan), 5000); + }); + }); + + group('safeDouble', () { + test('passes finite values through', () { + expect(safeDouble(1.5), 1.5); + expect(safeDouble(-1.5), -1.5); + expect(safeDouble(0), 0); + }); + + test('maps non-finite values to zero', () { + // This is the guard that keeps NaN out of the timeline arithmetic. + expect(safeDouble(double.nan), 0); + expect(safeDouble(double.infinity), 0); + expect(safeDouble(double.negativeInfinity), 0); + }); + }); + + group('finiteOrNull', () { + test('keeps finite values and nulls the rest', () { + expect(1.5.finiteOrNull(), 1.5); + expect(double.nan.finiteOrNull(), isNull); + expect(double.infinity.finiteOrNull(), isNull); + }); + }); + + group('asOrNull', () { + test('casts on a match and yields null otherwise', () { + const Object value = 'hello'; + expect(value.asOrNull(), 'hello'); + expect(value.asOrNull(), isNull); + expect(null.asOrNull(), isNull); + }); + }); + + group('VideoSize.aspectRatio', () { + test('is width over height', () { + expect(VideoSize(width: 1920, height: 1080).aspectRatio, 1920 / 1080); + expect(VideoSize(width: 1080, height: 1920).aspectRatio, 1080 / 1920); + expect(VideoSize(width: 100, height: 100).aspectRatio, 1); + }); + + test('is non-finite for a zero height rather than throwing', () { + // Consumers branch on `> 1` / `< 1` (see BccmPlayerViewController's + // orientation logic), so this needs to not blow up on an uninitialised + // video size. + expect(VideoSize(width: 1920, height: 0).aspectRatio.isFinite, isFalse); + }); + }); + + group('Track.labelWithFallback', () { + Track videoTrack({int? height, double? frameRate}) => + Track(id: 'v', height: height, frameRate: frameRate, isSelected: false); + + test('describes a video track by height', () { + expect(videoTrack(height: 720).labelWithFallback, '720p'); + }); + + test('appends the frame rate only when it is not 30', () { + expect(videoTrack(height: 720, frameRate: 30).labelWithFallback, '720p'); + expect(videoTrack(height: 720, frameRate: 60).labelWithFallback, '720p (60fps)'); + expect(videoTrack(height: 1080, frameRate: 59.94).labelWithFallback, '1080p (59fps)'); + }); + + test('falls back label -> language -> id when there is no height', () { + expect( + Track(id: 'id', label: 'Norsk', language: 'nor', isSelected: false).labelWithFallback, + 'Norsk', + ); + expect(Track(id: 'id', language: 'nor', isSelected: false).labelWithFallback, 'nor'); + expect(Track(id: 'id', isSelected: false).labelWithFallback, 'id'); + }); + }); + + group('TrackListX.safe', () { + test('drops the nulls pigeon leaves in track lists', () { + final tracks = [Track(id: 'a', isSelected: false), null, Track(id: 'b', isSelected: false)]; + + expect(tracks.safe.map((t) => t.id), ['a', 'b']); + }); + }); +} diff --git a/test/utils/timeline_test.dart b/test/utils/timeline_test.dart new file mode 100644 index 00000000..572f1b1f --- /dev/null +++ b/test/utils/timeline_test.dart @@ -0,0 +1,354 @@ +import 'package:bccm_player/bccm_player.dart'; +import 'package:bccm_player/controls.dart'; +import 'package:flutter/widgets.dart' hide RepeatMode; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'fake_platform.dart'; +import 'fixtures.dart'; + +void main() { + late FakeBccmPlayerInterface fake; + late BccmPlayerController controller; + late PlayerStateNotifier notifier; + + setUp(() async { + fake = FakeBccmPlayerInterface.install(); + controller = BccmPlayerController(mediaItem(id: 'a')); + await controller.initialize(); + notifier = fake.stateNotifier.getPlayerNotifier(controller.value.playerId)!; + }); + + tearDown(() => fake.restore()); + + /// Mounts [useTimeline] and returns a getter for the latest helper plus a + /// build counter. + Future<_Harness> pumpTimeline(WidgetTester tester) async { + final harness = _Harness(); + await tester.pumpWidget(HookBuilder(builder: (context) { + harness.builds++; + harness.timeline = useTimeline(controller); + return const SizedBox.shrink(); + })); + return harness; + } + + group('range resolution', () { + testWidgets('VOD uses the media duration and starts at zero', (tester) async { + notifier.setStateFromSnapshot(snapshot( + playerId: controller.value.playerId, + currentMediaItem: mediaItem(durationMs: 100000), + playbackPositionMs: 50000, + )); + final harness = await pumpTimeline(tester); + + expect(harness.timeline.rangeStartMs, 0); + expect(harness.timeline.rangeEndMs, 100000); + expect(harness.timeline.timeFraction, 0.5); + }); + + testWidgets('the native seekable range wins over the media duration', (tester) async { + notifier.setStateFromSnapshot(snapshot( + playerId: controller.value.playerId, + currentMediaItem: mediaItem(durationMs: 100000), + playbackPositionMs: 50000, + seekableRangeStartMs: 0, + seekableRangeEndMs: 400000, + )); + final harness = await pumpTimeline(tester); + + expect(harness.timeline.rangeEndMs, 400000); + expect(harness.timeline.timeFraction, 0.125); + }); + + testWidgets('a live DVR window is measured from its start, not from zero', (tester) async { + // The whole point of the seekable range: for a DVR window running + // 100s..200s, being at 150s is halfway along the bar, not three quarters. + notifier.setStateFromSnapshot(snapshot( + playerId: controller.value.playerId, + playbackPositionMs: 150000, + seekableRangeStartMs: 100000, + seekableRangeEndMs: 200000, + )); + final harness = await pumpTimeline(tester); + + expect(harness.timeline.rangeStartMs, 100000); + expect(harness.timeline.rangeEndMs, 200000); + expect(harness.timeline.timeFraction, 0.5); + expect(harness.timeline.duration, 200000, reason: 'duration aliases rangeEndMs'); + }); + + testWidgets('falls back to the current position when nothing else is known', (tester) async { + notifier.setStateFromSnapshot(snapshot( + playerId: controller.value.playerId, + playbackPositionMs: 7000, + )); + final harness = await pumpTimeline(tester); + + expect(harness.timeline.rangeEndMs, 7000); + expect(harness.timeline.timeFraction, 1.0); + }); + + testWidgets('degrades to an empty range before anything has loaded', (tester) async { + final harness = await pumpTimeline(tester); + + expect(harness.timeline.rangeStartMs, 0); + expect(harness.timeline.actualTimeMs, 0); + expect(harness.timeline.timeFraction, 0.0); + }); + + testWidgets('clamps a negative range start to zero', (tester) async { + notifier.setStateFromSnapshot(snapshot( + playerId: controller.value.playerId, + playbackPositionMs: 500, + seekableRangeStartMs: -5000, + seekableRangeEndMs: 1000, + )); + final harness = await pumpTimeline(tester); + + expect(harness.timeline.rangeStartMs, 0); + expect(harness.timeline.timeFraction, 0.5); + }); + + testWidgets('never lets the range end fall below its start', (tester) async { + notifier.setStateFromSnapshot(snapshot( + playerId: controller.value.playerId, + playbackPositionMs: 5000, + seekableRangeStartMs: 8000, + seekableRangeEndMs: 2000, + )); + final harness = await pumpTimeline(tester); + + expect(harness.timeline.rangeEndMs, 8000); + expect(harness.timeline.timeFraction, 0.0, reason: 'a zero-width range has no meaningful fraction'); + }); + + testWidgets('clamps the fraction when the position sits outside the range', (tester) async { + notifier.setStateFromSnapshot(snapshot( + playerId: controller.value.playerId, + playbackPositionMs: 500000, + seekableRangeStartMs: 1000, + seekableRangeEndMs: 2000, + )); + final harness = await pumpTimeline(tester); + + expect(harness.timeline.timeFraction, 1.0); + }); + }); + + group('positionFromFraction', () { + testWidgets('is the exact inverse of timeFraction across a DVR window', (tester) async { + // Regression: the seekbar multiplied the fraction by the range *end*, + // ignoring the start. The thumb sat at 0.5 but dragging to 0.5 seeked to + // 100s into a window that begins at 100s. + notifier.setStateFromSnapshot(snapshot( + playerId: controller.value.playerId, + playbackPositionMs: 150000, + seekableRangeStartMs: 100000, + seekableRangeEndMs: 200000, + )); + final harness = await pumpTimeline(tester); + final timeline = harness.timeline; + + expect(timeline.positionFromFraction(timeline.timeFraction), 150000); + expect(timeline.positionFromFraction(0), 100000); + expect(timeline.positionFromFraction(0.5), 150000); + expect(timeline.positionFromFraction(1), 200000); + }); + + testWidgets('is a plain scale for VOD', (tester) async { + notifier.setStateFromSnapshot(snapshot( + playerId: controller.value.playerId, + currentMediaItem: mediaItem(durationMs: 100000), + playbackPositionMs: 0, + )); + final harness = await pumpTimeline(tester); + + expect(harness.timeline.positionFromFraction(0.25), 25000); + }); + + testWidgets('clamps fractions outside [0,1]', (tester) async { + notifier.setStateFromSnapshot(snapshot( + playerId: controller.value.playerId, + currentMediaItem: mediaItem(durationMs: 100000), + playbackPositionMs: 0, + )); + final harness = await pumpTimeline(tester); + + expect(harness.timeline.positionFromFraction(-1), 0); + expect(harness.timeline.positionFromFraction(2), 100000); + }); + }); + + group('rebuild throttling', () { + testWidgets('position changes within the same 500ms bucket do not rebuild', (tester) async { + // Regression: `positionMs ?? 0 / 500` parsed as `positionMs ?? (0 / 500)`, + // so the selector key was the raw position and the controls rebuilt on + // every single millisecond reported by the player. + final harness = await pumpTimeline(tester); + final initialBuilds = harness.builds; + + notifier.setPlaybackPosition(100); + await tester.pump(); + notifier.setPlaybackPosition(200); + await tester.pump(); + + expect(harness.builds, initialBuilds, reason: 'both positions round into bucket 0'); + }); + + testWidgets('crossing a bucket boundary does rebuild', (tester) async { + final harness = await pumpTimeline(tester); + final initialBuilds = harness.builds; + + notifier.setPlaybackPosition(400); + await tester.pump(); + + expect(harness.builds, initialBuilds + 1); + }); + + testWidgets('a change of seekable range rebuilds', (tester) async { + final harness = await pumpTimeline(tester); + final initialBuilds = harness.builds; + + notifier.setStateFromSnapshot(snapshot( + playerId: controller.value.playerId, + seekableRangeStartMs: 1000, + seekableRangeEndMs: 9000, + )); + await tester.pump(); + + expect(harness.builds, greaterThan(initialBuilds)); + }); + }); + + group('scrubbing', () { + testWidgets('scrubTo seeks and reports the scrub position while in flight', (tester) async { + notifier.setStateFromSnapshot(snapshot( + playerId: controller.value.playerId, + currentMediaItem: mediaItem(durationMs: 100000), + playbackPositionMs: 0, + )); + final harness = await pumpTimeline(tester); + + harness.timeline.scrubTo(60000); + await tester.pump(); + + expect(fake.seekToCalls.single.positionMs, 60000); + }); + + testWidgets('scrubTo ignores targets within 500ms of the last one mid-gesture', (tester) async { + // The dead-zone exists because Slider.onChanged fires continuously during + // a drag. It is measured against the previous scrub *target*, so it only + // suppresses within one gesture — no pumping between these calls. + notifier.setStateFromSnapshot(snapshot( + playerId: controller.value.playerId, + currentMediaItem: mediaItem(durationMs: 100000), + playbackPositionMs: 0, + )); + final harness = await pumpTimeline(tester); + + harness.timeline.scrubTo(60000); + harness.timeline.scrubTo(60200); + harness.timeline.scrubTo(60400); + await tester.pump(); + + expect(fake.seekToCalls, hasLength(1), reason: 'the two nearby targets were swallowed'); + expect(fake.seekToCalls.single.positionMs, 60000); + }); + + testWidgets('the very first scrub near zero is swallowed by the dead-zone', (tester) async { + // A consequence of comparing against the previous target rather than the + // playhead: currentScrub starts at 0, so dragging to under 500ms does + // nothing at all. + notifier.setStateFromSnapshot(snapshot( + playerId: controller.value.playerId, + currentMediaItem: mediaItem(durationMs: 100000), + playbackPositionMs: 30000, + )); + final harness = await pumpTimeline(tester); + + harness.timeline.scrubTo(400); + await tester.pump(); + + expect(fake.seekToCalls, isEmpty); + }); + + testWidgets('a completed seek clears the scrub state', (tester) async { + // seekToScrubbed resets seeking/currentScrub in a post-frame callback once + // the platform round-trip finishes, which is what hands display back to + // the player's own reported position. + notifier.setStateFromSnapshot(snapshot( + playerId: controller.value.playerId, + currentMediaItem: mediaItem(durationMs: 100000), + playbackPositionMs: 0, + )); + final harness = await pumpTimeline(tester); + + harness.timeline.scrubTo(60000); + await tester.pump(); + expect(harness.timeline.seeking, isTrue); + + await tester.pump(); + + expect(harness.timeline.seeking, isFalse); + expect(harness.timeline.currentScrub, 0); + }); + + testWidgets('scrubTo clamps into the seekable range', (tester) async { + notifier.setStateFromSnapshot(snapshot( + playerId: controller.value.playerId, + playbackPositionMs: 150000, + seekableRangeStartMs: 100000, + seekableRangeEndMs: 200000, + )); + final harness = await pumpTimeline(tester); + + harness.timeline.scrubTo(999999); + await tester.pump(); + expect(fake.seekToCalls.last.positionMs, 200000); + + harness.timeline.scrubTo(-999999); + await tester.pump(); + expect(fake.seekToCalls.last.positionMs, 100000); + }); + + testWidgets('scrubToRelative offsets the actual position when not seeking', (tester) async { + notifier.setStateFromSnapshot(snapshot( + playerId: controller.value.playerId, + currentMediaItem: mediaItem(durationMs: 100000), + playbackPositionMs: 30000, + )); + final harness = await pumpTimeline(tester); + + harness.timeline.scrubToRelative(15000); + await tester.pump(); + + expect(fake.seekToCalls.single.positionMs, 45000); + }); + + testWidgets('scrubToRelative accumulates while a seek is still in flight', (tester) async { + // Tapping +15s twice before the first seek lands has to reach +30s, which + // is why the base is currentScrub rather than the playhead once seeking + // has begun. No pump between the taps: after one completes the scrub state + // is cleared and the base returns to the player's reported position. + notifier.setStateFromSnapshot(snapshot( + playerId: controller.value.playerId, + currentMediaItem: mediaItem(durationMs: 100000), + playbackPositionMs: 30000, + )); + final harness = await pumpTimeline(tester); + + harness.timeline.scrubToRelative(15000); + harness.timeline.scrubToRelative(15000); + await tester.pump(); + await tester.pump(); + + expect(fake.seekToCalls.last.positionMs, 60000); + }); + }); +} + +class _Harness { + int builds = 0; + late TimelineHelper timeline; +} diff --git a/test/widgets/controls_wrapper_test.dart b/test/widgets/controls_wrapper_test.dart new file mode 100644 index 00000000..f62ad75d --- /dev/null +++ b/test/widgets/controls_wrapper_test.dart @@ -0,0 +1,323 @@ +import 'package:bccm_player/controls.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + /// Mounts a wrapper whose child reports the visibility it was handed. + /// + /// [child] matters for the key tests: the wrapper's `Focus` has + /// `canRequestFocus: false`, so it only receives key events that bubble up + /// from a focused descendant. With an unfocusable child its `onKeyEvent` + /// never runs at all — which is not how the real controls are built. + Future pumpWrapper( + WidgetTester tester, { + required bool autoHide, + bool showByDefault = true, + bool isTv = false, + bool pure = false, + void Function(ControlsState)? capture, + Widget? child, + }) { + return tester.pumpWidget(MaterialApp( + home: ControlsWrapper( + autoHide: autoHide, + showByDefault: showByDefault, + isTv: isTv, + pure: pure, + builder: (context) { + capture?.call(ControlsState.of(context)); + return child ?? const Text('controls'); + }, + ), + )); + } + + ControlsWrapperState stateOf(WidgetTester tester) => + tester.state(find.byType(ControlsWrapper)); + + group('auto-hide', () { + testWidgets('hides after five seconds when autoHide is on', (tester) async { + late ControlsState controls; + await pumpWrapper(tester, autoHide: true, capture: (c) => controls = c); + + expect(controls.visible, isTrue); + + await tester.pump(const Duration(seconds: 5)); + await tester.pumpAndSettle(); + + expect(controls.visible, isFalse); + }); + + testWidgets('stays visible when autoHide is off', (tester) async { + late ControlsState controls; + await pumpWrapper(tester, autoHide: false, capture: (c) => controls = c); + + await tester.pump(const Duration(seconds: 10)); + await tester.pumpAndSettle(); + + expect(controls.visible, isTrue); + }); + + testWidgets('does not hide before the timer elapses', (tester) async { + late ControlsState controls; + await pumpWrapper(tester, autoHide: true, capture: (c) => controls = c); + + await tester.pump(const Duration(seconds: 4)); + + expect(controls.visible, isTrue); + }); + + testWidgets('showByDefault false starts the animation closed', (tester) async { + await pumpWrapper(tester, autoHide: false, showByDefault: false); + + expect(stateOf(tester).visibilityAnimationController.value, 0.0); + }); + + testWidgets('showByDefault true starts the animation open', (tester) async { + await pumpWrapper(tester, autoHide: false); + + expect(stateOf(tester).visibilityAnimationController.value, 1.0); + }); + }); + + group('pointer interaction', () { + testWidgets('tapping toggles visibility', (tester) async { + late ControlsState controls; + await pumpWrapper(tester, autoHide: false, capture: (c) => controls = c); + + await tester.tap(find.text('controls')); + await tester.pumpAndSettle(); + expect(controls.visible, isFalse); + + await tester.tap(find.byType(ControlsWrapper)); + await tester.pumpAndSettle(); + expect(controls.visible, isTrue); + }); + + testWidgets('a pointer move brings hidden controls back', (tester) async { + late ControlsState controls; + await pumpWrapper(tester, autoHide: false, capture: (c) => controls = c); + + // Put the pointer down first (which shows them), then hide, so the move + // below is what does the work rather than onPointerDown. + final gesture = await tester.startGesture(tester.getCenter(find.text('controls'))); + await tester.pumpAndSettle(); + controls.hide(); + await tester.pumpAndSettle(); + expect(controls.visible, isFalse); + + await gesture.moveTo(const Offset(100, 100)); + await tester.pumpAndSettle(); + expect(controls.visible, isTrue); + + await gesture.up(); + }); + + testWidgets('a pointer down brings hidden controls back', (tester) async { + late ControlsState controls; + await pumpWrapper(tester, autoHide: true, capture: (c) => controls = c); + + await tester.pump(const Duration(seconds: 5)); + await tester.pumpAndSettle(); + expect(controls.visible, isFalse); + + final gesture = await tester.startGesture(tester.getCenter(find.text('controls'))); + await tester.pumpAndSettle(); + + expect(controls.visible, isTrue); + await gesture.up(); + }); + + testWidgets('the auto-hide timer restarts every time the controls are shown', (tester) async { + late ControlsState controls; + await pumpWrapper(tester, autoHide: true, capture: (c) => controls = c); + + await tester.pump(const Duration(seconds: 4)); + await tester.tap(find.byType(ControlsWrapper)); // hide + await tester.pumpAndSettle(); + await tester.tap(find.byType(ControlsWrapper)); // show, restarting the timer + await tester.pumpAndSettle(); + + await tester.pump(const Duration(seconds: 4)); + expect(controls.visible, isTrue, reason: 'the 5s window restarted on show'); + + await tester.pump(const Duration(seconds: 2)); + await tester.pumpAndSettle(); + expect(controls.visible, isFalse); + }); + }); + + group('hide callback', () { + testWidgets('ControlsState.hide hides the controls', (tester) async { + late ControlsState controls; + await pumpWrapper(tester, autoHide: false, capture: (c) => controls = c); + + controls.hide(); + await tester.pumpAndSettle(); + + expect(controls.visible, isFalse); + }); + }); + + group('backdrop', () { + testWidgets('renders a scrim by default', (tester) async { + await pumpWrapper(tester, autoHide: false); + + expect(find.byType(ControlFadeOut), findsOneWidget); + }); + + testWidgets('pure suppresses the scrim', (tester) async { + await pumpWrapper(tester, autoHide: false, pure: true); + + expect(find.byType(ControlFadeOut), findsNothing); + }); + }); + + group('TV key handling', () { + /// A focusable button that records activations, so we can tell whether a key + /// press reached the focused control or was swallowed by the wrapper. + /// + /// Deliberately activated through the ancestor Shortcuts/Actions that + /// MaterialApp installs — the same route the real IconButtons take. A Focus + /// with its own `onKeyEvent` would sit *below* the wrapper and consume the + /// key first, since key events travel from the focused node upwards. + Widget activateProbe(List activations) => ElevatedButton( + autofocus: true, + onPressed: () => activations.add('pressed'), + child: const Text('controls'), + ); + + testWidgets('the first key press only wakes the controls, without activating', (tester) async { + // Otherwise waking a TV player would also trigger whatever button + // happened to hold focus. + final activations = []; + late ControlsState controls; + await pumpWrapper( + tester, + autoHide: true, + isTv: true, + capture: (c) => controls = c, + child: activateProbe(activations), + ); + + await tester.pump(const Duration(seconds: 5)); + await tester.pumpAndSettle(); + expect(controls.visible, isFalse); + + await _sendKey(tester, LogicalKeyboardKey.enter); + await tester.pumpAndSettle(); + + expect(controls.visible, isTrue); + expect(activations, isEmpty, reason: 'the wake-up press was consumed'); + }); + + testWidgets('a key press with the controls visible reaches the control', (tester) async { + final activations = []; + await pumpWrapper( + tester, + autoHide: false, + isTv: true, + child: activateProbe(activations), + ); + + await _sendKey(tester, LogicalKeyboardKey.enter); + await tester.pumpAndSettle(); + + expect(activations, ['pressed']); + }); + + testWidgets('the back key does not wake the controls', (tester) async { + // Back has to keep closing the player rather than being eaten as a + // wake-up press. + late ControlsState controls; + await pumpWrapper( + tester, + autoHide: true, + isTv: true, + capture: (c) => controls = c, + child: activateProbe([]), + ); + + await tester.pump(const Duration(seconds: 5)); + await tester.pumpAndSettle(); + + await _sendKey(tester, LogicalKeyboardKey.goBack); + await tester.pumpAndSettle(); + + expect(controls.visible, isFalse); + }); + + testWidgets('the wake-on-key behaviour is TV-only', (tester) async { + final activations = []; + late ControlsState controls; + await pumpWrapper( + tester, + autoHide: true, + capture: (c) => controls = c, + child: activateProbe(activations), + ); + + await tester.pump(const Duration(seconds: 5)); + await tester.pumpAndSettle(); + expect(controls.visible, isFalse); + + await _sendKey(tester, LogicalKeyboardKey.enter); + await tester.pumpAndSettle(); + + expect(controls.visible, isFalse, reason: 'no wake-on-key off TV'); + expect(activations, ['pressed'], reason: 'and the key is not swallowed either'); + }); + }); + + group('focus', () { + testWidgets('a descendant taking focus reveals the controls', (tester) async { + late ControlsState controls; + await pumpWrapper( + tester, + autoHide: false, + showByDefault: false, + capture: (c) => controls = c, + child: const Focus(autofocus: true, child: Text('controls')), + ); + await tester.pumpAndSettle(); + + expect(controls.visible, isTrue); + }); + }); + + group('ControlsState.updateShouldNotify', () { + ControlsState state({required bool visible, required Animation animation}) => ControlsState( + visible: visible, + visibilityAnimation: animation, + hide: () {}, + child: const SizedBox.shrink(), + ); + + test('notifies only when visibility or the animation identity changes', () { + const a = AlwaysStoppedAnimation(1.0); + const b = AlwaysStoppedAnimation(0.0); + + expect(state(visible: true, animation: a).updateShouldNotify(state(visible: true, animation: a)), + isFalse); + expect(state(visible: false, animation: a).updateShouldNotify(state(visible: true, animation: a)), + isTrue); + expect(state(visible: true, animation: b).updateShouldNotify(state(visible: true, animation: a)), + isTrue); + }); + }); +} + +/// Sends a key down/up through the focus tree. +/// +/// `goBack` (the Android TV back button) is awkward to simulate: the harness +/// cannot infer a physical key for it, and `browserBack` is absent from every +/// platform's scan-code map except web's. Hence the special case — it is a +/// limitation of the test key maps, not of the code under test. +Future _sendKey(WidgetTester tester, LogicalKeyboardKey key) async { + final isGoBack = key == LogicalKeyboardKey.goBack; + final physical = isGoBack ? PhysicalKeyboardKey.browserBack : null; + final platform = isGoBack ? 'web' : null; + await tester.sendKeyDownEvent(key, physicalKey: physical, platform: platform); + await tester.sendKeyUpEvent(key, physicalKey: physical, platform: platform); +} diff --git a/test/widgets/mini_player_test.dart b/test/widgets/mini_player_test.dart new file mode 100644 index 00000000..f98b5236 --- /dev/null +++ b/test/widgets/mini_player_test.dart @@ -0,0 +1,295 @@ +import 'package:bccm_player/bccm_player.dart'; +import 'package:bccm_player/controls.dart'; +import 'package:bccm_player/src/widgets/mini_player/loading_indicator.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../utils/fake_platform.dart'; +import '../utils/fixtures.dart'; + +void main() { + late FakeBccmPlayerInterface fake; + late BccmPlayerController controller; + + // The controller is built here rather than inside a testWidgets body on + // purpose: PlayerStateNotifier starts a periodic timer, and a timer created + // inside the body lands in the test's fake-async zone and trips the + // pending-timer assertion before tearDown can dispose it. + setUp(() async { + fake = FakeBccmPlayerInterface.install(); + controller = BccmPlayerController(mediaItem(id: 'a')); + await controller.initialize(); + }); + + tearDown(() => fake.restore()); + + Future pumpMiniPlayer(WidgetTester tester, MiniPlayer miniPlayer) { + return tester.pumpWidget(MaterialApp( + home: Scaffold(body: miniPlayer), + )); + } + + group('artwork', () { + testWidgets('shows a thumbnail for a non-empty uri', (tester) async { + await pumpMiniPlayer( + tester, + const MiniPlayer( + secondaryTitle: 'Show', + title: 'Episode', + isPlaying: false, + artworkUri: 'https://example.test/a.jpg', + ), + ); + + expect(find.byType(FadeInImage), findsOneWidget); + }); + + testWidgets('omits the thumbnail for an empty uri', (tester) async { + // The apps pass '' rather than null when an episode has no image. + await pumpMiniPlayer( + tester, + const MiniPlayer( + secondaryTitle: 'Show', + title: 'Episode', + isPlaying: false, + artworkUri: '', + ), + ); + + expect(find.byType(FadeInImage), findsNothing); + }); + + testWidgets('requires either an artwork uri or an image provider', (tester) async { + expect( + () => MiniPlayer(secondaryTitle: null, title: 'Episode', isPlaying: false), + throwsAssertionError, + ); + }); + }); + + group('titles', () { + testWidgets('renders both titles', (tester) async { + await pumpMiniPlayer( + tester, + const MiniPlayer( + secondaryTitle: 'Show name', + title: 'Episode name', + isPlaying: false, + artworkUri: 'https://example.test/a.jpg', + ), + ); + + expect(find.text('Show name'), findsOneWidget); + expect(find.text('Episode name'), findsOneWidget); + }); + + testWidgets('omits the secondary title when null', (tester) async { + await pumpMiniPlayer( + tester, + const MiniPlayer( + secondaryTitle: null, + title: 'Episode name', + isPlaying: false, + artworkUri: 'https://example.test/a.jpg', + ), + ); + + expect(find.text('Episode name'), findsOneWidget); + expect(find.byType(Text), findsOneWidget); + }); + }); + + group('play / pause', () { + testWidgets('a paused player offers play, and tapping it calls onPlayTap', (tester) async { + var plays = 0; + var pauses = 0; + await pumpMiniPlayer( + tester, + MiniPlayer( + secondaryTitle: null, + title: 'Episode', + isPlaying: false, + artworkUri: 'https://example.test/a.jpg', + playSemanticLabel: 'Play', + pauseSemanticLabel: 'Pause', + onPlayTap: () => plays++, + onPauseTap: () => pauses++, + ), + ); + + expect(find.bySemanticsLabel('Play'), findsOneWidget); + await tester.tap(find.bySemanticsLabel('Play')); + + expect(plays, 1); + expect(pauses, 0); + }); + + testWidgets('a playing player offers pause, and tapping it calls onPauseTap', (tester) async { + var plays = 0; + var pauses = 0; + await pumpMiniPlayer( + tester, + MiniPlayer( + secondaryTitle: null, + title: 'Episode', + isPlaying: true, + artworkUri: 'https://example.test/a.jpg', + playSemanticLabel: 'Play', + pauseSemanticLabel: 'Pause', + onPlayTap: () => plays++, + onPauseTap: () => pauses++, + ), + ); + + expect(find.bySemanticsLabel('Pause'), findsOneWidget); + await tester.tap(find.bySemanticsLabel('Pause')); + + expect(pauses, 1); + expect(plays, 0); + }); + + testWidgets('loading replaces the button with an indicator', (tester) async { + await pumpMiniPlayer( + tester, + const MiniPlayer( + secondaryTitle: null, + title: 'Episode', + isPlaying: true, + loading: true, + artworkUri: 'https://example.test/a.jpg', + pauseSemanticLabel: 'Pause', + ), + ); + + expect(find.byType(LoadingIndicator), findsOneWidget); + expect(find.bySemanticsLabel('Pause'), findsNothing); + }); + + testWidgets('a custom loading indicator is used when given', (tester) async { + await pumpMiniPlayer( + tester, + const MiniPlayer( + secondaryTitle: null, + title: 'Episode', + isPlaying: true, + loading: true, + artworkUri: 'https://example.test/a.jpg', + loadingIndicator: Text('custom'), + ), + ); + + expect(find.text('custom'), findsOneWidget); + expect(find.byType(LoadingIndicator), findsNothing); + }); + }); + + group('close button', () { + testWidgets('is shown and wired by default', (tester) async { + var closes = 0; + await pumpMiniPlayer( + tester, + MiniPlayer( + secondaryTitle: null, + title: 'Episode', + isPlaying: false, + artworkUri: 'https://example.test/a.jpg', + onCloseTap: () => closes++, + ), + ); + + // Three tappables: artwork area aside, the play button and the close + // button are the GestureDetectors; close is the last one. + final closeButton = find.byType(GestureDetector).last; + await tester.tap(closeButton); + + expect(closes, 1); + }); + + testWidgets('is omitted when hidden', (tester) async { + await pumpMiniPlayer( + tester, + const MiniPlayer( + secondaryTitle: null, + title: 'Episode', + isPlaying: false, + artworkUri: 'https://example.test/a.jpg', + hideCloseButton: true, + ), + ); + + expect(find.byType(GestureDetector), findsOneWidget, reason: 'only the play/pause tappable'); + }); + }); + + group('progress bar', () { + /// The filled part of the 2px bar, if it is drawn at all. + Finder barFinder() => find.descendant( + of: find.byType(SmoothVideoProgress), + matching: find.byType(Container), + ); + + testWidgets('draws nothing when the media has no duration', (tester) async { + // Guards a division by zero: without the duration check the width would + // be NaN. + await pumpMiniPlayer( + tester, + MiniPlayer( + secondaryTitle: null, + title: 'Episode', + isPlaying: true, + artworkUri: 'https://example.test/a.jpg', + playerController: controller, + ), + ); + + expect(find.byType(SmoothVideoProgress), findsOneWidget); + expect(barFinder(), findsNothing); + expect(tester.takeException(), isNull); + }); + + testWidgets('fills the fraction of the width matching the position', (tester) async { + final notifier = fake.stateNotifier.getPlayerNotifier(controller.value.playerId)!; + notifier.setStateFromSnapshot(snapshot( + playerId: controller.value.playerId, + playbackState: PlaybackState.paused, + currentMediaItem: mediaItem(durationMs: 100000), + playbackPositionMs: 25000, + )); + + await pumpMiniPlayer( + tester, + MiniPlayer( + secondaryTitle: null, + title: 'Episode', + isPlaying: false, + artworkUri: 'https://example.test/a.jpg', + playerController: controller, + ), + ); + await tester.pump(); + + final screenWidth = tester.view.physicalSize.width / tester.view.devicePixelRatio; + final size = tester.getSize(barFinder()); + + expect(size.width, closeTo(screenWidth * 0.25, 1)); + expect(size.height, 2); + }); + + testWidgets('falls back to the primary controller when none is given', (tester) async { + // The apps rely on this: MiniPlayer with no controller tracks whatever is + // playing. + await pumpMiniPlayer( + tester, + const MiniPlayer( + secondaryTitle: null, + title: 'Episode', + isPlaying: false, + artworkUri: 'https://example.test/a.jpg', + ), + ); + + expect(tester.takeException(), isNull); + expect(find.byType(SmoothVideoProgress), findsOneWidget); + }); + }); +} diff --git a/test/widgets/play_next_button_test.dart b/test/widgets/play_next_button_test.dart new file mode 100644 index 00000000..c5af6a71 --- /dev/null +++ b/test/widgets/play_next_button_test.dart @@ -0,0 +1,153 @@ +import 'package:bccm_player/bccm_player.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../utils/fake_platform.dart'; +import '../utils/fixtures.dart'; + +void main() { + late FakeBccmPlayerInterface fake; + late BccmPlayerController controller; + late PlayerStateNotifier notifier; + + setUp(() async { + fake = FakeBccmPlayerInterface.install(); + controller = BccmPlayerController(mediaItem(id: 'a')); + await controller.initialize(); + notifier = fake.stateNotifier.getPlayerNotifier(controller.value.playerId)!; + }); + + tearDown(() => fake.restore()); + + void setState({ + required PlaybackState playbackState, + double? durationMs, + double? positionMs, + }) { + notifier.setStateFromSnapshot(snapshot( + playerId: controller.value.playerId, + playbackState: playbackState, + currentMediaItem: durationMs == null ? null : mediaItem(durationMs: durationMs), + playbackPositionMs: positionMs, + )); + } + + Future pumpButton( + WidgetTester tester, { + VoidCallback? onTap, + String? text, + Duration appearAtTimeLeft = const Duration(seconds: 10), + }) async { + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: PlayNextButton( + playerController: controller, + onTap: onTap, + text: text, + appearAtTimeLeft: appearAtTimeLeft, + ), + ), + )); + await tester.pumpAndSettle(); + } + + group('visibility', () { + testWidgets('appears once the time left drops below the threshold', (tester) async { + setState(playbackState: PlaybackState.playing, durationMs: 100000, positionMs: 95000); + + await pumpButton(tester); + + expect(find.text('Next Video'), findsOneWidget); + }); + + testWidgets('stays hidden while there is plenty left', (tester) async { + setState(playbackState: PlaybackState.playing, durationMs: 100000, positionMs: 50000); + + await pumpButton(tester); + + expect(find.text('Next Video'), findsNothing); + }); + + testWidgets('never appears for media shorter than the threshold', (tester) async { + // A five second clip would otherwise show the button for its whole + // duration. + setState(playbackState: PlaybackState.playing, durationMs: 5000, positionMs: 0); + + await pumpButton(tester); + + expect(find.text('Next Video'), findsNothing); + }); + + testWidgets('stays hidden while the duration is unknown', (tester) async { + setState(playbackState: PlaybackState.playing, positionMs: 1000); + + await pumpButton(tester); + + expect(find.text('Next Video'), findsNothing); + }); + + testWidgets('appears when playback crosses the threshold after mounting', (tester) async { + setState(playbackState: PlaybackState.playing, durationMs: 100000, positionMs: 50000); + await pumpButton(tester); + expect(find.text('Next Video'), findsNothing); + + setState(playbackState: PlaybackState.playing, durationMs: 100000, positionMs: 92000); + await tester.pumpAndSettle(); + + expect(find.text('Next Video'), findsOneWidget); + }); + + testWidgets('respects a custom threshold', (tester) async { + setState(playbackState: PlaybackState.playing, durationMs: 100000, positionMs: 75000); + + await pumpButton(tester, appearAtTimeLeft: const Duration(seconds: 30)); + + expect(find.text('Next Video'), findsOneWidget); + }); + }); + + group('content', () { + testWidgets('uses custom label text', (tester) async { + setState(playbackState: PlaybackState.playing, durationMs: 100000, positionMs: 95000); + + await pumpButton(tester, text: 'Neste episode'); + + expect(find.text('Neste episode'), findsOneWidget); + expect(find.text('Next Video'), findsNothing); + }); + + testWidgets('shows a spinner instead of the play icon at the very end', (tester) async { + // timeLeft == 0 while still playing means the next item is being loaded. + setState(playbackState: PlaybackState.playing, durationMs: 100000, positionMs: 100000); + + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: PlayNextButton(playerController: controller, onTap: () {}), + ), + )); + await tester.pump(); + + expect(find.byType(CircularProgressIndicator), findsOneWidget); + }); + + testWidgets('shows the play icon, not a spinner, before the end', (tester) async { + setState(playbackState: PlaybackState.playing, durationMs: 100000, positionMs: 95000); + + await pumpButton(tester); + + expect(find.byType(CircularProgressIndicator), findsNothing); + }); + }); + + group('interaction', () { + testWidgets('tapping invokes onTap', (tester) async { + var taps = 0; + setState(playbackState: PlaybackState.playing, durationMs: 100000, positionMs: 95000); + + await pumpButton(tester, onTap: () => taps++); + await tester.tap(find.text('Next Video')); + + expect(taps, 1); + }); + }); +} diff --git a/test/widgets/smooth_video_progress_test.dart b/test/widgets/smooth_video_progress_test.dart new file mode 100644 index 00000000..089f595a --- /dev/null +++ b/test/widgets/smooth_video_progress_test.dart @@ -0,0 +1,159 @@ +import 'package:bccm_player/bccm_player.dart'; +import 'package:bccm_player/controls.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../utils/fake_platform.dart'; +import '../utils/fixtures.dart'; + +void main() { + late FakeBccmPlayerInterface fake; + late BccmPlayerController controller; + late PlayerStateNotifier notifier; + + // Built outside the testWidgets body so the notifier's periodic timer does + // not land in the test's fake-async zone. + setUp(() async { + fake = FakeBccmPlayerInterface.install(); + controller = BccmPlayerController(mediaItem(id: 'a')); + await controller.initialize(); + notifier = fake.stateNotifier.getPlayerNotifier(controller.value.playerId)!; + }); + + tearDown(() => fake.restore()); + + /// Mounts the widget and records every (progress, duration) it builds with. + Future> pumpProgress(WidgetTester tester) async { + final progresses = []; + await tester.pumpWidget(MaterialApp( + home: SmoothVideoProgress( + controller: controller, + builder: (context, progress, duration, child) { + progresses.add(progress); + return const SizedBox.shrink(); + }, + ), + )); + return progresses; + } + + void setState({ + required PlaybackState playbackState, + double? durationMs, + double? positionMs, + }) { + notifier.setStateFromSnapshot(snapshot( + playerId: controller.value.playerId, + playbackState: playbackState, + currentMediaItem: durationMs == null ? null : mediaItem(durationMs: durationMs), + playbackPositionMs: positionMs, + )); + } + + testWidgets('reports the position it was given while paused', (tester) async { + setState(playbackState: PlaybackState.paused, durationMs: 100000, positionMs: 25000); + + final progresses = await pumpProgress(tester); + + expect(progresses.last, const Duration(milliseconds: 25000)); + }); + + testWidgets('interpolates forward between position updates while playing', (tester) async { + setState(playbackState: PlaybackState.playing, durationMs: 100000, positionMs: 10000); + final progresses = await pumpProgress(tester); + + // Nudge the position so the widget starts animating from it, then let time + // pass without any further update from the player. + setState(playbackState: PlaybackState.playing, durationMs: 100000, positionMs: 11000); + await tester.pump(); + final atStart = progresses.last; + + await tester.pump(const Duration(seconds: 2)); + + expect(progresses.last, greaterThan(atStart), + reason: 'progress advances on its own between player updates'); + }); + + testWidgets('stops advancing once paused', (tester) async { + setState(playbackState: PlaybackState.playing, durationMs: 100000, positionMs: 10000); + final progresses = await pumpProgress(tester); + setState(playbackState: PlaybackState.playing, durationMs: 100000, positionMs: 11000); + await tester.pump(); + + setState(playbackState: PlaybackState.paused, durationMs: 100000, positionMs: 11000); + await tester.pump(); + final atPause = progresses.last; + + await tester.pump(const Duration(seconds: 2)); + + expect(progresses.last, atPause); + }); + + testWidgets('a zero duration does not produce a NaN progress', (tester) async { + // targetRelativePosition divides by duration, so this is the guard that + // keeps NaN out of the animation controller. + setState(playbackState: PlaybackState.playing, positionMs: 0); + + final progresses = await pumpProgress(tester); + await tester.pump(const Duration(seconds: 1)); + + expect(progresses, isNotEmpty); + expect(tester.takeException(), isNull); + }); + + testWidgets('survives a position update arriving before the duration is known', (tester) async { + // The normal startup order: the player reports a position while + // metadata.durationMs is still null, so position/duration is Infinity and + // reaches AnimationController.forward(from:). + setState(playbackState: PlaybackState.playing, positionMs: 0); + await pumpProgress(tester); + + setState(playbackState: PlaybackState.playing, positionMs: 1000); + await tester.pump(); + + expect(tester.takeException(), isNull); + }); + + testWidgets('retains the last non-zero progress rather than snapping to zero', (tester) async { + // Between a media-item change and the first position report the animation + // reads zero; showing that would make the bar jump backwards. + setState(playbackState: PlaybackState.paused, durationMs: 100000, positionMs: 40000); + final progresses = await pumpProgress(tester); + expect(progresses.last, const Duration(milliseconds: 40000)); + + setState(playbackState: PlaybackState.paused, durationMs: 100000, positionMs: 0); + await tester.pump(); + + expect(progresses.last, const Duration(milliseconds: 40000), + reason: 'a zero progress is replaced by the last non-zero one'); + }); + + testWidgets('passes the child through to the builder', (tester) async { + setState(playbackState: PlaybackState.paused, durationMs: 100000, positionMs: 0); + await tester.pumpWidget(MaterialApp( + home: SmoothVideoProgress( + controller: controller, + child: const Text('child'), + builder: (context, progress, duration, child) => child!, + ), + )); + + expect(find.text('child'), findsOneWidget); + }); + + testWidgets('reports the media duration alongside the progress', (tester) async { + setState(playbackState: PlaybackState.paused, durationMs: 100000, positionMs: 0); + final durations = []; + await tester.pumpWidget(MaterialApp( + home: SmoothVideoProgress( + controller: controller, + builder: (context, progress, duration, child) { + durations.add(duration); + return const SizedBox.shrink(); + }, + ), + )); + + expect(durations.last, const Duration(milliseconds: 100000)); + }); +} diff --git a/test/widgets/state_builder_test.dart b/test/widgets/state_builder_test.dart new file mode 100644 index 00000000..c5f50ccc --- /dev/null +++ b/test/widgets/state_builder_test.dart @@ -0,0 +1,259 @@ +import 'package:bccm_player/bccm_player.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../utils/fake_platform.dart'; +import '../utils/fixtures.dart'; + +void main() { + late FakeBccmPlayerInterface fake; + late PlayerStateNotifier p1; + late PlayerStateNotifier p2; + late PlayerStateNotifier local; + late PlayerStateNotifier cast; + + // Players are created here, not inside the test bodies: PlayerStateNotifier + // starts a periodic timer, and one created inside a testWidgets body lands in + // that test's fake-async zone and trips the pending-timer assertion before + // tearDown can dispose it. + setUp(() { + fake = FakeBccmPlayerInterface.install(); + p1 = fake.stateNotifier.getOrAddPlayerNotifier('p1'); + p2 = fake.stateNotifier.getOrAddPlayerNotifier('p2'); + local = fake.stateNotifier.getOrAddPlayerNotifier('local'); + cast = fake.stateNotifier.getOrAddPlayerNotifier('chromecast'); + }); + + tearDown(() => fake.restore()); + + group('BccmPlayerStateBuilder', () { + testWidgets('builds with the selected value for an explicit player', (tester) async { + final notifier = p1; + notifier.setPlaybackState(PlaybackState.playing); + + await tester.pumpWidget(MaterialApp( + home: BccmPlayerStateBuilder( + playerId: 'p1', + select: (state) => state.playbackState, + builder: (context, state) => Text('$state'), + ), + )); + + expect(find.text('PlaybackState.playing'), findsOneWidget); + }); + + testWidgets('rebuilds when the selected value changes', (tester) async { + final notifier = p1; + notifier.setPlaybackState(PlaybackState.paused); + + await tester.pumpWidget(MaterialApp( + home: BccmPlayerStateBuilder( + playerId: 'p1', + select: (state) => state.playbackState, + builder: (context, state) => Text('$state'), + ), + )); + expect(find.text('PlaybackState.paused'), findsOneWidget); + + notifier.setPlaybackState(PlaybackState.playing); + await tester.pump(); + + expect(find.text('PlaybackState.playing'), findsOneWidget); + }); + + testWidgets('does not rebuild for state changes outside the selection', (tester) async { + final notifier = p1; + notifier.setPlaybackState(PlaybackState.playing); + var builds = 0; + + await tester.pumpWidget(MaterialApp( + home: BccmPlayerStateBuilder( + playerId: 'p1', + select: (state) => state.playbackState, + builder: (context, state) { + builds++; + return const SizedBox.shrink(); + }, + ), + )); + final initial = builds; + + notifier.setPlaybackPosition(9999); + await tester.pump(); + + expect(builds, initial, reason: 'position is not part of the selection'); + }); + + testWidgets('hands the builder null for an unknown player', (tester) async { + await tester.pumpWidget(MaterialApp( + home: BccmPlayerStateBuilder( + playerId: 'does-not-exist', + select: (state) => state.playbackState, + builder: (context, state) => Text(state == null ? 'no player' : '$state'), + ), + )); + + expect(find.text('no player'), findsOneWidget); + }); + + testWidgets('a null playerId follows the primary player', (tester) async { + final primary = p1; + primary.setPlaybackState(PlaybackState.playing); + fake.stateNotifier.setPrimaryPlayer('p1'); + + await tester.pumpWidget(MaterialApp( + home: BccmPlayerStateBuilder( + playerId: null, + select: (state) => state.playbackState, + builder: (context, state) => Text('$state'), + ), + )); + + expect(find.text('PlaybackState.playing'), findsOneWidget); + }); + + testWidgets('a null playerId picks up a change of primary player', (tester) async { + // The cast-handover case: the same widget has to start reading the other + // player without being rebuilt by its parent. + local.setPlaybackState(PlaybackState.playing); + cast.setPlaybackState(PlaybackState.paused); + fake.stateNotifier.setPrimaryPlayer('local'); + + await tester.pumpWidget(MaterialApp( + home: BccmPlayerStateBuilder( + playerId: null, + select: (state) => state.playbackState, + builder: (context, state) => Text('$state'), + ), + )); + expect(find.text('PlaybackState.playing'), findsOneWidget); + + fake.stateNotifier.setPrimaryPlayer('chromecast'); + await tester.pump(); + + expect(find.text('PlaybackState.paused'), findsOneWidget); + }); + + testWidgets('builds null when there is no primary player at all', (tester) async { + await tester.pumpWidget(MaterialApp( + home: BccmPlayerStateBuilder( + playerId: null, + select: (state) => state.playbackState, + builder: (context, state) => Text(state == null ? 'no player' : '$state'), + ), + )); + + expect(find.text('no player'), findsOneWidget); + }); + }); + + group('StateNotifierSelectBuilder', () { + testWidgets('switches to a new notifier when the widget is updated', (tester) async { + final first = p1; + first.setPlaybackState(PlaybackState.playing); + final second = p2; + second.setPlaybackState(PlaybackState.paused); + + Widget build(PlayerStateNotifier notifier) => MaterialApp( + home: StateNotifierSelectBuilder( + stateNotifier: notifier, + select: (state) => state.playbackState, + builder: (context, state, child) => Text('$state'), + ), + ); + + await tester.pumpWidget(build(first)); + expect(find.text('PlaybackState.playing'), findsOneWidget); + + await tester.pumpWidget(build(second)); + expect(find.text('PlaybackState.paused'), findsOneWidget); + }); + + testWidgets('stops listening to the notifier it was moved off', (tester) async { + final first = p1; + first.setPlaybackState(PlaybackState.playing); + final second = p2; + second.setPlaybackState(PlaybackState.paused); + + Widget build(PlayerStateNotifier notifier) => MaterialApp( + home: StateNotifierSelectBuilder( + stateNotifier: notifier, + select: (state) => state.playbackState, + builder: (context, state, child) => Text('$state'), + ), + ); + + await tester.pumpWidget(build(first)); + await tester.pumpWidget(build(second)); + + first.setPlaybackState(PlaybackState.stopped); + await tester.pump(); + + expect(find.text('PlaybackState.paused'), findsOneWidget, + reason: 'the old notifier must no longer drive this widget'); + }); + + testWidgets('passes the child straight through', (tester) async { + final notifier = p1; + + await tester.pumpWidget(MaterialApp( + home: StateNotifierSelectBuilder( + stateNotifier: notifier, + select: (state) => state.playbackState, + child: const Text('child'), + builder: (context, state, child) => child!, + ), + )); + + expect(find.text('child'), findsOneWidget); + }); + + testWidgets('removes its listener on dispose', (tester) async { + final notifier = p1; + + await tester.pumpWidget(MaterialApp( + home: StateNotifierSelectBuilder( + stateNotifier: notifier, + select: (state) => state.playbackState, + builder: (context, state, child) => const SizedBox.shrink(), + ), + )); + await tester.pumpWidget(const MaterialApp(home: SizedBox.shrink())); + + // A setState on a disposed State would throw here. + notifier.setPlaybackState(PlaybackState.stopped); + await tester.pump(); + + expect(tester.takeException(), isNull); + }); + + testWidgets('compares selections by identity, so equal strings still rebuild', (tester) async { + // `_listener` uses `!identical(temp, state)` rather than `!=`. For enums, + // bools and small ints that behaves like equality, but a select that + // builds a String rebuilds on every notification even when the value has + // not changed. Pinned rather than changed: it is a rebuild-frequency + // wrinkle, not a correctness bug. + final notifier = p1; + notifier.setMediaItem(mediaItem(id: 'a', title: 'Same')); + var builds = 0; + + await tester.pumpWidget(MaterialApp( + home: StateNotifierSelectBuilder( + stateNotifier: notifier, + // A fresh String instance each time, equal but never identical. + select: (state) => 'title: ${state.currentMediaItem?.metadata?.title}', + builder: (context, state, child) { + builds++; + return const SizedBox.shrink(); + }, + ), + )); + final initial = builds; + + notifier.setMediaItem(mediaItem(id: 'a', title: 'Same')); + await tester.pump(); + + expect(builds, greaterThan(initial)); + }); + }); +}