Protocol 12: Up next (a shared queue) and where the video comes from
Up next: a server-side queue anyone edits by item id (add at end or next, move, remove, play now, skip); each item says who added it and how it plays. At the real end the first item swaps in after the end card's countdown, put up by whoever can (its adder for through-me links and files, someone with access for Jellyfin, else anyone); items that can't play are skipped with the reason. The next item's link is resolved ahead. Greyed "Continues with" episodes below. A chip with the controls opens it as a popover (leaf_widgets, local path dependency for now); "Add to queue" next to Put it up everywhere. Delivery: the server measures the source's rate, the media's bitrate and each member's read rate; the members card shows a source line (who passes it on, through what, keeping up or not, source left) and each member's route and buffer. A slow source is told once per media. A failed server fetch is announced to everyone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
95cd668cfb
commit
6b10cae4e6
50 files changed
+4239
-157
No files matched your search
@@ -14,6 +14,7 @@ import '../controls/uosc/buttons.dart';
|
||||
import '../controls/uosc/controls.dart';
|
||||
import '../controls/uosc/timeline.dart';
|
||||
import '../controls/volume.dart';
|
||||
import '../queue/queue_ui.dart';
|
||||
import '../playback/media_marks.dart' show SkipKind, SkipOffer, formatClock;
|
||||
import '../screens/session/jellyfin_widgets.dart';
|
||||
import '../screens/session/player_controls.dart';
|
||||
@@ -200,6 +201,12 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
// mpv's playlist keys (< >) and Shift+P / Shift+N: Jellyfin episodes.
|
||||
final shift = HardwareKeyboard.instance.isShiftPressed;
|
||||
if (event.character == '>' || (shift && key == LogicalKeyboardKey.keyN)) {
|
||||
// Up next first, then the next episode.
|
||||
if (coord.queue.items.peek().isNotEmpty) {
|
||||
_lastKeyAt = now;
|
||||
coord.queue.skip();
|
||||
return true;
|
||||
}
|
||||
if (coord.nextEpisode.peek() == null) return false;
|
||||
_lastKeyAt = now;
|
||||
coord.playNextEpisode();
|
||||
@@ -287,6 +294,7 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
_status(),
|
||||
_progressLine(),
|
||||
_jellyfinOverlay(),
|
||||
_upNextChip(),
|
||||
if (widget.fullscreen) ...[if (!SessionState.instance.solo.value) _pausedParty(), _unreadChip()],
|
||||
] else
|
||||
_miniOverlay(),
|
||||
@@ -678,6 +686,8 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
final blocked = coord.jellyfinBlocked.value;
|
||||
final next = coord.nextEpisode.value;
|
||||
final countdown = coord.autoAdvance.value;
|
||||
// Up next goes before the next episode.
|
||||
final upNext = coord.queue.countdown.value;
|
||||
final finished = coord.serverState.value?.transport == 'finished';
|
||||
final pill = coord.nextPromptVisible.value;
|
||||
final SkipOffer? skip = coord.skipOffer.value;
|
||||
@@ -692,7 +702,16 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
onRetry: coord.retryJellyfin,
|
||||
),
|
||||
),
|
||||
if (next != null && (finished || countdown != null))
|
||||
if (upNext != null && finished)
|
||||
Positioned(
|
||||
right: 24,
|
||||
bottom: 138,
|
||||
child: UpNextCard(queue: coord.queue, countdown: upNext),
|
||||
)
|
||||
// "Clear" in Up next hides the next episode for this media.
|
||||
else if (next != null &&
|
||||
(finished || countdown != null) &&
|
||||
coord.queue.shownContinuation.value.isNotEmpty)
|
||||
Positioned(
|
||||
right: 24,
|
||||
bottom: 138,
|
||||
@@ -737,6 +756,24 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
);
|
||||
}
|
||||
|
||||
/// "Up next · <title>" top right, with the controls (and while its
|
||||
/// popover is open); under the party in fullscreen.
|
||||
Widget _upNextChip() {
|
||||
final queue = _coord.queue;
|
||||
return Positioned(
|
||||
right: 16,
|
||||
top: widget.fullscreen ? _barHeight + 60 : _topBarHeight + 8,
|
||||
child: SignalBuilder(
|
||||
builder: (context) => PillGate(
|
||||
key: const ValueKey('up-next-gate'),
|
||||
kind: 'up-next',
|
||||
controlsShown: queue.popoverOpen.value ? const _AlwaysOn() : _controlsShown,
|
||||
child: UpNextChip(queue: queue, onPlayNextEpisode: _coord.playNextEpisode),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Fullscreen hides the strip: while paused the party stays in a corner.
|
||||
Widget _pausedParty() => Positioned(
|
||||
right: 16,
|
||||
@@ -908,6 +945,17 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Always up (a pill whose popover is open).
|
||||
class _AlwaysOn implements ValueListenable<bool> {
|
||||
const _AlwaysOn();
|
||||
@override
|
||||
bool get value => true;
|
||||
@override
|
||||
void addListener(VoidCallback listener) {}
|
||||
@override
|
||||
void removeListener(VoidCallback listener) {}
|
||||
}
|
||||
|
||||
/// A floating pill's visibility: only while the controls are up (the
|
||||
/// mouse moved) or the mouse is on it. With [announce] it is also up for
|
||||
/// [firstShown] whenever [kind] changes (a new offer: Skip OP, Skip 1:30).
|
||||
|
||||
Reference in new issue
Block a user