Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6d388483ef | ||
|
|
bbecdcc4d8 | ||
|
|
f1ccf88b06 | ||
|
|
d3be13ecda | ||
|
|
1b04e002c2 | ||
|
|
f23cc7b311 | ||
|
|
473c82ec0a | ||
|
|
e621b392af | ||
|
|
884f208330 | ||
|
|
cc45b86271 | ||
|
|
84c5cda668 | ||
|
|
bc96cb13d9 | ||
|
|
f7f2f0cddd | ||
|
|
05b06b1fc1 | ||
|
|
748d58f036 | ||
|
|
1fd4081461 | ||
|
|
2d39018a73 | ||
|
|
059b7dd306 | ||
|
|
0115d54ab8 | ||
|
|
fe5d6c3b3f | ||
|
|
05f9e794a9 | ||
|
|
75065f90cd | ||
|
|
e0db5cc655 | ||
|
|
8186fbb5d3 | ||
|
|
8b81da4069 | ||
|
|
dbfd351ee4 | ||
|
|
a4be24a2a5 | ||
|
|
b038706dff | ||
|
|
b2b77a6b3f | ||
|
|
d002ff6941 | ||
|
|
151ec95289 | ||
|
|
745717a4a0 | ||
|
|
9ddd85de90 | ||
|
|
24fc137e4f | ||
|
|
9a4b04672b |
No files matched your search
@@ -1,6 +1,9 @@
|
||||
name: ci
|
||||
on:
|
||||
# Branches only: a release tag points at a commit CI already checked on
|
||||
# main, and release.yml builds it.
|
||||
push:
|
||||
branches: ['**']
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
@@ -55,5 +58,13 @@ 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
|
||||
# The release build, without publishing: a broken Windows build shows
|
||||
# here, not only on a tag.
|
||||
- name: Windows build
|
||||
shell: powershell
|
||||
run: powershell -NoProfile -ExecutionPolicy Bypass -File tool/package_windows.ps1
|
||||
@@ -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,20 @@ 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.
|
||||
you). Your last 12 links are listed under it. A link that does not
|
||||
open here (needs cookies, 403, 404, not a video) says why and goes up
|
||||
only with "Put it up anyway"; in the player, whoever cannot play it is
|
||||
named ("Bob can't play this: Not allowed (403)") and nobody waits for them.
|
||||
- **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 +172,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 +202,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 +218,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 +338,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
|
||||
|
||||
|
||||
@@ -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 |
|
After Width: | Height: | Size: 7.3 KiB |
|
After Width: | Height: | Size: 732 B |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
@@ -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,62 +139,59 @@ class FileRow extends StatelessWidget {
|
||||
builder: (context, t, _) {
|
||||
final duration = durationText(t);
|
||||
final meta = [?duration, ?size].join(' · ');
|
||||
return _offlineTip(
|
||||
offline,
|
||||
Material(
|
||||
color: playing ? scheme.primary.withValues(alpha: 0.08) : Colors.transparent,
|
||||
return Material(
|
||||
color: playing ? scheme.primary.withValues(alpha: 0.08) : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: Padding(
|
||||
padding: EdgeInsets.fromLTRB(indent ? 20 : 4, 5, 8, 5),
|
||||
child: Row(
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: ThumbImage(t, width: 80, height: 45),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
SizedBox(width: 20, child: Center(child: icon)),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: playing ? scheme.primary : null,
|
||||
),
|
||||
child: Padding(
|
||||
padding: EdgeInsets.fromLTRB(indent ? 20 : 4, 5, 8, 5),
|
||||
child: Row(
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: ThumbImage(t, width: 80, height: 45),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
SizedBox(width: 20, child: Center(child: icon)),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: playing ? scheme.primary : null,
|
||||
),
|
||||
),
|
||||
if (playing) ...[
|
||||
const SizedBox(width: 8),
|
||||
Pill('now playing', scheme.primary),
|
||||
],
|
||||
),
|
||||
if (playing) ...[
|
||||
const SizedBox(width: 8),
|
||||
Pill('now playing', scheme.primary),
|
||||
],
|
||||
),
|
||||
Text(subtitle,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(color: muted, fontSize: 12)),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
Text(subtitle,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(color: muted, fontSize: 12)),
|
||||
],
|
||||
),
|
||||
if (meta.isNotEmpty) ...[
|
||||
const SizedBox(width: 12),
|
||||
Text(meta, style: TextStyle(color: muted, fontSize: 12)),
|
||||
],
|
||||
?trailing,
|
||||
),
|
||||
if (meta.isNotEmpty) ...[
|
||||
const SizedBox(width: 12),
|
||||
Text(meta, style: TextStyle(color: muted, fontSize: 12)),
|
||||
],
|
||||
),
|
||||
?trailing,
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -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,65 +228,62 @@ class VideoCard extends StatelessWidget {
|
||||
path: video.path,
|
||||
builder: (context, t, _) {
|
||||
final duration = durationText(t);
|
||||
return _offlineTip(
|
||||
offline,
|
||||
InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
AspectRatio(
|
||||
aspectRatio: 16 / 9,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: playing ? scheme.primary : Colors.transparent,
|
||||
width: 2,
|
||||
),
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
AspectRatio(
|
||||
aspectRatio: 16 / 9,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: playing ? scheme.primary : Colors.transparent,
|
||||
width: 2,
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
ThumbImage(t),
|
||||
if (duration != null)
|
||||
Positioned(right: 6, bottom: 6, child: _Badge(duration)),
|
||||
if (state != PlayState.idle)
|
||||
Container(
|
||||
color: Colors.black38,
|
||||
alignment: Alignment.center,
|
||||
child: state == PlayState.playing
|
||||
? const Icon(Icons.graphic_eq, color: Colors.white, size: 32)
|
||||
: const SizedBox.square(
|
||||
dimension: 28,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 3, color: Colors.white)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
ThumbImage(t),
|
||||
if (duration != null)
|
||||
Positioned(right: 6, bottom: 6, child: _Badge(duration)),
|
||||
if (state != PlayState.idle)
|
||||
Container(
|
||||
color: Colors.black38,
|
||||
alignment: Alignment.center,
|
||||
child: state == PlayState.playing
|
||||
? const Icon(Icons.graphic_eq, color: Colors.white, size: 32)
|
||||
: const SizedBox.square(
|
||||
dimension: 28,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 3, color: Colors.white)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Flexible(
|
||||
child: Text(
|
||||
cleanTitle(video.parsed),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 13,
|
||||
color: playing ? scheme.primary : null,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Flexible(
|
||||
child: Text(
|
||||
cleanTitle(video.parsed),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 13,
|
||||
color: playing ? scheme.primary : null,
|
||||
),
|
||||
),
|
||||
Text(formatSize(video.size),
|
||||
style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12)),
|
||||
],
|
||||
),
|
||||
),
|
||||
Text(formatSize(video.size),
|
||||
style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12)),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
''';
|
||||
@@ -1,6 +1,8 @@
|
||||
import 'dart:async';
|
||||
import 'dart:ffi' show Int8;
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:ffi/ffi.dart' show StringUtf8Pointer, malloc;
|
||||
import 'package:flutter/foundation.dart' show listEquals;
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:media_kit/media_kit.dart';
|
||||
@@ -16,6 +18,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 +63,52 @@ 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;
|
||||
|
||||
/// The current media failed to load (the HTTP error when there was one,
|
||||
/// e.g. a CDN's 403), at most once per [openUrl].
|
||||
void Function(String error)? onLoadError;
|
||||
|
||||
// Per opened media: the last HTTP error ffmpeg logged, and whether a
|
||||
// load error was reported.
|
||||
String? _httpError;
|
||||
bool _loadErrorSent = false;
|
||||
|
||||
/// mpv's log level for this app. media_kit drains mpv's log on the UI
|
||||
/// thread one line at a time, so a chatty level (ffmpeg retrying a
|
||||
/// refused request logs every attempt) can starve the UI: warnings and
|
||||
/// errors only, except while the video output starts ([video]), when
|
||||
/// the render-mode probe needs mpv's verbose GL init lines.
|
||||
static const logLevel = 'warn';
|
||||
|
||||
/// 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;
|
||||
@@ -131,6 +180,7 @@ class PlaybackController {
|
||||
final existing = _video;
|
||||
if (existing != null) return existing;
|
||||
final hwdec = _hwdecOverride;
|
||||
_requestLog(player, 'v'); // back to [logLevel] by _detectRenderMode
|
||||
// media_kit's video output sets hwdec itself on attach ('auto' unless
|
||||
// configured), which used to override SYNC_RELAY_HWDEC.
|
||||
final video = _video = VideoController(
|
||||
@@ -156,8 +206,7 @@ class PlaybackController {
|
||||
bufferSize: maxBytes,
|
||||
libass: true,
|
||||
vo: Platform.environment['SYNC_RELAY_VO'],
|
||||
// Verbose: the render-mode probe needs mpv's GL init lines.
|
||||
logLevel: MPVLogLevel.v,
|
||||
logLevel: MPVLogLevel.warn, // see [logLevel]
|
||||
))
|
||||
..onUserSeek = (d) => onUserSeek?.call(d);
|
||||
_shaders = '';
|
||||
@@ -212,8 +261,29 @@ class PlaybackController {
|
||||
if (hwdec != null) {
|
||||
await _pin('hwdec', hwdec);
|
||||
}
|
||||
await _pin('stream-lavf-o',
|
||||
'reconnect_streamed=1,reconnect_on_http_error=1,reconnect_delay_max=4');
|
||||
await _pin('stream-lavf-o', streamLavfOptions);
|
||||
}
|
||||
|
||||
/// ffmpeg's HTTP options (mpv `stream-lavf-o`, comma form): reconnect
|
||||
/// after a dropped connection, also on a streamed (growing) relay
|
||||
/// response, and a server error (5xx) while connecting; a refused request
|
||||
/// (4xx) fails at once. At most 3 tries per read, 4 s apart at most.
|
||||
static const streamLavfOptions = 'reconnect_streamed=1,reconnect_on_http_error=5xx,'
|
||||
'reconnect_delay_max=4,reconnect_max_retries=3';
|
||||
|
||||
// mpv_request_log_messages on [p]'s handle (media_kit has no API for it).
|
||||
static void _requestLog(Player p, String level) {
|
||||
try {
|
||||
final dynamic platform = p.platform;
|
||||
final name = level.toNativeUtf8();
|
||||
try {
|
||||
platform.mpv.mpv_request_log_messages(platform.ctx, name.cast<Int8>());
|
||||
} finally {
|
||||
malloc.free(name);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('mpv log level $level not set: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Puts the saved shader choice (Settings, Ctrl+0..6, shader button) on
|
||||
@@ -298,6 +368,31 @@ 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) {
|
||||
_loadErrorSent = true;
|
||||
onYtdlError?.call(text);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// ffmpeg logs through one mpv instance per process (the first one
|
||||
// alive), so the HTTP status is a bonus, not a given.
|
||||
if (log.prefix == 'ffmpeg') {
|
||||
final http = RegExp(r'HTTP error \d{3}.*').firstMatch(log.text);
|
||||
if (http != null) _httpError = http[0]!.trim();
|
||||
return;
|
||||
}
|
||||
// Nothing playable opened (a track of mpv's yt-dlp EDL refused, ...).
|
||||
if (!_loadErrorSent &&
|
||||
duration.peek() == Duration.zero &&
|
||||
((log.prefix == 'cplayer' && log.level == 'fatal') ||
|
||||
(log.prefix == 'demux' && log.level == 'error'))) {
|
||||
_loadErrorSent = true;
|
||||
final http = _httpError;
|
||||
onLoadError?.call(http == null ? 'the video stream could not be opened' : 'the video server answered $http');
|
||||
return;
|
||||
}
|
||||
if (log.prefix != 'libmpv_render') return;
|
||||
if (log.text.contains('suspected software renderer')) {
|
||||
_setRenderMode(RenderMode.softwareGl);
|
||||
@@ -328,10 +423,12 @@ class PlaybackController {
|
||||
Future<void> _detectRenderMode(Player p, VideoController video) async {
|
||||
try {
|
||||
await video.platform.future;
|
||||
await Future.delayed(const Duration(milliseconds: 1500));
|
||||
} catch (_) {
|
||||
return;
|
||||
} finally {
|
||||
if (identical(p, _player)) _requestLog(p, logLevel);
|
||||
}
|
||||
await Future.delayed(const Duration(milliseconds: 1500));
|
||||
if (!identical(p, _player) || renderMode.peek() != RenderMode.unknown) {
|
||||
return;
|
||||
}
|
||||
@@ -415,6 +512,8 @@ class PlaybackController {
|
||||
if (!_ready) return;
|
||||
try {
|
||||
_chapterJson = '';
|
||||
_httpError = null;
|
||||
_loadErrorSent = false;
|
||||
chapters.value = const [];
|
||||
tracks.value = const [];
|
||||
buffered.value = Duration.zero;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
@@ -222,7 +248,6 @@ class AnimeFlowScheduleItem {
|
||||
final int? anidbId;
|
||||
final int? tvdbId;
|
||||
final String? jellyfinSeriesId;
|
||||
final bool followed;
|
||||
|
||||
const AnimeFlowScheduleItem({
|
||||
required this.episode,
|
||||
@@ -231,7 +256,6 @@ class AnimeFlowScheduleItem {
|
||||
this.anidbId,
|
||||
this.tvdbId,
|
||||
this.jellyfinSeriesId,
|
||||
this.followed = false,
|
||||
});
|
||||
|
||||
static AnimeFlowScheduleItem fromJson(Map j) => AnimeFlowScheduleItem(
|
||||
@@ -241,7 +265,6 @@ class AnimeFlowScheduleItem {
|
||||
anidbId: _int(j['anidb_id']),
|
||||
tvdbId: _int(j['tvdb_id']),
|
||||
jellyfinSeriesId: _str(j['jellyfin_series_id']),
|
||||
followed: j['followed'] == true,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -270,7 +293,6 @@ class AnimeFlowSeries {
|
||||
final String? jellyfinSeriesId;
|
||||
final SeriesState state;
|
||||
final bool monitored;
|
||||
final bool followed;
|
||||
final int episodesTotal;
|
||||
final int episodesAvailable;
|
||||
final DateTime? nextAirDate;
|
||||
@@ -286,7 +308,6 @@ class AnimeFlowSeries {
|
||||
this.jellyfinSeriesId,
|
||||
this.state = SeriesState.releasing,
|
||||
this.monitored = false,
|
||||
this.followed = false,
|
||||
this.episodesTotal = 0,
|
||||
this.episodesAvailable = 0,
|
||||
this.nextAirDate,
|
||||
@@ -302,7 +323,6 @@ class AnimeFlowSeries {
|
||||
jellyfinSeriesId: _str(j['jellyfin_series_id']),
|
||||
state: SeriesState.parse(j['state']),
|
||||
monitored: j['monitored'] == true,
|
||||
followed: j['followed'] == true,
|
||||
episodesTotal: _int(j['episodes_total']) ?? 0,
|
||||
episodesAvailable: _int(j['episodes_available']) ?? 0,
|
||||
nextAirDate: _time(j['next_air_date']),
|
||||
@@ -658,10 +678,6 @@ class AnimeFlowClient {
|
||||
return AnimeFlowSeries.fromJson(j is Map ? j : const {});
|
||||
}
|
||||
|
||||
/// Follow (or unfollow) a series [view].
|
||||
Future<void> setFollowed(int seriesId, bool followed) =>
|
||||
_call(followed ? 'PUT' : 'DELETE', '/series/$seriesId/follow');
|
||||
|
||||
/// Titles that can be requested [request].
|
||||
Future<List<AnimeFlowSearchHit>> search(String q) async => [
|
||||
for (final h in _items(await _call('GET', '/search', query: {'q': q}))) AnimeFlowSearchHit.fromJson(h),
|
||||
|
||||
@@ -17,9 +17,16 @@ enum ScheduleStatus {
|
||||
unmonitored,
|
||||
/// AnimeFlow does not track this series (or is unreachable).
|
||||
notTracked,
|
||||
/// Not tracked by AnimeFlow, but the series is on Jellyfin (`/library`):
|
||||
/// the episode may be there; Put it up finds out.
|
||||
inLibrary;
|
||||
/// Not tracked by AnimeFlow, the series is on Jellyfin (`/library`) and
|
||||
/// so is the episode ([confirmOnJellyfin]; [matchSchedule] alone sets
|
||||
/// it from the series).
|
||||
inLibrary,
|
||||
/// Not tracked, the series is on Jellyfin, the episode aired but is not
|
||||
/// there yet.
|
||||
notOnJellyfin,
|
||||
/// Not tracked, the series is on Jellyfin, the episode aired; whether
|
||||
/// it is there is unknown (no linked account, lookup failed).
|
||||
aired;
|
||||
|
||||
static ScheduleStatus of(EpisodeStatus s) => switch (s) {
|
||||
EpisodeStatus.available => available,
|
||||
@@ -41,11 +48,14 @@ class ScheduleEntry {
|
||||
final String? reason;
|
||||
/// AnimeFlow tracks the series.
|
||||
final bool tracked;
|
||||
final bool followed;
|
||||
/// AnimeFlow's series id (follow, requests).
|
||||
/// AnimeFlow's series id (requests).
|
||||
final int? seriesId;
|
||||
/// The AnimeFlow season of the matched episode (requests).
|
||||
/// The AnimeFlow season of the matched episode (requests); untracked
|
||||
/// on Jellyfin: the TVDB season of the AniList entry.
|
||||
final int? season;
|
||||
/// Untracked on Jellyfin: the TVDB episode number in [season] (AniList
|
||||
/// episode + the `anilist_map` run's offset).
|
||||
final int? jellyfinEpisode;
|
||||
/// Play target once available.
|
||||
final String? jellyfinSeriesId;
|
||||
/// The series is on Jellyfin (tracked or not).
|
||||
@@ -57,12 +67,24 @@ class ScheduleEntry {
|
||||
this.progress,
|
||||
this.reason,
|
||||
this.tracked = false,
|
||||
this.followed = false,
|
||||
this.seriesId,
|
||||
this.season,
|
||||
this.jellyfinEpisode,
|
||||
this.jellyfinSeriesId,
|
||||
});
|
||||
|
||||
ScheduleEntry withStatus(ScheduleStatus s) => ScheduleEntry(
|
||||
airing: airing,
|
||||
status: s,
|
||||
progress: progress,
|
||||
reason: reason,
|
||||
tracked: tracked,
|
||||
seriesId: seriesId,
|
||||
season: season,
|
||||
jellyfinEpisode: jellyfinEpisode,
|
||||
jellyfinSeriesId: jellyfinSeriesId,
|
||||
);
|
||||
|
||||
@override
|
||||
String toString() => 'ScheduleEntry(${airing.anilistId}#${airing.episode} $status)';
|
||||
}
|
||||
@@ -81,8 +103,7 @@ String _day(DateTime d) {
|
||||
class _SeriesRef {
|
||||
final int seriesId;
|
||||
final String? jellyfinSeriesId;
|
||||
bool followed;
|
||||
_SeriesRef(this.seriesId, this.jellyfinSeriesId, this.followed);
|
||||
_SeriesRef(this.seriesId, this.jellyfinSeriesId);
|
||||
}
|
||||
|
||||
/// Merges AniList [airings] with AnimeFlow's [schedule] rows and tracked
|
||||
@@ -98,30 +119,40 @@ List<ScheduleEntry> matchSchedule(
|
||||
DateTime? now,
|
||||
}) {
|
||||
final at = now ?? DateTime.now();
|
||||
// AniList id -> (Jellyfin series, its TVDB season) from /library.
|
||||
final lib = <int, (String, int)>{};
|
||||
// AniList id -> (Jellyfin series, its TVDB season, TVDB - AniList
|
||||
// episode offset) from /library.
|
||||
final lib = <int, (String, int, int)>{};
|
||||
for (final item in library) {
|
||||
if (item.jellyfinSeriesId.isEmpty) continue;
|
||||
for (final r in item.anilistMap) {
|
||||
lib[r.anilistId] ??= (item.jellyfinSeriesId, r.season, r.offset);
|
||||
}
|
||||
for (final e in item.anilistIds.entries) {
|
||||
lib[e.value] ??= (item.jellyfinSeriesId, e.key);
|
||||
lib[e.value] ??= (item.jellyfinSeriesId, e.key, 0);
|
||||
}
|
||||
}
|
||||
final refs = <int, _SeriesRef>{};
|
||||
for (final s in series) {
|
||||
final id = s.anilistId;
|
||||
if (id != null) refs[id] ??= _SeriesRef(s.seriesId, s.jellyfinSeriesId, s.followed);
|
||||
if (id != null) refs[id] ??= _SeriesRef(s.seriesId, s.jellyfinSeriesId);
|
||||
}
|
||||
// AnimeFlow's AniList episode number wins outright: TVDB numbering runs
|
||||
// on across split entries (JoJo S6E4 is "2nd & 3rd STAGE" ep 3), so a TVDB
|
||||
// or absolute number, or an air day, is only a guess for AniList entries
|
||||
// AnimeFlow gives no AniList numbers for.
|
||||
final byAni = <String, AnimeFlowScheduleItem>{};
|
||||
final byEp = <String, AnimeFlowScheduleItem>{};
|
||||
final byAbs = <String, AnimeFlowScheduleItem>{};
|
||||
final byDay = <String, AnimeFlowScheduleItem>{};
|
||||
final numbered = <int>{};
|
||||
for (final it in schedule) {
|
||||
final e = it.episode, id = e.anilistId;
|
||||
if (id == null) continue;
|
||||
final ref = refs[id] ??= _SeriesRef(it.seriesId, it.jellyfinSeriesId, it.followed);
|
||||
if (it.followed) ref.followed = true;
|
||||
// AnimeFlow's AniList episode number first: TVDB numbering can run on
|
||||
// across split cours.
|
||||
if (e.anilistEpisode != null) byEp.putIfAbsent('$id#${e.anilistEpisode}', () => it);
|
||||
refs[id] ??= _SeriesRef(it.seriesId, it.jellyfinSeriesId);
|
||||
if (e.anilistEpisode != null) {
|
||||
numbered.add(id);
|
||||
byAni.putIfAbsent('$id#${e.anilistEpisode}', () => it);
|
||||
}
|
||||
byEp.putIfAbsent('$id#${e.episode}', () => it);
|
||||
if (e.absolute != null) byAbs.putIfAbsent('$id#${e.absolute}', () => it);
|
||||
if (e.airDate != null) byDay.putIfAbsent('$id@${_day(e.airDate!)}', () => it);
|
||||
@@ -131,7 +162,9 @@ List<ScheduleEntry> matchSchedule(
|
||||
() {
|
||||
final id = a.anilistId;
|
||||
final ref = refs[id];
|
||||
final it = byEp['$id#${a.episode}'] ?? byAbs['$id#${a.episode}'] ?? byDay['$id@${_day(a.airingAt)}'];
|
||||
final it = numbered.contains(id)
|
||||
? byAni['$id#${a.episode}']
|
||||
: byEp['$id#${a.episode}'] ?? byAbs['$id#${a.episode}'] ?? byDay['$id@${_day(a.airingAt)}'];
|
||||
if (it != null) {
|
||||
final e = it.episode;
|
||||
return ScheduleEntry(
|
||||
@@ -140,7 +173,6 @@ List<ScheduleEntry> matchSchedule(
|
||||
progress: e.status == EpisodeStatus.downloading ? e.progress : null,
|
||||
reason: e.status == EpisodeStatus.failed ? e.reason : null,
|
||||
tracked: true,
|
||||
followed: it.followed || (ref?.followed ?? false),
|
||||
seriesId: it.seriesId,
|
||||
season: e.season,
|
||||
jellyfinSeriesId: it.jellyfinSeriesId ?? ref?.jellyfinSeriesId ?? lib[id]?.$1,
|
||||
@@ -153,6 +185,7 @@ List<ScheduleEntry> matchSchedule(
|
||||
airing: a,
|
||||
status: a.airingAt.isAfter(at) ? ScheduleStatus.upcoming : ScheduleStatus.inLibrary,
|
||||
season: onJf.$2,
|
||||
jellyfinEpisode: a.episode + onJf.$3,
|
||||
jellyfinSeriesId: onJf.$1,
|
||||
);
|
||||
}
|
||||
@@ -160,7 +193,6 @@ List<ScheduleEntry> matchSchedule(
|
||||
airing: a,
|
||||
status: a.airingAt.isAfter(at) ? ScheduleStatus.upcoming : ScheduleStatus.missing,
|
||||
tracked: true,
|
||||
followed: ref.followed,
|
||||
seriesId: ref.seriesId,
|
||||
jellyfinSeriesId: ref.jellyfinSeriesId ?? lib[id]?.$1,
|
||||
);
|
||||
@@ -168,6 +200,27 @@ List<ScheduleEntry> matchSchedule(
|
||||
];
|
||||
}
|
||||
|
||||
/// Checks [matchSchedule]'s [ScheduleStatus.inLibrary] entries (series
|
||||
/// on Jellyfin) against the episodes Jellyfin has: [episodes] maps a
|
||||
/// Jellyfin series id to its (season, episode) numbers. Found stays
|
||||
/// inLibrary, absent becomes [ScheduleStatus.notOnJellyfin], a series
|
||||
/// missing from [episodes] (not looked up) [ScheduleStatus.aired].
|
||||
List<ScheduleEntry> confirmOnJellyfin(List<ScheduleEntry> entries, Map<String, Set<(int, int)>> episodes) => [
|
||||
for (final e in entries)
|
||||
if (e.status != ScheduleStatus.inLibrary)
|
||||
e
|
||||
else
|
||||
switch (episodes[e.jellyfinSeriesId]) {
|
||||
null => e.withStatus(ScheduleStatus.aired),
|
||||
final have when have.contains((e.season, e.jellyfinEpisode)) => e,
|
||||
_ => e.withStatus(ScheduleStatus.notOnJellyfin),
|
||||
},
|
||||
];
|
||||
|
||||
/// The (season, episode) numbers Jellyfin has of a series; null when it
|
||||
/// cannot tell (no linked account for that server).
|
||||
typedef JellyfinEpisodeLookup = Future<Set<(int, int)>?> Function(String jellyfinSeriesId);
|
||||
|
||||
/// One loaded window. Errors are display text; [entries] is empty only
|
||||
/// when AniList failed with nothing cached.
|
||||
class ScheduleSnapshot {
|
||||
@@ -203,12 +256,15 @@ class ScheduleSnapshot {
|
||||
|
||||
/// Loads AniList and AnimeFlow for a window and merges them.
|
||||
class ScheduleRepository {
|
||||
ScheduleRepository({required this.aniList, this.animeFlow, DateTime Function()? now})
|
||||
ScheduleRepository({required this.aniList, this.animeFlow, this.jellyfinEpisodes, DateTime Function()? now})
|
||||
: _now = now ?? DateTime.now;
|
||||
|
||||
final AniListClient aniList;
|
||||
/// Null = not configured (AniList only).
|
||||
final AnimeFlowClient? animeFlow;
|
||||
/// Confirms "on Jellyfin" per episode ([confirmOnJellyfin]); null =
|
||||
/// never confirmed (aired entries read [ScheduleStatus.aired]).
|
||||
final JellyfinEpisodeLookup? jellyfinEpisodes;
|
||||
final DateTime Function() _now;
|
||||
|
||||
/// Last week's Monday 00:00 local .. 4 weeks later.
|
||||
@@ -233,7 +289,8 @@ class ScheduleRepository {
|
||||
final flow = await flowF;
|
||||
final airings = [...ani.airings]..sort((a, b) => a.airingAt.compareTo(b.airingAt));
|
||||
final entries = flow.error == null
|
||||
? matchSchedule(airings, schedule: flow.schedule, series: flow.series, library: flow.library, now: now)
|
||||
? await _confirm(
|
||||
matchSchedule(airings, schedule: flow.schedule, series: flow.series, library: flow.library, now: now))
|
||||
: [for (final a in airings) untrackedEntry(a, now)];
|
||||
return ScheduleSnapshot(
|
||||
from: start,
|
||||
@@ -246,6 +303,26 @@ class ScheduleRepository {
|
||||
);
|
||||
}
|
||||
|
||||
/// Looks up the episodes of every series an inLibrary entry is on, in
|
||||
/// parallel; a failed or impossible lookup leaves that series out.
|
||||
Future<List<ScheduleEntry>> _confirm(List<ScheduleEntry> entries) async {
|
||||
final lookup = jellyfinEpisodes;
|
||||
final ids = {
|
||||
for (final e in entries)
|
||||
if (e.status == ScheduleStatus.inLibrary && e.jellyfinSeriesId != null) e.jellyfinSeriesId!,
|
||||
};
|
||||
final found = <String, Set<(int, int)>>{};
|
||||
if (lookup != null) {
|
||||
await Future.wait([
|
||||
for (final id in ids)
|
||||
lookup(id).then((eps) {
|
||||
if (eps != null) found[id] = eps;
|
||||
}, onError: (_) {}),
|
||||
]);
|
||||
}
|
||||
return confirmOnJellyfin(entries, found);
|
||||
}
|
||||
|
||||
Future<({AnimeFlowMe? me, List<AnimeFlowScheduleItem> schedule, List<AnimeFlowSeries> series, List<AnimeFlowLibraryItem> library, Object? error})>
|
||||
_animeFlow(DateTime from, DateTime to, bool force) async {
|
||||
final c = animeFlow;
|
||||
|
||||
@@ -78,7 +78,7 @@ Future<({int season, int episode})> flowNumbering(AnimeFlowClient? client, Sched
|
||||
}
|
||||
}
|
||||
if (ep != null) return (season: ep.season, episode: ep.episode);
|
||||
return (season: e.season ?? 1, episode: e.airing.episode);
|
||||
return (season: e.season ?? 1, episode: e.jellyfinEpisode ?? e.airing.episode);
|
||||
}
|
||||
|
||||
/// The AniDB id of an untracked AniList entry, from AnimeFlow's search:
|
||||
@@ -112,6 +112,25 @@ JellyfinAccount? jellyfinAccountFor(JellyfinAccounts accounts, AnimeFlowClient?
|
||||
return accounts.defaultAccount;
|
||||
}
|
||||
|
||||
/// The (season, episode) numbers of the episodes Jellyfin has of
|
||||
/// [jellyfinSeriesId], seen by the account AnimeFlow's ids belong to
|
||||
/// ([jellyfinAccountFor]); null without a linked account. One request per
|
||||
/// series, memoised a few minutes ([JellyfinAccounts.cached]).
|
||||
Future<Set<(int, int)>?> jellyfinEpisodeNumbers(
|
||||
JellyfinAccounts accounts,
|
||||
AnimeFlowClient? flow,
|
||||
String jellyfinSeriesId,
|
||||
) async {
|
||||
await accounts.ensureLoaded(validate: false);
|
||||
final account = jellyfinAccountFor(accounts, flow);
|
||||
final client = account == null ? null : accounts.clientFor(account.serverId);
|
||||
if (account == null || client == null) return null;
|
||||
return accounts.cached(account.serverId, 'schedule-episodes:$jellyfinSeriesId', () async => {
|
||||
for (final x in await client.episodes(jellyfinSeriesId))
|
||||
if (x.parentIndexNumber != null && x.indexNumber != null) (x.parentIndexNumber!, x.indexNumber!),
|
||||
});
|
||||
}
|
||||
|
||||
/// The Jellyfin episode of an available [e] as a media pick. Throws
|
||||
/// [ScheduleActionException] (or a client error) when it cannot be found.
|
||||
Future<JellyfinMedia> resolveJellyfinEpisode(JellyfinAccounts accounts, AnimeFlowClient? flow, ScheduleEntry e) async {
|
||||
|
||||
@@ -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,
|
||||
@@ -49,6 +49,8 @@ String countdownText(DateTime at, DateTime now) {
|
||||
ScheduleStatus.unmonitored => ('not monitored', Colors.grey, Icons.visibility_off_outlined),
|
||||
ScheduleStatus.notTracked => ('not in AnimeFlow', Colors.grey, Icons.public),
|
||||
ScheduleStatus.inLibrary => ('on Jellyfin', Colors.greenAccent, Icons.video_library_outlined),
|
||||
ScheduleStatus.notOnJellyfin => ('aired · not on Jellyfin yet', Colors.grey, Icons.hourglass_empty),
|
||||
ScheduleStatus.aired => ('aired', Colors.grey, Icons.done),
|
||||
};
|
||||
|
||||
class DayColumn extends StatelessWidget {
|
||||
@@ -125,7 +127,6 @@ class AiringCard extends StatelessWidget {
|
||||
final ScheduleEntry entry;
|
||||
final DateTime now;
|
||||
final bool compact;
|
||||
final bool followed;
|
||||
final CardAction action;
|
||||
|
||||
/// Why [action] is disabled (no request scope, not on Jellyfin, ...);
|
||||
@@ -136,20 +137,15 @@ class AiringCard extends StatelessWidget {
|
||||
final bool busy;
|
||||
final VoidCallback onAction;
|
||||
|
||||
/// Null hides the bookmark (untracked series).
|
||||
final VoidCallback? onFollow;
|
||||
|
||||
const AiringCard({
|
||||
super.key,
|
||||
required this.entry,
|
||||
required this.now,
|
||||
required this.compact,
|
||||
required this.followed,
|
||||
required this.action,
|
||||
required this.onAction,
|
||||
this.disabledHint,
|
||||
this.busy = false,
|
||||
this.onFollow,
|
||||
});
|
||||
|
||||
String get _id => '${entry.airing.anilistId}-${entry.airing.episode}';
|
||||
@@ -162,15 +158,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,
|
||||
@@ -184,22 +175,6 @@ class AiringCard extends StatelessWidget {
|
||||
return disabledHint == null ? b : Tooltip(message: disabledHint!, child: b);
|
||||
}
|
||||
|
||||
Widget? _follow(BuildContext context, double size) {
|
||||
final f = onFollow;
|
||||
if (f == null) return null;
|
||||
return IconButton(
|
||||
key: ValueKey('follow-$_id'),
|
||||
onPressed: f,
|
||||
tooltip: followed ? 'Stop following' : 'Follow',
|
||||
visualDensity: VisualDensity.compact,
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: BoxConstraints.tightFor(width: size + 10, height: size + 10),
|
||||
iconSize: size,
|
||||
color: followed ? Theme.of(context).colorScheme.primary : null,
|
||||
icon: Icon(followed ? Icons.bookmark : Icons.bookmark_outline),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
@@ -216,7 +191,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(
|
||||
@@ -249,7 +224,6 @@ class AiringCard extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
),
|
||||
if (followed && onFollow == null) Icon(Icons.bookmark, size: 14, color: scheme.primary),
|
||||
if (requestable)
|
||||
Tooltip(
|
||||
message: disabledHint ?? (action == CardAction.request ? 'Request' : 'Request series'),
|
||||
@@ -273,7 +247,6 @@ class AiringCard extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
final button = _button(context);
|
||||
final follow = _follow(context, 16);
|
||||
// The cover poster leads: it is what people recognise at a glance.
|
||||
return Material(
|
||||
key: ValueKey('card-$_id'),
|
||||
@@ -343,25 +316,11 @@ class AiringCard extends StatelessWidget {
|
||||
style: const TextStyle(fontWeight: FontWeight.w600, height: 1.25),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Row(
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
'Episode ${a.episode}',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 2),
|
||||
if (follow != null)
|
||||
follow
|
||||
else if (followed)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 4),
|
||||
child: Icon(Icons.bookmark, size: 14, color: scheme.primary),
|
||||
),
|
||||
],
|
||||
Text(
|
||||
'Episode ${a.episode}',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12),
|
||||
),
|
||||
const Spacer(),
|
||||
Row(
|
||||
|
||||
@@ -18,11 +18,12 @@ import 'schedule_card.dart';
|
||||
// one thing to do about it (put it up, request it). Refreshes every
|
||||
// minute while visible; ETags keep that cheap.
|
||||
|
||||
enum ScheduleFilter { jellyfin, following, all }
|
||||
enum ScheduleFilter { jellyfin, 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,
|
||||
@@ -74,8 +75,6 @@ class _SchedulePageState extends State<SchedulePage> {
|
||||
/// The sheet stays built while it slides out.
|
||||
bool _sheetBuilt = false;
|
||||
|
||||
/// Follow toggles not yet confirmed by a reload (series id -> followed).
|
||||
final _followOverride = <int, bool>{};
|
||||
|
||||
/// Requested in this run: "anilistId#episode" or "series:anilistId".
|
||||
final _requested = <String>{};
|
||||
@@ -96,18 +95,22 @@ class _SchedulePageState extends State<SchedulePage> {
|
||||
void initState() {
|
||||
super.initState();
|
||||
_ownsRepo = widget.repository == null;
|
||||
_repo =
|
||||
widget.repository ??
|
||||
ScheduleRepository(
|
||||
aniList: AniListClient(),
|
||||
animeFlow: AnimeFlowClient.fromAccounts(JellyfinAccounts.instance),
|
||||
);
|
||||
_repo = widget.repository ?? _ownRepo();
|
||||
_load();
|
||||
_timer = Timer.periodic(SchedulePage.refreshEvery, (_) {
|
||||
if (mounted && !_refreshing && TickerMode.valuesOf(context).enabled) _load();
|
||||
});
|
||||
}
|
||||
|
||||
ScheduleRepository _ownRepo() {
|
||||
final flow = AnimeFlowClient.fromAccounts(_accounts);
|
||||
return ScheduleRepository(
|
||||
aniList: AniListClient(),
|
||||
animeFlow: flow,
|
||||
jellyfinEpisodes: (id) => jellyfinEpisodeNumbers(_accounts, flow, id),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_timer?.cancel();
|
||||
@@ -185,11 +188,8 @@ class _SchedulePageState extends State<SchedulePage> {
|
||||
animate ? _scroll.animateTo(x, duration: easeTime, curve: ease) : _scroll.jumpTo(x);
|
||||
}
|
||||
|
||||
bool _followed(ScheduleEntry e) => (e.seriesId == null ? null : _followOverride[e.seriesId]) ?? e.followed;
|
||||
|
||||
bool _shows(ScheduleEntry e) => switch (_filter) {
|
||||
ScheduleFilter.jellyfin => e.tracked || e.onJellyfin,
|
||||
ScheduleFilter.following => _followed(e),
|
||||
ScheduleFilter.all => _niche || e.tracked || e.airing.popularity >= SchedulePage.nicheBelow,
|
||||
};
|
||||
|
||||
@@ -215,9 +215,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 +242,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:
|
||||
@@ -335,21 +336,6 @@ class _SchedulePageState extends State<SchedulePage> {
|
||||
},
|
||||
);
|
||||
|
||||
Future<void> _toggleFollow(ScheduleEntry e) async {
|
||||
final c = _flow, id = e.seriesId;
|
||||
if (c == null || id == null) return;
|
||||
final next = !_followed(e);
|
||||
setState(() => _followOverride[id] = next);
|
||||
try {
|
||||
await c.setFollowed(id, next);
|
||||
await _load();
|
||||
} catch (err) {
|
||||
_toast(friendlyError(err));
|
||||
} finally {
|
||||
if (mounted) setState(() => _followOverride.remove(id));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _decide(AnimeFlowRequest r, {required bool approve}) async {
|
||||
final c = _flow;
|
||||
if (c == null || _reqBusy.contains(r.id)) return;
|
||||
@@ -387,11 +373,6 @@ class _SchedulePageState extends State<SchedulePage> {
|
||||
icon: Icon(Icons.video_library_outlined),
|
||||
label: Text('On Jellyfin'),
|
||||
),
|
||||
ButtonSegment(
|
||||
value: ScheduleFilter.following,
|
||||
icon: Icon(Icons.bookmark_outline),
|
||||
label: Text('Following'),
|
||||
),
|
||||
ButtonSegment(value: ScheduleFilter.all, icon: Icon(Icons.public), label: Text('All airing')),
|
||||
],
|
||||
selected: {_filter},
|
||||
@@ -530,17 +511,15 @@ 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,
|
||||
compact: _compact,
|
||||
followed: _followed(e),
|
||||
action: action,
|
||||
disabledHint: hint,
|
||||
busy: busy,
|
||||
onAction: () => _onAction(e, action),
|
||||
onFollow: e.tracked && e.seriesId != null && _flow != null ? () => _toggleFollow(e) : null,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
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 {
|
||||
await _session.leave();
|
||||
await _session.stopServer();
|
||||
if (fromSolo) {
|
||||
await _session.cancelInvite();
|
||||
} else {
|
||||
await _session.leave();
|
||||
await _session.stopServer();
|
||||
}
|
||||
} catch (_) {}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -16,7 +16,10 @@ enum LinkRelay { direct, server, me }
|
||||
final class LinkMedia extends PickedMedia {
|
||||
final String url;
|
||||
final LinkRelay relay;
|
||||
const LinkMedia(this.url, {this.relay = LinkRelay.direct});
|
||||
/// Put it up even though this computer's probe found a problem
|
||||
/// (`LinkProbe.problem`): the server or a friend may still open it.
|
||||
final bool anyway;
|
||||
const LinkMedia(this.url, {this.relay = LinkRelay.direct, this.anyway = false});
|
||||
|
||||
@override
|
||||
String get label {
|
||||
|
||||
@@ -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,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';
|
||||
|
||||
|
||||
@@ -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,8 +181,11 @@ 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);
|
||||
/// Why this player cannot open the current media, in plain words
|
||||
/// ([playErrorText]); empty while it can. It rides in `report.error`, so
|
||||
/// the server stops waiting for this member and the others see it.
|
||||
final playerError = signal('');
|
||||
|
||||
/// The current media's Jellyfin ref (null for links and files).
|
||||
final currentRef = signal<JellyfinRef?>(null);
|
||||
@@ -219,14 +230,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 +255,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 +284,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;
|
||||
@@ -291,10 +322,17 @@ class SessionCoordinator {
|
||||
int _advanceDoneFor = -1;
|
||||
String _blockedKey = '';
|
||||
bool _jellyfinListening = false;
|
||||
// The mediaId [playerError] belongs to, and the player's duration when
|
||||
// it came: a new duration means the media opened after all.
|
||||
int _playerErrorFor = -1;
|
||||
Duration _playerErrorDuration = Duration.zero;
|
||||
// Members' last `error`, by id, so a new one toasts once.
|
||||
Map<String, String> _memberErrors = const {};
|
||||
|
||||
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 +342,8 @@ class SessionCoordinator {
|
||||
if (_playerReady) return;
|
||||
try {
|
||||
playback.onFrozen = () => unawaited(reloadVideo(auto: true));
|
||||
playback.onYtdlError = _setPlayerError;
|
||||
playback.onLoadError = _setPlayerError;
|
||||
playback.onUserSeek = (d) => _userSeek(d.inMicroseconds / 1e6);
|
||||
await playback.init();
|
||||
_playerReady = true;
|
||||
@@ -315,6 +355,35 @@ class SessionCoordinator {
|
||||
}
|
||||
}
|
||||
|
||||
void _setPlayerError(String raw) {
|
||||
debugPrint('player cannot open media $_mediaId: $raw');
|
||||
_playerErrorFor = _mediaId;
|
||||
_playerErrorDuration = playback.duration.peek();
|
||||
playerError.value = playErrorText(raw);
|
||||
}
|
||||
|
||||
/// Clears [playerError] once the media opened (a new duration) or the
|
||||
/// server moved to another media.
|
||||
void _checkPlayerError(int mediaId) {
|
||||
if (playerError.peek().isEmpty) return;
|
||||
final d = playback.duration.peek();
|
||||
if (_playerErrorFor != mediaId || (d > Duration.zero && d != _playerErrorDuration)) {
|
||||
playerError.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
/// A member's new `error` toasts once (the pill is hidden while playing).
|
||||
void _toastMemberErrors(StateBroadcast r) {
|
||||
final now = {for (final m in r.members) if (m.error.isNotEmpty) m.id: m.error};
|
||||
for (final m in r.members) {
|
||||
final e = now[m.id];
|
||||
if (e != null && m.id != sync.memberId && _memberErrors[m.id] != e) {
|
||||
_pushToast("${m.name} can't play this: $e", actor: m.name);
|
||||
}
|
||||
}
|
||||
_memberErrors = now;
|
||||
}
|
||||
|
||||
bool _reloadingVideo = false;
|
||||
|
||||
/// "Reload video" (button, Ctrl+R, frozen-picture watchdog): fresh
|
||||
@@ -363,14 +432,17 @@ class SessionCoordinator {
|
||||
if (epoch != _epoch) throw const ConnectCancelled();
|
||||
_params = params;
|
||||
_manualClose = false;
|
||||
kickReason.value = null;
|
||||
_wsHost = params.host;
|
||||
_wsPort = params.port;
|
||||
diff.reset();
|
||||
_mediaId = -1;
|
||||
_openUrl = null;
|
||||
playerError.value = '';
|
||||
_memberErrors = const {};
|
||||
_lastReady = false;
|
||||
_restoringReady = false;
|
||||
_lastOut = false;
|
||||
_joinBackAt = null;
|
||||
_seenMediaId = -1;
|
||||
_wasPlayAllowed = false;
|
||||
_everConnected = false;
|
||||
@@ -379,6 +451,7 @@ class SessionCoordinator {
|
||||
_seekGate.reset();
|
||||
_clearPendingSeek();
|
||||
_lastEventId = -1;
|
||||
_lastPick = null;
|
||||
_resetJellyfin();
|
||||
_jfToastShown = false;
|
||||
if (!_jellyfinListening) {
|
||||
@@ -434,12 +507,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,22 +529,44 @@ class SessionCoordinator {
|
||||
diff.reset();
|
||||
_opening = true;
|
||||
try {
|
||||
await _prepareYtDlp(url);
|
||||
await playback.openUrl(url, headers: headers);
|
||||
health.value =
|
||||
const HealthStatus(HealthSeverity.ok, 'connected');
|
||||
return true;
|
||||
} catch (e) {
|
||||
debugPrint('open media failed: $e');
|
||||
health.value =
|
||||
HealthStatus(HealthSeverity.error, 'cannot open media: $e');
|
||||
_setPlayerError('$e');
|
||||
return false;
|
||||
} finally {
|
||||
_opening = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 +588,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;
|
||||
@@ -556,6 +654,8 @@ class SessionCoordinator {
|
||||
void _onState(StateBroadcast r) {
|
||||
_lastStateAt = DateTime.now().toUtc();
|
||||
serverState.value = r;
|
||||
_toastMemberErrors(r);
|
||||
_checkPlayerError(r.mediaId);
|
||||
_maybeSendArt(r);
|
||||
_queuedState = r;
|
||||
if (_applying) return;
|
||||
@@ -579,6 +679,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 +720,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 +751,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 +812,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 +828,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 +867,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 +904,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 +928,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 +935,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 +957,25 @@ 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;
|
||||
_checkPlayerError(r.mediaId);
|
||||
final error = playerError.peek();
|
||||
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),
|
||||
error: known && _playerErrorFor == r.mediaId ? error : '',
|
||||
);
|
||||
_checkSeekLanded(r);
|
||||
unawaited(_tickJellyfin(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,9 +996,13 @@ 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,
|
||||
playing: serverState.peek()?.transport == 'playing',
|
||||
selfReady: selfMember.peek()?.ready ?? false);
|
||||
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) {
|
||||
case IdleEvent.warn:
|
||||
_toastId++;
|
||||
@@ -867,18 +1010,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;
|
||||
sync.sendReady(false, source: 'auto');
|
||||
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 +1116,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 +1207,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 +1265,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);
|
||||
@@ -1077,46 +1289,91 @@ class SessionCoordinator {
|
||||
/// A pasted link: a recognised Jellyfin item link goes up as a ref
|
||||
/// (see [mediaForLink]) that this member relays for viewers without an
|
||||
/// account (with the link's key, which stays here); anything else as the
|
||||
/// link, or relayed per [relay]. Returns an error text, or null when sent.
|
||||
Future<String?> swapLink(String url, {LinkRelay relay = LinkRelay.direct}) async {
|
||||
/// link, or relayed per [relay]. A link this computer cannot open (its
|
||||
/// probe's [LinkProbe.problem], or unreachable) is refused with the
|
||||
/// reason in plain words unless [anyway]. Returns an error text, or null
|
||||
/// when sent.
|
||||
Future<String?> swapLink(String url, {LinkRelay relay = LinkRelay.direct, bool anyway = false}) async {
|
||||
markInput();
|
||||
_lastPick = LinkMedia(url, relay: relay, anyway: anyway);
|
||||
final media = await mediaForLink(jellyfin, url);
|
||||
if (media.jellyfin != null) {
|
||||
_pendingOrigin = _JellyfinOrigin(url);
|
||||
_swap(media.copyWith(relay: const MediaRelay()));
|
||||
return null;
|
||||
}
|
||||
LinkProbe? probe;
|
||||
String? probeError;
|
||||
try {
|
||||
probe = await ytDlp.probe(url).timeout(const Duration(seconds: 45));
|
||||
} catch (e) {
|
||||
probeError = '$e';
|
||||
}
|
||||
final problem = probe == null ? playErrorText(probeError ?? '') : probe.problem;
|
||||
if (problem != null && !anyway) return problem;
|
||||
// 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(media);
|
||||
_swap(HelloMedia(name: name, size: 0, duration: 0, url: url));
|
||||
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;
|
||||
try {
|
||||
size = await probe.size().timeout(const Duration(seconds: 15));
|
||||
} catch (e) {
|
||||
return relay == LinkRelay.me
|
||||
? 'cannot pass this link on: $e'
|
||||
: 'cannot check this link: $e';
|
||||
} finally {
|
||||
await probe.close();
|
||||
}
|
||||
if (relay == LinkRelay.me) {
|
||||
_pendingOrigin = _LinkOrigin(url);
|
||||
_swap(HelloMedia(name: media.name, size: size, duration: 0, relay: const MediaRelay()));
|
||||
} else {
|
||||
// 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 problem;
|
||||
final relayProblem = probe.relayProblem;
|
||||
if (relayProblem != null) return relayProblem;
|
||||
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 {
|
||||
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: media.name, size: size, duration: 0, url: url, relay: const MediaRelay(fetch: true)));
|
||||
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 +1383,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);
|
||||
@@ -1144,8 +1402,8 @@ class SessionCoordinator {
|
||||
/// Any picker selection.
|
||||
Future<String?> swapPicked(PickedMedia media) async {
|
||||
switch (media) {
|
||||
case LinkMedia(:final url, :final relay):
|
||||
return swapLink(url, relay: relay);
|
||||
case LinkMedia(:final url, :final relay, :final anyway):
|
||||
return swapLink(url, relay: relay, anyway: anyway);
|
||||
case JellyfinMedia():
|
||||
swapJellyfin(media);
|
||||
case FileMedia(:final path):
|
||||
@@ -1180,11 +1438,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 +1476,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 +1559,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 +1764,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 +1779,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 +1828,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 +1877,7 @@ class SessionCoordinator {
|
||||
}
|
||||
|
||||
void retry() {
|
||||
if (kickReason.peek() != null || _params == null) return;
|
||||
if (_params == null) return;
|
||||
_invalidateAttempts();
|
||||
_manualClose = false;
|
||||
reconnecting.value = false;
|
||||
@@ -1675,7 +1938,7 @@ class SessionCoordinator {
|
||||
}
|
||||
await _stateSub?.cancel();
|
||||
await _feedSub?.cancel();
|
||||
await _kickSub?.cancel();
|
||||
await _nudgeSub?.cancel();
|
||||
await _errSub?.cancel();
|
||||
await _statusSub?.cancel();
|
||||
_aniList?.close();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)'),
|
||||
};
|
||||
@@ -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,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 {
|
||||
|
||||
@@ -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;
|
||||
final out = coord.selfOut.value;
|
||||
if (wasOut && !out && session.route.peek() == AppRoute.session) {
|
||||
_later(() {
|
||||
if (_mode == PlayerMode.none) _show(PlayerMode.mini);
|
||||
});
|
||||
}
|
||||
untracked(() {
|
||||
if (session.route.value == AppRoute.session) session.route.value = AppRoute.home;
|
||||
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();
|
||||
}
|
||||
_show(PlayerMode.none);
|
||||
}
|
||||
|
||||
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, anyway: media.anyway);
|
||||
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,
|
||||
),
|
||||
|
||||
@@ -17,6 +17,11 @@ 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;
|
||||
/// Why their player cannot open the media (`members[].error`): an error
|
||||
/// mark; empty when it can.
|
||||
final String error;
|
||||
const AvatarInfo({
|
||||
this.id = '',
|
||||
required this.name,
|
||||
@@ -28,6 +33,8 @@ class AvatarInfo {
|
||||
this.stale = false,
|
||||
this.host = false,
|
||||
this.self = false,
|
||||
this.out = false,
|
||||
this.error = '',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -75,7 +82,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, String error = ''}) {
|
||||
final p = Profile.instance;
|
||||
final png = p.picture.value;
|
||||
final name = p.name.value;
|
||||
@@ -86,6 +93,8 @@ AvatarInfo selfAvatarInfo({bool ready = false, bool host = false}) {
|
||||
ready: ready,
|
||||
host: host,
|
||||
self: true,
|
||||
out: out,
|
||||
error: error,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -100,13 +109,15 @@ AvatarInfo memberAvatarInfo(MemberView m, StateBroadcast? r, SessionCoordinator
|
||||
color: self ? Profile.instance.color.value : colorForName(m.name),
|
||||
image: memberAvatarImage(m, coord),
|
||||
ready: m.ready,
|
||||
buffering: !m.stale && m.ready && playing && m.buffered < r!.bufferNeed,
|
||||
buffering: !m.stale && m.ready && m.error.isEmpty && playing && m.buffered < r!.bufferNeed,
|
||||
relaying: relay != null && relay.source.isNotEmpty && relay.source == m.id,
|
||||
stale: m.stale,
|
||||
// The state marks the host (`members[].host`); your own app also
|
||||
// knows before the first state.
|
||||
host: m.host || (self && coord.hostFile != null),
|
||||
self: self,
|
||||
out: m.out,
|
||||
error: m.error,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -132,7 +143,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, red "!" = cannot play the media.
|
||||
class StatusAvatar extends StatelessWidget {
|
||||
final AvatarInfo info;
|
||||
final double size;
|
||||
@@ -176,7 +188,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 +217,28 @@ 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 (info.error.isNotEmpty)
|
||||
Positioned(
|
||||
key: const ValueKey('error-mark'),
|
||||
right: -3,
|
||||
top: -3,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(1),
|
||||
decoration: BoxDecoration(shape: BoxShape.circle, color: bg),
|
||||
child: Icon(Icons.error, size: (badge * 0.8).clamp(10.0, 24.0), color: Colors.redAccent.shade200),
|
||||
),
|
||||
),
|
||||
if (badges && info.relaying)
|
||||
Positioned(
|
||||
right: -3,
|
||||
@@ -240,7 +274,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;
|
||||
@@ -270,7 +304,9 @@ class _AvatarStackState extends State<AvatarStack> {
|
||||
left: i * step,
|
||||
top: 0,
|
||||
child: Tooltip(
|
||||
message: visible[i].name,
|
||||
message: visible[i].error.isEmpty
|
||||
? visible[i].name
|
||||
: "${visible[i].name} can't play this: ${visible[i].error}",
|
||||
child: StatusAvatar(info: visible[i], size: size),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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, parseJellyfinLink, playErrorText;
|
||||
|
||||
import '../playback/yt_dlp_app.dart';
|
||||
import '../screens/home/media_picker.dart';
|
||||
import '../state/connect_prefs.dart';
|
||||
import 'motion.dart';
|
||||
@@ -106,52 +107,61 @@ class LinkPreview {
|
||||
/// "youtube.com · Channel", or the host.
|
||||
final String subtitle;
|
||||
final String? thumbnail;
|
||||
const LinkPreview({required this.title, required this.subtitle, this.thumbnail});
|
||||
/// Why this computer cannot open it, in plain words ([LinkProbe.problem]);
|
||||
/// null when it looks playable.
|
||||
final String? problem;
|
||||
const LinkPreview({required this.title, required this.subtitle, this.thumbnail, this.problem});
|
||||
}
|
||||
|
||||
/// 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; a media file its file name and host; a link
|
||||
/// that does not open, why ([LinkPreview.problem]). 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 Jellyfin item goes up as the item (see `mediaForLink`), not the page.
|
||||
if (parseJellyfinLink(url.trim()) != null) return LinkPreview(title: host, subtitle: '$host · Jellyfin');
|
||||
final file = hasMediaExtension(uri);
|
||||
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) {
|
||||
final problem = playErrorText('$e');
|
||||
return LinkPreview(title: LinkMedia(url.trim()).label, subtitle: '$host · $problem', problem: problem);
|
||||
}
|
||||
final info = p.info;
|
||||
if (!p.isPage) return LinkPreview(title: LinkMedia(url.trim()).label, subtitle: host);
|
||||
final problem = p.problem;
|
||||
if (info == null || problem != null) {
|
||||
// A media file name that did not answer as one keeps its name.
|
||||
final title = file ? LinkMedia(url.trim()).label : p.title ?? host;
|
||||
return LinkPreview(title: title, subtitle: '$host · ${problem ?? 'Not a video'}', problem: problem);
|
||||
}
|
||||
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 +198,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
|
||||
@@ -216,6 +230,8 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
|
||||
Timer? _debounce;
|
||||
bool _busy = false;
|
||||
String? _error;
|
||||
/// [_error] is the probe's problem: "Put it up anyway" is offered.
|
||||
bool _canForce = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -237,7 +253,12 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
|
||||
if (url == _previewUrl) return;
|
||||
_debounce?.cancel();
|
||||
_debounce = Timer(const Duration(milliseconds: 350), () => _preview(url));
|
||||
if (mounted) setState(() => _error = null);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_error = null;
|
||||
_canForce = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _preview(String url) async {
|
||||
@@ -246,7 +267,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(() {});
|
||||
}
|
||||
@@ -258,21 +279,29 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
Future<void> _go() async {
|
||||
Future<void> _go({bool anyway = false}) 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;
|
||||
_canForce = false;
|
||||
});
|
||||
String? error;
|
||||
try {
|
||||
error = await widget.onPutUp(LinkMedia(url, relay: _c.linkRelay));
|
||||
} catch (e) {
|
||||
error = '$e';
|
||||
// What the probe found refuses it here, with "Put it up anyway": the
|
||||
// server or a friend (with cookies) may still open it.
|
||||
final problem = anyway ? null : (_previews[url] ?? await previewLink(url, probe: widget.probe))?.problem;
|
||||
String? error = problem;
|
||||
if (problem == null) {
|
||||
try {
|
||||
error = await widget.onPutUp(
|
||||
LinkMedia(url, relay: widget.solo ? LinkRelay.direct : _c.linkRelay, anyway: anyway));
|
||||
} catch (e) {
|
||||
error = '$e';
|
||||
}
|
||||
}
|
||||
final canForce = problem != null;
|
||||
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,
|
||||
@@ -284,6 +313,7 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
|
||||
setState(() {
|
||||
_busy = false;
|
||||
_error = error;
|
||||
_canForce = canForce;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -291,7 +321,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 +341,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 +376,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,14 +390,22 @@ 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),
|
||||
),
|
||||
if (_canForce)
|
||||
Center(
|
||||
child: TextButton(
|
||||
key: const ValueKey('link-put-up-anyway'),
|
||||
onPressed: _busy ? null : () => _go(anyway: true),
|
||||
child: Text(solo ? 'Play it anyway' : 'Put it up anyway'),
|
||||
),
|
||||
),
|
||||
],
|
||||
AnimatedSize(
|
||||
duration: easeTime,
|
||||
@@ -379,7 +417,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),
|
||||
@@ -470,9 +508,9 @@ class _PreviewCard extends StatelessWidget {
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
p?.subtitle ?? 'Paste an http(s) address',
|
||||
maxLines: 1,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(color: Colors.grey),
|
||||
style: TextStyle(color: p?.problem != null ? scheme.error : Colors.grey),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,8 @@ class PartyStrip extends StatelessWidget {
|
||||
child: StatusAvatar(
|
||||
info: selfAvatarInfo(
|
||||
ready: self?.ready ?? false,
|
||||
host: online && coord.hostFile != null,
|
||||
host: online && !solo && coord.hostFile != null,
|
||||
error: online ? coord.playerError.value : '',
|
||||
),
|
||||
size: stripAvatarSize,
|
||||
ring: online,
|
||||
@@ -131,14 +136,36 @@ class PartyStrip extends StatelessWidget {
|
||||
),
|
||||
UpdateIndicator(controller: updates),
|
||||
const SizedBox(width: 8),
|
||||
if (online) ...[
|
||||
if (onShowPlayer != null)
|
||||
IconButton(
|
||||
key: const ValueKey('show-player'),
|
||||
tooltip: 'Show the player',
|
||||
onPressed: onShowPlayer,
|
||||
icon: const Icon(Icons.smart_display_outlined, size: 20),
|
||||
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,23 +205,35 @@ 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: 12),
|
||||
FilledButton.tonal(
|
||||
key: const ValueKey('strip-join'),
|
||||
onPressed: onJoin,
|
||||
child: const Text('Join'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
FilledButton(
|
||||
key: const ValueKey('strip-host'),
|
||||
onPressed: onHost,
|
||||
child: const Text('Host'),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text('Offline', style: TextStyle(color: scheme.onSurfaceVariant)),
|
||||
const SizedBox(width: 12),
|
||||
],
|
||||
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,
|
||||
icon: const Icon(Icons.login_rounded, size: 18),
|
||||
label: const Text('Join a friend'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
FilledButton.tonalIcon(
|
||||
key: const ValueKey('strip-host'),
|
||||
onPressed: onHost,
|
||||
icon: const Icon(Icons.podcasts_rounded, size: 18),
|
||||
label: const Text('Host'),
|
||||
),
|
||||
],
|
||||
],
|
||||
const SizedBox(width: 8),
|
||||
if (windowControls) ...[
|
||||
@@ -271,11 +310,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 +419,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 +552,10 @@ class _MemberTile extends StatelessWidget {
|
||||
final m = member;
|
||||
final status = m.stale
|
||||
? 'reconnecting…'
|
||||
: m.out
|
||||
? 'sitting out'
|
||||
: m.error.isNotEmpty
|
||||
? "can't play this: ${m.error}"
|
||||
: info.buffering
|
||||
? 'buffering ${m.buffered.toStringAsFixed(1)} s'
|
||||
: m.ready
|
||||
@@ -494,27 +581,30 @@ class _MemberTile extends StatelessWidget {
|
||||
trailing: PopupMenuButton<String>(
|
||||
tooltip: 'More',
|
||||
icon: const Icon(Icons.more_vert, size: 18),
|
||||
onSelected: (v) async {
|
||||
if (v == '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);
|
||||
onSelected: (v) {
|
||||
switch (v) {
|
||||
case 'force':
|
||||
coord.forceTarget(m.id, !m.ready);
|
||||
case 'nudge':
|
||||
coord.nudgeMember(m.id);
|
||||
case 'out':
|
||||
coord.sitOut(m.id);
|
||||
case 'back':
|
||||
coord.joinBack();
|
||||
}
|
||||
},
|
||||
itemBuilder: (_) => [
|
||||
PopupMenuItem(
|
||||
value: 'force',
|
||||
child: Text(m.ready ? 'Force unready' : 'Force ready'),
|
||||
),
|
||||
if (!info.self && !info.host)
|
||||
if (!m.out)
|
||||
PopupMenuItem(
|
||||
value: 'kick',
|
||||
child: Text(
|
||||
'Kick',
|
||||
style: TextStyle(color: Theme.of(context).colorScheme.error),
|
||||
),
|
||||
value: 'force',
|
||||
child: Text(m.ready ? 'Force unready' : 'Force ready'),
|
||||
),
|
||||
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')),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -11,11 +11,12 @@ 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, cannotPlay, waiting }
|
||||
|
||||
/// The pill's button: stop reconnecting, retry, or mark yourself ready
|
||||
/// (the whole pill).
|
||||
enum StatusAction { cancel, retry, ready }
|
||||
/// The pill's button: stop reconnecting, retry (reconnect), reload the
|
||||
/// video (your player could not open it), or mark yourself ready (the
|
||||
/// whole pill).
|
||||
enum StatusAction { cancel, retry, reload, ready }
|
||||
|
||||
/// One status line: [text], a muted [detail], a coloured [tone] for
|
||||
/// problems (warn, error), the members it is about ([who]; [self] for you)
|
||||
@@ -55,24 +56,24 @@ String _sentence(String s) => s.isEmpty ? s : s[0].toUpperCase() + s.substring(1
|
||||
|
||||
/// The player's status from the session's signals; null when there is
|
||||
/// nothing to say (playing, connected, everyone ready). [slowConnect]:
|
||||
/// connecting has taken over 2 s.
|
||||
/// connecting has taken over 2 s. [playerError]: why your player cannot
|
||||
/// open the media (the coordinator's `playerError`).
|
||||
StatusView? playerStatusFor({
|
||||
required HealthStatus health,
|
||||
bool reconnecting = false,
|
||||
String? kickReason,
|
||||
StateBroadcast? state,
|
||||
MemberView? self,
|
||||
bool slowConnect = false,
|
||||
String playerError = '',
|
||||
}) {
|
||||
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);
|
||||
}
|
||||
if (playerError.isNotEmpty && state?.media != null && !(self?.out ?? false)) {
|
||||
return StatusView(StatusKind.problem, "Can't play this: $playerError",
|
||||
tone: HealthSeverity.error, action: StatusAction.reload, short: "Can't play this");
|
||||
}
|
||||
if (health.severity == HealthSeverity.warn || health.severity == HealthSeverity.error) {
|
||||
return StatusView(StatusKind.problem, _sentence(health.message),
|
||||
tone: health.severity, action: StatusAction.retry);
|
||||
@@ -82,9 +83,17 @@ 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');
|
||||
}
|
||||
// Members whose player cannot open it: the server does not wait for
|
||||
// them; say why instead of "waiting for".
|
||||
final failed = [
|
||||
for (final m in r.members)
|
||||
if (m.error.isNotEmpty && !m.out && (self == null || m.id != self.id)) m,
|
||||
];
|
||||
// As the server holds playback: someone in the ready set short of the
|
||||
// minimum buffer.
|
||||
final need = r.bufferNeed;
|
||||
@@ -96,11 +105,22 @@ StatusView? playerStatusFor({
|
||||
who: [m], self: you, progress: p, detail: '${(p * 100).round()}%');
|
||||
}
|
||||
}
|
||||
if (failed.isNotEmpty) {
|
||||
final f = failed.first;
|
||||
final who = nameList([for (final m in failed) m.name]);
|
||||
final unready = self != null && !self.ready;
|
||||
return StatusView(StatusKind.cannotPlay, "$who can't play this: ${f.error}",
|
||||
who: failed,
|
||||
short: "$who can't play this",
|
||||
detail: unready ? "Press Space when you're ready" : null,
|
||||
action: unready ? StatusAction.ready : null);
|
||||
}
|
||||
if (self != null && !self.ready) {
|
||||
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.error.isEmpty) m];
|
||||
final others = [
|
||||
for (final m in active)
|
||||
if ((self == null || m.id != self.id) && !(m.inSet && m.ready)) m,
|
||||
@@ -117,10 +137,10 @@ 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,
|
||||
playerError: c.playerError.value,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -168,6 +188,8 @@ class _PlayerStatusState extends State<PlayerStatus> {
|
||||
coord.cancelReconnect();
|
||||
case StatusAction.retry:
|
||||
coord.retry();
|
||||
case StatusAction.reload:
|
||||
unawaited(coord.reloadVideo());
|
||||
case StatusAction.ready:
|
||||
coord.toggleReady();
|
||||
}
|
||||
@@ -258,7 +280,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,
|
||||
@@ -271,6 +292,7 @@ class _StatusLine extends StatelessWidget {
|
||||
return null;
|
||||
case StatusKind.loading:
|
||||
case StatusKind.you:
|
||||
case StatusKind.cannotPlay:
|
||||
case StatusKind.waiting:
|
||||
final r = coord.serverState.value;
|
||||
final infos = [
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -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(
|
||||
key: ValueKey('sound-on-${kind.name}'),
|
||||
value: !off.contains(kind.name),
|
||||
onChanged: volume == 0 ? null : (on) => s.setSoundOn(kind.name, on),
|
||||
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 || 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,
|
||||
),
|
||||
],
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -34,11 +34,13 @@ ShellToast shellToastFor(ToastItem t, AvatarInfo Function(String name) avatar) {
|
||||
? Icons.login
|
||||
: rest.startsWith('left')
|
||||
? Icons.logout
|
||||
: rest.startsWith('kicked')
|
||||
? Icons.gavel
|
||||
: rest.startsWith('changed')
|
||||
? Icons.swap_horiz
|
||||
: Icons.info_outline;
|
||||
: rest.startsWith('sat you out')
|
||||
? Icons.bedtime
|
||||
: rest.startsWith('nudged')
|
||||
? Icons.notifications_active_outlined
|
||||
: rest.startsWith('changed')
|
||||
? Icons.swap_horiz
|
||||
: Icons.info_outline;
|
||||
return ShellToast(id: t.id, who: avatar(n), text: rest, icon: icon);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
@@ -222,6 +219,7 @@ class SyncClient {
|
||||
double duration = 0,
|
||||
int mediaId = 0,
|
||||
Map<String, bool>? jf,
|
||||
String error = '',
|
||||
}) {
|
||||
final safeBuffered = buffered.isNaN || buffered < 0 ? 0.0 : buffered;
|
||||
_send(
|
||||
@@ -232,6 +230,7 @@ class SyncClient {
|
||||
duration: duration.isFinite && duration > 0 ? duration : 0,
|
||||
mediaId: mediaId,
|
||||
jf: jf,
|
||||
error: error,
|
||||
).toJson(),
|
||||
);
|
||||
}
|
||||
@@ -255,8 +254,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 +288,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 %*
|
||||
pause
|
||||
@echo off
|
||||
rem Update this install in place (same as clicking Update in the app).
|
||||
"%~dp0watch-together-server.exe" --upgrade %*
|
||||
pause
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,18 +5,18 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: android_file_picker
|
||||
sha256: "014c74ab48d452c3252465682375a7fe6ddf56abb908ec361f207a3c4ffb2444"
|
||||
sha256: "537bce9a43bc6fc08670cb68498251acc6eb9134b86a391b43e1f7c863588388"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.1"
|
||||
version: "2.0.1"
|
||||
archive:
|
||||
dependency: "direct main"
|
||||
dependency: transitive
|
||||
description:
|
||||
name: archive
|
||||
sha256: ace891da0862b0e4cabbb064ee3fd87b2728b898949fdb366d83fe98342c9f19
|
||||
sha256: "3aa20c6b5617b632a1b7198dcca3d88065d37232485b9a6c6c14dc8f5357d279"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.2.0"
|
||||
version: "4.4.0"
|
||||
args:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -61,10 +61,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: code_assets
|
||||
sha256: cfd4f5f575a49c5f10ca856e9846073f1e6c3ee94912377eea5f6cefc5272941
|
||||
sha256: "828110d598123b5ea96c00c9f3c72105bf79f8ee36c20a39b26209ade421ec57"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.0"
|
||||
version: "2.1.0"
|
||||
collection:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -97,14 +97,22 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.7"
|
||||
cupertino_ui:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: cupertino_ui
|
||||
sha256: ea0b8170b40a86262b2fc21ac0625303f52df209dbf6fccc2b98b27eff44f6ce
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.2"
|
||||
dbus:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: dbus
|
||||
sha256: a48d5da28e89bd02196e80d81ed8d7954923d00a0f4a68cc20b575038f023383
|
||||
sha256: f7db8504e715b835f5ebe5fe74604ccd961d504c73389a5d8e81bafc2e2f3612
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.15"
|
||||
version: "0.8.0"
|
||||
dio:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -130,7 +138,7 @@ packages:
|
||||
source: hosted
|
||||
version: "1.3.3"
|
||||
ffi:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: ffi
|
||||
sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45"
|
||||
@@ -149,42 +157,42 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: file_picker
|
||||
sha256: "9be6aac79508dbcf8dac80a4fb20f27ce6f910837ca27e3670b9295995cc0110"
|
||||
sha256: "98c0b156b6380ba55bc767a14e2e6b3feb246e713ad40092424596fa79005bea"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "12.3.0"
|
||||
version: "13.1.0"
|
||||
file_picker_darwin:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: file_picker_darwin
|
||||
sha256: "59fa5394cfa5b6dc8bf491630cb17077b77b844adbb840df371e7be5355d6cf6"
|
||||
sha256: "3df1dc8e881de9ffd3953c9e482ac5d6e1ffe1e7f7b231b5695543e251ab7918"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.0"
|
||||
version: "2.1.3"
|
||||
file_picker_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: file_picker_linux
|
||||
sha256: bd52ff1e0048f29df95f913c55ad2991c72791d93e6c42241912c4bdee946cb9
|
||||
sha256: c6562fc7d426db2fb85ea66fe2627953d2709932df5584ceaacf0aa5e39cf4da
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.0"
|
||||
version: "2.0.2"
|
||||
file_picker_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: file_picker_platform_interface
|
||||
sha256: "0355558fd9af6da499d18e333d6b3beb44b0bf19e00933fccd15d18eb8d0e9ca"
|
||||
sha256: bbdc085a6f168e63f147e9ce8988f79fa0800a58e7f25a48f762c60837107ba5
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.4.0"
|
||||
version: "4.0.0"
|
||||
file_picker_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: file_picker_web
|
||||
sha256: "935560a9d29fa6f006f2855addebb88e88d438e13627d4cc24187033c106f227"
|
||||
sha256: "32af8262aca907b02b8970b32328b0312db0b42ba6f930be6d27fde179d59b7d"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.0"
|
||||
version: "4.1.0"
|
||||
fixnum:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -210,10 +218,15 @@ packages:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: flutter_lints
|
||||
sha256: "5398f14efa795ffb7a33e9b6a08798b26a180edac4ad7db3f231e40f82ce11e1"
|
||||
sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.0.0"
|
||||
version: "6.0.0"
|
||||
flutter_localizations:
|
||||
dependency: transitive
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
flutter_test:
|
||||
dependency: "direct dev"
|
||||
description: flutter
|
||||
@@ -228,27 +241,27 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
path: "."
|
||||
ref: "v0.1.7"
|
||||
resolved-ref: dc16bba05491f54daf528ed83484ea9b6eab8825
|
||||
ref: "v0.1.9"
|
||||
resolved-ref: ef96868b87e55935792f392dd5881d49bc672ce5
|
||||
url: "https://git.senkochan.moe/Foxyz/fox_utils.git"
|
||||
source: git
|
||||
version: "0.1.7"
|
||||
version: "0.1.9"
|
||||
hive_ce:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: hive_ce
|
||||
sha256: "8e9980e68643afb1e765d3af32b47996552a64e190d03faf622cea07c1294418"
|
||||
sha256: "85d43209e2b4903a95d65d054407ea9ad4989d17b4d47e71732070d41b427c87"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.19.3"
|
||||
version: "2.20.2"
|
||||
hive_ce_flutter:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: hive_ce_flutter
|
||||
sha256: "2677e95a333ff15af43ccd06af7eb7abbf1a4f154ea071997f3de4346cae913a"
|
||||
sha256: "231c5f025a44f4a555b4e7417d1468027cc32d9471b00543bd5bce72464a1009"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.3.4"
|
||||
version: "2.4.0"
|
||||
hooks:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -277,10 +290,18 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: image
|
||||
sha256: "1976370a4df3091bb0f72409c187ad1f9132a818bc6b95ca59c0bae1c75c688e"
|
||||
sha256: "0be525eb917b032c7c9c980597c508a255814a858bd8d9abfc9c57e04f3ebf89"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.9.2"
|
||||
version: "4.10.2"
|
||||
intl:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: intl
|
||||
sha256: "1ca20c894b1717686a2319b8548763d812bc0aabdac580420a44c5178c57a867"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.20.3"
|
||||
isolate_channel:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -293,18 +314,18 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: jni
|
||||
sha256: f038e58b4dc2c9037f50e233175086337e0b305e356d28211bf55f21c504cbd3
|
||||
sha256: "6b9ca0602fef0230e83149afbe3f9f8227e94f2bbe84a1da4f14d699742f402d"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.3"
|
||||
version: "1.1.0"
|
||||
jni_flutter:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: jni_flutter
|
||||
sha256: b2310cdd4c18c65c081ab141a41efa94aa26c65431803703ece51996f174f351
|
||||
sha256: "0dd33d67797560f66e961a6053882d4df54bd345bf063b8fe7ea133f7ad8b191"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.3"
|
||||
version: "1.0.4+1"
|
||||
jni_util:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -349,10 +370,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: lints
|
||||
sha256: c35bb79562d980e9a453fc715854e1ed39e24e7d0297a880ef54e17f9874a9d7
|
||||
sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.1.1"
|
||||
version: "6.1.0"
|
||||
logging:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -377,6 +398,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.13.0"
|
||||
material_ui:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: material_ui
|
||||
sha256: "98ab9eda579ecae623e4b376c603035c5def78ce706f735f30bd632af5814695"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.6.0"
|
||||
media_kit:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -461,10 +490,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: objective_c
|
||||
sha256: ad56fd53a78ff6b1472fa59ff2a4e8b8ccabafc586fc263a1dfad0b99b5553e3
|
||||
sha256: "5c80c2ad6e2c0397de5db38c6d653e69616a3397e13aa7633bf39eb668f2041b"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "9.6.0"
|
||||
version: "9.6.2"
|
||||
package_config:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -477,10 +506,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: package_info_plus
|
||||
sha256: "127e1751e37ffb2ff4658beeaca77bad0c27bf5f932bd3a501c2296926d4b481"
|
||||
sha256: "73ef2cd5c11a7548f9b7f6fc2a4f6113de2e64aa2a70de63feede95548822908"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "10.2.1"
|
||||
version: "10.2.2"
|
||||
package_info_plus_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -549,10 +578,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: petitparser
|
||||
sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675"
|
||||
sha256: "2f1564aadb14215e478563b5406cb80729d022158c01dc50c96013c2f7eb4790"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.0.2"
|
||||
version: "7.1.0"
|
||||
platform:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -748,14 +777,14 @@ packages:
|
||||
path: "../../packages/core"
|
||||
relative: true
|
||||
source: path
|
||||
version: "1.0.0"
|
||||
version: "1.1.1"
|
||||
sync_relay_server:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
path: "../../packages/server"
|
||||
relative: true
|
||||
source: path
|
||||
version: "1.0.0"
|
||||
version: "1.1.1"
|
||||
synchronized:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -816,10 +845,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: vector_math
|
||||
sha256: f36f9f3be64c6198714492bb455c11056e33e2f85d9a0b676a48301e44fdcf47
|
||||
sha256: "92b9910f66ed1057fd4da7b040ae7c74cafacf885bdc81be496928d5049b032d"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.2"
|
||||
version: "2.4.3"
|
||||
vm_service:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -832,10 +861,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: wakelock_plus
|
||||
sha256: "22b3e7e937721de70e63c85e7139f4ac781dc22863b9262431a53ac030eb074b"
|
||||
sha256: "9a91139ee876c24a9ebe1484e4b9b7923b07d368566a5e0b2301870b0f5029c8"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.8.0"
|
||||
version: "1.8.1"
|
||||
wakelock_plus_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -888,10 +917,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: windows_file_picker
|
||||
sha256: "62e6e6e115231d1d1d71c7b5883b4091ce3e0190e49548dfb0ff8cba8d91ad96"
|
||||
sha256: "18452aa293de3b6961c6d05b0e71b4be7bb90c67635abdd3aa5c4e75dd875072"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.0"
|
||||
version: "2.0.1"
|
||||
xdg_directories:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -904,10 +933,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: xml
|
||||
sha256: "67f0aff7be013d107995e9b75bf4e7f2c3ef2dfdb2c8e68024bba0a7fd5756a4"
|
||||
sha256: "0fe8cc98946def31cbcbc8734aa4067031e6f65ea3a78a508060bfed2200bfeb"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.0.1"
|
||||
version: "7.1.0"
|
||||
yaml:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -917,5 +946,5 @@ packages:
|
||||
source: hosted
|
||||
version: "3.1.4"
|
||||
sdks:
|
||||
dart: ">=3.13.0-0 <4.0.0"
|
||||
flutter: ">=3.44.0"
|
||||
dart: ">=3.13.0 <4.0.0"
|
||||
flutter: ">=3.47.0"
|
||||
@@ -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.2
|
||||
environment:
|
||||
sdk: '>=3.13.0-0 <4.0.0'
|
||||
|
||||
@@ -18,23 +18,24 @@ dependencies:
|
||||
path: ../../packages/core
|
||||
sync_relay_server:
|
||||
path: ../../packages/server
|
||||
file_picker: ^12.3.0
|
||||
file_picker: ^13.1.0
|
||||
hive_ce: ^2.19.3
|
||||
hive_ce_flutter: ^2.3.4
|
||||
image: ^4.9.2
|
||||
window_manager: ^0.5.2
|
||||
archive: ^4.0.7
|
||||
crypto: ^3.0.6
|
||||
ffi: ^2.1.0
|
||||
# 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.9
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
flutter_lints: ^5.0.0
|
||||
flutter_lints: ^6.0.0
|
||||
flutter:
|
||||
uses-material-design: true
|
||||
assets:
|
||||
- assets/shaders/anime4k/
|
||||
- assets/icon/
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -189,23 +189,24 @@ 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, page.problem),
|
||||
('Example', 'example.com · Not a video page yt-dlp knows', 'Not a video page yt-dlp knows'));
|
||||
final refused = await previewLink('https://cdn.example/b.mp4',
|
||||
probe: (url) async => LinkProbe.page(Uri.parse(url), ytDlpError: 'odd', status: 403));
|
||||
expect((refused!.title, refused.problem), ('b.mp4', 'Not allowed (403)'));
|
||||
expect(await previewLink('not a link'), isNull);
|
||||
});
|
||||
|
||||
|
||||
@@ -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,18 @@ 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)
|
||||
: url.contains('bilibili')
|
||||
? LinkProbe.page(Uri.parse(url), ytDlpError: 'HTTP Error 412: Precondition Failed')
|
||||
: 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 +154,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 +249,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);
|
||||
});
|
||||
|
||||
@@ -303,12 +298,24 @@ void main() {
|
||||
await settle(tester, 10);
|
||||
expect(h.controller.link.text, url);
|
||||
|
||||
// A link the probe cannot open: the reason, not put up; "anyway" is.
|
||||
const bili = 'https://www.bilibili.com/video/BV1';
|
||||
await tester.enterText(find.byKey(const ValueKey('link-field')), bili);
|
||||
await settle(tester, 10);
|
||||
await tester.tap(find.byKey(const ValueKey('link-put-up')));
|
||||
await settle(tester, 10);
|
||||
expect(find.text(needsCookiesText), findsOneWidget);
|
||||
expect(h.put, hasLength(1));
|
||||
await tester.tap(find.byKey(const ValueKey('link-put-up-anyway')));
|
||||
await settle(tester, 10);
|
||||
expect((h.put.last as LinkMedia).anyway, isTrue);
|
||||
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
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 +333,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);
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,14 +101,30 @@ 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']);
|
||||
});
|
||||
|
||||
test("can't play: yours with Retry (reload); others' instead of waiting", () {
|
||||
var v = playerStatusFor(health: ok, state: held([me]), self: me, playerError: 'Not allowed (403)')!;
|
||||
expect((v.kind, v.text, v.action), (StatusKind.problem, "Can't play this: Not allowed (403)", StatusAction.reload));
|
||||
final bob = MemberView(id: 'b', name: 'Bob', ready: false, inSet: false, buffered: 0, stale: false,
|
||||
error: 'Not allowed (403)');
|
||||
v = playerStatusFor(health: ok, state: held([me, bob, mv('m', 'Mika', ready: true)]), self: me)!;
|
||||
expect((v.kind, v.text, v.action), (StatusKind.cannotPlay, "Bob can't play this: Not allowed (403)", null));
|
||||
expect(v.who.single.id, 'b');
|
||||
final notMe = mv('me', 'Me');
|
||||
v = playerStatusFor(health: ok, state: held([notMe, bob]), self: notMe)!;
|
||||
expect((v.kind, v.action), (StatusKind.cannotPlay, StatusAction.ready));
|
||||
});
|
||||
|
||||
test('names read as a sentence', () {
|
||||
expect(nameList(['Bob']), 'Bob');
|
||||
expect(nameList(['Bob', 'Mika']), 'Bob and Mika');
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -29,7 +29,6 @@ final seriesJson = {
|
||||
'jellyfin_series_id': 'jf7',
|
||||
'state': 'released',
|
||||
'monitored': true,
|
||||
'followed': true,
|
||||
'episodes_total': 24,
|
||||
'episodes_available': 20,
|
||||
'next_air_date': null,
|
||||
@@ -110,7 +109,7 @@ void main() {
|
||||
'to': req.uri.queryParameters['to'],
|
||||
'items': [
|
||||
{...episodeJson, 'series_id': 7, 'title': 'Show', 'anidb_id': 70, 'tvdb_id': 700,
|
||||
'jellyfin_series_id': 'jf7', 'followed': true},
|
||||
'jellyfin_series_id': 'jf7'},
|
||||
],
|
||||
}, etag: 'W/"s1"');
|
||||
},
|
||||
@@ -126,7 +125,6 @@ void main() {
|
||||
expect(it.anidbId, 70);
|
||||
expect(it.tvdbId, 700);
|
||||
expect(it.jellyfinSeriesId, 'jf7');
|
||||
expect(it.followed, isTrue);
|
||||
final e = it.episode;
|
||||
expect((e.season, e.episode, e.absolute, e.title, e.anilistId), (2, 3, 15, 'Three', 222));
|
||||
expect(e.airDate, DateTime.utc(2026, 10, 8, 19, 36, 32));
|
||||
@@ -146,7 +144,7 @@ void main() {
|
||||
expect(server.seen, isEmpty);
|
||||
});
|
||||
|
||||
test('/series, /series/{id} with episodes, follow/unfollow', () async {
|
||||
test('/series, /series/{id} with episodes', () async {
|
||||
await serve({
|
||||
'GET /series': (req, res) async => json(res, {'items': [seriesJson]}, etag: 'W/"a"'),
|
||||
'GET /series/7': (req, res) async => json(res, {
|
||||
@@ -156,24 +154,18 @@ void main() {
|
||||
{'season': 1, 'episode': 1, 'status': 'failed', 'reason': 'No release'},
|
||||
],
|
||||
}),
|
||||
'PUT /series/7/follow': (req, res) async => res.statusCode = 204,
|
||||
'DELETE /series/7/follow': (req, res) async => res.statusCode = 204,
|
||||
});
|
||||
final c = client();
|
||||
final s = (await c.series()).single;
|
||||
expect(s.seriesId, 7);
|
||||
expect(s.anilistId, 111);
|
||||
expect(s.state, SeriesState.released);
|
||||
expect(s.monitored && s.followed, isTrue);
|
||||
expect(s.monitored, isTrue);
|
||||
expect((s.episodesTotal, s.episodesAvailable, s.nextAirDate), (24, 20, null));
|
||||
final d = await c.seriesDetail(7);
|
||||
expect(d.episodes.map((e) => e.season), [0, 1]);
|
||||
expect(d.episodes.last.status, EpisodeStatus.failed);
|
||||
expect(d.episodes.last.reason, 'No release');
|
||||
await c.setFollowed(7, true);
|
||||
await c.setFollowed(7, false);
|
||||
expect(server.seen.skip(2).map((r) => '${r.method} ${r.uri.path}'),
|
||||
['PUT /api/app/v1/series/7/follow', 'DELETE /api/app/v1/series/7/follow']);
|
||||
});
|
||||
|
||||
test('/search and the three request bodies', () async {
|
||||
|
||||
@@ -20,7 +20,7 @@ Airing airing(int id, int ep, DateTime local) => Airing(
|
||||
);
|
||||
|
||||
AnimeFlowScheduleItem item(int? anilistId, int ep, EpisodeStatus status,
|
||||
{int season = 1, int? absolute, DateTime? air, int seriesId = 7, bool followed = false,
|
||||
{int season = 1, int? absolute, DateTime? air, int seriesId = 7,
|
||||
double? progress, String? reason}) =>
|
||||
AnimeFlowScheduleItem(
|
||||
episode: AnimeFlowEpisode(
|
||||
@@ -36,7 +36,6 @@ AnimeFlowScheduleItem item(int? anilistId, int ep, EpisodeStatus status,
|
||||
seriesId: seriesId,
|
||||
title: 'Show',
|
||||
jellyfinSeriesId: 'jf$seriesId',
|
||||
followed: followed,
|
||||
);
|
||||
|
||||
void main() {
|
||||
@@ -63,6 +62,53 @@ 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('confirmOnJellyfin: the episode (with the run offset) must be on Jellyfin', () {
|
||||
final lib = AnimeFlowLibraryItem.fromJson({
|
||||
'jellyfin_series_id': 'jfJ',
|
||||
'name': 'JoJo',
|
||||
'anilist_map': [
|
||||
{'season': 6, 'anilist_id': 210482, 'first_episode': 13, 'offset': 12},
|
||||
],
|
||||
});
|
||||
final matched = matchSchedule(
|
||||
[
|
||||
airing(210482, 1, DateTime(2026, 10, 1, 18)), // S6E13: there
|
||||
airing(210482, 2, DateTime(2026, 10, 8, 11)), // S6E14: not yet
|
||||
airing(172192, 3, DateTime(2026, 10, 6, 18)), // series not looked up
|
||||
],
|
||||
schedule: const [],
|
||||
library: [
|
||||
lib,
|
||||
const AnimeFlowLibraryItem(jellyfinSeriesId: 'jfR', name: 'Returner', anilistIds: {2: 172192}),
|
||||
],
|
||||
now: now,
|
||||
);
|
||||
expect(matched.map((e) => e.jellyfinEpisode), [13, 14, 3]);
|
||||
final out = confirmOnJellyfin(matched, {
|
||||
'jfJ': {(6, 12), (6, 13), (1, 14)},
|
||||
});
|
||||
expect(out.map((e) => e.status),
|
||||
[ScheduleStatus.inLibrary, ScheduleStatus.notOnJellyfin, ScheduleStatus.aired]);
|
||||
expect(out[1].jellyfinSeriesId, 'jfJ');
|
||||
});
|
||||
|
||||
test("AnimeFlow's anilist_episode wins over TVDB numbering (split cour)", () {
|
||||
// S2E13 in TVDB is part 2 (AniList 300) episode 1.
|
||||
final out = matchSchedule(
|
||||
@@ -82,13 +128,28 @@ void main() {
|
||||
expect(out.single.season, 2);
|
||||
});
|
||||
|
||||
test('a TVDB number never claims another AniList episode (JoJo S6E4 = 2nd STAGE ep 3)', () {
|
||||
AnimeFlowScheduleItem row(int tvdbEp, int aniEp, EpisodeStatus st) => AnimeFlowScheduleItem(
|
||||
episode: AnimeFlowEpisode(season: 6, episode: tvdbEp, anilistId: 210482, anilistEpisode: aniEp, status: st),
|
||||
seriesId: 40,
|
||||
title: 'JoJo',
|
||||
jellyfinSeriesId: 'jfJ',
|
||||
);
|
||||
final out = matchSchedule(
|
||||
[airing(210482, 3, DateTime(2026, 10, 9, 11)), airing(210482, 4, DateTime(2026, 10, 16, 11))],
|
||||
schedule: [row(4, 3, EpisodeStatus.available), row(5, 4, EpisodeStatus.upcoming)],
|
||||
now: now,
|
||||
);
|
||||
expect(out.map((e) => e.status), [ScheduleStatus.available, ScheduleStatus.upcoming]);
|
||||
});
|
||||
|
||||
test('by AniList id + episode, per season ids', () {
|
||||
// One AnimeFlow series (7) spans two AniList entries: S1 = 100, S2 = 200.
|
||||
final out = matchSchedule(
|
||||
[airing(100, 3, DateTime(2026, 10, 1, 20)), airing(200, 3, DateTime(2026, 10, 9, 20))],
|
||||
schedule: [
|
||||
item(100, 3, EpisodeStatus.available, season: 1),
|
||||
item(200, 3, EpisodeStatus.downloading, season: 2, progress: 0.4, followed: true),
|
||||
item(200, 3, EpisodeStatus.downloading, season: 2, progress: 0.4),
|
||||
],
|
||||
series: [const AnimeFlowSeries(seriesId: 7, title: 'Show', anilistId: 100, jellyfinSeriesId: 'jf7')],
|
||||
now: now,
|
||||
@@ -96,7 +157,6 @@ void main() {
|
||||
expect(out.map((e) => e.status), [ScheduleStatus.available, ScheduleStatus.downloading]);
|
||||
expect(out.map((e) => e.season), [1, 2]);
|
||||
expect(out[1].progress, 0.4);
|
||||
expect(out[1].followed, isTrue);
|
||||
expect(out.every((e) => e.tracked && e.seriesId == 7 && e.jellyfinSeriesId == 'jf7'), isTrue);
|
||||
});
|
||||
|
||||
@@ -143,11 +203,11 @@ void main() {
|
||||
final out = matchSchedule(
|
||||
[airing(500, 2, DateTime(2026, 10, 7, 20)), airing(500, 3, DateTime(2026, 10, 14, 20))],
|
||||
schedule: const [],
|
||||
series: [const AnimeFlowSeries(seriesId: 9, title: 'S', anilistId: 500, followed: true)],
|
||||
series: [const AnimeFlowSeries(seriesId: 9, title: 'S', anilistId: 500)],
|
||||
now: now,
|
||||
);
|
||||
expect(out.map((e) => e.status), [ScheduleStatus.missing, ScheduleStatus.upcoming]);
|
||||
expect(out.every((e) => e.followed && e.seriesId == 9), isTrue);
|
||||
expect(out.every((e) => e.seriesId == 9), isTrue);
|
||||
});
|
||||
|
||||
test('progress only while downloading, reason only when failed', () {
|
||||
@@ -209,7 +269,7 @@ void main() {
|
||||
return json(res, {
|
||||
'items': [
|
||||
{'season': 1, 'episode': 3, 'anilist_id': 100, 'status': 'available', 'series_id': 7,
|
||||
'title': 'Show', 'followed': false},
|
||||
'title': 'Show'},
|
||||
],
|
||||
});
|
||||
case '/flow/series':
|
||||
|
||||
@@ -8,6 +8,7 @@ import 'package:watch_together/src/jellyfin/accounts.dart';
|
||||
import 'package:watch_together/src/schedule/anilist.dart';
|
||||
import 'package:watch_together/src/schedule/animeflow.dart';
|
||||
import 'package:watch_together/src/schedule/schedule_model.dart';
|
||||
import 'package:watch_together/src/schedule/ui/schedule_actions.dart';
|
||||
import 'package:watch_together/src/schedule/ui/schedule_page.dart';
|
||||
import 'package:watch_together/src/screens/home/picked_media.dart';
|
||||
|
||||
@@ -87,7 +88,6 @@ List<ScheduleEntry> entries() => [
|
||||
airing: airing(111, 'Avail Show', 3, day(0, 9)),
|
||||
status: ScheduleStatus.available,
|
||||
tracked: true,
|
||||
followed: true,
|
||||
seriesId: 7,
|
||||
season: 1,
|
||||
jellyfinSeriesId: 'jf7',
|
||||
@@ -200,8 +200,6 @@ void main() {
|
||||
});
|
||||
case ('GET', '/search'):
|
||||
json(res, {'items': searchHits});
|
||||
case ('PUT' || 'DELETE', '/series/7/follow'):
|
||||
res.statusCode = 204;
|
||||
case ('POST', '/requests/1/approve' || '/requests/2/reject'):
|
||||
json(res, {});
|
||||
default:
|
||||
@@ -231,7 +229,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 +243,7 @@ void main() {
|
||||
theme: ThemeData.dark(),
|
||||
home: Scaffold(
|
||||
body: SchedulePage(
|
||||
online: online,
|
||||
solo: solo,
|
||||
repository: repo,
|
||||
accounts: accounts,
|
||||
onToast: toasts.add,
|
||||
@@ -297,13 +295,9 @@ void main() {
|
||||
await done(tester);
|
||||
});
|
||||
|
||||
testWidgets('filters: following, all airing, niche too, compact', (tester) async {
|
||||
testWidgets('filters: all airing, niche too, compact', (tester) async {
|
||||
await pumpPage(tester);
|
||||
await tester.tap(find.text('Following'));
|
||||
await tester.pump();
|
||||
expect(card(111, 3), findsOneWidget);
|
||||
expect(card(222, 5), findsNothing);
|
||||
|
||||
expect(find.text('Following'), findsNothing);
|
||||
await tester.tap(find.text('All airing'));
|
||||
await tester.pump();
|
||||
expect(card(444, 1), findsOneWidget);
|
||||
@@ -397,15 +391,6 @@ void main() {
|
||||
await done(tester);
|
||||
});
|
||||
|
||||
testWidgets('follow toggles through AnimeFlow', (tester) async {
|
||||
final repo = await pumpPage(tester);
|
||||
await tester.tap(find.byKey(const ValueKey('follow-111-3')));
|
||||
await io(tester);
|
||||
expect(server.seen.where((r) => r.method == 'DELETE' && r.uri.path.endsWith('/series/7/follow')), hasLength(1));
|
||||
expect(repo.forced.length, greaterThanOrEqualTo(2)); // reloaded
|
||||
await done(tester);
|
||||
});
|
||||
|
||||
testWidgets('put it up resolves the Jellyfin episode', (tester) async {
|
||||
final jf = FakeJf();
|
||||
final accounts = JellyfinAccounts(
|
||||
@@ -434,11 +419,56 @@ void main() {
|
||||
await done(tester);
|
||||
});
|
||||
|
||||
testWidgets('offline: Play is disabled', (tester) async {
|
||||
await pumpPage(tester, online: false);
|
||||
testWidgets('untracked on Jellyfin: episode lookup, aired-not-there has no Play', (tester) async {
|
||||
final jf = FakeJf();
|
||||
final accounts = JellyfinAccounts(
|
||||
store: MemoryJellyfinStore({
|
||||
'accounts': jsonEncode([
|
||||
{
|
||||
'serverId': 'srv',
|
||||
'serverName': 'Home',
|
||||
'urls': ['http://jf.test'],
|
||||
'userId': 'uid',
|
||||
'userName': 'me',
|
||||
'token': 'tok',
|
||||
},
|
||||
]),
|
||||
}),
|
||||
httpFactory: () => jf,
|
||||
deviceName: 'Test',
|
||||
);
|
||||
final eps = await tester.runAsync(() => jellyfinEpisodeNumbers(accounts, flow, 'jf7'));
|
||||
expect(eps, {(1, 2), (1, 3)});
|
||||
await tester.runAsync(() => jellyfinEpisodeNumbers(accounts, flow, 'jf7'));
|
||||
expect(jf.seen.where((u) => u.path.endsWith('/Episodes')), hasLength(1), reason: 'memoised');
|
||||
final none = JellyfinAccounts(store: MemoryJellyfinStore(), deviceName: 'Test');
|
||||
expect(await tester.runAsync(() => jellyfinEpisodeNumbers(none, flow, 'jf7')), isNull);
|
||||
|
||||
await pumpPage(
|
||||
tester,
|
||||
snap: () => snapshot(
|
||||
list: [
|
||||
ScheduleEntry(
|
||||
airing: airing(777, 'Clover', 2, day(0, 9)),
|
||||
status: ScheduleStatus.notOnJellyfin,
|
||||
season: 1,
|
||||
jellyfinEpisode: 2,
|
||||
jellyfinSeriesId: 'jfC',
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
expect(card(777, 2), findsOneWidget);
|
||||
expect(find.text('aired · not on Jellyfin yet'), findsOneWidget);
|
||||
expect(action(777, 2), findsNothing);
|
||||
await done(tester);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -26,20 +26,26 @@ double _pitch(List<double> x, int from) {
|
||||
|
||||
void main() {
|
||||
test('every sound is described, quiet, short and clean', () {
|
||||
for (final k in SfxKind.values) {
|
||||
expect(soundInfo[k], isNotNull, reason: k.name);
|
||||
final r = render(soundFor(k));
|
||||
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');
|
||||
for (final p in SoundPreset.values) {
|
||||
expect(presetInfo[p], isNotNull, reason: p.name);
|
||||
for (final k in SfxKind.values) {
|
||||
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: '$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', () {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:media_kit/media_kit.dart';
|
||||
import 'package:watch_together/src/playback/playback_controller.dart';
|
||||
|
||||
final _tmp = Platform.environment['WT_TEST_TMP'] ?? Directory.systemTemp.path;
|
||||
const _referer = 'https://www.bilibili.com/';
|
||||
|
||||
/// Separate video-only and audio-only files, like Bilibili's DASH .m4s.
|
||||
Future<(String, String)> _media() async {
|
||||
final v = '$_tmp/ytdl_headers_v.mp4', a = '$_tmp/ytdl_headers_a.m4a';
|
||||
for (final (path, args) in [
|
||||
(v, ['-f', 'lavfi', '-i', 'testsrc2=size=320x180:rate=24', '-c:v', 'libx264', '-preset', 'ultrafast']),
|
||||
(a, ['-f', 'lavfi', '-i', 'sine=frequency=440:sample_rate=48000', '-c:a', 'aac']),
|
||||
]) {
|
||||
if (await File(path).exists()) continue;
|
||||
final r = await Process.run('ffmpeg', ['-y', '-v', 'error', ...args, '-t', '30', path]);
|
||||
if (r.exitCode != 0) fail('ffmpeg failed: ${r.stderr}');
|
||||
}
|
||||
return (v, a);
|
||||
}
|
||||
|
||||
/// A CDN like Bilibili's: 403 without the page's Referer.
|
||||
class _Cdn {
|
||||
_Cdn(this._server, this._files) {
|
||||
_server.listen(_serve);
|
||||
}
|
||||
final HttpServer _server;
|
||||
final Map<String, String> _files;
|
||||
int ok = 0, forbidden = 0;
|
||||
|
||||
String url(String name) => 'http://127.0.0.1:${_server.port}/$name';
|
||||
|
||||
Future<void> _serve(HttpRequest req) async {
|
||||
final res = req.response;
|
||||
final file = _files[req.uri.pathSegments.lastOrNull];
|
||||
if (file == null || req.headers.value('referer') != _referer) {
|
||||
forbidden++;
|
||||
res.statusCode = HttpStatus.forbidden;
|
||||
await res.close();
|
||||
return;
|
||||
}
|
||||
ok++;
|
||||
final bytes = await File(file).readAsBytes();
|
||||
var start = 0, end = bytes.length - 1;
|
||||
final range = RegExp(r'bytes=(\d*)-(\d*)').firstMatch(req.headers.value('range') ?? '');
|
||||
res.headers.set('accept-ranges', 'bytes');
|
||||
if (range != null) {
|
||||
start = int.tryParse(range[1]!) ?? 0;
|
||||
end = int.tryParse(range[2]!) ?? end;
|
||||
res.statusCode = HttpStatus.partialContent;
|
||||
res.headers.set('content-range', 'bytes $start-$end/${bytes.length}');
|
||||
}
|
||||
res.contentLength = end - start + 1;
|
||||
res.add(bytes.sublist(start, end + 1));
|
||||
await res.close();
|
||||
}
|
||||
}
|
||||
|
||||
/// A stand-in yt-dlp printing what yt-dlp gives for a split-track page:
|
||||
/// `requested_formats` with per-format `http_headers`, none at the top.
|
||||
Future<String> _fakeYtDlp(_Cdn cdn, {required bool headers}) async {
|
||||
final dir = await Directory('$_tmp/ytdl_headers_fake').create(recursive: true);
|
||||
final h = headers ? {'Referer': _referer, 'User-Agent': 'fake-agent'} : <String, String>{};
|
||||
final json = jsonEncode({
|
||||
'id': 'BV1', 'title': 'Fake page', 'duration': 30, 'extractor': 'fake',
|
||||
'format_id': 'v+a', 'ext': 'mp4',
|
||||
'requested_formats': [
|
||||
{'format_id': 'v', 'url': cdn.url('v.m4s'), 'protocol': 'https', 'ext': 'mp4',
|
||||
'vcodec': 'avc1.64001F', 'acodec': 'none', 'width': 320, 'height': 180, 'http_headers': h},
|
||||
{'format_id': 'a', 'url': cdn.url('a.m4s'), 'protocol': 'https', 'ext': 'm4a',
|
||||
'vcodec': 'none', 'acodec': 'mp4a.40.2', 'http_headers': h},
|
||||
],
|
||||
});
|
||||
final f = File('${dir.path}/yt-dlp-${headers ? 'h' : 'n'}');
|
||||
await f.writeAsString("#!/bin/sh\ncat <<'EOF'\n$json\nEOF\n");
|
||||
await Process.run('chmod', ['+x', f.path]);
|
||||
return f.path;
|
||||
}
|
||||
|
||||
void main() {
|
||||
late _Cdn cdn;
|
||||
late HttpServer server;
|
||||
|
||||
setUpAll(() async {
|
||||
MediaKit.ensureInitialized();
|
||||
final (v, a) = await _media();
|
||||
server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
|
||||
cdn = _Cdn(server, {'v.m4s': v, 'a.m4s': a});
|
||||
});
|
||||
tearDownAll(() => server.close(force: true));
|
||||
|
||||
test(
|
||||
"mpv's yt-dlp hook sends each format's Referer to the CDN",
|
||||
timeout: const Timeout(Duration(minutes: 1)),
|
||||
() async {
|
||||
final p = PlaybackController();
|
||||
try {
|
||||
await p.init();
|
||||
expect(p.pinMismatches, isEmpty, reason: 'stream-lavf-o and the rest took effect');
|
||||
await p.useYtDlp(await _fakeYtDlp(cdn, headers: true));
|
||||
await p.openUrl('https://www.bilibili.com/video/BV1fake');
|
||||
final end = DateTime.now().add(const Duration(seconds: 20));
|
||||
while (DateTime.now().isBefore(end) && p.duration.peek() == Duration.zero) {
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
}
|
||||
expect(p.duration.peek(), greaterThan(Duration.zero), reason: 'both tracks opened');
|
||||
expect(cdn.forbidden, 0, reason: 'every request had the Referer');
|
||||
expect(cdn.ok, greaterThanOrEqualTo(2));
|
||||
} finally {
|
||||
await p.dispose();
|
||||
}
|
||||
},
|
||||
skip: Platform.isLinux ? false : 'fake yt-dlp is a shell script',
|
||||
);
|
||||
|
||||
test(
|
||||
'a 403 is a load error, not a reconnect loop',
|
||||
timeout: const Timeout(Duration(minutes: 1)),
|
||||
() async {
|
||||
final p = PlaybackController();
|
||||
final errors = <String>[];
|
||||
p.onLoadError = errors.add;
|
||||
try {
|
||||
await p.init();
|
||||
var logs = 0;
|
||||
final sub = p.player.stream.log.listen((_) => logs++);
|
||||
await p.useYtDlp(await _fakeYtDlp(cdn, headers: false));
|
||||
final before = cdn.forbidden;
|
||||
await p.openUrl('https://www.bilibili.com/video/BV2fake');
|
||||
await Future.delayed(const Duration(seconds: 8));
|
||||
await sub.cancel();
|
||||
// ignore: avoid_print
|
||||
print('403s: ${cdn.forbidden - before}, log lines: $logs, errors: $errors');
|
||||
expect(cdn.forbidden - before, lessThan(10), reason: 'no retry loop on 403');
|
||||
expect(errors, hasLength(1));
|
||||
// The status only when ffmpeg logs through this mpv (see _onLog).
|
||||
expect(errors.single, anyOf(contains('403'), contains('could not be opened')));
|
||||
expect(logs, lessThan(30), reason: 'warnings and errors only');
|
||||
} finally {
|
||||
await p.dispose();
|
||||
}
|
||||
},
|
||||
skip: Platform.isLinux ? false : 'fake yt-dlp is a shell script',
|
||||
);
|
||||
}
|
||||
@@ -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_
|
||||
@@ -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);
|
||||
|
||||
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 361 KiB |
@@ -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]
|
||||
|
||||
@@ -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,31 @@ 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, sitting out and a
|
||||
player error remove from the set.
|
||||
- **Player error** (`EngineMember.error`, `report.error`,
|
||||
`state.members[].error`): a member whose player cannot open the current
|
||||
media says why in its reports; the server takes it out of the set at
|
||||
once (like sitting out, but automatic: `ready`/`force` do not put it
|
||||
back, its ready flag is kept), so the others start without it. A report
|
||||
without it (the media opened, after a reload too) or a swap clears it:
|
||||
back in the set like a join while not PLAYING; while PLAYING from its
|
||||
next frame if it is ready (the others then hold for its buffer, as for a
|
||||
stale member coming back), else from its `ready`.
|
||||
- **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 +179,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 +212,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 +244,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`;
|
||||
@@ -212,8 +265,25 @@ mpv settings (`PlaybackController`): `Player` with `bufferSize` 256 MiB
|
||||
read-ahead: rides out short drops, intro skips are instant),
|
||||
`demuxer-max-bytes=268435456` (150 s up to ~14 Mbit/s; a 1.4 GB / 24 min
|
||||
file is ~8), `demuxer-max-back-bytes=67108864`,
|
||||
`stream-lavf-o=reconnect_streamed=1,reconnect_on_http_error=1,reconnect_delay_max=4`
|
||||
(comma form; this mpv build rejects colon-joined). The cache is in RAM:
|
||||
`stream-lavf-o=reconnect_streamed=1,reconnect_on_http_error=5xx,reconnect_delay_max=4,reconnect_max_retries=3`
|
||||
(comma form; this mpv build rejects colon-joined): a dropped connection
|
||||
or a 5xx is retried, at most 3 times per read and 4 s apart at most; a
|
||||
4xx (a CDN refusing a request) fails at once instead of being retried
|
||||
(older ffmpeg ignores `reconnect_max_retries`). A load that fails (mpv's
|
||||
fatal "No video or audio streams selected", or an EDL track that cannot
|
||||
be opened) before a duration is known is reported once per open
|
||||
(`onLoadError`), with ffmpeg's HTTP status when this player gets
|
||||
ffmpeg's log (ffmpeg logs through one mpv instance per process, the
|
||||
first one alive). That, a yt-dlp hook error and a failed `openUrl` become
|
||||
the coordinator's `playerError` in plain words (`playErrorText`, core
|
||||
`link_probe.dart`: 412/precondition/login/cookies/sign in → "This site
|
||||
needs cookies — Settings → Player → yt-dlp → Cookies", 403 → "Not allowed
|
||||
(403)", 404/410 → "Not found (404)", "Unsupported URL" → "Not a video
|
||||
page yt-dlp knows", network errors → "Couldn't reach the site", anything
|
||||
else as is). It rides in `report.error` (see "Player error" in the sync
|
||||
model) until the media opens (a new duration) or the server's `mediaId`
|
||||
moves on; the pill shows "Can't play this: ..." with Retry (Reload
|
||||
video). The cache is in RAM:
|
||||
media_kit sets `cache-on-disk` without a `cache-dir`, and mpv falls back. mpv
|
||||
ignores invalid values silently, so every pin is read back and a mismatch is
|
||||
logged and kept in `pinMismatches`. Measured against the `/m` endpoint
|
||||
@@ -244,12 +314,18 @@ turns shaders off (saved) with a toast.
|
||||
Render mode: media_kit's Linux/Windows video output creates mpv's render
|
||||
context (OpenGL, else its software renderer) when the `VideoController` is
|
||||
created, and nothing in its API says which. The player therefore requests
|
||||
mpv logs at `v`: OpenGL init logs `GL_VERSION` under `libmpv_render`
|
||||
mpv logs at `v` while the controller is created: OpenGL init logs
|
||||
`GL_VERSION` under `libmpv_render`
|
||||
(`RenderMode.gpu`; "suspected software renderer" means llvmpipe,
|
||||
`softwareGl`); none within 1.5 s of the controller being ready means the
|
||||
software path (`software`, where shaders do nothing; the shader toast, the
|
||||
menu and Settings say so). After startup the `v` level adds no steady
|
||||
traffic (measured: 0 messages over 5 s of playback). The hardware path is
|
||||
menu and Settings say so). Otherwise the session player logs at `warn`
|
||||
(`PlaybackController.logLevel`, switched with `mpv_request_log_messages`
|
||||
on media_kit's handle): media_kit drains mpv's log on the UI thread, one
|
||||
message at a time, so a chatty level, or ffmpeg logging every retry of a
|
||||
refused request, could starve the UI. Shader and yt-dlp hook errors are
|
||||
`error` lines and still arrive. The thumbnail and sound players keep
|
||||
media_kit's default (`error`) and nothing listens to their log. The hardware path is
|
||||
fragile: media_kit borrows whatever EGL context GDK left current on the main
|
||||
thread (upstream media-kit#1404), so an X11 session or a Flutter/GDK change
|
||||
can silently fall back to software.
|
||||
@@ -364,7 +440,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 +456,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 +471,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 +571,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 +598,119 @@ 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 and the GET's HTTP status. `LinkProbe.problem`: why it will not
|
||||
play, in plain words (`playErrorText` of yt-dlp's reason, else of a 4xx
|
||||
status), null for a file or a page yt-dlp read. The app probes on paste
|
||||
(`AppYtDlp.probe`, remembered 10 minutes; media file names too, not a
|
||||
Jellyfin item link) for the
|
||||
Link preview and the swap: a page is named by its title, never its URL
|
||||
tail, and its thumbnail goes up as the banner (no poster). A link with a
|
||||
problem, or one the probe cannot reach ("Couldn't reach the site"), is
|
||||
not put up in any mode: the Link page shows the reason (preview and
|
||||
under the button) and offers "Put it up anyway" (`LinkMedia.anyway`),
|
||||
for when the server or a friend with cookies may still open it;
|
||||
`swapLink` refuses it the same way for any other caller.
|
||||
- **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 is a player error (see the mpv settings under "Media"). The hook (mpv
|
||||
0.41) gives mpv the first requested format's `http_headers` (User-Agent
|
||||
as `user-agent`; Referer, Cookie, X-Forwarded-For as file-local
|
||||
`http-header-fields`, which win over the empty list media_kit sets on
|
||||
load) and its `cookies` (`stream-lavf-o`), for every track of its EDL:
|
||||
Bilibili's CDN answers 403 without the Referer. The app never opens a
|
||||
resolved format URL in mpv itself (the putter of "Through me" plays the
|
||||
page through the hook too); relays read them with yt-dlp's headers.
|
||||
- **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 +733,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 +786,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 +804,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 +818,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 +828,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,19 +851,28 @@ 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
|
||||
episode (`schedule_model.dart`); requests and the owner's inbox are in
|
||||
`/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`). An aired episode of such a show reads
|
||||
"on Jellyfin" only when Jellyfin has it: the season plus AniList
|
||||
episode + the run's offset, checked against the series' episodes seen
|
||||
by the linked account (`jellyfinEpisodeNumbers`, one request per series
|
||||
memoised 3 min; `confirmOnJellyfin` is the pure step); absent reads
|
||||
"aired · not on Jellyfin yet", unknown (no linked account, lookup
|
||||
failed) "aired", neither with Put it up. 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.
|
||||
Settings (`settings_tab.dart`) has a left nav with icons
|
||||
@@ -611,23 +890,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 +919,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,10 +947,16 @@ 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
|
||||
"2/3 ready"); nothing otherwise, info never. Your own `playerError`
|
||||
comes right after reconnecting ("Can't play this: ...", Retry reloads
|
||||
the video); members with `members[].error` show as "Bob can't play
|
||||
this: ..." (before your ready prompt, which then rides in the detail
|
||||
line) and are never in "waiting for". A member's new error also toasts
|
||||
once (the pill is hidden while playing); avatars carry a red error mark
|
||||
and the members card says "can't play this: ...". 34 px avatars, a main line
|
||||
and a muted detail line. Frosted glass, colour only for problems; it fades through
|
||||
between kinds. The mini shows its short line in its hover bar. ⚙ in both
|
||||
control designs opens the player settings card (`controls/
|
||||
@@ -694,8 +986,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 +1094,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 +1102,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 +1150,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,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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
@@ -40,10 +40,11 @@ with different `proto` refuse each other at `hello` (`proto mismatch`).
|
||||
{"t":"swap","media":{"name":"","size":0,"duration":0,"jellyfin":{<ref>}}}
|
||||
{"t":"swap","media":{"name":"","size":123,"duration":0,"url":"...","relay":{}}}
|
||||
{"t":"report","position":123.1,"buffered":6.2,"eventId":41,
|
||||
"duration":1440.5,"mediaId":2,"jf":{"<serverId>":true}}
|
||||
"duration":1440.5,"mediaId":2,"jf":{"<serverId>":true},"error":"Not allowed (403)"}
|
||||
{"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.
|
||||
@@ -71,6 +72,17 @@ Field notes:
|
||||
client plays the item with its own account, `false` = through the relay.
|
||||
Sticky: a report without `jf` keeps the last one. At most 8 entries, keys
|
||||
up to 64 characters; others are dropped.
|
||||
- `report.error` (optional, omitted when empty): short plain text saying
|
||||
why this client's player cannot open the media `report.mediaId` (e.g.
|
||||
`Not allowed (403)`). The client sends it in every report while that
|
||||
lasts and drops it once the media opens (a reload included). The server
|
||||
keeps it per member (one line, cut to 120 characters) only for the
|
||||
current `mediaId`: a report without it, or for another `mediaId`,
|
||||
clears it, and so does a swap. An errored member is out of the ready
|
||||
gate: out of the set, a `ready` or `force` does not put it back; when
|
||||
the error clears it is in the set again like a join (unless `playing`,
|
||||
then from its `ready`). Optional on both sides: older servers ignore it,
|
||||
older clients ignore `members[].error`.
|
||||
- `ping.c`: the client's clock in epoch ms; answered with `pong`.
|
||||
- `user.avatar`: base64 PNG, at most 256 KiB of characters (else
|
||||
`rejected: ...`). `name` is trimmed, empty becomes `?`, cut to 32
|
||||
@@ -81,6 +93,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 +124,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"?,"error"?}],
|
||||
"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 +155,15 @@ 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`). `members[].error` (omitted when
|
||||
empty): the member's player cannot open the current media (see
|
||||
`report.error`).
|
||||
- `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 +182,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 +232,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 +246,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 +315,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
|
||||
|
||||
|
||||
@@ -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".
|
||||
filter on the Schedule from the user's AniList lists. This is the
|
||||
Schedule's personal tracking; AnimeFlow has no per-user follow.
|
||||
|
||||
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
|
||||
@@ -63,8 +76,6 @@ off by deleting it in the same commit as the code. Wire changes also edit
|
||||
not wanted yet.
|
||||
- HTTP: move the app's clients (Jellyfin, AniList, AnimeFlow, updater)
|
||||
to dio from `fox_utils` when touching them, for retries/interceptors.
|
||||
- The `state` broadcast carries the full media URL, which can include an
|
||||
`api_key` (recognised Jellyfin links become refs; other links do not).
|
||||
- TLS: document a reverse proxy in front of the server.
|
||||
- Playlist: `queue-add`, `queue-skip`, auto-advance like a swap.
|
||||
- Sidecar subtitles.
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 =>
|
||||
|
||||
@@ -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,10 @@ abstract class RelayConstants {
|
||||
static const int maxBannerBytes = 768 * 1024;
|
||||
static const int maxNameLength = 32;
|
||||
static const int maxChatLength = 500;
|
||||
/// Characters of `report.error` the server keeps.
|
||||
static const int maxMemberErrorLength = 120;
|
||||
/// 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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,470 @@
|
||||
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('"', '"')
|
||||
.replaceAll(''', "'")
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll(' ', ' ')
|
||||
.replaceAll('&', '&')
|
||||
.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.";
|
||||
|
||||
/// The plain-words texts of [playErrorText].
|
||||
const needsCookiesText = 'This site needs cookies — Settings → Player → yt-dlp → Cookies';
|
||||
const notAllowedText = 'Not allowed (403)';
|
||||
const notFoundText = 'Not found (404)';
|
||||
const unsupportedUrlText = 'Not a video page yt-dlp knows';
|
||||
const unreachableText = "Couldn't reach the site";
|
||||
const _plainTexts = {needsCookiesText, notAllowedText, notFoundText, unsupportedUrlText, unreachableText};
|
||||
|
||||
final _cookiesHint = RegExp(r'\b412\b|precondition|log ?in\b|logged|cookies|sign ?in\b', caseSensitive: false);
|
||||
final _forbidden = RegExp(r'(^|[\s(:])403\b|forbidden', caseSensitive: false);
|
||||
final _notFound = RegExp(r'(^|[\s(:])404\b|\b410\b|not found', caseSensitive: false);
|
||||
final _unreachable = RegExp(
|
||||
r'SocketException|HandshakeException|TimeoutException|timed out|failed host lookup|connection (refused|reset|closed)|'
|
||||
r'network is unreachable|no route to host|name or service not known|getaddrinfo|unable to download webpage',
|
||||
caseSensitive: false);
|
||||
|
||||
/// Why a link or media cannot be played, in plain words, from the raw
|
||||
/// error of yt-dlp, ffmpeg/mpv or the link probe; anything it does not
|
||||
/// know comes back as is (one line, at most 100 characters).
|
||||
String playErrorText(String raw) {
|
||||
final s = raw.trim();
|
||||
// First: its message quotes the URL, which may say anything.
|
||||
if (s.toLowerCase().contains('unsupported url')) return unsupportedUrlText;
|
||||
if (_cookiesHint.hasMatch(s)) return needsCookiesText;
|
||||
if (_forbidden.hasMatch(s)) return notAllowedText;
|
||||
if (_notFound.hasMatch(s)) return notFoundText;
|
||||
if (_unreachable.hasMatch(s)) return unreachableText;
|
||||
final line = s.replaceAll(RegExp(r'\s+'), ' ');
|
||||
if (line.isEmpty) return "Couldn't open it";
|
||||
return line.length > 100 ? '${line.substring(0, 99)}…' : line;
|
||||
}
|
||||
|
||||
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;
|
||||
/// The HTTP status of the probe's GET; 0 when unknown.
|
||||
final int status;
|
||||
const LinkProbe.file(this.url, this.size)
|
||||
: isPage = false,
|
||||
info = null,
|
||||
pageTitle = null,
|
||||
ytDlpError = null,
|
||||
status = 0;
|
||||
const LinkProbe.page(this.url, {this.info, this.pageTitle, this.ytDlpError, this.status = 0})
|
||||
: isPage = true,
|
||||
size = -1;
|
||||
|
||||
/// Why nobody may be able to play this link ([playErrorText]), or null
|
||||
/// when it looks playable: a page yt-dlp could not read, or a link
|
||||
/// answering 4xx.
|
||||
String? get problem {
|
||||
if (!isPage || info != null) return null;
|
||||
final err = ytDlpError == null ? null : playErrorText(ytDlpError!);
|
||||
// A 4xx says more than yt-dlp's words it has no plain text for.
|
||||
final known = err != null && _plainTexts.contains(err);
|
||||
if (!known && status >= 400 && status < 500) return playErrorText('HTTP Error $status');
|
||||
return err;
|
||||
}
|
||||
|
||||
/// 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;
|
||||
var status = 0;
|
||||
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));
|
||||
status = res.statusCode;
|
||||
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()), status: status);
|
||||
} 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, status: status);
|
||||
} 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();
|
||||
}
|
||||
@@ -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(
|
||||
source: json['source'] is String ? json['source'] as String : '',
|
||||
fetch: json['fetch'] == true,
|
||||
live: json['live'] == true);
|
||||
/// 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,
|
||||
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 = ''});
|
||||
/// 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 bool out;
|
||||
const OutIntent({required this.target, required this.out});
|
||||
|
||||
Map<String, Object?> toJson() => {'t': 'kicked', 'reason': reason};
|
||||
Map<String, Object?> toJson() => {'t': 'out', 'target': target, 'out': out};
|
||||
|
||||
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?) ?? '');
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
class KickRequest {
|
||||
/// 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;
|
||||
final String reason;
|
||||
const KickRequest({required this.target, this.reason = ''});
|
||||
const NudgeIntent({required this.target});
|
||||
|
||||
Map<String, Object?> toJson() => {'t': 'kick', 'target': target, 'reason': reason};
|
||||
Map<String, Object?> toJson() => {'t': 'nudge', 'target': target};
|
||||
|
||||
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 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?) ?? '');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -373,13 +435,17 @@ class ReportMessage {
|
||||
/// Jellyfin access by `ServerId`: true = plays with its own account,
|
||||
/// false = through the server's relay. Null leaves the last one in place.
|
||||
final Map<String, bool>? jf;
|
||||
/// Why the player cannot open [mediaId] (short plain text); empty while
|
||||
/// it can.
|
||||
final String error;
|
||||
const ReportMessage(
|
||||
{required this.position,
|
||||
required this.buffered,
|
||||
required this.eventId,
|
||||
this.duration = 0,
|
||||
this.mediaId = 0,
|
||||
this.jf});
|
||||
this.jf,
|
||||
this.error = ''});
|
||||
|
||||
Map<String, Object?> toJson() => {
|
||||
't': 'report',
|
||||
@@ -389,6 +455,7 @@ class ReportMessage {
|
||||
if (duration > 0) 'duration': duration,
|
||||
if (mediaId != 0) 'mediaId': mediaId,
|
||||
if (jf != null) 'jf': jf,
|
||||
if (error.isNotEmpty) 'error': error,
|
||||
};
|
||||
|
||||
factory ReportMessage.fromJson(Map<String, Object?> json) {
|
||||
@@ -400,10 +467,20 @@ class ReportMessage {
|
||||
duration: finiteDouble(json['duration'], 'duration'),
|
||||
mediaId: finiteInt(json['mediaId'], 'mediaId'),
|
||||
jf: jfAccessFromJson(json['jf']),
|
||||
error: memberErrorFromJson(json['error']),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads a `report.error` / `members[].error`: trimmed, single line, at
|
||||
/// most [RelayConstants.maxMemberErrorLength] characters; empty otherwise.
|
||||
String memberErrorFromJson(Object? value) {
|
||||
if (value is! String) return '';
|
||||
final line = value.replaceAll(RegExp(r'\s+'), ' ').trim();
|
||||
const max = RelayConstants.maxMemberErrorLength;
|
||||
return line.length > max ? line.substring(0, max) : line;
|
||||
}
|
||||
|
||||
/// Reads a `jf` map (`{serverId: bool}`), dropping non-bool entries and
|
||||
/// keys over 64 characters, at most 8 entries (it rides in every `state`);
|
||||
/// null when absent or not a map.
|
||||
@@ -518,6 +595,11 @@ 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;
|
||||
/// Why its player cannot open the current media (`report.error`); empty
|
||||
/// while it can. Errored: outside the ready gate.
|
||||
final String error;
|
||||
const MemberView({
|
||||
required this.id,
|
||||
required this.name,
|
||||
@@ -528,6 +610,8 @@ class MemberView {
|
||||
required this.stale,
|
||||
this.jf = const {},
|
||||
this.host = false,
|
||||
this.out = false,
|
||||
this.error = '',
|
||||
});
|
||||
|
||||
Map<String, Object?> toJson() => {
|
||||
@@ -540,6 +624,8 @@ class MemberView {
|
||||
'stale': stale,
|
||||
if (jf.isNotEmpty) 'jf': jf,
|
||||
if (host) 'host': true,
|
||||
if (out) 'out': true,
|
||||
if (error.isNotEmpty) 'error': error,
|
||||
};
|
||||
|
||||
factory MemberView.fromJson(Map<String, Object?> json) => MemberView(
|
||||
@@ -552,6 +638,8 @@ class MemberView {
|
||||
stale: (json['stale'] as bool?) ?? false,
|
||||
jf: jfAccessFromJson(json['jf']) ?? const {},
|
||||
host: json['host'] == true,
|
||||
out: json['out'] == true,
|
||||
error: memberErrorFromJson(json['error']),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -559,12 +647,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;
|
||||
|
||||
@@ -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) {
|
||||
throw HttpException('upstream ignores Range', uri: url);
|
||||
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);
|
||||
}
|
||||
// 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);
|
||||
}
|
||||
yield* res;
|
||||
}
|
||||
|
||||
@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((_) {});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||