Up next control: the next item's picture and title in the bar, a preview card on hover

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
foxyzandClaude Opus 5.5 committed 2026-10-11 15:42:28 +03:00
1 parent cf2805d3bd
commit 4275abe470
4 files changed
+379 -75

No files matched your search

+355 -67
View File
@@ -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 · <title>" 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';
}
}
@@ -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,