diff --git a/README.md b/README.md index cb9a6ea..13c9c65 100644 --- a/README.md +++ b/README.md @@ -170,8 +170,10 @@ checked first, like Put it up. The next item is kept ready on the app that will put it up, so skipping to it starts at once. Everyone shares one Up next and anyone can change it; what you added stays when you leave. -In the player, **Up next · title** sits in the top bar next to the chat -button: ⏭ plays it now, the title opens the queue: drag rows to reorder, hover a +In the player, Up next sits in the top bar next to the chat button: the +next item's picture and title (or the next episode's). Rest the mouse on +it for a larger preview (who added it, its length, whether it is ready +to start); ⏭ plays it now, a click opens the queue: drag rows to reorder, hover a row to play it now (▶) or remove it (×), paste a link in the field or drop a link or a video on it to add. When a video ends, a card shows what is up next with a 10 s countdown (Cancel stops it on your screen) diff --git a/apps/desktop/lib/src/queue/queue_ui.dart b/apps/desktop/lib/src/queue/queue_ui.dart index 5ebdc65..95c781e 100644 --- a/apps/desktop/lib/src/queue/queue_ui.dart +++ b/apps/desktop/lib/src/queue/queue_ui.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:math' as math; import 'package:flutter/material.dart'; @@ -6,6 +7,7 @@ import 'package:leaf_widgets/leaf_widgets.dart' show LeafPopover, LeafPopoverCon import 'package:signals/signals_flutter.dart'; import 'package:sync_relay_core/core.dart'; +import '../controls/controls_probe.dart' show ControlsHold; import '../controls/uosc/buttons.dart' show UoscButton; import '../controls/uosc/controls.dart' show UoscControls; import '../controls/uosc/style.dart'; @@ -26,22 +28,27 @@ import 'queue_logic.dart'; // fullscreen), the popover it opens (the queue, the continuation, a field // and a drop zone to add), and the end-of-media card for the first item. + // ---------------------------------------------------------------- button /// Which controls design the Up next control sits in: it takes the look /// of that bar's buttons. enum UpNextLook { standard, uosc } -/// "Up next · " and ▶ (play it now) in the player's top bar, one -/// flat control among its buttons; the title opens the queue. Without Up -/// next, the continuation ("Next · S1E8 …", ▶ plays it). Nothing when -/// both are empty. +/// The next item's picture, "UP NEXT" over its title, a +n count and ⏭ +/// (play it now) in the player's top bar, one flat control among its +/// buttons; the title opens the queue, resting on it shows a preview card. +/// Without Up next, the continuation ("NEXT · S1 E8" over its name, ⏭ +/// plays it). Nothing when both are empty. class UpNextButton extends StatefulWidget { final QueueController queue; /// Plays the continuation's first episode (the next episode). final VoidCallback onPlayNextEpisode; final UpNextLook look; - const UpNextButton({super.key, required this.queue, required this.onPlayNextEpisode, required this.look}); + /// Held while the preview card is up, so the controls stay up with it. + final ControlsHold? hold; + const UpNextButton( + {super.key, required this.queue, required this.onPlayNextEpisode, required this.look, this.hold}); @override State<UpNextButton> createState() => _UpNextButtonState(); @@ -49,13 +56,27 @@ class UpNextButton extends StatefulWidget { class _UpNextButtonState extends State<UpNextButton> { final _popover = LeafPopoverController(); + final _preview = OverlayPortalController(); + final _link = LayerLink(); + Timer? _previewTimer; bool _hoverTitle = false; QueueController get _q => widget.queue; bool get _uosc => widget.look == UpNextLook.uosc; + @override + void didUpdateWidget(UpNextButton oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.hold != widget.hold) { + oldWidget.hold?.set(this, false); + widget.hold?.set(this, _preview.isShowing); + } + } + @override void dispose() { + _previewTimer?.cancel(); + widget.hold?.set(this, false); if (_q.popoverOpen.peek()) { _q.popoverOpen.value = false; handoffHolds = math.max(0, handoffHolds - 1); @@ -64,6 +85,7 @@ class _UpNextButtonState extends State<UpNextButton> { } void _toggle() { + _hidePreview(); if (_popover.isShowing) { _popover.hide(); return; @@ -80,9 +102,32 @@ class _UpNextButtonState extends State<UpNextButton> { handoffHolds = math.max(0, handoffHolds - 1); } - Widget _tip(String message, Widget child) => _uosc - ? UoscTooltip(message: message, child: child) - : Tooltip(message: message, waitDuration: const Duration(milliseconds: 500), child: child); + // The preview: up after resting on the title a moment (never with the + // popover open), down shortly after the mouse left both it and the + // title, so the mouse can cross the gap. While up it holds the controls. + void _previewSoon() { + _previewTimer?.cancel(); + if (_preview.isShowing || _q.popoverOpen.peek()) return; + _previewTimer = Timer(const Duration(milliseconds: 600), () { + if (!mounted || _q.popoverOpen.peek()) return; + _preview.show(); + widget.hold?.set(this, true); + }); + } + + void _keepPreview() => _previewTimer?.cancel(); + + void _leave() { + _previewTimer?.cancel(); + if (!_preview.isShowing) return; + _previewTimer = Timer(const Duration(milliseconds: 200), _hidePreview); + } + + void _hidePreview() { + _previewTimer?.cancel(); + if (_preview.isShowing) _preview.hide(); + widget.hold?.set(this, false); + } @override Widget build(BuildContext context) { @@ -93,18 +138,53 @@ class _UpNextButtonState extends State<UpNextButton> { final hover = _uosc ? UoscStyle.hover : Colors.white10; final fontSize = _uosc ? UoscStyle.fontSize : 14.0; final dim = _uosc ? UoscStyle.dim : Colors.white70; + // The picture fills the button's height but for a 7 px margin. + final thumbWidth = (height - 14) * 16 / 9; + final thumbRadius = _uosc ? UoscStyle.radius : 6.0; return SignalBuilder(builder: (context) { final items = _q.items.value; final cont = _q.shownContinuation.value; final open = _q.popoverOpen.value; - if (items.isEmpty && cont.isEmpty && !open) return const SizedBox.shrink(); + if (items.isEmpty && cont.isEmpty && !open) { + // Gone with the preview up: let go of the controls. + if (_preview.isShowing) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _hidePreview(); + }); + } + return const SizedBox.shrink(); + } final queued = items.isNotEmpty; - final title = queued ? items.first.title : (cont.isNotEmpty ? cont.first.label : 'Nothing yet'); + final item = items.firstOrNull; + final episode = cont.firstOrNull; final more = items.length - 1; + final String kicker; + final String title; + final Widget thumb; + if (item != null) { + kicker = 'UP NEXT'; + title = item.title; + thumb = QueueThumb(queue: _q, item: item, width: thumbWidth, radius: thumbRadius); + } else if (episode != null) { + final n = episodeNumbers(episode.item); + kicker = n == null ? 'NEXT' : 'NEXT · $n'; + title = episode.item.name; + thumb = _EpisodeThumb(queue: _q, episode: episode, width: thumbWidth, radius: thumbRadius); + } else { + kicker = 'UP NEXT'; + title = 'Nothing yet'; + thumb = _BlankThumb(icon: Icons.queue_play_next_rounded, width: thumbWidth, radius: thumbRadius); + } final label = MouseRegion( cursor: SystemMouseCursors.click, - onEnter: (_) => setState(() => _hoverTitle = true), - onExit: (_) => setState(() => _hoverTitle = false), + onEnter: (_) { + setState(() => _hoverTitle = true); + _previewSoon(); + }, + onExit: (_) { + setState(() => _hoverTitle = false); + _leave(); + }, child: GestureDetector( key: const ValueKey('up-next-button'), behavior: HitTestBehavior.opaque, @@ -112,7 +192,8 @@ class _UpNextButtonState extends State<UpNextButton> { child: AnimatedContainer( duration: const Duration(milliseconds: 150), height: height, - padding: const EdgeInsets.only(left: 10, right: 6), + // The picture clears a round button's curve. + padding: EdgeInsets.only(left: _uosc ? 7 : 12, right: 6), decoration: BoxDecoration( color: _hoverTitle || open ? hover : Colors.transparent, borderRadius: BorderRadius.circular(radius), @@ -120,19 +201,29 @@ class _UpNextButtonState extends State<UpNextButton> { child: Row( mainAxisSize: MainAxisSize.min, children: [ - Icon(queued ? Icons.queue_play_next_rounded : Icons.playlist_play_rounded, - size: _uosc ? 22 : 24, color: Colors.white), - const SizedBox(width: 8), - Text(queued ? 'Up next' : 'Next', style: TextStyle(color: dim, fontSize: fontSize)), - Text(' · ', style: TextStyle(color: dim, fontSize: fontSize)), + thumb, + const SizedBox(width: 10), ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 240), - child: Text( - title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - color: queued ? Colors.white : dim, fontSize: fontSize, fontWeight: FontWeight.w500), + constraints: const BoxConstraints(maxWidth: 220), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(kicker, + maxLines: 1, + style: TextStyle( + color: dim, fontSize: 10, height: 1.2, letterSpacing: 0.8, fontWeight: FontWeight.w600)), + Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: queued ? Colors.white : Colors.white.withValues(alpha: 0.85), + fontSize: fontSize, + height: 1.25, + fontWeight: FontWeight.w500), + ), + ], ), ), if (more > 0) ...[ @@ -155,13 +246,16 @@ class _UpNextButtonState extends State<UpNextButton> { ), ), ); - final playTip = queued ? 'Play ${items.first.title} now' : 'Play ${cont.isEmpty ? '' : cont.first.label} now'; - final VoidCallback? play = queued ? _q.skip : (cont.isNotEmpty ? widget.onPlayNextEpisode : null); + final playTip = queued + ? 'Play ${item!.title} now ($playNextKeys)' + : 'Play ${episode == null ? '' : episode.label} now ($playNextKeys)'; + final VoidCallback? play = queued ? _q.skip : (episode != null ? widget.onPlayNextEpisode : null); final playButton = _uosc ? UoscButton(key: const ValueKey('up-next-play'), icon: Icons.skip_next_rounded, tooltip: playTip, onPressed: play) - : _tip( - playTip, - IconButton( + : Tooltip( + message: playTip, + waitDuration: const Duration(milliseconds: 500), + child: IconButton( key: const ValueKey('up-next-play'), iconSize: 28, color: Colors.white, @@ -169,7 +263,8 @@ class _UpNextButtonState extends State<UpNextButton> { icon: const Icon(Icons.skip_next_rounded), ), ); - // The popover hangs under the whole control, right edges aligned. + // The popover and the preview hang under the whole control, right + // edges aligned. return LeafPopover( controller: _popover, popoverAnchor: Alignment.topRight, @@ -177,12 +272,34 @@ class _UpNextButtonState extends State<UpNextButton> { spacing: 8, onDismissed: _closed, popoverBuilder: (context, c) => QueuePanel(queue: _q, onClose: c.hide), - // A little apart from the bar's other buttons: its own group. - child: Padding( - padding: const EdgeInsets.only(right: 8), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [label, if (play != null) playButton], + child: OverlayPortal( + controller: _preview, + overlayChildBuilder: (context) => Positioned( + left: 0, + top: 0, + child: CompositedTransformFollower( + link: _link, + showWhenUnlinked: false, + targetAnchor: Alignment.bottomRight, + followerAnchor: Alignment.topRight, + offset: const Offset(0, 8), + child: MouseRegion( + onEnter: (_) => _keepPreview(), + onExit: (_) => _leave(), + child: GestureDetector(onTap: _toggle, child: UpNextPreview(queue: _q)), + ), + ), + ), + child: CompositedTransformTarget( + link: _link, + // A little apart from the bar's other buttons: its own group. + child: Padding( + padding: const EdgeInsets.only(right: 8), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [label, if (play != null) playButton], + ), + ), ), ), ); @@ -190,6 +307,201 @@ class _UpNextButtonState extends State<UpNextButton> { } } +/// The keys that play Up next (else the next episode) now. +const playNextKeys = '> or Shift+N'; + +/// "S1 E8" for an episode with both numbers, else null. +String? episodeNumbers(JellyfinItem e) => + e.parentIndexNumber != null && e.indexNumber != null ? 'S${e.parentIndexNumber} E${e.indexNumber}' : null; + +/// What the Up next control shows while the mouse rests on it: the first +/// item (or the next episode) larger, with everything known about it, how +/// ready it is and the keys that play it. +class UpNextPreview extends StatelessWidget { + final QueueController queue; + const UpNextPreview({super.key, required this.queue}); + + static const _width = 320.0; + static const _pad = 12.0; + static const _kicker = + TextStyle(color: Colors.white54, fontSize: 11, letterSpacing: 0.8, fontWeight: FontWeight.w600); + static const _title = TextStyle(color: Colors.white, fontSize: 15, fontWeight: FontWeight.w600, height: 1.25); + static const _sub = TextStyle(color: Colors.white60, fontSize: 12, height: 1.3); + + @override + Widget build(BuildContext context) { + return AppGlass( + key: const ValueKey('up-next-preview'), + child: SizedBox( + width: _width, + child: Padding( + padding: const EdgeInsets.all(_pad), + child: SignalBuilder(builder: (context) { + final item = queue.items.value.firstOrNull; + final episode = queue.shownContinuation.value.firstOrNull; + final warm = queue.warm.value; + const picture = _width - 2 * _pad; + if (item != null) { + final r = queue.coord.serverState.value; + final selfId = queue.coord.sync.memberId; + final more = queue.items.value.length - 1; + final ready = warm?.id == item.id ? warm!.state : null; + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + QueueThumb(queue: queue, item: item, width: picture, radius: 8), + const SizedBox(height: 10), + Text(more > 0 ? 'UP NEXT · $more MORE AFTER IT' : 'UP NEXT', style: _kicker), + const SizedBox(height: 3), + Text(item.title, maxLines: 2, overflow: TextOverflow.ellipsis, style: _title), + if (item.sub.isNotEmpty) + Text(item.sub, maxLines: 1, overflow: TextOverflow.ellipsis, style: _sub), + const SizedBox(height: 6), + _MetaLine(item: item, members: r?.members ?? const [], selfId: selfId, wrap: true), + _footer(ready, ready == null ? performerNote(queue, item, r) : null), + ], + ); + } + if (episode == null) return const SizedBox.shrink(); + final n = episodeNumbers(episode.item); + final runtime = episode.pick.runtime; + final sub = [episode.item.seriesName ?? '', if (runtime != null) queueClock(runtime.inSeconds.toDouble())] + .where((s) => s.isNotEmpty) + .join(' · '); + final overview = episode.item.overview?.trim() ?? ''; + final ready = warm?.id == 'next:${episode.item.id}' ? warm!.state : null; + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _EpisodeThumb(queue: queue, episode: episode, width: picture, radius: 8), + const SizedBox(height: 10), + Text(n == null ? 'NEXT EPISODE' : 'NEXT EPISODE · $n', style: _kicker), + const SizedBox(height: 3), + Text(episode.item.name, maxLines: 2, overflow: TextOverflow.ellipsis, style: _title), + if (sub.isNotEmpty) Text(sub, maxLines: 1, overflow: TextOverflow.ellipsis, style: _sub), + if (overview.isNotEmpty) ...[ + const SizedBox(height: 6), + Text(overview, + maxLines: 3, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: Colors.white54, fontSize: 12, height: 1.35)), + ], + _footer(ready, null), + ], + ); + }), + ), + ), + ); + } + + /// How ready it is here (or who puts it up), and the keys that play it. + Widget _footer(WarmState? ready, String? note) { + const style = TextStyle(color: Colors.white60, fontSize: 12); + final (text, color) = switch (ready) { + WarmState.idle => ('Waiting to get ready', Colors.white54), + WarmState.warming => ('Getting ready…', Colors.white70), + WarmState.ready => ('Ready to play', Colors.greenAccent.shade200), + WarmState.failed => ("Couldn't get it ready", Colors.redAccent.shade100), + null => (note ?? '', Colors.white54), + }; + return Padding( + padding: const EdgeInsets.only(top: 10), + child: Row( + children: [ + if (ready != null) ...[_WarmDot(state: ready), const SizedBox(width: 6)], + Expanded( + child: Text(text, maxLines: 1, overflow: TextOverflow.ellipsis, style: style.copyWith(color: color)), + ), + const Text('Play now', style: style), + const SizedBox(width: 6), + const _KeyCap('>'), + ], + ), + ); + } +} + +/// A key as printed on the keyboard, small. +class _KeyCap extends StatelessWidget { + final String label; + const _KeyCap(this.label); + + @override + Widget build(BuildContext context) => Container( + constraints: const BoxConstraints(minWidth: 20), + padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1), + alignment: Alignment.center, + decoration: BoxDecoration( + color: const Color(0x14FFFFFF), + borderRadius: BorderRadius.circular(4), + border: Border.all(color: AppMenuStyle.border, width: 0.5), + ), + child: Text(label, + style: const TextStyle(color: Colors.white70, fontSize: 11, fontWeight: FontWeight.w600, height: 1.3)), + ); +} + +/// Another app puts [item] up: "Bob puts it up"; null when this one does. +String? performerNote(QueueController queue, QueueItem item, StateBroadcast? r) { + if (r == null) return null; + final id = queuePerformer(item, r.members).id; + if (id == null || id == queue.coord.sync.memberId) return null; + final name = r.members.where((m) => m.id == id).firstOrNull?.name; + return name == null ? null : '$name puts it up'; +} + +/// The neutral picture placeholder of the queue (a faint box, an icon). +class _BlankThumb extends StatelessWidget { + final IconData icon; + final double width; + final double radius; + const _BlankThumb({required this.icon, required this.width, this.radius = 6}); + + @override + Widget build(BuildContext context) => ClipRRect( + borderRadius: BorderRadius.circular(radius), + child: SizedBox( + width: width, + height: width * 9 / 16, + child: _blank(icon, width), + ), + ); +} + +Widget _blank(IconData icon, double width) => ColoredBox( + color: const Color(0x1FFFFFFF), + child: Center(child: Icon(icon, size: (width / 4).clamp(12, 32), color: Colors.white38)), + ); + +/// An episode's still (else the series' thumb), 16:9 and rounded. +class _EpisodeThumb extends StatelessWidget { + final QueueController queue; + final EpisodeLink episode; + final double width; + final double radius; + const _EpisodeThumb({required this.queue, required this.episode, required this.width, this.radius = 6}); + + @override + Widget build(BuildContext context) { + final e = episode; + final client = queue.coord.jellyfin.clientFor(e.pick.serverId); + final image = e.item.ownImage(JellyfinImageType.primary) ?? e.item.image(JellyfinImageType.thumb); + return ClipRRect( + borderRadius: BorderRadius.circular(radius), + child: SizedBox( + width: width, + height: width * 9 / 16, + child: client == null + ? _blank(Icons.tv_rounded, width) + : JellyfinImage(client: client, image: image, placeholder: _blank(Icons.tv_rounded, width)), + ), + ); + } +} + class _RoundIcon extends StatefulWidget { final IconData icon; final String tooltip; @@ -577,12 +889,13 @@ class QueueThumb extends StatelessWidget { final QueueController queue; final QueueItem item; final double width; - const QueueThumb({super.key, required this.queue, required this.item, this.width = 72}); + final double radius; + const QueueThumb({super.key, required this.queue, required this.item, this.width = 72, this.radius = 6}); @override Widget build(BuildContext context) { return ClipRRect( - borderRadius: BorderRadius.circular(6), + borderRadius: BorderRadius.circular(radius), child: SizedBox( width: width, height: width * 9 / 16, @@ -590,12 +903,7 @@ class QueueThumb extends StatelessWidget { listenable: queue.thumbs, builder: (context, _) { final bytes = queue.thumbs.bytesFor(item.thumb); - if (bytes == null) { - return ColoredBox( - color: const Color(0x1FFFFFFF), - child: Icon(howIcon(item.how), size: width / 4, color: Colors.white38), - ); - } + if (bytes == null) return _blank(howIcon(item.how), width); return Image.memory(bytes, fit: BoxFit.cover, gaplessPlayback: true, filterQuality: FilterQuality.medium); }, ), @@ -910,8 +1218,6 @@ class _ContinuationRowState extends State<_ContinuationRow> { @override Widget build(BuildContext context) { final e = widget.episode; - final client = widget.queue.coord.jellyfin.clientFor(e.pick.serverId); - final image = e.item.ownImage(JellyfinImageType.primary) ?? e.item.image(JellyfinImageType.thumb); final runtime = e.pick.runtime; return MouseRegion( onEnter: (_) => setState(() => _hover = true), @@ -928,16 +1234,7 @@ class _ContinuationRowState extends State<_ContinuationRow> { ), child: Row( children: [ - ClipRRect( - borderRadius: BorderRadius.circular(6), - child: SizedBox( - width: _thumb, - height: _thumb * 9 / 16, - child: client == null - ? const ColoredBox(color: Color(0x1FFFFFFF)) - : JellyfinImage(client: client, image: image, icon: Icons.tv_rounded), - ), - ), + _EpisodeThumb(queue: widget.queue, episode: e, width: _thumb), const SizedBox(width: 12), Expanded( child: Column( @@ -986,7 +1283,7 @@ class UpNextCard extends StatelessWidget { final left = c.secondsLeft; final r = queue.coord.serverState.value; final selfId = queue.coord.sync.memberId; - final waiting = left == null ? _waitingFor(item, r) : null; + final waiting = left == null ? performerNote(queue, item, r) : null; return AppGlass( key: const ValueKey('up-next-card'), child: SizedBox( @@ -1070,13 +1367,4 @@ class UpNextCard extends StatelessWidget { ), ); } - - /// Another app puts it up: say whose. - String? _waitingFor(QueueItem item, StateBroadcast? r) { - if (r == null) return null; - final id = queuePerformer(item, r.members).id; - if (id == null || id == queue.coord.sync.memberId) return null; - final name = r.members.where((m) => m.id == id).firstOrNull?.name; - return name == null ? null : '$name puts it up'; - } } diff --git a/apps/desktop/lib/src/shell/session_player.dart b/apps/desktop/lib/src/shell/session_player.dart index 6fa30a8..8fa088f 100644 --- a/apps/desktop/lib/src/shell/session_player.dart +++ b/apps/desktop/lib/src/shell/session_player.dart @@ -399,7 +399,11 @@ class _SessionPlayerState extends State<SessionPlayer> { child: Row( children: [ const Expanded(child: NowPlayingTitle()), - UpNextButton(queue: coord.queue, onPlayNextEpisode: coord.playNextEpisode, look: UpNextLook.standard), + UpNextButton( + queue: coord.queue, + onPlayNextEpisode: coord.playNextEpisode, + look: UpNextLook.standard, + hold: _hold), MaterialDesktopCustomButton( icon: const Icon(Icons.video_library_outlined), onPressed: widget.onChangeVideo, @@ -571,7 +575,8 @@ class _SessionPlayerState extends State<SessionPlayer> { top: Row( mainAxisSize: MainAxisSize.min, children: [ - UpNextButton(queue: coord.queue, onPlayNextEpisode: coord.playNextEpisode, look: UpNextLook.uosc), + UpNextButton( + queue: coord.queue, onPlayNextEpisode: coord.playNextEpisode, look: UpNextLook.uosc, hold: _hold), UoscButton( key: const ValueKey('uosc-chat'), icon: widget.chatOpen ? Icons.chat_bubble_rounded : Icons.chat_bubble_outline_rounded, diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 688d5e5..067a855 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -898,9 +898,18 @@ every `state` and feed), the pure rules in `queue_logic.dart`, the UI in - **UI**: `UpNextButton` is one of the player's top-bar controls, before chat (see "Player overlay" under Desktop UI), in the look of the design's buttons (`UpNextLook`); it shows and hides with the controls, - which an open popover holds up: "Up next · title", a +n count, ⏭ plays - it now; without Up next the continuation ("Next · S1E8 …", ⏭ = next - episode); nothing when both are empty. Its title opens the queue as a + which an open popover holds up: the first item's picture (`QueueThumb`, + the rows' faint box and icon until it arrives), "UP NEXT" over its + title, a +n count, ⏭ plays it now; without Up next the continuation + (the episode's image, "NEXT · S1 E8" over its name, ⏭ = next episode); + nothing when both are empty. Resting on it 600 ms (popover closed) + shows `UpNextPreview` under it, right edges aligned, in `AppGlass`: + the picture large, title, second line, "length · added by (avatar) + name · how" (an episode: series, length, overview), how ready it is + here (the row's dot and words, else who puts it up) and the `>` key; + it stays while the mouse is on either, holds the controls up + (`ControlsHold`) while shown, and a click on it opens the queue. Its + title opens the queue as a `LeafPopover` (package `leaf_widgets`, an overlay rather than a route) hanging under it, right edges aligned: the app's glass (`AppGlass`), the menus' paddings and row hover, 64 px rows with an 80 px 16:9 @@ -1165,7 +1174,7 @@ from `AppMenuStyle.theme`; the uosc controls keep their own flat menus. tall like the status pill without avatars). The mouse on any floating element counts as the mouse on the controls: a `ControlsHold` (`controls/controls_probe.dart`) held by - `ControlsHoldRegion`s (and by the open Up next popover) keeps either + `ControlsHoldRegion`s (and by the open Up next popover or its preview) keeps either design's controls up (`UoscControls.holdBy`; `ControlsHoverProbe.hold`, which re-wakes media_kit's controls through their private hover handler, the same guarded dynamic call as `keepVideoControlsAwake`),