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
+369 -65

No files matched your search

+4 -2
View File
@@ -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 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. you added stays when you leave.
In the player, **Up next · title** sits in the top bar next to the chat In the player, Up next sits in the top bar next to the chat button: the
button: ⏭ plays it now, the title opens the queue: drag rows to reorder, hover a 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 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 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) what is up next with a 10 s countdown (Cancel stops it on your screen)
+345 -57
View File
@@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:math' as math; import 'dart:math' as math;
import 'package:flutter/material.dart'; 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:signals/signals_flutter.dart';
import 'package:sync_relay_core/core.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/buttons.dart' show UoscButton;
import '../controls/uosc/controls.dart' show UoscControls; import '../controls/uosc/controls.dart' show UoscControls;
import '../controls/uosc/style.dart'; import '../controls/uosc/style.dart';
@@ -26,22 +28,27 @@ import 'queue_logic.dart';
// fullscreen), the popover it opens (the queue, the continuation, a field // 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. // and a drop zone to add), and the end-of-media card for the first item.
// ---------------------------------------------------------------- button // ---------------------------------------------------------------- button
/// Which controls design the Up next control sits in: it takes the look /// Which controls design the Up next control sits in: it takes the look
/// of that bar's buttons. /// of that bar's buttons.
enum UpNextLook { standard, uosc } enum UpNextLook { standard, uosc }
/// "Up next · <title>" and ▶ (play it now) in the player's top bar, one /// The next item's picture, "UP NEXT" over its title, a +n count and ⏭
/// flat control among its buttons; the title opens the queue. Without Up /// (play it now) in the player's top bar, one flat control among its
/// next, the continuation ("Next · S1E8 …", ▶ plays it). Nothing when /// buttons; the title opens the queue, resting on it shows a preview card.
/// both are empty. /// Without Up next, the continuation ("NEXT · S1 E8" over its name, ⏭
/// plays it). Nothing when both are empty.
class UpNextButton extends StatefulWidget { class UpNextButton extends StatefulWidget {
final QueueController queue; final QueueController queue;
/// Plays the continuation's first episode (the next episode). /// Plays the continuation's first episode (the next episode).
final VoidCallback onPlayNextEpisode; final VoidCallback onPlayNextEpisode;
final UpNextLook look; 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 @override
State<UpNextButton> createState() => _UpNextButtonState(); State<UpNextButton> createState() => _UpNextButtonState();
@@ -49,13 +56,27 @@ class UpNextButton extends StatefulWidget {
class _UpNextButtonState extends State<UpNextButton> { class _UpNextButtonState extends State<UpNextButton> {
final _popover = LeafPopoverController(); final _popover = LeafPopoverController();
final _preview = OverlayPortalController();
final _link = LayerLink();
Timer? _previewTimer;
bool _hoverTitle = false; bool _hoverTitle = false;
QueueController get _q => widget.queue; QueueController get _q => widget.queue;
bool get _uosc => widget.look == UpNextLook.uosc; 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 @override
void dispose() { void dispose() {
_previewTimer?.cancel();
widget.hold?.set(this, false);
if (_q.popoverOpen.peek()) { if (_q.popoverOpen.peek()) {
_q.popoverOpen.value = false; _q.popoverOpen.value = false;
handoffHolds = math.max(0, handoffHolds - 1); handoffHolds = math.max(0, handoffHolds - 1);
@@ -64,6 +85,7 @@ class _UpNextButtonState extends State<UpNextButton> {
} }
void _toggle() { void _toggle() {
_hidePreview();
if (_popover.isShowing) { if (_popover.isShowing) {
_popover.hide(); _popover.hide();
return; return;
@@ -80,9 +102,32 @@ class _UpNextButtonState extends State<UpNextButton> {
handoffHolds = math.max(0, handoffHolds - 1); handoffHolds = math.max(0, handoffHolds - 1);
} }
Widget _tip(String message, Widget child) => _uosc // The preview: up after resting on the title a moment (never with the
? UoscTooltip(message: message, child: child) // popover open), down shortly after the mouse left both it and the
: Tooltip(message: message, waitDuration: const Duration(milliseconds: 500), child: child); // 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -93,18 +138,53 @@ class _UpNextButtonState extends State<UpNextButton> {
final hover = _uosc ? UoscStyle.hover : Colors.white10; final hover = _uosc ? UoscStyle.hover : Colors.white10;
final fontSize = _uosc ? UoscStyle.fontSize : 14.0; final fontSize = _uosc ? UoscStyle.fontSize : 14.0;
final dim = _uosc ? UoscStyle.dim : Colors.white70; 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) { return SignalBuilder(builder: (context) {
final items = _q.items.value; final items = _q.items.value;
final cont = _q.shownContinuation.value; final cont = _q.shownContinuation.value;
final open = _q.popoverOpen.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 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 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( final label = MouseRegion(
cursor: SystemMouseCursors.click, cursor: SystemMouseCursors.click,
onEnter: (_) => setState(() => _hoverTitle = true), onEnter: (_) {
onExit: (_) => setState(() => _hoverTitle = false), setState(() => _hoverTitle = true);
_previewSoon();
},
onExit: (_) {
setState(() => _hoverTitle = false);
_leave();
},
child: GestureDetector( child: GestureDetector(
key: const ValueKey('up-next-button'), key: const ValueKey('up-next-button'),
behavior: HitTestBehavior.opaque, behavior: HitTestBehavior.opaque,
@@ -112,7 +192,8 @@ class _UpNextButtonState extends State<UpNextButton> {
child: AnimatedContainer( child: AnimatedContainer(
duration: const Duration(milliseconds: 150), duration: const Duration(milliseconds: 150),
height: height, 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( decoration: BoxDecoration(
color: _hoverTitle || open ? hover : Colors.transparent, color: _hoverTitle || open ? hover : Colors.transparent,
borderRadius: BorderRadius.circular(radius), borderRadius: BorderRadius.circular(radius),
@@ -120,19 +201,29 @@ class _UpNextButtonState extends State<UpNextButton> {
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Icon(queued ? Icons.queue_play_next_rounded : Icons.playlist_play_rounded, thumb,
size: _uosc ? 22 : 24, color: Colors.white), const SizedBox(width: 10),
const SizedBox(width: 8),
Text(queued ? 'Up next' : 'Next', style: TextStyle(color: dim, fontSize: fontSize)),
Text(' · ', style: TextStyle(color: dim, fontSize: fontSize)),
ConstrainedBox( ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 240), constraints: const BoxConstraints(maxWidth: 220),
child: Text( 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, title,
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: TextStyle( style: TextStyle(
color: queued ? Colors.white : dim, fontSize: fontSize, fontWeight: FontWeight.w500), color: queued ? Colors.white : Colors.white.withValues(alpha: 0.85),
fontSize: fontSize,
height: 1.25,
fontWeight: FontWeight.w500),
),
],
), ),
), ),
if (more > 0) ...[ 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 playTip = queued
final VoidCallback? play = queued ? _q.skip : (cont.isNotEmpty ? widget.onPlayNextEpisode : null); ? '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 final playButton = _uosc
? UoscButton(key: const ValueKey('up-next-play'), icon: Icons.skip_next_rounded, tooltip: playTip, onPressed: play) ? UoscButton(key: const ValueKey('up-next-play'), icon: Icons.skip_next_rounded, tooltip: playTip, onPressed: play)
: _tip( : Tooltip(
playTip, message: playTip,
IconButton( waitDuration: const Duration(milliseconds: 500),
child: IconButton(
key: const ValueKey('up-next-play'), key: const ValueKey('up-next-play'),
iconSize: 28, iconSize: 28,
color: Colors.white, color: Colors.white,
@@ -169,7 +263,8 @@ class _UpNextButtonState extends State<UpNextButton> {
icon: const Icon(Icons.skip_next_rounded), 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( return LeafPopover(
controller: _popover, controller: _popover,
popoverAnchor: Alignment.topRight, popoverAnchor: Alignment.topRight,
@@ -177,6 +272,26 @@ class _UpNextButtonState extends State<UpNextButton> {
spacing: 8, spacing: 8,
onDismissed: _closed, onDismissed: _closed,
popoverBuilder: (context, c) => QueuePanel(queue: _q, onClose: c.hide), popoverBuilder: (context, c) => QueuePanel(queue: _q, onClose: c.hide),
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. // A little apart from the bar's other buttons: its own group.
child: Padding( child: Padding(
padding: const EdgeInsets.only(right: 8), padding: const EdgeInsets.only(right: 8),
@@ -185,11 +300,208 @@ class _UpNextButtonState extends State<UpNextButton> {
children: [label, if (play != null) playButton], children: [label, if (play != null) playButton],
), ),
), ),
),
),
); );
}); });
} }
} }
/// 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 { class _RoundIcon extends StatefulWidget {
final IconData icon; final IconData icon;
final String tooltip; final String tooltip;
@@ -577,12 +889,13 @@ class QueueThumb extends StatelessWidget {
final QueueController queue; final QueueController queue;
final QueueItem item; final QueueItem item;
final double width; 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return ClipRRect( return ClipRRect(
borderRadius: BorderRadius.circular(6), borderRadius: BorderRadius.circular(radius),
child: SizedBox( child: SizedBox(
width: width, width: width,
height: width * 9 / 16, height: width * 9 / 16,
@@ -590,12 +903,7 @@ class QueueThumb extends StatelessWidget {
listenable: queue.thumbs, listenable: queue.thumbs,
builder: (context, _) { builder: (context, _) {
final bytes = queue.thumbs.bytesFor(item.thumb); final bytes = queue.thumbs.bytesFor(item.thumb);
if (bytes == null) { if (bytes == null) return _blank(howIcon(item.how), width);
return ColoredBox(
color: const Color(0x1FFFFFFF),
child: Icon(howIcon(item.how), size: width / 4, color: Colors.white38),
);
}
return Image.memory(bytes, fit: BoxFit.cover, gaplessPlayback: true, filterQuality: FilterQuality.medium); return Image.memory(bytes, fit: BoxFit.cover, gaplessPlayback: true, filterQuality: FilterQuality.medium);
}, },
), ),
@@ -910,8 +1218,6 @@ class _ContinuationRowState extends State<_ContinuationRow> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final e = widget.episode; 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; final runtime = e.pick.runtime;
return MouseRegion( return MouseRegion(
onEnter: (_) => setState(() => _hover = true), onEnter: (_) => setState(() => _hover = true),
@@ -928,16 +1234,7 @@ class _ContinuationRowState extends State<_ContinuationRow> {
), ),
child: Row( child: Row(
children: [ children: [
ClipRRect( _EpisodeThumb(queue: widget.queue, episode: e, width: _thumb),
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),
),
),
const SizedBox(width: 12), const SizedBox(width: 12),
Expanded( Expanded(
child: Column( child: Column(
@@ -986,7 +1283,7 @@ class UpNextCard extends StatelessWidget {
final left = c.secondsLeft; final left = c.secondsLeft;
final r = queue.coord.serverState.value; final r = queue.coord.serverState.value;
final selfId = queue.coord.sync.memberId; 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( return AppGlass(
key: const ValueKey('up-next-card'), key: const ValueKey('up-next-card'),
child: SizedBox( 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( child: Row(
children: [ children: [
const Expanded(child: NowPlayingTitle()), 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( MaterialDesktopCustomButton(
icon: const Icon(Icons.video_library_outlined), icon: const Icon(Icons.video_library_outlined),
onPressed: widget.onChangeVideo, onPressed: widget.onChangeVideo,
@@ -571,7 +575,8 @@ class _SessionPlayerState extends State<SessionPlayer> {
top: Row( top: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
UpNextButton(queue: coord.queue, onPlayNextEpisode: coord.playNextEpisode, look: UpNextLook.uosc), UpNextButton(
queue: coord.queue, onPlayNextEpisode: coord.playNextEpisode, look: UpNextLook.uosc, hold: _hold),
UoscButton( UoscButton(
key: const ValueKey('uosc-chat'), key: const ValueKey('uosc-chat'),
icon: widget.chatOpen ? Icons.chat_bubble_rounded : Icons.chat_bubble_outline_rounded, icon: widget.chatOpen ? Icons.chat_bubble_rounded : Icons.chat_bubble_outline_rounded,
+13 -4
View File
@@ -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 - **UI**: `UpNextButton` is one of the player's top-bar controls, before
chat (see "Player overlay" under Desktop UI), in the look of the chat (see "Player overlay" under Desktop UI), in the look of the
design's buttons (`UpNextLook`); it shows and hides with the controls, design's buttons (`UpNextLook`); it shows and hides with the controls,
which an open popover holds up: "Up next · title", a +n count, ⏭ plays which an open popover holds up: the first item's picture (`QueueThumb`,
it now; without Up next the continuation ("Next · S1E8 …", ⏭ = next the rows' faint box and icon until it arrives), "UP NEXT" over its
episode); nothing when both are empty. Its title opens the queue as a 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) `LeafPopover` (package `leaf_widgets`, an overlay rather than a route)
hanging under it, right edges aligned: the app's glass (`AppGlass`), 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 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 tall like the status pill without avatars). The mouse on any
floating element counts as the mouse on the controls: a floating element counts as the mouse on the controls: a
`ControlsHold` (`controls/controls_probe.dart`) held by `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`, design's controls up (`UoscControls.holdBy`; `ControlsHoverProbe.hold`,
which re-wakes media_kit's controls through their private hover which re-wakes media_kit's controls through their private hover
handler, the same guarded dynamic call as `keepVideoControlsAwake`), handler, the same guarded dynamic call as `keepVideoControlsAwake`),