Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f1ac4c3e64 | ||
|
|
96bb7e90f4 | ||
|
|
6e14061a8a | ||
|
|
cf61453e28 | ||
|
|
5116923858 | ||
|
|
ad53b6822a | ||
|
|
4c8ddeb034 | ||
|
|
f2721d125c | ||
|
|
69fae18fdc | ||
|
|
6048fbe7d1 | ||
|
|
d48915dc47 | ||
|
|
5bc252bf02 | ||
|
|
638a30e16c | ||
|
|
ec0849eb01 | ||
|
|
7715dc479a | ||
|
|
e91539d113 | ||
|
|
5e3834e6ba |
No files matched your search
@@ -0,0 +1,99 @@
|
||||
# Changelog
|
||||
|
||||
What changed in each Starsync version, newest first, in plain words. The
|
||||
app shows a version's section when it offers the update and once after it
|
||||
(`What's new`); the release page shows it too. How entries are added and
|
||||
cut into a release: [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md#changelog).
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Added
|
||||
|
||||
### Changed
|
||||
|
||||
### Fixed
|
||||
|
||||
## 1.4.0
|
||||
|
||||
Links you put up are downloaded once for everyone, quick chat and danmaku in the player, plus What's new and fixes for Windows, the relay and chat.
|
||||
|
||||
### Added
|
||||
|
||||
- What's new: the update band shows what changed, and after an update the app shows the full list once (also in Settings → About)
|
||||
- Update channel in Settings → Updates: Stable, or Dev for test builds; Dev stays on test builds after a release and still gets the newer release when there is one
|
||||
- Sharper subtitles and picture: the video and its subtitles are drawn at your screen's resolution instead of the video's (Settings → Player → Sharp video and subtitles, on by default)
|
||||
- Invite links: Invite copies just a starsync:// link, password included, so a friend just clicks it (or copies it and switches to Starsync) and the app asks "Join Bob's server?"
|
||||
- Invite finds your internet address on its own and says when your router needs the port forwarded; Other addresses picks your home-network or VPN address instead
|
||||
- Starsync runs once: a link clicked while it is open goes to the open window
|
||||
- Quick chat: press Enter in the big player to type a message in a small box at the bottom, without opening the chat panel and without the mouse pointer or the controls popping up; it shares its draft with the chat panel, closes by itself when you stop typing, and subtitles move up out of its way
|
||||
- Danmaku notifications: chat, joins, leaves and Up next can fly across the top of the picture instead of popping up (Settings → Notifications & sounds → Notifications: Danmaku, off by default), with speed, how many at once, where, opacity, size, boldness, outline and avatars all adjustable and a live preview
|
||||
- Player panel: ⚙ → Player settings opens a panel at the right of the player that stays open while you watch, with tracks, subtitle size, picture, volume and notification settings
|
||||
- Subtitles can move up above the controls while they show (Settings → Player → Subtitles above the controls, off by default), and their size can be changed
|
||||
|
||||
### Changed
|
||||
|
||||
- A link you put up is downloaded once: you and everyone else watch the same stream, so nobody drifts ahead or stalls alone, and sites like Bilibili are far less likely to block it
|
||||
|
||||
### Fixed
|
||||
|
||||
- Upscaling shaders (Anime4K, the HQ modes) no longer shift the colours or draw a grid over the picture, which showed most in a large window or fullscreen
|
||||
- Sharp video: resizing the window or opening the chat panel no longer shrinks the picture for a moment before it jumps back
|
||||
- Joining while something plays on Windows: the video opens again by itself when it was too early
|
||||
- Relay: players no longer get stuck after a slow or stalled source
|
||||
- Relay: X (Twitter) clips play through the relay
|
||||
- Relay: gentler retries, so Bilibili stops blocking the server
|
||||
- Relay: a source that refuses the old link (412) gets a fresh address and keeps playing
|
||||
- Chat pop-ups stay away while the chat panel is open (the sound still plays)
|
||||
- Bilibili and other sites no longer stop a video halfway when you watch alone or with "Everyone fetches it": a cut connection picks up where it left off
|
||||
|
||||
## 1.3.2
|
||||
|
||||
Fixes Windows closing by itself about 3 seconds after every start.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Windows: the app no longer closes by itself about 3 seconds after starting (1.3.0 and 1.3.1)
|
||||
|
||||
## 1.3.1
|
||||
|
||||
Joining another server no longer opens an empty player, a cleaner mini player and a softer Minimal progress line.
|
||||
|
||||
### Changed
|
||||
|
||||
- A cleaner mini player: just the picture, play and a plain progress bar
|
||||
- Minimal controls: the progress line shown while the controls hide is softer and easier on the eyes
|
||||
|
||||
### Fixed
|
||||
|
||||
- Joining another server no longer opens an empty player
|
||||
|
||||
## 1.3.0
|
||||
|
||||
The app is now called Starsync and was rebuilt inside for stability: a shared countdown to what plays next, honest watched marks, faster relay seeks and many fixes. Everyone must update to keep watching together.
|
||||
|
||||
### Added
|
||||
|
||||
- A shared countdown to what plays next, and anyone can cancel it
|
||||
- The next episode of your local files continues by itself ("Continues with")
|
||||
- System notifications while the window is in the background
|
||||
- Your own server is always running: Invite your friends straight in
|
||||
- See the server's version and everyone's app version
|
||||
- The library matches your shows with AniList
|
||||
- A new sound engine and new sounds
|
||||
|
||||
### Changed
|
||||
|
||||
- The app is now called Starsync (your settings and data come along)
|
||||
- "Watched" counts only the time you actually watched (Jellyfin too)
|
||||
- Joining no longer pauses everyone
|
||||
- A dropped connection comes back as the same member, not a new one
|
||||
- A server restart no longer loses what was playing
|
||||
- Faster seeks over the relay
|
||||
|
||||
### Fixed
|
||||
|
||||
- The app no longer freezes on exit
|
||||
- Shaders on Windows
|
||||
- X11: video renders properly again (also with software rendering)
|
||||
- Black video when joining mid-play on X11
|
||||
- Many stability fixes from the rebuild
|
||||
@@ -46,9 +46,11 @@ Documents folder) and leaves the originals where they were.
|
||||
|
||||
Update from the app: the app looks for a new version at start and every
|
||||
few hours, and when one exists a band across the top of the window says so
|
||||
(never over a playing video). "Update and restart" downloads,
|
||||
verifies, applies in place and restarts; "Later" hides it until the next
|
||||
check; the install folder must be writable by you. The version you run is
|
||||
(never over a playing video), with its first changes. "What's new" lists
|
||||
them all ([CHANGELOG.md](CHANGELOG.md)); the first start after an update
|
||||
shows that list once, and Settings → About has it again. "Update and
|
||||
restart" downloads, verifies, applies in place and restarts; "Later" hides
|
||||
it until the next check; the install folder must be writable by you. The version you run is
|
||||
at the bottom of every Settings page (and in the Settings tab's tooltip). Settings has Check now and an auto-check toggle. From a terminal:
|
||||
`./starsync-server --upgrade` (no display needed, so it works on a
|
||||
server box too), `./starsync --upgrade`, or the shipped `update.sh` /
|
||||
@@ -64,8 +66,9 @@ The app always runs its own server, quietly: your server. When it starts
|
||||
you are on it, alone, and it is a plain player. Three ways in, at the top
|
||||
of the Library the first time; none needs Jellyfin or an account.
|
||||
|
||||
- **Join a friend** (most people): your friend sends you an invite (an
|
||||
address) and, maybe, a password. See [Join](#join).
|
||||
- **Join a friend** (most people): your friend sends you an invite (a
|
||||
`starsync://` link) and, maybe, a password. Click the link, or just
|
||||
copy it: the app asks "Join <their server>?". See [Join](#join).
|
||||
- **Invite friends**: you have the video and friends watch it with you.
|
||||
See [Your server and Invite](#your-server-and-invite).
|
||||
- **Watch on your own**: just play something from the Library, with
|
||||
@@ -86,8 +89,8 @@ out; a crown on whoever
|
||||
hosts, a tower on whoever passes the video on). Click the avatars for the
|
||||
member list (each member's app version, amber when it differs from yours,
|
||||
and the server's at the top; force ready, Nudge, Sit out; on your
|
||||
server the invite with Copy invite and Stop sharing, on a friend's "Copy
|
||||
invite address"). Beside them the server and "ready n/m" (who is sitting out or
|
||||
server where friends can reach it, Copy invite link and Stop sharing,
|
||||
on a friend's "Copy invite link"). Beside them the server and "ready n/m" (who is sitting out or
|
||||
reconnecting does not count), the chat button with unread
|
||||
count, and Invite on your server or Leave on a friend's. Empty strip
|
||||
space drags the window.
|
||||
@@ -119,6 +122,26 @@ by default, or Only when paused, or Off; messages always reach the chat,
|
||||
with their sound). Watching alone, nothing pops up or sounds about the
|
||||
party (there is nobody); the app's own notices still show.
|
||||
|
||||
**Quick chat.** Enter in the big player opens a small chat box at the
|
||||
bottom centre without opening the chat panel, also while the video
|
||||
plays: type (the player's keys wait meanwhile), Enter sends, Esc or an
|
||||
empty Enter closes it, and it closes by itself after a few seconds
|
||||
without typing. It is the same draft as the chat panel's field, so a
|
||||
message started in one is there in the other. What you sent shows for a
|
||||
moment where the box was (or flies as your own line with danmaku, below);
|
||||
bottom subtitles move up out of its way and back, top signs stay. The
|
||||
mouse pointer and the controls stay hidden: typing is not moving the
|
||||
mouse.
|
||||
|
||||
**Danmaku.** Settings → Notifications & sounds → Over the player →
|
||||
Notifications: Toasts (default) or Danmaku. With Danmaku, over the big
|
||||
player chat, joins, leaves, Up next and "starting now" fly right to left
|
||||
across the top of the picture instead of popping up (problems and errors
|
||||
still pop up, and everything does while the player is small). The look
|
||||
is yours to tune there, with a live preview: crossing time, how many at
|
||||
once, which band of the picture they use, opacity, size, boldness,
|
||||
outline, avatars, and a frame round your own lines.
|
||||
|
||||
**While sitting out or in the background.** Sitting out, only chat,
|
||||
nudges, "starting now" (the video starts for the others) and Up next
|
||||
reach you; joins and leaves too if you turn on Settings → Notifications &
|
||||
@@ -167,13 +190,22 @@ Your server starts with the app and stops when it closes. Only this app
|
||||
can reach it until you press **Invite** (in the strip, or in the member
|
||||
list): that opens it on your network at the port from Settings → Party &
|
||||
network (default 8080, with the password set there; blank = anyone can
|
||||
join) and copies an invite ("Join me on Starsync: 192.168.1.20:8080 ·
|
||||
Starsync 1.3.0") to send to friends. Nothing restarts: what plays and Up
|
||||
next carry on, and you are the host (the crown). The invite picks this
|
||||
computer's home-network address (not a VPN or proxy one, nor a Docker or
|
||||
VM bridge). Friends elsewhere need the TCP port forwarded on your router
|
||||
(or a VPN address, or a server they can all reach, like a VPS). Friends
|
||||
open nothing. **Stop sharing** (in the member list) closes it again:
|
||||
join) and copies an invite link (`starsync://203.0.113.7:8080/?name=…`,
|
||||
just the link) to send to friends. Nothing restarts: what plays and Up next carry on, and you are
|
||||
the host (the crown).
|
||||
|
||||
The link carries your internet address when the app can find it (it asks
|
||||
a "what is my IP" service such as ipify over HTTPS when you share), else
|
||||
this computer's home-network address (not a proxy one, nor a Docker or VM
|
||||
bridge). In the member list, **Copy invite link** copies it again;
|
||||
**Other addresses** there lets you pick another: **Internet** for friends
|
||||
elsewhere, **Home network** for friends on the same Wi-Fi or router,
|
||||
**VPN** (Tailscale and the like). Behind a router, friends elsewhere get
|
||||
in only when the TCP port is forwarded on your router to this computer
|
||||
(the card says so); the app does not open ports itself. The link
|
||||
includes the password when one is set, so friends just click it: share
|
||||
it only with them. Friends open nothing. **Stop sharing** (in the member list)
|
||||
closes it again:
|
||||
everyone else leaves (their app goes back to their own server and says
|
||||
why), you and Up next stay.
|
||||
|
||||
@@ -349,7 +381,15 @@ Join a friend in the strip. Servers you got into are listed first (up
|
||||
to 6), with live status (online and how many are watching, or offline,
|
||||
plus "needs update" when its version and yours do not match): click one
|
||||
to rejoin, x to forget it. Or paste the invite (or the address and port)
|
||||
and the password if there is one, then Enter or Join. Before connecting
|
||||
and the password if there is one, then Enter or Join.
|
||||
|
||||
Faster: click a friend's `starsync://` link, or copy their invite and
|
||||
switch to Starsync. The app asks "Join Bob's server (203.0.113.7:8080)?"
|
||||
with the server's version and who is watching; Join joins (and adds it
|
||||
to your recent servers), Not now leaves it (a copied invite is then not
|
||||
offered again). The clipboard is only looked at for an invite when the
|
||||
window comes to the front, and never saved or logged. The app runs once:
|
||||
a link clicked while it runs goes to the open window. Before connecting
|
||||
the app checks the server's version: when they do not match it says who
|
||||
has to update ("Bob's server runs Starsync 1.2.1 — they need to
|
||||
update"), and offers the update when it is you. Your name and picture
|
||||
@@ -369,15 +409,21 @@ volume and sound set, and every sound with a preview, its own set and a switch);
|
||||
(the version, also at the bottom of every Settings page and in the
|
||||
Settings tab's tooltip; and the credits, e.g. that the Minimal player design is inspired by uosc).
|
||||
|
||||
Player settings live in the player too: ⚙ → Player settings, at the
|
||||
right of its controls, and Settings → Player has the same ones (plus the
|
||||
controls' layout and the custom shader files). Player design: Standard or
|
||||
Minimal (below); progress line when hidden (on by default: a thin line at
|
||||
the bottom of the video while the controls are hidden); upscaling shader
|
||||
(see below); hardware decoding (auto, off or custom; auto is mpv's
|
||||
`auto-copy` on Windows, `auto` elsewhere; the env var
|
||||
`SYNC_RELAY_HWDEC` overrides it); in the player also the subtitle and
|
||||
audio track. Settings → Player → Controls: time display (elapsed / total
|
||||
Player settings live in the player too: ⚙ → Player settings opens a
|
||||
narrow panel at the right edge of the player that stays open while you
|
||||
watch (the controls hide and show as usual) until its × or Esc. Its
|
||||
sections fold: tracks (subtitles, audio), subtitles (size, above the
|
||||
controls), picture (upscaling shader, sharp video, hardware decoding),
|
||||
sound (volume), notifications (toasts or danmaku and the danmaku look)
|
||||
and controls (design, progress line). Settings → Player has the same
|
||||
ones (plus the controls' layout and the custom shader files). Player
|
||||
design: Standard or Minimal (below); progress line when hidden (on by
|
||||
default: a thin line at the bottom of the video while the controls are
|
||||
hidden); upscaling shader (see below); hardware decoding (auto, off or
|
||||
custom; auto is mpv's `auto-copy` on Windows, `auto` elsewhere; the env
|
||||
var `SYNC_RELAY_HWDEC` overrides it); subtitle size; subtitles above the
|
||||
controls (off by default: bottom lines move up while the control bar
|
||||
shows; the quick chat box always moves them). Settings → Player → Controls: time display (elapsed / total
|
||||
or remaining), controls size (normal or large), hide the controls after
|
||||
2, 3 or 5 s, and which optional buttons sit in the bar (chapter buttons,
|
||||
subtitles, audio, chat; hidden ones are still in ⚙). Every change
|
||||
@@ -387,8 +433,9 @@ Anywhere in a server (unless you are typing): Space = ready / unready,
|
||||
Tab = chat & activity (Tab never moves between buttons). Anywhere:
|
||||
Ctrl+Shift+B = mark a problem in the session log. Keys while the
|
||||
player fills the window: Left/Right = seek 10 s, Up/Down = volume, Shift+Right = skip 1:30,
|
||||
PgUp/PgDn = previous / next chapter (as in mpv), F = fullscreen, Esc =
|
||||
close the chat, then fullscreen, then shrink the player to mini; from the
|
||||
PgUp/PgDn = previous / next chapter (as in mpv), F = fullscreen, Enter =
|
||||
quick chat, Esc = close the player panel, then
|
||||
the chat, then fullscreen, then shrink the player to mini; from the
|
||||
mini it asks to leave, Ctrl+R = reload video,
|
||||
`<` / `>` or Shift+P / Shift+N = previous / next Jellyfin episode,
|
||||
Ctrl+1..6 = Anime4K mode A, B, C, A+A, B+B, C+A, Ctrl+0 = shaders off.
|
||||
|
||||
@@ -10,16 +10,26 @@ import 'src/app/app_graph.dart';
|
||||
import 'src/files/thumbnails.dart' show thumbnailPlayers;
|
||||
import 'src/platform/app_log.dart';
|
||||
import 'src/platform/app_shutdown.dart' show closeAppWith;
|
||||
import 'src/platform/desktop/attention.dart' show exitNow, windowLaunchToken;
|
||||
import 'src/platform/desktop/desktop_app_dirs.dart';
|
||||
import 'src/platform/desktop/windows_api.dart' show allowSetForegroundWindow;
|
||||
import 'src/platform/desktop_entry.dart';
|
||||
import 'src/platform/ports/window_activity.dart';
|
||||
import 'src/platform/single_instance.dart';
|
||||
import 'src/platform/url_scheme.dart';
|
||||
import 'src/platform/smoke.dart';
|
||||
import 'src/screens/home/ui.dart';
|
||||
import 'src/session/app_events.dart' show UpdateAvailable;
|
||||
import 'src/session/invite.dart' show InviteLink;
|
||||
import 'src/state/settings.dart';
|
||||
import 'src/update/update_controller.dart' show UpdateController, UpdatePhase, launchArgs;
|
||||
import 'src/update/update_controller.dart' show UpdatePhase, launchArgs;
|
||||
|
||||
Future<void> main(List<String> args) async {
|
||||
launchArgs = List.unmodifiable(args);
|
||||
// An invite link is handled once: Update and restart does not pass it on.
|
||||
launchArgs = List.unmodifiable([
|
||||
for (final a in args)
|
||||
if (InviteLink.parse(a) == null) a,
|
||||
]);
|
||||
if (args.contains('--upgrade')) {
|
||||
exitCode = await runUpgrade();
|
||||
exit(exitCode);
|
||||
@@ -44,6 +54,15 @@ Future<void> main(List<String> args) async {
|
||||
);
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
final dirs = await const DesktopAppDirs().resolve();
|
||||
// One app per profile: a second start (a clicked starsync:// link) hands
|
||||
// its arguments to the running one and ends before its window shows.
|
||||
final instance = await InstanceLock.claim(
|
||||
Directory(dirs.hive).parent.path,
|
||||
args: args,
|
||||
activation: await windowLaunchToken(),
|
||||
beforeSend: Platform.isWindows ? allowSetForegroundWindow : null,
|
||||
);
|
||||
if (instance == null) await exitNow();
|
||||
await startAppLogging(dirs);
|
||||
Hive.init(dirs.hive);
|
||||
await AppSettings.instance.ensureLoaded();
|
||||
@@ -52,14 +71,19 @@ Future<void> main(List<String> args) async {
|
||||
final ports = graph.ports;
|
||||
// Close button / Alt+F4: the orderly close ([shutDownApp]), so others
|
||||
// see "left", your server shuts down and no player outlives the engine.
|
||||
final close = closeAppWith(graph);
|
||||
final close = closeAppWith(graph, instance: instance);
|
||||
await ports.shell.start(
|
||||
title: dirs.profile == null ? appName : '$appName [${dirs.profile}]', onCloseRequest: () => close());
|
||||
// Focus, minimise and restore feed auto sit-out and the Notifier (in
|
||||
// the background, notices go out as system notifications).
|
||||
// Coming to the front also looks for an invite on the clipboard (the
|
||||
// join prompt; nothing of it is logged).
|
||||
ports.shell.watchActivity((a) {
|
||||
graph.coordinator.windowActivity(a);
|
||||
graph.notifier.window = a;
|
||||
if (a == WindowActivity.focused) {
|
||||
unawaited(ports.shell.readClipboardText().then(graph.joinOffers.fromClipboard));
|
||||
}
|
||||
});
|
||||
try {
|
||||
await graph.forms.load();
|
||||
@@ -76,8 +100,10 @@ Future<void> main(List<String> args) async {
|
||||
ports.links.warmUp();
|
||||
// Linux: the launcher entry and icon Wayland needs (release installs).
|
||||
unawaited(installDesktopEntry());
|
||||
// Windows: starsync:// links start this install (HKCU, release builds).
|
||||
unawaited(registerWindowsScheme());
|
||||
// A found update is an event too (the band shows it).
|
||||
final updates = UpdateController.instance;
|
||||
final updates = graph.updates;
|
||||
updates.status.addListener(() {
|
||||
final s = updates.status.value;
|
||||
final version = s.manifest?.version;
|
||||
@@ -94,6 +120,14 @@ Future<void> main(List<String> args) async {
|
||||
// Home: your own server, quietly (only this app can reach it until
|
||||
// Invite).
|
||||
unawaited(graph.navigation.start());
|
||||
// A clicked invite link: the one this app started with, then each one a
|
||||
// later start hands over (the window comes to the front for it).
|
||||
graph.joinOffers.fromArgs(args);
|
||||
instance.handoffs.listen((h) {
|
||||
unawaited(ports.shell.focus(activation: h.activation));
|
||||
graph.joinOffers.fromArgs(h.args);
|
||||
});
|
||||
unawaited(ports.shell.readClipboardText().then(graph.joinOffers.fromClipboard));
|
||||
// The sound device opens once the window is up, not on the first sound.
|
||||
Future.delayed(const Duration(seconds: 2), graph.sfx.warmUp);
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import '../schedule/animeflow.dart';
|
||||
import '../screens/home/health_probe.dart';
|
||||
import '../session/connection.dart' show Connection;
|
||||
import '../session/identity.dart' show ClientIdentity, Profile;
|
||||
import '../session/join_offers.dart';
|
||||
import '../session/navigation.dart';
|
||||
import '../session/notifier.dart';
|
||||
import '../session/session_coordinator.dart';
|
||||
@@ -21,6 +22,8 @@ import '../sfx/sound_bank.dart';
|
||||
import '../sfx/synth_sfx.dart';
|
||||
import '../state/connect_prefs.dart';
|
||||
import '../state/settings.dart';
|
||||
import '../update/update_controller.dart';
|
||||
import '../update/whats_new.dart';
|
||||
import '../watch/watch_history.dart';
|
||||
import '../watch/watch_tracker.dart';
|
||||
|
||||
@@ -57,6 +60,15 @@ class AppGraph {
|
||||
/// Watch tracking and the device's watch history (D3).
|
||||
final WatchTracker watch;
|
||||
|
||||
/// The app's updater (checks, the update band, install and restart).
|
||||
final UpdateController updates;
|
||||
|
||||
/// What's new once after an update; keeps the notes the updater saves.
|
||||
final WhatsNew whatsNew;
|
||||
|
||||
/// The join prompt for a clicked invite link or one on the clipboard.
|
||||
final JoinOffers joinOffers;
|
||||
|
||||
AppGraph._({
|
||||
required this.ports,
|
||||
required this.sfx,
|
||||
@@ -68,10 +80,14 @@ class AppGraph {
|
||||
required this.navigation,
|
||||
required this.library,
|
||||
required this.watch,
|
||||
required this.updates,
|
||||
required this.whatsNew,
|
||||
required this.joinOffers,
|
||||
});
|
||||
|
||||
/// The desktop app's graph. Tests pass their own [ports], [identity]
|
||||
/// (default: the install's stored one), [healthGet] and server starter.
|
||||
/// (default: the install's stored one), [healthGet], server starter and
|
||||
/// internet address lookup ([publicAddress]).
|
||||
factory AppGraph.build({
|
||||
Ports? ports,
|
||||
ClientIdentity? identity,
|
||||
@@ -80,6 +96,7 @@ class AppGraph {
|
||||
JellyfinAccounts? jellyfin,
|
||||
ShowMatcher? library,
|
||||
WatchHistory? history,
|
||||
Future<String?> Function()? publicAddress,
|
||||
}) {
|
||||
final p = ports ?? desktopPorts();
|
||||
final settings = AppSettings.instance;
|
||||
@@ -132,6 +149,7 @@ class AppGraph {
|
||||
},
|
||||
logDir: () => appLogDir,
|
||||
starter: serverStarter,
|
||||
publicAddress: publicAddress,
|
||||
);
|
||||
final navigation = Navigation(
|
||||
coordinator: coordinator,
|
||||
@@ -146,6 +164,10 @@ class AppGraph {
|
||||
..push = ({String? name, String? avatar}) {
|
||||
if (navigation.online.peek()) coordinator.updateProfile(name: name, avatar: avatar);
|
||||
};
|
||||
final updates = UpdateController.instance;
|
||||
final whatsNew = WhatsNew(store: SettingsWhatsNewStore(settings));
|
||||
updates.beforeRestart ??= whatsNew.installing;
|
||||
updates.channelOf ??= () => settings.updateChannel.peek();
|
||||
return AppGraph._(
|
||||
ports: p,
|
||||
sfx: sfx,
|
||||
@@ -157,6 +179,9 @@ class AppGraph {
|
||||
navigation: navigation,
|
||||
library: matcher,
|
||||
watch: watch,
|
||||
updates: updates,
|
||||
whatsNew: whatsNew,
|
||||
joinOffers: JoinOffers(navigation: navigation, yourServer: yourServer, forms: forms),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@ import '../widgets/copy_open.dart';
|
||||
import 'frame.dart';
|
||||
import 'model/controls_layout.dart';
|
||||
import 'model/controls_model.dart';
|
||||
import 'player_settings.dart';
|
||||
|
||||
// The controls' menus, shared by both skins: the track and chapter lists
|
||||
// and the ⚙ menu, all on the app's menu (`showAppMenu`), anchored on the
|
||||
@@ -129,7 +128,7 @@ Future<void> runControlAction(BuildContext context, PlayerControlsModel model, C
|
||||
case ControlAction.markProblem:
|
||||
await ControlsFrame.holdWhile(context, () => showMarkProblem(context));
|
||||
case ControlAction.playerSettings:
|
||||
await showPlayerSettings(context, renderMode: model.renderMode);
|
||||
model.togglePanel();
|
||||
case ControlAction.leave:
|
||||
model.leave();
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart' show ValueListenable;
|
||||
import 'package:flutter/gestures.dart' show kPrimaryMouseButton;
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
@@ -10,7 +11,10 @@ import 'controls_probe.dart' show ControlsHold;
|
||||
// without any (or the mouse leaving) hides them and the cursor. Holds keep
|
||||
// them up: a seek drag (no hover events arrive), an open menu (a route
|
||||
// takes the mouse), and the session's [ControlsHold] (the mouse on a
|
||||
// floating pill or card, the Up next popover).
|
||||
// floating pill or card, the Up next popover). Only real mouse movement
|
||||
// wakes them: a widget appearing or going under a still pointer (the
|
||||
// quick chat box) does not, nor does typing. [ControlsFrame.suppress]
|
||||
// hides them while something takes their place (the quick chat box).
|
||||
|
||||
class ControlsFrame extends StatefulWidget {
|
||||
/// Draws the controls; [visible] false while they are hidden.
|
||||
@@ -28,6 +32,11 @@ class ControlsFrame extends StatefulWidget {
|
||||
|
||||
/// Keeps them up while held.
|
||||
final ControlsHold? holdBy;
|
||||
|
||||
/// While true the controls are drawn hidden and [shown] says so; the
|
||||
/// cursor still follows the mouse. They come back as they would have
|
||||
/// been once it is false again.
|
||||
final ValueListenable<bool>? suppress;
|
||||
const ControlsFrame({
|
||||
super.key,
|
||||
required this.builder,
|
||||
@@ -37,6 +46,7 @@ class ControlsFrame extends StatefulWidget {
|
||||
this.hideAfter = const Duration(seconds: 3),
|
||||
this.shown,
|
||||
this.holdBy,
|
||||
this.suppress,
|
||||
});
|
||||
|
||||
/// The frame around [context], for widgets that keep it up (a drag, a
|
||||
@@ -62,16 +72,23 @@ class ControlsFrameState extends State<ControlsFrame> {
|
||||
int _holds = 0;
|
||||
bool _heldBy = false;
|
||||
Timer? _timer;
|
||||
// Where the mouse was last seen, to tell real movement from a widget
|
||||
// appearing or going under it.
|
||||
Offset? _lastPos;
|
||||
|
||||
bool get visible => _visible;
|
||||
bool get _suppressed => widget.suppress?.value ?? false;
|
||||
|
||||
/// The controls are drawn (up and not suppressed).
|
||||
bool get visible => _visible && !_suppressed;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
widget.holdBy?.addListener(_holdByChanged);
|
||||
widget.suppress?.addListener(_suppressChanged);
|
||||
// Not during this build: an overlay listening to [shown] may be built.
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) widget.shown?.value = true;
|
||||
if (mounted) widget.shown?.value = visible;
|
||||
});
|
||||
_hideLater();
|
||||
}
|
||||
@@ -84,12 +101,17 @@ class ControlsFrameState extends State<ControlsFrame> {
|
||||
widget.holdBy?.addListener(_holdByChanged);
|
||||
_holdByChanged();
|
||||
}
|
||||
if (oldWidget.suppress != widget.suppress) {
|
||||
oldWidget.suppress?.removeListener(_suppressChanged);
|
||||
widget.suppress?.addListener(_suppressChanged);
|
||||
}
|
||||
if (oldWidget.hideAfter != widget.hideAfter && _visible) _hideLater();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
widget.holdBy?.removeListener(_holdByChanged);
|
||||
widget.suppress?.removeListener(_suppressChanged);
|
||||
_timer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
@@ -101,15 +123,29 @@ class ControlsFrameState extends State<ControlsFrame> {
|
||||
hold(on);
|
||||
}
|
||||
|
||||
void _suppressChanged() {
|
||||
if (!mounted) return;
|
||||
setState(() {});
|
||||
widget.shown?.value = visible;
|
||||
}
|
||||
|
||||
/// Show the controls and restart the hide countdown.
|
||||
void wake() {
|
||||
if (!_visible && mounted) {
|
||||
setState(() => _visible = true);
|
||||
widget.shown?.value = true;
|
||||
widget.shown?.value = visible;
|
||||
}
|
||||
_hideLater();
|
||||
}
|
||||
|
||||
/// A hover or enter at [pos]: wakes only when the mouse really moved.
|
||||
void _moved(Offset pos) {
|
||||
final last = _lastPos;
|
||||
_lastPos = pos;
|
||||
if (last != null && (pos - last).distance < 0.5) return;
|
||||
wake();
|
||||
}
|
||||
|
||||
/// Keep the controls up while [on] (paired calls).
|
||||
void hold(bool on) {
|
||||
_holds = on ? _holds + 1 : (_holds - 1).clamp(0, 1 << 20);
|
||||
@@ -141,9 +177,10 @@ class ControlsFrameState extends State<ControlsFrame> {
|
||||
Widget build(BuildContext context) {
|
||||
return MouseRegion(
|
||||
cursor: _visible ? SystemMouseCursors.basic : SystemMouseCursors.none,
|
||||
onEnter: (_) => wake(),
|
||||
onHover: (_) => wake(),
|
||||
onExit: (_) {
|
||||
onEnter: (e) => _moved(e.position),
|
||||
onHover: (e) => _moved(e.position),
|
||||
onExit: (e) {
|
||||
_lastPos = e.position;
|
||||
_timer?.cancel();
|
||||
_hide();
|
||||
},
|
||||
@@ -154,12 +191,12 @@ class ControlsFrameState extends State<ControlsFrame> {
|
||||
Positioned.fill(
|
||||
child: DoubleClickListener(
|
||||
onDoubleClick: widget.onDoubleClick!,
|
||||
skipTop: _visible ? widget.barTop : 0,
|
||||
skipBottom: _visible ? widget.barBottom : 0,
|
||||
skipTop: visible ? widget.barTop : 0,
|
||||
skipBottom: visible ? widget.barBottom : 0,
|
||||
child: const ColoredBox(color: Color(0x00000000), child: SizedBox.expand()),
|
||||
),
|
||||
),
|
||||
widget.builder(context, _visible),
|
||||
widget.builder(context, visible),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -115,6 +115,12 @@ class PlayerControlsModel {
|
||||
/// A menu of the controls is open (the player's Esc closes it).
|
||||
int menusOpen = 0;
|
||||
|
||||
/// The player panel docked at the right (⚙ → Player settings; Esc or
|
||||
/// its × closes it). Stays open while the controls hide and show.
|
||||
final panelOpen = signal(false);
|
||||
|
||||
void togglePanel() => panelOpen.value = !panelOpen.peek();
|
||||
|
||||
void dispose() => volumeFlash.dispose();
|
||||
|
||||
// -------------------------------------------------------------- actions
|
||||
|
||||
@@ -0,0 +1,454 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:signals/signals_flutter.dart';
|
||||
import 'package:sync_relay_core/core.dart' show JellyfinStreamType;
|
||||
|
||||
import '../app/app_graph.dart';
|
||||
import '../danmaku/danmaku_settings.dart' show DanmakuKnobs, NotifyStyleSelect;
|
||||
import '../playback/playback_controller.dart' show RenderMode;
|
||||
import '../shaders/shader_presets.dart';
|
||||
import '../shell/mark_problem.dart' show MarkProblemButton;
|
||||
import '../shell/motion.dart';
|
||||
import '../state/settings.dart';
|
||||
import '../widgets/app_menu.dart';
|
||||
import 'model/controls_prefs.dart';
|
||||
import 'player_settings.dart' show applyHwdecLive, applyShaderChoice;
|
||||
import 'volume.dart' as vol;
|
||||
|
||||
// The player panel (⚙ → Player settings, both skins): a narrow glass
|
||||
// column docked at the right edge of the full player that stays open
|
||||
// while the controls hide and show, until its × or Esc. What is useful
|
||||
// while watching, in collapsible sections: tracks, subtitles, picture,
|
||||
// sound, notifications (toasts or danmaku, the danmaku look) and the
|
||||
// controls. Everything applies live and is saved; the rest of Settings →
|
||||
// Player stays there.
|
||||
|
||||
/// Sections folded by the user, kept for the app's run.
|
||||
final _folded = <String>{'Notifications', 'Controls'};
|
||||
|
||||
class PlayerPanel extends StatelessWidget {
|
||||
static const width = 312.0;
|
||||
final VoidCallback onClose;
|
||||
|
||||
/// The running player's render mode (shader and sharp video notes).
|
||||
final ReadonlySignal<RenderMode>? renderMode;
|
||||
|
||||
/// The player's volume (0..[vol.maxVolume]) and how to set it; the
|
||||
/// Sound section only with both.
|
||||
final ReadonlySignal<double>? volume;
|
||||
final ValueChanged<double>? onVolume;
|
||||
const PlayerPanel({super.key, required this.onClose, this.renderMode, this.volume, this.onVolume});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final graph = AppScope.of(context);
|
||||
final s = graph.coordinator.settings;
|
||||
return AppGlass(
|
||||
key: const ValueKey('player-panel'),
|
||||
child: SizedBox(
|
||||
width: width,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 6, 2),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.tune_rounded, size: 18, color: Colors.white70),
|
||||
const SizedBox(width: 10),
|
||||
const Expanded(
|
||||
child: Text('Player', style: TextStyle(fontWeight: FontWeight.w600, fontSize: 14)),
|
||||
),
|
||||
IconButton(
|
||||
key: const ValueKey('player-panel-close'),
|
||||
tooltip: 'Close (Esc)',
|
||||
visualDensity: VisualDensity.compact,
|
||||
iconSize: 18,
|
||||
onPressed: onClose,
|
||||
icon: const Icon(Icons.close_rounded),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Flexible(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 10),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const _Section(title: 'Tracks', children: [
|
||||
_TrackRow(type: JellyfinStreamType.subtitle),
|
||||
_TrackRow(type: JellyfinStreamType.audio),
|
||||
]),
|
||||
_Section(title: 'Subtitles', children: [
|
||||
SignalBuilder(
|
||||
builder: (context) => _SliderRow(
|
||||
key: const ValueKey('panel-sub-scale'),
|
||||
label: 'Size',
|
||||
value: s.subScale.value,
|
||||
min: 0.5,
|
||||
max: 2,
|
||||
divisions: 30,
|
||||
readout: '${(s.subScale.value * 100).round()} %',
|
||||
onChanged: s.setSubScale,
|
||||
),
|
||||
),
|
||||
SignalBuilder(
|
||||
builder: (context) => _SwitchRow(
|
||||
key: const ValueKey('panel-lift'),
|
||||
label: 'Above the controls',
|
||||
help: 'Bottom lines move up while the control bar shows (the chat box always lifts them)',
|
||||
value: s.liftSubtitles.value,
|
||||
onChanged: s.setLiftSubtitles,
|
||||
),
|
||||
),
|
||||
]),
|
||||
_Section(title: 'Picture', children: [
|
||||
_ShaderRow(renderMode: renderMode),
|
||||
SignalBuilder(builder: (context) {
|
||||
final m = renderMode?.value;
|
||||
final software = m == RenderMode.software || m == RenderMode.softwareGl;
|
||||
return _SwitchRow(
|
||||
key: const ValueKey('panel-sharp'),
|
||||
label: 'Sharp video and subtitles',
|
||||
help: software ? 'No effect: software rendering' : "At your screen's resolution",
|
||||
value: s.screenResVideo.value,
|
||||
onChanged: s.setScreenResVideo,
|
||||
);
|
||||
}),
|
||||
SignalBuilder(
|
||||
builder: (context) => _Row(
|
||||
label: 'Hardware decoding',
|
||||
control: AppSelect<HwdecMode>(
|
||||
key: const ValueKey('panel-hwdec'),
|
||||
value: s.hwdecMode.value,
|
||||
items: const [(HwdecMode.auto, 'Auto'), (HwdecMode.off, 'Off'), (HwdecMode.custom, 'Custom')],
|
||||
onChanged: (m) {
|
||||
s.setHwdecMode(m);
|
||||
applyHwdecLive(graph.coordinator.playback);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
]),
|
||||
if (volume case final v?)
|
||||
_Section(title: 'Sound', children: [
|
||||
SignalBuilder(
|
||||
builder: (context) => _SliderRow(
|
||||
key: const ValueKey('panel-volume'),
|
||||
label: 'Volume',
|
||||
value: v.value,
|
||||
min: 0,
|
||||
max: vol.maxVolume,
|
||||
readout: '${v.value.round()} %',
|
||||
onChanged: onVolume,
|
||||
),
|
||||
),
|
||||
]),
|
||||
_Section(title: 'Notifications', children: [
|
||||
_Row(label: 'Show as', control: NotifyStyleSelect(settings: s)),
|
||||
SignalBuilder(
|
||||
builder: (context) => AnimatedSize(
|
||||
duration: easeTime,
|
||||
curve: ease,
|
||||
alignment: Alignment.topCenter,
|
||||
child: s.notifyStyle.value == NotifyStyle.danmaku
|
||||
? DanmakuKnobs(settings: s, dense: true)
|
||||
: const SizedBox(width: double.infinity),
|
||||
),
|
||||
),
|
||||
]),
|
||||
_Section(title: 'Controls', children: [
|
||||
SignalBuilder(
|
||||
builder: (context) => _Row(
|
||||
label: 'Design',
|
||||
control: AppSelect<ControlsSkin>(
|
||||
key: const ValueKey('panel-design'),
|
||||
value: s.controlsSkin.value,
|
||||
items: [for (final k in ControlsSkin.values) (k, k.label)],
|
||||
onChanged: s.setControlsSkin,
|
||||
),
|
||||
),
|
||||
),
|
||||
SignalBuilder(
|
||||
builder: (context) => _SwitchRow(
|
||||
key: const ValueKey('panel-progress-line'),
|
||||
label: 'Progress line when hidden',
|
||||
value: s.progressLine.value,
|
||||
onChanged: s.setProgressLine,
|
||||
),
|
||||
),
|
||||
]),
|
||||
const SizedBox(height: 6),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
'More in Settings → Player',
|
||||
style: TextStyle(color: Colors.white.withValues(alpha: 0.5), fontSize: 12),
|
||||
),
|
||||
),
|
||||
const MarkProblemButton(),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A titled group that folds (its title is the toggle).
|
||||
class _Section extends StatefulWidget {
|
||||
final String title;
|
||||
final List<Widget> children;
|
||||
const _Section({required this.title, required this.children});
|
||||
|
||||
@override
|
||||
State<_Section> createState() => _SectionState();
|
||||
}
|
||||
|
||||
class _SectionState extends State<_Section> {
|
||||
late bool _open = !_folded.contains(widget.title);
|
||||
|
||||
void _toggle() {
|
||||
setState(() => _open = !_open);
|
||||
_open ? _folded.remove(widget.title) : _folded.add(widget.title);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final muted = Colors.white.withValues(alpha: 0.55);
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
InkWell(
|
||||
key: ValueKey('panel-section-${widget.title}'),
|
||||
borderRadius: BorderRadius.circular(AppMenuStyle.rowRadius),
|
||||
onTap: _toggle,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 10, bottom: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(widget.title.toUpperCase(),
|
||||
style: TextStyle(fontSize: 11, letterSpacing: 0.8, fontWeight: FontWeight.w600, color: muted)),
|
||||
),
|
||||
AnimatedRotation(
|
||||
turns: _open ? 0 : -0.25,
|
||||
duration: easeTime,
|
||||
curve: ease,
|
||||
child: Icon(Icons.expand_more_rounded, size: 18, color: muted),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
AnimatedSize(
|
||||
duration: easeTime,
|
||||
curve: ease,
|
||||
alignment: Alignment.topCenter,
|
||||
child: _open
|
||||
? Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: widget.children)
|
||||
: const SizedBox(width: double.infinity),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Row extends StatelessWidget {
|
||||
final String label;
|
||||
final Widget control;
|
||||
const _Row({required this.label, required this.control});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => SizedBox(
|
||||
height: 40,
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(child: Text(label, style: const TextStyle(fontSize: 13))),
|
||||
Flexible(flex: 2, child: Align(alignment: Alignment.centerRight, child: control)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _SwitchRow extends StatelessWidget {
|
||||
final String label;
|
||||
final String? help;
|
||||
final bool value;
|
||||
final ValueChanged<bool> onChanged;
|
||||
const _SwitchRow({super.key, required this.label, this.help, required this.value, required this.onChanged});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(label, style: const TextStyle(fontSize: 13)),
|
||||
if (help != null)
|
||||
Text(help!, style: TextStyle(fontSize: 11.5, color: Colors.white.withValues(alpha: 0.5))),
|
||||
],
|
||||
),
|
||||
),
|
||||
Transform.scale(
|
||||
scale: 0.8,
|
||||
alignment: Alignment.centerRight,
|
||||
child: Switch(value: value, onChanged: onChanged),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _SliderRow extends StatelessWidget {
|
||||
final String label;
|
||||
final double value, min, max;
|
||||
final int? divisions;
|
||||
final String readout;
|
||||
final ValueChanged<double>? onChanged;
|
||||
const _SliderRow({
|
||||
super.key,
|
||||
required this.label,
|
||||
required this.value,
|
||||
required this.min,
|
||||
required this.max,
|
||||
this.divisions,
|
||||
required this.readout,
|
||||
required this.onChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => SizedBox(
|
||||
height: 38,
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(width: 84, child: Text(label, style: const TextStyle(fontSize: 13))),
|
||||
Expanded(
|
||||
child: SliderTheme(
|
||||
data: SliderTheme.of(context).copyWith(
|
||||
trackHeight: 3,
|
||||
overlayShape: SliderComponentShape.noOverlay,
|
||||
thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 7),
|
||||
tickMarkShape: SliderTickMarkShape.noTickMark,
|
||||
),
|
||||
child: Slider(value: value.clamp(min, max), min: min, max: max, divisions: divisions, onChanged: onChanged),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: 52,
|
||||
child: Text(readout,
|
||||
textAlign: TextAlign.right,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.white.withValues(alpha: 0.7),
|
||||
fontFeatures: const [FontFeature.tabularFigures()],
|
||||
)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Subtitle or audio track of the running player; remembered for the
|
||||
/// next episode.
|
||||
class _TrackRow extends StatelessWidget {
|
||||
final JellyfinStreamType type;
|
||||
const _TrackRow({required this.type});
|
||||
|
||||
bool get _audio => type == JellyfinStreamType.audio;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final coord = AppScope.of(context).coordinator;
|
||||
return SignalBuilder(builder: (context) {
|
||||
final list = [for (final t in coord.playback.tracks.value) if (t.type == type) t];
|
||||
final selected = list.where((t) => t.selected).firstOrNull?.id ?? (_audio ? null : -1);
|
||||
final items = <(int, String)>[
|
||||
if (!_audio) (-1, 'Off'),
|
||||
for (final t in list) (t.id, t.label),
|
||||
];
|
||||
final key = ValueKey(_audio ? 'panel-audio' : 'panel-subtitles');
|
||||
return _Row(
|
||||
label: _audio ? 'Audio' : 'Subtitles',
|
||||
control: items.isEmpty || selected == null
|
||||
? Text(items.isEmpty ? 'None' : 'Pick one…', key: key, style: const TextStyle(fontSize: 13))
|
||||
: AppSelect<int>(
|
||||
key: key,
|
||||
value: selected,
|
||||
expanded: true,
|
||||
items: items,
|
||||
onChanged: (id) {
|
||||
coord.markInput();
|
||||
if (id == -1) {
|
||||
coord.selectTrack(subtitlesOff: true);
|
||||
} else if (_audio) {
|
||||
coord.selectTrack(aid: id);
|
||||
} else {
|
||||
coord.selectTrack(sid: id);
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// The upscaling shader: Anime4K mode (or custom files) and HQ / Fast.
|
||||
class _ShaderRow extends StatelessWidget {
|
||||
final ReadonlySignal<RenderMode>? renderMode;
|
||||
const _ShaderRow({this.renderMode});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final coord = AppScope.of(context).coordinator;
|
||||
final s = coord.settings;
|
||||
return SignalBuilder(builder: (context) {
|
||||
final preset = s.shaderPreset.value;
|
||||
final hasCustom = s.customShaders.value.isNotEmpty;
|
||||
final software = switch (renderMode?.value) {
|
||||
RenderMode.software || RenderMode.softwareGl => true,
|
||||
_ => false,
|
||||
};
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_Row(
|
||||
label: 'Upscaling',
|
||||
control: AppSelect<ShaderPreset>(
|
||||
key: const ValueKey('panel-shader'),
|
||||
value: preset,
|
||||
items: [
|
||||
for (final p in ShaderPreset.values)
|
||||
if (p != ShaderPreset.custom || hasCustom || preset == p) (p, p.label),
|
||||
],
|
||||
onChanged: (p) => applyShaderChoice(coord, p, null),
|
||||
),
|
||||
),
|
||||
if (preset.isAnime4k)
|
||||
_Row(
|
||||
label: 'Shader quality',
|
||||
control: AppSelect<ShaderQuality>(
|
||||
key: const ValueKey('panel-shader-quality'),
|
||||
value: s.shaderQuality.value,
|
||||
items: [for (final q in ShaderQuality.values) (q, q.label)],
|
||||
onChanged: (q) => applyShaderChoice(coord, preset, q),
|
||||
),
|
||||
),
|
||||
if (software && preset != ShaderPreset.off)
|
||||
const Text('Software rendering: shaders have little or no effect',
|
||||
style: TextStyle(fontSize: 11.5, color: Colors.orange)),
|
||||
],
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -2,23 +2,19 @@ import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:signals/signals_flutter.dart';
|
||||
import 'package:sync_relay_core/core.dart' show JellyfinStreamType;
|
||||
|
||||
import '../jellyfin/track_prefs.dart' show MpvTrack;
|
||||
import '../playback/playback_controller.dart' show PlaybackController, RenderMode;
|
||||
import '../session/session_coordinator.dart' show SessionCoordinator;
|
||||
import '../shaders/shader_presets.dart';
|
||||
import '../app/app_graph.dart';
|
||||
import '../shell/mark_problem.dart';
|
||||
import '../shell/motion.dart';
|
||||
import '../state/settings.dart';
|
||||
import '../widgets/app_menu.dart';
|
||||
import 'frame.dart';
|
||||
import 'model/controls_prefs.dart';
|
||||
|
||||
// Everything about the player, defined once: the rows of the player
|
||||
// settings card (⚙ → Player settings, both skins) and of Settings → Player. Each row reads and
|
||||
// writes [AppSettings] and applies to a running player right away.
|
||||
// Everything about the player, defined once: the rows of Settings →
|
||||
// Player (the player panel, `player_panel.dart`, has compact rows for the
|
||||
// same settings). Each row reads and writes [AppSettings] and applies to
|
||||
// a running player right away.
|
||||
|
||||
/// One setting: a label, one line of help under it, the control at the
|
||||
/// right ([below]: an extra line under the row, e.g. a text field).
|
||||
@@ -199,6 +195,35 @@ class ProgressLineSetting extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// The video output at the player's size on screen ([AppSettings.screenResVideo]).
|
||||
class ScreenResSetting extends StatelessWidget {
|
||||
final ReadonlySignal<RenderMode>? renderMode;
|
||||
const ScreenResSetting({super.key, this.renderMode});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final s = AppScope.of(context).coordinator.settings;
|
||||
return SignalBuilder(builder: (context) {
|
||||
final software = switch (renderMode?.value) {
|
||||
RenderMode.software || RenderMode.softwareGl => true,
|
||||
_ => false,
|
||||
};
|
||||
return SettingRow(
|
||||
label: 'Sharp video and subtitles',
|
||||
help: software
|
||||
? 'No effect: video uses software rendering'
|
||||
: "Scale the picture and draw subtitles at your screen's resolution, not the video's. "
|
||||
'Off: lighter on a weak GPU',
|
||||
control: Switch(
|
||||
key: const ValueKey('screen-res-video'),
|
||||
value: s.screenResVideo.value,
|
||||
onChanged: s.setScreenResVideo,
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Saves a shader choice (also used on the next join) and puts it on the
|
||||
/// running player, if any; the player announces the result.
|
||||
void applyShaderChoice(SessionCoordinator coord, ShaderPreset preset, ShaderQuality? quality) {
|
||||
@@ -333,58 +358,6 @@ class _HwdecSettingState extends State<HwdecSetting> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Audio or subtitle track of the running player (in the player only).
|
||||
/// The pick is remembered for the next Jellyfin item.
|
||||
class TrackSetting extends StatelessWidget {
|
||||
final JellyfinStreamType type;
|
||||
const TrackSetting({super.key, required this.type});
|
||||
|
||||
bool get _audio => type == JellyfinStreamType.audio;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final coord = AppScope.of(context).coordinator;
|
||||
return SignalBuilder(builder: (context) {
|
||||
final list = [
|
||||
for (final t in coord.playback.tracks.value)
|
||||
if (t.type == type) t,
|
||||
];
|
||||
// -1: subtitles off.
|
||||
final selected = list.where((t) => t.selected).firstOrNull?.id ?? (_audio ? null : -1);
|
||||
final items = <(int, String)>[
|
||||
if (!_audio) (-1, 'Off'),
|
||||
for (final MpvTrack t in list) (t.id, t.label),
|
||||
];
|
||||
return SettingRow(
|
||||
label: _audio ? 'Audio' : 'Subtitles',
|
||||
help: 'Remembered for the next episode',
|
||||
control: SizedBox(
|
||||
width: 190,
|
||||
child: items.isEmpty || selected == null
|
||||
? Text(items.isEmpty ? 'None' : 'Pick one…',
|
||||
key: ValueKey(_audio ? 'audio-track' : 'subtitle-track'), textAlign: TextAlign.end)
|
||||
: _dropdown<int>(
|
||||
key: ValueKey(_audio ? 'audio-track' : 'subtitle-track'),
|
||||
value: selected,
|
||||
expanded: true,
|
||||
items: items,
|
||||
onChanged: (id) {
|
||||
coord.markInput();
|
||||
if (id == -1) {
|
||||
coord.selectTrack(subtitlesOff: true);
|
||||
} else if (_audio) {
|
||||
coord.selectTrack(aid: id);
|
||||
} else {
|
||||
coord.selectTrack(sid: id);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// The controls' own choices (Settings → Player → Controls).
|
||||
List<Widget> controlsSettingRows() => [
|
||||
const TimeDisplaySetting(),
|
||||
@@ -397,57 +370,38 @@ List<Widget> controlsSettingRows() => [
|
||||
for (final c in OptionalControl.values) BarButtonSetting(control: c),
|
||||
];
|
||||
|
||||
/// The player rows of Settings → Player and the player settings card, in
|
||||
/// order.
|
||||
/// The player rows of Settings → Player, in order (the player panel has
|
||||
/// its own compact rows for the same settings).
|
||||
List<Widget> playerSettingRows({ReadonlySignal<RenderMode>? renderMode}) => [
|
||||
const PlayerDesignSetting(),
|
||||
const ProgressLineSetting(),
|
||||
ShaderPresetSetting(renderMode: renderMode),
|
||||
ScreenResSetting(renderMode: renderMode),
|
||||
const HwdecSetting(),
|
||||
const SubScaleSetting(),
|
||||
const LiftSubtitlesSetting(),
|
||||
];
|
||||
|
||||
/// The player settings card: every player setting, plus the running
|
||||
/// player's tracks.
|
||||
class PlayerSettingsCard extends StatelessWidget {
|
||||
final ReadonlySignal<RenderMode>? renderMode;
|
||||
/// Show the audio / subtitle pickers (a player is running).
|
||||
final bool tracks;
|
||||
const PlayerSettingsCard({super.key, this.renderMode, this.tracks = true});
|
||||
/// Subtitle size (mpv `sub-scale`, [AppSettings.subScale]). Live.
|
||||
class SubScaleSetting extends StatelessWidget {
|
||||
const SubScaleSetting({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// The app's glass, like the Up next popover and the menus.
|
||||
return AppGlass(
|
||||
key: const ValueKey('player-settings-card'),
|
||||
child: SizedBox(
|
||||
width: 420,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 10),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const Text('Player', style: TextStyle(fontWeight: FontWeight.w600, fontSize: 14)),
|
||||
const SizedBox(height: 4),
|
||||
...playerSettingRows(renderMode: renderMode),
|
||||
if (tracks) ...[
|
||||
const Divider(),
|
||||
const TrackSetting(type: JellyfinStreamType.subtitle),
|
||||
const TrackSetting(type: JellyfinStreamType.audio),
|
||||
],
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Controls layout and custom shaders: Settings → Player',
|
||||
style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant, fontSize: 12),
|
||||
),
|
||||
),
|
||||
const MarkProblemButton(),
|
||||
],
|
||||
),
|
||||
],
|
||||
final s = AppScope.of(context).coordinator.settings;
|
||||
return SignalBuilder(
|
||||
builder: (context) => SettingRow(
|
||||
label: 'Subtitle size',
|
||||
help: '${(s.subScale.value * 100).round()} % of what the file sets',
|
||||
control: SizedBox(
|
||||
width: 220,
|
||||
child: Slider(
|
||||
key: const ValueKey('sub-scale'),
|
||||
value: s.subScale.value,
|
||||
min: 0.5,
|
||||
max: 2,
|
||||
divisions: 30,
|
||||
onChanged: s.setSubScale,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -455,14 +409,25 @@ class PlayerSettingsCard extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens the player settings card over the player, near the controls'
|
||||
/// right end; the controls stay up while it is open.
|
||||
Future<void> showPlayerSettings(BuildContext context, {ReadonlySignal<RenderMode>? renderMode}) =>
|
||||
ControlsFrame.holdWhile(
|
||||
context,
|
||||
() => showPopover<void>(
|
||||
context,
|
||||
at: const Alignment(0.96, 0.5),
|
||||
child: PlayerSettingsCard(renderMode: renderMode),
|
||||
/// Bottom subtitles above the control bar while it is up
|
||||
/// ([AppSettings.liftSubtitles]). Live.
|
||||
class LiftSubtitlesSetting extends StatelessWidget {
|
||||
const LiftSubtitlesSetting({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final s = AppScope.of(context).coordinator.settings;
|
||||
return SignalBuilder(
|
||||
builder: (context) => SettingRow(
|
||||
label: 'Subtitles above the controls',
|
||||
help: 'Bottom lines move up while the control bar shows; signs at the top stay. '
|
||||
'The chat box always moves them',
|
||||
control: Switch(
|
||||
key: const ValueKey('lift-subtitles'),
|
||||
value: s.liftSubtitles.value,
|
||||
onChanged: s.setLiftSubtitles,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'package:flutter/foundation.dart' show ValueListenable;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:signals/signals_flutter.dart';
|
||||
|
||||
@@ -34,6 +35,9 @@ class SessionControls extends StatelessWidget {
|
||||
final ControlsHold hold;
|
||||
final VoidCallback onDoubleClick;
|
||||
final Widget? windowButtons;
|
||||
|
||||
/// Hides them while something takes their place ([ControlsFrame.suppress]).
|
||||
final ValueListenable<bool>? suppress;
|
||||
const SessionControls({
|
||||
super.key,
|
||||
required this.model,
|
||||
@@ -41,6 +45,7 @@ class SessionControls extends StatelessWidget {
|
||||
required this.hold,
|
||||
required this.onDoubleClick,
|
||||
this.windowButtons,
|
||||
this.suppress,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -56,6 +61,7 @@ class SessionControls extends StatelessWidget {
|
||||
key: ValueKey(skin),
|
||||
shown: shown,
|
||||
holdBy: hold,
|
||||
suppress: suppress,
|
||||
hideAfter: Duration(seconds: s.controlsHideSecs.value),
|
||||
onDoubleClick: onDoubleClick,
|
||||
barTop: controlsTopHeight(skin),
|
||||
|
||||
@@ -0,0 +1,582 @@
|
||||
import 'dart:collection';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
|
||||
import '../shell/avatar.dart' show AvatarInfo, StatusAvatar;
|
||||
|
||||
// Danmaku (Bilibili / niconico style): short lines that fly right to left
|
||||
// across the upper part of the picture. Each line takes a free lane (no
|
||||
// two ever touch), at most [DanmakuStyle.maxOnScreen] fly at once and the
|
||||
// rest wait their turn; every line crosses in the same time, so a long
|
||||
// one moves faster. The clock is the layer's own: lines keep flying while
|
||||
// the video is paused. The session's notifications use it when the user
|
||||
// picks "Notifications as danmaku" (`danmaku_notices.dart`); the dev
|
||||
// preview tries it out (DEVELOPMENT.md "Dev tools").
|
||||
|
||||
/// What a line is: a chat message or a short notice.
|
||||
enum DanmakuKind { chat, join, leave, upNext, starting }
|
||||
|
||||
/// One line: [sender] (a coloured prefix, [senderColor]) and [text];
|
||||
/// [own] lines (this member's) get a hairline frame. [avatar]: the
|
||||
/// sender's picture (or initial) before it; the app's own notices have
|
||||
/// none.
|
||||
@immutable
|
||||
class DanmakuItem {
|
||||
final DanmakuKind kind;
|
||||
final String? sender;
|
||||
final Color? senderColor;
|
||||
final String text;
|
||||
final bool own;
|
||||
final AvatarInfo? avatar;
|
||||
const DanmakuItem(
|
||||
{required this.kind, required this.text, this.sender, this.senderColor, this.own = false, this.avatar});
|
||||
}
|
||||
|
||||
/// How lines look and move. Persisted as [toJson] (Settings →
|
||||
/// Notifications & sounds); [DanmakuStyle.fromJson] clamps every value to
|
||||
/// its range ([limits]), so a hand-edited or older box never breaks it.
|
||||
@immutable
|
||||
class DanmakuStyle {
|
||||
/// Seconds a line takes from the right edge until it has left on the
|
||||
/// left, whatever its length.
|
||||
final double crossSecs;
|
||||
|
||||
/// Lines on screen at once; more wait.
|
||||
final int maxOnScreen;
|
||||
|
||||
/// The band the lanes fill, as fractions of the height from the top:
|
||||
/// below where top signs sit, never down into the dialogue.
|
||||
final double bandTop;
|
||||
final double bandBottom;
|
||||
final double opacity;
|
||||
|
||||
/// Text size relative to the picture's height ([fontSizeFor]).
|
||||
final double size;
|
||||
|
||||
/// Font weight of the text, 400 (regular) to 900 (black); the sender's
|
||||
/// name is a step bolder.
|
||||
final int weight;
|
||||
|
||||
/// Width of the dark outline round the white text, logical px.
|
||||
final double outline;
|
||||
final bool highlightOwn;
|
||||
|
||||
/// The sender's avatar before the line.
|
||||
final bool avatars;
|
||||
|
||||
const DanmakuStyle({
|
||||
this.crossSecs = 8,
|
||||
this.maxOnScreen = 12,
|
||||
this.bandTop = 0.10,
|
||||
this.bandBottom = 0.45,
|
||||
this.opacity = 0.92,
|
||||
this.size = 1,
|
||||
this.weight = 600,
|
||||
this.outline = 1.4,
|
||||
this.highlightOwn = true,
|
||||
this.avatars = true,
|
||||
});
|
||||
|
||||
/// The range of each knob (the settings' sliders use them too).
|
||||
static const limits = (
|
||||
crossSecs: (4.0, 16.0),
|
||||
maxOnScreen: (1, 40),
|
||||
band: (0.0, 0.9),
|
||||
minBand: 0.05,
|
||||
opacity: (0.3, 1.0),
|
||||
size: (0.6, 1.8),
|
||||
weight: (400, 900),
|
||||
outline: (0.0, 4.0),
|
||||
);
|
||||
|
||||
DanmakuStyle copyWith({
|
||||
double? crossSecs,
|
||||
int? maxOnScreen,
|
||||
double? bandTop,
|
||||
double? bandBottom,
|
||||
double? opacity,
|
||||
double? size,
|
||||
int? weight,
|
||||
double? outline,
|
||||
bool? highlightOwn,
|
||||
bool? avatars,
|
||||
}) =>
|
||||
DanmakuStyle(
|
||||
crossSecs: crossSecs ?? this.crossSecs,
|
||||
maxOnScreen: maxOnScreen ?? this.maxOnScreen,
|
||||
bandTop: bandTop ?? this.bandTop,
|
||||
bandBottom: bandBottom ?? this.bandBottom,
|
||||
opacity: opacity ?? this.opacity,
|
||||
size: size ?? this.size,
|
||||
weight: weight ?? this.weight,
|
||||
outline: outline ?? this.outline,
|
||||
highlightOwn: highlightOwn ?? this.highlightOwn,
|
||||
avatars: avatars ?? this.avatars,
|
||||
);
|
||||
|
||||
Map<String, Object> toJson() => {
|
||||
'crossSecs': crossSecs,
|
||||
'maxOnScreen': maxOnScreen,
|
||||
'bandTop': bandTop,
|
||||
'bandBottom': bandBottom,
|
||||
'opacity': opacity,
|
||||
'size': size,
|
||||
'weight': weight,
|
||||
'outline': outline,
|
||||
'highlightOwn': highlightOwn,
|
||||
'avatars': avatars,
|
||||
};
|
||||
|
||||
/// [json] as stored ([toJson]); missing or wrong values take the
|
||||
/// default, out-of-range ones the nearest end of their range.
|
||||
factory DanmakuStyle.fromJson(Map<Object?, Object?> json) {
|
||||
const d = DanmakuStyle();
|
||||
const l = limits;
|
||||
double num_(String k, double def, (double, double) r) {
|
||||
final v = json[k];
|
||||
return v is num && v.isFinite ? v.toDouble().clamp(r.$1, r.$2) : def;
|
||||
}
|
||||
|
||||
int int_(String k, int def, (int, int) r) {
|
||||
final v = json[k];
|
||||
return v is num && v.isFinite ? v.round().clamp(r.$1, r.$2) : def;
|
||||
}
|
||||
|
||||
bool bool_(String k, bool def) {
|
||||
final v = json[k];
|
||||
return v is bool ? v : def;
|
||||
}
|
||||
|
||||
var top = num_('bandTop', d.bandTop, l.band);
|
||||
var bottom = num_('bandBottom', d.bandBottom, l.band);
|
||||
if (bottom - top < l.minBand) (top, bottom) = (d.bandTop, d.bandBottom);
|
||||
return DanmakuStyle(
|
||||
crossSecs: num_('crossSecs', d.crossSecs, l.crossSecs),
|
||||
maxOnScreen: int_('maxOnScreen', d.maxOnScreen, l.maxOnScreen),
|
||||
bandTop: top,
|
||||
bandBottom: bottom,
|
||||
opacity: num_('opacity', d.opacity, l.opacity),
|
||||
size: num_('size', d.size, l.size),
|
||||
weight: (int_('weight', d.weight, l.weight) / 100).round() * 100,
|
||||
outline: num_('outline', d.outline, l.outline),
|
||||
highlightOwn: bool_('highlightOwn', d.highlightOwn),
|
||||
avatars: bool_('avatars', d.avatars),
|
||||
);
|
||||
}
|
||||
|
||||
/// [weight] as a [FontWeight] (400..900 in steps of 100).
|
||||
FontWeight get fontWeight => FontWeight.values[((weight.clamp(100, 900) / 100).round() - 1)];
|
||||
|
||||
/// The sender's name: a step bolder than the text.
|
||||
FontWeight get nameWeight =>
|
||||
FontWeight.values[math.min(FontWeight.values.length - 1, (weight.clamp(100, 900) / 100).round())];
|
||||
|
||||
/// The text size in a picture [height] px tall: 4.2 % of it (about 30 px
|
||||
/// on a 720 px player), 13 to 44 px, times [size].
|
||||
double fontSizeFor(double height) => (height * 0.042).clamp(13.0, 44.0) * size;
|
||||
|
||||
/// A lane: the line's height plus a little air.
|
||||
double laneHeightFor(double height) => fontSizeFor(height) * 1.5;
|
||||
|
||||
/// Lanes in the band of a picture [height] px tall (at least one).
|
||||
int lanesFor(double height) {
|
||||
final band = (bandBottom - bandTop).clamp(0.0, 1.0) * height;
|
||||
return math.max(1, (band / laneHeightFor(height)).floor());
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
other is DanmakuStyle &&
|
||||
other.crossSecs == crossSecs &&
|
||||
other.maxOnScreen == maxOnScreen &&
|
||||
other.bandTop == bandTop &&
|
||||
other.bandBottom == bandBottom &&
|
||||
other.opacity == opacity &&
|
||||
other.size == size &&
|
||||
other.weight == weight &&
|
||||
other.outline == outline &&
|
||||
other.highlightOwn == highlightOwn &&
|
||||
other.avatars == avatars;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
Object.hash(crossSecs, maxOnScreen, bandTop, bandBottom, opacity, size, weight, outline, highlightOwn, avatars);
|
||||
}
|
||||
|
||||
/// One line in flight in [lane], from [start] (seconds on the layer's
|
||||
/// clock), [width] px wide, crossing in [crossSecs].
|
||||
class DanmakuFlight<T> {
|
||||
final T item;
|
||||
final int lane;
|
||||
final double start;
|
||||
final double width;
|
||||
final double crossSecs;
|
||||
DanmakuFlight({required this.item, required this.lane, required this.start, required this.width, required this.crossSecs});
|
||||
|
||||
/// Its left edge at [t] in a picture [w] px wide: [w] at the start,
|
||||
/// -[width] at the end.
|
||||
double x(double t, double w) => w - (w + width) * ((t - start) / crossSecs);
|
||||
double end() => start + crossSecs;
|
||||
bool doneAt(double t) => t >= end();
|
||||
}
|
||||
|
||||
/// Lane assignment, pure (no widgets, a clock passed in): lines queue in
|
||||
/// order of arrival; [tick] lets the waiting ones in while a lane is free
|
||||
/// and fewer than the cap fly. A lane is free for a new line when the last
|
||||
/// line in it has fully entered with [gap] behind it and the new one, at
|
||||
/// its own speed, cannot catch up with it before it has left.
|
||||
class DanmakuLanes<T> {
|
||||
/// Space kept between two lines in a lane, px.
|
||||
final double gap;
|
||||
|
||||
/// A line waiting longer than this is dropped (a burst on a crowded
|
||||
/// picture would otherwise fly long after it was said).
|
||||
final double maxWaitSecs;
|
||||
|
||||
/// Lines kept waiting at most; older ones go first when it is full.
|
||||
final int maxWaiting;
|
||||
|
||||
DanmakuLanes({this.gap = 24, this.maxWaitSecs = 15, this.maxWaiting = 60});
|
||||
|
||||
final List<DanmakuFlight<T>> flights = [];
|
||||
final Queue<(T, double)> _waiting = Queue();
|
||||
int dropped = 0;
|
||||
|
||||
int get waiting => _waiting.length;
|
||||
|
||||
/// Everything flying and waiting goes; returns it.
|
||||
List<T> clear() {
|
||||
final all = [for (final f in flights) f.item, for (final w in _waiting) w.$1];
|
||||
flights.clear();
|
||||
_waiting.clear();
|
||||
return all;
|
||||
}
|
||||
|
||||
/// [item] arrived at [t]; it flies on the next [tick] that has room.
|
||||
void add(T item, double t) {
|
||||
_waiting.add((item, t));
|
||||
while (_waiting.length > maxWaiting) {
|
||||
_waiting.removeFirst();
|
||||
dropped++;
|
||||
}
|
||||
}
|
||||
|
||||
/// Advances to [t] in a picture [w] px wide with [lanes] lanes: drops
|
||||
/// what has left and what waited too long, then places waiting lines in
|
||||
/// order (the topmost free lane each) until the cap or no lane is free.
|
||||
/// [measure] gives a line's width. Returns the lines placed.
|
||||
List<DanmakuFlight<T>> tick(double t,
|
||||
{required double w, required int lanes, required int cap, required double crossSecs, required double Function(T) measure}) {
|
||||
flights.removeWhere((f) => f.doneAt(t));
|
||||
while (_waiting.isNotEmpty && t - _waiting.first.$2 > maxWaitSecs) {
|
||||
_waiting.removeFirst();
|
||||
dropped++;
|
||||
}
|
||||
final placed = <DanmakuFlight<T>>[];
|
||||
while (_waiting.isNotEmpty && flights.length < cap) {
|
||||
final item = _waiting.first.$1;
|
||||
final width = measure(item);
|
||||
final lane = freeLane(t, w: w, lanes: lanes, width: width, crossSecs: crossSecs);
|
||||
if (lane == null) break;
|
||||
_waiting.removeFirst();
|
||||
final f = DanmakuFlight(item: item, lane: lane, start: t, width: width, crossSecs: crossSecs);
|
||||
flights.add(f);
|
||||
placed.add(f);
|
||||
}
|
||||
return placed;
|
||||
}
|
||||
|
||||
/// The topmost lane a line [width] px wide can start in at [t], or null.
|
||||
int? freeLane(double t, {required double w, required int lanes, required double width, required double crossSecs}) {
|
||||
final last = <int, DanmakuFlight<T>>{};
|
||||
for (final f in flights) {
|
||||
final l = last[f.lane];
|
||||
if (l == null || f.start > l.start) last[f.lane] = f;
|
||||
}
|
||||
for (var lane = 0; lane < lanes; lane++) {
|
||||
final prev = last[lane];
|
||||
if (prev == null || fits(prev, t, w: w, width: width, crossSecs: crossSecs, gap: gap)) return lane;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// A new line ([width], [crossSecs]) may follow [prev] in its lane at [t].
|
||||
/// Both move linearly, so checking now and the moment [prev] has left is
|
||||
/// enough.
|
||||
static bool fits(DanmakuFlight<Object?> prev, double t,
|
||||
{required double w, required double width, required double crossSecs, required double gap}) {
|
||||
// Fully in, with the gap behind it.
|
||||
if (prev.x(t, w) + prev.width + gap > w) return false;
|
||||
final leaves = prev.end();
|
||||
if (leaves <= t) return true;
|
||||
// Where the new line's head is when prev's tail reaches the left edge.
|
||||
final head = w - (w + width) * ((leaves - t) / crossSecs);
|
||||
return head >= gap;
|
||||
}
|
||||
}
|
||||
|
||||
/// Feeds a [DanmakuLayer]; [onScreen] / [waiting] follow what the layer
|
||||
/// does with the lines (for a status readout).
|
||||
class DanmakuController extends ChangeNotifier {
|
||||
final _inbox = <DanmakuItem>[];
|
||||
final onScreen = ValueNotifier(0);
|
||||
final waiting = ValueNotifier(0);
|
||||
final dropped = ValueNotifier(0);
|
||||
|
||||
void add(DanmakuItem item) {
|
||||
_inbox.add(item);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Everything in flight and waiting goes.
|
||||
void clear() {
|
||||
_clear = true;
|
||||
_inbox.clear();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
bool _clear = false;
|
||||
|
||||
List<DanmakuItem> _take() {
|
||||
final items = List.of(_inbox);
|
||||
_inbox.clear();
|
||||
return items;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
onScreen.dispose();
|
||||
waiting.dispose();
|
||||
dropped.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// The lines over a picture: fills its box, lets every click through.
|
||||
/// Each line is a widget (avatar, outlined text, own frame) moved by a
|
||||
/// [Flow] on every frame: only paint runs per frame, a build only when a
|
||||
/// line comes or goes.
|
||||
class DanmakuLayer extends StatefulWidget {
|
||||
final DanmakuController controller;
|
||||
final DanmakuStyle style;
|
||||
|
||||
/// The accent of [DanmakuItem.own] lines' frame and of "starting now".
|
||||
final Color accent;
|
||||
const DanmakuLayer({super.key, required this.controller, this.style = const DanmakuStyle(), required this.accent});
|
||||
|
||||
@override
|
||||
State<DanmakuLayer> createState() => _DanmakuLayerState();
|
||||
}
|
||||
|
||||
/// A line measured once (its width decides its speed and its lane) and
|
||||
/// its widget, built once.
|
||||
class _Line {
|
||||
final DanmakuItem item;
|
||||
final double width;
|
||||
final double height;
|
||||
final Widget widget;
|
||||
_Line(this.item, this.width, this.height, this.widget);
|
||||
}
|
||||
|
||||
class _DanmakuLayerState extends State<DanmakuLayer> with SingleTickerProviderStateMixin {
|
||||
late final Ticker _ticker = createTicker(_onTick);
|
||||
final _lanes = DanmakuLanes<_Line>();
|
||||
final _repaint = ValueNotifier(0);
|
||||
// The layer's clock: [_base] from earlier runs of the ticker, which
|
||||
// only runs while lines fly or wait (an idle layer asks for no frames).
|
||||
Duration _base = Duration.zero;
|
||||
Duration _elapsed = Duration.zero;
|
||||
Size _size = Size.zero;
|
||||
var _pending = <DanmakuItem>[];
|
||||
var _shown = <DanmakuFlight<_Line>>[];
|
||||
|
||||
double get _t => (_base + _elapsed).inMicroseconds / 1e6;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
widget.controller.addListener(_onAdd);
|
||||
_onAdd();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(DanmakuLayer old) {
|
||||
super.didUpdateWidget(old);
|
||||
if (old.controller != widget.controller) {
|
||||
old.controller.removeListener(_onAdd);
|
||||
widget.controller.addListener(_onAdd);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
widget.controller.removeListener(_onAdd);
|
||||
_ticker.dispose();
|
||||
_repaint.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onAdd() {
|
||||
final c = widget.controller;
|
||||
if (c._clear) {
|
||||
c._clear = false;
|
||||
_lanes.clear();
|
||||
_pending = [];
|
||||
}
|
||||
_pending.addAll(c._take());
|
||||
if (!_ticker.isActive) _ticker.start();
|
||||
}
|
||||
|
||||
_Line _build(DanmakuItem item) {
|
||||
final s = widget.style;
|
||||
final size = s.fontSizeFor(_size.height);
|
||||
final base = TextStyle(
|
||||
fontSize: size,
|
||||
height: 1.2,
|
||||
fontWeight: s.fontWeight,
|
||||
color: Colors.white,
|
||||
letterSpacing: 0.2,
|
||||
shadows: const [],
|
||||
);
|
||||
InlineSpan span({Paint? stroke}) {
|
||||
final name = item.sender;
|
||||
final accent = item.kind == DanmakuKind.starting && stroke == null ? widget.accent : null;
|
||||
return TextSpan(style: stroke == null ? base : base.copyWith(foreground: stroke, color: null), children: [
|
||||
if (name != null)
|
||||
TextSpan(
|
||||
text: item.kind == DanmakuKind.chat ? '$name ' : '$name ',
|
||||
style: TextStyle(
|
||||
fontWeight: s.nameWeight,
|
||||
color: stroke == null ? (item.senderColor ?? Colors.white) : null,
|
||||
),
|
||||
),
|
||||
TextSpan(text: item.text, style: accent == null ? null : TextStyle(color: accent)),
|
||||
]);
|
||||
}
|
||||
|
||||
final measure = TextPainter(text: span(), textDirection: TextDirection.ltr, maxLines: 1)..layout();
|
||||
final textW = measure.width, textH = measure.height;
|
||||
measure.dispose();
|
||||
final avatar = s.avatars ? item.avatar : null;
|
||||
final avatarSize = size * 1.15;
|
||||
final avatarGap = size * 0.3;
|
||||
final frame = item.own && s.highlightOwn;
|
||||
final padX = frame ? size * 0.4 : 0.0;
|
||||
final width = textW + padX * 2 + (avatar == null ? 0 : avatarSize + avatarGap);
|
||||
Widget text = Text.rich(span(), maxLines: 1, softWrap: false);
|
||||
if (s.outline > 0) {
|
||||
final stroke = Paint()
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = s.outline * 2
|
||||
..strokeJoin = StrokeJoin.round
|
||||
..color = const Color(0xE6000000);
|
||||
text = Stack(children: [Text.rich(span(stroke: stroke), maxLines: 1, softWrap: false), text]);
|
||||
}
|
||||
Widget line = Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (avatar != null) ...[
|
||||
StatusAvatar(info: avatar, size: avatarSize, badges: false, ring: false),
|
||||
SizedBox(width: avatarGap),
|
||||
],
|
||||
text,
|
||||
],
|
||||
);
|
||||
if (frame) {
|
||||
line = DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: widget.accent.withValues(alpha: 0.14),
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
border: Border.all(color: widget.accent.withValues(alpha: 0.85), width: 1.2),
|
||||
),
|
||||
child: Padding(padding: EdgeInsets.symmetric(horizontal: padX), child: line),
|
||||
);
|
||||
}
|
||||
final height = math.max(textH, avatar == null ? 0.0 : avatarSize);
|
||||
return _Line(item, width, height, SizedBox(width: width, height: height, child: Center(child: line)));
|
||||
}
|
||||
|
||||
void _onTick(Duration elapsed) {
|
||||
_elapsed = elapsed;
|
||||
if (_size.isEmpty) return;
|
||||
final t = _t;
|
||||
for (final item in _pending) {
|
||||
_lanes.add(_build(item), t);
|
||||
}
|
||||
_pending = [];
|
||||
final s = widget.style;
|
||||
_lanes.tick(t,
|
||||
w: _size.width,
|
||||
lanes: s.lanesFor(_size.height),
|
||||
cap: s.maxOnScreen,
|
||||
crossSecs: s.crossSecs,
|
||||
measure: (l) => l.width);
|
||||
final c = widget.controller;
|
||||
c.onScreen.value = _lanes.flights.length;
|
||||
c.waiting.value = _lanes.waiting;
|
||||
c.dropped.value = _lanes.dropped;
|
||||
final now = List.of(_lanes.flights);
|
||||
if (now.length != _shown.length || !_sameFlights(now, _shown)) {
|
||||
setState(() => _shown = now);
|
||||
} else if (now.isNotEmpty) {
|
||||
_repaint.value++;
|
||||
}
|
||||
if (now.isEmpty && _lanes.waiting == 0 && _pending.isEmpty) {
|
||||
_base += _elapsed;
|
||||
_elapsed = Duration.zero;
|
||||
_ticker.stop();
|
||||
}
|
||||
}
|
||||
|
||||
static bool _sameFlights(List<DanmakuFlight<_Line>> a, List<DanmakuFlight<_Line>> b) {
|
||||
for (var i = 0; i < a.length; i++) {
|
||||
if (!identical(a[i], b[i])) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return IgnorePointer(
|
||||
child: LayoutBuilder(builder: (context, box) {
|
||||
_size = box.biggest;
|
||||
return Opacity(
|
||||
opacity: widget.style.opacity.clamp(0.0, 1.0),
|
||||
child: Flow(
|
||||
delegate: _DanmakuFlow(this, _repaint),
|
||||
children: [for (final f in _shown) KeyedSubtree(key: ObjectKey(f), child: f.item.widget)],
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DanmakuFlow extends FlowDelegate {
|
||||
final _DanmakuLayerState state;
|
||||
_DanmakuFlow(this.state, Listenable repaint) : super(repaint: repaint);
|
||||
|
||||
@override
|
||||
BoxConstraints getConstraintsForChild(int i, BoxConstraints constraints) => const BoxConstraints();
|
||||
|
||||
@override
|
||||
void paintChildren(FlowPaintingContext context) {
|
||||
final s = state.widget.style;
|
||||
final size = context.size;
|
||||
final t = state._t;
|
||||
final top = s.bandTop * size.height;
|
||||
final lane = s.laneHeightFor(size.height);
|
||||
final flights = state._shown;
|
||||
for (var i = 0; i < flights.length && i < context.childCount; i++) {
|
||||
final f = flights[i];
|
||||
final x = f.x(t, size.width);
|
||||
final y = top + f.lane * lane + (lane - f.item.height) / 2;
|
||||
context.paintChild(i, transform: Matrix4.translationValues(x, y, 0));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(_DanmakuFlow old) => true;
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:signals/signals_flutter.dart';
|
||||
|
||||
import '../session/notifier.dart' show NoticeCategory, ToastItem;
|
||||
import '../shell/avatar.dart' show AvatarInfo, colorForName;
|
||||
import 'danmaku.dart';
|
||||
|
||||
// "Notifications: Danmaku" (Settings → Notifications & sounds): over the
|
||||
// full player, chat and short notices fly across the picture instead of
|
||||
// popping up as toasts. Problems, errors and the app's own notices stay
|
||||
// toasts, and so does everything while the player is small or closed.
|
||||
|
||||
/// Whether [t] flies as a line (when danmaku is the style in use): chat,
|
||||
/// joins / leaves / lost connection / back, "starting now", and Up next
|
||||
/// news someone caused (added, changed the video). Nudges, problems, your
|
||||
/// own state and the app's notices stay toasts.
|
||||
bool fliesAsDanmaku(ToastItem t) => switch (t.category) {
|
||||
NoticeCategory.chat || NoticeCategory.people || NoticeCategory.start => true,
|
||||
NoticeCategory.upNext => t.actor != null && t.actor!.isNotEmpty,
|
||||
_ => false,
|
||||
};
|
||||
|
||||
/// [t] as a line: the sender (their avatar from [avatar], their colour)
|
||||
/// before what they said or did; "starting now" without one. Null for
|
||||
/// what does not fly ([fliesAsDanmaku]).
|
||||
DanmakuItem? danmakuForToast(ToastItem t, {AvatarInfo Function(String name, String id)? avatar}) {
|
||||
if (!fliesAsDanmaku(t)) return null;
|
||||
final who = t.actor;
|
||||
if (who == null || who.isEmpty) {
|
||||
return DanmakuItem(kind: DanmakuKind.starting, text: '▶ ${t.text}');
|
||||
}
|
||||
final pic = avatar?.call(who, t.actorId) ?? AvatarInfo(name: who, color: colorForName(who));
|
||||
final color = pic.color;
|
||||
if (t.chat && t.text.startsWith('$who: ')) {
|
||||
return DanmakuItem(
|
||||
kind: DanmakuKind.chat,
|
||||
sender: who,
|
||||
senderColor: color,
|
||||
text: t.text.substring(who.length + 2),
|
||||
own: t.own,
|
||||
avatar: pic);
|
||||
}
|
||||
final rest = t.text.startsWith('$who ') ? t.text.substring(who.length + 1) : t.text;
|
||||
final kind = switch (t.category) {
|
||||
NoticeCategory.upNext => DanmakuKind.upNext,
|
||||
_ when rest.startsWith('left') || rest.startsWith('lost') => DanmakuKind.leave,
|
||||
_ => DanmakuKind.join,
|
||||
};
|
||||
return DanmakuItem(kind: kind, sender: who, senderColor: color, text: rest, avatar: pic);
|
||||
}
|
||||
|
||||
/// The session's lines over the picture: fresh toasts that fly
|
||||
/// ([fliesAsDanmaku], and not [skip]) go to [controller] while this is
|
||||
/// mounted (the player mounts it only while danmaku is the style and the
|
||||
/// full player is up); what was there before it mounted is old news.
|
||||
class DanmakuNotices extends StatefulWidget {
|
||||
final ReadonlySignal<List<ToastItem>> toasts;
|
||||
final ReadonlySignal<DanmakuStyle> style;
|
||||
final DanmakuController controller;
|
||||
final AvatarInfo Function(String name, String id)? avatar;
|
||||
|
||||
/// Toasts shown another way already (your own quick chat echo).
|
||||
final bool Function(ToastItem t)? skip;
|
||||
const DanmakuNotices({
|
||||
super.key,
|
||||
required this.toasts,
|
||||
required this.style,
|
||||
required this.controller,
|
||||
this.avatar,
|
||||
this.skip,
|
||||
});
|
||||
|
||||
@override
|
||||
State<DanmakuNotices> createState() => _DanmakuNoticesState();
|
||||
}
|
||||
|
||||
class _DanmakuNoticesState extends State<DanmakuNotices> {
|
||||
late Set<int> _seen = {for (final t in widget.toasts.peek()) t.id};
|
||||
late final void Function() _stop;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_stop = effect(() {
|
||||
final items = widget.toasts.value;
|
||||
untracked(() {
|
||||
for (final t in items) {
|
||||
if (_seen.contains(t.id) || (widget.skip?.call(t) ?? false)) continue;
|
||||
final line = danmakuForToast(t, avatar: widget.avatar);
|
||||
if (line != null) widget.controller.add(line);
|
||||
}
|
||||
_seen = {for (final t in items) t.id};
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_stop();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => SignalBuilder(
|
||||
builder: (context) => DanmakuLayer(
|
||||
controller: widget.controller,
|
||||
style: widget.style.value,
|
||||
accent: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,851 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/foundation.dart' show ValueListenable;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:signals/signals_flutter.dart';
|
||||
|
||||
import '../controls/minimal/style.dart';
|
||||
import '../controls/session_controls.dart' show controlsBottomHeight;
|
||||
import '../session/notifier.dart' show ToastItem;
|
||||
import '../session/session_coordinator.dart' show SessionCoordinator;
|
||||
import '../shell/avatar.dart' show AvatarInfo, avatarForName, colorForName, memberAvatarInfo, selfAvatarInfo;
|
||||
import '../shell/motion.dart';
|
||||
import '../shell/quick_chat.dart';
|
||||
import '../shell/video_view.dart' show SubtitleLift;
|
||||
import '../app/app_graph.dart' show AppScope;
|
||||
import '../widgets/app_menu.dart' show AppGlass, AppMenuStyle;
|
||||
import 'danmaku.dart';
|
||||
import 'danmaku_notices.dart' show danmakuForToast;
|
||||
|
||||
// Dev builds only (Settings → About → Developer): try the danmaku look on
|
||||
// the real player (when something is loaded) or on a stand-in picture,
|
||||
// fed with samples, a random stream or this session's own toasts, with
|
||||
// every knob of [DanmakuStyle] live and mock subtitles to judge overlap.
|
||||
|
||||
/// The preview's state, owned by the shell while the app runs.
|
||||
class DanmakuPreview {
|
||||
final controller = DanmakuController();
|
||||
|
||||
/// Shown over the full session player.
|
||||
final active = signal(false);
|
||||
final Signal<DanmakuStyle> style;
|
||||
|
||||
/// A random stream: lines per minute, lengths from one word to a long
|
||||
/// sentence, CJK and emoji among them.
|
||||
final auto = signal(false);
|
||||
final autoRate = signal(40.0);
|
||||
|
||||
/// Mock subtitles: a top sign and bottom dialogue.
|
||||
final mockTop = signal(false);
|
||||
final mockBottom = signal(false);
|
||||
|
||||
/// The stand-in picture's control band (the real player has its own).
|
||||
final controlsBand = signal(true);
|
||||
|
||||
/// This session's chat and notice toasts fly too (session only).
|
||||
final live = signal(false);
|
||||
|
||||
/// In a session: the party's real avatars for the samples and the
|
||||
/// session's toasts (set while the session panel is up).
|
||||
SessionCoordinator? coord;
|
||||
|
||||
final _rng = math.Random();
|
||||
Timer? _autoTimer;
|
||||
late final void Function() _stopAuto;
|
||||
|
||||
/// Starts from [style] (the user's look, Settings → Notifications &
|
||||
/// sounds); the preview's knobs change only the preview.
|
||||
DanmakuPreview({DanmakuStyle style = const DanmakuStyle()}) : style = signal(style) {
|
||||
_stopAuto = effect(() {
|
||||
final on = auto.value;
|
||||
autoRate.value;
|
||||
untracked(() {
|
||||
_autoTimer?.cancel();
|
||||
if (on) _scheduleAuto();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_stopAuto();
|
||||
_autoTimer?.cancel();
|
||||
controller.dispose();
|
||||
}
|
||||
|
||||
void _scheduleAuto() {
|
||||
// Poisson arrivals: bursts and lulls like a real chat.
|
||||
final perSec = autoRate.peek() / 60;
|
||||
final wait = -math.log(1 - _rng.nextDouble()) / perSec;
|
||||
_autoTimer = Timer(Duration(milliseconds: (wait * 1000).round().clamp(30, 60000)), () {
|
||||
if (!auto.peek()) return;
|
||||
controller.add(randomSample());
|
||||
_scheduleAuto();
|
||||
});
|
||||
}
|
||||
|
||||
/// One sample line of [kind]; [own]: as if this member wrote it.
|
||||
/// Senders come from the party in a session (their pictures), else
|
||||
/// from [sampleNames] (initials).
|
||||
DanmakuItem sample(DanmakuKind kind, {bool own = false}) {
|
||||
final a = own ? selfAvatarInfo() : _sampleSender();
|
||||
final who = a.name;
|
||||
final color = own ? a.color : colorForName(who);
|
||||
return switch (kind) {
|
||||
DanmakuKind.chat =>
|
||||
DanmakuItem(kind: kind, sender: who, senderColor: color, text: _pick(sampleChat), own: own, avatar: a),
|
||||
DanmakuKind.join => DanmakuItem(kind: kind, sender: who, senderColor: color, text: 'joined', avatar: a),
|
||||
DanmakuKind.leave => DanmakuItem(kind: kind, sender: who, senderColor: color, text: 'left', avatar: a),
|
||||
DanmakuKind.upNext => DanmakuItem(
|
||||
kind: kind, sender: who, senderColor: color, text: 'added ${_pick(sampleTitles)} to Up next', avatar: a),
|
||||
DanmakuKind.starting => const DanmakuItem(kind: DanmakuKind.starting, text: '▶ Starting now'),
|
||||
};
|
||||
}
|
||||
|
||||
/// [text] as this member's own chat line.
|
||||
DanmakuItem own(String text) {
|
||||
final a = selfAvatarInfo();
|
||||
return DanmakuItem(kind: DanmakuKind.chat, sender: a.name, senderColor: a.color, text: text, own: true, avatar: a);
|
||||
}
|
||||
|
||||
AvatarInfo _sampleSender() {
|
||||
final c = coord;
|
||||
final r = c?.serverState.peek();
|
||||
final others = [
|
||||
if (c != null && r != null)
|
||||
for (final m in r.members)
|
||||
if (m.id != c.connection.memberId) memberAvatarInfo(m, r, c),
|
||||
];
|
||||
// The party's real ones half the time, so initials show up too.
|
||||
if (others.isNotEmpty && _rng.nextBool()) return _pick(others);
|
||||
final name = _pick(sampleNames);
|
||||
return AvatarInfo(name: name, color: colorForName(name));
|
||||
}
|
||||
|
||||
/// What the random stream sends: mostly chat, some of it yours, now and
|
||||
/// then a notice.
|
||||
DanmakuItem randomSample() {
|
||||
final r = _rng.nextDouble();
|
||||
if (r < 0.70) return sample(DanmakuKind.chat);
|
||||
if (r < 0.80) return sample(DanmakuKind.chat, own: true);
|
||||
if (r < 0.88) return sample(DanmakuKind.join);
|
||||
if (r < 0.94) return sample(DanmakuKind.leave);
|
||||
return sample(DanmakuKind.upNext);
|
||||
}
|
||||
|
||||
/// A toast of this session as a line, as "Notifications: Danmaku" maps
|
||||
/// it ([danmakuForToast]); the rest (problems) stay toasts.
|
||||
DanmakuItem? fromToast(ToastItem t) {
|
||||
final c = coord;
|
||||
return danmakuForToast(t, avatar: c == null ? null : (name, id) => avatarForName(name, c, id));
|
||||
}
|
||||
|
||||
/// Follows [toasts] while [live] is on; returns the stop.
|
||||
void Function() watchToasts(ReadonlySignal<List<ToastItem>> toasts) {
|
||||
var seen = {for (final t in toasts.peek()) t.id};
|
||||
return effect(() {
|
||||
final items = toasts.value;
|
||||
final on = live.value;
|
||||
untracked(() {
|
||||
for (final t in items) {
|
||||
if (!seen.add(t.id) || !on) continue;
|
||||
final line = fromToast(t);
|
||||
if (line != null) controller.add(line);
|
||||
}
|
||||
seen = {for (final t in items) t.id};
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
T _pick<T>(List<T> from) => from[_rng.nextInt(from.length)];
|
||||
|
||||
static const sampleNames = ['Alice', 'Bob', 'Chiyo', '小雪', 'Kenji', 'Mara'];
|
||||
static const sampleTitles = ['Frieren · 12', 'Bocchi the Rock! · 5', 'Dungeon Meshi · 20', 'Mob Psycho 100 · 3'];
|
||||
static const sampleChat = [
|
||||
'lol',
|
||||
'www',
|
||||
'草',
|
||||
'8888888',
|
||||
'NICE',
|
||||
'it\'s so over',
|
||||
'she\'s back!!',
|
||||
'wait, who is that?',
|
||||
'op skip pls 🙏',
|
||||
'the animation in this scene is insane',
|
||||
'I can\'t believe they actually adapted this arc, the manga panel looked exactly like this, chills honestly',
|
||||
'brb getting snacks, don\'t wait for me (actually do wait)',
|
||||
'前方高能预警!!!',
|
||||
'このシーン最高すぎる',
|
||||
'神回確定',
|
||||
'画质好清晰啊',
|
||||
'🔥🔥🔥',
|
||||
'😭😭 my heart',
|
||||
'🎉 finally',
|
||||
'Himmel would have done the same 🥲',
|
||||
];
|
||||
}
|
||||
|
||||
/// The danmaku with this preview's style and mock subtitles over a
|
||||
/// picture; lets clicks through.
|
||||
class DanmakuPreviewLayers extends StatelessWidget {
|
||||
final DanmakuPreview preview;
|
||||
|
||||
/// The covered bottom the mock dialogue moves above, as the real
|
||||
/// subtitles do ([SubtitleLift]).
|
||||
final ValueListenable<double>? lift;
|
||||
const DanmakuPreviewLayers({super.key, required this.preview, this.lift});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final accent = Theme.of(context).colorScheme.primary;
|
||||
return SignalBuilder(
|
||||
builder: (context) => Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
MockSubtitles(top: preview.mockTop.value, bottom: preview.mockBottom.value, lift: lift),
|
||||
DanmakuLayer(controller: preview.controller, style: preview.style.value, accent: accent),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Stand-ins for subtitles where real ones sit: an ASS sign at the top
|
||||
/// (`\an8`, yellow serif) and two lines of dialogue at the bottom. The
|
||||
/// dialogue moves up by [lift] (px covered at the bottom) in the same
|
||||
/// steps as mpv's `sub-pos` moves the real ones; the sign stays.
|
||||
class MockSubtitles extends StatelessWidget {
|
||||
final bool top;
|
||||
final bool bottom;
|
||||
final ValueListenable<double>? lift;
|
||||
const MockSubtitles({super.key, required this.top, required this.bottom, this.lift});
|
||||
|
||||
static Widget _outlined(String text, TextStyle style, double outline) => Stack(
|
||||
children: [
|
||||
Text(text,
|
||||
textAlign: TextAlign.center,
|
||||
style: style.copyWith(
|
||||
color: null,
|
||||
foreground: Paint()
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = outline
|
||||
..strokeJoin = StrokeJoin.round
|
||||
..color = const Color(0xFF1A1206),
|
||||
)),
|
||||
Text(text, textAlign: TextAlign.center, style: style),
|
||||
],
|
||||
);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!top && !bottom) return const SizedBox.shrink();
|
||||
return IgnorePointer(
|
||||
child: LayoutBuilder(builder: (context, box) {
|
||||
final h = box.maxHeight;
|
||||
final dialogue = (h * 0.05).clamp(12.0, 52.0);
|
||||
return Stack(
|
||||
children: [
|
||||
if (top)
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: h * 0.035,
|
||||
child: Center(
|
||||
child: _outlined(
|
||||
'TOP SIGN · 第3新東京市',
|
||||
TextStyle(
|
||||
fontSize: dialogue * 0.85,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: const Color(0xFFFFE14D),
|
||||
),
|
||||
dialogue * 0.14,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (bottom)
|
||||
ValueListenableBuilder<double>(
|
||||
valueListenable: lift ?? const AlwaysStoppedAnimation(0.0),
|
||||
builder: (context, up, child) => Positioned(
|
||||
key: const ValueKey('mock-dialogue'),
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: h * 0.05 + up,
|
||||
child: child!,
|
||||
),
|
||||
child: Center(
|
||||
child: _outlined(
|
||||
'I told you the controls would cover this line.\nThey never do now — or do they?',
|
||||
TextStyle(fontSize: dialogue, height: 1.15, color: Colors.white, fontWeight: FontWeight.w500),
|
||||
dialogue * 0.12,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A moving picture for the preview when nothing is loaded: slow colour
|
||||
/// fields drifting over a dusk gradient, so motion and contrast behind
|
||||
/// the lines are realistic.
|
||||
class DanmakuStandIn extends StatefulWidget {
|
||||
/// False: a still picture (the settings' strip while it rests).
|
||||
final bool animate;
|
||||
const DanmakuStandIn({super.key, this.animate = true});
|
||||
|
||||
@override
|
||||
State<DanmakuStandIn> createState() => _DanmakuStandInState();
|
||||
}
|
||||
|
||||
class _DanmakuStandInState extends State<DanmakuStandIn> with SingleTickerProviderStateMixin {
|
||||
late final AnimationController _clock = AnimationController(vsync: this, duration: const Duration(seconds: 60));
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
if (widget.animate) _clock.repeat();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(DanmakuStandIn old) {
|
||||
super.didUpdateWidget(old);
|
||||
if (widget.animate && !_clock.isAnimating) _clock.repeat();
|
||||
if (!widget.animate && _clock.isAnimating) _clock.stop();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_clock.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => RepaintBoundary(
|
||||
child: CustomPaint(painter: _StandInPainter(_clock), child: const SizedBox.expand()),
|
||||
);
|
||||
}
|
||||
|
||||
class _StandInPainter extends CustomPainter {
|
||||
final Animation<double> clock;
|
||||
_StandInPainter(this.clock) : super(repaint: clock);
|
||||
|
||||
static const _blobs = [
|
||||
(Color(0xFFFF7A59), 0.9, 0.0, 0.42),
|
||||
(Color(0xFF7C5CFF), 1.3, 1.7, 0.5),
|
||||
(Color(0xFF2EC5CE), 0.7, 3.1, 0.38),
|
||||
(Color(0xFFFFC857), 1.1, 4.4, 0.3),
|
||||
(Color(0xFFE85D9E), 0.5, 5.2, 0.45),
|
||||
];
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final rect = Offset.zero & size;
|
||||
canvas.drawRect(
|
||||
rect,
|
||||
Paint()
|
||||
..shader = const LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [Color(0xFF0B1030), Color(0xFF2A1B4A), Color(0xFF5A2F4F), Color(0xFF14141C)],
|
||||
stops: [0, 0.45, 0.72, 1],
|
||||
).createShader(rect),
|
||||
);
|
||||
final t = clock.value * 2 * math.pi;
|
||||
final r = size.shortestSide;
|
||||
for (final (color, speed, phase, radius) in _blobs) {
|
||||
final c = Offset(
|
||||
size.width * (0.5 + 0.38 * math.sin(t * speed + phase)),
|
||||
size.height * (0.5 + 0.32 * math.cos(t * speed * 0.8 + phase * 1.3)),
|
||||
);
|
||||
canvas.drawCircle(
|
||||
c,
|
||||
r * radius,
|
||||
Paint()
|
||||
..shader = RadialGradient(colors: [color.withValues(alpha: 0.55), color.withValues(alpha: 0)])
|
||||
.createShader(Rect.fromCircle(center: c, radius: r * radius)),
|
||||
);
|
||||
}
|
||||
// A bright horizon line and some stars: hard edges behind the text.
|
||||
final horizon = size.height * 0.68;
|
||||
canvas.drawRect(
|
||||
Rect.fromLTWH(0, horizon, size.width, size.height - horizon),
|
||||
Paint()..color = const Color(0xCC0D0D14),
|
||||
);
|
||||
canvas.drawLine(Offset(0, horizon), Offset(size.width, horizon),
|
||||
Paint()
|
||||
..color = const Color(0x88FFD7A8)
|
||||
..strokeWidth = 1.5);
|
||||
final stars = math.Random(7);
|
||||
for (var i = 0; i < 90; i++) {
|
||||
final x = stars.nextDouble() * size.width;
|
||||
final y = stars.nextDouble() * horizon;
|
||||
final twinkle = 0.4 + 0.6 * (0.5 + 0.5 * math.sin(t * 8 + i));
|
||||
canvas.drawCircle(Offset(x, y), 0.6 + stars.nextDouble() * 1.2,
|
||||
Paint()..color = Colors.white.withValues(alpha: 0.5 * twinkle));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(_StandInPainter old) => false;
|
||||
}
|
||||
|
||||
/// The preview's knobs on the app's glass: fire samples, the random
|
||||
/// stream, the look, mock subtitles. [onClose] ends the preview; it
|
||||
/// collapses to its title bar.
|
||||
class DanmakuPanel extends StatefulWidget {
|
||||
final DanmakuPreview preview;
|
||||
final VoidCallback onClose;
|
||||
|
||||
/// In a session: offer this session's own toasts as a source.
|
||||
final bool session;
|
||||
const DanmakuPanel({super.key, required this.preview, required this.onClose, this.session = false});
|
||||
|
||||
@override
|
||||
State<DanmakuPanel> createState() => _DanmakuPanelState();
|
||||
}
|
||||
|
||||
class _DanmakuPanelState extends State<DanmakuPanel> {
|
||||
bool _open = true;
|
||||
|
||||
DanmakuPreview get p => widget.preview;
|
||||
|
||||
void _style(DanmakuStyle Function(DanmakuStyle s) f) => p.style.value = f(p.style.peek());
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final muted = Colors.white.withValues(alpha: 0.6);
|
||||
return ExcludeFocus(
|
||||
child: AppGlass(
|
||||
child: SizedBox(
|
||||
width: 300,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(14, 8, 6, 4),
|
||||
child: Row(
|
||||
children: [
|
||||
const Expanded(
|
||||
child: Text('Danmaku preview', style: TextStyle(fontWeight: FontWeight.w600, fontSize: 14)),
|
||||
),
|
||||
_Counts(controller: p.controller),
|
||||
IconButton(
|
||||
tooltip: _open ? 'Collapse' : 'Expand',
|
||||
visualDensity: VisualDensity.compact,
|
||||
iconSize: 18,
|
||||
onPressed: () => setState(() => _open = !_open),
|
||||
icon: AnimatedRotation(
|
||||
turns: _open ? 0 : 0.5,
|
||||
duration: easeTime,
|
||||
curve: ease,
|
||||
child: const Icon(Icons.expand_less),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Close the preview',
|
||||
visualDensity: VisualDensity.compact,
|
||||
iconSize: 18,
|
||||
onPressed: widget.onClose,
|
||||
icon: const Icon(Icons.close),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
AnimatedSize(
|
||||
duration: easeTime,
|
||||
curve: ease,
|
||||
alignment: Alignment.topCenter,
|
||||
child: !_open
|
||||
? const SizedBox(width: double.infinity)
|
||||
: ConstrainedBox(
|
||||
constraints: BoxConstraints(maxHeight: MediaQuery.sizeOf(context).height * 0.7),
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.fromLTRB(14, 0, 14, 12),
|
||||
child: SignalBuilder(builder: (context) => _body(muted)),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _body(Color muted) {
|
||||
final s = p.style.value;
|
||||
Widget fire(IconData icon, String label, DanmakuItem Function() item) => MinimalButton(
|
||||
icon: icon,
|
||||
label: label,
|
||||
tooltip: '',
|
||||
scale: 0.8,
|
||||
onPressed: () => p.controller.add(item()),
|
||||
);
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_Head('Send', muted),
|
||||
Wrap(
|
||||
spacing: 4,
|
||||
runSpacing: 4,
|
||||
children: [
|
||||
fire(Icons.chat_bubble_outline, 'Chat', () => p.sample(DanmakuKind.chat)),
|
||||
fire(Icons.person_outline, 'Mine', () => p.sample(DanmakuKind.chat, own: true)),
|
||||
fire(Icons.login, 'Join', () => p.sample(DanmakuKind.join)),
|
||||
fire(Icons.logout, 'Leave', () => p.sample(DanmakuKind.leave)),
|
||||
fire(Icons.playlist_add_rounded, 'Up next', () => p.sample(DanmakuKind.upNext)),
|
||||
fire(Icons.play_arrow_rounded, 'Starting', () => p.sample(DanmakuKind.starting)),
|
||||
fire(Icons.bolt, 'Burst ×12', () {
|
||||
for (var i = 0; i < 11; i++) {
|
||||
p.controller.add(p.randomSample());
|
||||
}
|
||||
return p.randomSample();
|
||||
}),
|
||||
MinimalButton(
|
||||
icon: Icons.clear_all,
|
||||
label: 'Clear',
|
||||
tooltip: '',
|
||||
scale: 0.8,
|
||||
onPressed: p.controller.clear,
|
||||
),
|
||||
],
|
||||
),
|
||||
_Toggle('Random stream', p.auto.value, (v) => p.auto.value = v),
|
||||
_Slider('Rate', p.autoRate.value, 5, 240, '${p.autoRate.value.round()}/min', (v) => p.autoRate.value = v,
|
||||
enabled: p.auto.value),
|
||||
if (widget.session) _Toggle("This session's chat and notices", p.live.value, (v) => p.live.value = v),
|
||||
_Head('Look', muted),
|
||||
_Slider('Crossing', s.crossSecs, 4, 16, '${s.crossSecs.toStringAsFixed(1)} s',
|
||||
(v) => _style((s) => s.copyWith(crossSecs: v))),
|
||||
_Slider('At most', s.maxOnScreen.toDouble(), 1, 40, '${s.maxOnScreen} lines',
|
||||
(v) => _style((s) => s.copyWith(maxOnScreen: v.round()))),
|
||||
_Range('Lanes', s.bandTop, s.bandBottom, '${(s.bandTop * 100).round()}–${(s.bandBottom * 100).round()} %',
|
||||
(a, b) => _style((s) => s.copyWith(bandTop: a, bandBottom: b))),
|
||||
_Slider('Opacity', s.opacity, 0.3, 1, '${(s.opacity * 100).round()} %',
|
||||
(v) => _style((s) => s.copyWith(opacity: v))),
|
||||
_Slider('Size', s.size, 0.6, 1.8, '×${s.size.toStringAsFixed(2)}', (v) => _style((s) => s.copyWith(size: v))),
|
||||
_Slider('Bold', s.weight.toDouble(), 400, 900, '${s.weight}',
|
||||
(v) => _style((s) => s.copyWith(weight: (v / 100).round() * 100))),
|
||||
_Slider('Outline', s.outline, 0, 4, '${s.outline.toStringAsFixed(1)} px',
|
||||
(v) => _style((s) => s.copyWith(outline: v))),
|
||||
_Toggle('Avatars', s.avatars, (v) => _style((s) => s.copyWith(avatars: v))),
|
||||
_Toggle('Frame my own lines', s.highlightOwn, (v) => _style((s) => s.copyWith(highlightOwn: v))),
|
||||
_Head('Subtitles (mock)', muted),
|
||||
_Toggle('Top sign', p.mockTop.value, (v) => p.mockTop.value = v),
|
||||
_Toggle('Bottom dialogue', p.mockBottom.value, (v) => p.mockBottom.value = v),
|
||||
if (!widget.session) _Toggle('Control bar', p.controlsBand.value, (v) => p.controlsBand.value = v),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Counts extends StatelessWidget {
|
||||
final DanmakuController controller;
|
||||
const _Counts({required this.controller});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => ListenableBuilder(
|
||||
listenable: Listenable.merge([controller.onScreen, controller.waiting]),
|
||||
builder: (context, _) => Tooltip(
|
||||
message: 'Flying · waiting for a lane',
|
||||
child: Text(
|
||||
'${controller.onScreen.value} · ${controller.waiting.value}',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.white.withValues(alpha: 0.6),
|
||||
fontFeatures: const [FontFeature.tabularFigures()],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _Head extends StatelessWidget {
|
||||
final String text;
|
||||
final Color color;
|
||||
const _Head(this.text, this.color);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Padding(
|
||||
padding: const EdgeInsets.only(top: 10, bottom: 4),
|
||||
child: Text(text.toUpperCase(),
|
||||
style: TextStyle(fontSize: 11, letterSpacing: 0.8, fontWeight: FontWeight.w600, color: color)),
|
||||
);
|
||||
}
|
||||
|
||||
class _Toggle extends StatelessWidget {
|
||||
final String label;
|
||||
final bool value;
|
||||
final ValueChanged<bool> onChanged;
|
||||
const _Toggle(this.label, this.value, this.onChanged);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => SizedBox(
|
||||
height: 34,
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(child: Text(label, style: const TextStyle(fontSize: 13))),
|
||||
Transform.scale(scale: 0.75, alignment: Alignment.centerRight, child: Switch(value: value, onChanged: onChanged)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _Slider extends StatelessWidget {
|
||||
final String label;
|
||||
final double value, min, max;
|
||||
final String readout;
|
||||
final ValueChanged<double> onChanged;
|
||||
final bool enabled;
|
||||
const _Slider(this.label, this.value, this.min, this.max, this.readout, this.onChanged, {this.enabled = true});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => _SliderFrame(
|
||||
label: label,
|
||||
readout: readout,
|
||||
child: Slider(value: value.clamp(min, max), min: min, max: max, onChanged: enabled ? onChanged : null),
|
||||
);
|
||||
}
|
||||
|
||||
class _Range extends StatelessWidget {
|
||||
final String label;
|
||||
final double a, b;
|
||||
final String readout;
|
||||
final void Function(double a, double b) onChanged;
|
||||
const _Range(this.label, this.a, this.b, this.readout, this.onChanged);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => _SliderFrame(
|
||||
label: label,
|
||||
readout: readout,
|
||||
child: RangeSlider(
|
||||
values: RangeValues(a, b),
|
||||
max: 0.9,
|
||||
onChanged: (v) {
|
||||
if (v.end - v.start >= 0.05) onChanged(v.start, v.end);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _SliderFrame extends StatelessWidget {
|
||||
final String label;
|
||||
final String readout;
|
||||
final Widget child;
|
||||
const _SliderFrame({required this.label, required this.readout, required this.child});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => SizedBox(
|
||||
height: 34,
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(width: 64, child: Text(label, style: const TextStyle(fontSize: 13))),
|
||||
Expanded(
|
||||
child: SliderTheme(
|
||||
data: SliderTheme.of(context).copyWith(
|
||||
trackHeight: 3,
|
||||
overlayShape: SliderComponentShape.noOverlay,
|
||||
thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 6),
|
||||
rangeThumbShape: const RoundRangeSliderThumbShape(enabledThumbRadius: 6),
|
||||
),
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: 76,
|
||||
child: Text(
|
||||
readout,
|
||||
textAlign: TextAlign.right,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.white.withValues(alpha: 0.7),
|
||||
fontFeatures: const [FontFeature.tabularFigures()],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// The preview without a session player: the stand-in picture, a mock
|
||||
/// control bar of the skin in use, the lines and the panel, over the
|
||||
/// whole window (Esc or × closes it).
|
||||
Future<void> showDanmakuPreviewPage(BuildContext context, DanmakuPreview preview) => showGeneralDialog<void>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
barrierLabel: 'Danmaku preview',
|
||||
barrierColor: Colors.black,
|
||||
transitionDuration: easeTime,
|
||||
pageBuilder: (context, _, _) => DanmakuPreviewPage(preview: preview),
|
||||
transitionBuilder: (context, anim, _, child) => FadeTransition(
|
||||
opacity: CurvedAnimation(parent: anim, curve: ease, reverseCurve: Curves.easeIn),
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
|
||||
class DanmakuPreviewPage extends StatefulWidget {
|
||||
final DanmakuPreview preview;
|
||||
const DanmakuPreviewPage({super.key, required this.preview});
|
||||
|
||||
@override
|
||||
State<DanmakuPreviewPage> createState() => _DanmakuPreviewPageState();
|
||||
}
|
||||
|
||||
/// The page also has the quick chat box (Enter): what you send flies as
|
||||
/// your own line, and the mock dialogue moves above the box in the lift's
|
||||
/// steps, as the real subtitles do over the player.
|
||||
class _DanmakuPreviewPageState extends State<DanmakuPreviewPage> {
|
||||
final _chat = QuickChat();
|
||||
final _lift = SubtitleLift();
|
||||
final _pageFocus = FocusNode(debugLabel: 'danmaku preview');
|
||||
double _boxH = QuickChatBox.height;
|
||||
|
||||
DanmakuPreview get preview => widget.preview;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_chat.open.addListener(_retarget);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _pageFocus.requestFocus());
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_chat.open.removeListener(_retarget);
|
||||
_chat.dispose();
|
||||
_lift.dispose();
|
||||
_pageFocus.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _retarget() {
|
||||
final s = AppScope.of(context).coordinator.settings;
|
||||
final bar = preview.controlsBand.peek() && s.liftSubtitles.peek()
|
||||
? controlsBottomHeight(s.controlsSkin.peek(), s.controlsSize.peek()) + 8
|
||||
: 0.0;
|
||||
final box = _chat.open.value ? 16 + _boxH + 8 : 0.0;
|
||||
_lift.animateTo(box > bar ? box : bar);
|
||||
if (!_chat.open.value) _pageFocus.requestFocus();
|
||||
}
|
||||
|
||||
KeyEventResult _key(FocusNode node, KeyEvent e) {
|
||||
if (e is! KeyDownEvent) return KeyEventResult.ignored;
|
||||
if (e.logicalKey == LogicalKeyboardKey.enter || e.logicalKey == LogicalKeyboardKey.numpadEnter) {
|
||||
_chat.show();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Focus(
|
||||
focusNode: _pageFocus,
|
||||
onKeyEvent: _key,
|
||||
child: Material(
|
||||
color: Colors.black,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
const DanmakuStandIn(),
|
||||
DanmakuPreviewLayers(preview: preview, lift: _lift),
|
||||
SignalBuilder(builder: (context) {
|
||||
if (!preview.controlsBand.value) return const SizedBox.shrink();
|
||||
final s = AppScope.of(context).coordinator.settings;
|
||||
final h = controlsBottomHeight(s.controlsSkin.value, s.controlsSize.value);
|
||||
return Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: IgnorePointer(
|
||||
child: ValueListenableBuilder<bool>(
|
||||
valueListenable: _chat.open,
|
||||
builder: (context, open, child) =>
|
||||
AnimatedOpacity(opacity: open ? 0 : 1, duration: easeTime, curve: ease, child: child),
|
||||
child: Container(
|
||||
height: h,
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [Color(0x00000000), MinimalStyle.band],
|
||||
),
|
||||
border: Border(top: BorderSide(color: AppMenuStyle.border, width: 0.5)),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Text('control bar',
|
||||
style: TextStyle(color: Colors.white.withValues(alpha: 0.35), fontSize: 12)),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
Positioned(
|
||||
left: 16,
|
||||
right: 16,
|
||||
bottom: 16,
|
||||
child: QuickChatBox(
|
||||
chat: _chat,
|
||||
onSend: (text) => preview.controller.add(preview.own(text)),
|
||||
onHeight: (h) {
|
||||
_boxH = h;
|
||||
if (_chat.open.value) _retarget();
|
||||
},
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
left: 16,
|
||||
top: 14,
|
||||
child: Text('Stand-in picture: load something to preview on the real player · Enter: chat box',
|
||||
style: TextStyle(color: Colors.white.withValues(alpha: 0.55), fontSize: 12)),
|
||||
),
|
||||
Positioned(
|
||||
right: 16,
|
||||
top: 16,
|
||||
child: DanmakuPanel(preview: preview, onClose: () => Navigator.of(context).pop()),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The panel over the session player; while mounted, this session's
|
||||
/// toasts reach the preview when its "chat and notices" switch is on.
|
||||
class DanmakuSessionPanel extends StatefulWidget {
|
||||
final DanmakuPreview preview;
|
||||
final SessionCoordinator coord;
|
||||
const DanmakuSessionPanel({super.key, required this.preview, required this.coord});
|
||||
|
||||
@override
|
||||
State<DanmakuSessionPanel> createState() => _DanmakuSessionPanelState();
|
||||
}
|
||||
|
||||
class _DanmakuSessionPanelState extends State<DanmakuSessionPanel> {
|
||||
late final void Function() _stop = widget.preview.watchToasts(widget.coord.toasts);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
widget.preview.coord = widget.coord;
|
||||
_stop;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_stop();
|
||||
widget.preview.coord = null;
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => DanmakuPanel(
|
||||
preview: widget.preview,
|
||||
session: true,
|
||||
onClose: () => widget.preview.active.value = false,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:signals/signals_flutter.dart';
|
||||
|
||||
import '../shell/avatar.dart' show AvatarInfo, colorForName, selfAvatarInfo;
|
||||
import '../state/settings.dart';
|
||||
import '../widgets/app_menu.dart' show AppSelect;
|
||||
import 'danmaku.dart';
|
||||
import 'danmaku_preview.dart' show DanmakuPreview, DanmakuStandIn, MockSubtitles;
|
||||
|
||||
// Settings → Notifications & sounds: how notifications show over the full
|
||||
// player (Toasts or Danmaku) and every knob of the danmaku look, with a
|
||||
// live preview strip. The player's side panel shows the style and the
|
||||
// knobs too ([DanmakuKnobs]).
|
||||
|
||||
/// Names of the weights the boldness slider steps through.
|
||||
const danmakuWeightNames = {
|
||||
400: 'Regular',
|
||||
500: 'Medium',
|
||||
600: 'Semibold',
|
||||
700: 'Bold',
|
||||
800: 'Extra bold',
|
||||
900: 'Black',
|
||||
};
|
||||
|
||||
/// "Notifications: Toasts / Danmaku" ([AppSettings.notifyStyle]).
|
||||
class NotifyStyleSelect extends StatelessWidget {
|
||||
final AppSettings settings;
|
||||
const NotifyStyleSelect({super.key, required this.settings});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => SignalBuilder(
|
||||
builder: (context) => AppSelect<NotifyStyle>(
|
||||
key: const ValueKey('notify-style'),
|
||||
value: settings.notifyStyle.value,
|
||||
onChanged: settings.setNotifyStyle,
|
||||
items: [for (final m in NotifyStyle.values) (m, m.label)],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// The card: the style picker, the preview strip and the knobs.
|
||||
class DanmakuSettingsPanel extends StatelessWidget {
|
||||
final AppSettings settings;
|
||||
const DanmakuSettingsPanel({super.key, required this.settings});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final muted = TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant, fontSize: 12);
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('Notifications'),
|
||||
Text(
|
||||
'Danmaku: over the full player, chat, joins, leaves, Up next and "starting now" fly across '
|
||||
'the top of the picture instead of popping up. Problems and errors stay pop-ups, and so does '
|
||||
'everything with the player small',
|
||||
style: muted),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
NotifyStyleSelect(settings: settings),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
DanmakuPreviewStrip(settings: settings),
|
||||
const SizedBox(height: 6),
|
||||
DanmakuKnobs(settings: settings),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A 16:9-ish strip with a moving picture, a top sign and bottom
|
||||
/// dialogue, and sample lines in the current look, a few a second.
|
||||
class DanmakuPreviewStrip extends StatefulWidget {
|
||||
final AppSettings settings;
|
||||
final double height;
|
||||
const DanmakuPreviewStrip({super.key, required this.settings, this.height = 190});
|
||||
|
||||
@override
|
||||
State<DanmakuPreviewStrip> createState() => _DanmakuPreviewStripState();
|
||||
}
|
||||
|
||||
class _DanmakuPreviewStripState extends State<DanmakuPreviewStrip> {
|
||||
/// The strip plays this long after it opens or a knob moves, then
|
||||
/// rests (a click plays it again): a settings page left open costs
|
||||
/// nothing.
|
||||
static const playFor = Duration(seconds: 16);
|
||||
final _controller = DanmakuController();
|
||||
final _rng = math.Random(3);
|
||||
Timer? _timer;
|
||||
Timer? _rest;
|
||||
bool _playing = false;
|
||||
int _n = 0;
|
||||
late final void Function() _stop;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_stop = effect(() {
|
||||
widget.settings.danmakuStyle.value;
|
||||
untracked(_play);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_stop();
|
||||
_timer?.cancel();
|
||||
_rest?.cancel();
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _play() {
|
||||
_rest?.cancel();
|
||||
_rest = Timer(playFor, () {
|
||||
_timer?.cancel();
|
||||
_timer = null;
|
||||
if (mounted) setState(() => _playing = false);
|
||||
});
|
||||
if (_timer != null) return;
|
||||
_send();
|
||||
_timer = Timer.periodic(const Duration(milliseconds: 1100), (_) => _send());
|
||||
if (_playing) return;
|
||||
_playing = true;
|
||||
if (_built && mounted) setState(() {});
|
||||
}
|
||||
|
||||
bool _built = false;
|
||||
|
||||
void _send() {
|
||||
final i = _n++;
|
||||
if (i % 7 == 3) {
|
||||
final me = selfAvatarInfo();
|
||||
_controller.add(DanmakuItem(
|
||||
kind: DanmakuKind.chat, sender: me.name, senderColor: me.color, text: 'this is my line', own: true, avatar: me));
|
||||
return;
|
||||
}
|
||||
final name = DanmakuPreview.sampleNames[_rng.nextInt(DanmakuPreview.sampleNames.length)];
|
||||
final who = AvatarInfo(name: name, color: colorForName(name));
|
||||
if (i % 9 == 5) {
|
||||
_controller.add(DanmakuItem(kind: DanmakuKind.join, sender: name, senderColor: who.color, text: 'joined', avatar: who));
|
||||
return;
|
||||
}
|
||||
// Short and medium lines: the long ones crowd a small strip.
|
||||
final pool = [for (final t in DanmakuPreview.sampleChat) if (t.length < 48) t];
|
||||
_controller.add(DanmakuItem(
|
||||
kind: DanmakuKind.chat,
|
||||
sender: name,
|
||||
senderColor: who.color,
|
||||
text: pool[_rng.nextInt(pool.length)],
|
||||
avatar: who));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
_built = true;
|
||||
final accent = Theme.of(context).colorScheme.primary;
|
||||
return ClipRRect(
|
||||
key: const ValueKey('danmaku-preview-strip'),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: SizedBox(
|
||||
height: widget.height,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
DanmakuStandIn(animate: _playing),
|
||||
const MockSubtitles(top: true, bottom: true),
|
||||
SignalBuilder(
|
||||
builder: (context) =>
|
||||
DanmakuLayer(controller: _controller, style: widget.settings.danmakuStyle.value, accent: accent),
|
||||
),
|
||||
Center(
|
||||
child: AnimatedOpacity(
|
||||
opacity: _playing ? 0 : 1,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
child: IgnorePointer(
|
||||
ignoring: _playing,
|
||||
child: FilledButton.tonalIcon(
|
||||
key: const ValueKey('danmaku-preview-play'),
|
||||
onPressed: _play,
|
||||
icon: const Icon(Icons.play_arrow_rounded, size: 18),
|
||||
label: const Text('Play preview'),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Every knob of the danmaku look, live and saved: crossing time, how
|
||||
/// many at once, the lane band, opacity, size, boldness, outline,
|
||||
/// avatars, your own lines framed; and a reset.
|
||||
class DanmakuKnobs extends StatelessWidget {
|
||||
final AppSettings settings;
|
||||
|
||||
/// Narrow (the player's side panel): shorter labels column.
|
||||
final bool dense;
|
||||
const DanmakuKnobs({super.key, required this.settings, this.dense = false});
|
||||
|
||||
void _set(DanmakuStyle Function(DanmakuStyle s) f) => settings.setDanmakuStyle(f(settings.danmakuStyle.peek()));
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
const l = DanmakuStyle.limits;
|
||||
return SignalBuilder(builder: (context) {
|
||||
final s = settings.danmakuStyle.value;
|
||||
final labelW = dense ? 84.0 : 150.0;
|
||||
Widget slider(String key, String label, double v, (num, num) r, String readout, ValueChanged<double> f,
|
||||
{int? divisions}) =>
|
||||
_Knob(
|
||||
label: label,
|
||||
labelWidth: labelW,
|
||||
readout: readout,
|
||||
child: Slider(
|
||||
key: ValueKey('danmaku-$key'),
|
||||
value: v.clamp(r.$1.toDouble(), r.$2.toDouble()),
|
||||
min: r.$1.toDouble(),
|
||||
max: r.$2.toDouble(),
|
||||
divisions: divisions,
|
||||
onChanged: f,
|
||||
),
|
||||
);
|
||||
Widget toggle(String key, String label, bool v, ValueChanged<bool> f) => _Knob(
|
||||
label: label,
|
||||
labelWidth: double.infinity,
|
||||
child: Switch(key: ValueKey('danmaku-$key'), value: v, onChanged: f),
|
||||
);
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
slider('cross', dense ? 'Crossing' : 'Crossing time', s.crossSecs, l.crossSecs,
|
||||
'${s.crossSecs.toStringAsFixed(1)} s', (v) => _set((s) => s.copyWith(crossSecs: v))),
|
||||
slider('cap', dense ? 'At most' : 'At most on screen', s.maxOnScreen.toDouble(), l.maxOnScreen,
|
||||
'${s.maxOnScreen} lines', (v) => _set((s) => s.copyWith(maxOnScreen: v.round())),
|
||||
divisions: l.maxOnScreen.$2 - l.maxOnScreen.$1),
|
||||
_Knob(
|
||||
label: dense ? 'Lanes' : 'Lanes (from the top)',
|
||||
labelWidth: labelW,
|
||||
readout: '${(s.bandTop * 100).round()}–${(s.bandBottom * 100).round()} %',
|
||||
child: RangeSlider(
|
||||
key: const ValueKey('danmaku-band'),
|
||||
values: RangeValues(s.bandTop, s.bandBottom),
|
||||
min: l.band.$1,
|
||||
max: l.band.$2,
|
||||
onChanged: (v) {
|
||||
if (v.end - v.start >= l.minBand) _set((s) => s.copyWith(bandTop: v.start, bandBottom: v.end));
|
||||
},
|
||||
),
|
||||
),
|
||||
slider('opacity', 'Opacity', s.opacity, l.opacity, '${(s.opacity * 100).round()} %',
|
||||
(v) => _set((s) => s.copyWith(opacity: v))),
|
||||
slider('size', 'Size', s.size, l.size, '×${s.size.toStringAsFixed(2)}', (v) => _set((s) => s.copyWith(size: v))),
|
||||
slider('weight', 'Boldness', s.weight.toDouble(), l.weight, danmakuWeightNames[s.weight] ?? '${s.weight}',
|
||||
(v) => _set((s) => s.copyWith(weight: (v / 100).round() * 100)),
|
||||
divisions: (l.weight.$2 - l.weight.$1) ~/ 100),
|
||||
slider('outline', 'Outline', s.outline, l.outline,
|
||||
s.outline < 0.05 ? 'none' : '${s.outline.toStringAsFixed(1)} px', (v) => _set((s) => s.copyWith(outline: v))),
|
||||
toggle('avatars', 'Avatars before the lines', s.avatars, (v) => _set((s) => s.copyWith(avatars: v))),
|
||||
toggle('own', 'Frame my own lines', s.highlightOwn, (v) => _set((s) => s.copyWith(highlightOwn: v))),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: TextButton.icon(
|
||||
key: const ValueKey('danmaku-reset'),
|
||||
onPressed: s == const DanmakuStyle() ? null : () => settings.setDanmakuStyle(const DanmakuStyle()),
|
||||
icon: const Icon(Icons.restart_alt, size: 18),
|
||||
label: const Text('Reset look'),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class _Knob extends StatelessWidget {
|
||||
final String label;
|
||||
final double labelWidth;
|
||||
final String? readout;
|
||||
final Widget child;
|
||||
const _Knob({required this.label, required this.labelWidth, this.readout, required this.child});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final text = Text(label, style: const TextStyle(fontSize: 13));
|
||||
if (labelWidth == double.infinity) {
|
||||
return SizedBox(
|
||||
height: 38,
|
||||
child: Row(children: [Expanded(child: text), Transform.scale(scale: 0.8, alignment: Alignment.centerRight, child: child)]),
|
||||
);
|
||||
}
|
||||
return SizedBox(
|
||||
height: 36,
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(width: labelWidth, child: text),
|
||||
Expanded(
|
||||
child: SliderTheme(
|
||||
data: SliderTheme.of(context).copyWith(
|
||||
trackHeight: 3,
|
||||
overlayShape: SliderComponentShape.noOverlay,
|
||||
thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 7),
|
||||
rangeThumbShape: const RoundRangeSliderThumbShape(enabledThumbRadius: 7),
|
||||
showValueIndicator: ShowValueIndicator.never,
|
||||
tickMarkShape: SliderTickMarkShape.noTickMark,
|
||||
),
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: 78,
|
||||
child: Text(
|
||||
readout ?? '',
|
||||
textAlign: TextAlign.right,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: scheme.onSurfaceVariant,
|
||||
fontFeatures: const [FontFeature.tabularFigures()],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../files/thumbnails.dart';
|
||||
import '../app/app_graph.dart';
|
||||
import 'single_instance.dart';
|
||||
|
||||
// The one way out of the app (close button, Alt+F4, Update and restart).
|
||||
// Order matters: every mpv player goes before the engine does (a live one
|
||||
@@ -21,10 +22,14 @@ bool get shuttingDown => _shuttingDown;
|
||||
/// The systems the close stops; `main` hands them over once.
|
||||
AppGraph? _graph;
|
||||
|
||||
/// Makes [graph] the one [shutDownApp] stops and returns the close
|
||||
/// request handler for the window.
|
||||
Future<void> Function() closeAppWith(AppGraph graph) {
|
||||
/// This app's claim on its profile folder (one app at a time).
|
||||
InstanceLock? _instance;
|
||||
|
||||
/// Makes [graph] the one [shutDownApp] stops (and [instance] the claim it
|
||||
/// gives up) and returns the close request handler for the window.
|
||||
Future<void> Function() closeAppWith(AppGraph graph, {InstanceLock? instance}) {
|
||||
_graph = graph;
|
||||
_instance = instance;
|
||||
return shutDownApp;
|
||||
}
|
||||
|
||||
@@ -38,6 +43,9 @@ Future<void> shutDownApp({Future<void> Function()? beforeQuit, bool restart = fa
|
||||
final graph = _graph;
|
||||
if (_shuttingDown || graph == null) return;
|
||||
_shuttingDown = true;
|
||||
// A start from now on waits for this app to end instead of handing its
|
||||
// link to it.
|
||||
_instance?.closing();
|
||||
final shell = graph.ports.shell;
|
||||
final total = Stopwatch()..start();
|
||||
await _step('window save', shell.saveWindow(), const Duration(milliseconds: 500));
|
||||
@@ -57,6 +65,8 @@ Future<void> shutDownApp({Future<void> Function()? beforeQuit, bool restart = fa
|
||||
// server: 3 s in all.
|
||||
await _step('leave', graph.navigation.leave(bye: !restart), const Duration(milliseconds: 2500));
|
||||
await _step('server stop', graph.yourServer.stop(), const Duration(milliseconds: 500));
|
||||
// Update and restart: the new binary claims the folder at once.
|
||||
if (restart) await _step('instance', _instance?.release(), const Duration(milliseconds: 500));
|
||||
if (beforeQuit != null) await _step('before quit', beforeQuit(), const Duration(seconds: 3));
|
||||
debugPrint('close: ready to quit after ${total.elapsedMilliseconds} ms');
|
||||
await shell.quit();
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
@@ -17,3 +19,41 @@ Future<void> requestWindowAttention() async {
|
||||
debugPrint('window attention: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// The desktop's activation token this process started with (Linux:
|
||||
/// `XDG_ACTIVATION_TOKEN` or `DESKTOP_STARTUP_ID`, kept by the runner
|
||||
/// before GTK used it); '' when none or elsewhere.
|
||||
Future<String> windowLaunchToken() async {
|
||||
if (!Platform.isLinux) return '';
|
||||
try {
|
||||
return await _window.invokeMethod<String>('launchToken') ?? '';
|
||||
} catch (_) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/// Linux: brings the window up with another start's activation token
|
||||
/// ([windowLaunchToken] of the start that handed over). False where the
|
||||
/// runner lacks it.
|
||||
Future<bool> presentWindow(String token) async {
|
||||
if (!Platform.isLinux) return false;
|
||||
try {
|
||||
await _window.invokeMethod<void>('present', token);
|
||||
return true;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Ends this process at once with no exit handlers (a second start that
|
||||
/// handed over; its window never showed). The runner does it; where it
|
||||
/// cannot, a kill.
|
||||
Future<Never> exitNow() async {
|
||||
try {
|
||||
await _window.invokeMethod<void>('exit');
|
||||
} catch (_) {}
|
||||
Process.killPid(pid, ProcessSignal.sigkill);
|
||||
// The kill lands before anything else runs.
|
||||
await Future<void>.delayed(const Duration(days: 1));
|
||||
throw StateError('unreachable');
|
||||
}
|
||||
@@ -98,10 +98,13 @@ class DesktopShell implements ShellPort {
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> focus() async {
|
||||
Future<void> focus({String activation = ''}) async {
|
||||
try {
|
||||
if (await windowManager.isMinimized()) await windowManager.restore();
|
||||
if (!await windowManager.isVisible()) await windowManager.show();
|
||||
// Linux: present with the token when there is one (else a
|
||||
// compositor only marks the window as wanting attention).
|
||||
if (activation.isNotEmpty && await presentWindow(activation)) return;
|
||||
await windowManager.focus();
|
||||
} catch (e) {
|
||||
debugPrint('window focus failed: $e');
|
||||
@@ -126,6 +129,15 @@ class DesktopShell implements ShellPort {
|
||||
return (files: files, text: text);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String?> readClipboardText() async {
|
||||
try {
|
||||
return await ClipboardX.readText();
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget dropTarget({
|
||||
Key? key,
|
||||
|
||||
@@ -75,6 +75,9 @@ class MediaKitHost implements PlayerHost {
|
||||
: _hwdecSetting = hwdecSetting ?? (() => AppSettings.instance.hwdec.peek());
|
||||
|
||||
final _live = <_MkPlayer>{};
|
||||
final _videos = <_MkVideo>{};
|
||||
// The size outputs render at ([setOutputSize]); null: the video's own.
|
||||
(int, int)? _outputSize;
|
||||
final _renderMode = signal(RenderMode.unknown);
|
||||
final _http = StreamController<String>.broadcast();
|
||||
final _throttle = LogThrottle(burst: 5);
|
||||
@@ -90,6 +93,16 @@ class MediaKitHost implements PlayerHost {
|
||||
@override
|
||||
ReadonlySignal<RenderMode> get renderMode => _renderMode;
|
||||
|
||||
@override
|
||||
void setOutputSize(int? width, int? height) {
|
||||
final size = width != null && height != null && width > 0 && height > 0 ? (width, height) : null;
|
||||
if (size == _outputSize) return;
|
||||
_outputSize = size;
|
||||
for (final v in _videos.toList()) {
|
||||
unawaited(v.applySize());
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<String> get httpErrors => _http.stream;
|
||||
|
||||
@@ -185,6 +198,9 @@ class MediaKitHost implements PlayerHost {
|
||||
// configured), which used to override SYNC_RELAY_HWDEC.
|
||||
final controller = VideoController(p.mk, configuration: VideoControllerConfiguration(hwdec: hwdec));
|
||||
final v = _MkVideo(p, controller);
|
||||
_videos.add(v);
|
||||
p._video = v;
|
||||
if (_outputSize != null) unawaited(v.applySize());
|
||||
// mpv gets `vo=libmpv` before the output's render context exists; a
|
||||
// media opened in between fails its video output ("No render context
|
||||
// set") and plays black. Opens wait for the output (seen on X11, where
|
||||
@@ -203,6 +219,20 @@ class MediaKitHost implements PlayerHost {
|
||||
// refused request makes ffmpeg retry and warn on every try); ffmpeg's
|
||||
// HTTP status to [httpErrors]; the GL init lines while probing.
|
||||
void _route(_MkPlayer p, PlayerLogLine log) {
|
||||
// Windows: the controller can be "ready" before mpv's render context
|
||||
// exists, and an open in that gap fails its video output ("No render
|
||||
// context set"). Open the same media again once, a moment later.
|
||||
if (log.prefix == 'cplayer' && log.text.contains('initializing the selected video_out')) {
|
||||
final last = p._lastOpen;
|
||||
if (last != null && !p._voRetried && !p._retired) {
|
||||
p._voRetried = true;
|
||||
_mpvLog.info('render: the video output was not up for the open; opening it again');
|
||||
unawaited(Future.delayed(const Duration(milliseconds: 500), () async {
|
||||
if (p._retired || !identical(p._lastOpen, last)) return;
|
||||
await p.mk.open(Media(last.url, httpHeaders: last.headers), play: last.play);
|
||||
}).catchError((Object e) => _mpvLog.warning('render: opening again failed: $e')));
|
||||
}
|
||||
}
|
||||
if (log.prefix == 'ffmpeg') {
|
||||
final http = RegExp(r'HTTP error \d{3}.*').firstMatch(log.text);
|
||||
if (http != null) _http.add(http[0]!.trim());
|
||||
@@ -326,6 +356,10 @@ class MediaKitHost implements PlayerHost {
|
||||
final p = player as _MkPlayer;
|
||||
if (!_live.remove(p)) return;
|
||||
p._retired = true;
|
||||
if (p._video case final v?) {
|
||||
_videos.remove(v);
|
||||
await v._paramsSub?.cancel();
|
||||
}
|
||||
await p._logSub?.cancel();
|
||||
try {
|
||||
await p.mk.dispose();
|
||||
@@ -390,6 +424,10 @@ class _MkPlayer implements HostedPlayer {
|
||||
bool _probing = false;
|
||||
bool _retired = false;
|
||||
Future<void>? _videoReady; // set once a video output is attached
|
||||
// The last open, for one retry when mpv's video output was not up yet.
|
||||
({String url, Map<String, String> headers, bool play})? _lastOpen;
|
||||
bool _voRetried = false;
|
||||
_MkVideo? _video;
|
||||
|
||||
dynamic get _mpv => mk.platform;
|
||||
|
||||
@@ -437,6 +475,8 @@ class _MkPlayer implements HostedPlayer {
|
||||
@override
|
||||
Future<void> open(String url, {Map<String, String> headers = const {}, bool play = false}) async {
|
||||
if (_videoReady case final ready?) await ready;
|
||||
_lastOpen = (url: url, headers: headers, play: play);
|
||||
_voRetried = false;
|
||||
// An empty map, not null: media_kit caches headers per URI.
|
||||
await mk.open(Media(url, httpHeaders: headers), play: play);
|
||||
}
|
||||
@@ -510,4 +550,56 @@ class _MkVideo implements HostedVideo {
|
||||
|
||||
@override
|
||||
Future<void> get ready => controller.platform.future;
|
||||
|
||||
// What this output was last set to: null with [_sized] the video's own
|
||||
// size again; nothing sent yet while [_sized] is false.
|
||||
(int, int)? _sent;
|
||||
bool _sized = false;
|
||||
StreamSubscription<VideoParams>? _paramsSub;
|
||||
Future<void> _sizeOp = Future.value();
|
||||
|
||||
/// Renders at the host's output size (or the video's own when it has
|
||||
/// none). media_kit's controller sets the output back to the video's
|
||||
/// size whenever the video's parameters change (a new file, a track
|
||||
/// with another size); the fixed size is sent again after it, under
|
||||
/// the controller's own lock so it lands last.
|
||||
Future<void> applySize({bool again = false}) => _sizeOp = _sizeOp.then((_) async {
|
||||
if (player.disposed) return;
|
||||
try {
|
||||
// NativeVideoController (Linux, Windows): not exported, reached
|
||||
// through its public fields.
|
||||
final dynamic native = await controller.platform.future;
|
||||
final view = player.host._outputSize;
|
||||
final params = player.mk.state.videoParams;
|
||||
final dw = params.dw ?? 0, dh = params.dh ?? 0;
|
||||
final want = view == null ? null : fitOutputSize(view, dw > 0 && dh > 0 ? (dw, dh) : null);
|
||||
if (_sized && want == _sent && !again) return;
|
||||
if (want == null) {
|
||||
if (!_sized) return; // never resized: already the video's size
|
||||
await native.setSize();
|
||||
} else {
|
||||
// setSize skips a size it believes is set; after media_kit's own
|
||||
// reset it is not.
|
||||
native.width = null;
|
||||
await native.setSize(width: want.$1, height: want.$2);
|
||||
}
|
||||
_sent = want;
|
||||
_sized = true;
|
||||
// The output takes its new size with the next frame mpv draws;
|
||||
// paused, none comes: a pan too small to see makes mpv redraw.
|
||||
if (!player.state.playing) {
|
||||
await player.setProperty('video-pan-x', '0.000001');
|
||||
await player.setProperty('video-pan-x', '0');
|
||||
}
|
||||
_paramsSub ??= player.mk.stream.videoParams.listen((params) async {
|
||||
if ((params.dw ?? 0) == 0 || _sent == null) return;
|
||||
try {
|
||||
await (native.lock.synchronized(() async {}) as Future);
|
||||
} catch (_) {}
|
||||
unawaited(applySize(again: true));
|
||||
});
|
||||
} catch (e) {
|
||||
debugPrint('video output size: $e');
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import 'dart:ffi';
|
||||
|
||||
import 'package:ffi/ffi.dart';
|
||||
|
||||
// The few Win32 calls the app makes itself (no package wraps them for
|
||||
// us without a new dependency). Only ever called on Windows.
|
||||
|
||||
/// HKEY_CURRENT_USER as the API takes it (sign-extended 0x80000001).
|
||||
const _hkeyCurrentUser = -0x7FFFFFFF;
|
||||
const _regSz = 1;
|
||||
const _rrfRtRegSz = 0x2;
|
||||
|
||||
typedef _AllowNative = Int32 Function(Uint32 pid);
|
||||
typedef _Allow = int Function(int pid);
|
||||
|
||||
/// Lets process [pid] take the foreground (a second start does this for
|
||||
/// the running app before handing over its link: Windows only lets a
|
||||
/// process that got the user's click bring a window up).
|
||||
void allowSetForegroundWindow(int pid) {
|
||||
final user32 = DynamicLibrary.open('user32.dll');
|
||||
user32.lookupFunction<_AllowNative, _Allow>('AllowSetForegroundWindow')(pid);
|
||||
}
|
||||
|
||||
typedef _SetNative = Int32 Function(
|
||||
IntPtr key, Pointer<Utf16> subKey, Pointer<Utf16> name, Uint32 type, Pointer<Void> data, Uint32 size);
|
||||
typedef _Set = int Function(
|
||||
int key, Pointer<Utf16> subKey, Pointer<Utf16> name, int type, Pointer<Void> data, int size);
|
||||
typedef _GetNative = Int32 Function(IntPtr key, Pointer<Utf16> subKey, Pointer<Utf16> name, Uint32 flags,
|
||||
Pointer<Uint32> type, Pointer<Void> data, Pointer<Uint32> size);
|
||||
typedef _Get = int Function(int key, Pointer<Utf16> subKey, Pointer<Utf16> name, int flags,
|
||||
Pointer<Uint32> type, Pointer<Void> data, Pointer<Uint32> size);
|
||||
|
||||
DynamicLibrary get _advapi => DynamicLibrary.open('advapi32.dll');
|
||||
|
||||
/// The string value [name] ('' = the key's default) of
|
||||
/// HKEY_CURRENT_USER\[subKey]; null when missing.
|
||||
String? readUserRegistryString(String subKey, String name) {
|
||||
final get = _advapi.lookupFunction<_GetNative, _Get>('RegGetValueW');
|
||||
return using((arena) {
|
||||
final key = subKey.toNativeUtf16(allocator: arena);
|
||||
final value = name.isEmpty ? nullptr : name.toNativeUtf16(allocator: arena);
|
||||
final size = arena<Uint32>()..value = 0;
|
||||
if (get(_hkeyCurrentUser, key, value, _rrfRtRegSz, nullptr, nullptr, size) != 0) return null;
|
||||
final data = arena<Uint16>(size.value ~/ 2 + 1);
|
||||
if (get(_hkeyCurrentUser, key, value, _rrfRtRegSz, nullptr, data.cast(), size) != 0) return null;
|
||||
return data.cast<Utf16>().toDartString();
|
||||
});
|
||||
}
|
||||
|
||||
/// Sets the string value [name] ('' = the key's default) of
|
||||
/// HKEY_CURRENT_USER\[subKey], creating the key; false when refused.
|
||||
bool writeUserRegistryString(String subKey, String name, String data) {
|
||||
final set = _advapi.lookupFunction<_SetNative, _Set>('RegSetKeyValueW');
|
||||
return using((arena) {
|
||||
final key = subKey.toNativeUtf16(allocator: arena);
|
||||
final value = name.isEmpty ? nullptr : name.toNativeUtf16(allocator: arena);
|
||||
final bytes = data.toNativeUtf16(allocator: arena);
|
||||
return set(_hkeyCurrentUser, key, value.cast(), _regSz, bytes.cast(), (data.length + 1) * 2) == 0;
|
||||
});
|
||||
}
|
||||
@@ -3,6 +3,8 @@ import 'dart:io';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:sync_relay_core/core.dart' show findInstallRoot;
|
||||
|
||||
import 'url_scheme.dart';
|
||||
|
||||
/// The app's id: GTK's application id, so the Wayland `app_id` the
|
||||
/// compositor matches to [desktopFileName] for the window's icon.
|
||||
const appId = 'dev.foxyz.starsync';
|
||||
@@ -18,10 +20,17 @@ const _iconSizes = [16, 32, 48, 64, 128, 256, 512];
|
||||
/// Wayland shows a generic icon. On start: write the `.desktop` file into
|
||||
/// `~/.local/share/applications` (Exec = this binary) and the icon into
|
||||
/// the hicolor theme, only when something changed (a moved install, a new
|
||||
/// icon). The entry and icons of [legacyAppId] go. Never in debug runs
|
||||
/// (unless [force], for tests); failures only log.
|
||||
Future<void> installDesktopEntry(
|
||||
{String? executable, String? dataHome, String? home, @visibleForTesting bool force = false}) async {
|
||||
/// icon). The entry and icons of [legacyAppId] go. The entry also takes
|
||||
/// `starsync://` links ([schemeMimeType], %u) and the app becomes their
|
||||
/// default handler ([registerScheme], default [registerLinuxScheme]).
|
||||
/// Never in debug runs (unless [force], for tests); failures only log.
|
||||
Future<void> installDesktopEntry({
|
||||
String? executable,
|
||||
String? dataHome,
|
||||
String? home,
|
||||
@visibleForTesting bool force = false,
|
||||
Future<void> Function(String desktopFile, String applicationsDir)? registerScheme,
|
||||
}) async {
|
||||
if (!Platform.isLinux || (kDebugMode && !force)) return;
|
||||
try {
|
||||
final exe = executable ?? Platform.resolvedExecutable;
|
||||
@@ -41,9 +50,11 @@ Future<void> installDesktopEntry(
|
||||
}
|
||||
final entry = desktopEntryText(exe);
|
||||
final file = File('$share/applications/$desktopFileName');
|
||||
if (file.existsSync() && file.readAsStringSync() == entry) return;
|
||||
await file.parent.create(recursive: true);
|
||||
await file.writeAsString(entry);
|
||||
if (!file.existsSync() || file.readAsStringSync() != entry) {
|
||||
await file.parent.create(recursive: true);
|
||||
await file.writeAsString(entry);
|
||||
}
|
||||
await (registerScheme ?? registerLinuxScheme)(desktopFileName, file.parent.path);
|
||||
} catch (e) {
|
||||
debugPrint('desktop entry: $e');
|
||||
}
|
||||
@@ -69,9 +80,11 @@ String desktopEntryText(String exe) => '''[Desktop Entry]
|
||||
Type=Application
|
||||
Name=Starsync
|
||||
Comment=Watch videos in sync with friends
|
||||
Exec="$exe"
|
||||
Exec="$exe" %u
|
||||
Icon=$appId
|
||||
StartupWMClass=$appId
|
||||
StartupNotify=true
|
||||
MimeType=$schemeMimeType;
|
||||
Categories=AudioVideo;Video;Player;Network;
|
||||
Terminal=false
|
||||
''';
|
||||
@@ -148,6 +148,13 @@ abstract interface class PlayerHost {
|
||||
/// start).
|
||||
HostedVideo video(HostedPlayer player, {bool probe = false});
|
||||
|
||||
/// The view's pixel size: every video output renders the picture fitted
|
||||
/// in it ([fitOutputSize]), so mpv scales the picture and draws
|
||||
/// subtitles at screen resolution (Settings → Player, "Sharp video").
|
||||
/// Null: each output at its video's own size. Applies to live outputs
|
||||
/// and to new ones once they are up.
|
||||
void setOutputSize(int? width, int? height);
|
||||
|
||||
/// How video renders, as last learned (kept after leaving).
|
||||
ReadonlySignal<RenderMode> get renderMode;
|
||||
|
||||
@@ -184,9 +191,39 @@ abstract interface class PlayerHost {
|
||||
int get live;
|
||||
}
|
||||
|
||||
/// [log] says ffmpeg's HTTP stream lost the file's size and so every
|
||||
/// seek on it fails ("Seek failed (to 3417735, size -38)": -38 is ENOSYS,
|
||||
/// what ffmpeg answers for the size once a reconnect's or a seek's request
|
||||
/// failed). Only a fresh open gets it back.
|
||||
bool mpvStreamLost(PlayerLogLine log) =>
|
||||
log.prefix == 'ffmpeg' && log.text.contains('Seek failed') && log.text.contains('size -38)');
|
||||
|
||||
/// [log] says the media could not be opened: before a [duration] is known,
|
||||
/// mpv's fatal "nothing to play" or a demuxer error.
|
||||
bool mpvLoadFailed(PlayerLogLine log, Duration duration) {
|
||||
return duration == Duration.zero &&
|
||||
((log.prefix == 'cplayer' && log.level == 'fatal') || (log.prefix == 'demux' && log.level == 'error'));
|
||||
}
|
||||
|
||||
/// The size a video output renders at for a [view] of that many pixels:
|
||||
/// the [video]'s picture (its display size, aspect included) fitted
|
||||
/// inside, no letterbox. The texture then has the picture's aspect, so
|
||||
/// while a resize settles Flutter scales the last one to the new view
|
||||
/// with no jump (a texture of the whole view would shrink inside its own
|
||||
/// black bars until the new one came). Drawn 1:1: the fitted side is the
|
||||
/// view's, the other is rounded down to the view's parity, so the
|
||||
/// picture sits on whole pixels when centred (a half-pixel offset would
|
||||
/// blur every line). Unknown video: the view.
|
||||
(int, int) fitOutputSize((int, int) view, (int, int)? video) {
|
||||
final (vw, vh) = view;
|
||||
if (video == null || video.$1 <= 0 || video.$2 <= 0) return view;
|
||||
final aspect = video.$1 / video.$2;
|
||||
int under(double x, int full) {
|
||||
var n = x.floor().clamp(1, full);
|
||||
if ((full - n).isOdd) n = n > 1 ? n - 1 : n;
|
||||
return n;
|
||||
}
|
||||
|
||||
if (vw / vh > aspect) return (under(vh * aspect, vw), vh); // pillarbox
|
||||
return (vw, under(vw / aspect, vh)); // letterbox (or exact)
|
||||
}
|
||||
@@ -43,8 +43,10 @@ abstract interface class ShellPort {
|
||||
Future<void> quit();
|
||||
|
||||
/// Brings the window up and gives it the focus (restored when
|
||||
/// minimised, shown when hidden): a click on a system notification.
|
||||
Future<void> focus();
|
||||
/// minimised, shown when hidden): a click on a system notification, a
|
||||
/// link handed over by a second start ([activation]: that start's
|
||||
/// desktop activation token, which lets the window take the focus).
|
||||
Future<void> focus({String activation = ''});
|
||||
|
||||
/// Points the desktop at the window when it is not focused (taskbar
|
||||
/// flash, urgency hint); nothing when it is.
|
||||
@@ -53,6 +55,10 @@ abstract interface class ShellPort {
|
||||
/// The clipboard's files, else its text (Ctrl+V outside a text field).
|
||||
Future<Paste> readPaste();
|
||||
|
||||
/// The clipboard's text only (the join prompt looks for an invite in
|
||||
/// it); null when there is none or it can't be read.
|
||||
Future<String?> readClipboardText();
|
||||
|
||||
/// [child] taking dropped files ([onDrop]: their paths and where they
|
||||
/// landed); [enable] off lets drops through.
|
||||
Widget dropTarget({
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/foundation.dart' show debugPrint, visibleForTesting;
|
||||
|
||||
/// What a second start hands to the running app: its arguments (a
|
||||
/// clicked `starsync://` link) and the desktop's activation token for
|
||||
/// bringing the window up (Linux: `XDG_ACTIVATION_TOKEN` /
|
||||
/// `DESKTOP_STARTUP_ID`; '' when none).
|
||||
typedef Handoff = ({List<String> args, String activation});
|
||||
|
||||
const _lockName = 'instance.lock';
|
||||
const _infoName = 'instance.json';
|
||||
|
||||
/// Lock paths this process holds: a second claim from the same process
|
||||
/// (tests) sees them held, as another process would.
|
||||
final _heldHere = <String>{};
|
||||
|
||||
/// One app per profile folder at a time. The first start holds an
|
||||
/// exclusive lock on `instance.lock` in [dir] (the OS drops it when the
|
||||
/// process ends, a crash too) and listens on a loopback port written to
|
||||
/// `instance.json` with a random token. A later start finds the lock
|
||||
/// held, sends its [Handoff] there and ends; the running app comes to
|
||||
/// the front and handles it ([handoffs]). Pure dart:io: the same on
|
||||
/// Linux (Wayland and X11) and Windows.
|
||||
class InstanceLock {
|
||||
InstanceLock._(this.dir, this._lock, this._server, this._token);
|
||||
|
||||
/// The profile folder (each `STARSYNC_PROFILE` has its own, so each is
|
||||
/// its own instance).
|
||||
final String dir;
|
||||
final RandomAccessFile? _lock;
|
||||
final ServerSocket? _server;
|
||||
final String _token;
|
||||
final _handoffs = StreamController<Handoff>();
|
||||
bool _closing = false;
|
||||
bool _released = false;
|
||||
|
||||
/// What later starts handed over, in order (buffered until listened to).
|
||||
Stream<Handoff> get handoffs => _handoffs.stream;
|
||||
|
||||
/// False when the lock could not be had nor the running app reached
|
||||
/// (this app runs anyway, as before single instance).
|
||||
bool get held => _lock != null;
|
||||
|
||||
/// Claims [dir] for this process, or hands [args] to the app that holds
|
||||
/// it: null when handed over (this start should end now). A holder
|
||||
/// that is closing (or not listening yet) is retried for [retryFor]
|
||||
/// ([closingWait] once it said it is closing, so a start during a close
|
||||
/// or an update's restart takes over after it). [beforeSend] gets the
|
||||
/// holder's process id first (Windows: allow it to take the
|
||||
/// foreground).
|
||||
static Future<InstanceLock?> claim(
|
||||
String dir, {
|
||||
List<String> args = const [],
|
||||
String activation = '',
|
||||
void Function(int pid)? beforeSend,
|
||||
Duration retryFor = const Duration(seconds: 5),
|
||||
Duration closingWait = const Duration(seconds: 20),
|
||||
Duration retryEvery = const Duration(milliseconds: 200),
|
||||
}) async {
|
||||
await Directory(dir).create(recursive: true);
|
||||
final started = DateTime.now();
|
||||
var closingSeen = false;
|
||||
while (true) {
|
||||
final lock = await _tryLock(dir);
|
||||
if (lock != null) return _listen(dir, lock);
|
||||
final answer = await _send(dir, (args: args, activation: activation), beforeSend);
|
||||
if (answer == _Answer.ok) return null;
|
||||
if (answer == _Answer.closing) closingSeen = true;
|
||||
final waited = DateTime.now().difference(started);
|
||||
if (waited > (closingSeen ? closingWait : retryFor)) {
|
||||
debugPrint('single instance: no lock and no answer after ${waited.inMilliseconds} ms; starting anyway');
|
||||
return InstanceLock._(dir, null, null, '');
|
||||
}
|
||||
await Future.delayed(retryEvery);
|
||||
}
|
||||
}
|
||||
|
||||
static Future<RandomAccessFile?> _tryLock(String dir) async {
|
||||
final path = '$dir${Platform.pathSeparator}$_lockName';
|
||||
if (_heldHere.contains(path)) return null;
|
||||
RandomAccessFile? raf;
|
||||
try {
|
||||
raf = await File(path).open(mode: FileMode.append);
|
||||
await raf.lock(FileLock.exclusive);
|
||||
_heldHere.add(path);
|
||||
return raf;
|
||||
} catch (_) {
|
||||
if (raf != null) await _quiet(raf.close());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static Future<InstanceLock> _listen(String dir, RandomAccessFile lock) async {
|
||||
final token = base64Url.encode([for (var i = 0; i < 18; i++) Random.secure().nextInt(256)]);
|
||||
ServerSocket? server;
|
||||
try {
|
||||
server = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0);
|
||||
await File('$dir${Platform.pathSeparator}$_infoName')
|
||||
.writeAsString(jsonEncode({'port': server.port, 'token': token, 'pid': pid}), flush: true);
|
||||
} catch (e) {
|
||||
debugPrint('single instance: cannot listen for later starts: $e');
|
||||
}
|
||||
final self = InstanceLock._(dir, lock, server, token);
|
||||
server?.listen(self._serve, onError: (Object e) => debugPrint('single instance: $e'));
|
||||
return self;
|
||||
}
|
||||
|
||||
Future<void> _serve(Socket socket) async {
|
||||
try {
|
||||
final line = await utf8.decoder
|
||||
.bind(socket)
|
||||
.transform(const LineSplitter())
|
||||
.first
|
||||
.timeout(const Duration(seconds: 3));
|
||||
if (line.length > 64 * 1024) throw const FormatException('too long');
|
||||
final j = jsonDecode(line);
|
||||
if (j is! Map || j['token'] != _token) {
|
||||
socket.writeln(jsonEncode({'ok': false}));
|
||||
} else if (_closing) {
|
||||
socket.writeln(jsonEncode({'closing': true}));
|
||||
} else {
|
||||
final args = j['args'];
|
||||
final activation = j['activation'];
|
||||
_handoffs.add((
|
||||
args: [if (args is List) for (final a in args) if (a is String) a],
|
||||
activation: activation is String ? activation : '',
|
||||
));
|
||||
socket.writeln(jsonEncode({'ok': true}));
|
||||
}
|
||||
await socket.flush();
|
||||
} catch (e) {
|
||||
debugPrint('single instance: a later start sent nothing usable (${e.runtimeType})');
|
||||
} finally {
|
||||
await _quiet(socket.close());
|
||||
}
|
||||
}
|
||||
|
||||
static Future<_Answer> _send(String dir, Handoff h, void Function(int pid)? beforeSend) async {
|
||||
Socket? socket;
|
||||
try {
|
||||
final info = jsonDecode(await File('$dir${Platform.pathSeparator}$_infoName').readAsString());
|
||||
final port = info is Map ? info['port'] : null;
|
||||
final token = info is Map ? info['token'] : null;
|
||||
final holder = info is Map ? info['pid'] : null;
|
||||
if (port is! int || token is! String) return _Answer.none;
|
||||
if (holder is int) beforeSend?.call(holder);
|
||||
socket = await Socket.connect(InternetAddress.loopbackIPv4, port, timeout: const Duration(seconds: 2));
|
||||
socket.writeln(jsonEncode({'token': token, 'args': h.args, 'activation': h.activation}));
|
||||
await socket.flush();
|
||||
final reply = await utf8.decoder
|
||||
.bind(socket)
|
||||
.transform(const LineSplitter())
|
||||
.first
|
||||
.timeout(const Duration(seconds: 3));
|
||||
final r = jsonDecode(reply);
|
||||
if (r is Map && r['ok'] == true) return _Answer.ok;
|
||||
if (r is Map && r['closing'] == true) return _Answer.closing;
|
||||
return _Answer.none;
|
||||
} catch (_) {
|
||||
// Not written yet, a stale one from a crash, or not listening yet.
|
||||
return _Answer.none;
|
||||
} finally {
|
||||
socket?.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
/// The app is closing: later starts are told so and wait for the lock
|
||||
/// instead of handing over to an app on its way out.
|
||||
void closing() => _closing = true;
|
||||
|
||||
/// Gives the folder up before this process ends (Update and restart:
|
||||
/// the new binary claims it at once).
|
||||
Future<void> release() async {
|
||||
if (_released) return;
|
||||
_released = true;
|
||||
_closing = true;
|
||||
await _server?.close();
|
||||
final lock = _lock;
|
||||
if (lock == null) return;
|
||||
try {
|
||||
await File('$dir${Platform.pathSeparator}$_infoName').delete();
|
||||
} catch (_) {}
|
||||
try {
|
||||
await lock.unlock();
|
||||
} catch (_) {}
|
||||
await _quiet(lock.close());
|
||||
_heldHere.remove('$dir${Platform.pathSeparator}$_lockName');
|
||||
unawaited(_handoffs.close());
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
int? get port => _server?.port;
|
||||
}
|
||||
|
||||
enum _Answer { ok, closing, none }
|
||||
|
||||
Future<void> _quiet(Future<Object?> f) async {
|
||||
try {
|
||||
await f;
|
||||
} catch (_) {}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:sync_relay_core/core.dart' show findInstallRoot;
|
||||
|
||||
import '../session/invite.dart' show inviteScheme;
|
||||
import 'desktop/windows_api.dart';
|
||||
|
||||
/// The MIME type desktops look up for a `starsync://` link (Linux).
|
||||
const schemeMimeType = 'x-scheme-handler/$inviteScheme';
|
||||
|
||||
/// HKCU\Software\Classes\starsync: what Windows opens a link with.
|
||||
const windowsSchemeKey = r'Software\Classes\' + inviteScheme;
|
||||
|
||||
/// The binary a link should start: `starsync.exe` next to an old-named
|
||||
/// copy (`watch_together.exe`, kept for 1.2.x updaters) when it is there,
|
||||
/// so links always start the current name.
|
||||
String schemeExecutable(String exe, {bool Function(String path)? exists}) {
|
||||
final has = exists ?? (p) => File(p).existsSync();
|
||||
final sep = exe.contains(r'\') ? r'\' : '/';
|
||||
final cut = exe.lastIndexOf(sep);
|
||||
final name = exe.substring(cut + 1).toLowerCase();
|
||||
if (name != 'watch_together.exe' && name != 'watch_together') return exe;
|
||||
final current = '${exe.substring(0, cut + 1)}starsync${name.endsWith('.exe') ? '.exe' : ''}';
|
||||
return has(current) ? current : exe;
|
||||
}
|
||||
|
||||
/// The registry values of the Windows link handler for [exe]: (key under
|
||||
/// HKEY_CURRENT_USER, value name ('' = default), data).
|
||||
List<(String, String, String)> windowsSchemeValues(String exe) => [
|
||||
(windowsSchemeKey, '', 'URL:Starsync invite'),
|
||||
(windowsSchemeKey, 'URL Protocol', ''),
|
||||
('$windowsSchemeKey\\DefaultIcon', '', '"$exe",0'),
|
||||
('$windowsSchemeKey\\shell\\open\\command', '', '"$exe" "%1"'),
|
||||
];
|
||||
|
||||
/// Windows: makes `starsync://` links start this install (per user, no
|
||||
/// admin: HKCU\Software\Classes), only when the command there is not this
|
||||
/// one already (first start, a moved install, an update from an old
|
||||
/// name). Release installs only (never a debug run); failures only log.
|
||||
Future<void> registerWindowsScheme({String? executable}) async {
|
||||
if (!Platform.isWindows || kDebugMode) return;
|
||||
try {
|
||||
final exe = schemeExecutable(executable ?? Platform.resolvedExecutable);
|
||||
if (findInstallRoot(exe, windows: true) == null) return;
|
||||
final values = windowsSchemeValues(exe);
|
||||
final command = values.last;
|
||||
if (readUserRegistryString(command.$1, command.$2) == command.$3) return;
|
||||
for (final (key, name, data) in values) {
|
||||
if (!writeUserRegistryString(key, name, data)) {
|
||||
debugPrint('url scheme: cannot write HKCU\\$key');
|
||||
return;
|
||||
}
|
||||
}
|
||||
debugPrint('url scheme: starsync:// links open $exe');
|
||||
} catch (e) {
|
||||
debugPrint('url scheme: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Linux: makes this app the default for [schemeMimeType] (xdg-mime,
|
||||
/// `~/.config/mimeapps.list`) unless it already is. [desktopFile]: the
|
||||
/// launcher entry's file name, which lists the type.
|
||||
Future<void> registerLinuxScheme(String desktopFile, String applicationsDir) async {
|
||||
try {
|
||||
final q = await Process.run('xdg-mime', ['query', 'default', schemeMimeType]);
|
||||
if (q.exitCode == 0 && '${q.stdout}'.trim() == desktopFile) return;
|
||||
final r = await Process.run('xdg-mime', ['default', desktopFile, schemeMimeType]);
|
||||
if (r.exitCode != 0) {
|
||||
debugPrint('url scheme: xdg-mime default failed: ${'${r.stderr}'.trim()}');
|
||||
return;
|
||||
}
|
||||
// The MIME cache some desktops read (missing on some systems: fine).
|
||||
try {
|
||||
await Process.run('update-desktop-database', ['-q', applicationsDir]);
|
||||
} catch (_) {}
|
||||
debugPrint('url scheme: starsync:// links open $desktopFile');
|
||||
} catch (e) {
|
||||
debugPrint('url scheme: $e');
|
||||
}
|
||||
}
|
||||
@@ -63,6 +63,10 @@ class PlaybackController {
|
||||
/// e.g. a CDN's 403), at most once per [beginOpen].
|
||||
void Function(String error)? onLoadError;
|
||||
|
||||
/// The current media's HTTP stream lost its size (see [mpvStreamLost]):
|
||||
/// at most once per player and media; the coordinator opens it again.
|
||||
void Function()? onStreamLost;
|
||||
|
||||
/// Seeks from the controls (seek bar, the video widget's own) go here
|
||||
/// instead of to mpv.
|
||||
void Function(Duration position)? onUserSeek;
|
||||
@@ -71,6 +75,7 @@ class PlaybackController {
|
||||
// load error was reported.
|
||||
String? _httpError;
|
||||
bool _loadErrorSent = false;
|
||||
bool _streamLostSent = false;
|
||||
|
||||
/// The current player. Only while [alive].
|
||||
HostedPlayer get player => _player!;
|
||||
@@ -144,6 +149,7 @@ class PlaybackController {
|
||||
_chapterJson = '';
|
||||
_httpError = null;
|
||||
_loadErrorSent = false;
|
||||
_streamLostSent = false;
|
||||
_shaders = '';
|
||||
_loaded = loaded && p.state.duration > Duration.zero;
|
||||
_opened = loaded;
|
||||
@@ -196,6 +202,7 @@ class PlaybackController {
|
||||
_chapterJson = '';
|
||||
_httpError = null;
|
||||
_loadErrorSent = false;
|
||||
_streamLostSent = false;
|
||||
_loaded = false;
|
||||
chapters.value = const [];
|
||||
tracks.value = const [];
|
||||
@@ -286,7 +293,14 @@ class PlaybackController {
|
||||
// error while shaders are on is a shader that failed to load or compile
|
||||
// (mpv then renders without it).
|
||||
void _onLog(PlayerLogLine log) {
|
||||
if (log.prefix == 'ffmpeg') return;
|
||||
if (log.prefix == 'ffmpeg') {
|
||||
if (!_streamLostSent && mpvStreamLost(log)) {
|
||||
_streamLostSent = true;
|
||||
_log.warning('the media stream lost its size, every seek fails: [ffmpeg] ${log.text.trim()}');
|
||||
onStreamLost?.call();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!_loadErrorSent && mpvLoadFailed(log, duration.peek())) {
|
||||
_loadErrorSent = true;
|
||||
final http = _httpError;
|
||||
|
||||
@@ -689,6 +689,9 @@ class QueueController {
|
||||
|
||||
// The warm relay this app feeds (`<queue id>|<token>`), started once.
|
||||
String _fedWarm = '';
|
||||
// The link whose warm reader is being set up: its preload waits for it
|
||||
// (it then reads that reader, not the site).
|
||||
String? _feedingLink;
|
||||
|
||||
/// Feeds the server's warm relay of Up next's first item from here when
|
||||
/// it names this member as its source (a link passed on, a file), or a
|
||||
@@ -712,15 +715,22 @@ class QueueController {
|
||||
coord.media.feeder.feedWarm(head.id, RelayMedia.single(FileRelayBytes(File(pick.path))));
|
||||
} else if (pick is LinkMedia) {
|
||||
_fedWarm = key;
|
||||
_feedingLink = pick.url;
|
||||
unawaited(() async {
|
||||
try {
|
||||
// The probe [_resolveWarm] keeps: no second yt-dlp run.
|
||||
final probe = await coord.ytDlp.probe(pick.url, keep: _warmKeep).timeout(const Duration(seconds: 60));
|
||||
final y = await coord.ytDlp.ytDlp;
|
||||
if (_fedWarm != key) return;
|
||||
coord.media.feeder.feedWarm(head.id, LinkRelayMedia(Uri.parse(pick.url), ytDlp: y, probe: probe));
|
||||
// Read once for the preload and the warm relay.
|
||||
await coord.media.feedWarmLink(head.id, pick.url, probe, y);
|
||||
} catch (e) {
|
||||
debugPrint('queue: feeding ${head.id}: $e');
|
||||
} finally {
|
||||
if (_feedingLink == pick.url) {
|
||||
_feedingLink = null;
|
||||
_syncPreload(coord.serverState.peek());
|
||||
}
|
||||
}
|
||||
}());
|
||||
}
|
||||
@@ -814,6 +824,7 @@ class QueueController {
|
||||
return;
|
||||
}
|
||||
if (!media.preloadEnabled) return;
|
||||
if (want.url == _feedingLink) return;
|
||||
final key = '${want.url}|${want.headers}';
|
||||
final fresh = _preloadFresh;
|
||||
// Asked for already: in flight, there, or failed (tried again with the
|
||||
|
||||
@@ -6,6 +6,8 @@ import '../../shell/mark_problem.dart';
|
||||
import '../../app/app_graph.dart';
|
||||
import '../../state/settings.dart';
|
||||
import '../../update/update_controller.dart';
|
||||
import '../../update/whats_new.dart';
|
||||
import '../../widgets/whats_new_card.dart';
|
||||
import '../../widgets/app_menu.dart';
|
||||
import '../../widgets/copy_open.dart';
|
||||
import 'settings_page.dart';
|
||||
@@ -16,11 +18,40 @@ import 'update_indicator.dart';
|
||||
const uoscCredit = 'Player timeline and controls inspired by uosc by tomasklaen, LGPL-2.1 '
|
||||
'(github.com/tomasklaen/uosc).';
|
||||
|
||||
/// Name, version, update state, the source link and the logs (Settings →
|
||||
/// About).
|
||||
/// Dev builds only ([devTools]): previews and experiments not decided
|
||||
/// yet (DEVELOPMENT.md "Dev tools").
|
||||
class DeveloperPanel extends StatelessWidget {
|
||||
final VoidCallback? onDanmakuPreview;
|
||||
const DeveloperPanel({super.key, this.onDanmakuPreview});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = Theme.of(context).textTheme;
|
||||
return SettingsPanel(
|
||||
title: 'Developer',
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Test builds only: try what is not decided yet.', style: t.bodySmall?.copyWith(color: Colors.grey)),
|
||||
const SizedBox(height: 8),
|
||||
FilledButton.tonalIcon(
|
||||
key: const ValueKey('danmaku-preview'),
|
||||
onPressed: onDanmakuPreview,
|
||||
icon: const Icon(Icons.subtitles_outlined, size: 18),
|
||||
label: const Text('Danmaku preview'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Name, version, update state, What's new in this version, the source
|
||||
/// link and the logs (Settings → About).
|
||||
class AboutPanel extends StatelessWidget {
|
||||
final UpdateController? updates;
|
||||
const AboutPanel({super.key, this.updates});
|
||||
final WhatsNew? whatsNew;
|
||||
const AboutPanel({super.key, this.updates, this.whatsNew});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -36,6 +67,23 @@ class AboutPanel extends StatelessWidget {
|
||||
style: t.bodyMedium?.copyWith(color: Colors.grey)),
|
||||
const SizedBox(height: 4),
|
||||
UpdateStatusLine(controller: updates),
|
||||
// This version's notes, once kept (the install, a check).
|
||||
ValueListenableBuilder(
|
||||
valueListenable: (updates ?? AppScope.of(context).updates).latest,
|
||||
builder: (context, _, _) {
|
||||
final notes = (whatsNew ?? AppScope.of(context).whatsNew).current;
|
||||
if (notes == null) return const SizedBox.shrink();
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: FilledButton.tonalIcon(
|
||||
key: const ValueKey('about-whats-new'),
|
||||
onPressed: () => showWhatsNew(context, notes),
|
||||
icon: const Icon(Icons.auto_awesome, size: 18),
|
||||
label: Text("What's new in ${notes.version}"),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: kGap),
|
||||
Row(
|
||||
children: [
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:signals/signals_flutter.dart';
|
||||
import 'package:sync_relay_core/core.dart' show UpdateChannel, isDevBuild;
|
||||
|
||||
import '../../app/app_graph.dart';
|
||||
import '../../state/settings.dart';
|
||||
@@ -16,6 +17,7 @@ const sessionShortcuts = [
|
||||
('Shift+→', 'Skip 1:30 (for everyone)'),
|
||||
('PgUp / PgDn', 'Previous / next chapter'),
|
||||
('F', 'Toggle fullscreen'),
|
||||
('Enter', 'Quick chat box (Enter sends, Esc closes)'),
|
||||
('Tab', 'Open / close chat & activity'),
|
||||
('Ctrl+R', 'Reload the video'),
|
||||
('> or Shift+N', 'Put up the next Jellyfin episode'),
|
||||
@@ -194,6 +196,7 @@ class UpdateSettings extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
UpdateChannelSetting(controller: c, settings: s),
|
||||
SignalBuilder(
|
||||
builder: (context) => SwitchListTile(
|
||||
key: const ValueKey('auto-check-updates'),
|
||||
@@ -209,6 +212,75 @@ class UpdateSettings extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// Stable or Dev (test builds). Leaving Dev in a test build asks whether
|
||||
/// to go back to the latest release now when that one is older.
|
||||
class UpdateChannelSetting extends StatelessWidget {
|
||||
final UpdateController controller;
|
||||
final AppSettings settings;
|
||||
const UpdateChannelSetting({super.key, required this.controller, required this.settings});
|
||||
|
||||
Future<void> _choose(BuildContext context, UpdateChannel ch) async {
|
||||
final s = settings;
|
||||
if (ch == s.updateChannel.peek()) return;
|
||||
s.setUpdateChannel(ch);
|
||||
if (ch == UpdateChannel.stable && isDevBuild(controller.currentVersion)) {
|
||||
final m = await controller.stableToGoBackTo();
|
||||
if (m != null && context.mounted) {
|
||||
final back = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text('Go back to stable ${m.version}?'),
|
||||
content: Text('This is test build ${controller.currentVersion}, newer than the latest release. '
|
||||
'Going back installs ${m.version} and restarts the app. Or stay on this build: '
|
||||
'the next release newer than it is offered as usual.'),
|
||||
actions: [
|
||||
TextButton(
|
||||
key: const ValueKey('stay-on-test-build'),
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: const Text('Stay on this build'),
|
||||
),
|
||||
TextButton(
|
||||
key: const ValueKey('go-back-to-stable'),
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child: Text('Go back to ${m.version}'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (back == true) {
|
||||
await controller.goBackTo(m);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
await runUpdateCheck(controller, true);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final s = settings;
|
||||
return SignalBuilder(builder: (context) {
|
||||
final ch = s.updateChannel.value;
|
||||
return ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: const Text('Update channel'),
|
||||
subtitle: Text('On ${ch == UpdateChannel.dev ? 'Dev' : 'Stable'}, running ${controller.currentVersion}. '
|
||||
'${ch == UpdateChannel.dev ? 'Test builds and releases, whichever is newer; stays on Dev after a release' : 'Releases only'}'),
|
||||
trailing: SegmentedButton<UpdateChannel>(
|
||||
key: const ValueKey('update-channel'),
|
||||
showSelectedIcon: false,
|
||||
segments: const [
|
||||
ButtonSegment(value: UpdateChannel.stable, label: Text('Stable')),
|
||||
ButtonSegment(value: UpdateChannel.dev, label: Text('Dev')),
|
||||
],
|
||||
selected: {ch},
|
||||
onSelectionChanged: (sel) => _choose(context, sel.first),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class AutoAdvanceSetting extends StatelessWidget {
|
||||
const AutoAdvanceSetting({super.key});
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'package:flutter/foundation.dart' show kDebugMode;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:sync_relay_core/core.dart' show appVersion, isDevBuild;
|
||||
|
||||
@@ -26,6 +27,14 @@ String displayVersion({String version = appVersion, String pubspec = _pubspecVer
|
||||
/// Settings tab): "1.2.1", "1.2.1-dev.2", "dev run".
|
||||
String shortVersion({String version = appVersion}) => version == '0.0.0-dev' ? 'dev run' : version;
|
||||
|
||||
/// Dev tools (Settings → About → Developer, the experiments they switch
|
||||
/// on) only in test builds and runs from source (a `-dev` version) and
|
||||
/// debug runs, never in a release.
|
||||
bool devToolsFor({String version = appVersion, bool debug = kDebugMode}) => debug || version.contains('-dev');
|
||||
|
||||
/// [devToolsFor] this build.
|
||||
final bool devTools = devToolsFor();
|
||||
|
||||
const kRadius = 12.0;
|
||||
const kGap = 16.0;
|
||||
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:sync_relay_core/core.dart' show isDevBuild;
|
||||
import 'package:sync_relay_core/core.dart' show UpdateManifest, isDevBuild;
|
||||
|
||||
import '../../update/update_controller.dart';
|
||||
import '../../update/whats_new.dart';
|
||||
import '../../widgets/update_banner.dart';
|
||||
import '../../widgets/whats_new_card.dart';
|
||||
import 'ui.dart';
|
||||
|
||||
/// The band across the top of the window while an update is up for
|
||||
/// grabs: "Starsync 1.2.1 is ready", its notes, Update and restart and
|
||||
/// Later (gone until the next check or start). The shell keeps it off
|
||||
/// grabs: "Starsync 1.2.1 is ready", its first changes (or its one-line
|
||||
/// notes), What's new (the whole list, [showWhatsNew]), Update and restart
|
||||
/// and Later (gone until the next check or start). The shell keeps it off
|
||||
/// the full player while a video is on ([AppShell]).
|
||||
class UpdateBar extends StatelessWidget {
|
||||
final UpdateController? controller;
|
||||
@@ -31,6 +34,8 @@ class UpdateBar extends StatelessWidget {
|
||||
final behind = isDevBuild(current) ? null : 'You are running $current';
|
||||
final String title;
|
||||
String detail;
|
||||
// The offered version's first changes instead of its notes.
|
||||
var preview = false;
|
||||
switch (s.phase) {
|
||||
case UpdatePhase.downloading:
|
||||
title = 'Downloading $appName ${m.version}… ${((s.progress ?? 0) * 100).round()}%';
|
||||
@@ -51,9 +56,11 @@ class UpdateBar extends StatelessWidget {
|
||||
} else if (m.isRequired) {
|
||||
title = '$appName ${m.version} is required to join servers on it';
|
||||
detail = m.notes;
|
||||
preview = m.changes != null;
|
||||
} else {
|
||||
title = '$appName ${m.version} is ready';
|
||||
detail = m.notes;
|
||||
preview = m.changes != null;
|
||||
}
|
||||
detail = [?behind, detail].where((t) => t.isNotEmpty).join(' · ');
|
||||
}
|
||||
@@ -88,7 +95,9 @@ class UpdateBar extends StatelessWidget {
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(color: fg, fontWeight: FontWeight.w600)),
|
||||
if (detail.isNotEmpty)
|
||||
if (preview)
|
||||
WhatsNewPreview(changes: m.changes!, color: fg, lead: behind)
|
||||
else if (detail.isNotEmpty)
|
||||
Tooltip(
|
||||
message: detail,
|
||||
waitDuration: const Duration(milliseconds: 600),
|
||||
@@ -102,6 +111,14 @@ class UpdateBar extends StatelessWidget {
|
||||
),
|
||||
if (!busy) ...[
|
||||
const SizedBox(width: 12),
|
||||
if (m.changes != null || m.notes.trim().isNotEmpty)
|
||||
TextButton.icon(
|
||||
key: const ValueKey('update-whats-new'),
|
||||
style: TextButton.styleFrom(foregroundColor: fg),
|
||||
onPressed: () => _openWhatsNew(context, c, m, s.canInstall),
|
||||
icon: const Icon(Icons.auto_awesome, size: 16),
|
||||
label: const Text("What's new"),
|
||||
),
|
||||
if (!m.isRequired)
|
||||
TextButton(
|
||||
key: const ValueKey('update-later'),
|
||||
@@ -127,6 +144,27 @@ class UpdateBar extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// The offered version's card, with Update and restart when it installs here.
|
||||
void _openWhatsNew(BuildContext context, UpdateController c, UpdateManifest m, bool canInstall) {
|
||||
showWhatsNew(
|
||||
context,
|
||||
ReleaseNotes.of(m),
|
||||
overline: canInstall ? '$appName · ready to install' : '$appName · new release',
|
||||
action: canInstall
|
||||
? Builder(
|
||||
builder: (context) => FilledButton(
|
||||
key: const ValueKey('whats-new-install'),
|
||||
onPressed: () {
|
||||
Navigator.of(context).maybePop();
|
||||
c.install();
|
||||
},
|
||||
child: const Text('Update and restart'),
|
||||
),
|
||||
)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
/// The existing update card in a dialog; closes itself when the card
|
||||
/// would render nothing (e.g. "Later").
|
||||
Future<void> showUpdateDialog(BuildContext context, {UpdateController? controller}) {
|
||||
|
||||
@@ -2,6 +2,12 @@ import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart' show debugPrint;
|
||||
|
||||
/// The URL scheme of an invite link ([InviteLink]).
|
||||
const inviteScheme = 'starsync';
|
||||
|
||||
/// The port a link without one means (the app's default sharing port).
|
||||
const defaultInvitePort = 8080;
|
||||
|
||||
/// What a friend needs to join your server: where ([host], [port]), what
|
||||
/// it is called ([name]), whether it asks a password ([password]; the
|
||||
/// password itself is never in it) and which Starsync runs it
|
||||
@@ -26,13 +32,14 @@ class Invite {
|
||||
/// What goes in the Join card's address and port.
|
||||
String get address => '${host.contains(':') ? '[$host]' : host}:$port';
|
||||
|
||||
/// The text Invite copies: the address, whether to ask for the password,
|
||||
/// the version to match.
|
||||
String get text => [
|
||||
'Join me on Starsync: $address',
|
||||
if (password) '(ask me for the password)',
|
||||
if (version.isNotEmpty) '· Starsync $version',
|
||||
].join(' ');
|
||||
/// The same invite at another of this machine's addresses.
|
||||
Invite at(String host) =>
|
||||
Invite(host: host, port: port, name: name, password: password, version: version, fp: fp);
|
||||
|
||||
/// Its `starsync://` link with the server's password ([secret], the
|
||||
/// host's; empty when none is set): what Invite copies, nothing around
|
||||
/// it, so a friend just clicks it.
|
||||
InviteLink link({String secret = ''}) => InviteLink(host: host, port: port, name: name, secret: secret);
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
@@ -51,6 +58,97 @@ class Invite {
|
||||
String toString() => 'Invite($address${password ? ', password' : ''})';
|
||||
}
|
||||
|
||||
/// A `starsync://host:port/?name=…&pw=…` invite link (PROTOCOL.md "Invite
|
||||
/// link"): where to join, the server's name for the prompt, and the
|
||||
/// password when the server has one. [toString] never shows the
|
||||
/// password; [uri] does.
|
||||
class InviteLink {
|
||||
final String host;
|
||||
final int port;
|
||||
final String name;
|
||||
final String secret;
|
||||
const InviteLink({required this.host, required this.port, this.name = '', this.secret = ''});
|
||||
|
||||
String get address => '${host.contains(':') ? '[$host]' : host}:$port';
|
||||
|
||||
/// One per server (for "already asked about this one").
|
||||
String get key => '${host.toLowerCase()}:$port';
|
||||
|
||||
/// The link itself (with the password, when it has one).
|
||||
String get uri {
|
||||
final query = [
|
||||
if (name.isNotEmpty) 'name=${Uri.encodeQueryComponent(name)}',
|
||||
if (secret.isNotEmpty) 'pw=${Uri.encodeQueryComponent(secret)}',
|
||||
];
|
||||
return '$inviteScheme://$address${query.isEmpty ? '' : '/?${query.join('&')}'}';
|
||||
}
|
||||
|
||||
/// Null unless [text] is a `starsync://` link with a host and a usable
|
||||
/// port (none means [defaultInvitePort]).
|
||||
static InviteLink? parse(String text) {
|
||||
final t = text.trim();
|
||||
if (!t.toLowerCase().startsWith('$inviteScheme://')) return null;
|
||||
final Uri u;
|
||||
try {
|
||||
u = Uri.parse(t);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
final host = u.host.trim();
|
||||
if (host.isEmpty || !_hostChars.hasMatch(host)) return null;
|
||||
final port = u.hasPort ? u.port : defaultInvitePort;
|
||||
if (port <= 0 || port > 65535) return null;
|
||||
final Map<String, String> q;
|
||||
try {
|
||||
q = u.queryParameters;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
return InviteLink(host: host, port: port, name: _cleanName(q['name'] ?? ''), secret: q['pw'] ?? '');
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
other is InviteLink && other.host == host && other.port == port && other.name == name && other.secret == secret;
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(host, port, name, secret);
|
||||
|
||||
@override
|
||||
String toString() => 'InviteLink($address${secret.isEmpty ? '' : ', password'})';
|
||||
}
|
||||
|
||||
final _hostChars = RegExp(r'^[A-Za-z0-9.:-]+$');
|
||||
final _control = RegExp(r'[\x00-\x1f\x7f]');
|
||||
|
||||
/// A link's name as shown in the prompt: no control characters, at most
|
||||
/// 64 characters.
|
||||
String _cleanName(String raw) {
|
||||
final n = raw.replaceAll(_control, '').trim();
|
||||
return n.length > 64 ? n.substring(0, 64) : n;
|
||||
}
|
||||
|
||||
final _linkIn = RegExp('$inviteScheme://[^\\s<>"\'`]+', caseSensitive: false);
|
||||
final _plainInvite = RegExp(r'Join me on Starsync:\s*(\S+)');
|
||||
|
||||
/// Only the start of a long text is searched (a clipboard can hold a book).
|
||||
const _searchLimit = 4096;
|
||||
|
||||
/// The invite in [text]: a `starsync://` link anywhere in it, else the
|
||||
/// address of a plain invite ("Join me on Starsync: 192.168.1.20:8080",
|
||||
/// what 1.3.x copied). Null for anything else, a bare address included.
|
||||
InviteLink? inviteIn(String text) {
|
||||
final t = text.length > _searchLimit ? text.substring(0, _searchLimit) : text;
|
||||
final m = _linkIn.firstMatch(t);
|
||||
if (m != null) return InviteLink.parse(m[0]!);
|
||||
final plain = _plainInvite.firstMatch(t);
|
||||
if (plain == null) return null;
|
||||
final a = addressIn(plain[1]!);
|
||||
final port = a == null ? null : int.tryParse(a.port);
|
||||
if (a == null || port == null || port <= 0 || port > 65535) return null;
|
||||
return InviteLink(host: a.host, port: port);
|
||||
}
|
||||
|
||||
/// The `host:port` in a pasted invite (or a bare address); null when
|
||||
/// there is none.
|
||||
({String host, String port})? addressIn(String text) {
|
||||
@@ -74,9 +172,48 @@ bool isFakeIp(List<int> b) => b[0] == 198 && (b[1] == 18 || b[1] == 19);
|
||||
bool _isPrivate(List<int> b) =>
|
||||
b[0] == 10 || (b[0] == 192 && b[1] == 168) || (b[0] == 172 && b[1] >= 16 && b[1] < 32);
|
||||
|
||||
/// Shared address space (100.64.0.0/10): mesh VPNs like Tailscale.
|
||||
/// Shared address space (100.64.0.0/10): mesh VPNs like Tailscale (and
|
||||
/// carrier-grade NAT).
|
||||
bool _isShared(List<int> b) => b[0] == 100 && b[1] >= 64 && b[1] < 128;
|
||||
|
||||
/// Where an invite address works.
|
||||
enum AddressKind {
|
||||
/// The internet: anyone, when the port is open to them.
|
||||
public,
|
||||
|
||||
/// The same home network.
|
||||
lan,
|
||||
|
||||
/// A mesh VPN (Tailscale and the like): everyone on it.
|
||||
vpn,
|
||||
}
|
||||
|
||||
/// [AddressKind] of an IPv4 address.
|
||||
AddressKind addressKind(String ip) {
|
||||
final a = InternetAddress.tryParse(ip);
|
||||
if (a == null || a.type != InternetAddressType.IPv4) return AddressKind.public;
|
||||
final b = a.rawAddress;
|
||||
if (_isPrivate(b)) return AddressKind.lan;
|
||||
if (_isShared(b)) return AddressKind.vpn;
|
||||
return AddressKind.public;
|
||||
}
|
||||
|
||||
/// [ip] is an IPv4 address the internet routes to this network: not
|
||||
/// private, shared, loopback, link-local, fake-IP, multicast or
|
||||
/// reserved (the documentation ranges pass: tests use them).
|
||||
bool isPublicIpv4(String ip) {
|
||||
final a = InternetAddress.tryParse(ip.trim());
|
||||
if (a == null || a.type != InternetAddressType.IPv4) return false;
|
||||
final b = a.rawAddress;
|
||||
return !(b[0] == 0 ||
|
||||
b[0] == 127 ||
|
||||
b[0] >= 224 ||
|
||||
(b[0] == 169 && b[1] == 254) ||
|
||||
_isPrivate(b) ||
|
||||
_isShared(b) ||
|
||||
isFakeIp(b));
|
||||
}
|
||||
|
||||
/// The addresses of [interfaces] a friend could dial, best first: IPv4,
|
||||
/// no loopback, link-local or fake-IP address, no container or VM bridge;
|
||||
/// home networks (192.168, then 10, then 172.16/12) first, then a mesh
|
||||
@@ -107,6 +244,36 @@ List<String> inviteAddresses(Iterable<({String name, InternetAddress address})>
|
||||
return {for (final (_, a) in indexed) a.address}.toList();
|
||||
}
|
||||
|
||||
/// One address an invite can carry and where it works.
|
||||
typedef InviteAddress = ({String host, AddressKind kind});
|
||||
|
||||
/// What the members card offers for a shared server: the internet
|
||||
/// address first when known ([public], from [lookUpPublicAddress]), then
|
||||
/// this machine's own ([local], [inviteAddresses] order). [forwarding]:
|
||||
/// the internet address is the router's, not this machine's, so friends
|
||||
/// elsewhere get in only when the port is forwarded to it.
|
||||
class InviteReach {
|
||||
final String? public;
|
||||
final List<String> local;
|
||||
/// The internet lookup is still under way.
|
||||
final bool looking;
|
||||
const InviteReach({this.public, this.local = const [], this.looking = false});
|
||||
|
||||
bool get forwarding => public != null && !local.contains(public);
|
||||
|
||||
/// Every address, best first, each once: the internet one, then this
|
||||
/// machine's.
|
||||
List<InviteAddress> get choices => [
|
||||
if (public != null) (host: public!, kind: AddressKind.public),
|
||||
for (final l in local)
|
||||
if (l != public) (host: l, kind: addressKind(l)),
|
||||
];
|
||||
|
||||
/// The address an invite carries unless the user picks another: the
|
||||
/// internet one when known, else the best of this machine's.
|
||||
String? get best => choices.firstOrNull?.host;
|
||||
}
|
||||
|
||||
/// This machine's interfaces as [inviteAddresses] takes them (empty when
|
||||
/// they can't be listed).
|
||||
Future<List<({String name, InternetAddress address})>> machineInterfaces() async {
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:signals/signals.dart';
|
||||
|
||||
import '../screens/home/health_probe.dart';
|
||||
import '../state/connect_prefs.dart';
|
||||
import 'invite.dart';
|
||||
import 'navigation.dart';
|
||||
import 'your_server.dart';
|
||||
|
||||
/// Where a join prompt came from.
|
||||
enum OfferSource {
|
||||
/// A clicked `starsync://` link (this app started with it, or a second
|
||||
/// start handed it over).
|
||||
link,
|
||||
|
||||
/// An invite found on the clipboard when the window came up.
|
||||
clipboard,
|
||||
}
|
||||
|
||||
/// A server the app offers to join: "Join Bob's server (1.2.3.4:8080)?".
|
||||
class JoinOffer {
|
||||
final InviteLink link;
|
||||
final OfferSource source;
|
||||
|
||||
/// Its `/health` once read (name, version gate); null while asking.
|
||||
final ServerHealth? health;
|
||||
const JoinOffer(this.link, this.source, {this.health});
|
||||
|
||||
JoinOffer withHealth(ServerHealth h) => JoinOffer(link, source, health: h);
|
||||
|
||||
/// The server's own name (`/health`), else the link's, else ''.
|
||||
String get name {
|
||||
final own = health?.label ?? '';
|
||||
return own.isNotEmpty ? own : link.name;
|
||||
}
|
||||
|
||||
/// "Join Bob's server (1.2.3.4:8080)?"
|
||||
String get question => name.isEmpty ? 'Join ${link.address}?' : 'Join $name (${link.address})?';
|
||||
|
||||
/// What `/health` said, in plain words: '' while asking.
|
||||
String get detail {
|
||||
final h = health;
|
||||
if (h == null) return '';
|
||||
if (!h.online) return "Can't reach it right now";
|
||||
if (h.needsUpdate) return versionGateText(h);
|
||||
return [
|
||||
if (h.version.isNotEmpty) 'Starsync ${h.version}',
|
||||
if (h.viewers > 0) '${h.viewers} watching',
|
||||
].join(' · ');
|
||||
}
|
||||
}
|
||||
|
||||
/// A fingerprint of [link] (password included) for "already asked": the
|
||||
/// link itself is never stored.
|
||||
String inviteFingerprint(InviteLink link) =>
|
||||
sha256.convert(utf8.encode('starsync-invite:${link.uri}')).toString().substring(0, 24);
|
||||
|
||||
/// The join prompt: a clicked invite link, or one found on the
|
||||
/// clipboard, becomes an [offer] (`Join <name> (host:port)?`) with the
|
||||
/// server's `/health`; [accept] hands back what to join, [dismiss] drops
|
||||
/// it. A clipboard invite is offered once: joined or dismissed, the same
|
||||
/// link is not offered again (a fingerprint is kept, never the link).
|
||||
/// Links to your own server, or to the one you are in, are not offered.
|
||||
/// Nothing here logs what the clipboard holds. Made by the composition
|
||||
/// root.
|
||||
class JoinOffers {
|
||||
JoinOffers({required this.navigation, required this.yourServer, required this.forms});
|
||||
|
||||
final Navigation navigation;
|
||||
final YourServer yourServer;
|
||||
final ConnectStore forms;
|
||||
|
||||
final offer = signal<JoinOffer?>(null);
|
||||
|
||||
/// The clipboard text last looked at (its hash): the same text is not
|
||||
/// parsed twice in a row.
|
||||
int? _lastClip;
|
||||
|
||||
/// The app's start arguments, or a second start's: the first
|
||||
/// `starsync://` link among them becomes an offer.
|
||||
void fromArgs(List<String> args) {
|
||||
for (final a in args) {
|
||||
final link = InviteLink.parse(a);
|
||||
if (link != null) {
|
||||
_offer(link, OfferSource.link);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The clipboard's text when the window came up ([text] null: none).
|
||||
void fromClipboard(String? text) {
|
||||
if (text == null || text.isEmpty) return;
|
||||
final h = text.hashCode;
|
||||
if (h == _lastClip) return;
|
||||
_lastClip = h;
|
||||
final link = inviteIn(text);
|
||||
if (link == null || forms.seenInvite(inviteFingerprint(link))) return;
|
||||
_offer(link, OfferSource.clipboard);
|
||||
}
|
||||
|
||||
/// This app put [text] on the clipboard (Copy invite): never offered
|
||||
/// back to it.
|
||||
void copied(String text) => _lastClip = text.hashCode;
|
||||
|
||||
void _offer(InviteLink link, OfferSource source) {
|
||||
if (yourServer.isOwn(link)) return;
|
||||
if (navigation.place.peek() == Place.friend && navigation.address.peek() == '${link.host}:${link.port}') return;
|
||||
final current = offer.peek();
|
||||
if (current != null && current.link == link) return;
|
||||
final o = JoinOffer(link, source);
|
||||
offer.value = o;
|
||||
unawaited(probeHealth(link.host, '${link.port}', navigation.healthGet).then((h) {
|
||||
if (identical(offer.peek(), o)) offer.value = o.withHealth(h);
|
||||
}));
|
||||
}
|
||||
|
||||
/// Not now: a clipboard invite is not offered again.
|
||||
Future<void> dismiss() async {
|
||||
final o = offer.peek();
|
||||
if (o == null) return;
|
||||
offer.value = null;
|
||||
if (o.source == OfferSource.clipboard) await forms.rememberInvite(inviteFingerprint(o.link));
|
||||
}
|
||||
|
||||
/// Join: the link to join (its password, else the one saved for that
|
||||
/// recent server); the invite is not offered again. Null when there
|
||||
/// was none.
|
||||
InviteLink? accept() {
|
||||
final o = offer.peek();
|
||||
if (o == null) return null;
|
||||
offer.value = null;
|
||||
unawaited(forms.rememberInvite(inviteFingerprint(o.link)));
|
||||
final l = o.link;
|
||||
if (l.secret.isNotEmpty) return l;
|
||||
return InviteLink(host: l.host, port: l.port, name: l.name, secret: forms.secretFor(l.host, l.port));
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import 'app_events.dart';
|
||||
import 'delivery.dart' show stateMediaRelay;
|
||||
import 'media_plan.dart';
|
||||
import 'relay_feeder.dart';
|
||||
import 'shared_links.dart';
|
||||
import 'switch_trace.dart';
|
||||
|
||||
export 'media_plan.dart';
|
||||
@@ -37,7 +38,9 @@ final _log = createLogger('session');
|
||||
// coordinator's report).
|
||||
//
|
||||
// Being a relay source ([RelayFeeder]) and the viewer's side of a source
|
||||
// that drops ([recover]) live here too.
|
||||
// that drops ([recover]) live here too. A link this member passes on is
|
||||
// read from the site once ([SharedLinks]): its player opens the shared
|
||||
// reader's loopback URL and the relay serves the same bytes.
|
||||
|
||||
/// What the coordinator does next after [MediaPipeline.switchTo]: a
|
||||
/// Jellyfin item resolves and opens through [MediaPipeline.openJellyfin]
|
||||
@@ -77,6 +80,10 @@ class MediaPipeline {
|
||||
final PlaybackController playback;
|
||||
final SwitchTrace trace;
|
||||
late final RelayFeeder feeder;
|
||||
|
||||
/// The links this member passes on, read once for its player and the
|
||||
/// relay.
|
||||
final shared = SharedLinks();
|
||||
MediaSession session;
|
||||
|
||||
/// The player could not open the current media (raw words: the
|
||||
@@ -98,7 +105,10 @@ class MediaPipeline {
|
||||
}) : playback = playback ?? PlaybackController(host),
|
||||
trace = trace ?? SwitchTrace(),
|
||||
session = session ?? MediaSession.none {
|
||||
feeder = RelayFeeder(server: () => this.session.server(), notify: (e) => notify(e));
|
||||
feeder = RelayFeeder(
|
||||
server: () => this.session.server(),
|
||||
notify: (e) => notify(e),
|
||||
onWarmEnded: (queueId) => shared.dropWarm(queueId));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ players
|
||||
@@ -202,6 +212,12 @@ class MediaPipeline {
|
||||
/// stream opened with headers (a Jellyfin item's, with its token). Throws
|
||||
/// a [ResolveFailure] in plain words, with the cookies used (D11).
|
||||
Future<_Target> _resolve(String url, Map<String, String>? headers, {bool fresh = false}) async {
|
||||
if (headers == null || headers.isEmpty) {
|
||||
// A link this member passes on: the shared reader's loopback URL
|
||||
// (it resolves an expired URL again itself, so [fresh] is moot).
|
||||
final link = await shared.playerFor(url);
|
||||
if (link != null) return _Target(link.source.playerUrl, const {}, subtitles: link.subtitles, shared: true);
|
||||
}
|
||||
if (!_isPage(url) || (headers != null && headers.isNotEmpty)) return _Target(url, headers ?? const {});
|
||||
final l = links;
|
||||
if (l == null) throw const ResolveFailure('no link resolver here');
|
||||
@@ -217,9 +233,24 @@ class MediaPipeline {
|
||||
throw ResolveFailure(probe.ytDlpError ?? (probe.info == null ? 'Not a video page yt-dlp knows' : 'no format a player opens'),
|
||||
cookies: probe.cookies);
|
||||
}
|
||||
if (headers == null || headers.isEmpty) {
|
||||
// Played here only: through a reader of its own too, which resumes
|
||||
// after a cut and gets a fresh address (a CDN cuts mpv's reconnects
|
||||
// off). Pieces tracks, or a reader that cannot open: the site as is.
|
||||
final y = await l.ytDlp;
|
||||
final local =
|
||||
await shared.local(url, probe, () => LinkRelayMedia(Uri.parse(url), ytDlp: y, probe: probe), fresh: fresh);
|
||||
if (local != null) {
|
||||
return _Target(local.source.playerUrl, const {}, subtitles: local.subtitles, shared: true, local: true);
|
||||
}
|
||||
}
|
||||
return _Target(play.mpvUrl, {...play.headers, ...?headers}, subtitles: play.subtitles);
|
||||
}
|
||||
|
||||
/// The [SharedLinks.local] readers the player and the preload use stay;
|
||||
/// the rest close.
|
||||
void _trimLocal() => shared.trimLocal({_openUrl, _pre?.url});
|
||||
|
||||
/// The page's subtitles on [p] once its media is in (not selected: the
|
||||
/// usual choice applies).
|
||||
Future<void> _addSubtitles(HostedPlayer p, List<YtDlpSubtitle> subs) async {
|
||||
@@ -287,6 +318,7 @@ class MediaPipeline {
|
||||
_openHeaders = headers;
|
||||
_openResolve = resolve;
|
||||
_opening = true;
|
||||
_trimLocal();
|
||||
try {
|
||||
trace.mark(mediaId, SwitchTrace.open);
|
||||
final promoted = await _openInPlayer(url, headers,
|
||||
@@ -343,6 +375,8 @@ class MediaPipeline {
|
||||
};
|
||||
}
|
||||
if (headers != null && headers.isNotEmpty) return 'Jellyfin, own sign-in';
|
||||
if (shared.has(url)) return 'link passed on, read once here';
|
||||
if (shared.hasLocal(url)) return 'link, read here with resume';
|
||||
return 'link, fetched here';
|
||||
}
|
||||
|
||||
@@ -398,6 +432,7 @@ class MediaPipeline {
|
||||
final name = (r.media?['name'] as String?) ?? '';
|
||||
final queueId = stateMediaRelay(r)?.queueId ?? '';
|
||||
final duration = (r.media?['duration'] as num?)?.toDouble() ?? 0;
|
||||
if (source is! OwnLink) await shared.closeCurrent();
|
||||
switch (source) {
|
||||
case JellyfinSource():
|
||||
return SwitchJellyfin(source);
|
||||
@@ -407,8 +442,11 @@ class MediaPipeline {
|
||||
announce: true, queueId: queueId, name: name, duration: duration);
|
||||
await open(path);
|
||||
case OwnLink(:final url, :final origin):
|
||||
feeder.serve(r.mediaId, LinkRelayMedia(Uri.parse(url), ytDlp: origin.ytDlp, probe: origin.probe),
|
||||
announce: true, queueId: queueId, name: name, duration: duration);
|
||||
// Read from the site once: the relay serves what the player reads
|
||||
// (pieces tracks keep the two fetches).
|
||||
RelayMedia link() => LinkRelayMedia(Uri.parse(url), ytDlp: origin.ytDlp, probe: origin.probe);
|
||||
final one = await shared.current(url, origin.probe, link, queueId: queueId);
|
||||
feeder.serve(r.mediaId, one?.upload ?? link(), announce: true, queueId: queueId, name: name, duration: duration);
|
||||
await open(url);
|
||||
case RelayedSource(:final url, :final waiting, :final fetch, :final sourceId):
|
||||
if (waiting) {
|
||||
@@ -444,6 +482,7 @@ class MediaPipeline {
|
||||
_openUrl = null;
|
||||
_openHeaders = null;
|
||||
await playback.stop();
|
||||
_trimLocal();
|
||||
}
|
||||
|
||||
/// The same media came back as [r.mediaId] (a restarted server put back
|
||||
@@ -462,7 +501,8 @@ class MediaPipeline {
|
||||
feeder.serve(r.mediaId, RelayMedia.single(FileRelayBytes(File(path))),
|
||||
queueId: queueId, name: name, duration: duration);
|
||||
case OwnLink(:final url, :final origin):
|
||||
feeder.serve(r.mediaId, LinkRelayMedia(Uri.parse(url), ytDlp: origin.ytDlp, probe: origin.probe),
|
||||
feeder.serve(r.mediaId,
|
||||
shared.currentFor(url)?.upload ?? LinkRelayMedia(Uri.parse(url), ytDlp: origin.ytDlp, probe: origin.probe),
|
||||
queueId: queueId, name: name, duration: duration);
|
||||
case JellyfinSource(:final ref, :final origin):
|
||||
if (origin == null || ownJellyfin == null) return;
|
||||
@@ -524,6 +564,7 @@ class MediaPipeline {
|
||||
_openHeaders = null;
|
||||
_recovery = null;
|
||||
if (ready) await playback.stop();
|
||||
_trimLocal();
|
||||
}
|
||||
|
||||
/// The next state's media is new to this player: it opens again from
|
||||
@@ -538,6 +579,7 @@ class MediaPipeline {
|
||||
_activeOrigin = null;
|
||||
_jellyfinFeed = null;
|
||||
feeder.stopAll();
|
||||
unawaited(shared.closeAll());
|
||||
}
|
||||
|
||||
/// The same media under a restarted server's numbering: the player keeps
|
||||
@@ -558,6 +600,7 @@ class MediaPipeline {
|
||||
_jellyfinFeed = null;
|
||||
settledMediaId.value = -1;
|
||||
feeder.stopAll();
|
||||
unawaited(shared.closeAll());
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------- Jellyfin
|
||||
@@ -738,6 +781,24 @@ class MediaPipeline {
|
||||
String warmRelayUrl(QueueItem item, MediaRelay relay) =>
|
||||
session.server().relayUrl(item.media.name.isEmpty ? item.title : item.media.name, item.id, relay.tracks);
|
||||
|
||||
/// Feeds the warm relay of Up next item [queueId], the link [url] this
|
||||
/// member passes on, from one shared reader (pieces tracks: from their
|
||||
/// own fetch, as [LinkRelayMedia]). A preload of [url] that reads the
|
||||
/// site itself (it was prepared before the reader existed) is prepared
|
||||
/// again on the reader, so the item is fetched once.
|
||||
Future<void> feedWarmLink(String queueId, String url, LinkProbe probe, YtDlp ytDlp) async {
|
||||
if (feeder.feedsWarm(queueId)) return;
|
||||
RelayMedia link() => LinkRelayMedia(Uri.parse(url), ytDlp: ytDlp, probe: probe);
|
||||
final one = await shared.warm(queueId, url, probe, link);
|
||||
if (feeder.feedsWarm(queueId)) return;
|
||||
feeder.feedWarm(queueId, one?.upload ?? link());
|
||||
final pre = _pre;
|
||||
if (one != null && pre != null && pre.url == url && pre.headers.isEmpty && !pre.shared && !pre.failed) {
|
||||
_log.info('Up next ${logUrl(url)}: preparing it again on the shared reader');
|
||||
unawaited(prepare(url, fresh: true));
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens [url] early in a hidden player (paused at the start, muted, a
|
||||
/// small cache, its own video output when the view is up), replacing any
|
||||
/// other; completes once [need] seconds are buffered or the whole media
|
||||
@@ -752,8 +813,10 @@ class MediaPipeline {
|
||||
_pre = null;
|
||||
if (had != null) unawaited(_disposePrepared(had));
|
||||
final pre = _pre = _Prepared(url, headers ?? const {});
|
||||
_trimLocal();
|
||||
try {
|
||||
final target = await _resolve(url, headers, fresh: fresh);
|
||||
pre.shared = target.shared && !target.local;
|
||||
if (!identical(_pre, pre)) {
|
||||
pre.fail();
|
||||
return false;
|
||||
@@ -814,6 +877,7 @@ class MediaPipeline {
|
||||
await pre.logSub?.cancel();
|
||||
final p = pre.player;
|
||||
if (p != null) await host.dispose(p);
|
||||
if (!identical(_pre, pre)) _trimLocal();
|
||||
}
|
||||
|
||||
/// [pre] becomes the session player; its buffer comes along.
|
||||
@@ -840,6 +904,7 @@ class MediaPipeline {
|
||||
Future<void> dispose() async {
|
||||
await release();
|
||||
feeder.stopAll();
|
||||
await shared.closeAll();
|
||||
await playback.dispose();
|
||||
}
|
||||
}
|
||||
@@ -864,6 +929,9 @@ class _Prepared {
|
||||
StreamSubscription<PlayerLogLine>? logSub;
|
||||
List<YtDlpSubtitle> subtitles = const [];
|
||||
|
||||
/// It reads a shared reader's loopback URL, not the site.
|
||||
bool shared = false;
|
||||
|
||||
/// Completes once the media is handed to mpv (or it failed).
|
||||
final opened = Completer<void>();
|
||||
bool failed = false;
|
||||
@@ -885,7 +953,13 @@ class _Target {
|
||||
final String url;
|
||||
final Map<String, String> headers;
|
||||
final List<YtDlpSubtitle> subtitles;
|
||||
const _Target(this.url, this.headers, {this.subtitles = const []});
|
||||
|
||||
/// A link this member passes on, read through its shared reader.
|
||||
final bool shared;
|
||||
|
||||
/// Read through a [SharedLinks.local] reader (played here only).
|
||||
final bool local;
|
||||
const _Target(this.url, this.headers, {this.subtitles = const [], this.shared = false, this.local = false});
|
||||
}
|
||||
|
||||
/// A page could not be resolved: why ([message], yt-dlp's words) and which
|
||||
|
||||
@@ -25,6 +25,11 @@ class ToastItem {
|
||||
final bool chat;
|
||||
/// A picture hash to show (an Up next item's), '' when none.
|
||||
final String thumb;
|
||||
/// What it is about (the shell may show some of it another way:
|
||||
/// "Notifications as danmaku").
|
||||
final NoticeCategory category;
|
||||
/// This member's own (their chat message coming back from the server).
|
||||
final bool own;
|
||||
const ToastItem(
|
||||
{required this.id,
|
||||
required this.text,
|
||||
@@ -32,7 +37,9 @@ class ToastItem {
|
||||
this.actor,
|
||||
this.actorId = '',
|
||||
this.chat = false,
|
||||
this.thumb = ''});
|
||||
this.thumb = '',
|
||||
this.category = NoticeCategory.app,
|
||||
this.own = false});
|
||||
}
|
||||
|
||||
/// What a notice is about; the policy filters by it ([noticeFor]).
|
||||
@@ -98,12 +105,15 @@ class NoticeContext {
|
||||
final WindowActivity window;
|
||||
final bool playing;
|
||||
final NotifyPrefs prefs;
|
||||
/// The chat panel is open: chat is read there, not in pop-ups.
|
||||
final bool chatOpen;
|
||||
const NoticeContext({
|
||||
this.self,
|
||||
this.alone = false,
|
||||
this.window = WindowActivity.focused,
|
||||
this.playing = false,
|
||||
this.prefs = const NotifyPrefs(),
|
||||
this.chatOpen = false,
|
||||
});
|
||||
|
||||
bool get away => self == Presence.away;
|
||||
@@ -132,6 +142,8 @@ class Notice {
|
||||
final bool chat;
|
||||
/// A picture hash for the toast (an Up next item's).
|
||||
final String thumb;
|
||||
/// This member is the actor (a feed line of its own).
|
||||
final bool own;
|
||||
final SfxCue? sound;
|
||||
final bool attention;
|
||||
final SystemNote? system;
|
||||
@@ -143,6 +155,7 @@ class Notice {
|
||||
this.actor,
|
||||
this.chat = false,
|
||||
this.thumb = '',
|
||||
this.own = false,
|
||||
this.sound,
|
||||
this.attention = false,
|
||||
this.system,
|
||||
@@ -151,12 +164,13 @@ class Notice {
|
||||
|
||||
static const none = Notice();
|
||||
|
||||
Notice _with({bool toast = true, bool sound = true, SystemNote? system}) => Notice(
|
||||
Notice _with({bool toast = true, bool sound = true, SystemNote? system, bool own = false}) => Notice(
|
||||
category: category,
|
||||
toast: toast ? this.toast : null,
|
||||
actor: toast ? actor : null,
|
||||
chat: toast && chat,
|
||||
thumb: toast ? thumb : '',
|
||||
own: own,
|
||||
sound: sound ? this.sound : null,
|
||||
attention: attention,
|
||||
system: system,
|
||||
@@ -317,6 +331,8 @@ Notice noticeFor(AppEvent e, [NoticeContext c = const NoticeContext()]) {
|
||||
}
|
||||
if (cat == NoticeCategory.start && !c.away && !c.window.background) toast = false;
|
||||
if (cat == NoticeCategory.chat && !c.prefs.chat.shows(playing: c.playing)) toast = false;
|
||||
// The open chat shows it already; the sound still plays.
|
||||
if (cat == NoticeCategory.chat && c.chatOpen && !c.window.background) toast = false;
|
||||
SystemNote? system;
|
||||
if (toast && !own && c.window.background && cat.system && cat.reachesAway(c.prefs)) {
|
||||
final actor = n.actor;
|
||||
@@ -329,7 +345,7 @@ Notice noticeFor(AppEvent e, [NoticeContext c = const NoticeContext()]) {
|
||||
payload: cat == NoticeCategory.chat ? SystemNote.payloadChat : '',
|
||||
);
|
||||
}
|
||||
return n._with(toast: toast, sound: sound, system: system);
|
||||
return n._with(toast: toast, sound: sound, system: system, own: own);
|
||||
}
|
||||
|
||||
/// The one way news reaches the user: [notify] turns an [AppEvent] into
|
||||
@@ -351,6 +367,9 @@ class Notifier {
|
||||
/// The window's activity ([ShellPort.watchActivity], wired in `main`).
|
||||
WindowActivity window = WindowActivity.focused;
|
||||
|
||||
/// The chat panel is open (set by the shell).
|
||||
bool chatOpen = false;
|
||||
|
||||
Notifier({
|
||||
this.sfx = const NoopSfxService(),
|
||||
Future<void> Function()? attention,
|
||||
@@ -379,7 +398,8 @@ class Notifier {
|
||||
|
||||
NoticeContext get context {
|
||||
final s = session();
|
||||
return NoticeContext(self: s.self, alone: s.alone, window: window, playing: s.playing, prefs: prefs());
|
||||
return NoticeContext(
|
||||
self: s.self, alone: s.alone, window: window, playing: s.playing, prefs: prefs(), chatOpen: chatOpen);
|
||||
}
|
||||
|
||||
void notify(AppEvent e) {
|
||||
@@ -401,7 +421,9 @@ class Notifier {
|
||||
actor: n.actor,
|
||||
actorId: n.actor == null ? '' : n.log?.actorId ?? '',
|
||||
chat: n.chat,
|
||||
thumb: n.thumb),
|
||||
thumb: n.thumb,
|
||||
category: n.category,
|
||||
own: n.own),
|
||||
];
|
||||
_timers[id] = Timer(toastLife, () => dismiss(id));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart' show debugPrint;
|
||||
|
||||
import 'invite.dart' show isPublicIpv4;
|
||||
|
||||
/// Fetches a URL's body as text; injectable so tests fake the services.
|
||||
typedef TextGet = Future<String> Function(Uri uri);
|
||||
|
||||
/// "What is my IP" services, asked in order until one answers with a
|
||||
/// public IPv4 address: each answers the plain address over HTTPS.
|
||||
final publicAddressServices = [
|
||||
Uri.parse('https://api.ipify.org'),
|
||||
Uri.parse('https://ipv4.icanhazip.com'),
|
||||
Uri.parse('https://checkip.amazonaws.com'),
|
||||
Uri.parse('https://ifconfig.me/ip'),
|
||||
];
|
||||
|
||||
const _eachTimeout = Duration(seconds: 3);
|
||||
|
||||
/// This network's internet (IPv4) address as the [services] see it: the
|
||||
/// first that answers with a public address wins; a failing, slow or odd
|
||||
/// one is skipped. Null when none does (offline, all blocked).
|
||||
Future<String?> lookUpPublicAddress({
|
||||
TextGet get = defaultTextGet,
|
||||
List<Uri>? services,
|
||||
Duration each = _eachTimeout,
|
||||
}) async {
|
||||
for (final uri in services ?? publicAddressServices) {
|
||||
try {
|
||||
final body = (await get(uri).timeout(each)).trim();
|
||||
// Some answer "ip\n", a few with a label: take the first word.
|
||||
final ip = body.split(RegExp(r'\s+')).first;
|
||||
if (isPublicIpv4(ip)) return ip;
|
||||
debugPrint('public address: ${uri.host} gave no public IPv4 address');
|
||||
} catch (e) {
|
||||
debugPrint('public address: ${uri.host} failed: ${e.runtimeType}');
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Plain dart:io GET of a small text body; non-200 throws.
|
||||
Future<String> defaultTextGet(Uri uri) async {
|
||||
final client = HttpClient()
|
||||
..connectionTimeout = _eachTimeout
|
||||
..userAgent = 'Starsync';
|
||||
try {
|
||||
final req = await client.getUrl(uri);
|
||||
final res = await req.close().timeout(_eachTimeout);
|
||||
if (res.statusCode != 200) {
|
||||
await res.drain<void>();
|
||||
throw HttpException('status ${res.statusCode}', uri: uri);
|
||||
}
|
||||
final body = StringBuffer();
|
||||
await for (final chunk in res.transform(utf8.decoder).timeout(_eachTimeout)) {
|
||||
body.write(chunk);
|
||||
if (body.length > 256) break; // an address, not a page
|
||||
}
|
||||
return body.toString();
|
||||
} finally {
|
||||
client.close(force: true);
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,12 @@ import 'party.dart' show isPresent;
|
||||
class RelayFeeder {
|
||||
final ServerAddress Function() server;
|
||||
final void Function(AppEvent event) notify;
|
||||
RelayFeeder({required this.server, required this.notify});
|
||||
|
||||
/// The warm relay of Up next item `queueId` stopped without being
|
||||
/// carried on as the media's (the server dropped it, or [stopWarm]).
|
||||
final void Function(String queueId) onWarmEnded;
|
||||
RelayFeeder({required this.server, required this.notify, void Function(String queueId)? onWarmEnded})
|
||||
: onWarmEnded = onWarmEnded ?? ((_) {});
|
||||
|
||||
RelaySource? _source;
|
||||
int _mediaId = -1;
|
||||
@@ -33,6 +38,9 @@ class RelayFeeder {
|
||||
/// relay and has opened them (empty otherwise).
|
||||
List<RelayTrack> warmTracks(String queueId) => _warm?.queueId == queueId ? _warm!.src.tracks : const [];
|
||||
|
||||
/// This member feeds the warm relay of Up next item [queueId].
|
||||
bool feedsWarm(String queueId) => _warm?.queueId == queueId;
|
||||
|
||||
/// Serves [media] for media [mediaId] on this member's relay channel.
|
||||
/// The server only pulls while someone watches through it. When the
|
||||
/// media came from Up next item [queueId] whose warm relay this member
|
||||
@@ -111,18 +119,26 @@ class RelayFeeder {
|
||||
await src.start();
|
||||
} catch (e) {
|
||||
debugPrint('warm relay source of $queueId failed: $e');
|
||||
if (identical(_warm?.src, src)) _warm = null;
|
||||
if (identical(_warm?.src, src)) {
|
||||
_warm = null;
|
||||
onWarmEnded(queueId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
await src.done;
|
||||
if (identical(_warm?.src, src)) _warm = null;
|
||||
if (identical(_warm?.src, src)) {
|
||||
_warm = null;
|
||||
onWarmEnded(queueId);
|
||||
}
|
||||
}());
|
||||
}
|
||||
|
||||
void stopWarm() {
|
||||
final w = _warm;
|
||||
_warm = null;
|
||||
if (w != null) unawaited(w.src.close());
|
||||
if (w == null) return;
|
||||
unawaited(w.src.close());
|
||||
onWarmEnded(w.queueId);
|
||||
}
|
||||
|
||||
/// Leave, or a media this member no longer serves.
|
||||
|
||||
@@ -38,6 +38,7 @@ import 'notifier.dart';
|
||||
import 'now_playing.dart';
|
||||
import 'now_playing_info.dart';
|
||||
import 'party.dart';
|
||||
import 'shared_links.dart';
|
||||
import 'status.dart';
|
||||
import 'switch_trace.dart';
|
||||
|
||||
@@ -426,6 +427,7 @@ class SessionCoordinator {
|
||||
if (_playerReady) return;
|
||||
try {
|
||||
playback.onLoadError = _setPlayerError;
|
||||
playback.onStreamLost = _streamLost;
|
||||
playback.onUserSeek = (d) => _userSeek(d.inMicroseconds / 1e6);
|
||||
_bufferSub ??= playback.buffered.subscribe((_) => _onBuffered());
|
||||
await media.start();
|
||||
@@ -479,6 +481,19 @@ class SessionCoordinator {
|
||||
}
|
||||
|
||||
bool _reloadingVideo = false;
|
||||
// The media whose lost stream was reloaded already (once per media).
|
||||
int _streamReloadedFor = -1;
|
||||
|
||||
/// The player's HTTP stream lost the file's size (an old server, a
|
||||
/// request that timed out): every seek on it would fail until a fresh
|
||||
/// open. "Reload video" once per media, as the user would.
|
||||
void _streamLost() {
|
||||
if (_streamReloadedFor == _mediaId || _mediaId < 0) return;
|
||||
_streamReloadedFor = _mediaId;
|
||||
_log.info('media $_mediaId: the stream lost its size; reloading the video once');
|
||||
unawaited(reloadVideo());
|
||||
}
|
||||
|
||||
void Function()? _bufferSub;
|
||||
|
||||
/// "Reload video" (button, Ctrl+R, status menu): fresh
|
||||
@@ -1265,11 +1280,21 @@ class SessionCoordinator {
|
||||
// Up next's item this app already feeds (its warm relay): read.
|
||||
final warm = queueId == null ? const <RelayTrack>[] : this.media.feeder.warmTracks(queueId);
|
||||
final fed = warm.isNotEmpty;
|
||||
final check = fed ? null : LinkRelayMedia(uri, ytDlp: y, probe: probe);
|
||||
// Read once for the player and the relay (SharedLinks): the check's
|
||||
// reader is the one the media then uses. Pieces tracks: a check of
|
||||
// their own.
|
||||
final share = !fed && SharedLinks.fits(probe);
|
||||
final check = fed || share ? null : LinkRelayMedia(uri, ytDlp: y, probe: probe);
|
||||
var size = 0;
|
||||
final List<RelayTrackSpec> specs;
|
||||
try {
|
||||
final tracks = fed ? warm : await check!.open().timeout(const Duration(seconds: 15));
|
||||
final tracks = fed
|
||||
? warm
|
||||
: share
|
||||
? await (await this.media.shared.forSwap(url, LinkRelayMedia(uri, ytDlp: y, probe: probe), probe))
|
||||
.upload
|
||||
.open()
|
||||
: await check!.open().timeout(const Duration(seconds: 15));
|
||||
for (final t in tracks) {
|
||||
// A playlist is read (DRM and live refused) but has no size.
|
||||
final pieces = t.pieces;
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
import 'package:sync_relay_server/server.dart' show SharedSource;
|
||||
|
||||
final _log = createLogger('session');
|
||||
|
||||
/// A link this member passes on, read from the site once ([SharedSource]):
|
||||
/// its page URL, the reader, and the page's subtitles for the player.
|
||||
class SharedLink {
|
||||
final String url;
|
||||
final SharedSource source;
|
||||
final List<YtDlpSubtitle> subtitles;
|
||||
SharedLink(this.url, this.source, {this.subtitles = const []});
|
||||
}
|
||||
|
||||
/// The links this member passes on and reads once, by page URL: the one a
|
||||
/// swap check read (until the swap's state takes it), Up next's warm one
|
||||
/// (until the swap that puts it up takes it), and the current media's.
|
||||
/// Each one's player opens [SharedSource.playerUrl] and its relay serves
|
||||
/// [SharedSource.upload]. A page with a pieces track (HLS/DASH) is not
|
||||
/// shared ([fits]): its player and relay keep their own fetches.
|
||||
/// Page links played here only ([local]) get a reader of their own too,
|
||||
/// for its resume after cuts.
|
||||
class SharedLinks {
|
||||
SharedLink? _current;
|
||||
({String queueId, SharedLink link})? _warm;
|
||||
SharedLink? _pending;
|
||||
Timer? _pendingTimer;
|
||||
|
||||
/// A link that can be shared: a media file, or a page whose relay tracks
|
||||
/// are all plain files (one, or a video and an audio).
|
||||
static bool fits(LinkProbe probe) =>
|
||||
probe.relayProblem == null && (!probe.isPage || (probe.info?.tracks.every((t) => !t.format.isPieces) ?? false));
|
||||
|
||||
static List<YtDlpSubtitle> _subs(LinkProbe? probe) => probe?.info?.play?.subtitles ?? const [];
|
||||
|
||||
/// The swap check of a link this member passes on: a shared source
|
||||
/// for [url] over [media], started (the tracks' sizes read). Kept for
|
||||
/// the swap's state ([current]) for a minute, then dropped.
|
||||
Future<SharedSource> forSwap(String url, RelayMedia media, LinkProbe probe,
|
||||
{Duration timeout = const Duration(seconds: 15)}) async {
|
||||
final s = SharedSource(media);
|
||||
try {
|
||||
await s.start().timeout(timeout);
|
||||
} catch (_) {
|
||||
unawaited(s.close());
|
||||
rethrow;
|
||||
}
|
||||
_dropPending();
|
||||
_pending = SharedLink(url, s, subtitles: _subs(probe));
|
||||
_pendingTimer = Timer(const Duration(seconds: 60), _dropPending);
|
||||
return s;
|
||||
}
|
||||
|
||||
void _dropPending() {
|
||||
_pendingTimer?.cancel();
|
||||
_pendingTimer = null;
|
||||
final p = _pending;
|
||||
_pending = null;
|
||||
if (p != null && !identical(p, _current)) unawaited(p.source.close());
|
||||
}
|
||||
|
||||
/// The current media's shared source for [url]: the one it has, else
|
||||
/// Up next's warm one for [queueId], else the swap check's, else a new
|
||||
/// one over [media] (opened, its sizes read meanwhile). Null when the
|
||||
/// link cannot be shared ([fits], or it does not open): the caller
|
||||
/// keeps today's two fetches. Any other current one is closed.
|
||||
Future<SharedSource?> current(String url, LinkProbe probe, RelayMedia Function() media,
|
||||
{String queueId = ''}) async {
|
||||
final had = _current;
|
||||
if (had != null && had.url == url) return had.source;
|
||||
await closeCurrent();
|
||||
SharedLink? link;
|
||||
final pending = _pending, warm = _warm;
|
||||
if (warm != null && warm.link.url == url && queueId.isNotEmpty && warm.queueId == queueId) {
|
||||
// The swap carries the warm relay's source on: the player reads the
|
||||
// reader that source serves.
|
||||
_warm = null;
|
||||
link = warm.link;
|
||||
if (pending != null && pending.url == url) _dropPending();
|
||||
} else if (pending != null && pending.url == url) {
|
||||
_pendingTimer?.cancel();
|
||||
_pending = null;
|
||||
link = pending;
|
||||
} else if (fits(probe)) {
|
||||
final s = SharedSource(media());
|
||||
try {
|
||||
await s.open();
|
||||
} catch (e) {
|
||||
_log.info('link ${logUrl(url)} not read once here: $e');
|
||||
unawaited(s.close());
|
||||
return null;
|
||||
}
|
||||
link = SharedLink(url, s, subtitles: _subs(probe));
|
||||
}
|
||||
_current = link;
|
||||
return link?.source;
|
||||
}
|
||||
|
||||
/// The current media's shared source when it reads [url].
|
||||
SharedSource? currentFor(String url) => _current?.url == url ? _current!.source : null;
|
||||
|
||||
/// Up next item [queueId]'s shared source for [url] (fed to its warm
|
||||
/// relay), made over [media] and opened; the one it has is kept. Null
|
||||
/// when the link cannot be shared.
|
||||
Future<SharedSource?> warm(String queueId, String url, LinkProbe probe, RelayMedia Function() media) async {
|
||||
final had = _warm;
|
||||
if (had != null && had.queueId == queueId && had.link.url == url) return had.link.source;
|
||||
dropWarm();
|
||||
if (!fits(probe)) return null;
|
||||
final s = SharedSource(media());
|
||||
final link = SharedLink(url, s, subtitles: _subs(probe));
|
||||
_warm = (queueId: queueId, link: link);
|
||||
try {
|
||||
await s.open();
|
||||
} catch (e) {
|
||||
_log.info('Up next ${logUrl(url)} not read once here: $e');
|
||||
if (identical(_warm?.link, link)) _warm = null;
|
||||
unawaited(s.close());
|
||||
return null;
|
||||
}
|
||||
return identical(_warm?.link, link) ? s : null;
|
||||
}
|
||||
|
||||
/// The warm one goes (its relay ended, the item is no longer first);
|
||||
/// with [queueId], only that item's.
|
||||
void dropWarm([String? queueId]) {
|
||||
final w = _warm;
|
||||
if (w == null || (queueId != null && w.queueId != queueId)) return;
|
||||
_warm = null;
|
||||
unawaited(w.link.source.close());
|
||||
}
|
||||
|
||||
/// What a player opens for [url] when it is shared here (the current
|
||||
/// media's or the warm one), opened: null otherwise.
|
||||
Future<SharedLink?> playerFor(String url) async {
|
||||
final link = _current?.url == url ? _current : (_warm?.link.url == url ? _warm!.link : null);
|
||||
if (link == null) return null;
|
||||
try {
|
||||
await link.source.open();
|
||||
return link;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// [url] is shared here (the current media's or the warm one).
|
||||
bool has(String url) => _current?.url == url || _warm?.link.url == url;
|
||||
|
||||
// Page links this app plays without passing them on (alone, or
|
||||
// everyone fetches it), by page URL: the session player's and the
|
||||
// preload's.
|
||||
final _local = <String, Future<SharedLink?>>{};
|
||||
|
||||
/// A page link played here only: read through its own [SharedSource]
|
||||
/// over [media] too, so the player gets the reader's resume after a cut,
|
||||
/// back-off and fresh address (403/410/412) instead of ffmpeg's
|
||||
/// reconnect, which a CDN like Bilibili cuts off for good. The one [url]
|
||||
/// has is kept unless [fresh]. Null when the page does not fit ([fits]:
|
||||
/// a pieces track) or the reader cannot open it: the player reads the
|
||||
/// site itself. [trimLocal] closes the ones no longer needed.
|
||||
Future<SharedLink?> local(String url, LinkProbe probe, RelayMedia Function() media,
|
||||
{bool fresh = false, Duration timeout = const Duration(seconds: 15)}) {
|
||||
final had = _local[url];
|
||||
if (had != null && !fresh) return had;
|
||||
if (had != null) _local.remove(url)!.then((l) => l?.source.close());
|
||||
if (!fits(probe)) return Future.value();
|
||||
Future<SharedLink?> make() async {
|
||||
final s = SharedSource(media());
|
||||
try {
|
||||
// The sizes too: a format URL the site refuses (403) fails here,
|
||||
// and the player then gets the site's own answer.
|
||||
await s.start().timeout(timeout);
|
||||
return SharedLink(url, s, subtitles: _subs(probe));
|
||||
} catch (e) {
|
||||
_log.info('link ${logUrl(url)} read by the player itself: $e');
|
||||
unawaited(s.close());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
final f = _local[url] = make();
|
||||
unawaited(f.then((l) {
|
||||
if (l == null && identical(_local[url], f)) _local.remove(url);
|
||||
}));
|
||||
return f;
|
||||
}
|
||||
|
||||
/// [url] is read here through a [local] reader.
|
||||
bool hasLocal(String url) => _local.containsKey(url);
|
||||
|
||||
/// Closes the [local] readers of every URL but [keep] (the player's and
|
||||
/// the preload's).
|
||||
void trimLocal(Set<String?> keep) {
|
||||
for (final url in [..._local.keys]) {
|
||||
if (keep.contains(url)) continue;
|
||||
unawaited(_local.remove(url)!.then((l) => l?.source.close()));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> closeCurrent() async {
|
||||
final c = _current;
|
||||
_current = null;
|
||||
if (c != null) await c.source.close();
|
||||
}
|
||||
|
||||
/// Leave, a new session: everything goes.
|
||||
Future<void> closeAll() async {
|
||||
_dropPending();
|
||||
dropWarm();
|
||||
trimLocal(const {});
|
||||
await closeCurrent();
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import '../jellyfin/session_media.dart' show relayCredentialsFor;
|
||||
import '../state/connect_prefs.dart';
|
||||
import '../state/server_isolate.dart';
|
||||
import 'invite.dart';
|
||||
import 'public_address.dart';
|
||||
|
||||
/// A fresh key per app run: this app's own client sends it in hello to be
|
||||
/// recognised as the host (`members[].host`; it also stands in for the
|
||||
@@ -44,8 +45,9 @@ Future<ServerIsolate> _startIsolate(
|
||||
/// port, so only this app reaches it; [invite] attaches a second
|
||||
/// listener on every interface at the configured port (Settings → Party
|
||||
/// & network: port and password) and [stopSharing] detaches it. Members,
|
||||
/// what plays and Up next never restart or reconnect for either. Made
|
||||
/// by the composition root.
|
||||
/// what plays and Up next never restart or reconnect for either. Sharing
|
||||
/// also looks up the internet address ([reach]). Made by the composition
|
||||
/// root.
|
||||
class YourServer {
|
||||
YourServer({
|
||||
required this.forms,
|
||||
@@ -54,10 +56,13 @@ class YourServer {
|
||||
String? Function()? logDir,
|
||||
ServerStarter? starter,
|
||||
Future<List<({String name, InternetAddress address})>> Function()? interfaces,
|
||||
Future<String?> Function()? publicAddress,
|
||||
this.publicWait = const Duration(seconds: 4),
|
||||
}) : _ytDlpDir = ytDlpDir ?? (() async => ''),
|
||||
_logDir = logDir ?? (() => null),
|
||||
_starter = starter ?? _startIsolate,
|
||||
_interfaces = interfaces ?? machineInterfaces;
|
||||
_interfaces = interfaces ?? machineInterfaces,
|
||||
_publicAddress = publicAddress ?? lookUpPublicAddress;
|
||||
|
||||
final ConnectStore forms;
|
||||
/// Linked Jellyfin servers (default: the app's): the relay logins.
|
||||
@@ -66,6 +71,18 @@ class YourServer {
|
||||
final String? Function() _logDir;
|
||||
final ServerStarter _starter;
|
||||
final Future<List<({String name, InternetAddress address})>> Function() _interfaces;
|
||||
final Future<String?> Function() _publicAddress;
|
||||
|
||||
/// How long [invite] waits for the internet address before it hands
|
||||
/// out a local one (the lookup goes on; [reach] and [shared] follow).
|
||||
final Duration publicWait;
|
||||
|
||||
/// Where friends can reach it while shared: the internet address (once
|
||||
/// looked up) and this machine's; null when not shared.
|
||||
final reach = signal<InviteReach?>(null);
|
||||
|
||||
/// What `/health` calls it ("Bob's server"), the invite's name.
|
||||
String _name = '';
|
||||
|
||||
/// Sent by this app's own client (see [newHostKey]).
|
||||
final String hostKey = newHostKey();
|
||||
@@ -81,6 +98,13 @@ class YourServer {
|
||||
/// An [invite] or [stopSharing] is under way.
|
||||
final busy = signal(false);
|
||||
|
||||
/// The password the server was shared with (empty: none).
|
||||
String _secret = '';
|
||||
|
||||
/// What Invite copies: [invite]'s bare `starsync://` link with the
|
||||
/// password the server was shared with, so a friend just clicks it.
|
||||
String inviteLink(Invite invite) => invite.link(secret: _secret).uri;
|
||||
|
||||
/// Set when the server died without being stopped; the shell says so
|
||||
/// and clears it. The next [start] makes a new one.
|
||||
final crash = signal<String?>(null);
|
||||
@@ -106,12 +130,13 @@ class YourServer {
|
||||
|
||||
Future<int> _start(String name) async {
|
||||
final who = name.trim();
|
||||
_name = who.isEmpty ? '' : "$who's server";
|
||||
late final ServerIsolate server;
|
||||
server = await _starter(
|
||||
AppConfig(
|
||||
port: 0,
|
||||
secret: '',
|
||||
name: who.isEmpty ? 'server' : "$who's server",
|
||||
name: who.isEmpty ? 'server' : _name,
|
||||
hostKey: hostKey,
|
||||
ytDlpDir: await _ytDlpDir(),
|
||||
),
|
||||
@@ -133,6 +158,7 @@ class YourServer {
|
||||
_server = null;
|
||||
port.value = null;
|
||||
shared.value = null;
|
||||
reach.value = null;
|
||||
final message = 'server crashed: $cause';
|
||||
debugPrint('[server] $message');
|
||||
crash.value = message;
|
||||
@@ -152,33 +178,77 @@ class YourServer {
|
||||
final form = forms.form;
|
||||
final want = int.tryParse(form.hostPort.trim()) ?? 8080;
|
||||
await _accounts.ensureLoaded(validate: false);
|
||||
final secret = form.hostSecret;
|
||||
final bound = await server.share(
|
||||
port: want,
|
||||
secret: form.hostSecret,
|
||||
secret: secret,
|
||||
// Guests without an account watch Jellyfin items through the
|
||||
// server, with this app's own sign-in on the item's server.
|
||||
jellyfin: relayCredentialsFor(_accounts),
|
||||
);
|
||||
final invite = await inviteFor(bound, password: form.hostSecret.isNotEmpty);
|
||||
_secret = secret;
|
||||
final local = inviteAddresses(await _interfaces());
|
||||
final gen = ++_shareGen;
|
||||
reach.value = InviteReach(local: local, looking: true);
|
||||
final lookup = _lookUp(local, gen);
|
||||
// The internet address when it comes quickly; else a local one now
|
||||
// and the internet one when it lands.
|
||||
final public = await lookup.timeout(publicWait, onTimeout: () => null);
|
||||
final invite = inviteFor(bound, password: secret.isNotEmpty, host: reach.peek()?.best);
|
||||
shared.value = invite;
|
||||
if (public == null) {
|
||||
unawaited(lookup.then((ip) {
|
||||
final now = shared.peek();
|
||||
if (ip != null && gen == _shareGen && identical(now, invite)) shared.value = now!.at(ip);
|
||||
}));
|
||||
}
|
||||
return invite;
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// The invite for [port] from this machine's best address (see
|
||||
/// [inviteAddresses]); 127.0.0.1 when there is none.
|
||||
Future<Invite> inviteFor(int port, {required bool password}) async {
|
||||
final addresses = inviteAddresses(await _interfaces());
|
||||
/// Bumped by every share and unshare: a lookup of an earlier one is
|
||||
/// dropped.
|
||||
int _shareGen = 0;
|
||||
|
||||
/// The internet address into [reach] (its own failure is no address).
|
||||
Future<String?> _lookUp(List<String> local, int gen) async {
|
||||
String? ip;
|
||||
try {
|
||||
ip = await _publicAddress();
|
||||
} catch (e) {
|
||||
debugPrint('public address lookup failed: $e');
|
||||
}
|
||||
if (gen != _shareGen || reach.peek() == null) return null;
|
||||
reach.value = InviteReach(public: ip, local: local);
|
||||
return ip;
|
||||
}
|
||||
|
||||
/// The invite for [port] at [host] (else this machine's best address,
|
||||
/// see [inviteAddresses]; 127.0.0.1 when there is none).
|
||||
Invite inviteFor(int port, {required bool password, String? host}) {
|
||||
final local = reach.peek()?.local ?? const [];
|
||||
return Invite(
|
||||
host: addresses.isEmpty ? '127.0.0.1' : addresses.first,
|
||||
host: host ?? (local.isEmpty ? '127.0.0.1' : local.first),
|
||||
port: port,
|
||||
name: _name,
|
||||
password: password,
|
||||
version: appVersion,
|
||||
);
|
||||
}
|
||||
|
||||
/// [link] points at this server (one of its invite addresses, or this
|
||||
/// machine, at its shared or loopback port).
|
||||
bool isOwn(InviteLink link) {
|
||||
final host = link.host.toLowerCase();
|
||||
final local = host == 'localhost' || host == '127.0.0.1' || host == '::1';
|
||||
if (local && link.port == port.peek()) return true;
|
||||
final s = shared.peek();
|
||||
if (s == null || link.port != s.port) return false;
|
||||
return local || host == s.host || (reach.peek()?.choices.any((c) => c.host == host) ?? false);
|
||||
}
|
||||
|
||||
/// Closes it to friends again: everyone but you leaves (told, so their
|
||||
/// apps do not retry); you, what plays and Up next stay.
|
||||
Future<void> stopSharing() async {
|
||||
@@ -187,7 +257,9 @@ class YourServer {
|
||||
busy.value = true;
|
||||
try {
|
||||
await server.unshare();
|
||||
_shareGen++;
|
||||
shared.value = null;
|
||||
reach.value = null;
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
@@ -202,6 +274,7 @@ class YourServer {
|
||||
_server = null;
|
||||
port.value = null;
|
||||
shared.value = null;
|
||||
reach.value = null;
|
||||
await server?.stop();
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
@@ -7,8 +8,13 @@ import 'shader_presets.dart';
|
||||
|
||||
const anime4kAssetDir = 'assets/shaders/anime4k';
|
||||
|
||||
/// The extracted copy's layout: bumped when [highpAnime4k] changes what
|
||||
/// is written, so an older copy is written again.
|
||||
const anime4kCopyRevision = 'hp1';
|
||||
|
||||
/// mpv needs real files: copies the shipped Anime4K shaders out of the
|
||||
/// asset bundle into `<supportDir>/shaders/anime4k-<version>`, once per
|
||||
/// asset bundle into `<supportDir>/shaders/anime4k-<version>-<revision>`,
|
||||
/// each made full precision on OpenGL ES ([highpAnime4k]), once per
|
||||
/// version (a `.complete` marker is written last, so a cut-short copy is
|
||||
/// redone). Returns that directory.
|
||||
Future<Directory> extractAnime4k(
|
||||
@@ -17,7 +23,7 @@ Future<Directory> extractAnime4k(
|
||||
}) async {
|
||||
final dir = Directory(
|
||||
'${supportDir.path}${Platform.pathSeparator}shaders'
|
||||
'${Platform.pathSeparator}anime4k-$anime4kVersion');
|
||||
'${Platform.pathSeparator}anime4k-$anime4kVersion-$anime4kCopyRevision');
|
||||
final marker = File('${dir.path}${Platform.pathSeparator}.complete');
|
||||
final files = anime4kAllFiles;
|
||||
if (await marker.exists() &&
|
||||
@@ -28,14 +34,68 @@ Future<Directory> extractAnime4k(
|
||||
final b = bundle ?? rootBundle;
|
||||
for (final name in [...files, 'LICENSE']) {
|
||||
final data = await b.load('$anime4kAssetDir/$name');
|
||||
final bytes = data.buffer.asUint8List(data.offsetInBytes, data.lengthInBytes);
|
||||
await File(_join(dir, name)).writeAsBytes(
|
||||
data.buffer.asUint8List(data.offsetInBytes, data.lengthInBytes),
|
||||
name.endsWith('.glsl') ? utf8.encode(highpAnime4k(utf8.decode(bytes))) : bytes,
|
||||
flush: true);
|
||||
}
|
||||
await marker.writeAsString(anime4kVersion, flush: true);
|
||||
// Older copies (another version or revision) are not read again.
|
||||
try {
|
||||
await for (final e in dir.parent.list()) {
|
||||
final name = e.uri.pathSegments.where((s) => s.isNotEmpty).last;
|
||||
if (e is Directory && name.startsWith('anime4k-') && e.path != dir.path) {
|
||||
await e.delete(recursive: true);
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
return dir;
|
||||
}
|
||||
|
||||
/// The name of the helper [highpAnime4k] adds to each pass.
|
||||
const _hp = 'starsync_hp';
|
||||
|
||||
/// [glsl] (an Anime4K file) with its convolutions computed at full
|
||||
/// precision on an OpenGL ES context, as they are on desktop OpenGL.
|
||||
///
|
||||
/// media_kit renders through OpenGL ES (EGL on Linux, ANGLE on Windows),
|
||||
/// where mpv declares `precision mediump sampler2D`: a texture read is
|
||||
/// mediump, and Anime4K's weights are bare literals, so every
|
||||
/// `mat4(...) * go_0(...)` of a network ran at half precision (fp16 on
|
||||
/// NVIDIA). The large (VL) networks overflow and round there: shifted
|
||||
/// colours, posterised areas and a regular grid, worse once the
|
||||
/// upscalers run at screen size. Each pass gets an identity function
|
||||
/// with a highp parameter and result, and every `go_N` / `g_N` read goes
|
||||
/// through it, so the products and sums that follow are highp. Desktop
|
||||
/// GLSL ignores precision qualifiers, so nothing changes there. Anything
|
||||
/// that is not such a read is left as it is.
|
||||
String highpAnime4k(String glsl) {
|
||||
final read = RegExp(r'\b\w+_tex(?:Off)?\((?:[^()]|\([^()]*\))*\)');
|
||||
final define = RegExp(r'^#define g(?:o)?_\d+\b');
|
||||
final out = <String>[];
|
||||
var inHeader = false, helper = false;
|
||||
for (final line in const LineSplitter().convert(glsl)) {
|
||||
if (line.startsWith('//!')) {
|
||||
// A new pass starts at the first `//!` line after code.
|
||||
if (!inHeader) helper = false;
|
||||
inHeader = true;
|
||||
out.add(line);
|
||||
continue;
|
||||
}
|
||||
inHeader = false;
|
||||
if (define.hasMatch(line)) {
|
||||
if (!helper) {
|
||||
out.add('highp vec4 $_hp(highp vec4 v) { return v; }');
|
||||
helper = true;
|
||||
}
|
||||
out.add(line.replaceAllMapped(read, (m) => '$_hp(${m[0]})'));
|
||||
continue;
|
||||
}
|
||||
out.add(line);
|
||||
}
|
||||
return '${out.join('\n')}\n';
|
||||
}
|
||||
|
||||
/// Absolute paths of [preset]'s Anime4K files inside [dir].
|
||||
List<String> anime4kPaths(
|
||||
Directory dir, ShaderPreset preset, ShaderQuality quality) =>
|
||||
|
||||
@@ -7,6 +7,8 @@ import 'package:signals/signals_flutter.dart';
|
||||
|
||||
import '../controls/frame.dart' show DoubleClickListener;
|
||||
import '../controls/session_controls.dart' show controlsBottomHeight;
|
||||
import '../danmaku/danmaku_notices.dart' show fliesAsDanmaku;
|
||||
import '../danmaku/danmaku_preview.dart';
|
||||
import '../files/folder_store.dart';
|
||||
import '../files/ui/files_page.dart' show showFilesFolder;
|
||||
import '../jellyfin/accounts.dart';
|
||||
@@ -17,14 +19,18 @@ import '../session/app_events.dart' show ActionFailed, ScheduleResult;
|
||||
import '../screens/home/health_probe.dart';
|
||||
import '../screens/home/media_picker.dart';
|
||||
import '../session/identity.dart';
|
||||
import '../screens/home/ui.dart' show appName;
|
||||
import '../screens/home/update_indicator.dart';
|
||||
import '../app/app_graph.dart';
|
||||
import '../session/navigation.dart' show Place;
|
||||
import '../state/settings.dart';
|
||||
import '../update/update_controller.dart';
|
||||
import '../update/whats_new.dart';
|
||||
import '../widgets/whats_new_card.dart';
|
||||
import 'chat_panel.dart';
|
||||
import 'connect_cards.dart';
|
||||
import 'handoff.dart';
|
||||
import 'join_offer_card.dart';
|
||||
import 'library_link.dart' show LinkProber;
|
||||
import 'library_tab.dart';
|
||||
import 'motion.dart';
|
||||
@@ -33,6 +39,7 @@ import 'profile_card.dart';
|
||||
import '../schedule/ui/schedule_page.dart';
|
||||
import 'session_player.dart';
|
||||
import 'settings_tab.dart';
|
||||
import 'quick_chat.dart';
|
||||
import 'toasts.dart';
|
||||
import 'welcome.dart';
|
||||
|
||||
@@ -52,6 +59,9 @@ import 'welcome.dart';
|
||||
class AppShell extends StatefulWidget {
|
||||
final UpdateController? updates;
|
||||
final bool autoUpdateCheck;
|
||||
/// The card once after an update; defaults to the graph's [WhatsNew] when
|
||||
/// [autoUpdateCheck] is on (off in tests unless given).
|
||||
final WhatsNew? whatsNew;
|
||||
/// Off in tests: the drag area, window buttons and fullscreen need
|
||||
/// window_manager.
|
||||
final bool windowControls;
|
||||
@@ -67,6 +77,7 @@ class AppShell extends StatefulWidget {
|
||||
super.key,
|
||||
this.updates,
|
||||
this.autoUpdateCheck = true,
|
||||
this.whatsNew,
|
||||
this.windowControls = true,
|
||||
this.healthGet = defaultHealthGet,
|
||||
this.jellyfin,
|
||||
@@ -103,6 +114,11 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
||||
/// Whether the full player's controls are up (the player's frame
|
||||
/// reports it): the toasts sit above them.
|
||||
final _controlsShown = ValueNotifier(true);
|
||||
/// The player's quick chat box (Enter); its draft is the chat panel's.
|
||||
late final _quickChat = QuickChat(draft: _chat);
|
||||
/// The player flies notifications as danmaku: those stay out of the
|
||||
/// toasts.
|
||||
final _danmakuActive = ValueNotifier(false);
|
||||
|
||||
Alignment _corner = Alignment.bottomRight;
|
||||
Offset? _drag; // mini top-left while dragging
|
||||
@@ -158,6 +174,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
||||
// Reads linked servers and checks their tokens in the background.
|
||||
_jellyfin.ensureLoaded();
|
||||
if (widget.autoUpdateCheck) _autoCheck();
|
||||
if (widget.whatsNew != null || widget.autoUpdateCheck) unawaited(_startWhatsNew());
|
||||
HardwareKeyboard.instance.addHandler(_onKey);
|
||||
final nav = _graph.navigation;
|
||||
final coord = _graph.coordinator;
|
||||
@@ -240,7 +257,9 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
||||
for (final stop in _stops) {
|
||||
stop();
|
||||
}
|
||||
_danmaku?.dispose();
|
||||
HardwareKeyboard.instance.removeHandler(_onKey);
|
||||
_whatsNewSource?.due.removeListener(_maybeWhatsNew);
|
||||
_updateTimer?.cancel();
|
||||
_fade.dispose();
|
||||
_online.dispose();
|
||||
@@ -250,6 +269,8 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
||||
_flow.dispose();
|
||||
_join.dispose();
|
||||
_picker.dispose();
|
||||
_quickChat.dispose();
|
||||
_danmakuActive.dispose();
|
||||
_chat.dispose();
|
||||
_chatFocus.dispose();
|
||||
_controlsShown.dispose();
|
||||
@@ -276,6 +297,36 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
||||
if (s.autoCheckUpdates.peek()) await runUpdateCheck(widget.updates);
|
||||
}
|
||||
|
||||
WhatsNew? _whatsNewSource;
|
||||
bool _whatsNewOpen = false;
|
||||
|
||||
/// What's new once after an update: due now (notes kept by the install)
|
||||
/// or once a check reads this version's manifest.
|
||||
Future<void> _startWhatsNew() async {
|
||||
await _graph.coordinator.settings.ensureLoaded();
|
||||
if (!mounted) return;
|
||||
final w = _whatsNewSource = widget.whatsNew ?? _graph.whatsNew;
|
||||
w.due.addListener(_maybeWhatsNew);
|
||||
// Not over the full player while a video is on: once it ends or minimises.
|
||||
_stops.add(effect(() {
|
||||
_watching.value;
|
||||
untracked(() => _later(_maybeWhatsNew));
|
||||
}));
|
||||
w.start(widget.updates ?? _graph.updates);
|
||||
_maybeWhatsNew();
|
||||
}
|
||||
|
||||
void _maybeWhatsNew() {
|
||||
final w = _whatsNewSource;
|
||||
final notes = w?.due.value;
|
||||
if (w == null || notes == null || !mounted || _whatsNewOpen) return;
|
||||
if (_shown == PlayerMode.full && _watching.peek()) return;
|
||||
_whatsNewOpen = true;
|
||||
w.shown();
|
||||
unawaited(showWhatsNew(context, notes, overline: '$appName · updated')
|
||||
.whenComplete(() => _whatsNewOpen = false));
|
||||
}
|
||||
|
||||
void _wentOnline() {
|
||||
_unread = 0;
|
||||
_feedSeen = _graph.coordinator.feedLog.peek().length;
|
||||
@@ -287,6 +338,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
||||
if (_fullscreen) _toggleFullscreen();
|
||||
setState(() {
|
||||
_chatOpen = false;
|
||||
_graph.notifier.chatOpen = false;
|
||||
_unread = 0;
|
||||
});
|
||||
_show(PlayerMode.none);
|
||||
@@ -312,11 +364,28 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
||||
}
|
||||
if (!mounted || seq != _modeSeq) return;
|
||||
setState(() => _shown = m);
|
||||
if (m != PlayerMode.full) _later(_maybeWhatsNew);
|
||||
await _fade.animateTo(1, duration: fadeThroughTime * (1 - fadeOutPart), curve: ease);
|
||||
}
|
||||
|
||||
void _expand() => _show(PlayerMode.full);
|
||||
|
||||
/// Dev builds: the danmaku preview, made on first use.
|
||||
DanmakuPreview? _danmaku;
|
||||
|
||||
/// Over the full player when a player is up, else on its own page with
|
||||
/// a stand-in picture.
|
||||
void _openDanmakuPreview() {
|
||||
final d = _danmaku ??= DanmakuPreview(style: _graph.coordinator.settings.danmakuStyle.peek());
|
||||
if (_online.peek() && _graph.coordinator.playback.alive.peek()) {
|
||||
setState(() => d.active.value = true);
|
||||
_show(PlayerMode.full);
|
||||
} else {
|
||||
d.active.value = false;
|
||||
showDanmakuPreviewPage(context, d);
|
||||
}
|
||||
}
|
||||
|
||||
void _collapse() {
|
||||
if (_fullscreen) _toggleFullscreen();
|
||||
_show(PlayerMode.mini);
|
||||
@@ -362,6 +431,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
||||
|
||||
void _toggleChat() => setState(() {
|
||||
_chatOpen = !_chatOpen;
|
||||
_graph.notifier.chatOpen = _chatOpen;
|
||||
_unread = 0;
|
||||
if (!_chatOpen) _chatFocus.unfocus();
|
||||
});
|
||||
@@ -602,6 +672,22 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
||||
|
||||
void _invite() => unawaited(inviteFriends(context));
|
||||
|
||||
/// The join prompt's Join: the invite into the Join card's fields, then
|
||||
/// the join (version gate and all); a failure opens the card with it.
|
||||
Future<void> _acceptOffer() async {
|
||||
final link = _graph.joinOffers.accept();
|
||||
if (link == null) return;
|
||||
final port = '${link.port}';
|
||||
_join.host.text = link.host;
|
||||
_join.port.text = port;
|
||||
_join.secret.text = link.secret;
|
||||
await _flow.submit((f) => f
|
||||
..host = link.host
|
||||
..port = port
|
||||
..secret = link.secret);
|
||||
if (mounted && _flow.error != null) _joinFriend();
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------- build
|
||||
|
||||
@override
|
||||
@@ -723,6 +809,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
||||
onEditProfile: () => showProfileCard(context, onOpenSettings: _openSettings, updates: widget.updates),
|
||||
updates: widget.updates,
|
||||
jellyfin: _jellyfin,
|
||||
onDanmakuPreview: _openDanmakuPreview,
|
||||
),
|
||||
};
|
||||
final full = _shown == PlayerMode.full;
|
||||
@@ -757,6 +844,12 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
||||
),
|
||||
if (online && _shown != PlayerMode.none) _playerLayer(area),
|
||||
_toasts(full && online),
|
||||
Positioned(
|
||||
top: 12,
|
||||
left: 16,
|
||||
right: 16,
|
||||
child: Center(child: JoinOfferCard(offers: _graph.joinOffers, onJoin: _acceptOffer)),
|
||||
),
|
||||
],
|
||||
);
|
||||
});
|
||||
@@ -771,6 +864,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
||||
onOpenChat: () {
|
||||
if (!_chatOpen) _toggleChat();
|
||||
},
|
||||
skip: (t) => (_danmakuActive.value && fliesAsDanmaku(t)) || _quickChat.echoed(t),
|
||||
);
|
||||
return ValueListenableBuilder<bool>(
|
||||
valueListenable: _controlsShown,
|
||||
@@ -866,6 +960,9 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
||||
videoBuilder: widget.videoBuilder,
|
||||
windowControls: widget.windowControls,
|
||||
controlsShown: _controlsShown,
|
||||
quickChat: _quickChat,
|
||||
danmakuActive: _danmakuActive,
|
||||
danmaku: _danmaku,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -7,7 +7,7 @@ import '../app/app_graph.dart';
|
||||
import '../screens/home/connect_flow.dart';
|
||||
import '../screens/home/connect_status.dart';
|
||||
import '../screens/home/health_probe.dart';
|
||||
import '../session/invite.dart' show addressIn;
|
||||
import '../session/invite.dart' show addressIn, inviteIn;
|
||||
import '../session/navigation.dart' show Place;
|
||||
import '../state/connect_prefs.dart';
|
||||
import '../update/update_controller.dart';
|
||||
@@ -138,9 +138,17 @@ class JoinCard extends StatelessWidget {
|
||||
_join();
|
||||
}
|
||||
|
||||
/// An invite or "host:port" pasted into the address: split it.
|
||||
/// An invite (link or text) or "host:port" pasted into the address:
|
||||
/// split it (a link's password goes into the password field).
|
||||
void _onAddress(String v) {
|
||||
flow.clearError();
|
||||
final link = inviteIn(v);
|
||||
if (link != null) {
|
||||
fields.host.text = link.host;
|
||||
fields.port.text = '${link.port}';
|
||||
if (link.secret.isNotEmpty) fields.secret.text = link.secret;
|
||||
return;
|
||||
}
|
||||
final a = addressIn(v);
|
||||
if (a == null || v.trim() == a.host) return;
|
||||
fields.host.text = a.host;
|
||||
@@ -223,7 +231,7 @@ class JoinCard extends StatelessWidget {
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(top: 8),
|
||||
child: Text(
|
||||
'Your friend gives you their invite (the address) and the password, if any. '
|
||||
'Paste your friend\'s invite link (it carries the password), or their address and password. '
|
||||
'No account or Jellyfin needed. Your own server stays as it is.',
|
||||
style: TextStyle(color: Colors.grey, fontSize: 12),
|
||||
),
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:signals/signals_flutter.dart';
|
||||
|
||||
import '../session/join_offers.dart';
|
||||
import 'motion.dart';
|
||||
|
||||
/// The join prompt, top centre over the body: "Join Bob's server
|
||||
/// (1.2.3.4:8080)?" with what its `/health` says (version, who is in,
|
||||
/// the version gate, unreachable), Not now and Join. Shown while
|
||||
/// [offers] has an offer (a clicked invite link, or one on the
|
||||
/// clipboard).
|
||||
class JoinOfferCard extends StatelessWidget {
|
||||
final JoinOffers offers;
|
||||
final VoidCallback onJoin;
|
||||
const JoinOfferCard({super.key, required this.offers, required this.onJoin});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => SignalBuilder(builder: (context) {
|
||||
final o = offers.offer.value;
|
||||
return AnimatedSwitcher(
|
||||
duration: easeTime,
|
||||
switchInCurve: ease,
|
||||
switchOutCurve: ease,
|
||||
transitionBuilder: (child, a) => FadeTransition(
|
||||
opacity: a,
|
||||
child: SlideTransition(
|
||||
position: Tween(begin: const Offset(0, -0.2), end: Offset.zero).animate(a),
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
child: o == null
|
||||
? const SizedBox.shrink()
|
||||
: _OfferBody(key: ValueKey(o.link), offer: o, onJoin: onJoin, onDismiss: offers.dismiss),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
class _OfferBody extends StatelessWidget {
|
||||
final JoinOffer offer;
|
||||
final VoidCallback onJoin;
|
||||
final VoidCallback onDismiss;
|
||||
const _OfferBody({super.key, required this.offer, required this.onJoin, required this.onDismiss});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final h = offer.health;
|
||||
final problem = h != null && (!h.online || h.needsUpdate);
|
||||
final detail = h == null ? 'Checking the server…' : offer.detail;
|
||||
return Material(
|
||||
key: const ValueKey('join-offer'),
|
||||
elevation: 12,
|
||||
color: scheme.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 560),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 10, 12),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(offer.source == OfferSource.clipboard ? Icons.content_paste_go : Icons.link,
|
||||
color: scheme.primary),
|
||||
const SizedBox(width: 12),
|
||||
Flexible(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(offer.question,
|
||||
key: const ValueKey('join-offer-question'),
|
||||
style: const TextStyle(fontWeight: FontWeight.w600)),
|
||||
if (detail.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 2),
|
||||
child: Text(
|
||||
[
|
||||
if (offer.source == OfferSource.clipboard) 'Invite on your clipboard',
|
||||
detail,
|
||||
].join(' · '),
|
||||
style: TextStyle(
|
||||
fontSize: 12, color: problem ? Colors.amber.shade300 : scheme.onSurfaceVariant),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
TextButton(key: const ValueKey('join-offer-dismiss'), onPressed: onDismiss, child: const Text('Not now')),
|
||||
const SizedBox(width: 4),
|
||||
FilledButton(key: const ValueKey('join-offer-join'), onPressed: onJoin, child: const Text('Join')),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,6 @@ import '../screens/home/ui.dart' show appName, displayVersion, shortVersion;
|
||||
import '../session/delivery.dart';
|
||||
import '../session/party.dart';
|
||||
import '../app/app_graph.dart';
|
||||
import '../session/app_events.dart' show ActionFailed;
|
||||
import '../session/invite.dart';
|
||||
import '../session/navigation.dart' show Place;
|
||||
import '../widgets/app_keys.dart';
|
||||
@@ -16,6 +15,9 @@ import '../widgets/window_buttons.dart';
|
||||
import 'avatar.dart';
|
||||
import 'delivery_line.dart';
|
||||
import 'motion.dart';
|
||||
import 'your_invite.dart';
|
||||
|
||||
export 'your_invite.dart' show YourInvite, inviteFriends;
|
||||
|
||||
/// The shell's tabs, in strip order.
|
||||
enum ShellTab {
|
||||
@@ -506,9 +508,13 @@ class MembersCard extends StatelessWidget {
|
||||
alignment: Alignment.centerLeft,
|
||||
child: TextButton.icon(
|
||||
key: const ValueKey('copy-invite'),
|
||||
onPressed: () => copyToClipboard(context, address, done: 'Invite address copied'),
|
||||
onPressed: () => copyToClipboard(
|
||||
context,
|
||||
friendInviteLink(address, nav.serverLabel.peek(),
|
||||
secret: AppScope.of(context).connection.params?.secret ?? ''),
|
||||
done: 'Invite link copied'),
|
||||
icon: const Icon(Icons.copy, size: 16),
|
||||
label: const Text('Copy invite address'),
|
||||
label: const Text('Copy invite link'),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -623,90 +629,12 @@ class VersionText extends StatelessWidget {
|
||||
bool versionDiffers(String version, {String own = appVersion}) =>
|
||||
version.isNotEmpty && own != '0.0.0-dev' && version != '0.0.0-dev' && version != own;
|
||||
|
||||
/// Your server in the members card: shared, the invite's address
|
||||
/// (Copy invite), a hint for friends outside the network and Stop
|
||||
/// sharing; not shared, Invite (opens it and copies the invite).
|
||||
class YourInvite extends StatelessWidget {
|
||||
const YourInvite({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final muted = Theme.of(context).colorScheme.onSurfaceVariant;
|
||||
final graph = AppScope.of(context);
|
||||
final yours = graph.yourServer;
|
||||
return SignalBuilder(builder: (context) {
|
||||
final invite = yours.shared.value;
|
||||
final busy = yours.busy.value || yours.port.value == null;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 0, 12, 12),
|
||||
child: Column(
|
||||
key: const ValueKey('your-invite'),
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (invite == null) ...[
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(8, 0, 8, 6),
|
||||
child: Text('Only you can reach it. Invite opens it to friends; nothing you watch stops.',
|
||||
style: TextStyle(color: muted, fontSize: 12)),
|
||||
),
|
||||
TextButton.icon(
|
||||
key: const ValueKey('copy-invite'),
|
||||
onPressed: busy ? null : () => inviteFriends(context),
|
||||
icon: const Icon(Icons.group_add_outlined, size: 16),
|
||||
label: const Text('Invite'),
|
||||
),
|
||||
] else ...[
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Text(invite.address,
|
||||
key: const ValueKey('invite-address'), style: const TextStyle(fontFamily: 'monospace')),
|
||||
),
|
||||
Wrap(
|
||||
spacing: 4,
|
||||
children: [
|
||||
TextButton.icon(
|
||||
key: const ValueKey('copy-invite'),
|
||||
onPressed: () => copyToClipboard(context, invite.text, done: 'Invite copied'),
|
||||
icon: const Icon(Icons.copy, size: 16),
|
||||
label: const Text('Copy invite'),
|
||||
),
|
||||
TextButton(
|
||||
key: const ValueKey('stop-sharing'),
|
||||
onPressed: busy ? null : () => yours.stopSharing(),
|
||||
child: const Text('Stop sharing'),
|
||||
),
|
||||
],
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(8, 2, 8, 0),
|
||||
child: Text(
|
||||
'Works for friends on your network. Friends elsewhere need port ${invite.port} '
|
||||
'forwarded on your router, or a server they can all reach (like a VPS). '
|
||||
'Stop sharing sends everyone else out; you and Up next stay.',
|
||||
style: TextStyle(color: muted, fontSize: 12),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Invite: opens your server to friends if it is not yet and copies the
|
||||
/// invite ("Invite copied"); a port that can't be opened is a notice.
|
||||
Future<void> inviteFriends(BuildContext context) async {
|
||||
final graph = AppScope.of(context);
|
||||
final Invite invite;
|
||||
try {
|
||||
invite = await graph.yourServer.invite();
|
||||
} catch (e) {
|
||||
final port = graph.forms.form.hostPort;
|
||||
graph.notifier.notify(ActionFailed(
|
||||
"couldn't open your server on port $port: $e — pick another port in Settings → Party & network"));
|
||||
return;
|
||||
}
|
||||
if (!context.mounted) return;
|
||||
await copyToClipboard(context, invite.text, done: 'Invite copied · ${invite.address}');
|
||||
/// The invite link to a friend's server you are in ([address] as dialled,
|
||||
/// "host:port"), with the password you joined with: the link is the key,
|
||||
/// as for your own server.
|
||||
String friendInviteLink(String address, String name, {String secret = ''}) {
|
||||
final a = addressIn(address);
|
||||
final port = a == null ? null : int.tryParse(a.port);
|
||||
if (a == null || port == null) return address;
|
||||
return InviteLink(host: a.host, port: port, name: name, secret: secret).uri;
|
||||
}
|
||||
@@ -0,0 +1,397 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../session/notifier.dart' show ToastItem;
|
||||
import '../widgets/app_menu.dart' show AppMenuStyle;
|
||||
import 'avatar.dart';
|
||||
import 'motion.dart';
|
||||
|
||||
// The quick chat box: Enter in the full player opens a small box at the
|
||||
// bottom centre, in the control bar's place, without opening the chat
|
||||
// panel. It types into the same draft as the chat panel's field.
|
||||
|
||||
/// The box's state, owned by the shell (it outlives the player's modes):
|
||||
/// open or not, the draft ([draft], the chat panel's controller), its
|
||||
/// focus, the echo of what was just sent, and the sends whose own chat
|
||||
/// toast should not show again ([echoed]).
|
||||
class QuickChat {
|
||||
/// Closes by itself after this long without typing; the draft stays.
|
||||
static const idleClose = Duration(seconds: 8);
|
||||
|
||||
/// How long the echo of a sent line stays where the box was.
|
||||
static const echoTime = Duration(milliseconds: 2600);
|
||||
|
||||
/// The draft, shared with the chat panel's field.
|
||||
final TextEditingController draft;
|
||||
final bool _ownsDraft;
|
||||
final focus = FocusNode(debugLabel: 'quick chat');
|
||||
final open = ValueNotifier(false);
|
||||
|
||||
/// The line just sent, shown briefly in the box's place (null: none).
|
||||
final echo = ValueNotifier<String?>(null);
|
||||
|
||||
/// Bumped on each send ([lastSent]): the player flies your own line
|
||||
/// when danmaku is the notification style (and drops the echo).
|
||||
final sends = ValueNotifier(0);
|
||||
String lastSent = '';
|
||||
|
||||
/// Without typing this long, the box closes ([idleClose]).
|
||||
final Duration idle;
|
||||
Timer? _idleTimer;
|
||||
Timer? _echoTimer;
|
||||
final _recent = <(String, DateTime)>[];
|
||||
|
||||
QuickChat({TextEditingController? draft, this.idle = idleClose})
|
||||
: draft = draft ?? TextEditingController(),
|
||||
_ownsDraft = draft == null {
|
||||
this.draft.addListener(_typed);
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
draft.removeListener(_typed);
|
||||
if (_ownsDraft) draft.dispose();
|
||||
_idleTimer?.cancel();
|
||||
_echoTimer?.cancel();
|
||||
focus.dispose();
|
||||
open.dispose();
|
||||
echo.dispose();
|
||||
sends.dispose();
|
||||
}
|
||||
|
||||
/// Opens the box with the draft as it was, caret at its end, and gives
|
||||
/// it the keyboard at once (keys typed right after go to it).
|
||||
void show() {
|
||||
_echoTimer?.cancel();
|
||||
echo.value = null;
|
||||
open.value = true;
|
||||
draft.selection = TextSelection.collapsed(offset: draft.text.length);
|
||||
focus.requestFocus();
|
||||
_armIdle();
|
||||
}
|
||||
|
||||
/// Closes the box; the draft stays for the next Enter (or the panel).
|
||||
void close() {
|
||||
_idleTimer?.cancel();
|
||||
if (!open.value) return;
|
||||
open.value = false;
|
||||
focus.unfocus();
|
||||
}
|
||||
|
||||
void _typed() {
|
||||
if (open.value) _armIdle();
|
||||
}
|
||||
|
||||
void _armIdle() {
|
||||
_idleTimer?.cancel();
|
||||
_idleTimer = Timer(idle, close);
|
||||
}
|
||||
|
||||
/// Enter: the trimmed draft to send (cleared, the box closes and the
|
||||
/// echo shows it), or null when blank (the box just closes).
|
||||
String? submit() {
|
||||
final text = draft.text.trim();
|
||||
close();
|
||||
if (text.isEmpty) return null;
|
||||
draft.clear();
|
||||
final now = DateTime.now();
|
||||
_recent.removeWhere((r) => now.difference(r.$2) > const Duration(seconds: 20));
|
||||
_recent.add((text, now));
|
||||
lastSent = text;
|
||||
echo.value = text;
|
||||
_echoTimer?.cancel();
|
||||
_echoTimer = Timer(echoTime, () => echo.value = null);
|
||||
sends.value++;
|
||||
return text;
|
||||
}
|
||||
|
||||
/// [t] is your own chat toast for a line this box already showed (as
|
||||
/// its echo or your own danmaku line): it should not pop up again.
|
||||
bool echoed(ToastItem t) {
|
||||
if (!t.chat || !t.own) return false;
|
||||
final now = DateTime.now();
|
||||
return _recent.any((r) => now.difference(r.$2) < const Duration(seconds: 20) && t.text.endsWith(': ${r.$1}'));
|
||||
}
|
||||
}
|
||||
|
||||
/// The box: a glass pill at the bottom centre that starts compact
|
||||
/// ([minWidth]) and widens with the text up to [maxWidth], then wraps to
|
||||
/// up to four lines, growing upward. Enter sends ([onSend], empty Enter
|
||||
/// closes), Esc closes. Your avatar leads it. While closed it stays
|
||||
/// mounted, invisible and out of the way, so [QuickChat.show] focuses it
|
||||
/// at once. The pointer over it stays hidden until the mouse moves.
|
||||
class QuickChatBox extends StatefulWidget {
|
||||
static const minWidth = 300.0;
|
||||
static const height = 44.0;
|
||||
final QuickChat chat;
|
||||
final double maxWidth;
|
||||
final void Function(String text) onSend;
|
||||
|
||||
/// Its height whenever it changes (the subtitle lift follows it).
|
||||
final ValueChanged<double>? onHeight;
|
||||
const QuickChatBox({super.key, required this.chat, required this.onSend, this.maxWidth = 640, this.onHeight});
|
||||
|
||||
@override
|
||||
State<QuickChatBox> createState() => _QuickChatBoxState();
|
||||
}
|
||||
|
||||
class _QuickChatBoxState extends State<QuickChatBox> {
|
||||
static const _text = TextStyle(color: Colors.white, fontSize: 15, height: 1.3);
|
||||
bool _pointerMoved = false;
|
||||
Offset? _pointerAt;
|
||||
|
||||
QuickChat get c => widget.chat;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
c.draft.addListener(_changed);
|
||||
c.open.addListener(_openChanged);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(QuickChatBox old) {
|
||||
super.didUpdateWidget(old);
|
||||
if (old.chat != widget.chat) {
|
||||
old.chat.draft.removeListener(_changed);
|
||||
old.chat.open.removeListener(_openChanged);
|
||||
c.draft.addListener(_changed);
|
||||
c.open.addListener(_openChanged);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
c.draft.removeListener(_changed);
|
||||
c.open.removeListener(_openChanged);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _changed() => setState(() {});
|
||||
|
||||
void _openChanged() {
|
||||
if (c.open.value) _pointerMoved = false;
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
KeyEventResult _key(FocusNode node, KeyEvent e) {
|
||||
if (e is! KeyDownEvent && e is! KeyRepeatEvent) return KeyEventResult.ignored;
|
||||
final k = e.logicalKey;
|
||||
if (k == LogicalKeyboardKey.escape) {
|
||||
c.close();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
if (k == LogicalKeyboardKey.enter || k == LogicalKeyboardKey.numpadEnter) {
|
||||
// An input method composing a word takes Enter itself.
|
||||
if (c.draft.value.composing.isValid) return KeyEventResult.ignored;
|
||||
if (e is KeyRepeatEvent) return KeyEventResult.handled;
|
||||
final text = c.submit();
|
||||
if (text != null) widget.onSend(text);
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
|
||||
/// Wide enough for the draft on one line, between [QuickChatBox.minWidth]
|
||||
/// and the room there is.
|
||||
double _width(double room) {
|
||||
final max = math.max(QuickChatBox.minWidth, math.min(widget.maxWidth, room));
|
||||
final tp = TextPainter(
|
||||
text: TextSpan(text: c.draft.text.isEmpty ? ' ' : c.draft.text, style: _text),
|
||||
textDirection: TextDirection.ltr,
|
||||
maxLines: 1,
|
||||
)..layout();
|
||||
// Avatar, gaps and padding around the text, plus room for the caret.
|
||||
final w = tp.width + 22 + 12 + 18 + 18 + 40;
|
||||
tp.dispose();
|
||||
return w.clamp(QuickChatBox.minWidth, max);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final open = c.open.value;
|
||||
final me = selfAvatarInfo();
|
||||
return LayoutBuilder(builder: (context, box) {
|
||||
final width = _width(box.maxWidth);
|
||||
final field = Focus(
|
||||
canRequestFocus: false,
|
||||
skipTraversal: true,
|
||||
onKeyEvent: _key,
|
||||
child: TextField(
|
||||
key: const ValueKey('quick-chat-field'),
|
||||
controller: c.draft,
|
||||
focusNode: c.focus,
|
||||
minLines: 1,
|
||||
maxLines: 4,
|
||||
keyboardType: TextInputType.text,
|
||||
textInputAction: TextInputAction.send,
|
||||
maxLength: 500,
|
||||
maxLengthEnforcement: MaxLengthEnforcement.enforced,
|
||||
// Enter is ours (above); a platform send action lands here.
|
||||
onSubmitted: (_) {
|
||||
final text = c.submit();
|
||||
if (text != null) widget.onSend(text);
|
||||
},
|
||||
mouseCursor: _pointerMoved ? SystemMouseCursors.text : SystemMouseCursors.none,
|
||||
style: _text,
|
||||
cursorColor: Colors.white,
|
||||
cursorWidth: 1.6,
|
||||
decoration: InputDecoration(
|
||||
isDense: true,
|
||||
counterText: '',
|
||||
border: InputBorder.none,
|
||||
contentPadding: const EdgeInsets.symmetric(vertical: 4),
|
||||
hintText: 'Say something',
|
||||
hintStyle: _text.copyWith(color: Colors.white.withValues(alpha: 0.42)),
|
||||
),
|
||||
),
|
||||
);
|
||||
return Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: IgnorePointer(
|
||||
ignoring: !open,
|
||||
child: AnimatedSlide(
|
||||
offset: open ? Offset.zero : const Offset(0, 0.35),
|
||||
duration: easeTime,
|
||||
curve: ease,
|
||||
child: AnimatedOpacity(
|
||||
opacity: open ? 1 : 0,
|
||||
duration: open ? easeTime : const Duration(milliseconds: 160),
|
||||
curve: ease,
|
||||
child: MouseRegion(
|
||||
onHover: (e) {
|
||||
final at = _pointerAt;
|
||||
_pointerAt = e.position;
|
||||
if (!_pointerMoved && at != null && (e.position - at).distance > 0.5) {
|
||||
setState(() => _pointerMoved = true);
|
||||
}
|
||||
},
|
||||
child: _ReportHeight(
|
||||
onHeight: widget.onHeight,
|
||||
child: AnimatedContainer(
|
||||
key: const ValueKey('quick-chat'),
|
||||
duration: const Duration(milliseconds: 160),
|
||||
curve: Curves.easeOutCubic,
|
||||
width: width,
|
||||
constraints: const BoxConstraints(minHeight: QuickChatBox.height),
|
||||
padding: const EdgeInsets.fromLTRB(11, 6, 16, 6),
|
||||
decoration: BoxDecoration(
|
||||
color: AppMenuStyle.glass,
|
||||
borderRadius: BorderRadius.circular(QuickChatBox.height / 2),
|
||||
border: Border.all(color: Colors.white.withValues(alpha: open ? 0.22 : 0.1), width: 0.8),
|
||||
boxShadow: const [BoxShadow(color: Color(0x55000000), blurRadius: 18, offset: Offset(0, 6))],
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
StatusAvatar(info: me, size: 22, badges: false, ring: false),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(child: field),
|
||||
const SizedBox(width: 8),
|
||||
Icon(Icons.keyboard_return_rounded, size: 16, color: Colors.white.withValues(alpha: 0.38)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// What you just sent, in the box's place for [QuickChat.echoTime]: your
|
||||
/// avatar and the line on the same glass, then it fades. Shown when the
|
||||
/// notifications are toasts (with danmaku your own line flies instead).
|
||||
class QuickChatEcho extends StatelessWidget {
|
||||
final QuickChat chat;
|
||||
const QuickChatEcho({super.key, required this.chat});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final accent = Theme.of(context).colorScheme.primary;
|
||||
return IgnorePointer(
|
||||
child: ValueListenableBuilder<String?>(
|
||||
valueListenable: chat.echo,
|
||||
builder: (context, text, _) => AnimatedSwitcher(
|
||||
duration: easeTime,
|
||||
switchInCurve: ease,
|
||||
switchOutCurve: Curves.easeIn,
|
||||
transitionBuilder: (child, a) => FadeTransition(
|
||||
opacity: a,
|
||||
child: SlideTransition(
|
||||
position: Tween(begin: const Offset(0, 0.25), end: Offset.zero).animate(a),
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
child: text == null
|
||||
? const SizedBox.shrink(key: ValueKey('no-echo'))
|
||||
: Align(
|
||||
key: ValueKey('echo-$text'),
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 640),
|
||||
child: Container(
|
||||
key: const ValueKey('quick-chat-echo'),
|
||||
padding: const EdgeInsets.fromLTRB(11, 10, 16, 10),
|
||||
decoration: BoxDecoration(
|
||||
color: AppMenuStyle.glass,
|
||||
borderRadius: BorderRadius.circular(QuickChatBox.height / 2),
|
||||
border: Border.all(color: accent.withValues(alpha: 0.7), width: 1),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
StatusAvatar(info: selfAvatarInfo(), size: 22, badges: false, ring: false),
|
||||
const SizedBox(width: 12),
|
||||
Flexible(
|
||||
child: Text(text,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(color: Colors.white, fontSize: 15, height: 1.3)),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Icon(Icons.check_rounded, size: 16, color: accent),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Reports its child's height after layout when it changes.
|
||||
class _ReportHeight extends SingleChildRenderObjectWidget {
|
||||
final ValueChanged<double>? onHeight;
|
||||
const _ReportHeight({required this.onHeight, required super.child});
|
||||
|
||||
@override
|
||||
RenderObject createRenderObject(BuildContext context) => _RenderReportHeight(onHeight);
|
||||
|
||||
@override
|
||||
void updateRenderObject(BuildContext context, _RenderReportHeight r) => r.onHeight = onHeight;
|
||||
}
|
||||
|
||||
class _RenderReportHeight extends RenderProxyBox {
|
||||
ValueChanged<double>? onHeight;
|
||||
double? _last;
|
||||
_RenderReportHeight(this.onHeight);
|
||||
|
||||
@override
|
||||
void performLayout() {
|
||||
super.performLayout();
|
||||
final h = size.height;
|
||||
if (h == _last) return;
|
||||
_last = h;
|
||||
final f = onHeight;
|
||||
if (f != null) WidgetsBinding.instance.addPostFrameCallback((_) => f(h));
|
||||
}
|
||||
}
|
||||
@@ -11,8 +11,12 @@ import '../controls/controls_probe.dart';
|
||||
import '../controls/minimal/style.dart';
|
||||
import '../controls/model/controls_layout.dart';
|
||||
import '../controls/model/controls_model.dart';
|
||||
import '../controls/player_panel.dart';
|
||||
import '../controls/session_controls.dart';
|
||||
import '../controls/volume.dart';
|
||||
import '../danmaku/danmaku.dart' show DanmakuController, DanmakuItem, DanmakuKind;
|
||||
import '../danmaku/danmaku_notices.dart';
|
||||
import '../danmaku/danmaku_preview.dart';
|
||||
import '../platform/desktop/media_kit_host.dart' show videoControllerOf;
|
||||
import '../queue/queue_ui.dart';
|
||||
import '../playback/media_marks.dart' show SkipKind, SkipOffer, formatClock;
|
||||
@@ -30,8 +34,10 @@ import 'avatar.dart';
|
||||
import 'motion.dart';
|
||||
import 'now_playing_title.dart';
|
||||
import 'player_status.dart';
|
||||
import 'quick_chat.dart';
|
||||
import 'sit_out_card.dart';
|
||||
import 'start_banner.dart';
|
||||
import 'video_view.dart';
|
||||
|
||||
/// Where the session's video is shown: nowhere, the floating mini player,
|
||||
/// or filling the body (and, with fullscreen, the window).
|
||||
@@ -44,9 +50,10 @@ typedef VideoViewBuilder = Widget Function(BuildContext context, VideoControlsBu
|
||||
|
||||
/// The session's player: the one `Video` of the current player and
|
||||
/// everything over it. Full: the controls (one model, the skin picked in
|
||||
/// Settings), the start card, the status pill, Jellyfin overlays and
|
||||
/// the player keys. Mini: hover controls and a progress line. The shell keeps this
|
||||
/// widget mounted at one place in the tree while the mode changes, so the
|
||||
/// Settings), the start card, the status pill, Jellyfin overlays, the
|
||||
/// quick chat box, the danmaku, the player panel and the player keys.
|
||||
/// Mini: hover controls and a progress line. The shell keeps this widget
|
||||
/// mounted at one place in the tree while the mode changes, so the
|
||||
/// `Video` is never mounted twice nor rebuilt for a move.
|
||||
class SessionPlayer extends StatefulWidget {
|
||||
final PlayerMode mode;
|
||||
@@ -69,6 +76,15 @@ class SessionPlayer extends StatefulWidget {
|
||||
/// Follows whether the full player's controls are up (the shell lifts
|
||||
/// its toasts above them); the player keeps its own when null.
|
||||
final ValueNotifier<bool>? controlsShown;
|
||||
/// The quick chat box's state (the shell's: its draft is the chat
|
||||
/// panel's); the player keeps its own when null.
|
||||
final QuickChat? quickChat;
|
||||
/// Set while the session's notifications fly as danmaku here (the
|
||||
/// shell then leaves those out of its toasts).
|
||||
final ValueNotifier<bool>? danmakuActive;
|
||||
/// Dev builds: the danmaku preview, shown over the full player while
|
||||
/// it is active.
|
||||
final DanmakuPreview? danmaku;
|
||||
const SessionPlayer({
|
||||
super.key,
|
||||
required this.mode,
|
||||
@@ -86,6 +102,9 @@ class SessionPlayer extends StatefulWidget {
|
||||
this.videoBuilder,
|
||||
this.windowControls = true,
|
||||
this.controlsShown,
|
||||
this.quickChat,
|
||||
this.danmakuActive,
|
||||
this.danmaku,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -100,11 +119,29 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
final _noFocus = FocusNode(skipTraversal: true, canRequestFocus: false);
|
||||
DateTime? _lastKeyAt;
|
||||
late final void Function() _stopShaderWatch;
|
||||
late final void Function() _stopSubScale;
|
||||
late final void Function() _stopDanmakuWatch;
|
||||
late final void Function() _stopLiftWatch;
|
||||
int _shaderNoticeSeen = 0;
|
||||
bool _miniHover = false;
|
||||
// Whether the controls are up (their frame reports it).
|
||||
final _ownShown = ValueNotifier(true);
|
||||
ValueNotifier<bool> get _controlsShown => widget.controlsShown ?? _ownShown;
|
||||
// The quick chat box (Enter): the shell's, else one of our own.
|
||||
QuickChat? _ownChat;
|
||||
QuickChat get _quick => widget.quickChat ?? (_ownChat ??= QuickChat());
|
||||
// The box or its echo sits in the control bar's place: the controls
|
||||
// stay hidden meanwhile.
|
||||
final _boxUp = ValueNotifier(false);
|
||||
double _boxHeight = QuickChatBox.height;
|
||||
// What covers the bottom, as bottom subtitles move above it.
|
||||
final _lift = SubtitleLift();
|
||||
// The Enter that opened the box: kept from the focused widget.
|
||||
KeyEvent? _swallow;
|
||||
// The session's danmaku lines (and your own sent ones).
|
||||
final _lines = DanmakuController();
|
||||
final _danmakuOn = signal(false);
|
||||
late final _full = signal(widget.mode == PlayerMode.full);
|
||||
// The mouse on a floating element, or the Up next popover open, keeps
|
||||
// the controls up.
|
||||
final _hold = ControlsHold();
|
||||
@@ -126,7 +163,8 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
);
|
||||
|
||||
late final SessionCoordinator _coord = AppScope.of(context).coordinator;
|
||||
bool get _full => widget.mode == PlayerMode.full;
|
||||
AppSettings get _settings => _coord.settings;
|
||||
bool get _isFull => widget.mode == PlayerMode.full;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -135,6 +173,7 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
_model;
|
||||
_coord.jellyfin.ensureLoaded();
|
||||
HardwareKeyboard.instance.addHandler(_globalKey);
|
||||
FocusManager.instance.addEarlyKeyEventHandler(_earlyKey);
|
||||
// Tab (AppKeys) reaches the chat through here in fullscreen.
|
||||
AppKeys.addChatToggle(_toggleChat);
|
||||
// Shader messages (preset changes, load failures) become toasts.
|
||||
@@ -148,6 +187,32 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
_stopPopoverWatch = effect(() {
|
||||
_hold.set(#upNextPopover, _coord.queue.popoverOpen.value);
|
||||
});
|
||||
// Subtitle size on every player, live.
|
||||
_stopSubScale = effect(() {
|
||||
final scale = _settings.subScale.value;
|
||||
_coord.playback.generation.value;
|
||||
final p = untracked(() => _coord.playback.current);
|
||||
if (p == null || p.disposed) return;
|
||||
unawaited(p.setProperty('sub-scale', scale.toStringAsFixed(2)).catchError((Object _) {}));
|
||||
});
|
||||
// Danmaku is the style and the full player is up (not sitting out).
|
||||
_stopDanmakuWatch = effect(() {
|
||||
final on = _full.value && _settings.notifyStyle.value == NotifyStyle.danmaku && !_coord.selfAway.value;
|
||||
untracked(() {
|
||||
_danmakuOn.value = on;
|
||||
widget.danmakuActive?.value = on;
|
||||
});
|
||||
});
|
||||
_stopLiftWatch = effect(() {
|
||||
_settings.liftSubtitles.value;
|
||||
_settings.controlsSkin.value;
|
||||
_settings.controlsSize.value;
|
||||
untracked(_retargetLift);
|
||||
});
|
||||
_quick.open.addListener(_boxChanged);
|
||||
_quick.echo.addListener(_boxChanged);
|
||||
_quick.sends.addListener(_sent);
|
||||
_controlsShown.addListener(_retargetLift);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -156,16 +221,48 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
_fullscreen.value = widget.fullscreen;
|
||||
_chatOpen.value = widget.chatOpen;
|
||||
_unread.value = widget.unread;
|
||||
_full.value = _isFull;
|
||||
if (!_isFull) {
|
||||
_quick.close();
|
||||
_model.panelOpen.value = false;
|
||||
}
|
||||
if (oldWidget.quickChat != widget.quickChat) {
|
||||
final old = oldWidget.quickChat ?? _ownChat;
|
||||
old?.open.removeListener(_boxChanged);
|
||||
old?.echo.removeListener(_boxChanged);
|
||||
old?.sends.removeListener(_sent);
|
||||
_quick.open.addListener(_boxChanged);
|
||||
_quick.echo.addListener(_boxChanged);
|
||||
_quick.sends.addListener(_sent);
|
||||
}
|
||||
if (oldWidget.controlsShown != widget.controlsShown) {
|
||||
(oldWidget.controlsShown ?? _ownShown).removeListener(_retargetLift);
|
||||
_controlsShown.addListener(_retargetLift);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
HardwareKeyboard.instance.removeHandler(_globalKey);
|
||||
FocusManager.instance.removeEarlyKeyEventHandler(_earlyKey);
|
||||
AppKeys.removeChatToggle(_toggleChat);
|
||||
_stopShaderWatch();
|
||||
_stopPopoverWatch();
|
||||
_stopSubScale();
|
||||
_stopDanmakuWatch();
|
||||
_stopLiftWatch();
|
||||
widget.danmakuActive?.value = false;
|
||||
_quick.open.removeListener(_boxChanged);
|
||||
_quick.echo.removeListener(_boxChanged);
|
||||
_quick.sends.removeListener(_sent);
|
||||
_controlsShown.removeListener(_retargetLift);
|
||||
_quick.close();
|
||||
_ownChat?.dispose();
|
||||
_noFocus.dispose();
|
||||
_ownShown.dispose();
|
||||
_boxUp.dispose();
|
||||
_lift.dispose();
|
||||
_lines.dispose();
|
||||
_hold.dispose();
|
||||
_model.dispose();
|
||||
super.dispose();
|
||||
@@ -173,6 +270,55 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
|
||||
void _toggleChat() => _model.toggleChat();
|
||||
|
||||
// ------------------------------------------------------- quick chat box
|
||||
|
||||
void _boxChanged() {
|
||||
_boxUp.value = _quick.open.value || _quick.echo.value != null;
|
||||
_retargetLift();
|
||||
}
|
||||
|
||||
/// What covers the bottom of the full player, for the subtitle lift:
|
||||
/// the box (or its echo) from 16 px up, else the control bar while it is
|
||||
/// up when "Subtitles above the controls" is on; 8 px above it.
|
||||
void _retargetLift() {
|
||||
if (!mounted) return;
|
||||
final box = _boxUp.value ? _edge + _boxHeight + 8 : 0.0;
|
||||
final bar = _settings.liftSubtitles.peek() && _controlsShown.value ? _bottomControlsHeight() + 8 : 0.0;
|
||||
_lift.animateTo(math.max(box, bar));
|
||||
}
|
||||
|
||||
void _boxHeightChanged(double h) {
|
||||
if (h == _boxHeight) return;
|
||||
_boxHeight = h;
|
||||
_retargetLift();
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
/// Enter in the box with text: to the chat; your own line flies right
|
||||
/// away when danmaku is the style (the echo is for toasts).
|
||||
void _send(String text) => _coord.sendChat(text);
|
||||
|
||||
void _sent() {
|
||||
if (!_danmakuOn.peek()) return;
|
||||
_quick.echo.value = null;
|
||||
final me = selfAvatarInfo();
|
||||
_lines.add(DanmakuItem(
|
||||
kind: DanmakuKind.chat, sender: me.name, senderColor: me.color, text: _quick.lastSent, own: true, avatar: me));
|
||||
}
|
||||
|
||||
/// Swallows the Enter that opened the box before the focused widget
|
||||
/// (a button keeps focus after a click) acts on it too.
|
||||
KeyEventResult _earlyKey(KeyEvent event) {
|
||||
final s = _swallow;
|
||||
if (s == null || event.logicalKey != s.logicalKey) return KeyEventResult.ignored;
|
||||
if (event is KeyDownEvent && !identical(event, s)) {
|
||||
_swallow = null;
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
if (event is KeyUpEvent) _swallow = null;
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
/// The player keys, while the player fills the body and nothing (a
|
||||
/// dialog, a card, a text field) is in front of it. Space and Tab are
|
||||
/// app-wide ([AppKeys]).
|
||||
@@ -181,7 +327,7 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
if (!AppScope.of(context).navigation.online.peek()) return false;
|
||||
// Mini: Esc is the last step out (a friend's server: asking to leave
|
||||
// it; yours: the player closes).
|
||||
if (!_full) {
|
||||
if (!_isFull) {
|
||||
if (event.logicalKey != LogicalKeyboardKey.escape) return false;
|
||||
if (ModalRoute.of(context)?.isCurrent == false || textInputFocused()) return false;
|
||||
widget.onLeave();
|
||||
@@ -197,12 +343,19 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
return false;
|
||||
}
|
||||
if (textInputFocused()) return false;
|
||||
final key = event.logicalKey;
|
||||
// Enter: the quick chat box (the chat panel's draft), whatever plays.
|
||||
if (key == LogicalKeyboardKey.enter || key == LogicalKeyboardKey.numpadEnter) {
|
||||
if (_coord.selfAway.peek()) return false;
|
||||
_swallow = event;
|
||||
_quick.show();
|
||||
return true;
|
||||
}
|
||||
final now = DateTime.now().toUtc();
|
||||
if (_lastKeyAt != null && now.difference(_lastKeyAt!).inMilliseconds < 350) {
|
||||
return false;
|
||||
}
|
||||
final model = _model;
|
||||
final key = event.logicalKey;
|
||||
// Ctrl+0..6: Anime4K's own mpv keys (off, modes A..C+A).
|
||||
final digit = _shaderKeys.indexOf(key);
|
||||
if (digit >= 0 && HardwareKeyboard.instance.isControlPressed) {
|
||||
@@ -254,7 +407,9 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
}
|
||||
if (key == LogicalKeyboardKey.escape) {
|
||||
_lastKeyAt = now;
|
||||
if (widget.chatOpen) {
|
||||
if (_model.panelOpen.peek()) {
|
||||
_model.panelOpen.value = false;
|
||||
} else if (widget.chatOpen) {
|
||||
widget.onToggleChat();
|
||||
} else if (widget.fullscreen) {
|
||||
widget.onToggleFullscreen();
|
||||
@@ -286,7 +441,7 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
if (!playback.alive.value) {
|
||||
return Center(
|
||||
child: Text('player starting…',
|
||||
style: TextStyle(color: Colors.white70, fontSize: _full ? 14 : 12)),
|
||||
style: TextStyle(color: Colors.white70, fontSize: _isFull ? 14 : 12)),
|
||||
);
|
||||
}
|
||||
if (gen != _videoGen) {
|
||||
@@ -295,22 +450,26 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
}
|
||||
// Never null for the mini: media_kit keeps the last controls
|
||||
// when given null, so the full player's would stay under it.
|
||||
final VideoControlsBuilder controls = _full ? _sessionControls : _noControls;
|
||||
final VideoControlsBuilder controls = _isFull ? _sessionControls : _noControls;
|
||||
final video = widget.videoBuilder?.call(context, controls) ?? _video(controls);
|
||||
// The wheel anywhere on the full player steps the volume
|
||||
// (lists, menus and popovers under the mouse scroll instead).
|
||||
return VolumeWheel(
|
||||
enabled: _full,
|
||||
enabled: _isFull,
|
||||
onStep: _model.stepVolume,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
video,
|
||||
if (_full) ...[
|
||||
SubtitleLifter(playback: _coord.playback, lift: _lift, active: _isFull),
|
||||
if (_isFull) ...[
|
||||
_blockedCard(),
|
||||
_actionArea(),
|
||||
if (widget.fullscreen) ...[if (!_coord.alone.value) _pausedParty(), _unreadChip()],
|
||||
_volumeReadout(),
|
||||
_quickChatBox(),
|
||||
_playerPanel(),
|
||||
if (widget.danmaku case final d?) _danmakuPanel(d),
|
||||
] else
|
||||
_miniOverlay(),
|
||||
// Sitting out: nothing is loaded; the card covers the
|
||||
@@ -324,11 +483,67 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _video(VideoControlsBuilder? controls) => Video(
|
||||
key: _videoKey,
|
||||
controller: videoControllerOf(_coord.playback.video),
|
||||
subtitleViewConfiguration: const SubtitleViewConfiguration(visible: false),
|
||||
controls: controls,
|
||||
Widget _video(VideoControlsBuilder? controls) => VideoOutputSizer(
|
||||
playback: _coord.playback,
|
||||
sharp: _settings.screenResVideo,
|
||||
child: Video(
|
||||
key: _videoKey,
|
||||
controller: videoControllerOf(_coord.playback.video),
|
||||
subtitleViewConfiguration: const SubtitleViewConfiguration(visible: false),
|
||||
controls: controls,
|
||||
),
|
||||
);
|
||||
|
||||
/// The quick chat box in the control bar's place, centred (Enter opens
|
||||
/// it), and the echo of what you sent.
|
||||
Widget _quickChatBox() => Positioned(
|
||||
left: _edge,
|
||||
right: _edge,
|
||||
bottom: _edge,
|
||||
child: Stack(
|
||||
alignment: Alignment.bottomCenter,
|
||||
children: [
|
||||
QuickChatEcho(chat: _quick),
|
||||
QuickChatBox(chat: _quick, onSend: _send, onHeight: _boxHeightChanged),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
/// The player panel docked at the right (⚙ → Player settings), between
|
||||
/// the top bar and the control bar; it stays while they hide.
|
||||
Widget _playerPanel() => Positioned(
|
||||
right: _edge,
|
||||
top: _topBarHeight + 8,
|
||||
bottom: _bottomControlsHeightNow() + _edge,
|
||||
child: SignalBuilder(builder: (context) {
|
||||
final open = _model.panelOpen.value;
|
||||
return AnimatedSwitcher(
|
||||
duration: easeTime,
|
||||
switchInCurve: ease,
|
||||
switchOutCurve: Curves.easeIn,
|
||||
layoutBuilder: (current, previous) =>
|
||||
Stack(alignment: Alignment.topRight, children: [...previous, ?current]),
|
||||
transitionBuilder: (child, a) => FadeTransition(
|
||||
opacity: a,
|
||||
child: SlideTransition(
|
||||
position: Tween(begin: const Offset(0.12, 0), end: Offset.zero).animate(a),
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
child: !open
|
||||
? const SizedBox.shrink(key: ValueKey('no-player-panel'))
|
||||
: ControlsHoldRegion(
|
||||
key: const ValueKey('player-panel-hold'),
|
||||
hold: _hold,
|
||||
child: PlayerPanel(
|
||||
onClose: () => _model.panelOpen.value = false,
|
||||
renderMode: _model.renderMode,
|
||||
volume: _model.volume,
|
||||
onVolume: _model.setVolume,
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
/// Fullscreen hides the strip and its window buttons: the top bar
|
||||
@@ -355,12 +570,30 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
const StartBanner(),
|
||||
// Under the controls, over the picture.
|
||||
SignalBuilder(
|
||||
builder: (context) => _danmakuOn.value
|
||||
? DanmakuNotices(
|
||||
toasts: _coord.toasts,
|
||||
style: _settings.danmakuStyle,
|
||||
controller: _lines,
|
||||
avatar: (name, id) => avatarForName(name, _coord, id),
|
||||
skip: _quick.echoed,
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
),
|
||||
if (widget.danmaku case final d?)
|
||||
SignalBuilder(
|
||||
builder: (context) =>
|
||||
d.active.value ? DanmakuPreviewLayers(preview: d, lift: _lift) : const SizedBox.shrink(),
|
||||
),
|
||||
SessionControls(
|
||||
model: _model,
|
||||
shown: _controlsShown,
|
||||
hold: _hold,
|
||||
onDoubleClick: widget.onToggleFullscreen,
|
||||
windowButtons: _fullscreenWindowButtons(),
|
||||
suppress: _boxUp,
|
||||
),
|
||||
],
|
||||
);
|
||||
@@ -371,12 +604,33 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
static const _edge = 16.0;
|
||||
|
||||
/// The bottom controls' height in the skin and size in use.
|
||||
static double _bottomControlsHeight() {
|
||||
final s = AppSettings.instance;
|
||||
return controlsBottomHeight(s.controlsSkin.value, s.controlsSize.value);
|
||||
}
|
||||
double _bottomControlsHeight() =>
|
||||
controlsBottomHeight(_settings.controlsSkin.value, _settings.controlsSize.value);
|
||||
|
||||
static double get _topBarHeight => controlsTopHeight(AppSettings.instance.controlsSkin.peek());
|
||||
double _bottomControlsHeightNow() =>
|
||||
controlsBottomHeight(_settings.controlsSkin.peek(), _settings.controlsSize.peek());
|
||||
|
||||
double get _topBarHeight => controlsTopHeight(_settings.controlsSkin.peek());
|
||||
|
||||
/// The danmaku preview's panel, top right under the top bar; the mouse
|
||||
/// on it keeps the controls up.
|
||||
Widget _danmakuPanel(DanmakuPreview d) => Positioned(
|
||||
right: _edge,
|
||||
top: _topBarHeight + 8,
|
||||
child: SignalBuilder(
|
||||
builder: (context) => fadeThrough(
|
||||
expand: false,
|
||||
alignment: Alignment.topRight,
|
||||
d.active.value
|
||||
? ControlsHoldRegion(
|
||||
key: const ValueKey('danmaku-panel'),
|
||||
hold: _hold,
|
||||
child: DanmakuSessionPanel(preview: d, coord: _coord),
|
||||
)
|
||||
: const SizedBox.shrink(key: ValueKey('danmaku-none')),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
/// The volume flash after a wheel or key step: a pill under the top bar.
|
||||
Widget _volumeReadout() => Positioned(
|
||||
@@ -401,15 +655,20 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
Widget _actionArea() {
|
||||
return SignalBuilder(builder: (context) {
|
||||
final controls = _bottomControlsHeight();
|
||||
return ValueListenableBuilder<bool>(
|
||||
valueListenable: _controlsShown,
|
||||
builder: (context, shown, child) => AnimatedPositioned(
|
||||
return ListenableBuilder(
|
||||
listenable: Listenable.merge([_controlsShown, _boxUp]),
|
||||
builder: (context, child) => AnimatedPositioned(
|
||||
duration: easeTime,
|
||||
curve: ease,
|
||||
left: _edge,
|
||||
right: _edge,
|
||||
top: 0,
|
||||
bottom: shown ? controls + _edge : _edge,
|
||||
// Above the quick chat box while it (or its echo) is up.
|
||||
bottom: _boxUp.value
|
||||
? _boxHeight + _edge * 2
|
||||
: _controlsShown.value
|
||||
? controls + _edge
|
||||
: _edge,
|
||||
child: child!,
|
||||
),
|
||||
child: CustomMultiChildLayout(
|
||||
@@ -546,7 +805,7 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
SitOutCard(key: const ValueKey('sit-out-card'), compact: !_full),
|
||||
SitOutCard(key: const ValueKey('sit-out-card'), compact: !_isFull),
|
||||
Positioned(
|
||||
top: 4,
|
||||
right: 4,
|
||||
@@ -555,9 +814,9 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
children: [
|
||||
IconButton(
|
||||
focusNode: _noFocus,
|
||||
onPressed: _full ? widget.onCollapse : widget.onExpand,
|
||||
tooltip: _full ? 'Small player' : 'Expand',
|
||||
icon: Icon(_full ? Icons.close_fullscreen : Icons.open_in_full, color: Colors.white, size: 18),
|
||||
onPressed: _isFull ? widget.onCollapse : widget.onExpand,
|
||||
tooltip: _isFull ? 'Small player' : 'Expand',
|
||||
icon: Icon(_isFull ? Icons.close_fullscreen : Icons.open_in_full, color: Colors.white, size: 18),
|
||||
),
|
||||
IconButton(
|
||||
focusNode: _noFocus,
|
||||
|
||||
@@ -8,12 +8,13 @@ import 'package:sync_relay_core/core.dart'
|
||||
show CookieBrowser, CookieFile, NetscapeCookie, YtDlp, YtDlpCookies, cookieBrowsers, parseCookies;
|
||||
|
||||
import '../controls/player_settings.dart';
|
||||
import '../danmaku/danmaku_settings.dart' show DanmakuSettingsPanel;
|
||||
import '../jellyfin/accounts.dart';
|
||||
import '../jellyfin/accounts_panel.dart';
|
||||
import '../schedule/animeflow.dart';
|
||||
import '../screens/home/about_page.dart';
|
||||
import '../screens/home/settings_page.dart';
|
||||
import '../screens/home/ui.dart' show appName, displayVersion;
|
||||
import '../screens/home/ui.dart' show appName, devTools, displayVersion;
|
||||
import '../session/auto_sit_out.dart' show AutoSitOutTimes;
|
||||
import '../sfx/sfx.dart';
|
||||
import '../sfx/sound_bank.dart';
|
||||
@@ -52,6 +53,8 @@ class SettingsTab extends StatelessWidget {
|
||||
final JellyfinAccounts? jellyfin;
|
||||
/// AnimeFlow's `/me` for the integrations page (tests pass a fake).
|
||||
final Future<AnimeFlowMe> Function()? animeFlowMe;
|
||||
/// Dev builds: About → Developer → Danmaku preview.
|
||||
final VoidCallback? onDanmakuPreview;
|
||||
const SettingsTab({
|
||||
super.key,
|
||||
required this.section,
|
||||
@@ -60,6 +63,7 @@ class SettingsTab extends StatelessWidget {
|
||||
this.updates,
|
||||
this.jellyfin,
|
||||
this.animeFlowMe,
|
||||
this.onDanmakuPreview,
|
||||
});
|
||||
|
||||
List<Widget> _page(BuildContext context) {
|
||||
@@ -121,7 +125,8 @@ class SettingsTab extends StatelessWidget {
|
||||
return [
|
||||
const SettingsHintCard(
|
||||
icon: Icons.tune,
|
||||
text: "Player settings are also in the player: ⚙ → Player settings, at the right of its controls.",
|
||||
text: "Player settings are also in the player: ⚙ → Player settings opens a panel at its right edge "
|
||||
"that stays while you watch.",
|
||||
),
|
||||
SettingsPanel(
|
||||
title: 'Player',
|
||||
@@ -162,7 +167,10 @@ class SettingsTab extends StatelessWidget {
|
||||
case SettingsSection.shortcuts:
|
||||
return const [SettingsPanel(title: 'Keyboard shortcuts', child: ShortcutList())];
|
||||
case SettingsSection.about:
|
||||
return [AboutPanel(updates: updates)];
|
||||
return [
|
||||
AboutPanel(updates: updates),
|
||||
if (devTools) DeveloperPanel(onDanmakuPreview: onDanmakuPreview),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -772,7 +780,8 @@ class NotificationsInfo extends StatelessWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
line('Joins, leaves, lost connections and video changes',
|
||||
"Pop up bottom left for 5 s with the person's picture, above the player's controls"),
|
||||
"Pop up bottom left for 5 s with the person's picture, above the player's controls "
|
||||
'(or fly across the picture: Danmaku, below)'),
|
||||
line('Added to Up next', 'With the title, its picture and who added it'),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||
@@ -805,6 +814,11 @@ class NotificationsInfo extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
),
|
||||
SettingsPanel(
|
||||
key: const ValueKey('danmaku-settings'),
|
||||
title: 'Over the player',
|
||||
child: DanmakuSettingsPanel(settings: AppScope.of(context).coordinator.settings),
|
||||
),
|
||||
SettingsPanel(
|
||||
title: 'Sitting out and in the background',
|
||||
child: Column(
|
||||
|
||||
@@ -58,7 +58,11 @@ ShellToast shellToastFor(ToastItem t, AvatarInfo Function(String name) avatar) {
|
||||
/// all (chat pop-ups, sitting out, alone) is the Notifier's policy.
|
||||
class PartyToasts extends StatefulWidget {
|
||||
final VoidCallback? onOpenChat;
|
||||
const PartyToasts({super.key, this.onOpenChat});
|
||||
|
||||
/// Toasts shown another way (flying as danmaku, your quick chat echo):
|
||||
/// left out when they come.
|
||||
final bool Function(ToastItem t)? skip;
|
||||
const PartyToasts({super.key, this.onOpenChat, this.skip});
|
||||
|
||||
@override
|
||||
State<PartyToasts> createState() => _PartyToastsState();
|
||||
@@ -86,6 +90,7 @@ class _PartyToastsState extends State<PartyToasts> {
|
||||
// Only new toasts re-run this, not the party they mention.
|
||||
untracked(() {
|
||||
for (final t in fresh) {
|
||||
if (widget.skip?.call(t) ?? false) continue;
|
||||
_add(shellToastFor(t, (n) => avatarForName(n, coord, t.actorId)));
|
||||
}
|
||||
if (gone.isNotEmpty) _leave(gone);
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/foundation.dart' show ValueListenable;
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:signals/signals_flutter.dart';
|
||||
|
||||
import '../playback/playback_controller.dart';
|
||||
|
||||
// What the session's video view tells mpv about where it is shown: the
|
||||
// size its output renders at ("Sharp video and subtitles") and how far
|
||||
// bottom subtitles move up while something covers the bottom of the
|
||||
// picture (the quick chat box; the control bar when chosen).
|
||||
|
||||
/// The view size the host fits each video output into ([fitOutputSize])
|
||||
/// for a view of [logical] size at [dpr]: its
|
||||
/// physical pixels when [on] and video renders on the GPU, else null (the
|
||||
/// video's own size). Software rendering keeps the video's size: every
|
||||
/// pixel there is CPU work.
|
||||
(int, int)? videoOutputSize(Size logical, double dpr, {required bool on, required RenderMode mode}) {
|
||||
if (!on || mode != RenderMode.gpu) return null;
|
||||
final w = (logical.width * dpr).round(), h = (logical.height * dpr).round();
|
||||
if (w < 16 || h < 16) return null;
|
||||
return (w, h);
|
||||
}
|
||||
|
||||
/// How long a view size must hold before the output follows it (a
|
||||
/// window drag resizes many times a second; each resize reallocates the
|
||||
/// output's texture).
|
||||
const outputResizeDelay = Duration(milliseconds: 120);
|
||||
|
||||
/// Sends the size of the view around [child] to the player host (see
|
||||
/// [videoOutputSize]): at once the first time, then once a resize has
|
||||
/// settled for [outputResizeDelay]. Follows the setting and the render
|
||||
/// mode live.
|
||||
class VideoOutputSizer extends StatefulWidget {
|
||||
final PlaybackController playback;
|
||||
|
||||
/// The setting ([AppSettings.screenResVideo]).
|
||||
final ReadonlySignal<bool> sharp;
|
||||
final Widget child;
|
||||
const VideoOutputSizer({super.key, required this.playback, required this.sharp, required this.child});
|
||||
|
||||
@override
|
||||
State<VideoOutputSizer> createState() => _VideoOutputSizerState();
|
||||
}
|
||||
|
||||
class _VideoOutputSizerState extends State<VideoOutputSizer> {
|
||||
Size? _view;
|
||||
double _dpr = 1;
|
||||
Timer? _debounce;
|
||||
late final void Function() _stop;
|
||||
var _first = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_stop = effect(() {
|
||||
widget.sharp.value;
|
||||
widget.playback.renderMode.value;
|
||||
untracked(_send);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_debounce?.cancel();
|
||||
_stop();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _send() {
|
||||
final view = _view;
|
||||
if (view == null) return;
|
||||
final size = videoOutputSize(view, _dpr,
|
||||
on: widget.sharp.peek(), mode: widget.playback.renderMode.peek());
|
||||
widget.playback.host.setOutputSize(size?.$1, size?.$2);
|
||||
}
|
||||
|
||||
void _resized(Size view, double dpr) {
|
||||
if (view == _view && dpr == _dpr) return;
|
||||
_view = view;
|
||||
_dpr = dpr;
|
||||
_debounce?.cancel();
|
||||
if (_first) {
|
||||
_first = false;
|
||||
_send();
|
||||
return;
|
||||
}
|
||||
_debounce = Timer(outputResizeDelay, _send);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final dpr = MediaQuery.devicePixelRatioOf(context);
|
||||
return LayoutBuilder(builder: (context, box) {
|
||||
_resized(box.biggest, dpr);
|
||||
return widget.child;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// mpv `sub-pos` (100 = where the file puts them) that lifts bottom
|
||||
/// subtitles clear of what covers the bottom of the view ([controls]
|
||||
/// logical px tall: the control bar or the chat line, plus [gap]) in a [view] showing a [videoW] x [videoH] video fitted
|
||||
/// inside it: only as far as the letterbox under the picture does not
|
||||
/// already make room. `sub-pos` moves bottom-aligned lines by a share of
|
||||
/// the picture's height; top-aligned ASS signs stay put.
|
||||
double subtitleLiftPos({required Size view, int? videoW, int? videoH, required double controls, double gap = 8}) {
|
||||
if (view.height <= 0 || view.width <= 0) return 100;
|
||||
var picH = view.height;
|
||||
if (videoW != null && videoH != null && videoW > 0 && videoH > 0) {
|
||||
picH = math.min(view.height, view.width * videoH / videoW);
|
||||
}
|
||||
final bar = (view.height - picH) / 2;
|
||||
final shift = math.max(0.0, controls + gap - bar);
|
||||
return (100 - shift / picH * 100).clamp(50.0, 100.0);
|
||||
}
|
||||
|
||||
/// The subtitle lift moves in [liftStepCount] steps [liftStepGap] apart
|
||||
/// (about 130 ms): each `sub-pos` change makes mpv re-render the
|
||||
/// subtitles, and a playing video shows a change only with its next
|
||||
/// frame, so a change per screen frame (60 a second against a video's 24)
|
||||
/// landed unevenly and looked choppy. One step per frame of a 24 fps
|
||||
/// video, eased out (most of the way in the first step), reads as one
|
||||
/// quick, smooth move.
|
||||
const liftStepCount = 4;
|
||||
const liftStepGap = Duration(milliseconds: 42);
|
||||
|
||||
/// The covered heights a lift from [from] to [to] passes through, one per
|
||||
/// step, eased out; the last is exactly [to]. Empty when there is nothing
|
||||
/// to move.
|
||||
List<double> liftSteps(double from, double to, {int count = liftStepCount}) {
|
||||
if ((to - from).abs() < 0.5) return const [];
|
||||
return [
|
||||
for (var i = 1; i <= count; i++) i == count ? to : from + (to - from) * Curves.easeOutQuad.transform(i / count),
|
||||
];
|
||||
}
|
||||
|
||||
/// How much of the bottom of the view is covered, as it moves: [value]
|
||||
/// the covered height in logical px (0: nothing), stepping toward the
|
||||
/// latest [animateTo] target ([liftSteps], [liftStepGap] apart; a new
|
||||
/// target starts from where it is). mpv's `sub-pos` follows it
|
||||
/// ([SubtitleLifter]), and so do the danmaku preview's mock subtitles.
|
||||
class SubtitleLift extends ChangeNotifier implements ValueListenable<double> {
|
||||
double _value = 0;
|
||||
double _target = 0;
|
||||
List<double> _steps = const [];
|
||||
int _next = 0;
|
||||
Timer? _timer;
|
||||
|
||||
@override
|
||||
double get value => _value;
|
||||
double get target => _target;
|
||||
|
||||
/// Moves toward [target] (px); the first step at once.
|
||||
void animateTo(double target) {
|
||||
target = math.max(0, target);
|
||||
if ((target - _target).abs() < 0.5) return;
|
||||
_target = target;
|
||||
_timer?.cancel();
|
||||
_timer = null;
|
||||
_steps = liftSteps(_value, target);
|
||||
_next = 0;
|
||||
if (_steps.isEmpty) return;
|
||||
_step();
|
||||
if (_next < _steps.length) _timer = Timer.periodic(liftStepGap, (_) => _step());
|
||||
}
|
||||
|
||||
void _step() {
|
||||
if (_next >= _steps.length) return;
|
||||
_value = _steps[_next++];
|
||||
if (_next >= _steps.length) {
|
||||
_timer?.cancel();
|
||||
_timer = null;
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_timer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// Bottom subtitles of the current player sit above what covers the
|
||||
/// bottom of the full player ([lift]: the quick chat box, and the control
|
||||
/// bar while it is up when [AppSettings.liftSubtitles] is on): mpv
|
||||
/// `sub-pos` follows each step of [lift], only as far as the letterbox
|
||||
/// under the picture leaves no room ([subtitleLiftPos]). Top-aligned
|
||||
/// signs stay put. Draws nothing.
|
||||
class SubtitleLifter extends StatefulWidget {
|
||||
final PlaybackController playback;
|
||||
final SubtitleLift lift;
|
||||
|
||||
/// Only the full player lifts.
|
||||
final bool active;
|
||||
const SubtitleLifter({super.key, required this.playback, required this.lift, required this.active});
|
||||
|
||||
@override
|
||||
State<SubtitleLifter> createState() => _SubtitleLifterState();
|
||||
}
|
||||
|
||||
class _SubtitleLifterState extends State<SubtitleLifter> {
|
||||
static const _home = '100.0';
|
||||
String _sent = _home;
|
||||
HostedPlayer? _player;
|
||||
Size _view = Size.zero;
|
||||
late final void Function() _stop;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
widget.lift.addListener(_apply);
|
||||
// A new player starts where the file wants its subtitles.
|
||||
_stop = effect(() {
|
||||
widget.playback.generation.value;
|
||||
untracked(_apply);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(SubtitleLifter old) {
|
||||
super.didUpdateWidget(old);
|
||||
if (old.lift != widget.lift) {
|
||||
old.lift.removeListener(_apply);
|
||||
widget.lift.addListener(_apply);
|
||||
}
|
||||
if (old.active != widget.active) _apply();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
widget.lift.removeListener(_apply);
|
||||
_stop();
|
||||
// Leave the player as the file wants it.
|
||||
if (_sent != _home) _send(_home);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _apply() {
|
||||
if (!mounted) return;
|
||||
final p = widget.playback.current;
|
||||
if (!identical(p, _player)) {
|
||||
_player = p;
|
||||
_sent = _home;
|
||||
}
|
||||
final cover = widget.active ? widget.lift.value : 0.0;
|
||||
final st = p?.state;
|
||||
final pos = cover <= 0
|
||||
? 100.0
|
||||
: subtitleLiftPos(view: _view, videoW: st?.width, videoH: st?.height, controls: cover, gap: 0);
|
||||
final v = pos.toStringAsFixed(1);
|
||||
if (v != _sent) _send(v);
|
||||
}
|
||||
|
||||
void _send(String pos) {
|
||||
_sent = pos;
|
||||
final p = widget.playback.current;
|
||||
if (p == null || p.disposed) return;
|
||||
unawaited(p.setProperty('sub-pos', pos).catchError((Object _) {}));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => LayoutBuilder(builder: (context, box) {
|
||||
if (box.biggest != _view) {
|
||||
_view = box.biggest;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _apply());
|
||||
}
|
||||
return const SizedBox.shrink();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:signals/signals_flutter.dart';
|
||||
|
||||
import '../app/app_graph.dart';
|
||||
import '../session/app_events.dart' show ActionFailed;
|
||||
import '../session/invite.dart';
|
||||
import '../widgets/copy_open.dart';
|
||||
|
||||
/// Your server in the members card. Not shared: "Only you can reach it"
|
||||
/// and Invite (opens it and copies the invite link). Shared: Copy invite
|
||||
/// link (the bare `starsync://` link with the password, at the address
|
||||
/// picked: the internet one when known, else this machine's), a short
|
||||
/// line only when something needs the user ([YourServer.reach]: the port
|
||||
/// needs forwarding, no internet address), Stop sharing, and the other
|
||||
/// addresses (Internet / Home network / VPN) folded under a toggle.
|
||||
class YourInvite extends StatefulWidget {
|
||||
const YourInvite({super.key});
|
||||
|
||||
@override
|
||||
State<YourInvite> createState() => _YourInviteState();
|
||||
}
|
||||
|
||||
class _YourInviteState extends State<YourInvite> {
|
||||
/// The address picked (null: the invite's own, the best one).
|
||||
String? _picked;
|
||||
bool _others = false;
|
||||
|
||||
static String _kindLabel(AddressKind k) => switch (k) {
|
||||
AddressKind.public => 'Internet',
|
||||
AddressKind.lan => 'Home network',
|
||||
AddressKind.vpn => 'VPN',
|
||||
};
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final muted = Theme.of(context).colorScheme.onSurfaceVariant;
|
||||
final graph = AppScope.of(context);
|
||||
final yours = graph.yourServer;
|
||||
return SignalBuilder(builder: (context) {
|
||||
final invite = yours.shared.value;
|
||||
final reach = yours.reach.value;
|
||||
final busy = yours.busy.value || yours.port.value == null;
|
||||
final small = TextStyle(color: muted, fontSize: 12);
|
||||
if (invite == null) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 0, 12, 12),
|
||||
child: Column(
|
||||
key: const ValueKey('your-invite'),
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(8, 0, 8, 6),
|
||||
child: Text('Only you can reach it. Invite opens it to friends; nothing you watch stops.',
|
||||
style: small),
|
||||
),
|
||||
TextButton.icon(
|
||||
key: const ValueKey('copy-invite'),
|
||||
onPressed: busy ? null : () => inviteFriends(context),
|
||||
icon: const Icon(Icons.group_add_outlined, size: 16),
|
||||
label: const Text('Invite'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
final choices = reach?.choices ?? [(host: invite.host, kind: addressKind(invite.host))];
|
||||
final picked = choices.any((c) => c.host == _picked) ? _picked! : invite.host;
|
||||
final kind = choices.where((c) => c.host == picked).firstOrNull?.kind ?? addressKind(picked);
|
||||
final looking = reach?.looking ?? false;
|
||||
final note = switch (kind) {
|
||||
AddressKind.public when reach?.forwarding ?? false =>
|
||||
'Friends outside your home network need port ${invite.port} forwarded to this computer.',
|
||||
AddressKind.public => null,
|
||||
_ when reach != null && !looking && reach.public == null =>
|
||||
"Couldn't find your internet address: this link works on your home network only.",
|
||||
_ => null,
|
||||
};
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 0, 12, 12),
|
||||
child: Column(
|
||||
key: const ValueKey('your-invite'),
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 4,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
Tooltip(
|
||||
message: invite.at(picked).address,
|
||||
child: FilledButton.icon(
|
||||
key: const ValueKey('copy-invite'),
|
||||
onPressed: () {
|
||||
final text = yours.inviteLink(invite.at(picked));
|
||||
graph.joinOffers.copied(text);
|
||||
copyToClipboard(context, text, done: 'Invite link copied');
|
||||
},
|
||||
icon: const Icon(Icons.link, size: 18),
|
||||
label: const Text('Copy invite link'),
|
||||
),
|
||||
),
|
||||
Tooltip(
|
||||
message: 'Sends everyone else out; you and Up next stay',
|
||||
child: TextButton(
|
||||
key: const ValueKey('stop-sharing'),
|
||||
onPressed: busy ? null : () => yours.stopSharing(),
|
||||
child: const Text('Stop sharing'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (looking)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(4, 8, 4, 0),
|
||||
child: Row(children: [
|
||||
const SizedBox(width: 12, height: 12, child: CircularProgressIndicator(strokeWidth: 2)),
|
||||
const SizedBox(width: 8),
|
||||
Flexible(child: Text('Looking up your internet address…', style: small)),
|
||||
]),
|
||||
)
|
||||
else if (note != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(4, 8, 4, 0),
|
||||
child: Text(note, key: const ValueKey('invite-note'), style: small),
|
||||
),
|
||||
if (choices.length > 1) ...[
|
||||
const SizedBox(height: 4),
|
||||
TextButton.icon(
|
||||
key: const ValueKey('invite-other-addresses'),
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: muted,
|
||||
visualDensity: VisualDensity.compact,
|
||||
textStyle: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
onPressed: () => setState(() => _others = !_others),
|
||||
icon: Icon(_others ? Icons.expand_less : Icons.expand_more, size: 16),
|
||||
label: const Text('Other addresses'),
|
||||
),
|
||||
if (_others)
|
||||
for (final c in choices)
|
||||
_AddressRow(
|
||||
key: ValueKey('invite-address-${c.host}'),
|
||||
label: _kindLabel(c.kind),
|
||||
address: invite.at(c.host).address,
|
||||
selected: c.host == picked,
|
||||
onTap: () => setState(() => _picked = c.host),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class _AddressRow extends StatelessWidget {
|
||||
final String label;
|
||||
final String address;
|
||||
final bool selected;
|
||||
final VoidCallback onTap;
|
||||
const _AddressRow(
|
||||
{super.key, required this.label, required this.address, required this.selected, required this.onTap});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
return InkWell(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
|
||||
child: Row(children: [
|
||||
Icon(selected ? Icons.radio_button_checked : Icons.radio_button_off,
|
||||
size: 18, color: selected ? scheme.primary : scheme.onSurfaceVariant),
|
||||
const SizedBox(width: 8),
|
||||
SizedBox(width: 96, child: Text(label, style: TextStyle(fontSize: 12, color: scheme.onSurfaceVariant))),
|
||||
Expanded(child: Text(address, style: const TextStyle(fontFamily: 'monospace'))),
|
||||
]),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Invite: opens your server to friends if it is not yet and copies the
|
||||
/// bare invite link (with the password, when one is set) at the best
|
||||
/// address (the internet one when found, else this machine's); a port
|
||||
/// that can't be opened is a notice.
|
||||
Future<void> inviteFriends(BuildContext context) async {
|
||||
final graph = AppScope.of(context);
|
||||
final Invite invite;
|
||||
try {
|
||||
invite = await graph.yourServer.invite();
|
||||
} catch (e) {
|
||||
final port = graph.forms.form.hostPort;
|
||||
graph.notifier.notify(ActionFailed(
|
||||
"couldn't open your server on port $port: $e — pick another port in Settings → Party & network"));
|
||||
return;
|
||||
}
|
||||
if (!context.mounted) return;
|
||||
final text = graph.yourServer.inviteLink(invite);
|
||||
graph.joinOffers.copied(text);
|
||||
await copyToClipboard(context, text, done: 'Invite link copied');
|
||||
}
|
||||
@@ -184,4 +184,46 @@ class ConnectStore {
|
||||
];
|
||||
await saveQuietly();
|
||||
}
|
||||
|
||||
/// The saved password of a recent server at [host]:[port], '' when none.
|
||||
String secretFor(String host, int port) =>
|
||||
recents.peek().where((r) => r.key == '${host.toLowerCase()}:$port').firstOrNull?.secret ?? '';
|
||||
|
||||
/// Invites the join prompt already asked about (joined or dismissed),
|
||||
/// as fingerprints ([inviteFingerprint]), newest last; never the links.
|
||||
final _seenInvites = <String>[];
|
||||
|
||||
bool seenInvite(String fingerprint) {
|
||||
_loadSeen();
|
||||
return _seenInvites.contains(fingerprint);
|
||||
}
|
||||
|
||||
Future<void> rememberInvite(String fingerprint) async {
|
||||
_loadSeen();
|
||||
_seenInvites
|
||||
..remove(fingerprint)
|
||||
..add(fingerprint);
|
||||
while (_seenInvites.length > maxSeenInvites) {
|
||||
_seenInvites.removeAt(0);
|
||||
}
|
||||
try {
|
||||
final box = connectFormBoxIfOpen() ?? await openConnectFormBox();
|
||||
await box.put(connectKeySeenInvites, [..._seenInvites]);
|
||||
} catch (e) {
|
||||
debugPrint('save seen invites failed: $e');
|
||||
}
|
||||
}
|
||||
|
||||
bool _seenLoaded = false;
|
||||
void _loadSeen() {
|
||||
if (_seenLoaded) return;
|
||||
final box = connectFormBoxIfOpen();
|
||||
if (box == null) return;
|
||||
_seenLoaded = true;
|
||||
final raw = box.get(connectKeySeenInvites);
|
||||
if (raw is List) _seenInvites.insertAll(0, [for (final v in raw) if (v is String && !_seenInvites.contains(v)) v]);
|
||||
}
|
||||
}
|
||||
|
||||
const connectKeySeenInvites = 'seenInvites';
|
||||
const maxSeenInvites = 30;
|
||||
@@ -1,10 +1,12 @@
|
||||
import 'dart:io' show Platform;
|
||||
import 'dart:io' show File, Platform;
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:hive_ce/hive.dart';
|
||||
import 'package:signals/signals_flutter.dart';
|
||||
import 'package:sync_relay_core/core.dart' show UpdateChannel, appVersion, defaultUpdateChannel;
|
||||
|
||||
import '../controls/model/controls_prefs.dart';
|
||||
import '../danmaku/danmaku.dart' show DanmakuStyle;
|
||||
import '../shaders/shader_presets.dart';
|
||||
import '../session/notice_prefs.dart';
|
||||
import 'settings_schema.dart';
|
||||
@@ -44,9 +46,31 @@ const keyAutoSitOut = 'autoSitOut';
|
||||
const _keyChatPopups = 'chatPopups';
|
||||
const _keyNotifyPeople = 'notifyPeople';
|
||||
const _keyShareDiagnostics = 'shareDiagnostics';
|
||||
const _keyWhatsNewSeen = 'whatsNewSeen';
|
||||
const _keyWhatsNewNotes = 'whatsNewNotes';
|
||||
const _keyScreenResVideo = 'screenResVideo';
|
||||
const _keyLiftSubtitles = 'liftSubtitles';
|
||||
const _keyNotifyStyle = 'notifyStyle';
|
||||
const _keySubScale = 'subScale';
|
||||
const _keyDanmakuStyle = 'danmakuStyle';
|
||||
const _keyUpdateChannel = 'updateChannel';
|
||||
|
||||
/// A file next to the binary naming a channel (`dev` / `stable`): the
|
||||
/// app takes it as the update channel on its next start and deletes it.
|
||||
/// The one-time command `tool/dev_release.sh` prints writes it.
|
||||
const updateChannelFileName = 'update-channel';
|
||||
|
||||
enum HwdecMode { auto, off, custom }
|
||||
|
||||
/// How notifications show over the full player ([AppSettings.notifyStyle]).
|
||||
enum NotifyStyle {
|
||||
toasts('Toasts'),
|
||||
danmaku('Danmaku');
|
||||
|
||||
final String label;
|
||||
const NotifyStyle(this.label);
|
||||
}
|
||||
|
||||
/// The mpv `hwdec` of [HwdecMode.auto]. Windows: 'auto-copy' (decoded on
|
||||
/// the GPU, frames copied back): media_kit renders through ANGLE, where
|
||||
/// plain 'auto' picks the dxva2 interop, which cannot create its EGL
|
||||
@@ -56,9 +80,30 @@ String get autoHwdec => Platform.isWindows ? 'auto-copy' : 'auto';
|
||||
class AppSettings {
|
||||
static final AppSettings instance = AppSettings();
|
||||
|
||||
/// The running build's version: picks the default [updateChannel].
|
||||
final String runningVersion;
|
||||
/// The [updateChannelFileName] file to adopt at load (null: none).
|
||||
final File? channelFile;
|
||||
|
||||
AppSettings({this.runningVersion = appVersion, File? channelFile})
|
||||
: channelFile = channelFile ?? _channelFileNextToBinary();
|
||||
|
||||
static File? _channelFileNextToBinary() {
|
||||
try {
|
||||
return File('${File(Platform.resolvedExecutable).parent.path}/$updateChannelFileName');
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
final hwdecMode = signal(HwdecMode.auto);
|
||||
final hwdecCustom = signal('');
|
||||
final autoCheckUpdates = signal(true);
|
||||
/// Where updates come from (Settings → Updates). Never chosen: dev for
|
||||
/// a test build, else stable; a test build stores dev on its first
|
||||
/// start, so the install stays on test builds after a release (also
|
||||
/// when that release updated it).
|
||||
late final updateChannel = signal(defaultUpdateChannel(runningVersion));
|
||||
/// An episode this app puts up (Jellyfin, a file of a local show) is
|
||||
/// continued by the next one (Up next's `auto` item, the server's
|
||||
/// countdown at its end).
|
||||
@@ -87,6 +132,24 @@ class AppSettings {
|
||||
/// Send this app's log lines to the server's session log while in a
|
||||
/// session (never chat). Off: nothing is sent but a note that it is off.
|
||||
final shareDiagnostics = signal(true);
|
||||
/// The video output renders at the player's size on screen, so mpv
|
||||
/// scales the picture (and runs shaders) and draws subtitles at screen
|
||||
/// resolution; off: at the video's own size, scaled by Flutter. Applies
|
||||
/// live (GPU rendering only).
|
||||
final screenResVideo = signal(true);
|
||||
/// Bottom subtitles also move up above the control bar while it is up
|
||||
/// (mpv `sub-pos`; the quick chat box always lifts them). Default off.
|
||||
/// Applies live.
|
||||
final liftSubtitles = signal(false);
|
||||
/// Subtitle size, mpv `sub-scale` (1: as the file has them), 0.5..2.
|
||||
/// Applies live and to each new player.
|
||||
final subScale = signal(1.0);
|
||||
/// How notifications show over the full player: toasts (the default),
|
||||
/// or chat and short notices flying across the picture as danmaku
|
||||
/// (`danmaku/danmaku_notices.dart`; problems stay toasts). Applies live.
|
||||
final notifyStyle = signal(NotifyStyle.toasts);
|
||||
/// How those lines look and move (stored as one map, [DanmakuStyle.toJson]).
|
||||
final danmakuStyle = signal(const DanmakuStyle());
|
||||
/// Chat message toasts (default: all). Applies live.
|
||||
final chatPopups = signal(ChatPopups.all);
|
||||
/// Joins and leaves also notify while sitting out or with the window in
|
||||
@@ -107,6 +170,13 @@ class AppSettings {
|
||||
/// `SfxKind` name -> `SoundPreset` name, per-sound overrides.
|
||||
final soundPresetFor = signal<Map<String, String>>(const {});
|
||||
|
||||
/// What's new (`update/whats_new.dart`): the version whose card was
|
||||
/// shown, and the notes last kept (a version's `ReleaseNotes` as JSON).
|
||||
String? whatsNewSeen;
|
||||
Map<String, Object?>? whatsNewNotes;
|
||||
/// The box was empty when this run opened it: a new install.
|
||||
bool freshInstall = false;
|
||||
|
||||
/// Video shaders: one choice, used on join and changed both here and
|
||||
/// in the session (Ctrl+0..6, the shader button).
|
||||
final shaderPreset = signal(ShaderPreset.off);
|
||||
@@ -131,6 +201,7 @@ class AppSettings {
|
||||
Future<void> _loadNow() async {
|
||||
try {
|
||||
final box = _box = await Hive.openBox(settingsBoxName);
|
||||
freshInstall = box.isEmpty;
|
||||
for (final line in await migrateSettings(box)) {
|
||||
debugPrint(line);
|
||||
}
|
||||
@@ -143,6 +214,7 @@ class AppSettings {
|
||||
if (custom is String) hwdecCustom.value = custom;
|
||||
final auto = box.get(_keyAutoCheckUpdates);
|
||||
if (auto is bool) autoCheckUpdates.value = auto;
|
||||
await _loadUpdateChannel(box);
|
||||
final advance = box.get(_keyAutoAdvance);
|
||||
if (advance is bool) autoAdvance.value = advance;
|
||||
final unready = box.get(_keyUnreadyOnSwap);
|
||||
@@ -165,6 +237,16 @@ class AppSettings {
|
||||
if (hideRe is bool) hideReReleases.value = hideRe;
|
||||
final line = box.get(_keyProgressLine);
|
||||
if (line is bool) progressLine.value = line;
|
||||
final screenRes = box.get(_keyScreenResVideo);
|
||||
if (screenRes is bool) screenResVideo.value = screenRes;
|
||||
final lift = box.get(_keyLiftSubtitles);
|
||||
if (lift is bool) liftSubtitles.value = lift;
|
||||
final scale = box.get(_keySubScale);
|
||||
if (scale is num && scale.isFinite) subScale.value = scale.toDouble().clamp(0.5, 2.0);
|
||||
final notifyAs = box.get(_keyNotifyStyle);
|
||||
notifyStyle.value = NotifyStyle.values.where((m) => m.name == notifyAs).firstOrNull ?? NotifyStyle.toasts;
|
||||
final style = box.get(_keyDanmakuStyle);
|
||||
if (style is Map) danmakuStyle.value = DanmakuStyle.fromJson(style);
|
||||
final share = box.get(_keyShareDiagnostics);
|
||||
if (share is bool) shareDiagnostics.value = share;
|
||||
final popups = box.get(_keyChatPopups);
|
||||
@@ -194,6 +276,10 @@ class AppSettings {
|
||||
shaderQuality.value =
|
||||
ShaderQuality.values.where((q) => q.name == quality).firstOrNull ??
|
||||
ShaderQuality.hq;
|
||||
final seen = box.get(_keyWhatsNewSeen);
|
||||
if (seen is String) whatsNewSeen = seen;
|
||||
final notes = box.get(_keyWhatsNewNotes);
|
||||
if (notes is Map) whatsNewNotes = {for (final e in notes.entries) '${e.key}': e.value};
|
||||
final files = box.get(_keyCustomShaders);
|
||||
if (files is List) {
|
||||
customShaders.value = List.unmodifiable(files.whereType<String>());
|
||||
@@ -218,6 +304,30 @@ class AppSettings {
|
||||
_put(_keyAutoCheckUpdates, on);
|
||||
}
|
||||
|
||||
Future<void> _loadUpdateChannel(Box box) async {
|
||||
UpdateChannel? requested;
|
||||
final file = channelFile;
|
||||
try {
|
||||
if (file != null && file.existsSync()) {
|
||||
requested = UpdateChannel.fromName(file.readAsStringSync().trim());
|
||||
file.deleteSync();
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('update channel file: $e');
|
||||
}
|
||||
final stored = requested ?? UpdateChannel.fromName(box.get(_keyUpdateChannel));
|
||||
final channel = stored ?? defaultUpdateChannel(runningVersion);
|
||||
updateChannel.value = channel;
|
||||
if (channel.name != box.get(_keyUpdateChannel) && (requested != null || channel == UpdateChannel.dev)) {
|
||||
await box.put(_keyUpdateChannel, channel.name);
|
||||
}
|
||||
}
|
||||
|
||||
void setUpdateChannel(UpdateChannel channel) {
|
||||
updateChannel.value = channel;
|
||||
_put(_keyUpdateChannel, channel.name);
|
||||
}
|
||||
|
||||
void setAutoAdvance(bool on) {
|
||||
autoAdvance.value = on;
|
||||
_put(_keyAutoAdvance, on);
|
||||
@@ -265,6 +375,32 @@ class AppSettings {
|
||||
_put(_keyProgressLine, on);
|
||||
}
|
||||
|
||||
void setScreenResVideo(bool on) {
|
||||
screenResVideo.value = on;
|
||||
_put(_keyScreenResVideo, on);
|
||||
}
|
||||
|
||||
void setLiftSubtitles(bool on) {
|
||||
liftSubtitles.value = on;
|
||||
_put(_keyLiftSubtitles, on);
|
||||
}
|
||||
|
||||
void setSubScale(double v) {
|
||||
subScale.value = v.clamp(0.5, 2.0);
|
||||
_put(_keySubScale, subScale.value);
|
||||
}
|
||||
|
||||
void setNotifyStyle(NotifyStyle style) {
|
||||
notifyStyle.value = style;
|
||||
_put(_keyNotifyStyle, style.name);
|
||||
}
|
||||
|
||||
void setDanmakuStyle(DanmakuStyle style) {
|
||||
if (style == danmakuStyle.peek()) return;
|
||||
danmakuStyle.value = style;
|
||||
_put(_keyDanmakuStyle, style.toJson());
|
||||
}
|
||||
|
||||
void setShareDiagnostics(bool on) {
|
||||
shareDiagnostics.value = on;
|
||||
_put(_keyShareDiagnostics, on);
|
||||
@@ -328,6 +464,16 @@ class AppSettings {
|
||||
_put(_keyCustomShaders, [...paths]);
|
||||
}
|
||||
|
||||
Future<void> setWhatsNewSeen(String version) {
|
||||
whatsNewSeen = version;
|
||||
return _put(_keyWhatsNewSeen, version);
|
||||
}
|
||||
|
||||
Future<void> setWhatsNewNotes(Map<String, Object?> notes) {
|
||||
whatsNewNotes = notes;
|
||||
return _put(_keyWhatsNewNotes, notes);
|
||||
}
|
||||
|
||||
Future<void> _put(String key, Object value) async {
|
||||
try {
|
||||
final box = _box ??
|
||||
|
||||
@@ -54,23 +54,41 @@ class UpdateController {
|
||||
|
||||
final String currentVersion;
|
||||
final String manifestUrl;
|
||||
final String devUrl;
|
||||
final String? executablePath;
|
||||
final Directory? installRoot;
|
||||
final RestartFn restart;
|
||||
final bool skipDevBuilds;
|
||||
/// Awaited after the apply, before [restart]: keeps the new version's
|
||||
/// notes for its first start ([WhatsNew.installing], wired by `AppGraph`).
|
||||
Future<void> Function(UpdateManifest manifest)? beforeRestart;
|
||||
/// The update channel each check reads (the setting, wired by
|
||||
/// `AppGraph`); null: [defaultUpdateChannel] of [currentVersion].
|
||||
UpdateChannel Function()? channelOf;
|
||||
|
||||
UpdateController(
|
||||
{this.currentVersion = appVersion,
|
||||
this.manifestUrl = updateManifestUrl,
|
||||
this.devUrl = devManifestUrl,
|
||||
this.executablePath,
|
||||
this.installRoot,
|
||||
this.restart = _closeAndRestart,
|
||||
this.skipDevBuilds = true});
|
||||
this.skipDevBuilds = true,
|
||||
this.beforeRestart,
|
||||
this.channelOf});
|
||||
|
||||
UpdateChannel get channel => channelOf?.call() ?? defaultUpdateChannel(currentVersion);
|
||||
|
||||
final status = ValueNotifier(const UpdateStatus(UpdatePhase.idle));
|
||||
/// The manifest of the last check that read one, newer or not (What's
|
||||
/// new picks up this version's notes from it).
|
||||
final latest = ValueNotifier<UpdateManifest?>(null);
|
||||
/// Outcome of the last finished check, for Settings/About wording
|
||||
/// ("Up to date", "Check failed: ..."); null until one finishes.
|
||||
final lastCheck = ValueNotifier<String?>(null);
|
||||
/// On the stable channel in a test build: the latest release when it
|
||||
/// is older than this build (offered only by [goBackTo], after asking).
|
||||
final stableBehind = ValueNotifier<UpdateManifest?>(null);
|
||||
Future<void>? _check;
|
||||
late final String _exe = executablePath ?? currentExecutablePath();
|
||||
late final Directory? _root = installRoot ?? findInstallRoot(_exe);
|
||||
@@ -106,8 +124,19 @@ class UpdateController {
|
||||
final root = _root;
|
||||
if (root != null) await cleanupUpdateLeftovers(root);
|
||||
try {
|
||||
final ch = channel;
|
||||
final m = await fetchLatestManifest(
|
||||
currentVersion: currentVersion, url: manifestUrl);
|
||||
currentVersion: currentVersion, channel: ch, url: manifestUrl, devUrl: devUrl);
|
||||
latest.value = m;
|
||||
stableBehind.value = ch == UpdateChannel.stable && isStableBehind(currentVersion, m) ? m : null;
|
||||
if (stableBehind.value != null) {
|
||||
_log.info('stable channel: latest release ${m.version} is older than $currentVersion');
|
||||
lastCheck.value = 'Stable is at ${m.version}, older than this test build';
|
||||
if (!_installing && status.value.manifest?.version != m.version) {
|
||||
status.value = const UpdateStatus(UpdatePhase.idle);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!isNewerVersion(currentVersion, m.version)) {
|
||||
_log.info('up to date ($currentVersion, latest ${m.version})');
|
||||
lastCheck.value = 'Up to date';
|
||||
@@ -125,6 +154,27 @@ class UpdateController {
|
||||
}
|
||||
}
|
||||
|
||||
/// The latest release when it is older than this test build (the
|
||||
/// question before going back to stable); null when it is not, or the
|
||||
/// check failed.
|
||||
Future<UpdateManifest?> stableToGoBackTo() async {
|
||||
try {
|
||||
final m = await fetchManifest(url: manifestUrl);
|
||||
return isStableBehind(currentVersion, m) ? m : null;
|
||||
} catch (e) {
|
||||
_log.warning('stable manifest: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Installs [m] although it is older (going back to stable, asked
|
||||
/// first): offered as an update, then [install].
|
||||
Future<void> goBackTo(UpdateManifest m) async {
|
||||
if (_installing) return;
|
||||
status.value = UpdateStatus(UpdatePhase.available, manifest: m, canInstall: _root != null);
|
||||
if (_root != null) await install();
|
||||
}
|
||||
|
||||
bool get _installing => switch (status.value.phase) {
|
||||
UpdatePhase.downloading || UpdatePhase.applying || UpdatePhase.restarting => true,
|
||||
_ => false,
|
||||
@@ -173,6 +223,11 @@ class UpdateController {
|
||||
_log.info('updated to ${m.version}, restarting');
|
||||
status.value =
|
||||
UpdateStatus(UpdatePhase.restarting, manifest: m, canInstall: true);
|
||||
try {
|
||||
await beforeRestart?.call(m);
|
||||
} catch (e) {
|
||||
_log.warning('keeping the notes of ${m.version}: $e');
|
||||
}
|
||||
// Started by an old name (1.2.x's restart, an old launcher): the new
|
||||
// binary next to it.
|
||||
await restart(relaunchExecutable(_exe), launchArgs);
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
|
||||
import '../state/settings.dart';
|
||||
import 'update_controller.dart';
|
||||
|
||||
// "What's new in X.Y.Z" once after an update. The notes come from the
|
||||
// manifest: the one installed (kept before the restart) or, for an update
|
||||
// made another way (1.3.x's updater, `--upgrade`, by hand), the manifest of
|
||||
// the first check that finds this very version. A fresh install shows
|
||||
// nothing; Settings → About opens the card again.
|
||||
|
||||
/// What the card shows for one version.
|
||||
class ReleaseNotes {
|
||||
final String version;
|
||||
|
||||
/// The one-line notes (all a manifest from before the changelog has).
|
||||
final String notes;
|
||||
final ReleaseChanges? changes;
|
||||
const ReleaseNotes({required this.version, this.notes = '', this.changes});
|
||||
|
||||
factory ReleaseNotes.of(UpdateManifest m) => ReleaseNotes(version: m.version, notes: m.notes, changes: m.changes);
|
||||
|
||||
/// Null for anything unreadable.
|
||||
static ReleaseNotes? fromJson(Object? json) {
|
||||
if (json is! Map) return null;
|
||||
final version = json['version'];
|
||||
if (version is! String || version.isEmpty) return null;
|
||||
final notes = json['notes'];
|
||||
return ReleaseNotes(
|
||||
version: version,
|
||||
notes: notes is String ? notes : '',
|
||||
changes: ReleaseChanges.fromJson(json['changes']),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, Object?> toJson() => {
|
||||
'version': version,
|
||||
'notes': notes,
|
||||
if (changes != null) 'changes': changes!.toJson(),
|
||||
};
|
||||
|
||||
bool get isEmpty => notes.trim().isEmpty && (changes?.isEmpty ?? true);
|
||||
}
|
||||
|
||||
/// Where [WhatsNew] keeps its two values (settings box; tests: memory).
|
||||
abstract class WhatsNewStore {
|
||||
/// The version whose card was shown (or that was installed fresh).
|
||||
String? get seen;
|
||||
Future<void> setSeen(String version);
|
||||
|
||||
/// The notes last known for an installed or current version.
|
||||
ReleaseNotes? get notes;
|
||||
Future<void> setNotes(ReleaseNotes notes);
|
||||
|
||||
/// The settings box was empty at start: a fresh install.
|
||||
bool get fresh;
|
||||
}
|
||||
|
||||
class SettingsWhatsNewStore implements WhatsNewStore {
|
||||
final AppSettings settings;
|
||||
SettingsWhatsNewStore(this.settings);
|
||||
@override
|
||||
String? get seen => settings.whatsNewSeen;
|
||||
@override
|
||||
Future<void> setSeen(String version) => settings.setWhatsNewSeen(version);
|
||||
@override
|
||||
ReleaseNotes? get notes => ReleaseNotes.fromJson(settings.whatsNewNotes);
|
||||
@override
|
||||
Future<void> setNotes(ReleaseNotes notes) => settings.setWhatsNewNotes(notes.toJson());
|
||||
@override
|
||||
bool get fresh => settings.freshInstall;
|
||||
}
|
||||
|
||||
class MemoryWhatsNewStore implements WhatsNewStore {
|
||||
@override
|
||||
String? seen;
|
||||
@override
|
||||
ReleaseNotes? notes;
|
||||
@override
|
||||
bool fresh;
|
||||
MemoryWhatsNewStore({this.seen, this.notes, this.fresh = false});
|
||||
@override
|
||||
Future<void> setSeen(String version) async => seen = version;
|
||||
@override
|
||||
Future<void> setNotes(ReleaseNotes notes) async => this.notes = notes;
|
||||
}
|
||||
|
||||
/// Made once by `AppGraph` (the shell and About read it from there).
|
||||
class WhatsNew {
|
||||
|
||||
final String currentVersion;
|
||||
|
||||
final WhatsNewStore store;
|
||||
|
||||
WhatsNew({this.currentVersion = appVersion, required this.store});
|
||||
|
||||
/// The card to show now (once); null when there is none.
|
||||
final due = ValueNotifier<ReleaseNotes?>(null);
|
||||
UpdateController? _updates;
|
||||
|
||||
/// At start, settings loaded: a pending card for this version, and
|
||||
/// [updates]' checks from now on.
|
||||
void start(UpdateController updates) {
|
||||
if (!identical(_updates, updates)) {
|
||||
_updates?.latest.removeListener(_onManifest);
|
||||
_updates = updates..latest.addListener(_onManifest);
|
||||
}
|
||||
final s = store;
|
||||
if (s.seen == currentVersion) return;
|
||||
if (s.seen == null && s.fresh) {
|
||||
// A new install: nothing changed for this person.
|
||||
unawaited(s.setSeen(currentVersion));
|
||||
return;
|
||||
}
|
||||
final known = s.notes;
|
||||
if (known != null && known.version == currentVersion && !known.isEmpty) due.value = known;
|
||||
}
|
||||
|
||||
void _onManifest() {
|
||||
final m = _updates?.latest.value;
|
||||
if (m != null) offer(ReleaseNotes.of(m));
|
||||
}
|
||||
|
||||
/// Notes for some version (an update check's manifest). Kept when they
|
||||
/// are this version's; due when its card was not shown yet.
|
||||
void offer(ReleaseNotes n) {
|
||||
if (n.version != currentVersion || n.isEmpty) return;
|
||||
final s = store;
|
||||
unawaited(s.setNotes(n));
|
||||
if (s.seen != currentVersion && !s.fresh) due.value = n;
|
||||
}
|
||||
|
||||
/// Before the restart into [m]: its notes for the first start.
|
||||
Future<void> installing(UpdateManifest m) => store.setNotes(ReleaseNotes.of(m));
|
||||
|
||||
/// The card was shown: never again for this version.
|
||||
void shown() {
|
||||
due.value = null;
|
||||
unawaited(store.setSeen(currentVersion));
|
||||
}
|
||||
|
||||
/// This version's notes, when known (Settings → About).
|
||||
ReleaseNotes? get current {
|
||||
final n = store.notes;
|
||||
return n != null && n.version == currentVersion && !n.isEmpty ? n : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
|
||||
import '../screens/home/ui.dart' show appName;
|
||||
import '../shell/motion.dart';
|
||||
import '../update/whats_new.dart';
|
||||
import 'app_menu.dart';
|
||||
|
||||
// The "What's new" card: a version's changes (CHANGELOG.md, through the
|
||||
// manifest's `changes`) as Added / Changed / Fixed sections, each with its
|
||||
// icon and colour, on the app's glass. The update band opens it for the
|
||||
// offered version, the first start after an update for the new one
|
||||
// ([WhatsNew]), Settings → About for the running one. Notes without
|
||||
// `changes` (older manifests) show as one paragraph.
|
||||
|
||||
enum ChangeKind {
|
||||
added('Added', Icons.auto_awesome_rounded, Icons.add_rounded, Color(0xFF7EE0A6)),
|
||||
changed('Changed', Icons.tune_rounded, Icons.arrow_forward_rounded, Color(0xFF8DB8FF)),
|
||||
fixed('Fixed', Icons.handyman_rounded, Icons.check_rounded, Color(0xFFFFC772));
|
||||
|
||||
final String label;
|
||||
final IconData icon;
|
||||
/// Before each item.
|
||||
final IconData bullet;
|
||||
|
||||
/// Reads on the dark glass.
|
||||
final Color color;
|
||||
const ChangeKind(this.label, this.icon, this.bullet, this.color);
|
||||
|
||||
List<String> of(ReleaseChanges c) => switch (this) {
|
||||
ChangeKind.added => c.added,
|
||||
ChangeKind.changed => c.changed,
|
||||
ChangeKind.fixed => c.fixed,
|
||||
};
|
||||
}
|
||||
|
||||
/// The first [n] items, Added before Changed before Fixed.
|
||||
List<(ChangeKind, String)> topChanges(ReleaseChanges c, int n) => [
|
||||
for (final k in ChangeKind.values)
|
||||
for (final item in k.of(c)) (k, item),
|
||||
].take(n).toList();
|
||||
|
||||
/// Opens the card over the window. [action]: a button beside Close (the
|
||||
/// band's "Update and restart").
|
||||
Future<void> showWhatsNew(BuildContext context, ReleaseNotes notes, {String? overline, Widget? action}) =>
|
||||
showPopover<void>(
|
||||
context,
|
||||
at: Alignment.center,
|
||||
dim: true,
|
||||
child: WhatsNewCard(notes: notes, overline: overline, action: action),
|
||||
);
|
||||
|
||||
class WhatsNewCard extends StatefulWidget {
|
||||
final ReleaseNotes notes;
|
||||
|
||||
/// The small line over the title (default: the app's name).
|
||||
final String? overline;
|
||||
final Widget? action;
|
||||
|
||||
/// Default: closes the route.
|
||||
final VoidCallback? onClose;
|
||||
const WhatsNewCard({super.key, required this.notes, this.overline, this.action, this.onClose});
|
||||
|
||||
@override
|
||||
State<WhatsNewCard> createState() => _WhatsNewCardState();
|
||||
}
|
||||
|
||||
class _WhatsNewCardState extends State<WhatsNewCard> with SingleTickerProviderStateMixin {
|
||||
late final _intro = AnimationController(
|
||||
vsync: this,
|
||||
duration: easeTime + Duration(milliseconds: 45 * math.min(_rows, 12)),
|
||||
)..forward();
|
||||
|
||||
int get _rows => widget.notes.changes?.length ?? 1;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_intro.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// Row [i] of [_rows] slides up and fades in a little after the one above.
|
||||
Widget _staggered(int i, Widget child) {
|
||||
final start = math.min(i, 12) * 0.06;
|
||||
final a = CurvedAnimation(
|
||||
parent: _intro,
|
||||
curve: Interval(math.min(start, 0.6), 1, curve: ease),
|
||||
);
|
||||
return FadeTransition(
|
||||
opacity: a,
|
||||
child: SlideTransition(
|
||||
position: Tween(begin: const Offset(0, 0.25), end: Offset.zero).animate(a),
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _close() => widget.onClose != null ? widget.onClose!() : Navigator.of(context).maybePop();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final scheme = theme.colorScheme;
|
||||
const muted = Color(0xB3FFFFFF);
|
||||
final n = widget.notes;
|
||||
final changes = n.changes;
|
||||
final screen = MediaQuery.sizeOf(context);
|
||||
final maxHeight = math.min(620.0, screen.height - 64);
|
||||
var row = 0;
|
||||
final body = <Widget>[
|
||||
if (changes == null)
|
||||
_staggered(row++, Text(n.notes, style: const TextStyle(fontSize: 13.5, height: 1.45)))
|
||||
else
|
||||
for (final k in ChangeKind.values)
|
||||
if (k.of(changes).isNotEmpty) ...[
|
||||
_staggered(row++, _SectionHead(kind: k, count: k.of(changes).length)),
|
||||
const SizedBox(height: 6),
|
||||
for (final item in k.of(changes)) _staggered(row++, _Item(kind: k, text: item)),
|
||||
const SizedBox(height: 10),
|
||||
],
|
||||
];
|
||||
return AppGlass(
|
||||
key: const ValueKey('whats-new-card'),
|
||||
radius: 16,
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints.tightFor(width: math.min(460.0, screen.width - 32)).copyWith(maxHeight: maxHeight),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(18, 16, 8, 0),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [scheme.primary, scheme.tertiary.withValues(alpha: 0.85)],
|
||||
),
|
||||
boxShadow: [BoxShadow(color: scheme.primary.withValues(alpha: 0.35), blurRadius: 14)],
|
||||
),
|
||||
child: const SizedBox.square(
|
||||
dimension: 40,
|
||||
child: Icon(Icons.auto_awesome, color: Colors.white, size: 22),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
(widget.overline ?? appName).toUpperCase(),
|
||||
style: TextStyle(
|
||||
color: scheme.primary,
|
||||
fontSize: 10.5,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 1.4,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
"What's new in ${n.version}",
|
||||
key: const ValueKey('whats-new-title'),
|
||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w700, height: 1.2),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Close',
|
||||
visualDensity: VisualDensity.compact,
|
||||
icon: const Icon(Icons.close, size: 18, color: muted),
|
||||
onPressed: _close,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (changes != null && n.notes.trim().isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(18, 10, 18, 0),
|
||||
child: Text(n.notes, style: const TextStyle(color: muted, fontSize: 13, height: 1.4)),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const Divider(height: 1, thickness: 0.5, color: AppMenuStyle.border),
|
||||
Flexible(
|
||||
child: SingleChildScrollView(
|
||||
key: const ValueKey('whats-new-list'),
|
||||
padding: const EdgeInsets.fromLTRB(18, 14, 18, 4),
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: body),
|
||||
),
|
||||
),
|
||||
const Divider(height: 1, thickness: 0.5, color: AppMenuStyle.border),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 10, 12, 10),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
if (widget.action != null) ...[
|
||||
TextButton(key: const ValueKey('whats-new-close'), onPressed: _close, child: const Text('Close')),
|
||||
const SizedBox(width: 8),
|
||||
widget.action!,
|
||||
] else
|
||||
FilledButton.tonal(
|
||||
key: const ValueKey('whats-new-close'),
|
||||
onPressed: _close,
|
||||
child: const Text('Nice'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SectionHead extends StatelessWidget {
|
||||
final ChangeKind kind;
|
||||
final int count;
|
||||
const _SectionHead({required this.kind, required this.count});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Align(
|
||||
alignment: AlignmentDirectional.centerStart,
|
||||
child: DecoratedBox(
|
||||
key: ValueKey('whats-new-${kind.name}'),
|
||||
decoration: BoxDecoration(
|
||||
color: kind.color.withValues(alpha: 0.14),
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
border: Border.all(color: kind.color.withValues(alpha: 0.28), width: 0.5),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(8, 3, 10, 3),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(kind.icon, size: 14, color: kind.color),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
kind.label,
|
||||
style: TextStyle(color: kind.color, fontSize: 12, fontWeight: FontWeight.w700, letterSpacing: 0.3),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text('$count', style: TextStyle(color: kind.color.withValues(alpha: 0.7), fontSize: 11.5)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _Item extends StatelessWidget {
|
||||
final ChangeKind kind;
|
||||
final String text;
|
||||
const _Item({required this.kind, required this.text});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 2, left: 2),
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(color: kind.color.withValues(alpha: 0.16), shape: BoxShape.circle),
|
||||
child: SizedBox.square(
|
||||
dimension: 16,
|
||||
child: Icon(
|
||||
kind.bullet,
|
||||
size: 12,
|
||||
color: kind.color,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(child: Text(text, style: const TextStyle(fontSize: 13.5, height: 1.4))),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// The band's line: the first items with their kind's icon, in [color].
|
||||
class WhatsNewPreview extends StatelessWidget {
|
||||
final ReleaseChanges changes;
|
||||
final Color color;
|
||||
final String? lead;
|
||||
const WhatsNewPreview({super.key, required this.changes, required this.color, this.lead});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final top = topChanges(changes, 3);
|
||||
final style = TextStyle(color: color.withValues(alpha: 0.85), fontSize: 12);
|
||||
return Text.rich(
|
||||
key: const ValueKey('whats-new-preview'),
|
||||
TextSpan(
|
||||
children: [
|
||||
if (lead != null) TextSpan(text: '$lead · '),
|
||||
for (final (i, (k, item)) in top.indexed) ...[
|
||||
if (i > 0) const TextSpan(text: ' '),
|
||||
WidgetSpan(
|
||||
alignment: PlaceholderAlignment.middle,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(right: 4),
|
||||
child: Icon(k.icon, size: 13, color: color.withValues(alpha: 0.85)),
|
||||
),
|
||||
),
|
||||
TextSpan(text: item),
|
||||
],
|
||||
],
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: style,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,9 @@
|
||||
#ifdef GDK_WINDOWING_X11
|
||||
#include <gdk/gdkx.h>
|
||||
#endif
|
||||
#ifdef GDK_WINDOWING_WAYLAND
|
||||
#include <gdk/gdkwayland.h>
|
||||
#endif
|
||||
|
||||
#include "flutter/generated_plugin_registrant.h"
|
||||
|
||||
@@ -24,17 +27,63 @@ static void first_frame_cb(MyApplication* self, FlView* view) {
|
||||
gtk_widget_show(gtk_widget_get_toplevel(GTK_WIDGET(view)));
|
||||
}
|
||||
|
||||
// The desktop's activation token this process was started with
|
||||
// (XDG_ACTIVATION_TOKEN on Wayland, DESKTOP_STARTUP_ID on X11), read
|
||||
// before GTK starts and consumes it. A second start hands it to the
|
||||
// running app (lib/src/platform/single_instance.dart), which brings its
|
||||
// window up with it: compositors only let a window take the focus with
|
||||
// a fresh token.
|
||||
static gchar* launch_token = nullptr;
|
||||
|
||||
// Brings the window up with an activation token from another process.
|
||||
static void present_with_token(GtkWindow* window, const gchar* token) {
|
||||
if (token != nullptr && token[0] != '\0') {
|
||||
GdkDisplay* display = gtk_widget_get_display(GTK_WIDGET(window));
|
||||
#ifdef GDK_WINDOWING_WAYLAND
|
||||
if (GDK_IS_WAYLAND_DISPLAY(display)) {
|
||||
gdk_wayland_display_set_startup_notification_id(display, token);
|
||||
}
|
||||
#endif
|
||||
#ifdef GDK_WINDOWING_X11
|
||||
if (GDK_IS_X11_DISPLAY(display)) {
|
||||
gtk_window_set_startup_id(window, token);
|
||||
}
|
||||
#endif
|
||||
}
|
||||
gtk_window_present(window);
|
||||
}
|
||||
|
||||
// "starsync/window": `attention` sets the urgency hint while the window is
|
||||
// not focused; focusing it clears the hint.
|
||||
// not focused (focusing it clears the hint); `launchToken` is this
|
||||
// process's activation token ('' when none); `present` brings the window
|
||||
// up with a token (argument, may be empty); `exit` ends a second start
|
||||
// that handed over (no exit handlers, see main.cc).
|
||||
static void window_method_cb(FlMethodChannel* channel, FlMethodCall* call,
|
||||
gpointer user_data) {
|
||||
GtkWindow* window = GTK_WINDOW(user_data);
|
||||
g_autoptr(FlMethodResponse) response = nullptr;
|
||||
if (g_strcmp0(fl_method_call_get_name(call), "attention") == 0) {
|
||||
const gchar* method = fl_method_call_get_name(call);
|
||||
if (g_strcmp0(method, "attention") == 0) {
|
||||
if (!gtk_window_is_active(window)) {
|
||||
gtk_window_set_urgency_hint(window, TRUE);
|
||||
}
|
||||
response = FL_METHOD_RESPONSE(fl_method_success_response_new(nullptr));
|
||||
} else if (g_strcmp0(method, "launchToken") == 0) {
|
||||
g_autoptr(FlValue) value =
|
||||
fl_value_new_string(launch_token != nullptr ? launch_token : "");
|
||||
response = FL_METHOD_RESPONSE(fl_method_success_response_new(value));
|
||||
} else if (g_strcmp0(method, "present") == 0) {
|
||||
FlValue* args = fl_method_call_get_args(call);
|
||||
const gchar* token = nullptr;
|
||||
if (args != nullptr && fl_value_get_type(args) == FL_VALUE_TYPE_STRING) {
|
||||
token = fl_value_get_string(args);
|
||||
}
|
||||
present_with_token(window, token);
|
||||
response = FL_METHOD_RESPONSE(fl_method_success_response_new(nullptr));
|
||||
} else if (g_strcmp0(method, "exit") == 0) {
|
||||
fflush(stdout);
|
||||
fflush(stderr);
|
||||
_exit(0);
|
||||
} else {
|
||||
response = FL_METHOD_RESPONSE(fl_method_not_implemented_response_new());
|
||||
}
|
||||
@@ -219,6 +268,11 @@ MyApplication* my_application_new() {
|
||||
// the application to be recognized beyond its binary name.
|
||||
g_set_prgname(APPLICATION_ID);
|
||||
|
||||
// Before GTK starts (it takes these out of the environment).
|
||||
const gchar* token = g_getenv("XDG_ACTIVATION_TOKEN");
|
||||
if (token == nullptr || token[0] == '\0') token = g_getenv("DESKTOP_STARTUP_ID");
|
||||
launch_token = g_strdup(token);
|
||||
|
||||
return MY_APPLICATION(g_object_new(my_application_get_type(),
|
||||
"application-id", APPLICATION_ID, "flags",
|
||||
G_APPLICATION_NON_UNIQUE, nullptr));
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
name: starsync
|
||||
description: Starsync desktop app (repo sync_relay_project). One binary.
|
||||
publish_to: none
|
||||
version: 1.3.2
|
||||
version: 1.4.0
|
||||
environment:
|
||||
sdk: '>=3.13.0-0 <4.0.0'
|
||||
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:starsync/src/danmaku/danmaku.dart';
|
||||
import 'package:starsync/src/danmaku/danmaku_notices.dart';
|
||||
import 'package:starsync/src/session/notifier.dart' show NoticeCategory, ToastItem;
|
||||
import 'package:starsync/src/shell/avatar.dart' show AvatarInfo;
|
||||
|
||||
// Danmaku lanes: free lanes only, no two lines in a lane ever touch, the
|
||||
// density cap with a queue in arrival order, and the layer over a real
|
||||
// widget clock.
|
||||
|
||||
const w = 1000.0;
|
||||
|
||||
/// Lines in one lane never overlap (gap kept) at any sampled time.
|
||||
void expectNoOverlap(DanmakuLanes<int> lanes, double t, {double gap = 0}) {
|
||||
final byLane = <int, List<DanmakuFlight<int>>>{};
|
||||
for (final f in lanes.flights) {
|
||||
byLane.putIfAbsent(f.lane, () => []).add(f);
|
||||
}
|
||||
for (final fs in byLane.values) {
|
||||
fs.sort((a, b) => a.start.compareTo(b.start));
|
||||
for (var i = 1; i < fs.length; i++) {
|
||||
final ahead = fs[i - 1], behind = fs[i];
|
||||
final tail = ahead.x(t, w) + ahead.width;
|
||||
// Only while both are on screen.
|
||||
if (tail <= 0 || behind.x(t, w) >= w) continue;
|
||||
expect(behind.x(t, w), greaterThanOrEqualTo(tail + gap - 1e-6),
|
||||
reason: 'lane ${behind.lane} at t=$t: #${behind.item} reaches #${ahead.item}');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
List<DanmakuFlight<int>> tick(DanmakuLanes<int> lanes, double t, Map<int, double> widths,
|
||||
{int lanesN = 5, int cap = 100, double cross = 8}) =>
|
||||
lanes.tick(t, w: w, lanes: lanesN, cap: cap, crossSecs: cross, measure: (i) => widths[i]!);
|
||||
|
||||
void main() {
|
||||
group('DanmakuLanes', () {
|
||||
test('lines at the same moment take the lanes top down', () {
|
||||
final lanes = DanmakuLanes<int>();
|
||||
final widths = {for (var i = 0; i < 3; i++) i: 200.0};
|
||||
for (var i = 0; i < 3; i++) {
|
||||
lanes.add(i, 0);
|
||||
}
|
||||
final placed = tick(lanes, 0, widths);
|
||||
expect([for (final f in placed) f.lane], [0, 1, 2]);
|
||||
});
|
||||
|
||||
test('a lane is reused once the line in it has fully entered', () {
|
||||
final lanes = DanmakuLanes<int>(gap: 20);
|
||||
final widths = {0: 200.0, 1: 200.0};
|
||||
lanes.add(0, 0);
|
||||
tick(lanes, 0, widths, lanesN: 1);
|
||||
lanes.add(1, 0.1);
|
||||
// 200 px + gap at 150 px/s: not yet in.
|
||||
expect(tick(lanes, 0.1, widths, lanesN: 1), isEmpty);
|
||||
expect(lanes.waiting, 1);
|
||||
// Same length, same speed: it may follow once 220 px have entered.
|
||||
final placed = tick(lanes, 1.6, widths, lanesN: 1);
|
||||
expect(placed.single.lane, 0);
|
||||
});
|
||||
|
||||
test('a long (faster) line never catches a short one ahead in its lane', () {
|
||||
final lanes = DanmakuLanes<int>(gap: 20);
|
||||
final widths = {0: 60.0, 1: 900.0};
|
||||
lanes.add(0, 0);
|
||||
tick(lanes, 0, widths, lanesN: 1);
|
||||
lanes.add(1, 0.5);
|
||||
// The short one is in, but the long one would run into it: wait.
|
||||
for (var t = 0.5; t < 8; t += 0.1) {
|
||||
final placed = tick(lanes, t, widths, lanesN: 1);
|
||||
if (placed.isNotEmpty) {
|
||||
// Placed: from then on they never touch.
|
||||
for (var s = t; s < t + 8; s += 0.05) {
|
||||
expectNoOverlap(lanes, s, gap: 20);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
fail('the long line never got the lane');
|
||||
});
|
||||
|
||||
test('a random stream of mixed lengths never overlaps', () {
|
||||
final rng = math.Random(3);
|
||||
final lanes = DanmakuLanes<int>(gap: 24, maxWaitSecs: 1000, maxWaiting: 10000);
|
||||
final widths = <int, double>{};
|
||||
var n = 0;
|
||||
for (var t = 0.0; t < 120; t += 0.05) {
|
||||
if (rng.nextDouble() < 0.15) {
|
||||
widths[n] = 20 + rng.nextDouble() * 900;
|
||||
lanes.add(n++, t);
|
||||
}
|
||||
tick(lanes, t, widths, lanesN: 6, cap: 14, cross: 6 + rng.nextDouble() * 0.0);
|
||||
expectNoOverlap(lanes, t, gap: 24);
|
||||
expect(lanes.flights.length, lessThanOrEqualTo(14));
|
||||
}
|
||||
expect(n, greaterThan(300));
|
||||
});
|
||||
|
||||
test('the density cap holds the rest in arrival order', () {
|
||||
final lanes = DanmakuLanes<int>();
|
||||
final widths = {for (var i = 0; i < 10; i++) i: 100.0};
|
||||
for (var i = 0; i < 10; i++) {
|
||||
lanes.add(i, 0);
|
||||
}
|
||||
final first = tick(lanes, 0, widths, lanesN: 20, cap: 4);
|
||||
expect([for (final f in first) f.item], [0, 1, 2, 3]);
|
||||
expect(lanes.waiting, 6);
|
||||
// Nothing leaves before the crossing time: still capped.
|
||||
expect(tick(lanes, 7.9, widths, lanesN: 20, cap: 4), isEmpty);
|
||||
final next = tick(lanes, 8, widths, lanesN: 20, cap: 4);
|
||||
expect([for (final f in next) f.item], [4, 5, 6, 7]);
|
||||
});
|
||||
|
||||
test('a line that waited too long is dropped; the queue is bounded', () {
|
||||
final lanes = DanmakuLanes<int>(maxWaitSecs: 5, maxWaiting: 3);
|
||||
final widths = {for (var i = 0; i < 6; i++) i: 100.0};
|
||||
for (var i = 0; i < 5; i++) {
|
||||
lanes.add(i, 0);
|
||||
}
|
||||
expect(lanes.waiting, 3);
|
||||
expect(lanes.dropped, 2);
|
||||
tick(lanes, 6, widths, cap: 0);
|
||||
expect(lanes.waiting, 0);
|
||||
expect(lanes.dropped, 5);
|
||||
});
|
||||
});
|
||||
|
||||
group('DanmakuStyle', () {
|
||||
test('lanes fill the band at the text size', () {
|
||||
const s = DanmakuStyle(bandTop: 0.1, bandBottom: 0.45);
|
||||
// 720 px: 30.24 px text, 45.36 px lanes in a 252 px band.
|
||||
expect(s.lanesFor(720), 5);
|
||||
expect(const DanmakuStyle(bandTop: 0.4, bandBottom: 0.41).lanesFor(720), 1);
|
||||
});
|
||||
});
|
||||
|
||||
testWidgets('the layer flies lines under the cap and lets the rest wait', (tester) async {
|
||||
final c = DanmakuController();
|
||||
addTearDown(c.dispose);
|
||||
await tester.pumpWidget(Directionality(
|
||||
textDirection: TextDirection.ltr,
|
||||
child: SizedBox(
|
||||
width: 800,
|
||||
height: 450,
|
||||
child: DanmakuLayer(
|
||||
controller: c,
|
||||
accent: Colors.purple,
|
||||
style: const DanmakuStyle(maxOnScreen: 3, crossSecs: 4),
|
||||
),
|
||||
),
|
||||
));
|
||||
for (var i = 0; i < 7; i++) {
|
||||
c.add(DanmakuItem(
|
||||
kind: DanmakuKind.chat,
|
||||
sender: 'Bob',
|
||||
text: 'line $i',
|
||||
own: i == 0,
|
||||
avatar: i.isEven ? const AvatarInfo(name: 'Bob', color: Colors.teal) : null));
|
||||
}
|
||||
await tester.pump(const Duration(milliseconds: 16));
|
||||
await tester.pump(const Duration(milliseconds: 16));
|
||||
expect(c.onScreen.value, 3);
|
||||
expect(c.waiting.value, 4);
|
||||
// After a crossing the next ones fly.
|
||||
await tester.pump(const Duration(seconds: 4));
|
||||
await tester.pump(const Duration(milliseconds: 16));
|
||||
expect(c.onScreen.value, 3);
|
||||
expect(c.waiting.value, 1);
|
||||
c.clear();
|
||||
await tester.pump(const Duration(milliseconds: 16));
|
||||
expect(c.onScreen.value, 0);
|
||||
expect(c.waiting.value, 0);
|
||||
await tester.pumpWidget(const SizedBox());
|
||||
});
|
||||
|
||||
|
||||
group('DanmakuStyle as a setting', () {
|
||||
test('round-trips through its map', () {
|
||||
const s = DanmakuStyle(
|
||||
crossSecs: 6, maxOnScreen: 5, bandTop: 0.2, bandBottom: 0.5, opacity: 0.7, size: 1.3, weight: 800,
|
||||
outline: 2, highlightOwn: false, avatars: false);
|
||||
expect(DanmakuStyle.fromJson(s.toJson()), s);
|
||||
});
|
||||
|
||||
test('junk and out-of-range values: defaults and the nearest ends', () {
|
||||
expect(DanmakuStyle.fromJson(const {}), const DanmakuStyle());
|
||||
final s = DanmakuStyle.fromJson(const {
|
||||
'crossSecs': 99,
|
||||
'maxOnScreen': 0,
|
||||
'opacity': 'x',
|
||||
'weight': 1234,
|
||||
'bandTop': 0.6,
|
||||
'bandBottom': 0.61,
|
||||
'avatars': 1,
|
||||
});
|
||||
expect(s.crossSecs, 16);
|
||||
expect(s.maxOnScreen, 1);
|
||||
expect(s.opacity, const DanmakuStyle().opacity);
|
||||
expect(s.weight, 900);
|
||||
expect((s.bandTop, s.bandBottom), (0.10, 0.45), reason: 'a band too thin takes the default');
|
||||
expect(s.avatars, isTrue);
|
||||
});
|
||||
|
||||
test('boldness: the text at its weight, the name a step bolder', () {
|
||||
expect(const DanmakuStyle(weight: 400).fontWeight, FontWeight.w400);
|
||||
expect(const DanmakuStyle(weight: 400).nameWeight, FontWeight.w500);
|
||||
expect(const DanmakuStyle(weight: 900).nameWeight, FontWeight.w900);
|
||||
});
|
||||
});
|
||||
|
||||
group('notifications as danmaku', () {
|
||||
ToastItem toast(String text,
|
||||
{String? actor, NoticeCategory category = NoticeCategory.app, bool chat = false, bool own = false}) =>
|
||||
ToastItem(id: 1, text: text, at: DateTime(2026), actor: actor, chat: chat, own: own, category: category);
|
||||
|
||||
test('chat and short notices fly; problems, nudges and the app stay toasts', () {
|
||||
expect(fliesAsDanmaku(toast('Bob: hi', actor: 'Bob', chat: true, category: NoticeCategory.chat)), isTrue);
|
||||
expect(fliesAsDanmaku(toast('Bob joined', actor: 'Bob', category: NoticeCategory.people)), isTrue);
|
||||
expect(fliesAsDanmaku(toast('Starting now', category: NoticeCategory.start)), isTrue);
|
||||
expect(fliesAsDanmaku(toast('Bob added X to Up next', actor: 'Bob', category: NoticeCategory.upNext)), isTrue);
|
||||
expect(fliesAsDanmaku(toast('Skipped X: gone', category: NoticeCategory.upNext)), isFalse);
|
||||
expect(fliesAsDanmaku(toast("Bob can't play this", actor: 'Bob', category: NoticeCategory.problem)), isFalse);
|
||||
expect(fliesAsDanmaku(toast('Bob nudged you', actor: 'Bob', category: NoticeCategory.nudge)), isFalse);
|
||||
expect(fliesAsDanmaku(toast('Link copied')), isFalse);
|
||||
expect(fliesAsDanmaku(toast('The server restarted', category: NoticeCategory.self)), isFalse);
|
||||
});
|
||||
|
||||
test('lines: the sender before the message, own chat framed', () {
|
||||
final chat = danmakuForToast(toast('Me: hello', actor: 'Me', chat: true, own: true, category: NoticeCategory.chat))!;
|
||||
expect((chat.kind, chat.sender, chat.text, chat.own), (DanmakuKind.chat, 'Me', 'hello', true));
|
||||
final left = danmakuForToast(toast('Bob lost connection', actor: 'Bob', category: NoticeCategory.people))!;
|
||||
expect((left.kind, left.text), (DanmakuKind.leave, 'lost connection'));
|
||||
final start = danmakuForToast(toast('Starting now: Frieren', category: NoticeCategory.start))!;
|
||||
expect((start.kind, start.sender), (DanmakuKind.starting, null));
|
||||
expect(danmakuForToast(toast('Copied')), isNull);
|
||||
});
|
||||
});
|
||||
|
||||
testWidgets('an idle layer asks for no frames', (tester) async {
|
||||
final c = DanmakuController();
|
||||
addTearDown(c.dispose);
|
||||
await tester.pumpWidget(Directionality(
|
||||
textDirection: TextDirection.ltr,
|
||||
child: SizedBox(width: 800, height: 450, child: DanmakuLayer(controller: c, accent: Colors.purple)),
|
||||
));
|
||||
await tester.pump(const Duration(milliseconds: 16));
|
||||
expect(tester.binding.hasScheduledFrame, isFalse);
|
||||
c.add(const DanmakuItem(kind: DanmakuKind.chat, sender: 'Bob', text: 'hi'));
|
||||
await tester.pump(const Duration(milliseconds: 16));
|
||||
expect(c.onScreen.value, 1);
|
||||
expect(tester.binding.hasScheduledFrame, isTrue);
|
||||
await tester.pump(const Duration(seconds: 9));
|
||||
await tester.pump(const Duration(milliseconds: 16));
|
||||
expect(c.onScreen.value, 0);
|
||||
expect(tester.binding.hasScheduledFrame, isFalse);
|
||||
await tester.pumpWidget(const SizedBox());
|
||||
});
|
||||
}
|
||||
@@ -19,16 +19,25 @@ void main() {
|
||||
..createSync(recursive: true);
|
||||
final otherIcon = File('$share/icons/hicolor/48x48/apps/other.png')..createSync();
|
||||
|
||||
await installDesktopEntry(executable: '$root/starsync', dataHome: share, force: true);
|
||||
final registered = <String>[];
|
||||
await installDesktopEntry(
|
||||
executable: '$root/starsync',
|
||||
dataHome: share,
|
||||
force: true,
|
||||
registerScheme: (file, dir) async => registered.add('$file in $dir'));
|
||||
|
||||
expect(oldEntry.existsSync(), isFalse);
|
||||
expect(oldIcon.existsSync(), isFalse);
|
||||
expect(other.existsSync(), isTrue);
|
||||
expect(otherIcon.existsSync(), isTrue);
|
||||
final entry = File('${apps.path}/dev.foxyz.starsync.desktop').readAsStringSync();
|
||||
expect(entry, contains('Exec="$root/starsync"'));
|
||||
expect(entry, contains('StartupWMClass=dev.foxyz.starsync'));
|
||||
expect(entry, contains('StartupWMClass=dev.foxyz.starsync'));
|
||||
expect(entry, contains('Icon=dev.foxyz.starsync'));
|
||||
// starsync:// links: the entry takes them and becomes their default.
|
||||
expect(entry, contains('Exec="$root/starsync" %u'));
|
||||
expect(entry, contains('MimeType=x-scheme-handler/starsync;'));
|
||||
expect(entry, contains('StartupNotify=true'));
|
||||
expect(registered, ['dev.foxyz.starsync.desktop in ${apps.path}']);
|
||||
expect(File('$share/icons/hicolor/48x48/apps/dev.foxyz.starsync.png').existsSync(), isTrue);
|
||||
} finally {
|
||||
await tmp.delete(recursive: true);
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
import 'package:starsync/src/platform/single_instance.dart';
|
||||
import 'package:starsync/src/platform/url_scheme.dart';
|
||||
import 'package:starsync/src/session/invite.dart';
|
||||
import 'package:starsync/src/session/join_offers.dart';
|
||||
import 'package:starsync/src/session/navigation.dart' show Place;
|
||||
import 'package:starsync/src/session/public_address.dart';
|
||||
import 'package:starsync/src/shell/party_strip.dart' show friendInviteLink;
|
||||
import 'package:starsync/src/shell/join_offer_card.dart';
|
||||
import 'package:starsync/src/state/connect_prefs.dart' show RecentServer;
|
||||
|
||||
import 'test_graph.dart';
|
||||
|
||||
// starsync:// invite links, the internet address lookup, the join
|
||||
// prompt (links and the clipboard), one app per profile and the link
|
||||
// handler's registration values.
|
||||
|
||||
String health({String name = "Bob's server", String version = '1.3.2', int viewers = 2}) => jsonEncode({
|
||||
'ok': true,
|
||||
'name': name,
|
||||
'proto': RelayConstants.protoMax,
|
||||
'protoMin': RelayConstants.protoMin,
|
||||
'protoMax': RelayConstants.protoMax,
|
||||
'version': version,
|
||||
'viewers': viewers,
|
||||
});
|
||||
|
||||
final _graph = testGraph(healthGet: (uri) async => health());
|
||||
|
||||
void main() {
|
||||
test('a friend\'s server: the copied link carries the password you joined with', () {
|
||||
final link = friendInviteLink('example.org:6969', "Bob's server", secret: 'hunter2');
|
||||
final back = InviteLink.parse(link)!;
|
||||
expect([back.host, back.port, back.secret], ['example.org', 6969, 'hunter2']);
|
||||
expect(friendInviteLink('example.org:6969', 'Bob'), isNot(contains('pw=')));
|
||||
});
|
||||
|
||||
group('invite link', () {
|
||||
test('round trip: address, name and password, which never shows in toString', () {
|
||||
const link = InviteLink(host: '203.0.113.7', port: 9000, name: "Bob's server & co", secret: 'p@ss w&rd=1');
|
||||
expect(link.uri, startsWith('starsync://203.0.113.7:9000/?name='));
|
||||
expect(InviteLink.parse(link.uri), link);
|
||||
expect(link.toString(), isNot(contains('p@ss')));
|
||||
expect(link.toString(), contains('password'));
|
||||
expect(redactLog('opening ${link.uri}'), isNot(contains('w%26rd')));
|
||||
});
|
||||
|
||||
test('bare links, IPv6, the default port; anything else is no link', () {
|
||||
expect(InviteLink.parse('starsync://192.168.1.20:8080'), const InviteLink(host: '192.168.1.20', port: 8080));
|
||||
expect(const InviteLink(host: '192.168.1.20', port: 8080).uri, 'starsync://192.168.1.20:8080');
|
||||
expect(InviteLink.parse('STARSYNC://vps.example.com'), const InviteLink(host: 'vps.example.com', port: 8080));
|
||||
const v6 = InviteLink(host: 'fe80::1', port: 8080);
|
||||
expect(v6.uri, 'starsync://[fe80::1]:8080');
|
||||
expect(InviteLink.parse(v6.uri)?.port, 8080);
|
||||
expect(InviteLink.parse('starsync://host:99999'), isNull);
|
||||
expect(InviteLink.parse('starsync://'), isNull);
|
||||
expect(InviteLink.parse('https://192.168.1.20:8080'), isNull);
|
||||
expect(InviteLink.parse('starsync://a b:1'), isNull);
|
||||
});
|
||||
|
||||
test('the invite is just its link, with the password when one is set', () {
|
||||
const invite = Invite(host: '192.168.1.20', port: 8080, name: "Bob's server", password: true, version: '1.3.2');
|
||||
final link = invite.link(secret: 'hunter2');
|
||||
expect(link.uri, 'starsync://192.168.1.20:8080/?name=Bob%27s+server&pw=hunter2');
|
||||
expect(inviteIn(link.uri), link);
|
||||
expect(link.toString(), isNot(contains('hunter2')));
|
||||
expect(const Invite(host: '192.168.1.20', port: 8080).link().uri, 'starsync://192.168.1.20:8080');
|
||||
// A 1.3.x app pasting it into the Join card's address.
|
||||
expect(addressIn(link.uri), (host: '192.168.1.20', port: '8080'));
|
||||
expect(invite.at('203.0.113.7').address, '203.0.113.7:8080');
|
||||
});
|
||||
|
||||
test('found in a text: a link anywhere, an old plain invite, never a bare address', () {
|
||||
expect(inviteIn('hey, come watch: starsync://10.0.0.5:8081/?name=Ann see you')?.address, '10.0.0.5:8081');
|
||||
expect(inviteIn('Join me on Starsync: 192.168.1.20:8080 (ask me for the password) · Starsync 1.3.0'),
|
||||
const InviteLink(host: '192.168.1.20', port: 8080));
|
||||
expect(inviteIn('192.168.1.20:8080'), isNull);
|
||||
expect(inviteIn('see https://example.com:443/x'), isNull);
|
||||
expect(inviteIn('${'x' * 5000} starsync://1.2.3.4:1'), isNull, reason: 'only the start of a long text');
|
||||
});
|
||||
|
||||
test('reach: the internet address first; forwarding when it is the router\'s', () {
|
||||
const behindRouter = InviteReach(public: '203.0.113.7', local: ['192.168.1.20', '100.64.1.2']);
|
||||
expect(behindRouter.best, '203.0.113.7');
|
||||
expect(behindRouter.forwarding, isTrue);
|
||||
expect([for (final c in behindRouter.choices) c.kind], [AddressKind.public, AddressKind.lan, AddressKind.vpn]);
|
||||
const direct = InviteReach(public: '203.0.113.7', local: ['203.0.113.7']);
|
||||
expect(direct.forwarding, isFalse);
|
||||
expect(direct.choices.length, 1);
|
||||
expect(const InviteReach(local: ['192.168.1.20']).best, '192.168.1.20');
|
||||
expect(isPublicIpv4('8.8.8.8'), isTrue);
|
||||
for (final ip in ['10.1.2.3', '192.168.0.1', '172.20.0.1', '100.100.1.1', '127.0.0.1', '198.18.0.1', '::1', 'x']) {
|
||||
expect(isPublicIpv4(ip), isFalse, reason: ip);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
group('internet address', () {
|
||||
final a = Uri.parse('https://a.test'), b = Uri.parse('https://b.test'), c = Uri.parse('https://c.test');
|
||||
|
||||
test('the first service with a public address wins; failing, private or odd answers are skipped', () async {
|
||||
final asked = <String>[];
|
||||
final ip = await lookUpPublicAddress(
|
||||
services: [a, b, c],
|
||||
get: (uri) async {
|
||||
asked.add(uri.host);
|
||||
return switch (uri.host) {
|
||||
'a.test' => throw const SocketException('blocked'),
|
||||
'b.test' => '192.168.1.1\n',
|
||||
_ => '203.0.113.7\n',
|
||||
};
|
||||
},
|
||||
);
|
||||
expect(ip, '203.0.113.7');
|
||||
expect(asked, ['a.test', 'b.test', 'c.test']);
|
||||
});
|
||||
|
||||
test('a slow service is given up on; none answering is no address', () async {
|
||||
final ip = await lookUpPublicAddress(
|
||||
services: [a, b],
|
||||
each: const Duration(milliseconds: 50),
|
||||
get: (uri) => uri.host == 'a.test' ? Completer<String>().future : Future.value('203.0.113.9'),
|
||||
);
|
||||
expect(ip, '203.0.113.9');
|
||||
expect(await lookUpPublicAddress(services: [a], get: (_) async => '<html>nope</html>'), isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('join prompt', () {
|
||||
final offers = _graph.joinOffers;
|
||||
final yours = _graph.yourServer;
|
||||
tearDown(() {
|
||||
offers.offer.value = null;
|
||||
yours.shared.value = null;
|
||||
yours.reach.value = null;
|
||||
_graph.forms.recents.value = const [];
|
||||
});
|
||||
|
||||
test('a clicked link becomes an offer with what /health says', () async {
|
||||
offers.fromArgs(['--whatever', 'starsync://203.0.113.7:8080/?name=Bob']);
|
||||
expect(offers.offer.peek()?.source, OfferSource.link);
|
||||
expect(offers.offer.peek()?.question, 'Join Bob (203.0.113.7:8080)?');
|
||||
await pumpEventQueue();
|
||||
final o = offers.offer.peek()!;
|
||||
expect(o.question, "Join Bob's server (203.0.113.7:8080)?");
|
||||
expect(o.detail, 'Starsync 1.3.2 · 2 watching');
|
||||
});
|
||||
|
||||
test('the clipboard: offered once; Not now and Join both stop it coming back', () async {
|
||||
offers.fromClipboard('come: starsync://203.0.113.20:8080');
|
||||
expect(offers.offer.peek()?.source, OfferSource.clipboard);
|
||||
await offers.dismiss();
|
||||
expect(offers.offer.peek(), isNull);
|
||||
offers.fromClipboard('something else');
|
||||
offers.fromClipboard('come: starsync://203.0.113.20:8080');
|
||||
expect(offers.offer.peek(), isNull, reason: 'dismissed');
|
||||
|
||||
offers.fromClipboard('starsync://203.0.113.21:8080');
|
||||
_graph.forms.recents.value = const [RecentServer(host: '203.0.113.21', port: '8080', secret: 'saved')];
|
||||
final link = offers.accept();
|
||||
expect(link?.secret, 'saved', reason: 'a link without its password takes the saved one');
|
||||
offers.fromClipboard('x');
|
||||
offers.fromClipboard('starsync://203.0.113.21:8080');
|
||||
expect(offers.offer.peek(), isNull, reason: 'joined');
|
||||
});
|
||||
|
||||
test('never your own invite, nor the server you are in, nor plain text', () {
|
||||
yours.port.value = 40000;
|
||||
addTearDown(() => yours.port.value = null);
|
||||
yours.shared.value = const Invite(host: '203.0.113.7', port: 8080);
|
||||
yours.reach.value = const InviteReach(public: '203.0.113.7', local: ['192.168.1.20']);
|
||||
offers.fromClipboard('starsync://192.168.1.20:8080');
|
||||
offers.fromClipboard('starsync://127.0.0.1:40000');
|
||||
expect(offers.offer.peek(), isNull);
|
||||
// What this app just copied is not offered back to it.
|
||||
const copied = 'starsync://198.51.100.4:8080';
|
||||
offers.copied(copied);
|
||||
offers.fromClipboard(copied);
|
||||
expect(offers.offer.peek(), isNull);
|
||||
offers.fromClipboard('just some text 10.0.0.1:80');
|
||||
expect(offers.offer.peek(), isNull);
|
||||
|
||||
final nav = _graph.navigation;
|
||||
nav.place.value = Place.friend;
|
||||
nav.address.value = '198.51.100.9:8080';
|
||||
addTearDown(() {
|
||||
nav.place.value = Place.none;
|
||||
nav.address.value = '';
|
||||
});
|
||||
offers.fromArgs(['starsync://198.51.100.9:8080']);
|
||||
expect(offers.offer.peek(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('the card: the question, Join and Not now', (tester) async {
|
||||
var joined = 0;
|
||||
await tester.pumpWidget(scoped(
|
||||
_graph,
|
||||
MaterialApp(
|
||||
theme: ThemeData.dark(),
|
||||
home: Scaffold(body: JoinOfferCard(offers: offers, onJoin: () => joined++)))));
|
||||
expect(find.byKey(const ValueKey('join-offer')), findsNothing);
|
||||
offers.offer.value = const JoinOffer(InviteLink(host: '203.0.113.7', port: 8080, name: 'Ann'), OfferSource.clipboard);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Join Ann (203.0.113.7:8080)?'), findsOneWidget);
|
||||
expect(find.text('Invite on your clipboard · Checking the server…'), findsOneWidget);
|
||||
await tester.tap(find.byKey(const ValueKey('join-offer-join')));
|
||||
expect(joined, 1);
|
||||
await tester.tap(find.byKey(const ValueKey('join-offer-dismiss')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byKey(const ValueKey('join-offer')), findsNothing);
|
||||
});
|
||||
});
|
||||
|
||||
group('one app per profile', () {
|
||||
late Directory tmp;
|
||||
setUp(() async => tmp = await Directory.systemTemp.createTemp('starsync-instance'));
|
||||
tearDown(() async => tmp.delete(recursive: true));
|
||||
|
||||
test('a second start hands its arguments and token over and ends', () async {
|
||||
final first = await InstanceLock.claim(tmp.path);
|
||||
expect(first, isNotNull);
|
||||
expect(first!.held, isTrue);
|
||||
final got = first.handoffs.first;
|
||||
int? allowed;
|
||||
final second = await InstanceLock.claim(tmp.path,
|
||||
args: ['starsync://1.2.3.4:8080/?pw=x'], activation: 'tok', beforeSend: (p) => allowed = p);
|
||||
expect(second, isNull, reason: 'handed over');
|
||||
expect(allowed, pid);
|
||||
final h = await got;
|
||||
expect(h.args, ['starsync://1.2.3.4:8080/?pw=x']);
|
||||
expect(h.activation, 'tok');
|
||||
await first.release();
|
||||
});
|
||||
|
||||
test('each profile folder is its own instance', () async {
|
||||
final a = await InstanceLock.claim('${tmp.path}/a');
|
||||
final b = await InstanceLock.claim('${tmp.path}/b');
|
||||
expect(a?.held, isTrue);
|
||||
expect(b?.held, isTrue);
|
||||
await a!.release();
|
||||
await b!.release();
|
||||
});
|
||||
|
||||
test('a start while the app closes waits and takes over once it is gone', () async {
|
||||
final first = (await InstanceLock.claim(tmp.path))!;
|
||||
first.closing();
|
||||
final second = InstanceLock.claim(tmp.path, retryEvery: const Duration(milliseconds: 20));
|
||||
await Future<void>.delayed(const Duration(milliseconds: 150));
|
||||
await first.release();
|
||||
final taken = await second;
|
||||
expect(taken?.held, isTrue);
|
||||
await taken!.release();
|
||||
});
|
||||
|
||||
test('a wrong token is refused (nothing handed over)', () async {
|
||||
final first = (await InstanceLock.claim(tmp.path))!;
|
||||
var handed = false;
|
||||
first.handoffs.listen((_) => handed = true);
|
||||
final s = await Socket.connect(InternetAddress.loopbackIPv4, first.port!);
|
||||
s.writeln(jsonEncode({'token': 'guess', 'args': ['x']}));
|
||||
final reply = await utf8.decoder.bind(s).join();
|
||||
expect(reply, contains('"ok":false'));
|
||||
expect(handed, isFalse);
|
||||
s.destroy();
|
||||
await first.release();
|
||||
});
|
||||
|
||||
test('no lock and nobody answering: starts anyway after a while', () async {
|
||||
final first = (await InstanceLock.claim(tmp.path))!;
|
||||
first.closing();
|
||||
final started = await InstanceLock.claim(tmp.path,
|
||||
retryFor: const Duration(milliseconds: 100),
|
||||
closingWait: const Duration(milliseconds: 100),
|
||||
retryEvery: const Duration(milliseconds: 20));
|
||||
expect(started?.held, isFalse);
|
||||
await first.release();
|
||||
});
|
||||
});
|
||||
|
||||
group('link handler', () {
|
||||
test('Windows: HKCU\\Software\\Classes\\starsync opens starsync.exe with the link', () {
|
||||
final v = windowsSchemeValues(r'C:\Apps\Starsync\starsync.exe');
|
||||
expect(v.first, (r'Software\Classes\starsync', '', 'URL:Starsync invite'));
|
||||
expect(v, contains((r'Software\Classes\starsync', 'URL Protocol', '')));
|
||||
expect(v.last, (r'Software\Classes\starsync\shell\open\command', '', r'"C:\Apps\Starsync\starsync.exe" "%1"'));
|
||||
});
|
||||
|
||||
test('an old-named copy points links at starsync next to it', () {
|
||||
expect(schemeExecutable(r'C:\S\watch_together.exe', exists: (_) => true), r'C:\S\starsync.exe');
|
||||
expect(schemeExecutable(r'C:\S\watch_together.exe', exists: (_) => false), r'C:\S\watch_together.exe');
|
||||
expect(schemeExecutable('/opt/s/watch_together', exists: (_) => true), '/opt/s/starsync');
|
||||
expect(schemeExecutable('/opt/s/starsync', exists: (_) => true), '/opt/s/starsync');
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -66,6 +66,8 @@ const _alone = NoticeContext(self: Presence.watching, alone: true);
|
||||
const _background = NoticeContext(self: Presence.watching, window: WindowActivity.unfocused);
|
||||
const _minimisedAway = NoticeContext(self: Presence.away, window: WindowActivity.minimised);
|
||||
const _playing = NoticeContext(self: Presence.watching, playing: true);
|
||||
const _chatOpen = NoticeContext(self: Presence.watching, chatOpen: true);
|
||||
const _chatOpenBackground = NoticeContext(self: Presence.watching, chatOpen: true, window: WindowActivity.unfocused);
|
||||
const _chatPaused = NoticeContext(self: Presence.watching, playing: true, prefs: NotifyPrefs(chat: ChatPopups.paused));
|
||||
const _chatOff = NoticeContext(self: Presence.watching, window: WindowActivity.unfocused, prefs: NotifyPrefs(chat: ChatPopups.off));
|
||||
|
||||
@@ -115,6 +117,10 @@ final _rows = <_Row>[
|
||||
_Row('finished', _of(_feed('finished', actor: 'server')), Finished, logged: true),
|
||||
_Row('chat from someone', _of(_feed('chat', reason: 'hi')), ChatPosted,
|
||||
toast: 'Ann: hi', actor: 'Ann', sound: SfxKind.chat, logged: true),
|
||||
_Row('chat with the chat open: only its sound', _of(_feed('chat', reason: 'hi')), ChatPosted,
|
||||
context: _chatOpen, sound: SfxKind.chat, logged: true),
|
||||
_Row('chat open but the window in the background: pops up and goes out', _of(_feed('chat', reason: 'hi')), ChatPosted,
|
||||
context: _chatOpenBackground, toast: 'Ann: hi', actor: 'Ann', sound: SfxKind.chat, logged: true, system: ('Ann', 'hi')),
|
||||
_Row('own chat', _of(_feed('chat', actor: 'Me', reason: 'hi')), ChatPosted,
|
||||
toast: 'Me: hi', actor: 'Me', logged: true),
|
||||
_Row('rename', _of(_feed('rename', target: 'Annie')), MemberRenamed, logged: true),
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/services.dart' show SystemChannels;
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:signals/signals_flutter.dart' show signal;
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
import 'package:starsync/src/controls/frame.dart';
|
||||
import 'package:starsync/src/controls/model/controls_prefs.dart';
|
||||
import 'package:starsync/src/controls/player_settings.dart';
|
||||
import 'package:starsync/src/controls/player_panel.dart';
|
||||
import 'package:starsync/src/danmaku/danmaku.dart' show DanmakuStyle;
|
||||
import 'package:starsync/src/jellyfin/track_prefs.dart';
|
||||
import 'package:starsync/src/schedule/animeflow.dart';
|
||||
import 'package:starsync/src/shaders/shader_presets.dart';
|
||||
@@ -37,6 +38,8 @@ void bigWindow(WidgetTester tester, [Size size = const Size(1280, 1100)]) {
|
||||
}
|
||||
|
||||
Future<void> pick(WidgetTester tester, String key, String item) async {
|
||||
await tester.ensureVisible(find.byKey(ValueKey(key)));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.byKey(ValueKey(key)));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text(item).last);
|
||||
@@ -57,6 +60,8 @@ void main() {
|
||||
s.shaderPreset.value = ShaderPreset.off;
|
||||
s.shaderQuality.value = ShaderQuality.hq;
|
||||
s.hwdecMode.value = HwdecMode.auto;
|
||||
s.liftSubtitles.value = false;
|
||||
s.notifyStyle.value = NotifyStyle.toasts;
|
||||
coord.playback.tracks.value = const [];
|
||||
coord.serverState.value = null;
|
||||
coord.hostFile = null;
|
||||
@@ -64,76 +69,66 @@ void main() {
|
||||
_graph.forms.recents.value = const [];
|
||||
});
|
||||
|
||||
Widget opener() => Builder(
|
||||
builder: (context) => TextButton(
|
||||
key: const ValueKey('open-settings'),
|
||||
onPressed: () => showPlayerSettings(context),
|
||||
child: const Text('settings'),
|
||||
),
|
||||
);
|
||||
|
||||
group('player settings card', () {
|
||||
testWidgets('lists every player setting, changes apply live', (tester) async {
|
||||
group('player panel', () {
|
||||
testWidgets('tracks, subtitles, picture, sound, notifications and controls; all live', (tester) async {
|
||||
bigWindow(tester);
|
||||
coord.playback.tracks.value = const [
|
||||
MpvTrack(id: 1, type: JellyfinStreamType.audio, language: 'jpn', codec: 'eac3', channels: 2, selected: true),
|
||||
MpvTrack(id: 1, type: JellyfinStreamType.subtitle, language: 'eng', codec: 'ass', title: 'Full'),
|
||||
];
|
||||
await tester.pumpWidget(scoped(_graph, app(Align(alignment: Alignment.bottomRight, child: opener()))));
|
||||
await tester.tap(find.byKey(const ValueKey('open-settings')));
|
||||
var volume = 80.0;
|
||||
var closed = 0;
|
||||
final vol = signal(volume);
|
||||
await tester.pumpWidget(scoped(
|
||||
_graph,
|
||||
app(Align(
|
||||
alignment: Alignment.topRight,
|
||||
child: SizedBox(
|
||||
height: 1000,
|
||||
child: PlayerPanel(
|
||||
onClose: () => closed++,
|
||||
volume: vol,
|
||||
onVolume: (v) => vol.value = volume = v,
|
||||
),
|
||||
),
|
||||
))));
|
||||
await tester.pumpAndSettle();
|
||||
final card = find.byKey(const ValueKey('player-settings-card'));
|
||||
expect(card, findsOneWidget);
|
||||
for (final label in [
|
||||
'Player design',
|
||||
'Progress line when hidden',
|
||||
'Upscaling shader',
|
||||
'Hardware decoding',
|
||||
'Subtitles',
|
||||
'Audio',
|
||||
]) {
|
||||
expect(find.descendant(of: card, matching: find.text(label)), findsOneWidget, reason: label);
|
||||
final panel = find.byKey(const ValueKey('player-panel'));
|
||||
expect(panel, findsOneWidget);
|
||||
for (final label in ['Subtitles', 'Audio', 'Size', 'Upscaling', 'Hardware decoding', 'Volume']) {
|
||||
expect(find.descendant(of: panel, matching: find.text(label)), findsWidgets, reason: label);
|
||||
}
|
||||
// Subtitles off (none selected), audio on the selected track.
|
||||
expect(find.descendant(of: find.byKey(const ValueKey('subtitle-track')), matching: find.text('Off')),
|
||||
expect(find.descendant(of: find.byKey(const ValueKey('panel-subtitles')), matching: find.text('Off')),
|
||||
findsOneWidget);
|
||||
expect(find.descendant(of: find.byKey(const ValueKey('audio-track')), matching: find.textContaining('Japanese')),
|
||||
expect(find.descendant(of: find.byKey(const ValueKey('panel-audio')), matching: find.textContaining('Japanese')),
|
||||
findsOneWidget);
|
||||
|
||||
await pick(tester, 'player-design', 'Minimal');
|
||||
expect(s.controlsSkin.value, ControlsSkin.minimal);
|
||||
await tester.tap(find.byKey(const ValueKey('progress-line')));
|
||||
await tester.pump();
|
||||
expect(s.progressLine.value, isFalse);
|
||||
await pick(tester, 'shader-preset', 'Mode B');
|
||||
await pick(tester, 'panel-shader', 'Mode B');
|
||||
expect(s.shaderPreset.value, ShaderPreset.b);
|
||||
await pick(tester, 'shader-quality', 'Fast');
|
||||
expect(s.shaderQuality.value, ShaderQuality.fast);
|
||||
await pick(tester, 'hwdec', 'Off');
|
||||
await pick(tester, 'panel-hwdec', 'Off');
|
||||
expect(s.hwdec.value, 'no');
|
||||
// Esc closes it.
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.escape);
|
||||
await tester.tap(find.descendant(of: find.byKey(const ValueKey('panel-lift')), matching: find.byType(Switch)));
|
||||
await tester.pump();
|
||||
expect(s.liftSubtitles.value, isTrue);
|
||||
await tester.drag(find.descendant(of: find.byKey(const ValueKey('panel-volume')), matching: find.byType(Slider)),
|
||||
const Offset(-60, 0));
|
||||
expect(volume, lessThan(80));
|
||||
// Folded sections open with a click: notifications, then danmaku's knobs.
|
||||
expect(find.byKey(const ValueKey('notify-style')), findsNothing);
|
||||
await tester.tap(find.byKey(const ValueKey('panel-section-Notifications')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(card, findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('keeps the controls up while open', (tester) async {
|
||||
bigWindow(tester);
|
||||
await tester.pumpWidget(scoped(_graph, app(ControlsFrame(
|
||||
hideAfter: const Duration(seconds: 1),
|
||||
builder: (context, visible) => Align(alignment: Alignment.bottomRight, child: opener()),
|
||||
))));
|
||||
final controls = tester.state<ControlsFrameState>(find.byType(ControlsFrame));
|
||||
await tester.tap(find.byKey(const ValueKey('open-settings')));
|
||||
await pick(tester, 'notify-style', 'Danmaku');
|
||||
expect(s.notifyStyle.value, NotifyStyle.danmaku);
|
||||
expect(find.byKey(const ValueKey('danmaku-weight')), findsOneWidget);
|
||||
await tester.ensureVisible(find.byKey(const ValueKey('panel-section-Controls')));
|
||||
await tester.tap(find.byKey(const ValueKey('panel-section-Controls')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byKey(const ValueKey('player-settings-card')), findsOneWidget);
|
||||
expect(find.text('Standard'), findsOneWidget, reason: 'the current skin');
|
||||
await tester.pump(const Duration(seconds: 3));
|
||||
expect(controls.visible, isTrue, reason: 'held while the card is open');
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.escape);
|
||||
await pick(tester, 'panel-design', 'Minimal');
|
||||
expect(s.controlsSkin.value, ControlsSkin.minimal);
|
||||
await tester.ensureVisible(find.byKey(const ValueKey('player-panel-close')));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.pump(const Duration(seconds: 2));
|
||||
expect(controls.visible, isFalse);
|
||||
await tester.tap(find.byKey(const ValueKey('player-panel-close')));
|
||||
expect(closed, 1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -169,6 +164,37 @@ void main() {
|
||||
expect(find.byIcon(Icons.hub_outlined), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('Notifications: Toasts / Danmaku, a preview strip and every knob of the look', (tester) async {
|
||||
addTearDown(() => s.danmakuStyle.value = const DanmakuStyle());
|
||||
await pumpSettings(tester, SettingsSection.notifications, size: const Size(1280, 2600));
|
||||
final card = find.byKey(const ValueKey('danmaku-settings'));
|
||||
expect(card, findsOneWidget);
|
||||
expect(find.byKey(const ValueKey('danmaku-preview-strip')), findsOneWidget);
|
||||
expect(s.notifyStyle.value, NotifyStyle.toasts, reason: 'toasts by default');
|
||||
await pick(tester, 'notify-style', 'Danmaku');
|
||||
expect(s.notifyStyle.value, NotifyStyle.danmaku);
|
||||
for (final k in ['cross', 'cap', 'band', 'opacity', 'size', 'weight', 'outline', 'avatars', 'own']) {
|
||||
expect(find.byKey(ValueKey('danmaku-$k')), findsOneWidget, reason: k);
|
||||
}
|
||||
// Boldness: one step right of Semibold is Bold.
|
||||
final weight = find.byKey(const ValueKey('danmaku-weight'));
|
||||
final box = tester.getRect(weight);
|
||||
await tester.tapAt(Offset(box.left + box.width * 0.6, box.center.dy));
|
||||
await tester.pump();
|
||||
expect(s.danmakuStyle.value.weight, 700);
|
||||
expect(find.text('Bold'), findsOneWidget);
|
||||
await tester.tap(find.byKey(const ValueKey('danmaku-avatars')));
|
||||
await tester.pump();
|
||||
expect(s.danmakuStyle.value.avatars, isFalse);
|
||||
await tester.tap(find.byKey(const ValueKey('danmaku-reset')));
|
||||
await tester.pump();
|
||||
expect(s.danmakuStyle.value, const DanmakuStyle());
|
||||
// The strip rests after a while and plays again on a click.
|
||||
await tester.pump(const Duration(seconds: 20));
|
||||
expect(find.byKey(const ValueKey('danmaku-preview-play')), findsOneWidget);
|
||||
await tester.pumpAndSettle();
|
||||
});
|
||||
|
||||
testWidgets('Player: the card\'s rows, a pointer to it, and the controls\' choices', (tester) async {
|
||||
await pumpSettings(tester, SettingsSection.player, size: const Size(1280, 2400));
|
||||
expect(find.textContaining('also in the player: ⚙ → Player settings'), findsOneWidget);
|
||||
@@ -177,6 +203,8 @@ void main() {
|
||||
'progress-line',
|
||||
'shader-preset',
|
||||
'hwdec',
|
||||
'sub-scale',
|
||||
'lift-subtitles',
|
||||
'time-display',
|
||||
'controls-size',
|
||||
'controls-hide',
|
||||
@@ -274,12 +302,12 @@ void main() {
|
||||
expect(isFakeIp(InternetAddress('198.20.1.1').rawAddress), isFalse);
|
||||
});
|
||||
|
||||
test('an invite: the address, whether to ask for a password, the version; pasted back', () {
|
||||
test('an invite: the address, its link; pasted back', () {
|
||||
const invite = Invite(host: '192.168.1.20', port: 8080, password: true, version: '1.3.0');
|
||||
expect(invite.address, '192.168.1.20:8080');
|
||||
expect(invite.text, 'Join me on Starsync: 192.168.1.20:8080 (ask me for the password) · Starsync 1.3.0');
|
||||
expect(invite.link().uri, 'starsync://192.168.1.20:8080');
|
||||
expect(invite.fp, isNull, reason: 'reserved for pinned certificates');
|
||||
expect(addressIn(invite.text), (host: '192.168.1.20', port: '8080'));
|
||||
expect(addressIn(invite.link().uri), (host: '192.168.1.20', port: '8080'));
|
||||
expect(addressIn('vps.example.com:9000'), (host: 'vps.example.com', port: '9000'));
|
||||
expect(addressIn('[fe80::1]:8080'), (host: 'fe80::1', port: '8080'));
|
||||
expect(addressIn('just a host'), isNull);
|
||||
@@ -336,12 +364,54 @@ void main() {
|
||||
expect(find.byKey(const ValueKey('stop-sharing')), findsNothing);
|
||||
|
||||
yours.shared.value = const Invite(host: '192.168.1.20', port: 8080, version: '1.3.0');
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('192.168.1.20:8080'), findsOneWidget);
|
||||
expect(find.text('Copy invite'), findsOneWidget);
|
||||
yours.reach.value = const InviteReach(local: ['192.168.1.20'], looking: true);
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 400));
|
||||
expect(find.text('Looking up your internet address…'), findsOneWidget);
|
||||
expect(find.text('Copy invite link'), findsOneWidget);
|
||||
expect(find.byKey(const ValueKey('stop-sharing')), findsOneWidget);
|
||||
expect(find.textContaining('port 8080 forwarded'), findsOneWidget);
|
||||
expect(find.textContaining('VPS'), findsOneWidget);
|
||||
// One address so far: nothing else to pick, nothing to say.
|
||||
expect(find.byKey(const ValueKey('invite-other-addresses')), findsNothing);
|
||||
expect(find.byKey(const ValueKey('invite-note')), findsNothing);
|
||||
|
||||
// Behind a router: the internet address, one line on forwarding;
|
||||
// the home address waits behind Other addresses.
|
||||
yours.shared.value = const Invite(host: '203.0.113.7', port: 8080, version: '1.3.0');
|
||||
yours.reach.value = const InviteReach(public: '203.0.113.7', local: ['192.168.1.20']);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Looking up your internet address…'), findsNothing);
|
||||
expect(find.text('Friends outside your home network need port 8080 forwarded to this computer.'),
|
||||
findsOneWidget);
|
||||
expect(find.text('192.168.1.20:8080'), findsNothing);
|
||||
Future<String?> copy() async {
|
||||
String? copied;
|
||||
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, (call) async {
|
||||
if (call.method == 'Clipboard.setData') copied = (call.arguments as Map)['text'] as String;
|
||||
return null;
|
||||
});
|
||||
addTearDown(() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, null));
|
||||
await tester.tap(find.byKey(const ValueKey('copy-invite')));
|
||||
await tester.pumpAndSettle();
|
||||
return copied;
|
||||
}
|
||||
|
||||
// Just the link, nothing around it.
|
||||
expect(await copy(), matches(RegExp(r'^starsync://203\.0\.113\.7:8080(/\?name=\S+)?$')));
|
||||
|
||||
await tester.tap(find.byKey(const ValueKey('invite-other-addresses')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('203.0.113.7:8080'), findsOneWidget);
|
||||
await tester.tap(find.text('192.168.1.20:8080'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byKey(const ValueKey('invite-note')), findsNothing, reason: 'home network picked');
|
||||
expect(await copy(), startsWith('starsync://192.168.1.20:8080'));
|
||||
|
||||
// No internet address found: one line saying so.
|
||||
yours.reach.value = const InviteReach(local: ['192.168.1.20']);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.textContaining("Couldn't find your internet address"), findsOneWidget);
|
||||
expect(_graph.notifier.toasts.peek().map((t) => t.text), contains('Invite link copied'));
|
||||
await tester.pump(const Duration(seconds: 6)); // the toasts go
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -343,6 +343,13 @@ void main() {
|
||||
final relay = stateMediaRelay(a.serverState.peek()!);
|
||||
expect(relay?.queueId, id, reason: 'the warm relay became the media\'s');
|
||||
expect(relay?.fetch, mode == LinkRelay.server);
|
||||
if (mode == LinkRelay.me) {
|
||||
// Read once: alice's preload and the warm relay read one shared
|
||||
// reader, and the swap carried it on as the media's.
|
||||
final one = a.media.shared.currentFor(linkUrl);
|
||||
expect(one, isNotNull, reason: 'alice reads the link once');
|
||||
expect(one!.bytesIn, greaterThan(0));
|
||||
}
|
||||
if (preload) {
|
||||
for (final x in all.values) {
|
||||
expect(x.switchTrace.of(x.serverState.peek()!.mediaId), contains(SwitchTrace.preloaded),
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:media_kit/media_kit.dart';
|
||||
import 'package:media_kit_video/media_kit_video.dart' show VideoState;
|
||||
import 'package:starsync/src/controls/frame.dart';
|
||||
import 'package:starsync/src/danmaku/danmaku.dart';
|
||||
import 'package:starsync/src/session/navigation.dart' show Place;
|
||||
import 'package:starsync/src/session/notifier.dart' show NoticeCategory, ToastItem;
|
||||
import 'package:starsync/src/shell/quick_chat.dart';
|
||||
import 'package:starsync/src/shell/session_player.dart';
|
||||
import 'package:starsync/src/state/settings.dart';
|
||||
import 'package:starsync/src/widgets/app_keys.dart';
|
||||
|
||||
import 'test_graph.dart';
|
||||
|
||||
/// This file's composition root (see test_graph.dart).
|
||||
final _graph = testGraph();
|
||||
|
||||
// The quick chat box over the full player: Enter opens it whatever plays,
|
||||
// keys go to it (not to the player), the cursor and the controls stay as
|
||||
// the mouse left them, a send shows your own line (echo, or danmaku), Esc
|
||||
// and the idle time close it with the draft kept, the draft is the chat
|
||||
// panel's. The picture is a black box with the session's controls on it
|
||||
// (never a media_kit `Video`).
|
||||
|
||||
void main() {
|
||||
final coord = _graph.coordinator;
|
||||
final s = coord.settings;
|
||||
|
||||
setUpAll(() async {
|
||||
MediaKit.ensureInitialized();
|
||||
await coord.media.start();
|
||||
_graph.navigation.place.value = Place.yours;
|
||||
});
|
||||
|
||||
late TextEditingController draft;
|
||||
late QuickChat quick;
|
||||
var fullscreenToggles = 0;
|
||||
var readyToggles = 0;
|
||||
|
||||
setUp(() {
|
||||
draft = TextEditingController();
|
||||
quick = QuickChat(draft: draft);
|
||||
fullscreenToggles = 0;
|
||||
readyToggles = 0;
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
s.notifyStyle.value = NotifyStyle.toasts;
|
||||
});
|
||||
|
||||
Future<void> mount(WidgetTester tester) async {
|
||||
tester.view.physicalSize = const Size(1280, 720);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.reset);
|
||||
await tester.pumpWidget(scoped(
|
||||
_graph,
|
||||
MaterialApp(
|
||||
theme: ThemeData.dark(),
|
||||
home: AppKeys(
|
||||
onToggleReady: () => readyToggles++,
|
||||
child: Scaffold(
|
||||
body: SessionPlayer(
|
||||
mode: PlayerMode.full,
|
||||
fullscreen: false,
|
||||
onExpand: () {},
|
||||
onCollapse: () {},
|
||||
onClose: () {},
|
||||
onToggleFullscreen: () => fullscreenToggles++,
|
||||
onToggleChat: () {},
|
||||
onChangeVideo: () {},
|
||||
onOpenIntegrations: () {},
|
||||
onLeave: () {},
|
||||
windowControls: false,
|
||||
quickChat: quick,
|
||||
videoBuilder: (context, controls) => Stack(fit: StackFit.expand, children: [
|
||||
const ColoredBox(color: Colors.black),
|
||||
if (controls != null) controls(VideoState()),
|
||||
]),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
));
|
||||
await tester.pump();
|
||||
}
|
||||
|
||||
Future<void> unmount(WidgetTester tester) async {
|
||||
await tester.pumpWidget(scoped(_graph, const SizedBox()));
|
||||
await tester.pump(const Duration(seconds: 5));
|
||||
quick.dispose();
|
||||
draft.dispose();
|
||||
}
|
||||
|
||||
ControlsFrameState frame(WidgetTester tester) => tester.state<ControlsFrameState>(find.byType(ControlsFrame));
|
||||
MouseCursor frameCursor(WidgetTester tester) => tester
|
||||
.widget<MouseRegion>(find.descendant(of: find.byType(ControlsFrame), matching: find.byType(MouseRegion)).first)
|
||||
.cursor;
|
||||
final field = find.byKey(const ValueKey('quick-chat-field'));
|
||||
bool fieldFocused() => quick.focus.hasFocus;
|
||||
|
||||
Future<void> hideControls(WidgetTester tester) async {
|
||||
await tester.pump(Duration(seconds: s.controlsHideSecs.value + 1));
|
||||
await tester.pump(const Duration(milliseconds: 500));
|
||||
}
|
||||
|
||||
testWidgets('Enter opens the box; keys go to it; the cursor and controls stay hidden', (tester) async {
|
||||
await mount(tester);
|
||||
// A still mouse over the bottom centre, where the box comes up.
|
||||
final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse);
|
||||
await mouse.addPointer(location: const Offset(640, 690));
|
||||
addTearDown(mouse.removePointer);
|
||||
await tester.pump();
|
||||
await hideControls(tester);
|
||||
expect(frame(tester).visible, isFalse);
|
||||
expect(frameCursor(tester), SystemMouseCursors.none);
|
||||
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
|
||||
await tester.pump();
|
||||
expect(quick.open.value, isTrue);
|
||||
expect(fieldFocused(), isTrue, reason: 'the box has the keyboard at once');
|
||||
await tester.pump(const Duration(milliseconds: 400));
|
||||
expect(frame(tester).visible, isFalse, reason: 'typing is not mouse activity');
|
||||
expect(frameCursor(tester), SystemMouseCursors.none);
|
||||
final fieldWidget = tester.widget<TextField>(field);
|
||||
expect(fieldWidget.mouseCursor, SystemMouseCursors.none, reason: 'no I-beam over a still mouse');
|
||||
|
||||
// Space and F reach the box, not ready or fullscreen.
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.space);
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.keyF);
|
||||
await tester.pump();
|
||||
expect(readyToggles, 0);
|
||||
expect(fullscreenToggles, 0);
|
||||
|
||||
// Esc closes; the draft stays; the controls do not come back.
|
||||
await tester.enterText(field, 'half a thought');
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.escape);
|
||||
await tester.pump(const Duration(milliseconds: 400));
|
||||
expect(quick.open.value, isFalse);
|
||||
expect(draft.text, 'half a thought');
|
||||
expect(frame(tester).visible, isFalse, reason: 'the box going is not mouse movement');
|
||||
expect(frameCursor(tester), SystemMouseCursors.none);
|
||||
|
||||
// The player's keys are back.
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.space);
|
||||
expect(readyToggles, 1);
|
||||
await tester.pump(const Duration(milliseconds: 400));
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.keyF);
|
||||
expect(fullscreenToggles, 1);
|
||||
|
||||
// Enter brings the draft back.
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
|
||||
await tester.pump();
|
||||
expect(quick.open.value, isTrue);
|
||||
expect(tester.widget<TextField>(field).controller!.text, 'half a thought');
|
||||
await unmount(tester);
|
||||
});
|
||||
|
||||
testWidgets('a send shows your own line: the echo with toasts, and the controls stay hidden', (tester) async {
|
||||
await mount(tester);
|
||||
await hideControls(tester);
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
|
||||
await tester.pump();
|
||||
await tester.enterText(field, 'hello there');
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
|
||||
await tester.pump();
|
||||
expect(quick.open.value, isFalse);
|
||||
expect(draft.text, isEmpty, reason: 'sent: the shared draft is cleared');
|
||||
await tester.pump(const Duration(milliseconds: 400));
|
||||
final echo = find.byKey(const ValueKey('quick-chat-echo'));
|
||||
expect(echo, findsOneWidget);
|
||||
expect(find.descendant(of: echo, matching: find.text('hello there')), findsOneWidget);
|
||||
expect(frame(tester).visible, isFalse, reason: 'held down while the echo is in their place');
|
||||
// Your own chat toast coming back is not shown again.
|
||||
final own = ToastItem(
|
||||
id: 99, text: 'Me: hello there', at: DateTime.now(), actor: 'Me', chat: true, own: true,
|
||||
category: NoticeCategory.chat);
|
||||
expect(quick.echoed(own), isTrue);
|
||||
await tester.pump(QuickChat.echoTime);
|
||||
await tester.pump(const Duration(milliseconds: 400));
|
||||
expect(echo, findsNothing);
|
||||
expect(frame(tester).visible, isFalse);
|
||||
// Empty Enter just closes.
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
|
||||
await tester.pump();
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
|
||||
await tester.pump(const Duration(milliseconds: 400));
|
||||
expect(quick.open.value, isFalse);
|
||||
expect(echo, findsNothing);
|
||||
await unmount(tester);
|
||||
});
|
||||
|
||||
testWidgets('with danmaku your own line flies instead of the echo', (tester) async {
|
||||
s.notifyStyle.value = NotifyStyle.danmaku;
|
||||
await mount(tester);
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
|
||||
await tester.pump();
|
||||
await tester.enterText(field, 'flying');
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
|
||||
await tester.pump(const Duration(milliseconds: 16));
|
||||
await tester.pump(const Duration(milliseconds: 16));
|
||||
expect(find.byKey(const ValueKey('quick-chat-echo')), findsNothing);
|
||||
final line = find.descendant(of: find.byType(DanmakuLayer), matching: find.textContaining('flying', findRichText: true));
|
||||
expect(line, findsWidgets);
|
||||
await tester.pump(const Duration(seconds: 10));
|
||||
await unmount(tester);
|
||||
});
|
||||
|
||||
testWidgets('the box hides by itself after a while without typing; the draft stays', (tester) async {
|
||||
await mount(tester);
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
|
||||
await tester.pump();
|
||||
await tester.enterText(field, 'later');
|
||||
await tester.pump(QuickChat.idleClose - const Duration(seconds: 1));
|
||||
expect(quick.open.value, isTrue);
|
||||
await tester.enterText(field, 'later, maybe');
|
||||
await tester.pump(QuickChat.idleClose - const Duration(seconds: 1));
|
||||
expect(quick.open.value, isTrue, reason: 'typing restarts the wait');
|
||||
await tester.pump(const Duration(seconds: 2));
|
||||
expect(quick.open.value, isFalse);
|
||||
expect(draft.text, 'later, maybe');
|
||||
await unmount(tester);
|
||||
});
|
||||
}
|
||||
@@ -159,12 +159,12 @@ class _BareSource {
|
||||
}
|
||||
}
|
||||
|
||||
/// Serves one file with ranges; [requests] counts hits.
|
||||
/// Serves one file with ranges; [requests] lists hits (user agent, URL).
|
||||
Future<(HttpServer, List<String>)> _origin(String file) async {
|
||||
final s = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
|
||||
final requests = <String>[];
|
||||
s.listen((req) async {
|
||||
requests.add(req.uri.toString());
|
||||
requests.add('${req.headers.value('user-agent')} ${req.uri}');
|
||||
final size = await File(file).length();
|
||||
final r = resolveMediaRequest(rangeHeader: req.headers.value('range'), size: size, filename: 'v.mkv');
|
||||
req.response.statusCode = r.status;
|
||||
@@ -274,6 +274,12 @@ void main() {
|
||||
expect(alice.serverState.peek()!.toJson().toString(), isNot(contains('SECRET')));
|
||||
await playTogether([alice, bob]);
|
||||
expect(server.hub.relay!.buffers.single.bytesToViewers, greaterThan(0));
|
||||
// Read once: bob's player plays his shared reader's loopback, so
|
||||
// every hit at the origin is that reader's (Dart's), none mpv's.
|
||||
final one = bob.media.shared.currentFor(link);
|
||||
expect(one, isNotNull);
|
||||
expect(one!.bytesIn, greaterThan(0));
|
||||
expect(hits.where((h) => !h.startsWith('Dart/')), isEmpty, reason: '$hits');
|
||||
|
||||
final before = hits.length;
|
||||
expect(await alice.swapPicked(LinkMedia(link, relay: LinkRelay.server)), isNull);
|
||||
|
||||
@@ -14,6 +14,7 @@ import 'package:starsync/src/session/session_coordinator.dart';
|
||||
import 'package:starsync/src/state/server_isolate.dart';
|
||||
import 'package:sync_relay_server/server.dart' show SyncServer;
|
||||
|
||||
import 'package:starsync/src/session/invite.dart' show InviteLink;
|
||||
import 'package:starsync/src/session/navigation.dart' show Place;
|
||||
|
||||
import 'test_graph.dart';
|
||||
@@ -166,6 +167,11 @@ void main() {
|
||||
final invite = await yours.invite();
|
||||
expect(invite.port, port);
|
||||
expect(invite.password, isTrue);
|
||||
// The link is the key: the password it was shared with, even when
|
||||
// the setting changes later.
|
||||
graph.forms.form.hostSecret = 'changed';
|
||||
expect(InviteLink.parse(yours.inviteLink(invite))?.secret, 'pw');
|
||||
graph.forms.form.hostSecret = 'pw';
|
||||
expect(yours.shared.peek(), invite);
|
||||
expect(identical(await yours.invite(), invite), isTrue, reason: 'already shared: the same invite');
|
||||
|
||||
|
||||
@@ -103,10 +103,11 @@ void main() {
|
||||
test('copies the bundle once per version, redoes a broken copy', () async {
|
||||
final support = _fresh('shaders_support');
|
||||
final dir = await extractAnime4k(support);
|
||||
expect(dir.path, endsWith('shaders/anime4k-$anime4kVersion'));
|
||||
expect(dir.path, endsWith('shaders/anime4k-$anime4kVersion-$anime4kCopyRevision'));
|
||||
for (final f in [...anime4kAllFiles, 'LICENSE']) {
|
||||
expect(File('${dir.path}/$f').readAsBytesSync(),
|
||||
File('$anime4kAssetDir/$f').readAsBytesSync(),
|
||||
final shipped = File('$anime4kAssetDir/$f');
|
||||
expect(File('${dir.path}/$f').readAsStringSync(),
|
||||
f.endsWith('.glsl') ? highpAnime4k(shipped.readAsStringSync()) : shipped.readAsStringSync(),
|
||||
reason: f);
|
||||
}
|
||||
// Second run is a no-op: a marker-tagged copy stays untouched.
|
||||
@@ -132,6 +133,36 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
test('Anime4K reads go through a highp helper, once per pass', () {
|
||||
const src = '''// header
|
||||
//!DESC pass 1
|
||||
//!HOOK MAIN
|
||||
//!BIND MAIN
|
||||
#define go_0(x_off, y_off) (MAIN_texOff(vec2(x_off, y_off)))
|
||||
vec4 hook() { return mat4(1.0) * go_0(0.0, 0.0); }
|
||||
//!DESC pass 2
|
||||
//!HOOK MAIN
|
||||
//!BIND conv2d_tf
|
||||
#define go_0(x_off, y_off) (max((conv2d_tf_texOff(vec2(x_off, y_off))), 0.0))
|
||||
#define go_1(x_off, y_off) (max(-(conv2d_tf_texOff(vec2(x_off, y_off))), 0.0))
|
||||
#define g_0 (max((conv2d_tf_tex(conv2d_tf_pos)), 0.0))
|
||||
vec4 hook() { return go_0(0.0, 0.0) + go_1(0.0, 0.0) + g_0 + MAIN_tex(MAIN_pos); }
|
||||
''';
|
||||
final out = highpAnime4k(src);
|
||||
expect('highp vec4 starsync_hp('.allMatches(out), hasLength(2), reason: 'one helper per pass');
|
||||
expect(out, contains('#define go_0(x_off, y_off) (starsync_hp(MAIN_texOff(vec2(x_off, y_off))))'));
|
||||
expect(out, contains('(max(-(starsync_hp(conv2d_tf_texOff(vec2(x_off, y_off)))), 0.0))'));
|
||||
expect(out, contains('#define g_0 (max((starsync_hp(conv2d_tf_tex(conv2d_tf_pos))), 0.0))'));
|
||||
expect(out, contains('+ g_0 + MAIN_tex(MAIN_pos); }'), reason: 'other code stays as it is');
|
||||
// Every shipped file: every network read wrapped, the helper before its first use.
|
||||
for (final f in anime4kAllFiles) {
|
||||
final o = highpAnime4k(File('$anime4kAssetDir/$f').readAsStringSync());
|
||||
for (final line in o.split('\n').where((l) => RegExp(r'^#define g(o)?_\d').hasMatch(l))) {
|
||||
expect(line, contains('starsync_hp('), reason: '$f: $line');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('shader settings persist across a reload', () async {
|
||||
Hive.init(_fresh('shaders_hive').path);
|
||||
final a = AppSettings();
|
||||
|
||||
@@ -19,6 +19,7 @@ import 'package:starsync/src/shell/toasts.dart';
|
||||
import 'package:starsync/src/session/session_coordinator.dart' show ToastItem;
|
||||
import 'package:starsync/src/state/settings.dart';
|
||||
import 'package:starsync/src/update/update_controller.dart';
|
||||
import 'package:starsync/src/update/whats_new.dart';
|
||||
|
||||
import 'package:starsync/src/session/invite.dart' show Invite;
|
||||
import 'package:starsync/src/session/navigation.dart' show Place;
|
||||
@@ -88,7 +89,8 @@ void main() {
|
||||
session.notifier.clear();
|
||||
});
|
||||
|
||||
Future<void> pumpShell(WidgetTester tester, Size size, {UpdateController? updates, VideoViewBuilder? video}) async {
|
||||
Future<void> pumpShell(WidgetTester tester, Size size,
|
||||
{UpdateController? updates, VideoViewBuilder? video, WhatsNew? whatsNew}) async {
|
||||
tester.view.physicalSize = size;
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.reset);
|
||||
@@ -97,6 +99,7 @@ void main() {
|
||||
home: AppShell(
|
||||
updates: updates ?? UpdateController(currentVersion: '0.0.0-dev'),
|
||||
autoUpdateCheck: false,
|
||||
whatsNew: whatsNew,
|
||||
windowControls: false,
|
||||
videoBuilder: video ?? (context, controls) => const FakeVideo(),
|
||||
// A real probe (network, yt-dlp) never answers on the test clock.
|
||||
@@ -152,6 +155,25 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
group('what\'s new', () {
|
||||
testWidgets('the first start after an update shows the card once', (tester) async {
|
||||
final store = MemoryWhatsNewStore(
|
||||
seen: '1.3.2',
|
||||
notes: const ReleaseNotes(version: '1.3.3', changes: ReleaseChanges(fixed: ['Various bug fixes'])));
|
||||
await pumpShell(tester, const Size(1280, 800), whatsNew: WhatsNew(currentVersion: '1.3.3', store: store));
|
||||
expect(find.text("What's new in 1.3.3"), findsOneWidget);
|
||||
expect(find.text('STARSYNC · UPDATED'), findsOneWidget);
|
||||
expect(store.seen, '1.3.3');
|
||||
await tester.tap(find.byKey(const ValueKey('whats-new-close')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byKey(const ValueKey('whats-new-card')), findsNothing);
|
||||
// The next start: nothing.
|
||||
await tester.pumpWidget(const SizedBox());
|
||||
await pumpShell(tester, const Size(1280, 800), whatsNew: WhatsNew(currentVersion: '1.3.3', store: store));
|
||||
expect(find.byKey(const ValueKey('whats-new-card')), findsNothing);
|
||||
});
|
||||
});
|
||||
|
||||
group('party strip', () {
|
||||
testWidgets('home, alone: a plain player\'s strip, Join a friend and Invite (once your server runs)',
|
||||
(tester) async {
|
||||
|
||||
@@ -7,7 +7,7 @@ import 'package:flutter_test/flutter_test.dart';
|
||||
/// (`AppGraph`, widgets through `AppScope`); this list may only shrink.
|
||||
/// Lower a number when a use goes; never raise one or add a class.
|
||||
const baseline = {
|
||||
'AppSettings': 49,
|
||||
'AppSettings': 47,
|
||||
'JellyfinAccounts': 10,
|
||||
'UpdateController': 8,
|
||||
'Profile': 8,
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:starsync/src/platform/ports/player_host.dart';
|
||||
|
||||
// ffmpeg's lost stream (its HTTP context lost the file's size, every seek
|
||||
// fails until a fresh open): the lines the coordinator reloads the video
|
||||
// on, once per media.
|
||||
void main() {
|
||||
test('a seek that failed for want of the size is a lost stream', () {
|
||||
expect(mpvStreamLost(const PlayerLogLine('ffmpeg', 'error', 'Seek failed (to 3417735, size -38)')), isTrue);
|
||||
expect(mpvStreamLost(const PlayerLogLine('ffmpeg', 'warn', 'Seek failed (to 11997, size -38)\n')), isTrue);
|
||||
});
|
||||
|
||||
test('anything else is not', () {
|
||||
// A seek that failed with the size known, a reconnect, another prefix.
|
||||
expect(mpvStreamLost(const PlayerLogLine('ffmpeg', 'error', 'Seek failed (to 20851, size 1023081)')), isFalse);
|
||||
expect(mpvStreamLost(const PlayerLogLine('ffmpeg', 'warn', 'http: Will reconnect at 3407872 in 0 second(s)')),
|
||||
isFalse);
|
||||
expect(mpvStreamLost(const PlayerLogLine('lavf', 'warn', 'Seek failed (to 1, size -38)')), isFalse);
|
||||
});
|
||||
}
|
||||
@@ -6,9 +6,13 @@ import 'package:starsync/src/session/identity.dart' show ClientIdentity;
|
||||
|
||||
/// One graph per test file, like the app's one per run: nothing starts
|
||||
/// until a test calls `navigation.start()` (or `goHome`), so widget tests
|
||||
/// see the app before your server is up.
|
||||
AppGraph testGraph({HealthGetter healthGet = defaultHealthGet}) =>
|
||||
AppGraph.build(identity: ClientIdentity.ephemeral(), healthGet: healthGet);
|
||||
/// see the app before your server is up. Sharing looks up no internet
|
||||
/// address unless [publicAddress] gives one.
|
||||
AppGraph testGraph({HealthGetter healthGet = defaultHealthGet, Future<String?> Function()? publicAddress}) =>
|
||||
AppGraph.build(
|
||||
identity: ClientIdentity.ephemeral(),
|
||||
healthGet: healthGet,
|
||||
publicAddress: publicAddress ?? () async => null);
|
||||
|
||||
/// [child] under [graph]'s [AppScope].
|
||||
Widget scoped(AppGraph graph, Widget child) => AppScope(graph: graph, child: child);
|
||||
@@ -3,7 +3,9 @@ import 'dart:io';
|
||||
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:hive_ce/hive.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
import 'package:starsync/src/state/settings.dart';
|
||||
import 'package:starsync/src/update/update_controller.dart';
|
||||
|
||||
// Work dir base: WT_TEST_TMP or the system temp. Staging lives inside
|
||||
@@ -257,5 +259,85 @@ void main() {
|
||||
expect(await File('${root.path}/watch_together').readAsString(),
|
||||
'binary-v1');
|
||||
}, skip: _posixOnly);
|
||||
|
||||
test('channel: dev offers a newer release; stable in a test build asks before going back', () async {
|
||||
var channel = UpdateChannel.dev;
|
||||
final root = await _fakeRoot(work);
|
||||
final restarts = <String>[];
|
||||
// The dev manifest is missing (404): only the stable 0.2.0 is read.
|
||||
final c = UpdateController(
|
||||
currentVersion: '0.2.0-dev.3',
|
||||
manifestUrl: server.manifestUrl,
|
||||
devUrl: '${server.base}/dev.json',
|
||||
executablePath: '${root.path}/watch_together',
|
||||
restart: (exe, _) async => restarts.add(exe))
|
||||
..channelOf = () => channel;
|
||||
await c.checkOnce();
|
||||
expect(c.status.value.phase, UpdatePhase.available);
|
||||
expect(c.status.value.manifest!.version, '0.2.0');
|
||||
expect(c.stableBehind.value, isNull);
|
||||
|
||||
final behind = UpdateController(
|
||||
currentVersion: '0.3.0-dev.1',
|
||||
manifestUrl: server.manifestUrl,
|
||||
devUrl: '${server.base}/dev.json',
|
||||
executablePath: '${root.path}/watch_together',
|
||||
restart: (exe, _) async => restarts.add(exe))
|
||||
..channelOf = () => channel;
|
||||
await behind.checkOnce();
|
||||
expect(behind.status.value.phase, UpdatePhase.idle); // dev: 0.2.0 is older
|
||||
expect(behind.stableBehind.value, isNull);
|
||||
channel = UpdateChannel.stable;
|
||||
await behind.checkNow();
|
||||
expect(behind.status.value.phase, UpdatePhase.idle); // never offered by itself
|
||||
expect(behind.stableBehind.value?.version, '0.2.0');
|
||||
expect(behind.lastCheck.value, contains('older than this test build'));
|
||||
final m = await behind.stableToGoBackTo();
|
||||
expect(m?.version, '0.2.0');
|
||||
await behind.goBackTo(m!);
|
||||
expect(behind.status.value.phase, UpdatePhase.restarting);
|
||||
expect(await File('${root.path}/starsync').readAsString(), 'binary-v2');
|
||||
expect(restarts, hasLength(1));
|
||||
}, skip: _posixOnly);
|
||||
});
|
||||
|
||||
group('update channel setting', () {
|
||||
late Directory dir;
|
||||
setUp(() async {
|
||||
dir = await _work();
|
||||
Hive.init('${dir.path}/hive');
|
||||
});
|
||||
tearDown(() async {
|
||||
await Hive.close();
|
||||
await dir.delete(recursive: true);
|
||||
});
|
||||
|
||||
Future<AppSettings> load(String version) async {
|
||||
await Hive.close();
|
||||
final s = AppSettings(runningVersion: version, channelFile: File('${dir.path}/update-channel'));
|
||||
await s.ensureLoaded();
|
||||
return s;
|
||||
}
|
||||
|
||||
test('a test build keeps Dev across an update to a release; a choice stays', () async {
|
||||
expect((await load('1.3.2')).updateChannel.value, UpdateChannel.stable);
|
||||
// Stable by default is not stored: a test build later still starts on Dev.
|
||||
expect((await load('1.4.0-dev.1')).updateChannel.value, UpdateChannel.dev);
|
||||
// Updated to the release: stays on Dev.
|
||||
final s = await load('1.4.0');
|
||||
expect(s.updateChannel.value, UpdateChannel.dev);
|
||||
s.setUpdateChannel(UpdateChannel.stable);
|
||||
await Future<void>.delayed(const Duration(milliseconds: 50));
|
||||
expect((await load('1.4.1-dev.1')).updateChannel.value, UpdateChannel.stable);
|
||||
});
|
||||
|
||||
test('the update-channel file sets the channel once and is deleted', () async {
|
||||
expect((await load('1.3.2')).updateChannel.value, UpdateChannel.stable);
|
||||
final f = File('${dir.path}/update-channel');
|
||||
await f.writeAsString('dev\n');
|
||||
expect((await load('1.3.2')).updateChannel.value, UpdateChannel.dev);
|
||||
expect(f.existsSync(), isFalse);
|
||||
expect((await load('1.3.2')).updateChannel.value, UpdateChannel.dev);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:starsync/src/platform/ports/player_host.dart' show RenderMode, fitOutputSize;
|
||||
import 'package:starsync/src/screens/home/ui.dart' show devToolsFor;
|
||||
import 'package:starsync/src/shell/video_view.dart';
|
||||
|
||||
// The video view's asks of mpv: the output size ("Sharp video and
|
||||
// subtitles") and the dev experiment's subtitle lift.
|
||||
|
||||
void main() {
|
||||
group('videoOutputSize', () {
|
||||
test('the view in physical pixels on the GPU', () {
|
||||
expect(videoOutputSize(const Size(1280, 720), 1.5, on: true, mode: RenderMode.gpu), (1920, 1080));
|
||||
expect(videoOutputSize(const Size(384, 216), 2, on: true, mode: RenderMode.gpu), (768, 432));
|
||||
});
|
||||
|
||||
test("the video's own size when off, not on the GPU yet, or in software", () {
|
||||
expect(videoOutputSize(const Size(1280, 720), 1, on: false, mode: RenderMode.gpu), isNull);
|
||||
for (final m in [RenderMode.unknown, RenderMode.software, RenderMode.softwareGl]) {
|
||||
expect(videoOutputSize(const Size(1280, 720), 1, on: true, mode: m), isNull, reason: m.name);
|
||||
}
|
||||
expect(videoOutputSize(const Size(4, 4), 1, on: true, mode: RenderMode.gpu), isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('fitOutputSize', () {
|
||||
test('the picture fitted in the view, no bars', () {
|
||||
expect(fitOutputSize((1920, 1080), (1280, 720)), (1920, 1080));
|
||||
// 16:9 on a 2560x1080 screen: pillarboxed, full height.
|
||||
expect(fitOutputSize((2560, 1080), (1280, 720)), (1920, 1080));
|
||||
// The chat panel narrows the view: letterboxed, full width.
|
||||
expect(fitOutputSize((880, 720), (1280, 720)), (880, 494));
|
||||
expect(fitOutputSize((1280, 720), null), (1280, 720));
|
||||
expect(fitOutputSize((1280, 720), (0, 0)), (1280, 720));
|
||||
});
|
||||
|
||||
test('the bars split evenly: the picture sits on whole pixels', () {
|
||||
for (final (view, video) in [((881, 720), (1280, 720)), ((1000, 999), (1920, 800)), ((1366, 768), (720, 576))]) {
|
||||
final (w, h) = fitOutputSize(view, video);
|
||||
expect(w <= view.$1 && h <= view.$2, isTrue);
|
||||
expect((view.$1 - w).isEven && (view.$2 - h).isEven, isTrue, reason: '$view $video -> ($w, $h)');
|
||||
expect(w == view.$1 || h == view.$2, isTrue, reason: 'one side fills the view');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
group('subtitleLiftPos', () {
|
||||
test('a picture filling the view lifts by the controls', () {
|
||||
// 108 px of controls + 8 px gap in a 1080 px picture: 10.74 %.
|
||||
final pos = subtitleLiftPos(view: const Size(1920, 1080), videoW: 1920, videoH: 1080, controls: 108);
|
||||
expect(pos, closeTo(100 - 116 / 1080 * 100, 1e-9));
|
||||
});
|
||||
|
||||
test('a letterbox under the picture counts toward the room', () {
|
||||
// 2.39:1 in 16:9: 803 px picture, 138 px bar below it.
|
||||
final none = subtitleLiftPos(view: const Size(1920, 1080), videoW: 1920, videoH: 803, controls: 100);
|
||||
expect(none, 100);
|
||||
final some = subtitleLiftPos(view: const Size(1920, 1080), videoW: 1920, videoH: 803, controls: 200);
|
||||
expect(some, lessThan(100));
|
||||
});
|
||||
|
||||
test('unknown video size: as if it filled the view; never past half', () {
|
||||
expect(subtitleLiftPos(view: const Size(800, 450), controls: 90), closeTo(100 - 98 / 450 * 100, 1e-9));
|
||||
expect(subtitleLiftPos(view: const Size(800, 100), controls: 500), 50);
|
||||
expect(subtitleLiftPos(view: Size.zero, controls: 90), 100);
|
||||
});
|
||||
});
|
||||
|
||||
group('subtitle lift steps', () {
|
||||
test('a few large eased-out steps, the last exactly on target', () {
|
||||
final up = liftSteps(0, 100);
|
||||
expect(up, hasLength(liftStepCount));
|
||||
expect(up.last, 100);
|
||||
// Eased out: the first step moves most, each one less.
|
||||
for (var i = 1; i < up.length; i++) {
|
||||
expect(up[i], greaterThan(up[i - 1]));
|
||||
if (i > 1) expect(up[i] - up[i - 1], lessThan(up[i - 1] - (i > 1 ? up[i - 2] : 0)));
|
||||
}
|
||||
expect(up.first, greaterThan(30), reason: 'most of the way at once');
|
||||
final down = liftSteps(100, 0);
|
||||
expect(down.last, 0);
|
||||
expect(down.first, lessThan(70));
|
||||
expect(liftSteps(40, 40.2), isEmpty);
|
||||
});
|
||||
|
||||
testWidgets('SubtitleLift steps toward the latest target, liftStepGap apart', (tester) async {
|
||||
final lift = SubtitleLift();
|
||||
addTearDown(lift.dispose);
|
||||
final seen = <double>[];
|
||||
lift.addListener(() => seen.add(lift.value));
|
||||
lift.animateTo(80);
|
||||
expect(seen, hasLength(1), reason: 'the first step at once');
|
||||
await tester.pump(liftStepGap * (liftStepCount - 1));
|
||||
expect(seen, liftSteps(0, 80));
|
||||
expect(lift.value, 80);
|
||||
await tester.pump(liftStepGap * 4);
|
||||
expect(seen, hasLength(liftStepCount), reason: 'nothing after the target');
|
||||
// A new target mid-way starts from where it is.
|
||||
seen.clear();
|
||||
lift.animateTo(0);
|
||||
await tester.pump(liftStepGap);
|
||||
final from = lift.value, n = seen.length;
|
||||
expect(from, inExclusiveRange(0, 80));
|
||||
lift.animateTo(80);
|
||||
await tester.pump(liftStepGap * liftStepCount);
|
||||
expect(seen.sublist(n), liftSteps(from, 80));
|
||||
});
|
||||
});
|
||||
|
||||
test('dev tools: dev versions and debug runs only', () {
|
||||
expect(devToolsFor(version: '1.3.3-dev.2', debug: false), isTrue);
|
||||
expect(devToolsFor(version: '0.0.0-dev', debug: false), isTrue);
|
||||
expect(devToolsFor(version: '1.3.2', debug: true), isTrue);
|
||||
expect(devToolsFor(version: '1.3.2', debug: false), isFalse);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
import 'package:starsync/src/screens/home/update_indicator.dart';
|
||||
import 'package:starsync/src/update/update_controller.dart';
|
||||
import 'package:starsync/src/update/whats_new.dart';
|
||||
import 'package:starsync/src/widgets/whats_new_card.dart';
|
||||
|
||||
const _changes = ReleaseChanges(
|
||||
added: ['Danmaku style notifications', 'Bilibili style chat box'],
|
||||
changed: ['A cleaner mini player'],
|
||||
fixed: ['Optimized relay', 'Various bug fixes'],
|
||||
);
|
||||
|
||||
UpdateManifest _manifest({String version = '1.3.3', ReleaseChanges? changes = _changes, String notes = 'Chat, relay'}) =>
|
||||
UpdateManifest(version: version, protocol: 1, url: 'http://x/a.tgz', notes: notes, changes: changes);
|
||||
|
||||
UpdateController _controller({String current = '1.3.2'}) => UpdateController(
|
||||
currentVersion: current,
|
||||
executablePath: '/nowhere/starsync',
|
||||
restart: (_, _) async {},
|
||||
beforeRestart: (_) async {});
|
||||
|
||||
Widget _app(Widget child) => MaterialApp(theme: ThemeData.dark(), home: Scaffold(body: child));
|
||||
|
||||
void main() {
|
||||
group('card', () {
|
||||
testWidgets('sections in order with their items, the summary, Nice closes', (tester) async {
|
||||
await tester.pumpWidget(_app(Builder(
|
||||
builder: (context) => TextButton(
|
||||
onPressed: () => showWhatsNew(
|
||||
context, const ReleaseNotes(version: '1.3.3', notes: 'Chat, relay', changes: _changes)),
|
||||
child: const Text('open'),
|
||||
),
|
||||
)));
|
||||
await tester.tap(find.text('open'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byKey(const ValueKey('whats-new-card')), findsOneWidget);
|
||||
expect(find.text("What's new in 1.3.3"), findsOneWidget);
|
||||
expect(find.text('Chat, relay'), findsOneWidget);
|
||||
final heads = [for (final k in ChangeKind.values) tester.getTopLeft(find.byKey(ValueKey('whats-new-${k.name}'))).dy];
|
||||
expect(heads, orderedEquals([...heads]..sort()), reason: 'Added, Changed, Fixed');
|
||||
for (final item in [..._changes.added, ..._changes.changed, ..._changes.fixed]) {
|
||||
expect(find.text(item), findsOneWidget);
|
||||
}
|
||||
await tester.tap(find.byKey(const ValueKey('whats-new-close')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byKey(const ValueKey('whats-new-card')), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('notes only (a manifest from before the changelog): one paragraph, no sections', (tester) async {
|
||||
await tester.pumpWidget(_app(const Center(
|
||||
child: WhatsNewCard(notes: ReleaseNotes(version: '1.3.2', notes: 'Fixes Windows closing by itself.')))));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Fixes Windows closing by itself.'), findsOneWidget);
|
||||
for (final k in ChangeKind.values) {
|
||||
expect(find.byKey(ValueKey('whats-new-${k.name}')), findsNothing);
|
||||
}
|
||||
});
|
||||
|
||||
testWidgets('a long list scrolls inside a short window, no overflow', (tester) async {
|
||||
tester.view.physicalSize = const Size(800, 420);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.reset);
|
||||
final many = ReleaseChanges(fixed: [for (var i = 0; i < 40; i++) 'Fix number $i']);
|
||||
await tester.pumpWidget(_app(Center(child: WhatsNewCard(notes: ReleaseNotes(version: '2.0.0', changes: many)))));
|
||||
await tester.pumpAndSettle();
|
||||
expect(tester.takeException(), isNull);
|
||||
expect(tester.getSize(find.byKey(const ValueKey('whats-new-card'))).height, lessThanOrEqualTo(420 - 64));
|
||||
await tester.drag(find.byKey(const ValueKey('whats-new-list')), const Offset(0, -2000));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Fix number 39'), findsOneWidget);
|
||||
});
|
||||
});
|
||||
|
||||
group('band', () {
|
||||
Future<UpdateController> pump(WidgetTester tester, UpdateManifest m) async {
|
||||
tester.view.physicalSize = const Size(1280, 800);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.reset);
|
||||
final c = _controller();
|
||||
c.status.value = UpdateStatus(UpdatePhase.available, manifest: m, canInstall: true);
|
||||
await tester.pumpWidget(_app(Column(children: [UpdateBar(controller: c)])));
|
||||
return c;
|
||||
}
|
||||
|
||||
testWidgets('with changes: the first items, What\'s new opens the card with Update and restart', (tester) async {
|
||||
await pump(tester, _manifest());
|
||||
final preview = tester.widget<Text>(find.byKey(const ValueKey('whats-new-preview'))).textSpan!.toPlainText();
|
||||
expect(preview, contains('You are running 1.3.2'));
|
||||
expect(preview, contains('Danmaku style notifications'));
|
||||
expect(preview, contains('A cleaner mini player'));
|
||||
expect(preview, isNot(contains('Optimized relay')), reason: 'three at most');
|
||||
expect(find.text('You are running 1.3.2 · Chat, relay'), findsNothing);
|
||||
await tester.tap(find.byKey(const ValueKey('update-whats-new')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text("What's new in 1.3.3"), findsOneWidget);
|
||||
expect(find.text('Various bug fixes'), findsOneWidget);
|
||||
expect(find.byKey(const ValueKey('whats-new-install')), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('without changes (old manifest): the notes line, What\'s new shows them', (tester) async {
|
||||
await pump(tester, _manifest(changes: null));
|
||||
expect(find.byKey(const ValueKey('whats-new-preview')), findsNothing);
|
||||
expect(find.text('You are running 1.3.2 · Chat, relay'), findsOneWidget);
|
||||
await tester.tap(find.byKey(const ValueKey('update-whats-new')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byKey(const ValueKey('whats-new-card')), findsOneWidget);
|
||||
expect(find.text('Chat, relay'), findsWidgets);
|
||||
});
|
||||
|
||||
testWidgets('nothing to tell: no What\'s new button', (tester) async {
|
||||
await pump(tester, _manifest(changes: null, notes: ''));
|
||||
expect(find.byKey(const ValueKey('update-whats-new')), findsNothing);
|
||||
expect(find.byKey(const ValueKey('update-install')), findsOneWidget);
|
||||
});
|
||||
});
|
||||
|
||||
group('show once', () {
|
||||
test('after an install: due at the first start, never again once shown', () async {
|
||||
final store = MemoryWhatsNewStore(seen: '1.3.2');
|
||||
// The old version keeps the new one's notes before restarting.
|
||||
await WhatsNew(currentVersion: '1.3.2', store: store).installing(_manifest());
|
||||
expect(store.notes!.version, '1.3.3');
|
||||
|
||||
final first = WhatsNew(currentVersion: '1.3.3', store: store)..start(_controller(current: '1.3.3'));
|
||||
expect(first.due.value?.version, '1.3.3');
|
||||
expect(first.due.value?.changes?.fixed, _changes.fixed);
|
||||
first.shown();
|
||||
expect(first.due.value, isNull);
|
||||
expect(store.seen, '1.3.3');
|
||||
|
||||
final second = WhatsNew(currentVersion: '1.3.3', store: store)..start(_controller(current: '1.3.3'));
|
||||
expect(second.due.value, isNull);
|
||||
expect(second.current?.version, '1.3.3', reason: 'About still opens it');
|
||||
});
|
||||
|
||||
test('updated another way (1.3.x updater): the first check reading this version brings it', () {
|
||||
final store = MemoryWhatsNewStore();
|
||||
final c = _controller(current: '1.3.3');
|
||||
final w = WhatsNew(currentVersion: '1.3.3', store: store)..start(c);
|
||||
expect(w.due.value, isNull);
|
||||
c.latest.value = _manifest(version: '1.3.4');
|
||||
expect(w.due.value, isNull, reason: 'another version');
|
||||
c.latest.value = _manifest();
|
||||
expect(w.due.value?.version, '1.3.3');
|
||||
expect(store.notes?.version, '1.3.3');
|
||||
});
|
||||
|
||||
test('a fresh install shows nothing, but About has the notes once known', () {
|
||||
final store = MemoryWhatsNewStore(fresh: true);
|
||||
final c = _controller(current: '1.3.3');
|
||||
final w = WhatsNew(currentVersion: '1.3.3', store: store)..start(c);
|
||||
expect(store.seen, '1.3.3');
|
||||
c.latest.value = _manifest();
|
||||
expect(w.due.value, isNull);
|
||||
expect(w.current?.version, '1.3.3');
|
||||
});
|
||||
|
||||
test('notes of another version or empty notes are never due', () {
|
||||
final store = MemoryWhatsNewStore(
|
||||
seen: '1.3.2', notes: const ReleaseNotes(version: '1.3.4', changes: _changes));
|
||||
expect((WhatsNew(currentVersion: '1.3.3', store: store)..start(_controller())).due.value, isNull);
|
||||
final empty = MemoryWhatsNewStore(seen: '1.3.2', notes: const ReleaseNotes(version: '1.3.3'));
|
||||
expect((WhatsNew(currentVersion: '1.3.3', store: empty)..start(_controller())).due.value, isNull);
|
||||
});
|
||||
|
||||
test('kept notes survive a round trip through settings JSON', () {
|
||||
final n = ReleaseNotes.of(_manifest());
|
||||
final back = ReleaseNotes.fromJson(n.toJson())!;
|
||||
expect(back.version, '1.3.3');
|
||||
expect(back.notes, 'Chat, relay');
|
||||
expect(back.changes!.toJson(), _changes.toJson());
|
||||
expect(ReleaseNotes.fromJson({'notes': 'no version'}), isNull);
|
||||
expect(ReleaseNotes.fromJson('junk'), isNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -34,6 +34,14 @@ class _Cdn {
|
||||
final Map<String, String> _files;
|
||||
int ok = 0, forbidden = 0;
|
||||
final cookies = <String?>[];
|
||||
final agents = <String?>[];
|
||||
|
||||
/// Like Bilibili cutting a long read: a response stops after this many
|
||||
/// bytes (the connection dropped), and the next request after a cut is
|
||||
/// refused with 412 (where ffmpeg's reconnect gives up). Null: whole.
|
||||
int? cutAfter;
|
||||
bool _cut = false;
|
||||
int cuts = 0, refused = 0;
|
||||
|
||||
String url(String name) => 'http://127.0.0.1:${_server.port}/$name';
|
||||
|
||||
@@ -61,12 +69,21 @@ class _Cdn {
|
||||
await res.close();
|
||||
return;
|
||||
}
|
||||
if (_cut) {
|
||||
_cut = false;
|
||||
refused++;
|
||||
res.statusCode = HttpStatus.preconditionFailed;
|
||||
await res.close();
|
||||
return;
|
||||
}
|
||||
ok++;
|
||||
cookies.add(req.headers.value('cookie'));
|
||||
agents.add(req.headers.value('user-agent'));
|
||||
final bytes = await File(file).readAsBytes();
|
||||
var start = 0, end = bytes.length - 1;
|
||||
final range = RegExp(r'bytes=(\d*)-(\d*)').firstMatch(req.headers.value('range') ?? '');
|
||||
res.headers.set('accept-ranges', 'bytes');
|
||||
res.headers.contentType = ContentType('video', 'mp4');
|
||||
if (range != null) {
|
||||
start = int.tryParse(range[1]!) ?? 0;
|
||||
end = int.tryParse(range[2]!) ?? end;
|
||||
@@ -74,6 +91,23 @@ class _Cdn {
|
||||
res.headers.set('content-range', 'bytes $start-$end/${bytes.length}');
|
||||
}
|
||||
res.contentLength = end - start + 1;
|
||||
final cut = cutAfter;
|
||||
if (cut != null && end - start + 1 > cut) {
|
||||
// Headers by hand on the raw socket, part of the body, then dropped.
|
||||
final socket = await res.detachSocket(writeHeaders: false);
|
||||
final status = range == null ? '200 OK' : '206 Partial Content';
|
||||
socket.add(utf8.encode('HTTP/1.1 $status\r\naccept-ranges: bytes\r\ncontent-type: video/mp4\r\n'
|
||||
'${range == null ? '' : 'content-range: bytes $start-$end/${bytes.length}\r\n'}'
|
||||
'content-length: ${end - start + 1}\r\nconnection: close\r\n\r\n'));
|
||||
socket.add(bytes.sublist(start, start + cut));
|
||||
_cut = true;
|
||||
cuts++;
|
||||
try {
|
||||
await socket.flush();
|
||||
} catch (_) {}
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
res.add(bytes.sublist(start, end + 1));
|
||||
await res.close();
|
||||
}
|
||||
@@ -88,16 +122,19 @@ Future<AppYtDlp> _fakeYtDlp(_Cdn cdn, {required bool headers, bool fail = false}
|
||||
if (dir.existsSync()) dir.deleteSync(recursive: true);
|
||||
dir.createSync(recursive: true);
|
||||
final h = headers ? {'Referer': _referer, 'User-Agent': 'fake-agent'} : <String, String>{};
|
||||
final formats = [
|
||||
{'format_id': 'v', 'url': cdn.url('v.m4s'), 'protocol': 'https', 'ext': 'mp4',
|
||||
'vcodec': 'avc1.64001F', 'acodec': 'none', 'width': 320, 'height': 180, 'http_headers': h,
|
||||
'cookies': 'SESSDATA=abc; Domain=.127.0.0.1; Path=/'},
|
||||
{'format_id': 'a', 'url': cdn.url('a.m4s'), 'protocol': 'https', 'ext': 'm4a',
|
||||
'vcodec': 'none', 'acodec': 'mp4a.40.2', 'http_headers': h,
|
||||
'cookies': 'SESSDATA=abc; Domain=.127.0.0.1; Path=/'},
|
||||
];
|
||||
final json = jsonEncode({
|
||||
'id': 'BV1', 'title': 'Fake page', 'duration': 30, 'extractor': 'fake',
|
||||
'format_id': 'v+a', 'ext': 'mp4',
|
||||
'requested_formats': [
|
||||
{'format_id': 'v', 'url': cdn.url('v.m4s'), 'protocol': 'https', 'ext': 'mp4',
|
||||
'vcodec': 'avc1.64001F', 'acodec': 'none', 'width': 320, 'height': 180, 'http_headers': h,
|
||||
'cookies': 'SESSDATA=abc; Domain=.127.0.0.1; Path=/'},
|
||||
{'format_id': 'a', 'url': cdn.url('a.m4s'), 'protocol': 'https', 'ext': 'm4a',
|
||||
'vcodec': 'none', 'acodec': 'mp4a.40.2', 'http_headers': h},
|
||||
],
|
||||
'formats': formats,
|
||||
'requested_formats': formats,
|
||||
'requested_subtitles': {
|
||||
'en': {'url': cdn.url('en.vtt'), 'ext': 'vtt', 'name': 'English'},
|
||||
'danmaku': {'url': cdn.url('danmaku.xml'), 'ext': 'xml'},
|
||||
@@ -124,8 +161,9 @@ void main() {
|
||||
tearDownAll(() => server.close(force: true));
|
||||
|
||||
// A page "everyone fetches" opens through the app's own resolve: its
|
||||
// stream URLs (an EDL of video + audio) with the CDN's headers; mpv's
|
||||
// yt-dlp hook is off.
|
||||
// stream URLs (video + audio) read by the app's own reader
|
||||
// (SharedLinks.local) with the CDN's headers and cookies, mpv on its
|
||||
// loopback EDL; mpv's yt-dlp hook is off.
|
||||
test(
|
||||
"a page plays from yt-dlp's stream URLs with each CDN's Referer and the cookies; subtitles but danmaku",
|
||||
timeout: const Timeout(Duration(minutes: 1)),
|
||||
@@ -146,6 +184,8 @@ void main() {
|
||||
expect(cdn.forbidden, 0, reason: 'every request had the Referer');
|
||||
expect(cdn.ok, greaterThanOrEqualTo(2));
|
||||
expect(cdn.cookies, everyElement('SESSDATA=abc'));
|
||||
expect(m.shared.hasLocal(cdn.page('BV1fake')), isTrue);
|
||||
expect(cdn.agents, everyElement(startsWith('fake-agent')), reason: 'the reader sends the format\'s headers');
|
||||
expect(cdn.subtitles, 1, reason: 'the page\'s subtitles are added, danmaku is not');
|
||||
await _until(() => p.tracks.peek().any((t) => t.isExternal), 'the subtitle track listed');
|
||||
} finally {
|
||||
@@ -185,6 +225,43 @@ void main() {
|
||||
skip: Platform.isLinux ? false : 'fake yt-dlp is a shell script',
|
||||
);
|
||||
|
||||
// Bilibili cuts a long read and refuses the reconnect right after it
|
||||
// (412): ffmpeg's own reconnect ends playback there ("Stream ends
|
||||
// prematurely", "Operation not permitted"); the reader resumes after a
|
||||
// back-off and the player never sees the cut.
|
||||
test(
|
||||
'a CDN that cuts reads and refuses the reconnect (412): played to the end without an error',
|
||||
timeout: const Timeout(Duration(minutes: 1)),
|
||||
() async {
|
||||
final m = MediaPipeline(host: MediaKitHost()..init(), links: await _fakeYtDlp(cdn, headers: true));
|
||||
final p = m.playback;
|
||||
final errors = <String>[];
|
||||
p.onLoadError = errors.add;
|
||||
m.onError = (raw, {cookies}) => errors.add(raw);
|
||||
cdn.cutAfter = 48 * 1024;
|
||||
final cutsBefore = cdn.cuts;
|
||||
try {
|
||||
await m.start();
|
||||
final page = cdn.page('BV4fake');
|
||||
expect(await m.open(page), isTrue);
|
||||
await _until(() => p.duration.peek() > Duration.zero, 'opened', secs: 20);
|
||||
await p.player.setProperty('speed', '8');
|
||||
await p.player.play();
|
||||
await _until(() => p.positionSecs > 25 || p.player.state.completed, 'played to the end', secs: 30);
|
||||
// ignore: avoid_print
|
||||
print('cuts: ${cdn.cuts - cutsBefore}, refused: ${cdn.refused}, errors: $errors');
|
||||
expect(cdn.cuts - cutsBefore, greaterThan(2), reason: 'the CDN did cut');
|
||||
expect(cdn.refused, greaterThan(0));
|
||||
expect(errors, isEmpty);
|
||||
expect(m.shared.hasLocal(page), isTrue);
|
||||
} finally {
|
||||
cdn.cutAfter = null;
|
||||
await m.dispose();
|
||||
}
|
||||
},
|
||||
skip: Platform.isLinux ? false : 'fake yt-dlp is a shell script',
|
||||
);
|
||||
|
||||
// D11: yt-dlp finds no video; the error names the cookies it used.
|
||||
test('a page yt-dlp finds no video on: the error says which cookies were used', () async {
|
||||
final m = MediaPipeline(host: MediaKitHost()..init(), links: await _fakeYtDlp(cdn, headers: true, fail: true));
|
||||
@@ -203,8 +280,8 @@ void main() {
|
||||
}, skip: Platform.isLinux ? false : 'fake yt-dlp is a shell script');
|
||||
}
|
||||
|
||||
Future<void> _until(bool Function() cond, String what) async {
|
||||
final end = DateTime.now().add(const Duration(seconds: 10));
|
||||
Future<void> _until(bool Function() cond, String what, {int secs = 10}) async {
|
||||
final end = DateTime.now().add(Duration(seconds: secs));
|
||||
while (!cond()) {
|
||||
if (DateTime.now().isAfter(end)) fail('timed out waiting for $what');
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
|
||||
@@ -52,6 +52,13 @@ bool FlutterWindow::OnCreate() {
|
||||
window_channel_->SetMethodCallHandler(
|
||||
[this](const flutter::MethodCall<flutter::EncodableValue>& call,
|
||||
std::unique_ptr<flutter::MethodResult<flutter::EncodableValue>> result) {
|
||||
// A second start that handed its link to the running app ends
|
||||
// here, before its window ever shows (no CRT exit, no DLL detach).
|
||||
if (call.method_name() == "exit") {
|
||||
fflush(nullptr);
|
||||
::TerminateProcess(::GetCurrentProcess(), 0);
|
||||
return;
|
||||
}
|
||||
if (call.method_name() != "attention") {
|
||||
result->NotImplemented();
|
||||
return;
|
||||
|
||||
+463
-70
@@ -61,21 +61,53 @@ attach and detach the network listener (below).
|
||||
its reason; nothing else changes.
|
||||
- **Invite** (`session/invite.dart`): `YourServer.invite()` shares the
|
||||
server if it is not yet and returns an `Invite`: the host, the port, the
|
||||
name, whether a password is needed (never the password), the version
|
||||
and an `fp` field reserved for a pinned certificate (`wss://` with a
|
||||
self-signed one, later; always null). The host is the best of
|
||||
`inviteAddresses` over `NetworkInterface.list`: IPv4 only, no loopback,
|
||||
link-local (169.254/16) or proxy fake-IP (198.18.0.0/15, what TUN
|
||||
proxies like Clash hand out) address and no container or VM bridge
|
||||
(`docker*`, `br-*`, `veth*`, `virbr*`, `vmnet*`, `vboxnet*`, `cni*`,
|
||||
`podman*`, `lxcbr*`, `lxdbr*`, `vEthernet`, VirtualBox, VMware); 192.168
|
||||
first, then 10/8 and 172.16/12, then a mesh VPN's 100.64/10, then any
|
||||
other (public) address. No public-address lookup: the members card says
|
||||
that friends elsewhere need the port forwarded or a server they can all
|
||||
reach. `Invite.text` is what Invite copies ("Join me on Starsync:
|
||||
192.168.1.20:8080 (ask me for the password) · Starsync 1.3.0");
|
||||
`addressIn` reads the address back out of a pasted invite (the Join
|
||||
card's address field). `stopSharing` unshares.
|
||||
name ("<your name>'s server"), whether a password is needed (not the
|
||||
password itself), the version and an `fp` field reserved for a pinned
|
||||
certificate (`wss://` with a self-signed one, later; always null). This
|
||||
machine's addresses are `inviteAddresses` over `NetworkInterface.list`:
|
||||
IPv4 only, no loopback, link-local (169.254/16) or proxy fake-IP
|
||||
(198.18.0.0/15, what TUN proxies like Clash hand out) address and no
|
||||
container or VM bridge (`docker*`, `br-*`, `veth*`, `virbr*`, `vmnet*`,
|
||||
`vboxnet*`, `cni*`, `podman*`, `lxcbr*`, `lxdbr*`, `vEthernet`,
|
||||
VirtualBox, VMware); 192.168 first, then 10/8 and 172.16/12, then a mesh
|
||||
VPN's 100.64/10, then any other (public) address. Sharing also looks up
|
||||
the internet address (`lookUpPublicAddress`, `session/public_address.dart`):
|
||||
plain-text "what is my IP" services over HTTPS (ipify, icanhazip,
|
||||
checkip.amazonaws, ifconfig.me), asked in order until one answers a
|
||||
public IPv4 address, 3 s each; that tells those services this network's
|
||||
address, nothing else. `YourServer.reach` (`InviteReach`) holds both: the
|
||||
internet address first, then this machine's, each with its
|
||||
`AddressKind` (internet, home network, VPN); `forwarding` when the
|
||||
internet address is not on this machine (behind a router: friends
|
||||
elsewhere get in only with the port forwarded). No UPnP. The invite
|
||||
carries the internet address when the lookup answers within 4 s
|
||||
(`publicWait`), else the best local one, switched to the internet one
|
||||
when it lands. `YourServer.inviteLink(invite)` is what Invite copies:
|
||||
the bare link with the password the server was shared with
|
||||
(`starsync://203.0.113.7:8080/?name=Bob%27s+server&pw=…`; the link is
|
||||
the key, nothing around it). The link format:
|
||||
[PROTOCOL.md "Invite link"](PROTOCOL.md#invite-link) (`InviteLink`:
|
||||
`parse`, `uri`, a `toString` without the password). `inviteIn` finds an
|
||||
invite in a text (a link anywhere, or a 1.3.x plain invite's address;
|
||||
never a bare address); `addressIn` reads `host:port` out of anything
|
||||
(the Join card's address field; a 1.3.x app pasting a link reads its
|
||||
address this way). `stopSharing` unshares.
|
||||
- **Links and the join prompt** (`session/join_offers.dart`, `JoinOffers`,
|
||||
in the composition root): a clicked `starsync://` link (the app's start
|
||||
arguments, or a second start's, see "Single instance" under "App
|
||||
events and platform ports") and an invite on the clipboard when the
|
||||
window gains the focus (and at start) become `offer`: "Join Bob's server
|
||||
(203.0.113.7:8080)?" with the server's `/health` (its name, version,
|
||||
how many watch, the version gate's sentence, or unreachable). Not
|
||||
offered: links to your own server (`YourServer.isOwn`), to the server
|
||||
you are in, and the text this app just copied (`copied`). Join
|
||||
(`accept`) fills the Join card's fields (a link without a password
|
||||
takes the one saved for that recent server) and joins through
|
||||
`ConnectFlow` and `Navigation.join` (version gate, recents); a failure
|
||||
opens the Join card with its error. A clipboard invite joined or
|
||||
dismissed (Not now) is not offered again: the connect form box keeps the
|
||||
last 30 as fingerprints (a hash of the link), never the links; the
|
||||
clipboard's text is only parsed, never logged or stored.
|
||||
- **Where you are** (`session/navigation.dart`, `Navigation`): `place` is
|
||||
`none` (starting, or between two servers), `yours` or `friend`. The app
|
||||
starts at home: your server, joined over 127.0.0.1 with the host key
|
||||
@@ -136,7 +168,7 @@ only stopping sharing sends guests out.
|
||||
`Ports` (the player host among them, handed to the coordinator), the sounds (`SynthSfx`), the `Notifier`, the `Connection`, the
|
||||
`SessionCoordinator` (handed the settings and the watch tracker), the
|
||||
connect form and recents (`ConnectStore`, `state/connect_prefs.dart`),
|
||||
`YourServer`, `Navigation`, the library's matches (`ShowMatcher`,
|
||||
`YourServer`, `Navigation`, the join prompt (`JoinOffers`), the library's matches (`ShowMatcher`,
|
||||
"Library", whose `refForPath` it hands the coordinator as `fileRef`, and
|
||||
a `LocalNextFinder` over it as `nextFile`) and watch tracking
|
||||
(`WatchTracker` with the device's `WatchHistory`, "Watch tracking"); it
|
||||
@@ -543,8 +575,20 @@ controls' ⚙ → Upscaling shader menu, Ctrl+0..6, both also save it). The Anim
|
||||
v4.0.1 files live in `apps/desktop/assets/shaders/anime4k/` (only the 14
|
||||
the presets use, plus its MIT LICENSE); mpv needs real paths, so
|
||||
`extractAnime4k` copies them once per version to
|
||||
`<app support>/shaders/anime4k-<version>/` (a `.complete` marker is written
|
||||
last). `anime4kFiles` maps each mode to the file list of Anime4K's mpv
|
||||
`<app support>/shaders/anime4k-<version>-<revision>/` (a `.complete`
|
||||
marker is written last; other `anime4k-*` copies are deleted), each
|
||||
rewritten by `highpAnime4k`. media_kit renders through OpenGL ES (EGL
|
||||
on Linux, ANGLE on Windows), where mpv declares `precision mediump
|
||||
sampler2D`: a texture read is mediump and Anime4K's weights are bare
|
||||
literals, so a network's `mat4(...) * go_0(...)` sums ran at half
|
||||
precision (fp16 on NVIDIA). The VL networks of HQ overflow and round
|
||||
there: shifted, posterised colours and a regular grid, strongest once
|
||||
the upscalers run (output over 1.2x the video, so mostly with "Sharp
|
||||
video" at a large size). The rewrite adds a highp identity function to
|
||||
each pass and routes every `go_N` / `g_N` read through it; the result on
|
||||
OpenGL ES then equals desktop OpenGL's (an mpv CLI frame grab,
|
||||
`--opengl-es=yes` vs `no`, identical). Desktop GLSL ignores the
|
||||
qualifiers. Custom shaders are passed as they are. `anime4kFiles` maps each mode to the file list of Anime4K's mpv
|
||||
templates: HQ = the high-end GPU set (VL + M networks), Fast = the low-end
|
||||
set (M + S). A missing file, a path containing the list separator, a
|
||||
read-back mismatch, or an mpv `libmpv_render` error while shaders are on
|
||||
@@ -554,7 +598,50 @@ A player with a video output opens media only once that output is up:
|
||||
media_kit sets mpv's `vo=libmpv` before the output's render context
|
||||
exists, and a media opened in between gets no video output ("No render
|
||||
context set", black picture with sound). On X11 the context comes up
|
||||
later, which widens that gap (joining with media already up).
|
||||
later, which widens that gap (joining with media already up). On Windows
|
||||
the controller can report ready before mpv's render context exists, so
|
||||
an open that still fails its video output that way (`cplayer`: "Error
|
||||
opening/initializing the selected video_out") is opened again once,
|
||||
500 ms later (`render: the video output was not up for the open`); the
|
||||
player error it raised clears when the media loads.
|
||||
|
||||
A player whose HTTP stream lost the file's size (ffmpeg's "Seek failed
|
||||
(to N, size -38)": a request of its that got no headers in time, from a
|
||||
server without the relay's header rule, or the connection reuse above)
|
||||
fails every seek until a fresh open, so the coordinator runs "Reload
|
||||
video" once per media for it (`mpvStreamLost`, `onStreamLost`).
|
||||
|
||||
Output size ("Sharp video and subtitles", `AppSettings.screenResVideo`,
|
||||
on by default, live; Settings → Player and the player panel):
|
||||
media_kit renders mpv into a texture the size of the video and Flutter
|
||||
scales it, so libass subtitles, mpv's scaler and the shaders all work on
|
||||
a 720p image for a 720p file. With the setting on and the GPU renderer
|
||||
(`renderMode` gpu; never in software rendering, where every pixel is CPU
|
||||
work, nor before the probe knows), the session view
|
||||
(`shell/video_view.dart`, `VideoOutputSizer` around the `Video`) sends its
|
||||
size in physical pixels to the host (`PlayerHost.setOutputSize`): at
|
||||
once the first time, then once a resize has held 120 ms
|
||||
(`outputResizeDelay`: a window drag, the chat panel, fullscreen, full ↔
|
||||
mini). The host renders each output at the picture fitted in that view
|
||||
(`fitOutputSize` over the video's display size, re-fitted when it
|
||||
changes), not the whole view: the texture has the picture's aspect, so
|
||||
while a resize settles Flutter scales the last texture to the new view
|
||||
without a jump (a texture of the whole view, bars included, shrank
|
||||
inside its own bars until the new one came, about 0.2 s later), and the
|
||||
shaders work on no bar pixels. The side that fills the view is exact and
|
||||
the other is rounded down to the view's parity, so the centred picture
|
||||
sits on whole pixels and is drawn 1:1. It applies to every live output
|
||||
and to each new one once it is up (`NativeVideoController.setSize`,
|
||||
which only changes the size the next frame is drawn at); subtitles are
|
||||
drawn at that size, inside the picture. media_kit's controller sets an output back to the video's
|
||||
size whenever the video's parameters change (each new file), so the host
|
||||
sends the size again after it, under the controller's own lock; a fresh
|
||||
player's first frame or two show at the video's size. A paused player
|
||||
draws no frame for the new size: the host nudges `video-pan-x` by a
|
||||
millionth and back so mpv redraws. Off: each output at its video's own
|
||||
size again (`setOutputSize(null)`). Measured on a GTX 1080, 1280x720
|
||||
H.264 at 2560x1080 fullscreen: no difference past the noise in the
|
||||
process's GPU (`nvidia-smi pmon`, 9 to 12 % either way) or CPU.
|
||||
|
||||
Render mode: media_kit's Linux/Windows video output creates mpv's render
|
||||
context (OpenGL, else its software renderer) when the `VideoController` is
|
||||
@@ -595,7 +682,8 @@ their own): creates players by role (`session`: the full cache, `warn`;
|
||||
no outputs, no cache, no log) with every pin set before the caller's
|
||||
first command (`pinMismatches` lists the read-backs that differ), gives
|
||||
a player its video output (`HostedVideo`, with the hwdec policy:
|
||||
`SYNC_RELAY_HWDEC`, else the setting, `auto-copy` on Windows), runs the
|
||||
`SYNC_RELAY_HWDEC`, else the setting, `auto-copy` on Windows) at the
|
||||
view's size when asked (`setOutputSize`, see "Output size"), runs the
|
||||
render-mode probe and its diagnostics (`renderMode`, also after leaving),
|
||||
routes mpv's log, and owns the order players go in: `retire` (pause, let
|
||||
the view drop the texture: two frames and a route pop, then dispose; a
|
||||
@@ -627,8 +715,9 @@ ready.
|
||||
can, so this is Android's plug-in point).
|
||||
- Resolve: a Jellyfin item through this app's account (or the plan
|
||||
resolved ahead for Up next, `warmJellyfin`), else the server's login,
|
||||
else the member passing it on, else blocked (`openJellyfin`); a web
|
||||
page through the app's own yt-dlp (`LinkResolver.probe`: yt-dlp's
|
||||
else the member passing it on, else blocked (`openJellyfin`); a link
|
||||
this member passes on through its shared reader's loopback URL ("Read
|
||||
once" under "Relay"); a web page through the app's own yt-dlp (`LinkResolver.probe`: yt-dlp's
|
||||
stream URLs, an EDL of video and audio, the CDN's headers with its
|
||||
cookies, the subtitles; see "yt-dlp"), failing in plain words with the
|
||||
cookies that were used (`ResolveFailure`, D11); a relay not fed yet is
|
||||
@@ -670,6 +759,10 @@ ready.
|
||||
- Being a relay source (`RelayFeeder`, `session/relay_feeder.dart`): the
|
||||
current media's channel and Up next's warm relay (see "Relay"), with
|
||||
the step-1 re-dial (a source rides through this app's own drop). A
|
||||
link it passes on is read once for its player and the relay
|
||||
(`SharedLinks`, `session/shared_links.dart`; "Read once" under
|
||||
"Relay"): the swap check's reader, else Up next's warm one, else a new
|
||||
one; it lives while the media does (a sit-out keeps it for the relay). A
|
||||
Jellyfin item this member put up is passed on only once someone
|
||||
present needs it (`jellyfinRelayNeeded`: the server's login does not
|
||||
cover it and a present member does not play that server with its own
|
||||
@@ -829,8 +922,8 @@ and URL, played together by viewers as an mpv EDL
|
||||
`relayViewerUrl` / `mpvEdl`). media_kit opens an `edl://` URL unchanged
|
||||
(it writes it to a playlist and `loadlist`s it) and mpv keeps both
|
||||
streams in sync across seeks; one URL keeps the app's "one media URL"
|
||||
path (sync, swap, reopen) as is. The source member plays the page
|
||||
itself (its own resolve, see "yt-dlp") like any other link.
|
||||
path (sync, swap, reopen) as is. The source member's own player reads
|
||||
the same tracks through its shared reader ("Read once" below).
|
||||
|
||||
A page with only segmented streams (HLS `m3u8_native`, DASH
|
||||
`http_dash_segments`, e.g. X's HLS, generic `.m3u8`/`.mpd`) is relayed as
|
||||
@@ -862,6 +955,64 @@ How bytes move:
|
||||
this machine). Viewers only ever talk HTTP to the server, so the source
|
||||
never sees mpv's request pattern. The tracks of a media share the
|
||||
channel and its flow control (they compete for the same uplink).
|
||||
- **Read once** (a link passed on: `SharedSource`,
|
||||
`packages/server/lib/src/shared_source.dart`, kept by the app's
|
||||
`SharedLinks`): the source reads each track from the site once. Per
|
||||
track a `RelayBuffer` on the source (64 MiB ahead and 16 MiB behind
|
||||
for all tracks, split by size like the server's, plus the pins:
|
||||
`sharedAheadBytes` / `sharedBackBytes`), filled by one sequential
|
||||
upstream reader, serves two readers: the source's own player, through
|
||||
a loopback endpoint (`http://127.0.0.1:<port>/<random secret>/<track>`,
|
||||
an mpv EDL for several tracks, no headers: the site's stay in the
|
||||
reader) answered with the server's own byte-answer code
|
||||
(`writeRelayRange`, below: headers within 4 s, `connection: close`),
|
||||
and the server's pulls (`SharedSource.upload`, served by the usual
|
||||
`RelaySource`). The player opens at once; the endpoint waits for the
|
||||
tracks' sizes. The two readers are kept within one window
|
||||
(`RelayBuffer.together`): while both have a request open, the one more
|
||||
than the track's `ahead` past the other waits without asking upstream,
|
||||
so one sequential read serves both instead of handing over at every
|
||||
slice. The server's read-ahead thus stops about 64 MiB past the
|
||||
source's player, and a source whose upload lags holds its own player
|
||||
there (viewers would wait at that point anyway); either reader alone
|
||||
reads freely, pinned slices always come. A seek (shared, so both
|
||||
readers go together) is a new upstream read there, as on the server.
|
||||
Upstream (`ResumingBytes`): a cut resumes from the byte it reached
|
||||
after 100 ms; an answer without a byte (412, 429, 5xx, a refused or
|
||||
dead connection, nothing for 20 s) backs off 0.5 s doubling to 8 s;
|
||||
60 s without a byte fails the read, and the buffer's own backoff asks
|
||||
again. 403/410/412 first resolve the page again (see "yt-dlp"). The
|
||||
source's player plays the relay's tracks, so the source and the
|
||||
viewers watch one format, and the reader sends the headers and cookies
|
||||
yt-dlp gave for it. The putter's swap check is this reader (its sizes
|
||||
are the media's: no second read); an Up next link passed on feeds its
|
||||
warm relay from its own reader, the preload player reads its loopback
|
||||
URL (one prepared on the site before the reader existed is prepared
|
||||
again), and the swap carries both on. Reload video opens the loopback
|
||||
URL again (the reader resolves an expired page itself). Not read once:
|
||||
a page with a pieces track (HLS/DASH, X's): its player plays yt-dlp's
|
||||
pick from the site and the relay reads the pieces itself, two fetches;
|
||||
a file passed on (disk) and a Jellyfin item (see "Media pipeline")
|
||||
keep their own paths. Measured (server `single_fetch_e2e`, opt-in: a
|
||||
real server and `RelaySource`, mpv 0.41 with the app's pins on
|
||||
loopback, a fake CDN with Bilibili's Referer rule and two tracks:
|
||||
60 s of 720p h264 at 4 Mbit/s and 128 kbit/s aac; 20 s of play, then
|
||||
a seek to 45 s and back to 5 s):
|
||||
|
||||
| CDN / source | First frame: source, viewer | Viewer stalls | Viewer seek to 45 s | CDN requests (player + relay) | CDN MiB | 412s |
|
||||
|---|---|---|---|---|---|---|
|
||||
| clean, two fetches | 0.22 s, 0.46 s | 0 | 0.27 s | 8 (2 + 6) | 51.8 | 0 |
|
||||
| clean, read once | 0.27 s, 0.33 s | 0 | 0.29 s | 6 (0 + 6) | 25.9 | 0 |
|
||||
| cuts every 0 to 2 MiB, two fetches | 0.22 s, 0.38 s | 1 | 0.27 s | 64 (28 + 36) | 55.9 | 0 |
|
||||
| cuts, read once (29 resumes) | 0.22 s, 0.27 s | 0 | 0.27 s | 33 (0 + 33) | 25.9 | 0 |
|
||||
| cuts + 412 lockout (> 40 requests in 10 s), two fetches | 0.21 s, 0.39 s | 1 | 4.03 s | 49 (23 + 26) | 35.3 | 6 |
|
||||
| cuts + lockout, read once | 0.26 s, 0.33 s | 0 | 0.29 s | 33 (0 + 33) | 25.9 | 0 |
|
||||
| 768 KiB/s per connection, two fetches | 0.40 s, 0.59 s | 1 | 0.80 s | 12 (5 + 7) | 34.0 | 0 |
|
||||
| 768 KiB/s, read once | 0.40 s, 0.47 s | 1 | 0.76 s | 7 (0 + 7) | 16.9 | 0 |
|
||||
|
||||
The source's own seeks took about what the viewer's did; back to 5 s
|
||||
took 0.25 to 0.29 s everywhere. The source's player stalled only on
|
||||
the slow CDN (once, in both modes).
|
||||
- Upstream GETs (`HttpRelayBytes.read`) are split into back-to-back
|
||||
ranges of at most yt-dlp's `http_chunk_size` for the format, else
|
||||
10 MiB (`relayUpstreamChunkBytes`); a buffer pull stays one read.
|
||||
@@ -894,7 +1045,7 @@ How bytes move:
|
||||
kept; segments are evicted by distance (behind costs 4x), sizes
|
||||
estimated from the bytes per second of the pieces in so far (before
|
||||
that 4 Mbit/s video, 160 kbit/s audio, which also splits the budget).
|
||||
A failed pull is retried after 1 s. Measured (mpv 0.41 through the
|
||||
A failed pull is retried with backoff, as for bytes. Measured (mpv 0.41 through the
|
||||
dedicated server, loopback, ~30 MB/s upstream): Mux's TS HLS (1080p,
|
||||
one muxed playlist) opened in 0.8 s, seeks 0.07 to 1.1 s; Akamai's DASH
|
||||
(1080p + aac, two playlists, EDL) 1.3 s, seeks 0.03 to 1.4 s; an X
|
||||
@@ -903,7 +1054,13 @@ How bytes move:
|
||||
opened in 0.02 s from the buffer.
|
||||
- Filling a byte buffer: a viewer blocked on a slice first (oldest wait
|
||||
first, handing the source over at slice boundaries), then the pinned
|
||||
slices, then read-ahead from the slowest viewer. A viewer reads the
|
||||
slices, then read-ahead from the slowest viewer. A failed pull (the
|
||||
source's upstream dropping, a CDN refusing) is followed by the next
|
||||
one after 250 ms, doubling up to 8 s (`relayRetryDelay`), back to none
|
||||
once a slice or a pull completes: a failing CDN is not asked again
|
||||
every round trip (that drew Bilibili's 412/403). Read-ahead never
|
||||
fetches the slice that would be evicted next, and a long pull evicts
|
||||
as its slices come, so a buffer stays within its budget. A viewer reads the
|
||||
slice being filled as its bytes arrive, not once it is whole (mpv's
|
||||
demuxer, blocked in a read, takes a seek only once that read returns).
|
||||
Nothing is fetched while nobody watches through the relay (a Jellyfin
|
||||
@@ -964,7 +1121,23 @@ How bytes move:
|
||||
its source before opening its own player, and the source reads its
|
||||
tracks' sizes at once.
|
||||
- Source away (D6): viewers keep playing from the buffer; a request
|
||||
waits up to 60 s for missing bytes. The source member going `gone`
|
||||
waits up to 60 s for missing bytes.
|
||||
- A viewer's byte answer is written on the raw connection (relic
|
||||
`Hijack`, `_rangeResponse`): its headers go out with the first bytes,
|
||||
or after 4 s without them (`relayHeaderWaitMs`). ffmpeg gives up on a
|
||||
request whose headers do not come within mpv's 5 s `network-timeout`
|
||||
and then has lost the file's size for good (8.x and 9.0 do not restore
|
||||
it): every later seek fails with "Seek failed (to N, size -38)". With
|
||||
the headers in time, a stalled read only times out and is reconnected
|
||||
(`reconnect_*`, see "Media pipeline"), and plays on once the bytes come.
|
||||
The answer always says `connection: close` and closes after its body:
|
||||
ffmpeg asks for close, and ffmpeg 8.x, not told so, sends its next seek
|
||||
on the connection after reading a range to its end (an mp4 with a box
|
||||
after `mdat`, like X's clips: the 58-byte tail, then back to the
|
||||
start) and fails it the same way. Other answers say so too when asked
|
||||
(`closeWhenAsked`, every server route). A swap that ends the relay
|
||||
closes its viewers' answers plainly (`RelayEnded`, not logged as a
|
||||
broken read). The source member going `gone`
|
||||
does not end the relay: it lives through the source's grace
|
||||
(`media.relay.sourceGone`, `ServerHub.refreshRelaySource`). The source
|
||||
(`RelaySource`, kept by the media pipeline's `RelayFeeder` through the
|
||||
@@ -1095,7 +1268,8 @@ all three link modes. Nothing is bundled or installed by hand.
|
||||
format's `http_headers` plus its `cookies` as a Cookie header; the
|
||||
requested subtitles but Bilibili's danmaku, an XML comment feed); and
|
||||
what a relay reads (`pickTracks`), each format with its URL,
|
||||
`http_headers` and `http_chunk_size`. For a relay only plain http(s)
|
||||
`http_headers` plus its `cookies` as a Cookie header (what a player
|
||||
sends) and `http_chunk_size`. For a relay only plain http(s)
|
||||
formats without DRM count:
|
||||
- the best single file (`pickSingleFile`: video and audio not `none`, a
|
||||
video container, tallest up to 1080p, else the shortest above), unless
|
||||
@@ -1130,10 +1304,21 @@ all three link modes. Nothing is bundled or installed by hand.
|
||||
`swapLink` refuses it the same way for any other caller.
|
||||
- **Everyone fetches it**: the swap sends the page URL. Every app
|
||||
resolves it with its own yt-dlp (the media pipeline's resolve step,
|
||||
`LinkResolver.probe`, waiting for the download on first use) and opens
|
||||
what yt-dlp picked (`YtDlpPlay.mpvUrl`) with its headers as mpv's
|
||||
`LinkResolver.probe`, waiting for the download on first use). A page
|
||||
whose relay tracks are plain files (`SharedLinks.fits`) plays through
|
||||
a reader of this app's own, as alone with no relay
|
||||
(`SharedLinks.local`: a `SharedSource` without the upload, "Read once"
|
||||
under "Relay"): its tracks with yt-dlp's headers and cookies, resumed
|
||||
after a cut, backed off on 412/429/5xx, the page resolved again on
|
||||
403/410/412, so a CDN cutting a long read (Bilibili: "Stream ends
|
||||
prematurely", then a refused reconnect that ffmpeg treats as fatal)
|
||||
never reaches the player. The reader is opened with its sizes (15 s);
|
||||
one that cannot (a refusal, a pieces track, a live stream) leaves the
|
||||
player on what yt-dlp picked (`YtDlpPlay.mpvUrl`) with its headers as mpv's
|
||||
`http-header-fields` (media_kit's `Media.httpHeaders`), for every
|
||||
track of the EDL: Bilibili's CDN answers 403 without the Referer. Its
|
||||
track of the EDL: Bilibili's CDN answers 403 without the Referer. The
|
||||
session player's and the preload's readers stay, any other closes
|
||||
(`trimLocal`); Reload video makes a new one. Its
|
||||
subtitles are added once the media is in (`sub-add <url> auto`). mpv's
|
||||
own Lua yt-dlp hook is off (`ytdl=no`): one resolve path for every mode,
|
||||
cookies and headers always the app's, and a libmpv without Lua plays
|
||||
@@ -1145,9 +1330,14 @@ all three link modes. Nothing is bundled or installed by hand.
|
||||
track's playlist must be readable, else the swap is refused ("Live
|
||||
streams can't be passed on", DRM, or nothing a relay can read) with a
|
||||
hint to use "Everyone fetches it". The swap names the tracks; its relay
|
||||
source reads them with yt-dlp's headers (`PageRelayMedia`). On 403/410
|
||||
(expired format URLs, 6 h on YouTube; signed segment URLs) from any
|
||||
track it resolves the page again once for all tracks, finds the same
|
||||
source reads them with yt-dlp's headers and cookies (`PageRelayMedia`),
|
||||
and the putter's own player plays those same tracks through the one
|
||||
reader ("Read once" under "Relay"), not yt-dlp's `requested_formats`:
|
||||
the putter sees the relay's pick (HEVC first) like the viewers. On
|
||||
403/410/412 (expired format URLs, 6 h on YouTube; signed segment URLs;
|
||||
Bilibili's 412) from any track it resolves the page again once for all
|
||||
tracks (not again within 60 s: a further refusal fails the read and is
|
||||
backed off), finds the same
|
||||
format ids with the same sizes and piece counts and roles (playlists
|
||||
re-read and re-mapped by index; else "the video changed on the page")
|
||||
and goes on from where it stopped. Format URLs are bound to the
|
||||
@@ -1642,8 +1832,10 @@ background, default off). The coordinator gives the session's part
|
||||
like, "starting now"); the app's notices, problems and your own show.
|
||||
- "Starting now" pops up only while you sit out or the window is in the
|
||||
background.
|
||||
- Chat pop-ups follow the setting (it is in the chat either way); the
|
||||
chat sound has its own switch.
|
||||
- Chat pop-ups follow the setting (it is in the chat either way), and
|
||||
none show while the chat panel is open in a focused window
|
||||
(`Notifier.chatOpen`, set by the shell); the chat sound has its own
|
||||
switch and still plays.
|
||||
- The window in the background (unfocused, minimised, hidden): what
|
||||
reaches a member who sits out (chat, nudges, "starting now", Up next,
|
||||
joins and leaves when chosen) also goes out as a system notification
|
||||
@@ -1669,8 +1861,10 @@ handed on from there (the coordinator gets its `Notifier`, `Connection` and
|
||||
(`watchActivity`: focused, unfocused, minimised, hidden; window_manager's
|
||||
focus, blur, minimise and restore, and the shell's own `hide`, wired to
|
||||
`SessionCoordinator.windowActivity` and `Notifier.window` in `main`),
|
||||
bringing the window up and focusing it (`focus`), dragging (`WindowDragArea`), close, the way out
|
||||
(save, hide, quit), attention, the clipboard paste (files, else text)
|
||||
bringing the window up and focusing it (`focus`, with another start's
|
||||
activation token, see "Single instance" below), dragging (`WindowDragArea`), close, the way out
|
||||
(save, hide, quit), attention, the clipboard paste (files, else text), the clipboard's
|
||||
text alone (`readClipboardText`, for the join prompt)
|
||||
and drop targets (fox_utils).
|
||||
- `UrlOpener` (`DesktopUrls`): `xdg-open`, `open`, Windows' `url.dll`.
|
||||
- `FolderAccess` (`DesktopFolders`): folder and file dialogs
|
||||
@@ -1695,6 +1889,47 @@ handed on from there (the coordinator gets its `Notifier`, `Connection` and
|
||||
(`AppDirs`, "Desktop UI"), and the copy of the data from before the
|
||||
rename; resolved in `main` before the composition root exists, so not a
|
||||
`Ports` field.
|
||||
|
||||
**Single instance** (`platform/single_instance.dart`, `InstanceLock`): one
|
||||
app per profile folder (`<app support>`, or `profiles/<name>/` with
|
||||
`STARSYNC_PROFILE`, so each profile is its own instance). `main` claims
|
||||
it right after resolving the folders, before the log or the window: an
|
||||
exclusive `RandomAccessFile.lock` on `instance.lock` (the OS drops it
|
||||
when the process ends, a crash too) and a loopback listener whose port,
|
||||
a random token and the pid go in `instance.json`. A start that finds the
|
||||
lock held sends its arguments and activation token there (one JSON line
|
||||
with the token) and ends at once through the runner (`exit` on the
|
||||
`starsync/window` channel: `_exit` / `TerminateProcess`, no exit
|
||||
handlers) before its window ever shows; the running app brings its
|
||||
window up (`ShellPort.focus(activation:)`) and hands the arguments to
|
||||
`JoinOffers`. Bringing a window up from another process needs the
|
||||
desktop's permission: on Linux the second start's `XDG_ACTIVATION_TOKEN`
|
||||
(Wayland) or `DESKTOP_STARTUP_ID` (X11), which the runner keeps before
|
||||
GTK takes it (`launchToken`) and the running app presents its window
|
||||
with (`present`: `gdk_wayland_display_set_startup_notification_id` /
|
||||
`gtk_window_set_startup_id`, then `gtk_window_present`; the launcher
|
||||
entry has `StartupNotify=true` so launchers hand one out); on Windows
|
||||
the second start calls `AllowSetForegroundWindow` for the holder's pid
|
||||
(`platform/desktop/windows_api.dart`). While the app closes it answers
|
||||
"closing" and a new start waits for the lock (up to 20 s; 5 s without
|
||||
any answer), then starts anyway; Update and restart gives the lock up
|
||||
before starting the new binary. Invite links among the arguments are
|
||||
not passed on to that restart (`launchArgs`).
|
||||
|
||||
**Link handler** (`platform/url_scheme.dart`): on Linux the launcher
|
||||
entry ("App icon and launcher entry") lists
|
||||
`MimeType=x-scheme-handler/starsync;` with `Exec=... %u`, and
|
||||
`xdg-mime default dev.foxyz.starsync.desktop x-scheme-handler/starsync`
|
||||
runs when `xdg-mime query default` names another (then
|
||||
`update-desktop-database` on the applications folder). On Windows
|
||||
(release builds) `HKCU\Software\Classes\starsync`: default value
|
||||
`URL:Starsync invite`, `URL Protocol` empty, `DefaultIcon` and
|
||||
`shell\open\command` = `"<install>\starsync.exe" "%1"` (`starsync.exe`
|
||||
next to an old-named `watch_together.exe` copy when it is there),
|
||||
written per user (no admin) through `RegSetKeyValueW` at start only when
|
||||
the command differs (first start, a moved install, an update from an old
|
||||
name).
|
||||
|
||||
Still direct: the updater's restart
|
||||
(`Process.start`), the session's own file reads for what it plays or
|
||||
relays (`QueueController`, thumbnails, cookies import), app-private
|
||||
@@ -1757,8 +1992,9 @@ panel) and chat messages (Copy message, and a link in it).
|
||||
`PresencePolicy`, so not away, gone or errored; "connecting…",
|
||||
"reconnecting…"; hidden under 1100 px), chat with an unread badge, then
|
||||
Invite on yours or Leave on a friend's (asks first, then you are home).
|
||||
Invite (`inviteFriends`) shares your server if it is not yet and copies
|
||||
the invite ("Invite copied · 192.168.1.20:8080"); a port it cannot open
|
||||
Invite (`inviteFriends`, `shell/your_invite.dart`) shares your server if
|
||||
it is not yet and copies the invite link ("Invite link copied"); a port
|
||||
it cannot open
|
||||
is a notice pointing at Settings. The stack
|
||||
opens the members card (the server's version under its title: yours is
|
||||
this app's, a joined one's from `/health.version`; the source
|
||||
@@ -1768,9 +2004,16 @@ panel) and chat messages (Copy message, and a link in it).
|
||||
"reconnecting… (2 min)" with how long; per member force ready, Nudge and Sit
|
||||
out (anyone, the host too), your own row Sit out / Join back; at the
|
||||
bottom, on yours: not shared, "Only you can reach it" and Invite;
|
||||
shared, the invite's address, Copy invite, Stop sharing and a note
|
||||
that friends outside the LAN need a forwarded port or a reachable
|
||||
server; on a friend's, "Copy invite address" (the dialled address)).
|
||||
shared (`YourInvite`), Copy invite link (filled; the address the app
|
||||
picks, `InviteReach.best`, in its tooltip) and Stop sharing; one line
|
||||
only when something needs the user ("Looking up your internet
|
||||
address…"; the router's address: the port needs forwarding to this
|
||||
computer; no internet address found: the link works on the home network
|
||||
only); "Other addresses" (folded, only with more than one) lists each
|
||||
address of `YourServer.reach` (Internet, Home network, VPN) to pick
|
||||
what Copy copies; on a friend's, "Copy invite link" (the dialled address,
|
||||
its name and the password you joined with: the link is the key there
|
||||
too))).
|
||||
Empty strip space is the window
|
||||
drag area; the window buttons sit at its end.
|
||||
- **Avatars** (`avatar.dart`): `StatusAvatar` draws a ring outside the
|
||||
@@ -1792,8 +2035,8 @@ panel) and chat messages (Copy message, and a link in it).
|
||||
owned by the shell so an attempt and its error outlive the card; the
|
||||
version gate's refusal says who has to update and, when it is you,
|
||||
offers "Check for the update". The card closes itself once in the
|
||||
friend's server. It says that a friend gives the invite and the
|
||||
password, that no account or Jellyfin is needed and that your own
|
||||
friend's server. It says to paste a friend's invite link (it carries
|
||||
the password) or their address and password, that no account or Jellyfin is needed and that your own
|
||||
server stays as it is.
|
||||
- **Profile** (`profile_card.dart`, `session/identity.dart`): a card
|
||||
tinted with your colour: picture (hover "Change" opens `CropPicker`:
|
||||
@@ -1909,7 +2152,8 @@ panel) and chat messages (Copy message, and a link in it).
|
||||
Jellyfin & integrations (linked servers, AnimeFlow's `/me` via
|
||||
`AnimeFlowClient.fromAccounts`, "Play the next episode automatically":
|
||||
whether this app adds the continuation, see "What's next"), notifications & sounds
|
||||
(what pops up, chat pop-ups, joins and leaves while sitting out or in
|
||||
(what pops up, chat pop-ups, toasts or danmaku over the player with the
|
||||
danmaku look, joins and leaves while sitting out or in
|
||||
the background), updates, shortcuts, about.
|
||||
- **Drop and paste** (`handoff.dart`, `fox_utils`): the shell is one
|
||||
`DropTarget` (a "Drop to open" overlay while dragging) and a
|
||||
@@ -2006,13 +2250,28 @@ panel) and chat messages (Copy message, and a link in it).
|
||||
and the members card says "can't play this: ...". 34 px avatars, a main line
|
||||
and a muted detail line. The pill glass, colour only for problems; it fades through
|
||||
between kinds. The mini shows its short line in its hover bar. ⚙ →
|
||||
Player settings (both skins) opens the player settings card (`controls/
|
||||
player_settings.dart`, `showPopover`, on `AppGlass`): player design, progress line
|
||||
when hidden, upscaling shader, hardware decoding (set live on mpv's
|
||||
`hwdec`), subtitle and audio track. The rows (`playerSettingRows`) are
|
||||
the same widgets Settings → Player shows; all apply live. Settings →
|
||||
Player also has the controls' own choices (`controlsSettingRows`, see
|
||||
"Player controls"). The progress line (`AppSettings.progressLine`) is
|
||||
Player settings (both skins) toggles the player panel
|
||||
(`controls/player_panel.dart`, `PlayerControlsModel.panelOpen`): a
|
||||
312 px `AppGlass` column docked at the right of the full player,
|
||||
between the top bar and the control bar (it never covers the bar),
|
||||
sliding and fading in from the right; it stays open while the controls
|
||||
hide and show, until its × or Esc (the player's Esc closes it first),
|
||||
and leaving the full player closes it. The mouse on it holds the
|
||||
controls up (`ControlsHoldRegion`). Its sections fold (their titles
|
||||
toggle; folded ones are kept for the app's run, Notifications and
|
||||
Controls start folded): tracks (subtitle, audio; remembered as from
|
||||
the track menus), subtitles (size, mpv `sub-scale` from
|
||||
`AppSettings.subScale`, set on every player; above the controls, see
|
||||
"Subtitle lift"), picture (upscaling shader and quality, sharp video
|
||||
and subtitles, see "Media", hardware decoding, set live on mpv's
|
||||
`hwdec`), sound (volume), notifications (Toasts / Danmaku and, with
|
||||
Danmaku, the look's knobs, see "Danmaku notifications") and controls
|
||||
(design, progress line); a pointer to Settings → Player and Mark a
|
||||
problem at its foot. Compact rows of its own over the same settings and
|
||||
actions; Settings → Player shows the full rows (`playerSettingRows`:
|
||||
design, progress line, shader, sharp video, hardware decoding, subtitle
|
||||
size, subtitles above the controls) and the controls' own choices
|
||||
(`controlsSettingRows`, see "Player controls"); all apply live. The progress line (`AppSettings.progressLine`) is
|
||||
what the bottom controls leave while hidden: Standard a 2 px line,
|
||||
Minimal its collapsed timeline. Fullscreen adds the
|
||||
`WindowButtons` to the top bar while the controls are up (restore
|
||||
@@ -2048,7 +2307,8 @@ panel) and chat messages (Copy message, and a link in it).
|
||||
- **Chat** (`chat_panel.dart`): a 340 px panel sliding in at the right
|
||||
(strip button or Tab): chat with avatars; the "Activity" chip (off by
|
||||
default, `showActivity`, kept for the app's run only) adds the other
|
||||
feed lines between them. Opening focuses the field; Tab or Esc in it
|
||||
feed lines between them. Its field shares the quick chat box's draft
|
||||
(see "Quick chat"). Opening focuses the field; Tab or Esc in it
|
||||
close the panel and focus goes back where it was. Unread chat badges
|
||||
the strip's and the player controls' chat buttons; in fullscreen with
|
||||
the controls down and the video not playing a glass chip
|
||||
@@ -2066,7 +2326,96 @@ panel) and chat messages (Copy message, and a link in it).
|
||||
are up and come down to 16 px from the edge while they are hidden
|
||||
(`AnimatedPositioned`, the shell's `ease`; the player reports its
|
||||
controls through the shell's `controlsShown`); elsewhere 16 px from the
|
||||
bottom left. What pops up at all is the Notifier's policy.
|
||||
bottom left. What pops up at all is the Notifier's policy. Each
|
||||
`ToastItem` carries its `NoticeCategory` and whether it is your own
|
||||
(`own`); the shell leaves out those flying as danmaku and your own chat
|
||||
coming back for a line the quick chat box already showed
|
||||
(`PartyToasts.skip`).
|
||||
- **Quick chat** (`shell/quick_chat.dart`): Enter in the full player
|
||||
(not the mini: it is a corner thumbnail while you are elsewhere in the
|
||||
app, where Enter belongs to that page; nor while sitting out) opens a
|
||||
small box at the bottom centre, 16 px up, in the control bar's place,
|
||||
without opening the chat panel. Enter is a `HardwareKeyboard` handler
|
||||
of the player, so it works whatever plays and whatever button kept
|
||||
focus; a `FocusManager` early handler swallows that Enter so the
|
||||
focused button does not act on it too. `QuickChat` (the shell's, it
|
||||
outlives the player's modes) holds the state: the draft is the chat
|
||||
panel's `TextEditingController` (typing in either shows in both, a send
|
||||
from either clears both), its `FocusNode` takes focus at once (keys
|
||||
typed right after go to the box; `textInputFocused` keeps Space, Tab,
|
||||
F and the arrows off the player meanwhile), and it closes by itself
|
||||
after `idleClose` (8 s) without typing, Esc, or an empty Enter, keeping
|
||||
the draft for the next Enter. Enter with text (an input method's
|
||||
composing Enter excepted) sends it (`sendChat`) and closes. The box
|
||||
(`QuickChatBox`) starts 300 px wide and widens with the text up to
|
||||
640 px (or the room there is), then wraps up to four lines, growing
|
||||
upward; your avatar leads it; it fades and slides in on the shell's
|
||||
curve and stays mounted while closed (invisible, ignoring the pointer)
|
||||
so focus is immediate. What you sent shows where the box was for
|
||||
`echoTime` (2.6 s, `QuickChatEcho`, with an accent hairline), or, with
|
||||
danmaku notifications, flies at once as your own line; your own chat
|
||||
toast coming back from the server is not shown again
|
||||
(`QuickChat.echoed`). While the box or its echo is up the controls are
|
||||
held hidden (`ControlsFrame.suppress`: drawn hidden, `shown` false, the
|
||||
cursor still follows the mouse) and the action area rides above the
|
||||
box. Typing is not mouse activity: the frame wakes the controls only
|
||||
on real pointer movement (an enter or hover at the last seen position,
|
||||
as when the box comes or goes under a still pointer, does nothing), and
|
||||
the field's I-beam stays hidden until the mouse moves over it, so
|
||||
Enter, typing and sending never show the cursor or the controls.
|
||||
- **Subtitle lift** (`video_view.dart`): bottom subtitles move above what
|
||||
covers the bottom of the full player: the quick chat box or its echo
|
||||
(always), and the control bar while it is up when
|
||||
`AppSettings.liftSubtitles` is on (off by default; Settings → Player
|
||||
and the player panel, "Subtitles above the controls"); 8 px above it.
|
||||
`SubtitleLift` steps the covered height toward each new target in
|
||||
`liftStepCount` (4) eased-out steps `liftStepGap` (42 ms) apart, the
|
||||
first at once: about one step per frame of a 24 fps video, so each
|
||||
lands on its own video frame (a `sub-pos` change per screen frame
|
||||
landed unevenly on the video's frames and looked choppy).
|
||||
`SubtitleLifter` turns each step into mpv `sub-pos` (`subtitleLiftPos`:
|
||||
only as far as the letterbox under the picture leaves no room, at most
|
||||
half the picture), back to 100 when nothing covers and for a new
|
||||
player. What each mpv property does, on `tool/make_sub_test.sh`'s file
|
||||
(mpv 0.41, libmpv as the app uses it): `sub-pos` moves bottom ASS
|
||||
dialogue and SRT lines up and leaves `\an8` signs in place (with
|
||||
`sub-ass-override` at its default `scale` or `yes`; `no` ignores it
|
||||
for ASS); `sub-margin-y` moves SRT only (ASS only with
|
||||
`sub-ass-override=force`, which restyles every line, signs too);
|
||||
`sub-ass-style-overrides=MarginV=...` moves top signs down as well;
|
||||
`video-margin-ratio-bottom` shrinks and moves the picture itself (with
|
||||
`sub-ass-force-margins` the dialogue goes into the new black band).
|
||||
- **Danmaku notifications** (`danmaku/`): Settings → Notifications &
|
||||
sounds → Over the player → Notifications: Toasts (default) or Danmaku
|
||||
(`AppSettings.notifyStyle`, live; also in the player panel). With
|
||||
Danmaku and the full player up (not sitting out), what
|
||||
`fliesAsDanmaku` picks (chat, joins, leaves, lost connection and back,
|
||||
"starting now", Up next news someone caused) flies right to left
|
||||
across the picture (`DanmakuNotices` under the controls feeds new
|
||||
toasts to a `DanmakuLayer` through `danmakuForToast`); nudges,
|
||||
problems, your own state and the app's notices stay toasts, and so does
|
||||
everything with the player small. The player sets the shell's
|
||||
`danmakuActive` so the toasts leave the flying ones out. The layer:
|
||||
lanes in a band of the height (`DanmakuStyle.bandTop` / `bandBottom`,
|
||||
default 10 to 45 %, under top signs, never down into the dialogue);
|
||||
each line takes the topmost free lane (`DanmakuLanes`, pure: the last
|
||||
line in a lane must have fully entered with a gap, and the new one, at
|
||||
its own speed, cannot catch it before it leaves); at most
|
||||
`maxOnScreen` fly, the rest wait in order (dropped after 15 s, 60 at
|
||||
most); every line crosses in `crossSecs`, so long ones move faster;
|
||||
white text at the chosen weight (`weight`, 400 to 900, the name a step
|
||||
bolder) with a dark outline and no box, the sender's avatar and
|
||||
coloured name first, the app's own notices without one, own lines in
|
||||
an accent frame; the layer's own clock (lines fly on while paused),
|
||||
ticking only while lines fly or wait. Each line is a widget moved by a
|
||||
`Flow`: per frame only paint runs. The look (`AppSettings.danmakuStyle`,
|
||||
one map, `DanmakuStyle.toJson` / `fromJson`, which clamps every value
|
||||
to `DanmakuStyle.limits`) has every knob in the settings card
|
||||
(`danmaku_settings.dart`: crossing time, at most on screen, lane band,
|
||||
opacity, size, boldness, outline, avatars, frame my own lines, reset)
|
||||
over a preview strip (a moving stand-in picture, a top sign, bottom
|
||||
dialogue and sample lines in the current look; it plays for 16 s after
|
||||
it opens or a knob moves, then rests until clicked).
|
||||
|
||||
Settings persist in two Hive boxes: `connect_form` (last form, recent
|
||||
servers and links, the profile picture and colour; saved even when a join
|
||||
@@ -2186,7 +2535,8 @@ Player controls (`lib/src/controls/`): one controls model, two skins.
|
||||
mark a problem, player settings, leave. Icons, tooltips (with keys) and
|
||||
the clock text (`clockText`) are defined there too, once.
|
||||
- **Shared pieces**: `ControlsFrame` (`frame.dart`): when the controls
|
||||
are up (mouse movement shows them, `controlsHideSecs` without any or the
|
||||
are up (real mouse movement shows them, see "Quick chat"; `suppress`
|
||||
holds them hidden; `controlsHideSecs` without movement or the
|
||||
mouse leaving hides them and the cursor), holds (`hold` during a seek
|
||||
drag, `holdDuring` / `holdWhile` around menus and popovers, `holdBy`
|
||||
the session's `ControlsHold`), and the double-click on the picture
|
||||
@@ -2232,7 +2582,7 @@ part not downloaded still reads; chapter cuts are a light dark line.
|
||||
`mpvControls: true` from before becomes minimal), `timeDisplay`,
|
||||
`controlsSize` (normal / large: the bottom bar ×1.25),
|
||||
`controlsHideSecs` (2 / 3 / 5), `hiddenControls`, `progressLine`.
|
||||
The player settings card has the skin and the progress line; Settings →
|
||||
The player panel has the skin and the progress line; Settings →
|
||||
Player → Controls has the rest.
|
||||
- Pills (Skip OP / ED / 1:30, Next episode) and cards live outside the
|
||||
controls, in the action area ("Player overlay"). The cards stay up; a
|
||||
@@ -2326,7 +2676,8 @@ dedicated server (`package:logging` loggers named by area: `relay`,
|
||||
once Flutter is up) are kept and written first.
|
||||
- **Redaction** (`redactLog`): every line, in one place
|
||||
(`formatLogLine`), before console or file: URL query strings cut to
|
||||
their parameter names (signed CDN links, Jellyfin `api_key`),
|
||||
their parameter names (signed CDN links, Jellyfin `api_key`, an invite
|
||||
link's `pw`: `starsync://` counts as a URL),
|
||||
`user:pass@` in URLs, member tokens in `/r /m /j /a /i /l` paths, and the
|
||||
values of passwords, secrets, tokens, API keys, cookies,
|
||||
`Authorization` / `X-Emby-*` headers (`name=value`, `"name":"value"`,
|
||||
@@ -2383,7 +2734,7 @@ dedicated server (`package:logging` loggers named by area: `relay`,
|
||||
- **Opting out**: Settings → About → "Share diagnostics with the server"
|
||||
(default on). Off: no lines are kept or sent; one batch per connection
|
||||
says so (`share:false`). The local log is unchanged.
|
||||
- **Mark a problem**: Ctrl+Shift+B (`AppKeys`), the player's ⚙ card and
|
||||
- **Mark a problem**: Ctrl+Shift+B (`AppKeys`), the player panel and
|
||||
Settings → About open a small card with an optional note (200
|
||||
characters, one line); the mark goes at once with the lines waiting,
|
||||
opted out or not, and the server writes `[who] === PROBLEM marked by
|
||||
@@ -2423,7 +2774,9 @@ take the icon from the `.desktop` file of that id. A tarball install has
|
||||
none, so on start a Linux release build (`platform/desktop_entry.dart`,
|
||||
skipped in debug) writes
|
||||
`~/.local/share/applications/dev.foxyz.starsync.desktop` (Exec = the
|
||||
running binary, `StartupWMClass` and `Icon` = the id) and the icons into
|
||||
running binary with `%u`, `StartupWMClass` and `Icon` = the id,
|
||||
`StartupNotify`, the `starsync://` link type: "Link handler" under "App
|
||||
events and platform ports") and the icons into
|
||||
the hicolor theme, only when they changed, and deletes the entry and
|
||||
icons 1.2.x wrote under its id `moe.senkochan.watch_together` (those exact
|
||||
files only). The runner also sets the window icon from the bundle (X11).
|
||||
@@ -2436,8 +2789,8 @@ company `foxyz`, product `Starsync`; the window class is
|
||||
One implementation, `packages/core/lib/src/updater.dart` (pure Dart, tested
|
||||
in `packages/core/test/updater_test.dart`), with two front ends: the app and
|
||||
the dedicated server. Both binaries ship in the same archive, so either one
|
||||
updates the whole install folder. Reads a manifest (`version`, `protocol`, `url`, optional `sha256`, `notes`)
|
||||
from `updateManifestUrl`, compares versions, downloads the tarball, checks
|
||||
updates the whole install folder. Reads a manifest (`version`, `protocol`, `url`, optional `sha256`, `notes`,
|
||||
optional `changes`) from `updateManifestUrl`, compares versions, downloads the tarball, checks
|
||||
sha256 when present, then extracts into a staging dir inside the install
|
||||
folder (same filesystem, so moves are renames; the folder must be
|
||||
user-writable). Each top-level entry is moved aside and the new one moved in,
|
||||
@@ -2478,9 +2831,11 @@ does the same from the app binary. In the app, `UpdateController`
|
||||
(`apps/desktop/lib/src/update/`) runs the same code for the update bar
|
||||
(`UpdateBar`, `screens/home/update_indicator.dart`): a band across the
|
||||
window under the strip while an update is available: "Starsync X.Y.Z is
|
||||
ready", the notes, Later (hidden until the next check or start) and
|
||||
"Update and restart" (download, apply, then the app close above with Hive
|
||||
closed and the new binary started), its progress in the band. A release
|
||||
ready", the first three changes (each with its kind's icon; the notes
|
||||
when the manifest has no `changes`), What's new, Later (hidden until the
|
||||
next check or start) and "Update and restart" (download, apply, then the
|
||||
app close above with Hive closed and the new binary started), its
|
||||
progress in the band. A release
|
||||
that is behind also says which version it runs; a copy that is not a
|
||||
packaged install says to replace the folder by hand. The shell keeps the
|
||||
band off the full player while a video is on (not finished); it shows on
|
||||
@@ -2489,11 +2844,49 @@ update has no Later. Nothing downloads before the click. The app checks at
|
||||
start and every 4 hours while running (`updateCheckEvery`), both with the
|
||||
auto-check setting on; a re-check never touches an install under way.
|
||||
Settings has Check now and the auto-check toggle. The manifest is `updates/manifest.json` in
|
||||
this repo, written by `tool/release.sh`. Test builds (`X.Y.Z-dev.N`,
|
||||
`isDevBuild`) also read `devManifestUrl`, the `manifest.json` of the `dev`
|
||||
prerelease (`tool/dev_release.sh`), and take the newer of the two
|
||||
(`fetchLatestManifest`); the release X.Y.Z outranks its test builds, so
|
||||
testers land back on stable. `0.0.0-dev` (a dev run) checks nothing.
|
||||
this repo, written by `tool/release.sh`.
|
||||
|
||||
**Update channel.** `UpdateChannel` stable or dev, the setting
|
||||
`updateChannel` (Settings → Updates, "Update channel", which also says
|
||||
the channel and version running). Dev also reads `devManifestUrl`, the
|
||||
`manifest.json` of the `dev` prerelease (`tool/dev_release.sh`), and takes
|
||||
the newer of the two by semver (`pickManifest`, `fetchLatestManifest`): a
|
||||
release 1.4.0 is offered over 1.4.0-dev.3, and after it 1.4.1-dev.1 is,
|
||||
the install staying on Dev. Stable reads only the stable manifest. Never
|
||||
chosen, the channel is `defaultUpdateChannel`: dev for a test build
|
||||
(`X.Y.Z-dev.N`, `isDevBuild`), else stable; a test build stores dev on its
|
||||
first start, so the release that later updates it stays on Dev (stable by
|
||||
default is not stored). A file `update-channel` next to the binary (`dev`
|
||||
/ `stable`, written by the one-time command `tool/dev_release.sh` prints)
|
||||
is taken as the channel at the next start and deleted. Switching the
|
||||
setting re-checks at once. On stable in a test build, a release older than
|
||||
the build (`isStableBehind`) is never offered by itself
|
||||
(`UpdateController.stableBehind`, "Stable is at X, older than this test
|
||||
build"); switching to Stable there asks "Go back to stable X?": going
|
||||
back installs it (`goBackTo`), staying keeps the build until a newer
|
||||
release. `--upgrade` uses the default channel of the binary's version.
|
||||
`0.0.0-dev` (a dev run) checks nothing.
|
||||
|
||||
**What's new.** The manifest's `notes` is one line (all 1.2.x and 1.3.x
|
||||
read; they ignore other keys) and `changes` (`{"added": [...], "changed":
|
||||
[...], "fixed": [...]}`, `ReleaseChanges`, read leniently: anything
|
||||
malformed is no `changes`) the version's lists; both come from
|
||||
`CHANGELOG.md` ([Changelog](DEVELOPMENT.md#changelog)). The card
|
||||
(`WhatsNewCard`, `widgets/whats_new_card.dart`) shows them as Added /
|
||||
Changed / Fixed sections, each with its icon and colour, on `AppGlass` in
|
||||
a popover, the rows easing in one after another, the list scrolling in a
|
||||
short window; notes alone show as one paragraph. Three ways open it: the
|
||||
band's What's new (the offered version, with Update and restart), the
|
||||
first start of a new version, and Settings → About ("What's new in X.Y.Z",
|
||||
once this version's notes are known). The first start (`WhatsNew`,
|
||||
`update/whats_new.dart`): before restarting, the update keeps the new
|
||||
version's notes in settings (`UpdateController.beforeRestart`); an update
|
||||
made otherwise (1.3.x's updater, `--upgrade`, by hand) gets them from the
|
||||
first check whose manifest is this very version (`UpdateController.latest`).
|
||||
The card shows once per version (`whatsNewSeen` in settings), never on a
|
||||
fresh install (the settings box was empty), and not over the full player
|
||||
while a video is on (it waits until the video ends or the player
|
||||
leaves full).
|
||||
|
||||
## Decisions
|
||||
|
||||
|
||||
+92
-22
@@ -61,8 +61,9 @@ the timings of each media change (`SwitchTrace`).
|
||||
Two instances on one machine: `STARSYNC_PROFILE=<name>` (letters, digits,
|
||||
`-`, `_`; at most 32) gives an instance its own Hive boxes (settings,
|
||||
profile, connect form, window bounds, folders, Jellyfin sign-ins, the
|
||||
client id: two instances without it are one install, and the second takes
|
||||
the first's member over) and logs under
|
||||
client id: without it a second start is the same install, so it hands
|
||||
its arguments to the running app and ends; each profile is its own
|
||||
instance) and logs under
|
||||
`<app support>/profiles/<name>/`, and ` [<name>]` in the window title;
|
||||
yt-dlp, shaders and caches stay shared (`AppDirs`, ARCHITECTURE "Desktop
|
||||
UI"). Without it nothing changes. Run one plain instance and one with
|
||||
@@ -75,6 +76,33 @@ Harnesses in `tool/` (own `pubspec.yaml`, `dart run tool/<name>.dart`):
|
||||
`range_logger` (serve a file and log every Range request), `shaper`
|
||||
(bandwidth cap, latency, `--kill-after` for outage tests), `fake_viewer`
|
||||
and `e2e_check` (simulated viewers against a live server).
|
||||
`tool/make_sub_test.sh [out.mkv] [seconds]` (ffmpeg) writes a 720p test
|
||||
video for subtitle placement and sharpness (default
|
||||
`~/Videos/starsync-sub-test.mkv`): an .ass track with a top sign
|
||||
(`\an8`) and bottom dialogue (English and Japanese), and an SRT track.
|
||||
|
||||
## Dev tools
|
||||
|
||||
Test builds and runs from source (a version with `-dev`) and debug runs
|
||||
(`devTools`, `screens/home/ui.dart`) get Settings → About → Developer;
|
||||
releases never show it:
|
||||
|
||||
- **Danmaku preview** (`danmaku/danmaku_preview.dart`): the danmaku layer
|
||||
of "Notifications: Danmaku" (ARCHITECTURE "Danmaku notifications") to
|
||||
play with, starting from your own look; its knobs change only the
|
||||
preview. The button opens it over the full player when one is up
|
||||
(under the controls; the panel top right holds them up), else on a
|
||||
page with a moving stand-in picture and a mock control bar of the skin
|
||||
in use. The panel (`DanmakuPanel`) fires samples (chat, own, join,
|
||||
leave, Up next, starting, a burst), a random stream (Poisson arrivals
|
||||
at a rate, short to long lines, CJK and emoji; in a session half the
|
||||
senders are the party's real members), this session's own chat and
|
||||
notice toasts (mapped as the real feature maps them), every
|
||||
`DanmakuStyle` knob, and mock subtitles (a top sign and bottom
|
||||
dialogue). The mock dialogue moves with the subtitle lift's steps as
|
||||
the real subtitles do: over the player when the quick chat box opens,
|
||||
and on the stand-in page, which has its own quick chat box (Enter;
|
||||
what you send flies as your own line). Tests: `danmaku_test`.
|
||||
|
||||
## Tests
|
||||
|
||||
@@ -94,7 +122,10 @@ Three suites, run from each package's folder:
|
||||
- `cd packages/server && dart test` (well under a minute): live servers
|
||||
on loopback: auth and version refusal, identity, takeover and grace
|
||||
(`presence_test` on a fake clock), media and relay byte serving with
|
||||
source drops, Jellyfin relay against a fake, queue (and the shared
|
||||
source drops, the read-once source (`shared_source_test`: its loopback
|
||||
range answers, a cut and resume with backoff, seeks of either reader,
|
||||
the two kept within one window, its memory bound), Jellyfin relay
|
||||
against a fake, queue (and the shared
|
||||
countdown on a fake clock), session log,
|
||||
isolate `serve`, sharing (`share_test`: a listener attached and detached
|
||||
under the same members and Up next, the host key standing in for the
|
||||
@@ -103,10 +134,17 @@ Three suites, run from each package's folder:
|
||||
controls, settings, schedule), pure app logic (`StateDiff`, the library's L2 matching against a
|
||||
fake AniList (`show_matcher_test`), `media.ref` producers,
|
||||
shader list, sound engine with a fake backend, invite addresses, the
|
||||
version gate, the media plan's table with the Jellyfin relay rule and
|
||||
`starsync://` links, the internet address lookup against fake
|
||||
services, the join prompt and one app per profile
|
||||
(`invite_links_test`), the version gate, the media plan's table with the Jellyfin relay rule and
|
||||
the "Waiting for" status (`media_plan_test`), watch tracking and the
|
||||
device history (`watch_tracker_test`), what continues a file of a
|
||||
local show (`queue_logic_test`)), `singletons_test` (the `.instance` count, see
|
||||
local show (`queue_logic_test`), the video view's output size and
|
||||
subtitle lift steps and the dev-tools gate (`video_view_test`), danmaku
|
||||
lanes, style, the toasts that fly and an idle layer
|
||||
(`danmaku_test`), the quick chat box over the player (`quick_chat_test`),
|
||||
the player panel and the danmaku settings card (`player_settings_test`)),
|
||||
`singletons_test` (the `.instance` count, see
|
||||
ARCHITECTURE "Composition root"; lower its baseline when a use goes) and the real-mpv `*_e2e`
|
||||
tests plus `coord_presence`. The whole suite takes a couple of minutes;
|
||||
run it only before a restructure step lands, otherwise only the files
|
||||
@@ -114,7 +152,10 @@ Three suites, run from each package's folder:
|
||||
|
||||
Opt-in, skipped unless set: `JELLYFIN_TEST_*` (core `jellyfin_live` and
|
||||
server `jellyfin_relay_live` against a real Jellyfin; read their headers),
|
||||
`YTDLP_LIVE=1` (downloads yt-dlp and resolves a real tweet). Everything
|
||||
`YTDLP_LIVE=1` (downloads yt-dlp and resolves a real tweet),
|
||||
`WT_SINGLE_FETCH=1` (server `single_fetch_e2e`: a link passed on read
|
||||
once against twice, two real mpv against a fake CDN that cuts and locks
|
||||
out; minutes; `WT_SF_SCENARIOS`, `WT_SF_PLAY` narrow it). Everything
|
||||
else is offline: Jellyfin and link hosts are local fakes.
|
||||
|
||||
Real-mpv tests: `sync_playback_e2e`, `coord_presence` (drops through a
|
||||
@@ -129,7 +170,9 @@ stand-in yt-dlp and a Referer-checking CDN: the EDL, the headers and
|
||||
cookies, subtitles without danmaku, a 403, the D11 error),
|
||||
`media_switch_e2e` (commands during a switch wait and apply once, no mpv
|
||||
errors), `relay_e2e` (a dedicated server with no file of its
|
||||
own: a guest's file, links passed on or fetched by the server, a late
|
||||
own: a guest's file, links passed on (read once: the source's player on
|
||||
its loopback reader, nothing from mpv at the origin) or fetched by the
|
||||
server, a late
|
||||
source, a source that drops and resumes; a seek past what a slow source
|
||||
has sent, timed; a source that drops while a viewer seeks past
|
||||
the buffer and another joins: no seek loop, "Waiting for", all resume
|
||||
@@ -227,11 +270,12 @@ Traps:
|
||||
so shell tests run the real player layout without it; otherwise test pure
|
||||
logic (`StateDiff`, coordinator without widgets).
|
||||
- A `SyncServer` started inside a test: relic reports an error writing a
|
||||
response (a relay response cut short when its relay is dropped at a
|
||||
swap or stop) to the zone the server started in, which fails the
|
||||
test; outside tests it is only logged. Start it in `runZonedGuarded`
|
||||
and fail on anything else (`queue_switch_e2e`, `relay_e2e`): a viewer's
|
||||
read is still in flight at the `stop` in tearDown.
|
||||
response (one cut short at `stop`: `/m`, `/j`; the relay's byte
|
||||
answers are written on the raw connection and end on their own) to
|
||||
the zone the server started in, which fails the test; outside tests it
|
||||
is only logged. Start it in `runZonedGuarded` and fail on anything
|
||||
else (`queue_switch_e2e`, `relay_e2e`): a viewer's read can still be
|
||||
in flight at the `stop` in tearDown.
|
||||
- The Link page probes a filled-in link (network, yt-dlp), which never
|
||||
answers on the widget-test clock: pass `AppShell(linkProbe:)` /
|
||||
`LibraryTab(probe:)` a fake, or `pumpAndSettle` never returns.
|
||||
@@ -245,17 +289,22 @@ Traps:
|
||||
User pushes tags; CI does the rest.
|
||||
|
||||
1. `tool/release.sh [patch|minor|major] [--dry-run]`: derives the next
|
||||
version from the latest tag, bumps the three pubspecs in lockstep, writes
|
||||
`updates/manifest.json` (per-platform `assets` URLs are predictable; `url`
|
||||
stays the Linux tarball for builds <= 0.2.1), commits `Release vX.Y.Z`,
|
||||
makes an annotated tag.
|
||||
version from the latest tag, refuses when the [changelog](#changelog)'s
|
||||
Unreleased has no items (warns when it has no summary) and prints it,
|
||||
renames Unreleased to `X.Y.Z` under a fresh empty Unreleased, bumps the
|
||||
three pubspecs in lockstep, writes `updates/manifest.json` (per-platform
|
||||
`assets` URLs are predictable; `url` stays the Linux tarball for builds
|
||||
<= 0.2.1; `notes` is the section's summary, `changes` its lists), commits
|
||||
`Release vX.Y.Z` (with `CHANGELOG.md`), makes an annotated tag.
|
||||
`--dry-run` stops after printing.
|
||||
2. Review, then `git push --follow-tags`.
|
||||
3. `.gitea/workflows/release.yml` runs one job per platform: Linux builds
|
||||
with `tool/package_linux.sh`, Windows (runner `yuuka-windows`, host mode,
|
||||
PowerShell only; `tool/setup_flutter_windows.ps1` also adds Visual
|
||||
Studio's C++ ATL component once, which the notifications plugin needs) with `tool/package_windows.ps1`. Each then runs
|
||||
`dart tool/release_publish.dart vX.Y.Z <archive>`, which creates the
|
||||
release if the other job hasn't yet and uploads (or replaces) its asset.
|
||||
release if the other job hasn't yet (its text: the version's changelog
|
||||
section) and uploads (or replaces) its asset.
|
||||
By hand: package, then the same `dart tool/release_publish.dart` line.
|
||||
|
||||
Test builds (Linux, no tag, no CI): `tool/dev_release.sh [--minor|--major]
|
||||
@@ -265,17 +314,38 @@ repository read/write) builds the working tree here as `<next>-dev.N` (N
|
||||
one past the published one; `<next>` the pubspec's next patch, or next
|
||||
minor / major when asked, and once such a line is published later runs
|
||||
follow it until a release moves the pubspec) and replaces the assets of the
|
||||
`dev` prerelease with the tarball and its `manifest.json`. Dev builds
|
||||
update from it ([Updater](ARCHITECTURE.md#updater)). A stable install
|
||||
moves onto it once with the command the script prints (run with the app
|
||||
open: it finds the install from the running process and copies the new
|
||||
files over it).
|
||||
`dev` prerelease with the tarball and its `manifest.json` (`changes`:
|
||||
the changelog's Unreleased, which also becomes the prerelease's text). Dev builds
|
||||
update from it on the Dev update channel, which a test build starts on
|
||||
and keeps after a release ([Updater](ARCHITECTURE.md#updater)). Any
|
||||
install switches channel in Settings → Updates; the command the script
|
||||
prints is a shortcut that moves a stable install onto the test build at
|
||||
once (run with the app open: it finds the install from the running
|
||||
process, copies the new files over it and writes the `update-channel`
|
||||
file that sets Dev).
|
||||
|
||||
Tokens come from the environment (`RELEASE_TOKEN`, or Actions-provided
|
||||
`GITHUB_TOKEN`/`GITEA_TOKEN`). The manifest has no `sha256` by default; the
|
||||
updater skips the check when absent. Workflows live in `.gitea/workflows`
|
||||
(the forge reads `.gitea` before `.github`).
|
||||
|
||||
### Changelog
|
||||
|
||||
`CHANGELOG.md` is what people read: the update band's first items, the
|
||||
What's new card ([Updater](ARCHITECTURE.md#updater)) and the release page.
|
||||
A user-facing change adds a line to its `## Unreleased` section in the same
|
||||
commit, under `### Added`, `### Changed` or `### Fixed` (no other
|
||||
headings), as a `- ` item in plain words for the people watching, not
|
||||
commit-speak ("Chat pop-ups stay away while the chat panel is open", not
|
||||
"Notifier: suppress toasts when panel open"). Internal changes (tests,
|
||||
refactors, docs, CI) get no line. The paragraph under the version heading
|
||||
is an optional one-line summary: it becomes the manifest's `notes`, the
|
||||
only thing 1.2.x/1.3.x apps show (without it, the first items); refresh it
|
||||
before cutting. Versions carry no date (the tag and release page have it).
|
||||
`tool/changelog.dart` reads and cuts the file (`cut X.Y.Z`, `json
|
||||
<version|unreleased>`, `markdown <version|unreleased>`); its tests are
|
||||
`tool/test/changelog_test.dart` (`cd tool && dart test`).
|
||||
|
||||
## Debugging your server
|
||||
|
||||
The app's own server runs on its own isolate (`ServerIsolate`,
|
||||
|
||||
@@ -45,7 +45,10 @@ A five-minute tour. Details: [ARCHITECTURE.md](ARCHITECTURE.md), wire format:
|
||||
not in a friend's, so watching alone works like a session (progress,
|
||||
next episode). Invite opens it to friends on your network (a second
|
||||
listener on the same server: nothing restarts, the video and Up next
|
||||
carry on) and copies an invite; Stop sharing closes it again.
|
||||
carry on) and copies an invite link (`starsync://`, with your internet
|
||||
address when the app finds it and the password when one is set); Stop
|
||||
sharing closes it again. A friend clicks the link, or just copies it,
|
||||
and their app asks to join.
|
||||
- **Dedicated server**: `starsync-server`, a separate program in the
|
||||
release tarball, for an always-on box. See the README.
|
||||
|
||||
|
||||
+31
-2
@@ -543,8 +543,12 @@ anything else means one file.
|
||||
one-file URL of a media with `tracks`) or piece (or `p/` of a byte
|
||||
track), 410 unknown session, 503 + `retry-after` the source never
|
||||
connected or the server could not read the link (60 s), or a piece did
|
||||
not arrive within 60 s. A byte request whose bytes do not arrive within
|
||||
60 s ends early. Bytes count toward `bytesServed`.
|
||||
not arrive within 60 s. A byte request's headers come with its first
|
||||
bytes, or after 4 s without them (under mpv's 5 s timeout); its body
|
||||
then waits for missing bytes, up to 60 s for each, else the connection
|
||||
is reset. Every byte answer says `connection: close` and closes after
|
||||
its body; a swap that ends the relay closes it early without a reset.
|
||||
Bytes count toward `bytesServed`.
|
||||
- `GET /r/<sid>/<token>/source` (WebSocket): the source's relay channel,
|
||||
dialled by the source (works behind NAT); `?q=<queueId>` for the warm
|
||||
relay of that item (or the media's relay started from it). Only that
|
||||
@@ -609,6 +613,31 @@ media URL unchanged, its ready flag and presence kept; after a
|
||||
non-resumed `welcome` (grace over, server restarted) the client sends
|
||||
its intent again (`ready`, `presence`).
|
||||
|
||||
## Invite link
|
||||
|
||||
Not a WebSocket message: what one app hands another (clipboard, chat,
|
||||
a clicked link), so every version reads the same format.
|
||||
|
||||
```
|
||||
starsync://203.0.113.7:8080/?name=Bob%27s+server&pw=hunter2
|
||||
starsync://192.168.1.20:8080
|
||||
starsync://[fe80::1]:8080
|
||||
```
|
||||
|
||||
- Scheme `starsync`, the server's host (IPv6 in brackets) and port; no
|
||||
port means 8080. The path is `/` or empty.
|
||||
- `name` (optional): the server's name for the prompt before its
|
||||
`/health` answers (at most 64 characters are shown, control characters
|
||||
dropped).
|
||||
- `pw` (optional): the server password, always there when the server has
|
||||
one: the link is the key, a friend just clicks it. Logs never show it (`redactLog` cuts a `starsync://` query to its
|
||||
names).
|
||||
- Unknown query keys are ignored; anything else (no host, a port outside
|
||||
1–65535) is no link.
|
||||
- Invite copies the bare link, nothing around it. An app also takes a
|
||||
1.3.x plain invite ("Join me on Starsync: 192.168.1.20:8080 …"), and a
|
||||
1.3.x app reads the `host:port` out of a link.
|
||||
|
||||
## Queue (proto 12)
|
||||
|
||||
Up next: what plays after the current media, held by the server for
|
||||
|
||||
+37
-4
@@ -58,6 +58,14 @@ Problems are numbered; delete a line when its fix ships.
|
||||
- P36 After a reconnect the app re-dials the warm relay of an Up next
|
||||
item that was already played ("relay channel refused: not the source of
|
||||
Up next item q8"): harmless, the feeder should end with its item.
|
||||
- P37 A relay viewer whose read waits on bytes the source does not send
|
||||
for longer than ffmpeg's reconnect budget (4 tries per read, about
|
||||
40 s with the 4 s header rule) gets an I/O error that mpv takes as the
|
||||
end of the file. Measured with mpv 0.41 / ffmpeg 9.0.2 and the source
|
||||
away about a minute across a seek: it played on; one read stalled past
|
||||
the budget ends the file. Options: a longer `reconnect_delay_max` for
|
||||
relay URLs only, or the end-of-file path reopening a relay viewer
|
||||
that stopped short while the server plays.
|
||||
|
||||
**Restructure** (in order; reviewed against the code). Truth lives in
|
||||
three places: the server (members, presence, gate, transport, queue incl.
|
||||
@@ -104,8 +112,11 @@ ever happen mid-session (D8 says never during playback).
|
||||
Left of step 3 (your server): a human check of Invite (Linux and, at
|
||||
1.3.0, Windows' firewall prompt, now on Invite rather than at start;
|
||||
that the invite skips the proxy's 198.18.x here) and of the update band
|
||||
from 1.2.1-dev.3 in real use. No public address lookup or UPnP: the
|
||||
members card only says friends elsewhere need the port forwarded. Your
|
||||
from 1.2.1-dev.3 in real use. No UPnP: the members card says when
|
||||
friends elsewhere need the port forwarded. Human checks of the
|
||||
`starsync://` links: clicked in a browser and in Discord on KDE Wayland,
|
||||
X11 and Windows (the window coming to the front, the registry key after
|
||||
first start and after an update from a `watch_together.exe` copy). Your
|
||||
server's `/health` name is the profile name at start (a rename shows
|
||||
after a restart). Joining a friend while others are on
|
||||
your server leaves them there without you, unwarned; a join refused
|
||||
@@ -162,6 +173,17 @@ seek or a new link; the start of a freshly put-up link was never
|
||||
measured on its own. Levers if it still shows: start the window from
|
||||
the last rate, prefetch from the source as soon as a link goes up,
|
||||
a smaller gate buffer when only the relay is behind.
|
||||
Left of the read-once source (ARCHITECTURE "Relay", "Read once"): a
|
||||
human check against real Bilibili and YouTube (the 412 storm gone, the
|
||||
putter's picture the relay's format, a Windows putter's loopback), and
|
||||
the source's first frame and seeks over a 12 Mbit/s uplink
|
||||
(`tool/shaper`): a putter whose upload lags now holds its own player
|
||||
about 64 MiB past it. Not built: pieces tracks (HLS/DASH, X's) are
|
||||
still fetched twice (the putter's player from the site, the relay's
|
||||
pieces on their own; the same idea needs a `PieceBuffer` and a loopback
|
||||
playlist); Jellyfin items a member passes on are still streamed twice
|
||||
when the relay is needed. The e2e's media (26 MiB) fits the putter's
|
||||
buffer whole, so the 64 + 16 MiB bound is only unit-tested.
|
||||
Left of step 4 (library): a human check of the Files library on the
|
||||
real folder (`/mnt/torrents/Torrents/Binux/`: Bleach over three folders
|
||||
with 66–107 missing, SAO S3 Alicization with OP/ED, Re.Zero S04E17, the
|
||||
@@ -298,9 +320,20 @@ core and app. Still open on the wire, frozen at the 1.3.0 release:
|
||||
shows none without an extension), so close-to-tray needs a fallback
|
||||
to quitting where no tray exists.
|
||||
|
||||
8. **Jellyfin** (epic, below).
|
||||
8. **Hosting without a VPS** (friends host from their own connection,
|
||||
on their own bandwidth; a tunnel through the VPS is out: everyone's
|
||||
traffic would land on it). In order: IPv6 (try a public IPv6 address
|
||||
first, no NAT); UPnP / NAT-PMP port opening later; hole punching
|
||||
(the VPS only introduces the two sides) only as a research spike: it
|
||||
needs UDP (WebRTC or QUIC) under the sync connection, a local HTTP
|
||||
stand-in so mpv can read relayed video, and still fails behind CGNAT
|
||||
and symmetric NAT. Also: an invite with a generated access code
|
||||
instead of the password (`starsync://host:port/<code>`, a "New link"
|
||||
button revokes old links; the server accepts code or password).
|
||||
|
||||
9. **Smaller items**
|
||||
9. **Jellyfin** (epic, below).
|
||||
|
||||
10. **Smaller items**
|
||||
- The video freeze after GPU idle: next suspect is media_kit's context
|
||||
switching on the raster thread with NVIDIA.
|
||||
- Timelines: draw mpv's real cached ranges (`demuxer-cache-state`
|
||||
|
||||
@@ -31,6 +31,18 @@ abstract class RelayConstants {
|
||||
/// A viewer request waits this long for its bytes (source away) before
|
||||
/// the server gives up on it.
|
||||
static const int relayWaitSecs = 60;
|
||||
/// Failed pulls in a row back off: 250 ms doubling up to this, back to
|
||||
/// none once bytes come again.
|
||||
static const int relayRetryMaxMs = 8000;
|
||||
/// A relay viewer's response headers wait at most this long for its
|
||||
/// first bytes, under mpv's 5 s `network-timeout`: a header that never
|
||||
/// comes in time costs ffmpeg the file's size for good.
|
||||
static const int relayHeaderWaitMs = 4000;
|
||||
/// The source's own buffer of a link it reads once for its player and
|
||||
/// the upload (`SharedSource`), all tracks together besides the pins:
|
||||
/// the two read within [sharedAheadBytes] of each other.
|
||||
static const int sharedAheadBytes = 64 << 20;
|
||||
static const int sharedBackBytes = 16 << 20;
|
||||
/// The warm relay (Up next's first relayed item, before it goes up)
|
||||
/// keeps this much ahead of and behind its viewers' preloads, besides
|
||||
/// the pins: about what a preload player reads ahead (48 MiB), never the
|
||||
|
||||
@@ -282,9 +282,12 @@ Future<String> _readText(HttpClientResponse res) async {
|
||||
/// audio, or segmented playlists). A byte track is range-read with
|
||||
/// yt-dlp's headers in GETs of the format's `http_chunk_size`; a pieces
|
||||
/// track's playlist (or DASH fragments) is read on first use, then each
|
||||
/// piece is one GET. Format URLs expire: on 403/410 from any track the page
|
||||
/// is resolved again once for all tracks (same format ids, same sizes and
|
||||
/// piece counts) and the read goes on from where it stopped.
|
||||
/// piece is one GET. Format URLs expire: on 403/410/412 from any track the
|
||||
/// page is resolved again once for all tracks (same format ids, same sizes
|
||||
/// and piece counts) and the read goes on from where it stopped. Another
|
||||
/// expiry within [refreshGap] of that resolve fails the read instead (the
|
||||
/// server backs off and asks again), so a site refusing the fresh URLs too
|
||||
/// is not resolved again and again.
|
||||
class PageRelayMedia implements RelayMedia {
|
||||
final Uri page;
|
||||
final YtDlp ytDlp;
|
||||
@@ -296,7 +299,10 @@ class PageRelayMedia implements RelayMedia {
|
||||
final _http = HttpClient();
|
||||
var _gen = 0;
|
||||
Future<void>? _refreshing;
|
||||
PageRelayMedia(this.page, this.ytDlp, {List<YtDlpTrack>? tracks}) : _picked = tracks;
|
||||
DateTime? _refreshedAt;
|
||||
final Duration refreshGap;
|
||||
PageRelayMedia(this.page, this.ytDlp, {List<YtDlpTrack>? tracks, this.refreshGap = const Duration(seconds: 60)})
|
||||
: _picked = tracks;
|
||||
|
||||
Future<YtDlpInfo> _info() async {
|
||||
final info = await ytDlp.resolve(page.toString());
|
||||
@@ -335,14 +341,22 @@ class PageRelayMedia implements RelayMedia {
|
||||
return fetchHlsPlaylist(_http, f.url, f.headers);
|
||||
}
|
||||
|
||||
static bool _expired(Object e) => e is UpstreamHttpException && (e.status == 403 || e.status == 410);
|
||||
/// A format URL the site no longer takes: 403, 410, or Bilibili's 412.
|
||||
static bool _expired(Object e) =>
|
||||
e is UpstreamHttpException && (e.status == 403 || e.status == 410 || e.status == 412);
|
||||
|
||||
/// Resolves the page again: the same format ids with the same sizes and
|
||||
/// piece shapes, else the video changed.
|
||||
Future<void> _refresh(int gen) {
|
||||
Future<void> _refresh(int gen, Object cause) {
|
||||
if (gen != _gen) return Future.value();
|
||||
final last = _refreshedAt;
|
||||
if (_refreshing == null && last != null && DateTime.now().difference(last) < refreshGap) {
|
||||
// Resolved a moment ago and refused again: not once more.
|
||||
return Future.error(cause);
|
||||
}
|
||||
return _refreshing ??= () async {
|
||||
_log.info('relay of ${logUrl(page)}: a format URL expired (403/410), resolving the page again');
|
||||
_refreshedAt = DateTime.now();
|
||||
_log.info('relay of ${logUrl(page)}: a format URL expired (HTTP ${cause is UpstreamHttpException ? cause.status : '?'}), resolving the page again');
|
||||
try {
|
||||
const changed = LinkProbeException('the video changed on the page');
|
||||
final info = await _info();
|
||||
@@ -384,7 +398,7 @@ class PageRelayMedia implements RelayMedia {
|
||||
return _sizes[i] = await _open(i).size();
|
||||
} catch (e) {
|
||||
if (!_expired(e)) rethrow;
|
||||
await _refresh(gen);
|
||||
await _refresh(gen, e);
|
||||
return _sizes[i] = await _open(i).size();
|
||||
}
|
||||
}
|
||||
@@ -397,7 +411,7 @@ class PageRelayMedia implements RelayMedia {
|
||||
return _pieces[i] = await _loadPieces(_picked![i].format);
|
||||
} catch (e) {
|
||||
if (!_expired(e)) rethrow;
|
||||
await _refresh(gen);
|
||||
await _refresh(gen, e);
|
||||
return _pieces[i] ??= await _loadPieces(_picked![i].format);
|
||||
}
|
||||
}
|
||||
@@ -416,7 +430,7 @@ class PageRelayMedia implements RelayMedia {
|
||||
} catch (e) {
|
||||
if (retried || !_expired(e)) rethrow;
|
||||
retried = true;
|
||||
await _refresh(gen);
|
||||
await _refresh(gen, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -435,7 +449,7 @@ class PageRelayMedia implements RelayMedia {
|
||||
} catch (e) {
|
||||
if (retried || !_expired(e)) rethrow;
|
||||
retried = true;
|
||||
await _refresh(gen);
|
||||
await _refresh(gen, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,7 +117,7 @@ void relogLine(String tag, String line) {
|
||||
// `"name":"value"` or `Name="value"` (Jellyfin's MediaBrowser header).
|
||||
const _secretNames = r'(?:x-emby-token|x-mediabrowser-token|x-emby-authorization|authorization|set-cookie|cookie|'
|
||||
r'api_?key|access_?token|refresh_?token|id_?token|auth_?token|token|password|passwd|secret|host_?key|'
|
||||
r'sessdata|bili_jct|client_?secret|client_?id|client)';
|
||||
r'sessdata|bili_jct|client_?secret|client_?id|client|pw)';
|
||||
final _jsonSecret = RegExp('"($_secretNames)"\\s*:\\s*"(?:[^"\\\\]|\\\\.)*"', caseSensitive: false);
|
||||
final _quotedSecret = RegExp('\\b($_secretNames)\\s*=\\s*"[^"]*"', caseSensitive: false);
|
||||
// Header-like `Name: value` runs to the end of the line (a cookie
|
||||
@@ -127,7 +127,8 @@ final _headerSecret = RegExp(
|
||||
caseSensitive: false);
|
||||
final _pairSecret = RegExp('\\b($_secretNames)\\s*=\\s*[^&\\s"\',;)}\\]]+', caseSensitive: false);
|
||||
final _bearer = RegExp(r'\b(Bearer|Basic)\s+[A-Za-z0-9._~+/=-]{8,}', caseSensitive: false);
|
||||
final _url = RegExp(r'''\b(?:https?|wss?)://[^\s"'<>]+''', caseSensitive: false);
|
||||
// starsync:// is an invite link (its `pw` is a server password).
|
||||
final _url = RegExp(r'''\b(?:https?|wss?|starsync)://[^\s"'<>]+''', caseSensitive: false);
|
||||
// A member token in a server path: /r/<sid>/<token>/..., /m, /j, /a, /i, /l.
|
||||
final _pathToken = RegExp(r'(/[rmjail]/[^/\s?#]+/)[0-9a-fA-F]{32}\b');
|
||||
final _userInfo = RegExp(r'^([a-z]+://)[^/@\s]+@', caseSensitive: false);
|
||||
|
||||
@@ -78,18 +78,50 @@ const _stagingDir = '.update-staging';
|
||||
const _backupDir = '.update-backup';
|
||||
const _downloadFile = '.update-download';
|
||||
|
||||
/// What a release changed, in the words of CHANGELOG.md: the manifest's
|
||||
/// `changes` ({"added": [...], "changed": [...], "fixed": [...]}).
|
||||
/// Manifests from before it have only `notes`.
|
||||
class ReleaseChanges {
|
||||
final List<String> added;
|
||||
final List<String> changed;
|
||||
final List<String> fixed;
|
||||
const ReleaseChanges({this.added = const [], this.changed = const [], this.fixed = const []});
|
||||
|
||||
/// Null for anything that is not a map, or one with no items; entries
|
||||
/// that are not strings (or blank) are dropped, unknown keys ignored.
|
||||
static ReleaseChanges? fromJson(Object? json) {
|
||||
if (json is! Map) return null;
|
||||
List<String> list(String key) => List.unmodifiable([
|
||||
if (json[key] case final List l)
|
||||
for (final item in l)
|
||||
if (item is String && item.trim().isNotEmpty) item.trim(),
|
||||
]);
|
||||
final c = ReleaseChanges(added: list('added'), changed: list('changed'), fixed: list('fixed'));
|
||||
return c.isEmpty ? null : c;
|
||||
}
|
||||
|
||||
Map<String, Object?> toJson() => {'added': added, 'changed': changed, 'fixed': fixed};
|
||||
|
||||
bool get isEmpty => added.isEmpty && changed.isEmpty && fixed.isEmpty;
|
||||
int get length => added.length + changed.length + fixed.length;
|
||||
}
|
||||
|
||||
class UpdateManifest {
|
||||
final String version;
|
||||
final int protocol;
|
||||
final String url;
|
||||
final String? sha256;
|
||||
/// One line for every app (1.2.x and 1.3.x show only this).
|
||||
final String notes;
|
||||
/// Null in manifests from before the changelog (show [notes] then).
|
||||
final ReleaseChanges? changes;
|
||||
const UpdateManifest(
|
||||
{required this.version,
|
||||
required this.protocol,
|
||||
required this.url,
|
||||
this.sha256,
|
||||
required this.notes});
|
||||
required this.notes,
|
||||
this.changes});
|
||||
|
||||
// `assets` maps platform → archive URL. Top-level `url` is the Linux
|
||||
// tarball, kept for builds from before `assets` existed (<= 0.2.1).
|
||||
@@ -115,7 +147,8 @@ class UpdateManifest {
|
||||
protocol: protocol,
|
||||
url: url,
|
||||
sha256: sha is String && sha.isNotEmpty ? sha : null,
|
||||
notes: (json['notes'] as String?) ?? '');
|
||||
notes: json['notes'] is String ? json['notes'] as String : '',
|
||||
changes: ReleaseChanges.fromJson(json['changes']));
|
||||
}
|
||||
|
||||
// Newer wire protocol: this build can't join servers running it.
|
||||
@@ -204,16 +237,58 @@ Future<UpdateManifest> fetchManifest(
|
||||
/// A test build (`1.1.3-dev.4`); `0.0.0-dev` (a dev run) is not one.
|
||||
bool isDevBuild(String version) => version.contains('-dev.');
|
||||
|
||||
/// The manifest to update from: the stable one, and for a [isDevBuild]
|
||||
/// also the dev one, whichever is newer (either alone if the other fails).
|
||||
/// A release outranks its own test builds, so testers return to stable.
|
||||
/// Where an install takes its updates from. [stable]: releases only.
|
||||
/// [dev]: test builds as well (`devManifestUrl`), whichever of the two is
|
||||
/// newer, so a release newer than the running test build is installed and
|
||||
/// the next test line after it still is.
|
||||
enum UpdateChannel {
|
||||
stable,
|
||||
dev;
|
||||
|
||||
static UpdateChannel? fromName(Object? name) {
|
||||
for (final c in values) {
|
||||
if (c.name == name) return c;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// The channel of an install that never chose one: [UpdateChannel.dev]
|
||||
/// for a test build, else [UpdateChannel.stable].
|
||||
UpdateChannel defaultUpdateChannel(String version) =>
|
||||
isDevBuild(version) ? UpdateChannel.dev : UpdateChannel.stable;
|
||||
|
||||
/// Of the stable and dev manifests read (null: that one failed), the one
|
||||
/// [channel] updates from: on stable the stable one, on dev the newer of
|
||||
/// the two by semver (a release outranks its own test builds).
|
||||
UpdateManifest? pickManifest(UpdateChannel channel,
|
||||
{UpdateManifest? stable, UpdateManifest? dev}) {
|
||||
if (channel == UpdateChannel.stable || dev == null) return stable;
|
||||
if (stable == null) return dev;
|
||||
return isNewerVersion(stable.version, dev.version) ? dev : stable;
|
||||
}
|
||||
|
||||
/// A stable [latest] that is older than the running test build
|
||||
/// [currentVersion]: on [UpdateChannel.stable] it is offered only after
|
||||
/// asking ("go back to stable X?"), never as an update.
|
||||
bool isStableBehind(String currentVersion, UpdateManifest latest) =>
|
||||
isDevBuild(currentVersion) &&
|
||||
!isDevBuild(latest.version) &&
|
||||
compareVersions(latest.version, currentVersion) < 0;
|
||||
|
||||
/// The manifest to update from on [channel] (default by [currentVersion]:
|
||||
/// [defaultUpdateChannel]): the stable one, and on dev also the dev one,
|
||||
/// the newer of the two ([pickManifest]; either alone if the other fails).
|
||||
Future<UpdateManifest> fetchLatestManifest(
|
||||
{HttpClient? http,
|
||||
String currentVersion = appVersion,
|
||||
UpdateChannel? channel,
|
||||
String url = updateManifestUrl,
|
||||
String devUrl = devManifestUrl}) async {
|
||||
final stable = fetchManifest(http: http, url: url);
|
||||
if (!isDevBuild(currentVersion)) return stable;
|
||||
if ((channel ?? defaultUpdateChannel(currentVersion)) == UpdateChannel.stable) {
|
||||
return stable;
|
||||
}
|
||||
stable.ignore(); // awaited below; don't let an early failure go uncaught
|
||||
final dev = await fetchManifest(http: http, url: devUrl)
|
||||
.then<UpdateManifest?>((m) => m, onError: (Object e) {
|
||||
@@ -221,12 +296,8 @@ Future<UpdateManifest> fetchLatestManifest(
|
||||
return null;
|
||||
});
|
||||
if (dev == null) return stable;
|
||||
try {
|
||||
final s = await stable;
|
||||
return isNewerVersion(s.version, dev.version) ? dev : s;
|
||||
} catch (_) {
|
||||
return dev;
|
||||
}
|
||||
final s = await stable.then<UpdateManifest?>((m) => m, onError: (Object _) => null);
|
||||
return pickManifest(UpdateChannel.dev, stable: s, dev: dev)!;
|
||||
}
|
||||
|
||||
typedef DownloadProgress = void Function(int received, int? total);
|
||||
|
||||
@@ -65,15 +65,21 @@ class YtDlpFormat {
|
||||
/// Relayed as pieces (see relay_pieces.dart), not as a byte stream.
|
||||
bool get isPieces => _piecesProtocols.contains(protocol);
|
||||
|
||||
/// From one of yt-dlp's formats: its `http_headers` plus its `cookies`
|
||||
/// as a Cookie header, the same a player gets ([YtDlpPlay.parse]).
|
||||
static YtDlpFormat _of(Map f) {
|
||||
final headers = f['http_headers'];
|
||||
final opts = f['downloader_options'];
|
||||
final chunk = opts is Map ? opts['http_chunk_size'] : null;
|
||||
final h = f['height'] is num ? (f['height'] as num).toInt() : 0;
|
||||
final cookie = cookieHeaderOf(f['cookies']);
|
||||
return YtDlpFormat(
|
||||
id: '${f['format_id'] ?? ''}',
|
||||
url: Uri.parse(f['url'] as String),
|
||||
headers: headers is Map ? {for (final e in headers.entries) '${e.key}': '${e.value}'} : const {},
|
||||
headers: {
|
||||
if (headers is Map) for (final e in headers.entries) '${e.key}': '${e.value}',
|
||||
if (cookie.isNotEmpty) 'Cookie': cookie,
|
||||
},
|
||||
height: h == 0 ? null : h,
|
||||
ext: '${f['ext']}',
|
||||
chunkSize: chunk is num && chunk > 0 ? chunk.toInt() : null,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
name: sync_relay_core
|
||||
description: Pure-Dart protocol, sync engine, media-source abstractions. No Flutter.
|
||||
version: 1.3.2
|
||||
version: 1.4.0
|
||||
environment:
|
||||
sdk: ^3.0.0
|
||||
dependencies:
|
||||
|
||||
@@ -24,6 +24,12 @@ void main() {
|
||||
expect(redactLog('http://user:pw@host/x#frag'), 'http://***@host/x');
|
||||
});
|
||||
|
||||
test('an invite link keeps its address, never its password', () {
|
||||
expect(redactLog('link starsync://203.0.113.7:8080/?name=Bob&pw=hunter2'),
|
||||
'link starsync://203.0.113.7:8080/?name&pw=***');
|
||||
expect(redactLog('pw=hunter2 port=1'), 'pw=*** port=1');
|
||||
});
|
||||
|
||||
test('member tokens in server paths', () {
|
||||
expect(redactLog('open http://1.2.3.4:8080/r/default/$tok/2/0/name'), 'open http://1.2.3.4:8080/r/default/***/2/0/name');
|
||||
expect(redactLog('ws://h:1/r/default/$tok/source?q=7'), 'ws://h:1/r/default/***/source?q=…');
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
// A page relayed by its formats: an expired format URL (403/410/412) is
|
||||
// resolved again once, and not again and again when the site keeps
|
||||
// refusing.
|
||||
|
||||
/// yt-dlp that resolves the page to the origin's current format URL.
|
||||
class _CountingYtDlp extends YtDlp {
|
||||
final Uri Function() url;
|
||||
var resolves = 0;
|
||||
_CountingYtDlp(super.dir, this.url);
|
||||
|
||||
@override
|
||||
Future<YtDlpInfo> resolve(String page, {Duration timeout = const Duration(seconds: 25)}) async {
|
||||
resolves++;
|
||||
final f = YtDlpFormat(id: 'v', url: url());
|
||||
return YtDlpInfo(title: 'page', tracks: [(kind: '', format: f)], formats: {'v': f});
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
late HttpServer origin;
|
||||
late Directory tmp;
|
||||
// Only `/v<gen>.mp4` of the current generation is served; older ones get
|
||||
// [refusal] (a format URL that expired).
|
||||
var gen = 1;
|
||||
var refusal = 412;
|
||||
final bytes = Uint8List.fromList(List.generate(100000, (i) => i % 251));
|
||||
|
||||
setUp(() async {
|
||||
gen = 1;
|
||||
refusal = 412;
|
||||
tmp = await Directory.systemTemp.createTemp('page-relay');
|
||||
origin = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
|
||||
origin.listen((req) async {
|
||||
if (req.uri.path != '/v$gen.mp4') {
|
||||
req.response.statusCode = refusal;
|
||||
await req.response.close();
|
||||
return;
|
||||
}
|
||||
final m = RegExp(r'bytes=(\d+)-(\d+)').firstMatch(req.headers.value('range') ?? '')!;
|
||||
final start = int.parse(m.group(1)!), end = int.parse(m.group(2)!);
|
||||
req.response
|
||||
..statusCode = 206
|
||||
..headers.contentType = ContentType('video', 'mp4')
|
||||
..headers.set('content-range', 'bytes $start-$end/${bytes.length}')
|
||||
..add(bytes.sublist(start, end + 1));
|
||||
await req.response.close();
|
||||
});
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await origin.close(force: true);
|
||||
await tmp.delete(recursive: true);
|
||||
});
|
||||
|
||||
Uri current() => Uri.parse('http://127.0.0.1:${origin.port}/v$gen.mp4');
|
||||
|
||||
Future<List<int>> read(RelayTrack t, int start, int end) async =>
|
||||
[for (final c in await t.bytes!.read(start, end).toList()) ...c];
|
||||
|
||||
for (final status in [403, 410, 412]) {
|
||||
test('HTTP $status: the page is resolved again once and the read goes on', () async {
|
||||
refusal = status;
|
||||
final y = _CountingYtDlp(tmp.path, current);
|
||||
final media = PageRelayMedia(Uri.parse('https://example.com/watch'), y);
|
||||
addTearDown(media.close);
|
||||
final t = (await media.open()).single;
|
||||
expect(await t.bytes!.size(), bytes.length);
|
||||
expect(await read(t, 0, 999), bytes.sublist(0, 1000));
|
||||
gen = 2; // the first URL expired
|
||||
expect(await read(t, 5000, 5999), bytes.sublist(5000, 6000));
|
||||
expect(y.resolves, 2);
|
||||
});
|
||||
}
|
||||
|
||||
test('refused again right after a resolve: the read fails, no resolve storm', () async {
|
||||
final y = _CountingYtDlp(tmp.path, current);
|
||||
final media = PageRelayMedia(Uri.parse('https://example.com/watch'), y);
|
||||
addTearDown(media.close);
|
||||
final t = (await media.open()).single;
|
||||
await t.bytes!.size();
|
||||
gen = 2;
|
||||
expect(await read(t, 0, 9), bytes.sublist(0, 10));
|
||||
gen = 3; // the fresh URL is refused too
|
||||
for (var i = 0; i < 5; i++) {
|
||||
await expectLater(read(t, 0, 9), throwsA(isA<UpstreamHttpException>().having((e) => e.status, 'status', 412)));
|
||||
}
|
||||
expect(y.resolves, 2);
|
||||
});
|
||||
|
||||
test('another expiry after the gap resolves again', () async {
|
||||
final y = _CountingYtDlp(tmp.path, current);
|
||||
final media = PageRelayMedia(Uri.parse('https://example.com/watch'), y, refreshGap: Duration.zero);
|
||||
addTearDown(media.close);
|
||||
final t = (await media.open()).single;
|
||||
await t.bytes!.size();
|
||||
for (gen = 2; gen <= 4; gen++) {
|
||||
expect(await read(t, 100, 199), bytes.sublist(100, 200));
|
||||
}
|
||||
expect(y.resolves, 4);
|
||||
});
|
||||
}
|
||||
@@ -190,6 +190,47 @@ void main() {
|
||||
isTrue);
|
||||
});
|
||||
|
||||
test('manifest changes: structured lists, notes kept, old manifests without', () {
|
||||
final base = {
|
||||
'version': '1.3.3',
|
||||
'protocol': RelayConstants.protoVersion,
|
||||
'url': 'https://x/y.tar.gz',
|
||||
'notes': 'One line for older apps.',
|
||||
};
|
||||
final old = UpdateManifest.fromJson(base);
|
||||
expect(old.changes, isNull);
|
||||
expect(old.notes, 'One line for older apps.');
|
||||
|
||||
final m = UpdateManifest.fromJson({
|
||||
...base,
|
||||
'changes': {
|
||||
'added': ['Danmaku style notifications', ' ', 7],
|
||||
'fixed': ['Various bug fixes'],
|
||||
'later': ['unknown kinds are ignored'],
|
||||
},
|
||||
});
|
||||
expect(m.notes, 'One line for older apps.');
|
||||
expect(m.changes!.added, ['Danmaku style notifications']);
|
||||
expect(m.changes!.changed, isEmpty);
|
||||
expect(m.changes!.fixed, ['Various bug fixes']);
|
||||
expect(m.changes!.length, 2);
|
||||
expect(ReleaseChanges.fromJson(m.changes!.toJson())!.toJson(), m.changes!.toJson());
|
||||
|
||||
// Junk or empty `changes` (and a non-string `notes`) do not break the manifest.
|
||||
for (final junk in [null, 'text', [], {}, {'added': 'no list'}]) {
|
||||
expect(UpdateManifest.fromJson({...base, 'changes': junk}).changes, isNull);
|
||||
}
|
||||
expect(UpdateManifest.fromJson({...base, 'notes': 3}).notes, '');
|
||||
});
|
||||
|
||||
test('the repo manifest parses, and keeps a one-line string notes for 1.2.x/1.3.x', () {
|
||||
final json = jsonDecode(File('../../updates/manifest.json').readAsStringSync()) as Map<String, Object?>;
|
||||
// Older apps read `(json['notes'] as String?)`: it must stay a string.
|
||||
expect(json['notes'], isA<String>());
|
||||
expect((json['notes'] as String).contains('\n'), isFalse);
|
||||
expect(UpdateManifest.fromJson(json, platform: 'linux-x64').version, json['version']);
|
||||
});
|
||||
|
||||
test('manifest picks the asset for this platform', () {
|
||||
final json = {
|
||||
'version': '0.2.2',
|
||||
@@ -237,21 +278,47 @@ void main() {
|
||||
await req.response.close();
|
||||
});
|
||||
final base = 'http://127.0.0.1:${http.port}';
|
||||
Future<String> latest(String current) async => (await fetchLatestManifest(
|
||||
currentVersion: current, url: '$base/stable', devUrl: '$base/dev'))
|
||||
Future<String> latest(String current, [UpdateChannel? channel]) async => (await fetchLatestManifest(
|
||||
currentVersion: current, channel: channel, url: '$base/stable', devUrl: '$base/dev'))
|
||||
.version;
|
||||
try {
|
||||
expect(await latest('1.1.2'), '1.1.2'); // stable builds never see dev
|
||||
expect(await latest('1.1.3-dev.1'), '1.1.3-dev.2');
|
||||
versions['/stable'] = '1.1.3'; // the release outranks its test builds
|
||||
expect(await latest('1.1.3-dev.2'), '1.1.3');
|
||||
// A release installed on the dev channel still sees the next test line.
|
||||
versions['/dev'] = '1.1.4-dev.1';
|
||||
expect(await latest('1.1.3', UpdateChannel.dev), '1.1.4-dev.1');
|
||||
expect(await latest('1.1.3', UpdateChannel.stable), '1.1.3');
|
||||
expect(await latest('1.1.4-dev.1', UpdateChannel.stable), '1.1.3');
|
||||
versions.remove('/stable');
|
||||
expect(await latest('1.1.3-dev.1'), '1.1.3-dev.2');
|
||||
expect(await latest('1.1.3-dev.1'), '1.1.4-dev.1');
|
||||
} finally {
|
||||
await http.close(force: true);
|
||||
}
|
||||
});
|
||||
|
||||
test('update channel: default, pick by semver, a stable behind a test build', () {
|
||||
UpdateManifest m(String v) => UpdateManifest(version: v, protocol: 1, url: 'http://x/$v', notes: '');
|
||||
expect(defaultUpdateChannel('1.4.0-dev.3'), UpdateChannel.dev);
|
||||
expect(defaultUpdateChannel('1.4.0'), UpdateChannel.stable);
|
||||
expect(defaultUpdateChannel('0.0.0-dev'), UpdateChannel.stable);
|
||||
expect(UpdateChannel.fromName('dev'), UpdateChannel.dev);
|
||||
expect(UpdateChannel.fromName('beta'), isNull);
|
||||
String? pick(UpdateChannel ch, String? s, String? d) =>
|
||||
pickManifest(ch, stable: s == null ? null : m(s), dev: d == null ? null : m(d))?.version;
|
||||
expect(pick(UpdateChannel.dev, '1.4.0', '1.4.0-dev.3'), '1.4.0');
|
||||
expect(pick(UpdateChannel.dev, '1.4.0', '1.4.1-dev.1'), '1.4.1-dev.1');
|
||||
expect(pick(UpdateChannel.dev, '1.3.2', '1.4.0-dev.3'), '1.4.0-dev.3');
|
||||
expect(pick(UpdateChannel.dev, null, '1.4.0-dev.3'), '1.4.0-dev.3');
|
||||
expect(pick(UpdateChannel.dev, '1.3.2', null), '1.3.2');
|
||||
expect(pick(UpdateChannel.stable, '1.3.2', '1.4.0-dev.3'), '1.3.2');
|
||||
expect(isStableBehind('1.4.0-dev.3', m('1.3.2')), isTrue);
|
||||
expect(isStableBehind('1.4.0-dev.3', m('1.4.0')), isFalse);
|
||||
expect(isStableBehind('1.3.2', m('1.3.1')), isFalse); // not a test build
|
||||
expect(isStableBehind('1.4.0-dev.3', m('1.4.0-dev.2')), isFalse);
|
||||
});
|
||||
|
||||
group('upgrade flow', () {
|
||||
late Directory work;
|
||||
late _Server server;
|
||||
|
||||
@@ -262,6 +262,25 @@ void main() {
|
||||
expect(pick(['avc1.640033', 'vp09.00.40.08']), 'v1');
|
||||
});
|
||||
|
||||
test('what a relay reads sends what a player sends: each format\'s headers with its cookies', () {
|
||||
const cookies = 'SESSDATA=abc; Domain=.bilibili.com; Path=/; Secure; Expires=1999999999; buvid3=xyz; Domain=.bilibili.com';
|
||||
Map<String, Object?> f(String id, String vcodec, String acodec, int? height) => {
|
||||
'format_id': id, 'url': 'https://cdn/$id.m4s', 'protocol': 'https', 'ext': 'mp4',
|
||||
'vcodec': vcodec, 'acodec': acodec, 'height': height,
|
||||
'http_headers': {'Referer': 'https://www.bilibili.com/', 'User-Agent': 'UA'},
|
||||
'cookies': cookies,
|
||||
};
|
||||
final v = f('30080', 'avc1.640032', 'none', 1080), a = f('30280', 'none', 'mp4a.40.2', null);
|
||||
final info = YtDlpInfo.parse({'title': 'Page', 'formats': [v, a], 'requested_formats': [v, a]});
|
||||
const want = {'Referer': 'https://www.bilibili.com/', 'User-Agent': 'UA', 'Cookie': 'SESSDATA=abc; buvid3=xyz'};
|
||||
expect([for (final t in info.tracks) t.format.id], ['30080', '30280']);
|
||||
for (final t in info.tracks) {
|
||||
expect(t.format.headers, want, reason: t.kind);
|
||||
}
|
||||
expect(info.play!.headers, want);
|
||||
expect(info.formats['30080']!.describe(), isNot(contains('SESSDATA')), reason: 'the log names headers only');
|
||||
});
|
||||
|
||||
test('what a player opens: yt-dlp\'s pick, an EDL for video + audio, headers with the cookies, subtitles but danmaku', () {
|
||||
final split = YtDlpInfo.parse({
|
||||
'title': 'Page',
|
||||
|
||||
@@ -8,3 +8,4 @@ export 'src/relay_buffer.dart';
|
||||
export 'src/relay_routes.dart';
|
||||
export 'src/relay_stats.dart';
|
||||
export 'src/session_log.dart';
|
||||
export 'src/shared_source.dart';
|
||||
@@ -249,3 +249,23 @@ MimeType _mimeOf(String? contentType) {
|
||||
return MimeType.octetStream;
|
||||
}
|
||||
}
|
||||
|
||||
/// Says `connection: close` on a response to a request that asked for it
|
||||
/// (ffmpeg always does). dart:io closes such a connection after the
|
||||
/// response but, behind relic, without saying so; ffmpeg (8.x) then sends
|
||||
/// its next seek on that dead connection after reading a range to its end
|
||||
/// (an mp4's tail) and the seek fails for good ("size -38").
|
||||
Handler closeWhenAsked(Handler next) => (req) async {
|
||||
final res = await next(req);
|
||||
if (res is! Response) return res;
|
||||
bool asked;
|
||||
try {
|
||||
asked = req.headers.connection?.isClose ?? false;
|
||||
} catch (_) {
|
||||
asked = false;
|
||||
}
|
||||
if (!asked) return res;
|
||||
return res.copyWith(
|
||||
headers: res.headers.transform(
|
||||
(h) => h.connection = const ConnectionHeader.directives([ConnectionHeaderType.close])));
|
||||
};
|
||||
@@ -81,6 +81,7 @@ class PieceBuffer implements TrackBuffer {
|
||||
/// null before a minute of it has arrived.
|
||||
double? get bytesPerSec => _knownSecs >= 60 ? _knownBytes / _knownSecs : null;
|
||||
DateTime? _retryAt;
|
||||
var _failures = 0; // failed pulls in a row (backoff)
|
||||
var _disposed = false;
|
||||
Completer<void> _kick = Completer();
|
||||
final Completer<void> _ready = Completer();
|
||||
@@ -167,6 +168,7 @@ class PieceBuffer implements TrackBuffer {
|
||||
final pl = _pull;
|
||||
if (pl == null || pl.id != id || _disposed) return;
|
||||
_pull = null;
|
||||
_failures = 0;
|
||||
final data = pl.buf.takeBytes();
|
||||
final i = pl.piece;
|
||||
_pieces[i] = data;
|
||||
@@ -182,12 +184,18 @@ class PieceBuffer implements TrackBuffer {
|
||||
_wake();
|
||||
}
|
||||
|
||||
static final _pullFailThrottle = LogThrottle(burst: 3);
|
||||
|
||||
@override
|
||||
void feedFail(int id, String reason) {
|
||||
if (_pull?.id != id) return;
|
||||
_log.warning('relay piece pull $id failed: $reason');
|
||||
_pull = null;
|
||||
_retryAt = DateTime.now().add(const Duration(seconds: 1));
|
||||
final delay = relayRetryDelay(++_failures);
|
||||
_retryAt = DateTime.now().add(delay);
|
||||
final pass = _pullFailThrottle.allow('${identityHashCode(this)}');
|
||||
if (pass != null) {
|
||||
_log.warning('${pass}relay piece pull $id failed ($_failures in a row, next in ${delay.inMilliseconds} ms): $reason');
|
||||
}
|
||||
_wake();
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,21 @@ import 'relay_stats.dart';
|
||||
|
||||
final _log = createLogger('relay');
|
||||
|
||||
/// A viewer's read ended because its relay went (a swap, server stop):
|
||||
/// not a failure, the response just ends.
|
||||
class RelayEnded implements Exception {
|
||||
const RelayEnded();
|
||||
@override
|
||||
String toString() => 'relay ended';
|
||||
}
|
||||
|
||||
/// The wait before the next pull after [failures] failed ones in a row:
|
||||
/// 250 ms doubling to [RelayConstants.relayRetryMaxMs] (a source whose
|
||||
/// upstream keeps failing must not be asked again and again at once).
|
||||
Duration relayRetryDelay(int failures) => failures <= 0
|
||||
? Duration.zero
|
||||
: Duration(milliseconds: min(RelayConstants.relayRetryMaxMs, 250 * (1 << min(failures - 1, 10))));
|
||||
|
||||
/// One track's buffer on the server: a byte stream ([RelayBuffer]) or
|
||||
/// pieces ([PieceBuffer]).
|
||||
abstract interface class TrackBuffer {
|
||||
@@ -143,11 +158,13 @@ class _Pull {
|
||||
}
|
||||
|
||||
class _Viewer {
|
||||
final String name;
|
||||
int pos = 0;
|
||||
int open = 0; // requests in flight
|
||||
int newest = 0; // sequence of its newest request
|
||||
(int slice, int req)? blocking; // where its newest request waits
|
||||
DateTime lastActive = DateTime.now();
|
||||
_Viewer(this.name);
|
||||
}
|
||||
|
||||
/// The server side of the relay for one media: an in-memory buffer of
|
||||
@@ -166,6 +183,9 @@ class _Viewer {
|
||||
/// - Only a viewer's newest request moves its position or asks the feed
|
||||
/// for anything; older ones still get bytes once they are buffered, and
|
||||
/// stop holding the feed once superseded (a seek elsewhere).
|
||||
/// - [together]: two viewers kept within [ahead] of each other (see the
|
||||
/// field).
|
||||
/// - Read-ahead never fetches a slice that would be the next one evicted.
|
||||
/// Everything lives in memory: dropping the buffer is the cleanup.
|
||||
class RelayBuffer implements TrackBuffer {
|
||||
/// Set by the relay when a media's tracks share the budget.
|
||||
@@ -175,11 +195,22 @@ class RelayBuffer implements TrackBuffer {
|
||||
final Duration waitLimit;
|
||||
/// Pull ids; the tracks of one media share one sequence (one channel).
|
||||
final int Function() _newId;
|
||||
/// Two viewers that read one stream at about one position (the source's
|
||||
/// own player and its upload to the server, see [SharedSource]) and
|
||||
/// must not pull the feed two ways: while both have a request open, a
|
||||
/// read of the one more than [ahead] past the other waits without
|
||||
/// asking the feed for anything (and without the wait limit), and its
|
||||
/// position does not count for read-ahead or eviction. So one
|
||||
/// sequential feed serves both, from one window, instead of handing
|
||||
/// over at every slice boundary. Pinned slices are always read. Null:
|
||||
/// every viewer drives on its own (the server's viewers).
|
||||
final (String, String)? together;
|
||||
RelayBuffer({
|
||||
this.ahead = RelayConstants.relayAheadBytes,
|
||||
this.back = RelayConstants.relayBackBytes,
|
||||
this.pin = RelayConstants.relayPinBytes,
|
||||
this.waitLimit = const Duration(seconds: RelayConstants.relayWaitSecs),
|
||||
this.together,
|
||||
int Function()? nextId,
|
||||
}) : _newId = nextId ?? _counter();
|
||||
|
||||
@@ -198,6 +229,8 @@ class RelayBuffer implements TrackBuffer {
|
||||
// The driving requests waiting on each blocked slice.
|
||||
final _blockedBy = <int, Set<int>>{};
|
||||
final _viewers = <String, _Viewer>{};
|
||||
// Reads held back by [together], woken when a position moves.
|
||||
var _moved = Completer<void>();
|
||||
@override
|
||||
FeedClock clock = FeedClock();
|
||||
_Pull? _pullNow;
|
||||
@@ -209,6 +242,9 @@ class RelayBuffer implements TrackBuffer {
|
||||
|
||||
var _reqSeq = 0;
|
||||
var _disposed = false;
|
||||
// Failed pulls in a row, and when the next pull may start (backoff).
|
||||
var _failures = 0;
|
||||
DateTime? _retryAt;
|
||||
Completer<void> _kick = Completer();
|
||||
final Completer<void> _ready = Completer();
|
||||
|
||||
@@ -264,12 +300,30 @@ class RelayBuffer implements TrackBuffer {
|
||||
_waiters.clear();
|
||||
if (!_ready.isCompleted) _ready.complete();
|
||||
_wake();
|
||||
_wakeHeld();
|
||||
}
|
||||
|
||||
void _wake() {
|
||||
if (!_kick.isCompleted) _kick.complete();
|
||||
}
|
||||
|
||||
void _wakeHeld() {
|
||||
if (together == null) return;
|
||||
final m = _moved;
|
||||
_moved = Completer<void>();
|
||||
m.complete();
|
||||
}
|
||||
|
||||
/// [v] is one of [together] and more than [ahead] past the other while
|
||||
/// that one has a request open: it does not drive.
|
||||
bool _held(_Viewer v) {
|
||||
final t = together;
|
||||
if (t == null) return false;
|
||||
final other = v.name == t.$1 ? t.$2 : (v.name == t.$2 ? t.$1 : null);
|
||||
final o = other == null ? null : _viewers[other];
|
||||
return o != null && o.open > 0 && v.pos > o.pos + ahead;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ feed input
|
||||
|
||||
@override
|
||||
@@ -292,7 +346,13 @@ class RelayBuffer implements TrackBuffer {
|
||||
off += take;
|
||||
pl.pos += take;
|
||||
final done = pl.pos == _sliceEnd(s) + 1;
|
||||
if (done) _slices[s] = pl.takeSlice();
|
||||
if (done) {
|
||||
_slices[s] = pl.takeSlice();
|
||||
// A whole slice came: the source works again.
|
||||
_failures = 0;
|
||||
// A long pull stays within the budget as it goes.
|
||||
_evict();
|
||||
}
|
||||
// Whole, or more of it: whoever waits on it reads on.
|
||||
for (final c in _waiters.remove(s) ?? const <Completer<void>>[]) {
|
||||
c.complete();
|
||||
@@ -309,17 +369,27 @@ class RelayBuffer implements TrackBuffer {
|
||||
final pl = _pull;
|
||||
if (pl != null && pl.id == id) {
|
||||
_pull = null;
|
||||
_failures = 0;
|
||||
_wake();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void feedFail(int id, String reason) {
|
||||
if (_pull?.id != id) return;
|
||||
_log.warning('relay pull $id failed: $reason');
|
||||
feedEnd(id);
|
||||
final pl = _pull;
|
||||
if (pl == null || pl.id != id) return;
|
||||
_pull = null;
|
||||
final delay = relayRetryDelay(++_failures);
|
||||
_retryAt = DateTime.now().add(delay);
|
||||
final pass = _pullFailThrottle.allow('${identityHashCode(this)}');
|
||||
if (pass != null) {
|
||||
_log.warning('${pass}relay pull $id failed ($_failures in a row, next in ${delay.inMilliseconds} ms): $reason');
|
||||
}
|
||||
_wake();
|
||||
}
|
||||
|
||||
static final _pullFailThrottle = LogThrottle(burst: 3);
|
||||
|
||||
// ------------------------------------------------------------- scheduling
|
||||
|
||||
bool _shouldStop(_Pull pl, int done) {
|
||||
@@ -330,7 +400,24 @@ class RelayBuffer implements TrackBuffer {
|
||||
return true;
|
||||
}
|
||||
if (_slices.containsKey(next)) return true;
|
||||
return !_wanted(next);
|
||||
if (!_wanted(next)) return true;
|
||||
// Read-ahead nobody waits on stops where the next slice would be the
|
||||
// next one evicted (else a full buffer fetches and drops it in turn).
|
||||
final s = next - 1;
|
||||
return !_blocked.containsKey(s) && !_blocked.containsKey(next) && !_roomFor(next);
|
||||
}
|
||||
|
||||
/// Slice [s] can come in without being the next one evicted: the
|
||||
/// buffer has room, or holds a slice farther from the viewers.
|
||||
bool _roomFor(int s) {
|
||||
if ((_slices.length + 1) * slice <= ahead + back + 2 * pin) return true;
|
||||
final at = _positions();
|
||||
final d = _distance(s, at);
|
||||
if (d < 0) return true;
|
||||
for (final c in _slices.keys) {
|
||||
if (!_waiters.containsKey(c) && _distance(c, at) > d) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
void _endPull(_Pull pl) {
|
||||
@@ -342,8 +429,8 @@ class RelayBuffer implements TrackBuffer {
|
||||
|
||||
Iterable<_Viewer> get _active {
|
||||
final now = DateTime.now();
|
||||
return _viewers.values
|
||||
.where((v) => v.open > 0 || now.difference(v.lastActive) < const Duration(seconds: 60));
|
||||
return _viewers.values.where(
|
||||
(v) => (v.open > 0 || now.difference(v.lastActive) < const Duration(seconds: 60)) && !_held(v));
|
||||
}
|
||||
|
||||
bool _pinned(int s) => s * slice < pin || s * slice >= size - pin;
|
||||
@@ -356,13 +443,17 @@ class RelayBuffer implements TrackBuffer {
|
||||
return off >= pos.reduce(min) - back && off <= pos.reduce(max) + ahead;
|
||||
}
|
||||
|
||||
int _distance(int s) {
|
||||
/// The active viewers' positions, for [_distance].
|
||||
List<int> _positions() => [for (final v in _active) v.pos];
|
||||
|
||||
/// How far slice [s] is from the viewers at [positions]; -1 pinned.
|
||||
int _distance(int s, List<int> positions) {
|
||||
if (_pinned(s)) return -1;
|
||||
final off = s * slice;
|
||||
var d = 1 << 62;
|
||||
for (final v in _active) {
|
||||
for (final p in positions) {
|
||||
// Behind a viewer costs more than ahead (it was already watched).
|
||||
d = min(d, off >= v.pos ? off - v.pos : (v.pos - off) * 4);
|
||||
d = min(d, off >= p ? off - p : (p - off) * 4);
|
||||
}
|
||||
return d;
|
||||
}
|
||||
@@ -371,9 +462,11 @@ class RelayBuffer implements TrackBuffer {
|
||||
final limit = ahead + back + 2 * pin;
|
||||
var total = _slices.length * slice;
|
||||
if (total <= limit) return;
|
||||
final order = _slices.keys.toList()..sort((a, b) => _distance(b).compareTo(_distance(a)));
|
||||
final at = _positions();
|
||||
final dist = {for (final s in _slices.keys) s: _distance(s, at)};
|
||||
final order = dist.keys.toList()..sort((a, b) => dist[b]!.compareTo(dist[a]!));
|
||||
for (final s in order) {
|
||||
if (total <= limit || _distance(s) < 0) break;
|
||||
if (total <= limit || dist[s]! < 0) break;
|
||||
if (_waiters.containsKey(s)) continue;
|
||||
_slices.remove(s);
|
||||
total -= slice;
|
||||
@@ -395,7 +488,7 @@ class RelayBuffer implements TrackBuffer {
|
||||
final starts = active.map((v) => v.pos).toList()..sort();
|
||||
for (final p in starts) {
|
||||
for (var s = p ~/ slice; s * slice <= p + ahead && s < _nSlices; s++) {
|
||||
if (!_slices.containsKey(s)) return s;
|
||||
if (!_slices.containsKey(s)) return _roomFor(s) ? s : null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
@@ -412,6 +505,12 @@ class RelayBuffer implements TrackBuffer {
|
||||
await _sleep();
|
||||
continue;
|
||||
}
|
||||
final retry = _retryAt;
|
||||
if (retry != null && DateTime.now().isBefore(retry)) {
|
||||
await _sleep(retry.difference(DateTime.now()));
|
||||
continue;
|
||||
}
|
||||
_retryAt = null;
|
||||
_evict();
|
||||
final s = _nextMissing();
|
||||
if (s == null) {
|
||||
@@ -431,10 +530,10 @@ class RelayBuffer implements TrackBuffer {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _sleep() async {
|
||||
Future<void> _sleep([Duration limit = const Duration(seconds: 5)]) async {
|
||||
_kick = Completer();
|
||||
// Also wake periodically: viewers age out of [_active].
|
||||
await _kick.future.timeout(const Duration(seconds: 5), onTimeout: () {});
|
||||
await _kick.future.timeout(limit, onTimeout: () {});
|
||||
}
|
||||
|
||||
/// The bytes of slice [s] from [from] (offset in the slice) as soon as
|
||||
@@ -443,7 +542,7 @@ class RelayBuffer implements TrackBuffer {
|
||||
/// request is its viewer's newest ([driving] says while it still is), so
|
||||
/// it may move the feed.
|
||||
Future<Uint8List?> _waitBytes(_Viewer v, int s, int from, int req, bool Function() driving) async {
|
||||
final deadline = DateTime.now().add(waitLimit);
|
||||
var deadline = DateTime.now().add(waitLimit);
|
||||
try {
|
||||
while (true) {
|
||||
final whole = _slices[s];
|
||||
@@ -454,6 +553,13 @@ class RelayBuffer implements TrackBuffer {
|
||||
if (part != null) return part;
|
||||
}
|
||||
if (_disposed || DateTime.now().isAfter(deadline)) return null;
|
||||
if (!_pinned(s) && driving() && _held(v)) {
|
||||
// Too far past its partner: waits for it, asking nothing.
|
||||
_unblock(s, req);
|
||||
deadline = DateTime.now().add(waitLimit);
|
||||
await _moved.future.timeout(const Duration(seconds: 2), onTimeout: () {});
|
||||
continue;
|
||||
}
|
||||
if (driving()) {
|
||||
_blocked.putIfAbsent(s, () => DateTime.now().microsecondsSinceEpoch);
|
||||
_blockedBy.putIfAbsent(s, () => {}).add(req);
|
||||
@@ -499,9 +605,9 @@ class RelayBuffer implements TrackBuffer {
|
||||
|
||||
/// Streams [start]..[end] to [viewer]. Cancelling the subscription (the
|
||||
/// viewer went away) ends it; a slice that never arrives ends it with an
|
||||
/// error after [waitLimit].
|
||||
/// error after [waitLimit]; the buffer's [dispose] with [RelayEnded].
|
||||
Stream<Uint8List> read(String viewer, int start, int end, {void Function(int n)? onSent}) async* {
|
||||
final v = _viewers.putIfAbsent(viewer, _Viewer.new);
|
||||
final v = _viewers.putIfAbsent(viewer, () => _Viewer(viewer));
|
||||
final tail = start >= size - pin;
|
||||
final me = ++_reqSeq;
|
||||
if (!tail) {
|
||||
@@ -515,11 +621,13 @@ class RelayBuffer implements TrackBuffer {
|
||||
v.open++;
|
||||
v.lastActive = DateTime.now();
|
||||
_wake();
|
||||
_wakeHeld();
|
||||
try {
|
||||
var off = start;
|
||||
while (off <= end) {
|
||||
final s = off ~/ slice;
|
||||
final data = await _waitBytes(v, s, off - s * slice, me, () => tail || v.newest == me);
|
||||
if (_disposed) throw const RelayEnded();
|
||||
if (data == null) throw StateError('relay: bytes at $off did not arrive');
|
||||
final n = min(data.length, end - off + 1);
|
||||
if (n <= 0) continue;
|
||||
@@ -530,10 +638,12 @@ class RelayBuffer implements TrackBuffer {
|
||||
v.lastActive = DateTime.now();
|
||||
if (!tail && v.newest == me) v.pos = off;
|
||||
_wake();
|
||||
_wakeHeld();
|
||||
}
|
||||
} finally {
|
||||
v.open--;
|
||||
v.lastActive = DateTime.now();
|
||||
_wakeHeld();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io' show HttpDate, IOSink, Socket;
|
||||
import 'dart:math';
|
||||
import 'dart:typed_data';
|
||||
|
||||
@@ -491,7 +492,7 @@ void mountRelayRoutes(
|
||||
});
|
||||
});
|
||||
|
||||
Future<Response> viewer(Request req, {required bool tracked, bool piece = false}) async {
|
||||
Future<Result> viewer(Request req, {required bool tracked, bool piece = false}) async {
|
||||
final stopwatch = Stopwatch()..start();
|
||||
final sid = req.pathParameters.raw[#sid] ?? '';
|
||||
final token = req.pathParameters.raw[#token] ?? '';
|
||||
@@ -506,17 +507,18 @@ void mountRelayRoutes(
|
||||
}
|
||||
// Every outcome goes to the log (throttled per member there); a 503
|
||||
// after a long wait is a source that never came up.
|
||||
void note(int status, {int bytes = 0}) => onLog?.call(
|
||||
token,
|
||||
MediaLog(
|
||||
method: req.method.name,
|
||||
path: '/r/$sid/$key/${track == null ? '' : '$track/'}${pieceIndex == null ? '' : 'p/$pieceIndex'}',
|
||||
rangeHeader: rangeHeader,
|
||||
status: status,
|
||||
bytesWritten: bytes,
|
||||
durationMs: stopwatch.elapsedMilliseconds,
|
||||
));
|
||||
Response logged(Response res, {int bytes = 0}) {
|
||||
onLog?.call(
|
||||
token,
|
||||
MediaLog(
|
||||
method: req.method.name,
|
||||
path: '/r/$sid/$key/${track == null ? '' : '$track/'}${pieceIndex == null ? '' : 'p/$pieceIndex'}',
|
||||
rangeHeader: rangeHeader,
|
||||
status: res.statusCode,
|
||||
bytesWritten: bytes,
|
||||
durationMs: stopwatch.elapsedMilliseconds,
|
||||
));
|
||||
note(res.statusCode, bytes: bytes);
|
||||
return res;
|
||||
}
|
||||
|
||||
@@ -571,24 +573,17 @@ void mountRelayRoutes(
|
||||
return logged(Response(res.status, body: Body.fromString('status ${res.status}'), headers: headers));
|
||||
}
|
||||
final end = res.end ?? size - 1;
|
||||
final Stream<Uint8List> body = buffer
|
||||
.read(token, res.start, end, onSent: (n) => session.addBytes(token, n))
|
||||
.transform(StreamTransformer.fromHandlers(handleError: (e, st, sink) {
|
||||
_readFails(token, '${r.name} track ${track ?? 0} from ${res.start}: $e');
|
||||
sink.addError(e, st);
|
||||
}));
|
||||
MimeType mime;
|
||||
try {
|
||||
mime = MimeType.parse(res.headers['content-type'] ?? '');
|
||||
} catch (_) {
|
||||
mime = MimeType.octetStream;
|
||||
}
|
||||
return logged(
|
||||
Response(res.status,
|
||||
body: Body.fromDataStream(body, mimeType: mime, contentLength: end - res.start + 1),
|
||||
headers: headers),
|
||||
bytes: end - res.start + 1);
|
||||
}
|
||||
final length = end - res.start + 1;
|
||||
note(res.status, bytes: length);
|
||||
final what = '${r.name} track ${track ?? 0} from ${res.start}';
|
||||
return _rangeResponse(res.status, res.headers, length,
|
||||
req.method == Method.head
|
||||
? null
|
||||
: buffer.read(token, res.start, end, onSent: (n) => session.addBytes(token, n)),
|
||||
onBroke: (e) => e is RelayEnded
|
||||
? _log.fine('relay ended under a viewer\'s read: $what')
|
||||
: _readFails(token, '$what: $e'));
|
||||
}
|
||||
|
||||
// `:seg` is the name of a single file, the track of several (the router
|
||||
// wants one parameter name per level).
|
||||
@@ -597,6 +592,88 @@ void mountRelayRoutes(
|
||||
app.get('/r/:sid/:token/:mediaId/:seg/p/:piece', (final req) => viewer(req, tracked: true, piece: true));
|
||||
}
|
||||
|
||||
/// A relayed byte range on the raw connection ([writeRelayRange]).
|
||||
Hijack _rangeResponse(int status, Map<String, String> headers, int length, Stream<Uint8List>? body,
|
||||
{required void Function(Object e) onBroke}) =>
|
||||
Hijack((ch) => writeRelayRange(ch.stream, ch.sink, status, headers, length, body, onBroke: onBroke));
|
||||
|
||||
/// Writes a relayed byte range on a raw connection ([input] / [out]: the
|
||||
/// server's `/r` answers, and the source's own loopback endpoint in
|
||||
/// shared_source.dart): the status line and headers go out with the
|
||||
/// first body bytes, or after [headerWait] without them (an HTTP stack
|
||||
/// would hold them until body bytes come, and an ffmpeg whose request
|
||||
/// gets no headers within its 5 s timeout has lost the file's size:
|
||||
/// every later seek fails with "size -38"). Then the body as the buffer
|
||||
/// has it; a read waiting on missing bytes keeps the connection open
|
||||
/// meanwhile. Always `connection: close` and closed after the body:
|
||||
/// ffmpeg asks for close, and one that is not told so reuses the
|
||||
/// connection the server closed for its next seek and fails it the same
|
||||
/// way. A relay that ends ([RelayEnded]) closes the connection plainly;
|
||||
/// any other failure ([onBroke]) resets it. The client going away cancels
|
||||
/// the read. Never throws.
|
||||
Future<void> writeRelayRange(Stream<List<int>> input, StreamSink<List<int>> out, int status,
|
||||
Map<String, String> headers, int length, Stream<Uint8List>? body,
|
||||
{required void Function(Object e) onBroke,
|
||||
Duration headerWait = const Duration(milliseconds: RelayConstants.relayHeaderWaitMs)}) async {
|
||||
final head = StringBuffer('HTTP/1.1 $status ${_reason(status)}\r\n')
|
||||
..write('date: ${HttpDate.format(DateTime.now())}\r\n');
|
||||
headers.forEach((k, v) {
|
||||
if (k != 'content-length' && k != 'connection') head.write('$k: $v\r\n');
|
||||
});
|
||||
head.write('content-length: $length\r\nconnection: close\r\n\r\n');
|
||||
final it = body == null ? null : StreamIterator(body);
|
||||
// A read cancelled while it waits ends with what it was waiting for
|
||||
// (its relay ending, say): moot then.
|
||||
Future<void> stop() async => it?.cancel().catchError((_) {});
|
||||
// The client closing (a seek elsewhere, the player gone) ends the read.
|
||||
final listening = input.listen(null,
|
||||
onDone: () => unawaited(stop()), onError: (_) => unawaited(stop()), cancelOnError: true);
|
||||
var reset = false;
|
||||
// The first bytes go out with the headers when they come in time.
|
||||
Future<bool>? first = it?.moveNext();
|
||||
if (first != null) {
|
||||
await Future.any([first.then((_) {}, onError: (_) {}), Future<void>.delayed(headerWait)]);
|
||||
}
|
||||
try {
|
||||
out.add(latin1.encode('$head'));
|
||||
if (out is IOSink) await out.flush();
|
||||
while (it != null) {
|
||||
final bool more;
|
||||
try {
|
||||
final next = first ?? it.moveNext();
|
||||
first = null;
|
||||
more = await next;
|
||||
} catch (e) {
|
||||
onBroke(e);
|
||||
reset = e is! RelayEnded;
|
||||
break;
|
||||
}
|
||||
if (!more) break;
|
||||
out.add(it.current);
|
||||
if (out is IOSink) await out.flush();
|
||||
}
|
||||
} catch (_) {
|
||||
// The client went away mid-write: nothing to say.
|
||||
reset = true;
|
||||
} finally {
|
||||
await listening.cancel().catchError((_) {});
|
||||
await stop();
|
||||
if (reset && out is Socket) {
|
||||
out.destroy();
|
||||
} else {
|
||||
await out.close().catchError((_) {});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
String _reason(int status) => switch (status) {
|
||||
200 => 'OK',
|
||||
206 => 'Partial Content',
|
||||
404 => 'Not Found',
|
||||
416 => 'Range Not Satisfiable',
|
||||
_ => 'Status',
|
||||
};
|
||||
|
||||
Response _plain(int status) => Response(status, body: Body.fromString('status $status'));
|
||||
|
||||
final _readFailThrottle = LogThrottle(burst: 3);
|
||||
|
||||
@@ -0,0 +1,381 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
|
||||
import 'media_endpoint.dart' show resolveMediaRequest;
|
||||
import 'relay_buffer.dart';
|
||||
import 'relay_routes.dart' show relayBudgets, writeRelayRange;
|
||||
|
||||
// The single-fetch source: the member who passes a link on reads it from
|
||||
// the site once. Per track, one [RelayBuffer] filled by one sequential
|
||||
// upstream reader ([LocalRelayFeed] over [ResumingBytes]) serves two
|
||||
// readers: this app's own player, through a loopback HTTP endpoint
|
||||
// ([SharedSource.playerUrl]), and the server's pulls
|
||||
// ([SharedSource.upload], handed to the relay's [RelaySource]). The two
|
||||
// are kept within one window ([RelayBuffer.together]). Byte tracks only:
|
||||
// a page with pieces tracks (HLS/DASH) keeps the two fetches.
|
||||
|
||||
final _log = createLogger('relay');
|
||||
|
||||
/// Upstream errors worth another try after a pause: a cut connection, a
|
||||
/// timeout, a 5xx, and the refusals a CDN gives a client it thinks is too
|
||||
/// busy (412, 429). 403/410 (an expired URL) is the page's job
|
||||
/// ([PageRelayMedia] resolves again once); anything else is final.
|
||||
bool retryableUpstream(Object e) {
|
||||
if (e is UpstreamHttpException) return e.status == 412 || e.status == 429 || e.status >= 500;
|
||||
return e is HttpException || e is SocketException || e is TimeoutException;
|
||||
}
|
||||
|
||||
/// [inner] read with resume: a read that breaks goes on from the byte it
|
||||
/// reached. After a cut that brought bytes it goes on after 100 ms; after
|
||||
/// an answer without a byte (a refusal, a dead connection) after
|
||||
/// [backoff] (attempt n, reset by any byte). A read that brings nothing
|
||||
/// for [stall] is cut and resumed the same way. It fails only on a final
|
||||
/// error or after [giveUp] without a byte. Cancelling stops it at once,
|
||||
/// also during a pause.
|
||||
class ResumingBytes implements RelayBytes {
|
||||
final RelayBytes inner;
|
||||
final Duration Function(int attempt) backoff;
|
||||
final Duration giveUp;
|
||||
final Duration stall;
|
||||
ResumingBytes(this.inner,
|
||||
{Duration Function(int attempt)? backoff,
|
||||
this.giveUp = const Duration(seconds: 60),
|
||||
this.stall = const Duration(seconds: 20)})
|
||||
: backoff = backoff ?? defaultBackoff;
|
||||
|
||||
static final _rnd = Random();
|
||||
|
||||
/// 0.5 s doubling to 8 s, with up to 25% jitter.
|
||||
static Duration defaultBackoff(int attempt) {
|
||||
final ms = min(8000, 500 * (1 << min(attempt, 4)));
|
||||
return Duration(milliseconds: ms + _rnd.nextInt(ms ~/ 4 + 1));
|
||||
}
|
||||
|
||||
/// Resumes so far, for logs and tests.
|
||||
int retries = 0;
|
||||
|
||||
/// Bytes read from [inner], for logs and tests.
|
||||
int bytesIn = 0;
|
||||
|
||||
@override
|
||||
Future<int> size() async {
|
||||
var attempt = 0;
|
||||
final since = DateTime.now();
|
||||
while (true) {
|
||||
try {
|
||||
return await inner.size();
|
||||
} catch (e) {
|
||||
if (!retryableUpstream(e) || DateTime.now().difference(since) > giveUp) rethrow;
|
||||
retries++;
|
||||
await Future<void>.delayed(backoff(attempt++));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<List<int>> read(int start, int end) {
|
||||
late final StreamController<List<int>> out;
|
||||
var cancelled = false;
|
||||
StreamSubscription<List<int>>? sub;
|
||||
Completer<void>? pause;
|
||||
Future<void> run() async {
|
||||
var pos = start, attempt = 0;
|
||||
var lastByte = DateTime.now();
|
||||
while (!cancelled && pos <= end) {
|
||||
var got = 0;
|
||||
final done = Completer<Object?>();
|
||||
Timer? quiet;
|
||||
void watch() {
|
||||
quiet?.cancel();
|
||||
quiet = Timer(stall, () {
|
||||
if (done.isCompleted) return;
|
||||
unawaited(sub?.cancel().catchError((_) {}));
|
||||
done.complete(TimeoutException('upstream sent nothing', stall));
|
||||
});
|
||||
}
|
||||
|
||||
watch();
|
||||
sub = inner.read(pos, end).listen((c) {
|
||||
if (done.isCompleted) return;
|
||||
pos += c.length;
|
||||
got += c.length;
|
||||
bytesIn += c.length;
|
||||
lastByte = DateTime.now();
|
||||
watch();
|
||||
out.add(c);
|
||||
}, onError: (Object e) {
|
||||
if (!done.isCompleted) done.complete(e);
|
||||
}, onDone: () {
|
||||
if (!done.isCompleted) done.complete(null);
|
||||
}, cancelOnError: true);
|
||||
final err = await done.future;
|
||||
quiet?.cancel();
|
||||
sub = null;
|
||||
if (cancelled) return;
|
||||
if (err == null) {
|
||||
if (pos <= end) continue; // ended short without an error: go on
|
||||
break;
|
||||
}
|
||||
if (!retryableUpstream(err) || DateTime.now().difference(lastByte) > giveUp) {
|
||||
out.addError(err);
|
||||
break;
|
||||
}
|
||||
// A cut after some bytes goes on almost at once (what mpv's
|
||||
// reconnect does); an answer without a byte backs off, doubling.
|
||||
if (got > 0) attempt = 0;
|
||||
final wait = got > 0 ? const Duration(milliseconds: 100) : backoff(attempt++);
|
||||
retries++;
|
||||
_log.fine('upstream read broke at $pos ($err); again in ${wait.inMilliseconds} ms');
|
||||
final p = pause = Completer<void>();
|
||||
final t = Timer(wait, () {
|
||||
if (!p.isCompleted) p.complete();
|
||||
});
|
||||
await p.future;
|
||||
t.cancel();
|
||||
pause = null;
|
||||
}
|
||||
if (!cancelled) await out.close();
|
||||
}
|
||||
|
||||
out = StreamController<List<int>>(
|
||||
onListen: () => unawaited(run()),
|
||||
onPause: () => sub?.pause(),
|
||||
onResume: () => sub?.resume(),
|
||||
onCancel: () async {
|
||||
cancelled = true;
|
||||
final p = pause;
|
||||
if (p != null && !p.isCompleted) p.complete();
|
||||
await sub?.cancel().catchError((_) {});
|
||||
},
|
||||
);
|
||||
return out.stream;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> close() => inner.close();
|
||||
}
|
||||
|
||||
/// A media a [SharedSource] cannot share (a pieces track): the caller
|
||||
/// keeps the player and the relay on their own fetches.
|
||||
class SharedSourceUnsupported implements Exception {
|
||||
final String reason;
|
||||
const SharedSourceUnsupported(this.reason);
|
||||
@override
|
||||
String toString() => reason;
|
||||
}
|
||||
|
||||
/// One track of a [SharedSource]: its buffer and its single upstream
|
||||
/// reader.
|
||||
class SharedTrack {
|
||||
final String kind;
|
||||
final int size;
|
||||
final RelayBuffer buffer;
|
||||
final LocalRelayFeed feed;
|
||||
final ResumingBytes upstream;
|
||||
SharedTrack(this.kind, this.size, this.buffer, this.feed, this.upstream);
|
||||
}
|
||||
|
||||
/// A media read from upstream once and served to this app's player and to
|
||||
/// the relay. [open] opens it (a page's tracks, the loopback endpoint) and
|
||||
/// starts reading the tracks' sizes; [ready] completes with them. The
|
||||
/// player can open [playerUrl] right after [open]: the endpoint waits for
|
||||
/// the sizes. Nothing is read upstream while neither reader asks.
|
||||
class SharedSource {
|
||||
final RelayMedia media;
|
||||
|
||||
/// The local buffer's budgets (all tracks together, split by size like
|
||||
/// the server's, [relayBudgets]). It only has to hold what the player
|
||||
/// and the upload have not both read yet: they read within [ahead] of
|
||||
/// each other ([RelayBuffer.together]).
|
||||
final int ahead, back;
|
||||
final Duration Function(int attempt)? backoff;
|
||||
|
||||
/// A read waiting for missing bytes gives up after this.
|
||||
final Duration waitLimit;
|
||||
SharedSource(this.media,
|
||||
{this.ahead = RelayConstants.sharedAheadBytes,
|
||||
this.back = RelayConstants.sharedBackBytes,
|
||||
this.backoff,
|
||||
this.waitLimit = const Duration(seconds: RelayConstants.relayWaitSecs)});
|
||||
|
||||
/// The tracks, once [ready].
|
||||
final tracks = <SharedTrack>[];
|
||||
List<RelayTrack> _upstream = const [];
|
||||
HttpServer? _http;
|
||||
final _secret = List.generate(16, (_) => Random.secure().nextInt(16).toRadixString(16)).join();
|
||||
Future<void>? _opening;
|
||||
final _ready = Completer<void>();
|
||||
var _closed = false;
|
||||
|
||||
/// Opens the media and binds the loopback endpoint; the sizes are read
|
||||
/// meanwhile ([ready]). Throws [SharedSourceUnsupported] for a media
|
||||
/// with pieces tracks, or why the media cannot be opened.
|
||||
Future<void> open() => _opening ??= _open();
|
||||
|
||||
Future<void> _open() async {
|
||||
final upstream = await media.open();
|
||||
if (upstream.any((t) => t.bytes == null)) {
|
||||
throw const SharedSourceUnsupported('a segmented stream (HLS/DASH) is not shared');
|
||||
}
|
||||
_upstream = upstream;
|
||||
final http = _http = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
|
||||
http.listen((req) => unawaited(_serve(req)), onError: (_) {});
|
||||
// Nobody may be waiting on [ready] yet: its error is not unhandled.
|
||||
_ready.future.ignore();
|
||||
unawaited(_readSizes());
|
||||
}
|
||||
|
||||
Future<void> _readSizes() async {
|
||||
final sw = Stopwatch()..start();
|
||||
try {
|
||||
final wrapped = [for (final t in _upstream) ResumingBytes(t.bytes!, backoff: backoff)];
|
||||
final sizes = await Future.wait([for (final w in wrapped) w.size()]);
|
||||
if (_closed) throw const RelayEnded();
|
||||
final budgets = relayBudgets(sizes, ahead: ahead, back: back);
|
||||
var ids = 0;
|
||||
for (var i = 0; i < _upstream.length; i++) {
|
||||
final b = RelayBuffer(
|
||||
ahead: budgets[i].$1,
|
||||
back: budgets[i].$2,
|
||||
waitLimit: waitLimit,
|
||||
together: (playerViewer, uploadViewer),
|
||||
nextId: () => ++ids);
|
||||
final f = LocalRelayFeed(RelayTrack(_upstream[i].kind, wrapped[i]), b);
|
||||
b.attach(f, sizes[i]);
|
||||
tracks.add(SharedTrack(_upstream[i].kind, sizes[i], b, f, wrapped[i]));
|
||||
}
|
||||
_log.info('shared source: ${tracks.map((t) => '${t.kind.isEmpty ? 'file' : t.kind} ${t.size} bytes').join(' + ')} '
|
||||
'in ${sw.elapsedMilliseconds} ms, the player on loopback port ${_http?.port}');
|
||||
_ready.complete();
|
||||
} catch (e, st) {
|
||||
_ready.completeError(e, st);
|
||||
}
|
||||
}
|
||||
|
||||
/// Completes once every track's size is known; throws why it could not
|
||||
/// be read.
|
||||
Future<void> get ready => _ready.future;
|
||||
|
||||
/// [open], then [ready].
|
||||
Future<void> start() async {
|
||||
await open();
|
||||
await ready;
|
||||
}
|
||||
|
||||
/// The two readers' names in each track's [RelayBuffer].
|
||||
static const playerViewer = 'player', uploadViewer = 'upload';
|
||||
|
||||
/// What this app's own player opens (after [open]): one loopback URL, or
|
||||
/// an mpv EDL of one per track. No headers: the site's stay in this
|
||||
/// process.
|
||||
String get playerUrl {
|
||||
final http = _http;
|
||||
if (http == null) throw StateError('shared source not open');
|
||||
final urls = [for (var i = 0; i < _upstream.length; i++) 'http://127.0.0.1:${http.port}/$_secret/$i'];
|
||||
return urls.length == 1 ? urls.single : mpvEdl(urls);
|
||||
}
|
||||
|
||||
/// What [RelaySource] serves: the same buffers, read as the viewer
|
||||
/// `upload`. Closing it leaves the shared source open (the player may
|
||||
/// still read it).
|
||||
RelayMedia get upload => _Upload(this);
|
||||
|
||||
/// Upstream reads resumed so far, all tracks (for logs and tests).
|
||||
int get resumes => tracks.fold(0, (a, t) => a + t.upstream.retries);
|
||||
|
||||
/// Bytes read upstream so far, all tracks.
|
||||
int get bytesIn => tracks.fold(0, (a, t) => a + t.upstream.bytesIn);
|
||||
|
||||
/// Bytes the local buffers hold now.
|
||||
int get cachedBytes => tracks.fold(0, (a, t) => a + t.buffer.cachedBytes);
|
||||
|
||||
Future<void> _serve(HttpRequest req) async {
|
||||
final res = req.response;
|
||||
Future<void> plain(int status, [Map<String, String> headers = const {}]) async {
|
||||
res.statusCode = status;
|
||||
headers.forEach(res.headers.set);
|
||||
res.headers.set(HttpHeaders.connectionHeader, 'close');
|
||||
await res.close().catchError((_) {});
|
||||
}
|
||||
|
||||
final seg = req.uri.pathSegments;
|
||||
final i = seg.length == 2 && seg[0] == _secret ? int.tryParse(seg[1]) : null;
|
||||
if (i == null || i < 0 || i >= _upstream.length || (req.method != 'GET' && req.method != 'HEAD')) {
|
||||
return plain(404);
|
||||
}
|
||||
try {
|
||||
await ready.timeout(waitLimit);
|
||||
} catch (_) {
|
||||
return plain(_closed ? 404 : 502);
|
||||
}
|
||||
if (_closed) return plain(404);
|
||||
final t = tracks[i];
|
||||
final r = resolveMediaRequest(
|
||||
rangeHeader: req.headers.value(HttpHeaders.rangeHeader), size: t.size, filename: 'track');
|
||||
if (r.status != 200 && r.status != 206) return plain(r.status, r.headers);
|
||||
final end = r.end ?? t.size - 1;
|
||||
final Socket socket;
|
||||
try {
|
||||
socket = await res.detachSocket(writeHeaders: false);
|
||||
} catch (_) {
|
||||
return;
|
||||
}
|
||||
// The server's own answer code: headers in time, connection: close,
|
||||
// a read that breaks resets the connection (the player reconnects).
|
||||
await writeRelayRange(socket, socket, r.status, r.headers, end - r.start + 1,
|
||||
req.method == 'HEAD' ? null : t.buffer.read(playerViewer, r.start, end),
|
||||
onBroke: (e) => _log.fine('shared source: the player\'s read of track $i broke: $e'));
|
||||
}
|
||||
|
||||
Future<void> close() async {
|
||||
if (_closed) return;
|
||||
_closed = true;
|
||||
await _http?.close(force: true);
|
||||
for (final t in tracks) {
|
||||
t.buffer.dispose();
|
||||
await t.feed.close();
|
||||
}
|
||||
if (tracks.isNotEmpty) _log.info('shared source closed: $bytesIn bytes read upstream, $resumes resumes');
|
||||
await media.close();
|
||||
}
|
||||
}
|
||||
|
||||
class _Upload implements RelayMedia {
|
||||
final SharedSource src;
|
||||
_Upload(this.src);
|
||||
|
||||
@override
|
||||
Future<List<RelayTrack>> open() async {
|
||||
await src.open();
|
||||
return [for (var i = 0; i < src._upstream.length; i++) RelayTrack(src._upstream[i].kind, _BufferBytes(src, i))];
|
||||
}
|
||||
|
||||
/// The shared source owns the upstream.
|
||||
@override
|
||||
Future<void> close() async {}
|
||||
}
|
||||
|
||||
class _BufferBytes implements RelayBytes {
|
||||
final SharedSource src;
|
||||
final int i;
|
||||
_BufferBytes(this.src, this.i);
|
||||
|
||||
@override
|
||||
Future<int> size() async {
|
||||
await src.ready;
|
||||
return src.tracks[i].size;
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<List<int>> read(int start, int end) async* {
|
||||
await src.ready;
|
||||
if (src._closed) throw const RelayEnded();
|
||||
yield* src.tracks[i].buffer.read(SharedSource.uploadViewer, start, end);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> close() async {}
|
||||
}
|
||||
@@ -121,6 +121,7 @@ class SyncServer {
|
||||
})),
|
||||
);
|
||||
});
|
||||
_app.use('/', closeWhenAsked);
|
||||
final requests = MediaRequestLog();
|
||||
void logRequest(String token, MediaLog log) => requests(hub.who(token), log);
|
||||
mountSessionMediaRoute(_app, sessions, onLog: logRequest);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
name: sync_relay_server
|
||||
description: Pure-Dart sync + media server. Embeddable, no Flutter.
|
||||
publish_to: none
|
||||
version: 1.3.2
|
||||
version: 1.4.0
|
||||
environment:
|
||||
sdk: ^3.0.0
|
||||
dependencies:
|
||||
|
||||
@@ -393,6 +393,64 @@ void main() {
|
||||
expect(carol.welcome.mediaUrl, mpvEdl(['$carolBase/0/$name.m3u8', '$carolBase/1/$name']));
|
||||
});
|
||||
|
||||
test('a slow source: the headers still come in time, the bytes once they arrive', () async {
|
||||
final alice = await join('alice');
|
||||
final bob = await join('bob');
|
||||
await swapRelay(alice);
|
||||
final gated = _GatedBytes(FileRelayBytes(file));
|
||||
await startSource(alice, from: RelayMedia.single(gated));
|
||||
await _until(() => server.hub.relay!.live, 'live');
|
||||
final url = Uri.parse(viewerUrl(bob, mediaIdOf(alice)));
|
||||
final start = 2 * RelayBuffer.slice + 10;
|
||||
final sw = Stopwatch()..start();
|
||||
final res = await _RawResponse.get(url, range: 'bytes=$start-');
|
||||
// No bytes: the headers go out on their own before mpv's 5 s timeout.
|
||||
final head = await res.head.timeout(const Duration(seconds: 6));
|
||||
expect(sw.elapsedMilliseconds, lessThan(5000));
|
||||
expect(head, startsWith('HTTP/1.1 206'));
|
||||
expect(head, contains('content-range: bytes $start-${bytes.length - 1}/${bytes.length}'));
|
||||
expect(head, contains('content-length: ${bytes.length - start}'));
|
||||
expect(head, contains('connection: close'));
|
||||
expect(res.body, isEmpty);
|
||||
gated.open();
|
||||
expect(await res.done.timeout(const Duration(seconds: 10)), isTrue, reason: 'closed plainly');
|
||||
expect(res.body, bytes.sublist(start));
|
||||
});
|
||||
|
||||
test('a range read to its end says connection: close (ffmpeg 8 reuses it otherwise)', () async {
|
||||
final alice = await join('alice');
|
||||
final bob = await join('bob');
|
||||
await swapRelay(alice);
|
||||
await startSource(alice);
|
||||
final url = Uri.parse(viewerUrl(bob, mediaIdOf(alice)));
|
||||
// The 58-byte tail box of an mp4 whose moov comes first, as ffmpeg asks.
|
||||
final res = await _RawResponse.get(url, range: 'bytes=${bytes.length - 58}-');
|
||||
expect(await res.head, contains('connection: close'));
|
||||
expect(await res.done.timeout(const Duration(seconds: 5)), isTrue);
|
||||
expect(res.body, bytes.sublist(bytes.length - 58));
|
||||
// Any other answer to a request that asks for close says so too.
|
||||
final health = await _RawResponse.get(Uri.parse('http://127.0.0.1:${server.boundPort}/health'));
|
||||
expect(await health.head, contains('connection: close'));
|
||||
await health.done;
|
||||
});
|
||||
|
||||
test('a swap ends a viewer\'s waiting read plainly', () async {
|
||||
final alice = await join('alice');
|
||||
final bob = await join('bob');
|
||||
await swapRelay(alice);
|
||||
await startSource(alice, from: RelayMedia.single(_GatedBytes(FileRelayBytes(file))));
|
||||
await _until(() => server.hub.relay!.live, 'live');
|
||||
final res = await _RawResponse.get(Uri.parse(viewerUrl(bob, mediaIdOf(alice))), range: 'bytes=1000-');
|
||||
await res.head.timeout(const Duration(seconds: 6));
|
||||
alice.send({
|
||||
't': 'swap',
|
||||
'media': {'name': 'x', 'size': 0, 'duration': 0, 'url': 'http://example.invalid/x.mkv'},
|
||||
});
|
||||
// Closed, not reset.
|
||||
expect(await res.done.timeout(const Duration(seconds: 5)), isTrue);
|
||||
expect(res.body, isEmpty);
|
||||
});
|
||||
|
||||
test('Jellyfin swap can carry a relay', () async {
|
||||
final alice = await join('alice');
|
||||
final before = alice.raw.length;
|
||||
@@ -704,6 +762,28 @@ void main() {
|
||||
await old.cancel();
|
||||
});
|
||||
|
||||
test('a source that keeps failing is asked again with backoff, at once again after bytes', () async {
|
||||
final failing = _FailingBytes(FileRelayBytes(file));
|
||||
final b = RelayBuffer(ahead: 2 * RelayBuffer.slice, back: RelayBuffer.slice, pin: RelayBuffer.slice);
|
||||
final f = LocalRelayFeed(RelayTrack('', failing), b);
|
||||
b.attach(f, bytes.length);
|
||||
addTearDown(() async {
|
||||
b.dispose();
|
||||
await f.close();
|
||||
});
|
||||
final reader = b.read('a', 0, 99).toList();
|
||||
await Future<void>.delayed(const Duration(milliseconds: 2500));
|
||||
// 0, 0.25, 0.75, 1.75 s: not one pull per round trip.
|
||||
expect(failing.reads, inInclusiveRange(3, 5));
|
||||
failing.failing = false;
|
||||
final got = await reader.timeout(const Duration(seconds: 10));
|
||||
expect([for (final c in got) ...c], bytes.sublist(0, 100));
|
||||
expect(relayRetryDelay(0), Duration.zero);
|
||||
expect(relayRetryDelay(1), const Duration(milliseconds: 250));
|
||||
expect(relayRetryDelay(3), const Duration(seconds: 1));
|
||||
expect(relayRetryDelay(50), const Duration(milliseconds: RelayConstants.relayRetryMaxMs));
|
||||
});
|
||||
|
||||
test('dispose frees everything and ends waiting readers', () async {
|
||||
await read('a', 0, 10);
|
||||
buf.dispose();
|
||||
@@ -762,3 +842,80 @@ class _FakeYtDlp extends YtDlp {
|
||||
Future<YtDlpInfo> resolve(String url, {Duration timeout = const Duration(seconds: 25)}) async =>
|
||||
YtDlpInfo(title: 'page', tracks: tracks, formats: {for (final t in tracks) t.format.id: t.format});
|
||||
}
|
||||
|
||||
/// Bytes that come only once [open] is called (a source whose upstream
|
||||
/// stalls).
|
||||
class _GatedBytes implements RelayBytes {
|
||||
final RelayBytes inner;
|
||||
final _gate = Completer<void>();
|
||||
_GatedBytes(this.inner);
|
||||
void open() => _gate.complete();
|
||||
@override
|
||||
Future<int> size() => inner.size();
|
||||
@override
|
||||
Stream<List<int>> read(int start, int end) async* {
|
||||
await _gate.future;
|
||||
yield* inner.read(start, end);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> close() => inner.close();
|
||||
}
|
||||
|
||||
/// Reads that fail at once while [failing] (an upstream dropping every
|
||||
/// connection); [reads] counts them.
|
||||
class _FailingBytes implements RelayBytes {
|
||||
final RelayBytes inner;
|
||||
var failing = true;
|
||||
var reads = 0;
|
||||
_FailingBytes(this.inner);
|
||||
@override
|
||||
Future<int> size() => inner.size();
|
||||
@override
|
||||
Stream<List<int>> read(int start, int end) {
|
||||
reads++;
|
||||
if (failing) return Stream.error(const HttpException('Connection closed while receiving data'));
|
||||
return inner.read(start, end);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> close() => inner.close();
|
||||
}
|
||||
|
||||
/// One HTTP/1.1 GET on its own socket, as ffmpeg sends it
|
||||
/// (`Connection: close`): the header block as soon as it comes, the body
|
||||
/// as it comes, and how the connection ended ([done]: true when the
|
||||
/// server closed it plainly, false on a reset).
|
||||
class _RawResponse {
|
||||
final _head = Completer<String>();
|
||||
Future<String> get head => _head.future;
|
||||
final body = <int>[];
|
||||
final _done = Completer<bool>();
|
||||
Future<bool> get done => _done.future;
|
||||
|
||||
static Future<_RawResponse> get(Uri url, {String? range}) async {
|
||||
final r = _RawResponse();
|
||||
final s = await Socket.connect(url.host, url.port);
|
||||
final buf = <int>[];
|
||||
s.listen((d) {
|
||||
if (r._head.isCompleted) {
|
||||
r.body.addAll(d);
|
||||
return;
|
||||
}
|
||||
buf.addAll(d);
|
||||
final text = latin1.decode(buf);
|
||||
final at = text.indexOf('\r\n\r\n');
|
||||
if (at < 0) return;
|
||||
r._head.complete(text.substring(0, at).toLowerCase().replaceFirst('http/1.1', 'HTTP/1.1'));
|
||||
r.body.addAll(buf.sublist(at + 4));
|
||||
}, onError: (_) {
|
||||
if (!r._done.isCompleted) r._done.complete(false);
|
||||
}, onDone: () {
|
||||
if (!r._done.isCompleted) r._done.complete(true);
|
||||
s.destroy();
|
||||
});
|
||||
s.write('GET ${url.path} HTTP/1.1\r\nHost: ${url.host}\r\n${range == null ? '' : 'Range: $range\r\n'}'
|
||||
'Connection: close\r\n\r\n');
|
||||
return r;
|
||||
}
|
||||
}
|
||||
Loaded 100 of 111 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user