Protocol 12: Up next (a shared queue) and where the video comes from
Up next: a server-side queue anyone edits by item id (add at end or next, move, remove, play now, skip); each item says who added it and how it plays. At the real end the first item swaps in after the end card's countdown, put up by whoever can (its adder for through-me links and files, someone with access for Jellyfin, else anyone); items that can't play are skipped with the reason. The next item's link is resolved ahead. Greyed "Continues with" episodes below. A chip with the controls opens it as a popover (leaf_widgets, local path dependency for now); "Add to queue" next to Put it up everywhere. Delivery: the server measures the source's rate, the media's bitrate and each member's read rate; the members card shows a source line (who passes it on, through what, keeping up or not, source left) and each member's route and buffer. A slow source is told once per media. A failed server fetch is announced to everyone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
95cd668cfb
commit
6b10cae4e6
50 files changed
+4239
-157
No files matched your search
@@ -420,7 +420,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
||||
// --------------------------------------------------------------- handoff
|
||||
|
||||
/// Nothing (a dialog, a popover) is over the shell.
|
||||
bool get _onTop => !_handoffOpen && (ModalRoute.of(context)?.isCurrent ?? true);
|
||||
bool get _onTop => !_handoffOpen && handoffHolds == 0 && (ModalRoute.of(context)?.isCurrent ?? true);
|
||||
|
||||
/// Ctrl+V outside a text field (a focused field pastes on its own).
|
||||
bool _onKey(KeyEvent e) {
|
||||
@@ -673,6 +673,8 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
||||
),
|
||||
jellyfinStart: _libraryStart,
|
||||
onPutUp: _putUp,
|
||||
// Up next needs a server (a solo session has one).
|
||||
onAddToQueue: online ? coord.queue.add : null,
|
||||
onOpenIntegrations: () => _openSettings(SettingsSection.integrations),
|
||||
),
|
||||
ShellTab.schedule => SchedulePage(
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:signals/signals_flutter.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
|
||||
import '../screens/home/profile.dart';
|
||||
import '../session/delivery.dart';
|
||||
import '../session/session_coordinator.dart';
|
||||
import '../state/session_state.dart';
|
||||
import 'motion.dart';
|
||||
@@ -22,6 +23,9 @@ class AvatarInfo {
|
||||
/// Why their player cannot open the media (`members[].error`): an error
|
||||
/// mark; empty when it can.
|
||||
final String error;
|
||||
/// How it gets the media and its buffer ([MemberDelivery.detail]), for
|
||||
/// the hover; empty when unknown.
|
||||
final String delivery;
|
||||
const AvatarInfo({
|
||||
this.id = '',
|
||||
required this.name,
|
||||
@@ -35,6 +39,7 @@ class AvatarInfo {
|
||||
this.self = false,
|
||||
this.out = false,
|
||||
this.error = '',
|
||||
this.delivery = '',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -103,6 +108,7 @@ AvatarInfo memberAvatarInfo(MemberView m, StateBroadcast? r, SessionCoordinator
|
||||
final self = m.id.isNotEmpty && m.id == coord.sync.memberId;
|
||||
final relay = r == null ? null : stateMediaRelay(r);
|
||||
final playing = r?.media != null;
|
||||
final delivery = r == null ? null : deliveryOf(r, selfId: coord.sync.memberId, names: coord.memberNames);
|
||||
return AvatarInfo(
|
||||
id: m.id,
|
||||
name: m.name,
|
||||
@@ -118,6 +124,7 @@ AvatarInfo memberAvatarInfo(MemberView m, StateBroadcast? r, SessionCoordinator
|
||||
self: self,
|
||||
out: m.out,
|
||||
error: m.error,
|
||||
delivery: delivery == null ? '' : memberDeliveryOf(m, r!, delivery).detail,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -304,9 +311,12 @@ class _AvatarStackState extends State<AvatarStack> {
|
||||
left: i * step,
|
||||
top: 0,
|
||||
child: Tooltip(
|
||||
message: visible[i].error.isEmpty
|
||||
? visible[i].name
|
||||
: "${visible[i].name} can't play this: ${visible[i].error}",
|
||||
message: [
|
||||
visible[i].error.isEmpty
|
||||
? visible[i].name
|
||||
: "${visible[i].name} can't play this: ${visible[i].error}",
|
||||
if (visible[i].delivery.isNotEmpty) visible[i].delivery,
|
||||
].join('\n'),
|
||||
child: StatusAvatar(info: visible[i], size: size),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -237,6 +237,15 @@ class ActivityRow extends StatelessWidget {
|
||||
return (Icons.bedtime, Colors.blueGrey, who == null || who.isEmpty ? 'is sitting out' : 'sat $who out');
|
||||
case 'back':
|
||||
return (Icons.login, Colors.green, 'joined back');
|
||||
case 'queue-add':
|
||||
return (Icons.playlist_add_rounded, scheme.primary, 'added ${f.target ?? ''} to Up next');
|
||||
case 'queue-remove':
|
||||
return (Icons.playlist_remove_rounded, Colors.grey, 'removed ${f.target ?? ''} from Up next');
|
||||
case 'queue-skip':
|
||||
final why = f.reason.isEmpty ? '' : ' · ${f.reason}';
|
||||
return (Icons.redo_rounded, Colors.orange, 'skipped ${f.target ?? ''}$why');
|
||||
case 'fetch-fail':
|
||||
return (Icons.cloud_off, Colors.red, "couldn't fetch ${f.target ?? 'the video'}");
|
||||
default:
|
||||
final target = f.target == null || f.target!.isEmpty ? '' : ' ${f.target}';
|
||||
return (Icons.info_outline, Colors.grey, '${f.action}$target');
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../session/delivery.dart';
|
||||
import 'motion.dart';
|
||||
|
||||
// The members card's view of [Delivery]: one source line on top, a buffer
|
||||
// dot on each member. Nothing of it sits on the picture.
|
||||
|
||||
Color _tone(SourceState s, ColorScheme scheme) => switch (s) {
|
||||
SourceState.ok => scheme.primary,
|
||||
SourceState.slow || SourceState.waiting => Colors.amber,
|
||||
SourceState.left => Colors.redAccent.shade200,
|
||||
};
|
||||
|
||||
IconData _icon(Delivery d) => switch (d.state) {
|
||||
SourceState.left => Icons.link_off_rounded,
|
||||
SourceState.waiting => Icons.hourglass_top_rounded,
|
||||
_ => switch (d.kind) {
|
||||
DeliveryKind.memberRelay => Icons.cell_tower,
|
||||
DeliveryKind.serverFetch => Icons.cloud_download_outlined,
|
||||
DeliveryKind.hostFile => Icons.folder_shared_outlined,
|
||||
DeliveryKind.serverFile => Icons.dns_outlined,
|
||||
DeliveryKind.link => Icons.public,
|
||||
DeliveryKind.jellyfin => Icons.video_library_outlined,
|
||||
},
|
||||
};
|
||||
|
||||
/// Where the current media comes from and how it reaches the others:
|
||||
/// "Foxyz passes it on through the server", "3 through the server ·
|
||||
/// 4.1 MB/s, needs 2.3 MB/s" and a bar of the source's speed against the
|
||||
/// video's (past the mark: keeping up). Amber when slow or waiting, red
|
||||
/// when the source left.
|
||||
class SourceLine extends StatelessWidget {
|
||||
final Delivery delivery;
|
||||
const SourceLine({super.key, required this.delivery});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final d = delivery;
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final tone = _tone(d.state, scheme);
|
||||
final detail = d.detail;
|
||||
final headroom = d.state == SourceState.left ? null : d.headroom;
|
||||
return Tooltip(
|
||||
message: d.explain,
|
||||
waitDuration: const Duration(milliseconds: 400),
|
||||
child: Container(
|
||||
key: const ValueKey('source-line'),
|
||||
margin: const EdgeInsets.fromLTRB(12, 0, 12, 6),
|
||||
padding: const EdgeInsets.fromLTRB(10, 8, 12, 8),
|
||||
decoration: BoxDecoration(
|
||||
color: d.state == SourceState.ok ? scheme.surfaceContainerHigh : tone.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 1),
|
||||
child: Icon(_icon(d), size: 18, color: tone),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(d.title, style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600)),
|
||||
if (detail.isNotEmpty) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(detail, style: TextStyle(fontSize: 11.5, color: scheme.onSurfaceVariant)),
|
||||
],
|
||||
if (headroom != null) ...[
|
||||
const SizedBox(height: 6),
|
||||
_HeadroomBar(headroom: headroom, slow: d.state == SourceState.slow),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The source's speed on a scale to twice the video's bitrate; the mark
|
||||
/// in the middle is what the video needs.
|
||||
class _HeadroomBar extends StatelessWidget {
|
||||
final double headroom;
|
||||
final bool slow;
|
||||
const _HeadroomBar({required this.headroom, required this.slow});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final fill = (headroom / 2).clamp(0.04, 1.0);
|
||||
final color = slow ? Colors.amber : Colors.greenAccent.shade400;
|
||||
return SizedBox(
|
||||
key: const ValueKey('headroom-bar'),
|
||||
height: 4,
|
||||
child: LayoutBuilder(
|
||||
builder: (context, box) => Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: scheme.outlineVariant.withValues(alpha: 0.4),
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
),
|
||||
AnimatedContainer(
|
||||
duration: easeTime,
|
||||
curve: ease,
|
||||
width: box.maxWidth * fill,
|
||||
decoration: BoxDecoration(color: color, borderRadius: BorderRadius.circular(2)),
|
||||
),
|
||||
Positioned(
|
||||
left: box.maxWidth / 2 - 0.75,
|
||||
top: -1,
|
||||
bottom: -1,
|
||||
child: Container(width: 1.5, color: scheme.onSurface.withValues(alpha: 0.7)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A member's buffer at a glance: green enough to play on, amber low,
|
||||
/// red empty while playing.
|
||||
Color healthColor(BufferHealth h) => switch (h) {
|
||||
BufferHealth.good => Colors.greenAccent.shade400,
|
||||
BufferHealth.low => Colors.amber,
|
||||
BufferHealth.starving => Colors.redAccent.shade200,
|
||||
};
|
||||
|
||||
/// The members card's subtitle: [status] ("ready"), then how the member
|
||||
/// gets the media (the source's own pill says it serves it), after a dot
|
||||
/// coloured by its buffer; the hover says how much it has buffered.
|
||||
class DeliveryStatus extends StatelessWidget {
|
||||
final String status;
|
||||
final MemberDelivery? delivery;
|
||||
const DeliveryStatus({super.key, required this.status, this.delivery});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final d = delivery;
|
||||
final route = d == null || d.route == DeliveryRoute.source ? '' : d.short;
|
||||
final text = Text([status, if (route.isNotEmpty) route].join(' · '),
|
||||
maxLines: 1, overflow: TextOverflow.ellipsis);
|
||||
if (d == null) return text;
|
||||
final health = d.health;
|
||||
return Tooltip(
|
||||
message: d.detail,
|
||||
waitDuration: const Duration(milliseconds: 400),
|
||||
child: Row(
|
||||
children: [
|
||||
if (health != null) ...[
|
||||
AnimatedContainer(
|
||||
key: const ValueKey('health-dot'),
|
||||
duration: easeTime,
|
||||
curve: ease,
|
||||
width: 7,
|
||||
height: 7,
|
||||
decoration: BoxDecoration(color: healthColor(health), shape: BoxShape.circle),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
],
|
||||
Flexible(child: text),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,11 @@ import '../files/ui/file_tiles.dart' show baseName;
|
||||
// field: what it is ([classifyHandoff], pure) and the small card that asks
|
||||
// how to open it ([HandoffCard]). The shell wires both.
|
||||
|
||||
/// Open overlays over the shell that take drops and pastes themselves
|
||||
/// (the Up next popover, which is not a route): the shell's own handoff
|
||||
/// stays off while any is open.
|
||||
int handoffHolds = 0;
|
||||
|
||||
/// What was dropped or pasted.
|
||||
sealed class Handoff {
|
||||
const Handoff();
|
||||
|
||||
@@ -7,6 +7,7 @@ import 'package:sync_relay_core/core.dart' show LinkProbe, hasMediaExtension, pa
|
||||
|
||||
import '../playback/yt_dlp_app.dart';
|
||||
import '../screens/home/media_picker.dart';
|
||||
import '../widgets/app_menu.dart';
|
||||
import '../state/connect_prefs.dart';
|
||||
import 'motion.dart';
|
||||
|
||||
@@ -217,12 +218,15 @@ class LibraryLinkSource extends StatefulWidget {
|
||||
final bool solo;
|
||||
/// Puts the link up; returns an error text, or null when sent.
|
||||
final Future<String?> Function(LinkMedia media) onPutUp;
|
||||
/// Adds the link to Up next in the chosen mode; null: no server.
|
||||
final AddToQueue? onAddToQueue;
|
||||
final RecentLinks recents;
|
||||
final LinkProber probe;
|
||||
const LibraryLinkSource({
|
||||
super.key,
|
||||
required this.controller,
|
||||
required this.onPutUp,
|
||||
this.onAddToQueue,
|
||||
required this.recents,
|
||||
this.solo = false,
|
||||
this.probe = defaultLinkProber,
|
||||
@@ -328,6 +332,49 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
|
||||
});
|
||||
}
|
||||
|
||||
/// "Add to queue" (or, from a recent card's menu, [url] and [next]):
|
||||
/// the link in the chosen mode; the probe refuses what will not play.
|
||||
Future<void> _queue({String? url, bool next = false}) async {
|
||||
final add = widget.onAddToQueue;
|
||||
final link = url ?? _c.link.text.trim();
|
||||
if (add == null || link.isEmpty || _busy) return;
|
||||
setState(() {
|
||||
_busy = true;
|
||||
_error = null;
|
||||
_canForce = false;
|
||||
});
|
||||
String? error;
|
||||
try {
|
||||
error = await add(LinkMedia(link, relay: widget.solo ? LinkRelay.direct : _c.linkRelay), next: next);
|
||||
} catch (e) {
|
||||
error = '$e';
|
||||
}
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_busy = false;
|
||||
_error = error;
|
||||
});
|
||||
}
|
||||
|
||||
/// Right-click on a recent link: put it up or queue it.
|
||||
Future<void> _recentMenu(BuildContext context, RecentLink l) async {
|
||||
final pick = await showAppMenu<String>(context, items: [
|
||||
AppMenuItem(value: 'up', label: widget.solo ? 'Play' : 'Put it up'),
|
||||
if (widget.onAddToQueue != null) ...const [
|
||||
AppMenuItem(value: 'queue', label: 'Add to queue'),
|
||||
AppMenuItem(value: 'next', label: 'Play next'),
|
||||
],
|
||||
]);
|
||||
if (pick == null || !mounted) return;
|
||||
_c.link.text = l.url;
|
||||
switch (pick) {
|
||||
case 'up':
|
||||
await _go();
|
||||
case 'queue' || 'next':
|
||||
await _queue(url: l.url, next: pick == 'next');
|
||||
}
|
||||
}
|
||||
|
||||
Widget _modeCard((LinkRelay, IconData, String, String) m, {bool compact = false}) => _ModeCard(
|
||||
key: ValueKey('link-mode-${m.$1.name}'),
|
||||
icon: m.$2,
|
||||
@@ -372,6 +419,22 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
),
|
||||
);
|
||||
final queue = widget.onAddToQueue == null
|
||||
? null
|
||||
: Tooltip(
|
||||
message: 'Plays after what is on now (right-click a recent link for Play next)',
|
||||
waitDuration: const Duration(milliseconds: 600),
|
||||
child: OutlinedButton.icon(
|
||||
key: const ValueKey('link-add-queue'),
|
||||
onPressed: url.isEmpty || _busy ? null : () => _queue(),
|
||||
icon: const Icon(Icons.playlist_add_rounded),
|
||||
label: const Text('Add to queue'),
|
||||
style: OutlinedButton.styleFrom(
|
||||
minimumSize: const Size(0, 56),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
return SingleChildScrollView(
|
||||
key: const ValueKey('media-link'),
|
||||
@@ -408,8 +471,14 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
|
||||
field,
|
||||
const SizedBox(height: 10),
|
||||
button,
|
||||
if (queue != null) ...[const SizedBox(height: 8), queue],
|
||||
])
|
||||
: Row(children: [Expanded(child: field), const SizedBox(width: 12), button]),
|
||||
: Row(children: [
|
||||
Expanded(child: field),
|
||||
const SizedBox(width: 12),
|
||||
button,
|
||||
if (queue != null) ...[const SizedBox(width: 8), queue],
|
||||
]),
|
||||
),
|
||||
if (solo || _error != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
@@ -510,7 +579,8 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
|
||||
? const SizedBox.shrink()
|
||||
: Padding(
|
||||
padding: const EdgeInsets.only(top: 32),
|
||||
child: _RecentGrid(recents: recents, onPick: (l) => _c.link.text = l.url),
|
||||
child: _RecentGrid(
|
||||
recents: recents, onPick: (l) => _c.link.text = l.url, onMenu: _recentMenu),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -655,7 +725,9 @@ class _ModeCard extends StatelessWidget {
|
||||
class _RecentGrid extends StatelessWidget {
|
||||
final List<RecentLink> recents;
|
||||
final ValueChanged<RecentLink> onPick;
|
||||
const _RecentGrid({required this.recents, required this.onPick});
|
||||
/// Right-click: Put it up / Add to queue / Play next.
|
||||
final void Function(BuildContext context, RecentLink link) onMenu;
|
||||
const _RecentGrid({required this.recents, required this.onPick, required this.onMenu});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -680,9 +752,11 @@ class _RecentGrid extends StatelessWidget {
|
||||
child: Tooltip(
|
||||
message: l.url,
|
||||
waitDuration: const Duration(milliseconds: 600),
|
||||
child: InkWell(
|
||||
// The card's own context: the menu opens at the card.
|
||||
child: Builder(builder: (context) => InkWell(
|
||||
key: ValueKey('recent-link-${l.url}'),
|
||||
onTap: () => onPick(l),
|
||||
onSecondaryTap: () => onMenu(context, l),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -709,7 +783,7 @@ class _RecentGrid extends StatelessWidget {
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
)),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -78,6 +78,8 @@ class LibraryTab extends StatefulWidget {
|
||||
final BrowseStart? jellyfinStart;
|
||||
/// Puts the pick up; returns an error text, or null when sent.
|
||||
final Future<String?> Function(PickedMedia media) onPutUp;
|
||||
/// Adds a pick to Up next; null without a server (no queue actions).
|
||||
final AddToQueue? onAddToQueue;
|
||||
final VoidCallback? onPutUpDone;
|
||||
final VoidCallback? onOpenIntegrations;
|
||||
/// Defaults to [RecentLinks.instance].
|
||||
@@ -97,6 +99,7 @@ class LibraryTab extends StatefulWidget {
|
||||
required this.solo,
|
||||
required this.isHost,
|
||||
required this.onPutUp,
|
||||
this.onAddToQueue,
|
||||
this.onPutUpDone,
|
||||
this.jellyfinStart,
|
||||
this.onOpenIntegrations,
|
||||
@@ -164,6 +167,13 @@ class _LibraryTabState extends State<LibraryTab> {
|
||||
|
||||
/// A Jellyfin card or episode: a series goes up as its next episode.
|
||||
Future<String?> _putUpJellyfin(JellyfinSelection sel) async {
|
||||
final pick = await _jellyfinPick(sel);
|
||||
return pick is JellyfinMedia ? _put(pick) : pick as String;
|
||||
}
|
||||
|
||||
/// What a Jellyfin card stands for (a series: its next episode), or an
|
||||
/// error text.
|
||||
Future<Object> _jellyfinPick(JellyfinSelection sel) async {
|
||||
final account = widget.accounts.account(sel.serverId);
|
||||
final client = widget.accounts.clientFor(sel.serverId);
|
||||
if (account == null || client == null) return 'that Jellyfin server is not linked any more';
|
||||
@@ -175,7 +185,23 @@ class _LibraryTabState extends State<LibraryTab> {
|
||||
return e.message;
|
||||
}
|
||||
if (item == null) return 'nothing to play in there';
|
||||
return _put(pickFor(account, item));
|
||||
return pickFor(account, item);
|
||||
}
|
||||
|
||||
/// "Add to queue" on a Jellyfin card, episode or the details panel.
|
||||
Future<String?> _queueJellyfin(JellyfinSelection sel, {bool next = false}) async {
|
||||
final add = widget.onAddToQueue;
|
||||
if (add == null) return null;
|
||||
final pick = await _jellyfinPick(sel);
|
||||
return pick is JellyfinMedia ? add(pick, next: next) : pick as String;
|
||||
}
|
||||
|
||||
/// A card's menu: errors as a snack bar.
|
||||
Future<void> _cardQueue(JellyfinSelection sel, {bool next = false}) async {
|
||||
final error = await _queueJellyfin(sel, next: next);
|
||||
if (error != null && mounted) {
|
||||
ScaffoldMessenger.maybeOf(context)?.showSnackBar(SnackBar(content: Text(error)));
|
||||
}
|
||||
}
|
||||
|
||||
/// Double-click on a card: put it up, errors as a snack bar.
|
||||
@@ -235,6 +261,7 @@ class _LibraryTabState extends State<LibraryTab> {
|
||||
selected: _c.selected,
|
||||
onSelect: _c.select,
|
||||
onPutUp: _doublePutUp,
|
||||
onAddToQueue: widget.onAddToQueue == null ? null : _cardQueue,
|
||||
onOpenIntegrations: widget.onOpenIntegrations,
|
||||
start: widget.jellyfinStart,
|
||||
libraryStrip: false,
|
||||
@@ -251,6 +278,7 @@ class _LibraryTabState extends State<LibraryTab> {
|
||||
recents: widget.recentLinks ?? RecentLinks.instance,
|
||||
probe: widget.probe,
|
||||
onPutUp: _put,
|
||||
onAddToQueue: widget.onAddToQueue,
|
||||
),
|
||||
MediaKind.file => _files(context),
|
||||
};
|
||||
@@ -282,6 +310,7 @@ class _LibraryTabState extends State<LibraryTab> {
|
||||
selection: shown,
|
||||
solo: widget.solo,
|
||||
onPutUp: _putUpJellyfin,
|
||||
onAddToQueue: widget.onAddToQueue == null ? null : _queueJellyfin,
|
||||
onClose: () => _c.select(null),
|
||||
),
|
||||
));
|
||||
@@ -348,7 +377,13 @@ class _LibraryTabState extends State<LibraryTab> {
|
||||
if (b != null) {
|
||||
return b(context, solo: widget.solo, relayed: relayed, onPlay: play, nowPlayingPath: now);
|
||||
}
|
||||
return FilesPage(solo: widget.solo, relayed: relayed, onPlay: play, nowPlayingPath: now);
|
||||
final add = widget.onAddToQueue;
|
||||
return FilesPage(
|
||||
solo: widget.solo,
|
||||
relayed: relayed,
|
||||
onPlay: play,
|
||||
onAddToQueue: add == null ? null : (path, {next = false}) => add(FileMedia(path), next: next),
|
||||
nowPlayingPath: now);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,13 +7,14 @@ import 'package:sync_relay_core/core.dart';
|
||||
import 'package:window_manager/window_manager.dart';
|
||||
|
||||
import '../screens/home/update_indicator.dart';
|
||||
import '../screens/session/jellyfin_widgets.dart' show ViaHostBadge;
|
||||
import '../session/delivery.dart';
|
||||
import '../state/session_state.dart';
|
||||
import '../update/update_controller.dart';
|
||||
import '../widgets/app_keys.dart';
|
||||
import '../widgets/app_menu.dart';
|
||||
import '../widgets/window_buttons.dart';
|
||||
import 'avatar.dart';
|
||||
import 'delivery_line.dart';
|
||||
import 'motion.dart';
|
||||
|
||||
/// The shell's tabs, in strip order.
|
||||
@@ -35,10 +36,6 @@ const stripAvatarSize = 42.0;
|
||||
/// so the tabs keep their room.
|
||||
const stripCompactBelow = 1100.0;
|
||||
|
||||
/// The member plays the current Jellyfin item through the host's relay.
|
||||
bool viaHostOf(MemberView m, JellyfinRef? ref) =>
|
||||
ref != null && m.jf[ref.serverId] == false;
|
||||
|
||||
/// The 60 px strip over everything: your avatar (profile), the tabs, and
|
||||
/// the party: offline Join a friend / Host, solo a "Solo" chip, Invite
|
||||
/// friends and Stop, online the avatars (members card), server and
|
||||
@@ -483,6 +480,9 @@ class MembersCard extends StatelessWidget {
|
||||
final address = hosting
|
||||
? null
|
||||
: '${session.lastForm.host}:${session.lastForm.port}';
|
||||
final delivery = r == null
|
||||
? null
|
||||
: deliveryOf(r, selfId: coord.sync.memberId, names: coord.memberNames);
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
@@ -494,11 +494,12 @@ class MembersCard extends StatelessWidget {
|
||||
style: const TextStyle(fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
if (delivery != null) SourceLine(delivery: delivery),
|
||||
for (final m in r?.members ?? const <MemberView>[])
|
||||
_MemberTile(
|
||||
member: m,
|
||||
info: memberAvatarInfo(m, r, coord),
|
||||
viaHost: viaHostOf(m, coord.currentRef.value),
|
||||
delivery: delivery == null ? null : memberDeliveryOf(m, r!, delivery),
|
||||
),
|
||||
if (r == null)
|
||||
Padding(
|
||||
@@ -540,11 +541,12 @@ class MembersCard extends StatelessWidget {
|
||||
class _MemberTile extends StatelessWidget {
|
||||
final MemberView member;
|
||||
final AvatarInfo info;
|
||||
final bool viaHost;
|
||||
/// How it gets the media and its buffer; null with nothing up or alone.
|
||||
final MemberDelivery? delivery;
|
||||
const _MemberTile({
|
||||
required this.member,
|
||||
required this.info,
|
||||
required this.viaHost,
|
||||
this.delivery,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -575,10 +577,9 @@ class _MemberTile extends StatelessWidget {
|
||||
if (info.host) const Pill('host', Colors.amber),
|
||||
if (info.relaying)
|
||||
const Pill('passing the video on', Colors.lightBlueAccent),
|
||||
if (viaHost) const ViaHostBadge(),
|
||||
],
|
||||
),
|
||||
subtitle: Text(status),
|
||||
subtitle: DeliveryStatus(status: status, delivery: delivery),
|
||||
trailing: AppMenuButton<String>(
|
||||
tooltip: 'More',
|
||||
onSelected: (v) {
|
||||
|
||||
@@ -14,6 +14,7 @@ import '../controls/uosc/buttons.dart';
|
||||
import '../controls/uosc/controls.dart';
|
||||
import '../controls/uosc/timeline.dart';
|
||||
import '../controls/volume.dart';
|
||||
import '../queue/queue_ui.dart';
|
||||
import '../playback/media_marks.dart' show SkipKind, SkipOffer, formatClock;
|
||||
import '../screens/session/jellyfin_widgets.dart';
|
||||
import '../screens/session/player_controls.dart';
|
||||
@@ -200,6 +201,12 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
// mpv's playlist keys (< >) and Shift+P / Shift+N: Jellyfin episodes.
|
||||
final shift = HardwareKeyboard.instance.isShiftPressed;
|
||||
if (event.character == '>' || (shift && key == LogicalKeyboardKey.keyN)) {
|
||||
// Up next first, then the next episode.
|
||||
if (coord.queue.items.peek().isNotEmpty) {
|
||||
_lastKeyAt = now;
|
||||
coord.queue.skip();
|
||||
return true;
|
||||
}
|
||||
if (coord.nextEpisode.peek() == null) return false;
|
||||
_lastKeyAt = now;
|
||||
coord.playNextEpisode();
|
||||
@@ -287,6 +294,7 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
_status(),
|
||||
_progressLine(),
|
||||
_jellyfinOverlay(),
|
||||
_upNextChip(),
|
||||
if (widget.fullscreen) ...[if (!SessionState.instance.solo.value) _pausedParty(), _unreadChip()],
|
||||
] else
|
||||
_miniOverlay(),
|
||||
@@ -678,6 +686,8 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
final blocked = coord.jellyfinBlocked.value;
|
||||
final next = coord.nextEpisode.value;
|
||||
final countdown = coord.autoAdvance.value;
|
||||
// Up next goes before the next episode.
|
||||
final upNext = coord.queue.countdown.value;
|
||||
final finished = coord.serverState.value?.transport == 'finished';
|
||||
final pill = coord.nextPromptVisible.value;
|
||||
final SkipOffer? skip = coord.skipOffer.value;
|
||||
@@ -692,7 +702,16 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
onRetry: coord.retryJellyfin,
|
||||
),
|
||||
),
|
||||
if (next != null && (finished || countdown != null))
|
||||
if (upNext != null && finished)
|
||||
Positioned(
|
||||
right: 24,
|
||||
bottom: 138,
|
||||
child: UpNextCard(queue: coord.queue, countdown: upNext),
|
||||
)
|
||||
// "Clear" in Up next hides the next episode for this media.
|
||||
else if (next != null &&
|
||||
(finished || countdown != null) &&
|
||||
coord.queue.shownContinuation.value.isNotEmpty)
|
||||
Positioned(
|
||||
right: 24,
|
||||
bottom: 138,
|
||||
@@ -737,6 +756,24 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
);
|
||||
}
|
||||
|
||||
/// "Up next · <title>" top right, with the controls (and while its
|
||||
/// popover is open); under the party in fullscreen.
|
||||
Widget _upNextChip() {
|
||||
final queue = _coord.queue;
|
||||
return Positioned(
|
||||
right: 16,
|
||||
top: widget.fullscreen ? _barHeight + 60 : _topBarHeight + 8,
|
||||
child: SignalBuilder(
|
||||
builder: (context) => PillGate(
|
||||
key: const ValueKey('up-next-gate'),
|
||||
kind: 'up-next',
|
||||
controlsShown: queue.popoverOpen.value ? const _AlwaysOn() : _controlsShown,
|
||||
child: UpNextChip(queue: queue, onPlayNextEpisode: _coord.playNextEpisode),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Fullscreen hides the strip: while paused the party stays in a corner.
|
||||
Widget _pausedParty() => Positioned(
|
||||
right: 16,
|
||||
@@ -908,6 +945,17 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Always up (a pill whose popover is open).
|
||||
class _AlwaysOn implements ValueListenable<bool> {
|
||||
const _AlwaysOn();
|
||||
@override
|
||||
bool get value => true;
|
||||
@override
|
||||
void addListener(VoidCallback listener) {}
|
||||
@override
|
||||
void removeListener(VoidCallback listener) {}
|
||||
}
|
||||
|
||||
/// A floating pill's visibility: only while the controls are up (the
|
||||
/// mouse moved) or the mouse is on it. With [announce] it is also up for
|
||||
/// [firstShown] whenever [kind] changes (a new offer: Skip OP, Skip 1:30).
|
||||
|
||||
Reference in new issue
Block a user