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:
1 parent
cf2805d3bd
commit
4275abe470
4 files changed
+379
-75
No files matched your search
@@ -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)
|
||||||
|
|||||||
@@ -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(
|
||||||
title,
|
mainAxisSize: MainAxisSize.min,
|
||||||
maxLines: 1,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
overflow: TextOverflow.ellipsis,
|
children: [
|
||||||
style: TextStyle(
|
Text(kicker,
|
||||||
color: queued ? Colors.white : dim, fontSize: fontSize, fontWeight: FontWeight.w500),
|
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) ...[
|
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,12 +272,34 @@ 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),
|
||||||
// A little apart from the bar's other buttons: its own group.
|
child: OverlayPortal(
|
||||||
child: Padding(
|
controller: _preview,
|
||||||
padding: const EdgeInsets.only(right: 8),
|
overlayChildBuilder: (context) => Positioned(
|
||||||
child: Row(
|
left: 0,
|
||||||
mainAxisSize: MainAxisSize.min,
|
top: 0,
|
||||||
children: [label, if (play != null) playButton],
|
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 {
|
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
@@ -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`),
|
||||||
|
|||||||
Reference in new issue
Block a user