Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion doc/contributing/todo.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
2 changes: 1 addition & 1 deletion example/lib/examples/queue.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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}'),
),
Expand Down
121 changes: 116 additions & 5 deletions lib/src/queue/default_queue_controller.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<List<QueueEntry>> _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<bool> get shuffleEnabled => _nextUp.shuffleNotifier;
@override
Expand All @@ -20,12 +34,17 @@ class DefaultQueueManager implements QueueManager {
ValueNotifier<List<MediaItem>> get queue => _queue.itemsNotifier;
@override
ValueNotifier<List<MediaItem>> get nextUp => _nextUp.itemsNotifier;
@override
ValueNotifier<List<QueueEntry>> get entries => _entries;

@override
void dispose() {
_queue.itemsNotifier.removeListener(_recomputeEntries);
_nextUp.itemsNotifier.removeListener(_recomputeEntries);
_queue.dispose();
_history.dispose();
_nextUp.dispose();
_entries.dispose();
}

@override
Expand All @@ -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<void> _restartCurrent(PlayerState state) {
return BccmPlayerInterface.instance.seekTo(state.playerId, 0);
}

void _removeIfUpcoming(String id) {
Expand Down Expand Up @@ -67,16 +101,34 @@ class DefaultQueueManager implements QueueManager {
Future<void> 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
Expand Down Expand Up @@ -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<void> addQueueItem(MediaItem mediaItem) async {
Future<void> addLast(MediaItem mediaItem) async {
if (_room <= 0) {
debugPrint('bccm: queue is at maxQueueLength ($maxQueueLength), dropping addLast');
return;
}
_queue.add(_withId(mediaItem));
}

@override
Future<void> addNext(MediaItem mediaItem) async {
if (_room <= 0) {
debugPrint('bccm: queue is at maxQueueLength ($maxQueueLength), dropping addNext');
return;
}
_queue.addToStart(_withId(mediaItem));
}

@override
Future<void> insertAll(List<MediaItem> 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<void> addQueueItem(MediaItem mediaItem) => addLast(mediaItem);

@override
Future<void> removeQueueItem(String id) async {
_queue.remove(id);
Expand All @@ -136,6 +218,30 @@ class DefaultQueueManager implements QueueManager {
_queue.clear();
}

@override
Future<void> 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<void> 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<void> _playMediaItem(MediaItem mediaItem) async {
final player = _playerNotifier;
if (player == null) return;
Expand All @@ -161,6 +267,11 @@ class QueueList {
itemsNotifier.value = [...itemsNotifier.value, item];
}

void addAll(List<MediaItem> items) {
if (items.isEmpty) return;
itemsNotifier.value = [...itemsNotifier.value, ...items];
}

void addToStart(MediaItem item) {
itemsNotifier.value = [item, ...itemsNotifier.value];
}
Expand Down
77 changes: 77 additions & 0 deletions lib/src/queue/queue_controller.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> 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<void> skipToPrevious();

Future<void> handlePlaybackEnded(MediaItem? current);

Future<void> setShuffleEnabled(bool enabled);

/// Replaces [nextUp] wholesale.
Future<void> setNextUp(List<MediaItem> mediaItems);

/// Appends to the end of [queue].
Future<void> addLast(MediaItem mediaItem);

/// Inserts at the front of [queue], so it plays immediately after the
/// current item.
Future<void> addNext(MediaItem mediaItem);

/// Appends several items to the end of [queue] in one go.
Future<void> insertAll(List<MediaItem> mediaItems);

@Deprecated('Renamed to addLast, to pair with addNext. Will be removed in a future release.')
Future<void> addQueueItem(MediaItem mediaItem);

Future<void> removeQueueItem(String id);

Future<void> moveQueueItem(int fromIndex, int toIndex);

Future<void> clearQueue();

/// Jumps straight to an upcoming item, leaving the rest of the queue in place.
///
/// Does nothing if [id] is not upcoming.
Future<void> playItem(String id);

/// Plays the entry at [index] of [entries]. Out-of-range indices and the
/// current entry are ignored.
Future<void> playAt(int index);

@internal
void setPlayer(PlayerStateNotifier playerStateNotifier) {}

ValueNotifier<bool> get shuffleEnabled;

/// Most recently played first.
ValueNotifier<List<MediaItem>> get history;

ValueNotifier<List<MediaItem>> get queue;

ValueNotifier<List<MediaItem>> get nextUp;

/// The current item followed by everything upcoming, as one list.
///
/// Saves every consumer assembling "current + upcoming, current highlighted"
/// for itself.
ValueNotifier<List<QueueEntry>> get entries;
}
2 changes: 0 additions & 2 deletions lib/src/state/plugin_state_notifier.dart
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import 'package:flutter/material.dart';

import 'player_state_notifier.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
Expand Down Expand Up @@ -47,7 +46,6 @@ class PlayerPluginStateNotifier extends StateNotifier<PlayerPluginState> {
}

void _removePlayer(String playerId) {
debugPrint('removing playerId: $playerId');
final player = state.players[playerId];
if (player != null) {
state = state.copyWith(players: {...state.players}..remove(playerId));
Expand Down
Loading
Loading