forked from Foxyz/sync_replay_project
Side-app use: copy/open links and titles everywhere, version on the Settings tab, known links preview instantly, Ctrl+V adds to Up next
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
9572e8457d
commit
1ec4fc8b13
22 files changed
+1190
-236
No files matched your search
@@ -2,7 +2,8 @@ import 'dart:async';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:fox_utils/fox_utils.dart' show DesktopDropX, DropTarget;
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:fox_utils/fox_utils.dart' show ClipboardX, DesktopDropX, DropTarget;
|
||||
import 'package:leaf_widgets/leaf_widgets.dart' show LeafPopover, LeafPopoverController;
|
||||
import 'package:signals/signals_flutter.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
@@ -18,9 +19,12 @@ import '../screens/home/picked_media.dart';
|
||||
import '../session/session_coordinator.dart' show autoAdvanceSecs;
|
||||
import '../shell/avatar.dart';
|
||||
import '../shell/handoff.dart' show handoffHolds;
|
||||
import '../shell/library_link.dart' show LinkPreview, cachedLinkPreview;
|
||||
import '../shell/motion.dart';
|
||||
import '../state/session_state.dart';
|
||||
import '../widgets/app_menu.dart' show AppGlass, AppMenuStyle;
|
||||
import '../widgets/app_menu.dart' show AppGlass, AppMenuItem, AppMenuStyle, showAppMenu;
|
||||
import '../widgets/copy_open.dart';
|
||||
import '../widgets/text_focus.dart' show KeyboardClaim;
|
||||
import 'queue_controller.dart';
|
||||
import 'queue_logic.dart';
|
||||
|
||||
@@ -546,22 +550,47 @@ class _RoundIconState extends State<_RoundIcon> {
|
||||
|
||||
// ----------------------------------------------------------------- panel
|
||||
|
||||
/// The queue, under the chip: Up next (drag to reorder, ▶ / × on hover),
|
||||
/// "Continues with" (greyed, + adds one), a field to paste a link, and
|
||||
/// drops of links and video files.
|
||||
/// The queue, under the chip: Up next (drag to reorder, ▶ / × on hover,
|
||||
/// right-click for more), "Continues with" (greyed, + adds one), and ways
|
||||
/// to add: Ctrl+V anywhere in it adds the clipboard's link (or video
|
||||
/// files), "+ Add link" opens a small field, and links and video files can
|
||||
/// be dropped on it. What is being added shows as a row of its own (the
|
||||
/// link's title and picture while it resolves, then "Added", or why not).
|
||||
class QueuePanel extends StatefulWidget {
|
||||
final QueueController queue;
|
||||
final VoidCallback onClose;
|
||||
const QueuePanel({super.key, required this.queue, required this.onClose});
|
||||
/// Tests: the clipboard instead of the system's (files, then text).
|
||||
final Future<({List<String> files, String? text})> Function()? readClipboard;
|
||||
/// Tests: the preview of a link being added.
|
||||
final Future<LinkPreview?> Function(String url)? preview;
|
||||
const QueuePanel({super.key, required this.queue, required this.onClose, this.readClipboard, this.preview});
|
||||
|
||||
@override
|
||||
State<QueuePanel> createState() => _QueuePanelState();
|
||||
}
|
||||
|
||||
/// Something being added from the popover: its link's preview while it
|
||||
/// resolves, then done or why not.
|
||||
class _Adding {
|
||||
final int id;
|
||||
final String label;
|
||||
LinkPreview? preview;
|
||||
String? error;
|
||||
bool done = false;
|
||||
_Adding(this.id, this.label);
|
||||
}
|
||||
|
||||
class _QueuePanelState extends State<QueuePanel> {
|
||||
/// The panel's own focus: the player's keys and Space rest while it has
|
||||
/// it ([KeyboardClaim]), Ctrl+V lands here.
|
||||
final _focus = FocusNode(debugLabel: 'queue-panel');
|
||||
final _field = TextEditingController();
|
||||
final _focus = FocusNode();
|
||||
bool _busy = false;
|
||||
final _fieldFocus = FocusNode(debugLabel: 'queue-add-field');
|
||||
/// "+ Add link" opened the field.
|
||||
bool _typing = false;
|
||||
final _adding = <_Adding>[];
|
||||
int _addingIds = 0;
|
||||
/// A paste or drop with nothing to add.
|
||||
String? _error;
|
||||
bool _dragging = false;
|
||||
// Reordered here before the server's state says so (no jump back while
|
||||
@@ -574,8 +603,6 @@ class _QueuePanelState extends State<QueuePanel> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Typing and Esc land here, not on the player (its keys pause while a
|
||||
// text field has focus).
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) _focus.requestFocus();
|
||||
});
|
||||
@@ -585,48 +612,149 @@ class _QueuePanelState extends State<QueuePanel> {
|
||||
void dispose() {
|
||||
_field.dispose();
|
||||
_focus.dispose();
|
||||
_fieldFocus.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _add(List<PickedMedia> picks) async {
|
||||
if (picks.isEmpty || _busy) return;
|
||||
setState(() {
|
||||
_busy = true;
|
||||
_error = null;
|
||||
});
|
||||
String? error;
|
||||
for (final p in picks) {
|
||||
error = await _q.add(p) ?? error;
|
||||
}
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_busy = false;
|
||||
_error = error;
|
||||
if (error == null) _field.clear();
|
||||
});
|
||||
/// Back to the panel (after a menu, or the field closing), so its keys
|
||||
/// keep working.
|
||||
void _refocus() {
|
||||
if (mounted) _focus.requestFocus();
|
||||
}
|
||||
|
||||
void _submit() {
|
||||
final text = _field.text.trim();
|
||||
if (text.isEmpty) return;
|
||||
if (!RegExp(r'^https?://\S+$', caseSensitive: false).hasMatch(text)) {
|
||||
setState(() => _error = 'Paste a link (http:// or https://)');
|
||||
KeyEventResult _key(FocusNode node, KeyEvent e) {
|
||||
if (e is! KeyDownEvent) return KeyEventResult.ignored;
|
||||
final keys = HardwareKeyboard.instance;
|
||||
if (e.logicalKey == LogicalKeyboardKey.keyV &&
|
||||
(keys.isControlPressed || keys.isMetaPressed) &&
|
||||
!keys.isShiftPressed &&
|
||||
!keys.isAltPressed) {
|
||||
// Typing a link: an ordinary paste into it.
|
||||
if (_fieldFocus.hasFocus && _field.text.isNotEmpty) return KeyEventResult.ignored;
|
||||
unawaited(_paste());
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
// Esc in the field closes the field; else the popover's own (it fades out).
|
||||
if (e.logicalKey == LogicalKeyboardKey.escape && _typing) {
|
||||
_closeField();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
|
||||
Future<({List<String> files, String? text})> _readClipboard() async {
|
||||
var files = const <String>[];
|
||||
String? text;
|
||||
try {
|
||||
files = await ClipboardX.readFiles();
|
||||
} catch (_) {}
|
||||
if (files.isEmpty) {
|
||||
try {
|
||||
text = await ClipboardX.readText();
|
||||
} catch (_) {}
|
||||
}
|
||||
return (files: files, text: text);
|
||||
}
|
||||
|
||||
/// Ctrl+V: the clipboard's video files, else the links in its text.
|
||||
Future<void> _paste() async {
|
||||
final clip = await (widget.readClipboard ?? _readClipboard)();
|
||||
final picks = _picksFrom([...clip.files, ...?clip.text?.split(RegExp(r'\s+'))]);
|
||||
if (!mounted) return;
|
||||
if (picks.isEmpty) {
|
||||
setState(() => _error = 'Nothing to add: copy a link (http:// or https://) or a video file');
|
||||
return;
|
||||
}
|
||||
// Links go "through me" by default, like the Link page (alone: direct).
|
||||
_add([LinkMedia(text, relay: LinkRelay.me)]);
|
||||
if (_typing && _field.text.isEmpty) _closeField();
|
||||
_add(picks);
|
||||
}
|
||||
|
||||
void _dropped(List<String> paths) {
|
||||
/// Links (passed on "through me", like the Link page; alone: direct) and
|
||||
/// video files among [raw], once each.
|
||||
List<PickedMedia> _picksFrom(Iterable<String> raw) {
|
||||
final seen = <String>{};
|
||||
final picks = <PickedMedia>[];
|
||||
for (final raw in paths) {
|
||||
final p = raw.trim();
|
||||
if (RegExp(r'^https?://', caseSensitive: false).hasMatch(p)) {
|
||||
for (final r in raw) {
|
||||
final p = r.trim();
|
||||
if (p.isEmpty || !seen.add(p)) continue;
|
||||
if (RegExp(r'^https?://\S+$', caseSensitive: false).hasMatch(p)) {
|
||||
picks.add(LinkMedia(p, relay: LinkRelay.me));
|
||||
} else if (kindOf(p) == FileKind.video) {
|
||||
picks.add(FileMedia(p));
|
||||
}
|
||||
}
|
||||
return picks;
|
||||
}
|
||||
|
||||
/// Adds [picks] in order, each shown as a row until it is in (or why
|
||||
/// not).
|
||||
Future<void> _add(List<PickedMedia> picks) async {
|
||||
setState(() {
|
||||
_error = null;
|
||||
_adding.removeWhere((a) => a.error != null);
|
||||
});
|
||||
final rows = [for (final p in picks) _Adding(_addingIds++, p.label)];
|
||||
setState(() => _adding.addAll(rows));
|
||||
for (final (i, p) in picks.indexed) {
|
||||
final row = rows[i];
|
||||
if (p case LinkMedia(:final url)) {
|
||||
// Its title and picture while it resolves (at once when seen before).
|
||||
unawaited((widget.preview ?? cachedLinkPreview)(url).then((v) {
|
||||
if (mounted) setState(() => row.preview = v);
|
||||
}, onError: (Object _) {}));
|
||||
}
|
||||
String? error;
|
||||
try {
|
||||
error = await _q.add(p);
|
||||
} catch (e) {
|
||||
error = '$e';
|
||||
}
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
row.error = error;
|
||||
row.done = error == null;
|
||||
});
|
||||
if (error == null) {
|
||||
// The item itself shows up in the list; this row goes shortly after.
|
||||
Timer(const Duration(milliseconds: 1500), () {
|
||||
if (mounted) setState(() => _adding.remove(row));
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _openField() {
|
||||
setState(() {
|
||||
_typing = true;
|
||||
_error = null;
|
||||
});
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) _fieldFocus.requestFocus();
|
||||
});
|
||||
}
|
||||
|
||||
void _closeField() {
|
||||
setState(() {
|
||||
_typing = false;
|
||||
_field.clear();
|
||||
});
|
||||
_refocus();
|
||||
}
|
||||
|
||||
void _submit() {
|
||||
final text = _field.text.trim();
|
||||
if (text.isEmpty) return;
|
||||
final picks = _picksFrom(text.split(RegExp(r'\s+'))).whereType<LinkMedia>().toList();
|
||||
if (picks.isEmpty) {
|
||||
setState(() => _error = 'Type or paste a link (http:// or https://)');
|
||||
return;
|
||||
}
|
||||
_closeField();
|
||||
_add(picks);
|
||||
}
|
||||
|
||||
void _dropped(List<String> paths) {
|
||||
final picks = _picksFrom(paths);
|
||||
if (picks.isEmpty) {
|
||||
setState(() => _error = 'Drop a video file or a link');
|
||||
return;
|
||||
@@ -634,6 +762,39 @@ class _QueuePanelState extends State<QueuePanel> {
|
||||
_add(picks);
|
||||
}
|
||||
|
||||
/// Right-click on a queued row: Play now, Remove, Copy title, and with
|
||||
/// its link known here ([QueueController.linkOf]) Copy link and Open in
|
||||
/// browser; a Jellyfin item on a server linked here, Open in Jellyfin.
|
||||
Future<void> _rowMenu(BuildContext context, QueueItem item) async {
|
||||
final link = _q.linkOf(item);
|
||||
final page = _q.jellyfinPageOf(item);
|
||||
final pick = await showAppMenu<String>(context, items: [
|
||||
const AppMenuItem(value: 'play', label: 'Play now'),
|
||||
const AppMenuItem(value: 'remove', label: 'Remove'),
|
||||
const AppMenuItem(value: 'title', label: ReuseText.copyTitle),
|
||||
if (link != null) ...const [
|
||||
AppMenuItem(value: 'link', label: ReuseText.copyLink),
|
||||
AppMenuItem(value: 'open', label: ReuseText.openInBrowser),
|
||||
],
|
||||
if (page != null) const AppMenuItem(value: 'jellyfin', label: ReuseText.openInJellyfin),
|
||||
]);
|
||||
_refocus();
|
||||
switch (pick) {
|
||||
case 'play':
|
||||
_q.playNow(item.id);
|
||||
case 'remove':
|
||||
_q.remove(item.id);
|
||||
case 'title':
|
||||
await copyToClipboard(item.title, done: 'Title copied');
|
||||
case 'link':
|
||||
await copyToClipboard(link!, done: 'Link copied');
|
||||
case 'open':
|
||||
await openLink(link!);
|
||||
case 'jellyfin':
|
||||
await openLink(page!);
|
||||
}
|
||||
}
|
||||
|
||||
List<QueueItem> _ordered(List<QueueItem> items) {
|
||||
final order = _order;
|
||||
if (order == null) return items;
|
||||
@@ -670,7 +831,7 @@ class _QueuePanelState extends State<QueuePanel> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final height = MediaQuery.sizeOf(context).height;
|
||||
return DropTarget(
|
||||
final drop = DropTarget(
|
||||
onDragEntered: (_) => setState(() => _dragging = true),
|
||||
onDragExited: (_) => setState(() => _dragging = false),
|
||||
onDragDone: (d) {
|
||||
@@ -704,13 +865,15 @@ class _QueuePanelState extends State<QueuePanel> {
|
||||
children: [
|
||||
_header(items.length),
|
||||
Flexible(
|
||||
child: items.isEmpty && cont.isEmpty
|
||||
child: items.isEmpty && cont.isEmpty && _adding.isEmpty
|
||||
? _empty()
|
||||
: ListView(
|
||||
shrinkWrap: true,
|
||||
padding: const EdgeInsets.fromLTRB(4, 0, 4, 4),
|
||||
children: [
|
||||
if (items.isEmpty)
|
||||
if (items.isEmpty && _adding.isNotEmpty)
|
||||
const SizedBox.shrink()
|
||||
else if (items.isEmpty)
|
||||
const Padding(
|
||||
padding: EdgeInsets.fromLTRB(12, 2, 12, 8),
|
||||
child: Text('Nothing added yet. What plays next is below.',
|
||||
@@ -744,10 +907,17 @@ class _QueuePanelState extends State<QueuePanel> {
|
||||
members: r?.members ?? const [],
|
||||
selfId: selfId,
|
||||
warm: i == 0 && warm?.id == item.id ? warm!.state : null,
|
||||
onMenu: _rowMenu,
|
||||
);
|
||||
},
|
||||
),
|
||||
if (cont.isNotEmpty) ..._continuation(cont, items.isEmpty),
|
||||
for (final a in _adding)
|
||||
_AddingRow(
|
||||
key: ValueKey('queue-adding-${a.id}'),
|
||||
adding: a,
|
||||
onDismiss: () => setState(() => _adding.remove(a)),
|
||||
),
|
||||
if (cont.isNotEmpty) ..._continuation(cont, items.isEmpty && _adding.isEmpty),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -761,6 +931,7 @@ class _QueuePanelState extends State<QueuePanel> {
|
||||
),
|
||||
),
|
||||
);
|
||||
return KeyboardClaim(child: Focus(focusNode: _focus, onKeyEvent: _key, child: drop));
|
||||
}
|
||||
|
||||
// Text lines up with the rows' (4 panel + 12 row padding).
|
||||
@@ -793,8 +964,8 @@ class _QueuePanelState extends State<QueuePanel> {
|
||||
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.',
|
||||
'Press Ctrl+V to add a copied link, 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, height: 1.35),
|
||||
),
|
||||
@@ -822,63 +993,179 @@ class _QueuePanelState extends State<QueuePanel> {
|
||||
for (final e in cont) _ContinuationRow(episode: e, queue: _q),
|
||||
];
|
||||
|
||||
Widget _footer() => Container(
|
||||
decoration: const BoxDecoration(border: Border(top: BorderSide(color: AppMenuStyle.border, width: 0.5))),
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextField(
|
||||
key: const ValueKey('queue-add-field'),
|
||||
controller: _field,
|
||||
focusNode: _focus,
|
||||
// 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: 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(0x0DFFFFFF),
|
||||
hintText: _dragging ? 'Drop to add' : 'Paste a link to add',
|
||||
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
|
||||
? const Padding(
|
||||
padding: EdgeInsets.all(10),
|
||||
child: SizedBox.square(dimension: 14, child: CircularProgressIndicator(strokeWidth: 2)),
|
||||
)
|
||||
: null,
|
||||
contentPadding: const EdgeInsets.symmetric(vertical: 10, horizontal: 8),
|
||||
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(
|
||||
duration: easeTime,
|
||||
curve: ease,
|
||||
alignment: Alignment.topCenter,
|
||||
child: _error == null
|
||||
? const SizedBox(width: double.infinity)
|
||||
: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(8, 6, 8, 0),
|
||||
child: Text(_error!, style: TextStyle(color: Colors.orange.shade300, fontSize: 12)),
|
||||
),
|
||||
),
|
||||
],
|
||||
// "+ Add link" (Ctrl+V and drops work without it), or the small field
|
||||
// it opens; under it what a paste or drop could not use.
|
||||
Widget _footer() {
|
||||
final accent = Theme.of(context).colorScheme.primary;
|
||||
final Widget row;
|
||||
if (_typing) {
|
||||
row = TextField(
|
||||
key: const ValueKey('queue-add-field'),
|
||||
controller: _field,
|
||||
focusNode: _fieldFocus,
|
||||
style: const TextStyle(fontSize: 13),
|
||||
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(0x0DFFFFFF),
|
||||
hintText: 'https://… (Enter adds)',
|
||||
hintStyle: const TextStyle(color: Colors.white38, fontSize: 13),
|
||||
prefixIcon: const Icon(Icons.add_link_rounded, size: 16, color: Colors.white54),
|
||||
prefixIconConstraints: const BoxConstraints(minWidth: 34),
|
||||
suffixIcon: _RoundIcon(
|
||||
icon: Icons.close_rounded,
|
||||
size: 16,
|
||||
color: Colors.white54,
|
||||
tooltip: 'Close (Esc)',
|
||||
onPressed: _closeField,
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(vertical: 8, horizontal: 8),
|
||||
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: accent.withValues(alpha: 0.6), width: 0.5),
|
||||
),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
row = Row(
|
||||
children: [
|
||||
TextButton.icon(
|
||||
key: const ValueKey('queue-add-link'),
|
||||
onPressed: _openField,
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: Colors.white70,
|
||||
visualDensity: VisualDensity.compact,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(AppMenuStyle.rowRadius)),
|
||||
),
|
||||
icon: const Icon(Icons.add_rounded, size: 18),
|
||||
label: const Text('Add link', style: TextStyle(fontSize: 13)),
|
||||
),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(left: 8, right: 8),
|
||||
child: Text(
|
||||
_dragging ? 'Drop to add' : 'Ctrl+V pastes · drop here',
|
||||
textAlign: TextAlign.end,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(color: _dragging ? accent : Colors.white38, fontSize: 12),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
return Container(
|
||||
decoration: const BoxDecoration(border: Border(top: BorderSide(color: AppMenuStyle.border, width: 0.5))),
|
||||
padding: const EdgeInsets.all(6),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
row,
|
||||
AnimatedSize(
|
||||
duration: easeTime,
|
||||
curve: ease,
|
||||
alignment: Alignment.topCenter,
|
||||
child: _error == null
|
||||
? const SizedBox(width: double.infinity)
|
||||
: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(10, 4, 10, 2),
|
||||
child: Text(_error!,
|
||||
key: const ValueKey('queue-add-error'),
|
||||
style: TextStyle(color: Colors.orange.shade300, fontSize: 12)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A link or file being added from the popover, in the rows' metrics: the
|
||||
/// link's picture and title as soon as they are known, then "Added" (the
|
||||
/// row leaves shortly after) or why not (× dismisses it).
|
||||
class _AddingRow extends StatelessWidget {
|
||||
final _Adding adding;
|
||||
final VoidCallback onDismiss;
|
||||
const _AddingRow({super.key, required this.adding, required this.onDismiss});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final a = adding;
|
||||
final p = a.preview;
|
||||
final thumb = p?.thumbnail;
|
||||
final error = a.error;
|
||||
final Widget status;
|
||||
if (error != null) {
|
||||
status = Text(error,
|
||||
maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(color: Colors.orange.shade300, fontSize: 12));
|
||||
} else if (a.done) {
|
||||
status = const Row(children: [
|
||||
Icon(Icons.check_rounded, size: 14, color: Color(0xFF7CF29C)),
|
||||
SizedBox(width: 4),
|
||||
Text('Added', style: _subStyle),
|
||||
]);
|
||||
} else {
|
||||
status = const Row(children: [
|
||||
SizedBox.square(dimension: 10, child: CircularProgressIndicator(strokeWidth: 1.5, color: Colors.white54)),
|
||||
SizedBox(width: 6),
|
||||
Text('Adding…', style: _subStyle),
|
||||
]);
|
||||
}
|
||||
return AnimatedOpacity(
|
||||
duration: easeTime,
|
||||
opacity: a.done ? 0.6 : 1,
|
||||
child: SizedBox(
|
||||
height: queueRowHeight,
|
||||
child: Row(
|
||||
children: [
|
||||
const SizedBox(width: _lead),
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: SizedBox(
|
||||
width: _thumb,
|
||||
height: _thumb * 9 / 16,
|
||||
child: thumb == null
|
||||
? _blank(Icons.link_rounded, _thumb)
|
||||
: Image.network(thumb,
|
||||
fit: BoxFit.cover, errorBuilder: (_, _, _) => _blank(Icons.link_rounded, _thumb)),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(p?.title ?? a.label, maxLines: 1, overflow: TextOverflow.ellipsis, style: _titleStyle),
|
||||
const SizedBox(height: 2),
|
||||
status,
|
||||
],
|
||||
),
|
||||
),
|
||||
if (error != null)
|
||||
_RoundIcon(
|
||||
icon: Icons.close_rounded,
|
||||
size: 18,
|
||||
color: Colors.white70,
|
||||
tooltip: 'Dismiss',
|
||||
onPressed: onDismiss,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ rows
|
||||
@@ -1009,6 +1296,8 @@ class _QueueRow extends StatefulWidget {
|
||||
final String selfId;
|
||||
/// The first item's readiness when this app puts it up; null: no dot.
|
||||
final WarmState? warm;
|
||||
/// Right-click ([context]: the row).
|
||||
final void Function(BuildContext context, QueueItem item) onMenu;
|
||||
const _QueueRow({
|
||||
super.key,
|
||||
required this.index,
|
||||
@@ -1017,6 +1306,7 @@ class _QueueRow extends StatefulWidget {
|
||||
required this.members,
|
||||
required this.selfId,
|
||||
required this.warm,
|
||||
required this.onMenu,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -1122,7 +1412,10 @@ class _QueueRowState extends State<_QueueRow> {
|
||||
return MouseRegion(
|
||||
onEnter: (_) => setState(() => _hover = true),
|
||||
onExit: (_) => setState(() => _hover = false),
|
||||
child: Stack(children: [row, actions]),
|
||||
child: GestureDetector(
|
||||
onSecondaryTap: () => widget.onMenu(context, item),
|
||||
child: Stack(children: [row, actions]),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user