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:
foxyzandClaude Opus 5.5 committed 2026-10-11 20:50:49 +03:00
1 parent 9572e8457d
commit 1ec4fc8b13
22 files changed
+1190 -236

No files matched your search

@@ -194,6 +194,13 @@ class JellyfinAccounts extends ChangeNotifier {
JellyfinAccount? account(String serverId) =>
_accounts.where((a) => a.serverId == serverId).firstOrNull;
/// [itemId]'s page in the web UI of linked server [serverId] ("Open in
/// Jellyfin"); null when that server is not linked here.
String? webUrlOf(String serverId, String itemId) {
final a = account(serverId);
return a == null ? null : jellyfinWebUrl(a.baseUrl, itemId, serverId: serverId);
}
/// The chosen default, else the first linked server.
JellyfinAccount? get defaultAccount =>
(_defaultServerId == null ? null : account(_defaultServerId!)) ?? _accounts.firstOrNull;
+27 -11
View File
@@ -7,6 +7,7 @@ import 'package:sync_relay_core/core.dart';
import 'accounts.dart';
import 'format.dart';
import '../widgets/app_menu.dart';
import '../widgets/copy_open.dart';
/// One level of the library browser's back stack.
sealed class BrowseRoute {
@@ -271,6 +272,14 @@ class BrowseController extends ChangeNotifier {
}
}
/// An item's title to copy: an episode with its series ("Show · Name").
String jellyfinCopyTitle(JellyfinItem item) {
final series = item.seriesName;
return item.kind == JellyfinItemKind.episode && series != null && series.isNotEmpty
? '$series · ${item.name}'
: item.name;
}
/// What every browser page needs, provided by [JellyfinBrowser].
class BrowseScope extends InheritedWidget {
final JellyfinAccounts accounts;
@@ -284,7 +293,7 @@ class BrowseScope extends InheritedWidget {
/// Double-click: put it up (a series: its next episode).
final ValueChanged<JellyfinItem> onPutUp;
/// Adds it to Up next (with next: right after what plays); null: no
/// server, the cards have no menu.
/// server (the cards' menu then only copies and opens).
final void Function(JellyfinItem item, {bool next})? onAddToQueue;
/// Home shows the library chips itself (the Library tab has them in its
/// header instead).
@@ -309,23 +318,30 @@ class BrowseScope extends InheritedWidget {
void onOpen(BrowseRoute route) => controller.open(route);
/// Right-click on a playable card ([context]: the card): Put it up, Add
/// to queue, Play next. Nothing without a server.
/// Right-click on a card ([context]: the card): Put it up, Add to queue,
/// Play next (playable, in a server), Copy title, Open in Jellyfin.
Future<void> cardMenu(BuildContext context, JellyfinItem item) async {
final queue = onAddToQueue;
if (queue == null || !isSelectable(item)) return;
final pick = await showAppMenu<int>(context, items: const [
AppMenuItem(value: 0, label: 'Put it up'),
AppMenuItem(value: 1, label: 'Add to queue'),
AppMenuItem(value: 2, label: 'Play next'),
final queue = isSelectable(item) ? onAddToQueue : null;
final pick = await showAppMenu<int>(context, items: [
if (queue != null) ...const [
AppMenuItem(value: 0, label: 'Put it up'),
AppMenuItem(value: 1, label: 'Add to queue'),
AppMenuItem(value: 2, label: 'Play next'),
],
const AppMenuItem(value: 3, label: ReuseText.copyTitle),
const AppMenuItem(value: 4, label: ReuseText.openInJellyfin),
]);
switch (pick) {
case 0:
onPutUp(item);
case 1:
queue(item);
queue!(item);
case 2:
queue(item, next: true);
queue!(item, next: true);
case 3:
await copyToClipboard(jellyfinCopyTitle(item), done: 'Title copied');
case 4:
await openLink(jellyfinWebUrl(client.baseUrl, item.id, serverId: account.serverId));
}
}
@@ -3,6 +3,7 @@ import 'package:sync_relay_core/core.dart';
import '../shell/motion.dart';
import '../widgets/app_menu.dart';
import '../widgets/copy_open.dart';
import 'accounts.dart';
import 'browse_scope.dart';
import 'cards.dart' show doubleClickTime, kindIcon;
@@ -148,14 +149,19 @@ class _JellyfinDetailsPanelState extends State<JellyfinDetailsPanel> {
}
/// Right-click on an episode ([context]: its row): Put it up, Add to
/// queue, Play next.
/// queue, Play next (in a server), Copy title, Open in Jellyfin.
Future<void> _episodeMenu(BuildContext context, JellyfinItem e) async {
if (widget.onAddToQueue == null) return;
setState(() => _targetId = e.id);
final queue = widget.onAddToQueue != null;
if (queue) setState(() => _targetId = e.id);
final client = _client;
final pick = await showAppMenu<int>(context, items: [
AppMenuItem(value: 0, label: widget.solo ? 'Play' : 'Put it up'),
const AppMenuItem(value: 1, label: 'Add to queue'),
const AppMenuItem(value: 2, label: 'Play next'),
if (queue) ...[
AppMenuItem(value: 0, label: widget.solo ? 'Play' : 'Put it up'),
const AppMenuItem(value: 1, label: 'Add to queue'),
const AppMenuItem(value: 2, label: 'Play next'),
],
const AppMenuItem(value: 3, label: ReuseText.copyTitle),
if (client != null) const AppMenuItem(value: 4, label: ReuseText.openInJellyfin),
]);
switch (pick) {
case 0:
@@ -164,6 +170,10 @@ class _JellyfinDetailsPanelState extends State<JellyfinDetailsPanel> {
await _queue(e);
case 2:
await _queue(e, next: true);
case 3:
await copyToClipboard(jellyfinCopyTitle(e), done: 'Title copied');
case 4:
await openLink(jellyfinWebUrl(client!.baseUrl, e.id, serverId: _serverId));
}
}
@@ -383,6 +393,13 @@ class _JellyfinDetailsPanelState extends State<JellyfinDetailsPanel> {
onPressed: () => _c.open(SeriesRoute(d.series!, seasonId: seasonId)),
icon: const Icon(Icons.open_in_new),
),
if (client != null)
IconButton(
key: const ValueKey('details-open-jellyfin'),
tooltip: ReuseText.openInJellyfin,
onPressed: () => openLink(jellyfinWebUrl(client.baseUrl, shown.id, serverId: _serverId)),
icon: const Icon(Icons.open_in_browser_rounded),
),
],
),
if (_error != null) ...[
@@ -265,6 +265,25 @@ class QueueController {
}
}
/// [item]'s link as this app may hand it out (Copy link, Open in
/// browser): a link everyone opens (it is in the state), or a link this
/// app passes on (its adder). Null otherwise: a server-fetched link stays
/// with the server, another member's passed-on link with them.
String? linkOf(QueueItem item) => switch (item.how) {
QueueHow.direct => item.media.url,
QueueHow.through when item.by == _me => switch (_local[item.key]?.pick) {
LinkMedia(:final url) => url,
_ => null,
},
_ => null,
};
/// [item]'s page on its Jellyfin server, when that server is linked here.
String? jellyfinPageOf(QueueItem item) {
final ref = item.media.jellyfin;
return ref == null ? null : coord.jellyfin.webUrlOf(ref.serverId, ref.itemId);
}
// ------------------------------------------------------------ editing
void move(String id, int to) {
+392 -99
View File
@@ -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]),
),
);
}
}
@@ -1,44 +1,16 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:sync_relay_core/core.dart';
import '../../update/update_controller.dart';
import '../../widgets/copy_open.dart';
import 'settings_page.dart';
import 'ui.dart';
import 'update_indicator.dart';
/// Opens [url] in the default browser (no url_launcher dependency).
Future<bool> openInBrowser(String url) async {
try {
final ProcessResult r;
if (Platform.isWindows) {
r = await Process.run('rundll32', ['url.dll,FileProtocolHandler', url]);
} else if (Platform.isMacOS) {
r = await Process.run('open', [url]);
} else {
r = await Process.run('xdg-open', [url]);
}
return r.exitCode == 0;
} catch (_) {
return false;
}
}
/// Name, version, update state and the source link (Settings → About).
class AboutPanel extends StatelessWidget {
final UpdateController? updates;
const AboutPanel({super.key, this.updates});
Future<void> _open(BuildContext context) async {
final messenger = ScaffoldMessenger.maybeOf(context);
if (await openInBrowser(repoUrl)) return;
await Clipboard.setData(const ClipboardData(text: repoUrl));
messenger?.showSnackBar(
const SnackBar(content: Text('Could not open a browser — link copied')));
}
@override
Widget build(BuildContext context) {
final t = Theme.of(context).textTheme;
@@ -49,7 +21,7 @@ class AboutPanel extends StatelessWidget {
children: [
Text(appName, style: t.titleLarge),
const SizedBox(height: 4),
SelectableText('Version $appVersion',
SelectableText('Version ${displayVersion()}',
style: t.bodyMedium?.copyWith(color: Colors.grey)),
const SizedBox(height: 4),
UpdateStatusLine(controller: updates),
@@ -58,13 +30,12 @@ class AboutPanel extends StatelessWidget {
children: [
Expanded(child: SelectableText(repoUrl, style: t.bodySmall)),
IconButton(
tooltip: 'Copy link',
tooltip: ReuseText.copyLink,
icon: const Icon(Icons.copy, size: 18),
onPressed: () =>
Clipboard.setData(const ClipboardData(text: repoUrl)),
onPressed: () => copyToClipboard(repoUrl, done: 'Link copied'),
),
FilledButton.tonalIcon(
onPressed: () => _open(context),
onPressed: () => openLink(repoUrl),
icon: const Icon(Icons.open_in_new, size: 18),
label: const Text('Source'),
),
+17
View File
@@ -1,10 +1,27 @@
import 'package:flutter/material.dart';
import 'package:sync_relay_core/core.dart' show appVersion, isDevBuild;
/// Product name (working title). Change it here only.
const appName = 'Starsync';
const repoUrl = 'https://git.senkochan.moe/Foxyz/sync_replay_project';
/// The pubspec's version: the Flutter tool defines it for every build and
/// run (`flutter run` too).
const _pubspecVersion = String.fromEnvironment('FLUTTER_BUILD_NAME');
/// The short commit a packaged build was made from (`tool/package_linux.sh`;
/// `-dirty` with uncommitted changes); empty otherwise.
const _gitCommit = String.fromEnvironment('GIT_COMMIT');
/// The version to show: a release's own ("1.1.2"), a test build's with its
/// commit ("1.1.3-dev.12 · 1a2b3c4"), and for a dev run (no `APP_VERSION`)
/// the pubspec's with "dev" ("1.1.2 · dev run").
String displayVersion({String version = appVersion, String pubspec = _pubspecVersion, String commit = _gitCommit}) {
if (version == '0.0.0-dev') return pubspec.isEmpty ? 'dev run' : '$pubspec · dev run';
return isDevBuild(version) && commit.isNotEmpty ? '$version · $commit' : version;
}
const kRadius = 12.0;
const kGap = 16.0;
@@ -1464,9 +1464,28 @@ class SessionCoordinator {
if (pending == null || r.mediaId <= pending.after) return;
if (sync.memberId.isEmpty || r.media?['swapBy'] != sync.memberId) return;
_pendingArt = null;
final link = pending.link;
_ownLink = link == null || link.isEmpty ? null : (r.mediaId, link);
unawaited(_sendArt(r.mediaId, pending.media, pending.link, given: pending.art));
}
/// The link this app put up as [StateBroadcast.mediaId] (passed on or
/// fetched by the server, so not in the state).
(int, String)? _ownLink;
/// The link of what plays, as this app may hand it out (Copy link, Open
/// in browser): one this app put up, else one everyone opens (it is in
/// the state); null otherwise (another member's passed-on link, a
/// server fetch by someone else, Jellyfin, files).
String? currentLink() {
final r = serverState.peek();
if (r == null || r.media == null) return null;
final own = _ownLink;
if (own != null && own.$1 == r.mediaId) return own.$2;
final url = stateMediaUrl(r);
return url != null && (url.startsWith('http://') || url.startsWith('https://')) ? url : null;
}
/// Fetches the art of [media] (this client's swap, now [mediaId], from
/// web [link] if any) and sends it; quietly nothing on any failure.
Future<void> _sendArt(int mediaId, HelloMedia media, String? link, {Uint8List? given}) async {
@@ -2177,6 +2196,7 @@ class SessionCoordinator {
stopWarmSource();
_pendingOrigin = null;
_pendingArt = null;
_ownLink = null;
_activeOrigin = null;
_playerReady = false;
try {
+72 -23
View File
@@ -3,6 +3,8 @@ import 'package:flutter/services.dart';
import 'package:sync_relay_core/core.dart';
import '../state/session_state.dart';
import '../widgets/app_menu.dart';
import '../widgets/copy_open.dart';
import '../widgets/text_focus.dart';
import 'avatar.dart';
@@ -158,34 +160,59 @@ class ActivityRow extends StatelessWidget {
final scheme = Theme.of(context).colorScheme;
final time = _clock(item.at);
if (item.action == 'chat') {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
StatusAvatar(info: avatarForName(item.actor), size: 34, badges: false),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
final link = firstLinkIn(item.reason);
// Right-click on the text: its selection menu plus the whole message
// and its link; beside it, the same as the app's menu.
final message = SelectableText(
item.reason,
contextMenuBuilder: (context, state) => AdaptiveTextSelectionToolbar.buttonItems(
anchors: state.contextMenuAnchors,
buttonItems: [
...state.contextMenuButtonItems,
for (final (label, action) in _messageActions(item.reason, link))
ContextMenuButtonItem(
label: label,
onPressed: () {
state.hideToolbar();
action();
},
),
],
),
);
return Builder(
builder: (context) => GestureDetector(
onSecondaryTap: () => _messageMenu(context, item.reason, link),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
StatusAvatar(info: avatarForName(item.actor), size: 34, badges: false),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Flexible(
child: Text(item.actor,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontWeight: FontWeight.w700)),
Row(
children: [
Flexible(
child: Text(item.actor,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontWeight: FontWeight.w700)),
),
const SizedBox(width: 6),
Text(time, style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 11)),
],
),
const SizedBox(width: 6),
Text(time, style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 11)),
message,
],
),
SelectableText(item.reason),
],
),
),
],
),
],
),
),
);
}
@@ -206,6 +233,28 @@ class ActivityRow extends StatelessWidget {
}
}
/// The first http(s) link in [text], if any.
String? firstLinkIn(String text) => RegExp(r'https?://[^\s<>"]+', caseSensitive: false).firstMatch(text)?.group(0);
/// What a chat message offers: Copy message, and its link's Copy link and
/// Open in browser.
List<(String, VoidCallback)> _messageActions(String text, String? link) => [
(ReuseText.copyMessage, () => copyToClipboard(text, done: 'Message copied')),
if (link != null) ...[
(ReuseText.copyLink, () => copyToClipboard(link, done: 'Link copied')),
(ReuseText.openInBrowser, () => openLink(link)),
],
];
/// Right-click beside a chat message: [_messageActions] in the app's menu.
Future<void> _messageMenu(BuildContext context, String text, String? link) async {
final actions = _messageActions(text, link);
final pick = await showAppMenu<int>(context, items: [
for (final (i, (label, _)) in actions.indexed) AppMenuItem(value: i, label: label),
]);
if (pick != null) actions[pick].$2();
}
/// Icon, colour and wording of a member's feed action.
(IconData, Color, String) feedStyle(FeedMessage f, ColorScheme scheme) {
switch (f.action) {
+243 -13
View File
@@ -3,11 +3,13 @@ import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:sync_relay_core/core.dart' show LinkProbe, hasMediaExtension, parseJellyfinLink, playErrorText;
import 'package:sync_relay_core/core.dart'
show LinkProbe, hasMediaExtension, parseJellyfinLink, playErrorText, unreachableText;
import '../playback/yt_dlp_app.dart';
import '../screens/home/media_picker.dart';
import '../widgets/app_menu.dart';
import '../widgets/copy_open.dart';
import '../state/connect_prefs.dart';
import 'motion.dart';
@@ -22,20 +24,33 @@ class RecentLink {
final String url;
final String title;
final String? thumbnail;
/// The preview's second line ("youtube.com · Channel · 3:07"); null for
/// links kept before it was.
final String? subtitle;
final DateTime at;
const RecentLink({required this.url, required this.title, this.thumbnail, required this.at});
const RecentLink({required this.url, required this.title, this.thumbnail, this.subtitle, required this.at});
Map<String, Object?> toJson() =>
{'url': url, 'title': title, 'thumb': ?thumbnail, 'at': at.millisecondsSinceEpoch};
/// What it showed when put up (the host for a second line, when older).
LinkPreview get preview =>
LinkPreview(title: title, subtitle: subtitle ?? _host(Uri.tryParse(url) ?? Uri()), thumbnail: thumbnail);
Map<String, Object?> toJson() => {
'url': url,
'title': title,
'thumb': ?thumbnail,
'sub': ?subtitle,
'at': at.millisecondsSinceEpoch,
};
static RecentLink? fromJson(Object? j) {
if (j is! Map) return null;
final url = j['url'], title = j['title'], at = j['at'], thumb = j['thumb'];
final url = j['url'], title = j['title'], at = j['at'], thumb = j['thumb'], sub = j['sub'];
if (url is! String || url.isEmpty) return null;
return RecentLink(
url: url,
title: title is String && title.isNotEmpty ? title : LinkMedia(url).label,
thumbnail: thumb is String && thumb.isNotEmpty ? thumb : null,
subtitle: sub is String && sub.isNotEmpty ? sub : null,
at: DateTime.fromMillisecondsSinceEpoch(at is int ? at : 0),
);
}
@@ -61,13 +76,18 @@ List<RecentLink> decodeRecentLinks(Object? raw) {
class RecentLinks {
static final RecentLinks instance = RecentLinks();
RecentLinks({Future<Object?> Function()? read, Future<void> Function(String value)? write})
/// [read] / [write] given (tests): [previews] defaults to one kept in
/// memory only.
RecentLinks({Future<Object?> Function()? read, Future<void> Function(String value)? write, LinkPreviewStore? previews})
: _read = read ?? _hiveRead,
_write = write ?? _hiveWrite;
_write = write ?? _hiveWrite,
previews = previews ?? (read == null && write == null ? LinkPreviewStore() : LinkPreviewStore.memory());
final Future<Object?> Function() _read;
final Future<void> Function(String value) _write;
final links = ValueNotifier<List<RecentLink>>(const []);
/// What links pasted or put up before turned out to be.
final LinkPreviewStore previews;
Future<void>? _load;
static Future<Object?> _hiveRead() async =>
@@ -112,6 +132,108 @@ class LinkPreview {
/// null when it looks playable.
final String? problem;
const LinkPreview({required this.title, required this.subtitle, this.thumbnail, this.problem});
/// Display text only: never a cookie or a resolved stream URL.
Map<String, Object?> toJson() => {'title': title, 'sub': subtitle, 'thumb': ?thumbnail};
static LinkPreview? fromJson(Object? j) {
if (j is! Map) return null;
final title = j['title'], sub = j['sub'], thumb = j['thumb'];
if (title is! String || title.isEmpty || sub is! String) return null;
return LinkPreview(title: title, subtitle: sub, thumbnail: thumb is String && thumb.isNotEmpty ? thumb : null);
}
}
/// A [LinkPreview] kept, and when it was found.
typedef StoredLinkPreview = ({LinkPreview preview, DateTime at});
/// How long a kept preview is shown as is; older, it is shown and
/// refreshed quietly behind it.
const linkPreviewFreshFor = Duration(days: 1);
const _keyLinkPreviews = 'linkPreviews';
/// What links turned out to be, by URL, so a link pasted or picked again
/// shows its preview at once instead of waiting seconds for yt-dlp. Only
/// links that looked playable, at most [maxEntries] (the oldest go), in
/// the `connect_form` box. Display text only ([LinkPreview.toJson]).
class LinkPreviewStore {
static const maxEntries = 200;
LinkPreviewStore({Future<Object?> Function()? read, Future<void> Function(String value)? write})
: _read = read ?? _hiveRead,
_write = write ?? _hiveWrite;
/// Kept in memory only.
LinkPreviewStore.memory()
: _read = _none,
_write = _drop;
final Future<Object?> Function() _read;
final Future<void> Function(String value) _write;
// Oldest first (insertion order); a put moves an entry to the end.
final _entries = <String, StoredLinkPreview>{};
Future<void>? _load;
static Future<Object?> _none() async => null;
static Future<void> _drop(String _) async {}
static Future<Object?> _hiveRead() async =>
(connectFormBoxIfOpen() ?? await openConnectFormBox()).get(_keyLinkPreviews);
static Future<void> _hiveWrite(String value) async =>
(connectFormBoxIfOpen() ?? await openConnectFormBox()).put(_keyLinkPreviews, value);
Future<void> ensureLoaded() => _load ??= () async {
try {
final raw = await _read();
final j = raw is String ? jsonDecode(raw) : raw;
if (j is! Map) return;
final read = <String, StoredLinkPreview>{};
for (final e in j.entries) {
final v = e.value;
final p = LinkPreview.fromJson(v);
if (e.key is! String || p == null || v is! Map) continue;
final at = v['at'];
read[e.key as String] = (preview: p, at: DateTime.fromMillisecondsSinceEpoch(at is int ? at : 0));
}
// Something found meanwhile is newer.
final sorted = read.entries.toList()..sort((a, b) => a.value.at.compareTo(b.value.at));
final mine = Map.of(_entries);
_entries
..clear()
..addEntries(sorted.where((e) => !mine.containsKey(e.key)))
..addAll(mine);
} catch (e) {
debugPrint('load link previews failed: $e');
}
}();
/// [url]'s kept preview, if any (after [ensureLoaded]).
StoredLinkPreview? lookup(String url) => _entries[url];
/// Keeps [preview] of [url] when it looked playable; one with a problem
/// forgets what was kept. Loads first, so a save never drops what is on
/// disk.
Future<void> put(String url, LinkPreview preview, {DateTime? at}) async {
await ensureLoaded();
_entries.remove(url);
if (preview.problem == null) _entries[url] = (preview: preview, at: at ?? DateTime.now());
while (_entries.length > maxEntries) {
_entries.remove(_entries.keys.first);
}
await _save();
}
Future<void> _save() async {
try {
await _write(jsonEncode({
for (final e in _entries.entries) e.key: {...e.value.preview.toJson(), 'at': e.value.at.millisecondsSinceEpoch},
}));
} catch (e) {
debugPrint('save link previews failed: $e');
}
}
}
/// What a link is (see [AppYtDlp.probe]).
@@ -165,6 +287,19 @@ Future<LinkPreview?> previewLink(String url, {LinkProber probe = defaultLinkProb
);
}
/// [url]'s preview at once when it was kept before ([LinkPreviewStore]),
/// else probed and kept (the Up next popover's paste; the Link page has
/// its own refresh rules).
Future<LinkPreview?> cachedLinkPreview(String url, {RecentLinks? recents, LinkProber probe = defaultLinkProber}) async {
final store = (recents ?? RecentLinks.instance).previews;
await store.ensureLoaded();
final kept = store.lookup(url);
if (kept != null) return kept.preview;
final p = await previewLink(url, probe: probe);
if (p != null && parseJellyfinLink(url) == null) unawaited(store.put(url, p));
return p;
}
/// "today", "yesterday", "3 days ago", "last week", "5 weeks ago", ...
String agoText(DateTime at, DateTime now) {
final d = now.difference(at).inDays;
@@ -248,12 +383,18 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
/// "Other ways" opened (always shown while one of them is picked).
bool _otherWays = false;
LinkPreviewStore get _store => widget.recents.previews;
@override
void initState() {
super.initState();
widget.recents.ensureLoaded();
_c.link.addListener(_typed);
_typed();
// A link already in the field may have been seen before.
_store.ensureLoaded().then((_) {
if (mounted && _c.link.text.trim() == _previewUrl) _preview(_previewUrl);
});
}
@override
@@ -267,7 +408,12 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
final url = _c.link.text.trim();
if (url == _previewUrl) return;
_debounce?.cancel();
_debounce = Timer(const Duration(milliseconds: 350), () => _preview(url));
// Seen before (picked from Recent, pasted again): at once.
if (_previews.containsKey(url) || _known(url) != null) {
_preview(url);
} else {
_debounce = Timer(const Duration(milliseconds: 350), () => _preview(url));
}
if (mounted) {
setState(() {
_error = null;
@@ -276,17 +422,58 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
}
}
/// What [url] was found to be before: the kept preview, else what its
/// Recent card showed.
StoredLinkPreview? _known(String url) {
if (url.isEmpty) return null;
final kept = _store.lookup(url);
if (kept != null) return kept;
for (final l in widget.recents.links.value) {
if (l.url == url) return (preview: l.preview, at: l.at);
}
return null;
}
/// The preview of [url]: from this page's memory, else kept from before
/// (refreshed quietly once older than [linkPreviewFreshFor]), else
/// probed (yt-dlp takes seconds).
Future<void> _preview(String url) async {
_previewUrl = url;
if (url.isEmpty || _previews.containsKey(url)) {
if (mounted) setState(() {});
return;
}
final known = _known(url);
if (known != null) {
_previews[url] = known.preview;
if (mounted) setState(() {});
if (DateTime.now().difference(known.at) > linkPreviewFreshFor) unawaited(_refresh(url));
return;
}
final p = await previewLink(url, probe: widget.probe);
_previews[url] = p;
_keep(url, p);
if (mounted) setState(() {});
}
/// Probes [url] again behind what is shown; a site out of reach keeps
/// what was found before. Returns the preview shown afterwards.
Future<LinkPreview?> _refresh(String url) async {
final p = await previewLink(url, probe: widget.probe);
if (p == null || p.problem == unreachableText) return _previews[url];
_previews[url] = p;
_keep(url, p);
if (mounted) setState(() {});
return p;
}
/// Keeps what [url] turned out to be for next time (a Jellyfin link's
/// preview needs no probe).
void _keep(String url, LinkPreview? p) {
if (p == null || parseJellyfinLink(url) != null) return;
unawaited(_store.put(url, p));
}
Future<void> _paste() async {
try {
final text = (await Clipboard.getData(Clipboard.kTextPlain))?.text?.trim();
@@ -314,13 +501,23 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
error = '$e';
}
}
final canForce = problem != null;
var canForce = problem != null;
if (error != null && problem == null && !anyway) {
// The kept preview said it plays but it did not: show what it is now
// (the put-up just probed it, so this is quick), "anyway" if refused.
canForce = (await _refresh(url))?.problem != null;
}
if (error == null) {
final p = _previews[url] ?? await previewLink(url, probe: widget.probe);
var p = _previews[url];
if (p == null) {
p = _previews[url] = await previewLink(url, probe: widget.probe);
_keep(url, p);
}
unawaited(widget.recents.add(RecentLink(
url: url,
title: p?.title ?? LinkMedia(url).label,
thumbnail: p?.thumbnail,
subtitle: p?.problem == null ? p?.subtitle : null,
at: DateTime.now(),
)));
}
@@ -356,7 +553,7 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
});
}
/// Right-click on a recent link: put it up or queue it.
/// Right-click on a recent link: put it up or queue it, copy or open 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'),
@@ -364,8 +561,19 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
AppMenuItem(value: 'queue', label: 'Add to queue'),
AppMenuItem(value: 'next', label: 'Play next'),
],
const AppMenuItem(value: 'copy', label: ReuseText.copyLink),
const AppMenuItem(value: 'title', label: ReuseText.copyTitle),
const AppMenuItem(value: 'open', label: ReuseText.openInBrowser),
]);
if (pick == null || !mounted) return;
switch (pick) {
case 'copy':
return copyToClipboard(l.url, done: 'Link copied');
case 'title':
return copyToClipboard(l.title, done: 'Title copied');
case 'open':
return openLink(l.url);
}
_c.link.text = l.url;
switch (pick) {
case 'up':
@@ -375,6 +583,23 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
}
}
/// Right-click on the preview: copy or open the link.
Future<void> _previewMenu(BuildContext context, String url, LinkPreview? p) async {
final pick = await showAppMenu<String>(context, items: [
const AppMenuItem(value: 'copy', label: ReuseText.copyLink),
if (p != null) const AppMenuItem(value: 'title', label: ReuseText.copyTitle),
const AppMenuItem(value: 'open', label: ReuseText.openInBrowser),
]);
switch (pick) {
case 'copy':
await copyToClipboard(url, done: 'Link copied');
case 'title':
await copyToClipboard(p!.title, done: 'Title copied');
case 'open':
await openLink(url);
}
}
Widget _modeCard((LinkRelay, IconData, String, String) m, {bool compact = false}) => _ModeCard(
key: ValueKey('link-mode-${m.$1.name}'),
icon: m.$2,
@@ -504,7 +729,12 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
? const SizedBox(width: double.infinity)
: Padding(
padding: const EdgeInsets.only(top: 14),
child: _PreviewCard(preview: preview, loading: loading),
child: Builder(
builder: (context) => GestureDetector(
onSecondaryTap: () => _previewMenu(context, url, preview),
child: _PreviewCard(preview: preview, loading: loading),
),
),
),
),
if (!solo) ...[
@@ -725,7 +955,7 @@ class _ModeCard extends StatelessWidget {
class _RecentGrid extends StatelessWidget {
final List<RecentLink> recents;
final ValueChanged<RecentLink> onPick;
/// Right-click: Put it up / Add to queue / Play next.
/// Right-click: Put it up / Add to queue / Play next, copy or open it.
final void Function(BuildContext context, RecentLink link) onMenu;
const _RecentGrid({required this.recents, required this.onPick, required this.onMenu});
@@ -4,6 +4,8 @@ import 'package:signals/signals_flutter.dart';
import '../session/now_playing.dart';
import '../state/session_state.dart';
import '../widgets/app_menu.dart';
import '../widgets/copy_open.dart';
import 'motion.dart';
/// Tests: art bytes by hash instead of the session's cache.
@@ -153,21 +155,57 @@ class NowPlayingBlock extends StatelessWidget {
}
}
/// [NowPlayingBlock] of the session's current media.
/// [NowPlayingBlock] of the session's current media; right-click copies
/// its title or link, or opens it ([nowPlayingMenu]).
class NowPlayingTitle extends StatelessWidget {
final bool cover;
final bool compact;
const NowPlayingTitle({super.key, this.cover = true, this.compact = false});
@override
Widget build(BuildContext context) => ListenableBuilder(
listenable: artChanges(),
builder: (context, _) => SignalBuilder(
builder: (context) => NowPlayingBlock(
now: SessionState.instance.coordinator.nowPlaying.value,
cover: cover,
compact: compact,
Widget build(BuildContext context) => GestureDetector(
onSecondaryTap: () => nowPlayingMenu(context),
child: ListenableBuilder(
listenable: artChanges(),
builder: (context, _) => SignalBuilder(
builder: (context) => NowPlayingBlock(
now: SessionState.instance.coordinator.nowPlaying.value,
cover: cover,
compact: compact,
),
),
),
);
}
/// Copy title; with the link known here ([SessionCoordinator.currentLink])
/// Copy link and Open in browser; a Jellyfin item on a server linked here,
/// Open in Jellyfin. [context]: where the menu opens.
Future<void> nowPlayingMenu(BuildContext context) async {
final coord = SessionState.instance.coordinator;
final now = coord.nowPlaying.peek();
if (now == null) return;
final link = coord.currentLink();
final ref = coord.currentRef.peek();
final page = ref == null ? null : coord.jellyfin.webUrlOf(ref.serverId, ref.itemId);
final episode = now.episode;
final title = episode == null || episode.isEmpty ? now.title : '${now.title} · $episode';
final pick = await showAppMenu<String>(context, items: [
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),
]);
switch (pick) {
case 'title':
await copyToClipboard(title, done: 'Title copied');
case 'link':
await copyToClipboard(link!, done: 'Link copied');
case 'open':
await openLink(link!);
case 'jellyfin':
await openLink(page!);
}
}
+12 -5
View File
@@ -1,17 +1,18 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:signals/signals_flutter.dart';
import 'package:sync_relay_core/core.dart';
import 'package:window_manager/window_manager.dart';
import '../screens/home/ui.dart' show appName, displayVersion;
import '../screens/home/update_indicator.dart';
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/copy_open.dart';
import '../widgets/window_buttons.dart';
import 'avatar.dart';
import 'delivery_line.dart';
@@ -124,6 +125,8 @@ class PartyStrip extends StatelessWidget {
label: t.label,
selected: t == tab,
compact: compact,
// The version, a rest of the mouse away.
tooltip: t == ShellTab.settings ? '$appName ${displayVersion()}' : null,
onTap: () => onTab(t),
),
],
@@ -364,18 +367,21 @@ class StripTab extends StatelessWidget {
final bool selected;
final bool compact;
final VoidCallback onTap;
/// Shown after resting on the tab.
final String? tooltip;
const StripTab({
super.key,
required this.label,
required this.selected,
required this.onTap,
this.compact = false,
this.tooltip,
});
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return Semantics(
final tab = Semantics(
selected: selected,
button: true,
child: InkWell(
@@ -413,6 +419,8 @@ class StripTab extends StatelessWidget {
),
),
);
final tip = tooltip;
return tip == null ? tab : Tooltip(message: tip, waitDuration: const Duration(milliseconds: 600), child: tab);
}
}
@@ -517,8 +525,7 @@ class MembersCard extends StatelessWidget {
alignment: Alignment.centerLeft,
child: TextButton.icon(
key: const ValueKey('copy-invite'),
onPressed: () =>
Clipboard.setData(ClipboardData(text: address)),
onPressed: () => copyToClipboard(address, done: 'Invite address copied'),
icon: const Icon(Icons.copy, size: 16),
label: const Text('Copy invite address'),
),
@@ -628,7 +635,7 @@ class _HostInviteState extends State<_HostInvite> {
future: _addresses,
builder: (context, snap) {
final list = snap.data ?? const <String>[];
void copy(String a) => Clipboard.setData(ClipboardData(text: a));
void copy(String a) => copyToClipboard(a, done: 'Invite address copied');
return Padding(
padding: const EdgeInsets.fromLTRB(12, 0, 12, 12),
child: Column(
@@ -0,0 +1,66 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import '../state/session_state.dart';
// Reusing what the app shows elsewhere: copying a link or a title, opening
// a page in the browser. One wording and one confirmation (a small toast)
// for every menu that offers them.
/// The labels of the reuse actions, the same in every menu.
abstract final class ReuseText {
static const copyLink = 'Copy link';
static const copyTitle = 'Copy title';
static const copyMessage = 'Copy message';
static const openInBrowser = 'Open in browser';
static const openInJellyfin = 'Open in Jellyfin';
}
/// Tests: where the confirmation goes instead of the session's toasts.
@visibleForTesting
void Function(String text)? debugReuseToast;
void _toast(String text) {
final debug = debugReuseToast;
if (debug != null) return debug(text);
SessionState.instance.coordinator.showToast(text);
}
/// Opens [url] in the default browser (no url_launcher dependency); false
/// when no browser could be started.
Future<bool> openInBrowser(String url) async {
try {
final ProcessResult r;
if (Platform.isWindows) {
r = await Process.run('rundll32', ['url.dll,FileProtocolHandler', url]);
} else if (Platform.isMacOS) {
r = await Process.run('open', [url]);
} else {
r = await Process.run('xdg-open', [url]);
}
return r.exitCode == 0;
} catch (_) {
return false;
}
}
/// Puts [text] on the clipboard and confirms it with a toast ([done]:
/// its text, e.g. "Link copied").
Future<void> copyToClipboard(String text, {String done = 'Copied'}) async {
try {
await Clipboard.setData(ClipboardData(text: text));
} catch (_) {
_toast("Couldn't copy");
return;
}
_toast(done);
}
/// Opens [url] in the browser; when none opens, copies it instead and
/// says so.
Future<void> openLink(String url) async {
if (await openInBrowser(url)) return;
await copyToClipboard(url, done: "Couldn't open a browser — link copied");
}
+20 -5
View File
@@ -1,9 +1,24 @@
import 'package:flutter/widgets.dart';
/// True while keyboard focus sits in a text field. The focused node's
/// context belongs to the `Focus` that `EditableText` builds, so look for
/// an `EditableText` above it (walking FocusNode parents never meets one).
/// True while keyboard focus sits in a text field, or anywhere under a
/// [KeyboardClaim]. The focused node's context belongs to the `Focus` that
/// `EditableText` builds, so look for an `EditableText` above it (walking
/// FocusNode parents never meets one).
bool textInputFocused([FocusNode? node]) {
final n = node ?? FocusManager.instance.primaryFocus;
return n?.context?.findAncestorWidgetOfExactType<EditableText>() != null;
final c = (node ?? FocusManager.instance.primaryFocus)?.context;
if (c == null) return false;
return c.findAncestorWidgetOfExactType<EditableText>() != null ||
c.findAncestorWidgetOfExactType<KeyboardClaim>() != null;
}
/// Keyboard focus under it counts as typing ([textInputFocused]): a panel
/// with keys of its own (the Up next popover's Ctrl+V) keeps the app's
/// global keys (Space, Tab, the player's) off while it has focus, as a
/// text field does.
class KeyboardClaim extends StatelessWidget {
final Widget child;
const KeyboardClaim({super.key, required this.child});
@override
Widget build(BuildContext context) => child;
}