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
|
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
|
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
|
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.
|
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
|
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
|
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
|
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
|
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
|
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,
|
(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 /
|
details open on the right with "Put it up · S1E3", mark watched /
|
||||||
unwatched, remove from Continue and the episode list (click an episode
|
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.
|
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
|
- **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
|
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
|
what it is (a page's title, uploader, length and thumbnail, else the
|
||||||
file name); in a server, pick how the
|
file name); in a server, pick how the
|
||||||
others get it (everyone fetches it, the server fetches it, or through
|
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
|
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
|
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.
|
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
|
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
|
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
|
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
|
row to play it now (▶) or remove it (×), right-click it for more (copy its
|
||||||
drop a link or a video on it to add. When a video ends, a card shows
|
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)
|
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,
|
and it plays; `>` or Shift+N plays it at once. Under the queue,
|
||||||
**Continues with** shows the next episodes of the Jellyfin series
|
**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
|
"connected as <name>" with what you may do, or why not, and playing the
|
||||||
next episode automatically); Notifications & sounds (what pops up; the sound
|
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
|
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
|
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
|
(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
|
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
|
(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
|
Subtitles buttons (with the track count) list tracks as "Japanese · E-AC3
|
||||||
2.0 · title"; subtitles have Off. Volume (hover the speaker, scroll on
|
2.0 · title"; subtitles have Off. Volume (scroll anywhere on the video,
|
||||||
it, or Up/Down) goes to 150 % like mpv: past the tick at 100 % the bar
|
hover the speaker, or Up/Down) goes to 150 % like mpv: past the tick at
|
||||||
turns amber and mpv amplifies.
|
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,
|
The uosc player design (⚙ → Player design, Standard by default,
|
||||||
switches right away): video controls modeled on mpv's uosc theme. A thick
|
switches right away): video controls modeled on mpv's uosc theme. A
|
||||||
timeline across the bottom shows the played part, the cached part ahead
|
translucent timeline across the bottom marks the position with a thin
|
||||||
(hatched), chapter starts as diamonds and the time; hovering it shows the
|
line, hatches the part not downloaded yet, shows chapter starts as
|
||||||
chapter title and the time there. With the controls hidden it stays as a
|
diamonds (click one to jump), the elapsed time left, the time remaining
|
||||||
thin line (unless "Progress line when hidden" is off). Above it flat buttons: ≡ menu, Subtitles and Audio (with the
|
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"),
|
track count), the shader badge (the active Anime4K mode, e.g. "A HQ"),
|
||||||
ready, chapters, 1:30, previous / next episode, change video, volume, ⚙
|
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
|
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) =>
|
JellyfinAccount? account(String serverId) =>
|
||||||
_accounts.where((a) => a.serverId == serverId).firstOrNull;
|
_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.
|
/// The chosen default, else the first linked server.
|
||||||
JellyfinAccount? get defaultAccount =>
|
JellyfinAccount? get defaultAccount =>
|
||||||
(_defaultServerId == null ? null : account(_defaultServerId!)) ?? _accounts.firstOrNull;
|
(_defaultServerId == null ? null : account(_defaultServerId!)) ?? _accounts.firstOrNull;
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import 'package:sync_relay_core/core.dart';
|
|||||||
import 'accounts.dart';
|
import 'accounts.dart';
|
||||||
import 'format.dart';
|
import 'format.dart';
|
||||||
import '../widgets/app_menu.dart';
|
import '../widgets/app_menu.dart';
|
||||||
|
import '../widgets/copy_open.dart';
|
||||||
|
|
||||||
/// One level of the library browser's back stack.
|
/// One level of the library browser's back stack.
|
||||||
sealed class BrowseRoute {
|
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].
|
/// What every browser page needs, provided by [JellyfinBrowser].
|
||||||
class BrowseScope extends InheritedWidget {
|
class BrowseScope extends InheritedWidget {
|
||||||
final JellyfinAccounts accounts;
|
final JellyfinAccounts accounts;
|
||||||
@@ -284,7 +293,7 @@ class BrowseScope extends InheritedWidget {
|
|||||||
/// Double-click: put it up (a series: its next episode).
|
/// Double-click: put it up (a series: its next episode).
|
||||||
final ValueChanged<JellyfinItem> onPutUp;
|
final ValueChanged<JellyfinItem> onPutUp;
|
||||||
/// Adds it to Up next (with next: right after what plays); null: no
|
/// 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;
|
final void Function(JellyfinItem item, {bool next})? onAddToQueue;
|
||||||
/// Home shows the library chips itself (the Library tab has them in its
|
/// Home shows the library chips itself (the Library tab has them in its
|
||||||
/// header instead).
|
/// header instead).
|
||||||
@@ -309,23 +318,30 @@ class BrowseScope extends InheritedWidget {
|
|||||||
|
|
||||||
void onOpen(BrowseRoute route) => controller.open(route);
|
void onOpen(BrowseRoute route) => controller.open(route);
|
||||||
|
|
||||||
/// Right-click on a playable card ([context]: the card): Put it up, Add
|
/// Right-click on a card ([context]: the card): Put it up, Add to queue,
|
||||||
/// to queue, Play next. Nothing without a server.
|
/// Play next (playable, in a server), Copy title, Open in Jellyfin.
|
||||||
Future<void> cardMenu(BuildContext context, JellyfinItem item) async {
|
Future<void> cardMenu(BuildContext context, JellyfinItem item) async {
|
||||||
final queue = onAddToQueue;
|
final queue = isSelectable(item) ? onAddToQueue : null;
|
||||||
if (queue == null || !isSelectable(item)) return;
|
final pick = await showAppMenu<int>(context, items: [
|
||||||
final pick = await showAppMenu<int>(context, items: const [
|
if (queue != null) ...const [
|
||||||
AppMenuItem(value: 0, label: 'Put it up'),
|
AppMenuItem(value: 0, label: 'Put it up'),
|
||||||
AppMenuItem(value: 1, label: 'Add to queue'),
|
AppMenuItem(value: 1, label: 'Add to queue'),
|
||||||
AppMenuItem(value: 2, label: 'Play next'),
|
AppMenuItem(value: 2, label: 'Play next'),
|
||||||
|
],
|
||||||
|
const AppMenuItem(value: 3, label: ReuseText.copyTitle),
|
||||||
|
const AppMenuItem(value: 4, label: ReuseText.openInJellyfin),
|
||||||
]);
|
]);
|
||||||
switch (pick) {
|
switch (pick) {
|
||||||
case 0:
|
case 0:
|
||||||
onPutUp(item);
|
onPutUp(item);
|
||||||
case 1:
|
case 1:
|
||||||
queue(item);
|
queue!(item);
|
||||||
case 2:
|
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 '../shell/motion.dart';
|
||||||
import '../widgets/app_menu.dart';
|
import '../widgets/app_menu.dart';
|
||||||
|
import '../widgets/copy_open.dart';
|
||||||
import 'accounts.dart';
|
import 'accounts.dart';
|
||||||
import 'browse_scope.dart';
|
import 'browse_scope.dart';
|
||||||
import 'cards.dart' show doubleClickTime, kindIcon;
|
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
|
/// 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 {
|
Future<void> _episodeMenu(BuildContext context, JellyfinItem e) async {
|
||||||
if (widget.onAddToQueue == null) return;
|
final queue = widget.onAddToQueue != null;
|
||||||
setState(() => _targetId = e.id);
|
if (queue) setState(() => _targetId = e.id);
|
||||||
|
final client = _client;
|
||||||
final pick = await showAppMenu<int>(context, items: [
|
final pick = await showAppMenu<int>(context, items: [
|
||||||
|
if (queue) ...[
|
||||||
AppMenuItem(value: 0, label: widget.solo ? 'Play' : 'Put it up'),
|
AppMenuItem(value: 0, label: widget.solo ? 'Play' : 'Put it up'),
|
||||||
const AppMenuItem(value: 1, label: 'Add to queue'),
|
const AppMenuItem(value: 1, label: 'Add to queue'),
|
||||||
const AppMenuItem(value: 2, label: 'Play next'),
|
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) {
|
switch (pick) {
|
||||||
case 0:
|
case 0:
|
||||||
@@ -164,6 +170,10 @@ class _JellyfinDetailsPanelState extends State<JellyfinDetailsPanel> {
|
|||||||
await _queue(e);
|
await _queue(e);
|
||||||
case 2:
|
case 2:
|
||||||
await _queue(e, next: true);
|
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)),
|
onPressed: () => _c.open(SeriesRoute(d.series!, seasonId: seasonId)),
|
||||||
icon: const Icon(Icons.open_in_new),
|
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) ...[
|
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
|
// ------------------------------------------------------------ editing
|
||||||
|
|
||||||
void move(String id, int to) {
|
void move(String id, int to) {
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import 'dart:async';
|
|||||||
import 'dart:math' as math;
|
import 'dart:math' as math;
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
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:leaf_widgets/leaf_widgets.dart' show LeafPopover, LeafPopoverController;
|
||||||
import 'package:signals/signals_flutter.dart';
|
import 'package:signals/signals_flutter.dart';
|
||||||
import 'package:sync_relay_core/core.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 '../session/session_coordinator.dart' show autoAdvanceSecs;
|
||||||
import '../shell/avatar.dart';
|
import '../shell/avatar.dart';
|
||||||
import '../shell/handoff.dart' show handoffHolds;
|
import '../shell/handoff.dart' show handoffHolds;
|
||||||
|
import '../shell/library_link.dart' show LinkPreview, cachedLinkPreview;
|
||||||
import '../shell/motion.dart';
|
import '../shell/motion.dart';
|
||||||
import '../state/session_state.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_controller.dart';
|
||||||
import 'queue_logic.dart';
|
import 'queue_logic.dart';
|
||||||
|
|
||||||
@@ -546,22 +550,47 @@ class _RoundIconState extends State<_RoundIcon> {
|
|||||||
|
|
||||||
// ----------------------------------------------------------------- panel
|
// ----------------------------------------------------------------- panel
|
||||||
|
|
||||||
/// The queue, under the chip: Up next (drag to reorder, ▶ / × on hover),
|
/// The queue, under the chip: Up next (drag to reorder, ▶ / × on hover,
|
||||||
/// "Continues with" (greyed, + adds one), a field to paste a link, and
|
/// right-click for more), "Continues with" (greyed, + adds one), and ways
|
||||||
/// drops of links and video files.
|
/// 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 {
|
class QueuePanel extends StatefulWidget {
|
||||||
final QueueController queue;
|
final QueueController queue;
|
||||||
final VoidCallback onClose;
|
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
|
@override
|
||||||
State<QueuePanel> createState() => _QueuePanelState();
|
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> {
|
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 _field = TextEditingController();
|
||||||
final _focus = FocusNode();
|
final _fieldFocus = FocusNode(debugLabel: 'queue-add-field');
|
||||||
bool _busy = false;
|
/// "+ Add link" opened the field.
|
||||||
|
bool _typing = false;
|
||||||
|
final _adding = <_Adding>[];
|
||||||
|
int _addingIds = 0;
|
||||||
|
/// A paste or drop with nothing to add.
|
||||||
String? _error;
|
String? _error;
|
||||||
bool _dragging = false;
|
bool _dragging = false;
|
||||||
// Reordered here before the server's state says so (no jump back while
|
// Reordered here before the server's state says so (no jump back while
|
||||||
@@ -574,8 +603,6 @@ class _QueuePanelState extends State<QueuePanel> {
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
// Typing and Esc land here, not on the player (its keys pause while a
|
|
||||||
// text field has focus).
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
if (mounted) _focus.requestFocus();
|
if (mounted) _focus.requestFocus();
|
||||||
});
|
});
|
||||||
@@ -585,48 +612,149 @@ class _QueuePanelState extends State<QueuePanel> {
|
|||||||
void dispose() {
|
void dispose() {
|
||||||
_field.dispose();
|
_field.dispose();
|
||||||
_focus.dispose();
|
_focus.dispose();
|
||||||
|
_fieldFocus.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _add(List<PickedMedia> picks) async {
|
/// Back to the panel (after a menu, or the field closing), so its keys
|
||||||
if (picks.isEmpty || _busy) return;
|
/// keep working.
|
||||||
setState(() {
|
void _refocus() {
|
||||||
_busy = true;
|
if (mounted) _focus.requestFocus();
|
||||||
_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();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _submit() {
|
KeyEventResult _key(FocusNode node, KeyEvent e) {
|
||||||
final text = _field.text.trim();
|
if (e is! KeyDownEvent) return KeyEventResult.ignored;
|
||||||
if (text.isEmpty) return;
|
final keys = HardwareKeyboard.instance;
|
||||||
if (!RegExp(r'^https?://\S+$', caseSensitive: false).hasMatch(text)) {
|
if (e.logicalKey == LogicalKeyboardKey.keyV &&
|
||||||
setState(() => _error = 'Paste a link (http:// or https://)');
|
(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;
|
return;
|
||||||
}
|
}
|
||||||
// Links go "through me" by default, like the Link page (alone: direct).
|
if (_typing && _field.text.isEmpty) _closeField();
|
||||||
_add([LinkMedia(text, relay: LinkRelay.me)]);
|
_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>[];
|
final picks = <PickedMedia>[];
|
||||||
for (final raw in paths) {
|
for (final r in raw) {
|
||||||
final p = raw.trim();
|
final p = r.trim();
|
||||||
if (RegExp(r'^https?://', caseSensitive: false).hasMatch(p)) {
|
if (p.isEmpty || !seen.add(p)) continue;
|
||||||
|
if (RegExp(r'^https?://\S+$', caseSensitive: false).hasMatch(p)) {
|
||||||
picks.add(LinkMedia(p, relay: LinkRelay.me));
|
picks.add(LinkMedia(p, relay: LinkRelay.me));
|
||||||
} else if (kindOf(p) == FileKind.video) {
|
} else if (kindOf(p) == FileKind.video) {
|
||||||
picks.add(FileMedia(p));
|
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) {
|
if (picks.isEmpty) {
|
||||||
setState(() => _error = 'Drop a video file or a link');
|
setState(() => _error = 'Drop a video file or a link');
|
||||||
return;
|
return;
|
||||||
@@ -634,6 +762,39 @@ class _QueuePanelState extends State<QueuePanel> {
|
|||||||
_add(picks);
|
_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) {
|
List<QueueItem> _ordered(List<QueueItem> items) {
|
||||||
final order = _order;
|
final order = _order;
|
||||||
if (order == null) return items;
|
if (order == null) return items;
|
||||||
@@ -670,7 +831,7 @@ class _QueuePanelState extends State<QueuePanel> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final height = MediaQuery.sizeOf(context).height;
|
final height = MediaQuery.sizeOf(context).height;
|
||||||
return DropTarget(
|
final drop = DropTarget(
|
||||||
onDragEntered: (_) => setState(() => _dragging = true),
|
onDragEntered: (_) => setState(() => _dragging = true),
|
||||||
onDragExited: (_) => setState(() => _dragging = false),
|
onDragExited: (_) => setState(() => _dragging = false),
|
||||||
onDragDone: (d) {
|
onDragDone: (d) {
|
||||||
@@ -704,13 +865,15 @@ class _QueuePanelState extends State<QueuePanel> {
|
|||||||
children: [
|
children: [
|
||||||
_header(items.length),
|
_header(items.length),
|
||||||
Flexible(
|
Flexible(
|
||||||
child: items.isEmpty && cont.isEmpty
|
child: items.isEmpty && cont.isEmpty && _adding.isEmpty
|
||||||
? _empty()
|
? _empty()
|
||||||
: ListView(
|
: ListView(
|
||||||
shrinkWrap: true,
|
shrinkWrap: true,
|
||||||
padding: const EdgeInsets.fromLTRB(4, 0, 4, 4),
|
padding: const EdgeInsets.fromLTRB(4, 0, 4, 4),
|
||||||
children: [
|
children: [
|
||||||
if (items.isEmpty)
|
if (items.isEmpty && _adding.isNotEmpty)
|
||||||
|
const SizedBox.shrink()
|
||||||
|
else if (items.isEmpty)
|
||||||
const Padding(
|
const Padding(
|
||||||
padding: EdgeInsets.fromLTRB(12, 2, 12, 8),
|
padding: EdgeInsets.fromLTRB(12, 2, 12, 8),
|
||||||
child: Text('Nothing added yet. What plays next is below.',
|
child: Text('Nothing added yet. What plays next is below.',
|
||||||
@@ -744,10 +907,17 @@ class _QueuePanelState extends State<QueuePanel> {
|
|||||||
members: r?.members ?? const [],
|
members: r?.members ?? const [],
|
||||||
selfId: selfId,
|
selfId: selfId,
|
||||||
warm: i == 0 && warm?.id == item.id ? warm!.state : null,
|
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).
|
// 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)),
|
Text('Nothing up next', style: TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.w600)),
|
||||||
SizedBox(height: 4),
|
SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
'Paste a link below, drop a video or a link here, or pick "Add to queue" in the library. '
|
'Press Ctrl+V to add a copied link, drop a video or a link here, or pick "Add to queue" '
|
||||||
'It plays when this one ends.',
|
'in the library. It plays when this one ends.',
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: TextStyle(color: Colors.white54, fontSize: 12, height: 1.35),
|
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),
|
for (final e in cont) _ContinuationRow(episode: e, queue: _q),
|
||||||
];
|
];
|
||||||
|
|
||||||
Widget _footer() => Container(
|
// "+ Add link" (Ctrl+V and drops work without it), or the small field
|
||||||
decoration: const BoxDecoration(border: Border(top: BorderSide(color: AppMenuStyle.border, width: 0.5))),
|
// it opens; under it what a paste or drop could not use.
|
||||||
padding: const EdgeInsets.all(8),
|
Widget _footer() {
|
||||||
child: Column(
|
final accent = Theme.of(context).colorScheme.primary;
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
final Widget row;
|
||||||
mainAxisSize: MainAxisSize.min,
|
if (_typing) {
|
||||||
children: [
|
row = TextField(
|
||||||
TextField(
|
|
||||||
key: const ValueKey('queue-add-field'),
|
key: const ValueKey('queue-add-field'),
|
||||||
controller: _field,
|
controller: _field,
|
||||||
focusNode: _focus,
|
focusNode: _fieldFocus,
|
||||||
// Read-only while adding, not disabled: focus stays here, so
|
style: const TextStyle(fontSize: 13),
|
||||||
// Space and the arrows do not reach the player.
|
|
||||||
readOnly: _busy,
|
|
||||||
style: const TextStyle(fontSize: 14),
|
|
||||||
onSubmitted: (_) => _submit(),
|
onSubmitted: (_) => _submit(),
|
||||||
// The app's field look (as `AppSelect`): a faint fill, a
|
// The app's field look (as `AppSelect`): a faint fill, a hairline,
|
||||||
// hairline, the accent while focused.
|
// the accent while focused.
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
isDense: true,
|
isDense: true,
|
||||||
filled: true,
|
filled: true,
|
||||||
fillColor: const Color(0x0DFFFFFF),
|
fillColor: const Color(0x0DFFFFFF),
|
||||||
hintText: _dragging ? 'Drop to add' : 'Paste a link to add',
|
hintText: 'https://… (Enter adds)',
|
||||||
hintStyle: const TextStyle(color: Colors.white38, fontSize: 14),
|
hintStyle: const TextStyle(color: Colors.white38, fontSize: 13),
|
||||||
prefixIcon: const Icon(Icons.add_link_rounded, size: 18, color: Colors.white54),
|
prefixIcon: const Icon(Icons.add_link_rounded, size: 16, color: Colors.white54),
|
||||||
prefixIconConstraints: const BoxConstraints(minWidth: 38),
|
prefixIconConstraints: const BoxConstraints(minWidth: 34),
|
||||||
suffixIcon: _busy
|
suffixIcon: _RoundIcon(
|
||||||
? const Padding(
|
icon: Icons.close_rounded,
|
||||||
padding: EdgeInsets.all(10),
|
size: 16,
|
||||||
child: SizedBox.square(dimension: 14, child: CircularProgressIndicator(strokeWidth: 2)),
|
color: Colors.white54,
|
||||||
)
|
tooltip: 'Close (Esc)',
|
||||||
: null,
|
onPressed: _closeField,
|
||||||
contentPadding: const EdgeInsets.symmetric(vertical: 10, horizontal: 8),
|
),
|
||||||
|
contentPadding: const EdgeInsets.symmetric(vertical: 8, horizontal: 8),
|
||||||
enabledBorder: OutlineInputBorder(
|
enabledBorder: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(AppMenuStyle.rowRadius),
|
borderRadius: BorderRadius.circular(AppMenuStyle.rowRadius),
|
||||||
borderSide: const BorderSide(color: AppMenuStyle.border, width: 0.5),
|
borderSide: const BorderSide(color: AppMenuStyle.border, width: 0.5),
|
||||||
),
|
),
|
||||||
focusedBorder: OutlineInputBorder(
|
focusedBorder: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(AppMenuStyle.rowRadius),
|
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(
|
AnimatedSize(
|
||||||
duration: easeTime,
|
duration: easeTime,
|
||||||
curve: ease,
|
curve: ease,
|
||||||
@@ -872,13 +1078,94 @@ class _QueuePanelState extends State<QueuePanel> {
|
|||||||
child: _error == null
|
child: _error == null
|
||||||
? const SizedBox(width: double.infinity)
|
? const SizedBox(width: double.infinity)
|
||||||
: Padding(
|
: Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(8, 6, 8, 0),
|
padding: const EdgeInsets.fromLTRB(10, 4, 10, 2),
|
||||||
child: Text(_error!, style: TextStyle(color: Colors.orange.shade300, fontSize: 12)),
|
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
|
// ------------------------------------------------------------------ rows
|
||||||
@@ -1009,6 +1296,8 @@ class _QueueRow extends StatefulWidget {
|
|||||||
final String selfId;
|
final String selfId;
|
||||||
/// The first item's readiness when this app puts it up; null: no dot.
|
/// The first item's readiness when this app puts it up; null: no dot.
|
||||||
final WarmState? warm;
|
final WarmState? warm;
|
||||||
|
/// Right-click ([context]: the row).
|
||||||
|
final void Function(BuildContext context, QueueItem item) onMenu;
|
||||||
const _QueueRow({
|
const _QueueRow({
|
||||||
super.key,
|
super.key,
|
||||||
required this.index,
|
required this.index,
|
||||||
@@ -1017,6 +1306,7 @@ class _QueueRow extends StatefulWidget {
|
|||||||
required this.members,
|
required this.members,
|
||||||
required this.selfId,
|
required this.selfId,
|
||||||
required this.warm,
|
required this.warm,
|
||||||
|
required this.onMenu,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -1122,7 +1412,10 @@ class _QueueRowState extends State<_QueueRow> {
|
|||||||
return MouseRegion(
|
return MouseRegion(
|
||||||
onEnter: (_) => setState(() => _hover = true),
|
onEnter: (_) => setState(() => _hover = true),
|
||||||
onExit: (_) => setState(() => _hover = false),
|
onExit: (_) => setState(() => _hover = false),
|
||||||
|
child: GestureDetector(
|
||||||
|
onSecondaryTap: () => widget.onMenu(context, item),
|
||||||
child: Stack(children: [row, actions]),
|
child: Stack(children: [row, actions]),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,44 +1,16 @@
|
|||||||
import 'dart:io';
|
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
|
||||||
import 'package:sync_relay_core/core.dart';
|
|
||||||
|
|
||||||
import '../../update/update_controller.dart';
|
import '../../update/update_controller.dart';
|
||||||
|
import '../../widgets/copy_open.dart';
|
||||||
import 'settings_page.dart';
|
import 'settings_page.dart';
|
||||||
import 'ui.dart';
|
import 'ui.dart';
|
||||||
import 'update_indicator.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).
|
/// Name, version, update state and the source link (Settings → About).
|
||||||
class AboutPanel extends StatelessWidget {
|
class AboutPanel extends StatelessWidget {
|
||||||
final UpdateController? updates;
|
final UpdateController? updates;
|
||||||
const AboutPanel({super.key, this.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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final t = Theme.of(context).textTheme;
|
final t = Theme.of(context).textTheme;
|
||||||
@@ -49,7 +21,7 @@ class AboutPanel extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
Text(appName, style: t.titleLarge),
|
Text(appName, style: t.titleLarge),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
SelectableText('Version $appVersion',
|
SelectableText('Version ${displayVersion()}',
|
||||||
style: t.bodyMedium?.copyWith(color: Colors.grey)),
|
style: t.bodyMedium?.copyWith(color: Colors.grey)),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
UpdateStatusLine(controller: updates),
|
UpdateStatusLine(controller: updates),
|
||||||
@@ -58,13 +30,12 @@ class AboutPanel extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
Expanded(child: SelectableText(repoUrl, style: t.bodySmall)),
|
Expanded(child: SelectableText(repoUrl, style: t.bodySmall)),
|
||||||
IconButton(
|
IconButton(
|
||||||
tooltip: 'Copy link',
|
tooltip: ReuseText.copyLink,
|
||||||
icon: const Icon(Icons.copy, size: 18),
|
icon: const Icon(Icons.copy, size: 18),
|
||||||
onPressed: () =>
|
onPressed: () => copyToClipboard(repoUrl, done: 'Link copied'),
|
||||||
Clipboard.setData(const ClipboardData(text: repoUrl)),
|
|
||||||
),
|
),
|
||||||
FilledButton.tonalIcon(
|
FilledButton.tonalIcon(
|
||||||
onPressed: () => _open(context),
|
onPressed: () => openLink(repoUrl),
|
||||||
icon: const Icon(Icons.open_in_new, size: 18),
|
icon: const Icon(Icons.open_in_new, size: 18),
|
||||||
label: const Text('Source'),
|
label: const Text('Source'),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,10 +1,27 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:sync_relay_core/core.dart' show appVersion, isDevBuild;
|
||||||
|
|
||||||
/// Product name (working title). Change it here only.
|
/// Product name (working title). Change it here only.
|
||||||
const appName = 'Starsync';
|
const appName = 'Starsync';
|
||||||
|
|
||||||
const repoUrl = 'https://git.senkochan.moe/Foxyz/sync_replay_project';
|
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 kRadius = 12.0;
|
||||||
const kGap = 16.0;
|
const kGap = 16.0;
|
||||||
|
|
||||||
|
|||||||
@@ -1464,9 +1464,28 @@ class SessionCoordinator {
|
|||||||
if (pending == null || r.mediaId <= pending.after) return;
|
if (pending == null || r.mediaId <= pending.after) return;
|
||||||
if (sync.memberId.isEmpty || r.media?['swapBy'] != sync.memberId) return;
|
if (sync.memberId.isEmpty || r.media?['swapBy'] != sync.memberId) return;
|
||||||
_pendingArt = null;
|
_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));
|
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
|
/// Fetches the art of [media] (this client's swap, now [mediaId], from
|
||||||
/// web [link] if any) and sends it; quietly nothing on any failure.
|
/// web [link] if any) and sends it; quietly nothing on any failure.
|
||||||
Future<void> _sendArt(int mediaId, HelloMedia media, String? link, {Uint8List? given}) async {
|
Future<void> _sendArt(int mediaId, HelloMedia media, String? link, {Uint8List? given}) async {
|
||||||
@@ -2177,6 +2196,7 @@ class SessionCoordinator {
|
|||||||
stopWarmSource();
|
stopWarmSource();
|
||||||
_pendingOrigin = null;
|
_pendingOrigin = null;
|
||||||
_pendingArt = null;
|
_pendingArt = null;
|
||||||
|
_ownLink = null;
|
||||||
_activeOrigin = null;
|
_activeOrigin = null;
|
||||||
_playerReady = false;
|
_playerReady = false;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import 'package:flutter/services.dart';
|
|||||||
import 'package:sync_relay_core/core.dart';
|
import 'package:sync_relay_core/core.dart';
|
||||||
|
|
||||||
import '../state/session_state.dart';
|
import '../state/session_state.dart';
|
||||||
|
import '../widgets/app_menu.dart';
|
||||||
|
import '../widgets/copy_open.dart';
|
||||||
import '../widgets/text_focus.dart';
|
import '../widgets/text_focus.dart';
|
||||||
import 'avatar.dart';
|
import 'avatar.dart';
|
||||||
|
|
||||||
@@ -158,7 +160,30 @@ class ActivityRow extends StatelessWidget {
|
|||||||
final scheme = Theme.of(context).colorScheme;
|
final scheme = Theme.of(context).colorScheme;
|
||||||
final time = _clock(item.at);
|
final time = _clock(item.at);
|
||||||
if (item.action == 'chat') {
|
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),
|
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||||
child: Row(
|
child: Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
@@ -181,12 +206,14 @@ class ActivityRow extends StatelessWidget {
|
|||||||
Text(time, style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 11)),
|
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);
|
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.
|
/// Icon, colour and wording of a member's feed action.
|
||||||
(IconData, Color, String) feedStyle(FeedMessage f, ColorScheme scheme) {
|
(IconData, Color, String) feedStyle(FeedMessage f, ColorScheme scheme) {
|
||||||
switch (f.action) {
|
switch (f.action) {
|
||||||
|
|||||||
@@ -3,11 +3,13 @@ import 'dart:convert';
|
|||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.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 '../playback/yt_dlp_app.dart';
|
||||||
import '../screens/home/media_picker.dart';
|
import '../screens/home/media_picker.dart';
|
||||||
import '../widgets/app_menu.dart';
|
import '../widgets/app_menu.dart';
|
||||||
|
import '../widgets/copy_open.dart';
|
||||||
import '../state/connect_prefs.dart';
|
import '../state/connect_prefs.dart';
|
||||||
import 'motion.dart';
|
import 'motion.dart';
|
||||||
|
|
||||||
@@ -22,20 +24,33 @@ class RecentLink {
|
|||||||
final String url;
|
final String url;
|
||||||
final String title;
|
final String title;
|
||||||
final String? thumbnail;
|
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;
|
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() =>
|
/// What it showed when put up (the host for a second line, when older).
|
||||||
{'url': url, 'title': title, 'thumb': ?thumbnail, 'at': at.millisecondsSinceEpoch};
|
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) {
|
static RecentLink? fromJson(Object? j) {
|
||||||
if (j is! Map) return null;
|
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;
|
if (url is! String || url.isEmpty) return null;
|
||||||
return RecentLink(
|
return RecentLink(
|
||||||
url: url,
|
url: url,
|
||||||
title: title is String && title.isNotEmpty ? title : LinkMedia(url).label,
|
title: title is String && title.isNotEmpty ? title : LinkMedia(url).label,
|
||||||
thumbnail: thumb is String && thumb.isNotEmpty ? thumb : null,
|
thumbnail: thumb is String && thumb.isNotEmpty ? thumb : null,
|
||||||
|
subtitle: sub is String && sub.isNotEmpty ? sub : null,
|
||||||
at: DateTime.fromMillisecondsSinceEpoch(at is int ? at : 0),
|
at: DateTime.fromMillisecondsSinceEpoch(at is int ? at : 0),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -61,13 +76,18 @@ List<RecentLink> decodeRecentLinks(Object? raw) {
|
|||||||
class RecentLinks {
|
class RecentLinks {
|
||||||
static final RecentLinks instance = 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,
|
: _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<Object?> Function() _read;
|
||||||
final Future<void> Function(String value) _write;
|
final Future<void> Function(String value) _write;
|
||||||
final links = ValueNotifier<List<RecentLink>>(const []);
|
final links = ValueNotifier<List<RecentLink>>(const []);
|
||||||
|
/// What links pasted or put up before turned out to be.
|
||||||
|
final LinkPreviewStore previews;
|
||||||
Future<void>? _load;
|
Future<void>? _load;
|
||||||
|
|
||||||
static Future<Object?> _hiveRead() async =>
|
static Future<Object?> _hiveRead() async =>
|
||||||
@@ -112,6 +132,108 @@ class LinkPreview {
|
|||||||
/// null when it looks playable.
|
/// null when it looks playable.
|
||||||
final String? problem;
|
final String? problem;
|
||||||
const LinkPreview({required this.title, required this.subtitle, this.thumbnail, this.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]).
|
/// 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", ...
|
/// "today", "yesterday", "3 days ago", "last week", "5 weeks ago", ...
|
||||||
String agoText(DateTime at, DateTime now) {
|
String agoText(DateTime at, DateTime now) {
|
||||||
final d = now.difference(at).inDays;
|
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).
|
/// "Other ways" opened (always shown while one of them is picked).
|
||||||
bool _otherWays = false;
|
bool _otherWays = false;
|
||||||
|
|
||||||
|
LinkPreviewStore get _store => widget.recents.previews;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
widget.recents.ensureLoaded();
|
widget.recents.ensureLoaded();
|
||||||
_c.link.addListener(_typed);
|
_c.link.addListener(_typed);
|
||||||
_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
|
@override
|
||||||
@@ -267,7 +408,12 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
|
|||||||
final url = _c.link.text.trim();
|
final url = _c.link.text.trim();
|
||||||
if (url == _previewUrl) return;
|
if (url == _previewUrl) return;
|
||||||
_debounce?.cancel();
|
_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));
|
_debounce = Timer(const Duration(milliseconds: 350), () => _preview(url));
|
||||||
|
}
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_error = null;
|
_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 {
|
Future<void> _preview(String url) async {
|
||||||
_previewUrl = url;
|
_previewUrl = url;
|
||||||
if (url.isEmpty || _previews.containsKey(url)) {
|
if (url.isEmpty || _previews.containsKey(url)) {
|
||||||
if (mounted) setState(() {});
|
if (mounted) setState(() {});
|
||||||
return;
|
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);
|
final p = await previewLink(url, probe: widget.probe);
|
||||||
_previews[url] = p;
|
_previews[url] = p;
|
||||||
|
_keep(url, p);
|
||||||
if (mounted) setState(() {});
|
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 {
|
Future<void> _paste() async {
|
||||||
try {
|
try {
|
||||||
final text = (await Clipboard.getData(Clipboard.kTextPlain))?.text?.trim();
|
final text = (await Clipboard.getData(Clipboard.kTextPlain))?.text?.trim();
|
||||||
@@ -314,13 +501,23 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
|
|||||||
error = '$e';
|
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) {
|
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(
|
unawaited(widget.recents.add(RecentLink(
|
||||||
url: url,
|
url: url,
|
||||||
title: p?.title ?? LinkMedia(url).label,
|
title: p?.title ?? LinkMedia(url).label,
|
||||||
thumbnail: p?.thumbnail,
|
thumbnail: p?.thumbnail,
|
||||||
|
subtitle: p?.problem == null ? p?.subtitle : null,
|
||||||
at: DateTime.now(),
|
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 {
|
Future<void> _recentMenu(BuildContext context, RecentLink l) async {
|
||||||
final pick = await showAppMenu<String>(context, items: [
|
final pick = await showAppMenu<String>(context, items: [
|
||||||
AppMenuItem(value: 'up', label: widget.solo ? 'Play' : 'Put it up'),
|
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: 'queue', label: 'Add to queue'),
|
||||||
AppMenuItem(value: 'next', label: 'Play next'),
|
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;
|
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;
|
_c.link.text = l.url;
|
||||||
switch (pick) {
|
switch (pick) {
|
||||||
case 'up':
|
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(
|
Widget _modeCard((LinkRelay, IconData, String, String) m, {bool compact = false}) => _ModeCard(
|
||||||
key: ValueKey('link-mode-${m.$1.name}'),
|
key: ValueKey('link-mode-${m.$1.name}'),
|
||||||
icon: m.$2,
|
icon: m.$2,
|
||||||
@@ -504,9 +729,14 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
|
|||||||
? const SizedBox(width: double.infinity)
|
? const SizedBox(width: double.infinity)
|
||||||
: Padding(
|
: Padding(
|
||||||
padding: const EdgeInsets.only(top: 14),
|
padding: const EdgeInsets.only(top: 14),
|
||||||
|
child: Builder(
|
||||||
|
builder: (context) => GestureDetector(
|
||||||
|
onSecondaryTap: () => _previewMenu(context, url, preview),
|
||||||
child: _PreviewCard(preview: preview, loading: loading),
|
child: _PreviewCard(preview: preview, loading: loading),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
if (!solo) ...[
|
if (!solo) ...[
|
||||||
const SizedBox(height: 28),
|
const SizedBox(height: 28),
|
||||||
Text('How the others get it', style: t.titleSmall),
|
Text('How the others get it', style: t.titleSmall),
|
||||||
@@ -725,7 +955,7 @@ class _ModeCard extends StatelessWidget {
|
|||||||
class _RecentGrid extends StatelessWidget {
|
class _RecentGrid extends StatelessWidget {
|
||||||
final List<RecentLink> recents;
|
final List<RecentLink> recents;
|
||||||
final ValueChanged<RecentLink> onPick;
|
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;
|
final void Function(BuildContext context, RecentLink link) onMenu;
|
||||||
const _RecentGrid({required this.recents, required this.onPick, required this.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 '../session/now_playing.dart';
|
||||||
import '../state/session_state.dart';
|
import '../state/session_state.dart';
|
||||||
|
import '../widgets/app_menu.dart';
|
||||||
|
import '../widgets/copy_open.dart';
|
||||||
import 'motion.dart';
|
import 'motion.dart';
|
||||||
|
|
||||||
/// Tests: art bytes by hash instead of the session's cache.
|
/// 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 {
|
class NowPlayingTitle extends StatelessWidget {
|
||||||
final bool cover;
|
final bool cover;
|
||||||
final bool compact;
|
final bool compact;
|
||||||
const NowPlayingTitle({super.key, this.cover = true, this.compact = false});
|
const NowPlayingTitle({super.key, this.cover = true, this.compact = false});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) => ListenableBuilder(
|
Widget build(BuildContext context) => GestureDetector(
|
||||||
|
onSecondaryTap: () => nowPlayingMenu(context),
|
||||||
|
child: ListenableBuilder(
|
||||||
listenable: artChanges(),
|
listenable: artChanges(),
|
||||||
builder: (context, _) => SignalBuilder(
|
builder: (context, _) => SignalBuilder(
|
||||||
builder: (context) => NowPlayingBlock(
|
builder: (context) => NowPlayingBlock(
|
||||||
@@ -169,5 +174,38 @@ class NowPlayingTitle extends StatelessWidget {
|
|||||||
compact: compact,
|
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 'dart:io';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
|
||||||
import 'package:signals/signals_flutter.dart';
|
import 'package:signals/signals_flutter.dart';
|
||||||
import 'package:sync_relay_core/core.dart';
|
import 'package:sync_relay_core/core.dart';
|
||||||
import 'package:window_manager/window_manager.dart';
|
import 'package:window_manager/window_manager.dart';
|
||||||
|
|
||||||
|
import '../screens/home/ui.dart' show appName, displayVersion;
|
||||||
import '../screens/home/update_indicator.dart';
|
import '../screens/home/update_indicator.dart';
|
||||||
import '../session/delivery.dart';
|
import '../session/delivery.dart';
|
||||||
import '../state/session_state.dart';
|
import '../state/session_state.dart';
|
||||||
import '../update/update_controller.dart';
|
import '../update/update_controller.dart';
|
||||||
import '../widgets/app_keys.dart';
|
import '../widgets/app_keys.dart';
|
||||||
import '../widgets/app_menu.dart';
|
import '../widgets/app_menu.dart';
|
||||||
|
import '../widgets/copy_open.dart';
|
||||||
import '../widgets/window_buttons.dart';
|
import '../widgets/window_buttons.dart';
|
||||||
import 'avatar.dart';
|
import 'avatar.dart';
|
||||||
import 'delivery_line.dart';
|
import 'delivery_line.dart';
|
||||||
@@ -124,6 +125,8 @@ class PartyStrip extends StatelessWidget {
|
|||||||
label: t.label,
|
label: t.label,
|
||||||
selected: t == tab,
|
selected: t == tab,
|
||||||
compact: compact,
|
compact: compact,
|
||||||
|
// The version, a rest of the mouse away.
|
||||||
|
tooltip: t == ShellTab.settings ? '$appName ${displayVersion()}' : null,
|
||||||
onTap: () => onTab(t),
|
onTap: () => onTab(t),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -364,18 +367,21 @@ class StripTab extends StatelessWidget {
|
|||||||
final bool selected;
|
final bool selected;
|
||||||
final bool compact;
|
final bool compact;
|
||||||
final VoidCallback onTap;
|
final VoidCallback onTap;
|
||||||
|
/// Shown after resting on the tab.
|
||||||
|
final String? tooltip;
|
||||||
const StripTab({
|
const StripTab({
|
||||||
super.key,
|
super.key,
|
||||||
required this.label,
|
required this.label,
|
||||||
required this.selected,
|
required this.selected,
|
||||||
required this.onTap,
|
required this.onTap,
|
||||||
this.compact = false,
|
this.compact = false,
|
||||||
|
this.tooltip,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final scheme = Theme.of(context).colorScheme;
|
final scheme = Theme.of(context).colorScheme;
|
||||||
return Semantics(
|
final tab = Semantics(
|
||||||
selected: selected,
|
selected: selected,
|
||||||
button: true,
|
button: true,
|
||||||
child: InkWell(
|
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,
|
alignment: Alignment.centerLeft,
|
||||||
child: TextButton.icon(
|
child: TextButton.icon(
|
||||||
key: const ValueKey('copy-invite'),
|
key: const ValueKey('copy-invite'),
|
||||||
onPressed: () =>
|
onPressed: () => copyToClipboard(address, done: 'Invite address copied'),
|
||||||
Clipboard.setData(ClipboardData(text: address)),
|
|
||||||
icon: const Icon(Icons.copy, size: 16),
|
icon: const Icon(Icons.copy, size: 16),
|
||||||
label: const Text('Copy invite address'),
|
label: const Text('Copy invite address'),
|
||||||
),
|
),
|
||||||
@@ -628,7 +635,7 @@ class _HostInviteState extends State<_HostInvite> {
|
|||||||
future: _addresses,
|
future: _addresses,
|
||||||
builder: (context, snap) {
|
builder: (context, snap) {
|
||||||
final list = snap.data ?? const <String>[];
|
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(
|
return Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(12, 0, 12, 12),
|
padding: const EdgeInsets.fromLTRB(12, 0, 12, 12),
|
||||||
child: Column(
|
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';
|
import 'package:flutter/widgets.dart';
|
||||||
|
|
||||||
/// True while keyboard focus sits in a text field. The focused node's
|
/// True while keyboard focus sits in a text field, or anywhere under a
|
||||||
/// context belongs to the `Focus` that `EditableText` builds, so look for
|
/// [KeyboardClaim]. The focused node's context belongs to the `Focus` that
|
||||||
/// an `EditableText` above it (walking FocusNode parents never meets one).
|
/// `EditableText` builds, so look for an `EditableText` above it (walking
|
||||||
|
/// FocusNode parents never meets one).
|
||||||
bool textInputFocused([FocusNode? node]) {
|
bool textInputFocused([FocusNode? node]) {
|
||||||
final n = node ?? FocusManager.instance.primaryFocus;
|
final c = (node ?? FocusManager.instance.primaryFocus)?.context;
|
||||||
return n?.context?.findAncestorWidgetOfExactType<EditableText>() != null;
|
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');
|
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 {
|
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',
|
final yt = await previewLink('https://www.youtube.com/watch?v=abc',
|
||||||
probe: (url) async => LinkProbe.page(Uri.parse(url),
|
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();
|
field.dispose();
|
||||||
button.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
|
(`AppYtDlp.probe`, remembered 10 minutes, longer for Up next's first
|
||||||
item (see "Up next"); media file names too, not a
|
item (see "Up next"); media file names too, not a
|
||||||
Jellyfin item link) for the
|
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
|
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
|
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
|
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
|
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
|
picture, title, second line, "length · added by (avatar) name · how", a drag handle
|
||||||
(`ReorderableListView`, reordered here at once and sent as `move`), ▶
|
(`ReorderableListView`, reordered here at once and sent as `move`), ▶
|
||||||
and × floating in on hover; "Continues with"; a "Paste a link to add"
|
and × floating in on hover, right-click for Play now, Remove, Copy
|
||||||
field (focused on open, so the player's keys and Space rest while it
|
title, and Copy link / Open in browser only for a link known here
|
||||||
is up; Esc closes) and a drop zone for links and video files. Being no
|
(`QueueController.linkOf`: a link everyone opens, or one passed on by
|
||||||
route, it holds the shell's own drop and paste handling off while open
|
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
|
(`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
|
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,
|
`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
|
menu. Material's own menus (the standard controls' track, chapter and
|
||||||
shader menus, the Jellyfin sort and server switch) get the same surface
|
shader menus, the Jellyfin sort and server switch) get the same surface
|
||||||
from `AppMenuStyle.theme`; the uosc controls keep their own flat menus.
|
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),
|
- **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
|
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
|
the controller's default, as the main card; "Everyone fetches it" and
|
||||||
"Server fetches it" under "Other ways", open while one of them is
|
"Server fetches it" under "Other ways", open while one of them is
|
||||||
picked, closing it goes back to Through me), and the
|
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
|
is `FilesPage` (`files/`): watched folders (`FolderStore`) scanned in
|
||||||
an isolate, videos grouped by series; series start collapsed, a header
|
an isolate, videos grouped by series; series start collapsed, a header
|
||||||
click opens one and open ones stay open for the app session
|
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
|
The player keys (Settings → Keyboard shortcuts) work while the player is
|
||||||
full and nothing is in front of it; ↑ / ↓ step the volume. Space (ready)
|
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
|
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
|
the shell) takes them first while in a server, unless a text field (or
|
||||||
focus or a dialog or menu is in front. Tab reaches the shell's chat
|
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
|
toggle through `ChatKey` (the strip while shown, the player in
|
||||||
fullscreen). The app's shortcuts map Tab / Shift+Tab to nothing, so focus
|
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
|
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
|
Volume (`controls/volume.dart`, both
|
||||||
designs): mpv's `volume-max` is pinned to `PlaybackController.maxVolume`
|
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
|
(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;
|
The uosc player design (`controls/uosc/`, after mpv's uosc theme;
|
||||||
"Player design" = `AppSettings.mpvControls`, default Standard): the `Video`'s
|
"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
|
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
|
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.
|
drag and while a menu route is open, double-click toggles fullscreen.
|
||||||
`UoscTimeline` runs the full width at the bottom: played part filled,
|
`UoscTimeline` ports uosc's `Timeline.lua` (style `line`, uosc's defaults
|
||||||
`cacheRect` (position to `buffered`) hatched (`hatchLines`), chapter
|
at scale 1, `UoscTimelineSpec`) across the full width at the bottom: a
|
||||||
diamonds on the top edge, current time and duration in boxes inside it, a
|
40 px band (plus a 1 px top border) of black at 0.9; the position is a
|
||||||
hover tip with the chapter title over the hover time; hidden, it shrinks
|
2 px white line, no filled part, and times map around it
|
||||||
to a 2 px line with chapter ticks (faded out when the progress line
|
(`UoscTimelineGeometry`, uosc's `t2x` / `get_time_at_x`); the uncached
|
||||||
setting is off). Above it a row of flat buttons: menu (≡:
|
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,
|
change video, chat & activity, reload, mark everyone ready, fullscreen,
|
||||||
leave),
|
leave),
|
||||||
subtitles and audio with track counts, a shader badge ("A HQ", opening the
|
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
|
`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,
|
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
|
`--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;
|
the shipped `update.sh` / `update.bat` run it), `--check-update` prints only;
|
||||||
it ends with "restart the server to run <version>". `watch_together --upgrade`
|
it ends with "restart the server to run <version>". `watch_together --upgrade`
|
||||||
does the same from the app binary. In the app, `UpdateController`
|
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
|
`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
|
tarball to Discord (never commit it). The packager always compiles the
|
||||||
dedicated server (with the Flutter SDK's `dart`) into the tarball as
|
dedicated server (with the Flutter SDK's `dart`) into the tarball as
|
||||||
`watch-together-server`, also with `--skip-build`.
|
`watch-together-server`, also with `--skip-build`.
|
||||||
|
|||||||
@@ -64,6 +64,15 @@ String? normalizeItemId(String id) {
|
|||||||
return id.replaceAll('-', '').toLowerCase();
|
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):
|
/// 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`,
|
/// - web UI: `/web/#/details?id=X&serverId=S`, `/web/index.html#!/details?id=X`,
|
||||||
/// any `#/...?id=X` route;
|
/// any `#/...?id=X` route;
|
||||||
|
|||||||
@@ -35,8 +35,15 @@ ARCH="x64"
|
|||||||
VERSION="${VERSION:-$(sed -n 's/^version: \([0-9.]*\).*/\1/p' "$APP/pubspec.yaml")}"
|
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
|
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
|
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
|
fi
|
||||||
|
|
||||||
BUNDLE="$APP/build/linux/$ARCH/release/bundle"
|
BUNDLE="$APP/build/linux/$ARCH/release/bundle"
|
||||||
|
|||||||
Reference in new issue
Block a user