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
@@ -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) {
|
||||
|
||||
@@ -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!);
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user