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

+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(