diff --git a/doc/contributing/todo.md b/doc/contributing/todo.md index e83684b..08c2b5a 100644 --- a/doc/contributing/todo.md +++ b/doc/contributing/todo.md @@ -29,7 +29,6 @@ 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/lib/examples/queue.dart b/example/lib/examples/queue.dart index 3fc4f49..87a18b5 100644 --- a/example/lib/examples/queue.dart +++ b/example/lib/examples/queue.dart @@ -132,7 +132,7 @@ class QueueExample extends HookWidget { ...exampleVideos.map( (MediaItem mediaItem) => ElevatedButton( onPressed: () { - controller.queue.addQueueItem(mediaItem); + controller.queue.addLast(mediaItem); }, child: Text('${mediaItem.metadata?.title}'), ), diff --git a/lib/src/queue/default_queue_controller.dart b/lib/src/queue/default_queue_controller.dart index 0b27663..1485114 100644 --- a/lib/src/queue/default_queue_controller.dart +++ b/lib/src/queue/default_queue_controller.dart @@ -5,13 +5,27 @@ import 'package:flutter/foundation.dart'; import 'package:uuid/uuid.dart'; class DefaultQueueManager implements QueueManager { + DefaultQueueManager() { + _queue.itemsNotifier.addListener(_recomputeEntries); + _nextUp.itemsNotifier.addListener(_recomputeEntries); + } + PlayerStateNotifier? _playerNotifier; final QueueList _queue = QueueList(); final QueueList _history = QueueList(); final ShuffleQueueList _nextUp = ShuffleQueueList(); + final ValueNotifier> _entries = ValueNotifier(const []); void Function()? stopPlayerListener; + /// How far into an item [skipToPrevious] stops going back and restarts + /// instead. Three seconds is the usual convention. + Duration restartThreshold = const Duration(seconds: 3); + + /// Upper bound on [queue], so a runaway caller cannot grow it without limit. + /// Additions past this are dropped rather than throwing. + int maxQueueLength = 1000; + @override ValueNotifier get shuffleEnabled => _nextUp.shuffleNotifier; @override @@ -20,12 +34,17 @@ class DefaultQueueManager implements QueueManager { ValueNotifier> get queue => _queue.itemsNotifier; @override ValueNotifier> get nextUp => _nextUp.itemsNotifier; + @override + ValueNotifier> get entries => _entries; @override void dispose() { + _queue.itemsNotifier.removeListener(_recomputeEntries); + _nextUp.itemsNotifier.removeListener(_recomputeEntries); _queue.dispose(); _history.dispose(); _nextUp.dispose(); + _entries.dispose(); } @override @@ -40,6 +59,21 @@ class DefaultQueueManager implements QueueManager { if (currentId != null) { _removeIfUpcoming(currentId); } + _recomputeEntries(); + } + + void _recomputeEntries() { + final current = _playerNotifier?.getState().currentMediaItem; + _entries.value = [ + if (current != null) QueueEntry(mediaItem: current, kind: QueueEntryKind.current), + for (final item in _queue.items) QueueEntry(mediaItem: item, kind: QueueEntryKind.queue), + for (final item in _nextUp.items) QueueEntry(mediaItem: item, kind: QueueEntryKind.nextUp), + ]; + } + + /// Seeks the current item back to the start. + Future _restartCurrent(PlayerState state) { + return BccmPlayerInterface.instance.seekTo(state.playerId, 0); } void _removeIfUpcoming(String id) { @@ -67,16 +101,34 @@ class DefaultQueueManager implements QueueManager { Future skipToPrevious() async { final player = _playerNotifier; if (player == null) return; - final current = player.getState().currentMediaItem; + final state = player.getState(); + + // Past the threshold, "previous" means "start this one again" — pressing it + // mid-track to jump backwards is almost never what was meant. + if ((state.playbackPositionMs ?? 0) > restartThreshold.inMilliseconds) { + return _restartCurrent(state); + } + + final current = state.currentMediaItem; final previous = _history.consumeNext(); - if (previous != null && current != null) { - if (queue.value.isNotEmpty) { + if (previous == null) { + // Nothing behind us: restart rather than doing nothing at all, so the + // button is never inert. + if (current != null) return _restartCurrent(state); + return; + } + + if (current != null) { + // Put the outgoing item back where it will play next. It belongs at the + // front of `queue` if anything is queued, otherwise at the front of the + // automatic continuation. + if (_queue.items.isNotEmpty) { _queue.addToStart(current); } else { _nextUp.addToStart(current); } - await _playMediaItem(previous); } + await _playMediaItem(previous); } @override @@ -116,11 +168,41 @@ class DefaultQueueManager implements QueueManager { return copy; } + /// Room left before [maxQueueLength] is reached. + int get _room => maxQueueLength - _queue.items.length; + @override - Future addQueueItem(MediaItem mediaItem) async { + Future addLast(MediaItem mediaItem) async { + if (_room <= 0) { + debugPrint('bccm: queue is at maxQueueLength ($maxQueueLength), dropping addLast'); + return; + } _queue.add(_withId(mediaItem)); } + @override + Future addNext(MediaItem mediaItem) async { + if (_room <= 0) { + debugPrint('bccm: queue is at maxQueueLength ($maxQueueLength), dropping addNext'); + return; + } + _queue.addToStart(_withId(mediaItem)); + } + + @override + Future insertAll(List mediaItems) async { + if (_room <= 0) return; + if (mediaItems.length > _room) { + debugPrint('bccm: queue is near maxQueueLength ($maxQueueLength), inserting only $_room of ${mediaItems.length}'); + } + // One notification for the whole batch rather than one per item. + _queue.addAll(mediaItems.take(_room).map(_withId).toList()); + } + + @Deprecated('Renamed to addLast, to pair with addNext. Will be removed in a future release.') + @override + Future addQueueItem(MediaItem mediaItem) => addLast(mediaItem); + @override Future removeQueueItem(String id) async { _queue.remove(id); @@ -136,6 +218,30 @@ class DefaultQueueManager implements QueueManager { _queue.clear(); } + @override + Future playItem(String id) async { + final player = _playerNotifier; + if (player == null) return; + // Only the chosen item is consumed — everything else stays queued, which is + // what "tap row 5" should do. + final target = _queue.consumeSpecific(id) ?? _nextUp.consumeSpecific(id); + if (target == null) return; + final current = player.getState().currentMediaItem; + if (current != null) _history.addToStart(current); + await _playMediaItem(target); + } + + @override + Future playAt(int index) async { + final list = _entries.value; + if (index < 0 || index >= list.length) return; + final entry = list[index]; + if (entry.isCurrent) return; + final id = entry.mediaItem.id; + if (id == null) return; + await playItem(id); + } + Future _playMediaItem(MediaItem mediaItem) async { final player = _playerNotifier; if (player == null) return; @@ -161,6 +267,11 @@ class QueueList { itemsNotifier.value = [...itemsNotifier.value, item]; } + void addAll(List items) { + if (items.isEmpty) return; + itemsNotifier.value = [...itemsNotifier.value, ...items]; + } + void addToStart(MediaItem item) { itemsNotifier.value = [item, ...itemsNotifier.value]; } diff --git a/lib/src/queue/queue_controller.dart b/lib/src/queue/queue_controller.dart index 68d0a94..60fc9ac 100644 --- a/lib/src/queue/queue_controller.dart +++ b/lib/src/queue/queue_controller.dart @@ -2,23 +2,100 @@ import 'package:bccm_player/bccm_player.dart'; import 'package:flutter/material.dart'; import 'package:meta/meta.dart'; +/// Which list an entry in [QueueManager.entries] came from. +enum QueueEntryKind { + /// The item the player is on right now. + current, + + /// Explicitly queued by the user; played before [nextUp]. + queue, + + /// The automatic continuation — the rest of the album, show, playlist. + nextUp, +} + +/// One row of the combined [QueueManager.entries] view. +class QueueEntry { + const QueueEntry({required this.mediaItem, required this.kind}); + + final MediaItem mediaItem; + final QueueEntryKind kind; + + bool get isCurrent => kind == QueueEntryKind.current; +} + +/// The queue behind a single player. +/// +/// Ordering is: the current item, then everything in [queue] (explicitly queued +/// by the user), then everything in [nextUp] (the automatic continuation). +/// [entries] presents all three as one list, which is what a queue UI wants. +/// +/// Not exported from the package barrel — reachable through +/// [BccmPlayerController.queue]. Implementations other than +/// [DefaultQueueManager] are not a supported extension point, which is why +/// methods can be added here without a major version bump. abstract class QueueManager { void dispose(); + Future skipToNext(); + + /// Goes back, or restarts the current item. + /// + /// Restarts when playback is further than a short threshold into the current + /// item, matching what every other media player does; only goes back to the + /// previous item when pressed near the start. Future skipToPrevious(); + Future handlePlaybackEnded(MediaItem? current); + Future setShuffleEnabled(bool enabled); + + /// Replaces [nextUp] wholesale. Future setNextUp(List mediaItems); + + /// Appends to the end of [queue]. + Future addLast(MediaItem mediaItem); + + /// Inserts at the front of [queue], so it plays immediately after the + /// current item. + Future addNext(MediaItem mediaItem); + + /// Appends several items to the end of [queue] in one go. + Future insertAll(List mediaItems); + + @Deprecated('Renamed to addLast, to pair with addNext. Will be removed in a future release.') Future addQueueItem(MediaItem mediaItem); + Future removeQueueItem(String id); + Future moveQueueItem(int fromIndex, int toIndex); + Future clearQueue(); + /// Jumps straight to an upcoming item, leaving the rest of the queue in place. + /// + /// Does nothing if [id] is not upcoming. + Future playItem(String id); + + /// Plays the entry at [index] of [entries]. Out-of-range indices and the + /// current entry are ignored. + Future playAt(int index); + @internal void setPlayer(PlayerStateNotifier playerStateNotifier) {} ValueNotifier get shuffleEnabled; + + /// Most recently played first. ValueNotifier> get history; + ValueNotifier> get queue; + ValueNotifier> get nextUp; + + /// The current item followed by everything upcoming, as one list. + /// + /// Saves every consumer assembling "current + upcoming, current highlighted" + /// for itself. + ValueNotifier> get entries; } diff --git a/lib/src/state/plugin_state_notifier.dart b/lib/src/state/plugin_state_notifier.dart index 5a1886b..e1b7c26 100644 --- a/lib/src/state/plugin_state_notifier.dart +++ b/lib/src/state/plugin_state_notifier.dart @@ -1,4 +1,3 @@ -import 'package:flutter/material.dart'; import 'player_state_notifier.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; @@ -47,7 +46,6 @@ class PlayerPluginStateNotifier extends StateNotifier { } void _removePlayer(String playerId) { - debugPrint('removing playerId: $playerId'); final player = state.players[playerId]; if (player != null) { state = state.copyWith(players: {...state.players}..remove(playerId)); diff --git a/test/queue/queue_manager_test.dart b/test/queue/queue_manager_test.dart index 1d0da14..afae20e 100644 --- a/test/queue/queue_manager_test.dart +++ b/test/queue/queue_manager_test.dart @@ -1,4 +1,5 @@ import 'package:bccm_player/bccm_player.dart'; +import 'package:bccm_player/src/queue/default_queue_controller.dart'; import 'package:bccm_player/src/queue/queue_controller.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -31,7 +32,7 @@ void main() { group('skipToNext', () { test('drains queue before nextUp', () async { - await queue.addQueueItem(mediaItem(id: 'q1')); + await queue.addLast(mediaItem(id: 'q1')); await queue.setNextUp([mediaItem(id: 'n1')]); await queue.skipToNext(); @@ -46,7 +47,7 @@ void main() { test('pushes the outgoing item onto history', () async { player.setMediaItem(mediaItem(id: 'current')); - await queue.addQueueItem(mediaItem(id: 'q1')); + await queue.addLast(mediaItem(id: 'q1')); await queue.skipToNext(); @@ -63,7 +64,7 @@ void main() { }); test('plays with autoplay and without inheriting the primary position', () async { - await queue.addQueueItem(mediaItem(id: 'q1')); + await queue.addLast(mediaItem(id: 'q1')); await queue.skipToNext(); @@ -77,10 +78,10 @@ void main() { 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.addLast(mediaItem(id: 'q1')); await queue.skipToNext(); // a -> history, q1 plays player.setMediaItem(mediaItem(id: 'q1')); - await queue.addQueueItem(mediaItem(id: 'q2')); + await queue.addLast(mediaItem(id: 'q2')); await queue.skipToPrevious(); @@ -102,18 +103,73 @@ void main() { expect(idsOf(queue.queue.value), isEmpty); }); - test('no-ops with empty history', () async { + test('restarts rather than doing nothing when history is empty', () async { + // The button should never be inert: with nothing behind us, "previous" + // means "start this one again". player.setMediaItem(mediaItem(id: 'current')); await queue.skipToPrevious(); expect(fake.replaceCurrentMediaItemCalls, isEmpty); + expect(fake.seekToCalls.single.positionMs, 0); + }); + + test('does nothing at all when there is no current item and no history', () async { + await queue.skipToPrevious(); + + expect(fake.replaceCurrentMediaItemCalls, isEmpty); + expect(fake.seekToCalls, isEmpty); + }); + + test('restarts the current item when past the threshold', () async { + // Regression: it used to always jump back, so pressing previous halfway + // through a track lost your place in it. + player.setMediaItem(mediaItem(id: 'a')); + await queue.addLast(mediaItem(id: 'q1')); + await queue.skipToNext(); // a -> history + player.setMediaItem(mediaItem(id: 'q1')); + player.setPlaybackPosition(30000); + fake.replaceCurrentMediaItemCalls.clear(); + + await queue.skipToPrevious(); + + expect(fake.seekToCalls.single.positionMs, 0); + expect(fake.replaceCurrentMediaItemCalls, isEmpty, reason: 'stays on the current item'); + expect(idsOf(queue.history.value), ['a'], reason: 'history is untouched'); + }); + + test('goes back when pressed near the start', () async { + player.setMediaItem(mediaItem(id: 'a')); + await queue.addLast(mediaItem(id: 'q1')); + await queue.skipToNext(); + player.setMediaItem(mediaItem(id: 'q1')); + player.setPlaybackPosition(1500); + fake.replaceCurrentMediaItemCalls.clear(); + + await queue.skipToPrevious(); + + expect(lastPlayed()?.id, 'a'); + expect(fake.seekToCalls, isEmpty); + }); + + test('the restart threshold is adjustable', () async { + (queue as DefaultQueueManager).restartThreshold = Duration.zero; + player.setMediaItem(mediaItem(id: 'a')); + await queue.addLast(mediaItem(id: 'q1')); + await queue.skipToNext(); + player.setMediaItem(mediaItem(id: 'q1')); + player.setPlaybackPosition(1); + fake.replaceCurrentMediaItemCalls.clear(); + + await queue.skipToPrevious(); + + expect(fake.seekToCalls.single.positionMs, 0, reason: 'a zero threshold always restarts'); }); }); group('handlePlaybackEnded', () { test('advances to the next item', () async { - await queue.addQueueItem(mediaItem(id: 'q1')); + await queue.addLast(mediaItem(id: 'q1')); await queue.handlePlaybackEnded(mediaItem(id: 'ended')); @@ -126,7 +182,7 @@ void main() { // though skipping forward manually did record history. final ended = mediaItem(id: 'ended'); player.setMediaItem(ended); - await queue.addQueueItem(mediaItem(id: 'q1')); + await queue.addLast(mediaItem(id: 'q1')); await queue.handlePlaybackEnded(ended); @@ -135,7 +191,7 @@ void main() { 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.addLast(mediaItem(id: 'q1')); await queue.handlePlaybackEnded(null); @@ -154,15 +210,15 @@ void main() { }); group('id backfill', () { - test('addQueueItem assigns an id when the caller supplies none', () async { - await queue.addQueueItem(mediaItem(url: 'https://example.test/x.m3u8')); + test('addLast assigns an id when the caller supplies none', () async { + await queue.addLast(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')); + test('addLast preserves a caller-supplied id', () async { + await queue.addLast(mediaItem(id: 'mine')); expect(queue.queue.value.single.id, 'mine'); }); @@ -189,8 +245,8 @@ void main() { group('queue mutation', () { test('removeQueueItem removes by id', () async { - await queue.addQueueItem(mediaItem(id: 'a')); - await queue.addQueueItem(mediaItem(id: 'b')); + await queue.addLast(mediaItem(id: 'a')); + await queue.addLast(mediaItem(id: 'b')); await queue.removeQueueItem('a'); @@ -198,7 +254,7 @@ void main() { }); test('clearQueue empties the queue but leaves nextUp alone', () async { - await queue.addQueueItem(mediaItem(id: 'a')); + await queue.addLast(mediaItem(id: 'a')); await queue.setNextUp([mediaItem(id: 'n1')]); await queue.clearQueue(); @@ -208,9 +264,9 @@ void main() { }); test('moveQueueItem reorders', () async { - await queue.addQueueItem(mediaItem(id: 'a')); - await queue.addQueueItem(mediaItem(id: 'b')); - await queue.addQueueItem(mediaItem(id: 'c')); + await queue.addLast(mediaItem(id: 'a')); + await queue.addLast(mediaItem(id: 'b')); + await queue.addLast(mediaItem(id: 'c')); await queue.moveQueueItem(0, 2); @@ -220,7 +276,7 @@ void main() { 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 queue.addLast(mediaItem(id: 'a')); await expectLater(queue.moveQueueItem(5, 0), completes); await expectLater(queue.moveQueueItem(0, 9), completes); @@ -297,7 +353,7 @@ void main() { 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.addLast(mediaItem(id: 'a')); await queue.setNextUp([mediaItem(id: 'a'), mediaItem(id: 'b')]); player.setMediaItem(mediaItem(id: 'a')); @@ -307,11 +363,227 @@ void main() { }); test('leaves the lists alone for an item that is not queued', () async { - await queue.addQueueItem(mediaItem(id: 'a')); + await queue.addLast(mediaItem(id: 'a')); player.setMediaItem(mediaItem(id: 'unrelated')); expect(idsOf(queue.queue.value), ['a']); }); }); + + group('adding items', () { + test('addLast appends to the end of the queue', () async { + await queue.addLast(mediaItem(id: 'a')); + await queue.addLast(mediaItem(id: 'b')); + + expect(idsOf(queue.queue.value), ['a', 'b']); + }); + + test('addNext jumps the item to the front, to play right after the current one', () async { + await queue.addLast(mediaItem(id: 'a')); + await queue.addLast(mediaItem(id: 'b')); + + await queue.addNext(mediaItem(id: 'urgent')); + + expect(idsOf(queue.queue.value), ['urgent', 'a', 'b']); + }); + + test('addNext backfills an id like the others do', () async { + await queue.addNext(mediaItem()); + + expect(queue.queue.value.single.id, isNotNull); + }); + + test('insertAll appends in order', () async { + await queue.addLast(mediaItem(id: 'a')); + + await queue.insertAll(mediaItems(3)); + + expect(idsOf(queue.queue.value), ['a', 'id-1', 'id-2', 'id-3']); + }); + + test('insertAll notifies once for the whole batch', () async { + var notifications = 0; + queue.queue.addListener(() => notifications++); + + await queue.insertAll(mediaItems(5)); + + expect(notifications, 1, reason: 'a per-item notification would rebuild the UI five times'); + }); + + test('insertAll of nothing does not notify', () async { + var notifications = 0; + queue.queue.addListener(() => notifications++); + + await queue.insertAll([]); + + expect(notifications, 0); + }); + + test('the deprecated addQueueItem still behaves like addLast', () async { + // ignore: deprecated_member_use_from_same_package + await queue.addQueueItem(mediaItem(id: 'a')); + + expect(idsOf(queue.queue.value), ['a']); + }); + }); + + group('max queue length', () { + test('drops additions past the cap instead of growing without bound', () async { + (queue as DefaultQueueManager).maxQueueLength = 2; + + await queue.addLast(mediaItem(id: 'a')); + await queue.addLast(mediaItem(id: 'b')); + await queue.addLast(mediaItem(id: 'c')); + await queue.addNext(mediaItem(id: 'd')); + + expect(idsOf(queue.queue.value), ['a', 'b']); + }); + + test('insertAll fills the remaining room and drops the rest', () async { + (queue as DefaultQueueManager).maxQueueLength = 3; + await queue.addLast(mediaItem(id: 'a')); + + await queue.insertAll(mediaItems(5)); + + expect(idsOf(queue.queue.value), ['a', 'id-1', 'id-2']); + }); + }); + + group('playItem', () { + test('plays an upcoming item and leaves the rest queued', () async { + // Tapping row 3 should not discard rows 1 and 2. + player.setMediaItem(mediaItem(id: 'current')); + await queue.insertAll(mediaItems(3)); + + await queue.playItem('id-3'); + + expect(lastPlayed()?.id, 'id-3'); + expect(idsOf(queue.queue.value), ['id-1', 'id-2']); + }); + + test('moves the outgoing item to history', () async { + player.setMediaItem(mediaItem(id: 'current')); + await queue.insertAll(mediaItems(2)); + + await queue.playItem('id-2'); + + expect(idsOf(queue.history.value), ['current']); + }); + + test('reaches into nextUp as well as queue', () async { + await queue.setNextUp(mediaItems(3, prefix: 'n')); + + await queue.playItem('n-2'); + + expect(lastPlayed()?.id, 'n-2'); + expect(idsOf(queue.nextUp.value), ['n-1', 'n-3']); + }); + + test('ignores an id that is not upcoming', () async { + await queue.addLast(mediaItem(id: 'a')); + + await queue.playItem('nope'); + + expect(fake.replaceCurrentMediaItemCalls, isEmpty); + expect(idsOf(queue.queue.value), ['a']); + }); + }); + + group('playAt', () { + test('plays the entry at that position in the combined view', () async { + player.setMediaItem(mediaItem(id: 'current')); + await queue.insertAll(mediaItems(2)); + await queue.setNextUp(mediaItems(2, prefix: 'n')); + + // entries == [current, id-1, id-2, n-1, n-2] + await queue.playAt(3); + + expect(lastPlayed()?.id, 'n-1'); + }); + + test('ignores the current entry', () async { + player.setMediaItem(mediaItem(id: 'current')); + await queue.addLast(mediaItem(id: 'a')); + + await queue.playAt(0); + + expect(fake.replaceCurrentMediaItemCalls, isEmpty); + }); + + test('ignores out-of-range indices', () async { + await queue.addLast(mediaItem(id: 'a')); + + await queue.playAt(-1); + await queue.playAt(99); + + expect(fake.replaceCurrentMediaItemCalls, isEmpty); + }); + }); + + group('entries', () { + List entryIds() => queue.entries.value.map((e) => e.mediaItem.id).toList(); + List entryKinds() => queue.entries.value.map((e) => e.kind).toList(); + + test('is current, then queue, then nextUp', () async { + player.setMediaItem(mediaItem(id: 'current')); + await queue.insertAll(mediaItems(2)); + await queue.setNextUp(mediaItems(2, prefix: 'n')); + + expect(entryIds(), ['current', 'id-1', 'id-2', 'n-1', 'n-2']); + expect(entryKinds(), [ + QueueEntryKind.current, + QueueEntryKind.queue, + QueueEntryKind.queue, + QueueEntryKind.nextUp, + QueueEntryKind.nextUp, + ]); + }); + + test('marks exactly one entry as current', () async { + player.setMediaItem(mediaItem(id: 'current')); + await queue.insertAll(mediaItems(2)); + + expect(queue.entries.value.where((e) => e.isCurrent).map((e) => e.mediaItem.id), ['current']); + }); + + test('holds only upcoming items when nothing is playing', () async { + await queue.insertAll(mediaItems(2)); + + expect(entryIds(), ['id-1', 'id-2']); + expect(entryKinds(), everyElement(QueueEntryKind.queue)); + }); + + test('tracks a change of current item', () async { + await queue.setNextUp(mediaItems(2, prefix: 'n')); + expect(entryIds(), ['n-1', 'n-2']); + + player.setMediaItem(mediaItem(id: 'now-playing')); + + expect(entryIds(), ['now-playing', 'n-1', 'n-2']); + }); + + test('tracks queue mutations', () async { + await queue.addLast(mediaItem(id: 'a')); + expect(entryIds(), ['a']); + + await queue.addNext(mediaItem(id: 'b')); + expect(entryIds(), ['b', 'a']); + + await queue.removeQueueItem('a'); + expect(entryIds(), ['b']); + + await queue.clearQueue(); + expect(entryIds(), isEmpty); + }); + + test('drops an item from the upcoming lists once it becomes current', () async { + await queue.setNextUp(mediaItems(2, prefix: 'n')); + + player.setMediaItem(mediaItem(id: 'n-1')); + + expect(entryIds(), ['n-1', 'n-2'], reason: 'it appears once, as the current entry'); + expect(entryKinds().first, QueueEntryKind.current); + }); + }); }