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
+1128
-174
No files matched your search
@@ -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) {
|
||||
|
||||
@@ -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'),
|
||||
),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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!);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user