Player overlay as one piece, Up next always warm and on every source, Jellyfin never unmarks a watched episode

- Overlay: one layout for both designs (top bar with Up next as one of
  its buttons; one action band above the controls for the status pill,
  skips, next episode and end cards), one glass recipe (AppGlass) and
  one pill (FloatingPill); hovering any of them, or the open queue,
  holds the controls up.
- Up next: the first item (or the first continuation episode) is kept
  resolved on the app that puts it up, refreshed before it expires;
  Schedule and drop/paste can queue too.
- Jellyfin: WatchSession (core) counts real watched coverage; an
  episode is watched at 80 % or at its ending after half; a played one
  is re-marked after Playing (10.11 unmarks on open) and never gets a
  position; a peek leaves the old resume point; part-way resume points
  stay under 85 %.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
foxyzandClaude Opus 5.5 committed 2026-10-11 07:53:42 +03:00
1 parent 6b10cae4e6
commit 771b961b32
27 files changed
+1396 -558

No files matched your search

+190 -179
View File
@@ -1,5 +1,4 @@
import 'dart:math' as math;
import 'dart:ui' show ImageFilter;
import 'package:flutter/material.dart';
import 'package:fox_utils/fox_utils.dart' show DesktopDropX, DropTarget;
@@ -7,6 +6,9 @@ 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/uosc/buttons.dart' show UoscButton;
import '../controls/uosc/controls.dart' show UoscControls;
import '../controls/uosc/style.dart';
import '../files/scanner.dart' show FileKind, kindOf;
import '../jellyfin/images.dart';
import '../jellyfin/session_media.dart' show EpisodeLink;
@@ -16,65 +18,41 @@ import '../shell/avatar.dart';
import '../shell/handoff.dart' show handoffHolds;
import '../shell/motion.dart';
import '../state/session_state.dart';
import '../widgets/app_menu.dart' show AppMenuStyle;
import '../widgets/app_menu.dart' show AppGlass, AppMenuStyle;
import 'queue_controller.dart';
import 'queue_logic.dart';
// Up next in the player: the chip top right (with the controls), 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.
// Up next in the player: its control in the top bar (with chat and
// 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.
/// Frosted dark glass like the app's menus.
class _Glass extends StatelessWidget {
final Widget child;
final double radius;
final Color color;
const _Glass({super.key, required this.child, this.radius = AppMenuStyle.radius, this.color = AppMenuStyle.glass});
// ---------------------------------------------------------------- button
@override
Widget build(BuildContext context) {
final r = BorderRadius.circular(radius);
return DecoratedBox(
decoration: BoxDecoration(
borderRadius: r,
boxShadow: const [BoxShadow(color: Color(0x66000000), blurRadius: 24, offset: Offset(0, 8))],
),
child: ClipRRect(
borderRadius: r,
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 16, sigmaY: 16),
child: Material(
color: color,
shape: RoundedRectangleBorder(
borderRadius: r, side: const BorderSide(color: AppMenuStyle.border, width: 0.5)),
child: child,
),
),
),
);
}
}
/// Which controls design the Up next control sits in: it takes the look
/// of that bar's buttons.
enum UpNextLook { standard, uosc }
// ------------------------------------------------------------------ chip
/// "Up next · <title>" with ▶ (play it now), top right while the controls
/// are up; the title opens the queue. Without Up next, the continuation
/// ("Next · S1E8 …", ▶ plays it). Nothing when both are empty.
class UpNextChip extends StatefulWidget {
/// "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.
class UpNextButton extends StatefulWidget {
final QueueController queue;
/// Plays the continuation's first episode (the next episode).
final VoidCallback onPlayNextEpisode;
const UpNextChip({super.key, required this.queue, required this.onPlayNextEpisode});
final UpNextLook look;
const UpNextButton({super.key, required this.queue, required this.onPlayNextEpisode, required this.look});
@override
State<UpNextChip> createState() => _UpNextChipState();
State<UpNextButton> createState() => _UpNextButtonState();
}
class _UpNextChipState extends State<UpNextChip> {
class _UpNextButtonState extends State<UpNextButton> {
final _popover = LeafPopoverController();
bool _hoverTitle = false;
QueueController get _q => widget.queue;
bool get _uosc => widget.look == UpNextLook.uosc;
@override
void dispose() {
@@ -102,8 +80,19 @@ class _UpNextChipState extends State<UpNextChip> {
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);
@override
Widget build(BuildContext context) {
// The bar's own metrics: uosc's flat 40 px boxes, or media_kit's
// round 44 px icon buttons.
final height = _uosc ? UoscControls.buttonRowHeight : 44.0;
final radius = _uosc ? UoscStyle.radius : height / 2;
final hover = _uosc ? UoscStyle.hover : Colors.white10;
final fontSize = _uosc ? UoscStyle.fontSize : 14.0;
final dim = _uosc ? UoscStyle.dim : Colors.white70;
return SignalBuilder(builder: (context) {
final items = _q.items.value;
final cont = _q.shownContinuation.value;
@@ -112,85 +101,89 @@ class _UpNextChipState extends State<UpNextChip> {
final queued = items.isNotEmpty;
final title = queued ? items.first.title : (cont.isNotEmpty ? cont.first.label : 'Nothing yet');
final more = items.length - 1;
return _Glass(
radius: 999,
color: const Color(0xB3141418),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
LeafPopover(
controller: _popover,
popoverAnchor: Alignment.topRight,
childAnchor: Alignment.bottomRight,
spacing: 8,
onDismissed: _closed,
popoverBuilder: (context, c) => QueuePanel(queue: _q, onClose: c.hide),
child: MouseRegion(
cursor: SystemMouseCursors.click,
onEnter: (_) => setState(() => _hoverTitle = true),
onExit: (_) => setState(() => _hoverTitle = false),
child: GestureDetector(
key: const ValueKey('up-next-chip'),
behavior: HitTestBehavior.opaque,
onTap: _toggle,
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
height: 36,
padding: const EdgeInsets.only(left: 12, right: 10),
color: _hoverTitle || open ? const Color(0x14FFFFFF) : Colors.transparent,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(queued ? Icons.queue_play_next_rounded : Icons.playlist_play_rounded,
size: 17, color: Colors.white70),
const SizedBox(width: 8),
Text(queued ? 'Up next' : 'Next',
style: const TextStyle(color: Colors.white60, fontSize: 12.5)),
const Text(' · ', style: TextStyle(color: Colors.white38, fontSize: 12.5)),
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 240),
child: Text(
title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: queued ? Colors.white : Colors.white70,
fontSize: 13,
fontWeight: FontWeight.w500),
),
),
if (more > 0) ...[
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
decoration: BoxDecoration(
color: const Color(0x24FFFFFF), borderRadius: BorderRadius.circular(999)),
child: Text('+$more', style: const TextStyle(color: Colors.white70, fontSize: 11)),
),
],
const SizedBox(width: 4),
AnimatedRotation(
duration: easeTime,
curve: ease,
turns: open ? 0.5 : 0,
child: const Icon(Icons.expand_more_rounded, size: 18, color: Colors.white54),
),
],
),
final label = MouseRegion(
cursor: SystemMouseCursors.click,
onEnter: (_) => setState(() => _hoverTitle = true),
onExit: (_) => setState(() => _hoverTitle = false),
child: GestureDetector(
key: const ValueKey('up-next-button'),
behavior: HitTestBehavior.opaque,
onTap: _toggle,
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
height: height,
padding: const EdgeInsets.only(left: 10, right: 6),
decoration: BoxDecoration(
color: _hoverTitle || open ? hover : Colors.transparent,
borderRadius: BorderRadius.circular(radius),
),
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)),
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),
),
),
),
if (more > 0) ...[
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
decoration: BoxDecoration(color: UoscStyle.hover, borderRadius: BorderRadius.circular(999)),
child: Text('+$more', style: TextStyle(color: dim, fontSize: 11)),
),
],
const SizedBox(width: 2),
AnimatedRotation(
duration: easeTime,
curve: ease,
turns: open ? 0.5 : 0,
child: Icon(Icons.expand_more_rounded, size: 18, color: dim),
),
],
),
if (queued || cont.isNotEmpty) ...[
Container(width: 0.5, height: 20, color: Colors.white24),
_RoundIcon(
),
),
);
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 playButton = _uosc
? UoscButton(key: const ValueKey('up-next-play'), icon: Icons.skip_next_rounded, tooltip: playTip, onPressed: play)
: _tip(
playTip,
IconButton(
key: const ValueKey('up-next-play'),
icon: Icons.play_arrow_rounded,
tooltip: queued ? 'Play ${items.first.title} now' : 'Play ${cont.first.label} now',
onPressed: queued ? _q.skip : widget.onPlayNextEpisode,
iconSize: 28,
color: Colors.white,
onPressed: play,
icon: const Icon(Icons.skip_next_rounded),
),
],
],
);
// The popover hangs under the whole control, right edges aligned.
return LeafPopover(
controller: _popover,
popoverAnchor: Alignment.topRight,
childAnchor: Alignment.bottomRight,
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],
),
),
);
});
@@ -378,7 +371,7 @@ class _QueuePanelState extends State<QueuePanel> {
width: 424,
child: ConstrainedBox(
constraints: BoxConstraints(maxHeight: math.max(260, math.min(600, height - 140))),
child: _Glass(
child: AppGlass(
child: AnimatedContainer(
duration: easeTime,
curve: ease,
@@ -403,13 +396,13 @@ class _QueuePanelState extends State<QueuePanel> {
? _empty()
: ListView(
shrinkWrap: true,
padding: const EdgeInsets.fromLTRB(6, 0, 6, 6),
padding: const EdgeInsets.fromLTRB(4, 0, 4, 4),
children: [
if (items.isEmpty)
const Padding(
padding: EdgeInsets.fromLTRB(10, 2, 10, 8),
padding: EdgeInsets.fromLTRB(12, 2, 12, 8),
child: Text('Nothing added yet. What plays next is below.',
style: TextStyle(color: Colors.white54, fontSize: 12.5)),
style: TextStyle(color: Colors.white54, fontSize: 12)),
)
else
ReorderableListView.builder(
@@ -458,17 +451,18 @@ class _QueuePanelState extends State<QueuePanel> {
);
}
// Text lines up with the rows' (4 panel + 12 row padding).
Widget _header(int count) => Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 8, 8),
padding: const EdgeInsets.fromLTRB(16, 8, 6, 4),
child: Row(
children: [
const Text('Up next', style: TextStyle(color: Colors.white, fontSize: 15, fontWeight: FontWeight.w600)),
const Text('Up next', style: TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.w600)),
if (count > 0) ...[
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 1),
decoration: BoxDecoration(color: const Color(0x24FFFFFF), borderRadius: BorderRadius.circular(999)),
child: Text('$count', style: const TextStyle(color: Colors.white70, fontSize: 11.5)),
decoration: BoxDecoration(color: AppMenuStyle.focus, borderRadius: BorderRadius.circular(999)),
child: Text('$count', style: const TextStyle(color: Colors.white70, fontSize: 11)),
),
],
const Spacer(),
@@ -484,13 +478,13 @@ class _QueuePanelState extends State<QueuePanel> {
children: [
Icon(Icons.queue_play_next_rounded, size: 34, color: Colors.white30),
SizedBox(height: 10),
Text('Nothing up next', style: TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.w500)),
Text('Nothing up next', style: TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.w600)),
SizedBox(height: 4),
Text(
'Paste a link below, drop a video or a link here, or pick "Add to queue" in the library. '
'It plays when this one ends.',
textAlign: TextAlign.center,
style: TextStyle(color: Colors.white54, fontSize: 12.5, height: 1.35),
style: TextStyle(color: Colors.white54, fontSize: 12, height: 1.35),
),
],
),
@@ -498,11 +492,11 @@ class _QueuePanelState extends State<QueuePanel> {
List<Widget> _continuation(List<EpisodeLink> cont, bool alone) => [
Padding(
padding: EdgeInsets.fromLTRB(10, alone ? 0 : 10, 2, 2),
padding: EdgeInsets.fromLTRB(12, alone ? 0 : 8, 2, 0),
child: Row(
children: [
const Text('CONTINUES WITH',
style: TextStyle(color: Colors.white38, fontSize: 10.5, letterSpacing: 0.8, fontWeight: FontWeight.w600)),
style: TextStyle(color: Colors.white38, fontSize: 11, letterSpacing: 0.8, fontWeight: FontWeight.w600)),
const Spacer(),
TextButton(
key: const ValueKey('queue-clear-continuation'),
@@ -518,7 +512,7 @@ class _QueuePanelState extends State<QueuePanel> {
Widget _footer() => Container(
decoration: const BoxDecoration(border: Border(top: BorderSide(color: AppMenuStyle.border, width: 0.5))),
padding: const EdgeInsets.fromLTRB(10, 8, 10, 10),
padding: const EdgeInsets.all(8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
@@ -530,14 +524,16 @@ class _QueuePanelState extends State<QueuePanel> {
// Read-only while adding, not disabled: focus stays here, so
// Space and the arrows do not reach the player.
readOnly: _busy,
style: const TextStyle(fontSize: 13.5),
style: const TextStyle(fontSize: 14),
onSubmitted: (_) => _submit(),
// The app's field look (as `AppSelect`): a faint fill, a
// hairline, the accent while focused.
decoration: InputDecoration(
isDense: true,
filled: true,
fillColor: const Color(0x14FFFFFF),
fillColor: const Color(0x0DFFFFFF),
hintText: _dragging ? 'Drop to add' : 'Paste a link to add',
hintStyle: const TextStyle(color: Colors.white38, fontSize: 13.5),
hintStyle: const TextStyle(color: Colors.white38, fontSize: 14),
prefixIcon: const Icon(Icons.add_link_rounded, size: 18, color: Colors.white54),
prefixIconConstraints: const BoxConstraints(minWidth: 38),
suffixIcon: _busy
@@ -547,7 +543,14 @@ class _QueuePanelState extends State<QueuePanel> {
)
: null,
contentPadding: const EdgeInsets.symmetric(vertical: 10, horizontal: 8),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: BorderSide.none),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppMenuStyle.rowRadius),
borderSide: const BorderSide(color: AppMenuStyle.border, width: 0.5),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppMenuStyle.rowRadius),
borderSide: BorderSide(color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.6), width: 0.5),
),
),
),
AnimatedSize(
@@ -557,7 +560,7 @@ class _QueuePanelState extends State<QueuePanel> {
child: _error == null
? const SizedBox(width: double.infinity)
: Padding(
padding: const EdgeInsets.fromLTRB(4, 6, 4, 0),
padding: const EdgeInsets.fromLTRB(8, 6, 8, 0),
child: Text(_error!, style: TextStyle(color: Colors.orange.shade300, fontSize: 12)),
),
),
@@ -629,7 +632,7 @@ class _MetaLine extends StatelessWidget {
/// Wraps onto a second line instead of cutting the end off.
final bool wrap;
const _MetaLine(
{required this.item, required this.members, required this.selfId, this.fontSize = 11.5, this.wrap = false});
{required this.item, required this.members, required this.selfId, this.fontSize = 12, this.wrap = false});
@override
Widget build(BuildContext context) {
@@ -637,7 +640,7 @@ class _MetaLine extends StatelessWidget {
final dur = queueClock(item.duration);
final mine = item.by == selfId && selfId.isNotEmpty;
final name = mine ? 'you' : (item.byName.isEmpty ? '?' : item.byName);
const dot = Text(' · ', style: TextStyle(color: Colors.white24, fontSize: 11));
final dot = Text(' · ', style: TextStyle(color: Colors.white24, fontSize: fontSize));
if (wrap) {
final how = queueHowLabel(item, selfId: selfId);
return Wrap(
@@ -681,6 +684,15 @@ class _MetaLine extends StatelessWidget {
}
}
/// Every row of the popover (queued and continuation): one height, the
/// drag handle's width before the picture (a spacer where there is none),
/// one picture size and one type scale.
const queueRowHeight = 64.0;
const _lead = 22.0;
const _thumb = 80.0;
const _titleStyle = TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.w500);
const _subStyle = TextStyle(color: Colors.white60, fontSize: 12);
class _QueueRow extends StatefulWidget {
final int index;
final QueueItem item;
@@ -711,8 +723,8 @@ class _QueueRowState extends State<_QueueRow> {
final item = widget.item;
final row = AnimatedContainer(
duration: const Duration(milliseconds: 120),
margin: const EdgeInsets.symmetric(vertical: 1),
padding: const EdgeInsets.fromLTRB(2, 6, 8, 6),
height: queueRowHeight,
padding: const EdgeInsets.only(right: 8),
decoration: BoxDecoration(
color: _hover ? AppMenuStyle.hover : Colors.transparent,
borderRadius: BorderRadius.circular(AppMenuStyle.rowRadius),
@@ -724,8 +736,8 @@ class _QueueRowState extends State<_QueueRow> {
child: MouseRegion(
cursor: SystemMouseCursors.grab,
child: SizedBox(
width: 22,
height: 40,
width: _lead,
height: queueRowHeight,
child: AnimatedOpacity(
duration: const Duration(milliseconds: 120),
opacity: _hover ? 0.8 : 0.25,
@@ -734,29 +746,22 @@ class _QueueRowState extends State<_QueueRow> {
),
),
),
QueueThumb(queue: widget.queue, item: item),
const SizedBox(width: 10),
// Its readiness rides on the picture's corner.
Stack(
children: [
QueueThumb(queue: widget.queue, item: item, width: _thumb),
if (widget.warm != null) Positioned(top: 3, right: 3, child: _WarmDot(state: widget.warm!)),
],
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
Flexible(
child: Text(item.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Colors.white, fontSize: 13.5, fontWeight: FontWeight.w500)),
),
if (widget.warm != null) ...[const SizedBox(width: 6), _WarmDot(state: widget.warm!)],
],
),
Text(item.title, maxLines: 1, overflow: TextOverflow.ellipsis, style: _titleStyle),
if (item.sub.isNotEmpty)
Text(item.sub,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Colors.white60, fontSize: 11.5)),
Text(item.sub, maxLines: 1, overflow: TextOverflow.ellipsis, style: _subStyle),
const SizedBox(height: 2),
_MetaLine(item: item, members: widget.members, selfId: widget.selfId),
],
@@ -859,22 +864,30 @@ class _WarmDotState extends State<_WarmDot> with SingleTickerProviderStateMixin
Widget build(BuildContext context) {
final green = Colors.greenAccent.shade400;
final (color, filled, tip) = switch (widget.state) {
WarmState.idle => (Colors.white54, false, 'Gets ready near the end of this one'),
WarmState.idle => (Colors.white54, false, 'Waiting to get ready'),
WarmState.warming => (green, true, 'Getting it ready…'),
WarmState.ready => (green, true, 'Ready to start'),
WarmState.failed => (Colors.redAccent, true, "Couldn't get it ready; it is tried again when it comes up"),
WarmState.failed => (Colors.redAccent, true, "Couldn't get it ready; trying again shortly"),
};
return Tooltip(
message: tip,
child: FadeTransition(
opacity: Tween(begin: 0.35, end: 1.0).animate(_pulse),
child: Container(
width: 8,
height: 8,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: filled ? color : Colors.transparent,
border: Border.all(color: color, width: 1.4),
// On a dark disc: it sits on the picture's corner.
child: Container(
key: const ValueKey('queue-warm-dot'),
width: 14,
height: 14,
alignment: Alignment.center,
decoration: const BoxDecoration(shape: BoxShape.circle, color: Color(0xB3000000)),
child: FadeTransition(
opacity: Tween(begin: 0.35, end: 1.0).animate(_pulse),
child: Container(
width: 8,
height: 8,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: filled ? color : Colors.transparent,
border: Border.all(color: color, width: 1.4),
),
),
),
),
@@ -907,7 +920,8 @@ class _ContinuationRowState extends State<_ContinuationRow> {
duration: const Duration(milliseconds: 120),
opacity: _hover ? 0.9 : 0.55,
child: Container(
padding: const EdgeInsets.fromLTRB(24, 5, 6, 5),
height: queueRowHeight,
padding: const EdgeInsets.only(left: _lead, right: 8),
decoration: BoxDecoration(
color: _hover ? AppMenuStyle.hover : Colors.transparent,
borderRadius: BorderRadius.circular(AppMenuStyle.rowRadius),
@@ -917,30 +931,27 @@ class _ContinuationRowState extends State<_ContinuationRow> {
ClipRRect(
borderRadius: BorderRadius.circular(6),
child: SizedBox(
width: 72,
height: 40.5,
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: 10),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(e.label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Colors.white, fontSize: 13)),
Text(e.label, maxLines: 1, overflow: TextOverflow.ellipsis, style: _titleStyle),
Text(
[if (runtime != null) queueClock(runtime.inSeconds.toDouble()), e.item.seriesName ?? '']
.where((s) => s.isNotEmpty)
.join(' · '),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Colors.white54, fontSize: 11.5),
style: _subStyle,
),
],
),
@@ -976,7 +987,7 @@ class UpNextCard extends StatelessWidget {
final r = queue.coord.serverState.value;
final selfId = queue.coord.sync.memberId;
final waiting = left == null ? _waitingFor(item, r) : null;
return _Glass(
return AppGlass(
key: const ValueKey('up-next-card'),
child: SizedBox(
width: 400,