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
+1128 -174

No files matched your search

+28 -14
View File
@@ -82,12 +82,15 @@ double-click on the picture goes fullscreen (the strip hides, the party
shows in a corner while paused, the window buttons sit in the player's top
bar while its controls are up), Esc leaves it. The controls show the
show's cover, title and episode top left (a web video: its thumbnail and
name; guests get the art too); before
name; guests get the art too; right-click to copy the title, or the link
when you have it, or open it in the browser or Jellyfin); before
a video first plays, its banner fills the picture until playback starts.
One pill bottom centre, just above the controls, says what playback waits for ("Press Space
when you're ready", "Waiting for Bob and Mika · 2/3 ready", "Bob is
loading…") or what went wrong, with Retry or Cancel when it helps. Chat and activity open in a panel on the right (chat button or
Tab), ready to type; it shows messages, and its "Activity" toggle adds
Tab), ready to type; it shows messages (select text to copy it, or
right-click for Copy message and, with a link in it, Copy link / Open in
browser), and its "Activity" toggle adds
joins, ready, seeks and the like between them. Joins, leaves, chat and
other news also pop up bottom left with the person's avatar; click a message to open the chat
(Settings → Notifications → Chat messages: Only when paused by default,
@@ -143,12 +146,16 @@ Up next, add the folder to Files, or fill Link).
details open on the right with "Put it up · S1E3", mark watched /
unwatched, remove from Continue and the episode list (click an episode
to choose it). Double-click a card or an episode to put it up at once.
Right-click a card or an episode to copy its title or open it in
Jellyfin's web page (the details panel has that button too).
- **Link**: paste an http(s) link: a video file, or a web page with a
video (YouTube, X, and whatever else yt-dlp knows). The preview shows
what it is (a page's title, uploader, length and thumbnail, else the
file name); in a server, pick how the
others get it (everyone fetches it, the server fetches it, or through
you). Your last 12 links are listed under it. A link that does not
you). Your last 12 links are listed under it (right-click one to copy
or open it). A link seen before shows its preview at once, without
asking the site again. A link that does not
open here (needs cookies, 403, 404, not a video) says why and goes up
only with "Put it up anyway"; in the player, whoever cannot play it is
named ("Bob can't play this: Not allowed (403)") and nobody waits for them.
@@ -179,8 +186,11 @@ In the player, Up next sits in the top bar next to the chat button: the
next item's picture and title (or the next episode's). Rest the mouse on
it for a larger preview (who added it, its length, whether it is ready
to start); ⏭ plays it now, a click opens the queue: drag rows to reorder, hover a
row to play it now (▶) or remove it (×), paste a link in the field or
drop a link or a video on it to add. When a video ends, a card shows
row to play it now (▶) or remove it (×), right-click it for more (copy its
title or link, open it in the browser or in Jellyfin). To add, press
Ctrl+V with the queue open (a copied link or video file goes in at once,
its title and picture showing while it is checked), type one after
**+ Add link**, or drop a link or a video on it. When a video ends, a card shows
what is up next with a 10 s countdown (Cancel stops it on your screen)
and it plays; `>` or Shift+N plays it at once. Under the queue,
**Continues with** shows the next episodes of the Jellyfin series
@@ -241,7 +251,7 @@ integrations (linked Jellyfin servers, below, the AnimeFlow connection:
"connected as <name>" with what you may do, or why not, and playing the
next episode automatically); Notifications & sounds (what pops up; the sound
volume and sound set, and every sound with a preview, its own set and a switch); Updates; Keyboard shortcuts (read-only); About
(the version).
(the version; resting the mouse on the Settings tab shows it too).
Player settings live in the player: ⚙ at the right end of its controls
(both designs) opens them, and Settings → Player has the same ones (plus
@@ -266,16 +276,20 @@ Controls: the seek bar marks chapter starts with a diamond; hovering it
shows the time and the chapter title. The chapters button lists them
(current one checked; click to jump). "1:30" skips ahead 1:30. Audio and
Subtitles buttons (with the track count) list tracks as "Japanese · E-AC3
2.0 · title"; subtitles have Off. Volume (hover the speaker, scroll on
it, or Up/Down) goes to 150 % like mpv: past the tick at 100 % the bar
turns amber and mpv amplifies.
2.0 · title"; subtitles have Off. Volume (scroll anywhere on the video,
hover the speaker, or Up/Down) goes to 150 % like mpv: past the tick at
100 % the bar turns amber and mpv amplifies; a scroll or key shows the
volume for a moment. Lists and menus under the mouse scroll as usual.
The uosc player design (⚙ → Player design, Standard by default,
switches right away): video controls modeled on mpv's uosc theme. A thick
timeline across the bottom shows the played part, the cached part ahead
(hatched), chapter starts as diamonds and the time; hovering it shows the
chapter title and the time there. With the controls hidden it stays as a
thin line (unless "Progress line when hidden" is off). Above it flat buttons: ≡ menu, Subtitles and Audio (with the
switches right away): video controls modeled on mpv's uosc theme. A
translucent timeline across the bottom marks the position with a thin
line, hatches the part not downloaded yet, shows chapter starts as
diamonds (click one to jump), the elapsed time left, the time remaining
right and, while less than a minute is buffered, the buffered seconds
next to the position; hovering it shows the time there and the chapter
title. With the controls hidden it stays as a thin line (unless
"Progress line when hidden" is off). Above it flat buttons: ≡ menu, Subtitles and Audio (with the
track count), the shader badge (the active Anime4K mode, e.g. "A HQ"),
ready, chapters, 1:30, previous / next episode, change video, volume, ⚙
and fullscreen; the title top left, Up next, chat and back-to-library top right. Keys and skip buttons work
@@ -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;
@@ -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 [
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: [
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) {
+365 -72
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,49 +993,84 @@ 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(
// "+ 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: _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),
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.
// 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),
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: Theme.of(context).colorScheme.primary.withValues(alpha: 0.6), width: 0.5),
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,
@@ -872,14 +1078,95 @@ class _QueuePanelState extends State<QueuePanel> {
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)),
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: 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 {
+51 -2
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,7 +160,30 @@ class ActivityRow extends StatelessWidget {
final scheme = Theme.of(context).colorScheme;
final time = _clock(item.at);
if (item.action == 'chat') {
return Padding(
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,
@@ -181,12 +206,14 @@ class ActivityRow extends StatelessWidget {
Text(time, style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 11)),
],
),
SelectableText(item.reason),
message,
],
),
),
],
),
),
),
);
}
final (icon, color, text) = item.actor == 'server' ? serverFeedStyle(item) : feedStyle(item, scheme);
@@ -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) {
+241 -11
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();
// 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,9 +729,14 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
? const SizedBox(width: double.infinity)
: Padding(
padding: const EdgeInsets.only(top: 14),
child: Builder(
builder: (context) => GestureDetector(
onSecondaryTap: () => _previewMenu(context, url, preview),
child: _PreviewCard(preview: preview, loading: loading),
),
),
),
),
if (!solo) ...[
const SizedBox(height: 28),
Text('How the others get it', style: t.titleSmall),
@@ -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,14 +155,17 @@ 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(
Widget build(BuildContext context) => GestureDetector(
onSecondaryTap: () => nowPlayingMenu(context),
child: ListenableBuilder(
listenable: artChanges(),
builder: (context, _) => SignalBuilder(
builder: (context) => NowPlayingBlock(
@@ -169,5 +174,38 @@ class NowPlayingTitle extends StatelessWidget {
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;
}
+19
View File
@@ -189,6 +189,25 @@ void main() {
expect(decodeRecentLinks(jsonEncode([{'url': ''}, {'url': 'https://a/b.mkv'}])).single.title, 'b.mkv');
});
test('kept previews: playable ones only, display text, survive a reload', () async {
String? disk;
LinkPreviewStore store() => LinkPreviewStore(read: () async => disk, write: (v) async => disk = v);
final a = store();
await a.put('https://a', const LinkPreview(title: 'A', subtitle: 'a · Chan · 3:07', thumbnail: 'https://i/a.jpg'),
at: DateTime.utc(2026, 1, 2));
await a.put('https://b', const LinkPreview(title: 'B', subtitle: 'b', problem: 'Not found (404)'));
expect(a.lookup('https://b'), isNull);
final b = store();
await b.ensureLoaded();
final kept = b.lookup('https://a')!;
expect((kept.preview.title, kept.preview.subtitle, kept.preview.thumbnail),
('A', 'a · Chan · 3:07', 'https://i/a.jpg'));
expect(kept.at.isAtSameMomentAs(DateTime.utc(2026, 1, 2)), isTrue);
// A problem later forgets it.
await b.put('https://a', const LinkPreview(title: 'A', subtitle: 'a', problem: 'Not allowed (403)'));
expect(b.lookup('https://a'), isNull);
});
test('preview: a page by yt-dlp, a file by its name, else why not', () async {
final yt = await previewLink('https://www.youtube.com/watch?v=abc',
probe: (url) async => LinkProbe.page(Uri.parse(url),
+59
View File
@@ -0,0 +1,59 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:watch_together/src/queue/queue_ui.dart';
import 'package:watch_together/src/session/session_coordinator.dart';
import 'package:watch_together/src/shell/library_link.dart' show LinkPreview;
import 'package:watch_together/src/widgets/text_focus.dart';
void main() {
testWidgets('Up next popover: holds the keys, Ctrl+V adds with a preview row, + Add link opens a field',
(tester) async {
final coord = SessionCoordinator();
var clip = (files: const <String>[], text: 'look https://example.com/v' as String?);
await tester.pumpWidget(MaterialApp(
theme: ThemeData.dark(),
home: Scaffold(
body: Center(
child: QueuePanel(
queue: coord.queue,
onClose: () {},
readClipboard: () async => clip,
preview: (url) async => const LinkPreview(title: 'A video', subtitle: 'example.com'),
),
),
),
));
await tester.pump();
expect(textInputFocused(), isTrue, reason: 'the player keys rest while it is open');
expect(find.byKey(const ValueKey('queue-add-field')), findsNothing);
// Ctrl+V: the link goes in at once, as a row with its preview; not in
// a server here, so it says why.
await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft);
await tester.sendKeyEvent(LogicalKeyboardKey.keyV);
await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft);
await tester.pump();
await tester.pump();
expect(find.text('A video'), findsOneWidget);
expect(find.text('not in a server'), findsOneWidget);
// Nothing to add on the clipboard.
clip = (files: const <String>[], text: 'just words');
await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft);
await tester.sendKeyEvent(LogicalKeyboardKey.keyV);
await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft);
await tester.pump();
expect(find.byKey(const ValueKey('queue-add-error')), findsOneWidget);
// + Add link: a small field with focus; Esc closes it, focus stays.
await tester.tap(find.byKey(const ValueKey('queue-add-link')));
await tester.pump();
await tester.pump();
expect(find.byKey(const ValueKey('queue-add-field')), findsOneWidget);
await tester.sendKeyEvent(LogicalKeyboardKey.escape);
await tester.pump();
expect(find.byKey(const ValueKey('queue-add-field')), findsNothing);
expect(textInputFocused(), isTrue);
});
}
+12
View File
@@ -30,4 +30,16 @@ void main() {
field.dispose();
button.dispose();
});
testWidgets('focus under a KeyboardClaim counts as typing', (tester) async {
final claimed = FocusNode();
await tester.pumpWidget(MaterialApp(
home: KeyboardClaim(child: Focus(focusNode: claimed, child: const SizedBox())),
));
expect(textInputFocused(), isFalse);
claimed.requestFocus();
await tester.pump();
expect(textInputFocused(), isTrue);
claimed.dispose();
});
}
+83 -16
View File
@@ -746,7 +746,7 @@ all three link modes. Nothing is bundled or installed by hand.
(`AppYtDlp.probe`, remembered 10 minutes, longer for Up next's first
item (see "Up next"); media file names too, not a
Jellyfin item link) for the
Link preview and the swap: a page is named by its title, never its URL
Link preview (kept on disk too; see "Tabs" under Desktop UI) and the swap: a page is named by its title, never its URL
tail, and its thumbnail goes up as its art (see "Media art"). A link with a
problem, or one the probe cannot reach ("Couldn't reach the site"), is
not put up in any mode: the Link page shows the reason (preview and
@@ -1021,10 +1021,23 @@ every `state` and feed), the pure rules in `queue_logic.dart`, the UI in
the menus' paddings and row hover, 64 px rows with an 80 px 16:9
picture, title, second line, "length · added by (avatar) name · how", a drag handle
(`ReorderableListView`, reordered here at once and sent as `move`), ▶
and × floating in on hover; "Continues with"; a "Paste a link to add"
field (focused on open, so the player's keys and Space rest while it
is up; Esc closes) and a drop zone for links and video files. Being no
route, it holds the shell's own drop and paste handling off while open
and × floating in on hover, right-click for Play now, Remove, Copy
title, and Copy link / Open in browser only for a link known here
(`QueueController.linkOf`: a link everyone opens, or one passed on by
this app, which only its adder has; never a server-fetched link or
another member's) and Open in Jellyfin for an item of a server linked
here; "Continues with". The panel itself holds focus on open, inside a
`KeyboardClaim` (`widgets/text_focus.dart`: focus under it counts as a
text field's, so the player's keys and Space rest while it is up; Esc
closes). Ctrl+V there adds the clipboard's video files, else the links
in its text, at once (an ordinary paste while typing in the field); "+ Add
link" opens a small field (Enter adds, Esc closes it); links and video
files can be dropped on it. Each addition is a row of its own under
the items: the link's title and thumbnail as soon as they are known
(`cachedLinkPreview`: a kept preview at once, else the same probe the
add runs), "Adding…", then "Added" (gone shortly after, the item itself
being in the list) or why not (× dismisses). Being no route, it holds
the shell's own drop and paste handling off while open
(`handoffHolds`). "Add to queue" elsewhere, on every source: the Link
page's button next to Put it up (in the chosen mode), a right-click
`showAppMenu` (Put it up / Add to queue / Play next) on recent links,
@@ -1061,6 +1074,19 @@ setting that picks one of a few values), `AppMenuButton` the ⋮ action
menu. Material's own menus (the standard controls' track, chapter and
shader menus, the Jellyfin sort and server switch) get the same surface
from `AppMenuStyle.theme`; the uosc controls keep their own flat menus.
Reusing what the app shows (`widgets/copy_open.dart`): one wording
(`ReuseText`: Copy link, Copy title, Copy message, Open in browser, Open
in Jellyfin) in every right-click menu that offers it, and a toast
confirming a copy ("Link copied"); a browser that will not start copies
the link instead. Offered on recent links and the Link preview (Copy
link, Copy title, Open in browser), Up next rows (see "Up next"), the
now-playing title (Copy title; with the link known here,
`SessionCoordinator.currentLink`: one this app put up, or one in the
state, Copy link and Open in browser; a Jellyfin item of a server linked
here, Open in Jellyfin), Jellyfin cards and episodes (Copy title, an
episode with its series; Open in Jellyfin: `jellyfinWebUrl`, the item's
page on its server's web UI, no credentials; also a button in the details
panel) and chat messages (Copy message, and a link in it).
- **Party strip** (`party_strip.dart`, 60 px): your avatar (profile card; 42 px like the party's),
the Library / Schedule / Settings tabs with a growing underline (an
@@ -1135,7 +1161,18 @@ from `AppMenuStyle.theme`; the uosc controls keep their own flat menus.
the controller's default, as the main card; "Everyone fetches it" and
"Server fetches it" under "Other ways", open while one of them is
picked, closing it goes back to Through me), and the
last 12 links put up (`recentLinks` in the `connect_form` box). Files
last 12 links put up (`recentLinks` in the `connect_form` box: URL,
title, thumbnail, the preview's second line, when). A link seen before
previews at once, without yt-dlp: `LinkPreviewStore` (`linkPreviews` in
the same box) keeps what a probe found for up to 200 links that looked
playable (title, second line, thumbnail URL and when; never cookies or
resolved stream URLs), else a recent link's card stands in. Older than a
day it is still shown and probed again quietly behind it (a site out of
reach keeps it; a problem replaces it and is not kept). Putting it up
or queueing resolves as before (reusing a probe still in memory); when
that fails although the kept preview looked fine, the preview is
probed again and shows why, with "Put it up anyway" when it is a
problem. Files
is `FilesPage` (`files/`): watched folders (`FolderStore`) scanned in
an isolate, videos grouped by series; series start collapsed, a header
click opens one and open ones stay open for the app session
@@ -1214,8 +1251,9 @@ from `AppMenuStyle.theme`; the uosc controls keep their own flat menus.
The player keys (Settings → Keyboard shortcuts) work while the player is
full and nothing is in front of it; ↑ / ↓ step the volume. Space (ready)
and Tab (chat) are app-wide: `AppKeys` (`widgets/app_keys.dart`, around
the shell) takes them first while in a server, unless a text field has
focus or a dialog or menu is in front. Tab reaches the shell's chat
the shell) takes them first while in a server, unless a text field (or
a `KeyboardClaim`, see "Up next") has focus or a dialog or menu is in
front. Tab reaches the shell's chat
toggle through `ChatKey` (the strip while shown, the player in
fullscreen). The app's shortcuts map Tab / Shift+Tab to nothing, so focus
never lands on a button Space would press. Something coming up while the player
@@ -1382,7 +1420,16 @@ a new kind appears (Skip ED and Next episode never without the controls).
Volume (`controls/volume.dart`, both
designs): mpv's `volume-max` is pinned to `PlaybackController.maxVolume`
(150) per player; `VolumeBar` runs 0..150 with a tick at 100 and the part
above in amber; the wheel and ↑ / ↓ step 5 %, clamped.
above in amber; the wheel and ↑ / ↓ step 5 % (`volumeStep`), clamped.
The wheel works anywhere on the full player (`VolumeWheel` around the
player's stack): `WheelSteps` makes a mouse notch one step and adds up
small smooth-scroll deltas, ignores mostly-horizontal scrolls, and claims
the event through `pointerSignalResolver`, so anything under the mouse
that scrolls (lists, menus, popovers in their own routes) or the volume
buttons themselves take it first. A wheel or key step flashes the
design's readout for 1 s (`VolumeFlash`): uosc's vertical volume slider
at the right edge (`controls/uosc/volume_flash.dart`, after uosc's
`Volume.lua`), else a pill under the top bar (`VolumeReadout`).
The uosc player design (`controls/uosc/`, after mpv's uosc theme;
"Player design" = `AppSettings.mpvControls`, default Standard): the `Video`'s
@@ -1391,12 +1438,27 @@ the setting live. `UoscControls` is our
own frame (no media_kit controls state): mouse movement shows it, 3 s
without any hides it and the cursor; `hold` keeps it up during a timeline
drag and while a menu route is open, double-click toggles fullscreen.
`UoscTimeline` runs the full width at the bottom: played part filled,
`cacheRect` (position to `buffered`) hatched (`hatchLines`), chapter
diamonds on the top edge, current time and duration in boxes inside it, a
hover tip with the chapter title over the hover time; hidden, it shrinks
to a 2 px line with chapter ticks (faded out when the progress line
setting is off). Above it a row of flat buttons: menu (≡:
`UoscTimeline` ports uosc's `Timeline.lua` (style `line`, uosc's defaults
at scale 1, `UoscTimelineSpec`) across the full width at the bottom: a
40 px band (plus a 1 px top border) of black at 0.9; the position is a
2 px white line, no filled part, and times map around it
(`UoscTimelineGeometry`, uosc's `t2x` / `get_time_at_x`); the uncached
part (from `buffered`, or the position when that is behind, to the end)
carries uosc's stripe texture (white then black "/" stripes, 0.2 / 0.4
strong); chapter diamonds sit on the top edge (white 0.8, 1 px black
border, doubled and clickable to jump while the mouse is within twice
their radius); elapsed time left and the time remaining ("-01:47:30")
right in 20 px outlined text, inverted where it crosses the line; while
less than 60 s is buffered ahead (and the cache does not reach the end)
a small "12s" rides right of the line. Hover: a faint 1 px line and a
black tooltip with the time (the chapter title, bold, above it). A press
seeks, every drag move seeks, and after release the line stays at the
target until the position catches up (at most 2 s). Hidden, it animates
down (100 ms) to uosc's progress line: 2 px with a 20 px position
marker, chapters as small diamonds (faded out when the progress line
setting is off). We know only the end of mpv's cache, so a dropped back
buffer is not hatched; uosc's pause while dragging, keyframe seeks for
fast drags, thumbnails and the fullscreen scale (1.3) are left out. Above it a row of flat buttons: menu (≡:
change video, chat & activity, reload, mark everyone ready, fullscreen,
leave),
subtitles and audio with track counts, a shader badge ("A HQ", opening the
@@ -1457,7 +1519,12 @@ running exe on NTFS. The manifest's `assets` maps `linux-x64` /
`windows-x64` to archive URLs; top-level `url` (Linux) is for builds <= 0.2.1. The version is
stamped into both binaries with `APP_VERSION` (`--dart-define` for the app,
`--define` for `dart compile exe`; dev runs report `0.0.0-dev` and the app
skips checks). CLI: `watch-together-server --upgrade` (no display needed;
skips checks). What the app shows (`displayVersion`, `screens/home/ui.dart`;
About and the tooltip of the strip's Settings tab): a release's version,
a test build's with its commit (`GIT_COMMIT`, short hash, `-dirty` with
uncommitted changes, passed by `tool/package_linux.sh`), and for a dev
run the pubspec's version (`FLUTTER_BUILD_NAME`, which the Flutter tool
defines for every build and run) with "dev run". CLI: `watch-together-server --upgrade` (no display needed;
the shipped `update.sh` / `update.bat` run it), `--check-update` prints only;
it ends with "restart the server to run <version>". `watch_together --upgrade`
does the same from the app binary. In the app, `UpdateController`
+3 -1
View File
@@ -21,7 +21,9 @@ cd packages/server && dart compile exe --define=APP_VERSION=<x.y.z> bin/server.d
```
`APP_VERSION` defaults to `0.0.0-dev`; the packagers stamp it from the
desktop pubspec into both the app and the server. Setting `DISCORD_WEBHOOK_URL` makes the packager post the
desktop pubspec into both the app and the server. `tool/package_linux.sh`
also passes `GIT_COMMIT` (what the app shows: "Updater" in
[ARCHITECTURE.md](ARCHITECTURE.md)). Setting `DISCORD_WEBHOOK_URL` makes the packager post the
tarball to Discord (never commit it). The packager always compiles the
dedicated server (with the Flutter SDK's `dart`) into the tarball as
`watch-together-server`, also with `--skip-build`.
@@ -64,6 +64,15 @@ String? normalizeItemId(String id) {
return id.replaceAll('-', '').toLowerCase();
}
/// [itemId]'s page in the web UI of the server at [baseUrl] (the form
/// [parseJellyfinLink] reads back). No credentials: whoever opens it signs
/// in on that server.
String jellyfinWebUrl(String baseUrl, String itemId, {String? serverId}) {
final base = baseUrl.replaceAll(RegExp(r'/+$'), '');
final server = serverId == null || serverId.isEmpty ? '' : '&serverId=${Uri.encodeQueryComponent(serverId)}';
return '$base/web/#/details?id=${Uri.encodeQueryComponent(itemId)}$server';
}
/// Recognises a Jellyfin item link and reduces it to (baseUrl, itemId):
/// - web UI: `/web/#/details?id=X&serverId=S`, `/web/index.html#!/details?id=X`,
/// any `#/...?id=X` route;
+8 -1
View File
@@ -35,8 +35,15 @@ ARCH="x64"
VERSION="${VERSION:-$(sed -n 's/^version: \([0-9.]*\).*/\1/p' "$APP/pubspec.yaml")}"
if [[ -z "$VERSION" ]]; then echo "error: version not found in $APP/pubspec.yaml" >&2; exit 1; fi
# The commit the app shows next to a test build's version (About, the
# Settings tab's tooltip); -dirty with uncommitted changes.
COMMIT="$(git -C "$ROOT" rev-parse --short HEAD 2>/dev/null || true)"
if [[ -n "$COMMIT" && -n "$(git -C "$ROOT" status --porcelain --untracked-files=no 2>/dev/null)" ]]; then
COMMIT="$COMMIT-dirty"
fi
if [[ "$SKIP_BUILD" != "1" ]]; then
(cd "$APP" && flutter build linux --release --dart-define=APP_VERSION="$VERSION")
(cd "$APP" && flutter build linux --release --dart-define=APP_VERSION="$VERSION" --dart-define=GIT_COMMIT="$COMMIT")
fi
BUNDLE="$APP/build/linux/$ARCH/release/bundle"