20 Commits
Author SHA1 Message Date
foxyz 1fd4081461 Release v1.1.0
ci / updater-windows (push) Successful in 32s
ci / core (push) Successful in 27s
release / windows (push) Failing after 20s
ci / server (push) Successful in 54s
release / linux (push) Successful in 3m46s
2026-10-09 23:53:53 +03:00
foxyzandClaude Opus 5.5 2d39018a73 One updater for app and server: updater in core; watch-together-server --upgrade / --check-update; update.sh uses the server
The server's version is stamped like the app's (APP_VERSION) when packaged,
so it can compare against the manifest; --upgrade swaps the whole install
folder (app and server) and asks for a restart. update.sh/.bat no longer
need GTK.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 23:53:52 +03:00
foxyzandClaude Opus 5.5 059b7dd306 Solo: no idle unready, no party corner in fullscreen; a cancelled Invite returns to solo
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 23:39:48 +03:00
foxyzandClaude Opus 5.5 0115d54ab8 Strip shows the joined server's name (its --name via /health), the address until known
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 23:36:30 +03:00
foxyzandClaude Opus 5.5 fe5d6c3b3f Relay HLS/DASH-only pages as pieces tracks (protocol 11)
A track can be served as pieces: the server writes a VOD playlist whose
entries are its own piece URLs (segments, init maps, AES-128 keys; byte
ranges folded in; yt-dlp's DASH fragments become the same list). A piece
buffer per track fills like the byte buffer. Picks a single file, then a
split byte pair, then pieces. DRM, live, master playlists are refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 23:35:31 +03:00
foxyzandClaude Opus 5.5 05f9e794a9 Cookies: keep every site of an imported file (no site picker); only the cookies box takes drops on Settings
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 23:26:55 +03:00
foxyzandClaude Opus 5.5 75065f90cd Welcome band only for newcomers, with Don't show again
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 23:13:34 +03:00
foxyzandClaude Opus 5.5 e0db5cc655 Solo watching and a clearer offline start
Putting anything up offline starts a solo session: an in-app server bound
to 127.0.0.1 only, joined like hosting, so tracking, next episode and the
player all work. Invite friends re-hosts on the Host card's port and
password with the same media and position; Stop needs no confirmation.
Offline, the Library opens with a welcome band (Join a friend, Host, Watch
solo); without a Jellyfin account it leads with Files and Link, Jellyfin
is an optional card. Play buttons work offline.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 23:08:11 +03:00
foxyzandClaude Opus 5.5 8186fbb5d3 yt-dlp cookies: from a browser or a file (cookies.txt or extension JSON), per site, private; server --yt-dlp-cookies
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 22:59:10 +03:00
foxyzandClaude Opus 5.5 8b81da4069 Protocol 11: sit out replaces kicking; nudge
Anyone can sit anyone out (host included); only you bring yourself back.
Out members stay in chat and the party, never block or count, and load no
media; a card with the live position and Join back (Space) replaces the
video. The mini player's x sits you out; optionally idle does too. Nudge:
the server forwards it to the target only (3/s, 20/min per sender); the
target hears the nudge sound, gets a toast and a taskbar attention
request. Kick, its messages, errors and dialog are gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 21:05:35 +03:00
foxyzandClaude Opus 5.5 dbfd351ee4 Relay picks the most compact video codec (HEVC, AV1, VP9, then h264)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 20:44:14 +03:00
foxyzandClaude Opus 5.5 a4be24a2a5 Sounds: three presets (Cosmic, Abyss, Wood & glass), chosen overall or per sound; nudge sound
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 20:21:43 +03:00
foxyzandClaude Opus 5.5 b038706dff Protocol 11: split-track relay (YouTube video+audio), chunked upstream reads
Relayed media can carry 2-4 byte tracks (video, audio): yt-dlp picks a
split pair when no single-file format exists (h264 first), the source
channel serves each track, the server keeps a buffer per track (budget
split by size, 8 MiB floor) and viewers open an mpv edl:// of the track
URLs. Expired URLs re-resolve once for all tracks. Upstream GETs are cut
into <= http_chunk_size (10 MiB) ranges: YouTube throttles larger ones.
Live streams and DRM are refused; HLS/DASH pieces are a follow-up.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 19:59:41 +03:00
foxyzandClaude Opus 5.5 b2b77a6b3f Hot-restart guard comes from fox_utils 0.1.8 (shared with other media_kit apps)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 18:58:08 +03:00
foxyzandClaude Opus 5.5 d002ff6941 Linux debug: hot restart no longer crashes with live mpv players
Hot restart frees the old isolate's NativeCallables while mpv handles stay
alive; media_kit then quits them on the next start and mpv calls the dead
wakeup callback (SIGSEGV on the mpv 'core' thread). A debug-only runner
hook clears every live handle's wakeup callback on the engine's
on-pre-engine-restart signal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 18:53:07 +03:00
foxyzandClaude Opus 5.5 151ec95289 Show the app as Starsync (window titles, launcher entry, About, README); internals unchanged
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 18:47:35 +03:00
foxyzandClaude Opus 5.5 745717a4a0 yt-dlp for page links in every Link mode; app icon and Linux launcher entry
yt-dlp: a managed, checksum-verified, daily-updated yt-dlp in the app and
the dedicated server (--[no-]yt-dlp, --yt-dlp-dir). Pasted links are
probed: direct files stay links, pages are resolved for title, uploader,
duration and thumbnail. Everyone fetches it: mpv's ytdl hook on the
managed copy. I pass it on / the server fetches it: the best single-file
format, re-resolved once when its URL expires. Relay names never split
the /r/ path; viewers of server-fetched pages wait for the relay to go
live. No wire change.

Icon: two stars orbiting a play button (assets/icon/icon.svg, PNGs, the
Windows .ico). GTK app id moe.senkochan.watch_together; release builds
install their .desktop file and icons into ~/.local/share so Wayland
shows the icon; the window icon is set for X11. README shows the icon.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 18:21:22 +03:00
foxyzandClaude Opus 5.5 9ddd85de90 Esc steps out one level: chat, fullscreen, big player to mini, then ask to leave
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 17:41:38 +03:00
foxyzandClaude Opus 5.5 24fc137e4f No buffering rings at the end of short videos; links never guess an AniList show
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 17:23:43 +03:00
foxyzandClaude Opus 5.5 9a4b04672b Schedule: AniList entries split within a season (anilist_map, per-episode AniList id) count as on Jellyfin
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 17:12:43 +03:00
129 changed files with 7178 additions and 1375 deletions

No files matched your search

+5 -2
View File
@@ -55,5 +55,8 @@ jobs:
run: powershell -NoProfile -ExecutionPolicy Bypass -File tool/setup_flutter_windows.ps1
- name: Updater tests
shell: powershell
working-directory: apps/desktop
run: flutter test test/updater_test.dart
working-directory: packages/core
run: |
dart pub get
if ($LASTEXITCODE) { exit 1 }
dart test test/updater_test.dart
+82 -22
View File
@@ -1,4 +1,8 @@
# watch-together
<p align="center">
<img src="apps/desktop/assets/icon/icon_256.png" width="128" height="128" alt="Starsync icon">
</p>
<h1 align="center">Starsync</h1>
Watch a video with friends, in sync, with the quality of a real mpv player.
Pick from your Jellyfin library, paste any direct video link, or pick a file on your own disk;
@@ -22,7 +26,8 @@ How it fits together: [docs/HOW-IT-WORKS.md](docs/HOW-IT-WORKS.md).
From the Releases page of this repo:
- Linux: `watch-together-<version>-linux-x64.tar.gz`. Extract, run
`watch_together`. Needs mpv's library installed (`mpv` or `libmpv2`).
`watch_together`. On start it adds itself to your app launcher (and its
icon), so the window shows its own icon on Wayland. Needs mpv's library installed (`mpv` or `libmpv2`).
- Windows: `watch-together-<version>-windows-x64.zip`. Extract, run
`watch_together.exe`. mpv is bundled. New and not yet tried on a real
Windows desktop; reports welcome.
@@ -31,29 +36,48 @@ Update from the app: when a newer version exists, an update indicator
appears in the strip at the top of the window and opens the update card. "Update and restart" downloads,
verifies, applies in place and restarts; the install folder must be writable
by you. Settings has Check now and an auto-check toggle. From a terminal:
`watch_together --upgrade`, or the shipped `update.sh` / `update.bat`. A
`./watch-together-server --upgrade` (no display needed, so it works on a
server box too), `watch_together --upgrade`, or the shipped `update.sh` /
`update.bat` (which run the first). Each updates the whole folder, app and
server; restart whichever is running to use the new version. A
release with a newer protocol is a required update: everyone on a server
must run the same protocol version, and a mismatch is refused with a message
telling you to update.
## Getting started
Three ways in, all at the top of the Library when you are not in a server.
None of them needs Jellyfin or an account.
- **Join a friend** (most people): your friend sends you an address and,
maybe, a password. See [Join](#join).
- **Host**: you have the video and friends watch it with you. See
[Host from the app](#host-from-the-app).
- **Watch solo**: just play something from the Library. It plays for you
alone, with everything a server has (Jellyfin progress, next episode,
skip buttons); nothing is reachable from outside this computer. Invite
friends in the strip turns it into a hosted server (the Host card's port
and password) and the video carries on from where it was; Stop ends it.
## The window
One window. Along the top, the strip: your avatar (click it for your
profile: name, picture with a crop editor, colour, and links to the
settings), the Library / Schedule / Settings tabs, and the party. Offline
the party side has Join and Host; in a server it shows everyone's avatar
the party side has Join a friend and Host; watching solo it says Solo,
with Invite friends and Stop; in a server it shows everyone's avatar
with a ring for their state (green ready, amber buffering, grey not ready,
dimmed reconnecting; a crown on whoever hosts, a tower on whoever
passes the video on). Click the avatars for the member list (force ready,
kick, "Copy invite address"; when you host it lists this computer's
dimmed reconnecting, dimmed with a moon sitting out; a crown on whoever
hosts, a tower on whoever passes the video on). Click the avatars for the
member list (force ready, Nudge, Sit out, "Copy invite address"; when you host it lists this computer's
network addresses with the port, and friends outside your network need
that port forwarded or a server they can all reach, like a VPS). Beside them the server and "ready n/m", the chat button with unread
count, and Leave. Empty strip space drags the window.
The player fills the window under the strip. Pick a tab and it shrinks to
a mini player in a corner: drag it anywhere and it snaps to the nearest
corner; double-click or ⤢ makes it big again, × stops watching (you stay in
the server, muted) and the strip's screen button brings it back. F or
corner; double-click or ⤢ makes it big again, × sits you out (below) and
× again closes it; the strip's screen button brings it back. F or
double-click on the picture goes fullscreen (the strip hides, the party
shows in a corner while paused, the window buttons sit in the player's top
bar while its controls are up), Esc leaves it. The controls show the
@@ -71,11 +95,23 @@ hidden, as a small chip top right.
Your name and picture change for everyone in the server right away; the
colour is yours only.
**Sitting out.** Step away without leaving: Sit out (your profile, your
row in the member list, or × on the mini player) unloads the video on your
end, and nobody waits for you any more; you stay in chat. Anyone can sit
anyone out, the host too ("Alice sat you out" tells you who). The player
then shows the cover, title and where the others are; Join back (or Space)
loads the video there and readies you. Settings → Party & network → "When idle,
sit out instead of just unready" does it after 10 idle minutes.
**Nudge.** Nudge in the member list pokes someone, sitting out or not:
only they hear it (a sound, a pop-up, and the taskbar entry asks for
attention when the window is in the background).
## Host from the app
Host in the strip: set a port and an optional password (blank = anyone can
join), then Host. The app starts a server inside itself and joins it; you
are the host and nobody can kick you. Nothing is up yet: pick what to
are the host (the crown). Nothing is up yet: pick what to
watch in the Library tab (below) and put it up. Leaving or
closing the app stops the server. Friends need to reach your machine: open
the TCP port on your router (or use a LAN or VPN address). Friends open
@@ -91,7 +127,8 @@ a folder or a link anywhere on the window, or press Ctrl+V outside a
text field, and a small card asks how to open it (put it up, add the
folder to Files, or fill Link).
- **Jellyfin**: your linked servers (see below). Library chips beside the
- **Jellyfin**: your linked servers (see below); with none linked it
points to Files and Link and offers Jellyfin as optional. Library chips beside the
switch, then one **Continue** row (what you were watching and what comes
next, one card per series, the one you watched most recently first;
series not touched for two months drop out) and **Latest in** each
@@ -103,15 +140,17 @@ folder to Files, or fill Link).
details open on the right with "Put it up · S1E3", mark watched /
unwatched, remove from Continue and the episode list (click an episode
to choose it). Double-click a card or an episode to put it up at once.
- **Link**: paste an http(s) link. The preview shows what it is (title and
thumbnail for YouTube, else the file name); in a server, pick how the
- **Link**: paste an http(s) link: a video file, or a web page with a
video (YouTube, X, and whatever else yt-dlp knows). The preview shows
what it is (a page's title, uploader, length and thumbnail, else the
file name); in a server, pick how the
others get it (everyone fetches it, the server fetches it, or through
you). Your last 12 links are listed under it.
- **Files**: video folders you add on this computer, grouped by series
(click a series to list its episodes), or any file through Browse. The others stream it from you.
Put it up and the player opens. Offline the library browses but nothing
goes up until you join or host ("Join or host to watch together").
Put it up and the player opens. Not in a server, the buttons say Play
and it plays for you alone ([Watch solo](#getting-started)).
## Dedicated server
@@ -130,10 +169,15 @@ then everyone (you included) uses Join.
| `--name` | `server` | name reported by `/health` |
| `--media` | none | local file to serve as the starting video |
| `--advertise-host` | none | host put in file URLs instead of the one each viewer dialled |
| `--config` | none | YAML or JSON file with the same settings (`port`, `secret`, `name`, `mediaPath`, `advertiseHost`); replaces the flags |
| `--[no-]yt-dlp` | on | read web pages (YouTube, X, …) with yt-dlp when the server fetches a link |
| `--yt-dlp-dir` | next to the binary | writable folder for the server's yt-dlp (systemd: the unit's `StateDirectory`) |
| `--yt-dlp-cookies` | none | cookies.txt or cookie-extension JSON export for that yt-dlp (sites that refuse guests); copied into the yt-dlp folder at start, removed from it when not given. **An account login**: keep it private, use a throwaway account for YouTube |
| `--config` | none | YAML or JSON file with the same settings (`port`, `secret`, `name`, `mediaPath`, `advertiseHost`, `ytDlp`, `ytDlpDir`, `ytDlpCookies`); replaces the flags |
| `--upgrade` | | update the install folder to the latest release ([Install and update](#install-and-update)), then exit |
| `--check-update` | | print whether a newer release exists, then exit |
It prints `READY port=... name=...` once listening and stops cleanly on
Ctrl+C or SIGTERM. A dedicated server has no host: anyone can kick anyone.
Ctrl+C or SIGTERM. A dedicated server has no host.
To run it as a service, unpack the tarball to `/opt/watch-together` and use
[deploy/watch-together.service](deploy/watch-together.service) (copy it to
`/etc/systemd/system/`, edit the flags, `systemctl enable --now watch-together`).
@@ -155,7 +199,7 @@ password, recent servers with remove); Player (below); Jellyfin &
integrations (linked Jellyfin servers, below, the AnimeFlow connection:
"connected as <name>" with what you may do, or why not, and playing the
next episode automatically); Notifications & sounds (what pops up; the sound
volume, and every sound with a preview and its own switch); Updates; Keyboard shortcuts (read-only); About
volume and sound set, and every sound with a preview, its own set and a switch); Updates; Keyboard shortcuts (read-only); About
(the version).
Player settings live in the player: ⚙ at the right end of its controls
@@ -171,7 +215,8 @@ Anywhere in a server (unless you are typing): Space = ready / unready,
Tab = chat & activity (Tab never moves between buttons). 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 or fullscreen, else ask to leave, Ctrl+R = reload video,
close 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.
Every seek, skip and chapter jump moves everyone.
@@ -290,9 +335,24 @@ the others get it:
- Through me: your computer downloads it and passes it on, for links only
you can open.
The last two need a link to a video file (not a page like YouTube) and
never show the link to anyone. Your upload has to beat the video's
bitrate: a 25-minute 2 GB episode is about 11 Mbit/s.
Web pages work in all three: the app and the dedicated server each
download [yt-dlp](https://github.com/yt-dlp/yt-dlp) by themselves (checked
against the release's checksums, updated once a day; Settings → Player
shows its version and has "Update now"), so there is nothing to install.
Sites that refuse guests (Bilibili answers "HTTP Error 412", YouTube may
ask to sign in) need cookies: Settings → Player → yt-dlp → Cookies reads
them from a browser, or from a cookies.txt or a cookie extension's JSON
export (picked or dropped; every site in it is kept). They stay on this
computer and are used by its yt-dlp only (your own playback, "through
me", and a server you host). Cookies log yt-dlp in as you on those
sites: keep the file private, and for YouTube consider a throwaway
account (Google may flag accounts used with yt-dlp).
YouTube works in all three too, and so do pages and links that only
offer streaming playlists (HLS `.m3u8`, DASH `.mpd`, the HLS of X videos).
The last two never show the link to anyone; they can't pass on live
streams or DRM-protected video (use "Everyone fetches it" for live).
Your upload has to beat the video's bitrate: a 25-minute 2 GB episode is
about 11 Mbit/s.
## Video quality and shaders
+9
View File
@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256">
<defs><linearGradient id="bg" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#2d2547"/><stop offset="1" stop-color="#14111d"/></linearGradient><linearGradient id="t1" gradientUnits="userSpaceOnUse" x1="50.7" y1="148.7" x2="184.6" y2="71.4"><stop offset="0" stop-color="#f0795a" stop-opacity="0"/><stop offset="0.45" stop-color="#f0795a" stop-opacity="0.85"/><stop offset="1" stop-color="#ffc56b"/></linearGradient><linearGradient id="t2" gradientUnits="userSpaceOnUse" x1="205.3" y1="107.3" x2="71.4" y2="184.6"><stop offset="0" stop-color="#ffc56b" stop-opacity="0"/><stop offset="0.45" stop-color="#ffc56b" stop-opacity="0.85"/><stop offset="1" stop-color="#f0795a"/></linearGradient></defs>
<rect x="8" y="8" width="240" height="240" rx="56" fill="url(#bg)"/>
<circle cx="128" cy="128" r="80" fill="none" stroke="#ffffff" stroke-opacity="0.10" stroke-width="6"/>
<path d="M205.27 107.29 A80 80 0 0 1 71.43 184.57" fill="none" stroke="url(#t2)" stroke-width="7" stroke-linecap="butt"/><path d="M50.73 148.71 A80 80 0 0 1 184.57 71.43" fill="none" stroke="url(#t1)" stroke-width="7" stroke-linecap="butt"/>
<path d="M110 96 L110 160 L162 128 Z" fill="#fff1d6" stroke="#fff1d6" stroke-width="16" stroke-linejoin="round"/>
<path d="M71.4 161.6 Q74.7 181.3 94.4 184.6 Q74.7 187.8 71.4 207.6 Q68.2 187.8 48.4 184.6 Q68.2 181.3 71.4 161.6 Z" fill="#f0795a"/>
<path d="M184.6 40.4 Q188.9 67.1 215.6 71.4 Q188.9 75.8 184.6 102.4 Q180.2 75.8 153.6 71.4 Q180.2 67.1 184.6 40.4 Z" fill="#ffc56b"/>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 732 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

+8 -1
View File
@@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart';
@@ -7,10 +8,12 @@ import 'package:sync_relay_core/core.dart';
import 'package:window_manager/window_manager.dart';
import 'app.dart';
import 'src/playback/yt_dlp_app.dart';
import 'src/platform/desktop_entry.dart';
import 'src/screens/home/ui.dart';
import 'src/state/session_state.dart';
import 'src/state/settings.dart';
import 'src/update/updater.dart';
import 'src/update/update_controller.dart' show launchArgs;
Future<void> main(List<String> args) async {
launchArgs = List.unmodifiable(args);
@@ -60,6 +63,10 @@ Future<void> main(List<String> args) async {
debugPrint('load connect form failed: $e\n$st');
}
MediaKit.ensureInitialized();
// yt-dlp for web page links: downloaded on first run, kept current.
AppYtDlp.instance.warmUp();
// Linux: the launcher entry and icon Wayland needs (release installs).
unawaited(installDesktopEntry());
runApp(const WatchTogetherApp());
}
+3 -20
View File
@@ -10,9 +10,6 @@ import 'thumb.dart';
// The Files page's rows and cards: a series header, an episode row (also
// used for Recent), a loose-video card, and the empty states.
/// Shown on play controls while there is nobody to watch with.
const offlinePlayHint = 'Join or host to watch';
/// What a play control is doing right now.
enum PlayState { idle, starting, playing }
@@ -28,10 +25,6 @@ String parentName(String path) => baseName(File(path).parent.path);
/// Cleaned title of a loose video: `Title (2019)`.
String cleanTitle(ParsedName p) => p.year == null ? p.series : '${p.series} (${p.year})';
/// Disabled with [offlinePlayHint] when [onTap] is null because offline.
Widget _offlineTip(bool offline, Widget child) =>
offline ? Tooltip(message: offlinePlayHint, child: child) : child;
class SeriesHeader extends StatelessWidget {
final SeriesGroup group;
final ThumbCache thumbs;
@@ -110,9 +103,8 @@ class FileRow extends StatelessWidget {
final String? size;
final PlayState state;
final ThumbCache thumbs;
/// Null disables the row ([offline] adds the reason as a tooltip).
/// Null disables the row.
final VoidCallback? onTap;
final bool offline;
final bool indent;
final Widget? trailing;
const FileRow({
@@ -124,7 +116,6 @@ class FileRow extends StatelessWidget {
required this.state,
required this.thumbs,
required this.onTap,
required this.offline,
this.indent = true,
this.trailing,
});
@@ -148,9 +139,7 @@ class FileRow extends StatelessWidget {
builder: (context, t, _) {
final duration = durationText(t);
final meta = [?duration, ?size].join(' · ');
return _offlineTip(
offline,
Material(
return Material(
color: playing ? scheme.primary.withValues(alpha: 0.08) : Colors.transparent,
borderRadius: BorderRadius.circular(10),
child: InkWell(
@@ -206,7 +195,6 @@ class FileRow extends StatelessWidget {
),
),
),
),
);
},
);
@@ -223,14 +211,12 @@ class VideoCard extends StatelessWidget {
final PlayState state;
final ThumbCache thumbs;
final VoidCallback? onTap;
final bool offline;
const VideoCard({
super.key,
required this.video,
required this.state,
required this.thumbs,
required this.onTap,
required this.offline,
});
@override
@@ -242,9 +228,7 @@ class VideoCard extends StatelessWidget {
path: video.path,
builder: (context, t, _) {
final duration = durationText(t);
return _offlineTip(
offline,
InkWell(
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: Column(
@@ -301,7 +285,6 @@ class VideoCard extends StatelessWidget {
style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12)),
],
),
),
);
},
);
+7 -15
View File
@@ -48,8 +48,8 @@ void resetFilesPageSession() {
}
class FilesPage extends StatefulWidget {
/// In a session: false disables playing (browsing still works).
final bool online;
/// Nobody else watches (offline or solo): no relay notice.
final bool solo;
/// The file will stream from this computer through the server.
final bool relayed;
/// Plays [path] in the session; returns an error text, or null.
@@ -67,7 +67,7 @@ class FilesPage extends StatefulWidget {
const FilesPage({
super.key,
required this.online,
this.solo = false,
required this.relayed,
required this.onPlay,
this.nowPlayingPath,
@@ -207,7 +207,7 @@ class _FilesPageState extends State<FilesPage> {
}
Future<void> _play(String path) async {
if (!widget.online || _starting != null) return;
if (_starting != null) return;
setState(() {
_starting = path;
_error = null;
@@ -236,7 +236,7 @@ class _FilesPageState extends State<FilesPage> {
? PlayState.playing
: PlayState.idle;
VoidCallback? _tapOf(String path) => widget.online ? () => _play(path) : null;
VoidCallback _tapOf(String path) => () => _play(path);
@override
Widget build(BuildContext context) => LayoutBuilder(
@@ -264,9 +264,7 @@ class _FilesPageState extends State<FilesPage> {
},
);
Widget _browseButton(Widget Function(VoidCallback? onPressed) build) => widget.online
? build(_browse)
: Tooltip(message: offlinePlayHint, child: build(null));
Widget _browseButton(Widget Function(VoidCallback? onPressed) build) => build(_browse);
Widget _sidebar(BuildContext context, List<String> folders, String? open) {
final scheme = Theme.of(context).colorScheme;
@@ -345,7 +343,7 @@ class _FilesPageState extends State<FilesPage> {
final top = <Widget>[
_header(context, open),
if (narrow) _chips(folders, open),
if (widget.online && widget.relayed) const _RelayNotice(),
if (!widget.solo && widget.relayed) const _RelayNotice(),
AnimatedSize(
duration: easeTime,
curve: ease,
@@ -476,7 +474,6 @@ class _FilesPageState extends State<FilesPage> {
state: _stateOf(path),
thumbs: _thumbs,
onTap: _tapOf(path),
offline: !widget.online,
onRemove: () => _store.removeRecent(path),
);
},
@@ -594,7 +591,6 @@ class _FilesPageState extends State<FilesPage> {
state: _stateOf(v.path),
thumbs: _thumbs,
onTap: _tapOf(v.path),
offline: !widget.online,
);
},
),
@@ -630,7 +626,6 @@ class _FilesPageState extends State<FilesPage> {
state: _stateOf(v.path),
thumbs: _thumbs,
onTap: _tapOf(v.path),
offline: !widget.online,
);
},
);
@@ -647,7 +642,6 @@ class _RecentRow extends StatefulWidget {
final PlayState state;
final ThumbCache thumbs;
final VoidCallback? onTap;
final bool offline;
final VoidCallback onRemove;
const _RecentRow({
super.key,
@@ -655,7 +649,6 @@ class _RecentRow extends StatefulWidget {
required this.state,
required this.thumbs,
required this.onTap,
required this.offline,
required this.onRemove,
});
@@ -688,7 +681,6 @@ class _RecentRowState extends State<_RecentRow> {
state: widget.state,
thumbs: widget.thumbs,
onTap: missing ? null : widget.onTap,
offline: widget.offline && !missing,
indent: false,
trailing: IconButton(
tooltip: 'Remove from recent',
+2 -3
View File
@@ -8,8 +8,6 @@ import 'package:hive_ce/hive.dart';
import 'package:sync_relay_core/core.dart';
import '../screens/home/picked_media.dart';
import '../screens/home/ui.dart' show appName;
import '../update/updater.dart' show appVersion;
// Linked Jellyfin servers, one account per ServerId, in their own Hive box.
//
@@ -215,7 +213,8 @@ class JellyfinAccounts extends ChangeNotifier {
String get deviceId => _deviceId;
JellyfinClientInfo get clientInfo => JellyfinClientInfo(
client: appName,
// The old name on purpose: Jellyfin lists devices under it.
client: 'Watch Together',
device: deviceName ?? _hostname(),
deviceId: _deviceId.isEmpty ? 'unknown' : _deviceId,
version: appVersion,
@@ -32,16 +32,16 @@ typedef _Data = ({
});
/// The selected card's details, at the right of the library: banner,
/// title, meta, "Put it up · S1E3" (a disabled Play with [offlineHint]
/// offline), mark watched / unwatched, remove from Continue, overview and,
/// title, meta, "Put it up · S1E3" ("Play · S1E3" when watching
/// [solo]), mark watched / unwatched, remove from Continue, overview and,
/// for a series or an episode, the episode list (click picks which
/// episode goes up, double-click puts it up). Reloads when the
/// [controller]'s data version moves.
class JellyfinDetailsPanel extends StatefulWidget {
final BrowseController controller;
final JellyfinSelection selection;
/// Null when online; else why nothing can go up.
final String? offlineHint;
/// Nobody else watches (offline or solo): the button says Play.
final bool solo;
/// Puts [item] (playable) up; returns an error text, or null when sent.
final Future<String?> Function(JellyfinSelection item) onPutUp;
final VoidCallback onClose;
@@ -51,7 +51,7 @@ class JellyfinDetailsPanel extends StatefulWidget {
required this.selection,
required this.onPutUp,
required this.onClose,
this.offlineHint,
this.solo = false,
});
@override
@@ -104,7 +104,7 @@ class _JellyfinDetailsPanelState extends State<JellyfinDetailsPanel> {
_cached('episodes:$seriesId:$seasonId', (c) => c.episodes(seriesId, seasonId: seasonId));
Future<void> _putUp(JellyfinItem target) async {
if (_busy || widget.offlineHint != null) return;
if (_busy) return;
setState(() {
_busy = true;
_error = null;
@@ -253,14 +253,8 @@ class _JellyfinDetailsPanelState extends State<JellyfinDetailsPanel> {
if (shown.communityRating != null) '★ ${shown.communityRating!.toStringAsFixed(1)}',
if (!isSeries && shown.userData.played) 'Watched',
].join(' · ');
final hint = widget.offlineHint;
final label = playable == null
? (hint == null ? 'Put it up' : 'Play')
: hint != null
? 'Play'
: isSeries
? 'Put it up · ${playable.episodeLabel ?? playable.name}'
: 'Put it up';
final verb = widget.solo ? 'Play' : 'Put it up';
final label = playable != null && isSeries ? '$verb · ${playable.episodeLabel ?? playable.name}' : verb;
final inContinue = playable != null &&
(playable.userData.isResumable || _c.continueKeys.contains(continueKey(playable)));
final left = playable == null ? null : timeLeft(playable);
@@ -307,7 +301,7 @@ class _JellyfinDetailsPanelState extends State<JellyfinDetailsPanel> {
children: [
FilledButton.icon(
key: const ValueKey('details-put-up'),
onPressed: playable == null || hint != null || _busy ? null : () => _putUp(playable),
onPressed: playable == null || _busy ? null : () => _putUp(playable),
icon: _busy
? const SizedBox.square(dimension: 16, child: CircularProgressIndicator(strokeWidth: 2))
: const Icon(Icons.play_arrow),
@@ -336,12 +330,12 @@ class _JellyfinDetailsPanelState extends State<JellyfinDetailsPanel> {
),
],
),
if (hint != null || _error != null) ...[
if (_error != null) ...[
const SizedBox(height: 6),
Text(
_error ?? hint!,
_error!,
key: const ValueKey('details-hint'),
style: t.bodySmall?.copyWith(color: _error != null ? scheme.error : Colors.grey),
style: t.bodySmall?.copyWith(color: scheme.error),
),
],
if (isSeries && playable != null) ...[
@@ -7,7 +7,7 @@ import 'browser.dart';
import 'sign_in_panel.dart';
/// The Library's Jellyfin source: the library browser once a server is
/// linked, else an inline add-server panel.
/// linked, else [empty] (default: an inline add-server panel).
class JellyfinPickerSource extends StatefulWidget {
final JellyfinAccounts accounts;
final BrowseController? controller;
@@ -17,6 +17,8 @@ class JellyfinPickerSource extends StatefulWidget {
final VoidCallback? onOpenIntegrations;
final BrowseStart? start;
final bool libraryStrip;
/// Shown while no server is linked.
final WidgetBuilder? empty;
const JellyfinPickerSource({
super.key,
this.start,
@@ -27,6 +29,7 @@ class JellyfinPickerSource extends StatefulWidget {
this.selected,
this.onOpenIntegrations,
this.libraryStrip = true,
this.empty,
});
@override
@@ -49,6 +52,7 @@ class _JellyfinPickerSourceState extends State<JellyfinPickerSource> {
if (!a.loaded) {
return const Center(child: CircularProgressIndicator(strokeWidth: 2));
}
if (a.isEmpty && widget.empty != null) return widget.empty!(context);
if (a.isEmpty) return _LinkFirst(accounts: a, onOpenIntegrations: widget.onOpenIntegrations);
return JellyfinBrowser(
key: const ValueKey('media-jellyfin'),
@@ -332,13 +332,13 @@ Duration nextPromptStart(JellyfinItem? item, Duration runtime, {Duration? ending
/// Who performs the auto-advance swap, so several counting clients do not
/// swap twice: the lowest member id among members that play the item with
/// their own account (`jf[serverId] == true`, not stale). Only what every
/// their own account (`jf[serverId] == true`, not stale, not out). Only what every
/// client sees in `state` decides (who put the item up is not in it: a
/// late joiner never saw that swap), so all clients agree.
String? autoAdvanceSwapper(List<MemberView> members, String serverId) {
final eligible = [
for (final m in members)
if (!m.stale && m.jf[serverId] == true) m
if (!m.stale && !m.out && m.jf[serverId] == true) m
];
if (eligible.isEmpty) return null;
eligible.sort((a, b) => compareMemberIds(a.id, b.id));
@@ -0,0 +1,19 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
/// The runner's window channel (linux/runner/my_application.cc,
/// windows/runner/flutter_window.cpp).
const _window = MethodChannel('starsync/window');
/// Asks the desktop to point at the app's window (taskbar flash, urgency
/// hint) when it is not focused; nothing when it is. The runner clears it
/// once the window gets focus. Quietly nothing where the runner lacks it.
Future<void> requestWindowAttention() async {
try {
await _window.invokeMethod<void>('attention');
} on MissingPluginException {
// Tests, other platforms.
} catch (e) {
debugPrint('window attention: $e');
}
}
@@ -0,0 +1,53 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:sync_relay_core/core.dart' show findInstallRoot;
/// 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 = 'moe.senkochan.watch_together';
const desktopFileName = '$appId.desktop';
/// Linux release installs (an unpacked tarball) carry no launcher entry, so
/// 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). Never in debug runs; failures only log.
Future<void> installDesktopEntry({String? executable, String? dataHome, String? home}) async {
if (!Platform.isLinux || kDebugMode) return;
try {
final exe = executable ?? Platform.resolvedExecutable;
final root = findInstallRoot(exe, windows: false);
if (root == null) return;
final share = dataHome ?? Platform.environment['XDG_DATA_HOME'] ??
'${home ?? Platform.environment['HOME']}/.local/share';
final icons = '${root.path}/data/flutter_assets/assets/icon';
for (final size in const [16, 32, 48, 64, 128, 256, 512]) {
final src = File('$icons/icon_$size.png');
if (!src.existsSync()) continue;
final dst = File('$share/icons/hicolor/${size}x$size/apps/$appId.png');
if (dst.existsSync() && dst.lengthSync() == src.lengthSync()) continue;
await dst.parent.create(recursive: true);
await src.copy(dst.path);
}
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);
} catch (e) {
debugPrint('desktop entry: $e');
}
}
@visibleForTesting
String desktopEntryText(String exe) => '''[Desktop Entry]
Type=Application
Name=Starsync
Comment=Watch videos in sync with friends
Exec="$exe"
Icon=$appId
StartupWMClass=$appId
Categories=AudioVideo;Video;Player;Network;
Terminal=false
''';
@@ -16,6 +16,7 @@ import '../sync/state_diff.dart';
import '../sync/user_seek.dart';
import 'frame_watchdog.dart';
import 'media_marks.dart';
import 'yt_dlp_app.dart' show AppYtDlp, ytdlFormat;
/// Owns one mpv [Player] (and its [VideoController]) per session: created
/// by [init] on join, released by [dispose] on leave, swapped by
@@ -60,6 +61,36 @@ class PlaybackController {
/// Called once when the watchdog sees a frozen picture.
void Function()? onFrozen;
/// mpv's yt-dlp hook could not open the current link (its error text).
void Function(String error)? onYtdlError;
/// Points mpv's yt-dlp hook at [path] (the app's managed copy) for web
/// page links, at most 1080p, once per player; its cookies
/// (`ytdl-raw-options`) whenever they changed.
Future<void> useYtDlp(String path) async {
final p = _player;
if (p == null) return;
try {
final dynamic platform = p.platform;
if (!identical(p, _ytdlSetOn)) {
await platform?.setProperty('script-opts', 'ytdl_hook-ytdl_path=$path');
await platform?.setProperty('ytdl-format', ytdlFormat);
_ytdlSetOn = p;
_ytdlRaw = '';
}
final raw = await AppYtDlp.instance.mpvRawOptions();
if (raw != _ytdlRaw && identical(p, _player)) {
await platform?.setProperty('ytdl-raw-options', raw);
_ytdlRaw = raw;
}
} catch (e) {
debugPrint('yt-dlp path not set: $e');
}
}
Player? _ytdlSetOn;
String _ytdlRaw = '';
/// Fingerprint of the picture on screen, installed by the video view
/// (`ShownFrameProbe.sample`). Without it the watchdog stays idle.
Future<int?> Function()? shownFrame;
@@ -298,6 +329,11 @@ class PlaybackController {
// an error there while shaders are on is a shader that failed to load
// or compile (mpv then renders without it).
void _onLog(PlayerLog log) {
if (log.prefix == 'ytdl_hook' && (log.level == 'error' || log.level == 'fatal')) {
final text = log.text.trim().replaceFirst(RegExp(r'^ERROR:\s*'), '');
if (text.isNotEmpty) onYtdlError?.call(text);
return;
}
if (log.prefix != 'libmpv_render') return;
if (log.text.contains('suspected software renderer')) {
_setRenderMode(RenderMode.softwareGl);
@@ -0,0 +1,57 @@
import 'dart:async';
import 'dart:io';
import 'package:path_provider/path_provider.dart';
import 'package:sync_relay_core/core.dart';
/// mpv's `ytdl-format` for links every player opens itself.
const ytdlFormat = 'bv*[height<=1080]+ba/b';
/// The app's yt-dlp (see [YtDlp]): in the app-support folder, shared with
/// the hosted server. [probe] remembers what pasted links are for a while,
/// so the Link preview and the swap ask once.
class AppYtDlp {
static final instance = AppYtDlp();
/// [ytDlp]: a given one (tests); default in the app-support folder.
AppYtDlp([YtDlp? ytDlp]) : _ytDlp = ytDlp == null ? null : Future.value(ytDlp);
Future<YtDlp>? _ytDlp;
Future<YtDlp> get ytDlp => _ytDlp ??= () async {
final base = await getApplicationSupportDirectory();
return YtDlp(dir(base.path));
}();
static String dir(String appSupport) => '$appSupport${Platform.pathSeparator}yt-dlp';
/// Gets yt-dlp ready in the background (download on first run, a daily
/// update check afterwards).
void warmUp() => unawaited(ytDlp.then((y) => y.ensure()).catchError((_) => null));
/// mpv's `ytdl-raw-options` for the cookies in the yt-dlp folder ('' for
/// none; see [YtDlpCookies]).
Future<String> mpvRawOptions() async {
try {
return (await ytDlp).cookies.mpvRawOptions();
} catch (_) {
return '';
}
}
final _probes = <String, (DateTime, Future<LinkProbe>)>{};
static const _probeTtl = Duration(minutes: 10);
Future<LinkProbe> probe(String url) {
final now = DateTime.now();
_probes.removeWhere((_, v) => now.difference(v.$1) > _probeTtl);
final hit = _probes[url];
if (hit != null) return hit.$2;
final f = ytDlp.then((y) => probeLink(Uri.parse(url), ytDlp: y));
_probes[url] = (now, f);
f.catchError((Object _) {
_probes.remove(url);
return LinkProbe.file(Uri(), -1);
});
return f;
}
}
@@ -6,7 +6,6 @@ import 'dart:ui' show Color;
import 'package:path_provider/path_provider.dart';
import 'package:sync_relay_core/core.dart';
import '../update/updater.dart' show appVersion;
// AniList airing schedule (public GraphQL, no login): every episode airing
// in a time range, with covers, banners and popularity; and a title search
+29 -3
View File
@@ -5,7 +5,6 @@ import 'dart:io';
import 'package:sync_relay_core/core.dart';
import '../jellyfin/accounts.dart';
import '../update/updater.dart' show appVersion;
// AnimeFlow app API: what the media server tracks, has, downloads and was
// asked for. Authenticated with the user's own Jellyfin access token
@@ -148,13 +147,17 @@ class AnimeFlowLibraryItem {
final String name;
/// Set when AnimeFlow tracks it.
final int? seriesId;
/// AniList entry per TVDB season.
/// AniList entry per TVDB season (the first, when a season spans more).
final Map<int, int> anilistIds;
/// Every AniList entry per season with its episode run (`anilist_map`):
/// a season AniList splits (one cour as two entries) has several rows.
final List<AniListRun> anilistMap;
const AnimeFlowLibraryItem({
required this.jellyfinSeriesId,
required this.name,
this.seriesId,
this.anilistIds = const {},
this.anilistMap = const [],
});
static AnimeFlowLibraryItem fromJson(Map j) => AnimeFlowLibraryItem(
@@ -165,9 +168,30 @@ class AnimeFlowLibraryItem {
for (final e in ((j['anilist_ids'] as Map?) ?? const {}).entries)
if (int.tryParse('${e.key}') case final season? when _int(e.value) != null) season: _int(e.value)!,
},
anilistMap: [
for (final r in (j['anilist_map'] as List?) ?? const [])
if (r is Map && _int(r['season']) != null && _int(r['anilist_id']) != null)
AniListRun(
season: _int(r['season'])!,
anilistId: _int(r['anilist_id'])!,
firstEpisode: _int(r['first_episode']) ?? 1,
offset: _int(r['offset']) ?? 0,
),
],
);
}
/// One AniList entry's run within a TVDB season: TVDB episode e (from
/// [firstEpisode] to the next run of that season) is AniList episode
/// e - [offset].
class AniListRun {
final int season;
final int anilistId;
final int firstEpisode;
final int offset;
const AniListRun({required this.season, required this.anilistId, this.firstEpisode = 1, this.offset = 0});
}
class AnimeFlowEpisode {
/// 0 = specials.
final int season;
@@ -206,7 +230,9 @@ class AnimeFlowEpisode {
absolute: _int(j['absolute']),
title: _str(j['episode_title']),
airDate: _time(j['air_date']),
anilistId: _int(j['anilist_id']),
// The episode's own entry (a split season rolls into its sequel),
// else the series' one.
anilistId: _int(j['episode_anilist_id']) ?? _int(j['anilist_id']),
anilistEpisode: _int(j['anilist_episode']),
status: EpisodeStatus.parse(j['status']),
progress: (j['progress'] as num?)?.toDouble(),
@@ -102,6 +102,9 @@ List<ScheduleEntry> matchSchedule(
final lib = <int, (String, int)>{};
for (final item in library) {
if (item.jellyfinSeriesId.isEmpty) continue;
for (final r in item.anilistMap) {
lib[r.anilistId] ??= (item.jellyfinSeriesId, r.season);
}
for (final e in item.anilistIds.entries) {
lib[e.value] ??= (item.jellyfinSeriesId, e.key);
}
@@ -13,8 +13,8 @@ enum CardAction {
none,
putUp,
/// Available, but not in a server: shown disabled with a hint.
playOffline,
/// Available, nobody else watching (offline or solo): plays it here.
play,
request,
requestSeries,
requested,
@@ -162,15 +162,10 @@ class AiringCard extends StatelessWidget {
final spinner = const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2));
Widget? b = switch (action) {
CardAction.none => null,
CardAction.putUp => FilledButton.tonalIcon(
CardAction.putUp || CardAction.play => FilledButton.tonalIcon(
onPressed: enabled ? onAction : null,
icon: busy ? spinner : const Icon(Icons.play_arrow, size: 18),
label: const Text('Put it up'),
),
CardAction.playOffline => FilledButton.tonalIcon(
onPressed: null,
icon: const Icon(Icons.play_arrow, size: 18),
label: const Text('Play'),
label: Text(action == CardAction.play ? 'Play' : 'Put it up'),
),
CardAction.request || CardAction.requestSeries => OutlinedButton.icon(
onPressed: enabled ? onAction : null,
@@ -216,7 +211,7 @@ class AiringCard extends StatelessWidget {
),
),
);
final tap = action == CardAction.putUp && disabledHint == null && !busy ? onAction : null;
final tap = (action == CardAction.putUp || action == CardAction.play) && disabledHint == null && !busy ? onAction : null;
if (compact) {
final requestable = action == CardAction.request || action == CardAction.requestSeries;
return Material(
@@ -21,8 +21,9 @@ import 'schedule_card.dart';
enum ScheduleFilter { jellyfin, following, all }
class SchedulePage extends StatefulWidget {
/// In a server: available episodes can be put up.
final bool online;
/// Nobody else watches (offline or solo): available episodes say Play
/// (offline that starts a solo session).
final bool solo;
/// Puts a Jellyfin episode up in the server; returns an error to show.
final Future<String?> Function(JellyfinMedia pick) onPutUp;
@@ -36,7 +37,7 @@ class SchedulePage extends StatefulWidget {
const SchedulePage({
super.key,
required this.online,
this.solo = false,
required this.onPutUp,
required this.onToast,
this.repository,
@@ -215,9 +216,9 @@ class _SchedulePageState extends State<SchedulePage> {
(CardAction, String?) _actionFor(ScheduleEntry e) {
if (e.status == ScheduleStatus.available || e.status == ScheduleStatus.inLibrary) {
if (!widget.online) return (CardAction.playOffline, 'Join or host a server to put it up');
if (e.jellyfinSeriesId == null) return (CardAction.putUp, 'Not linked to a Jellyfin series yet');
return (CardAction.putUp, null);
final put = widget.solo ? CardAction.play : CardAction.putUp;
if (e.jellyfinSeriesId == null) return (put, 'Not linked to a Jellyfin series yet');
return (put, null);
}
final me = _me;
final CardAction kind;
@@ -242,6 +243,7 @@ class _SchedulePageState extends State<SchedulePage> {
Future<void> _onAction(ScheduleEntry e, CardAction action) async {
switch (action) {
case CardAction.putUp:
case CardAction.play:
return _putUp(e);
case CardAction.request:
case CardAction.requestSeries:
@@ -530,7 +532,7 @@ class _SchedulePageState extends State<SchedulePage> {
Widget _card(ScheduleEntry e, DateTime now) {
final (action, hint) = _actionFor(e);
final busy = _busy.contains(action == CardAction.putUp ? 'play:${_key(e)}' : _key(e));
final busy = _busy.contains(action == CardAction.putUp || action == CardAction.play ? 'play:${_key(e)}' : _key(e));
return AiringCard(
entry: e,
now: now,
@@ -2,9 +2,9 @@ import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:sync_relay_core/core.dart';
import '../../update/update_controller.dart';
import '../../update/updater.dart';
import 'settings_page.dart';
import 'ui.dart';
import 'update_indicator.dart';
@@ -23,10 +23,6 @@ class ConnectFlow extends ChangeNotifier {
int _attempt = 0;
bool _disposed = false;
/// Set when an attempt ends in a kick, so the shell shows the removal
/// dialog.
void Function(String reason)? onKicked;
@override
void dispose() {
_disposed = true;
@@ -101,6 +97,7 @@ class ConnectFlow extends ChangeNotifier {
_notify();
return;
}
final fromSolo = mode == ConnectMode.host && _session.solo.value;
try {
if (mode == ConnectMode.host) {
await _session.host(params, form);
@@ -110,20 +107,19 @@ class ConnectFlow extends ChangeNotifier {
} catch (e) {
if (myAttempt != _attempt) return;
busy = null;
final kick = _session.coordinator.kickReason.peek();
if (kick != null && kick.isNotEmpty) {
onKicked?.call(kick);
} else {
errors[mode] = CardError('Could not connect', '$e');
}
_notify();
return;
}
if (myAttempt != _attempt) {
// Cancelled after it got through: back out.
// Cancelled after it got through: back out (an Invite: back to solo).
try {
if (fromSolo) {
await _session.cancelInvite();
} else {
await _session.leave();
await _session.stopServer();
}
} catch (_) {}
return;
}
@@ -22,7 +22,7 @@ const sessionShortcuts = [
('< or Shift+P', 'Put up the previous Jellyfin episode'),
('Ctrl+1…6', 'Shaders: Anime4K mode A, B, C, A+A, B+B, C+A'),
('Ctrl+0', 'Shaders off'),
('Esc', 'Close the panel or fullscreen, else ask to leave'),
('Esc', 'Close the panel or fullscreen, shrink the player, then ask to leave'),
];
/// One titled card of the Settings tab (rounded Material, so list tiles
+1 -1
View File
@@ -1,7 +1,7 @@
import 'package:flutter/material.dart';
/// Product name (working title). Change it here only.
const appName = 'Watch Together';
const appName = 'Starsync';
const repoUrl = 'https://git.senkochan.moe/Foxyz/sync_replay_project';
+2 -1
View File
@@ -51,7 +51,8 @@ Future<ArtBytes> jellyfinArt(JellyfinClient client, String itemId,
Future<ArtBytes?> aniListArt(AniListClient aniList, String name,
{Future<Uint8List?> Function(Uri url, Map<String, String> headers, int maxBytes) fetch = fetchImage}) async {
final series = parseReleaseName(name).series.trim();
if (series.isEmpty) return null;
// "1", "2026-10-09": nothing a show could be called.
if (!RegExp(r'\p{L}', unicode: true).hasMatch(series)) return null;
final match = await aniList.searchMedia(series);
if (match == null) return null;
Future<Uint8List?> get(String? url, int maxBytes) async {
@@ -1,6 +1,7 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:math';
import 'package:flutter/widgets.dart';
import 'package:signals/signals_flutter.dart';
@@ -12,6 +13,8 @@ import '../jellyfin/session_media.dart';
import '../jellyfin/track_prefs.dart';
import '../playback/media_marks.dart';
import '../playback/playback_controller.dart';
import '../platform/attention.dart';
import '../playback/yt_dlp_app.dart';
import '../schedule/anilist.dart';
import '../screens/home/picked_media.dart';
import '../sfx/sfx.dart';
@@ -100,7 +103,7 @@ class ToastItem {
const ToastItem({required this.id, required this.text, required this.at, this.actor});
}
const _toastActions = {'join', 'leave', 'kick', 'swap', 'chat'};
const _toastActions = {'join', 'leave', 'swap', 'chat'};
/// "Unready when the video changes": a state with a newer [mediaId] than
/// the last one this session saw ([lastMediaId], -1 before the first) while
@@ -132,7 +135,10 @@ final class _FileOrigin extends _RelayOrigin {
final class _LinkOrigin extends _RelayOrigin {
final String url;
const _LinkOrigin(this.url);
/// What the link is (a page goes through [ytDlp]).
final LinkProbe probe;
final YtDlp ytDlp;
const _LinkOrigin(this.url, this.probe, this.ytDlp);
}
/// A Jellyfin item: served with this install's account, else with the
@@ -159,6 +165,8 @@ class SessionCoordinator {
/// Set by the hosting app: makes a local file the hosted one (File swap);
/// null when this app is not the host.
Future<({String name, int size})?> Function(String path)? hostFile;
/// The app's yt-dlp and link probes (tests pass their own).
final AppYtDlp ytDlp;
final SyncClient sync = SyncClient();
/// Member pictures by `avatarHash`, fetched from the server.
late final AvatarCache avatars = AvatarCache(sync);
@@ -173,7 +181,6 @@ class SessionCoordinator {
final feedLog = signal<List<FeedMessage>>([]);
final toasts = signal<List<ToastItem>>([]);
final reconnecting = signal(false);
final kickReason = signal<String?>(null);
final starving = signal<String?>(null);
/// The current media's Jellyfin ref (null for links and files).
@@ -219,14 +226,23 @@ class SessionCoordinator {
return null;
});
/// Self sits out: the player has nothing open until [joinBack].
late final Computed<bool> selfOut = computed(() => selfMember.value?.out ?? false);
/// Asks the desktop to point at the window (a nudge); tests swap it.
Future<void> Function() requestAttention = requestWindowAttention;
Timer? _ticker;
StreamSubscription? _stateSub;
StreamSubscription? _feedSub;
StreamSubscription? _kickSub;
StreamSubscription? _nudgeSub;
StreamSubscription? _errSub;
StreamSubscription? _statusSub;
bool _manualClose = false;
/// A solo session (set by SessionState): no idle unready / sit out,
/// there is nobody to hold up.
bool solo = false;
bool _playerReady = false;
bool _wasPlayAllowed = false;
// Last ready flag the server reported for self; restored after a
@@ -235,6 +251,13 @@ class SessionCoordinator {
// Set after a reconnect until the server echoes the restored ready, so
// the fresh member's ready=false doesn't overwrite [_lastReady].
bool _restoringReady = false;
// Last out flag the server reported for self; a reconnect that lands
// as a new member sits out again.
bool _lastOut = false;
// [joinBack] ran while playing: ready once the player has buffered
// (or after [_joinBackWait]), so the others do not pause for the load.
DateTime? _joinBackAt;
static const _joinBackWait = Duration(seconds: 15);
// Last mediaId seen in a state; unlike [_mediaId] it survives a
// reconnect, so only a real swap counts as a new video.
int _seenMediaId = -1;
@@ -257,6 +280,10 @@ class SessionCoordinator {
int _relaySourceMediaId = -1;
/// This member serves the current media's bytes through the server.
final relaying = signal<bool>(false);
/// What this client put up last in this connection (a solo session
/// carries it over when it becomes a hosted server).
PickedMedia? get lastPick => _lastPick;
PickedMedia? _lastPick;
// Server seek applied to the player (event id and when), checked once it
// should have landed.
int? _pendingSeekEvent;
@@ -293,8 +320,9 @@ class SessionCoordinator {
bool _jellyfinListening = false;
SessionCoordinator(
{this.sfx = const NoopSfxService(), JellyfinAccounts? jellyfin, TrackPrefs? trackPrefs})
: jellyfin = jellyfin ?? JellyfinAccounts.instance,
{this.sfx = const NoopSfxService(), JellyfinAccounts? jellyfin, TrackPrefs? trackPrefs, AppYtDlp? ytDlp})
: ytDlp = ytDlp ?? AppYtDlp.instance,
jellyfin = jellyfin ?? JellyfinAccounts.instance,
trackPrefs = trackPrefs ?? TrackPrefs();
/// Player lifetime = one session: created here on connect, disposed by
@@ -304,6 +332,7 @@ class SessionCoordinator {
if (_playerReady) return;
try {
playback.onFrozen = () => unawaited(reloadVideo(auto: true));
playback.onYtdlError = (e) => health.value = HealthStatus(HealthSeverity.error, "can't open this link: $e");
playback.onUserSeek = (d) => _userSeek(d.inMicroseconds / 1e6);
await playback.init();
_playerReady = true;
@@ -363,7 +392,6 @@ class SessionCoordinator {
if (epoch != _epoch) throw const ConnectCancelled();
_params = params;
_manualClose = false;
kickReason.value = null;
_wsHost = params.host;
_wsPort = params.port;
diff.reset();
@@ -371,6 +399,8 @@ class SessionCoordinator {
_openUrl = null;
_lastReady = false;
_restoringReady = false;
_lastOut = false;
_joinBackAt = null;
_seenMediaId = -1;
_wasPlayAllowed = false;
_everConnected = false;
@@ -379,6 +409,7 @@ class SessionCoordinator {
_seekGate.reset();
_clearPendingSeek();
_lastEventId = -1;
_lastPick = null;
_resetJellyfin();
_jfToastShown = false;
if (!_jellyfinListening) {
@@ -434,12 +465,12 @@ class SessionCoordinator {
void _subscribe() {
_stateSub ??= sync.states.listen(_onState);
_feedSub ??= sync.feeds.listen(_onFeed);
_kickSub ??= sync.kicked.listen(_onKicked);
_nudgeSub ??= sync.nudges.listen(_onNudge);
_errSub ??= sync.errors.listen((msg) {
health.value = HealthStatus(HealthSeverity.warn, msg);
});
_statusSub ??= sync.status.listen((s) {
if (s == SyncStatus.closed && !_manualClose && kickReason.peek() == null) {
if (s == SyncStatus.closed && !_manualClose) {
health.value = const HealthStatus(
HealthSeverity.warn, 'connection lost — retrying…');
_soundLost();
@@ -456,6 +487,7 @@ class SessionCoordinator {
diff.reset();
_opening = true;
try {
await _prepareYtDlp(url);
await playback.openUrl(url, headers: headers);
health.value =
const HealthStatus(HealthSeverity.ok, 'connected');
@@ -470,8 +502,30 @@ class SessionCoordinator {
}
}
/// A web link (not this server's): mpv's yt-dlp hook gets the app's
/// managed yt-dlp, downloaded first if needed; a media file name does
/// not wait for that.
Future<void> _prepareYtDlp(String url) async {
final uri = Uri.tryParse(url);
if (uri == null || !(uri.isScheme('http') || uri.isScheme('https'))) return;
if (uri.host == _wsHost && uri.port == _wsPort) return;
try {
final y = await ytDlp.ytDlp;
final ready = y.ensure();
final path = hasMediaExtension(uri)
? y.path
: await ready.timeout(const Duration(seconds: 90), onTimeout: () => null);
if (path != null) await playback.useYtDlp(path);
} catch (e) {
debugPrint('yt-dlp not ready: $e');
}
}
bool _relayWaitShown = false;
/// The server-fetched media this viewer opens once it is live.
int _awaitLiveFor = -1;
/// Relayed through a member whose channel is down: say who everyone is
/// waiting for (the buffer still plays what it has).
void _relayStatus(StateBroadcast r) {
@@ -493,21 +547,24 @@ class SessionCoordinator {
}
}
String _relayMediaUrlFor(String name, int mediaId) =>
'http://$_wsHost:$_wsPort/r/default/${sync.token}/$mediaId/${Uri.encodeComponent(name)}';
/// This viewer's `/r/` URL; with tracks, a pieces track's playlist, or
/// an mpv EDL of every track when there are several (a page's separate
/// video and audio).
String _relayMediaUrlFor(String name, int mediaId, [List<RelayTrackSpec> tracks = const []]) =>
relayViewerUrl('http://$_wsHost:$_wsPort/r/default/${sync.token}/$mediaId', name, tracks: tracks);
/// Serves [bytes] for media [mediaId] on this member's relay channel.
/// Serves [media] for media [mediaId] on this member's relay channel.
/// The server only pulls while someone watches through it.
void _startRelaySource(int mediaId, RelayBytes bytes, {bool announce = false}) {
void _startRelaySource(int mediaId, RelayMedia media, {bool announce = false}) {
// Back after a reconnect on the same media: it is still serving (and
// redials by itself).
if (_relaySource != null && _relaySourceMediaId == mediaId) {
unawaited(bytes.close());
unawaited(media.close());
return;
}
_stopRelaySource();
final src = RelaySource(
Uri.parse('ws://$_wsHost:$_wsPort/r/default/${sync.token}/source'), bytes);
Uri.parse('ws://$_wsHost:$_wsPort/r/default/${sync.token}/source'), media);
_relaySource = src;
_relaySourceMediaId = mediaId;
relaying.value = true;
@@ -579,6 +636,12 @@ class SessionCoordinator {
Future<void> _applyState(StateBroadcast r) async {
_trackSelfReady(r);
if (_lastOut) {
// A newer media than the one this member serves: that relay ended.
if (_relaySourceMediaId != -1 && _relaySourceMediaId != r.mediaId) _stopRelaySource();
await _unloadForOut();
return;
}
_seekGate.observe(r, sync.memberId);
final newEvent = r.eventId != _lastEventId;
_lastEventId = r.eventId;
@@ -614,12 +677,22 @@ class SessionCoordinator {
memberRelayUrl: relay != null && !mine ? _relayMediaUrlFor(name, r.mediaId) : null);
} else if (origin is _FileOrigin) {
await _openMedia(origin.path);
_startRelaySource(r.mediaId, FileRelayBytes(File(origin.path)), announce: true);
_startRelaySource(r.mediaId, RelayMedia.single(FileRelayBytes(File(origin.path))), announce: true);
} else if (origin is _LinkOrigin) {
await _openMedia(origin.url);
_startRelaySource(r.mediaId, HttpRelayBytes(Uri.parse(origin.url)), announce: true);
_startRelaySource(
r.mediaId, LinkRelayMedia(Uri.parse(origin.url), ytDlp: origin.ytDlp, probe: origin.probe),
announce: true);
} else if (relay != null && !mine) {
await _openMedia(_relayMediaUrlFor(name, r.mediaId));
if (relay.fetch && !relay.live) {
// The server is still getting it (yt-dlp takes seconds; mpv
// gives up on a silent request after 5): open once it is live.
_openUrl = null;
_awaitLiveFor = r.mediaId;
health.value = const HealthStatus(HealthSeverity.info, 'the server is getting the video…');
} else {
await _openMedia(_relayMediaUrlFor(name, r.mediaId, relay.tracks));
}
} else {
var url = stateMediaUrl(r);
if ((url == null || url.isEmpty) && sync.token.isNotEmpty) {
@@ -635,6 +708,10 @@ class SessionCoordinator {
}
_seekGate.reset();
_clearPendingSeek();
} else if (_awaitLiveFor == r.mediaId && (stateMediaRelay(r)?.live ?? false)) {
_awaitLiveFor = -1;
await _openMedia(
_relayMediaUrlFor((r.media?['name'] as String?) ?? '', r.mediaId, stateMediaRelay(r)!.tracks));
}
_jellyfinTransport(r);
_relayStatus(r);
@@ -692,6 +769,7 @@ class SessionCoordinator {
} else {
_lastReady = self.ready;
}
_lastOut = self.out;
final last = _seenMediaId;
_seenMediaId = r.mediaId;
// A ready restored by a reconnect may not have echoed yet: unready
@@ -707,6 +785,27 @@ class SessionCoordinator {
}
}
/// Sitting out: nothing open, nothing pulled. A relay this member
/// serves goes on (it does not use the player). The next state after
/// [joinBack] opens the media as new.
Future<void> _unloadForOut() async {
_wasPlayAllowed = false;
_awaitLiveFor = -1;
starving.value = null;
if (_mediaId == -1 && _openUrl == null) return;
_mediaId = -1;
_openUrl = null;
_openHeaders = null;
_seekGate.reset();
_clearPendingSeek();
diff.reset();
_leaveJellyfinItem();
if (!_playerReady) return;
try {
await playback.stop();
} catch (_) {}
}
void _updateStarving(StateBroadcast r, double bufferedAhead) {
if (r.transport == 'playing') {
starving.value = null;
@@ -725,14 +824,15 @@ class SessionCoordinator {
}
final self = selfMember.peek();
if (self != null && self.ready) {
final waiters = r.members
final present = r.members.where((m) => !m.out).toList();
final waiters = present
.where((m) => m.id != self.id && !m.stale && !(m.inSet && m.ready))
.map((m) => m.name)
.toList();
if (waiters.isNotEmpty) {
final readyCount = r.members.where((m) => m.ready).length;
final readyCount = present.where((m) => m.ready).length;
starving.value =
'waiting for ${waiters.join(', ')} ($readyCount/${r.members.length} ready)';
'waiting for ${waiters.join(', ')} ($readyCount/${present.length} ready)';
return;
}
}
@@ -761,9 +861,15 @@ class SessionCoordinator {
ToastItem(id: _toastId, text: text, at: DateTime.now().toUtc(), actor: f.actor)
];
}
final selfName = selfMember.peek()?.name;
// Sat out by someone else: say who (the feed names members by name).
if (f.action == 'out' && f.target != null && f.target == selfName && f.actor != selfName) {
_pushToast('${f.actor} sat you out', actor: f.actor);
unawaited(sfx.play(const SfxCue(SfxKind.kick)));
}
final kind = sfxForFeedAction(f.action);
// Your own message needs no sound.
final own = f.action == 'chat' && f.actor == selfMember.peek()?.name;
final own = f.action == 'chat' && f.actor == selfName;
if (kind != null && !own) {
var readyIndex = 0;
if (f.action == 'ready') {
@@ -779,8 +885,6 @@ class SessionCoordinator {
return '${f.actor} joined';
case 'leave':
return '${f.actor} left';
case 'kick':
return '${f.actor} kicked ${f.target ?? ''}';
case 'swap':
return '${f.actor} changed media';
default:
@@ -788,26 +892,13 @@ class SessionCoordinator {
}
}
Future<void> _onKicked(String reason) async {
kickReason.value = reason;
_manualClose = true;
_epoch++;
_reconnectTimer?.cancel();
_reconnectTimer = null;
reconnecting.value = false;
_ticker?.cancel();
_ticker = null;
// Stop, not pause: a paused player keeps filling its cache from the
// host's media URL.
_openUrl = null;
await _stopJellyfin();
try {
await playback.stop();
} catch (_) {}
await sync.disconnect();
health.value = HealthStatus(
HealthSeverity.error,
reason.isEmpty ? 'kicked from server' : 'kicked: $reason');
/// Someone poked this member: sound (even when its switch is off; the
/// volume still applies), a toast, and the window asks for attention.
void _onNudge(NudgeNotice n) {
final name = n.name.isEmpty ? 'Someone' : n.name;
_pushToast('$name nudged you', actor: n.name.isEmpty ? null : n.name);
unawaited(sfx.play(const SfxCue(SfxKind.nudge)));
unawaited(requestAttention());
}
void _startTicker() {
@@ -823,20 +914,22 @@ class SessionCoordinator {
if (sync.token.isNotEmpty) sync.sendPing();
if (sync.token.isNotEmpty && r != null && _playerReady) {
// Duration only once the open media is the server's current one.
final known = !_opening && _openUrl != null && _mediaId == r.mediaId;
// Sitting out: still a report (it keeps this member fresh), empty.
final known = !_lastOut && !_opening && _openUrl != null && _mediaId == r.mediaId;
sync.sendReport(
position: playback.positionSecs,
buffered: playback.bufferedSecsAhead,
position: _lastOut ? 0 : playback.positionSecs,
buffered: _lastOut ? 0 : playback.bufferedSecsAhead,
eventId: r.eventId,
duration: known ? playback.durationSecs : 0,
mediaId: known ? r.mediaId : 0,
jf: _jfAccessFor(r),
);
if (!_lastOut) {
_checkSeekLanded(r);
unawaited(_tickJellyfin(r));
}
}
if (!_manualClose &&
kickReason.peek() == null &&
_lastStateAt != null &&
now.difference(_lastStateAt!).inSeconds >= 5) {
if (serverState.peek() != null) {
@@ -857,7 +950,11 @@ class SessionCoordinator {
// No warning when there is nothing to lose (unready), and none while
// PLAYING: the ready flag gates nothing then, and watching counts as
// activity so a pause after a long watch starts a fresh idle clock.
final event = _idle.tick(now,
if (r != null) _readyAfterJoinBack(r, now);
final sitOutWhenIdle = AppSettings.instance.idleSitsOut.peek();
final event = solo
? IdleEvent.none
: _idle.tick(now,
playing: serverState.peek()?.transport == 'playing',
selfReady: selfMember.peek()?.ready ?? false);
switch (event) {
@@ -867,18 +964,46 @@ class SessionCoordinator {
...toasts.peek(),
ToastItem(
id: _toastId,
text: 'idle — auto-unready in 2 min unless you press Space',
text: sitOutWhenIdle
? 'idle — sitting out in 2 min unless you press Space'
: 'idle — auto-unready in 2 min unless you press Space',
at: now)
];
unawaited(sfx.play(const SfxCue(SfxKind.warn)));
case IdleEvent.unready:
_lastReady = false;
if (sitOutWhenIdle) {
sitOut();
} else {
sync.sendReady(false, source: 'auto');
}
case IdleEvent.none:
break;
}
}
/// After [joinBack] while playing: ready once the player holds what the
/// gate needs, or after [_joinBackWait] (then the gate waits for it).
void _readyAfterJoinBack(StateBroadcast r, DateTime now) {
final at = _joinBackAt;
if (at == null) return;
final self = selfMember.peek();
if (self == null || self.out) {
if (now.difference(at) > _joinBackWait) _joinBackAt = null;
return;
}
final loaded = _playerReady && !_opening && _openUrl != null && _mediaId == r.mediaId;
if (self.ready ||
(loaded && playback.bufferedSecsAhead >= r.bufferNeed) ||
now.difference(at) > _joinBackWait) {
_joinBackAt = null;
if (!self.ready) {
_lastReady = true;
sync.sendReady(true, source: 'manual');
}
}
}
void _clearPendingSeek() {
_pendingSeekEvent = null;
_pendingSeekAt = null;
@@ -945,6 +1070,10 @@ class SessionCoordinator {
void toggleReady() {
final self = selfMember.peek();
if (self?.out ?? false) {
joinBack();
return;
}
// Ready but out of the set (dropped out while stale) gates like
// unready, so Space puts self back in instead of unreadying.
final next = !((self?.ready ?? false) && (self?.inSet ?? false));
@@ -1032,12 +1161,43 @@ class SessionCoordinator {
sync.sendProfile(name: name, avatar: avatar);
}
void kick(String memberId, {String reason = ''}) {
/// Sits [memberId] out (anyone, the host included), self when null.
void sitOut([String? memberId]) {
final target = memberId ?? sync.memberId;
if (target.isEmpty) return;
markInput();
sync.sendKick(memberId, reason: reason);
if (target == sync.memberId) _joinBackAt = null;
sync.sendOut(target, true);
}
/// Back from sitting out: the media opens at the server position and
/// this member readies. Held: at once (the gate waits for the buffer);
/// playing: once buffered, so the others do not pause for the load.
void joinBack() {
final self = selfMember.peek();
if (self == null || !self.out) return;
markInput();
sync.sendOut(self.id, false);
if (serverState.peek()?.transport == 'playing') {
_joinBackAt = DateTime.now().toUtc();
} else {
_joinBackAt = null;
_lastReady = true;
sync.sendReady(true, source: 'manual');
}
}
/// Pokes [memberId]: only they hear it (sitting out or not).
void nudgeMember(String memberId) {
if (memberId.isEmpty || memberId == sync.memberId) return;
markInput();
sync.sendNudge(memberId);
}
void _swap(HelloMedia media) {
// Putting something up means watching it: back first.
final self = selfMember.peek();
if (self != null && self.out) sync.sendOut(self.id, false);
_pendingArt = (media: media, after: serverState.peek()?.mediaId ?? 0);
sync.sendSwap(media);
}
@@ -1059,6 +1219,12 @@ class SessionCoordinator {
final ArtBytes? bytes;
if (ref != null && client != null) {
bytes = await jellyfinArt(client, ref.itemId);
} else if (ref == null && (media.url ?? '').isNotEmpty) {
// A web link is not a show: never guess. A page's thumbnail is
// its banner.
final thumb = (await _probeFor(media.url!))?.info?.thumbnail;
final uri = thumb == null ? null : Uri.tryParse(thumb);
bytes = uri == null ? null : (banner: await fetchImage(uri, const {}, RelayConstants.maxBannerBytes), poster: null);
} else {
final name = ref == null ? media.name : jellyfinTitle(ref.title, ref.subtitle).$1;
bytes = await aniListArt(_aniList ??= AniListClient(), name);
@@ -1080,43 +1246,83 @@ class SessionCoordinator {
/// link, or relayed per [relay]. Returns an error text, or null when sent.
Future<String?> swapLink(String url, {LinkRelay relay = LinkRelay.direct}) async {
markInput();
_lastPick = LinkMedia(url, relay: relay);
final media = await mediaForLink(jellyfin, url);
if (media.jellyfin != null) {
_pendingOrigin = _JellyfinOrigin(url);
_swap(media.copyWith(relay: const MediaRelay()));
return null;
}
if (relay == LinkRelay.direct) {
_swap(media);
return null;
}
// Relayed: it must be a single file the relay can range-read (not a
// page like YouTube, which only the player can resolve).
final probe = HttpRelayBytes(Uri.parse(url));
final int size;
LinkProbe? probe;
String? probeError;
try {
size = await probe.size().timeout(const Duration(seconds: 15));
probe = await ytDlp.probe(url).timeout(const Duration(seconds: 45));
} catch (e) {
return relay == LinkRelay.me
? 'cannot pass this link on: $e'
: 'cannot check this link: $e';
} finally {
await probe.close();
probeError = '$e';
}
// A page is named by its title, never its URL tail ("1", "watch").
final uri = Uri.tryParse(url) ?? Uri();
final name = probe?.title ?? (probe != null || hasMediaExtension(uri) || uri.host.isEmpty ? media.name : uri.host);
if (relay == LinkRelay.direct) {
_swap(HelloMedia(name: name, size: 0, duration: 0, url: url));
return null;
}
if (relay == LinkRelay.me) {
_pendingOrigin = _LinkOrigin(url);
_swap(HelloMedia(name: media.name, size: size, duration: 0, relay: const MediaRelay()));
// This computer reads it: a file, or a page's formats (one file,
// separate video and audio tracks, or segmented playlists).
if (probe == null) return 'cannot pass this link on: $probeError';
final problem = probe.relayProblem;
if (problem != null) return problem;
final y = await ytDlp.ytDlp;
final check = LinkRelayMedia(uri, ytDlp: y, probe: probe);
var size = 0;
final List<RelayTrackSpec> specs;
try {
final tracks = 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;
if (pieces != null) {
await pieces.list().timeout(const Duration(seconds: 15));
} else {
_swap(HelloMedia(
name: media.name, size: size, duration: 0, url: url, relay: const MediaRelay(fetch: true)));
size += await t.bytes!.size().timeout(const Duration(seconds: 15));
}
}
specs = relayTrackSpecs(tracks);
} catch (e) {
return 'cannot pass this link on: $e';
} finally {
await check.close();
}
_pendingOrigin = _LinkOrigin(url, probe, y);
_swap(HelloMedia(
name: name, size: size, duration: 0, relay: MediaRelay(tracks: specs)));
return null;
}
// The server reads it with its own yt-dlp; only a page this app knows
// it cannot relay (live, nothing a relay can read) is refused here (the server
// may reach what this computer cannot).
if (probe != null && probe.isPage && probe.info != null && probe.relayProblem != null) {
return probe.relayProblem;
}
_swap(HelloMedia(
name: name, size: max(0, probe?.size ?? 0), duration: 0, url: url, relay: const MediaRelay(fetch: true)));
return null;
}
Future<LinkProbe?> _probeFor(String url) async {
try {
return await ytDlp.probe(url).timeout(const Duration(seconds: 45));
} catch (_) {
return null;
}
}
/// A Jellyfin pick (browser, next / previous episode) goes up as a ref,
/// relayed by this member for viewers without an account.
void swapJellyfin(JellyfinMedia media) {
markInput();
_lastPick = media;
_pendingOrigin = const _JellyfinOrigin(null);
_swap(mediaForPick(media).copyWith(relay: const MediaRelay()));
}
@@ -1126,6 +1332,7 @@ class SessionCoordinator {
/// when sent.
Future<String?> swapFile(String path) async {
markInput();
_lastPick = FileMedia(path);
final host = hostFile;
if (host != null) {
final file = await host(path);
@@ -1180,11 +1387,11 @@ class SessionCoordinator {
/// results), shown like the session's own.
void showToast(String text) => _pushToast(text);
void _pushToast(String text) {
void _pushToast(String text, {String? actor}) {
_toastId++;
toasts.value = [
...toasts.peek(),
ToastItem(id: _toastId, text: text, at: DateTime.now().toUtc())
ToastItem(id: _toastId, text: text, at: DateTime.now().toUtc(), actor: actor)
];
}
@@ -1218,7 +1425,7 @@ class SessionCoordinator {
if (reporter != null && snap != null) unawaited(reporter.stop(snap));
}
/// Leave / kick / window close: Stopped, bounded so leaving never hangs.
/// Leave / window close: Stopped, bounded so leaving never hangs.
Future<void> _stopJellyfin() async {
final reporter = _reporter;
final snap = _snapNow();
@@ -1301,10 +1508,10 @@ class SessionCoordinator {
// its own sign-in, else the pasted link (played directly here too).
if (origin != null) {
if (plan is OwnPlay) {
_startRelaySource(r.mediaId, HttpRelayBytes(Uri.parse(plan.url), headers: plan.headers));
_startRelaySource(r.mediaId, RelayMedia.single(HttpRelayBytes(Uri.parse(plan.url), headers: plan.headers)));
} else if (origin.link != null) {
plan = RelayPlay(origin.link!);
_startRelaySource(r.mediaId, HttpRelayBytes(Uri.parse(origin.link!)));
_startRelaySource(r.mediaId, RelayMedia.single(HttpRelayBytes(Uri.parse(origin.link!))));
}
}
jellyfinPlan.value = plan;
@@ -1506,7 +1713,7 @@ class SessionCoordinator {
}
void _scheduleReconnect({bool manual = false}) {
if (_manualClose || kickReason.peek() != null || _params == null) return;
if (_manualClose || _params == null) return;
if (!manual && !_everConnected) return;
if (reconnecting.peek()) return;
reconnecting.value = true;
@@ -1521,14 +1728,15 @@ class SessionCoordinator {
/// would kill the newer connection and leave a ghost member).
Future<void> _reconnect(int epoch) async {
_reconnectTimer = null;
bool stale() => epoch != _epoch || kickReason.peek() != null;
bool stale() => epoch != _epoch;
final params = _params;
if (stale() || params == null) return;
if (_manualClose) {
reconnecting.value = false;
return;
}
var wantReady = _lastReady;
final wasOut = _lastOut;
var wantReady = _lastReady && !wasOut;
final seenBefore = _seenMediaId;
try {
health.value = const HealthStatus(
@@ -1569,6 +1777,10 @@ class SessionCoordinator {
wantReady = false;
_lastReady = false;
}
// A new member (the server dropped the old one) sits out again; a
// resumed one still does.
final selfNow = sync.lastState?.members.where((m) => m.id == sync.memberId).firstOrNull;
if (wasOut && !(selfNow?.out ?? false)) sync.sendOut(sync.memberId, true);
if (wantReady) {
_lastReady = true;
_restoringReady = true;
@@ -1614,7 +1826,7 @@ class SessionCoordinator {
}
void retry() {
if (kickReason.peek() != null || _params == null) return;
if (_params == null) return;
_invalidateAttempts();
_manualClose = false;
reconnecting.value = false;
@@ -1675,7 +1887,7 @@ class SessionCoordinator {
}
await _stateSub?.cancel();
await _feedSub?.cancel();
await _kickSub?.cancel();
await _nudgeSub?.cancel();
await _errSub?.cancel();
await _statusSub?.cancel();
_aniList?.close();
+3 -1
View File
@@ -8,6 +8,7 @@ enum SfxKind {
forceReady,
join,
leave,
/// Someone sat you out (played by the session, not from a feed action).
kick,
/// A new video or episode is put up.
swap,
@@ -17,6 +18,8 @@ enum SfxKind {
/// The connection to the server dropped / came back.
lost,
back,
/// Someone pokes you for attention; plays only on your end.
nudge,
}
class SfxCue {
@@ -29,7 +32,6 @@ SfxKind? sfxForFeedAction(String action) => switch (action) {
'ready' => SfxKind.ready,
'unready' => SfxKind.unready,
'force-ready' => SfxKind.forceReady,
'kick' => SfxKind.kick,
'join' => SfxKind.join,
'leave' => SfxKind.leave,
'swap' => SfxKind.swap,
+205 -33
View File
@@ -1,83 +1,254 @@
import 'sfx.dart';
import 'synth.dart';
// The sound of the house: koto for readiness, the wind chime for people
// arriving and leaving, the shoji door for a new video, bamboo for chat,
// clappers for a kick, the singing bowl for the idle warning, a string
// going slack and re-tuned for the connection. All in the Yo scale on D
// (D E G A B), so overlapping sounds agree; quiet and short.
// The app's sounds come in presets; each action can take its sound from any
// of them. Every preset is quiet, short and on D, so sounds from different
// presets still agree when they overlap.
// - Cosmic: star bells, stardust, bubbles, soft pads, a space tail.
// - Abyss: deep space and its dread. Far beacons, nebula washes, slow
// beating clusters and tritone sags, on D phrygian, with a vast tail.
// - Wood & glass: plucked strings, glass bells, wood knocks, a short room.
const _d4 = -7, _a4 = 0, _b4 = 2;
const _d5 = 5, _g5 = 10, _a5 = 12, _b5 = 14;
const _d6 = 17, _e6 = 19, _g6 = 22, _a6 = 24, _b6 = 26;
enum SoundPreset { cosmic, abyss, woodAndGlass }
/// Shown in Settings: name and feel.
const presetInfo = {
SoundPreset.cosmic: ('Cosmic', 'Star bells, stardust and a soft space tail'),
SoundPreset.abyss: ('Abyss', 'Deep space: far beacons, nebula, a vast dark tail'),
SoundPreset.woodAndGlass: ('Wood & glass', 'Plucked strings, glass bells and wood, close and dry'),
};
/// The preset used when nothing else is chosen.
const defaultPreset = SoundPreset.abyss;
const _d3 = -19, _ab3 = -13;
const _d4 = -7, _eb4 = -6, _ab4 = -1, _a4 = 0, _b4 = 2;
const _d5 = 5, _eb5 = 6, _e5 = 7, _f5 = 8, _fs5 = 9, _g5 = 10, _a5 = 12, _bb5 = 13, _b5 = 14;
const _c6 = 15, _cs6 = 16, _d6 = 17, _e6 = 19, _f6 = 20, _fs6 = 21, _g6 = 22, _a6 = 24, _b6 = 26;
double _p(int s) => semi(s);
/// The ready pluck's answer climbs with each person ready.
const _readyLadder = [_g5, _a5, _b5, _d6, _e6, _g6];
/// The ready sound's answer climbs with each person ready.
const _cosmicLadder = [_a5, _b5, _d6, _e6, _fs6, _a6];
const _abyssLadder = [_f5, _g5, _a5, _c6, _d6, _f6];
const _woodLadder = [_g5, _a5, _b5, _d6, _e6, _g6];
SoundSpec readySound(int readyIndex) {
final top = _readyLadder[(readyIndex - 1).clamp(0, _readyLadder.length - 1)];
return SoundSpec([
Note(0, _p(_d5), 0.9, gain: 0.75, bright: 0.55),
Note(0.085, _p(top), 1.1, bright: 0.65),
], peakDb: -17);
SoundSpec readySound(int readyIndex, {SoundPreset preset = defaultPreset}) =>
soundFor(SfxKind.ready, readyIndex: readyIndex, preset: preset);
SoundSpec soundFor(SfxKind kind, {int readyIndex = 1, SoundPreset preset = defaultPreset}) {
final i = (readyIndex - 1).clamp(0, 5);
return switch (preset) {
SoundPreset.cosmic => _cosmic(kind, i),
SoundPreset.abyss => _abyss(kind, i),
SoundPreset.woodAndGlass => _wood(kind, i),
};
}
SoundSpec soundFor(SfxKind kind, {int readyIndex = 1}) => switch (kind) {
SfxKind.ready => readySound(readyIndex),
SoundSpec _cosmic(SfxKind kind, int i) => switch (kind) {
SfxKind.ready => SoundSpec([
Note(0, _p(_d4), 1.0, voice: Voice.pad, gain: 0.3),
Note(0, _p(_d5), 1.2, voice: Voice.star, gain: 0.8, bright: 0.4),
Note(0.09, _p(_cosmicLadder[i]), 1.5, voice: Voice.star, bright: 0.5),
], peakDb: -17, room: 0.3),
// The same bells, dimmed and stepping down.
SfxKind.unready => SoundSpec([
Note(0, _p(_fs5), 0.6, voice: Voice.star, gain: 0.8, bright: 0.15),
Note(0.1, _p(_d5), 0.8, voice: Voice.star, bright: 0.1),
], cutoff: 3500, peakDb: -20, room: 0.25),
// Just the bell, left hanging in space: playback is the rest of the cue.
SfxKind.forceReady => SoundSpec([
Note(0, _p(_d6), 1.8, voice: Voice.star, bright: 0.7),
], cutoff: 7000, peakDb: -18, room: 0.35, tail: 1.6),
// A star arriving: dust rising into a bell.
SfxKind.join => SoundSpec([
Note(0, _p(_d6), 1.0, voice: Voice.dust, to: _p(_a6), gain: 0.5),
Note(0.15, _p(_a5), 1.4, voice: Voice.star, bright: 0.35, gain: 0.6),
], cutoff: 7000, peakDb: -19, room: 0.3),
// Dust drifting down, a low bell going out.
SfxKind.leave => SoundSpec([
Note(0, _p(_a6), 0.9, voice: Voice.dust, to: _p(_d6), gain: 0.5),
Note(0.1, _p(_d5), 1.3, voice: Voice.star, bright: 0.2, gain: 0.5),
], cutoff: 5000, peakDb: -22, room: 0.3),
// Falling into the dark: a deep tone sinking an octave under a sagging pad.
SfxKind.kick => SoundSpec([
Note(0, _p(_d4), 1.0, voice: Voice.sub, to: _p(_d3)),
Note(0, _p(_a4), 0.9, voice: Voice.pad, to: _p(_d4), gain: 0.4),
], cutoff: 3000, peakDb: -19, room: 0.25),
// Something new opens up: a rush of air, then a wide chord blooms.
SfxKind.swap => SoundSpec([
Note(0, 300, 0.6, voice: Voice.slide, to: 2400, gain: 0.35),
Note(0.35, _p(_d4), 1.4, voice: Voice.pad, gain: 0.5, bright: 0.3),
Note(0.35, _p(_a4), 1.4, voice: Voice.pad, gain: 0.5, bright: 0.3),
Note(0.35, _p(_e5), 1.4, voice: Voice.pad, gain: 0.4, bright: 0.3),
Note(0.38, _p(_fs6), 1.2, voice: Voice.star, bright: 0.3, gain: 0.3),
], peakDb: -18, room: 0.3),
// Two bubbles rising: bloop-bloop, the second higher.
SfxKind.chat => SoundSpec([
Note(0, _p(_a5), 0.18, voice: Voice.drop, gain: 0.8),
Note(0.08, _p(_e6), 0.22, voice: Voice.drop),
], peakDb: -19, room: 0.15, tail: 1.0),
// Something huge drifting past: a slow, deep swell.
SfxKind.warn => SoundSpec([
Note(0, _p(_d4), 2.0, voice: Voice.pad),
Note(0, _p(_a4), 2.0, voice: Voice.pad, gain: 0.5),
Note(0.3, _p(_d3), 1.7, voice: Voice.sub, gain: 0.4),
], cutoff: 3000, peakDb: -20, room: 0.2, tail: 1.0),
// The signal fading: a chord sagging down and dimming.
SfxKind.lost => SoundSpec([
Note(0, _p(_a4), 1.2, voice: Voice.pad, to: _p(_d4), gain: 0.7),
Note(0, _p(_e5), 1.2, voice: Voice.pad, to: _p(_a4), gain: 0.4),
], cutoff: 2500, peakDb: -19, room: 0.3, tail: 1.2),
// Re-acquired: a bell gliding up into tune, landing with dust.
SfxKind.back => SoundSpec([
Note(0, _p(_a4), 0.6, voice: Voice.star, to: _p(_d5), bright: 0.3, gain: 0.5),
Note(0.22, _p(_a5), 1.2, voice: Voice.star, bright: 0.5),
Note(0.25, _p(_d6), 0.6, voice: Voice.dust, to: _p(_a6), gain: 0.3),
], peakDb: -18, room: 0.25),
// A poke: two quick bright bubbles flicking up, a glint on the second.
// High and dry so it cuts through a video and stays a patter when spammed.
SfxKind.nudge => SoundSpec([
Note(0, _p(_d6), 0.12, voice: Voice.drop, to: _p(_fs6), gain: 0.8),
Note(0.09, _p(_a6), 0.16, voice: Voice.drop),
Note(0.09, _p(_cs6) * 2, 0.3, voice: Voice.star, bright: 0.6, gain: 0.2),
], cutoff: 7000, peakDb: -18, room: 0.05, tail: 0.6),
};
SoundSpec _abyss(SfxKind kind, int i) => switch (kind) {
// A beacon pinging far out, the void humming under it.
SfxKind.ready => SoundSpec([
Note(0, _p(_d3), 1.2, voice: Voice.pad, gain: 0.35),
Note(0, _p(_d5), 1.4, voice: Voice.star, gain: 0.8, bright: 0.25),
Note(0.12, _p(_abyssLadder[i]), 1.6, voice: Voice.star, bright: 0.3),
], cutoff: 5000, peakDb: -18, room: 0.4, tail: 1.8),
// The beacon answering itself a tritone down, dimmer.
SfxKind.unready => SoundSpec([
Note(0, _p(_a4), 0.7, voice: Voice.star, gain: 0.8, bright: 0.1),
Note(0.12, _p(_eb4), 0.9, voice: Voice.star, bright: 0.1),
], cutoff: 2500, peakDb: -21, room: 0.35, tail: 1.6),
// One bell, huge and very far away.
SfxKind.forceReady => SoundSpec([
Note(0, _p(_d5), 1.7, voice: Voice.star, bright: 0.5),
], cutoff: 5000, peakDb: -18, room: 0.45, tail: 1.8),
// Something emerging from the nebula: a dark wash brightening into a bell.
SfxKind.join => SoundSpec([
Note(0, 150, 1.2, voice: Voice.slide, to: 900, gain: 0.4),
Note(0.05, _p(_d4), 1.2, voice: Voice.pad, gain: 0.3),
Note(0.3, _p(_a5), 1.3, voice: Voice.star, bright: 0.3, gain: 0.5),
], cutoff: 4500, peakDb: -19, room: 0.4, tail: 1.6),
// Swallowed back into it: the wash darkening over a fading bell.
SfxKind.leave => SoundSpec([
Note(0, 900, 1.1, voice: Voice.slide, to: 150, gain: 0.4),
Note(0.1, _p(_d5), 1.2, voice: Voice.star, bright: 0.15, gain: 0.4),
], cutoff: 3500, peakDb: -22, room: 0.4, tail: 1.6),
// Pulled under: a deep fall with a tritone sagging over it.
SfxKind.kick => SoundSpec([
Note(0, _p(_d4), 1.3, voice: Voice.sub, to: _p(_d3)),
Note(0, _p(_ab4), 1.2, voice: Voice.pad, to: _p(_d4), gain: 0.35),
], cutoff: 2000, peakDb: -19, room: 0.35, tail: 1.6),
// A door into the dark: low air, then an unresolved chord opens.
SfxKind.swap => SoundSpec([
Note(0, 120, 0.8, voice: Voice.slide, to: 1200, gain: 0.35),
Note(0.45, _p(_d4), 1.3, voice: Voice.pad, gain: 0.5, bright: 0.2),
Note(0.45, _p(_a4), 1.3, voice: Voice.pad, gain: 0.45, bright: 0.2),
Note(0.45, _p(_eb5), 1.3, voice: Voice.pad, gain: 0.3, bright: 0.2),
Note(0.5, _p(_bb5), 1.1, voice: Voice.star, bright: 0.2, gain: 0.25),
], cutoff: 4000, peakDb: -19, room: 0.35, tail: 1.4),
// Bubbles rising from deep water.
SfxKind.chat => SoundSpec([
Note(0, _p(_d5), 0.25, voice: Voice.drop, gain: 0.8),
Note(0.1, _p(_a5), 0.3, voice: Voice.drop),
], cutoff: 4500, peakDb: -19, room: 0.3, tail: 1.4),
// Something vast drawing near: a slow swell of two notes rubbing.
SfxKind.warn => SoundSpec([
Note(0, _p(_d4), 2.0, voice: Voice.pad),
Note(0, _p(_eb4), 2.0, voice: Voice.pad, gain: 0.35),
Note(0.3, _p(_d3), 1.7, voice: Voice.sub, gain: 0.45),
], cutoff: 2200, peakDb: -20, room: 0.25, tail: 1.0),
// The signal drowning: a chord sagging by a tritone into static.
SfxKind.lost => SoundSpec([
Note(0, _p(_a4), 1.3, voice: Voice.pad, to: _p(_eb4), gain: 0.7),
Note(0, _p(_d4), 1.3, voice: Voice.pad, to: _p(_ab3), gain: 0.5),
Note(0.2, 600, 1.0, voice: Voice.slide, to: 200, gain: 0.2),
], cutoff: 2000, peakDb: -19, room: 0.35, tail: 1.4),
// Found again: a bell climbing out of the tritone into tune.
SfxKind.back => SoundSpec([
Note(0, _p(_ab4), 0.6, voice: Voice.star, to: _p(_d5), bright: 0.25, gain: 0.5),
Note(0.25, _p(_a5), 1.3, voice: Voice.star, bright: 0.4),
Note(0.28, _p(_d5), 0.6, voice: Voice.dust, to: _p(_a5), gain: 0.25),
], cutoff: 5000, peakDb: -18, room: 0.35, tail: 1.6),
// A poke from the dark: two quick bubbles flicking up, a pale glint.
// Still high and fairly dry so it cuts through a video when spammed.
SfxKind.nudge => SoundSpec([
Note(0, _p(_a5), 0.13, voice: Voice.drop, to: _p(_c6), gain: 0.8),
Note(0.1, _p(_d6), 0.18, voice: Voice.drop),
Note(0.1, _p(_a6), 0.35, voice: Voice.star, bright: 0.5, gain: 0.2),
], cutoff: 6000, peakDb: -18, room: 0.1, tail: 1.0),
};
SoundSpec _wood(SfxKind kind, int i) => switch (kind) {
SfxKind.ready => SoundSpec([
Note(0, _p(_d5), 0.9, gain: 0.75, bright: 0.55),
Note(0.085, _p(_woodLadder[i]), 1.1, bright: 0.65),
], peakDb: -17, room: 0.14, tail: 0.5),
// The same strings, muted and stepping down.
SfxKind.unready => SoundSpec([
Note(0, _p(_a5), 0.35, gain: 0.8, bright: 0.2),
Note(0.09, _p(_g5), 0.45, bright: 0.15),
], cutoff: 3800, peakDb: -20, room: 0.1),
], cutoff: 3800, peakDb: -20, room: 0.1, tail: 0.5),
// Just the bell: playback starting is the rest of the cue.
SfxKind.forceReady => SoundSpec([
Note(0, _p(_d6) * 2, 1.6, voice: Voice.chime),
], cutoff: 7000, peakDb: -18, room: 0.18),
// Wind through the furin: three uneven tinkles.
], cutoff: 7000, peakDb: -18, room: 0.18, tail: 0.5),
// Wind through glass: three uneven tinkles.
SfxKind.join => SoundSpec([
Note(0, _p(_a6), 1.3, voice: Voice.chime, gain: 0.8),
Note(0.11, _p(_d6) * 2, 1.2, voice: Voice.chime, gain: 0.55),
Note(0.26, _p(_e6) * 2, 1.4, voice: Voice.chime, gain: 0.45),
], cutoff: 7000, peakDb: -19, room: 0.18),
], cutoff: 7000, peakDb: -19, room: 0.18, tail: 0.5),
// The wind settling: two softer, lower tinkles.
SfxKind.leave => SoundSpec([
Note(0, _p(_b6), 1.0, voice: Voice.chime, gain: 0.6),
Note(0.17, _p(_e6), 1.3, voice: Voice.chime, gain: 0.5),
], cutoff: 5000, peakDb: -22, room: 0.18),
// Hyoshigi: clack… clack, then a low muted string.
], cutoff: 5000, peakDb: -22, room: 0.18, tail: 0.5),
// Two hardwood clacks, then a low muted string.
SfxKind.kick => SoundSpec([
Note(0, 1900, 0.09, voice: Voice.clack),
Note(0.16, 1750, 0.11, voice: Voice.clack, gain: 0.9),
Note(0.3, _p(_d4), 0.7, bright: 0.15, gain: 0.7),
], cutoff: 5000, peakDb: -19, room: 0.12),
], cutoff: 5000, peakDb: -19, room: 0.12, tail: 0.5),
// The door slides open on something new: air, then a fifth.
SfxKind.swap => SoundSpec([
Note(0, 500, 0.42, voice: Voice.slide, to: 1700, gain: 0.5),
Note(0.33, _p(_d5), 1.1, bright: 0.6, gain: 0.8),
Note(0.36, _p(_a5), 1.1, bright: 0.6, gain: 0.7),
], peakDb: -18),
// Bamboo: a hollow tok-tok, the second higher.
], peakDb: -18, tail: 0.5),
// A hollow wooden tok-tok, the second higher.
SfxKind.chat => SoundSpec([
Note(0, _p(_g5), 0.13, voice: Voice.bamboo, gain: 0.85),
Note(0.075, _p(_d6), 0.16, voice: Voice.bamboo),
], cutoff: 5500, peakDb: -19, room: 0.1),
// The singing bowl: one soft strike that hums.
], cutoff: 5500, peakDb: -19, room: 0.1, tail: 0.5),
// A bowl: one soft strike that hums.
SfxKind.warn => SoundSpec([
Note(0, _p(_d5), 2.6, voice: Voice.rin),
], cutoff: 4500, peakDb: -20, room: 0.1),
], cutoff: 4500, peakDb: -20, room: 0.1, tail: 0.5),
// A string going slack: plucked, its pitch sagging away.
SfxKind.lost => SoundSpec([
Note(0, _p(_a5), 1.1, to: _p(_d5), bright: 0.4),
Note(0.05, _p(_d5), 1.0, to: _p(_a4), bright: 0.3, gain: 0.6),
], cutoff: 3500, peakDb: -19),
], cutoff: 3500, peakDb: -19, tail: 0.5),
// Re-tuned: a quick bend up that lands, and a tinkle.
SfxKind.back => SoundSpec([
Note(0, _p(_b4), 0.9, to: _p(_d5), bright: 0.5),
Note(0.2, _p(_a5), 1.0, bright: 0.6, gain: 0.7),
Note(0.3, _p(_d6) * 2, 1.0, voice: Voice.chime, gain: 0.2),
], peakDb: -18),
], peakDb: -18, tail: 0.5),
// Two quick wooden taps, the second flicking up, with a glint.
SfxKind.nudge => SoundSpec([
Note(0, _p(_a5), 0.12, voice: Voice.bamboo, gain: 0.8),
Note(0.09, _p(_e6), 0.16, voice: Voice.bamboo),
Note(0.09, _p(_b6), 0.35, voice: Voice.chime, gain: 0.3),
], cutoff: 6500, peakDb: -18, room: 0.04, tail: 0.5),
};
/// Shown in Settings → Notifications & sounds: name and when it plays.
@@ -87,10 +258,11 @@ const soundInfo = {
SfxKind.forceReady: ('Everyone ready', 'Someone marks everyone ready'),
SfxKind.join: ('Joined', 'Someone joins the server'),
SfxKind.leave: ('Left', 'Someone leaves'),
SfxKind.kick: ('Removed', 'Someone is kicked'),
SfxKind.kick: ('Sat out', 'Someone sits you out'),
SfxKind.swap: ('New video', 'A different video or episode is put up'),
SfxKind.chat: ('Message', 'A chat message from someone else'),
SfxKind.warn: ('Idle warning', 'Auto-unready soon unless you press Space'),
SfxKind.lost: ('Connection lost', 'The server stopped answering; reconnecting'),
SfxKind.back: ('Reconnected', 'Back in the server after a drop'),
SfxKind.nudge: ('Nudge', 'Someone nudges you; always plays (volume still applies)'),
};
+170 -19
View File
@@ -1,10 +1,11 @@
import 'dart:math' as math;
import 'dart:typed_data';
// A tiny offline synthesizer for UI sounds, voiced as a Japanese house:
// koto strings (Karplus-Strong), a glass wind chime (furin), a singing
// bowl (rin), bamboo knocks, wooden clappers (hyoshigi) and the air of a
// sliding door (shoji). Renders to 16-bit mono WAV bytes. Pure Dart.
// A tiny offline synthesizer for UI sounds. The current palette is
// spacious: FM star bells, bubble drops, slow pads, a sub that falls away,
// stardust and a long dark space tail. Older voices (plucked string, glass
// and bowl bells, wood) stay available. Renders to 16-bit mono WAV bytes.
// Pure Dart.
const sampleRate = 44100;
@@ -22,6 +23,19 @@ enum Voice {
/// Breathy air sweeping up (a sliding door); [Note.hz]→[Note.to] is the
/// sweep of its band.
slide,
/// FM bell: strikes glassy, settles to a pure hanging tone; [Note.bright]
/// scales the strike, [Note.to] glides it.
star,
/// A bubble: the pitch rises into the note fast; [Note.to] flicks it on.
drop,
/// Slow detuned swell (in over a third of its length, then out);
/// [Note.bright] adds overtones, [Note.to] glides it.
pad,
/// Deep round tone that falls from [Note.hz] to [Note.to].
sub,
/// A scatter of tiny pings on the scale above [Note.hz], drifting
/// towards [Note.to].
dust,
}
class Note {
@@ -44,12 +58,15 @@ class SoundSpec {
final double cutoff;
/// Peak level after normalising, dBFS (quiet by design).
final double peakDb;
/// Room tail mix (0..1): the veranda, not a hall.
/// Space tail mix (0..1).
final double room;
const SoundSpec(this.notes, {this.cutoff = 6000, this.peakDb = -17, this.room = 0.14});
/// Space tail decay to -60 dB (s); the sound keeps about half of it.
final double tail;
const SoundSpec(this.notes,
{this.cutoff = 6000, this.peakDb = -17, this.room = 0.14, this.tail = 1.4});
double get length =>
notes.fold<double>(0, (m, n) => math.max(m, n.at + n.len)) + (room > 0 ? 0.25 : 0.02);
notes.fold<double>(0, (m, n) => math.max(m, n.at + n.len)) + (room > 0 ? tail * 0.55 : 0.02);
}
/// Semitones from A4 (440 Hz) to Hz.
@@ -69,13 +86,18 @@ Float64List render(SoundSpec spec, {int seed = 7}) {
Voice.bamboo => _bamboo(n, rnd),
Voice.clack => _clack(n, rnd),
Voice.slide => _slide(n, rnd),
Voice.star => _star(n),
Voice.drop => _drop(n),
Voice.pad => _pad(n, rnd),
Voice.sub => _sub(n),
Voice.dust => _dust(n, rnd),
};
for (var i = 0; i < s.length && start + i < total; i++) {
out[start + i] += n.gain * s[i];
}
}
_lowpass(out, spec.cutoff);
if (spec.room > 0) _room(out, spec.room);
if (spec.room > 0) _space(out, spec.room, spec.tail);
// 1 ms in, 10 ms out: no clicks at either end.
final fadeIn = (0.001 * sampleRate).round();
for (var i = 0; i < fadeIn && i < total; i++) {
@@ -276,20 +298,149 @@ void _lowpass(Float64List out, double cutoff) {
}
}
/// A few feedback delays: wood and paper walls, a short tail.
void _room(Float64List out, double mix) {
const taps = [0.029, 0.041, 0.059];
final dry = Float64List.fromList(out);
for (final d in taps) {
/// Space: four damped feedback combs and two allpasses (a dark, smooth
/// tail decaying to -60 dB in [tail] s) after a short pre-delay.
void _space(Float64List out, double mix, double tail) {
const combs = [0.0297, 0.0371, 0.0411, 0.0437];
const damp = 0.35;
final len = out.length;
final pre = _n(0.018);
final dry = Float64List(len);
for (var i = pre; i < len; i++) {
dry[i] = out[i - pre];
}
final wet = Float64List(len);
for (final d in combs) {
final delay = _n(d);
final buf = Float64List(out.length);
for (var i = delay; i < out.length; i++) {
buf[i] = dry[i - delay] + 0.42 * buf[i - delay];
}
for (var i = 0; i < out.length; i++) {
out[i] += mix / taps.length * buf[i];
final g = math.pow(0.001, d / tail).toDouble();
final y = Float64List(len);
var lp = 0.0;
for (var i = 0; i < len; i++) {
final back = i >= delay ? y[i - delay] : 0.0;
lp = back * (1 - damp) + lp * damp;
y[i] = dry[i] + g * lp;
wet[i] += y[i] / combs.length;
}
}
for (final d in const [0.005, 0.0017]) {
final delay = _n(d);
final x = Float64List.fromList(wet);
for (var i = 0; i < len; i++) {
final xd = i >= delay ? x[i - delay] : 0.0;
final yd = i >= delay ? wet[i - delay] : 0.0;
wet[i] = -0.5 * x[i] + xd + 0.5 * yd;
}
}
for (var i = 0; i < len; i++) {
out[i] += mix * wet[i];
}
}
/// [Note.hz] gliding to [Note.to] over the first ~60% of the note.
double _glide(Note n, double t) =>
n.to == null ? n.hz : n.hz * math.pow(n.to! / n.hz, _smooth(math.min(1, t * 1.6))).toDouble();
Float64List _star(Note n) {
final len = _n(n.len);
final out = Float64List(len);
final k = math.log(100) / len;
final attack = _n(0.004);
final index0 = 0.3 + 3.0 * n.bright;
final idxTau = _n(0.09 + 0.12 * n.bright);
const ratio = 3.5;
var c1 = 0.0, c2 = 0.0, m1 = 0.0, m2 = 0.0;
for (var i = 0; i < len; i++) {
final hz = _glide(n, i / len);
final w = 2 * math.pi * hz / sampleRate;
c1 += w;
c2 += w * 1.0017;
m1 += w * ratio;
m2 += w * ratio * 1.0017;
final idx = index0 * math.exp(-i / idxTau);
final env = math.exp(-k * i) * (i < attack ? _smooth(i / attack) : 1);
out[i] = env * 0.5 * (math.sin(c1 + idx * math.sin(m1)) + math.sin(c2 + idx * math.sin(m2)));
}
return out;
}
Float64List _drop(Note n) {
final len = _n(n.len);
final out = Float64List(len);
final k = math.log(100) / len;
final attack = _n(0.002);
final riseTau = _n(0.016);
var ph = 0.0;
for (var i = 0; i < len; i++) {
final t = i / len;
final base = n.to == null ? n.hz : n.hz * math.pow(n.to! / n.hz, _smooth(math.min(1, t * 2.5)));
final hz = base * (0.62 + 0.38 * (1 - math.exp(-i / riseTau)));
ph += 2 * math.pi * hz / sampleRate;
final env = math.exp(-k * i) * (i < attack ? _smooth(i / attack) : 1);
out[i] = env * (math.sin(ph) + 0.15 * math.sin(2 * ph));
}
return out;
}
Float64List _pad(Note n, math.Random rnd) {
final len = _n(n.len);
final out = Float64List(len);
const detune = [-9.0, -3.0, 4.0, 10.0];
final ph = [for (var j = 0; j < detune.length + 1; j++) rnd.nextDouble() * 2 * math.pi];
final ratios = [for (final c in detune) math.pow(2, c / 1200).toDouble(), 0.5];
final amps = [for (final _ in detune) 1.0, 0.45];
const a = 0.33;
for (var i = 0; i < len; i++) {
final t = i / len;
final env = t < a ? _smooth(t / a) : math.pow(1 - _smooth((t - a) / (1 - a)), 2).toDouble();
final vib = 1 + 0.002 * math.sin(2 * math.pi * 0.3 * i / sampleRate);
final hz = _glide(n, t) * vib;
var v = 0.0;
for (var j = 0; j < ratios.length; j++) {
ph[j] += 2 * math.pi * hz * ratios[j] / sampleRate;
v += amps[j] * (math.sin(ph[j]) + n.bright * (0.25 * math.sin(2 * ph[j]) + 0.1 * math.sin(3 * ph[j])));
}
out[i] = env * v / ratios.length;
}
return out;
}
Float64List _sub(Note n) {
final len = _n(n.len);
final out = Float64List(len);
final k = math.log(100) / len;
final attack = _n(0.025);
var ph = 0.0;
for (var i = 0; i < len; i++) {
final t = i / len;
final hz = n.to == null ? n.hz : n.hz * math.pow(n.to! / n.hz, _smooth(t));
ph += 2 * math.pi * hz / sampleRate;
final env = math.exp(-k * i) * (i < attack ? _smooth(i / attack) : 1);
// Overtones so it still reads on small speakers.
out[i] = env * (math.sin(ph) + 0.35 * math.sin(2 * ph) + 0.12 * math.sin(3 * ph));
}
return out;
}
Float64List _dust(Note n, math.Random rnd) {
final len = _n(n.len);
final out = Float64List(len);
const steps = [0, 2, 4, 7, 9, 12, 14];
final count = math.max(4, (n.len * 14).round());
for (var g = 0; g < count; g++) {
final tg = (g + rnd.nextDouble() * 0.6) / count * 0.75;
final start = _n(tg * n.len);
final centre = n.to == null ? n.hz : n.hz * math.pow(n.to! / n.hz, tg);
final f = centre * math.pow(2, steps[rnd.nextInt(steps.length)] / 12);
final glen = math.min(_n(0.1 + 0.15 * rnd.nextDouble()), len - start);
if (glen <= 0) continue;
final amp = (1 - tg) * (0.5 + 0.5 * rnd.nextDouble());
final k = math.log(100) / glen;
final w = 2 * math.pi * f / sampleRate;
for (var i = 0; i < glen; i++) {
out[start + i] += amp * math.exp(-k * i) * (i < 88 ? i / 88 : 1) * math.sin(w * i);
}
}
return out;
}
/// 16-bit PCM mono WAV.
+16 -5
View File
@@ -16,7 +16,11 @@ import 'synth.dart';
class SynthSfx implements SfxService {
final double Function() volume;
final bool Function(SfxKind kind) enabled;
SynthSfx({required this.volume, bool Function(SfxKind kind)? enabled}) : enabled = enabled ?? ((_) => true);
/// Which sound set each sound plays from.
final SoundPreset Function(SfxKind kind) presetFor;
SynthSfx({required this.volume, bool Function(SfxKind kind)? enabled, SoundPreset Function(SfxKind kind)? presetFor})
: enabled = enabled ?? ((_) => true),
presetFor = presetFor ?? ((_) => defaultPreset);
static const _poolSize = 3;
final List<Player> _pool = [];
@@ -27,12 +31,19 @@ class SynthSfx implements SfxService {
@override
Future<void> play(SfxCue cue) async {
if (!enabled(cue.kind)) return;
await playSpec('${cue.kind.name}-${cue.readyIndex}', soundFor(cue.kind, readyIndex: cue.readyIndex));
final preset = presetFor(cue.kind);
await playSpec('${preset.name}-${cue.kind.name}-${cue.readyIndex}',
soundFor(cue.kind, readyIndex: cue.readyIndex, preset: preset));
}
/// Plays [kind] even when it is switched off (Settings' preview).
Future<void> preview(SfxKind kind, {int readyIndex = 1}) =>
playSpec('${kind.name}-$readyIndex', soundFor(kind, readyIndex: readyIndex));
/// Plays [kind] even when it is switched off (Settings' preview), always
/// freshly rendered: an edited spec plays after a hot reload.
Future<void> preview(SfxKind kind, {int readyIndex = 1, SoundPreset? preset}) {
final p = preset ?? presetFor(kind);
final key = '${p.name}-${kind.name}-$readyIndex';
_files.remove(key);
return playSpec(key, soundFor(kind, readyIndex: readyIndex, preset: p));
}
/// Plays [spec], cached under [key] (same key = same sound).
Future<void> playSpec(String key, SoundSpec spec) async {
+98 -50
View File
@@ -13,6 +13,7 @@ import '../jellyfin/accounts.dart';
import '../jellyfin/browse_scope.dart' show BrowseStart;
import '../jellyfin/session_media.dart';
import '../screens/home/connect_flow.dart';
import '../session/session_coordinator.dart' show ConnectParams;
import '../screens/home/health_probe.dart';
import '../screens/home/media_picker.dart';
import '../screens/home/profile.dart';
@@ -31,6 +32,7 @@ import '../schedule/ui/schedule_page.dart';
import 'session_player.dart';
import 'settings_tab.dart';
import 'toasts.dart';
import 'welcome.dart';
/// The one window: the party strip over a body that fades through between
/// the tabs and the full player, the floating mini player, the chat panel
@@ -112,12 +114,15 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
/// The next media to come up opens full (just joined, or put up here).
bool _autoFull = false;
int _lastMediaId = -1;
double? _mutedVolume;
bool _dialogOpen = false;
String? _lastKickShown;
final _stops = <void Function()>[];
// Rebuild the shell on these flips, not on every state broadcast.
final _online = computed(() => SessionState.instance.route.value == AppRoute.session);
/// Nobody else watches: offline, solo, or solo becoming a server.
final _alone = computed(() {
final s = SessionState.instance;
return s.route.value != AppRoute.session || s.solo.value || s.switching.value;
});
final _hasMedia = computed(() =>
SessionState.instance.route.value == AppRoute.session &&
SessionState.instance.coordinator.serverState.value?.media != null);
@@ -140,7 +145,6 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
// Reads linked servers and checks their tokens in the background.
_jellyfin.ensureLoaded();
if (widget.autoUpdateCheck) _autoCheck();
_flow.onKicked = _showKicked;
HardwareKeyboard.instance.addHandler(_onKey);
final session = SessionState.instance;
final coord = session.coordinator;
@@ -181,17 +185,28 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
if (!_chatOpen) setState(() => _unread += chats);
}));
}));
// Kicked: back offline, and say so.
// Back from sitting out with the player closed: show it (the media
// plays again).
var wasOut = false;
_stops.add(effect(() {
final kick = coord.kickReason.value;
if (kick == null) {
_lastKickShown = null;
return;
}
untracked(() {
if (session.route.value == AppRoute.session) session.route.value = AppRoute.home;
final out = coord.selfOut.value;
if (wasOut && !out && session.route.peek() == AppRoute.session) {
_later(() {
if (_mode == PlayerMode.none) _show(PlayerMode.mini);
});
}
wasOut = out;
}));
// Solo becoming a hosted server: the player closes while the media
// moves over and comes back the way it was once it is up again.
_stops.add(effect(() {
if (!session.switching.value) return;
_later(() {
_lastMediaId = -1;
_autoFull = _mode == PlayerMode.full;
if (_fullscreen) _toggleFullscreen();
_show(PlayerMode.none);
});
if (kick.isNotEmpty) _later(() => _showKicked(kick));
}));
// The hosted server died mid-session (SessionState leaves): say so.
_stops.add(effect(() {
@@ -212,6 +227,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
HardwareKeyboard.instance.removeHandler(_onKey);
_fade.dispose();
_online.dispose();
_alone.dispose();
_hasMedia.dispose();
_flow.dispose();
_join.dispose();
@@ -242,7 +258,6 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
}
void _wentOffline() {
_mutedVolume = null;
if (_fullscreen) _toggleFullscreen();
setState(() {
_chatOpen = false;
@@ -258,7 +273,6 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
Future<void> _show(PlayerMode m) async {
if (m == _mode) return;
final seq = ++_modeSeq;
if (m != PlayerMode.none) _unmute();
setState(() {
_from = _shown;
_mode = m;
@@ -282,27 +296,15 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
_show(PlayerMode.mini);
}
/// Mini's ×: stop watching but stay in the server (muted until shown).
/// The player's ×: sit out (stay in the server, nothing loaded; the
/// player shows the join-back card). Once out, × closes the player.
void _closePlayer() {
final playback = SessionState.instance.coordinator.playback;
if (playback.alive.peek()) {
try {
_mutedVolume = playback.player.state.volume;
playback.player.setVolume(0);
} catch (_) {}
}
final coord = SessionState.instance.coordinator;
if (coord.selfOut.peek()) {
_show(PlayerMode.none);
} else {
coord.sitOut();
}
void _unmute() {
final v = _mutedVolume;
if (v == null) return;
_mutedVolume = null;
final playback = SessionState.instance.coordinator.playback;
if (!playback.alive.peek()) return;
try {
playback.player.setVolume(v);
} catch (_) {}
}
Future<void> _setWindowFullscreen(bool on) async {
@@ -360,7 +362,8 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
_selectTab(ShellTab.library);
}
/// Library → "Put it up for everyone"; the player opens full.
/// Library → "Put it up for everyone"; the player opens full. Offline
/// it starts a solo session first (just you, on this computer).
Future<String?> _putUp(PickedMedia media) async {
final session = SessionState.instance;
if (media is JellyfinMedia) {
@@ -371,6 +374,12 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
}
unawaited(_jellyfin.rememberPick(media));
}
if (!_online.peek()) {
final error = await _startSolo();
if (error != null || !mounted) return error;
}
// Alone, a link plays direct: there is nobody to pass it on to.
if (media is LinkMedia && session.solo.peek()) media = LinkMedia(media.url);
final error = await session.coordinator.swapPicked(media);
if (error != null || !mounted) return error;
final f = session.lastForm;
@@ -383,6 +392,29 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
return null;
}
/// Starts a solo session; returns an error text, or null.
Future<String?> _startSolo() async {
final profile = Profile.instance;
try {
await profile.ensureLoaded();
final name = profile.name.peek().trim();
await SessionState.instance.startSolo(ConnectParams(
host: '127.0.0.1',
port: 0,
secret: '',
name: name.isEmpty ? 'Me' : name,
avatar: profile.avatarBase64(),
));
return null;
} catch (e) {
return "couldn't start playing: $e";
}
}
/// Library's "Watch solo": somewhere to pick from (Jellyfin when linked,
/// else Files).
void _watchSolo() => _openLibrary(_jellyfin.isEmpty ? MediaKind.file : MediaKind.jellyfin);
// --------------------------------------------------------------- handoff
/// Nothing (a dialog, a popover) is over the shell.
@@ -423,11 +455,10 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
final align = at == null || size.isEmpty
? Alignment.center
: Alignment((at.dx / size.width * 2 - 1).clamp(-1, 1), (at.dy / size.height * 2 - 1).clamp(-1, 1));
final online = _online.peek();
_handoffOpen = true;
final HandoffAction? action;
try {
action = await showPopover<HandoffAction>(context, at: align, child: HandoffCard(item: item, online: online));
action = await showPopover<HandoffAction>(context, at: align, child: HandoffCard(item: item, solo: _alone.peek()));
} finally {
_handoffOpen = false;
}
@@ -441,10 +472,6 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
await FolderStore.instance.ensureLoaded();
FolderStore.instance.addRecent(paths.first);
}
case (HandoffVideos(:final paths), HandoffAction.openInFiles):
await FolderStore.instance.ensureLoaded();
FolderStore.instance.addRecent(paths.first);
_openFiles(null);
case (HandoffVideos(:final paths), HandoffAction.addFolder):
await _addFolder(File(paths.first).parent.path);
case (HandoffFolder(:final path), _):
@@ -474,7 +501,9 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
_selectTab(ShellTab.library);
}
// Off on Settings: its own drop targets (cookies) take the files there.
Widget _dropTarget(Widget child) => DropTarget(
enable: _tab != ShellTab.settings,
onDragEntered: (_) => setState(() => _dragging = _onTop),
onDragExited: (_) => setState(() => _dragging = false),
onDragDone: (d) {
@@ -501,7 +530,13 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
// --------------------------------------------------------------- dialogs
/// Leave asks first; a solo session just stops.
Future<void> _confirmLeave() async {
final session = SessionState.instance;
if (session.solo.peek()) {
await session.leave();
return;
}
final ok = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
@@ -520,12 +555,6 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
if (ok == true) await SessionState.instance.leave();
}
Future<void> _showKicked(String reason) async {
if (reason == _lastKickShown) return;
_lastKickShown = reason;
await _alert('You were removed', reason.isEmpty ? 'The host removed you from the server.' : reason);
}
Future<void> _alert(String title, String text) async {
if (!mounted || _dialogOpen) return;
_dialogOpen = true;
@@ -565,6 +594,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
backgroundColor: _fullscreen ? Colors.black : scheme.surface,
body: SignalBuilder(builder: (context) {
final online = _online.value;
final alone = _alone.value;
final hasMedia = _hasMedia.value;
return Column(
children: [
@@ -579,6 +609,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
onJoin: () => _connect(ConnectMode.join),
onHost: () => _connect(ConnectMode.host),
onLeave: _confirmLeave,
onInvite: () => _connect(ConnectMode.host),
onProfile: () => showProfileCard(context, onOpenSettings: _openSettings, updates: widget.updates),
onShowPlayer: hasMedia && _mode == PlayerMode.none ? () => _show(PlayerMode.mini) : null,
updates: widget.updates,
@@ -588,7 +619,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
key: const ValueKey('body'),
child: Row(
children: [
Expanded(child: _body(online)),
Expanded(child: _body(online, alone)),
AnimatedSize(
duration: easeTime,
curve: ease,
@@ -612,21 +643,38 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
);
}
Widget _body(bool online) {
/// No Jellyfin account, no server joined or hosted yet, band not
/// dismissed: the welcome band explains the three ways in.
bool get _newcomer =>
!AppSettings.instance.welcomeDismissed.value &&
_jellyfin.isEmpty &&
SessionState.instance.recents.value.isEmpty;
Widget _body(bool online, bool alone) {
final coord = SessionState.instance.coordinator;
final tabBody = switch (_tab) {
ShellTab.library => LibraryTab(
key: ValueKey('library-$_libraryGen'),
controller: _picker,
accounts: _jellyfin,
online: online,
isHost: coord.hostFile != null,
solo: alone,
// Offline, a put-up starts a solo session, which hosts.
isHost: !online || coord.hostFile != null,
// Only for newcomers: the strip has Join and Host for everyone else.
banner: online || !_newcomer
? null
: WelcomeBand(
onJoin: () => _connect(ConnectMode.join),
onHost: () => _connect(ConnectMode.host),
onSolo: _watchSolo,
onDismiss: () => setState(AppSettings.instance.dismissWelcome),
),
jellyfinStart: _libraryStart,
onPutUp: _putUp,
onOpenIntegrations: () => _openSettings(SettingsSection.integrations),
),
ShellTab.schedule => SchedulePage(
online: online,
solo: alone,
onPutUp: _putUp,
onToast: coord.showToast,
),
+21 -4
View File
@@ -17,6 +17,8 @@ class AvatarInfo {
final Color color;
final ImageProvider? image;
final bool ready, buffering, relaying, stale, host, self;
/// Sitting out: dimmed, with an "away" mark.
final bool out;
const AvatarInfo({
this.id = '',
required this.name,
@@ -28,6 +30,7 @@ class AvatarInfo {
this.stale = false,
this.host = false,
this.self = false,
this.out = false,
});
}
@@ -75,7 +78,7 @@ ImageProvider? memberAvatarImage(MemberView m, SessionCoordinator coord) {
}
/// Your own avatar from the profile (also offline).
AvatarInfo selfAvatarInfo({bool ready = false, bool host = false}) {
AvatarInfo selfAvatarInfo({bool ready = false, bool host = false, bool out = false}) {
final p = Profile.instance;
final png = p.picture.value;
final name = p.name.value;
@@ -86,6 +89,7 @@ AvatarInfo selfAvatarInfo({bool ready = false, bool host = false}) {
ready: ready,
host: host,
self: true,
out: out,
);
}
@@ -107,6 +111,7 @@ AvatarInfo memberAvatarInfo(MemberView m, StateBroadcast? r, SessionCoordinator
// knows before the first state.
host: m.host || (self && coord.hostFile != null),
self: self,
out: m.out,
);
}
@@ -132,7 +137,8 @@ class PartyWatch extends StatelessWidget {
/// Circle avatar with a status ring outside it and a gap in the background
/// colour, so overlapping neighbours never hide the status. Crown = host,
/// tower = passing the video on, spinner = buffering, dimmed = stale.
/// tower = passing the video on, spinner = buffering, dimmed = stale,
/// dimmed with a moon = sitting out.
class StatusAvatar extends StatelessWidget {
final AvatarInfo info;
final double size;
@@ -176,7 +182,7 @@ class StatusAvatar extends StatelessWidget {
decoration: BoxDecoration(shape: BoxShape.circle, color: bg),
padding: EdgeInsets.all(stroke),
child: Opacity(
opacity: info.stale ? 0.4 : 1,
opacity: info.stale || info.out ? 0.4 : 1,
child: CircleAvatar(
radius: size / 2,
backgroundColor: info.color,
@@ -205,6 +211,17 @@ class StatusAvatar extends StatelessWidget {
),
),
),
if (info.out)
Positioned(
key: const ValueKey('away-mark'),
left: -3,
bottom: -3,
child: Container(
padding: const EdgeInsets.all(2),
decoration: BoxDecoration(shape: BoxShape.circle, color: bg),
child: Icon(Icons.bedtime, size: (badge * 0.8).clamp(10.0, 24.0), color: Colors.blueGrey.shade200),
),
),
if (badges && info.relaying)
Positioned(
right: -3,
@@ -240,7 +257,7 @@ class _AvatarStackState extends State<AvatarStack> {
@override
Widget build(BuildContext context) {
final size = widget.size;
int rank(AvatarInfo m) => m.host ? 0 : (m.ready ? 1 : 2);
int rank(AvatarInfo m) => m.host ? 0 : (m.out ? 3 : m.ready ? 1 : 2);
final sorted = [...widget.members]..sort((a, b) => rank(a) - rank(b));
final visible = sorted.take(widget.shown).toList();
final extra = sorted.length - visible.length;
+5 -3
View File
@@ -232,9 +232,11 @@ class ActivityRow extends StatelessWidget {
return (Icons.swap_horiz, scheme.primary, 'changed the media');
case 'stale':
return (Icons.cloud_off, Colors.orange, 'lost signal');
case 'kick':
final reason = f.reason.isEmpty ? '' : ' · ${f.reason}';
return (Icons.gavel, Colors.red, 'kicked ${f.target ?? ''}$reason');
case 'out':
final who = f.target;
return (Icons.bedtime, Colors.blueGrey, who == null || who.isEmpty ? 'is sitting out' : 'sat $who out');
case 'back':
return (Icons.login, Colors.green, 'joined back');
default:
final target = f.target == null || f.target!.isEmpty ? '' : ' ${f.target}';
return (Icons.info_outline, Colors.grey, '${f.action}$target');
+26 -10
View File
@@ -41,8 +41,9 @@ class HostFields {
}
}
/// The strip's Join / Host buttons: a card over the window. It closes
/// itself once the attempt got in; a failed one shows its error inside.
/// The strip's Join / Host buttons (and solo's Invite friends, the Host
/// card): a card over the window. It closes itself once the attempt got
/// in; a failed one shows its error inside.
Future<void> showConnectCard(
BuildContext context, {
required ConnectMode mode,
@@ -77,7 +78,9 @@ class _CloseWhenOnlineState extends State<_CloseWhenOnline> {
void initState() {
super.initState();
_stop = effect(() {
if (SessionState.instance.route.value != AppRoute.session) return;
final s = SessionState.instance;
// Solo is in a session too: wait until it is a hosted server.
if (s.route.value != AppRoute.session || s.solo.value || s.switching.value) return;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) Navigator.of(context).maybePop();
});
@@ -153,7 +156,7 @@ class JoinCard extends StatelessWidget {
builder: (context, _) {
final busy = flow.busy == ConnectMode.join;
return _Card(
title: 'Join a server',
title: 'Join a friend',
children: [
SignalBuilder(builder: (context) {
final list = [
@@ -221,6 +224,13 @@ class JoinCard extends StatelessWidget {
const SizedBox(height: 16),
ConnectButton(flow: flow, mode: ConnectMode.join, label: 'Join', onSubmit: _join, expand: true),
ConnectFeedback(flow: flow, mode: ConnectMode.join),
const Padding(
padding: EdgeInsets.only(top: 8),
child: Text(
'Your friend gives you the address and password. No account or Jellyfin needed.',
style: TextStyle(color: Colors.grey, fontSize: 12),
),
),
],
);
},
@@ -243,10 +253,13 @@ class HostCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
// From a solo session this is "Invite friends": same server settings,
// and what plays carries over.
final solo = SessionState.instance.solo.peek();
return ListenableBuilder(
listenable: flow,
builder: (context, _) => _Card(
title: 'Host a server',
title: solo ? 'Invite friends' : 'Host a server',
children: [
AbsorbPointer(
absorbing: flow.busy == ConnectMode.host,
@@ -281,13 +294,16 @@ class HostCard extends StatelessWidget {
),
),
const SizedBox(height: 16),
ConnectButton(flow: flow, mode: ConnectMode.host, label: 'Host', onSubmit: _host, expand: true),
ConnectButton(
flow: flow, mode: ConnectMode.host, label: solo ? 'Start hosting' : 'Host', onSubmit: _host, expand: true),
ConnectFeedback(flow: flow, mode: ConnectMode.host),
const Padding(
padding: EdgeInsets.only(top: 8),
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(
'You pick what to watch afterwards, in the library.',
style: TextStyle(color: Colors.grey, fontSize: 12),
solo
? 'Your video keeps playing from where it is; friends join with your address.'
: 'You pick what to watch afterwards, in the library.',
style: const TextStyle(color: Colors.grey, fontSize: 12),
),
),
],
+7 -12
View File
@@ -4,7 +4,6 @@ import 'package:flutter/material.dart';
import '../files/scanner.dart' show FileKind, kindOf;
import '../files/ui/file_tiles.dart' show baseName;
import 'library_tab.dart' show offlinePutUpHint;
// Something handed to the app, by a drop or by Ctrl+V outside a text
// field: what it is ([classifyHandoff], pure) and the small card that asks
@@ -95,10 +94,8 @@ Handoff classifyHandoff({
/// What the card's buttons ask the shell to do.
enum HandoffAction {
/// Put the (first) video up for everyone.
/// Put the (first) video up (offline: watch it solo).
putUp,
/// Offline: list the video in Files → Recent and open it there.
openInFiles,
/// Add the video's folder (or the folder) to Files and open it.
addFolder,
/// Open Library → Link with the URL filled.
@@ -109,8 +106,9 @@ enum HandoffAction {
/// pops the [HandoffAction] chosen.
class HandoffCard extends StatelessWidget {
final Handoff item;
final bool online;
const HandoffCard({super.key, required this.item, required this.online});
/// Nobody else watches (offline or solo): videos play, no relay talk.
final bool solo;
const HandoffCard({super.key, required this.item, required this.solo});
@override
Widget build(BuildContext context) {
@@ -128,10 +126,7 @@ class HandoffCard extends StatelessWidget {
baseName(paths.first),
paths.length > 1 ? 'and ${paths.length - 1} more · the first one goes up' : 'A video on this computer',
[
if (online)
button(HandoffAction.putUp, Icons.play_arrow, 'Put it up', primary: true)
else
button(HandoffAction.openInFiles, Icons.history, 'Open in Files', primary: true),
button(HandoffAction.putUp, Icons.play_arrow, solo ? 'Play' : 'Put it up', primary: true),
button(HandoffAction.addFolder, Icons.create_new_folder_outlined, 'Add its folder to Files'),
],
),
@@ -144,9 +139,9 @@ class HandoffCard extends StatelessWidget {
HandoffLink(:final url) => (
Icons.link,
url,
online ? 'Pick how the others get it on the Link page' : offlinePutUpHint,
solo ? 'Opens it on the Link page' : 'Pick how the others get it on the Link page',
[
button(HandoffAction.openLink, Icons.link, online ? 'Put it up as a link…' : 'Open in Link',
button(HandoffAction.openLink, Icons.link, solo ? 'Open in Link' : 'Put it up as a link…',
primary: true),
],
),
+57 -52
View File
@@ -1,10 +1,11 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:sync_relay_core/core.dart' show LinkProbe, hasMediaExtension, notAVideoText;
import '../playback/yt_dlp_app.dart';
import '../screens/home/media_picker.dart';
import '../state/connect_prefs.dart';
import 'motion.dart';
@@ -109,49 +110,49 @@ class LinkPreview {
const LinkPreview({required this.title, required this.subtitle, this.thumbnail});
}
/// GET [uri] and return the body (oEmbed).
typedef OEmbedGetter = Future<String> Function(Uri uri);
/// What a link is (see [AppYtDlp.probe]).
typedef LinkProber = Future<LinkProbe> Function(String url);
Future<String> defaultOEmbedGet(Uri uri) async {
final client = HttpClient()..connectionTimeout = const Duration(seconds: 6);
try {
final req = await client.getUrl(uri);
final res = await req.close().timeout(const Duration(seconds: 8));
if (res.statusCode != 200) throw HttpException('status ${res.statusCode}', uri: uri);
return await res.transform(utf8.decoder).join().timeout(const Duration(seconds: 8));
} finally {
client.close(force: true);
}
}
bool isYouTubeLink(Uri uri) {
final h = uri.host.toLowerCase();
return h == 'youtu.be' || h == 'youtube.com' || h.endsWith('.youtube.com');
}
Future<LinkProbe> defaultLinkProber(String url) => AppYtDlp.instance.probe(url);
String _host(Uri uri) => uri.host.startsWith('www.') ? uri.host.substring(4) : uri.host;
/// The preview of [url]: YouTube links ask YouTube's oEmbed for the title,
/// channel and thumbnail; anything else (or a failed lookup) shows the
/// file name and host. Null for text that is not an http(s) link.
Future<LinkPreview?> previewLink(String url, {OEmbedGetter get = defaultOEmbedGet}) async {
/// "3:07", "1:02:03".
String clockText(double seconds) {
final s = seconds.round();
final h = s ~/ 3600, m = s % 3600 ~/ 60, sec = (s % 60).toString().padLeft(2, '0');
return h > 0 ? '$h:${m.toString().padLeft(2, '0')}:$sec' : '$m:$sec';
}
/// The preview of [url]: a web page shows yt-dlp's title, uploader,
/// duration and thumbnail (or why it is not a video); a media file (by
/// name or by probe) its file name and host. Null for text that is not an http(s) link.
Future<LinkPreview?> previewLink(String url, {LinkProber probe = defaultLinkProber}) async {
final uri = Uri.tryParse(url.trim());
if (uri == null || !(uri.scheme == 'http' || uri.scheme == 'https') || uri.host.isEmpty) return null;
final plain = LinkPreview(title: LinkMedia(url.trim()).label, subtitle: _host(uri));
if (!isYouTubeLink(uri)) return plain;
final host = _host(uri);
// A media file name needs no lookup.
if (hasMediaExtension(uri)) return LinkPreview(title: LinkMedia(url.trim()).label, subtitle: host);
final LinkProbe p;
try {
final body = await get(Uri.https('www.youtube.com', '/oembed', {'url': url.trim(), 'format': 'json'}));
final j = jsonDecode(body);
if (j is! Map || j['title'] is! String) return plain;
final author = j['author_name'];
return LinkPreview(
title: j['title'] as String,
subtitle: [_host(uri), if (author is String && author.isNotEmpty) author].join(' · '),
thumbnail: j['thumbnail_url'] is String ? j['thumbnail_url'] as String : null,
);
} catch (_) {
return plain;
p = await probe(url.trim());
} catch (e) {
return LinkPreview(title: LinkMedia(url.trim()).label, subtitle: '$host · $e');
}
final info = p.info;
if (!p.isPage) return LinkPreview(title: LinkMedia(url.trim()).label, subtitle: host);
if (info == null) {
return LinkPreview(title: p.title ?? host, subtitle: '$host · ${notAVideoText(p.ytDlpError)}');
}
return LinkPreview(
title: p.title ?? host,
subtitle: [
host,
if (info.uploader.isNotEmpty) info.uploader,
if (info.duration case final d? when d > 0) clockText(d),
].join(' · '),
thumbnail: info.thumbnail,
);
}
/// "today", "yesterday", "3 days ago", "last week", "5 weeks ago", ...
@@ -188,21 +189,25 @@ String linkRelayHelp(LinkRelay r) => switch (r) {
'while it plays; the link is not shown to anyone.',
};
/// The Link source. [offlineHint] set: the button is off and says why.
/// Under Play when nobody else watches.
const soloPlayHint = 'Plays just for you. Invite friends any time; it keeps playing.';
/// The Link source. [solo]: nobody else watches (offline or solo), so the
/// button says Play and there are no relay modes (the link plays direct).
class LibraryLinkSource extends StatefulWidget {
final MediaPickerController controller;
final String? offlineHint;
final bool solo;
/// Puts the link up; returns an error text, or null when sent.
final Future<String?> Function(LinkMedia media) onPutUp;
final RecentLinks recents;
final OEmbedGetter oembed;
final LinkProber probe;
const LibraryLinkSource({
super.key,
required this.controller,
required this.onPutUp,
required this.recents,
this.offlineHint,
this.oembed = defaultOEmbedGet,
this.solo = false,
this.probe = defaultLinkProber,
});
@override
@@ -246,7 +251,7 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
if (mounted) setState(() {});
return;
}
final p = await previewLink(url, get: widget.oembed);
final p = await previewLink(url, probe: widget.probe);
_previews[url] = p;
if (mounted) setState(() {});
}
@@ -260,19 +265,19 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
Future<void> _go() async {
final url = _c.link.text.trim();
if (url.isEmpty || _busy || widget.offlineHint != null) return;
if (url.isEmpty || _busy) return;
setState(() {
_busy = true;
_error = null;
});
String? error;
try {
error = await widget.onPutUp(LinkMedia(url, relay: _c.linkRelay));
error = await widget.onPutUp(LinkMedia(url, relay: widget.solo ? LinkRelay.direct : _c.linkRelay));
} catch (e) {
error = '$e';
}
if (error == null) {
final p = _previews[url] ?? await previewLink(url, get: widget.oembed);
final p = _previews[url] ?? await previewLink(url, probe: widget.probe);
unawaited(widget.recents.add(RecentLink(
url: url,
title: p?.title ?? LinkMedia(url).label,
@@ -291,7 +296,7 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final t = Theme.of(context).textTheme;
final hint = widget.offlineHint;
final solo = widget.solo;
final url = _c.link.text.trim();
final preview = url.isEmpty ? null : _previews[url];
final loading = url.isNotEmpty && !_previews.containsKey(url);
@@ -311,11 +316,11 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
);
final button = FilledButton.icon(
key: const ValueKey('link-put-up'),
onPressed: url.isEmpty || _busy || hint != null ? null : _go,
onPressed: url.isEmpty || _busy ? null : _go,
icon: _busy
? const SizedBox.square(dimension: 16, child: CircularProgressIndicator(strokeWidth: 2))
: const Icon(Icons.play_arrow),
label: Text(hint == null ? 'Put it up' : 'Play'),
label: Text(solo ? 'Play' : 'Put it up'),
style: FilledButton.styleFrom(
minimumSize: const Size(0, 56),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
@@ -346,7 +351,7 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
Text('Watch from a link', textAlign: TextAlign.center, style: t.headlineSmall),
const SizedBox(height: 6),
Text(
'A video file, YouTube, a Jellyfin link… Ctrl+V or drop it anywhere to bring it here.',
'A video file, a page (YouTube, X, …) or a Jellyfin link. Ctrl+V or drop it anywhere to bring it here.',
textAlign: TextAlign.center,
style: TextStyle(color: scheme.onSurfaceVariant),
),
@@ -360,10 +365,10 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
])
: Row(children: [Expanded(child: field), const SizedBox(width: 12), button]),
),
if (hint != null || _error != null) ...[
if (solo || _error != null) ...[
const SizedBox(height: 8),
Text(
_error ?? hint!,
_error ?? soloPlayHint,
key: const ValueKey('link-hint'),
textAlign: TextAlign.center,
style: t.bodySmall?.copyWith(color: _error != null ? scheme.error : Colors.grey),
@@ -379,7 +384,7 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
child: _PreviewCard(preview: preview, loading: loading),
),
),
if (hint == null) ...[
if (!solo) ...[
const SizedBox(height: 28),
Text('How the others get it', style: t.titleSmall),
const SizedBox(height: 10),
+32 -19
View File
@@ -16,6 +16,7 @@ import '../screens/home/media_picker.dart';
import '../state/session_state.dart';
import 'library_link.dart';
import 'motion.dart';
import 'welcome.dart';
/// What putting up a Jellyfin pick sends: its ref (no URL, no token).
/// Throws [FormatException] with a user-facing message when the server is
@@ -58,9 +59,6 @@ String? nowPlayingFile(Map<String, Object?>? media, String lastPath) {
return name == media['name'] ? lastPath : null;
}
/// Offline, nothing goes up; this says why.
const offlinePutUpHint = 'Join or host to watch together';
/// Window width under which the details panel floats over the library.
const detailsDockFrom = 1000.0;
@@ -68,12 +66,13 @@ const detailsDockFrom = 1000.0;
/// through), the Jellyfin library chips beside it, and the source below.
/// Jellyfin cards select on click and open their details in a panel at
/// the right (docked from [detailsDockFrom], floating over the library
/// below it); a double-click or the panel's button puts it up. Offline
/// everything browses and the play buttons say [offlinePutUpHint].
/// below it); a double-click or the panel's button puts it up. With
/// nobody else watching ([solo]: offline or a solo session) the buttons
/// say Play and putting something up starts (or uses) a solo session.
class LibraryTab extends StatefulWidget {
final MediaPickerController controller;
final JellyfinAccounts accounts;
final bool online;
final bool solo;
/// This app hosts the server: its files stream from here directly.
final bool isHost;
final BrowseStart? jellyfinStart;
@@ -83,26 +82,29 @@ class LibraryTab extends StatefulWidget {
final VoidCallback? onOpenIntegrations;
/// Defaults to [RecentLinks.instance].
final RecentLinks? recentLinks;
final OEmbedGetter oembed;
final LinkProber probe;
/// The Files source (tests inject one); defaults to [FilesPage].
final Widget Function(BuildContext context, {required bool online, required bool relayed,
final Widget Function(BuildContext context, {required bool solo, required bool relayed,
required Future<String?> Function(String path) onPlay, String? nowPlayingPath})? filesBuilder;
/// Clock for the Jellyfin Continue row (tests pin it).
final DateTime Function() now;
/// Over the source switch (the shell's welcome band while offline).
final Widget? banner;
const LibraryTab({
super.key,
required this.controller,
required this.accounts,
required this.online,
required this.solo,
required this.isHost,
required this.onPutUp,
this.onPutUpDone,
this.jellyfinStart,
this.onOpenIntegrations,
this.recentLinks,
this.oembed = defaultOEmbedGet,
this.probe = defaultLinkProber,
this.filesBuilder,
this.now = DateTime.now,
this.banner,
});
@override
@@ -115,7 +117,6 @@ class _LibraryTabState extends State<LibraryTab> {
JellyfinSelection? _shown;
MediaPickerController get _c => widget.controller;
String? get _hint => widget.online ? null : offlinePutUpHint;
@override
void initState() {
@@ -163,7 +164,6 @@ class _LibraryTabState extends State<LibraryTab> {
/// A Jellyfin card or episode: a series goes up as its next episode.
Future<String?> _putUpJellyfin(JellyfinSelection sel) async {
if (!widget.online) return offlinePutUpHint;
final account = widget.accounts.account(sel.serverId);
final client = widget.accounts.clientFor(sel.serverId);
if (account == null || client == null) return 'that Jellyfin server is not linked any more';
@@ -181,7 +181,6 @@ class _LibraryTabState extends State<LibraryTab> {
/// Double-click on a card: put it up, errors as a snack bar.
Future<void> _doublePutUp(JellyfinSelection sel) async {
_c.select(sel);
if (!widget.online) return;
final error = await _putUpJellyfin(sel);
if (error != null && mounted) {
ScaffoldMessenger.maybeOf(context)?.showSnackBar(SnackBar(content: Text(error)));
@@ -239,13 +238,18 @@ class _LibraryTabState extends State<LibraryTab> {
onOpenIntegrations: widget.onOpenIntegrations,
start: widget.jellyfinStart,
libraryStrip: false,
empty: (context) => NoJellyfinPanel(
accounts: widget.accounts,
onFiles: () => _c.source = MediaKind.file,
onLink: () => _c.source = MediaKind.link,
),
),
),
MediaKind.link => LibraryLinkSource(
controller: _c,
offlineHint: _hint,
solo: widget.solo,
recents: widget.recentLinks ?? RecentLinks.instance,
oembed: widget.oembed,
probe: widget.probe,
onPutUp: _put,
),
MediaKind.file => _files(context),
@@ -253,6 +257,15 @@ class _LibraryTabState extends State<LibraryTab> {
final main = Column(
children: [
AnimatedSize(
duration: easeTime,
curve: ease,
alignment: Alignment.topCenter,
child: AnimatedSwitcher(
duration: easeTime,
child: widget.banner ?? const SizedBox(width: double.infinity),
),
),
header,
Expanded(child: fadeThrough(KeyedSubtree(key: ValueKey(source), child: content))),
],
@@ -267,7 +280,7 @@ class _LibraryTabState extends State<LibraryTab> {
child: JellyfinDetailsPanel(
controller: _browse,
selection: shown,
offlineHint: _hint,
solo: widget.solo,
onPutUp: _putUpJellyfin,
onClose: () => _c.select(null),
),
@@ -328,14 +341,14 @@ class _LibraryTabState extends State<LibraryTab> {
Future<String?> play(String path) => _put(FileMedia(path));
return SignalBuilder(builder: (context) {
final session = SessionState.instance;
final now = widget.online
final now = session.route.value == AppRoute.session
? nowPlayingFile(session.coordinator.serverState.value?.media, session.lastForm.mediaPath)
: null;
final b = widget.filesBuilder;
if (b != null) {
return b(context, online: widget.online, relayed: relayed, onPlay: play, nowPlayingPath: now);
return b(context, solo: widget.solo, relayed: relayed, onPlay: play, nowPlayingPath: now);
}
return FilesPage(online: widget.online, relayed: relayed, onPlay: play, nowPlayingPath: now);
return FilesPage(solo: widget.solo, relayed: relayed, onPlay: play, nowPlayingPath: now);
});
}
}
+117 -30
View File
@@ -11,7 +11,6 @@ import '../screens/session/jellyfin_widgets.dart' show ViaHostBadge;
import '../state/session_state.dart';
import '../update/update_controller.dart';
import '../widgets/app_keys.dart';
import '../widgets/dialogs.dart';
import '../widgets/window_buttons.dart';
import 'avatar.dart';
import 'motion.dart';
@@ -40,8 +39,9 @@ bool viaHostOf(MemberView m, JellyfinRef? ref) =>
ref != null && m.jf[ref.serverId] == false;
/// The 60 px strip over everything: your avatar (profile), the tabs, and
/// the party: offline Join / Host, online the avatars (members card),
/// server and readiness, chat and Leave. Empty space drags the window.
/// the party: offline Join a friend / Host, solo a "Solo" chip, Invite
/// friends and Stop, online the avatars (members card), server and
/// readiness, chat and Leave. Empty space drags the window.
class PartyStrip extends StatelessWidget {
final ShellTab tab;
final ValueChanged<ShellTab> onTab;
@@ -52,6 +52,8 @@ class PartyStrip extends StatelessWidget {
final VoidCallback onHost;
final VoidCallback onLeave;
final VoidCallback onProfile;
/// Solo: host from here, keeping what plays.
final VoidCallback? onInvite;
/// Online with something up but the player closed: brings it back.
final VoidCallback? onShowPlayer;
@@ -70,6 +72,7 @@ class PartyStrip extends StatelessWidget {
required this.onHost,
required this.onLeave,
required this.onProfile,
this.onInvite,
this.onShowPlayer,
this.updates,
this.windowControls = true,
@@ -84,6 +87,7 @@ class PartyStrip extends StatelessWidget {
final session = SessionState.instance;
final coord = session.coordinator;
final online = session.route.value == AppRoute.session;
final solo = online && (session.solo.value || session.switching.value);
final self = online ? coord.selfMember.value : null;
return Row(
children: [
@@ -98,7 +102,7 @@ class PartyStrip extends StatelessWidget {
child: StatusAvatar(
info: selfAvatarInfo(
ready: self?.ready ?? false,
host: online && coord.hostFile != null,
host: online && !solo && coord.hostFile != null,
),
size: stripAvatarSize,
ring: online,
@@ -131,14 +135,36 @@ class PartyStrip extends StatelessWidget {
),
UpdateIndicator(controller: updates),
const SizedBox(width: 8),
if (online) ...[
if (onShowPlayer != null)
if (online && onShowPlayer != null)
IconButton(
key: const ValueKey('show-player'),
tooltip: 'Show the player',
onPressed: onShowPlayer,
icon: const Icon(Icons.smart_display_outlined, size: 20),
),
if (solo) ...[
_SoloChip(compact: compact, busy: session.switching.value),
const SizedBox(width: 10),
if (compact)
FilledButton(
key: const ValueKey('strip-invite'),
onPressed: session.switching.value ? null : onInvite,
child: const Text('Invite'),
)
else
FilledButton.icon(
key: const ValueKey('strip-invite'),
onPressed: session.switching.value ? null : onInvite,
icon: const Icon(Icons.group_add_outlined, size: 18),
label: const Text('Invite friends'),
),
const SizedBox(width: 8),
OutlinedButton(
key: const ValueKey('strip-leave'),
onPressed: session.switching.value ? null : onLeave,
child: const Text('Stop'),
),
] else if (online) ...[
PartyWatch(
builder: (context) => AvatarStack(
key: const ValueKey('party-avatars'),
@@ -178,24 +204,36 @@ class PartyStrip extends StatelessWidget {
child: const Text('Leave'),
),
] else ...[
if (!compact)
Text(
'Offline',
style: TextStyle(color: scheme.onSurfaceVariant),
if (!compact) ...[
Container(
width: 7,
height: 7,
decoration: BoxDecoration(color: scheme.outline, shape: BoxShape.circle),
),
const SizedBox(width: 6),
Text('Offline', style: TextStyle(color: scheme.onSurfaceVariant)),
const SizedBox(width: 12),
FilledButton.tonal(
],
if (compact) ...[
FilledButton(key: const ValueKey('strip-join'), onPressed: onJoin, child: const Text('Join')),
const SizedBox(width: 8),
FilledButton.tonal(key: const ValueKey('strip-host'), onPressed: onHost, child: const Text('Host')),
] else ...[
FilledButton.icon(
key: const ValueKey('strip-join'),
onPressed: onJoin,
child: const Text('Join'),
icon: const Icon(Icons.login_rounded, size: 18),
label: const Text('Join a friend'),
),
const SizedBox(width: 8),
FilledButton(
FilledButton.tonalIcon(
key: const ValueKey('strip-host'),
onPressed: onHost,
child: const Text('Host'),
icon: const Icon(Icons.podcasts_rounded, size: 18),
label: const Text('Host'),
),
],
],
const SizedBox(width: 8),
if (windowControls) ...[
SizedBox(
@@ -271,11 +309,54 @@ class _ServerLabel extends StatelessWidget {
);
}
/// "Your server" when hosting, else the address joined.
/// "Solo" alone, "Your server" when hosting, else the joined server's
/// name (its `--name`), or its address while unknown.
String serverName() {
final session = SessionState.instance;
if (session.solo.value) return 'Solo';
if (session.coordinator.hostFile != null) return 'Your server';
return session.lastForm.host;
final label = session.serverLabel.value;
return label.isNotEmpty ? label : session.lastForm.host;
}
/// Solo: just you, on your own; a spinner while it becomes a server.
class _SoloChip extends StatelessWidget {
final bool compact;
final bool busy;
const _SoloChip({required this.compact, required this.busy});
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return Tooltip(
message: busy
? 'Starting your server…'
: 'Watching on your own. Nobody can join until you invite friends.',
child: Container(
key: const ValueKey('solo-chip'),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: scheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(20),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (busy)
const SizedBox.square(dimension: 14, child: CircularProgressIndicator(strokeWidth: 2))
else
Icon(Icons.person_outline_rounded, size: 16, color: scheme.primary),
const SizedBox(width: 6),
Text(busy ? 'Inviting…' : 'Solo', style: const TextStyle(fontWeight: FontWeight.w600)),
if (!compact && !busy) ...[
const SizedBox(width: 6),
Text('only you', style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12)),
],
],
),
),
);
}
}
/// One strip tab: label and an underline that grows in when selected.
@@ -337,7 +418,8 @@ class StripTab extends StatelessWidget {
}
/// Who is in the server, from the avatars: status, host / relay pills,
/// per member force ready and kick, and the address to invite friends.
/// per member force ready, nudge and sit out, and the address to invite
/// friends.
Future<void> showMembersCard(BuildContext context) => showPopover<void>(
context,
at: const Alignment(0.7, -0.84),
@@ -469,6 +551,8 @@ class _MemberTile extends StatelessWidget {
final m = member;
final status = m.stale
? 'reconnecting…'
: m.out
? 'sitting out'
: info.buffering
? 'buffering ${m.buffered.toStringAsFixed(1)} s'
: m.ready
@@ -494,27 +578,30 @@ class _MemberTile extends StatelessWidget {
trailing: PopupMenuButton<String>(
tooltip: 'More',
icon: const Icon(Icons.more_vert, size: 18),
onSelected: (v) async {
if (v == 'force') {
onSelected: (v) {
switch (v) {
case 'force':
coord.forceTarget(m.id, !m.ready);
} else if (v == 'kick') {
final ok = await showKickConfirmDialog(context, m.name);
if (ok == true) coord.kick(m.id);
case 'nudge':
coord.nudgeMember(m.id);
case 'out':
coord.sitOut(m.id);
case 'back':
coord.joinBack();
}
},
itemBuilder: (_) => [
if (!m.out)
PopupMenuItem(
value: 'force',
child: Text(m.ready ? 'Force unready' : 'Force ready'),
),
if (!info.self && !info.host)
PopupMenuItem(
value: 'kick',
child: Text(
'Kick',
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
),
if (!info.self)
const PopupMenuItem(value: 'nudge', child: Text('Nudge')),
if (info.self && m.out)
const PopupMenuItem(value: 'back', child: Text('Join back'))
else if (!m.out)
const PopupMenuItem(value: 'out', child: Text('Sit out')),
],
),
);
+5 -10
View File
@@ -11,7 +11,7 @@ import 'avatar.dart';
import 'motion.dart';
/// What the player's status pill says, most pressing first.
enum StatusKind { kicked, reconnecting, problem, connecting, finished, loading, you, waiting }
enum StatusKind { reconnecting, problem, connecting, finished, loading, you, waiting }
/// The pill's button: stop reconnecting, retry, or mark yourself ready
/// (the whole pill).
@@ -59,16 +59,10 @@ String _sentence(String s) => s.isEmpty ? s : s[0].toUpperCase() + s.substring(1
StatusView? playerStatusFor({
required HealthStatus health,
bool reconnecting = false,
String? kickReason,
StateBroadcast? state,
MemberView? self,
bool slowConnect = false,
}) {
if (kickReason != null) {
return StatusView(StatusKind.kicked,
kickReason.isEmpty ? 'You were removed from the server' : 'You were removed: $kickReason',
tone: HealthSeverity.error, short: 'Removed from the server');
}
if (reconnecting) {
return const StatusView(StatusKind.reconnecting, 'Reconnecting…',
tone: HealthSeverity.warn, action: StatusAction.cancel);
@@ -82,6 +76,8 @@ StatusView? playerStatusFor({
}
final r = state;
if (r == null || r.media == null || r.transport == 'playing') return null;
// Sitting out: the join-back card says it; nobody waits for you.
if (self != null && self.out) return null;
if (r.transport == 'finished') {
return const StatusView(StatusKind.finished, 'Finished · seek back or pick the next video', short: 'Finished');
}
@@ -100,7 +96,8 @@ StatusView? playerStatusFor({
return StatusView(StatusKind.you, "Press Space when you're ready",
who: [self], self: true, action: StatusAction.ready, detail: 'or click here', short: 'Press Space when ready');
}
final active = [for (final m in r.members) if (!m.stale) m];
// Members sitting out are never waited for nor counted.
final active = [for (final m in r.members) if (!m.stale && !m.out) m];
final others = [
for (final m in active)
if ((self == null || m.id != self.id) && !(m.inSet && m.ready)) m,
@@ -117,7 +114,6 @@ StatusView? playerStatusNow({bool slowConnect = false}) {
return playerStatusFor(
health: c.health.value,
reconnecting: c.reconnecting.value,
kickReason: c.kickReason.value,
state: c.serverState.value,
self: c.selfMember.value,
slowConnect: slowConnect,
@@ -258,7 +254,6 @@ class _StatusLine extends StatelessWidget {
Widget? _lead() {
final v = view;
switch (v.kind) {
case StatusKind.kicked:
case StatusKind.problem:
final error = v.tone == HealthSeverity.error;
return Icon(error ? Icons.error_outline_rounded : Icons.warning_amber_rounded,
+15 -3
View File
@@ -78,10 +78,11 @@ class _ProfileCardState extends State<ProfileCard> {
final coord = session.coordinator;
final online = session.route.value == AppRoute.session;
final self = online ? coord.selfMember.value : null;
final hosting = online && coord.hostFile != null;
final hosting = online && !session.solo.value && coord.hostFile != null;
final color = _p.color.value;
final png = _p.picture.value;
final me = selfAvatarInfo(ready: self?.ready ?? false, host: hosting);
final out = self?.out ?? false;
final me = selfAvatarInfo(ready: self?.ready ?? false, host: hosting, out: out);
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
@@ -166,7 +167,8 @@ class _ProfileCardState extends State<ProfileCard> {
Pill(online ? 'in a server' : 'offline',
online ? Colors.greenAccent : Colors.grey),
if (online)
Pill((self?.ready ?? false) ? 'ready' : 'not ready', Colors.lightBlueAccent),
Pill(out ? 'sitting out' : (self?.ready ?? false) ? 'ready' : 'not ready',
Colors.lightBlueAccent),
],
),
],
@@ -237,6 +239,16 @@ class _ProfileCardState extends State<ProfileCard> {
Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Column(children: [
if (self != null)
_link(
out ? Icons.login_rounded : Icons.event_seat_outlined,
out ? 'Join back' : 'Sit out',
trailing: Text(out ? 'Space' : 'stay in the server', style: small),
onTap: () {
out ? coord.joinBack() : coord.sitOut();
Navigator.of(context).maybePop();
},
),
_link(
Icons.extension_outlined,
'Jellyfin & integrations',
+48 -4
View File
@@ -30,6 +30,7 @@ import 'avatar.dart';
import 'motion.dart';
import 'now_playing_title.dart';
import 'player_status.dart';
import 'sit_out_card.dart';
import 'start_banner.dart';
/// Where the session's video is shown: nowhere, the floating mini player,
@@ -164,8 +165,15 @@ class _SessionPlayerState extends State<SessionPlayer> {
/// dialog, a card, a text field) is in front of it. Space and Tab are
/// app-wide ([AppKeys]).
bool _globalKey(KeyEvent event) {
if (event is! KeyDownEvent || !mounted || !_full) return false;
if (event is! KeyDownEvent || !mounted) return false;
if (SessionState.instance.route.peek() != AppRoute.session) return false;
// Mini: Esc is the last step out, asking to leave the server.
if (!_full) {
if (event.logicalKey != LogicalKeyboardKey.escape) return false;
if (ModalRoute.of(context)?.isCurrent == false || textInputFocused()) return false;
widget.onLeave();
return true;
}
final route = ModalRoute.of(context);
if (route != null && !route.isCurrent) {
// A menu of ours is open: Esc closes it.
@@ -240,7 +248,8 @@ class _SessionPlayerState extends State<SessionPlayer> {
} else if (widget.fullscreen) {
widget.onToggleFullscreen();
} else {
widget.onLeave();
// One step at a time: big player → mini, then (from mini) leave.
widget.onCollapse();
}
return true;
}
@@ -287,9 +296,12 @@ class _SessionPlayerState extends State<SessionPlayer> {
_status(),
_progressLine(),
_jellyfinOverlay(),
if (widget.fullscreen) ...[_pausedParty(), _unreadChip()],
if (widget.fullscreen) ...[if (!SessionState.instance.solo.value) _pausedParty(), _unreadChip()],
] else
_miniOverlay(),
// Sitting out: nothing is loaded; the card covers the
// empty player (which stays mounted for joining back).
if (_coord.selfOut.value) _sitOut(),
],
);
}),
@@ -776,6 +788,38 @@ class _SessionPlayerState extends State<SessionPlayer> {
),
);
/// [SitOutCard] with the mini's (or the full player's) expand /
/// collapse and close.
Widget _sitOut() => Positioned.fill(
child: Stack(
fit: StackFit.expand,
children: [
SitOutCard(key: const ValueKey('sit-out-card'), compact: !_full),
Positioned(
top: 4,
right: 4,
child: Row(
mainAxisSize: MainAxisSize.min,
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),
),
IconButton(
focusNode: _noFocus,
onPressed: widget.onClose,
tooltip: 'Close the player',
icon: const Icon(Icons.close, color: Colors.white, size: 18),
),
],
),
),
],
),
);
/// Mini: title, expand and close on hover; ready, time and a seekable
/// bar at the bottom. A thin progress line stays while not hovered.
Widget _miniOverlay() {
@@ -815,7 +859,7 @@ class _SessionPlayerState extends State<SessionPlayer> {
IconButton(
focusNode: _noFocus,
onPressed: widget.onClose,
tooltip: 'Stop watching (stay in the server)',
tooltip: 'Sit out (stay in the server)',
icon: const Icon(Icons.close, color: Colors.white, size: 18),
),
],
+348 -4
View File
@@ -1,11 +1,18 @@
import 'dart:async';
import 'dart:io';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:fox_utils/fox_utils.dart' show DesktopDropX, DropTarget;
import 'package:signals/signals_flutter.dart';
import 'package:sync_relay_core/core.dart'
show CookieBrowser, CookieFile, NetscapeCookie, YtDlp, YtDlpCookies, cookieBrowsers, parseCookies;
import '../controls/player_settings.dart';
import '../jellyfin/accounts.dart';
import '../jellyfin/accounts_panel.dart';
import '../playback/yt_dlp_app.dart';
import '../schedule/animeflow.dart';
import '../screens/home/about_page.dart';
import '../screens/home/settings_page.dart';
@@ -95,6 +102,19 @@ class SettingsTab extends StatelessWidget {
),
),
),
SettingsPanel(
title: 'When idle',
child: SignalBuilder(
builder: (context) => SwitchListTile(
key: const ValueKey('idle-sits-out'),
contentPadding: EdgeInsets.zero,
title: const Text('When idle, sit out instead of just unready'),
subtitle: const Text('After 10 min away while ready and held, your player unloads the video until you join back (Space)'),
value: AppSettings.instance.idleSitsOut.value,
onChanged: AppSettings.instance.setIdleSitsOut,
),
),
),
];
case SettingsSection.player:
return [
@@ -107,6 +127,7 @@ class SettingsTab extends StatelessWidget {
child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: playerSettingRows()),
),
const SettingsPanel(title: 'Custom shaders', child: CustomShaderFiles()),
const SettingsPanel(title: 'yt-dlp', child: YtDlpSettings()),
];
case SettingsSection.integrations:
final accounts = jellyfin ?? JellyfinAccounts.instance;
@@ -352,6 +373,285 @@ class RecentServersPanel extends StatelessWidget {
}
}
/// The app's yt-dlp (web page links): its version, "Update now", and the
/// last error.
class YtDlpSettings extends StatefulWidget {
/// Defaults to [AppYtDlp.instance]'s.
final Future<YtDlp>? ytDlp;
const YtDlpSettings({super.key, this.ytDlp});
@override
State<YtDlpSettings> createState() => _YtDlpSettingsState();
}
class _YtDlpSettingsState extends State<YtDlpSettings> {
late final Future<YtDlp> _y = widget.ytDlp ?? AppYtDlp.instance.ytDlp;
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return FutureBuilder<YtDlp>(
future: _y,
builder: (context, snap) {
final y = snap.data;
if (y == null) return const SizedBox.shrink();
return StreamBuilder<void>(
stream: y.changes,
builder: (context, _) {
final err = y.lastError;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.travel_explore),
title: Text(y.busy
? 'Downloading…'
: y.version == null
? 'Not downloaded yet'
: 'Version ${y.version}'),
subtitle: err == null
? null
: Text(err, key: const ValueKey('ytdlp-error'), style: TextStyle(color: scheme.error)),
trailing: TextButton(
onPressed: y.busy
? null
: () => y.path == null ? y.ensure() : y.update(force: true),
child: const Text('Update now'),
),
),
Text(
'Plays links to web pages (YouTube, X, …) in all three link modes. '
'Downloaded and kept up to date by the app; checked once a day.',
style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12),
),
const SizedBox(height: 12),
YtDlpCookieSettings(cookies: y.cookies),
],
);
},
);
},
);
}
}
/// yt-dlp's cookies (sites that refuse guests): from a browser, or a
/// cookies.txt / JSON export picked or dropped, filtered by site into the
/// private copy in the yt-dlp folder (see [YtDlpCookies]).
class YtDlpCookieSettings extends StatefulWidget {
final YtDlpCookies cookies;
const YtDlpCookieSettings({super.key, required this.cookies});
@override
State<YtDlpCookieSettings> createState() => _YtDlpCookieSettingsState();
}
class _YtDlpCookieSettingsState extends State<YtDlpCookieSettings> {
late var _source = widget.cookies.read();
String? _error;
bool _dragging = false;
void _reload([String? error]) {
if (!mounted) return;
setState(() {
_source = widget.cookies.read();
_error = error;
});
}
String get _summary => switch (_source) {
CookieFile f => '${f.count} cookie${f.count == 1 ? '' : 's'} for '
'${f.sites.length <= 3 ? f.sites.join(', ') : '${f.sites.length} sites'}',
CookieBrowser b => 'From ${b.browser}${b.profile.isEmpty ? '' : ' (profile ${b.profile})'}',
_ => 'No cookies: yt-dlp visits sites as a guest',
};
Future<void> _pickFile() async {
final List<PlatformFile> files;
try {
files = await FilePicker.pickFiles(
dialogTitle: 'Pick a cookies.txt or a JSON cookie export',
type: FileType.custom,
allowedExtensions: const ['txt', 'json'],
);
} catch (_) {
_reload('file dialog unavailable — try again');
return;
}
final path = files.firstOrNull?.path;
if (path != null) await _importFile(path);
}
Future<void> _importFile(String path) async {
final List<NetscapeCookie> all;
try {
all = parseCookies(await File(path).readAsString());
} on FormatException catch (e) {
_reload("Can't read that file: ${e.message}");
return;
} on FileSystemException catch (e) {
_reload("Can't read that file: ${e.message}");
return;
}
if (!mounted) return;
// Every site in the file: an export is "my logins", not a pick list.
try {
await widget.cookies.saveFile(all);
_reload();
} catch (e) {
_reload('Could not keep the cookies: $e');
}
}
Future<void> _pickBrowser() async {
final current = _source;
final picked = await showDialog<CookieBrowser>(
context: context,
builder: (_) => _CookieBrowserDialog(initial: current is CookieBrowser ? current : null),
);
if (picked == null) return;
try {
await widget.cookies.saveBrowser(picked.browser, picked.profile);
_reload();
} catch (e) {
_reload('Could not save: $e');
}
}
Future<void> _remove() async {
try {
await widget.cookies.remove();
_reload();
} catch (e) {
_reload('Could not remove the cookies: $e');
}
}
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return DropTarget(
onDragEntered: (_) => setState(() => _dragging = true),
onDragExited: (_) => setState(() => _dragging = false),
onDragDone: (d) {
setState(() => _dragging = false);
final path = DesktopDropX.pathsOf(d.files).firstOrNull;
if (path != null) _importFile(path);
},
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
border: Border.all(color: _dragging ? scheme.primary : scheme.outlineVariant),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.cookie_outlined),
title: const Text('Cookies'),
subtitle: Text(_summary, key: const ValueKey('ytdlp-cookies-summary')),
),
if (_error != null)
Text(_error!, key: const ValueKey('ytdlp-cookies-error'), style: TextStyle(color: scheme.error)),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
OutlinedButton.icon(
onPressed: _pickBrowser,
icon: const Icon(Icons.public),
label: const Text('From a browser…'),
),
OutlinedButton.icon(
onPressed: _pickFile,
icon: const Icon(Icons.file_open_outlined),
label: const Text('From a file…'),
),
if (_source != null)
TextButton(
key: const ValueKey('ytdlp-cookies-remove'),
onPressed: _remove,
child: const Text('Remove cookies'),
),
],
),
const SizedBox(height: 8),
Text(
'For sites that refuse guests (Bilibili, YouTube asking to sign in). Drop a cookies.txt or a '
'cookie-extension JSON export here, or pick one; all of it is kept, on this computer only. '
'Cookies log yt-dlp in as you on those sites. Keep the file private; for YouTube consider a '
'throwaway account (Google may flag accounts used with yt-dlp).',
style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12),
),
],
),
),
);
}
}
class _CookieBrowserDialog extends StatefulWidget {
final CookieBrowser? initial;
const _CookieBrowserDialog({this.initial});
@override
State<_CookieBrowserDialog> createState() => _CookieBrowserDialogState();
}
class _CookieBrowserDialogState extends State<_CookieBrowserDialog> {
late var _browser = widget.initial?.browser ?? cookieBrowsers.first;
late final _profile = TextEditingController(text: widget.initial?.profile ?? '');
@override
void dispose() {
_profile.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('Cookies from a browser'),
content: SizedBox(
width: 380,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
DropdownButtonFormField<String>(
initialValue: _browser,
decoration: const InputDecoration(labelText: 'Browser'),
items: [for (final b in cookieBrowsers) DropdownMenuItem(value: b, child: Text(b))],
onChanged: (v) => setState(() => _browser = v ?? _browser),
),
const SizedBox(height: 12),
TextField(
controller: _profile,
decoration: const InputDecoration(labelText: 'Profile (optional)', hintText: 'the default profile'),
),
const SizedBox(height: 12),
Text(
'yt-dlp reads them each time it opens a link (the browser may need to be closed on Windows).',
style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant, fontSize: 12),
),
],
),
),
actions: [
TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel')),
FilledButton(
onPressed: () => Navigator.pop(context, CookieBrowser(_browser, _profile.text.trim())),
child: const Text('Use'),
),
],
);
}
}
/// AnimeFlow (the Schedule tab's source): connected as whom, with which
/// rights, or why not.
class AnimeFlowCard extends StatefulWidget {
@@ -446,8 +746,9 @@ class NotificationsInfo extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
line('Joins, leaves, kicks, video changes and chat',
line('Joins, leaves, video changes and chat',
'Pop up bottom left for 5 s, with the person\'s picture'),
line('Sat out or nudged by someone', 'Only on your end, with their picture'),
line('Ready, seeks and pauses', 'Only in Chat & activity (Tab), never as a pop-up'),
line('App notices', 'Shader changes, a frozen video reloaded, the idle warning'),
],
@@ -459,7 +760,8 @@ class NotificationsInfo extends StatelessWidget {
}
}
/// Volume, then every sound with a preview button and its own switch.
/// Volume and the sound set, then every sound with a preview button, its
/// own set (or the overall one) and its own switch.
class SoundSettings extends StatelessWidget {
const SoundSettings({super.key});
@@ -476,6 +778,8 @@ class SoundSettings extends StatelessWidget {
return SignalBuilder(builder: (context) {
final volume = s.soundVolume.value;
final off = s.soundsOff.value;
final overall = s.soundPreset.value;
final per = s.soundPresetFor.value;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
@@ -496,6 +800,26 @@ class SoundSettings extends StatelessWidget {
),
Text('Soft cues for people and readiness; the video itself covers play, pause and the end. '
'Letting go of the slider plays the ready sound.', style: muted),
const SizedBox(height: 12),
Row(
children: [
const Expanded(child: Text('Sound set')),
DropdownButton<String>(
key: const ValueKey('sound-preset'),
value: overall,
onChanged: (v) {
if (v == null) return;
s.setSoundPreset(v);
_preview(SfxKind.ready);
},
items: [
for (final MapEntry(key: p, value: (name, _)) in presetInfo.entries)
DropdownMenuItem(value: p.name, child: Text(name)),
],
),
],
),
Text(presetInfo.entries.where((e) => e.key.name == overall).firstOrNull?.value.$2 ?? '', style: muted),
const SizedBox(height: 8),
for (final MapEntry(key: kind, value: (name, help)) in soundInfo.entries)
Padding(
@@ -515,10 +839,30 @@ class SoundSettings extends StatelessWidget {
children: [Text(name), Text(help, style: muted)],
),
),
Switch(
DropdownButton<String?>(
key: ValueKey('sound-preset-${kind.name}'),
value: per[kind.name],
underline: const SizedBox.shrink(),
style: muted,
onChanged: (v) {
s.setSoundPresetFor(kind.name, v);
if (volume > 0) _preview(kind);
},
items: [
const DropdownMenuItem<String?>(value: null, child: Text('Same as set')),
for (final MapEntry(key: p, value: (name, _)) in presetInfo.entries)
DropdownMenuItem<String?>(value: p.name, child: Text(name)),
],
),
// A nudge always plays (an explicit poke): no switch,
// its place kept so the rows line up.
Visibility.maintain(
visible: kind != SfxKind.nudge,
child: Switch(
key: ValueKey('sound-on-${kind.name}'),
value: !off.contains(kind.name),
onChanged: volume == 0 ? null : (on) => s.setSoundOn(kind.name, on),
onChanged: volume == 0 || kind == SfxKind.nudge ? null : (on) => s.setSoundOn(kind.name, on),
),
),
],
),
@@ -0,0 +1,106 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:signals/signals_flutter.dart';
import '../playback/media_marks.dart' show formatClock;
import '../state/session_state.dart';
import '../sync/state_diff.dart' show serverPositionAt;
import 'now_playing_title.dart';
import 'start_banner.dart';
/// In place of the video while self sits out: the media's start card
/// (cover, title, episode), where the others are ("12:34 / 24:00",
/// playing or paused) and "Join back" (or Space). [compact]: the mini
/// player.
class SitOutCard extends StatefulWidget {
final bool compact;
const SitOutCard({super.key, this.compact = false});
@override
State<SitOutCard> createState() => _SitOutCardState();
}
class _SitOutCardState extends State<SitOutCard> {
// The position moves on while playing: redraw each second.
late final Timer _tick;
@override
void initState() {
super.initState();
_tick = Timer.periodic(const Duration(seconds: 1), (_) {
if (mounted) setState(() {});
});
}
@override
void dispose() {
_tick.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
final coord = SessionState.instance.coordinator;
final small = widget.compact;
return ListenableBuilder(
listenable: artChanges(),
builder: (context, _) => SignalBuilder(builder: (context) {
final r = coord.serverState.value;
final now = coord.nowPlaying.value;
final playing = r?.transport == 'playing';
final duration = (r?.media?['duration'] as num?)?.toDouble() ?? 0;
final at = r == null ? 0.0 : serverPositionAt(r, coord.sync.serverNow());
Duration secs(double s) => Duration(milliseconds: (s * 1000).round());
final where = r?.media == null
? 'Nothing is up'
: duration > 0
? '${formatClock(secs(at.clamp(0, duration)))} / ${formatClock(secs(duration))}'
: formatClock(secs(at));
final text = TextStyle(color: Colors.white, fontSize: small ? 12 : 15);
final panel = Column(
mainAxisSize: MainAxisSize.min,
children: [
Text("You're sitting out", style: text.copyWith(fontWeight: FontWeight.w700, fontSize: small ? 13 : 20)),
SizedBox(height: small ? 4 : 8),
Row(
mainAxisSize: MainAxisSize.min,
children: [
if (r?.media != null) ...[
Icon(playing ? Icons.play_arrow_rounded : Icons.pause_rounded,
color: Colors.white70, size: small ? 14 : 18),
const SizedBox(width: 4),
],
Text(where, key: const ValueKey('sit-out-position'), style: text.copyWith(color: Colors.white70)),
],
),
SizedBox(height: small ? 8 : 16),
FilledButton.icon(
key: const ValueKey('join-back'),
onPressed: coord.joinBack,
icon: Icon(Icons.login_rounded, size: small ? 16 : 20),
label: Text(small ? 'Join back' : 'Join back (Space)'),
style: small
? FilledButton.styleFrom(
visualDensity: VisualDensity.compact,
padding: const EdgeInsets.symmetric(horizontal: 12),
)
: null,
),
],
);
return Stack(
fit: StackFit.expand,
children: [
if (now != null) StartCard(now: now) else const ColoredBox(color: Color(0xFF0C0D11)),
// Small: the card's title sits at the bottom, so the panel goes up top.
Align(
alignment: small ? const Alignment(0, -0.35) : Alignment.center,
child: panel,
),
],
);
}),
);
}
}
+4 -2
View File
@@ -34,8 +34,10 @@ ShellToast shellToastFor(ToastItem t, AvatarInfo Function(String name) avatar) {
? Icons.login
: rest.startsWith('left')
? Icons.logout
: rest.startsWith('kicked')
? Icons.gavel
: rest.startsWith('sat you out')
? Icons.bedtime
: rest.startsWith('nudged')
? Icons.notifications_active_outlined
: rest.startsWith('changed')
? Icons.swap_horiz
: Icons.info_outline;
+347
View File
@@ -0,0 +1,347 @@
import 'package:flutter/material.dart';
import '../jellyfin/accounts.dart';
import '../jellyfin/sign_in_panel.dart';
import '../screens/home/ui.dart' show appName;
import 'motion.dart';
/// One line under the app name.
const appTagline = 'Watch together, in sync';
/// Window width from which the welcome band puts the three ways beside
/// the app name (else under it).
const welcomeWideFrom = 980.0;
/// Top of the Library while not in a server: the app's name and the three
/// ways in, Join a friend first (what most people came for), then Host and
/// Watch solo. None of them needs Jellyfin.
class WelcomeBand extends StatelessWidget {
final VoidCallback onJoin;
final VoidCallback onHost;
final VoidCallback onSolo;
final VoidCallback? onDismiss;
const WelcomeBand({super.key, required this.onJoin, required this.onHost, required this.onSolo, this.onDismiss});
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final t = Theme.of(context).textTheme;
final hero = Row(
mainAxisSize: MainAxisSize.min,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(14),
child: Image.asset('assets/icon/icon_256.png', width: 52, height: 52, filterQuality: FilterQuality.medium),
),
const SizedBox(width: 14),
Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(appName, style: t.titleLarge?.copyWith(fontWeight: FontWeight.w700, height: 1.1)),
const SizedBox(height: 2),
Text(appTagline, style: TextStyle(color: scheme.onSurfaceVariant)),
if (onDismiss != null)
TextButton(
key: const ValueKey('welcome-dismiss'),
onPressed: onDismiss,
style: TextButton.styleFrom(
padding: EdgeInsets.zero,
minimumSize: const Size(0, 24),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
foregroundColor: scheme.onSurfaceVariant,
textStyle: const TextStyle(fontSize: 12),
),
child: const Text("Don't show again"),
),
],
),
],
);
final ways = [
WayCard(
key: const ValueKey('way-join'),
icon: Icons.login_rounded,
title: 'Join a friend',
caption: 'Got an address from a friend? Join with it. Nothing to set up.',
primary: true,
onTap: onJoin,
),
WayCard(
key: const ValueKey('way-host'),
icon: Icons.podcasts_rounded,
title: 'Host',
caption: 'You have the video. Friends join you and watch it with you.',
onTap: onHost,
),
WayCard(
key: const ValueKey('way-solo'),
icon: Icons.person_outline_rounded,
title: 'Watch solo',
caption: 'Just you: play anything below. Invite friends any time.',
onTap: onSolo,
),
];
Widget row(List<Widget> cards) => IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (final (i, c) in cards.indexed) ...[if (i > 0) const SizedBox(width: 10), Expanded(child: c)],
],
),
);
final band = Container(
key: const ValueKey('welcome-band'),
margin: const EdgeInsets.fromLTRB(20, 16, 20, 0),
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(18),
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [scheme.primary.withValues(alpha: 0.16), scheme.primary.withValues(alpha: 0.03)],
),
border: Border.all(color: scheme.outlineVariant.withValues(alpha: 0.4)),
),
child: LayoutBuilder(builder: (context, box) {
if (box.maxWidth >= welcomeWideFrom - 40) {
return Row(
children: [
Padding(padding: const EdgeInsets.only(left: 4, right: 20), child: hero),
Expanded(child: row(ways)),
],
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(padding: const EdgeInsets.only(left: 4, bottom: 12), child: hero),
if (box.maxWidth >= 520)
row(ways)
else
for (final (i, c) in ways.indexed) ...[if (i > 0) const SizedBox(height: 8), c],
],
);
}),
);
return band;
}
}
/// One way in: a tinted icon, a title and a one-line caption.
class WayCard extends StatefulWidget {
final IconData icon;
final String title;
final String caption;
final bool primary;
final VoidCallback onTap;
const WayCard({
super.key,
required this.icon,
required this.title,
required this.caption,
required this.onTap,
this.primary = false,
});
@override
State<WayCard> createState() => _WayCardState();
}
class _WayCardState extends State<WayCard> {
bool _hover = false;
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final primary = widget.primary;
final bg = primary ? scheme.primaryContainer : scheme.surfaceContainerHigh;
final fg = primary ? scheme.onPrimaryContainer : scheme.onSurface;
return MouseRegion(
onEnter: (_) => setState(() => _hover = true),
onExit: (_) => setState(() => _hover = false),
child: AnimatedScale(
duration: easeTime,
curve: ease,
scale: _hover ? 1.015 : 1,
child: Material(
color: bg,
borderRadius: BorderRadius.circular(14),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: widget.onTap,
child: Padding(
padding: const EdgeInsets.fromLTRB(12, 12, 14, 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: primary ? scheme.primary : scheme.primary.withValues(alpha: 0.14),
borderRadius: BorderRadius.circular(10),
),
child: Icon(widget.icon, size: 20, color: primary ? scheme.onPrimary : scheme.primary),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(widget.title, style: TextStyle(fontWeight: FontWeight.w700, color: fg)),
const SizedBox(height: 2),
Text(widget.caption,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: 12, height: 1.3, color: fg.withValues(alpha: 0.72))),
],
),
),
],
),
),
),
),
),
);
}
}
/// The Library's Jellyfin source with no server linked: what plays with
/// nothing set up (Files, Link), then Jellyfin as an optional extra that
/// guests never need. The sign-in opens in place.
class NoJellyfinPanel extends StatefulWidget {
final JellyfinAccounts accounts;
final VoidCallback onFiles;
final VoidCallback onLink;
const NoJellyfinPanel({super.key, required this.accounts, required this.onFiles, required this.onLink});
@override
State<NoJellyfinPanel> createState() => _NoJellyfinPanelState();
}
class _NoJellyfinPanelState extends State<NoJellyfinPanel> {
bool _signIn = false;
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final t = Theme.of(context).textTheme;
final muted = TextStyle(color: scheme.onSurfaceVariant);
final sources = [
WayCard(
key: const ValueKey('source-files'),
icon: Icons.folder_outlined,
title: 'Files',
caption: 'Videos on this computer. Drop a file or folder anywhere.',
onTap: widget.onFiles,
),
WayCard(
key: const ValueKey('source-link'),
icon: Icons.link,
title: 'Link',
caption: 'YouTube, X, or any video address. Ctrl+V anywhere.',
onTap: widget.onLink,
),
];
return SingleChildScrollView(
key: const ValueKey('media-jellyfin'),
padding: const EdgeInsets.only(bottom: 24),
child: Align(
alignment: Alignment.topLeft,
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 640),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('Pick something to watch', style: t.titleMedium),
const SizedBox(height: 4),
Text('No account needed: these work for everyone.', style: muted),
const SizedBox(height: 12),
LayoutBuilder(
builder: (context, box) => box.maxWidth < 460
? Column(children: [sources[0], const SizedBox(height: 8), sources[1]])
: IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(child: sources[0]),
const SizedBox(width: 10),
Expanded(child: sources[1]),
],
),
),
),
const SizedBox(height: 20),
Material(
key: const ValueKey('jellyfin-optional'),
color: scheme.surfaceContainer,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
side: BorderSide(color: scheme.outlineVariant.withValues(alpha: 0.5)),
),
clipBehavior: Clip.antiAlias,
child: AnimatedSize(
duration: easeTime,
curve: ease,
alignment: Alignment.topCenter,
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Icon(Icons.video_library_outlined, color: scheme.onSurfaceVariant),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Wrap(spacing: 8, crossAxisAlignment: WrapCrossAlignment.center, children: [
const Text('Jellyfin', style: TextStyle(fontWeight: FontWeight.w600)),
Container(
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 1),
decoration: BoxDecoration(
color: scheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(6),
),
child: Text('optional', style: TextStyle(fontSize: 11, color: scheme.onSurfaceVariant)),
),
]),
Text(
'Only if you have a Jellyfin server: browse it here, '
'with progress and next episodes. Joining a friend never needs it.',
style: muted.copyWith(fontSize: 12),
),
],
),
),
],
),
if (!_signIn)
Align(
alignment: Alignment.centerRight,
child: TextButton(
key: const ValueKey('jellyfin-link'),
onPressed: () => setState(() => _signIn = true),
child: const Text('Link a server'),
),
),
if (_signIn) ...[
const SizedBox(height: 14),
JellyfinSignInPanel(accounts: widget.accounts, onCancel: () => setState(() => _signIn = false)),
],
],
),
),
),
),
],
),
),
),
);
}
}
@@ -33,8 +33,10 @@ class ServerIsolate {
/// Spawns the server and waits until it listens. Throws a
/// [StateError] with the server's message if it cannot start.
/// [loopbackOnly]: 127.0.0.1 only (the solo session).
static Future<ServerIsolate> start(
AppConfig config, {
bool loopbackOnly = false,
required void Function(String line) onLog,
required void Function(String error) onCrash,
Duration timeout = const Duration(seconds: 15),
@@ -83,7 +85,7 @@ class ServerIsolate {
final Isolate isolate;
try {
isolate = await Isolate.spawn(
_serverMain, (config.toMap(), events.sendPort),
_serverMain, ({...config.toMap(), if (loopbackOnly) 'loopbackOnly': true}, events.sendPort),
errorsAreFatal: false,
onError: errors.sendPort,
onExit: exit.sendPort,
+151 -23
View File
@@ -8,7 +8,11 @@ import 'package:sync_relay_core/core.dart';
import '../jellyfin/accounts.dart';
import '../jellyfin/session_media.dart';
import '../screens/home/health_probe.dart';
import '../screens/home/picked_media.dart';
import '../playback/yt_dlp_app.dart';
import '../sfx/sfx.dart';
import '../sfx/sound_bank.dart';
import '../sfx/synth_sfx.dart';
import '../session/health.dart';
import '../session/session_coordinator.dart';
@@ -142,7 +146,7 @@ class ServerCrashed implements Exception {
}
/// A fresh per-hosting key: the app's own client sends it in hello to be
/// recognised as the (unkickable) host.
/// recognised as the host (`members[].host`).
String newHostKey() {
final r = Random.secure();
return base64Url.encode([for (var i = 0; i < 24; i++) r.nextInt(256)]);
@@ -157,7 +161,9 @@ class SessionState {
final status = signal(const HealthStatus(HealthSeverity.info, 'idle'));
final SfxService sfx = SynthSfx(
volume: () => AppSettings.instance.soundVolume.peek(),
enabled: (k) => !AppSettings.instance.soundsOff.peek().contains(k.name),
// A nudge is an explicit poke: it ignores its switch (not the volume).
enabled: (k) => k == SfxKind.nudge || !AppSettings.instance.soundsOff.peek().contains(k.name),
presetFor: soundPresetFor,
);
late final SessionCoordinator coordinator = SessionCoordinator(sfx: sfx);
final lastForm = ConnectForm();
@@ -165,12 +171,31 @@ class SessionState {
/// Set when the hosted server died mid-session; Home shows it on the
/// Host card and clears it.
final serverCrash = signal<String?>(null);
/// In a solo session: this app's own server on 127.0.0.1, only you in
/// it. Hosting from it ([host]) keeps what plays and where.
final solo = signal(false);
/// The joined server's own name (`/health`), '' until known or when it
/// only has the default name.
final serverLabel = signal('');
/// A solo session is becoming a hosted one: the player has nothing open
/// until the media is up again on the new server.
final switching = signal(false);
ConnectParams? _soloParams;
/// The app's yt-dlp folder for the hosted server; '' (the server's own
/// default) when the app-support folder is not available.
static Future<String> _sharedYtDlpDir() async {
try {
return (await AppYtDlp.instance.ytDlp).dir;
} catch (_) {
return '';
}
}
ServerIsolate? _server;
String? _setupCrash;
@visibleForTesting
ServerIsolate? get hostedServer => _server;
void Function()? _stopKickWatch;
// Bumped by host/join/cancelPending: a cancelled setup notices at its
// next step and unwinds instead of connecting.
int _op = 0;
@@ -230,9 +255,102 @@ class SessionState {
await _saveFormQuietly();
}
/// Hosts a server friends can join. From a solo session it keeps the
/// media and position: the solo server goes, the new one gets the same
/// pick, seeked to where it was; if hosting fails, solo comes back.
Future<void> host(ConnectParams params, ConnectForm form) async {
if (!solo.peek()) return _host(params, form: form);
final carry = _invited = _carry();
switching.value = true;
try {
await coordinator.leave();
await stopServer();
solo.value = false;
coordinator.solo = false;
try {
await _host(params, form: form);
} catch (_) {
// Not hosted (failed or cancelled): back to solo, same media.
try {
await _host(_soloParams ?? params, soloSession: true);
if (carry != null) await _resume(carry);
} catch (e) {
debugPrint('back to solo failed: $e');
await leave();
}
rethrow;
}
try {
if (carry != null) await _resume(carry);
} catch (e) {
coordinator.showToast("couldn't put it up again: $e");
}
} finally {
switching.value = false;
}
}
/// What an Invite from solo carried over, for [cancelInvite].
({PickedMedia pick, double position})? _invited;
/// Invite cancelled after the hosted server already came up: back to
/// solo with the same media, not out of the session.
Future<void> cancelInvite() async {
final carry = _invited;
switching.value = true;
try {
await coordinator.leave();
await stopServer();
await _host(_soloParams!, soloSession: true);
if (carry != null) await _resume(carry);
} catch (e) {
debugPrint('back to solo failed: $e');
await leave();
} finally {
switching.value = false;
}
}
/// Watch alone: the same hosted path, on 127.0.0.1 and a free port, no
/// password, nothing put up yet. Nobody else can reach it.
Future<void> startSolo(ConnectParams params) async {
if (route.peek() == AppRoute.session) return;
_soloParams = params;
await _host(params, soloSession: true);
}
({PickedMedia pick, double position})? _carry() {
final pick = coordinator.lastPick;
if (pick == null || coordinator.serverState.peek()?.media == null) return null;
final pos = coordinator.playback.positionSecs;
return (pick: pick, position: pos.isFinite && pos > 0 ? pos : 0.0);
}
/// Puts [carry] up on the server just joined and seeks to its position
/// once the swap shows in a state.
Future<void> _resume(({PickedMedia pick, double position}) carry) async {
final before = coordinator.serverState.peek()?.mediaId ?? 0;
final error = await coordinator.swapPicked(carry.pick);
if (error != null) throw StateError(error);
if (carry.position < 1) return;
final landed = Completer<void>();
final stop = effect(() {
final r = coordinator.serverState.value;
if (r != null && r.media != null && r.mediaId > before && !landed.isCompleted) landed.complete();
});
try {
await landed.future.timeout(const Duration(seconds: 20));
coordinator.sync.sendSeek(carry.position);
} on TimeoutException {
debugPrint('resume: the media did not come up; not seeking');
} finally {
stop();
}
}
Future<void> _host(ConnectParams params, {ConnectForm? form, bool soloSession = false}) async {
final op = ++_op;
_rememberForm(form);
if (form != null) _rememberForm(form);
try {
status.value =
const HealthStatus(HealthSeverity.info, 'starting server…');
@@ -252,13 +370,15 @@ class SessionState {
try {
server = await ServerIsolate.start(
AppConfig(
port: params.port,
secret: params.secret,
port: soloSession ? 0 : params.port,
secret: soloSession ? '' : params.secret,
name: 'server',
mediaPath: params.mediaPath,
hostKey: hostKey,
jellyfin: relay,
ytDlpDir: await _sharedYtDlpDir(),
),
loopbackOnly: soloSession,
onLog: (line) => debugPrint('[server] $line'),
onCrash: (cause) => _onServerCrash(server, cause),
);
@@ -275,13 +395,13 @@ class SessionState {
_server = server;
coordinator.hostFile = (path) async =>
identical(_server, server) ? server.setFile(path) : null;
status.value =
HealthStatus(HealthSeverity.ok, 'hosting on port ${server.port}');
status.value = HealthStatus(
HealthSeverity.ok, soloSession ? 'watching solo' : 'hosting on port ${server.port}');
try {
await coordinator.connect(ConnectParams(
host: '127.0.0.1',
port: server.port,
secret: params.secret,
secret: soloSession ? '' : params.secret,
name: params.name,
avatar: params.avatar,
hostKey: hostKey,
@@ -295,10 +415,11 @@ class SessionState {
if (crash != null && e is! ConnectCancelled) throw ServerCrashed(crash);
rethrow;
}
_watchKick();
solo.value = soloSession;
coordinator.solo = soloSession;
route.value = AppRoute.session;
} finally {
await _saveFormQuietly();
if (form != null) await _saveFormQuietly();
}
}
@@ -324,6 +445,11 @@ class SessionState {
await coordinator.connect(params);
if (op != _op) throw const ConnectCancelled();
route.value = AppRoute.session;
serverLabel.value = '';
unawaited(probeHealth(params.host, '${params.port}', defaultHealthGet).then((h) {
// 'server' is the dedicated server's default --name: not a name.
if (op == _op && h.online && h.name.isNotEmpty && h.name != 'server') serverLabel.value = h.name;
}).catchError((Object _) {}));
} finally {
await _saveFormQuietly();
}
@@ -336,16 +462,9 @@ class SessionState {
try {
await coordinator.cancelConnect();
} catch (_) {}
await stopServer();
}
/// A kicked host must not keep serving.
void _watchKick() {
_stopKickWatch?.call();
_stopKickWatch = effect(() {
// Microtask: stopServer disposes this effect, not from inside it.
if (coordinator.kickReason.value != null) scheduleMicrotask(stopServer);
});
// Solo becoming hosted: [host] stops what it started and goes back
// to solo itself.
if (!switching.peek()) await stopServer();
}
/// Saved whatever the outcome: a failed join is exactly when the user
@@ -373,8 +492,6 @@ class SessionState {
Future<void> stopServer() async {
coordinator.hostFile = null;
_stopKickWatch?.call();
_stopKickWatch = null;
final server = _server;
_server = null;
await server?.stop();
@@ -383,7 +500,18 @@ class SessionState {
Future<void> leave() async {
await coordinator.leave();
await stopServer();
solo.value = false;
coordinator.solo = false;
serverLabel.value = '';
status.value = const HealthStatus(HealthSeverity.info, 'idle');
route.value = AppRoute.home;
}
}
/// The sound set [kind] plays from: its own pick in Settings, else the
/// overall one.
SoundPreset soundPresetFor(SfxKind kind) {
final s = AppSettings.instance;
final name = s.soundPresetFor.peek()[kind.name] ?? s.soundPreset.peek();
return SoundPreset.values.where((p) => p.name == name).firstOrNull ?? defaultPreset;
}
+49
View File
@@ -22,7 +22,11 @@ const _keyHideReReleases = 'hideReReleases';
const _keyProgressLine = 'progressLine';
const _keySoundVolume = 'soundVolume';
const _keySoundsOff = 'soundsOff';
const _keySoundPreset = 'soundPreset';
const _keyWelcomeDismissed = 'welcomeDismissed';
const _keySoundPresetFor = 'soundPresetFor';
const _keyUnreadyOnSwap = 'unreadyOnSwap';
const _keyIdleSitsOut = 'idleSitsOut';
enum HwdecMode { auto, off, custom }
@@ -37,6 +41,8 @@ class AppSettings {
/// Session: send ready=false when the video changes (a swap keeps the
/// server's ready flags).
final unreadyOnSwap = signal(false);
/// Session: the idle auto-unready sits this member out instead.
final idleSitsOut = signal(false);
/// Player design: the uosc controls (`controls/uosc/`) instead of the
/// standard ones (media_kit's with our bars). Applies live.
final mpvControls = signal(false);
@@ -50,6 +56,13 @@ class AppSettings {
final soundVolume = signal(1.0);
/// `SfxKind` names switched off one by one.
final soundsOff = signal<Set<String>>(const {});
/// The sound set (`SoundPreset` name) every sound uses unless
/// [soundPresetFor] picks another one for it.
final soundPreset = signal('cosmic');
/// The offline welcome band's × was pressed: never show it again.
final welcomeDismissed = signal(false);
/// `SfxKind` name -> `SoundPreset` name, per-sound overrides.
final soundPresetFor = signal<Map<String, String>>(const {});
/// Video shaders: one choice, used on join and changed both here and
/// in the session (Ctrl+0..6, the shader button).
@@ -88,6 +101,8 @@ class AppSettings {
if (advance is bool) autoAdvance.value = advance;
final unready = box.get(_keyUnreadyOnSwap);
if (unready is bool) unreadyOnSwap.value = unready;
final idleOut = box.get(_keyIdleSitsOut);
if (idleOut is bool) idleSitsOut.value = idleOut;
final mpv = box.get(_keyMpvControls);
if (mpv is bool) mpvControls.value = mpv;
final hideRe = box.get(_keyHideReReleases);
@@ -98,6 +113,17 @@ class AppSettings {
if (vol is num) soundVolume.value = vol.toDouble().clamp(0, 1);
final off = box.get(_keySoundsOff);
if (off is List) soundsOff.value = Set.unmodifiable(off.whereType<String>());
final welcomeOff = box.get(_keyWelcomeDismissed);
if (welcomeOff is bool) welcomeDismissed.value = welcomeOff;
final soundSet = box.get(_keySoundPreset);
if (soundSet is String) soundPreset.value = soundSet;
final per = box.get(_keySoundPresetFor);
if (per is Map) {
soundPresetFor.value = Map.unmodifiable({
for (final e in per.entries)
if (e.key is String && e.value is String) e.key as String: e.value as String,
});
}
final preset = box.get(_keyShaderPreset);
shaderPreset.value =
ShaderPreset.values.where((p) => p.name == preset).firstOrNull ??
@@ -140,6 +166,11 @@ class AppSettings {
_put(_keyUnreadyOnSwap, on);
}
void setIdleSitsOut(bool on) {
idleSitsOut.value = on;
_put(_keyIdleSitsOut, on);
}
void setMpvControls(bool on) {
mpvControls.value = on;
_put(_keyMpvControls, on);
@@ -160,6 +191,24 @@ class AppSettings {
_put(_keySoundsOff, soundsOff.value.toList());
}
void dismissWelcome() {
welcomeDismissed.value = true;
_put(_keyWelcomeDismissed, true);
}
void setSoundPreset(String preset) {
soundPreset.value = preset;
_put(_keySoundPreset, preset);
}
/// [preset] null: the sound follows [soundPreset] again.
void setSoundPresetFor(String kind, String? preset) {
final m = {...soundPresetFor.value};
preset == null ? m.remove(kind) : m[kind] = preset;
soundPresetFor.value = Map.unmodifiable(m);
_put(_keySoundPresetFor, m);
}
void setHideReReleases(bool on) {
hideReReleases.value = on;
_put(_keyHideReReleases, on);
+16 -13
View File
@@ -4,7 +4,7 @@ import 'dart:convert';
import 'package:sync_relay_core/core.dart';
import 'package:web_socket_channel/web_socket_channel.dart';
enum SyncStatus { idle, connecting, open, kicked, closed, error }
enum SyncStatus { idle, connecting, open, closed, error }
class SyncClient {
/// Local clock; injectable so tests can run a client whose clock is off.
@@ -23,13 +23,14 @@ class SyncClient {
StreamSubscription? _sub;
final StreamController<StateBroadcast> _states = StreamController.broadcast();
final StreamController<FeedMessage> _feeds = StreamController.broadcast();
final StreamController<String> _kicked = StreamController.broadcast();
final StreamController<NudgeNotice> _nudges = StreamController.broadcast();
final StreamController<String> _errors = StreamController.broadcast();
final StreamController<SyncStatus> _status = StreamController.broadcast();
Stream<StateBroadcast> get states => _states.stream;
Stream<FeedMessage> get feeds => _feeds.stream;
Stream<String> get kicked => _kicked.stream;
/// Someone poked this member (`nudge`).
Stream<NudgeNotice> get nudges => _nudges.stream;
Stream<String> get errors => _errors.stream;
Stream<SyncStatus> get status => _status.stream;
@@ -153,14 +154,10 @@ class SyncClient {
// one toast, one sfx.
if (f.action != 'chat') _feeds.add(f);
} catch (_) {}
case 'kicked':
case 'nudge':
try {
final msg = KickedMessage.fromJson(decoded);
_kicked.add(msg.reason);
_status.add(SyncStatus.kicked);
} catch (_) {
_kicked.add('');
}
_nudges.add(NudgeNotice.fromJson(decoded));
} catch (_) {}
case 'pong':
try {
final pong = PongMessage.fromJson(decoded);
@@ -255,8 +252,14 @@ class SyncClient {
_send(ArtMessage(mediaId: mediaId, poster: poster, banner: banner).toJson());
}
void sendKick(String target, {String reason = ''}) {
_send(KickRequest(target: target, reason: reason).toJson());
/// Sits the member [target] out, or with [out] false brings this
/// member back (only oneself can come back).
void sendOut(String target, bool out) {
_send(OutIntent(target: target, out: out).toJson());
}
void sendNudge(String target) {
_send(NudgeIntent(target: target).toJson());
}
Future<void> disconnect() async {
@@ -283,7 +286,7 @@ class SyncClient {
await disconnect();
await _states.close();
await _feeds.close();
await _kicked.close();
await _nudges.close();
await _errors.close();
await _status.close();
}
@@ -1,4 +1,4 @@
@echo off
rem Update this install in place (same as clicking Update in the app).
"%~dp0watch_together.exe" --upgrade %*
"%~dp0watch-together-server.exe" --upgrade %*
pause
+2 -1
View File
@@ -1,3 +1,4 @@
#!/bin/sh
# Update this install in place (same as clicking Update in the app).
cd "$(dirname "$0")" && exec ./watch_together --upgrade "$@"
# The server binary runs the updater: it needs no GTK/display (VPS).
cd "$(dirname "$0")" && exec ./watch-together-server --upgrade "$@"
@@ -4,11 +4,9 @@ import 'package:flutter/foundation.dart';
import 'package:hive_ce_flutter/hive_flutter.dart';
import 'package:sync_relay_core/core.dart';
import 'updater.dart';
// In-app update flow behind the Home update indicator. Same code path as
// `--upgrade` (downloadAndApply); this adds state for the UI and the
// restart. Check failures are logged only — the banner shows up just when
// `--upgrade` (core's updater, downloadAndApply); this adds state for the
// UI and the restart. Check failures are logged only — the banner shows up just when
// there is something to install.
final _log = createLogger('update');
@@ -27,6 +25,19 @@ class UpdateStatus {
{this.manifest, this.progress, this.error, this.canInstall = false});
}
// Process args as main() received them — restart relaunches with these.
List<String> launchArgs = const [];
// Relaunch the (now replaced) binary detached with the same args, then
// exit this process.
Future<Never> restartApp(String executable, List<String> args) async {
final keep = args.where((a) => a != '--upgrade').toList();
await Process.start(executable, keep,
mode: ProcessStartMode.detached,
workingDirectory: Directory.current.path);
exit(0);
}
typedef RestartFn = Future<void> Function(String executable, List<String> args);
// Release Hive's box locks before the new instance opens the same boxes.
-22
View File
@@ -1,22 +0,0 @@
import 'package:flutter/material.dart';
Future<bool?> showKickConfirmDialog(
BuildContext context, String memberName) {
return showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text('Remove $memberName?'),
content: const Text('They can come straight back in.'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('Remove'),
),
],
),
);
}
@@ -1,7 +1,7 @@
import 'package:flutter/material.dart';
import 'package:sync_relay_core/core.dart';
import '../update/update_controller.dart';
import '../update/updater.dart';
// "Update available" card (Home update indicator dialog). Kicks off the one
// background check on first build; renders nothing until there is an
+1 -1
View File
@@ -7,7 +7,7 @@ project(runner LANGUAGES CXX)
set(BINARY_NAME "watch_together")
# The unique GTK application identifier for this application. See:
# https://wiki.gnome.org/HowDoI/ChooseApplicationID
set(APPLICATION_ID "com.example.watch_together")
set(APPLICATION_ID "moe.senkochan.watch_together")
# Explicitly opt in to modern CMake behaviors to avoid warnings with recent
# versions of CMake.
+50 -2
View File
@@ -10,6 +10,8 @@
struct _MyApplication {
GtkApplication parent_instance;
char** dart_entrypoint_arguments;
// "starsync/window" (lib/src/platform/attention.dart).
FlMethodChannel* window_channel;
};
G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION)
@@ -19,6 +21,29 @@ static void first_frame_cb(MyApplication* self, FlView* view) {
gtk_widget_show(gtk_widget_get_toplevel(GTK_WIDGET(view)));
}
// "starsync/window": `attention` sets the urgency hint while the window is
// not focused; focusing it clears the hint.
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) {
if (!gtk_window_is_active(window)) {
gtk_window_set_urgency_hint(window, TRUE);
}
response = FL_METHOD_RESPONSE(fl_method_success_response_new(nullptr));
} else {
response = FL_METHOD_RESPONSE(fl_method_not_implemented_response_new());
}
fl_method_call_respond(call, response, nullptr);
}
static gboolean window_focus_in_cb(GtkWidget* widget, GdkEvent* event,
gpointer user_data) {
gtk_window_set_urgency_hint(GTK_WINDOW(widget), FALSE);
return FALSE;
}
// Implements GApplication::activate.
static void my_application_activate(GApplication* application) {
MyApplication* self = MY_APPLICATION(application);
@@ -45,15 +70,28 @@ static void my_application_activate(GApplication* application) {
if (use_header_bar) {
GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new());
gtk_widget_show(GTK_WIDGET(header_bar));
gtk_header_bar_set_title(header_bar, "watch_together");
gtk_header_bar_set_title(header_bar, "Starsync");
gtk_header_bar_set_show_close_button(header_bar, TRUE);
gtk_window_set_titlebar(window, GTK_WIDGET(header_bar));
} else {
gtk_window_set_title(window, "watch_together");
gtk_window_set_title(window, "Starsync");
}
gtk_window_set_default_size(window, 1280, 720);
// The window icon (X11; Wayland takes it from the .desktop file the app
// installs for its application id).
{
g_autofree gchar* exe = g_file_read_link("/proc/self/exe", nullptr);
if (exe != nullptr) {
g_autofree gchar* dir = g_path_get_dirname(exe);
g_autofree gchar* icon = g_build_filename(
dir, "data", "flutter_assets", "assets", "icon", "icon_256.png",
nullptr);
gtk_window_set_icon_from_file(window, icon, nullptr);
}
}
g_autoptr(FlDartProject) project = fl_dart_project_new();
fl_dart_project_set_dart_entrypoint_arguments(
project, self->dart_entrypoint_arguments);
@@ -75,6 +113,15 @@ static void my_application_activate(GApplication* application) {
fl_register_plugins(FL_PLUGIN_REGISTRY(view));
g_autoptr(FlStandardMethodCodec) codec = fl_standard_method_codec_new();
self->window_channel = fl_method_channel_new(
fl_engine_get_binary_messenger(fl_view_get_engine(view)),
"starsync/window", FL_METHOD_CODEC(codec));
fl_method_channel_set_method_call_handler(self->window_channel,
window_method_cb, window, nullptr);
g_signal_connect(window, "focus-in-event", G_CALLBACK(window_focus_in_cb),
nullptr);
gtk_widget_grab_focus(GTK_WIDGET(view));
}
@@ -121,6 +168,7 @@ static void my_application_shutdown(GApplication* application) {
static void my_application_dispose(GObject* object) {
MyApplication* self = MY_APPLICATION(object);
g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev);
g_clear_object(&self->window_channel);
G_OBJECT_CLASS(my_application_parent_class)->dispose(object);
}
+6 -6
View File
@@ -10,7 +10,7 @@ packages:
source: hosted
version: "1.1.1"
archive:
dependency: "direct main"
dependency: transitive
description:
name: archive
sha256: ace891da0862b0e4cabbb064ee3fd87b2728b898949fdb366d83fe98342c9f19
@@ -228,11 +228,11 @@ packages:
dependency: "direct main"
description:
path: "."
ref: "v0.1.7"
resolved-ref: dc16bba05491f54daf528ed83484ea9b6eab8825
ref: "v0.1.8"
resolved-ref: d2435a06b26d4f94c3c3777e6ef006ac84f4eef4
url: "https://git.senkochan.moe/Foxyz/fox_utils.git"
source: git
version: "0.1.7"
version: "0.1.8"
hive_ce:
dependency: "direct main"
description:
@@ -748,14 +748,14 @@ packages:
path: "../../packages/core"
relative: true
source: path
version: "1.0.0"
version: "1.0.1"
sync_relay_server:
dependency: "direct main"
description:
path: "../../packages/server"
relative: true
source: path
version: "1.0.0"
version: "1.0.1"
synchronized:
dependency: transitive
description:
+3 -3
View File
@@ -1,7 +1,7 @@
name: watch_together
description: Watch-together desktop app (working title, repo sync_relay_project). One binary.
publish_to: none
version: 1.0.1
version: 1.1.0
environment:
sdk: '>=3.13.0-0 <4.0.0'
@@ -23,13 +23,12 @@ dependencies:
hive_ce_flutter: ^2.3.4
image: ^4.9.2
window_manager: ^0.5.2
archive: ^4.0.7
crypto: ^3.0.6
# Drag & drop and clipboard files/images (vendored desktop_drop + pasteboard).
fox_utils:
git:
url: https://git.senkochan.moe/Foxyz/fox_utils.git
ref: v0.1.7
ref: v0.1.8
dev_dependencies:
flutter_test:
sdk: flutter
@@ -38,3 +37,4 @@ flutter:
uses-material-design: true
assets:
- assets/shaders/anime4k/
- assets/icon/
+2 -21
View File
@@ -44,7 +44,7 @@ Future<void> frames(WidgetTester tester, [int n = 10]) async {
Future<void> pumpPage(
WidgetTester tester, {
Size size = const Size(1280, 800),
bool online = true,
bool solo = false,
bool relayed = false,
Future<String?> Function(String path)? onPlay,
ThumbnailLoader? thumbnails,
@@ -56,7 +56,7 @@ Future<void> pumpPage(
await tester.pumpWidget(MaterialApp(
home: Scaffold(
body: FilesPage(
online: online,
solo: solo,
relayed: relayed,
onPlay: onPlay ?? (_) async => null,
thumbnails: thumbnails ?? fakeThumb,
@@ -206,24 +206,6 @@ void main() {
expect(find.text('4 episodes · 1 extra · Grp'), findsOneWidget);
});
testWidgets('offline: browsing works, playing is disabled', (tester) async {
store.addFolder(anime);
final played = <String>[];
await pumpPage(tester, online: false, onPlay: (p) async {
played.add(p);
return null;
});
await openFolder(tester, 'anime');
await openSeries(tester);
expect(find.text('E01'), findsOneWidget);
await tester.tap(find.text('E01'));
await tester.tap(find.text('Some Movie (2019)'));
await frames(tester);
expect(played, isEmpty);
expect(store.recent.value, isEmpty);
expect(find.byTooltip(offlinePlayHint), findsWidgets);
});
testWidgets('a play goes to onPlay and into Recent', (tester) async {
store.addFolder(anime);
final played = <String>[];
@@ -261,7 +243,6 @@ void main() {
await tester.pumpWidget(MaterialApp(
home: Scaffold(
body: FilesPage(
online: true,
relayed: false,
onPlay: (_) async => null,
nowPlayingPath: '$anime/[Grp] Show - 03.mkv',
+16 -11
View File
@@ -8,6 +8,7 @@ import 'package:sync_relay_core/core.dart';
import 'package:watch_together/src/screens/home/health_probe.dart';
import 'package:watch_together/src/screens/home/media_picker.dart';
import 'package:watch_together/src/shell/app_shell.dart';
import 'package:watch_together/src/shell/library_link.dart' show soloPlayHint;
import 'package:watch_together/src/state/session_state.dart';
import 'package:watch_together/src/state/settings.dart';
import 'package:watch_together/src/update/update_controller.dart';
@@ -230,24 +231,28 @@ void main() {
expect(find.byKey(const ValueKey('join-row')), findsOneWidget);
});
testWidgets('library: sources switch; offline nothing goes up; Integrations opens Settings', (tester) async {
testWidgets('library offline: welcome band, sources that need nothing, Jellyfin optional', (tester) async {
await pumpShell(tester, const Size(1280, 900));
await tester.tap(find.text('Files'));
// The three ways in, Join first; none needs Jellyfin.
expect(find.byKey(const ValueKey('welcome-band')), findsOneWidget);
expect(find.text('Watch together, in sync'), findsOneWidget);
final join = tester.getTopLeft(find.byKey(const ValueKey('way-join')));
expect(join.dx, lessThan(tester.getTopLeft(find.byKey(const ValueKey('way-solo'))).dx));
// No linked server: Files and Link first, Jellyfin as an optional card.
expect(find.text('Pick something to watch'), findsOneWidget);
expect(find.text('Add a Jellyfin server'), findsNothing);
await tester.tap(find.byKey(const ValueKey('jellyfin-link')));
await tester.pumpAndSettle();
expect(find.text('Add a Jellyfin server'), findsOneWidget);
await tester.tap(find.byKey(const ValueKey('source-files')));
await tester.pumpAndSettle();
expect(find.byType(FilesPage), findsOneWidget);
await tester.tap(find.text('Link'));
await tester.pumpAndSettle();
expect(find.text('Watch from a link'), findsOneWidget);
// Offline nothing goes up.
expect(find.text('Join or host to watch together'), findsOneWidget);
// Alone: Play, no relay modes.
expect(find.text(soloPlayHint), findsOneWidget);
expect(find.text('How the others get it'), findsNothing);
await tester.tap(find.text('Jellyfin'));
await tester.pumpAndSettle();
// No linked server: sign-in right in the library.
expect(find.text('Add a Jellyfin server'), findsOneWidget);
await tester.tap(find.text('Open Integrations'));
await tester.pumpAndSettle();
expect(find.text('Add server'), findsOneWidget);
expect(tester.takeException(), isNull);
});
+11 -13
View File
@@ -189,23 +189,21 @@ void main() {
expect(decodeRecentLinks(jsonEncode([{'url': ''}, {'url': 'https://a/b.mkv'}])).single.title, 'b.mkv');
});
test('preview: YouTube via oEmbed, other links by file name, failures fall back', () async {
Uri? asked;
final yt = await previewLink('https://www.youtube.com/watch?v=abc', get: (uri) async {
asked = uri;
return jsonEncode({'title': 'A video', 'author_name': 'Chan', 'thumbnail_url': 'https://i.ytimg/x.jpg'});
});
expect(asked!.host, 'www.youtube.com');
expect(asked!.path, '/oembed');
expect(asked!.queryParameters, {'url': 'https://www.youtube.com/watch?v=abc', 'format': 'json'});
expect((yt!.title, yt.subtitle, yt.thumbnail), ('A video', 'youtube.com · Chan', 'https://i.ytimg/x.jpg'));
test('preview: a page by yt-dlp, a file by its name, else why not', () async {
final yt = await previewLink('https://www.youtube.com/watch?v=abc',
probe: (url) async => LinkProbe.page(Uri.parse(url),
info: const YtDlpInfo(
title: 'A video', uploader: 'Chan', duration: 187, thumbnail: 'https://i.ytimg/x.jpg')));
expect((yt!.title, yt.subtitle, yt.thumbnail), ('A video', 'youtube.com · Chan · 3:07', 'https://i.ytimg/x.jpg'));
final file = await previewLink('https://cdn.example/dir/My%20Movie.mkv?sig=1',
get: (_) => throw StateError('no lookup for plain links'));
probe: (url) async => LinkProbe.file(Uri.parse(url), 10));
expect((file!.title, file.subtitle, file.thumbnail), ('My Movie.mkv', 'cdn.example', null));
final down = await previewLink('https://youtu.be/abc', get: (_) async => throw Exception('offline'));
expect((down!.title, down.subtitle), ('abc', 'youtu.be'));
final page = await previewLink('https://example.com/a',
probe: (url) async => LinkProbe.page(Uri.parse(url), pageTitle: 'Example', ytDlpError: 'Unsupported URL'));
expect((page!.title, page.subtitle),
('Example', "example.com · Not a video (and yt-dlp couldn't read it: Unsupported URL)"));
expect(await previewLink('not a link'), isNull);
});
+24 -31
View File
@@ -48,16 +48,16 @@ FakeJellyfin libraryFake() {
}
class FilesStub extends StatelessWidget {
final bool online, relayed;
final bool solo, relayed;
final Future<String?> Function(String path) onPlay;
const FilesStub({super.key, required this.online, required this.relayed, required this.onPlay});
const FilesStub({super.key, required this.solo, required this.relayed, required this.onPlay});
@override
Widget build(BuildContext context) => Column(children: [
Text('files online=$online relayed=$relayed'),
Text('files solo=$solo relayed=$relayed'),
TextButton(
key: const ValueKey('stub-play'),
onPressed: online ? () => onPlay('/v/a.mkv') : null,
onPressed: () => onPlay('/v/a.mkv'),
child: const Text('play'),
),
]);
@@ -75,7 +75,7 @@ class Harness {
Future<Harness> pumpLibrary(
WidgetTester tester,
Size size, {
bool online = true,
bool solo = false,
bool isHost = false,
MediaKind source = MediaKind.jellyfin,
Harness? reuse,
@@ -103,17 +103,16 @@ Future<Harness> pumpLibrary(
key: UniqueKey(),
controller: h.controller,
accounts: h.accounts,
online: online,
solo: solo,
isHost: isHost,
jellyfinStart: start,
recentLinks: h.recents,
now: () => fixtureNow,
oembed: (uri) async => jsonEncode({
'title': 'A video',
'author_name': 'Chan',
}),
filesBuilder: (context, {required online, required relayed, required onPlay, nowPlayingPath}) =>
FilesStub(online: online, relayed: relayed, onPlay: onPlay),
probe: (url) async => url.endsWith('.mkv')
? LinkProbe.file(Uri.parse(url), 1)
: LinkProbe.page(Uri.parse(url), info: const YtDlpInfo(title: 'A video', uploader: 'Chan')),
filesBuilder: (context, {required solo, required relayed, required onPlay, nowPlayingPath}) =>
FilesStub(solo: solo, relayed: relayed, onPlay: onPlay),
onPutUp: (m) async {
h.put.add(m);
return null;
@@ -153,7 +152,7 @@ void main() {
await tester.tap(find.text('Files'));
await settle(tester, 10);
expect(find.text('files online=true relayed=true'), findsOneWidget);
expect(find.text('files solo=false relayed=true'), findsOneWidget);
await tester.tap(find.byKey(const ValueKey('stub-play')));
await settle(tester);
expect(h.put, [const FileMedia('/v/a.mkv')]);
@@ -248,32 +247,26 @@ void main() {
expect(tester.takeException(), isNull);
});
testWidgets('offline: everything browses, nothing goes up', (tester) async {
final h = await pumpLibrary(tester, const Size(1600, 1000), online: false);
testWidgets('solo: buttons say Play and put up; a link goes direct, no relay modes', (tester) async {
final h = await pumpLibrary(tester, const Size(1600, 1000), solo: true);
await tester.tap(arifuretaCard());
await settle(tester, 10);
final go = find.byKey(const ValueKey('details-put-up'));
expect(tester.widget<ButtonStyleButton>(go).onPressed, isNull);
expect(find.descendant(of: go, matching: find.text('Play')), findsOneWidget);
expect(find.text(offlinePutUpHint), findsOneWidget);
await doubleTap(tester, arifuretaCard());
expect(h.put, isEmpty);
expect(find.descendant(of: go, matching: find.textContaining('Play')), findsOneWidget);
await tester.tap(go);
await settle(tester, 10);
expect(h.put.single, isA<JellyfinMedia>());
await tester.tap(find.text('Link'));
await settle(tester, 10);
h.controller.linkRelay = LinkRelay.me;
await tester.enterText(find.byKey(const ValueKey('link-field')), 'https://cdn.example/a.mkv');
await settle(tester, 10);
final linkGo = find.byKey(const ValueKey('link-put-up'));
expect(tester.widget<ButtonStyleButton>(linkGo).onPressed, isNull);
expect(find.descendant(of: find.byKey(const ValueKey('media-link')), matching: find.text(offlinePutUpHint)),
findsOneWidget);
expect(find.text('How the others get it'), findsNothing);
expect(find.text('a.mkv'), findsOneWidget, reason: 'the preview still works');
await tester.tap(find.text('Files'));
expect(find.text(soloPlayHint), findsOneWidget);
await tester.tap(find.byKey(const ValueKey('link-put-up')));
await settle(tester, 10);
expect(find.text('files online=false relayed=true'), findsOneWidget);
expect(h.put, isEmpty);
expect(h.put.last, const LinkMedia('https://cdn.example/a.mkv'));
expect(tester.takeException(), isNull);
});
@@ -308,7 +301,7 @@ void main() {
testWidgets('hosting: files stream from here; now-playing file passes through', (tester) async {
await pumpLibrary(tester, const Size(1400, 900), source: MediaKind.file, isHost: true);
expect(find.text('files online=true relayed=false'), findsOneWidget);
expect(find.text('files solo=false relayed=false'), findsOneWidget);
});
testWidgets('the last pick comes back selected; "Change video" opens on its series', (tester) async {
@@ -326,7 +319,7 @@ void main() {
expect(c.selected!.itemId, bsEpisode);
expect(c.source, MediaKind.jellyfin);
final h = Harness(c, accounts, FakeJellyfin());
await pumpLibrary(tester, const Size(1600, 1000), reuse: h, online: false);
await pumpLibrary(tester, const Size(1600, 1000), reuse: h, solo: true);
expect(find.byKey(const ValueKey('jf-details')), findsOneWidget);
expect(find.text('Black Summoner'), findsWidgets);
+2 -2
View File
@@ -296,10 +296,10 @@ void main() {
findsOneWidget);
expect(find.descendant(of: find.byKey(const ValueKey('member-m2')), matching: find.text('host')),
findsNothing);
// The host cannot be kicked: no Kick in its menu.
// The host can be sat out like anyone.
await tester.tap(find.descendant(of: find.byKey(const ValueKey('member-m1')), matching: find.byTooltip('More')));
await tester.pumpAndSettle();
expect(find.text('Kick'), findsNothing);
expect(find.text('Sit out'), findsOneWidget);
});
testWidgets('hosting: Copy invite address lists this machine\'s addresses', (tester) async {
+10 -15
View File
@@ -26,8 +26,9 @@ final png = Uint8List.fromList(const [
0x42, 0x60, 0x82,
]);
MemberView mv(String id, String name, {bool ready = false, bool? inSet, double buffered = 30, bool stale = false}) =>
MemberView(id: id, name: name, ready: ready, inSet: inSet ?? ready, buffered: buffered, stale: stale);
MemberView mv(String id, String name,
{bool ready = false, bool? inSet, double buffered = 30, bool stale = false, bool out = false}) =>
MemberView(id: id, name: name, ready: ready, inSet: inSet ?? ready, buffered: buffered, stale: stale, out: out);
StateBroadcast held(List<MemberView> members, {String transport = 'held', double position = 0, int mediaId = 1}) =>
StateBroadcast(
@@ -55,17 +56,8 @@ void main() {
group('status', () {
final me = mv('me', 'Me', ready: true);
test('problems first: removed, reconnecting, health', () {
var v = playerStatusFor(
health: const HealthStatus(HealthSeverity.error, 'kicked: spam'),
kickReason: 'spam',
reconnecting: true,
state: held([me]));
expect(v!.kind, StatusKind.kicked);
expect(v.text, 'You were removed: spam');
expect(v.tone, HealthSeverity.error);
expect(v.action, isNull);
v = playerStatusFor(health: const HealthStatus(HealthSeverity.warn, 'connection lost — retrying…'),
test('problems first: reconnecting, health', () {
var v = playerStatusFor(health: const HealthStatus(HealthSeverity.warn, 'connection lost — retrying…'),
reconnecting: true);
expect(v!.text, 'Reconnecting…');
expect(v.action, StatusAction.cancel);
@@ -109,11 +101,14 @@ void main() {
expect(v.self, isTrue);
final w = playerStatusFor(
health: ok,
state: held([me, mv('b', 'Bob'), mv('m', 'Mika'), mv('z', 'Zed', stale: true)]),
state: held([me, mv('b', 'Bob'), mv('m', 'Mika'), mv('z', 'Zed', stale: true), mv('o', 'Ola', out: true)]),
self: me)!;
expect(w.kind, StatusKind.waiting);
expect(w.text, 'Waiting for Bob and Mika');
expect(w.text, 'Waiting for Bob and Mika', reason: 'nobody waits for who sits out');
expect(w.detail, '1/3 ready');
final outMe = mv('me', 'Me', out: true);
expect(playerStatusFor(health: ok, state: held([outMe, mv('b', 'Bob')]), self: outMe), isNull,
reason: 'sitting out: the join-back card speaks');
expect(w.who.map((m) => m.name), ['Bob', 'Mika']);
});
+9 -5
View File
@@ -7,6 +7,7 @@ import 'package:sync_relay_core/core.dart';
import 'package:sync_relay_server/server.dart';
import 'package:watch_together/src/jellyfin/accounts.dart';
import 'package:watch_together/src/jellyfin/track_prefs.dart';
import 'package:watch_together/src/playback/yt_dlp_app.dart';
import 'package:watch_together/src/screens/home/picked_media.dart';
import 'package:watch_together/src/session/session_coordinator.dart';
@@ -67,7 +68,10 @@ void main() {
Future<SessionCoordinator> member(String name) async {
final acc = JellyfinAccounts(store: MemoryJellyfinStore());
await acc.ensureLoaded(validate: false);
final c = SessionCoordinator(jellyfin: acc, trackPrefs: TrackPrefs(store: MemoryJellyfinStore()));
final c = SessionCoordinator(
jellyfin: acc,
trackPrefs: TrackPrefs(store: MemoryJellyfinStore()),
ytDlp: AppYtDlp(YtDlp('$_tmp/relay_e2e_no_ytdlp', enabled: false)));
all.add(c);
await c.connect(ConnectParams(host: '127.0.0.1', port: server.boundPort, secret: 'pw', name: name));
await c.playback.player.setVolume(0);
@@ -119,7 +123,7 @@ void main() {
expect(bob.relaying.peek(), isTrue);
await playTogether([alice, bob]);
final relay = server.hub.relay!;
expect(relay.buffer.bytesToViewers, greaterThan(0), reason: 'alice played through the relay');
expect(relay.buffers.single.bytesToViewers, greaterThan(0), reason: 'alice played through the relay');
expect(relay.sourceToken, bob.sync.token);
// The next swap retires bob's source and the buffer.
@@ -140,7 +144,7 @@ void main() {
await _poll(() => stateMediaRelay(alice.serverState.peek()!)?.source == bob.sync.memberId, 'me state');
expect(alice.serverState.peek()!.toJson().toString(), isNot(contains('SECRET')));
await playTogether([alice, bob]);
expect(server.hub.relay!.buffer.bytesToViewers, greaterThan(0));
expect(server.hub.relay!.buffers.single.bytesToViewers, greaterThan(0));
final before = hits.length;
expect(await alice.swapPicked(LinkMedia(link, relay: LinkRelay.server)), isNull);
@@ -150,7 +154,7 @@ void main() {
await playTogether([alice, bob]);
expect(hits.length, greaterThan(before));
// A page instead of a file is refused up front.
// A page yt-dlp cannot read is refused up front.
final page = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
page.listen((r) {
r.response.headers.contentType = ContentType.html;
@@ -158,7 +162,7 @@ void main() {
r.response.close();
});
expect(await alice.swapPicked(LinkMedia('http://127.0.0.1:${page.port}/watch?v=x', relay: LinkRelay.me)),
contains('cannot pass this link on'));
contains('Not a video'));
await page.close(force: true);
} finally {
await origin.close(force: true);
@@ -208,6 +208,8 @@ void main() {
expect(art.banner, jpeg);
expect(asked, unorderedEquals(['https://img/xl.jpg', 'https://img/banner.jpg', 'https://img/l.jpg']));
expect(await aniListArt(c, 'Nothing Like It.mkv', fetch: (_, _, _) async => jpeg), isNull);
// A link tail like ".../video/1" names no show: no AniList search at all.
expect(await aniListArt(c, '1', fetch: (_, _, _) async => jpeg), isNull);
c.close();
});
@@ -63,6 +63,23 @@ void main() {
expect(out[2].onJellyfin, isFalse);
});
test('anilist_map: a season split into two AniList entries is on Jellyfin', () {
final item = AnimeFlowLibraryItem.fromJson({
'jellyfin_series_id': 'jfJ',
'name': 'JoJo',
'anilist_ids': {'6': 190327},
'anilist_map': [
{'season': 6, 'anilist_id': 190327, 'first_episode': 1, 'offset': 0},
{'season': 6, 'anilist_id': 210482, 'first_episode': 2, 'offset': 1},
],
});
final out = matchSchedule([airing(210482, 3, DateTime(2026, 10, 6, 18))],
schedule: const [], library: [item], now: now);
expect(out.single.status, ScheduleStatus.inLibrary);
expect(out.single.jellyfinSeriesId, 'jfJ');
expect(out.single.season, 6);
});
test("AnimeFlow's anilist_episode wins over TVDB numbering (split cour)", () {
// S2E13 in TVDB is part 2 (AniList 300) episode 1.
final out = matchSchedule(
+5 -5
View File
@@ -231,7 +231,7 @@ void main() {
WidgetTester tester, {
Size size = const Size(2560, 1080),
ScheduleSnapshot Function()? snap,
bool online = true,
bool solo = false,
JellyfinAccounts? accounts,
}) async {
toasts.clear();
@@ -245,7 +245,7 @@ void main() {
theme: ThemeData.dark(),
home: Scaffold(
body: SchedulePage(
online: online,
solo: solo,
repository: repo,
accounts: accounts,
onToast: toasts.add,
@@ -434,11 +434,11 @@ void main() {
await done(tester);
});
testWidgets('offline: Play is disabled', (tester) async {
await pumpPage(tester, online: false);
testWidgets('solo: the button says Play and works', (tester) async {
await pumpPage(tester, solo: true);
expect(find.text('Put it up'), findsNothing);
final b = widgetOf<ButtonStyleButton>(tester, action(111, 3));
expect(b.onPressed, isNull);
expect(b.onPressed, isNotNull);
expect(find.text('Play'), findsOneWidget);
await done(tester);
});
+49 -7
View File
@@ -8,6 +8,7 @@ import 'package:hive_ce/hive.dart';
import 'package:media_kit/media_kit.dart';
import 'package:sync_relay_core/core.dart';
import 'package:watch_together/src/jellyfin/accounts.dart';
import 'package:watch_together/src/screens/home/picked_media.dart';
import 'package:watch_together/src/session/session_coordinator.dart';
import 'package:watch_together/src/state/server_isolate.dart';
import 'package:watch_together/src/state/session_state.dart';
@@ -110,7 +111,7 @@ void main() {
}
});
test('host path: isolate server, host unkickable, crash leaves the session',
test('host path: isolate server, host sat out and nudging, crash leaves the session',
timeout: const Timeout(Duration(minutes: 2)), () async {
MediaKit.ensureInitialized();
Hive.init(
@@ -131,16 +132,16 @@ void main() {
final hostId = ((state['members'] as List)
.cast<Map>()
.firstWhere((m) => m['name'] == 'host'))['id'];
friend.ws.add(jsonEncode(KickRequest(target: '$hostId').toJson()));
final err = await friend.next('err');
expect(err['message'], "can't kick the host");
// Anyone may sit the host out; its player unloads, the server serves on.
friend.ws.add(jsonEncode(OutIntent(target: '$hostId', out: true).toJson()));
await _poll(() => session.coordinator.selfOut.peek(), 'host sat out');
// The host can kick the friend (plain loopback peers are not hosts).
// The host nudges the friend: only the friend hears it.
final friendId = ((state['members'] as List)
.cast<Map>()
.firstWhere((m) => m['name'] == 'friend'))['id'];
session.coordinator.kick('$friendId');
await friend.next('kicked');
session.coordinator.nudgeMember('$friendId');
expect((await friend.next('nudge'))['name'], 'host');
session.hostedServer!.isolate.kill(priority: Isolate.immediate);
await _poll(() => session.route.peek() == AppRoute.home, 'leave after crash');
@@ -218,4 +219,45 @@ void main() {
if (file.existsSync()) file.deleteSync();
}
});
test('solo: loopback-only server; inviting friends hosts the same media',
timeout: const Timeout(Duration(minutes: 2)), () async {
MediaKit.ensureInitialized();
final tmp = Platform.environment['WT_TEST_TMP'] ?? Directory.systemTemp.path;
Hive.init('$tmp/hive_isolate_solo_test');
final session = SessionState.instance;
final port = await _freePort();
_Raw? friend;
final file = File('$tmp/solo carry.mkv')..writeAsBytesSync(List.filled(4096, 7));
session.route.value = AppRoute.home; // the tests above leave it set
try {
await session.startSolo(const ConnectParams(host: '127.0.0.1', port: 0, secret: '', name: 'me'));
expect(session.route.peek(), AppRoute.session);
expect(session.solo.peek(), isTrue);
await session.coordinator.playback.player.setVolume(0);
final soloPort = session.hostedServer!.port;
// Reachable on loopback only: a LAN address of this machine is refused.
final lan = [
for (final i in await NetworkInterface.list(type: InternetAddressType.IPv4))
for (final a in i.addresses)
if (!a.isLoopback) a,
];
if (lan.isNotEmpty) {
await expectLater(Socket.connect(lan.first, soloPort, timeout: const Duration(seconds: 3)),
throwsA(isA<SocketException>()));
}
expect(await session.coordinator.swapPicked(FileMedia(file.path)), isNull);
await _poll(() => session.coordinator.serverState.peek()?.media?['name'] == 'solo carry.mkv', 'solo media');
await session.host(ConnectParams(host: '', port: port, secret: 'pw', name: 'me'), ConnectForm());
expect(session.solo.peek(), isFalse);
expect(session.route.peek(), AppRoute.session);
friend = await _Raw.join(port, 'friend', secret: 'pw');
await friend.next('state', where: (m) => (m['media'] as Map?)?['name'] == 'solo carry.mkv');
} finally {
await friend?.ws.close();
await session.leave();
if (file.existsSync()) file.deleteSync();
}
});
}
+14 -8
View File
@@ -26,20 +26,26 @@ double _pitch(List<double> x, int from) {
void main() {
test('every sound is described, quiet, short and clean', () {
for (final p in SoundPreset.values) {
expect(presetInfo[p], isNotNull, reason: p.name);
for (final k in SfxKind.values) {
expect(soundInfo[k], isNotNull, reason: k.name);
final r = render(soundFor(k));
final name = '${p.name}/${k.name}';
expect(soundInfo[k], isNotNull, reason: name);
final r = render(soundFor(k, preset: p));
final peak = r.fold<double>(0, (m, v) => math.max(m, v.abs()));
expect(20 * math.log(peak) / math.ln10, lessThanOrEqualTo(-16.9), reason: '${k.name} peak');
expect(r.length / sampleRate, lessThan(3), reason: '${k.name} length');
expect(r.every((v) => v.isFinite), isTrue, reason: k.name);
expect(r.first.abs() + r.last.abs(), lessThan(1e-3), reason: '${k.name} clicks');
expect(20 * math.log(peak) / math.ln10, lessThanOrEqualTo(-16.9), reason: '$name peak');
expect(r.length / sampleRate, lessThan(3), reason: '$name length');
expect(r.every((v) => v.isFinite), isTrue, reason: name);
expect(r.first.abs() + r.last.abs(), lessThan(1e-3), reason: '$name clicks');
}
}
});
test('the ready ladder climbs and stays in range', () {
expect(render(readySound(0)), render(readySound(1)));
expect(render(readySound(99)), render(readySound(6)));
for (final p in SoundPreset.values) {
expect(render(readySound(0, preset: p)), render(readySound(1, preset: p)));
expect(render(readySound(99, preset: p)), render(readySound(6, preset: p)));
}
});
test('koto strings are in tune', () {
+39 -5
View File
@@ -128,6 +128,29 @@ void main() {
expect(session.route.value, AppRoute.session);
});
testWidgets('solo: just you, Invite friends opens the host card, Stop asks nothing', (tester) async {
await pumpShell(tester, const Size(1280, 800));
session.solo.value = true;
addTearDown(() => session.solo.value = false);
online([member('me', 'Me')], media: false);
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('solo-chip')), findsOneWidget);
expect(find.byKey(const ValueKey('party-avatars')), findsNothing);
expect(find.byKey(const ValueKey('strip-chat')), findsNothing);
expect(find.byKey(const ValueKey('welcome-band')), findsNothing);
await tester.tap(find.byKey(const ValueKey('strip-invite')));
await tester.pumpAndSettle();
expect(find.text('Invite friends'), findsWidgets);
expect(find.text('Start hosting'), findsOneWidget);
await tester.tapAt(const Offset(5, 790));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const ValueKey('strip-leave')));
await tester.pumpAndSettle();
expect(find.text('Leave server?'), findsNothing);
expect(session.route.value, AppRoute.home);
expect(session.solo.value, isFalse);
});
testWidgets('compact below 1100 px: 3 avatars, no server text', (tester) async {
await pumpShell(tester, const Size(1000, 800));
online(party, media: false);
@@ -166,13 +189,13 @@ void main() {
expect(find.descendant(of: card, matching: find.text(m.name)), findsOneWidget);
}
expect(find.descendant(of: card, matching: find.text('reconnecting…')), findsOneWidget);
// The kick menu asks before kicking.
// Someone else: nudge or sit them out (no kicking).
await tester.tap(find.descendant(of: find.byKey(const ValueKey('member-b')), matching: find.byTooltip('More')));
await tester.pumpAndSettle();
await tester.tap(find.text('Kick'));
await tester.pumpAndSettle();
expect(find.text('Remove Bob?'), findsOneWidget);
await tester.tap(find.text('Cancel'));
expect(find.text('Nudge'), findsOneWidget);
expect(find.text('Sit out'), findsOneWidget);
expect(find.text('Kick'), findsNothing);
await tester.sendKeyEvent(LogicalKeyboardKey.escape);
await tester.pumpAndSettle();
await tester.tapAt(const Offset(40, 700));
await tester.pumpAndSettle();
@@ -306,6 +329,17 @@ void main() {
await stepThrough();
expect(shell().fullscreen, isFalse);
expect(find.byType(PartyStrip), findsOneWidget);
// Esc again: big → mini, not leave; from mini it asks to leave.
await tester.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 400)));
await tester.sendKeyEvent(LogicalKeyboardKey.escape);
await stepThrough();
expect(shell().mode, PlayerMode.mini);
expect(find.text('Leave server?'), findsNothing);
await tester.sendKeyEvent(LogicalKeyboardKey.escape);
await tester.pumpAndSettle();
expect(find.text('Leave server?'), findsOneWidget);
await tester.tap(find.text('Cancel'));
await tester.pumpAndSettle();
// Never rebuilt from scratch on the way.
expect(FakeVideo.created, 1);
// Offline: no player at all.
@@ -0,0 +1,227 @@
import 'dart:convert';
import 'dart:io';
import 'package:crypto/crypto.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:sync_relay_core/core.dart';
import 'package:watch_together/src/update/update_controller.dart';
// Work dir base: WT_TEST_TMP or the system temp. Staging lives inside
// the install root, so the result doesn't depend on which filesystem
// this is (the old /tmp staging broke when /tmp was tmpfs).
final _tmpBase =
Platform.environment['WT_TEST_TMP'] ?? Directory.systemTemp.path;
Future<Directory> _work() async {
final base = Directory(_tmpBase);
await base.create(recursive: true);
return base.createTemp('wt-up-test');
}
// Linux-layout tests check exec bits and symlinks, which NTFS lacks.
final _posixOnly = Platform.isWindows ? 'POSIX install layout' : null;
bool _isExec(String path) => File(path).statSync().mode & 0x40 != 0;
// Builds the release archive exactly like tool/package_linux.sh:
// bundle copied into <stage>/watch-together-<ver>/, then
// `tar -czf out -C <stage> watch-together-<ver>`. [zip]: like
// tool/package_windows.ps1 (`tar -a -c -f out.zip`, bsdtar — Windows only).
Future<List<int>> _releaseTarball(Directory work,
{String version = '0.2.0',
bool windows = false,
bool bundle = true,
bool zip = false}) async {
final stage = Directory('${work.path}/pkg-$version-${windows ? 'win' : 'lin'}');
final folder = Directory('${stage.path}/watch-together-$version');
await Directory('${folder.path}/data/flutter_assets').create(recursive: true);
await File('${folder.path}/data/flutter_assets/fresh.cfg')
.writeAsString('fresh-config');
if (windows) {
await File('${folder.path}/watch_together.exe').writeAsString('binary-v2');
await File('${folder.path}/flutter_windows.dll').writeAsString('dll-v2');
} else {
await File('${folder.path}/watch_together').writeAsString('binary-v2');
if (bundle) {
await Directory('${folder.path}/lib').create();
await File('${folder.path}/lib/libapp.so.1').writeAsString('lib-v2');
await Link('${folder.path}/lib/libapp.so').create('libapp.so.1');
}
await File('${folder.path}/update.sh')
.writeAsString('#!/bin/sh\nexec ./watch_together --upgrade\n');
if (!Platform.isWindows) {
final r = await Process.run('chmod',
['755', '${folder.path}/watch_together', '${folder.path}/update.sh']);
expect(r.exitCode, 0);
}
}
final out = File('${work.path}/out-$version.${zip ? 'zip' : 'tar.gz'}');
final r = await Process.run('tar', [
...zip ? ['-a', '-c', '-f'] : ['-czf'],
out.path,
'-C',
stage.path,
'watch-together-$version',
]);
expect(r.exitCode, 0, reason: '${r.stderr}');
return out.readAsBytes();
}
Future<Directory> _fakeRoot(Directory work, {bool windows = false}) async {
final root = Directory('${work.path}/install');
await Directory('${root.path}/data').create(recursive: true);
await File('${root.path}/data/old.cfg').writeAsString('old-config');
if (windows) {
await File('${root.path}/watch_together.exe').writeAsString('binary-v1');
await File('${root.path}/flutter_windows.dll').writeAsString('dll-v1');
} else {
await Directory('${root.path}/lib').create();
await File('${root.path}/lib/libgone.so').writeAsString('lib-v1');
await File('${root.path}/watch_together').writeAsString('binary-v1');
if (!Platform.isWindows) {
await Process.run('chmod', ['755', '${root.path}/watch_together']);
}
}
await File('${root.path}/user-notes.txt').writeAsString('mine');
return root;
}
// Serves /manifest.json and the tarball. Fields can be overridden per test.
class _Server {
late HttpServer http;
List<int> tarball;
Map<String, Object?> manifestExtra = {};
bool brokenManifest = false;
int tarHits = 0;
_Server(this.tarball);
String get base => 'http://127.0.0.1:${http.port}';
String get manifestUrl => '$base/manifest.json';
Future<void> start() async {
http = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
http.listen((req) async {
if (req.uri.path == '/manifest.json' && !brokenManifest) {
req.response.write(jsonEncode({
'version': '0.2.0',
'protocol': RelayConstants.protoVersion,
'url': '$base/watch-together-0.2.0-linux-x64.tar.gz',
'sha256': sha256.convert(tarball).toString(),
'notes': 'test build',
...manifestExtra,
}));
} else if (req.uri.path == '/watch-together-0.2.0-linux-x64.tar.gz') {
tarHits++;
req.response.contentLength = tarball.length;
req.response.add(tarball);
} else {
req.response.statusCode = 404;
}
await req.response.close();
});
}
Future<void> stop() => http.close(force: true);
}
// The updater itself is tested in packages/core/test/updater_test.dart
// (fixtures above mirror it); this covers the app's UpdateController.
void main() {
group('in-app controller', () {
late Directory work;
late _Server server;
setUp(() async {
work = await _work();
server = _Server(await _releaseTarball(work));
await server.start();
});
tearDown(() async {
await server.stop();
if (await work.exists()) await work.delete(recursive: true);
});
test('check, download with progress, apply, restart same binary',
() async {
final root = await _fakeRoot(work);
final restarts = <(String, List<String>)>[];
launchArgs = const ['--some-flag'];
final c = UpdateController(
currentVersion: '0.1.0',
manifestUrl: server.manifestUrl,
executablePath: '${root.path}/watch_together',
restart: (exe, args) async => restarts.add((exe, args)));
await c.checkOnce();
expect(c.status.value.phase, UpdatePhase.available);
expect(c.status.value.canInstall, isTrue);
expect(c.status.value.manifest!.isRequired, isFalse);
final progress = <double>[];
c.status.addListener(() {
final p = c.status.value.progress;
if (p != null) progress.add(p);
});
await c.install();
expect(progress.last, 1.0);
expect(c.status.value.phase, UpdatePhase.restarting);
expect(await File('${root.path}/watch_together').readAsString(),
'binary-v2');
expect(_isExec('${root.path}/watch_together'), isTrue);
expect(restarts.single.$1, '${root.path}/watch_together');
expect(restarts.single.$2, ['--some-flag']);
}, skip: _posixOnly);
test('newer protocol is a required update, cannot be dismissed',
() async {
server.manifestExtra = {'protocol': RelayConstants.protoVersion + 1};
final c = UpdateController(
currentVersion: '0.1.0',
manifestUrl: server.manifestUrl,
executablePath: '${work.path}/stray-binary',
restart: (_, _) async {});
await c.checkOnce();
expect(c.status.value.manifest!.isRequired, isTrue);
expect(c.status.value.canInstall, isFalse);
c.dismiss();
expect(c.status.value.phase, UpdatePhase.available);
});
test('failed check stays quiet; dev builds skip the check', () async {
server.brokenManifest = true;
final c = UpdateController(
currentVersion: '0.1.0',
manifestUrl: server.manifestUrl,
executablePath: '${work.path}/stray-binary',
restart: (_, _) async {});
await c.checkOnce();
expect(c.status.value.phase, UpdatePhase.idle);
server.brokenManifest = false;
final dev = UpdateController(
currentVersion: '0.0.0-dev',
manifestUrl: server.manifestUrl,
executablePath: '${work.path}/stray-binary',
restart: (_, _) async {});
await dev.checkOnce();
expect(dev.status.value.phase, UpdatePhase.idle);
});
test('failed install shows error, install untouched, retry works',
() async {
server.manifestExtra = {'sha256': '0' * 64};
final root = await _fakeRoot(work);
var restarted = false;
final c = UpdateController(
currentVersion: '0.1.0',
manifestUrl: server.manifestUrl,
executablePath: '${root.path}/watch_together',
restart: (_, _) async => restarted = true);
await c.checkOnce();
await c.install();
expect(c.status.value.phase, UpdatePhase.failed);
expect(c.status.value.error, contains('sha256'));
expect(restarted, isFalse);
expect(await File('${root.path}/watch_together').readAsString(),
'binary-v1');
}, skip: _posixOnly);
});
}
@@ -2,6 +2,8 @@
#include <optional>
#include <flutter/standard_method_codec.h>
#include "flutter/generated_plugin_registrant.h"
FlutterWindow::FlutterWindow(const flutter::DartProject& project)
@@ -25,6 +27,24 @@ bool FlutterWindow::OnCreate() {
return false;
}
RegisterPlugins(flutter_controller_->engine());
window_channel_ = std::make_unique<flutter::MethodChannel<flutter::EncodableValue>>(
flutter_controller_->engine()->messenger(), "starsync/window",
&flutter::StandardMethodCodec::GetInstance());
window_channel_->SetMethodCallHandler(
[this](const flutter::MethodCall<flutter::EncodableValue>& call,
std::unique_ptr<flutter::MethodResult<flutter::EncodableValue>> result) {
if (call.method_name() != "attention") {
result->NotImplemented();
return;
}
HWND hwnd = GetHandle();
if (hwnd != nullptr && GetForegroundWindow() != hwnd) {
// Flashes until the window comes to the front.
FLASHWINFO info{sizeof(FLASHWINFO), hwnd, FLASHW_ALL | FLASHW_TIMERNOFG, 0, 0};
FlashWindowEx(&info);
}
result->Success();
});
SetChildContent(flutter_controller_->view()->GetNativeWindow());
flutter_controller_->engine()->SetNextFrameCallback([&]() {
@@ -40,6 +60,7 @@ bool FlutterWindow::OnCreate() {
}
void FlutterWindow::OnDestroy() {
window_channel_ = nullptr;
if (flutter_controller_) {
flutter_controller_ = nullptr;
}
@@ -3,6 +3,8 @@
#include <flutter/dart_project.h>
#include <flutter/flutter_view_controller.h>
#include <flutter/method_channel.h>
#include <flutter/encodable_value.h>
#include <memory>
@@ -28,6 +30,10 @@ class FlutterWindow : public Win32Window {
// The Flutter instance hosted by this window.
std::unique_ptr<flutter::FlutterViewController> flutter_controller_;
// "starsync/window": `attention` flashes the taskbar entry while the
// window is not in front (lib/src/platform/attention.dart).
std::unique_ptr<flutter::MethodChannel<flutter::EncodableValue>> window_channel_;
};
#endif // RUNNER_FLUTTER_WINDOW_H_
+1 -1
View File
@@ -27,7 +27,7 @@ int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev,
FlutterWindow window(project);
Win32Window::Point origin(10, 10);
Win32Window::Size size(1280, 720);
if (!window.Create(L"watch_together", origin, size)) {
if (!window.Create(L"Starsync", origin, size)) {
return EXIT_FAILURE;
}
window.SetQuitOnClose(true);
Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 361 KiB

+4 -1
View File
@@ -4,7 +4,9 @@
# /etc/systemd/system/, then `systemctl enable --now watch-together`.
# Flags: --port <n> (default 8080), --secret <password> (empty = open
# server, anyone who can reach the port can join), --name, --media <file>,
# --advertise-host <host>, --config <file.yaml|json>. See README.
# --advertise-host <host>, --no-yt-dlp, --yt-dlp-dir <dir>, --config
# <file.yaml|json>. See README. The server's yt-dlp (for web page links it
# fetches) is downloaded into the state directory below.
# Prefer --config with a root-only file over --secret here: unit files and
# process lists are readable by other users.
@@ -17,6 +19,7 @@ Wants=network-online.target
ExecStart=/opt/watch-together/watch-together-server --port 8080 --secret change-me
Restart=on-failure
DynamicUser=true
StateDirectory=watch-together
NoNewPrivileges=true
[Install]
+324 -70
View File
@@ -7,9 +7,9 @@ Plain overview first: [HOW-IT-WORKS.md](HOW-IT-WORKS.md). Wire format:
| Path | What | Rule |
|---|---|---|
| `packages/core` | protocol messages, sync engine (`SyncEngine`), constants, config, Range parsing, logging, Jellyfin client | pure Dart, no Flutter |
| `packages/core` | protocol messages, sync engine (`SyncEngine`), constants, config, Range parsing, logging, Jellyfin client, updater | pure Dart, no Flutter |
| `packages/server` | Relic HTTP + WebSocket server (`SyncServer`, `ServerHub`, `Session`, media endpoint, relay buffer, Jellyfin relay), the `serve()` entrypoint, `bin/server.dart` (dedicated server) | pure Dart, no Flutter |
| `apps/desktop` | Flutter app: UI, `SyncClient`, `SessionCoordinator`, `PlaybackController` (media_kit/mpv), `ServerIsolate`, updater | ships with `watch-together-server` |
| `apps/desktop` | Flutter app: UI, `SyncClient`, `SessionCoordinator`, `PlaybackController` (media_kit/mpv), `ServerIsolate`, update UI (`UpdateController`) | ships with `watch-together-server` |
| `tool` | harnesses: `range_logger`, `shaper`, `fake_viewer`, `e2e_check` | dev only |
| `deploy` | systemd unit for the dedicated server | |
@@ -38,12 +38,30 @@ size (`ServerIsolate.setFile`): the host's in-session File swap then sends a
uncaught async error is logged and the server keeps going. If the isolate
dies anyway, the session shows "server crashed: ..." and leaves; the
shell says so in a dialog (during setup the host attempt fails with it).
Stop (leave, close, cancel during setup, a failed own join, being kicked)
Stop (leave, close, cancel during setup, a failed own join)
sends `stop`, waits up to 3 s for exit, then kills the isolate.
- **Solo session** (watching alone): the same hosting path, so nothing
about solo is a second code path or on the wire. Putting something up
while offline (`AppShellState._putUp`) calls `SessionState.startSolo`:
a `ServerIsolate` with `loopbackOnly` (an extra `'loopbackOnly': true`
key next to the `AppConfig` map; `SyncServer` binds 127.0.0.1 instead
of every IPv4 interface), port 0 (a free one), no password, a host key;
the app joins it and `SessionState.solo` is set. Everything a session
has works (Jellyfin progress, next episode, skip pills, the player); a
link goes up direct (no relay mode). Invite friends opens the Host card,
whose submit (`SessionState.host` while solo) remembers the
coordinator's `lastPick` (the last `swapLink` / `swapJellyfin` /
`swapFile` of this connection) and position, leaves and stops the solo
server, hosts on the card's port and password, swaps the same pick and
seeks once a state shows it (`switching` is set meanwhile; the shell
closes the player and reopens it as it was). If hosting fails or is
cancelled, solo starts again with the same media. Stop leaves without
asking.
- **Dedicated server**: `bin/server.dart` compiled with `dart compile exe`
and shipped next to the app as `watch-together-server`. It parses flags
(`--port`, `--secret`, `--name`, `--media`, `--advertise-host`,
`--jellyfin <file>`, `--config`; default port 8080) into the same map and calls `serve`;
`--jellyfin <file>`, `--[no-]yt-dlp`, `--yt-dlp-dir`, `--yt-dlp-cookies`, `--config`; default port 8080) into the same map and calls `serve`
(`--upgrade` / `--check-update` run the [updater](#updater) instead);
SIGINT and SIGTERM stop it. A Flutter binary cannot run without a
display, which is why the server is its own build.
@@ -55,11 +73,12 @@ warning is logged). Otherwise one shared secret, compared at hello.
Host: when hosting, the app generates a random `hostKey` per hosting, puts
it in the server config and sends it in its own `hello`. The member whose
key matches is the host and cannot be kicked. The address a connection
comes from plays no part (so a reverse proxy or tunnel on the same machine
does not make everyone the host). The dedicated server has no key, so it
has no host. Kick = revoke token, send `kicked`, close; the person may
rejoin at once with a new token.
key matches is the host (`members[].host`: the crown, art rights). The
address a connection comes from plays no part (so a reverse proxy or tunnel
on the same machine does not make everyone the host). The dedicated server
has no key, so it has no host. Nobody removes anyone: members sit each
other out ("Sitting out" below), the host included, since the hosted
server and its `/m/` file serving do not need the host's player.
## Sync model
@@ -94,8 +113,22 @@ Loosely modeled on Syncplay, with a different readiness design.
setting "Unready when the video changes", `AppSettings.unreadyOnSwap`,
default off, sends `ready=false` itself on a new `mediaId`; not for the
first state of a session, and a reconnect does not restore a ready that
a swap meanwhile would have dropped). Stale, leave and kick remove from
the set.
a swap meanwhile would have dropped). Stale, leave and sitting out
remove from the set.
- **Sitting out** (`EngineMember.out`, `state.members[].out`): a member
stays connected (chat, feed, party, nudges) but is never in the set and
never ready: no `ready` or `force` reaches it, a swap leaves it out, its
reports keep it fresh but their `buffered` counts as 0. Anyone can sit
anyone out (`out` with `out:true`, the host included); only the member
itself comes back (`out:false`): it is then in the set like a fresh join
(unless PLAYING) and still unready. The app: an out member's player has
nothing open (no stream or relay pulls; a relay it serves goes on) and
shows the join-back card (cover, title, episode, the server position and
playing/paused). "Join back" or Space sends `out:false` and then
`ready`: at once while HELD (the gate waits for its buffer), while
PLAYING once the player has buffered `need` (at most 15 s), so the others
do not pause for the load. Putting something up while out comes back
first. A reconnect that lands as a new member sits out again.
- **Pause**: while PLAYING, any in-set member who becomes not ready, under
`need` buffered or stale flips the server to HELD (feed action `pause` by
`server`). Recovery resumes by itself when the gate passes.
@@ -137,16 +170,26 @@ Loosely modeled on Syncplay, with a different readiness design.
`hello.resume`. If the server still has that member (an unclean drop it has
not noticed), the new socket takes it over: same token, member id, ready
flag and media URL (the player keeps streaming), no join/leave feed, and the
dead socket is closed without removing the member. An unknown or kicked
token joins as a new member.
dead socket is closed without removing the member. An unknown token joins
as a new member.
- **Client policies**: no state broadcast for 5 s means connection lost, clear
the server state and reconnect every 1.5 s until cancelled (an epoch counter makes
a cancelled attempt stale) or kicked. After a reconnect the ready flag is
a cancelled attempt stale). After a reconnect the ready flag is
restored from the server's state. Cancel during setup aborts the join.
Closing the window leaves gracefully (2 s timeout).
- **Idle** (`IdleTracker`): measured from the later of the last input and the
moment playback stopped. While HELD and self ready: warning at 8 min,
auto-unready at 10 min, and never less than 2 min after the warning.
With "When idle, sit out instead of just unready"
(`AppSettings.idleSitsOut`, default off) the 10 min step sits out.
- **Nudge**: `nudge` names a member; the server passes it to that member
only (no feed), at most 3 per second and 20 per minute per sender
(`nudgesPerSec`, `nudgesPerMin`; the rest is dropped silently). The
nudged app plays the nudge sound whatever its per-sound switch (the
volume still applies), toasts "Alice nudged you" and asks the desktop
for attention when its window is not focused: the runners' `starsync/window`
channel (`attention`), the urgency hint on Linux (GTK, cleared on focus)
and `FlashWindowEx` until foreground on Windows.
- **Deviations from Syncplay**: clock never follows the slowest watcher; no
pause-on-leave; anyone can force ready/unready (all or one); presence gate;
the clock is server time (clients correct for their offset to it).
@@ -160,7 +203,8 @@ Three sources, same sync. Any of them can also go through the relay
- **Link**: the media field holds an http(s) URL. It travels as `media.url`
in `swap` and `state`, and as `mediaUrl` in `welcome`. Each app plays it
directly; the server is only the clock. A pasted link that
directly (a web page through mpv's yt-dlp hook, see "yt-dlp"); the
server is only the clock. A pasted link that
`parseJellyfinLink` recognises goes up as a Jellyfin ref instead when the
ServerId is known (in the link, else `/System/Info/Public`), so its
`api_key` is never shared (`mediaForLink`, host and swap alike).
@@ -191,7 +235,7 @@ Range table (`resolveMediaRequest`, tested byte-exact):
| `bytes=-n` | 206 last n bytes |
| unsatisfiable | 416, `Content-Range: bytes */size` |
| host has no file | 503 + `Retry-After: 2` |
| bad / unknown token, kicked, unknown session | 401, 403, 410 |
| bad / unknown token, unknown session | 401, 410 |
Always `Accept-Ranges: bytes`; `Content-Type` from the extension
(`contentTypeForFile`). Relic hands the Range over as a typed `RangeHeader`;
@@ -364,7 +408,7 @@ errors leave it out).
- Available (`state.relay`) only while the current media is a ref whose
`serverId` equals the credential's. Only that item is relayed, only to a
valid, unkicked viewer token: no open proxy.
valid viewer token: no open proxy.
- Each request opens `/Videos/{id}/stream?static=true&mediaSourceId=` with
`Authorization: MediaBrowser` built from exactly the configured fields
(Jellyfin rewrites a token's device fields from the header), forwards
@@ -380,7 +424,8 @@ errors leave it out).
A member's media through the server's memory, for viewers who cannot
reach it themselves (`packages/core/lib/src/relay.dart` for the wire and
the source, `packages/server/lib/src/relay_buffer.dart`,
the source, `relay_pieces.dart` for segmented streams,
`packages/server/lib/src/relay_buffer.dart`, `piece_buffer.dart`,
`relay_routes.dart`; wire in [PROTOCOL.md](PROTOCOL.md) "Relay").
What gets relayed (`SessionCoordinator.swapPicked`):
@@ -394,27 +439,91 @@ What gets relayed (`SessionCoordinator.swapPicked`):
| Link, "Through me" | relayed from that member's computer; for links only they can open |
| Jellyfin item (browser pick or pasted link) | own account first, then the server's login (`/j`), then relayed from the member who put it up (with their sign-in, or the pasted link's key, which stays on their machine) |
Relayed links must be one file the relay can range-read: a `text/*`
response (YouTube and other pages, which only the player resolves) is
refused before the swap.
Relayed links are range-read: a media file as is, a web page as yt-dlp's
formats, resolved where the bytes are fetched (see "yt-dlp"); the swap
carries only the page URL and the mode. A page's formats are one file, or
a video-only and an audio-only file (YouTube has only those): a media of
two **tracks**, each relayed as its own byte stream with its own buffer
and URL, played together by viewers as an mpv EDL
(`edl://!no_clip;%n%<video>;!new_stream;!no_clip;%n%<audio>`,
`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 (yt-dlp hook) like any other link.
A page with only segmented streams (HLS `m3u8_native`, DASH
`http_dash_segments`, e.g. X's HLS, generic `.m3u8`/`.mpd`) is relayed as
**pieces** tracks (`"as":"pieces"`, one muxed playlist or video + audio):
the fetcher parses the upstream media playlist (`parseHlsPlaylist`), or
yt-dlp's DASH `fragments` (`dashPieces`: a first fragment without a
duration is the init section, so no MPD parser), into a
`RelayPieceList`: each segment, `EXT-X-MAP` init section and AES-128 key
is a piece, one upstream GET, with `EXT-X-BYTERANGE` folded into the
piece. The server serves a VOD media playlist it writes itself from that
list (`RelayPieceList.playlist`: durations, media sequence,
discontinuities, MAP, AES-128 keys with their IVs; every URI its own
`p/<i>`), so a source can never point viewers anywhere else. One URL per
piece also avoids mpv's seek hang on byte-range fMP4 playlists (Apple's
`bipbop` hung on the first seek read directly). Refused: DRM (SAMPLE-AES,
`skd:`, any non-identity key format, `EXT-X-SESSION-KEY`, yt-dlp's
`has_drm`), a live playlist (no `EXT-X-ENDLIST`; yt-dlp's `is_live`
first), a master or I-frame playlist. A remux on the fetcher (ffmpeg
`-c copy` into one pipe) and yt-dlp's own download + merge were rejected:
the size is unknown until the end, a seek past the written part cannot be
served, and it needs ffmpeg or disk.
How bytes move:
- The source dials `/r/.../source` (outbound, so NAT/CGNAT are fine) and
answers the server's pulls from the file or the URL (`RelaySource`,
`FileRelayBytes`, `HttpRelayBytes`; a Jellyfin source sends its own
`Authorization` header from this machine). Viewers only ever talk HTTP
to the server, so the source never sees mpv's request pattern.
- The server keeps one `RelayBuffer` per relayed media: 1 MiB slices in
answers the server's pulls from the file or the URL (`RelaySource` over
a `RelayMedia`: its `RelayTrack`s, each a `RelayBytes`:
`FileRelayBytes`, `HttpRelayBytes`, `LinkRelayMedia` / `PageRelayMedia`
for links; a Jellyfin source sends its own `Authorization` header from
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).
- 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.
YouTube serves a range up to 10 MiB at full speed but throttles a longer
or open-ended one to about twice the bitrate (measured 0.8 MB/s against
8.6 to 12.5 MB/s); chunked, a 1080p relay seeks in under 0.8 s (4 to
6.6 s unchunked) and even beats mpv reading YouTube directly, since the
buffer reads ahead at full speed.
- The server keeps one buffer per track of a relayed media (one for a
file), a `RelayBuffer` for bytes: 1 MiB slices in
memory, filled by one sequential pull at a time. Kept: the first and
last 2 MiB (container indexes; mpv reads both on open and on seeks),
64 MiB behind the slowest viewer and 256 MiB past the furthest, the
same as each viewer's mpv buffer (`PlaybackController`), so a skip
inside the viewers' buffer window is served from memory. Evicted by
distance from the viewers, never LRU. With 2+ viewers the source sends
each byte about once.
- Filling: 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. Nothing is fetched while nobody
each byte about once. Several tracks split the ahead and behind budgets
by size (one duration, so by bitrate; `relayBudgets`), at least 8 MiB
each, which keeps both windows at the same playback distance (1080p
video + aac: about 246 + 10 MiB ahead); each pins its own head and
tail. A viewer is its token in every track's buffer.
- A pieces track gets a `PieceBuffer`: whole pieces by index, each one
pull (a piece names its index on the channel), served whole with Range
inside a piece. A viewer's position is its newest requested segment
(opening the playlist puts a new viewer at the first one, so its first
pieces are fetched before it asks). Filled: a blocked viewer's piece
(oldest wait first; a pull nobody waits on gives way at once), the init
and key pieces the viewers' segments use, then segments ahead of each
viewer up to the ahead budget, slowest first. Init and key pieces are
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
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
video's HLS video + audio 0.3 to 0.75 s, seeks 0.03 to 0.4 s; A/V sync
within 0.2 ms after every seek; a second viewer of the same pieces
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. Nothing is fetched while nobody
watches through the relay (a Jellyfin source with only account holders
uploads nothing).
- mpv leaves superseded requests open but unread after a seek: only a
@@ -430,7 +539,8 @@ How bytes move:
stop. A crash frees it with the process.
- Source away: viewers keep playing from the buffer; a request waits up
to 60 s for missing bytes. The source reconnects with backoff (same
token after a resume); a different file size drops the buffer.
token after a resume); a different size (or piece count and roles)
drops that track's buffer.
`state.media.relay.live` follows the channel, and viewers show "waiting
for <name> to pass the video on" while it is down.
@@ -456,6 +566,104 @@ so for quick seeks. Rejected along the way: a fixed per-request window
pipe), one shared window across requests (deadlocked on mpv's unread
requests), Dart `http2` (connection window stuck at 64 KiB).
## yt-dlp
Web pages with a video (YouTube, Twitter/X, ...) play through yt-dlp in
all three link modes. Nothing is bundled or installed by hand.
- **Manager** (`YtDlp`, `packages/core/lib/src/yt_dlp.dart`, used by the
app and the server): the managed copy in its folder (`yt-dlp` /
`yt-dlp.exe`) first; if missing it downloads the official standalone
build for the platform (`ytDlpAssetName`: `yt-dlp_linux`,
`yt-dlp_linux_aarch64`, musl variants, `yt-dlp.exe`, ...) from the tag
`releases/latest` redirects to, checks it against that release's
`SHA2-256SUMS`, makes it executable (not on Windows) and renames it into
place. If the download fails, a `yt-dlp` on PATH is the fallback. After
that, at most once a day (`last-check` stamp in the folder) and in the
background, a newer tag is downloaded the same way; nothing waits for
it. `version`, `lastError`, `busy` and `changes` feed Settings → Player
→ yt-dlp ("Update now" forces the check). Every failure is non-fatal.
Folders: the app's is `<app support>/yt-dlp` (`AppYtDlp`, warmed up at
start), shared with its hosted server; the dedicated server's is
`--yt-dlp-dir`, else `$STATE_DIRECTORY/yt-dlp` (systemd), else
`yt-dlp` next to the binary if writable, else
`~/.cache/watch-together/yt-dlp`.
- **Cookies** (`YtDlpCookies`, `cookies.dart`): a yt-dlp folder holds at
most one source, `cookies.txt` (a private Netscape copy, mode 0600,
written to a `.part` that is made private before anything goes into
it) or `cookies-browser` (`BROWSER[:PROFILE]`). Every resolve adds
`--cookies <copy>` or `--cookies-from-browser <spec>` (the update and
`--version` runs do not); yt-dlp may write the copy back. Imports
(`parseCookies`) take a Netscape cookies.txt or a cookie extension's
JSON array (`name`, `value`, `domain`, `path`, `expirationDate` /
`expires`, `hostOnly`, `secure`, `httpOnly`, `session`; host-only
cookies keep their bare host with `FALSE`, the rest get a leading '.';
session cookies expire `0`), grouped by site (`cookieSite`: the
registrable domain by a short rule, with google.com filed under
youtube.com and bilibili's CDNs under bilibili.com). The app: Settings
→ Player → yt-dlp → Cookies (a browser and profile, or a file picked or
dropped, kept whole: every site in it; "Remove cookies"; the shell's
own drop target is off on Settings so only this box takes the file); its hosted server shares the folder,
so it uses them too. mpv's hook gets the same as `ytdl-raw-options`
(`cookies=%N%<path>` / `cookies-from-browser=%N%<spec>`, length-quoted),
re-set before each external link when it changed. The dedicated
server: `--yt-dlp-cookies <file>` / `ytDlpCookies` converted into its
folder at start; without it the copy and browser setting are removed.
Values are never logged and never leave the machine.
- **Resolve** (`YtDlp.resolve`, `resolveArgs`): `yt-dlp -J --no-playlist --no-warnings
--ignore-config` plus the cookies, 25 s timeout: title, uploader, duration, thumbnail,
live or not, and what a relay reads (`pickTracks`), each format with
its URL, `http_headers` and `http_chunk_size`. 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
- a video-only + audio-only pair is taller (`pickSplit`): video tallest
up to 1080p (else the shortest above), HEVC before AV1 before VP9
before h264 (the most compact first: relayed bytes cost every viewer
and the server buffer, and mpv decodes them all), then bitrate; audio the original language, not a
`-drc` variant unless alone, m4a/aac before opus, then bitrate.
YouTube has only these (299 + 140 for a 1080p60 video).
- Neither: segmented streams (`pickPieces`: `m3u8_native`/`m3u8` with a
media playlist URL, or `http_dash_segments` with http(s) `fragments`;
not `has_drm`): one muxed playlist (video and audio not known to be
absent) unless a video-only + audio-only pair is taller, with the
same height, codec and audio rules. Relayed as pieces (see "Relay").
- A live stream, or nothing of the above: nothing to relay.
- **Probe** (`probeLink`, `link_probe.dart`): a `Range: bytes=0-0` GET; a
video/audio type, or a generic one on a media file name, is a file
(`looksLikeMediaFile`); anything else goes to yt-dlp, and when yt-dlp
cannot read it the page's `og:title` / `<title>` is kept with yt-dlp's
reason ("Not a video (and yt-dlp couldn't read it: ...)"). The app
probes on paste (`AppYtDlp.probe`, remembered 10 minutes) for the Link
preview and the swap: a page is named by its title, never its URL tail,
and its thumbnail goes up as the banner (no poster).
- **Everyone fetches it**: the swap sends the page URL. Before opening an
external link every app points mpv's hook at its managed copy
(`script-opts=ytdl_hook-ytdl_path=...`, `ytdl-format=bv*[height<=1080]+ba/b`,
once per player), waiting for the download unless the URL names a media
file. A hook error shows as "can't open this link: ...".
- **Through me**: the putter's probe must give tracks, and a pieces
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
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
resolving machine's address, so they never leave it.
- **Server fetches it**: the server probes the link itself
(`LinkRelayMedia`) with its own yt-dlp (off with `--no-yt-dlp` /
`ytDlp: false`) and relays its tracks the same way, setting
`relay.tracks` with `live`. The putter only refuses a page its own
yt-dlp says cannot be relayed (the server may reach what the putter
cannot); when the server cannot read it, the putter gets an `err` with
the reason. Viewers open a fetched
link's `/r/` URL only once `relay.live` is set ("the server is getting
the video…" until then): resolving takes seconds and media_kit's mpv
gives up on a silent request after 5 s (`network-timeout`).
## Jellyfin in a session
Client side (`lib/src/jellyfin/session_media.dart`, `progress.dart`,
@@ -478,7 +686,7 @@ Client side (`lib/src/jellyfin/session_media.dart`, `progress.dart`,
- **Progress** (`JellyfinProgressReporter`, `OwnPlay` only): Playing once the
file is open and mpv has picked its tracks; Progress every 10 s and at
once on pause, resume, a seek (position more than 3 s from where playback
should be) or a track change; Stopped on swap, leave, kick, window close
should be) or a track change; Stopped on swap, leave, sitting out, window close
(bounded to 1.5 s inside the graceful leave) and at `finished` (position =
duration, so Jellyfin marks it played); seeking back from the end sends
Playing again. Reports carry `PositionTicks`, `IsPaused` and mpv's
@@ -531,12 +739,16 @@ always scrolls the page). Space never presses a focused button
- **Party strip** (`party_strip.dart`, 60 px): your avatar (profile card; 42 px like the party's),
the Library / Schedule / Settings tabs with a growing underline (an
`HScroll` when narrow), the update indicator, then offline Join and Host
(connect cards), online the party: `AvatarStack` (host first, then
`HScroll` when narrow), the update indicator, then offline a grey dot,
"Offline", Join a friend and Host (connect cards; icon-less "Join" and
"Host" under 1100 px), solo a "Solo · only you" chip, Invite friends
(the Host card, titled "Invite friends", "Start hosting") and Stop (no
dialog; no avatars or chat), online the party: `AvatarStack` (host first, then
ready; spreads on hover; 5 shown, 3 under 1100 px), the server name and
"ready n/m" (hidden under 1100 px), chat with an unread badge, and Leave
(asks first). The stack opens the members card (status, host / relay
pills, "via host", force ready, kick for all but the host, "Copy invite
pills, "via host", "sitting out"; per member force ready, Nudge and Sit
out (anyone, the host too), your own row Sit out / Join back; "Copy invite
address": the dialled address when joined; hosting, `inviteAddresses`
of `NetworkInterface.list` (IPv4, no loopback or link-local, private
ranges first) with the Host port, and a note that friends outside the
@@ -545,7 +757,7 @@ always scrolls the page). Space never presses a focused button
- **Avatars** (`avatar.dart`): `StatusAvatar` draws a ring outside the
picture with a gap in the background colour (both scale with the size):
green ready, amber buffering (with a spinner), grey not ready, dimmed
stale; a crown for the host (`members[].host` in the state; your own
stale, dimmed with a moon ("away") sitting out (last in the stack); a crown for the host (`members[].host` in the state; your own
app also knows before the first state) and a tower for the member
passing the video on. `memberAvatarImage` is the one picture lookup: yours from the
profile, anyone else's from `coordinator.avatars` by `avatarHash`.
@@ -559,7 +771,8 @@ always scrolls the page). Space never presses a focused button
to watch is picked in the library afterwards. Submit state (busy,
cancel, per-card errors) is `ConnectFlow`, owned by the shell so an
attempt and its error outlive the card; a card closes itself once
online.
in a server that is not solo. Join says that a friend gives the address
and that no account or Jellyfin is needed.
- **Profile** (`profile_card.dart`, `screens/home/profile.dart`): a card
tinted with your colour: picture (hover "Change" opens `CropPicker`:
drag, wheel or slider zoom, previews at 88/42/24 px, exports a 256 px
@@ -568,6 +781,16 @@ always scrolls the page). Space never presses a focused button
the `connect_form` box (an old picture path is shrunk once and kept as
bytes). Online, a name change (once typing settles) or picture change
goes out through `Profile.push` (`coordinator.updateProfile`).
- **Welcome** (`welcome.dart`): offline the Library opens with
`WelcomeBand` over the source switch, only for newcomers (no Jellyfin
account, no recent server, not dismissed with "Don't show again";
sizes away when online): the app
icon, Starsync and "Watch together, in sync", then three `WayCard`s,
Join a friend first (filled), Host, Watch solo (opens Files, or
Jellyfin when linked); beside the name from 940 px, under it below,
stacked under 520 px. With no Jellyfin server linked the Jellyfin source
is `NoJellyfinPanel`: Files and Link cards ("No account needed") and an
optional Jellyfin card whose "Link a server" opens the sign-in in place.
- **Tabs**: Library (`library_tab.dart`): a source switch (Jellyfin /
Link / Files, fade through) with the Jellyfin library chips beside it.
Its state (source, link field and relay mode, selected Jellyfin card,
@@ -581,18 +804,21 @@ always scrolls the page). Space never presses a focused button
watched / unwatched, remove from Continue, overview, the episode list
(click picks the episode, double-click puts it up). A double-click on a
card puts it up (a series: `playableFor`, its next episode). Link
(`library_link.dart`): paste field, a preview (YouTube oEmbed title and
thumbnail, else the file name), the three relay modes online, and the
(`library_link.dart`): paste field, a preview (a page's yt-dlp title,
uploader, length and thumbnail, else the file name; see "yt-dlp"), the three relay modes online, and the
last 12 links put up (`recentLinks` in the `connect_form` box). Files
is `FilesPage` (`files/`): watched folders (`FolderStore`) scanned in
an isolate, videos grouped by series; series start collapsed, a header
click opens one and open ones stay open for the app session
(`showFilesFolder` opens a folder from outside). Offline everything browses; the buttons say "Join or host to
watch together". Schedule (`schedule/`) is a week of day columns built
(`showFilesFolder` opens a folder from outside). With nobody else
watching (`solo` on the Library, Files and Schedule: offline or solo)
the buttons say Play, Link has no relay modes and Files no relay notice;
offline a put-up starts a solo session. Schedule (`schedule/`) is a week of day columns built
from AniList's airing schedule (`anilist.dart`: pages cached 10 min in
memory and 1 h on disk, stale data shown on 429 or failure) matched
against AnimeFlow (`animeflow.dart`, app API with the Jellyfin token;
`/library` marks shows on Jellyfin it doesn't track) into a status per
`/library` marks shows on Jellyfin it doesn't track, through every AniList
entry of a season (`anilist_map`: a season AniList splits has several)) into a status per
episode (`schedule_model.dart`); requests and the owner's inbox are in
`requests_sheet.dart`. It refreshes every minute; mouse drag and
Shift+wheel scroll the board sideways, the plain wheel scrolls a column.
@@ -611,23 +837,28 @@ always scrolls the page). Space never presses a focused button
into videos (the Files extensions, `kindOf`), a folder, an http(s) link,
an image or nothing; videos win over folders, folders over images.
`HandoffCard` opens as a popover at the pointer: a video goes up through
the shell's put-up (offline: into Files → Recent) or adds its folder to
Files; a folder is added to Files and opened; a link fills Library →
Link (online the relay mode is picked there). Images are ignored.
the shell's put-up (Play when alone; offline that starts solo) or adds
its folder to Files; a folder is added to Files and opened; a link fills
Library → Link (with others the relay mode is picked there). Images are ignored.
Nothing happens while a dialog or popover is over the shell.
- **Sounds** (`sfx/`): the coordinator plays an `SfxCue` for ready,
unready, force-ready, join, leave, kick, swap and others' chat (feed
events), the idle warning, and connection lost / back (once per drop).
unready, force-ready, join, leave, swap and others' chat (feed
events), being sat out by someone (the `kick` sound), a nudge (always,
whatever its switch), the idle warning, and connection lost / back (once per drop).
Nothing for what the video already says (play, pause, seeks, the end).
The set is synthesized, not sampled: `synth.dart` renders Japanese-house
voices (Karplus-Strong koto with bends, furin chime, rin bowl, bamboo,
hyoshigi clappers, a shoji slide of filtered noise) through a low-pass
and a short room to quiet WAVs; `sound_bank.dart` voices each event in
the Yo scale on D. `SynthSfx` renders a sound on first use into the temp
dir and plays it on a pool of three audio-only mpv players, at
The sounds are synthesized, not sampled: `synth.dart` renders voices
(plucked strings with bends, glass and star bells, a singing bowl,
hollow knocks, wooden clacks, filtered-noise air and stardust, a long
dark space reverb) to quiet WAVs; `sound_bank.dart` voices every event
in three presets (`SoundPreset`: Cosmic, Abyss, Wood & glass) in one
shared key, so mixed presets still agree. `SynthSfx` renders a sound on
first use into the temp dir (cache key: preset, sound, ready index) and
plays it on a pool of three audio-only mpv players, at
`AppSettings.soundVolume` (square curve) unless switched off in
`soundsOff`. Settings → Notifications & sounds has the volume and a
preview and switch per sound.
`soundsOff`. Settings → Notifications & sounds has the volume, the
overall sound set (`soundPreset`), and per sound a preview, its own set
(`soundPresetFor`, else the overall one) and a switch; a preview always
re-renders, so an edited spec plays after a hot reload.
- **Player** (`session_player.dart`): `SessionPlayer` holds the one
`Video` of the current player (a new `GlobalKey` per player generation).
The shell keeps it in one layer of the body at one place in the element
@@ -635,8 +866,10 @@ always scrolls the page). Space never presses a focused button
snaps to the nearest corner with the shared curve). A mode change fades
the layer out, moves it while invisible and fades it in (with the tabs
when the full player comes or goes), so the video is never mounted twice
nor remounted. A tab sends the full player to mini; × on the mini closes
it (muted, still in the server) and the strip brings it back. Fullscreen
nor remounted. A tab sends the full player to mini; × on the mini sits
out (the player unloads and shows the join-back card, `SitOutCard`, over
the still mounted `Video`); × on that card closes the player, the strip
brings it back, and joining back with it closed shows the mini. Fullscreen
is `windowManager.setFullScreen` with the strip hidden (party avatars in
a corner while paused); F, Esc and double-click on the picture toggle it.
The player keys (Settings → Keyboard shortcuts) work while the player is
@@ -661,8 +894,8 @@ always scrolls the page). Space never presses a focused button
the controls: `playerStatusFor` (pure) picks the most pressing of
removed, reconnecting (Cancel), a warn/error health message (Retry),
connecting (only after 2 s), finished, someone loading (below
`StateBroadcast.bufferNeed`, the gate's own need: less near the end,
none at finished; their avatar with the progress round it), you not
`StateBroadcast.bufferNeed`, the gate's own need at the current
position: less near the end, none at finished; their avatar with the progress round it), you not
ready (a click marks you ready) and who everyone waits for (avatars,
"2/3 ready"); nothing otherwise, info never. 34 px avatars, a main line
and a muted detail line. Frosted glass, colour only for problems; it fades through
@@ -694,8 +927,8 @@ always scrolls the page). Space never presses a focused button
Settings persist in two Hive boxes: `connect_form` (last form, recent
servers and links, the profile picture and colour; saved even when a join
fails) and
`settings`. Link media names are derived from the URL path and never
contain a query string.
`settings`. Link media names are a web page's title (see "yt-dlp"), else
derived from the URL path, never containing a query string.
Jellyfin on the desktop (`lib/src/jellyfin/`, over the core client):
- `accounts.dart`: `JellyfinAccounts` (singleton `instance`), one
@@ -802,7 +1035,7 @@ fullscreen has no drag area and shows the buttons in the player's top bar.
Every failure must show in the UI with a cause and a next step; nothing
stalls silently. The app has a `HealthStatus` signal (severity + message)
for connecting, reconnecting, wrong password, version mismatch, kicked,
for connecting, reconnecting, wrong password, version mismatch,
host offline, and starvation ("waiting for X - N.N s buffered").
Errors are logged with a stack trace at the boundary, then turned into a
displayable state. The server's `GET /health` returns JSON (`ok`, `name`,
@@ -810,25 +1043,45 @@ displayable state. The server's `GET /health` returns JSON (`ok`, `name`,
it carries nothing sensitive. The Join card's recent servers poll it for live
status.
## App icon and launcher entry
`assets/icon/icon.svg` is the source; `icon_<size>.png` and the Windows
`app_icon.ico` are rendered from it (rsvg-convert, magick). The GTK
application id is `moe.senkochan.watch_together`, which Wayland uses as the
window's `app_id`; compositors 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/moe.senkochan.watch_together.desktop` (Exec =
the running binary) and the icons into the hicolor theme, only when they
changed. The runner also sets the window icon from the bundle (X11).
## Updater
`apps/desktop/lib/src/update/updater.dart` (no Flutter imports, unit tested).
Reads a manifest (`version`, `protocol`, `url`, optional `sha256`, `notes`)
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
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,
with rollback on failure (writing the running executable fails with ETXTBSY;
rename works). A packaged install is detected by `data/` and `lib/` next to
the binary; anything else exits with "update manually". A manifest whose
the binary (app or server, same folder); anything else exits with "update
manually". A manifest whose
protocol is newer than the local one is a required update. Windows apply
renames in-use files to `*.old` (swept on next start); CI tests it against a
running exe on NTFS. The manifest's `assets` maps `linux-x64` /
`windows-x64` to archive URLs; top-level `url` (Linux) is for builds <= 0.2.1. The version is
stamped with `--dart-define=APP_VERSION` (dev runs report `0.0.0-dev` and skip
checks). `watch_together --upgrade` (or the shipped `update.sh`) runs it from
the CLI; `UpdateController` runs the same code for the strip's update indicator,
which opens the update card, and restarts the app ("Update and restart").
stamped into both binaries with `APP_VERSION` (`--dart-define` for the app,
`--define` for `dart compile exe`; dev runs report `0.0.0-dev` and the app
skips checks). CLI: `watch-together-server --upgrade` (no display needed;
the shipped `update.sh` / `update.bat` run it), `--check-update` prints only;
it ends with "restart the server to run <version>". `watch_together --upgrade`
does the same from the app binary. In the app, `UpdateController`
(`apps/desktop/lib/src/update/`) runs the same code for the strip's update
indicator, which opens the update card, and restarts the app ("Update and
restart", closing Hive first).
Settings has Check now and an auto-check toggle. The manifest is `updates/manifest.json` in
this repo, written by `tool/release.sh`.
@@ -838,7 +1091,8 @@ this repo, written by `tool/release.sh`.
- No transcoding: bandwidth is plentiful and the Jellyfin box should not be loaded.
- No roles, friends trust model: simplest thing that fits the use.
- Blank password = open server: the owner chooses between open and private.
- The host cannot be kicked: the server dies with the host anyway.
- Sit out instead of kick: friends never need to throw someone out, only to
stop waiting for them; the person stays in chat and can be nudged back.
- Server runs inside the app on an isolate, and ships as a separate compiled
server: a Flutter binary cannot run headless, so a dedicated server needs
its own build of `packages/server`. An isolate keeps the UI responsive and
+17 -4
View File
@@ -17,11 +17,11 @@ cd apps/desktop && flutter pub get && flutter run -d linux
cd apps/desktop && flutter build linux --release --dart-define=APP_VERSION=<x.y.z>
tool/package_linux.sh [--skip-build] [--noupload] # dist/*.tar.gz
powershell -File tool/package_windows.ps1 [-SkipBuild] # dist/*.zip (on Windows)
cd packages/server && dart compile exe bin/server.dart -o watch-together-server
cd packages/server && dart compile exe --define=APP_VERSION=<x.y.z> bin/server.dart -o watch-together-server
```
`APP_VERSION` defaults to `0.0.0-dev`; `package_linux.sh` stamps it from the
desktop pubspec. Setting `DISCORD_WEBHOOK_URL` makes the packager post the
`APP_VERSION` defaults to `0.0.0-dev`; the packagers stamp it from the
desktop pubspec into both the app and the server. Setting `DISCORD_WEBHOOK_URL` makes the packager post the
tarball to Discord (never commit it). The packager always compiles the
dedicated server (with the Flutter SDK's `dart`) into the tarball as
`watch-together-server`, also with `--skip-build`.
@@ -108,7 +108,10 @@ attaches to spawned isolates too (it shows as `server`). Its log lines are
printed with a `[server]` prefix. Hot reload reloads the server isolate's
code too, but its state (members, clock, sockets) survives until hosting
restarts; changes to startup code (`SyncServer.start`, routes) need a new
Host. Without the app: `dart run packages/server/bin/server.dart`.
Host. Hot restart kills the server isolate (flutter_tools kills every
non-UI isolate first), so a `[server] server crashed: the server stopped
unexpectedly` line just before it is expected. Without the app:
`dart run packages/server/bin/server.dart`.
## Gotchas
@@ -125,6 +128,16 @@ Host. Without the app: `dart run packages/server/bin/server.dart`.
- media_kit has no public `Player.setProperty`; the code reaches mpv through a
dynamic call on `player.platform`. Cache size goes through
`PlayerConfiguration(bufferSize:)`.
- Hot restart with any media_kit `Player` alive (session, thumbnails, sfx)
used to SIGSEGV on mpv's `core` thread: each mpv handle's wakeup callback
is a `NativeCallable` of the old isolate, freed by the restart, and media_kit
then sends the leftover handles `quit`, which fires it. `fox_utils`' Linux
plugin (from 0.1.8, debug builds only) clears those callbacks on the
engine's `on-pre-engine-restart` signal, reading the handles from
media_kit's debug-only `NativeReferenceHolder` buffer (address in
`$TMPDIR/com.alexmercerind.media_kit.NativeReferenceHolder.<pid>`). Old
players are leaked, not destroyed, until the process exits. Re-check this
after a media_kit upgrade.
- mpv reconnect flags: comma form works, colon form is rejected. Re-verify
through `setProperty` if touched.
- Overlays that are hidden but mounted keep focus (Space falls into a hidden
+14 -4
View File
@@ -44,7 +44,13 @@ A five-minute tour. Details: [ARCHITECTURE.md](ARCHITECTURE.md), wire format:
- **Dedicated server**: `watch-together-server`, a separate program in the
release tarball, for an always-on box. See the README.
Friends join either way with Join in the strip at the top of the window.
Friends join either way with Join a friend in the strip at the top of the
window; guests need no Jellyfin or account.
- **Watch solo**: put something up without joining or hosting and the
app hosts a private server reachable only from this computer, so
watching alone works like a session (progress, next episode). Invite
friends turns it into a normal hosted server, video and position kept.
## One session, start to finish
@@ -74,12 +80,16 @@ Friends join either way with Join in the strip at the top of the window.
(stops blocking) and removed after 30 s. The app reconnects by itself and
takes over your old place (same member, ready flag and video).
There are no roles: anyone can seek, swap, force-ready others and kick
anyone except the host.
There are no roles: anyone can seek, swap, force-ready others, nudge
anyone, and sit anyone out (the host too). Someone sitting out stays
connected and in the chat, but their app loads nothing and nobody waits
for them until they join back.
## Where video comes from
- **Link**: you paste an http(s) URL. Every app plays it directly with mpv.
- **Link**: you paste an http(s) URL. Every app plays it directly with mpv;
a web page (YouTube, X, ...) goes through yt-dlp, which the app and the
server download and update by themselves.
A pasted Jellyfin link becomes the Jellyfin item below when possible.
- **Jellyfin item**: picked in the app's library browser. Shared as "this
item on this Jellyfin server", never as a link with someone's sign-in.
+98 -24
View File
@@ -1,4 +1,4 @@
# Protocol (proto = 10)
# Protocol (proto = 11)
Wire format only. Behavior (gate, clock, timeouts) is in
[ARCHITECTURE.md](ARCHITECTURE.md). Source of truth:
@@ -30,7 +30,7 @@ with different `proto` refuse each other at `hello` (`proto mismatch`).
## Client to server
```
{"t":"hello","proto":10,"secret":"","user":{"name":"","avatar":""},
{"t":"hello","proto":11,"secret":"","user":{"name":"","avatar":""},
"media":{"name":"","size":0,"duration":0,"url":null},"hostKey":"...",
"resume":"<token>"}
{"t":"ready","ready":true,"source":"manual"} source: manual | auto
@@ -43,7 +43,8 @@ with different `proto` refuse each other at `hello` (`proto mismatch`).
"duration":1440.5,"mediaId":2,"jf":{"<serverId>":true}}
{"t":"ping","c":1767225600000}
{"t":"chat","text":"..."}
{"t":"kick","target":"<memberId>","reason":""}
{"t":"out","target":"<memberId>","out":true}
{"t":"nudge","target":"<memberId>"}
{"t":"profile","name":"...","avatar":"<base64>"}
{"t":"art","mediaId":2,"poster":"<base64>","banner":"<base64>"}
```
@@ -54,11 +55,11 @@ Field notes:
- `hello.hostKey` (optional, omitted when empty): the hosting app
generates a random key per hosting, gives it to its server and sends it
from its own client. The member whose key equals the server's is the host
and cannot be kicked. A server without a key (the dedicated server) has no
(`members[].host`). A server without a key (the dedicated server) has no
host. Where the connection comes from does not matter.
- `hello.resume` (optional, omitted when empty): the token from this
client's previous `welcome`, sent on reconnect. If the server still has
that member and it is not kicked, the connection takes it over: `welcome`
that member, the connection takes it over: `welcome`
returns the same `token` and `memberId`, the member keeps its flags, and
there is no `join`/`leave` feed. Otherwise it is ignored and a new member
joins.
@@ -81,6 +82,19 @@ Field notes:
`rejected: avatar too large` (the socket stays open, nothing changes).
- `art`: pictures of the current media (see "Media art").
- `chat.text` is cut to 500 characters.
- `out`: `out:true` sits the member `target` out (anyone, the host
included); `out:false` brings the sender back and only works with its own
member id as `target`. An out member is never in the set and never
ready: `ready` from it and `force` on it are ignored, a swap leaves it
out, the `buffered` of its reports counts as 0 (it keeps sending
`report`: that keeps it fresh). Coming back puts it in the set like a
join (unless `playing`), unready; it sends `ready` itself. Unknown
targets and no-op changes are ignored. Feed: `out` (`target` = the name
of who was sat out, absent when the actor sat itself out) and `back`.
- `nudge`: the server sends `{"t":"nudge","from":<memberId>,"name":<name>}`
to `target` only (no feed). At most 3 per second and 20 per minute per
sender; the rest, nudges to oneself and to unknown targets are dropped
silently.
- `swap.media.url` is the link; `name`, `size`, `duration` are informational.
`swap.media.jellyfin` makes it Jellyfin media (see below); then `url` is
dropped. `swap.media.relay` makes it relayed media (see "Relay").
@@ -99,12 +113,12 @@ Field notes:
"serverTime":"<ISO-8601 UTC>","mediaId":1,
"media":{name,size,duration,url?,jellyfin?,relay?,swapBy?,art?}|absent,
"members":[{"id","name","avatarHash"?,"ready","inSet","buffered","stale","jf"?,
"host"?}],
"host"?,"out"?}],
"seekBy":"m1","seekSeq":3,"relay":true}
{"t":"pong","c":1767225600000,"s":1767225600012}
{"t":"feed","actor":"","action":"","target":"","reason":"","at":"<ISO-8601>"}
{"t":"chat","text":"...","from":"<name>"}
{"t":"kicked","reason":""}
{"t":"nudge","from":"m2","name":"Alice"}
{"t":"err","message":""}
```
@@ -130,12 +144,13 @@ Field notes:
`report.jf`, omitted when it never sent one. `members[].avatarHash`
(omitted when the member has no picture) names the picture's bytes (see
"Avatars"). `members[].host` (omitted when false): the member sent the
server's `hostKey` (see `hello.hostKey`).
server's `hostKey` (see `hello.hostKey`). `members[].out` (omitted when
false): the member sits out (see `out`).
- `pong.c` echoes the ping's `c`; `pong.s` is the server's clock in epoch ms
(the clock `serverTime` uses). With `t3` the local receive time, the offset
is `s - (c + t3) / 2`, accurate to half the round trip.
- `feed.action` values: `ready`, `unready`, `force-ready`, `force-unready`,
`force-play`, `force-hold`, `kick`, `join`, `leave`, `stale`, `swap`,
`force-play`, `force-hold`, `out`, `back`, `join`, `leave`, `stale`, `swap`,
`seek`, `pause`, `resume`, `finished`, `chat`, `rename`, `avatar`. `target`
and `reason` are optional; `reason` carries `auto`/`manual` for ready and
`silent too long` or `no signal` for leave/stale. `rename`: `actor` is the
@@ -154,7 +169,7 @@ Field notes:
- `GET /a/<sid>/<token>/<hash>`: the bytes with their image content type,
`cache-control: private, max-age=31536000, immutable` (a hash names fixed
bytes; clients fetch each hash once, from the host they dialled).
Errors: 401 bad token, 403 kicked, 404 no current member has that
Errors: 401 bad token, 404 no current member has that
picture, 410 unknown session. Pictures go with their member (leave,
change, removal).
@@ -204,7 +219,7 @@ state media.art = {"poster":"<hash>","banner":"<hash>"}
file) with its own credential and streams it through. `Range` is forwarded
as sent; status, `Content-Range`, `Content-Length`, `Content-Type` and
`Accept-Ranges` come back as Jellyfin sent them. Errors: 401 bad token,
403 kicked or not the current item, 404 no relay for the current media,
403 not the current item, 404 no relay for the current media,
410 unknown session, 502 Jellyfin unreachable or rejecting the server's
credential. Bytes count toward `bytesServed`.
- Pasted Jellyfin links sent as plain `url` stay plain links.
@@ -218,29 +233,68 @@ flow control, cases) is in [ARCHITECTURE.md](ARCHITECTURE.md), "Relay".
```
swap media.relay = {} the swapper's machine serves the bytes
media.relay = {"tracks":[{"kind":"video"},{"kind":"audio"}]}
the same, as two tracks played together
media.relay = {"tracks":[{"kind":"video","as":"pieces"}]}
the same, as one segmented playlist
media.relay = {"fetch":true} the server reads media.url itself
state media.relay = {"source":"m2","live":true} or {"fetch":true,"live":true}
media.relay = {"fetch":true,"live":true,"tracks":[{"kind":"video"},{"kind":"audio"}]}
```
Tracks: a relayed media is one file, or tracks that viewers play as one
(a page's separate video and audio). `tracks` lists them in order, up to
4 entries of `kind` `video` or `audio`, each a Range-readable byte stream,
or with `"as":"pieces"` a segmented stream (an HLS media playlist or DASH
fragments) relayed piece by piece and served as a VOD media playlist. A
piece is one segment, init section (`EXT-X-MAP`) or AES-128 key, fetched
upstream whole with one GET. At least 2 entries, or 1 pieces entry (a
muxed playlist, `kind` `video`); absent, an unknown `kind` or `as`, or
anything else means one file.
- `swap`: `relay` with `jellyfin` keeps the ref (the swapper serves it to
viewers without an account; `fetch` is ignored there). `relay` without
`jellyfin`: no URL is stored or broadcast; `fetch` needs an http(s)
`url` (else `relay fetch needs an http(s) url`), which the server keeps
to itself, and a server with a password (else `server fetch needs a
server password`: an open server would fetch any address it can reach
for anyone). Any later swap ends the relay.
for anyone). A web page `url` is read through the server's yt-dlp; when
the server cannot read the link, the swapper later gets `The server
can't fetch this link: <reason>`. Any later swap ends the relay.
`tracks` on a member's swap names the tracks its source will serve; the
server ignores it with `fetch` and with `jellyfin`.
- `state.media.relay.source`: member id of the source (absent with
`fetch`). `live` (omitted when false): bytes can flow (the source's
channel is connected, or the server could read the link).
channel is connected, or the server could read the link). `tracks`: as
the swap named them, or, with `fetch`, set once the server has read the
link (together with `live`).
- `welcome.mediaUrl` for relayed media without `jellyfin`: this member's
`/r/` URL; absent for the source itself (it plays its own copy). Clients build it after a swap like the `/m` one:
`http://<host>/r/default/<token>/<mediaId>/<name>` (name URL-encoded).
- `GET /r/<sid>/<token>/<mediaId>/<name>`: the media, Range table as for
`/m`. Errors: 401 bad token, 403 kicked, 404 not the current relayed
media (a player still on an older `mediaId` gets 404, never the new
bytes), 410 unknown session, 503 + `retry-after` the source never
connected (60 s). A request whose bytes do not arrive within 60 s ends
early. Bytes count toward `bytesServed`.
`/r/` URL; absent for the source itself (it plays its own copy) and for
a fetched link the server has not read yet (clients open it on `live`).
Clients build it after a swap like the `/m` one:
`http://<host>/r/default/<token>/<mediaId>/<name>` (name with `/` and
`\` made `_`, then URL-encoded; the server ignores it). With `tracks`,
each track's URL is `http://<host>/r/default/<token>/<mediaId>/<i>/<name>`
(a pieces track: `<name>.m3u8`); one track is opened as is, several as
an mpv EDL of them all, each framed by its byte length:
`edl://!no_clip;%<len>%<track 0 url>;!new_stream;!no_clip;%<len>%<track 1 url>`.
- `GET /r/<sid>/<token>/<mediaId>/<name>`: the media (one file), Range
table as for `/m`. `GET /r/<sid>/<token>/<mediaId>/<i>/<name>`: track
`i` (from 0) of a media with `tracks` (track 0 of a one-file media
too); for a pieces track (any name), its media playlist
(`application/vnd.apple.mpegurl`): `#EXT-X-PLAYLIST-TYPE:VOD`,
`EXT-X-MEDIA-SEQUENCE`, `EXTINF` durations, `EXT-X-DISCONTINUITY`,
`EXT-X-MAP` and `EXT-X-KEY:METHOD=AES-128` (with `IV` when given) as
upstream, `EXT-X-ENDLIST`; every URI is `p/<j>`. `GET
/r/<sid>/<token>/<mediaId>/<i>/p/<j>`: piece `j` of pieces track `i`,
whole, once it is in (a Range inside the piece answers 206). Errors: 401
bad token, 404 not the current relayed media (a player still on an
older `mediaId` gets 404, never the new bytes), no such track (the
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`.
- `GET /r/<sid>/<token>/source` (WebSocket): the source's relay channel,
dialled by the source (works behind NAT). Only the current source's
token is accepted; anyone else, or the source after the next swap, is
@@ -248,15 +302,35 @@ state media.relay = {"source":"m2","live":true} or {"fetch":true,"live":true}
Frames:
```
source -> server {"t":"info","size":<bytes>} first frame
source -> server {"t":"info","size":<bytes>} first frame, one file
{"t":"info","tracks":[{"kind":"video","size":<bytes>},
{"kind":"audio","size":<bytes>}]}
first frame, tracks
{"t":"info","tracks":[{"kind":"video","as":"pieces","seq":5,
"pieces":[{"init":true},{"key":true},
{"d":6.0,"map":0,"key":1,"iv":"0x..."},
{"d":4.5,"map":0,"disc":true}]}]}
a pieces track
binary: [u32 big-endian pull id][bytes] in order per pull
{"t":"end","id":1} {"t":"fail","id":1,"reason":"..."}
server -> source {"t":"pull","id":1,"start":0,"end":1048575} inclusive
server -> source {"t":"pull","id":1,"track":0,"start":0,"end":1048575}
inclusive; no track: 0
{"t":"pull","id":2,"track":1,"piece":17} piece 17, whole
{"t":"ack","id":1,"n":65536} bytes arrived
{"t":"cancel","id":1} stop that pull
```
A `size` different from the one before (a reconnect) drops the buffer.
Pull ids are unique across the tracks of a connection; `track` is sent
only for a media with `tracks`. A pieces track's `pieces` are its
pieces in order: `{"init":true}` an init section, `{"key":true}` an
AES-128 key, else a segment of `d` seconds whose `map` / `key` name the
init and key pieces it uses, `iv` an explicit IV (`0x` + hex), `disc` a
discontinuity before it; `seq` (default 0) the media sequence of the
first segment. A piece pull's bytes end with `end` like a range pull.
An `info` whose tracks differ from the swap's `tracks` (count, bytes or
pieces), or a malformed piece list, closes the channel with 4001. A
track `size` or piece count/roles different from the one before (a
reconnect) drops that track's buffer.
## Join and reconnect
+18 -5
View File
@@ -14,16 +14,29 @@ off by deleting it in the same commit as the code. Wire changes also edit
2. **Files**: next episode from Files (by parsed episode, with the
countdown). Later: tracking for file series.
3. **yt-dlp** (epic): players that open links themselves use mpv's ytdl
hook (bundled on Windows); then relay of single-file formats; then a
resolver for the Link preview (title, thumbnail, duration).
3. **yt-dlp follow-ups** (see ARCHITECTURE "yt-dlp"): check that the
bundled Windows libmpv has the ytdl hook (Lua) and plays `edl://` URLs
through media_kit; if not, resolve in the app and open the relay
tracks' formats. YouTube: yt-dlp wants a JS runtime (deno) for full
extraction. Formats that need yt-dlp's cookies.
- Live streams through the relay (today refused: the sync model needs a
fixed duration and seeking).
- `tool/relay_e2e.dart`: a two-track case (bytes and pieces) over a
shaped source link. Pieces on a slow source: a cold piece can take
longer than media_kit's 5 s `network-timeout`; if it does, fetch the
first pieces before setting `relay.live`.
- Pieces, unverified so far: the 403/410 re-resolve against a real
expiring HLS (signed segment URLs), HLS discontinuities / ad
splicing, Windows' bundled ffmpeg with extensionless piece URLs
(`p/<i>`; fine with ffmpeg 9 on Linux).
- Thumbnails and scrubbing previews of a relayed media with tracks
should open the video track's URL alone, not the EDL.
4. **AniList account**: sign in (OAuth via a localhost callback), "My list"
filter on the Schedule; today's "Following" (AnimeFlow's monitored
shows) becomes "On the server".
5. **Ops**: `watch-together-server --upgrade` (a VPS has no GTK for the
app's updater); check shaders on Windows; the relay e2e "source drops
5. **Ops**: check shaders on Windows; the relay e2e "source drops
uncleanly and resumes" fails about half the time under the full suite.
6. **Windows: first real use.** CI builds and publishes
+5
View File
@@ -5,8 +5,13 @@ export 'src/clock_sync.dart';
export 'src/config.dart';
export 'src/protocol.dart';
export 'src/relay.dart';
export 'src/relay_pieces.dart';
export 'src/link_probe.dart';
export 'src/yt_dlp.dart';
export 'src/cookies.dart';
export 'src/media_source.dart';
export 'src/sync_engine.dart';
export 'src/updater.dart';
export 'src/jellyfin/client.dart';
export 'src/jellyfin/client_info.dart';
export 'src/jellyfin/http.dart';
+20 -1
View File
@@ -101,10 +101,20 @@ class AppConfig {
/// client dialled.
final String advertiseHost;
/// Secret the hosting app's own client sends in hello to be marked host
/// (unkickable). Empty means no member is the host (dedicated server).
/// (`members[].host`). Empty means no member is the host (dedicated server).
final String hostKey;
/// Optional login for relaying Jellyfin media to guests; null = no relay.
final JellyfinCredential? jellyfin;
/// "The server fetches it" reads web pages (YouTube, Twitter/X, ...)
/// through its own yt-dlp, downloaded on first use.
final bool ytDlp;
/// Where that yt-dlp lives (writable); empty: a `yt-dlp` folder next to
/// the server binary, else `~/.cache/watch-together/yt-dlp`.
final String ytDlpDir;
/// Dedicated server: a cookies.txt or browser-extension JSON export
/// copied (converted) into [ytDlpDir] at start, so yt-dlp is logged in
/// (see `YtDlpCookies`); empty: no cookies.
final String ytDlpCookies;
const AppConfig({
required this.port,
@@ -115,6 +125,9 @@ class AppConfig {
this.advertiseHost = '',
this.hostKey = '',
this.jellyfin,
this.ytDlp = true,
this.ytDlpDir = '',
this.ytDlpCookies = '',
});
factory AppConfig.defaults() =>
@@ -131,6 +144,9 @@ class AppConfig {
advertiseHost: (map['advertiseHost'] as String?) ?? '',
hostKey: (map['hostKey'] as String?) ?? '',
jellyfin: JellyfinCredential.fromMap(map['jellyfin']),
ytDlp: (map['ytDlp'] as bool?) ?? true,
ytDlpDir: (map['ytDlpDir'] as String?) ?? '',
ytDlpCookies: (map['ytDlpCookies'] as String?) ?? '',
);
} on ConfigException {
rethrow;
@@ -148,6 +164,9 @@ class AppConfig {
'advertiseHost': advertiseHost,
'hostKey': hostKey,
if (jellyfin != null) 'jellyfin': jellyfin!.toMap(),
'ytDlp': ytDlp,
'ytDlpDir': ytDlpDir,
'ytDlpCookies': ytDlpCookies,
};
static Future<AppConfig> loadFile(String path) async =>
+12 -1
View File
@@ -1,5 +1,5 @@
abstract class RelayConstants {
static const int protoVersion = 10;
static const int protoVersion = 11;
// Relay (`/r/...`): a member's bytes through the server's buffer.
static const int relaySliceBytes = 1 << 20;
@@ -12,6 +12,13 @@ abstract class RelayConstants {
/// Kept regardless of position: containers keep their index at the start
/// or the end, and players read both on open and on seeks.
static const int relayPinBytes = 2 << 20;
/// A relayed media of several tracks splits the buffer by bitrate; no
/// track gets less than this ahead or behind.
static const int relayTrackFloorBytes = 8 << 20;
/// Upstream GETs are split into ranges of at most this (yt-dlp's
/// `http_chunk_size` when it gives one): YouTube throttles longer ones to
/// about twice the bitrate.
static const int relayUpstreamChunkBytes = 10 << 20;
static const int relayMinWindowBytes = 256 * 1024;
static const int relayMaxWindowBytes = 8 << 20;
/// A viewer request waits this long for its bytes (source away) before
@@ -43,4 +50,8 @@ abstract class RelayConstants {
static const int maxBannerBytes = 768 * 1024;
static const int maxNameLength = 32;
static const int maxChatLength = 500;
/// Nudges one member may send: at most this many per second and per
/// minute; the server drops the rest silently.
static const int nudgesPerSec = 3;
static const int nudgesPerMin = 20;
}
+297
View File
@@ -0,0 +1,297 @@
import 'dart:convert';
import 'dart:io';
import 'package:path/path.dart' as p;
// Cookies for yt-dlp: some sites refuse guests (Bilibili answers HTTP 412,
// YouTube may ask to sign in). A machine's yt-dlp folder holds at most one
// source: a private Netscape cookies.txt (mode 0600) or the name of a
// browser to read them from. They only ever go to this machine's yt-dlp;
// values are never logged or sent anywhere.
/// One cookie, as a line of a Netscape cookies.txt.
class NetscapeCookie {
/// With a leading '.' when [includeSubdomains].
final String domain;
final bool includeSubdomains;
final String path;
final bool secure;
/// Unix seconds; 0 for a session cookie.
final int expires;
final String name;
final String value;
final bool httpOnly;
const NetscapeCookie({
required this.domain,
this.includeSubdomains = true,
this.path = '/',
this.secure = false,
this.expires = 0,
required this.name,
required this.value,
this.httpOnly = false,
});
/// The host without a leading '.', lowercase.
String get host => (domain.startsWith('.') ? domain.substring(1) : domain).toLowerCase();
String toLine() => [
'${httpOnly ? '#HttpOnly_' : ''}$domain',
includeSubdomains ? 'TRUE' : 'FALSE',
path,
secure ? 'TRUE' : 'FALSE',
'$expires',
name,
value,
].join('\t');
}
/// Reads a Netscape cookies.txt or the JSON array browser extensions
/// export (Cookie-Editor, "Get cookies.txt" style: objects with name,
/// value, domain, path, expirationDate or expires, httpOnly, secure,
/// hostOnly, session; also `{"cookies": [...]}`). Throws [FormatException]
/// without quoting the input (it holds logins).
List<NetscapeCookie> parseCookies(String text) {
final t = text.trimLeft();
if (t.startsWith('[') || t.startsWith('{')) return _fromJson(t);
final out = <NetscapeCookie>[];
for (var line in const LineSplitter().convert(text)) {
var httpOnly = false;
if (line.startsWith('#HttpOnly_')) {
httpOnly = true;
line = line.substring('#HttpOnly_'.length);
} else if (line.startsWith('#') || line.trim().isEmpty) {
continue;
}
final f = line.split('\t');
if (f.length < 7) continue;
out.add(NetscapeCookie(
domain: f[0],
includeSubdomains: f[1].toUpperCase() == 'TRUE',
path: f[2],
secure: f[3].toUpperCase() == 'TRUE',
expires: int.tryParse(f[4]) ?? double.tryParse(f[4])?.toInt() ?? 0,
name: f[5],
value: f.sublist(6).join('\t'),
httpOnly: httpOnly,
));
}
if (out.isEmpty) throw const FormatException('no cookies found (expected a cookies.txt or a JSON export)');
return out;
}
List<NetscapeCookie> _fromJson(String text) {
Object? root;
try {
root = jsonDecode(text);
} on FormatException {
throw const FormatException('not valid JSON');
}
if (root is Map && root['cookies'] is List) root = root['cookies'];
if (root is! List) throw const FormatException('expected a JSON array of cookies');
final out = <NetscapeCookie>[];
for (final c in root) {
if (c is! Map) continue;
final name = c['name'], value = c['value'], domain = c['domain'];
if (name is! String || value is! String || domain is! String || domain.isEmpty) continue;
final hostOnly = c['hostOnly'] == true;
final exp = c['expirationDate'] ?? c['expires'] ?? c['expiry'];
final session = c['session'] == true || exp is! num || exp <= 0;
final path = c['path'];
out.add(NetscapeCookie(
domain: hostOnly ? (domain.startsWith('.') ? domain.substring(1) : domain) : (domain.startsWith('.') ? domain : '.$domain'),
includeSubdomains: !hostOnly,
path: path is String && path.isNotEmpty ? path : '/',
secure: c['secure'] == true,
expires: session ? 0 : exp.toInt(),
name: name,
value: value,
httpOnly: c['httpOnly'] == true,
));
}
if (out.isEmpty) throw const FormatException('no cookies found in the JSON');
return out;
}
/// [cookies] as a Netscape cookies.txt (what yt-dlp's `--cookies` reads).
String netscapeCookiesText(Iterable<NetscapeCookie> cookies) => [
'# Netscape HTTP Cookie File',
'# Written by Starsync for yt-dlp. Holds logins: keep it private.',
'',
for (final c in cookies) c.toLine(),
'',
].join('\n');
// Second-level labels under which a country code needs a third label
// (bbc.co.uk, example.com.cn).
const _secondLevel = {'co', 'com', 'net', 'org', 'ac', 'gov', 'edu', 'ne', 'or', 'go'};
// Domains one login spans, filed under one site.
const _siteAlias = {
'google.com': 'youtube.com',
'youtu.be': 'youtube.com',
'hdslb.com': 'bilibili.com',
'bilivideo.com': 'bilibili.com',
'biliapi.net': 'bilibili.com',
'b23.tv': 'bilibili.com',
'x.com': 'twitter.com',
};
/// The registrable domain of [host] (`www.bilibili.com` -> `bilibili.com`,
/// `a.bbc.co.uk` -> `bbc.co.uk`), by a short rule rather than the full
/// public suffix list.
String registrableDomain(String host) {
final labels = host.toLowerCase().replaceFirst(RegExp(r'^\.+'), '').split('.').where((l) => l.isNotEmpty).toList();
if (labels.length <= 2) return labels.join('.');
final n = labels.length;
final keep = labels[n - 1].length == 2 && _secondLevel.contains(labels[n - 2]) ? 3 : 2;
return labels.sublist(n - keep).join('.');
}
/// The site a cookie of [host] belongs to: its registrable domain, with the
/// domains one login spans folded together (google.com under youtube.com).
String cookieSite(String host) {
final d = registrableDomain(host);
return _siteAlias[d] ?? d;
}
/// [cookies] grouped by [cookieSite], sites sorted by name.
Map<String, List<NetscapeCookie>> cookiesBySite(Iterable<NetscapeCookie> cookies) {
final m = <String, List<NetscapeCookie>>{};
for (final c in cookies) {
m.putIfAbsent(cookieSite(c.host), () => []).add(c);
}
return {for (final k in m.keys.toList()..sort()) k: m[k]!};
}
/// Sites ticked by default after an import: those yt-dlp is usually given
/// cookies for.
const suggestedCookieSites = {'bilibili.com', 'youtube.com'};
/// Browsers `--cookies-from-browser` reads.
const cookieBrowsers = ['firefox', 'chrome', 'chromium', 'brave', 'edge', 'opera', 'vivaldi'];
/// Where a machine's yt-dlp gets cookies: a private file or a browser.
sealed class CookieSource {
const CookieSource();
}
/// [path] is the private copy in the yt-dlp folder.
class CookieFile extends CookieSource {
final String path;
final int count;
/// [cookieSite]s in it, sorted.
final List<String> sites;
const CookieFile(this.path, this.count, this.sites);
}
class CookieBrowser extends CookieSource {
final String browser;
/// Empty: the browser's default profile.
final String profile;
const CookieBrowser(this.browser, [this.profile = '']);
/// `BROWSER[:PROFILE]`, as `--cookies-from-browser` takes it.
String get spec => profile.isEmpty ? browser : '$browser:$profile';
}
/// The cookie setting kept in a yt-dlp folder ([dir]): `cookies.txt`
/// (mode 0600) or `cookies-browser` (the `BROWSER[:PROFILE]` spec), never
/// both.
class YtDlpCookies {
final String dir;
const YtDlpCookies(this.dir);
String get filePath => p.join(dir, 'cookies.txt');
String get _browserPath => p.join(dir, 'cookies-browser');
/// The current source, or null for none. Without [count] a file is not
/// parsed ([CookieFile.count] -1, no sites).
CookieSource? read({bool count = true}) {
try {
final b = File(_browserPath);
if (b.existsSync()) {
final spec = b.readAsStringSync().trim();
if (spec.isNotEmpty) {
final i = spec.indexOf(':');
return i < 0 ? CookieBrowser(spec) : CookieBrowser(spec.substring(0, i), spec.substring(i + 1));
}
}
final f = File(filePath);
if (f.existsSync()) {
if (!count) return CookieFile(filePath, -1, const []);
final cookies = parseCookies(f.readAsStringSync());
return CookieFile(filePath, cookies.length, cookiesBySite(cookies).keys.toList());
}
} catch (_) {}
return null;
}
/// yt-dlp arguments for [source] (default: [read]).
List<String> args([CookieSource? source]) => switch (source ?? read(count: false)) {
CookieFile(:final path) => ['--cookies', path],
CookieBrowser b => ['--cookies-from-browser', b.spec],
null => const [],
};
/// The same as mpv's `ytdl-raw-options` value ('' for none); each value
/// length-quoted (`%N%`) so commas and '=' in paths survive.
String mpvRawOptions([CookieSource? source]) {
String q(String v) => '%${utf8.encode(v).length}%$v';
return switch (source ?? read(count: false)) {
CookieFile(:final path) => 'cookies=${q(path)}',
CookieBrowser b => 'cookies-from-browser=${q(b.spec)}',
null => '',
};
}
/// Keeps [cookies] as the private copy, replacing any browser setting.
Future<CookieFile> saveFile(List<NetscapeCookie> cookies) async {
await Directory(dir).create(recursive: true);
final part = File('$filePath.part$pid');
try {
// Private before anything is written into it.
await part.writeAsString('');
if (!Platform.isWindows) {
final r = await Process.run('chmod', ['600', part.path]);
if (r.exitCode != 0) throw FileSystemException('chmod failed', part.path);
}
await part.writeAsString(netscapeCookiesText(cookies), flush: true);
final target = File(filePath);
if (Platform.isWindows && await target.exists()) await target.delete();
await part.rename(filePath);
} finally {
if (await part.exists()) await part.delete().catchError((_) => part);
}
await _delete(_browserPath);
return CookieFile(filePath, cookies.length, cookiesBySite(cookies).keys.toList());
}
/// Converts the cookies.txt or JSON export at [source] into the private
/// copy, keeping only [sites] ([cookieSite]s; null: all).
Future<CookieFile> import(String source, {Set<String>? sites}) async {
final all = parseCookies(await File(source).readAsString());
final kept = sites == null ? all : [for (final c in all) if (sites.contains(cookieSite(c.host))) c];
if (kept.isEmpty) throw const FormatException('no cookies for the chosen sites');
return saveFile(kept);
}
/// Reads cookies from [browser] (one of [cookieBrowsers]) from now on,
/// replacing any file.
Future<void> saveBrowser(String browser, [String profile = '']) async {
if (!cookieBrowsers.contains(browser)) throw ArgumentError.value(browser, 'browser');
await Directory(dir).create(recursive: true);
await File(_browserPath).writeAsString(CookieBrowser(browser, profile.trim()).spec);
await _delete(filePath);
}
/// No cookies any more.
Future<void> remove() async {
await _delete(filePath);
await _delete(_browserPath);
}
static Future<void> _delete(String path) async {
final f = File(path);
if (await f.exists()) await f.delete();
}
}
+421
View File
@@ -0,0 +1,421 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'constants.dart';
import 'relay.dart';
import 'relay_pieces.dart';
import 'yt_dlp.dart';
// What a pasted link is: a media file (range-readable as is) or a web page
// that yt-dlp turns into one. Used where the bytes are fetched: the
// putter's app (preview, "I pass it on") and the server ("the server
// fetches it").
const _mediaExtensions = {
'mkv', 'mp4', 'm4v', 'webm', 'mov', 'avi', 'wmv', 'flv', 'ts', 'm2ts', 'mpg', 'mpeg', 'ogv', //
'mp3', 'm4a', 'flac', 'opus', 'ogg', 'wav',
};
const _fileTypes = {'application/mp4', 'application/ogg', 'application/x-matroska'};
const _genericTypes = {'', 'application/octet-stream', 'binary/octet-stream', 'application/binary'};
/// True when a `Range: bytes=0-0` answer ([status], [contentType]'s mime
/// type) means [url] is a media file: a video/audio type, or a generic one
/// on a media file name. Pages, HLS playlists and errors are not.
bool looksLikeMediaFile({required int status, required String contentType, required Uri url}) {
if (status != 200 && status != 206) return false;
final type = contentType.toLowerCase();
if (type.contains('mpegurl')) return false;
if (type.startsWith('video/') || type.startsWith('audio/') || _fileTypes.contains(type)) return true;
return _genericTypes.contains(type) && hasMediaExtension(url);
}
/// [url]'s file name ends in a media extension (`.mkv`, `.mp4`, ...).
bool hasMediaExtension(Uri url) {
final last = url.pathSegments.where((s) => s.isNotEmpty).lastOrNull ?? '';
final dot = last.lastIndexOf('.');
return dot > 0 && _mediaExtensions.contains(last.substring(dot + 1).toLowerCase());
}
/// A page's `og:title`, else its `<title>`, entities decoded; null if
/// neither.
String? htmlTitle(String html) {
for (final tag in RegExp(r'<meta\b[^>]*>', caseSensitive: false).allMatches(html)) {
final t = tag.group(0)!;
if (!RegExp(r'''(?:property|name)\s*=\s*["']og:title["']''', caseSensitive: false).hasMatch(t)) continue;
final c = RegExp('content\\s*=\\s*(?:"([^"]*)"|\'([^\']*)\')', caseSensitive: false).firstMatch(t);
final v = _unescape(c?.group(1) ?? c?.group(2) ?? '');
if (v.isNotEmpty) return v;
}
final m = RegExp(r'<title[^>]*>([\s\S]*?)</title>', caseSensitive: false).firstMatch(html);
final v = _unescape(m?.group(1) ?? '');
return v.isEmpty ? null : v;
}
String _unescape(String s) => s
.replaceAllMapped(RegExp(r'&#(x?)([0-9a-fA-F]+);'), (m) {
final n = int.tryParse(m.group(2)!, radix: m.group(1)!.isEmpty ? 10 : 16);
return n == null ? m.group(0)! : String.fromCharCode(n);
})
.replaceAll('&quot;', '"')
.replaceAll('&apos;', "'")
.replaceAll('&lt;', '<')
.replaceAll('&gt;', '>')
.replaceAll('&nbsp;', ' ')
.replaceAll('&amp;', '&')
.replaceAll(RegExp(r'\s+'), ' ')
.trim();
/// The text for a page yt-dlp cannot read.
String notAVideoText(String? ytDlpError) =>
ytDlpError == null ? 'Not a video' : "Not a video (and yt-dlp couldn't read it: $ytDlpError)";
/// The text for a page with no video a relay can read.
const noRelayFormatText = "This page has no video a relay can read. Choose 'Everyone fetches it' instead.";
class LinkProbeException implements Exception {
final String message;
const LinkProbeException(this.message);
@override
String toString() => message;
}
/// What [probeLink] found.
class LinkProbe {
final Uri url;
final bool isPage;
/// A file's byte size; -1 when unknown or a page.
final int size;
/// yt-dlp's answer for a page it could read.
final YtDlpInfo? info;
/// A page's title from its HTML when yt-dlp could not read it.
final String? pageTitle;
/// Why yt-dlp could not read the page.
final String? ytDlpError;
const LinkProbe.file(this.url, this.size)
: isPage = false,
info = null,
pageTitle = null,
ytDlpError = null;
const LinkProbe.page(this.url, {this.info, this.pageTitle, this.ytDlpError})
: isPage = true,
size = -1;
/// The name to show: yt-dlp's title, the page's, else the host; null
/// for a file (named by its file name).
String? get title {
if (!isPage) return null;
final t = info?.title ?? pageTitle ?? '';
return t.isNotEmpty ? t : url.host;
}
/// Why a relay cannot read this link, or null when it can.
String? get relayProblem => !isPage
? null
: info == null
? notAVideoText(ytDlpError)
: info!.isLive
? liveStreamText
: info!.tracks.isEmpty
? noRelayFormatText
: null;
}
const _probeLimit = 512 * 1024;
/// Finds out what [url] is: a `Range: bytes=0-0` GET; a media file is
/// done, anything else goes to yt-dlp ([ytDlp] null: no yt-dlp), and when
/// yt-dlp cannot read it, the page's own title. Throws
/// [LinkProbeException] when the link cannot be reached at all.
Future<LinkProbe> probeLink(Uri url, {YtDlp? ytDlp, HttpClient? client}) async {
final c = client ?? (HttpClient()..connectionTimeout = const Duration(seconds: 10));
try {
String? body;
Object? getError;
try {
final req = await c.getUrl(url).timeout(const Duration(seconds: 15));
req.headers
..set(HttpHeaders.rangeHeader, 'bytes=0-0')
..set(HttpHeaders.acceptEncodingHeader, 'identity');
final res = await req.close().timeout(const Duration(seconds: 15));
final type = res.headers.contentType?.mimeType ?? '';
if (looksLikeMediaFile(status: res.statusCode, contentType: type, url: url)) {
await res.drain<void>().catchError((_) {});
final total = res.headers.value(HttpHeaders.contentRangeHeader)?.split('/').last;
final n = int.tryParse(total ?? '') ?? (res.statusCode == 200 ? res.contentLength : -1);
return LinkProbe.file(url, n > 0 ? n : -1);
}
body = type.contains('html') ? await _readText(res) : null;
if (body == null) await res.drain<void>().catchError((_) {});
} catch (e) {
getError = e;
}
String? ytErr;
if (ytDlp != null) {
try {
return LinkProbe.page(url, info: await ytDlp.resolve(url.toString()));
} catch (e) {
ytErr = '$e';
}
}
if (getError != null) {
throw LinkProbeException('cannot open the link: $getError${ytErr == null ? '' : ' (yt-dlp: $ytErr)'}');
}
// A server honouring the Range sent one byte of the page: get it all.
if (body != null && body.length < 2) {
try {
final req = await c.getUrl(url).timeout(const Duration(seconds: 15));
body = await _readText(await req.close().timeout(const Duration(seconds: 15)));
} catch (_) {}
}
return LinkProbe.page(url, pageTitle: body == null ? null : htmlTitle(body), ytDlpError: ytErr);
} finally {
if (client == null) c.close(force: true);
}
}
Future<String> _readText(HttpClientResponse res) async {
final out = BytesBuilder(copy: false);
try {
await for (final chunk in res.timeout(const Duration(seconds: 15))) {
out.add(chunk);
if (out.length >= _probeLimit) break;
}
} catch (_) {}
return utf8.decode(out.takeBytes(), allowMalformed: true);
}
/// A page's relay tracks (see [YtDlpInfo.tracks]: one file, video +
/// 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.
class PageRelayMedia implements RelayMedia {
final Uri page;
final YtDlp ytDlp;
List<YtDlpTrack>? _picked;
List<HttpRelayBytes?> _inner = const [];
List<UpstreamPieces?> _pieces = const [];
List<RelayTrack>? _tracks;
final _sizes = <int, int>{};
final _http = HttpClient();
var _gen = 0;
Future<void>? _refreshing;
PageRelayMedia(this.page, this.ytDlp, {List<YtDlpTrack>? tracks}) : _picked = tracks;
Future<YtDlpInfo> _info() async {
final info = await ytDlp.resolve(page.toString());
if (info.isLive) throw const LinkProbeException(liveStreamText);
return info;
}
@override
Future<List<RelayTrack>> open() async {
if (_tracks != null) return _tracks!;
final picked = _picked ??= (await _info()).tracks;
if (picked.isEmpty) throw const LinkProbeException(noRelayFormatText);
_inner = List.filled(picked.length, null, growable: false);
_pieces = List.filled(picked.length, null, growable: false);
return _tracks = [
for (var i = 0; i < picked.length; i++)
picked[i].format.isPieces
? RelayTrack.pieces(picked[i].kind, _PagePieces(this, i))
: RelayTrack(picked[i].kind, _PageTrack(this, i)),
];
}
HttpRelayBytes _open(int i) => _inner[i] ??= () {
final f = _picked![i].format;
return HttpRelayBytes(f.url,
headers: f.headers, chunkSize: f.chunkSize ?? RelayConstants.relayUpstreamChunkBytes);
}();
/// Reads track [i]'s playlist (or DASH fragments) into pieces.
Future<UpstreamPieces> _loadPieces(YtDlpFormat f) async {
final frags = f.fragments;
if (f.protocol == 'http_dash_segments') {
if (frags == null) throw const RelayPiecesException('no DASH fragments');
return dashPieces(frags);
}
return fetchHlsPlaylist(_http, f.url, f.headers);
}
static bool _expired(Object e) => e is UpstreamHttpException && (e.status == 403 || e.status == 410);
/// Resolves the page again: the same format ids with the same sizes and
/// piece shapes, else the video changed.
Future<void> _refresh(int gen) {
if (gen != _gen) return Future.value();
return _refreshing ??= () async {
try {
const changed = LinkProbeException('the video changed on the page');
final info = await _info();
final next = <YtDlpTrack>[];
for (final t in _picked!) {
final f = info.formats[t.format.id];
if (f == null || f.isPieces != t.format.isPieces) throw changed;
next.add((kind: t.kind, format: f));
}
final pieces = List<UpstreamPieces?>.filled(next.length, null, growable: false);
for (var i = 0; i < next.length; i++) {
final had = _pieces[i];
if (had == null) continue;
final p = pieces[i] = await _loadPieces(next[i].format);
if (!p.list.sameShape(had.list)) throw changed;
}
final old = _inner;
_picked = next;
_pieces = pieces;
_inner = List.filled(next.length, null, growable: false);
for (final e in _sizes.entries) {
if (await _open(e.key).size() != e.value) throw changed;
}
_gen++;
for (final b in old) {
await b?.close();
}
} finally {
_refreshing = null;
}
}();
}
Future<int> _size(int i) async {
final known = _sizes[i];
if (known != null) return known;
final gen = _gen;
try {
return _sizes[i] = await _open(i).size();
} catch (e) {
if (!_expired(e)) rethrow;
await _refresh(gen);
return _sizes[i] = await _open(i).size();
}
}
Future<UpstreamPieces> _piecesOf(int i) async {
final known = _pieces[i];
if (known != null) return known;
final gen = _gen;
try {
return _pieces[i] = await _loadPieces(_picked![i].format);
} catch (e) {
if (!_expired(e)) rethrow;
await _refresh(gen);
return _pieces[i] ??= await _loadPieces(_picked![i].format);
}
}
Stream<List<int>> _read(int i, int start, int end) async* {
var pos = start;
var retried = false;
while (true) {
final gen = _gen;
try {
await for (final chunk in _open(i).read(pos, end)) {
pos += chunk.length;
yield chunk;
}
return;
} catch (e) {
if (retried || !_expired(e)) rethrow;
retried = true;
await _refresh(gen);
}
}
}
/// Piece [index] of track [i]; on expiry once more after a re-resolve
/// (nothing was sent yet: an expired URL fails before its first byte).
Stream<List<int>> _readPiece(int i, int index) async* {
var retried = false;
while (true) {
final gen = _gen;
final p = await _piecesOf(i);
if (index >= p.sources.length) throw RangeError.index(index, p.sources, 'piece');
try {
yield* readPiece(_http, p.sources[index], _picked![i].format.headers);
return;
} catch (e) {
if (retried || !_expired(e)) rethrow;
retried = true;
await _refresh(gen);
}
}
}
@override
Future<void> close() async {
for (final b in _inner) {
await b?.close();
}
_http.close(force: true);
}
}
class _PageTrack implements RelayBytes {
final PageRelayMedia media;
final int index;
_PageTrack(this.media, this.index);
@override
Future<int> size() => media._size(index);
@override
Stream<List<int>> read(int start, int end) => media._read(index, start, end);
/// The media closes its tracks.
@override
Future<void> close() async {}
}
class _PagePieces implements RelayPieces {
final PageRelayMedia media;
final int index;
_PagePieces(this.media, this.index);
@override
Future<RelayPieceList> list() async => (await media._piecesOf(index)).list;
@override
Stream<List<int>> read(int piece) => media._readPiece(index, piece);
/// The media closes its tracks.
@override
Future<void> close() async {}
}
/// A link of either kind, probed on first [open] (or given [probe]): a
/// file as one track ([HttpRelayBytes]), a page as its picked formats
/// ([PageRelayMedia]). Throws [LinkProbeException] with the reason when it
/// cannot be relayed.
class LinkRelayMedia implements RelayMedia {
final Uri url;
final YtDlp? ytDlp;
LinkProbe? _probe;
RelayMedia? _inner;
LinkRelayMedia(this.url, {this.ytDlp, LinkProbe? probe}) : _probe = probe;
/// The probe, after [open].
LinkProbe? get probe => _probe;
@override
Future<List<RelayTrack>> open() async {
if (_inner == null) {
final probe = _probe ??= await probeLink(url, ytDlp: ytDlp);
final problem = probe.relayProblem;
if (problem != null) throw LinkProbeException(problem);
_inner = probe.isPage
? PageRelayMedia(url, ytDlp!, tracks: probe.info!.tracks)
: RelayMedia.single(HttpRelayBytes(url));
}
return _inner!.open();
}
@override
Future<void> close() async => _inner?.close();
}
+100 -28
View File
@@ -144,29 +144,76 @@ class HelloMedia {
}
}
/// One track of a relayed media: [kind] `video` or `audio`; a byte stream
/// (a Range-readable file) unless [pieces] (a segmented playlist relayed
/// piece by piece, served as a media playlist). JSON `{"kind":"video"}`,
/// `{"kind":"audio","as":"pieces"}`.
class RelayTrackSpec {
final String kind;
final bool pieces;
const RelayTrackSpec(this.kind, {this.pieces = false});
Map<String, Object?> toJson() => {'kind': kind, if (pieces) 'as': 'pieces'};
/// Null when [j] is not a track entry this side knows.
static RelayTrackSpec? fromJson(Object? j) {
if (j is! Map || !MediaRelay.trackKinds.contains(j['kind'])) return null;
final as = j['as'];
if (as != null && as != 'bytes' && as != 'pieces') return null;
return RelayTrackSpec(j['kind'] as String, pieces: as == 'pieces');
}
@override
bool operator ==(Object other) => other is RelayTrackSpec && other.kind == kind && other.pieces == pieces;
@override
int get hashCode => Object.hash(kind, pieces);
@override
String toString() => pieces ? '$kind pieces' : kind;
}
/// Relayed media: the bytes go through the server's buffer (`/r/...`).
/// Either from the [source] member's machine (it dials the relay channel),
/// or, with [fetch], the server reads the swap's link itself. A swap sends
/// `{}` or `{"fetch":true}`; the server fills in [source] (the swapper's
/// member id, empty with [fetch]) and whether bytes can flow ([live]).
/// [tracks]: the tracks of a media relayed as several played together, or
/// as one segmented playlist; empty for one file. A member swap names
/// them, the server fills them in for [fetch] once it has read the link.
class MediaRelay {
final String source;
final bool fetch;
final bool live;
const MediaRelay({this.source = '', this.fetch = false, this.live = false});
final List<RelayTrackSpec> tracks;
const MediaRelay({this.source = '', this.fetch = false, this.live = false, this.tracks = const []});
MediaRelay copyWith({bool? live}) => MediaRelay(source: source, fetch: fetch, live: live ?? this.live);
static const trackKinds = {'video', 'audio'};
static const maxTracks = 4;
/// [tracks] as sent: several, or one pieces track; else one file.
static bool validTracks(List<RelayTrackSpec> t) =>
t.length <= maxTracks && (t.length > 1 || (t.length == 1 && t.single.pieces));
MediaRelay copyWith({bool? live, List<RelayTrackSpec>? tracks}) =>
MediaRelay(source: source, fetch: fetch, live: live ?? this.live, tracks: tracks ?? this.tracks);
Map<String, Object?> toJson() => {
if (source.isNotEmpty) 'source': source,
if (fetch) 'fetch': true,
if (live) 'live': true,
if (validTracks(tracks)) 'tracks': [for (final t in tracks) t.toJson()],
};
factory MediaRelay.fromJson(Map<String, Object?> json) => MediaRelay(
/// Unknown kinds, a single byte track or too many make it one file.
factory MediaRelay.fromJson(Map<String, Object?> json) {
final t = json['tracks'];
final specs = t is List ? [for (final e in t) RelayTrackSpec.fromJson(e)] : const <RelayTrackSpec?>[];
final ok = !specs.contains(null) && validTracks(specs.cast<RelayTrackSpec>());
return MediaRelay(
source: json['source'] is String ? json['source'] as String : '',
fetch: json['fetch'] == true,
live: json['live'] == true);
live: json['live'] == true,
tracks: ok ? List.unmodifiable(specs.cast<RelayTrackSpec>()) : const []);
}
}
class HelloMessage {
@@ -270,31 +317,46 @@ class WelcomeMessage {
}
}
class KickedMessage {
final String reason;
const KickedMessage({this.reason = ''});
Map<String, Object?> toJson() => {'t': 'kicked', 'reason': reason};
factory KickedMessage.fromJson(Map<String, Object?> json) {
if (json['t'] != 'kicked') throw const ProtocolException('not a kicked message');
return KickedMessage(reason: (json['reason'] as String?) ?? '');
}
}
class KickRequest {
/// Sits [target] (a member id) out, or with [out] false brings oneself
/// back. Out: connected, but not in the ready gate and not playing.
class OutIntent {
final String target;
final String reason;
const KickRequest({required this.target, this.reason = ''});
final bool out;
const OutIntent({required this.target, required this.out});
Map<String, Object?> toJson() => {'t': 'kick', 'target': target, 'reason': reason};
Map<String, Object?> toJson() => {'t': 'out', 'target': target, 'out': out};
factory KickRequest.fromJson(Map<String, Object?> json) {
if (json['t'] != 'kick') throw const ProtocolException('not a kick request');
return KickRequest(
target: (json['target'] as String?) ?? '',
reason: (json['reason'] as String?) ?? '',
);
factory OutIntent.fromJson(Map<String, Object?> json) {
if (json['t'] != 'out') throw const ProtocolException('not an out intent');
return OutIntent(target: (json['target'] as String?) ?? '', out: (json['out'] as bool?) ?? true);
}
}
/// Client to server: poke [target] (a member id). The server passes it on
/// to that member only, as a [NudgeNotice].
class NudgeIntent {
final String target;
const NudgeIntent({required this.target});
Map<String, Object?> toJson() => {'t': 'nudge', 'target': target};
factory NudgeIntent.fromJson(Map<String, Object?> json) {
if (json['t'] != 'nudge') throw const ProtocolException('not a nudge intent');
return NudgeIntent(target: (json['target'] as String?) ?? '');
}
}
/// Server to the nudged member: who poked it ([from] member id, [name]).
class NudgeNotice {
final String from;
final String name;
const NudgeNotice({required this.from, required this.name});
Map<String, Object?> toJson() => {'t': 'nudge', 'from': from, 'name': name};
factory NudgeNotice.fromJson(Map<String, Object?> json) {
if (json['t'] != 'nudge') throw const ProtocolException('not a nudge notice');
return NudgeNotice(from: (json['from'] as String?) ?? '', name: (json['name'] as String?) ?? '');
}
}
@@ -518,6 +580,8 @@ class MemberView {
final Map<String, bool> jf;
/// The hosting app's own member (sent the server's host key).
final bool host;
/// Sitting out: connected, outside the ready gate, not playing.
final bool out;
const MemberView({
required this.id,
required this.name,
@@ -528,6 +592,7 @@ class MemberView {
required this.stale,
this.jf = const {},
this.host = false,
this.out = false,
});
Map<String, Object?> toJson() => {
@@ -540,6 +605,7 @@ class MemberView {
'stale': stale,
if (jf.isNotEmpty) 'jf': jf,
if (host) 'host': true,
if (out) 'out': true,
};
factory MemberView.fromJson(Map<String, Object?> json) => MemberView(
@@ -552,6 +618,7 @@ class MemberView {
stale: (json['stale'] as bool?) ?? false,
jf: jfAccessFromJson(json['jf']) ?? const {},
host: json['host'] == true,
out: json['out'] == true,
);
}
@@ -559,12 +626,17 @@ class StateBroadcast {
/// Buffer an in-set member needs before playback can go on, as the
/// server's gate counts it: [RelayConstants.minBufferSecs], less near the
/// end (mpv cannot buffer past it), none once finished.
double get bufferNeed {
double get bufferNeed => bufferNeedAt(DateTime.now().toUtc());
/// [bufferNeed] at [now]: while playing the position has moved on since
/// [serverTime] (the local clock is close enough for this).
double bufferNeedAt(DateTime now) {
if (transport == 'finished') return 0;
final d = (media?['duration'] as num?)?.toDouble() ?? 0;
const full = RelayConstants.minBufferSecs;
if (d <= 0) return full;
return (d - position - RelayConstants.endSlackSecs).clamp(0.0, full).toDouble();
final moved = transport == 'playing' ? now.difference(serverTime).inMilliseconds / 1000 : 0;
return (d - (position + moved) - RelayConstants.endSlackSecs).clamp(0.0, full).toDouble();
}
final int eventId;
+168 -27
View File
@@ -7,26 +7,58 @@ import 'dart:typed_data';
import 'constants.dart';
import 'logging.dart';
import 'protocol.dart';
import 'relay_pieces.dart';
// The relay channel: a member's machine (the source) serves the current
// media's bytes to the server, which buffers them for every viewer (`/r/...`).
// The source dials out (works behind NAT/CGNAT) on its own WebSocket, so
// video bytes never queue behind `/sync` messages.
// video bytes never queue behind `/sync` messages. A media is one track (a
// file) or several (a page's separate video and audio); a track is a
// byte stream or pieces (a segmented playlist, see relay_pieces.dart).
//
// source -> server {"t":"info","size":N} first frame
// source -> server {"t":"info","size":N} first frame, one track
// {"t":"info","tracks":[{"kind":"video","size":N},...]}
// {"t":"info","tracks":[{"kind":"video","as":"pieces",
// "pieces":[{"init":true},{"d":6.0,"map":0},...]}]}
// binary [u32 BE pull id][bytes] in order per pull
// {"t":"end","id":1} pull complete
// {"t":"fail","id":1,"reason":"..."} cannot read
// server -> source {"t":"pull","id":1,"start":0,"end":N} bytes start..end
// server -> source {"t":"pull","id":1,"track":0,"start":0,"end":N}
// bytes start..end
// {"t":"pull","id":2,"track":0,"piece":17} one piece, whole
// {"t":"ack","id":1,"n":65536} n bytes arrived
// {"t":"cancel","id":1} stop this pull
//
// Flow control is on the source: one adaptive in-flight budget per
// connection (see [RelayWindow]); a cancelled pull's unacked bytes are
// written off.
// Pull ids are unique across tracks. Flow control is on the source: one
// adaptive in-flight budget per connection (see [RelayWindow]), shared by
// the tracks; a cancelled pull's unacked bytes are written off.
final _log = createLogger('relay');
/// The `<name>` segment of a `/r/` URL: the media name URL-encoded, its
/// slashes made `_` first (a page title can hold a URL, and an encoded
/// slash still splits the path). The server ignores the segment.
String relayUrlName(String name) => Uri.encodeComponent(name.replaceAll(RegExp(r'[/\\]'), '_'));
/// What a viewer opens for relayed media at [base]
/// (`http://<host>/r/<sid>/<token>/<mediaId>`): `<base>/<name>` for one
/// file; with [tracks], each track's URL (`<base>/<i>/<name>`, a pieces
/// track's playlist `<base>/<i>/<name>.m3u8`), several played together as
/// an mpv EDL (see [mpvEdl]).
String relayViewerUrl(String base, String name, {List<RelayTrackSpec> tracks = const []}) {
final n = relayUrlName(name);
if (tracks.isEmpty) return '$base/$n';
final urls = [for (var i = 0; i < tracks.length; i++) '$base/$i/$n${tracks[i].pieces ? '.m3u8' : ''}'];
return urls.length == 1 ? urls.single : mpvEdl(urls);
}
/// An mpv `edl://` URL playing [urls] as the streams of one file (a video
/// track and its audio track), each `%<byte length>%`-framed so `;` and
/// `,` inside a URL are safe.
String mpvEdl(List<String> urls) =>
'edl://${urls.map((u) => '!no_clip;%${utf8.encode(u).length}%$u').join(';!new_stream;')}';
/// Binary relay frame: 4-byte big-endian pull id, then payload.
Uint8List encodeRelayFrame(int id, List<int> payload) {
final out = Uint8List(4 + payload.length);
@@ -66,14 +98,24 @@ class FileRelayBytes implements RelayBytes {
Future<void> close() async {}
}
/// A non-2xx answer from an [HttpRelayBytes] upstream.
class UpstreamHttpException extends HttpException {
final int status;
UpstreamHttpException(this.status, Uri url) : super('upstream HTTP $status', uri: url);
}
/// An HTTP(S) resource with Range support, e.g. a link or a Jellyfin
/// stream; [headers] (auth) never leave this machine.
/// stream; [headers] (auth) never leave this machine. A read is one GET
/// per [chunkSize] bytes, back to back (see
/// [RelayConstants.relayUpstreamChunkBytes]).
class HttpRelayBytes implements RelayBytes {
final Uri url;
final Map<String, String> headers;
final int chunkSize;
final HttpClient _client;
int? _size;
HttpRelayBytes(this.url, {this.headers = const {}, HttpClient? client})
HttpRelayBytes(this.url,
{this.headers = const {}, this.chunkSize = RelayConstants.relayUpstreamChunkBytes, HttpClient? client})
: _client = client ?? HttpClient();
Future<HttpClientResponse> _get(String range) async {
@@ -85,7 +127,7 @@ class HttpRelayBytes implements RelayBytes {
final res = await req.close().timeout(const Duration(seconds: 20));
if (res.statusCode != 200 && res.statusCode != 206) {
await res.drain<void>().catchError((_) {});
throw HttpException('upstream HTTP ${res.statusCode}', uri: url);
throw UpstreamHttpException(res.statusCode, url);
}
return res;
}
@@ -107,11 +149,33 @@ class HttpRelayBytes implements RelayBytes {
@override
Stream<List<int>> read(int start, int end) async* {
final res = await _get('bytes=$start-$end');
if (res.statusCode == 200 && start > 0) {
var pos = start;
while (pos <= end) {
final last = chunkSize > 0 ? min(end, pos + chunkSize - 1) : end;
final res = await _get('bytes=$pos-$last');
if (res.statusCode == 200) {
if (pos > 0) {
await res.drain<void>().catchError((_) {});
throw HttpException('upstream ignores Range', uri: url);
}
yield* res;
// The whole file from 0: take what was asked in this one answer.
await for (final c in res) {
final take = min(c.length, end - pos + 1);
yield take == c.length ? c : c.sublist(0, take);
pos += take;
if (pos > end) return;
}
if (pos <= end) throw HttpException('upstream ended early', uri: url);
return;
}
await for (final c in res) {
final take = min(c.length, last - pos + 1);
if (take <= 0) break;
yield take == c.length ? c : c.sublist(0, take);
pos += take;
}
if (pos <= last) throw HttpException('upstream ended early', uri: url);
}
}
@override
@@ -189,6 +253,64 @@ class RelayWindow {
}
}
/// Where a source reads a pieces track from (see relay_pieces.dart).
abstract interface class RelayPieces {
/// The track's shape; a playlist is fetched on the first call. Throws
/// [RelayPiecesException] when it cannot be relayed (DRM, live).
Future<RelayPieceList> list();
/// Piece [index], whole, in order. Cancelling stops reading.
Stream<List<int>> read(int index);
Future<void> close();
}
/// One track of a relayed media: [kind] is `video` or `audio` when a
/// media has several (or is pieces), empty for a single file. Read as
/// [bytes] (a Range-readable stream) or as [pieces].
class RelayTrack {
final String kind;
final RelayBytes? bytes;
final RelayPieces? pieces;
const RelayTrack(this.kind, RelayBytes this.bytes) : pieces = null;
const RelayTrack.pieces(this.kind, RelayPieces this.pieces) : bytes = null;
RelayTrackSpec get spec => RelayTrackSpec(kind.isEmpty ? 'video' : kind, pieces: pieces != null);
}
/// The wire's `relay.tracks` for [tracks]: empty for one file.
List<RelayTrackSpec> relayTrackSpecs(List<RelayTrack> tracks) =>
tracks.length == 1 && tracks.single.pieces == null ? const [] : [for (final t in tracks) t.spec];
/// A track's `info` entry: its kind and size, or its pieces.
Future<Map<String, Object?>> relayTrackInfo(RelayTrack t) async => t.pieces != null
? {...t.spec.toJson(), ...(await t.pieces!.list()).toJson()}
: {'kind': t.kind, 'size': await t.bytes!.size()};
/// What a relay serves: one track, or several played together (a page's
/// separate video and audio).
abstract interface class RelayMedia {
/// The tracks, in order; a link is resolved on the first call. Throws
/// with the reason when it cannot be relayed.
Future<List<RelayTrack>> open();
Future<void> close();
/// One file.
factory RelayMedia.single(RelayBytes bytes) = _SingleRelayMedia;
}
class _SingleRelayMedia implements RelayMedia {
final RelayBytes bytes;
_SingleRelayMedia(this.bytes);
@override
Future<List<RelayTrack>> open() async => [RelayTrack('', bytes)];
@override
Future<void> close() => bytes.close();
}
class _Pull {
final int id;
var sent = 0, acked = 0;
@@ -202,14 +324,14 @@ class _Pull {
/// the current media": the source stops instead of reconnecting.
const relayCloseNotSource = 4001;
/// Serves [bytes] on the relay channel at [url] (`ws://host/r/<sid>/<token>/source`)
/// Serves [media] on the relay channel at [url] (`ws://host/r/<sid>/<token>/source`)
/// until [close]. Reconnects with backoff when the channel drops; stops for
/// good when the server closes it with [relayCloseNotSource].
class RelaySource {
final Uri url;
final RelayBytes bytes;
final RelayMedia media;
final Duration Function(int attempt) backoff;
RelaySource(this.url, this.bytes, {Duration Function(int attempt)? backoff})
RelaySource(this.url, this.media, {Duration Function(int attempt)? backoff})
: backoff = backoff ?? ((a) => Duration(milliseconds: min(8000, 500 * (1 << min(a, 4)))));
final window = RelayWindow();
@@ -224,12 +346,21 @@ class RelaySource {
/// Bytes sent to the server, for stats.
int bytesSent = 0;
var _tracks = const <RelayTrack>[];
/// Opens the media (a page is resolved) and starts serving; throws
/// when it cannot be read.
Future<void> start() async {
final size = await bytes.size();
unawaited(_loop(size));
final tracks = await media.open();
final infos = [for (final t in tracks) await relayTrackInfo(t)];
_tracks = tracks;
unawaited(_loop({
't': 'info',
if (relayTrackSpecs(tracks).isEmpty) 'size': infos.single['size'] else 'tracks': infos,
}));
}
Future<void> _loop(int size) async {
Future<void> _loop(Map<String, Object?> info) async {
var attempt = 0;
while (!_closed) {
try {
@@ -240,7 +371,7 @@ class RelaySource {
}
_ws = ws;
attempt = 0;
ws.add(jsonEncode({'t': 'info', 'size': size}));
ws.add(jsonEncode(info));
await for (final m in ws) {
if (m is String) _onText(ws, m);
}
@@ -257,7 +388,7 @@ class RelaySource {
await Future<void>.delayed(backoff(attempt++));
}
_abandonAll();
await bytes.close();
await media.close();
if (!_done.isCompleted) _done.complete();
}
@@ -272,11 +403,20 @@ class RelaySource {
if (id is! int) return;
switch (j['t']) {
case 'pull':
final start = j['start'], end = j['end'];
if (start is! int || end is! int || start < 0 || end < start) return;
final start = j['start'], end = j['end'], track = j['track'] ?? 0, piece = j['piece'];
if (track is! int || track < 0 || track >= _tracks.length) return;
final t = _tracks[track];
final Stream<List<int>> Function() read;
if (piece is int) {
if (t.pieces == null || piece < 0) return;
read = () => t.pieces!.read(piece);
} else {
if (start is! int || end is! int || start < 0 || end < start || t.bytes == null) return;
read = () => t.bytes!.read(start, end);
}
final p = _Pull(id);
_pulls[id] = p;
unawaited(_serve(ws, p, start, end));
unawaited(_serve(ws, p, read));
case 'ack':
final p = _pulls[id];
final n = j['n'];
@@ -296,7 +436,8 @@ class RelaySource {
p.cancelled = true;
window.writeOff(p.sent - p.acked);
p.acked = p.sent;
unawaited(p.it?.cancel());
// A read cut off mid-request (the client closing) errors here: moot.
unawaited(p.it?.cancel().catchError((_) {}));
}
void _abandonAll() {
@@ -306,8 +447,8 @@ class RelaySource {
_pulls.clear();
}
Future<void> _serve(WebSocket ws, _Pull p, int start, int end) async {
final it = p.it = StreamIterator(bytes.read(start, end));
Future<void> _serve(WebSocket ws, _Pull p, Stream<List<int>> Function() read) async {
final it = p.it = StreamIterator(read());
final pending = BytesBuilder(copy: false);
const frame = RelayConstants.relayFrameBytes;
try {
@@ -341,7 +482,7 @@ class RelaySource {
} finally {
p.finished = true;
if (p.settled) _pulls.remove(p.id);
await it.cancel();
await it.cancel().catchError((_) {});
}
}
+385
View File
@@ -0,0 +1,385 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:math';
import 'relay.dart';
// Pieces tracks: a segmented stream (an HLS media playlist, or yt-dlp's
// DASH fragments) relayed piece by piece. A piece is one upstream GET
// served whole: a segment, an init section (`EXT-X-MAP`) or an AES-128
// key. The fetcher (the source member, or the server for a link it reads
// itself) parses the upstream playlist into a [RelayPieceList] (no URLs);
// the server turns that into the VOD media playlist viewers open, whose
// entries are its own `p/<i>` piece URLs (see [RelayPieceList.playlist]).
/// Why a segmented stream cannot be relayed.
class RelayPiecesException implements Exception {
final String message;
const RelayPiecesException(this.message);
@override
String toString() => message;
}
/// The text for DRM-protected media.
const drmText = "This video is DRM-protected; it can't be relayed.";
/// The text for a live stream.
const liveStreamText = "Live streams can't be passed on. Choose 'Everyone fetches it' instead.";
enum RelayPieceRole { segment, init, key }
/// One piece of a [RelayPieceList]. A segment has its duration [d], the
/// init piece [map] and AES-128 key piece [key] (with an explicit [iv])
/// that apply to it, and [disc] when an `EXT-X-DISCONTINUITY` precedes it.
class RelayPiece {
final RelayPieceRole role;
final double d;
final int? map, key;
final String? iv;
final bool disc;
const RelayPiece.segment(this.d, {this.map, this.key, this.iv, this.disc = false})
: role = RelayPieceRole.segment;
const RelayPiece.init()
: role = RelayPieceRole.init,
d = 0,
map = null,
key = null,
iv = null,
disc = false;
const RelayPiece.key()
: role = RelayPieceRole.key,
d = 0,
map = null,
key = null,
iv = null,
disc = false;
bool get isSegment => role == RelayPieceRole.segment;
Map<String, Object?> toJson() => switch (role) {
RelayPieceRole.init => {'init': true},
RelayPieceRole.key => {'key': true},
RelayPieceRole.segment => {
'd': d,
if (map != null) 'map': map,
if (key != null) 'key': key,
if (iv != null) 'iv': iv,
if (disc) 'disc': true,
},
};
}
final _ivRe = RegExp(r'^0[xX][0-9a-fA-F]{1,32}$');
/// The shape of a pieces track: what the server needs to serve it (no
/// upstream URLs). Sent in the relay channel's `info` (see PROTOCOL.md).
class RelayPieceList {
final List<RelayPiece> pieces;
/// `EXT-X-MEDIA-SEQUENCE` of the first segment (AES-128 without an
/// explicit IV uses the segment's sequence number).
final int seq;
const RelayPieceList(this.pieces, {this.seq = 0});
static const maxPieces = 200000;
int get length => pieces.length;
double get duration => pieces.fold(0.0, (a, p) => a + p.d);
/// Same piece count and roles (a re-resolve or a reconnect of the same
/// media).
bool sameShape(RelayPieceList o) {
if (o.length != length) return false;
for (var i = 0; i < length; i++) {
if (o.pieces[i].role != pieces[i].role) return false;
}
return true;
}
Map<String, Object?> toJson() => {
if (seq != 0) 'seq': seq,
'pieces': [for (final p in pieces) p.toJson()],
};
/// Parses and checks a track entry's `seq` / `pieces`; null when
/// malformed (an index out of range or of the wrong role, a bad IV).
static RelayPieceList? fromJson(Map j) {
final raw = j['pieces'];
final seq = j['seq'] ?? 0;
if (raw is! List || raw.isEmpty || raw.length > maxPieces || seq is! int || seq < 0) return null;
final roles = <RelayPieceRole>[];
for (final e in raw) {
if (e is! Map) return null;
roles.add(e['init'] == true
? RelayPieceRole.init
: e['key'] == true
? RelayPieceRole.key
: RelayPieceRole.segment);
}
int? ref(Object? v, RelayPieceRole want) {
if (v == null) return null;
if (v is! int || v < 0 || v >= roles.length || roles[v] != want) throw const FormatException();
return v;
}
final out = <RelayPiece>[];
try {
for (var i = 0; i < raw.length; i++) {
final e = raw[i] as Map;
switch (roles[i]) {
case RelayPieceRole.init:
out.add(const RelayPiece.init());
case RelayPieceRole.key:
out.add(const RelayPiece.key());
case RelayPieceRole.segment:
final d = e['d'], iv = e['iv'];
if (d is! num || !d.isFinite || d < 0 || d > 3600) return null;
if (iv != null && (iv is! String || !_ivRe.hasMatch(iv))) return null;
out.add(RelayPiece.segment(d.toDouble(),
map: ref(e['map'], RelayPieceRole.init),
key: ref(e['key'], RelayPieceRole.key),
iv: iv as String?,
disc: e['disc'] == true));
}
}
} on FormatException {
return null;
}
if (!out.any((p) => p.isSegment)) return null;
return RelayPieceList(List.unmodifiable(out), seq: seq);
}
/// The VOD media playlist viewers open: every entry is `p/<i>`
/// (relative to the playlist's URL), init and key pieces included.
String playlist() {
final segs = pieces.where((p) => p.isSegment);
final target = max(1, segs.map((p) => p.d).fold(0.0, max).ceil());
final b = StringBuffer()
..writeln('#EXTM3U')
..writeln('#EXT-X-VERSION:6')
..writeln('#EXT-X-TARGETDURATION:$target')
..writeln('#EXT-X-MEDIA-SEQUENCE:$seq')
..writeln('#EXT-X-PLAYLIST-TYPE:VOD');
int? map, key;
String? iv;
for (var i = 0; i < pieces.length; i++) {
final p = pieces[i];
if (!p.isSegment) continue;
if (p.disc) b.writeln('#EXT-X-DISCONTINUITY');
if (p.map != null && p.map != map) b.writeln('#EXT-X-MAP:URI="p/${p.map}"');
if (p.key != key || p.iv != iv) {
b.writeln(p.key == null
? '#EXT-X-KEY:METHOD=NONE'
: '#EXT-X-KEY:METHOD=AES-128,URI="p/${p.key}"${p.iv == null ? '' : ',IV=${p.iv}'}');
}
map = p.map;
key = p.key;
iv = p.iv;
b
..writeln('#EXTINF:${p.d.toStringAsFixed(6)},')
..writeln('p/$i');
}
b.writeln('#EXT-X-ENDLIST');
return b.toString();
}
}
/// Where the fetcher reads piece `i`: one GET of [url], of [length] bytes
/// at [offset] when it is a byte range of a bigger file.
typedef PieceSource = ({Uri url, int? offset, int? length});
/// A parsed upstream stream: the shape plus where each piece lives.
typedef UpstreamPieces = ({RelayPieceList list, List<PieceSource> sources});
Map<String, String> _attrs(String s) {
final out = <String, String>{};
for (final m in RegExp(r'([A-Z0-9-]+)=("[^"]*"|[^,]*)').allMatches(s)) {
var v = m.group(2)!;
if (v.length >= 2 && v.startsWith('"') && v.endsWith('"')) v = v.substring(1, v.length - 1);
out[m.group(1)!] = v;
}
return out;
}
/// An HLS media playlist at [base] as pieces: every segment, `EXT-X-MAP`
/// and AES-128 `EXT-X-KEY` becomes a piece; `EXT-X-BYTERANGE` (and a MAP's
/// `BYTERANGE`) is folded into the piece (the offset defaults to the end
/// of the previous range of the same URI). Throws [RelayPiecesException]
/// for DRM (SAMPLE-AES, `skd:`, any non-identity key format), a live
/// playlist (no `EXT-X-ENDLIST`), a master playlist or no segments.
UpstreamPieces parseHlsPlaylist(String text, Uri base) {
final lines = const LineSplitter().convert(text).map((l) => l.trim()).toList();
if (lines.isEmpty || !lines.first.startsWith('#EXTM3U')) {
throw const RelayPiecesException('not an HLS playlist');
}
final pieces = <RelayPiece>[];
final sources = <PieceSource>[];
final lastEnd = <String, int>{};
final shared = <String, int>{}; // init/key piece by "url offset length"
var seq = 0;
var ended = false;
double? extinf;
int? brLen, brOff, map, key;
String? iv;
var disc = false;
int add(RelayPiece p, Uri url, int? len, int? off) {
if (len != null) {
off ??= lastEnd['$url'] ?? 0;
lastEnd['$url'] = off + len;
}
pieces.add(p);
sources.add((url: url, offset: len == null ? null : off, length: len));
return pieces.length - 1;
}
int sharedPiece(RelayPiece p, String uri, (int, int?)? range) {
final url = base.resolve(uri);
if (!url.isScheme('http') && !url.isScheme('https')) throw const RelayPiecesException(drmText);
final k = '${p.role} $url ${range?.$2} ${range?.$1}';
return shared[k] ??= add(p, url, range?.$1, range?.$2);
}
(int, int?) range(String v) {
final parts = v.split('@');
final len = int.tryParse(parts[0]);
final off = parts.length > 1 ? int.tryParse(parts[1]) : null;
if (len == null || len <= 0 || (parts.length > 1 && (off == null || off < 0))) {
throw const RelayPiecesException('bad byte range in the playlist');
}
return (len, off);
}
for (final line in lines) {
if (line.isEmpty) continue;
if (!line.startsWith('#')) {
final url = base.resolve(line);
if (!url.isScheme('http') && !url.isScheme('https')) {
throw const RelayPiecesException('the playlist has a segment that is not http(s)');
}
add(RelayPiece.segment(extinf ?? 0, map: map, key: key, iv: iv, disc: disc), url, brLen, brOff);
extinf = null;
brLen = brOff = null;
disc = false;
continue;
}
final colon = line.indexOf(':');
final tag = colon < 0 ? line : line.substring(0, colon);
final value = colon < 0 ? '' : line.substring(colon + 1);
switch (tag) {
case '#EXT-X-STREAM-INF':
throw const RelayPiecesException('a master playlist, not a media playlist');
case '#EXT-X-I-FRAMES-ONLY':
throw const RelayPiecesException('an I-frame playlist');
case '#EXT-X-SESSION-KEY':
throw const RelayPiecesException(drmText);
case '#EXT-X-MEDIA-SEQUENCE':
seq = int.tryParse(value) ?? 0;
case '#EXT-X-ENDLIST':
ended = true;
case '#EXT-X-DISCONTINUITY':
disc = true;
case '#EXTINF':
extinf = double.tryParse(value.split(',').first.trim());
case '#EXT-X-BYTERANGE':
(brLen, brOff) = range(value);
case '#EXT-X-MAP':
final a = _attrs(value);
final uri = a['URI'];
if (uri == null) throw const RelayPiecesException('EXT-X-MAP without a URI');
map = sharedPiece(const RelayPiece.init(), uri, a['BYTERANGE'] == null ? null : range(a['BYTERANGE']!));
case '#EXT-X-KEY':
final a = _attrs(value);
final method = (a['METHOD'] ?? '').toUpperCase();
if (method == 'NONE') {
key = null;
iv = null;
continue;
}
final format = a['KEYFORMAT'];
final uri = a['URI'] ?? '';
if (method != 'AES-128' || (format != null && format != 'identity') || uri.isEmpty) {
throw const RelayPiecesException(drmText);
}
final v = a['IV'];
if (v != null && !_ivRe.hasMatch(v)) throw const RelayPiecesException('bad IV in the playlist');
key = sharedPiece(const RelayPiece.key(), uri, null);
iv = v;
}
}
if (!ended) throw const RelayPiecesException(liveStreamText);
if (!pieces.any((p) => p.isSegment)) throw const RelayPiecesException('the playlist has no segments');
if (pieces.length > RelayPieceList.maxPieces) throw const RelayPiecesException('the playlist is too long');
return (list: RelayPieceList(List.unmodifiable(pieces), seq: seq), sources: List.unmodifiable(sources));
}
/// yt-dlp's DASH `fragments` (`http_dash_segments`) as pieces: a first
/// fragment without a duration is the init section, every other one a
/// segment (so no MPD parser of our own).
UpstreamPieces dashPieces(List<({Uri url, double? duration})> fragments) {
if (fragments.isEmpty) throw const RelayPiecesException('no fragments');
final pieces = <RelayPiece>[];
final sources = <PieceSource>[];
int? map;
for (var i = 0; i < fragments.length; i++) {
final f = fragments[i];
if (i == 0 && f.duration == null) {
map = 0;
pieces.add(const RelayPiece.init());
} else {
pieces.add(RelayPiece.segment(f.duration ?? 0, map: map));
}
sources.add((url: f.url, offset: null, length: null));
}
if (!pieces.any((p) => p.isSegment)) throw const RelayPiecesException('no fragments');
return (list: RelayPieceList(List.unmodifiable(pieces)), sources: List.unmodifiable(sources));
}
/// Fetches an HLS media playlist with [headers] and parses it
/// ([parseHlsPlaylist]).
Future<UpstreamPieces> fetchHlsPlaylist(HttpClient client, Uri url, Map<String, String> headers) async {
final req = await client.getUrl(url).timeout(const Duration(seconds: 20));
headers.forEach(req.headers.set);
final res = await req.close().timeout(const Duration(seconds: 20));
if (res.statusCode != 200) {
await res.drain<void>().catchError((_) {});
throw UpstreamHttpException(res.statusCode, url);
}
final text = await res.transform(utf8.decoder).join().timeout(const Duration(seconds: 30));
// Relative entries resolve against where the playlist ended up.
final at = res.redirects.isEmpty ? url : url.resolveUri(res.redirects.last.location);
return parseHlsPlaylist(text, at);
}
/// Reads one piece: a GET of its URL (with its byte range), whole.
Stream<List<int>> readPiece(HttpClient client, PieceSource s, Map<String, String> headers) async* {
final req = await client.getUrl(s.url).timeout(const Duration(seconds: 20));
headers.forEach(req.headers.set);
req.headers.set(HttpHeaders.acceptEncodingHeader, 'identity');
final off = s.offset, len = s.length;
if (len != null) req.headers.set(HttpHeaders.rangeHeader, 'bytes=${off ?? 0}-${(off ?? 0) + len - 1}');
final res = await req.close().timeout(const Duration(seconds: 20));
if (res.statusCode != 200 && res.statusCode != 206) {
await res.drain<void>().catchError((_) {});
throw UpstreamHttpException(res.statusCode, s.url);
}
if (len == null) {
yield* res;
return;
}
// A server ignoring Range sends the whole file: cut the piece out.
var skip = res.statusCode == 200 ? (off ?? 0) : 0;
var left = len;
await for (final c in res) {
var from = 0;
if (skip > 0) {
from = min(skip, c.length);
skip -= from;
}
final take = min(c.length - from, left);
if (take > 0) yield from == 0 && take == c.length ? c : c.sublist(from, from + take);
left -= take;
if (left <= 0) return;
}
if (left > 0) throw HttpException('upstream ended early', uri: s.url);
}
+33 -4
View File
@@ -54,6 +54,8 @@ class EngineMember {
Map<String, bool> jf = const {};
/// The hosting app's own member (set by the server from the host key).
bool host = false;
/// Sitting out: never in the set, never ready, its buffer ignored.
bool out = false;
DateTime lastSeen;
EngineMember({
required this.id,
@@ -94,6 +96,7 @@ class EngineMember {
stale: stale,
jf: jf,
host: host,
out: out,
);
}
@@ -193,7 +196,8 @@ class SyncEngine {
void _seen(EngineMember member) {
final wasStale = isStale(member);
member.lastSeen = now;
if (!member.inSet &&
if (!member.out &&
!member.inSet &&
(member.ready || (wasStale && transport != Transport.playing))) {
member.inSet = true;
}
@@ -301,6 +305,8 @@ class SyncEngine {
final member = members[token];
if (member == null) return;
_seen(member);
// Sitting out: back first ([applyOut]), then ready.
if (member.out) return;
if (member.ready == ready && member.inSet) {
evaluate();
return;
@@ -320,12 +326,13 @@ class SyncEngine {
final actorName = actor?.name ?? '?';
if (target == 'all') {
for (final member in members.values) {
if (member.out) continue;
member.ready = ready;
if (ready) member.inSet = true;
}
} else {
final member = byId(target);
if (member == null) return;
if (member == null || member.out) return;
member.ready = ready;
if (ready) member.inSet = true;
feed.add(FeedMessage(
@@ -343,6 +350,28 @@ class SyncEngine {
evaluate();
}
/// Sits the member [targetId] out ([out] true; anyone may, the host
/// included), or brings [actorToken]'s own member back ([out] false;
/// only oneself). Out: not ready, not in the set, so it never blocks.
/// Back: in the set like a fresh join (unless playing), still unready:
/// its own `ready` follows once it has buffered.
void applyOut(String actorToken, String targetId, bool out) {
final actor = members[actorToken];
final member = byId(targetId);
if (actor == null || member == null) return;
_seen(actor);
final self = identical(actor, member);
if (!out && !self) return;
if (member.out == out) return;
member.out = out;
member.ready = false;
member.buffered = 0;
member.inSet = !out && transport != Transport.playing;
feed.add(FeedMessage(
actor: actor.name, action: out ? 'out' : 'back', target: self ? null : member.name, at: now));
evaluate();
}
void applySeek(String token, double seekPosition, {int seq = 0}) {
final member = members[token];
if (member == null) return;
@@ -379,7 +408,7 @@ class SyncEngine {
// (a stale member rejoins the set with its next frame if still ready).
final t = now;
for (final member in members.values) {
member.inSet = !isStale(member, t);
member.inSet = !member.out && !isStale(member, t);
member.buffered = 0;
}
feed.add(FeedMessage(actor: actor?.name ?? '?', action: 'swap', at: now));
@@ -393,7 +422,7 @@ class SyncEngine {
if (!reportPosition.isFinite) return;
_seen(member);
if (jf != null) member.jf = Map.unmodifiable(jf);
member.buffered = !buffered.isFinite || buffered < 0 ? 0 : buffered;
member.buffered = member.out || !buffered.isFinite || buffered < 0 ? 0 : buffered;
final m = media;
// First report of the length wins; only for the media the client has open.
if (m != null && m.duration <= 0 && mediaId == this.mediaId &&
@@ -3,12 +3,15 @@ import 'dart:io';
import 'package:archive/archive.dart';
import 'package:crypto/crypto.dart';
import 'package:sync_relay_core/core.dart';
// Zero Flutter imports: this file must stay unit-testable with plain
// `flutter test` (no widgets, no media_kit). One code path for both the
// CLI (`watch_together --upgrade`) and the in-app banner: fetch manifest,
// download, verify sha256, apply over the live install.
import 'constants.dart';
import 'logging.dart';
// The one updater for both programs: the app (`watch_together --upgrade`
// and the in-app update card, via UpdateController) and the dedicated
// server (`watch-together-server --upgrade`). Both ship in the same
// release archive, so either one swaps the whole install folder: fetch
// manifest, download, verify sha256, apply over the live install.
//
// Apply stages inside the install root (same filesystem, so every move is
// a rename — /tmp is often tmpfs and rename across filesystems fails).
@@ -28,9 +31,6 @@ const updateManifestUrl =
const appVersion = String.fromEnvironment('APP_VERSION',
defaultValue: '0.0.0-dev');
// Process args as main() received them — restart relaunches with these.
List<String> launchArgs = const [];
// Key into the manifest's `assets` map, e.g. linux-x64, windows-x64.
final updatePlatform = '${Platform.operatingSystem}-x64';
@@ -518,29 +518,24 @@ Future<void> downloadAndApply(UpdateManifest manifest, Directory root,
}
}
// Relaunch the (now replaced) binary detached with the same args, then
// exit this process.
Future<Never> restartApp(String executable, List<String> args) async {
final keep = args.where((a) => a != '--upgrade').toList();
await Process.start(executable, keep,
mode: ProcessStartMode.detached,
workingDirectory: Directory.current.path);
exit(0);
}
String requiredUpdateNote(UpdateManifest m) =>
'Required update: servers on ${m.version} speak protocol ${m.protocol}, '
'this build speaks ${RelayConstants.protoVersion} and cannot join them.';
// Returns a process exit code: 0 ok / already current, 1 failure,
// 2 not a packaged install (update manually).
// 2 not a packaged install (update manually). [program] names the front
// end in the first line; [restartNote] is the last line after an update.
// [checkOnly] reports what is available and changes nothing.
Future<int> runUpgrade(
{String currentVersion = appVersion,
String manifestUrl = updateManifestUrl,
HttpClient? http,
String? executablePath,
Directory? installRoot}) async {
stdout.writeln('watch-together $currentVersion (proto ${RelayConstants.protoVersion})');
Directory? installRoot,
String program = 'watch-together',
String Function(String version)? restartNote,
bool checkOnly = false}) async {
stdout.writeln('$program $currentVersion (proto ${RelayConstants.protoVersion})');
late final UpdateManifest manifest;
try {
manifest = await fetchManifest(http: http, url: manifestUrl);
@@ -559,6 +554,7 @@ Future<int> runUpgrade(
if (manifest.isRequired) {
stdout.writeln(requiredUpdateNote(manifest));
}
if (checkOnly) return 0;
final exe = executablePath ?? currentExecutablePath();
final root = installRoot ?? findInstallRoot(exe);
if (root == null) {
@@ -575,6 +571,7 @@ Future<int> runUpgrade(
stdout.writeln('update failed: $e');
return 1;
}
stdout.writeln('updated to ${manifest.version} — restart the app to use it.');
stdout.writeln(restartNote?.call(manifest.version) ??
'updated to ${manifest.version} — restart the app to use it.');
return 0;
}
+604
View File
@@ -0,0 +1,604 @@
import 'dart:async';
import 'dart:convert';
import 'dart:ffi' show Abi;
import 'dart:io';
import 'dart:typed_data';
import 'package:crypto/crypto.dart';
import 'package:path/path.dart' as p;
import 'cookies.dart';
import 'logging.dart';
// yt-dlp: turns a web page (YouTube, Twitter/X, ...) into media URLs. The
// app and the server each keep their own copy, the official standalone
// build downloaded from GitHub and checked against the release's
// SHA2-256SUMS, refreshed at most once a day. Nothing is installed by hand.
final _log = createLogger('yt-dlp');
class YtDlpException implements Exception {
final String message;
const YtDlpException(this.message);
@override
String toString() => message;
}
/// One playable format of a resolved page: a URL and the headers yt-dlp
/// says to send with it. A segmented format ([isPieces]) is an HLS media
/// playlist at [url], or DASH [fragments].
class YtDlpFormat {
final String id;
final Uri url;
final Map<String, String> headers;
final int? height;
final String ext;
/// yt-dlp's `downloader_options.http_chunk_size`: the longest GET the
/// site serves at full speed.
final int? chunkSize;
/// yt-dlp's `protocol`: `https`/`http` (a file), `m3u8_native`/`m3u8`
/// (an HLS media playlist), `http_dash_segments` ([fragments]).
final String protocol;
/// DASH fragments (a first one without a duration is the init section).
final List<({Uri url, double? duration})>? fragments;
const YtDlpFormat(
{required this.id,
required this.url,
this.headers = const {},
this.height,
this.ext = '',
this.chunkSize,
this.protocol = 'https',
this.fragments});
/// Relayed as pieces (see relay_pieces.dart), not as a byte stream.
bool get isPieces => _piecesProtocols.contains(protocol);
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;
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 {},
height: h == 0 ? null : h,
ext: '${f['ext']}',
chunkSize: chunk is num && chunk > 0 ? chunk.toInt() : null,
protocol: '${f['protocol']}',
fragments: f['protocol'] == 'http_dash_segments' ? _fragments(f) : null,
);
}
/// yt-dlp's `fragments` (`url`, or `path` under `fragment_base_url`),
/// or null when one is not plain http(s).
static List<({Uri url, double? duration})>? _fragments(Map f) {
final raw = f['fragments'];
final base = Uri.tryParse('${f['fragment_base_url'] ?? f['url'] ?? ''}');
if (raw is! List || raw.isEmpty) return null;
final out = <({Uri url, double? duration})>[];
for (final fr in raw) {
if (fr is! Map) return null;
final u = fr['url'] is String
? Uri.tryParse(fr['url'] as String)
: fr['path'] is String && base != null
? base.resolve(fr['path'] as String)
: null;
if (u == null || (!u.isScheme('http') && !u.isScheme('https'))) return null;
out.add((url: u, duration: fr['duration'] is num ? (fr['duration'] as num).toDouble() : null));
}
return out;
}
}
const _piecesProtocols = {'m3u8_native', 'm3u8', 'http_dash_segments'};
/// A format a relay plays as one of a media's tracks ([kind] `video` or
/// `audio`; empty for a single file holding both).
typedef YtDlpTrack = ({String kind, YtDlpFormat format});
/// What `yt-dlp -J` says about a page.
class YtDlpInfo {
final String title;
final String uploader;
final String extractor;
final String webpageUrl;
/// Seconds; null when unknown (a live stream).
final double? duration;
final String? thumbnail;
/// A live stream (never relayed).
final bool isLive;
/// What a relay reads (see [pickTracks]): one file, a video and an
/// audio file, or segmented playlists; empty when there is nothing (or
/// live).
final List<YtDlpTrack> tracks;
/// Every relayable format by id, to find [tracks] again after a
/// re-resolve.
final Map<String, YtDlpFormat> formats;
const YtDlpInfo({
required this.title,
this.uploader = '',
this.extractor = '',
this.webpageUrl = '',
this.duration,
this.thumbnail,
this.isLive = false,
this.tracks = const [],
this.formats = const {},
});
/// Parses `yt-dlp -J` output; a playlist answer uses its first entry.
static YtDlpInfo parse(Map<String, Object?> j) {
final entries = j['entries'];
if (j['_type'] == 'playlist' && entries is List) {
final first = entries.whereType<Map>().firstOrNull;
if (first == null) throw const YtDlpException('the page has no videos');
return parse(Map<String, Object?>.from(first));
}
String str(String k) => j[k] is String ? (j[k] as String).trim() : '';
final formats = j['formats'] is List ? (j['formats'] as List) : [if (j['url'] is String) j];
final isLive = j['is_live'] == true || j['live_status'] == 'is_live' || j['live_status'] == 'is_upcoming';
return YtDlpInfo(
title: str('title').isNotEmpty ? str('title') : str('fulltitle'),
uploader: str('uploader').isNotEmpty ? str('uploader') : str('channel'),
extractor: str('extractor_key').isNotEmpty ? str('extractor_key') : str('extractor'),
webpageUrl: str('webpage_url'),
duration: j['duration'] is num ? (j['duration'] as num).toDouble() : null,
thumbnail: str('thumbnail').isEmpty ? null : str('thumbnail'),
isLive: isLive,
tracks: isLive ? const [] : pickTracks(formats),
formats: {
for (final f in formats)
if ((_httpUrl(f) || _piecesUrl(f)) && f is Map) '${f['format_id'] ?? ''}': YtDlpFormat._of(f),
},
);
}
}
const _singleFileExts = {'mp4', 'webm', 'mov', 'm4v', 'mkv'};
const _audioExts = {'m4a', 'mp4', 'webm', 'opus', 'ogg', 'mp3'};
/// A plain http(s) format with a URL, not DRM-protected.
bool _httpUrl(Object? f) {
if (f is! Map || f['has_drm'] == true) return false;
final url = f['url'];
final protocol = f['protocol'];
if (url is! String || (protocol != 'http' && protocol != 'https')) return false;
final uri = Uri.tryParse(url);
return uri != null && (uri.isScheme('http') || uri.isScheme('https'));
}
/// A segmented format (HLS playlist or DASH fragments) over http(s), not
/// DRM-protected (the playlist's keys are checked when it is read).
bool _piecesUrl(Object? f) {
if (f is! Map || f['has_drm'] == true || !_piecesProtocols.contains(f['protocol'])) return false;
final url = f['url'];
final uri = url is String ? Uri.tryParse(url) : null;
if (uri == null || (!uri.isScheme('http') && !uri.isScheme('https'))) return false;
return f['protocol'] != 'http_dash_segments' || YtDlpFormat._fragments(f) != null;
}
int _height(Map f) => f['height'] is num ? (f['height'] as num).toInt() : 0;
double _tbr(Map f) =>
f['tbr'] is num ? (f['tbr'] as num).toDouble() : (f['abr'] is num ? (f['abr'] as num).toDouble() : 0);
/// Height order: up to [maxHeight] the tallest first, then the shortest
/// above it.
int _byHeight(int ha, int hb, int maxHeight) {
final ina = ha <= maxHeight, inb = hb <= maxHeight;
if (ina != inb) return ina ? -1 : 1;
return ha == hb ? 0 : (ina ? hb.compareTo(ha) : ha.compareTo(hb));
}
/// The best single-file format of [formats] (yt-dlp format maps): video
/// and audio both present (or not known to be absent), plain http(s), a
/// video container; the tallest up to [maxHeight], else the shortest above
/// it; mp4/webm and higher bitrate break ties.
YtDlpFormat? pickSingleFile(List<Object?> formats, {int maxHeight = 1080}) {
final ok = [
for (final f in formats)
if (_httpUrl(f) && (f as Map)['vcodec'] != 'none' && f['acodec'] != 'none' && _singleFileExts.contains(f['ext']))
f,
];
if (ok.isEmpty) return null;
bool mp4(Map f) => f['ext'] == 'mp4' || f['ext'] == 'webm';
ok.sort((a, b) {
final h = _byHeight(_height(a), _height(b), maxHeight);
if (h != 0) return h;
if (mp4(a) != mp4(b)) return mp4(a) ? -1 : 1;
return _tbr(b).compareTo(_tbr(a));
});
return YtDlpFormat._of(ok.first);
}
// Relayed bytes cost every viewer bandwidth and the server buffer, and mpv
// decodes all of these: the most compact codec first. HEVC (hardware
// decoding nearly everywhere), then AV1 (smallest; software is fine to
// 1080p), then VP9, then h264.
int _videoCodecRank(Map f) {
final c = '${f['vcodec']}'.toLowerCase();
if (c.startsWith('hvc') || c.startsWith('hev') || c.startsWith('h265')) return 0;
if (c.startsWith('av01')) return 1;
if (c.startsWith('vp9') || c.startsWith('vp09')) return 2;
if (c.startsWith('avc') || c.startsWith('h264')) return 3;
return 4;
}
/// The best video-only + audio-only pair of plain http(s) files (YouTube's
/// DASH formats), or null. Video: the tallest up to [maxHeight] (else the
/// shortest above), HEVC before AV1 before VP9 before h264 (smallest
/// first, see [_videoCodecRank]), then bitrate. Audio: the original language, not a `-drc` variant, m4a/aac
/// before others (opus), then bitrate.
(YtDlpFormat, YtDlpFormat)? pickSplit(List<Object?> formats, {int maxHeight = 1080}) => _pickPair(
[
for (final f in formats)
if (_httpUrl(f) && (f as Map)['acodec'] == 'none' && f['vcodec'] is String && f['vcodec'] != 'none' &&
_singleFileExts.contains(f['ext']))
f,
],
[
for (final f in formats)
if (_httpUrl(f) && (f as Map)['vcodec'] == 'none' && f['acodec'] is String && f['acodec'] != 'none' &&
_audioExts.contains(f['ext']))
f,
],
maxHeight);
(YtDlpFormat, YtDlpFormat)? _pickPair(List<Map> video, List<Map> audio, int maxHeight) {
if (video.isEmpty || audio.isEmpty) return null;
video.sort((a, b) {
final h = _byHeight(_height(a), _height(b), maxHeight);
if (h != 0) return h;
final c = _videoCodecRank(a).compareTo(_videoCodecRank(b));
return c != 0 ? c : _tbr(b).compareTo(_tbr(a));
});
bool drc(Map f) => '${f['format_id']}'.contains('drc');
int lang(Map f) => f['language_preference'] is num ? (f['language_preference'] as num).toInt() : 0;
bool aac(Map f) => f['ext'] == 'm4a' || '${f['acodec']}'.startsWith('mp4a');
audio.sort((a, b) {
if (drc(a) != drc(b)) return drc(a) ? 1 : -1;
final l = lang(b).compareTo(lang(a));
if (l != 0) return l;
if (aac(a) != aac(b)) return aac(a) ? -1 : 1;
return _tbr(b).compareTo(_tbr(a));
});
return (YtDlpFormat._of(video.first), YtDlpFormat._of(audio.first));
}
/// The pair if its video is taller than [single] (by the [maxHeight] rule).
bool _pairWins((YtDlpFormat, YtDlpFormat)? pair, YtDlpFormat? single, int maxHeight) =>
pair != null &&
(single == null ||
(single.height != null &&
pair.$1.height != null &&
_byHeight(pair.$1.height!, single.height!, maxHeight) < 0));
/// Segmented formats (HLS / DASH): one muxed playlist (video and audio
/// not known to be absent) unless a video-only + audio-only pair is
/// taller; the same height, codec and audio rules as files.
List<YtDlpTrack> pickPieces(List<Object?> formats, {int maxHeight = 1080}) {
final ok = [for (final f in formats) if (_piecesUrl(f)) f as Map];
final muxed = [for (final f in ok) if (f['vcodec'] != 'none' && f['acodec'] != 'none') f]..sort((a, b) {
final h = _byHeight(_height(a), _height(b), maxHeight);
return h != 0 ? h : _tbr(b).compareTo(_tbr(a));
});
final pair = _pickPair([for (final f in ok) if (f['acodec'] == 'none' && f['vcodec'] != 'none') f],
[for (final f in ok) if (f['vcodec'] == 'none' && f['acodec'] != 'none') f], maxHeight);
final single = muxed.isEmpty ? null : YtDlpFormat._of(muxed.first);
if (_pairWins(pair, single, maxHeight)) {
return [(kind: 'video', format: pair!.$1), (kind: 'audio', format: pair.$2)];
}
return single == null ? const [] : [(kind: 'video', format: single)];
}
/// What a relay reads of [formats]: the single file ([pickSingleFile])
/// unless a video + audio pair ([pickSplit]) is taller (both by the
/// [maxHeight] rule); with neither, segmented playlists ([pickPieces]);
/// empty when there is nothing.
List<YtDlpTrack> pickTracks(List<Object?> formats, {int maxHeight = 1080}) {
final single = pickSingleFile(formats, maxHeight: maxHeight);
final split = pickSplit(formats, maxHeight: maxHeight);
if (_pairWins(split, single, maxHeight)) {
return [(kind: 'video', format: split!.$1), (kind: 'audio', format: split.$2)];
}
if (single != null) return [(kind: '', format: single)];
return pickPieces(formats, maxHeight: maxHeight);
}
/// The official standalone build for this platform, or null if there is
/// none.
String? ytDlpAssetName(Abi abi, {bool musl = false}) => switch (abi) {
Abi.linuxX64 => musl ? 'yt-dlp_musllinux' : 'yt-dlp_linux',
Abi.linuxArm64 => musl ? 'yt-dlp_musllinux_aarch64' : 'yt-dlp_linux_aarch64',
Abi.windowsX64 => 'yt-dlp.exe',
Abi.windowsArm64 => 'yt-dlp_arm64.exe',
Abi.windowsIA32 => 'yt-dlp_x86.exe',
Abi.macosX64 || Abi.macosArm64 => 'yt-dlp_macos',
_ => null,
};
/// `SHA2-256SUMS` lines (`<hex> <name>`) as name -> lowercase hex.
Map<String, String> parseSha256Sums(String text) => {
for (final line in const LineSplitter().convert(text))
if (RegExp(r'^([0-9a-fA-F]{64})\s+\*?(\S+)\s*$').firstMatch(line) case final m?)
m.group(2)!: m.group(1)!.toLowerCase(),
};
/// The last `ERROR:` line of yt-dlp's stderr without its prefixes
/// (`ERROR: [twitter] 123: No video` -> `No video`), else the last line.
String ytDlpErrorText(String stderr) {
final lines = const LineSplitter().convert(stderr).map((l) => l.trim()).where((l) => l.isNotEmpty).toList();
if (lines.isEmpty) return 'yt-dlp failed';
final err = lines.lastWhere((l) => l.startsWith('ERROR:'), orElse: () => lines.last);
return err.replaceFirst(RegExp(r'^ERROR:\s*'), '').replaceFirst(RegExp(r'^\[[^\]]+\]\s*[^:\s]*:\s*'), '');
}
/// Finds, downloads, updates and runs yt-dlp. The managed copy lives in
/// [dir] (writable); without it a `yt-dlp` on PATH is the fallback when
/// the download fails. Every failure is non-fatal: [lastError] says why.
class YtDlp {
final String dir;
final bool enabled;
/// How often [ensure] looks for a newer release in the background.
final Duration updateEvery;
final HttpClient Function() _http;
static const releases = 'https://github.com/yt-dlp/yt-dlp/releases';
YtDlp(this.dir, {this.enabled = true, this.updateEvery = const Duration(days: 1), HttpClient Function()? http})
: _http = http ?? HttpClient.new;
String? _path;
/// The binary in use, once found.
String? get path => _path;
/// `yt-dlp --version` of [path].
String? version;
String? lastError;
/// A download or update is running.
bool busy = false;
final _changes = StreamController<void>.broadcast();
/// Fires when [version], [lastError] or [busy] change.
Stream<void> get changes => _changes.stream;
Future<String?>? _ensuring;
Future<void>? _updating;
/// Cookies every resolve sends (see [YtDlpCookies]), kept in [dir].
late final cookies = YtDlpCookies(dir);
String get managedPath => p.join(dir, Platform.isWindows ? 'yt-dlp.exe' : 'yt-dlp');
String get _stampPath => p.join(dir, 'last-check');
void _changed() {
if (!_changes.isClosed) _changes.add(null);
}
void _fail(String message) {
lastError = message;
_log.warning(message);
_changed();
}
/// The binary to run, downloading it first if needed; null (see
/// [lastError]) when there is none. A newer release is looked for in the
/// background at most every [updateEvery].
Future<String?> ensure() {
final known = _path;
if (known != null && (known == 'yt-dlp' || File(known).existsSync())) {
unawaited(update());
return Future.value(known);
}
return _ensuring ??= _ensure().whenComplete(() => _ensuring = null);
}
Future<String?> _ensure() async {
if (!enabled) {
lastError = 'yt-dlp is turned off';
return null;
}
if (await File(managedPath).exists()) {
final v = await _versionOf(managedPath);
if (v != null) {
_use(managedPath, v);
unawaited(update());
return managedPath;
}
}
try {
await _download(await _latestTag());
return managedPath;
} catch (e) {
final onPath = await _versionOf('yt-dlp');
if (onPath != null) {
_use('yt-dlp', onPath);
_fail('could not download yt-dlp ($e); using the one on PATH');
return 'yt-dlp';
}
_fail('could not download yt-dlp: $e');
return null;
}
}
void _use(String path, String v) {
_path = path;
version = v;
_changed();
}
Future<String?> _versionOf(String exe) async {
try {
final r = await Process.run(exe, ['--version']).timeout(const Duration(seconds: 20));
final v = '${r.stdout}'.trim();
return r.exitCode == 0 && v.isNotEmpty ? v : null;
} catch (_) {
return null;
}
}
/// Replaces the managed copy when a newer release is out. Without
/// [force], at most every [updateEvery] (remembered across restarts).
Future<void> update({bool force = false}) => _updating ??= _update(force).whenComplete(() => _updating = null);
Future<void> _update(bool force) async {
if (!enabled) return;
try {
if (!force) {
final stamp = File(_stampPath);
if (await stamp.exists()) {
final at = DateTime.tryParse((await stamp.readAsString()).trim());
if (at != null && DateTime.now().difference(at) < updateEvery) return;
}
}
final tag = await _latestTag();
final managed = await File(managedPath).exists() ? await _versionOf(managedPath) : null;
if (managed != tag) {
await _download(tag);
} else {
_use(managedPath, managed!);
await _stamp();
}
if (lastError != null) {
lastError = null;
_changed();
}
} catch (e) {
_fail('yt-dlp update failed: $e');
}
}
Future<void> _stamp() async {
try {
await File(_stampPath).writeAsString(DateTime.now().toIso8601String());
} catch (_) {}
}
/// The tag `releases/latest` redirects to.
Future<String> _latestTag() async {
final client = _http();
try {
final req = await client.getUrl(Uri.parse('$releases/latest')).timeout(const Duration(seconds: 20));
req.followRedirects = false;
final res = await req.close().timeout(const Duration(seconds: 20));
await res.drain<void>().catchError((_) {});
final loc = res.headers.value(HttpHeaders.locationHeader) ?? '';
final tag = Uri.tryParse(loc)?.pathSegments.lastOrNull ?? '';
if (!res.isRedirect || tag.isEmpty || tag == 'latest') {
throw YtDlpException('no latest release (HTTP ${res.statusCode})');
}
return tag;
} finally {
client.close(force: true);
}
}
Future<void> _download(String tag) async {
final asset = ytDlpAssetName(Abi.current(), musl: _isMusl());
if (asset == null) throw YtDlpException('no yt-dlp build for ${Abi.current()}');
busy = true;
_changed();
final client = _http();
final part = File('$managedPath.part$pid');
try {
await Directory(dir).create(recursive: true);
final sums = parseSha256Sums(utf8.decode(await _get(client, '$releases/download/$tag/SHA2-256SUMS')));
final want = sums[asset];
if (want == null) throw YtDlpException('$asset is not in the release checksums');
_log.info('downloading yt-dlp $tag ($asset)');
final req = await client.getUrl(Uri.parse('$releases/download/$tag/$asset')).timeout(const Duration(seconds: 30));
final res = await req.close().timeout(const Duration(seconds: 30));
if (res.statusCode != 200) {
await res.drain<void>().catchError((_) {});
throw YtDlpException('download HTTP ${res.statusCode}');
}
final sink = part.openWrite();
final hashOut = _DigestSink();
final hasher = sha256.startChunkedConversion(hashOut);
await for (final chunk in res.timeout(const Duration(seconds: 60))) {
hasher.add(chunk);
sink.add(chunk);
}
hasher.close();
await sink.close();
if (hashOut.value.toString() != want) throw const YtDlpException('checksum mismatch');
if (!Platform.isWindows) {
final r = await Process.run('chmod', ['755', part.path]);
if (r.exitCode != 0) throw YtDlpException('chmod failed: ${r.stderr}');
}
final v = await _versionOf(part.path);
if (v == null) throw const YtDlpException('the downloaded yt-dlp does not run');
final target = File(managedPath);
// Windows cannot rename onto an existing file.
if (Platform.isWindows && await target.exists()) await target.delete();
await part.rename(managedPath);
_use(managedPath, v);
await _stamp();
_log.info('yt-dlp $v ready at $managedPath');
} finally {
client.close(force: true);
if (await part.exists()) await part.delete().catchError((_) => part);
busy = false;
_changed();
}
}
Future<List<int>> _get(HttpClient client, String url) async {
final req = await client.getUrl(Uri.parse(url)).timeout(const Duration(seconds: 20));
final res = await req.close().timeout(const Duration(seconds: 20));
if (res.statusCode != 200) {
await res.drain<void>().catchError((_) {});
throw YtDlpException('$url: HTTP ${res.statusCode}');
}
final out = BytesBuilder(copy: false);
await for (final c in res.timeout(const Duration(seconds: 20))) {
out.add(c);
}
return out.takeBytes();
}
static bool _isMusl() {
if (!Platform.isLinux) return false;
try {
return Directory('/lib').listSync().any((e) => p.basename(e.path).startsWith('ld-musl-'));
} catch (_) {
return false;
}
}
/// Asks yt-dlp what [url] is, with [cookies] if any. Throws
/// [YtDlpException] with yt-dlp's own reason on failure.
Future<YtDlpInfo> resolve(String url, {Duration timeout = const Duration(seconds: 25)}) async {
final exe = await ensure();
if (exe == null) throw YtDlpException(lastError ?? 'yt-dlp is not available');
final proc = await Process.start(exe, resolveArgs(url));
final out = proc.stdout.transform(utf8.decoder).join();
final err = proc.stderr.transform(utf8.decoder).join();
final int code;
try {
code = await proc.exitCode.timeout(timeout);
} on TimeoutException {
proc.kill();
throw YtDlpException('yt-dlp took longer than ${timeout.inSeconds} s');
}
if (code != 0) throw YtDlpException(ytDlpErrorText(await err));
try {
final j = jsonDecode(await out);
if (j is! Map) throw const FormatException('not an object');
return YtDlpInfo.parse(Map<String, Object?>.from(j));
} on FormatException catch (e) {
throw YtDlpException('unreadable yt-dlp output: ${e.message}');
}
}
/// The command line [resolve] runs.
List<String> resolveArgs(String url) =>
['-J', '--no-playlist', '--no-warnings', '--ignore-config', ...cookies.args(), '--', url];
Future<void> close() => _changes.close();
}
class _DigestSink implements Sink<Digest> {
late Digest value;
@override
void add(Digest data) => value = data;
@override
void close() {}
}
+24
View File
@@ -17,6 +17,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "14.3.0"
archive:
dependency: "direct main"
description:
name: archive
sha256: "6c5bcd986e06b94e3c40244af471750840a3d2341d1f9763a1100a14add517b4"
url: "https://pub.dev"
source: hosted
version: "4.3.0"
args:
dependency: transitive
description:
@@ -81,6 +89,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.0.7"
ffi:
dependency: transitive
description:
name: ffi
sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45"
url: "https://pub.dev"
source: hosted
version: "2.2.0"
file:
dependency: transitive
description:
@@ -201,6 +217,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.5.3"
posix:
dependency: transitive
description:
name: posix
sha256: bc1bad54ad2b735816e31f8d4600cfde6c7839975085ddfbca48b6c9f7c4044e
url: "https://pub.dev"
source: hosted
version: "6.5.2"
pub_semver:
dependency: transitive
description:
Loaded 100 of 129 files, more files were not shown because too many files have changed in this diff. Show more