Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1fd4081461 | ||
|
|
2d39018a73 | ||
|
|
059b7dd306 | ||
|
|
0115d54ab8 | ||
|
|
fe5d6c3b3f | ||
|
|
05f9e794a9 | ||
|
|
75065f90cd | ||
|
|
e0db5cc655 | ||
|
|
8186fbb5d3 | ||
|
|
8b81da4069 | ||
|
|
dbfd351ee4 | ||
|
|
a4be24a2a5 | ||
|
|
b038706dff | ||
|
|
b2b77a6b3f | ||
|
|
d002ff6941 | ||
|
|
151ec95289 | ||
|
|
745717a4a0 | ||
|
|
9ddd85de90 | ||
|
|
24fc137e4f | ||
|
|
9a4b04672b | ||
|
|
ff51d52fbb | ||
|
|
f2272d7980 | ||
|
|
cf80bb2167 | ||
|
|
8effa573a0 | ||
|
|
5f22be6c20 | ||
|
|
740273745b | ||
|
|
27b4e1dc0c | ||
|
|
fce392e425 | ||
|
|
d8fd464e33 |
No files matched your search
@@ -55,5 +55,8 @@ jobs:
|
||||
run: powershell -NoProfile -ExecutionPolicy Bypass -File tool/setup_flutter_windows.ps1
|
||||
- name: Updater tests
|
||||
shell: powershell
|
||||
working-directory: apps/desktop
|
||||
run: flutter test test/updater_test.dart
|
||||
working-directory: packages/core
|
||||
run: |
|
||||
dart pub get
|
||||
if ($LASTEXITCODE) { exit 1 }
|
||||
dart test test/updater_test.dart
|
||||
@@ -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
|
||||
@@ -62,17 +86,32 @@ a video first plays, its banner fills the picture until playback starts.
|
||||
One pill above the controls says what playback waits for ("Press Space
|
||||
when you're ready", "Waiting for Bob and Mika · 2/3 ready", "Bob is
|
||||
loading…") or what went wrong, with Retry or Cancel when it helps. Chat and activity open in a panel on the right (chat button or
|
||||
Tab); joins, leaves, chat and other news also pop up bottom left with the
|
||||
person's avatar.
|
||||
Tab), ready to type; it shows messages, and its "Activity" toggle adds
|
||||
joins, ready, seeks and the like between them. Joins, leaves, chat and
|
||||
other news also pop up bottom left with the person's avatar; click a message to open the chat. Unread chat
|
||||
shows as a count on the chat buttons and, in fullscreen with the controls
|
||||
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
|
||||
@@ -83,10 +122,13 @@ disk. Nothing is uploaded or stored anywhere.
|
||||
|
||||
## Library
|
||||
|
||||
What to watch, from three sources (the switch at the top; Ctrl+V outside
|
||||
a field pastes a link into Link):
|
||||
What to watch, from three sources (the switch at the top). Drop a video,
|
||||
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
|
||||
@@ -98,15 +140,17 @@ a field pastes a link into Link):
|
||||
details open on the right with "Put it up · S1E3", mark watched /
|
||||
unwatched, remove from Continue and the episode list (click an episode
|
||||
to choose it). Double-click a card or an episode to put it up at once.
|
||||
- **Link**: paste an http(s) link. The preview shows what it is (title and
|
||||
thumbnail for YouTube, else the file name); in a server, pick how the
|
||||
- **Link**: paste an http(s) link: a video file, or a web page with a
|
||||
video (YouTube, X, and whatever else yt-dlp knows). The preview shows
|
||||
what it is (a page's title, uploader, length and thumbnail, else the
|
||||
file name); in a server, pick how the
|
||||
others get it (everyone fetches it, the server fetches it, or through
|
||||
you). Your last 12 links are listed under it.
|
||||
- **Files**: video folders you add on this computer, grouped by series,
|
||||
or any file through Browse. The others stream it from you.
|
||||
- **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
|
||||
|
||||
@@ -125,10 +169,15 @@ then everyone (you included) uses Join.
|
||||
| `--name` | `server` | name reported by `/health` |
|
||||
| `--media` | none | local file to serve as the starting video |
|
||||
| `--advertise-host` | none | host put in file URLs instead of the one each viewer dialled |
|
||||
| `--config` | none | YAML or JSON file with the same settings (`port`, `secret`, `name`, `mediaPath`, `advertiseHost`); replaces the flags |
|
||||
| `--[no-]yt-dlp` | on | read web pages (YouTube, X, …) with yt-dlp when the server fetches a link |
|
||||
| `--yt-dlp-dir` | next to the binary | writable folder for the server's yt-dlp (systemd: the unit's `StateDirectory`) |
|
||||
| `--yt-dlp-cookies` | none | cookies.txt or cookie-extension JSON export for that yt-dlp (sites that refuse guests); copied into the yt-dlp folder at start, removed from it when not given. **An account login**: keep it private, use a throwaway account for YouTube |
|
||||
| `--config` | none | YAML or JSON file with the same settings (`port`, `secret`, `name`, `mediaPath`, `advertiseHost`, `ytDlp`, `ytDlpDir`, `ytDlpCookies`); replaces the flags |
|
||||
| `--upgrade` | | update the install folder to the latest release ([Install and update](#install-and-update)), then exit |
|
||||
| `--check-update` | | print whether a newer release exists, then exit |
|
||||
|
||||
It prints `READY port=... name=...` once listening and stops cleanly on
|
||||
Ctrl+C or SIGTERM. A dedicated server has no host: anyone can kick anyone.
|
||||
Ctrl+C or SIGTERM. A dedicated server has no host.
|
||||
To run it as a service, unpack the tarball to `/opt/watch-together` and use
|
||||
[deploy/watch-together.service](deploy/watch-together.service) (copy it to
|
||||
`/etc/systemd/system/`, edit the flags, `systemctl enable --now watch-together`).
|
||||
@@ -149,8 +198,8 @@ of help: Profile; Party & network (the Host card's default port and
|
||||
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; there
|
||||
is nothing to switch yet); Updates; Keyboard shortcuts (read-only); About
|
||||
next episode automatically); Notifications & sounds (what pops up; the sound
|
||||
volume and sound set, and every sound with a preview, its own set and a switch); Updates; Keyboard shortcuts (read-only); About
|
||||
(the version).
|
||||
|
||||
Player settings live in the player: ⚙ at the right end of its controls
|
||||
@@ -162,10 +211,12 @@ hardware decoding (auto, off or custom; the env var `SYNC_RELAY_HWDEC`
|
||||
overrides it); in the player also the subtitle and audio track. Every
|
||||
change applies right away.
|
||||
|
||||
Keys while the player fills the window: Space = ready / unready, Tab =
|
||||
chat & activity, Left/Right = seek 10 s, Shift+Right = skip 1:30,
|
||||
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.
|
||||
@@ -174,7 +225,9 @@ Controls: the seek bar marks chapter starts with a diamond; hovering it
|
||||
shows the time and the chapter title. The chapters button lists them
|
||||
(current one checked; click to jump). "1:30" skips ahead 1:30. Audio and
|
||||
Subtitles buttons (with the track count) list tracks as "Japanese · E-AC3
|
||||
2.0 · title"; subtitles have Off.
|
||||
2.0 · title"; subtitles have Off. Volume (hover the speaker, scroll on
|
||||
it, or Up/Down) goes to 150 % like mpv: past the tick at 100 % the bar
|
||||
turns amber and mpv amplifies.
|
||||
|
||||
The uosc player design (⚙ → Player design, Standard by default,
|
||||
switches right away): video controls modeled on mpv's uosc theme. A thick
|
||||
@@ -189,12 +242,14 @@ the same.
|
||||
|
||||
Skipping: during the opening a "Skip OP" button floats in the corner (it
|
||||
jumps to the end of the opening), during the ending "Skip ED" (to the end
|
||||
of the ending only; what comes after is yours to skip). The opening and
|
||||
of the ending only; what comes after is yours to skip). Any skip button
|
||||
(also "Skip 1:30") shows for 5 seconds when it comes up, then only while
|
||||
the controls are up (move the mouse). The opening and
|
||||
ending come from Jellyfin's intro / outro segments when your Jellyfin has
|
||||
them (needs a segment plugin, e.g. Intro Skipper), else from chapter names
|
||||
(OP, Opening, Intro / ED, Ending, Credits, Outro). Without an opening, on
|
||||
anything longer than 20 minutes, "Skip 1:30" floats there during the first
|
||||
6 minutes.
|
||||
(OP, Opening, Intro / ED, Ending, Credits, Outro). Without an opening or
|
||||
any chapters, on anything longer than 20 minutes, "Skip 1:30" floats there
|
||||
during the first 6 minutes.
|
||||
|
||||
## Ports
|
||||
|
||||
@@ -280,9 +335,24 @@ the others get it:
|
||||
- Through me: your computer downloads it and passes it on, for links only
|
||||
you can open.
|
||||
|
||||
The last two need a link to a video file (not a page like YouTube) and
|
||||
never show the link to anyone. Your upload has to beat the video's
|
||||
bitrate: a 25-minute 2 GB episode is about 11 Mbit/s.
|
||||
Web pages work in all three: the app and the dedicated server each
|
||||
download [yt-dlp](https://github.com/yt-dlp/yt-dlp) by themselves (checked
|
||||
against the release's checksums, updated once a day; Settings → Player
|
||||
shows its version and has "Update now"), so there is nothing to install.
|
||||
Sites that refuse guests (Bilibili answers "HTTP Error 412", YouTube may
|
||||
ask to sign in) need cookies: Settings → Player → yt-dlp → Cookies reads
|
||||
them from a browser, or from a cookies.txt or a cookie extension's JSON
|
||||
export (picked or dropped; every site in it is kept). They stay on this
|
||||
computer and are used by its yt-dlp only (your own playback, "through
|
||||
me", and a server you host). Cookies log yt-dlp in as you on those
|
||||
sites: keep the file private, and for YouTube consider a throwaway
|
||||
account (Google may flag accounts used with yt-dlp).
|
||||
YouTube works in all three too, and so do pages and links that only
|
||||
offer streaming playlists (HLS `.m3u8`, DASH `.mpd`, the HLS of X videos).
|
||||
The last two never show the link to anyone; they can't pass on live
|
||||
streams or DRM-protected video (use "Everyone fetches it" for live).
|
||||
Your upload has to beat the video's bitrate: a 25-minute 2 GB episode is
|
||||
about 11 Mbit/s.
|
||||
|
||||
## Video quality and shaders
|
||||
|
||||
|
||||
@@ -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 |
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||
|
||||
import 'src/screens/home/ui.dart';
|
||||
import 'src/shell/app_shell.dart';
|
||||
import 'src/widgets/app_keys.dart';
|
||||
|
||||
class WatchTogetherApp extends StatelessWidget {
|
||||
const WatchTogetherApp({super.key});
|
||||
@@ -11,7 +12,8 @@ class WatchTogetherApp extends StatelessWidget {
|
||||
return MaterialApp(
|
||||
title: appName,
|
||||
theme: ThemeData.dark(),
|
||||
home: const AppShell(),
|
||||
shortcuts: appShortcuts,
|
||||
home: const AppKeys(child: AppShell()),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,6 +10,7 @@ import '../../screens/session/player_controls.dart' show TrackPick;
|
||||
import '../../shaders/shader_menu.dart';
|
||||
import '../../shaders/shader_presets.dart';
|
||||
import '../../state/settings.dart';
|
||||
import '../volume.dart';
|
||||
import 'controls.dart';
|
||||
import 'menu.dart';
|
||||
import 'style.dart';
|
||||
@@ -166,9 +167,10 @@ class _UoscBadgeButtonState extends State<UoscBadgeButton> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Volume: click mutes / unmutes, the wheel steps 5 %.
|
||||
/// Volume: click mutes / unmutes, hovering shows a [VolumeBar] up to
|
||||
/// [maxVolume], the wheel steps 5 %.
|
||||
class UoscVolumeButton extends StatefulWidget {
|
||||
/// 0..100.
|
||||
/// 0..[maxVolume].
|
||||
final double volume;
|
||||
final ValueChanged<double> onChanged;
|
||||
const UoscVolumeButton({super.key, required this.volume, required this.onChanged});
|
||||
@@ -179,20 +181,43 @@ class UoscVolumeButton extends StatefulWidget {
|
||||
|
||||
class _UoscVolumeButtonState extends State<UoscVolumeButton> {
|
||||
double _beforeMute = 100;
|
||||
bool _hover = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final v = widget.volume;
|
||||
final icon = v <= 0 ? Icons.volume_off_rounded : (v < 50 ? Icons.volume_down_rounded : Icons.volume_up_rounded);
|
||||
return Listener(
|
||||
return MouseRegion(
|
||||
onEnter: (_) => setState(() => _hover = true),
|
||||
onExit: (_) => setState(() => _hover = false),
|
||||
child: Listener(
|
||||
onPointerSignal: (e) {
|
||||
if (e is PointerScrollEvent && e.scrollDelta.dy != 0) {
|
||||
widget.onChanged((v + (e.scrollDelta.dy < 0 ? 5 : -5)).clamp(0.0, 100.0));
|
||||
widget.onChanged(stepVolume(v, e.scrollDelta.dy < 0 ? 5 : -5));
|
||||
}
|
||||
},
|
||||
child: UoscButton(
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
AnimatedSize(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
child: _hover
|
||||
? Padding(
|
||||
padding: const EdgeInsets.only(left: 6, right: 2),
|
||||
child: VolumeBar(
|
||||
key: const ValueKey('uosc-volume-bar'),
|
||||
volume: v,
|
||||
onChanged: widget.onChanged,
|
||||
color: UoscStyle.text,
|
||||
trackColor: UoscStyle.dim.withValues(alpha: 0.5),
|
||||
),
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
),
|
||||
UoscButton(
|
||||
key: const ValueKey('uosc-volume'),
|
||||
icon: icon,
|
||||
color: v > 100 ? boostColor : null,
|
||||
tooltip: 'Volume ${v.round()} % (scroll to change)',
|
||||
onPressed: () {
|
||||
if (v > 0) {
|
||||
@@ -203,6 +228,9 @@ class _UoscVolumeButtonState extends State<UoscVolumeButton> {
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,8 @@ class UoscControls extends StatefulWidget {
|
||||
final Widget? topLeft;
|
||||
final VoidCallback? onDoubleTap;
|
||||
final Duration hideAfter;
|
||||
/// Follows whether the controls are up (for overlays outside them).
|
||||
final ValueNotifier<bool>? shown;
|
||||
const UoscControls({
|
||||
super.key,
|
||||
required this.timeline,
|
||||
@@ -35,6 +37,7 @@ class UoscControls extends StatefulWidget {
|
||||
this.topLeft,
|
||||
this.onDoubleTap,
|
||||
this.hideAfter = const Duration(seconds: 3),
|
||||
this.shown,
|
||||
});
|
||||
|
||||
static const buttonRowHeight = 40.0;
|
||||
@@ -58,6 +61,10 @@ class UoscControlsState extends State<UoscControls> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Not during this build: an overlay listening to [shown] may be built.
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) widget.shown?.value = true;
|
||||
});
|
||||
_hideLater();
|
||||
}
|
||||
|
||||
@@ -69,7 +76,10 @@ class UoscControlsState extends State<UoscControls> {
|
||||
|
||||
/// Show the controls and restart the hide countdown.
|
||||
void wake() {
|
||||
if (!_visible && mounted) setState(() => _visible = true);
|
||||
if (!_visible && mounted) {
|
||||
setState(() => _visible = true);
|
||||
widget.shown?.value = true;
|
||||
}
|
||||
_hideLater();
|
||||
}
|
||||
|
||||
@@ -98,6 +108,7 @@ class UoscControlsState extends State<UoscControls> {
|
||||
void _hide() {
|
||||
if (!mounted || _holds > 0 || !_visible) return;
|
||||
setState(() => _visible = false);
|
||||
widget.shown?.value = false;
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:media_kit/media_kit.dart';
|
||||
|
||||
import '../playback/playback_controller.dart' show PlaybackController;
|
||||
|
||||
/// The player's top volume (mpv's `volume-max`): past 100 % mpv amplifies.
|
||||
const maxVolume = PlaybackController.maxVolume * 1.0;
|
||||
|
||||
/// The part of the volume bar above 100 %.
|
||||
const boostColor = Color(0xFFFFB74D);
|
||||
|
||||
/// [volume] moved by [delta] (wheel, keys), kept within 0..[maxVolume].
|
||||
double stepVolume(double volume, double delta) => (volume + delta).clamp(0.0, maxVolume);
|
||||
|
||||
/// A thin volume track over 0..[maxVolume]: filled up to the volume, the
|
||||
/// part above 100 % in [boostColor], a tick at 100 %. Click or drag sets
|
||||
/// it.
|
||||
class VolumeBar extends StatelessWidget {
|
||||
final double volume;
|
||||
final ValueChanged<double> onChanged;
|
||||
final double width;
|
||||
final Color color;
|
||||
final Color trackColor;
|
||||
const VolumeBar({
|
||||
super.key,
|
||||
required this.volume,
|
||||
required this.onChanged,
|
||||
this.width = 64,
|
||||
this.color = Colors.white,
|
||||
this.trackColor = Colors.white30,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
void at(double x) => onChanged((x / width * maxVolume).clamp(0.0, maxVolume).roundToDouble());
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTapDown: (e) => at(e.localPosition.dx),
|
||||
onHorizontalDragUpdate: (e) => at(e.localPosition.dx),
|
||||
child: SizedBox(
|
||||
width: width,
|
||||
height: 24,
|
||||
child: CustomPaint(painter: _VolumePainter(volume.clamp(0.0, maxVolume), color, trackColor)),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _VolumePainter extends CustomPainter {
|
||||
final double volume;
|
||||
final Color color, trackColor;
|
||||
_VolumePainter(this.volume, this.color, this.trackColor);
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final y = size.height / 2;
|
||||
double x(double v) => size.width * v / maxVolume;
|
||||
final line = Paint()
|
||||
..strokeWidth = 3
|
||||
..strokeCap = StrokeCap.round;
|
||||
canvas.drawLine(Offset(0, y), Offset(size.width, y), line..color = trackColor);
|
||||
final full = x(100);
|
||||
canvas.drawLine(Offset(full, y - 5), Offset(full, y + 5), Paint()
|
||||
..color = trackColor
|
||||
..strokeWidth = 1.5);
|
||||
if (volume > 0) canvas.drawLine(Offset(0, y), Offset(x(volume.clamp(0, 100)), y), line..color = color);
|
||||
if (volume > 100) canvas.drawLine(Offset(full, y), Offset(x(volume), y), line..color = boostColor);
|
||||
canvas.drawCircle(Offset(x(volume), y), 5, Paint()..color = volume > 100 ? boostColor : color);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(_VolumePainter old) =>
|
||||
old.volume != volume || old.color != color || old.trackColor != trackColor;
|
||||
}
|
||||
|
||||
/// Volume for the standard bar (instead of media_kit's, which stops at
|
||||
/// 100): the icon mutes / unmutes, hovering shows a [VolumeBar], the
|
||||
/// wheel steps 5 %.
|
||||
class VolumeButton extends StatefulWidget {
|
||||
final Player player;
|
||||
final FocusNode? focusNode;
|
||||
const VolumeButton({super.key, required this.player, this.focusNode});
|
||||
|
||||
@override
|
||||
State<VolumeButton> createState() => _VolumeButtonState();
|
||||
}
|
||||
|
||||
class _VolumeButtonState extends State<VolumeButton> {
|
||||
bool _hover = false;
|
||||
double _beforeMute = 100;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final player = widget.player;
|
||||
return StreamBuilder<double>(
|
||||
stream: player.stream.volume,
|
||||
initialData: player.state.volume,
|
||||
builder: (context, snap) {
|
||||
final v = snap.data ?? 100;
|
||||
return MouseRegion(
|
||||
onEnter: (_) => setState(() => _hover = true),
|
||||
onExit: (_) => setState(() => _hover = false),
|
||||
child: Listener(
|
||||
onPointerSignal: (e) {
|
||||
if (e is PointerScrollEvent && e.scrollDelta.dy != 0) {
|
||||
player.setVolume(stepVolume(v, e.scrollDelta.dy < 0 ? 5 : -5));
|
||||
}
|
||||
},
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const SizedBox(width: 4),
|
||||
IconButton(
|
||||
key: const ValueKey('volume-button'),
|
||||
focusNode: widget.focusNode,
|
||||
tooltip: 'Volume ${v.round()} %',
|
||||
iconSize: 22,
|
||||
color: Colors.white,
|
||||
onPressed: () {
|
||||
if (v > 0) {
|
||||
_beforeMute = v;
|
||||
player.setVolume(0);
|
||||
} else {
|
||||
player.setVolume(_beforeMute <= 0 ? 100 : _beforeMute);
|
||||
}
|
||||
},
|
||||
icon: Icon(v <= 0 ? Icons.volume_off : (v < 50 ? Icons.volume_down : Icons.volume_up)),
|
||||
),
|
||||
AnimatedSize(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
child: _hover
|
||||
? Padding(
|
||||
padding: const EdgeInsets.only(left: 4, right: 12),
|
||||
child: VolumeBar(volume: v, onChanged: player.setVolume),
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,14 +25,14 @@ 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;
|
||||
const SeriesHeader({super.key, required this.group, required this.thumbs});
|
||||
/// Its episodes are listed below; [onTap] opens or closes them.
|
||||
final bool expanded;
|
||||
final VoidCallback? onTap;
|
||||
const SeriesHeader(
|
||||
{super.key, required this.group, required this.thumbs, this.expanded = true, this.onTap});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -49,7 +46,12 @@ class SeriesHeader extends StatelessWidget {
|
||||
?first.parsed.group,
|
||||
].join(' · ');
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 18, bottom: 4),
|
||||
padding: const EdgeInsets.only(top: 14),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
ClipRRect(
|
||||
@@ -76,8 +78,17 @@ class SeriesHeader extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
),
|
||||
if (onTap != null)
|
||||
AnimatedRotation(
|
||||
turns: expanded ? 0.5 : 0,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
child: Icon(Icons.expand_more, color: scheme.onSurfaceVariant),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -92,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({
|
||||
@@ -106,7 +116,6 @@ class FileRow extends StatelessWidget {
|
||||
required this.state,
|
||||
required this.thumbs,
|
||||
required this.onTap,
|
||||
required this.offline,
|
||||
this.indent = true,
|
||||
this.trailing,
|
||||
});
|
||||
@@ -130,9 +139,7 @@ class FileRow extends StatelessWidget {
|
||||
builder: (context, t, _) {
|
||||
final duration = durationText(t);
|
||||
final meta = [?duration, ?size].join(' · ');
|
||||
return _offlineTip(
|
||||
offline,
|
||||
Material(
|
||||
return Material(
|
||||
color: playing ? scheme.primary.withValues(alpha: 0.08) : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: InkWell(
|
||||
@@ -188,7 +195,6 @@ class FileRow extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -205,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
|
||||
@@ -224,9 +228,7 @@ class VideoCard extends StatelessWidget {
|
||||
path: video.path,
|
||||
builder: (context, t, _) {
|
||||
final duration = durationText(t);
|
||||
return _offlineTip(
|
||||
offline,
|
||||
InkWell(
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Column(
|
||||
@@ -283,7 +285,6 @@ class VideoCard extends StatelessWidget {
|
||||
style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -17,7 +17,8 @@ export 'thumb.dart' show ThumbnailLoader;
|
||||
// The Files source: watched folders on the left (chips on top when
|
||||
// narrow), the open one on the right grouped into series and loose
|
||||
// videos, or Recent. A folder is scanned when first opened and on Rescan;
|
||||
// scans are kept for the app session. Playing goes through [onPlay].
|
||||
// scans are kept for the app session. Series start collapsed; the ones
|
||||
// opened stay open for the app session. Playing goes through [onPlay].
|
||||
|
||||
/// Below this width the sidebar turns into chips above the list.
|
||||
const filesNarrowWidth = 700.0;
|
||||
@@ -26,18 +27,29 @@ const filesNarrowWidth = 700.0;
|
||||
final _scans = <String, FolderScan>{};
|
||||
final _running = <String, Future<FolderScan>>{};
|
||||
String? _lastOpen;
|
||||
/// Series opened this session, by `folder\nseries key`.
|
||||
final _expanded = <String>{};
|
||||
final _showRequests = ValueNotifier(0);
|
||||
|
||||
/// Forgets the session's scans and the open folder.
|
||||
/// Opens [dir] (null: Recent) in the Files page: now if one is showing,
|
||||
/// else when it next opens.
|
||||
void showFilesFolder(String? dir) {
|
||||
_lastOpen = dir;
|
||||
_showRequests.value++;
|
||||
}
|
||||
|
||||
/// Forgets the session's scans, open series and the open folder.
|
||||
@visibleForTesting
|
||||
void resetFilesPageSession() {
|
||||
_scans.clear();
|
||||
_running.clear();
|
||||
_expanded.clear();
|
||||
_lastOpen = null;
|
||||
}
|
||||
|
||||
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.
|
||||
@@ -55,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,
|
||||
@@ -73,22 +85,21 @@ class FilesPage extends StatefulWidget {
|
||||
/// A scan grouped for display, made once per scan.
|
||||
class _Library {
|
||||
final GroupedVideos grouped;
|
||||
/// Series rows in order: a header (video null), then its episodes and
|
||||
/// extras.
|
||||
final List<(SeriesGroup, VideoFile?)> rows;
|
||||
final int subtitleFiles;
|
||||
|
||||
_Library(FolderScan scan)
|
||||
: grouped = groupBySeries(scan.videos),
|
||||
rows = [],
|
||||
subtitleFiles = {for (final l in scan.subtitles.values) ...l}.length {
|
||||
for (final g in grouped.series) {
|
||||
rows.add((g, null));
|
||||
for (final v in [...g.episodes, ...g.extras]) {
|
||||
rows.add((g, v));
|
||||
}
|
||||
}
|
||||
}
|
||||
subtitleFiles = {for (final l in scan.subtitles.values) ...l}.length;
|
||||
|
||||
/// Series rows in order: a header (video null), then, for the series
|
||||
/// [open] says, its episodes and extras.
|
||||
List<(SeriesGroup, VideoFile?)> rows(bool Function(SeriesGroup g) open) => [
|
||||
for (final g in grouped.series) ...[
|
||||
(g, null),
|
||||
if (open(g))
|
||||
for (final v in [...g.episodes, ...g.extras]) (g, v),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
final _libraries = Expando<_Library>();
|
||||
@@ -107,6 +118,7 @@ class _FilesPageState extends State<FilesPage> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_showRequests.addListener(_shown);
|
||||
_store.ensureLoaded().then((_) {
|
||||
if (!mounted) return;
|
||||
final open = _open;
|
||||
@@ -118,6 +130,14 @@ class _FilesPageState extends State<FilesPage> {
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_showRequests.removeListener(_shown);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _shown() => _select(_lastOpen);
|
||||
|
||||
Future<FolderScan> _runScan(String dir) => (widget.scan ?? scanFolder)(dir);
|
||||
|
||||
void _select(String? dir) {
|
||||
@@ -187,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;
|
||||
@@ -216,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(
|
||||
@@ -244,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;
|
||||
@@ -325,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,
|
||||
@@ -456,7 +474,6 @@ class _FilesPageState extends State<FilesPage> {
|
||||
state: _stateOf(path),
|
||||
thumbs: _thumbs,
|
||||
onTap: _tapOf(path),
|
||||
offline: !widget.online,
|
||||
onRemove: () => _store.removeRecent(path),
|
||||
);
|
||||
},
|
||||
@@ -477,6 +494,8 @@ class _FilesPageState extends State<FilesPage> {
|
||||
if (scan.truncated) 'stopped after $maxScannedFiles files',
|
||||
].join(' · ');
|
||||
final loose = lib.grouped.loose;
|
||||
String seriesId(SeriesGroup g) => '$dir\n${g.key}';
|
||||
final rows = lib.rows((g) => _expanded.contains(seriesId(g)));
|
||||
return [
|
||||
SliverToBoxAdapter(
|
||||
child: Wrap(
|
||||
@@ -547,13 +566,21 @@ class _FilesPageState extends State<FilesPage> {
|
||||
'It may have been moved, renamed or be on a drive that is not connected.',
|
||||
),
|
||||
),
|
||||
// Series: files whose names carry an episode number.
|
||||
// Series: files whose names carry an episode number; a header
|
||||
// click opens or closes its episodes.
|
||||
SliverList.builder(
|
||||
itemCount: lib.rows.length,
|
||||
itemCount: rows.length,
|
||||
itemBuilder: (context, i) {
|
||||
final (group, v) = lib.rows[i];
|
||||
final (group, v) = rows[i];
|
||||
if (v == null) {
|
||||
return SeriesHeader(key: ValueKey('series:${group.key}'), group: group, thumbs: _thumbs);
|
||||
final id = seriesId(group);
|
||||
return SeriesHeader(
|
||||
key: ValueKey('series:${group.key}'),
|
||||
group: group,
|
||||
thumbs: _thumbs,
|
||||
expanded: _expanded.contains(id),
|
||||
onTap: () => setState(() => _expanded.contains(id) ? _expanded.remove(id) : _expanded.add(id)),
|
||||
);
|
||||
}
|
||||
return FileRow(
|
||||
key: ValueKey(v.path),
|
||||
@@ -564,7 +591,6 @@ class _FilesPageState extends State<FilesPage> {
|
||||
state: _stateOf(v.path),
|
||||
thumbs: _thumbs,
|
||||
onTap: _tapOf(v.path),
|
||||
offline: !widget.online,
|
||||
);
|
||||
},
|
||||
),
|
||||
@@ -600,7 +626,6 @@ class _FilesPageState extends State<FilesPage> {
|
||||
state: _stateOf(v.path),
|
||||
thumbs: _thumbs,
|
||||
onTap: _tapOf(v.path),
|
||||
offline: !widget.online,
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -617,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,
|
||||
@@ -625,7 +649,6 @@ class _RecentRow extends StatefulWidget {
|
||||
required this.state,
|
||||
required this.thumbs,
|
||||
required this.onTap,
|
||||
required this.offline,
|
||||
required this.onRemove,
|
||||
});
|
||||
|
||||
@@ -658,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
|
||||
''';
|
||||
@@ -243,18 +243,20 @@ const _skipTail = Duration(seconds: 1);
|
||||
/// The skip pill at [position]: inside the opening "Skip OP" (to its end),
|
||||
/// inside the ending "Skip ED" (to its end only: what follows, preview or
|
||||
/// more episode, is the viewer's call). Without any opening info on media
|
||||
/// longer than [fallbackMinDuration], "Skip 1:30" during the first
|
||||
/// [fallbackWindow]. Null otherwise.
|
||||
/// longer than [fallbackMinDuration] and without chapters ([chaptered]: a
|
||||
/// chaptered file without an OP chapter has none on purpose), "Skip 1:30"
|
||||
/// during the first [fallbackWindow]. Null otherwise.
|
||||
SkipOffer? skipOfferAt({
|
||||
required Duration position,
|
||||
required Duration duration,
|
||||
required SkipRange? opening,
|
||||
required SkipRange? ending,
|
||||
bool chaptered = false,
|
||||
}) {
|
||||
bool inside(SkipRange r) => position >= r.start && position < r.end - _skipTail;
|
||||
if (opening != null && inside(opening)) return SkipOffer(SkipKind.opening, opening.end);
|
||||
if (ending != null && inside(ending)) return SkipOffer(SkipKind.ending, ending.end);
|
||||
if (opening == null && duration > fallbackMinDuration && position < fallbackWindow) {
|
||||
if (opening == null && !chaptered && duration > fallbackMinDuration && position < fallbackWindow) {
|
||||
final to = position + fallbackSkip;
|
||||
return SkipOffer(SkipKind.fixed, to < duration ? to : duration);
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import '../sync/state_diff.dart';
|
||||
import '../sync/user_seek.dart';
|
||||
import 'frame_watchdog.dart';
|
||||
import 'media_marks.dart';
|
||||
import 'yt_dlp_app.dart' show AppYtDlp, ytdlFormat;
|
||||
|
||||
/// Owns one mpv [Player] (and its [VideoController]) per session: created
|
||||
/// by [init] on join, released by [dispose] on leave, swapped by
|
||||
@@ -32,6 +33,9 @@ class PlaybackController {
|
||||
/// Back buffer (media_kit sets it to bufferSize too): enough for a short
|
||||
/// seek back without holding another 256 MiB.
|
||||
static const maxBackBytes = 64 * 1024 * 1024;
|
||||
/// mpv's `volume-max` (its default is 130): the volume controls go
|
||||
/// this far, past 100 % amplified.
|
||||
static const maxVolume = 150;
|
||||
|
||||
SyncPlayer? _player;
|
||||
VideoController? _video;
|
||||
@@ -39,7 +43,7 @@ class PlaybackController {
|
||||
bool _probing = false;
|
||||
final FrameWatchdog _watchdog = FrameWatchdog();
|
||||
StreamSubscription<PlayerLog>? _logSub;
|
||||
final List<StreamSubscription<Duration>> _timeSubs = [];
|
||||
final List<StreamSubscription<Object?>> _timeSubs = [];
|
||||
String _chapterJson = '';
|
||||
// `glsl-shaders` as last set on the current player ('' = none).
|
||||
String _shaders = '';
|
||||
@@ -57,6 +61,36 @@ class PlaybackController {
|
||||
/// Called once when the watchdog sees a frozen picture.
|
||||
void Function()? onFrozen;
|
||||
|
||||
/// mpv's yt-dlp hook could not open the current link (its error text).
|
||||
void Function(String error)? onYtdlError;
|
||||
|
||||
/// Points mpv's yt-dlp hook at [path] (the app's managed copy) for web
|
||||
/// page links, at most 1080p, once per player; its cookies
|
||||
/// (`ytdl-raw-options`) whenever they changed.
|
||||
Future<void> useYtDlp(String path) async {
|
||||
final p = _player;
|
||||
if (p == null) return;
|
||||
try {
|
||||
final dynamic platform = p.platform;
|
||||
if (!identical(p, _ytdlSetOn)) {
|
||||
await platform?.setProperty('script-opts', 'ytdl_hook-ytdl_path=$path');
|
||||
await platform?.setProperty('ytdl-format', ytdlFormat);
|
||||
_ytdlSetOn = p;
|
||||
_ytdlRaw = '';
|
||||
}
|
||||
final raw = await AppYtDlp.instance.mpvRawOptions();
|
||||
if (raw != _ytdlRaw && identical(p, _player)) {
|
||||
await platform?.setProperty('ytdl-raw-options', raw);
|
||||
_ytdlRaw = raw;
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('yt-dlp path not set: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Player? _ytdlSetOn;
|
||||
String _ytdlRaw = '';
|
||||
|
||||
/// Fingerprint of the picture on screen, installed by the video view
|
||||
/// (`ShownFrameProbe.sample`). Without it the watchdog stays idle.
|
||||
Future<int?> Function()? shownFrame;
|
||||
@@ -163,6 +197,7 @@ class PlaybackController {
|
||||
_logSub = _player!.stream.log.listen(_onLog);
|
||||
_watchMarks(_player!);
|
||||
await _pinBufferProps();
|
||||
await _pin('volume-max', '$maxVolume');
|
||||
await applyShaders();
|
||||
_watchdog.reset();
|
||||
_watchTimer = Timer.periodic(const Duration(seconds: 1), (_) => _watch());
|
||||
@@ -294,6 +329,11 @@ class PlaybackController {
|
||||
// an error there while shaders are on is a shader that failed to load
|
||||
// or compile (mpv then renders without it).
|
||||
void _onLog(PlayerLog log) {
|
||||
if (log.prefix == 'ytdl_hook' && (log.level == 'error' || log.level == 'fatal')) {
|
||||
final text = log.text.trim().replaceFirst(RegExp(r'^ERROR:\s*'), '');
|
||||
if (text.isNotEmpty) onYtdlError?.call(text);
|
||||
return;
|
||||
}
|
||||
if (log.prefix != 'libmpv_render') return;
|
||||
if (log.text.contains('suspected software renderer')) {
|
||||
_setRenderMode(RenderMode.softwareGl);
|
||||
@@ -426,9 +466,18 @@ class PlaybackController {
|
||||
// track-list observed in mpv (read back as JSON).
|
||||
void _watchMarks(SyncPlayer p) {
|
||||
_timeSubs.add(p.stream.position.listen((d) {
|
||||
// At the end the bar shows full: mpv's last position is the last
|
||||
// frame's start (and the 250 ms steps round down), short of duration.
|
||||
if (p.state.completed && duration.value > Duration.zero) {
|
||||
position.value = duration.value;
|
||||
return;
|
||||
}
|
||||
final ms = d.inMilliseconds;
|
||||
position.value = Duration(milliseconds: ms - ms % 250);
|
||||
}));
|
||||
_timeSubs.add(p.stream.completed.listen((done) {
|
||||
if (done && duration.value > Duration.zero) position.value = duration.value;
|
||||
}));
|
||||
_timeSubs.add(p.stream.buffer.listen((d) {
|
||||
final ms = d.inMilliseconds;
|
||||
buffered.value = Duration(milliseconds: ms - ms % 250);
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -102,6 +102,9 @@ List<ScheduleEntry> matchSchedule(
|
||||
final lib = <int, (String, int)>{};
|
||||
for (final item in library) {
|
||||
if (item.jellyfinSeriesId.isEmpty) continue;
|
||||
for (final r in item.anilistMap) {
|
||||
lib[r.anilistId] ??= (item.jellyfinSeriesId, r.season);
|
||||
}
|
||||
for (final e in item.anilistIds.entries) {
|
||||
lib[e.value] ??= (item.jellyfinSeriesId, e.key);
|
||||
}
|
||||
|
||||
@@ -13,8 +13,8 @@ enum CardAction {
|
||||
none,
|
||||
putUp,
|
||||
|
||||
/// Available, but not in a server: shown disabled with a hint.
|
||||
playOffline,
|
||||
/// Available, nobody else watching (offline or solo): plays it here.
|
||||
play,
|
||||
request,
|
||||
requestSeries,
|
||||
requested,
|
||||
@@ -162,15 +162,10 @@ class AiringCard extends StatelessWidget {
|
||||
final spinner = const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2));
|
||||
Widget? b = switch (action) {
|
||||
CardAction.none => null,
|
||||
CardAction.putUp => FilledButton.tonalIcon(
|
||||
CardAction.putUp || CardAction.play => FilledButton.tonalIcon(
|
||||
onPressed: enabled ? onAction : null,
|
||||
icon: busy ? spinner : const Icon(Icons.play_arrow, size: 18),
|
||||
label: const Text('Put it up'),
|
||||
),
|
||||
CardAction.playOffline => FilledButton.tonalIcon(
|
||||
onPressed: null,
|
||||
icon: const Icon(Icons.play_arrow, size: 18),
|
||||
label: const Text('Play'),
|
||||
label: Text(action == CardAction.play ? 'Play' : 'Put it up'),
|
||||
),
|
||||
CardAction.request || CardAction.requestSeries => OutlinedButton.icon(
|
||||
onPressed: enabled ? onAction : null,
|
||||
@@ -216,7 +211,7 @@ class AiringCard extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
);
|
||||
final tap = action == CardAction.putUp && disabledHint == null && !busy ? onAction : null;
|
||||
final tap = (action == CardAction.putUp || action == CardAction.play) && disabledHint == null && !busy ? onAction : null;
|
||||
if (compact) {
|
||||
final requestable = action == CardAction.request || action == CardAction.requestSeries;
|
||||
return Material(
|
||||
|
||||
@@ -21,8 +21,9 @@ import 'schedule_card.dart';
|
||||
enum ScheduleFilter { jellyfin, following, all }
|
||||
|
||||
class SchedulePage extends StatefulWidget {
|
||||
/// In a server: available episodes can be put up.
|
||||
final bool online;
|
||||
/// Nobody else watches (offline or solo): available episodes say Play
|
||||
/// (offline that starts a solo session).
|
||||
final bool solo;
|
||||
|
||||
/// Puts a Jellyfin episode up in the server; returns an error to show.
|
||||
final Future<String?> Function(JellyfinMedia pick) onPutUp;
|
||||
@@ -36,7 +37,7 @@ class SchedulePage extends StatefulWidget {
|
||||
|
||||
const SchedulePage({
|
||||
super.key,
|
||||
required this.online,
|
||||
this.solo = false,
|
||||
required this.onPutUp,
|
||||
required this.onToast,
|
||||
this.repository,
|
||||
@@ -215,9 +216,9 @@ class _SchedulePageState extends State<SchedulePage> {
|
||||
|
||||
(CardAction, String?) _actionFor(ScheduleEntry e) {
|
||||
if (e.status == ScheduleStatus.available || e.status == ScheduleStatus.inLibrary) {
|
||||
if (!widget.online) return (CardAction.playOffline, 'Join or host a server to put it up');
|
||||
if (e.jellyfinSeriesId == null) return (CardAction.putUp, 'Not linked to a Jellyfin series yet');
|
||||
return (CardAction.putUp, null);
|
||||
final put = widget.solo ? CardAction.play : CardAction.putUp;
|
||||
if (e.jellyfinSeriesId == null) return (put, 'Not linked to a Jellyfin series yet');
|
||||
return (put, null);
|
||||
}
|
||||
final me = _me;
|
||||
final CardAction kind;
|
||||
@@ -242,6 +243,7 @@ class _SchedulePageState extends State<SchedulePage> {
|
||||
Future<void> _onAction(ScheduleEntry e, CardAction action) async {
|
||||
switch (action) {
|
||||
case CardAction.putUp:
|
||||
case CardAction.play:
|
||||
return _putUp(e);
|
||||
case CardAction.request:
|
||||
case CardAction.requestSeries:
|
||||
@@ -501,7 +503,6 @@ class _SchedulePageState extends State<SchedulePage> {
|
||||
(byDay[DateTime(l.year, l.month, l.day)] ??= []).add(e);
|
||||
}
|
||||
return HScroll(
|
||||
wheel: false,
|
||||
controller: _scroll,
|
||||
builder: (c) => ListView.separated(
|
||||
key: const ValueKey('schedule-board'),
|
||||
@@ -531,7 +532,7 @@ class _SchedulePageState extends State<SchedulePage> {
|
||||
|
||||
Widget _card(ScheduleEntry e, DateTime now) {
|
||||
final (action, hint) = _actionFor(e);
|
||||
final busy = _busy.contains(action == CardAction.putUp ? 'play:${_key(e)}' : _key(e));
|
||||
final busy = _busy.contains(action == CardAction.putUp || action == CardAction.play ? 'play:${_key(e)}' : _key(e));
|
||||
return AiringCard(
|
||||
entry: e,
|
||||
now: now,
|
||||
|
||||
@@ -1,920 +0,0 @@
|
||||
part of 'shell_demo.dart';
|
||||
|
||||
// DEBUG ONLY: the Files source for real. Folders you add are scanned for
|
||||
// videos (everything else is counted and hidden), grouped into series by
|
||||
// a small anitomy-style name parser; a click plays the file in the demo
|
||||
// player. Kept in memory for the demo session.
|
||||
|
||||
const _videoExt = {
|
||||
'mkv',
|
||||
'mp4',
|
||||
'webm',
|
||||
'avi',
|
||||
'mov',
|
||||
'm4v',
|
||||
'ts',
|
||||
'm2ts',
|
||||
'wmv',
|
||||
'flv',
|
||||
};
|
||||
const _subExt = {'ass', 'ssa', 'srt', 'vtt', 'sup'};
|
||||
|
||||
class _VideoFile {
|
||||
final String path;
|
||||
final int size;
|
||||
final _ParsedName parsed;
|
||||
const _VideoFile(this.path, this.size, this.parsed);
|
||||
String get name => _baseName(path);
|
||||
String get folder => _baseName(File(path).parent.path);
|
||||
}
|
||||
|
||||
class _FolderScan {
|
||||
final List<_VideoFile> videos;
|
||||
final int hidden;
|
||||
final int subtitles;
|
||||
final List<String> hiddenExamples;
|
||||
final bool truncated;
|
||||
const _FolderScan(
|
||||
this.videos,
|
||||
this.hidden,
|
||||
this.subtitles,
|
||||
this.hiddenExamples,
|
||||
this.truncated,
|
||||
);
|
||||
}
|
||||
|
||||
final _folders = ValueNotifier<List<String>>([]);
|
||||
final _scans = <String, _FolderScan>{};
|
||||
final _recentFiles = ValueNotifier<List<_VideoFile>>([]);
|
||||
|
||||
/// What a release file name says: series, season, episode, group.
|
||||
class _ParsedName {
|
||||
final String series;
|
||||
final int? season;
|
||||
final int? episode;
|
||||
final String? group;
|
||||
const _ParsedName(this.series, {this.season, this.episode, this.group});
|
||||
|
||||
String get label {
|
||||
if (episode == null) return series;
|
||||
final ep = 'E${episode.toString().padLeft(2, '0')}';
|
||||
return season == null ? ep : 'S${season.toString().padLeft(2, '0')}$ep';
|
||||
}
|
||||
}
|
||||
|
||||
_ParsedName _parseName(String file) {
|
||||
var s = file;
|
||||
final dot = s.lastIndexOf('.');
|
||||
if (dot > 0) s = s.substring(0, dot);
|
||||
final group = RegExp(r'^\s*\[([^\]]+)\]').firstMatch(s)?.group(1);
|
||||
// Brackets/parens hold group, resolution, codec, CRC.
|
||||
s = s.replaceAll(RegExp(r'\[[^\]]*\]|\([^)]*\)'), ' ');
|
||||
// Dots/underscores as separators (scene style), unless it has spaces.
|
||||
if (!s.contains(' ') || RegExp(r'\w\.\w').allMatches(s).length > 2) {
|
||||
s = s.replaceAll(RegExp(r'[._]'), ' ');
|
||||
}
|
||||
int? season, episode;
|
||||
var cut = s.length;
|
||||
final se = RegExp(
|
||||
r'\bS(\d{1,2})\s?E(\d{1,4})\b',
|
||||
caseSensitive: false,
|
||||
).firstMatch(s);
|
||||
if (se != null) {
|
||||
season = int.parse(se.group(1)!);
|
||||
episode = int.parse(se.group(2)!);
|
||||
cut = se.start;
|
||||
} else {
|
||||
final dash = RegExp(r'\s-\s(\d{1,4})(?:v\d)?\b').firstMatch(s);
|
||||
final ep = RegExp(
|
||||
r'\b(?:E|EP|Episode)\s?(\d{1,4})\b',
|
||||
caseSensitive: false,
|
||||
).firstMatch(s);
|
||||
final m = dash ?? ep;
|
||||
if (m != null) {
|
||||
episode = int.parse(m.group(1)!);
|
||||
cut = m.start;
|
||||
}
|
||||
}
|
||||
var series = s.substring(0, cut);
|
||||
// Season markers inside the title ("Title S2", "Title 2nd Season").
|
||||
final sInTitle = RegExp(
|
||||
r'\s(?:S(\d{1,2})|(\d)(?:st|nd|rd|th) Season)\s*$',
|
||||
caseSensitive: false,
|
||||
).firstMatch(series);
|
||||
if (sInTitle != null && season == null) {
|
||||
season = int.parse(sInTitle.group(1) ?? sInTitle.group(2)!);
|
||||
}
|
||||
// Release junk after the title.
|
||||
series = series
|
||||
.replaceAll(
|
||||
RegExp(
|
||||
r'\b(1080p|720p|2160p|480p|x26[45]|HEVC|WEB(-?DL|Rip)?|BD(Rip)?|BluRay|AAC|FLAC|10bit)\b.*',
|
||||
caseSensitive: false,
|
||||
),
|
||||
'',
|
||||
)
|
||||
.replaceAll(RegExp(r'\s+'), ' ')
|
||||
.replaceAll(RegExp(r'[\s\-–:]+$'), '')
|
||||
.trim();
|
||||
if (series.isEmpty) series = file;
|
||||
return _ParsedName(series, season: season, episode: episode, group: group);
|
||||
}
|
||||
|
||||
Future<_FolderScan> _scanFolder(String dir) async {
|
||||
final videos = <_VideoFile>[];
|
||||
var hidden = 0, subs = 0;
|
||||
final examples = <String>[];
|
||||
var seen = 0;
|
||||
var truncated = false;
|
||||
try {
|
||||
await for (final e in Directory(
|
||||
dir,
|
||||
).list(recursive: true, followLinks: false)) {
|
||||
if (e is! File) continue;
|
||||
if (++seen > 20000) {
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
final name = e.path.split(Platform.pathSeparator).last;
|
||||
if (name.startsWith('.')) continue;
|
||||
final ext = name.contains('.') ? name.split('.').last.toLowerCase() : '';
|
||||
if (_subExt.contains(ext)) {
|
||||
subs++;
|
||||
continue;
|
||||
}
|
||||
final isVideo = _videoExt.contains(ext);
|
||||
final lower = name.toLowerCase();
|
||||
int size = 0;
|
||||
try {
|
||||
size = await e.length();
|
||||
} catch (_) {}
|
||||
// Samples and trailers next to the real thing.
|
||||
final junk =
|
||||
isVideo &&
|
||||
(lower.contains('sample') || lower.contains('trailer')) &&
|
||||
size < 300 << 20;
|
||||
if (!isVideo || junk) {
|
||||
hidden++;
|
||||
if (examples.length < 6) examples.add(name);
|
||||
continue;
|
||||
}
|
||||
videos.add(_VideoFile(e.path, size, _parseName(name)));
|
||||
}
|
||||
} catch (_) {
|
||||
// Unreadable subfolders: show what was found.
|
||||
}
|
||||
return _FolderScan(videos, hidden, subs, examples, truncated);
|
||||
}
|
||||
|
||||
String _size(int b) => b >= 1 << 30
|
||||
? '${(b / (1 << 30)).toStringAsFixed(1)} GB'
|
||||
: '${(b / (1 << 20)).toStringAsFixed(0)} MB';
|
||||
|
||||
class _RealFilesTab extends StatefulWidget {
|
||||
final bool online;
|
||||
final VoidCallback onPlay;
|
||||
const _RealFilesTab({required this.online, required this.onPlay});
|
||||
|
||||
@override
|
||||
State<_RealFilesTab> createState() => _RealFilesTabState();
|
||||
}
|
||||
|
||||
class _RealFilesTabState extends State<_RealFilesTab> {
|
||||
String? _open; // null = Recent
|
||||
bool _scanning = false;
|
||||
bool _showOther = false;
|
||||
|
||||
Future<void> _addFolder() async {
|
||||
String? dir;
|
||||
try {
|
||||
dir = await FilePicker.getDirectoryPath(
|
||||
dialogTitle: 'Add a folder with videos',
|
||||
);
|
||||
} catch (_) {}
|
||||
if (dir == null || _folders.value.contains(dir)) return;
|
||||
_folders.value = [..._folders.value, dir];
|
||||
await _select(dir, rescan: true);
|
||||
}
|
||||
|
||||
Future<void> _select(String? dir, {bool rescan = false}) async {
|
||||
setState(() {
|
||||
_open = dir;
|
||||
_showOther = false;
|
||||
});
|
||||
if (dir == null || (_scans.containsKey(dir) && !rescan)) return;
|
||||
setState(() => _scanning = true);
|
||||
final scan = await _scanFolder(dir);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_scans[dir] = scan;
|
||||
_scanning = false;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _browse() async {
|
||||
List<PlatformFile> files = const [];
|
||||
try {
|
||||
files = await FilePicker.pickFiles(
|
||||
dialogTitle: 'Pick a video',
|
||||
type: FileType.custom,
|
||||
allowedExtensions: _videoExt.toList(),
|
||||
);
|
||||
} catch (_) {}
|
||||
final path = files.isEmpty ? null : files.first.path;
|
||||
if (path == null) return;
|
||||
final f = File(path);
|
||||
await _play(
|
||||
_VideoFile(path, await f.length(), _parseName(f.uri.pathSegments.last)),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _play(_VideoFile v) async {
|
||||
_recentFiles.value = [
|
||||
v,
|
||||
..._recentFiles.value.where((x) => x.path != v.path),
|
||||
].take(30).toList();
|
||||
// Structured names read as "Series · S01E03"; anything else as the
|
||||
// cleaned name and the folder it is in.
|
||||
await _playMedia(
|
||||
_DemoMedia(
|
||||
v.parsed.series,
|
||||
v.parsed.episode == null ? v.folder : v.parsed.label,
|
||||
v.path,
|
||||
),
|
||||
);
|
||||
widget.onPlay();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return LayoutBuilder(
|
||||
builder: (context, box) {
|
||||
final narrow = box.maxWidth < 700;
|
||||
final list = _list(context, narrow);
|
||||
if (narrow) return list;
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(width: 260, child: _sidebar(context)),
|
||||
Expanded(child: list),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _sidebar(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
return ValueListenableBuilder(
|
||||
valueListenable: _folders,
|
||||
builder: (context, folders, _) => ListView(
|
||||
padding: const EdgeInsets.fromLTRB(20, 20, 8, 20),
|
||||
children: [
|
||||
const Text('Folders', style: TextStyle(fontWeight: FontWeight.w600)),
|
||||
const SizedBox(height: 8),
|
||||
ListTile(
|
||||
dense: true,
|
||||
selected: _open == null,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
leading: const Icon(Icons.history, size: 18),
|
||||
title: const Text('Recent'),
|
||||
onTap: () => _select(null),
|
||||
),
|
||||
for (final f in folders)
|
||||
ListTile(
|
||||
dense: true,
|
||||
selected: _open == f,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
leading: const Icon(Icons.folder_outlined, size: 18),
|
||||
title: Text(
|
||||
_baseName(f),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
subtitle: Text(
|
||||
_scans[f] == null
|
||||
? 'scanning…'
|
||||
: '${_scans[f]!.videos.length} videos',
|
||||
style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 11),
|
||||
),
|
||||
trailing: IconButton(
|
||||
tooltip: 'Remove folder',
|
||||
icon: const Icon(Icons.close, size: 16),
|
||||
onPressed: () {
|
||||
_folders.value = [...folders]..remove(f);
|
||||
_scans.remove(f);
|
||||
if (_open == f) _select(null);
|
||||
},
|
||||
),
|
||||
onTap: () => _select(f),
|
||||
),
|
||||
TextButton.icon(
|
||||
onPressed: _addFolder,
|
||||
icon: const Icon(Icons.add, size: 18),
|
||||
label: const Text('Add folder'),
|
||||
),
|
||||
const Divider(),
|
||||
OutlinedButton.icon(
|
||||
onPressed: _browse,
|
||||
icon: const Icon(Icons.folder_open, size: 18),
|
||||
label: const Text('Browse…'),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
const Text(
|
||||
'The system file picker, for anything outside your folders.',
|
||||
style: TextStyle(color: Colors.grey, fontSize: 11),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _list(BuildContext context, bool narrow) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final open = _open;
|
||||
final scan = open == null ? null : _scans[open];
|
||||
final children = <Widget>[
|
||||
// Where you are: Recent, or the folder and its path.
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 14),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
open == null ? Icons.history : Icons.folder,
|
||||
color: scheme.primary,
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
open == null ? 'Recent' : _baseName(open),
|
||||
style: Theme.of(context).textTheme.headlineSmall,
|
||||
),
|
||||
if (open != null)
|
||||
Text(
|
||||
open,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: scheme.onSurfaceVariant,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (narrow)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 14),
|
||||
child: ValueListenableBuilder(
|
||||
valueListenable: _folders,
|
||||
builder: (context, folders, _) => Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
ChoiceChip(
|
||||
label: const Text('Recent'),
|
||||
selected: open == null,
|
||||
onSelected: (_) => _select(null),
|
||||
),
|
||||
for (final f in folders)
|
||||
ChoiceChip(
|
||||
label: Text(_baseName(f)),
|
||||
selected: open == f,
|
||||
onSelected: (_) => _select(f),
|
||||
),
|
||||
ActionChip(
|
||||
avatar: const Icon(Icons.add, size: 16),
|
||||
label: const Text('Folder'),
|
||||
onPressed: _addFolder,
|
||||
),
|
||||
ActionChip(
|
||||
avatar: const Icon(Icons.folder_open, size: 16),
|
||||
label: const Text('Browse…'),
|
||||
onPressed: _browse,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (widget.online)
|
||||
Container(
|
||||
padding: const EdgeInsets.all(10),
|
||||
margin: const EdgeInsets.only(bottom: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.lightBlueAccent.withValues(alpha: 0.08),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: const Row(
|
||||
children: [
|
||||
Icon(Icons.cell_tower, size: 18, color: Colors.lightBlueAccent),
|
||||
SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Files stream from this computer through the server. Keep the app open while it plays.',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
];
|
||||
if (open == null) {
|
||||
children.add(
|
||||
ValueListenableBuilder(
|
||||
valueListenable: _recentFiles,
|
||||
builder: (context, recent, _) => recent.isEmpty
|
||||
? _empty(
|
||||
context,
|
||||
Icons.video_library_outlined,
|
||||
'Nothing played yet',
|
||||
'Add a folder with your videos, or Browse for a single file.',
|
||||
action: FilledButton.icon(
|
||||
onPressed: _addFolder,
|
||||
icon: const Icon(Icons.create_new_folder_outlined),
|
||||
label: const Text('Add folder'),
|
||||
),
|
||||
)
|
||||
: Column(
|
||||
children: [
|
||||
for (final v in recent)
|
||||
_episodeTile(context, v, showSeries: true),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
} else if (_scanning || scan == null) {
|
||||
children.add(
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(top: 60),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
final groups = <String, List<_VideoFile>>{};
|
||||
for (final v in scan.videos) {
|
||||
groups.putIfAbsent(v.parsed.series, () => []).add(v);
|
||||
}
|
||||
final names = groups.keys.toList()
|
||||
..sort((a, b) => a.toLowerCase().compareTo(b.toLowerCase()));
|
||||
for (final g in groups.values) {
|
||||
g.sort((a, b) {
|
||||
final s = (a.parsed.season ?? 0).compareTo(b.parsed.season ?? 0);
|
||||
if (s != 0) return s;
|
||||
final e = (a.parsed.episode ?? 0).compareTo(b.parsed.episode ?? 0);
|
||||
return e != 0 ? e : a.name.compareTo(b.name);
|
||||
});
|
||||
}
|
||||
children.add(
|
||||
Wrap(
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.filter_alt_outlined,
|
||||
size: 16,
|
||||
color: scheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
'${scan.videos.length} videos in ${groups.length} series'
|
||||
'${scan.hidden > 0 ? ' · ${scan.hidden} other files hidden' : ''}'
|
||||
'${scan.subtitles > 0 ? ' · ${scan.subtitles} subtitle files' : ''}'
|
||||
'${scan.truncated ? ' · stopped after 20 000 files' : ''}',
|
||||
style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12),
|
||||
),
|
||||
if (scan.hidden > 0) ...[
|
||||
const SizedBox(width: 8),
|
||||
TextButton(
|
||||
onPressed: () => setState(() => _showOther = !_showOther),
|
||||
child: Text(_showOther ? 'Hide them' : 'Show some'),
|
||||
),
|
||||
],
|
||||
TextButton.icon(
|
||||
onPressed: () => _select(open, rescan: true),
|
||||
icon: const Icon(Icons.refresh, size: 16),
|
||||
label: const Text('Rescan'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
children.add(
|
||||
AnimatedSize(
|
||||
duration: _easeTime,
|
||||
curve: _ease,
|
||||
alignment: Alignment.topLeft,
|
||||
child: !_showOther
|
||||
? const SizedBox(width: double.infinity)
|
||||
: Column(
|
||||
children: [
|
||||
for (final f in scan.hiddenExamples)
|
||||
ListTile(
|
||||
dense: true,
|
||||
enabled: false,
|
||||
leading: const Icon(
|
||||
Icons.insert_drive_file_outlined,
|
||||
size: 18,
|
||||
),
|
||||
title: Text(f),
|
||||
trailing: const Text(
|
||||
'not a video',
|
||||
style: TextStyle(fontSize: 12),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
if (scan.videos.isEmpty) {
|
||||
children.add(
|
||||
_empty(
|
||||
context,
|
||||
Icons.search_off,
|
||||
'No videos here',
|
||||
'Only video files are listed (mkv, mp4, webm…).',
|
||||
),
|
||||
);
|
||||
}
|
||||
// Series: files whose names carry an episode number. Everything
|
||||
// else is a loose video, shown as a thumbnail grid.
|
||||
final loose = <_VideoFile>[];
|
||||
for (final name in names) {
|
||||
final eps = groups[name]!;
|
||||
if (eps.every((e) => e.parsed.episode == null)) {
|
||||
loose.addAll(eps);
|
||||
continue;
|
||||
}
|
||||
children.add(
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 18, bottom: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: _Thumb(eps.first.path, width: 96, height: 54),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
name,
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
Text(
|
||||
'${eps.length} ${eps.length == 1 ? 'episode' : 'episodes'}'
|
||||
'${eps.first.parsed.group == null ? '' : ' · ${eps.first.parsed.group}'}',
|
||||
style: const TextStyle(
|
||||
color: Colors.grey,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
for (final v in eps) {
|
||||
children.add(_episodeTile(context, v));
|
||||
}
|
||||
}
|
||||
if (loose.isNotEmpty) {
|
||||
children.add(
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 22, bottom: 10),
|
||||
child: Text(
|
||||
names.length == loose.length ? 'Videos' : 'Other videos',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
),
|
||||
);
|
||||
children.add(
|
||||
LayoutBuilder(
|
||||
builder: (context, box) {
|
||||
const gap = 12.0;
|
||||
final cols = (box.maxWidth / 240).floor().clamp(2, 8);
|
||||
final w = (box.maxWidth - gap * (cols - 1)) / cols;
|
||||
return Wrap(
|
||||
spacing: gap,
|
||||
runSpacing: gap,
|
||||
children: [
|
||||
for (final v in loose)
|
||||
SizedBox(width: w, child: _videoCard(context, v)),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
return ListView(
|
||||
padding: EdgeInsets.fromLTRB(narrow ? 20 : 12, 20, 20, 20),
|
||||
children: children,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _episodeTile(
|
||||
BuildContext context,
|
||||
_VideoFile v, {
|
||||
bool showSeries = false,
|
||||
}) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
return ValueListenableBuilder(
|
||||
valueListenable: _nowPlaying,
|
||||
builder: (context, now, _) {
|
||||
final playing = now?.path == v.path;
|
||||
return ListTile(
|
||||
dense: true,
|
||||
contentPadding: EdgeInsets.only(left: showSeries ? 0 : 20),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
selected: playing,
|
||||
leading: SizedBox(
|
||||
width: 112,
|
||||
child: Row(
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: _Thumb(v.path, width: 80, height: 45),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Icon(
|
||||
playing ? Icons.graphic_eq : Icons.play_circle_outline,
|
||||
size: 20,
|
||||
color: scheme.primary,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
minLeadingWidth: 112,
|
||||
title: Text(
|
||||
showSeries
|
||||
? '${v.parsed.series} · ${v.parsed.label}'
|
||||
: v.parsed.label,
|
||||
),
|
||||
subtitle: Text(
|
||||
showSeries && v.parsed.episode == null ? v.folder : v.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
trailing: Text(
|
||||
_size(v.size),
|
||||
style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12),
|
||||
),
|
||||
onTap: () => _play(v),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _videoCard(BuildContext context, _VideoFile v) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
return ValueListenableBuilder(
|
||||
valueListenable: _nowPlaying,
|
||||
builder: (context, now, _) {
|
||||
final playing = now?.path == v.path;
|
||||
return InkWell(
|
||||
onTap: () => _play(v),
|
||||
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: [
|
||||
_Thumb(v.path),
|
||||
Positioned(
|
||||
right: 6,
|
||||
bottom: 6,
|
||||
child: _DurationBadge(v.path),
|
||||
),
|
||||
if (playing)
|
||||
const Center(
|
||||
child: Icon(
|
||||
Icons.graphic_eq,
|
||||
color: Colors.white,
|
||||
size: 32,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
v.parsed.series,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: playing ? scheme.primary : null,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
_size(v.size),
|
||||
style: const TextStyle(color: Colors.grey, fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _empty(
|
||||
BuildContext context,
|
||||
IconData icon,
|
||||
String title,
|
||||
String sub, {
|
||||
Widget? action,
|
||||
}) => Padding(
|
||||
padding: const EdgeInsets.only(top: 60),
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
size: 44,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Text(title, style: Theme.of(context).textTheme.titleMedium),
|
||||
const SizedBox(height: 4),
|
||||
Text(sub, style: const TextStyle(color: Colors.grey)),
|
||||
if (action != null) ...[const SizedBox(height: 14), action],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _baseName(String path) {
|
||||
final parts = path
|
||||
.split(RegExp(r'[/\\]'))
|
||||
.where((p) => p.isNotEmpty)
|
||||
.toList();
|
||||
return parts.isEmpty ? path : parts.last;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ thumbnails
|
||||
//
|
||||
// Demo: ffmpeg/ffprobe when installed (a frame at 25%, cached in the temp
|
||||
// dir). The real app would ask mpv for the frame instead (no extra tool).
|
||||
|
||||
class _ThumbInfo {
|
||||
final File? image;
|
||||
final Duration? duration;
|
||||
const _ThumbInfo(this.image, this.duration);
|
||||
}
|
||||
|
||||
final _thumbs = <String, Future<_ThumbInfo>>{};
|
||||
var _thumbRunning = 0;
|
||||
final _thumbQueue = <Completer<void>>[];
|
||||
|
||||
Future<_ThumbInfo> _thumbFor(String path) =>
|
||||
_thumbs.putIfAbsent(path, () async {
|
||||
// At most 3 ffmpeg runs at a time.
|
||||
if (_thumbRunning >= 3) {
|
||||
final c = Completer<void>();
|
||||
_thumbQueue.add(c);
|
||||
await c.future;
|
||||
}
|
||||
_thumbRunning++;
|
||||
try {
|
||||
return await _makeThumb(path);
|
||||
} finally {
|
||||
_thumbRunning--;
|
||||
if (_thumbQueue.isNotEmpty) _thumbQueue.removeAt(0).complete();
|
||||
}
|
||||
});
|
||||
|
||||
Future<_ThumbInfo> _makeThumb(String path) async {
|
||||
Duration? dur;
|
||||
try {
|
||||
final r = await Process.run('ffprobe', [
|
||||
'-v',
|
||||
'error',
|
||||
'-show_entries',
|
||||
'format=duration',
|
||||
'-of',
|
||||
'csv=p=0',
|
||||
path,
|
||||
]);
|
||||
final secs = double.tryParse('${r.stdout}'.trim());
|
||||
if (secs != null) dur = Duration(milliseconds: (secs * 1000).round());
|
||||
} catch (_) {
|
||||
return const _ThumbInfo(null, null); // no ffmpeg on this machine
|
||||
}
|
||||
final dir = Directory('${Directory.systemTemp.path}/wt-demo-thumbs')
|
||||
..createSync(recursive: true);
|
||||
final key = path.hashCode.toUnsigned(32).toRadixString(16);
|
||||
final out = File('${dir.path}/$key.jpg');
|
||||
if (out.existsSync() && out.lengthSync() > 0) return _ThumbInfo(out, dur);
|
||||
final at = dur == null ? 5.0 : dur.inMilliseconds / 1000 * 0.25;
|
||||
try {
|
||||
final r = await Process.run('ffmpeg', [
|
||||
'-v',
|
||||
'error',
|
||||
'-y',
|
||||
'-ss',
|
||||
at.toStringAsFixed(2),
|
||||
'-i',
|
||||
path,
|
||||
'-frames:v',
|
||||
'1',
|
||||
'-vf',
|
||||
'scale=480:-2',
|
||||
'-q:v',
|
||||
'4',
|
||||
out.path,
|
||||
]);
|
||||
if (r.exitCode == 0 && out.existsSync()) return _ThumbInfo(out, dur);
|
||||
} catch (_) {}
|
||||
return _ThumbInfo(null, dur);
|
||||
}
|
||||
|
||||
class _Thumb extends StatelessWidget {
|
||||
final String path;
|
||||
final double? width, height;
|
||||
const _Thumb(this.path, {this.width, this.height});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final placeholder = Container(
|
||||
width: width,
|
||||
height: height,
|
||||
color: scheme.surfaceContainerHighest,
|
||||
child: Icon(
|
||||
Icons.movie_outlined,
|
||||
color: scheme.onSurfaceVariant,
|
||||
size: 20,
|
||||
),
|
||||
);
|
||||
return FutureBuilder<_ThumbInfo>(
|
||||
future: _thumbFor(path),
|
||||
builder: (context, snap) {
|
||||
final img = snap.data?.image;
|
||||
return AnimatedSwitcher(
|
||||
duration: _easeTime,
|
||||
child: img == null
|
||||
? placeholder
|
||||
: Image.file(
|
||||
img,
|
||||
key: ValueKey(img.path),
|
||||
width: width,
|
||||
height: height,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DurationBadge extends StatelessWidget {
|
||||
final String path;
|
||||
const _DurationBadge(this.path);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => FutureBuilder<_ThumbInfo>(
|
||||
future: _thumbFor(path),
|
||||
builder: (context, snap) {
|
||||
final d = snap.data?.duration;
|
||||
if (d == null) return const SizedBox.shrink();
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black87,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
_fmt(d),
|
||||
style: const TextStyle(color: Colors.white, fontSize: 11),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -1,577 +0,0 @@
|
||||
part of 'shell_demo.dart';
|
||||
|
||||
// DEBUG ONLY: a real media_kit player for the layout demo. One Player and
|
||||
// VideoController, shown by whichever view is active (mini, full or
|
||||
// fullscreen); only one Video widget is mounted at a time.
|
||||
|
||||
class _DemoMedia {
|
||||
final String title;
|
||||
final String subtitle;
|
||||
final String? path;
|
||||
const _DemoMedia(this.title, this.subtitle, [this.path]);
|
||||
}
|
||||
|
||||
/// What is playing; null = nothing.
|
||||
final _nowPlaying = ValueNotifier<_DemoMedia?>(null);
|
||||
Player? _demoPlayer;
|
||||
VideoController? _demoVideo;
|
||||
|
||||
Future<void> _playMedia(_DemoMedia m) async {
|
||||
_nowPlaying.value = m;
|
||||
if (m.path == null) return;
|
||||
final p = _demoPlayer ??= Player();
|
||||
_demoVideo ??= VideoController(p);
|
||||
await p.open(Media(m.path!));
|
||||
}
|
||||
|
||||
Future<void> _disposeDemoPlayer() async {
|
||||
final p = _demoPlayer;
|
||||
_demoPlayer = null;
|
||||
_demoVideo = null;
|
||||
_nowPlaying.value = null;
|
||||
await p?.dispose();
|
||||
}
|
||||
|
||||
String _fmt(Duration d) {
|
||||
final h = d.inHours, m = d.inMinutes % 60, s = d.inSeconds % 60;
|
||||
String two(int n) => n.toString().padLeft(2, '0');
|
||||
return h > 0 ? '$h:${two(m)}:${two(s)}' : '${two(m)}:${two(s)}';
|
||||
}
|
||||
|
||||
/// The video itself, or a placeholder when nothing real is open.
|
||||
class _VideoSurface extends StatelessWidget {
|
||||
final double placeholderSize;
|
||||
const _VideoSurface({this.placeholderSize = 32});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final v = _demoVideo;
|
||||
if (v == null || _nowPlaying.value?.path == null) {
|
||||
return Container(
|
||||
color: Colors.black,
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
'video · play something from Files',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: Colors.white24,
|
||||
fontSize: placeholderSize * 0.6,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return Video(controller: v, controls: NoVideoControls, fill: Colors.black);
|
||||
}
|
||||
}
|
||||
|
||||
/// Position bar + play/pause + time, bound to the real player (inert
|
||||
/// when nothing is open).
|
||||
class _Transport extends StatelessWidget {
|
||||
final bool compact;
|
||||
final List<Widget> trailing;
|
||||
const _Transport({this.compact = false, this.trailing = const []});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final p = _demoPlayer;
|
||||
if (p == null) {
|
||||
return Row(
|
||||
children: [
|
||||
const IconButton(
|
||||
onPressed: null,
|
||||
icon: Icon(Icons.play_arrow, color: Colors.white38),
|
||||
),
|
||||
const Text('--:-- / --:--', style: TextStyle(color: Colors.white38)),
|
||||
const Spacer(),
|
||||
...trailing,
|
||||
],
|
||||
);
|
||||
}
|
||||
return StreamBuilder<Duration>(
|
||||
stream: p.stream.position,
|
||||
builder: (context, _) {
|
||||
final pos = p.state.position, dur = p.state.duration;
|
||||
final frac = dur.inMilliseconds == 0
|
||||
? 0.0
|
||||
: pos.inMilliseconds / dur.inMilliseconds;
|
||||
final bar = SliderTheme(
|
||||
data: SliderTheme.of(context).copyWith(
|
||||
trackHeight: compact ? 3 : 4,
|
||||
thumbShape: RoundSliderThumbShape(
|
||||
enabledThumbRadius: compact ? 0 : 7,
|
||||
),
|
||||
overlayShape: SliderComponentShape.noOverlay,
|
||||
),
|
||||
child: Slider(
|
||||
value: frac.clamp(0.0, 1.0),
|
||||
onChanged: dur == Duration.zero ? null : (v) => p.seek(dur * v),
|
||||
),
|
||||
);
|
||||
final row = Row(
|
||||
children: [
|
||||
StreamBuilder<bool>(
|
||||
stream: p.stream.playing,
|
||||
builder: (context, _) => IconButton(
|
||||
onPressed: p.playOrPause,
|
||||
icon: Icon(
|
||||
p.state.playing ? Icons.pause : Icons.play_arrow,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'${_fmt(pos)} / ${_fmt(dur)}',
|
||||
style: TextStyle(
|
||||
color: Colors.white70,
|
||||
fontSize: compact ? 12 : 14,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
...trailing,
|
||||
],
|
||||
);
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [if (!compact) bar, row, if (compact) bar],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Full and fullscreen player. Controls fade out after 2.5 s without the
|
||||
/// mouse moving; in fullscreen the party sits top-right and toasts stay.
|
||||
class _RealFullPlayer extends StatefulWidget {
|
||||
final bool fullscreen;
|
||||
final VoidCallback onCollapse, onToggleFullscreen;
|
||||
final Widget? partyOverlay;
|
||||
const _RealFullPlayer({
|
||||
required this.fullscreen,
|
||||
required this.onCollapse,
|
||||
required this.onToggleFullscreen,
|
||||
this.partyOverlay,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_RealFullPlayer> createState() => _RealFullPlayerState();
|
||||
}
|
||||
|
||||
class _RealFullPlayerState extends State<_RealFullPlayer> {
|
||||
bool _shown = true;
|
||||
Timer? _hide;
|
||||
|
||||
void _poke() {
|
||||
if (!_shown) setState(() => _shown = true);
|
||||
_hide?.cancel();
|
||||
_hide = Timer(const Duration(milliseconds: 2500), () {
|
||||
if (mounted) setState(() => _shown = false);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_poke();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_hide?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final m = _nowPlaying.value;
|
||||
final controls = AnimatedOpacity(
|
||||
duration: _easeTime,
|
||||
curve: _ease,
|
||||
opacity: _shown ? 1 : 0,
|
||||
child: IgnorePointer(
|
||||
ignoring: !_shown,
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 8, 28),
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [Colors.black87, Colors.transparent],
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 34,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white12,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
m?.title ?? 'Nothing playing',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
m?.subtitle ?? '',
|
||||
style: const TextStyle(color: Colors.white60),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (widget.partyOverlay != null) ...[
|
||||
widget.partyOverlay!,
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
if (!widget.fullscreen)
|
||||
IconButton(
|
||||
onPressed: widget.onCollapse,
|
||||
tooltip: 'Back to the library (keeps playing)',
|
||||
icon: const Icon(
|
||||
Icons.close_fullscreen,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.fromLTRB(16, 32, 16, 10),
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.bottomCenter,
|
||||
end: Alignment.topCenter,
|
||||
colors: [Colors.black87, Colors.transparent],
|
||||
),
|
||||
),
|
||||
child: _Transport(
|
||||
trailing: [
|
||||
Builder(
|
||||
builder: (context) => IconButton(
|
||||
tooltip: 'Player settings',
|
||||
onPressed: () => _popover(
|
||||
context,
|
||||
at: const Alignment(0.97, 0.78),
|
||||
child: const _PlayerSettingsCard(),
|
||||
),
|
||||
icon: const Icon(Icons.tune, color: Colors.white),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: widget.fullscreen
|
||||
? 'Exit fullscreen (Esc)'
|
||||
: 'Fullscreen (F)',
|
||||
onPressed: widget.onToggleFullscreen,
|
||||
icon: Icon(
|
||||
widget.fullscreen
|
||||
? Icons.fullscreen_exit
|
||||
: Icons.fullscreen,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
return CallbackShortcuts(
|
||||
bindings: {
|
||||
const SingleActivator(LogicalKeyboardKey.keyF):
|
||||
widget.onToggleFullscreen,
|
||||
const SingleActivator(LogicalKeyboardKey.escape): () {
|
||||
if (widget.fullscreen) widget.onToggleFullscreen();
|
||||
},
|
||||
const SingleActivator(LogicalKeyboardKey.space): () =>
|
||||
_demoPlayer?.playOrPause(),
|
||||
},
|
||||
child: Focus(
|
||||
autofocus: true,
|
||||
child: MouseRegion(
|
||||
onHover: (_) => _poke(),
|
||||
cursor: _shown ? MouseCursor.defer : SystemMouseCursors.none,
|
||||
child: GestureDetector(
|
||||
onDoubleTap: widget.onToggleFullscreen,
|
||||
onTap: () => _demoPlayer?.playOrPause(),
|
||||
child: Container(
|
||||
color: Colors.black,
|
||||
child: Stack(
|
||||
children: [
|
||||
const Positioned.fill(child: _VideoSurface()),
|
||||
Positioned.fill(child: controls),
|
||||
// A thin progress line stays when the controls hide.
|
||||
if (!_shown && _demoPlayer != null)
|
||||
const Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: _ThinProgress(),
|
||||
),
|
||||
// Paused: who everyone is waiting for, always visible.
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: _shown ? 96 : 28,
|
||||
child: const Center(child: _WaitingPill()),
|
||||
),
|
||||
// Fullscreen with the controls hidden: the party stays
|
||||
// in the corner while paused.
|
||||
if (widget.fullscreen &&
|
||||
!_shown &&
|
||||
widget.partyOverlay != null)
|
||||
Positioned(
|
||||
right: 16,
|
||||
top: 14,
|
||||
child: _PausedOnly(child: widget.partyOverlay!),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ThinProgress extends StatelessWidget {
|
||||
const _ThinProgress();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final p = _demoPlayer!;
|
||||
return StreamBuilder<Duration>(
|
||||
stream: p.stream.position,
|
||||
builder: (context, _) {
|
||||
final d = p.state.duration.inMilliseconds;
|
||||
return LinearProgressIndicator(
|
||||
value: d == 0 ? 0 : p.state.position.inMilliseconds / d,
|
||||
minHeight: 2,
|
||||
backgroundColor: Colors.transparent,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The gear in the player: everything about the player lives here.
|
||||
class _PlayerSettingsCard extends StatefulWidget {
|
||||
const _PlayerSettingsCard();
|
||||
|
||||
@override
|
||||
State<_PlayerSettingsCard> createState() => _PlayerSettingsCardState();
|
||||
}
|
||||
|
||||
class _PlayerSettingsCardState extends State<_PlayerSettingsCard> {
|
||||
String _design = 'Standard';
|
||||
bool _line = true;
|
||||
String _shader = 'Off';
|
||||
String _hwdec = 'Auto';
|
||||
double _subSize = 1;
|
||||
|
||||
Widget _row(String label, Widget control, {String? help}) => Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(label),
|
||||
if (help != null)
|
||||
Text(
|
||||
help,
|
||||
style: const TextStyle(color: Colors.grey, fontSize: 11),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
control,
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
Widget _drop(String value, List<String> items, ValueChanged<String> on) =>
|
||||
DropdownButton<String>(
|
||||
value: value,
|
||||
underline: const SizedBox.shrink(),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
items: [
|
||||
for (final i in items) DropdownMenuItem(value: i, child: Text(i)),
|
||||
],
|
||||
onChanged: (v) => setState(() => on(v!)),
|
||||
);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
elevation: 12,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: SizedBox(
|
||||
width: 380,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(18, 14, 18, 10),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const Text(
|
||||
'Player',
|
||||
style: TextStyle(fontWeight: FontWeight.w700, fontSize: 16),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
_row(
|
||||
'Player design',
|
||||
_drop(_design, const ['Standard', 'uosc'], (v) => _design = v),
|
||||
),
|
||||
_row(
|
||||
'Progress line when hidden',
|
||||
Switch(
|
||||
value: _line,
|
||||
onChanged: (v) => setState(() => _line = v),
|
||||
),
|
||||
help: 'A thin bar at the bottom while the controls are hidden',
|
||||
),
|
||||
_row(
|
||||
'Upscaling shader',
|
||||
_drop(_shader, const [
|
||||
'Off',
|
||||
'Anime4K A (HQ)',
|
||||
'Anime4K B (HQ)',
|
||||
'Anime4K A (Fast)',
|
||||
], (v) => _shader = v),
|
||||
help: 'Runs on your GPU; only you see it',
|
||||
),
|
||||
_row(
|
||||
'Hardware decoding',
|
||||
_drop(_hwdec, const ['Auto', 'On', 'Off'], (v) => _hwdec = v),
|
||||
),
|
||||
const Divider(),
|
||||
_row(
|
||||
'Subtitles',
|
||||
_drop('English · ASS', const [
|
||||
'Off',
|
||||
'English · ASS',
|
||||
'Signs only',
|
||||
], (_) {}),
|
||||
),
|
||||
_row(
|
||||
'Subtitle size',
|
||||
SizedBox(
|
||||
width: 140,
|
||||
child: Slider(
|
||||
value: _subSize,
|
||||
min: 0.6,
|
||||
max: 1.6,
|
||||
onChanged: (v) => setState(() => _subSize = v),
|
||||
),
|
||||
),
|
||||
),
|
||||
_row(
|
||||
'Audio',
|
||||
_drop('Japanese', const ['Japanese', 'English'], (_) {}),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
const Text(
|
||||
'Track choices carry to the next episode.',
|
||||
style: TextStyle(color: Colors.grey, fontSize: 11),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Shows [child] only while the demo player is paused (or has nothing).
|
||||
class _PausedOnly extends StatelessWidget {
|
||||
final Widget child;
|
||||
const _PausedOnly({required this.child});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final p = _demoPlayer;
|
||||
return StreamBuilder<bool>(
|
||||
stream: p?.stream.playing,
|
||||
builder: (context, _) {
|
||||
final paused = p == null || !p.state.playing;
|
||||
return AnimatedOpacity(
|
||||
duration: _easeTime,
|
||||
curve: _ease,
|
||||
opacity: paused ? 1 : 0,
|
||||
child: IgnorePointer(ignoring: !paused, child: child),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// "Waiting for Alex, Mika" or "Waiting for you · press Space", with
|
||||
/// their avatars; only while paused.
|
||||
class _WaitingPill extends StatelessWidget {
|
||||
const _WaitingPill();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final notReady = _members.where((m) => !m.ready && !m.stale).toList();
|
||||
final meWaiting = !_me.value.ready;
|
||||
final text = meWaiting
|
||||
? 'Waiting for you · press Space when ready'
|
||||
: notReady.isEmpty
|
||||
? 'Paused'
|
||||
: 'Waiting for ${notReady.map((m) => m.name).join(', ')}';
|
||||
return _PausedOnly(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.fromLTRB(8, 6, 16, 6),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withValues(alpha: 0.72),
|
||||
borderRadius: BorderRadius.circular(30),
|
||||
border: Border.all(color: Colors.white12),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
for (final m in (meWaiting ? [_me.value] : notReady).take(4))
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 4),
|
||||
child: _Avatar(member: m, size: 28),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
text,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,355 +0,0 @@
|
||||
part of 'shell_demo.dart';
|
||||
|
||||
// DEBUG ONLY: the Settings tab. App-wide things only; everything about
|
||||
// the player lives behind the gear in the player (pointer card at the top
|
||||
// of every page). Controls are placeholders that hold their own state.
|
||||
|
||||
class _SettingsTab extends StatefulWidget {
|
||||
final VoidCallback onOpenPlayerSettings;
|
||||
const _SettingsTab({required this.onOpenPlayerSettings});
|
||||
|
||||
@override
|
||||
State<_SettingsTab> createState() => _SettingsTabState();
|
||||
}
|
||||
|
||||
class _SettingsTabState extends State<_SettingsTab> {
|
||||
static const _sections = [
|
||||
(Icons.person_outline, 'Profile'),
|
||||
(Icons.hub_outlined, 'Party & network'),
|
||||
(Icons.extension_outlined, 'Jellyfin & integrations'),
|
||||
(Icons.notifications_none, 'Notifications & sounds'),
|
||||
(Icons.palette_outlined, 'Appearance'),
|
||||
(Icons.system_update_alt, 'Updates'),
|
||||
(Icons.info_outline, 'About'),
|
||||
];
|
||||
int _at = 0;
|
||||
final _bools = <String, bool>{
|
||||
'toast-chat': true,
|
||||
'toast-ready': true,
|
||||
'toast-seek': false,
|
||||
'unfocused': true,
|
||||
'sounds': true,
|
||||
'reduce-motion': false,
|
||||
'auto-update': true,
|
||||
'auto-next': true,
|
||||
'remember-folders': true,
|
||||
};
|
||||
|
||||
Widget _switch(String key, String title, [String? help]) => SwitchListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: Text(title),
|
||||
subtitle: help == null ? null : Text(help),
|
||||
value: _bools[key]!,
|
||||
onChanged: (v) => setState(() => _bools[key] = v),
|
||||
);
|
||||
|
||||
// Material, not a coloured box: list tiles paint their ripple on it.
|
||||
Widget _card(String title, List<Widget> children) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 16),
|
||||
child: Material(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerLow,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(18, 14, 18, 10),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(title, style: const TextStyle(fontWeight: FontWeight.w700)),
|
||||
const SizedBox(height: 6),
|
||||
...children,
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
List<Widget> _page() {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
switch (_at) {
|
||||
case 0:
|
||||
return [
|
||||
_card('You', [
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: _Avatar(member: _me.value, size: 48, badges: false),
|
||||
title: Text(_me.value.name),
|
||||
subtitle: const Text(
|
||||
'Name, picture and colour: click your avatar in the top left',
|
||||
),
|
||||
),
|
||||
]),
|
||||
];
|
||||
case 1:
|
||||
return [
|
||||
_card('Hosting', [
|
||||
const ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: Text('Default port'),
|
||||
trailing: Text('8080'),
|
||||
),
|
||||
const ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: Text('Password'),
|
||||
subtitle: Text(
|
||||
'Asked when you host; blank = anyone who reaches the port can join',
|
||||
),
|
||||
),
|
||||
const ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: Text('Relay buffer'),
|
||||
subtitle: Text('Memory the server keeps for relayed videos'),
|
||||
trailing: Text('256 MB ahead · 64 MB back'),
|
||||
),
|
||||
]),
|
||||
_card('Recent servers', [
|
||||
for (final s in [
|
||||
'VPS · vps.example:8080',
|
||||
'Bob · 192.168.1.20:8080',
|
||||
])
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: const Icon(Icons.dns_outlined),
|
||||
title: Text(s),
|
||||
trailing: IconButton(
|
||||
onPressed: () {},
|
||||
icon: const Icon(Icons.close, size: 18),
|
||||
),
|
||||
),
|
||||
]),
|
||||
];
|
||||
case 2:
|
||||
return [
|
||||
_card('Jellyfin', [
|
||||
for (final (name, user) in [
|
||||
('Senko Jellyfin', 'foxyz'),
|
||||
('Bob\'s box', 'guest'),
|
||||
])
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: const Icon(Icons.video_library_outlined),
|
||||
title: Text(name),
|
||||
subtitle: Text('signed in as $user'),
|
||||
trailing: TextButton(
|
||||
onPressed: () {},
|
||||
child: const Text('Sign out'),
|
||||
),
|
||||
),
|
||||
TextButton.icon(
|
||||
onPressed: () {},
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('Add a Jellyfin server'),
|
||||
),
|
||||
]),
|
||||
_card('AnimeFlow', [
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: const Icon(Icons.downloading_outlined),
|
||||
title: const Text('anime.senkochan.moe'),
|
||||
subtitle: const Text(
|
||||
'Uses your Senko Jellyfin sign-in · can view, request, approve',
|
||||
),
|
||||
trailing: _Pill('connected', Colors.greenAccent.shade400),
|
||||
),
|
||||
Text(
|
||||
'Powers the Schedule tab: what is downloaded, downloading or missing, and requests.',
|
||||
style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12),
|
||||
),
|
||||
]),
|
||||
_card('Local files', [
|
||||
_switch(
|
||||
'remember-folders',
|
||||
'Remember folders',
|
||||
'Folders added in Files stay between launches',
|
||||
),
|
||||
]),
|
||||
];
|
||||
case 3:
|
||||
return [
|
||||
_card('Pop-ups', [
|
||||
_switch('toast-chat', 'Chat messages'),
|
||||
_switch('toast-ready', 'Ready / joined / left'),
|
||||
_switch(
|
||||
'toast-seek',
|
||||
'Seeks and skips',
|
||||
'Always in the activity log',
|
||||
),
|
||||
_switch(
|
||||
'unfocused',
|
||||
'Desktop notification when the app is in the background',
|
||||
),
|
||||
]),
|
||||
_card('Sounds', [
|
||||
_switch(
|
||||
'sounds',
|
||||
'Play sounds',
|
||||
'Soft cues for ready, join and chat; seeks stay silent',
|
||||
),
|
||||
]),
|
||||
];
|
||||
case 4:
|
||||
return [
|
||||
_card('Look', [
|
||||
const ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: Text('Theme'),
|
||||
trailing: Text('Dark'),
|
||||
),
|
||||
_switch(
|
||||
'reduce-motion',
|
||||
'Reduce motion',
|
||||
'Fades instead of slides, no spreading avatars',
|
||||
),
|
||||
]),
|
||||
_card('Playback', [
|
||||
_switch(
|
||||
'auto-next',
|
||||
'Play the next episode automatically',
|
||||
'10 s countdown at the end',
|
||||
),
|
||||
]),
|
||||
];
|
||||
case 5:
|
||||
return [
|
||||
_card('Updates', [
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: const Text('Watch together 0.2.2'),
|
||||
subtitle: const Text('0.3.0 is available'),
|
||||
trailing: FilledButton(
|
||||
onPressed: () {},
|
||||
child: const Text('Update and restart'),
|
||||
),
|
||||
),
|
||||
_switch('auto-update', 'Check for updates on launch'),
|
||||
]),
|
||||
];
|
||||
default:
|
||||
return [
|
||||
_card('About', [
|
||||
const Text(
|
||||
'Watch together for friends: synced playback, Jellyfin, relay.',
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'mpv · media_kit · Flutter',
|
||||
style: TextStyle(color: scheme.onSurfaceVariant),
|
||||
),
|
||||
]),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final pointer = Container(
|
||||
margin: const EdgeInsets.only(bottom: 16),
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: scheme.primary.withValues(alpha: 0.08),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.tune, color: scheme.primary),
|
||||
const SizedBox(width: 12),
|
||||
const Expanded(
|
||||
child: Text(
|
||||
'Player design, shaders, subtitles and audio live in the player: the ⚙ in its controls.',
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: widget.onOpenPlayerSettings,
|
||||
child: const Text('Open player settings'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
return LayoutBuilder(
|
||||
builder: (context, box) {
|
||||
final narrow = box.maxWidth < 760;
|
||||
final nav = narrow
|
||||
? SizedBox(
|
||||
height: 52,
|
||||
child: _HScroll(
|
||||
builder: (c) => ListView(
|
||||
controller: c,
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.fromLTRB(20, 8, 20, 4),
|
||||
children: [
|
||||
for (var i = 0; i < _sections.length; i++)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: ChoiceChip(
|
||||
label: Text(_sections[i].$2),
|
||||
selected: _at == i,
|
||||
onSelected: (_) => setState(() => _at = i),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
: SizedBox(
|
||||
width: 250,
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(16, 20, 8, 20),
|
||||
children: [
|
||||
for (var i = 0; i < _sections.length; i++)
|
||||
ListTile(
|
||||
selected: _at == i,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
leading: Icon(_sections[i].$1, size: 20),
|
||||
title: Text(_sections[i].$2),
|
||||
onTap: () => setState(() => _at = i),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
final page = _fadeSwitch(
|
||||
KeyedSubtree(
|
||||
key: ValueKey(_at),
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(20, 20, 24, 24),
|
||||
children: [
|
||||
Align(
|
||||
alignment: Alignment.topLeft,
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 760),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
_sections[_at].$2,
|
||||
style: Theme.of(context).textTheme.headlineSmall,
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
pointer,
|
||||
..._page(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
return narrow
|
||||
? Column(
|
||||
children: [
|
||||
nav,
|
||||
Expanded(child: page),
|
||||
],
|
||||
)
|
||||
: Row(
|
||||
children: [
|
||||
nav,
|
||||
Expanded(child: page),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,9 @@ import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
|
||||
import '../../update/update_controller.dart';
|
||||
import '../../update/updater.dart';
|
||||
import 'settings_page.dart';
|
||||
import 'ui.dart';
|
||||
import 'update_indicator.dart';
|
||||
|
||||
@@ -23,10 +23,6 @@ class ConnectFlow extends ChangeNotifier {
|
||||
int _attempt = 0;
|
||||
bool _disposed = false;
|
||||
|
||||
/// Set when an attempt ends in a kick, so the shell shows the removal
|
||||
/// dialog.
|
||||
void Function(String reason)? onKicked;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_disposed = true;
|
||||
@@ -101,6 +97,7 @@ class ConnectFlow extends ChangeNotifier {
|
||||
_notify();
|
||||
return;
|
||||
}
|
||||
final fromSolo = mode == ConnectMode.host && _session.solo.value;
|
||||
try {
|
||||
if (mode == ConnectMode.host) {
|
||||
await _session.host(params, form);
|
||||
@@ -110,20 +107,19 @@ class ConnectFlow extends ChangeNotifier {
|
||||
} catch (e) {
|
||||
if (myAttempt != _attempt) return;
|
||||
busy = null;
|
||||
final kick = _session.coordinator.kickReason.peek();
|
||||
if (kick != null && kick.isNotEmpty) {
|
||||
onKicked?.call(kick);
|
||||
} else {
|
||||
errors[mode] = CardError('Could not connect', '$e');
|
||||
}
|
||||
_notify();
|
||||
return;
|
||||
}
|
||||
if (myAttempt != _attempt) {
|
||||
// Cancelled after it got through: back out.
|
||||
// Cancelled after it got through: back out (an Invite: back to solo).
|
||||
try {
|
||||
if (fromSolo) {
|
||||
await _session.cancelInvite();
|
||||
} else {
|
||||
await _session.leave();
|
||||
await _session.stopServer();
|
||||
}
|
||||
} catch (_) {}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -7,11 +7,12 @@ import '../../update/update_controller.dart';
|
||||
import 'ui.dart';
|
||||
import 'update_indicator.dart';
|
||||
|
||||
/// Player keys, as handled by the session player's key handler (while
|
||||
/// the player fills the window).
|
||||
/// Session keys: Space and Tab anywhere in a server (`AppKeys`), the
|
||||
/// rest by the session player's key handler (while it fills the window).
|
||||
const sessionShortcuts = [
|
||||
('Space', 'Toggle ready'),
|
||||
('← / →', 'Seek back / forward 10 s'),
|
||||
('↑ / ↓', 'Volume up / down (up to 150 %)'),
|
||||
('Shift+→', 'Skip 1:30 (for everyone)'),
|
||||
('PgUp / PgDn', 'Previous / next chapter'),
|
||||
('F', 'Toggle fullscreen'),
|
||||
@@ -21,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,18 @@ 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
|
||||
/// self is ready (or a reconnect is restoring it). A swap keeps the
|
||||
/// server's ready flags, so the client unreadies itself.
|
||||
bool unreadyForNewMedia(
|
||||
{required bool enabled,
|
||||
required int lastMediaId,
|
||||
required int mediaId,
|
||||
required bool ready}) =>
|
||||
enabled && lastMediaId >= 0 && mediaId != lastMediaId && ready;
|
||||
|
||||
/// `media.relay` of a state, or null when the media is not relayed.
|
||||
MediaRelay? stateMediaRelay(StateBroadcast state) {
|
||||
@@ -121,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
|
||||
@@ -148,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);
|
||||
@@ -162,7 +181,6 @@ class SessionCoordinator {
|
||||
final feedLog = signal<List<FeedMessage>>([]);
|
||||
final toasts = signal<List<ToastItem>>([]);
|
||||
final reconnecting = signal(false);
|
||||
final kickReason = signal<String?>(null);
|
||||
final starving = signal<String?>(null);
|
||||
|
||||
/// The current media's Jellyfin ref (null for links and files).
|
||||
@@ -184,7 +202,7 @@ class SessionCoordinator {
|
||||
final autoAdvance = signal<AutoAdvance?>(null);
|
||||
|
||||
/// The skip pill for the current position ([skipOfferAt]: Skip OP,
|
||||
/// Skip ED, or Skip 1:30 without opening info); null while nothing
|
||||
/// Skip ED, or Skip 1:30 without opening info or chapters); null while nothing
|
||||
/// is open or at `finished`. Press it with [skip].
|
||||
late final Computed<SkipOffer?> skipOffer = computed(() {
|
||||
final r = serverState.value;
|
||||
@@ -195,6 +213,7 @@ class SessionCoordinator {
|
||||
duration: duration,
|
||||
opening: playback.opening.value,
|
||||
ending: playback.ending.value,
|
||||
chaptered: playback.chapters.value.isNotEmpty,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -207,14 +226,23 @@ class SessionCoordinator {
|
||||
return null;
|
||||
});
|
||||
|
||||
/// Self sits out: the player has nothing open until [joinBack].
|
||||
late final Computed<bool> selfOut = computed(() => selfMember.value?.out ?? false);
|
||||
|
||||
/// Asks the desktop to point at the window (a nudge); tests swap it.
|
||||
Future<void> Function() requestAttention = requestWindowAttention;
|
||||
|
||||
Timer? _ticker;
|
||||
StreamSubscription? _stateSub;
|
||||
StreamSubscription? _feedSub;
|
||||
StreamSubscription? _kickSub;
|
||||
StreamSubscription? _nudgeSub;
|
||||
StreamSubscription? _errSub;
|
||||
StreamSubscription? _statusSub;
|
||||
|
||||
bool _manualClose = false;
|
||||
/// A solo session (set by SessionState): no idle unready / sit out,
|
||||
/// there is nobody to hold up.
|
||||
bool solo = false;
|
||||
bool _playerReady = false;
|
||||
bool _wasPlayAllowed = false;
|
||||
// Last ready flag the server reported for self; restored after a
|
||||
@@ -223,6 +251,16 @@ 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;
|
||||
// Bumped by every connect/leave/cancel/retry: stale attempts no-op.
|
||||
int _epoch = 0;
|
||||
Timer? _reconnectTimer;
|
||||
@@ -242,6 +280,10 @@ class SessionCoordinator {
|
||||
int _relaySourceMediaId = -1;
|
||||
/// This member serves the current media's bytes through the server.
|
||||
final relaying = signal<bool>(false);
|
||||
/// What this client put up last in this connection (a solo session
|
||||
/// carries it over when it becomes a hosted server).
|
||||
PickedMedia? get lastPick => _lastPick;
|
||||
PickedMedia? _lastPick;
|
||||
// Server seek applied to the player (event id and when), checked once it
|
||||
// should have landed.
|
||||
int? _pendingSeekEvent;
|
||||
@@ -260,6 +302,8 @@ class SessionCoordinator {
|
||||
int _toastId = 0;
|
||||
ConnectParams? _params;
|
||||
bool _everConnected = false;
|
||||
/// The connection-lost sound played; the reconnected one answers it once.
|
||||
bool _lostSounded = false;
|
||||
Map<String, String>? _openHeaders;
|
||||
|
||||
// Jellyfin: the resolved ref, the mediaId it belongs to, a generation
|
||||
@@ -276,8 +320,9 @@ class SessionCoordinator {
|
||||
bool _jellyfinListening = false;
|
||||
|
||||
SessionCoordinator(
|
||||
{this.sfx = const NoopSfxService(), JellyfinAccounts? jellyfin, TrackPrefs? trackPrefs})
|
||||
: jellyfin = jellyfin ?? JellyfinAccounts.instance,
|
||||
{this.sfx = const NoopSfxService(), JellyfinAccounts? jellyfin, TrackPrefs? trackPrefs, AppYtDlp? ytDlp})
|
||||
: ytDlp = ytDlp ?? AppYtDlp.instance,
|
||||
jellyfin = jellyfin ?? JellyfinAccounts.instance,
|
||||
trackPrefs = trackPrefs ?? TrackPrefs();
|
||||
|
||||
/// Player lifetime = one session: created here on connect, disposed by
|
||||
@@ -287,6 +332,7 @@ class SessionCoordinator {
|
||||
if (_playerReady) return;
|
||||
try {
|
||||
playback.onFrozen = () => unawaited(reloadVideo(auto: true));
|
||||
playback.onYtdlError = (e) => health.value = HealthStatus(HealthSeverity.error, "can't open this link: $e");
|
||||
playback.onUserSeek = (d) => _userSeek(d.inMicroseconds / 1e6);
|
||||
await playback.init();
|
||||
_playerReady = true;
|
||||
@@ -346,7 +392,6 @@ class SessionCoordinator {
|
||||
if (epoch != _epoch) throw const ConnectCancelled();
|
||||
_params = params;
|
||||
_manualClose = false;
|
||||
kickReason.value = null;
|
||||
_wsHost = params.host;
|
||||
_wsPort = params.port;
|
||||
diff.reset();
|
||||
@@ -354,12 +399,17 @@ class SessionCoordinator {
|
||||
_openUrl = null;
|
||||
_lastReady = false;
|
||||
_restoringReady = false;
|
||||
_lastOut = false;
|
||||
_joinBackAt = null;
|
||||
_seenMediaId = -1;
|
||||
_wasPlayAllowed = false;
|
||||
_everConnected = false;
|
||||
_lostSounded = false;
|
||||
_resumeToken = '';
|
||||
_seekGate.reset();
|
||||
_clearPendingSeek();
|
||||
_lastEventId = -1;
|
||||
_lastPick = null;
|
||||
_resetJellyfin();
|
||||
_jfToastShown = false;
|
||||
if (!_jellyfinListening) {
|
||||
@@ -415,14 +465,15 @@ 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();
|
||||
_scheduleReconnect();
|
||||
}
|
||||
});
|
||||
@@ -436,6 +487,7 @@ class SessionCoordinator {
|
||||
diff.reset();
|
||||
_opening = true;
|
||||
try {
|
||||
await _prepareYtDlp(url);
|
||||
await playback.openUrl(url, headers: headers);
|
||||
health.value =
|
||||
const HealthStatus(HealthSeverity.ok, 'connected');
|
||||
@@ -450,8 +502,30 @@ class SessionCoordinator {
|
||||
}
|
||||
}
|
||||
|
||||
/// A web link (not this server's): mpv's yt-dlp hook gets the app's
|
||||
/// managed yt-dlp, downloaded first if needed; a media file name does
|
||||
/// not wait for that.
|
||||
Future<void> _prepareYtDlp(String url) async {
|
||||
final uri = Uri.tryParse(url);
|
||||
if (uri == null || !(uri.isScheme('http') || uri.isScheme('https'))) return;
|
||||
if (uri.host == _wsHost && uri.port == _wsPort) return;
|
||||
try {
|
||||
final y = await ytDlp.ytDlp;
|
||||
final ready = y.ensure();
|
||||
final path = hasMediaExtension(uri)
|
||||
? y.path
|
||||
: await ready.timeout(const Duration(seconds: 90), onTimeout: () => null);
|
||||
if (path != null) await playback.useYtDlp(path);
|
||||
} catch (e) {
|
||||
debugPrint('yt-dlp not ready: $e');
|
||||
}
|
||||
}
|
||||
|
||||
bool _relayWaitShown = false;
|
||||
|
||||
/// The server-fetched media this viewer opens once it is live.
|
||||
int _awaitLiveFor = -1;
|
||||
|
||||
/// Relayed through a member whose channel is down: say who everyone is
|
||||
/// waiting for (the buffer still plays what it has).
|
||||
void _relayStatus(StateBroadcast r) {
|
||||
@@ -473,21 +547,24 @@ class SessionCoordinator {
|
||||
}
|
||||
}
|
||||
|
||||
String _relayMediaUrlFor(String name, int mediaId) =>
|
||||
'http://$_wsHost:$_wsPort/r/default/${sync.token}/$mediaId/${Uri.encodeComponent(name)}';
|
||||
/// This viewer's `/r/` URL; with tracks, a pieces track's playlist, or
|
||||
/// an mpv EDL of every track when there are several (a page's separate
|
||||
/// video and audio).
|
||||
String _relayMediaUrlFor(String name, int mediaId, [List<RelayTrackSpec> tracks = const []]) =>
|
||||
relayViewerUrl('http://$_wsHost:$_wsPort/r/default/${sync.token}/$mediaId', name, tracks: tracks);
|
||||
|
||||
/// Serves [bytes] for media [mediaId] on this member's relay channel.
|
||||
/// Serves [media] for media [mediaId] on this member's relay channel.
|
||||
/// The server only pulls while someone watches through it.
|
||||
void _startRelaySource(int mediaId, RelayBytes bytes, {bool announce = false}) {
|
||||
void _startRelaySource(int mediaId, RelayMedia media, {bool announce = false}) {
|
||||
// Back after a reconnect on the same media: it is still serving (and
|
||||
// redials by itself).
|
||||
if (_relaySource != null && _relaySourceMediaId == mediaId) {
|
||||
unawaited(bytes.close());
|
||||
unawaited(media.close());
|
||||
return;
|
||||
}
|
||||
_stopRelaySource();
|
||||
final src = RelaySource(
|
||||
Uri.parse('ws://$_wsHost:$_wsPort/r/default/${sync.token}/source'), bytes);
|
||||
Uri.parse('ws://$_wsHost:$_wsPort/r/default/${sync.token}/source'), media);
|
||||
_relaySource = src;
|
||||
_relaySourceMediaId = mediaId;
|
||||
relaying.value = true;
|
||||
@@ -559,6 +636,12 @@ class SessionCoordinator {
|
||||
|
||||
Future<void> _applyState(StateBroadcast r) async {
|
||||
_trackSelfReady(r);
|
||||
if (_lastOut) {
|
||||
// A newer media than the one this member serves: that relay ended.
|
||||
if (_relaySourceMediaId != -1 && _relaySourceMediaId != r.mediaId) _stopRelaySource();
|
||||
await _unloadForOut();
|
||||
return;
|
||||
}
|
||||
_seekGate.observe(r, sync.memberId);
|
||||
final newEvent = r.eventId != _lastEventId;
|
||||
_lastEventId = r.eventId;
|
||||
@@ -594,12 +677,22 @@ class SessionCoordinator {
|
||||
memberRelayUrl: relay != null && !mine ? _relayMediaUrlFor(name, r.mediaId) : null);
|
||||
} else if (origin is _FileOrigin) {
|
||||
await _openMedia(origin.path);
|
||||
_startRelaySource(r.mediaId, FileRelayBytes(File(origin.path)), announce: true);
|
||||
_startRelaySource(r.mediaId, RelayMedia.single(FileRelayBytes(File(origin.path))), announce: true);
|
||||
} else if (origin is _LinkOrigin) {
|
||||
await _openMedia(origin.url);
|
||||
_startRelaySource(r.mediaId, HttpRelayBytes(Uri.parse(origin.url)), announce: true);
|
||||
_startRelaySource(
|
||||
r.mediaId, LinkRelayMedia(Uri.parse(origin.url), ytDlp: origin.ytDlp, probe: origin.probe),
|
||||
announce: true);
|
||||
} else if (relay != null && !mine) {
|
||||
await _openMedia(_relayMediaUrlFor(name, r.mediaId));
|
||||
if (relay.fetch && !relay.live) {
|
||||
// The server is still getting it (yt-dlp takes seconds; mpv
|
||||
// gives up on a silent request after 5): open once it is live.
|
||||
_openUrl = null;
|
||||
_awaitLiveFor = r.mediaId;
|
||||
health.value = const HealthStatus(HealthSeverity.info, 'the server is getting the video…');
|
||||
} else {
|
||||
await _openMedia(_relayMediaUrlFor(name, r.mediaId, relay.tracks));
|
||||
}
|
||||
} else {
|
||||
var url = stateMediaUrl(r);
|
||||
if ((url == null || url.isEmpty) && sync.token.isNotEmpty) {
|
||||
@@ -615,6 +708,10 @@ class SessionCoordinator {
|
||||
}
|
||||
_seekGate.reset();
|
||||
_clearPendingSeek();
|
||||
} else if (_awaitLiveFor == r.mediaId && (stateMediaRelay(r)?.live ?? false)) {
|
||||
_awaitLiveFor = -1;
|
||||
await _openMedia(
|
||||
_relayMediaUrlFor((r.media?['name'] as String?) ?? '', r.mediaId, stateMediaRelay(r)!.tracks));
|
||||
}
|
||||
_jellyfinTransport(r);
|
||||
_relayStatus(r);
|
||||
@@ -664,12 +761,49 @@ class SessionCoordinator {
|
||||
void _trackSelfReady(StateBroadcast r) {
|
||||
final self = selfMember.peek();
|
||||
if (self == null) return;
|
||||
if (r.mediaId != _mediaId) _restoringReady = false;
|
||||
// [_seenMediaId], not [_mediaId]: that one is -1 after a lost
|
||||
// connection, which would end the restore before its echo.
|
||||
if (_seenMediaId >= 0 && r.mediaId != _seenMediaId) _restoringReady = false;
|
||||
if (_restoringReady) {
|
||||
if (self.ready) _restoringReady = false;
|
||||
} 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
|
||||
// anyway (it was sent first, so this one lands last).
|
||||
if (unreadyForNewMedia(
|
||||
enabled: AppSettings.instance.unreadyOnSwap.peek(),
|
||||
lastMediaId: last,
|
||||
mediaId: r.mediaId,
|
||||
ready: self.ready || _restoringReady)) {
|
||||
_lastReady = false;
|
||||
_restoringReady = false;
|
||||
sync.sendReady(false, source: 'auto');
|
||||
}
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
@@ -682,7 +816,7 @@ class SessionCoordinator {
|
||||
return;
|
||||
}
|
||||
for (final m in r.members) {
|
||||
if (m.inSet && m.buffered < RelayConstants.minBufferSecs) {
|
||||
if (m.inSet && m.buffered < r.bufferNeed) {
|
||||
starving.value =
|
||||
'waiting for ${m.name} — ${m.buffered.toStringAsFixed(1)} s buffered';
|
||||
return;
|
||||
@@ -690,14 +824,15 @@ class SessionCoordinator {
|
||||
}
|
||||
final self = selfMember.peek();
|
||||
if (self != null && self.ready) {
|
||||
final waiters = r.members
|
||||
final present = r.members.where((m) => !m.out).toList();
|
||||
final waiters = present
|
||||
.where((m) => m.id != self.id && !m.stale && !(m.inSet && m.ready))
|
||||
.map((m) => m.name)
|
||||
.toList();
|
||||
if (waiters.isNotEmpty) {
|
||||
final readyCount = r.members.where((m) => m.ready).length;
|
||||
final readyCount = present.where((m) => m.ready).length;
|
||||
starving.value =
|
||||
'waiting for ${waiters.join(', ')} ($readyCount/${r.members.length} ready)';
|
||||
'waiting for ${waiters.join(', ')} ($readyCount/${present.length} ready)';
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -726,8 +861,16 @@ 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);
|
||||
if (kind != null) {
|
||||
// Your own message needs no sound.
|
||||
final own = f.action == 'chat' && f.actor == selfName;
|
||||
if (kind != null && !own) {
|
||||
var readyIndex = 0;
|
||||
if (f.action == 'ready') {
|
||||
readyIndex = serverState.peek()?.members.where((m) => m.ready).length ?? 0;
|
||||
@@ -742,8 +885,6 @@ class SessionCoordinator {
|
||||
return '${f.actor} joined';
|
||||
case 'leave':
|
||||
return '${f.actor} left';
|
||||
case 'kick':
|
||||
return '${f.actor} kicked ${f.target ?? ''}';
|
||||
case 'swap':
|
||||
return '${f.actor} changed media';
|
||||
default:
|
||||
@@ -751,26 +892,13 @@ class SessionCoordinator {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onKicked(String reason) async {
|
||||
kickReason.value = reason;
|
||||
_manualClose = true;
|
||||
_epoch++;
|
||||
_reconnectTimer?.cancel();
|
||||
_reconnectTimer = null;
|
||||
reconnecting.value = false;
|
||||
_ticker?.cancel();
|
||||
_ticker = null;
|
||||
// Stop, not pause: a paused player keeps filling its cache from the
|
||||
// host's media URL.
|
||||
_openUrl = null;
|
||||
await _stopJellyfin();
|
||||
try {
|
||||
await playback.stop();
|
||||
} catch (_) {}
|
||||
await sync.disconnect();
|
||||
health.value = HealthStatus(
|
||||
HealthSeverity.error,
|
||||
reason.isEmpty ? 'kicked from server' : 'kicked: $reason');
|
||||
/// Someone poked this member: sound (even when its switch is off; the
|
||||
/// volume still applies), a toast, and the window asks for attention.
|
||||
void _onNudge(NudgeNotice n) {
|
||||
final name = n.name.isEmpty ? 'Someone' : n.name;
|
||||
_pushToast('$name nudged you', actor: n.name.isEmpty ? null : n.name);
|
||||
unawaited(sfx.play(const SfxCue(SfxKind.nudge)));
|
||||
unawaited(requestAttention());
|
||||
}
|
||||
|
||||
void _startTicker() {
|
||||
@@ -786,20 +914,22 @@ class SessionCoordinator {
|
||||
if (sync.token.isNotEmpty) sync.sendPing();
|
||||
if (sync.token.isNotEmpty && r != null && _playerReady) {
|
||||
// Duration only once the open media is the server's current one.
|
||||
final known = !_opening && _openUrl != null && _mediaId == r.mediaId;
|
||||
// Sitting out: still a report (it keeps this member fresh), empty.
|
||||
final known = !_lastOut && !_opening && _openUrl != null && _mediaId == r.mediaId;
|
||||
sync.sendReport(
|
||||
position: playback.positionSecs,
|
||||
buffered: playback.bufferedSecsAhead,
|
||||
position: _lastOut ? 0 : playback.positionSecs,
|
||||
buffered: _lastOut ? 0 : playback.bufferedSecsAhead,
|
||||
eventId: r.eventId,
|
||||
duration: known ? playback.durationSecs : 0,
|
||||
mediaId: known ? r.mediaId : 0,
|
||||
jf: _jfAccessFor(r),
|
||||
);
|
||||
if (!_lastOut) {
|
||||
_checkSeekLanded(r);
|
||||
unawaited(_tickJellyfin(r));
|
||||
}
|
||||
}
|
||||
if (!_manualClose &&
|
||||
kickReason.peek() == null &&
|
||||
_lastStateAt != null &&
|
||||
now.difference(_lastStateAt!).inSeconds >= 5) {
|
||||
if (serverState.peek() != null) {
|
||||
@@ -813,13 +943,18 @@ class SessionCoordinator {
|
||||
if (!reconnecting.peek()) {
|
||||
health.value = const HealthStatus(
|
||||
HealthSeverity.warn, 'connection lost — retrying…');
|
||||
_soundLost();
|
||||
_scheduleReconnect();
|
||||
}
|
||||
}
|
||||
// No warning when there is nothing to lose (unready), and none while
|
||||
// PLAYING: the ready flag gates nothing then, and watching counts as
|
||||
// activity so a pause after a long watch starts a fresh idle clock.
|
||||
final event = _idle.tick(now,
|
||||
if (r != null) _readyAfterJoinBack(r, now);
|
||||
final sitOutWhenIdle = AppSettings.instance.idleSitsOut.peek();
|
||||
final event = solo
|
||||
? IdleEvent.none
|
||||
: _idle.tick(now,
|
||||
playing: serverState.peek()?.transport == 'playing',
|
||||
selfReady: selfMember.peek()?.ready ?? false);
|
||||
switch (event) {
|
||||
@@ -829,18 +964,46 @@ class SessionCoordinator {
|
||||
...toasts.peek(),
|
||||
ToastItem(
|
||||
id: _toastId,
|
||||
text: 'idle — auto-unready in 2 min unless you press Space',
|
||||
text: sitOutWhenIdle
|
||||
? 'idle — sitting out in 2 min unless you press Space'
|
||||
: 'idle — auto-unready in 2 min unless you press Space',
|
||||
at: now)
|
||||
];
|
||||
unawaited(sfx.play(const SfxCue(SfxKind.warn)));
|
||||
case IdleEvent.unready:
|
||||
_lastReady = false;
|
||||
if (sitOutWhenIdle) {
|
||||
sitOut();
|
||||
} else {
|
||||
sync.sendReady(false, source: 'auto');
|
||||
}
|
||||
case IdleEvent.none:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/// After [joinBack] while playing: ready once the player holds what the
|
||||
/// gate needs, or after [_joinBackWait] (then the gate waits for it).
|
||||
void _readyAfterJoinBack(StateBroadcast r, DateTime now) {
|
||||
final at = _joinBackAt;
|
||||
if (at == null) return;
|
||||
final self = selfMember.peek();
|
||||
if (self == null || self.out) {
|
||||
if (now.difference(at) > _joinBackWait) _joinBackAt = null;
|
||||
return;
|
||||
}
|
||||
final loaded = _playerReady && !_opening && _openUrl != null && _mediaId == r.mediaId;
|
||||
if (self.ready ||
|
||||
(loaded && playback.bufferedSecsAhead >= r.bufferNeed) ||
|
||||
now.difference(at) > _joinBackWait) {
|
||||
_joinBackAt = null;
|
||||
if (!self.ready) {
|
||||
_lastReady = true;
|
||||
sync.sendReady(true, source: 'manual');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _clearPendingSeek() {
|
||||
_pendingSeekEvent = null;
|
||||
_pendingSeekAt = null;
|
||||
@@ -907,6 +1070,10 @@ class SessionCoordinator {
|
||||
|
||||
void toggleReady() {
|
||||
final self = selfMember.peek();
|
||||
if (self?.out ?? false) {
|
||||
joinBack();
|
||||
return;
|
||||
}
|
||||
// Ready but out of the set (dropped out while stale) gates like
|
||||
// unready, so Space puts self back in instead of unreadying.
|
||||
final next = !((self?.ready ?? false) && (self?.inSet ?? false));
|
||||
@@ -994,12 +1161,43 @@ class SessionCoordinator {
|
||||
sync.sendProfile(name: name, avatar: avatar);
|
||||
}
|
||||
|
||||
void kick(String memberId, {String reason = ''}) {
|
||||
/// Sits [memberId] out (anyone, the host included), self when null.
|
||||
void sitOut([String? memberId]) {
|
||||
final target = memberId ?? sync.memberId;
|
||||
if (target.isEmpty) return;
|
||||
markInput();
|
||||
sync.sendKick(memberId, reason: reason);
|
||||
if (target == sync.memberId) _joinBackAt = null;
|
||||
sync.sendOut(target, true);
|
||||
}
|
||||
|
||||
/// Back from sitting out: the media opens at the server position and
|
||||
/// this member readies. Held: at once (the gate waits for the buffer);
|
||||
/// playing: once buffered, so the others do not pause for the load.
|
||||
void joinBack() {
|
||||
final self = selfMember.peek();
|
||||
if (self == null || !self.out) return;
|
||||
markInput();
|
||||
sync.sendOut(self.id, false);
|
||||
if (serverState.peek()?.transport == 'playing') {
|
||||
_joinBackAt = DateTime.now().toUtc();
|
||||
} else {
|
||||
_joinBackAt = null;
|
||||
_lastReady = true;
|
||||
sync.sendReady(true, source: 'manual');
|
||||
}
|
||||
}
|
||||
|
||||
/// Pokes [memberId]: only they hear it (sitting out or not).
|
||||
void nudgeMember(String memberId) {
|
||||
if (memberId.isEmpty || memberId == sync.memberId) return;
|
||||
markInput();
|
||||
sync.sendNudge(memberId);
|
||||
}
|
||||
|
||||
void _swap(HelloMedia media) {
|
||||
// Putting something up means watching it: back first.
|
||||
final self = selfMember.peek();
|
||||
if (self != null && self.out) sync.sendOut(self.id, false);
|
||||
_pendingArt = (media: media, after: serverState.peek()?.mediaId ?? 0);
|
||||
sync.sendSwap(media);
|
||||
}
|
||||
@@ -1021,6 +1219,12 @@ class SessionCoordinator {
|
||||
final ArtBytes? bytes;
|
||||
if (ref != null && client != null) {
|
||||
bytes = await jellyfinArt(client, ref.itemId);
|
||||
} else if (ref == null && (media.url ?? '').isNotEmpty) {
|
||||
// A web link is not a show: never guess. A page's thumbnail is
|
||||
// its banner.
|
||||
final thumb = (await _probeFor(media.url!))?.info?.thumbnail;
|
||||
final uri = thumb == null ? null : Uri.tryParse(thumb);
|
||||
bytes = uri == null ? null : (banner: await fetchImage(uri, const {}, RelayConstants.maxBannerBytes), poster: null);
|
||||
} else {
|
||||
final name = ref == null ? media.name : jellyfinTitle(ref.title, ref.subtitle).$1;
|
||||
bytes = await aniListArt(_aniList ??= AniListClient(), name);
|
||||
@@ -1042,43 +1246,83 @@ class SessionCoordinator {
|
||||
/// link, or relayed per [relay]. Returns an error text, or null when sent.
|
||||
Future<String?> swapLink(String url, {LinkRelay relay = LinkRelay.direct}) async {
|
||||
markInput();
|
||||
_lastPick = LinkMedia(url, relay: relay);
|
||||
final media = await mediaForLink(jellyfin, url);
|
||||
if (media.jellyfin != null) {
|
||||
_pendingOrigin = _JellyfinOrigin(url);
|
||||
_swap(media.copyWith(relay: const MediaRelay()));
|
||||
return null;
|
||||
}
|
||||
if (relay == LinkRelay.direct) {
|
||||
_swap(media);
|
||||
return null;
|
||||
}
|
||||
// Relayed: it must be a single file the relay can range-read (not a
|
||||
// page like YouTube, which only the player can resolve).
|
||||
final probe = HttpRelayBytes(Uri.parse(url));
|
||||
final int size;
|
||||
LinkProbe? probe;
|
||||
String? probeError;
|
||||
try {
|
||||
size = await probe.size().timeout(const Duration(seconds: 15));
|
||||
probe = await ytDlp.probe(url).timeout(const Duration(seconds: 45));
|
||||
} catch (e) {
|
||||
return relay == LinkRelay.me
|
||||
? 'cannot pass this link on: $e'
|
||||
: 'cannot check this link: $e';
|
||||
} finally {
|
||||
await probe.close();
|
||||
probeError = '$e';
|
||||
}
|
||||
// A page is named by its title, never its URL tail ("1", "watch").
|
||||
final uri = Uri.tryParse(url) ?? Uri();
|
||||
final name = probe?.title ?? (probe != null || hasMediaExtension(uri) || uri.host.isEmpty ? media.name : uri.host);
|
||||
if (relay == LinkRelay.direct) {
|
||||
_swap(HelloMedia(name: name, size: 0, duration: 0, url: url));
|
||||
return null;
|
||||
}
|
||||
if (relay == LinkRelay.me) {
|
||||
_pendingOrigin = _LinkOrigin(url);
|
||||
_swap(HelloMedia(name: media.name, size: size, duration: 0, relay: const MediaRelay()));
|
||||
// This computer reads it: a file, or a page's formats (one file,
|
||||
// separate video and audio tracks, or segmented playlists).
|
||||
if (probe == null) return 'cannot pass this link on: $probeError';
|
||||
final problem = probe.relayProblem;
|
||||
if (problem != null) return problem;
|
||||
final y = await ytDlp.ytDlp;
|
||||
final check = LinkRelayMedia(uri, ytDlp: y, probe: probe);
|
||||
var size = 0;
|
||||
final List<RelayTrackSpec> specs;
|
||||
try {
|
||||
final tracks = await check.open().timeout(const Duration(seconds: 15));
|
||||
for (final t in tracks) {
|
||||
// A playlist is read (DRM and live refused) but has no size.
|
||||
final pieces = t.pieces;
|
||||
if (pieces != null) {
|
||||
await pieces.list().timeout(const Duration(seconds: 15));
|
||||
} else {
|
||||
_swap(HelloMedia(
|
||||
name: media.name, size: size, duration: 0, url: url, relay: const MediaRelay(fetch: true)));
|
||||
size += await t.bytes!.size().timeout(const Duration(seconds: 15));
|
||||
}
|
||||
}
|
||||
specs = relayTrackSpecs(tracks);
|
||||
} catch (e) {
|
||||
return 'cannot pass this link on: $e';
|
||||
} finally {
|
||||
await check.close();
|
||||
}
|
||||
_pendingOrigin = _LinkOrigin(url, probe, y);
|
||||
_swap(HelloMedia(
|
||||
name: name, size: size, duration: 0, relay: MediaRelay(tracks: specs)));
|
||||
return null;
|
||||
}
|
||||
// The server reads it with its own yt-dlp; only a page this app knows
|
||||
// it cannot relay (live, nothing a relay can read) is refused here (the server
|
||||
// may reach what this computer cannot).
|
||||
if (probe != null && probe.isPage && probe.info != null && probe.relayProblem != null) {
|
||||
return probe.relayProblem;
|
||||
}
|
||||
_swap(HelloMedia(
|
||||
name: name, size: max(0, probe?.size ?? 0), duration: 0, url: url, relay: const MediaRelay(fetch: true)));
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<LinkProbe?> _probeFor(String url) async {
|
||||
try {
|
||||
return await ytDlp.probe(url).timeout(const Duration(seconds: 45));
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// A Jellyfin pick (browser, next / previous episode) goes up as a ref,
|
||||
/// relayed by this member for viewers without an account.
|
||||
void swapJellyfin(JellyfinMedia media) {
|
||||
markInput();
|
||||
_lastPick = media;
|
||||
_pendingOrigin = const _JellyfinOrigin(null);
|
||||
_swap(mediaForPick(media).copyWith(relay: const MediaRelay()));
|
||||
}
|
||||
@@ -1088,6 +1332,7 @@ class SessionCoordinator {
|
||||
/// when sent.
|
||||
Future<String?> swapFile(String path) async {
|
||||
markInput();
|
||||
_lastPick = FileMedia(path);
|
||||
final host = hostFile;
|
||||
if (host != null) {
|
||||
final file = await host(path);
|
||||
@@ -1142,11 +1387,11 @@ class SessionCoordinator {
|
||||
/// results), shown like the session's own.
|
||||
void showToast(String text) => _pushToast(text);
|
||||
|
||||
void _pushToast(String text) {
|
||||
void _pushToast(String text, {String? actor}) {
|
||||
_toastId++;
|
||||
toasts.value = [
|
||||
...toasts.peek(),
|
||||
ToastItem(id: _toastId, text: text, at: DateTime.now().toUtc())
|
||||
ToastItem(id: _toastId, text: text, at: DateTime.now().toUtc(), actor: actor)
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1180,7 +1425,7 @@ class SessionCoordinator {
|
||||
if (reporter != null && snap != null) unawaited(reporter.stop(snap));
|
||||
}
|
||||
|
||||
/// Leave / kick / window close: Stopped, bounded so leaving never hangs.
|
||||
/// Leave / window close: Stopped, bounded so leaving never hangs.
|
||||
Future<void> _stopJellyfin() async {
|
||||
final reporter = _reporter;
|
||||
final snap = _snapNow();
|
||||
@@ -1263,10 +1508,10 @@ class SessionCoordinator {
|
||||
// its own sign-in, else the pasted link (played directly here too).
|
||||
if (origin != null) {
|
||||
if (plan is OwnPlay) {
|
||||
_startRelaySource(r.mediaId, HttpRelayBytes(Uri.parse(plan.url), headers: plan.headers));
|
||||
_startRelaySource(r.mediaId, RelayMedia.single(HttpRelayBytes(Uri.parse(plan.url), headers: plan.headers)));
|
||||
} else if (origin.link != null) {
|
||||
plan = RelayPlay(origin.link!);
|
||||
_startRelaySource(r.mediaId, HttpRelayBytes(Uri.parse(origin.link!)));
|
||||
_startRelaySource(r.mediaId, RelayMedia.single(HttpRelayBytes(Uri.parse(origin.link!))));
|
||||
}
|
||||
}
|
||||
jellyfinPlan.value = plan;
|
||||
@@ -1468,7 +1713,7 @@ class SessionCoordinator {
|
||||
}
|
||||
|
||||
void _scheduleReconnect({bool manual = false}) {
|
||||
if (_manualClose || kickReason.peek() != null || _params == null) return;
|
||||
if (_manualClose || _params == null) return;
|
||||
if (!manual && !_everConnected) return;
|
||||
if (reconnecting.peek()) return;
|
||||
reconnecting.value = true;
|
||||
@@ -1483,14 +1728,16 @@ 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;
|
||||
}
|
||||
final wantReady = _lastReady;
|
||||
final wasOut = _lastOut;
|
||||
var wantReady = _lastReady && !wasOut;
|
||||
final seenBefore = _seenMediaId;
|
||||
try {
|
||||
health.value = const HealthStatus(
|
||||
HealthSeverity.info, 'reconnecting…');
|
||||
@@ -1507,6 +1754,10 @@ class SessionCoordinator {
|
||||
if (stale()) return;
|
||||
reconnecting.value = false;
|
||||
_everConnected = true;
|
||||
if (_lostSounded) {
|
||||
_lostSounded = false;
|
||||
unawaited(sfx.play(const SfxCue(SfxKind.back)));
|
||||
}
|
||||
_resumeToken = sync.token;
|
||||
_lastStateAt = DateTime.now().toUtc();
|
||||
health.value = const HealthStatus(HealthSeverity.ok, 'connected');
|
||||
@@ -1517,6 +1768,19 @@ class SessionCoordinator {
|
||||
if (stale()) return;
|
||||
// The server forgot the dropped member; restore the ready flag it
|
||||
// last reported (not a local guess: others may have forced it).
|
||||
// Not when the video changed meanwhile and the user unreadies then.
|
||||
if (unreadyForNewMedia(
|
||||
enabled: AppSettings.instance.unreadyOnSwap.peek(),
|
||||
lastMediaId: seenBefore,
|
||||
mediaId: sync.lastState?.mediaId ?? seenBefore,
|
||||
ready: wantReady)) {
|
||||
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;
|
||||
@@ -1531,6 +1795,12 @@ class SessionCoordinator {
|
||||
}
|
||||
}
|
||||
|
||||
void _soundLost() {
|
||||
if (!_everConnected || _lostSounded) return;
|
||||
_lostSounded = true;
|
||||
unawaited(sfx.play(const SfxCue(SfxKind.lost)));
|
||||
}
|
||||
|
||||
void _invalidateAttempts() {
|
||||
_epoch++;
|
||||
_reconnectTimer?.cancel();
|
||||
@@ -1556,7 +1826,7 @@ class SessionCoordinator {
|
||||
}
|
||||
|
||||
void retry() {
|
||||
if (kickReason.peek() != null || _params == null) return;
|
||||
if (_params == null) return;
|
||||
_invalidateAttempts();
|
||||
_manualClose = false;
|
||||
reconnecting.value = false;
|
||||
@@ -1617,7 +1887,7 @@ class SessionCoordinator {
|
||||
}
|
||||
await _stateSub?.cancel();
|
||||
await _feedSub?.cancel();
|
||||
await _kickSub?.cancel();
|
||||
await _nudgeSub?.cancel();
|
||||
await _errSub?.cancel();
|
||||
await _statusSub?.cancel();
|
||||
_aniList?.close();
|
||||
|
||||
@@ -1,20 +1,25 @@
|
||||
/// The app's sounds. Only events that carry news get one: no sound for
|
||||
/// what the video itself makes obvious (start, pause, end, seeks).
|
||||
enum SfxKind {
|
||||
/// Someone readies up; pitch climbs with [SfxCue.readyIndex].
|
||||
ready,
|
||||
unready,
|
||||
/// Someone marks everyone ready.
|
||||
forceReady,
|
||||
forceUnready,
|
||||
forcePlay,
|
||||
forceHold,
|
||||
kick,
|
||||
join,
|
||||
leave,
|
||||
stale,
|
||||
/// Someone sat you out (played by the session, not from a feed action).
|
||||
kick,
|
||||
/// A new video or episode is put up.
|
||||
swap,
|
||||
seek,
|
||||
pause,
|
||||
resume,
|
||||
chat,
|
||||
/// Idle warning: auto-unready soon.
|
||||
warn,
|
||||
/// The connection to the server dropped / came back.
|
||||
lost,
|
||||
back,
|
||||
/// Someone pokes you for attention; plays only on your end.
|
||||
nudge,
|
||||
}
|
||||
|
||||
class SfxCue {
|
||||
@@ -23,42 +28,16 @@ class SfxCue {
|
||||
const SfxCue(this.kind, {this.readyIndex = 0});
|
||||
}
|
||||
|
||||
SfxKind? sfxForFeedAction(String action) {
|
||||
switch (action) {
|
||||
case 'ready':
|
||||
return SfxKind.ready;
|
||||
case 'unready':
|
||||
return SfxKind.unready;
|
||||
case 'force-ready':
|
||||
return SfxKind.forceReady;
|
||||
case 'force-unready':
|
||||
return SfxKind.forceUnready;
|
||||
case 'force-play':
|
||||
return SfxKind.forcePlay;
|
||||
case 'force-hold':
|
||||
return SfxKind.forceHold;
|
||||
case 'kick':
|
||||
return SfxKind.kick;
|
||||
case 'join':
|
||||
return SfxKind.join;
|
||||
case 'leave':
|
||||
return SfxKind.leave;
|
||||
case 'stale':
|
||||
return SfxKind.stale;
|
||||
case 'swap':
|
||||
return SfxKind.swap;
|
||||
case 'seek':
|
||||
return SfxKind.seek;
|
||||
case 'pause':
|
||||
return SfxKind.pause;
|
||||
case 'resume':
|
||||
return SfxKind.resume;
|
||||
case 'chat':
|
||||
return SfxKind.chat;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
SfxKind? sfxForFeedAction(String action) => switch (action) {
|
||||
'ready' => SfxKind.ready,
|
||||
'unready' => SfxKind.unready,
|
||||
'force-ready' => SfxKind.forceReady,
|
||||
'join' => SfxKind.join,
|
||||
'leave' => SfxKind.leave,
|
||||
'swap' => SfxKind.swap,
|
||||
'chat' => SfxKind.chat,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
abstract class SfxService {
|
||||
Future<void> play(SfxCue cue);
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
import 'sfx.dart';
|
||||
import 'synth.dart';
|
||||
|
||||
// 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.
|
||||
|
||||
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 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, {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 _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, 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, 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, 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, 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, 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, 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, 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, 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, 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, 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.
|
||||
const soundInfo = {
|
||||
SfxKind.ready: ('Ready', 'Someone presses Space; climbs with each ready person'),
|
||||
SfxKind.unready: ('Not ready', 'Someone takes their ready back'),
|
||||
SfxKind.forceReady: ('Everyone ready', 'Someone marks everyone ready'),
|
||||
SfxKind.join: ('Joined', 'Someone joins the server'),
|
||||
SfxKind.leave: ('Left', 'Someone leaves'),
|
||||
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)'),
|
||||
};
|
||||
@@ -0,0 +1,473 @@
|
||||
import 'dart:math' as math;
|
||||
import 'dart:typed_data';
|
||||
|
||||
// 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;
|
||||
|
||||
enum Voice {
|
||||
/// Plucked string; [Note.to] bends it (the delay line stretches).
|
||||
koto,
|
||||
/// Small glass bell: inharmonic partials, a slow shimmer.
|
||||
chime,
|
||||
/// Singing bowl: a soft strike and a long beating hum.
|
||||
rin,
|
||||
/// Hollow bamboo knock, pitched.
|
||||
bamboo,
|
||||
/// Two hardwood blocks struck together.
|
||||
clack,
|
||||
/// 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 {
|
||||
final double at;
|
||||
final double hz;
|
||||
final double? to;
|
||||
/// Length (s) to about -40 dB.
|
||||
final double len;
|
||||
final double gain;
|
||||
final Voice voice;
|
||||
/// Koto only: 0 = dark/muted pluck, 1 = bright.
|
||||
final double bright;
|
||||
const Note(this.at, this.hz, this.len,
|
||||
{this.to, this.gain = 1, this.voice = Voice.koto, this.bright = 0.6});
|
||||
}
|
||||
|
||||
class SoundSpec {
|
||||
final List<Note> notes;
|
||||
/// Gentle low-pass cutoff (Hz) over the mix.
|
||||
final double cutoff;
|
||||
/// Peak level after normalising, dBFS (quiet by design).
|
||||
final double peakDb;
|
||||
/// Space tail mix (0..1).
|
||||
final double room;
|
||||
/// 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 ? tail * 0.55 : 0.02);
|
||||
}
|
||||
|
||||
/// Semitones from A4 (440 Hz) to Hz.
|
||||
double semi(num n) => 440 * math.pow(2, n / 12).toDouble();
|
||||
|
||||
Float64List render(SoundSpec spec, {int seed = 7}) {
|
||||
final total = (spec.length * sampleRate).ceil();
|
||||
final out = Float64List(total);
|
||||
final rnd = math.Random(seed);
|
||||
for (final n in spec.notes) {
|
||||
final start = (n.at * sampleRate).round();
|
||||
if (start >= total) continue;
|
||||
final s = switch (n.voice) {
|
||||
Voice.koto => _koto(n, rnd),
|
||||
Voice.chime => _bell(n, const [(1.0, 1.0, 1.0), (2.76, 0.45, 1.9), (5.40, 0.2, 3.2), (8.93, 0.08, 4.5)], 1.6, 0.003),
|
||||
Voice.rin => _bell(n, const [(1.0, 1.0, 1.0), (2.71, 0.35, 1.5), (5.15, 0.12, 2.4)], 2.4, 0.012),
|
||||
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) _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++) {
|
||||
out[i] *= i / fadeIn;
|
||||
}
|
||||
final fade = (0.01 * sampleRate).round();
|
||||
for (var i = 0; i < fade && i < total; i++) {
|
||||
out[total - 1 - i] *= i / fade;
|
||||
}
|
||||
var peak = 0.0;
|
||||
for (final v in out) {
|
||||
peak = math.max(peak, v.abs());
|
||||
}
|
||||
if (peak > 0) {
|
||||
final g = math.pow(10, spec.peakDb / 20) / peak;
|
||||
for (var i = 0; i < total; i++) {
|
||||
out[i] *= g;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
int _n(double secs) => (secs * sampleRate).round();
|
||||
double _smooth(double t) => t * t * (3 - 2 * t);
|
||||
|
||||
/// Karplus-Strong with a fractional, movable delay: a noise burst in a
|
||||
/// loop that loses a little of its highs each pass.
|
||||
Float64List _koto(Note n, math.Random rnd) {
|
||||
final len = _n(n.len);
|
||||
final out = Float64List(len);
|
||||
final maxPeriod = sampleRate / math.min(n.hz, n.to ?? n.hz) + 4;
|
||||
final buf = Float64List(maxPeriod.ceil() + 4);
|
||||
final size = buf.length;
|
||||
// Excitation: noise, smoothed more for a darker pluck.
|
||||
final period0 = sampleRate / n.hz;
|
||||
var prev = 0.0;
|
||||
final smooth = 1 - (0.25 + 0.7 * n.bright);
|
||||
for (var i = 0; i < period0.ceil(); i++) {
|
||||
final x = rnd.nextDouble() * 2 - 1;
|
||||
prev = smooth * prev + (1 - smooth) * x;
|
||||
buf[i % size] = prev;
|
||||
}
|
||||
// Loop gain so the note reaches -40 dB at len.
|
||||
final loss = math.pow(0.01, 1 / (n.len * n.hz)).toDouble();
|
||||
var w = period0.ceil();
|
||||
var last = 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(math.min(1, t * 1.6)));
|
||||
// The two-point average below delays half a sample: take it off.
|
||||
final period = sampleRate / hz - 0.5;
|
||||
final r = w - period;
|
||||
final ri = r.floor();
|
||||
final f = r - ri;
|
||||
final a = buf[(ri % size + size) % size];
|
||||
final b = buf[((ri + 1) % size + size) % size];
|
||||
final y = a + (b - a) * f;
|
||||
// Two-point average = the string's damping; the 0.5 mix keeps it bright.
|
||||
final v = loss * (0.5 * y + 0.5 * last);
|
||||
last = y;
|
||||
buf[w % size] = v;
|
||||
w++;
|
||||
// A 3 ms attack takes the click off the pluck.
|
||||
out[i] = v * (i < 132 ? i / 132 : 1);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/// Inharmonic partials (ratio, amplitude, decay multiplier), each doubled
|
||||
/// with a slightly detuned twin so the tail shimmers.
|
||||
Float64List _bell(Note n, List<(double, double, double)> partials, double beatHz, double attackSecs) {
|
||||
final len = _n(n.len);
|
||||
final out = Float64List(len);
|
||||
final k = math.log(100) / len;
|
||||
final attack = _n(attackSecs);
|
||||
for (final (ratio, amp, decay) in partials) {
|
||||
final f1 = n.hz * ratio, f2 = f1 + beatHz * ratio;
|
||||
for (var i = 0; i < len; i++) {
|
||||
final env = math.exp(-k * decay * i) * (i < attack ? _smooth(i / attack) : 1);
|
||||
final ph = 2 * math.pi * i / sampleRate;
|
||||
out[i] += amp * env * 0.5 * (math.sin(ph * f1) + math.sin(ph * f2));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/// A hollow tube: a pitched thump that drops into its note, an overtone
|
||||
/// that dies fast, and a short click of air.
|
||||
Float64List _bamboo(Note n, math.Random rnd) {
|
||||
final len = _n(n.len);
|
||||
final out = Float64List(len);
|
||||
final k = math.log(100) / len;
|
||||
var ph = 0.0, ph2 = 0.0;
|
||||
for (var i = 0; i < len; i++) {
|
||||
final drop = math.exp(-i / _n(0.012));
|
||||
final hz = n.hz * (1 + 0.5 * drop);
|
||||
ph += 2 * math.pi * hz / sampleRate;
|
||||
ph2 += 2 * math.pi * hz * 2.31 / sampleRate;
|
||||
out[i] = math.exp(-k * i) * (math.sin(ph) + 0.35 * math.sin(ph2) * math.exp(-k * 3 * i));
|
||||
}
|
||||
final click = _bandNoise(_n(0.006), n.hz * 3, 2.0, rnd);
|
||||
for (var i = 0; i < click.length && i < len; i++) {
|
||||
out[i] += 0.5 * click[i] * (1 - i / click.length);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/// Hardwood: two resonances excited by a very short noise burst.
|
||||
Float64List _clack(Note n, math.Random rnd) {
|
||||
final len = _n(n.len);
|
||||
final out = Float64List(len);
|
||||
final burst = Float64List(len);
|
||||
for (var i = 0; i < _n(0.002) && i < len; i++) {
|
||||
burst[i] = rnd.nextDouble() * 2 - 1;
|
||||
}
|
||||
for (final (f, q, g) in [(n.hz, 18.0, 1.0), (n.hz * 1.47, 14.0, 0.6), (n.hz * 2.6, 10.0, 0.25)]) {
|
||||
final r = _biquadBand(burst, f, q);
|
||||
for (var i = 0; i < len; i++) {
|
||||
out[i] += g * r[i];
|
||||
}
|
||||
}
|
||||
final k = math.log(100) / len;
|
||||
for (var i = 0; i < len; i++) {
|
||||
out[i] *= math.exp(-k * i);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/// Air through a band that sweeps from [Note.hz] to [Note.to], swelling in
|
||||
/// and out (a paper door sliding along its track).
|
||||
Float64List _slide(Note n, math.Random rnd) {
|
||||
final len = _n(n.len);
|
||||
final noise = Float64List(len);
|
||||
for (var i = 0; i < len; i++) {
|
||||
noise[i] = rnd.nextDouble() * 2 - 1;
|
||||
}
|
||||
final out = Float64List(len);
|
||||
// Sweep in short blocks (a fixed band per block is plenty).
|
||||
const block = 256;
|
||||
var z1 = 0.0, z2 = 0.0;
|
||||
for (var b0 = 0; b0 < len; b0 += block) {
|
||||
final t = b0 / len;
|
||||
final f = n.hz * math.pow((n.to ?? n.hz) / n.hz, t);
|
||||
final c = _bandCoeffs(f.toDouble(), 2.5);
|
||||
for (var i = b0; i < math.min(len, b0 + block); i++) {
|
||||
final y = c.$1 * noise[i] + z1;
|
||||
z1 = c.$2 * noise[i] - c.$4 * y + z2;
|
||||
z2 = c.$3 * noise[i] - c.$5 * y;
|
||||
final tt = i / len;
|
||||
// Rises, holds, eases off: sin^2 swell with a tail.
|
||||
final env = math.pow(math.sin(math.pi * math.min(1, tt * 1.25)), 2).toDouble();
|
||||
// The track's grain: a slow flutter.
|
||||
final grain = 1 + 0.25 * math.sin(2 * math.pi * 23 * i / sampleRate);
|
||||
out[i] = y * env * grain;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
Float64List _bandNoise(int len, double f, double q, math.Random rnd) {
|
||||
final x = Float64List(len);
|
||||
for (var i = 0; i < len; i++) {
|
||||
x[i] = rnd.nextDouble() * 2 - 1;
|
||||
}
|
||||
return _biquadBand(x, f, q);
|
||||
}
|
||||
|
||||
/// RBJ band-pass (constant 0 dB peak): (b0, b1, b2, a1, a2), b1 = 0.
|
||||
(double, double, double, double, double) _bandCoeffs(double f, double q) {
|
||||
final w = 2 * math.pi * math.min(f, sampleRate * 0.45) / sampleRate;
|
||||
final alpha = math.sin(w) / (2 * q);
|
||||
final a0 = 1 + alpha;
|
||||
return (alpha / a0, 0, -alpha / a0, -2 * math.cos(w) / a0, (1 - alpha) / a0);
|
||||
}
|
||||
|
||||
Float64List _biquadBand(Float64List x, double f, double q) {
|
||||
final c = _bandCoeffs(f, q);
|
||||
final y = Float64List(x.length);
|
||||
var z1 = 0.0, z2 = 0.0;
|
||||
for (var i = 0; i < x.length; i++) {
|
||||
final v = c.$1 * x[i] + z1;
|
||||
z1 = c.$2 * x[i] - c.$4 * v + z2;
|
||||
z2 = c.$3 * x[i] - c.$5 * v;
|
||||
y[i] = v;
|
||||
}
|
||||
return y;
|
||||
}
|
||||
|
||||
/// One pole, twice: a soft 12 dB/oct roll-off.
|
||||
void _lowpass(Float64List out, double cutoff) {
|
||||
final a = math.exp(-2 * math.pi * cutoff / sampleRate);
|
||||
for (var pass = 0; pass < 2; pass++) {
|
||||
var y = 0.0;
|
||||
for (var i = 0; i < out.length; i++) {
|
||||
y = (1 - a) * out[i] + a * y;
|
||||
out[i] = y;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 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.
|
||||
Uint8List wav(Float64List samples) {
|
||||
final data = samples.length * 2;
|
||||
final b = ByteData(44 + data);
|
||||
void str(int o, String s) {
|
||||
for (var i = 0; i < s.length; i++) {
|
||||
b.setUint8(o + i, s.codeUnitAt(i));
|
||||
}
|
||||
}
|
||||
|
||||
str(0, 'RIFF');
|
||||
b.setUint32(4, 36 + data, Endian.little);
|
||||
str(8, 'WAVE');
|
||||
str(12, 'fmt ');
|
||||
b.setUint32(16, 16, Endian.little);
|
||||
b.setUint16(20, 1, Endian.little);
|
||||
b.setUint16(22, 1, Endian.little);
|
||||
b.setUint32(24, sampleRate, Endian.little);
|
||||
b.setUint32(28, sampleRate * 2, Endian.little);
|
||||
b.setUint16(32, 2, Endian.little);
|
||||
b.setUint16(34, 16, Endian.little);
|
||||
str(36, 'data');
|
||||
b.setUint32(40, data, Endian.little);
|
||||
for (var i = 0; i < samples.length; i++) {
|
||||
b.setInt16(44 + i * 2, (samples[i].clamp(-1.0, 1.0) * 32767).round(), Endian.little);
|
||||
}
|
||||
return b.buffer.asUint8List();
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:media_kit/media_kit.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
import 'sfx.dart';
|
||||
import 'sound_bank.dart';
|
||||
import 'synth.dart';
|
||||
|
||||
/// Plays [SoundSpec]s: each is rendered once to a WAV in the temp dir and
|
||||
/// played on a small pool of audio-only mpv players (so two sounds can
|
||||
/// overlap). [volume] (0..1) is read per play and applied on a square
|
||||
/// curve (ears are log); [enabled] filters cues.
|
||||
class SynthSfx implements SfxService {
|
||||
final double Function() volume;
|
||||
final bool Function(SfxKind kind) enabled;
|
||||
/// 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 = [];
|
||||
var _next = 0;
|
||||
final Map<String, Future<String>> _files = {};
|
||||
Directory? _dir;
|
||||
|
||||
@override
|
||||
Future<void> play(SfxCue cue) async {
|
||||
if (!enabled(cue.kind)) return;
|
||||
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), 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 {
|
||||
final v = volume();
|
||||
if (v <= 0) return;
|
||||
try {
|
||||
final path = await (_files[key] ??= _write(key, spec));
|
||||
final player = await _player();
|
||||
await player.setVolume(100 * v * v);
|
||||
await player.open(Media(path));
|
||||
} catch (e) {
|
||||
_files.remove(key);
|
||||
debugPrint('sfx $key: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<String> _write(String key, SoundSpec spec) async {
|
||||
_dir ??= await Directory('${(await getTemporaryDirectory()).path}/watch_together_sfx').create(recursive: true);
|
||||
final f = File('${_dir!.path}/$key.wav');
|
||||
await f.writeAsBytes(wav(render(spec)));
|
||||
return f.path;
|
||||
}
|
||||
|
||||
Future<Player> _player() async {
|
||||
if (_pool.length < _poolSize) {
|
||||
final p = Player(
|
||||
configuration: const PlayerConfiguration(vo: 'null', osc: false, libass: false, title: 'sfx'),
|
||||
);
|
||||
await (p.platform as NativePlayer).setProperty('audio-display', 'no');
|
||||
_pool.add(p);
|
||||
return p;
|
||||
}
|
||||
return _pool[_next++ % _poolSize];
|
||||
}
|
||||
|
||||
Future<void> dispose() async {
|
||||
for (final p in _pool) {
|
||||
await p.dispose();
|
||||
}
|
||||
_pool.clear();
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,19 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io' show File;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:fox_utils/fox_utils.dart' show ClipboardX, DesktopDropX, DropTarget;
|
||||
import 'package:signals/signals_flutter.dart';
|
||||
import 'package:window_manager/window_manager.dart';
|
||||
|
||||
import '../files/folder_store.dart';
|
||||
import '../files/ui/files_page.dart' show showFilesFolder;
|
||||
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';
|
||||
@@ -18,6 +23,7 @@ import '../state/settings.dart';
|
||||
import '../update/update_controller.dart';
|
||||
import 'chat_panel.dart';
|
||||
import 'connect_cards.dart';
|
||||
import 'handoff.dart';
|
||||
import 'library_tab.dart';
|
||||
import 'motion.dart';
|
||||
import 'party_strip.dart';
|
||||
@@ -26,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
|
||||
@@ -36,6 +43,9 @@ import 'toasts.dart';
|
||||
/// hidden), mini it is a 384x216 box snapped to a corner. A mode change
|
||||
/// fades the layer out, moves it while invisible and fades it back in, so
|
||||
/// the video is never mounted twice nor remounted.
|
||||
///
|
||||
/// A drop anywhere on the window or Ctrl+V outside a text field is a
|
||||
/// [Handoff]: a card near the pointer asks how to open it.
|
||||
class AppShell extends StatefulWidget {
|
||||
final UpdateController? updates;
|
||||
final bool autoUpdateCheck;
|
||||
@@ -88,6 +98,11 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
||||
Offset? _drag; // mini top-left while dragging
|
||||
bool _snapping = false;
|
||||
|
||||
/// Last pointer position in the window (where a paste's card opens).
|
||||
Offset? _pointer;
|
||||
bool _dragging = false;
|
||||
bool _handoffOpen = false;
|
||||
|
||||
final _flow = ConnectFlow();
|
||||
late final _join = JoinFields(SessionState.instance.lastForm);
|
||||
late final _host = HostFields(SessionState.instance.lastForm);
|
||||
@@ -99,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);
|
||||
@@ -127,7 +145,7 @@ 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;
|
||||
_feedSeen = coord.feedLog.peek().length;
|
||||
@@ -167,17 +185,28 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
||||
if (!_chatOpen) setState(() => _unread += chats);
|
||||
}));
|
||||
}));
|
||||
// Kicked: back offline, and say so.
|
||||
// Back from sitting out with the player closed: show it (the media
|
||||
// plays again).
|
||||
var wasOut = false;
|
||||
_stops.add(effect(() {
|
||||
final kick = coord.kickReason.value;
|
||||
if (kick == null) {
|
||||
_lastKickShown = null;
|
||||
return;
|
||||
}
|
||||
untracked(() {
|
||||
if (session.route.value == AppRoute.session) session.route.value = AppRoute.home;
|
||||
final out = coord.selfOut.value;
|
||||
if (wasOut && !out && session.route.peek() == AppRoute.session) {
|
||||
_later(() {
|
||||
if (_mode == PlayerMode.none) _show(PlayerMode.mini);
|
||||
});
|
||||
}
|
||||
wasOut = out;
|
||||
}));
|
||||
// Solo becoming a hosted server: the player closes while the media
|
||||
// moves over and comes back the way it was once it is up again.
|
||||
_stops.add(effect(() {
|
||||
if (!session.switching.value) return;
|
||||
_later(() {
|
||||
_lastMediaId = -1;
|
||||
_autoFull = _mode == PlayerMode.full;
|
||||
if (_fullscreen) _toggleFullscreen();
|
||||
_show(PlayerMode.none);
|
||||
});
|
||||
if (kick.isNotEmpty) _later(() => _showKicked(kick));
|
||||
}));
|
||||
// The hosted server died mid-session (SessionState leaves): say so.
|
||||
_stops.add(effect(() {
|
||||
@@ -195,8 +224,10 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
||||
for (final stop in _stops) {
|
||||
stop();
|
||||
}
|
||||
HardwareKeyboard.instance.removeHandler(_onKey);
|
||||
_fade.dispose();
|
||||
_online.dispose();
|
||||
_alone.dispose();
|
||||
_hasMedia.dispose();
|
||||
_flow.dispose();
|
||||
_join.dispose();
|
||||
@@ -227,7 +258,6 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
||||
}
|
||||
|
||||
void _wentOffline() {
|
||||
_mutedVolume = null;
|
||||
if (_fullscreen) _toggleFullscreen();
|
||||
setState(() {
|
||||
_chatOpen = false;
|
||||
@@ -243,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;
|
||||
@@ -267,27 +296,15 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
||||
_show(PlayerMode.mini);
|
||||
}
|
||||
|
||||
/// Mini's ×: stop watching but stay in the server (muted until shown).
|
||||
/// The player's ×: sit out (stay in the server, nothing loaded; the
|
||||
/// player shows the join-back card). Once out, × closes the player.
|
||||
void _closePlayer() {
|
||||
final playback = SessionState.instance.coordinator.playback;
|
||||
if (playback.alive.peek()) {
|
||||
try {
|
||||
_mutedVolume = playback.player.state.volume;
|
||||
playback.player.setVolume(0);
|
||||
} catch (_) {}
|
||||
}
|
||||
final coord = SessionState.instance.coordinator;
|
||||
if (coord.selfOut.peek()) {
|
||||
_show(PlayerMode.none);
|
||||
} else {
|
||||
coord.sitOut();
|
||||
}
|
||||
|
||||
void _unmute() {
|
||||
final v = _mutedVolume;
|
||||
if (v == null) return;
|
||||
_mutedVolume = null;
|
||||
final playback = SessionState.instance.coordinator.playback;
|
||||
if (!playback.alive.peek()) return;
|
||||
try {
|
||||
playback.player.setVolume(v);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
Future<void> _setWindowFullscreen(bool on) async {
|
||||
@@ -345,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) {
|
||||
@@ -356,6 +374,12 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
||||
}
|
||||
unawaited(_jellyfin.rememberPick(media));
|
||||
}
|
||||
if (!_online.peek()) {
|
||||
final error = await _startSolo();
|
||||
if (error != null || !mounted) return error;
|
||||
}
|
||||
// Alone, a link plays direct: there is nobody to pass it on to.
|
||||
if (media is LinkMedia && session.solo.peek()) media = LinkMedia(media.url);
|
||||
final error = await session.coordinator.swapPicked(media);
|
||||
if (error != null || !mounted) return error;
|
||||
final f = session.lastForm;
|
||||
@@ -368,9 +392,151 @@ 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.
|
||||
bool get _onTop => !_handoffOpen && (ModalRoute.of(context)?.isCurrent ?? true);
|
||||
|
||||
/// Ctrl+V outside a text field (a focused field pastes on its own).
|
||||
bool _onKey(KeyEvent e) {
|
||||
if (e is! KeyDownEvent || e.logicalKey != LogicalKeyboardKey.keyV) return false;
|
||||
final keys = HardwareKeyboard.instance;
|
||||
if (!(keys.isControlPressed || keys.isMetaPressed) || keys.isShiftPressed || keys.isAltPressed) return false;
|
||||
if (!mounted || !_onTop) return false;
|
||||
final inField =
|
||||
FocusManager.instance.primaryFocus?.context?.findAncestorStateOfType<EditableTextState>() != null;
|
||||
if (inField) return false;
|
||||
unawaited(_paste());
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Clipboard files first, then text (a link or absolute paths). A pasted
|
||||
/// image has no taker yet, so the clipboard's image data is not read.
|
||||
Future<void> _paste() async {
|
||||
var files = const <String>[];
|
||||
String? text;
|
||||
try {
|
||||
files = await ClipboardX.readFiles();
|
||||
} catch (_) {}
|
||||
if (files.isEmpty) {
|
||||
try {
|
||||
text = await ClipboardX.readText();
|
||||
} catch (_) {}
|
||||
}
|
||||
await _handOff(classifyHandoff(paths: files, text: text), _pointer);
|
||||
}
|
||||
|
||||
Future<void> _handOff(Handoff item, Offset? at) async {
|
||||
if (!mounted || !_onTop || item is HandoffNothing || item is HandoffImage) return;
|
||||
final size = MediaQuery.sizeOf(context);
|
||||
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));
|
||||
_handoffOpen = true;
|
||||
final HandoffAction? action;
|
||||
try {
|
||||
action = await showPopover<HandoffAction>(context, at: align, child: HandoffCard(item: item, solo: _alone.peek()));
|
||||
} finally {
|
||||
_handoffOpen = false;
|
||||
}
|
||||
if (action == null || !mounted) return;
|
||||
switch ((item, action)) {
|
||||
case (HandoffVideos(:final paths), HandoffAction.putUp):
|
||||
final error = await _putUp(FileMedia(paths.first));
|
||||
if (error != null) {
|
||||
SessionState.instance.coordinator.showToast(error);
|
||||
} else {
|
||||
await FolderStore.instance.ensureLoaded();
|
||||
FolderStore.instance.addRecent(paths.first);
|
||||
}
|
||||
case (HandoffVideos(:final paths), HandoffAction.addFolder):
|
||||
await _addFolder(File(paths.first).parent.path);
|
||||
case (HandoffFolder(:final path), _):
|
||||
await _addFolder(path);
|
||||
case (HandoffLink(:final url), _):
|
||||
_picker.link.text = url;
|
||||
_openLibrary(MediaKind.link);
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _addFolder(String dir) async {
|
||||
await FolderStore.instance.ensureLoaded();
|
||||
FolderStore.instance.addFolder(dir);
|
||||
_openFiles(dir);
|
||||
}
|
||||
|
||||
/// Library → Files on [dir] (null: Recent).
|
||||
void _openFiles(String? dir) {
|
||||
showFilesFolder(dir);
|
||||
_openLibrary(MediaKind.file);
|
||||
}
|
||||
|
||||
void _openLibrary(MediaKind source) {
|
||||
if (!mounted) return;
|
||||
_picker.source = source;
|
||||
_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) {
|
||||
setState(() => _dragging = false);
|
||||
_handOff(classifyHandoff(paths: DesktopDropX.pathsOf(d.files)), d.localPosition);
|
||||
},
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
child,
|
||||
Positioned.fill(
|
||||
child: IgnorePointer(
|
||||
child: AnimatedOpacity(
|
||||
duration: easeTime,
|
||||
curve: ease,
|
||||
opacity: _dragging ? 1 : 0,
|
||||
child: const _DropOverlay(),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
// --------------------------------------------------------------- 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(
|
||||
@@ -389,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;
|
||||
@@ -426,7 +586,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
return CallbackShortcuts(
|
||||
final shell = CallbackShortcuts(
|
||||
bindings: {
|
||||
const SingleActivator(LogicalKeyboardKey.comma, control: true): () => _selectTab(ShellTab.settings),
|
||||
},
|
||||
@@ -434,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: [
|
||||
@@ -448,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,
|
||||
@@ -457,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,
|
||||
@@ -474,23 +636,45 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
||||
}),
|
||||
),
|
||||
);
|
||||
return Listener(
|
||||
onPointerHover: (e) => _pointer = e.localPosition,
|
||||
onPointerDown: (e) => _pointer = e.localPosition,
|
||||
child: _dropTarget(shell),
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
),
|
||||
@@ -533,7 +717,11 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
||||
),
|
||||
),
|
||||
if (online && _shown != PlayerMode.none) _playerLayer(area),
|
||||
Positioned(left: 16, bottom: _fullscreen ? 90 : 16, child: const PartyToasts()),
|
||||
Positioned(left: 16, bottom: _fullscreen ? 90 : 16, child: PartyToasts(
|
||||
onOpenChat: () {
|
||||
if (!_chatOpen) _toggleChat();
|
||||
},
|
||||
)),
|
||||
],
|
||||
);
|
||||
});
|
||||
@@ -619,3 +807,39 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Over the window while something is dragged over it.
|
||||
class _DropOverlay extends StatelessWidget {
|
||||
const _DropOverlay();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
return Container(
|
||||
key: const ValueKey('drop-overlay'),
|
||||
margin: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: scheme.surface.withValues(alpha: 0.55),
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
border: Border.all(color: scheme.primary.withValues(alpha: 0.7), width: 2),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Material(
|
||||
color: scheme.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
elevation: 8,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 12),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.file_download_outlined, color: scheme.primary),
|
||||
const SizedBox(width: 10),
|
||||
Text('Drop to open', style: Theme.of(context).textTheme.titleMedium),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,8 @@ class AvatarInfo {
|
||||
final Color color;
|
||||
final ImageProvider? image;
|
||||
final bool ready, buffering, relaying, stale, host, self;
|
||||
/// Sitting out: dimmed, with an "away" mark.
|
||||
final bool out;
|
||||
const AvatarInfo({
|
||||
this.id = '',
|
||||
required this.name,
|
||||
@@ -28,6 +30,7 @@ class AvatarInfo {
|
||||
this.stale = false,
|
||||
this.host = false,
|
||||
this.self = false,
|
||||
this.out = false,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -75,7 +78,7 @@ ImageProvider? memberAvatarImage(MemberView m, SessionCoordinator coord) {
|
||||
}
|
||||
|
||||
/// Your own avatar from the profile (also offline).
|
||||
AvatarInfo selfAvatarInfo({bool ready = false, bool host = false}) {
|
||||
AvatarInfo selfAvatarInfo({bool ready = false, bool host = false, bool out = false}) {
|
||||
final p = Profile.instance;
|
||||
final png = p.picture.value;
|
||||
final name = p.name.value;
|
||||
@@ -86,6 +89,7 @@ AvatarInfo selfAvatarInfo({bool ready = false, bool host = false}) {
|
||||
ready: ready,
|
||||
host: host,
|
||||
self: true,
|
||||
out: out,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -100,13 +104,14 @@ 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 < RelayConstants.minBufferSecs,
|
||||
buffering: !m.stale && m.ready && 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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -132,7 +137,8 @@ class PartyWatch extends StatelessWidget {
|
||||
|
||||
/// Circle avatar with a status ring outside it and a gap in the background
|
||||
/// colour, so overlapping neighbours never hide the status. Crown = host,
|
||||
/// tower = passing the video on, spinner = buffering, dimmed = stale.
|
||||
/// tower = passing the video on, spinner = buffering, dimmed = stale,
|
||||
/// dimmed with a moon = sitting out.
|
||||
class StatusAvatar extends StatelessWidget {
|
||||
final AvatarInfo info;
|
||||
final double size;
|
||||
@@ -176,7 +182,7 @@ class StatusAvatar extends StatelessWidget {
|
||||
decoration: BoxDecoration(shape: BoxShape.circle, color: bg),
|
||||
padding: EdgeInsets.all(stroke),
|
||||
child: Opacity(
|
||||
opacity: info.stale ? 0.4 : 1,
|
||||
opacity: info.stale || info.out ? 0.4 : 1,
|
||||
child: CircleAvatar(
|
||||
radius: size / 2,
|
||||
backgroundColor: info.color,
|
||||
@@ -205,6 +211,17 @@ class StatusAvatar extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
if (info.out)
|
||||
Positioned(
|
||||
key: const ValueKey('away-mark'),
|
||||
left: -3,
|
||||
bottom: -3,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(2),
|
||||
decoration: BoxDecoration(shape: BoxShape.circle, color: bg),
|
||||
child: Icon(Icons.bedtime, size: (badge * 0.8).clamp(10.0, 24.0), color: Colors.blueGrey.shade200),
|
||||
),
|
||||
),
|
||||
if (badges && info.relaying)
|
||||
Positioned(
|
||||
right: -3,
|
||||
@@ -240,7 +257,7 @@ class _AvatarStackState extends State<AvatarStack> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final size = widget.size;
|
||||
int rank(AvatarInfo m) => m.host ? 0 : (m.ready ? 1 : 2);
|
||||
int rank(AvatarInfo m) => m.host ? 0 : (m.out ? 3 : m.ready ? 1 : 2);
|
||||
final sorted = [...widget.members]..sort((a, b) => rank(a) - rank(b));
|
||||
final visible = sorted.take(widget.shown).toList();
|
||||
final extra = sorted.length - visible.length;
|
||||
|
||||
@@ -1,22 +1,70 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
|
||||
import '../state/session_state.dart';
|
||||
import '../widgets/text_focus.dart';
|
||||
import 'avatar.dart';
|
||||
|
||||
/// Side panel: chat with avatars, other activity as quiet lines, and the
|
||||
/// message field. Width is fixed; the shell slides it in and out.
|
||||
class ChatPanel extends StatelessWidget {
|
||||
/// Activity lines (joins, ready, seeks...) between the messages: off by
|
||||
/// default, kept for the app's run.
|
||||
final showActivity = ValueNotifier(false);
|
||||
|
||||
/// Side panel: chat with avatars, other activity as quiet lines (behind
|
||||
/// the "Activity" toggle), and the message field. Width is fixed; the
|
||||
/// shell slides it in and out. The field takes focus on open; Tab and Esc
|
||||
/// in it close the panel.
|
||||
class ChatPanel extends StatefulWidget {
|
||||
static const width = 340.0;
|
||||
final TextEditingController chat;
|
||||
final FocusNode chatFocus;
|
||||
final VoidCallback onClose;
|
||||
const ChatPanel({super.key, required this.chat, required this.chatFocus, required this.onClose});
|
||||
|
||||
@override
|
||||
State<ChatPanel> createState() => _ChatPanelState();
|
||||
}
|
||||
|
||||
class _ChatPanelState extends State<ChatPanel> {
|
||||
// Where focus was before the field took it; it goes back there on close
|
||||
// (else a field behind could take it, and Space with it).
|
||||
FocusNode? _before;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
final before = FocusManager.instance.primaryFocus;
|
||||
if (before != widget.chatFocus) _before = before;
|
||||
widget.chatFocus.requestFocus();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
final before = _before;
|
||||
if (before != null && before.context != null && before.canRequestFocus && !textInputFocused(before)) {
|
||||
before.requestFocus();
|
||||
} else if (textInputFocused()) {
|
||||
FocusManager.instance.primaryFocus?.unfocus();
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _send() {
|
||||
SessionState.instance.coordinator.sendChat(chat.text);
|
||||
chat.clear();
|
||||
chatFocus.requestFocus();
|
||||
SessionState.instance.coordinator.sendChat(widget.chat.text);
|
||||
widget.chat.clear();
|
||||
widget.chatFocus.requestFocus();
|
||||
}
|
||||
|
||||
KeyEventResult _fieldKey(FocusNode node, KeyEvent e) {
|
||||
if (e is! KeyDownEvent) return KeyEventResult.ignored;
|
||||
if (e.logicalKey != LogicalKeyboardKey.tab && e.logicalKey != LogicalKeyboardKey.escape) {
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
widget.onClose();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -24,7 +72,7 @@ class ChatPanel extends StatelessWidget {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
return Container(
|
||||
key: const ValueKey('chat-panel'),
|
||||
width: width,
|
||||
width: ChatPanel.width,
|
||||
color: scheme.surfaceContainerLow,
|
||||
child: Column(
|
||||
children: [
|
||||
@@ -32,18 +80,35 @@ class ChatPanel extends StatelessWidget {
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 4, 0),
|
||||
child: Row(
|
||||
children: [
|
||||
const Text('Chat & activity', style: TextStyle(fontWeight: FontWeight.w600)),
|
||||
const Text('Chat', style: TextStyle(fontWeight: FontWeight.w600)),
|
||||
const Spacer(),
|
||||
IconButton(tooltip: 'Close (Tab)', onPressed: onClose, icon: const Icon(Icons.close, size: 18)),
|
||||
ValueListenableBuilder<bool>(
|
||||
valueListenable: showActivity,
|
||||
builder: (context, on, _) => FilterChip(
|
||||
key: const ValueKey('chat-activity'),
|
||||
label: const Text('Activity', style: TextStyle(fontSize: 12)),
|
||||
tooltip: 'Show joins, ready, seeks and other activity',
|
||||
selected: on,
|
||||
showCheckmark: false,
|
||||
visualDensity: VisualDensity.compact,
|
||||
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
onSelected: (v) => showActivity.value = v,
|
||||
),
|
||||
),
|
||||
IconButton(tooltip: 'Close (Tab)', onPressed: widget.onClose, icon: const Icon(Icons.close, size: 18)),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: PartyWatch(builder: (context) {
|
||||
final log = SessionState.instance.coordinator.feedLog.value;
|
||||
child: ValueListenableBuilder<bool>(
|
||||
valueListenable: showActivity,
|
||||
builder: (context, activity, _) => PartyWatch(builder: (context) {
|
||||
final all = SessionState.instance.coordinator.feedLog.value;
|
||||
final log = activity ? all : [for (final f in all) if (f.action == 'chat') f];
|
||||
if (log.isEmpty) {
|
||||
return Center(
|
||||
child: Text('Nothing yet', style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12)),
|
||||
child: Text(activity ? 'Nothing yet' : 'No messages yet',
|
||||
style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12)),
|
||||
);
|
||||
}
|
||||
return ListView.builder(
|
||||
@@ -54,12 +119,17 @@ class ChatPanel extends StatelessWidget {
|
||||
);
|
||||
}),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Focus(
|
||||
canRequestFocus: false,
|
||||
skipTraversal: true,
|
||||
onKeyEvent: _fieldKey,
|
||||
child: TextField(
|
||||
key: const ValueKey('chat-field'),
|
||||
controller: chat,
|
||||
focusNode: chatFocus,
|
||||
controller: widget.chat,
|
||||
focusNode: widget.chatFocus,
|
||||
onSubmitted: (_) => _send(),
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Message (Enter)',
|
||||
@@ -70,6 +140,7 @@ class ChatPanel extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -161,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),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../files/scanner.dart' show FileKind, kindOf;
|
||||
import '../files/ui/file_tiles.dart' show baseName;
|
||||
|
||||
// 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
|
||||
// how to open it ([HandoffCard]). The shell wires both.
|
||||
|
||||
/// What was dropped or pasted.
|
||||
sealed class Handoff {
|
||||
const Handoff();
|
||||
}
|
||||
|
||||
/// Video files (by the Files page's extensions), in the order given.
|
||||
final class HandoffVideos extends Handoff {
|
||||
final List<String> paths;
|
||||
const HandoffVideos(this.paths);
|
||||
}
|
||||
|
||||
final class HandoffFolder extends Handoff {
|
||||
final String path;
|
||||
const HandoffFolder(this.path);
|
||||
}
|
||||
|
||||
/// An http(s) link.
|
||||
final class HandoffLink extends Handoff {
|
||||
final String url;
|
||||
const HandoffLink(this.url);
|
||||
}
|
||||
|
||||
/// An image file. Nothing takes one yet; the shell ignores it.
|
||||
final class HandoffImage extends Handoff {
|
||||
final String path;
|
||||
const HandoffImage(this.path);
|
||||
}
|
||||
|
||||
final class HandoffNothing extends Handoff {
|
||||
const HandoffNothing();
|
||||
}
|
||||
|
||||
const _imageExtensions = {'png', 'jpg', 'jpeg', 'webp', 'gif', 'bmp'};
|
||||
|
||||
final _link = RegExp(r'^https?://\S+$', caseSensitive: false);
|
||||
final _windowsAbsolute = RegExp(r'^([A-Za-z]:[\\/]|\\\\)');
|
||||
|
||||
bool _absolute(String p) => p.startsWith('/') || _windowsAbsolute.hasMatch(p);
|
||||
|
||||
/// A `file://` URI as a path; anything else as is.
|
||||
String _pathOf(String p) {
|
||||
if (!p.startsWith('file://')) return p;
|
||||
try {
|
||||
return Uri.parse(p).toFilePath();
|
||||
} catch (_) {
|
||||
return p;
|
||||
}
|
||||
}
|
||||
|
||||
/// Classifies dropped or pasted [paths] (files first) or, when there are
|
||||
/// none, pasted [text]: a link, or one absolute path (or `file://` URI) per
|
||||
/// line. Videos win over folders, folders over images; paths that do not
|
||||
/// exist ([typeOf], defaults to the file system) count for nothing.
|
||||
Handoff classifyHandoff({
|
||||
List<String> paths = const [],
|
||||
String? text,
|
||||
FileSystemEntityType Function(String path)? typeOf,
|
||||
}) {
|
||||
final type = typeOf ?? (p) => FileSystemEntity.typeSync(p);
|
||||
var list = [for (final p in paths) if (p.trim().isNotEmpty) _pathOf(p.trim())];
|
||||
if (list.isEmpty && text != null) {
|
||||
final t = text.trim();
|
||||
if (_link.hasMatch(t)) return HandoffLink(t);
|
||||
final lines = [for (final l in t.split(RegExp(r'[\r\n]+'))) if (l.trim().isNotEmpty) _pathOf(l.trim())];
|
||||
if (lines.isEmpty || !lines.every(_absolute)) return const HandoffNothing();
|
||||
list = lines;
|
||||
}
|
||||
final types = {for (final p in list) p: type(p)};
|
||||
bool file(String p) => types[p] == FileSystemEntityType.file;
|
||||
final videos = [for (final p in list) if (file(p) && kindOf(p) == FileKind.video) p];
|
||||
if (videos.isNotEmpty) return HandoffVideos(videos);
|
||||
for (final p in list) {
|
||||
if (types[p] == FileSystemEntityType.directory) return HandoffFolder(p);
|
||||
}
|
||||
for (final p in list) {
|
||||
final dot = p.lastIndexOf('.');
|
||||
if (file(p) && dot >= 0 && _imageExtensions.contains(p.substring(dot + 1).toLowerCase())) {
|
||||
return HandoffImage(p);
|
||||
}
|
||||
}
|
||||
return const HandoffNothing();
|
||||
}
|
||||
|
||||
/// What the card's buttons ask the shell to do.
|
||||
enum HandoffAction {
|
||||
/// Put the (first) video up (offline: watch it solo).
|
||||
putUp,
|
||||
/// Add the video's folder (or the folder) to Files and open it.
|
||||
addFolder,
|
||||
/// Open Library → Link with the URL filled.
|
||||
openLink,
|
||||
}
|
||||
|
||||
/// The card asking how to open a [Handoff] (videos, a folder or a link);
|
||||
/// pops the [HandoffAction] chosen.
|
||||
class HandoffCard extends StatelessWidget {
|
||||
final Handoff item;
|
||||
/// 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) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
void pick(HandoffAction a) => Navigator.of(context).pop(a);
|
||||
Widget button(HandoffAction a, IconData icon, String label, {bool primary = false}) => primary
|
||||
? FilledButton.icon(
|
||||
key: ValueKey('handoff-${a.name}'), onPressed: () => pick(a), icon: Icon(icon, size: 18), label: Text(label))
|
||||
: OutlinedButton.icon(
|
||||
key: ValueKey('handoff-${a.name}'), onPressed: () => pick(a), icon: Icon(icon, size: 18), label: Text(label));
|
||||
|
||||
final (IconData icon, String title, String detail, List<Widget> buttons) = switch (item) {
|
||||
HandoffVideos(:final paths) => (
|
||||
Icons.movie_outlined,
|
||||
baseName(paths.first),
|
||||
paths.length > 1 ? 'and ${paths.length - 1} more · the first one goes up' : 'A video on this computer',
|
||||
[
|
||||
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'),
|
||||
],
|
||||
),
|
||||
HandoffFolder(:final path) => (
|
||||
Icons.folder_outlined,
|
||||
baseName(path),
|
||||
path,
|
||||
[button(HandoffAction.addFolder, Icons.create_new_folder_outlined, 'Add to Files', primary: true)],
|
||||
),
|
||||
HandoffLink(:final url) => (
|
||||
Icons.link,
|
||||
url,
|
||||
solo ? 'Opens it on the Link page' : 'Pick how the others get it on the Link page',
|
||||
[
|
||||
button(HandoffAction.openLink, Icons.link, solo ? 'Open in Link' : 'Put it up as a link…',
|
||||
primary: true),
|
||||
],
|
||||
),
|
||||
HandoffImage() || HandoffNothing() => (Icons.help_outline, '', '', const <Widget>[]),
|
||||
};
|
||||
return Material(
|
||||
key: const ValueKey('handoff-card'),
|
||||
elevation: 12,
|
||||
color: scheme.surfaceContainer,
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 380),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(18, 16, 18, 16),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(icon, color: scheme.primary),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(title,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.titleSmall),
|
||||
Text(detail,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
Wrap(spacing: 8, runSpacing: 8, children: buttons),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:sync_relay_core/core.dart' show LinkProbe, hasMediaExtension, notAVideoText;
|
||||
|
||||
import '../playback/yt_dlp_app.dart';
|
||||
import '../screens/home/media_picker.dart';
|
||||
import '../state/connect_prefs.dart';
|
||||
import 'motion.dart';
|
||||
@@ -109,49 +110,49 @@ class LinkPreview {
|
||||
const LinkPreview({required this.title, required this.subtitle, this.thumbnail});
|
||||
}
|
||||
|
||||
/// GET [uri] and return the body (oEmbed).
|
||||
typedef OEmbedGetter = Future<String> Function(Uri uri);
|
||||
/// What a link is (see [AppYtDlp.probe]).
|
||||
typedef LinkProber = Future<LinkProbe> Function(String url);
|
||||
|
||||
Future<String> defaultOEmbedGet(Uri uri) async {
|
||||
final client = HttpClient()..connectionTimeout = const Duration(seconds: 6);
|
||||
try {
|
||||
final req = await client.getUrl(uri);
|
||||
final res = await req.close().timeout(const Duration(seconds: 8));
|
||||
if (res.statusCode != 200) throw HttpException('status ${res.statusCode}', uri: uri);
|
||||
return await res.transform(utf8.decoder).join().timeout(const Duration(seconds: 8));
|
||||
} finally {
|
||||
client.close(force: true);
|
||||
}
|
||||
}
|
||||
|
||||
bool isYouTubeLink(Uri uri) {
|
||||
final h = uri.host.toLowerCase();
|
||||
return h == 'youtu.be' || h == 'youtube.com' || h.endsWith('.youtube.com');
|
||||
}
|
||||
Future<LinkProbe> defaultLinkProber(String url) => AppYtDlp.instance.probe(url);
|
||||
|
||||
String _host(Uri uri) => uri.host.startsWith('www.') ? uri.host.substring(4) : uri.host;
|
||||
|
||||
/// The preview of [url]: YouTube links ask YouTube's oEmbed for the title,
|
||||
/// channel and thumbnail; anything else (or a failed lookup) shows the
|
||||
/// file name and host. Null for text that is not an http(s) link.
|
||||
Future<LinkPreview?> previewLink(String url, {OEmbedGetter get = defaultOEmbedGet}) async {
|
||||
/// "3:07", "1:02:03".
|
||||
String clockText(double seconds) {
|
||||
final s = seconds.round();
|
||||
final h = s ~/ 3600, m = s % 3600 ~/ 60, sec = (s % 60).toString().padLeft(2, '0');
|
||||
return h > 0 ? '$h:${m.toString().padLeft(2, '0')}:$sec' : '$m:$sec';
|
||||
}
|
||||
|
||||
/// The preview of [url]: a web page shows yt-dlp's title, uploader,
|
||||
/// duration and thumbnail (or why it is not a video); a media file (by
|
||||
/// name or by probe) its file name and host. Null for text that is not an http(s) link.
|
||||
Future<LinkPreview?> previewLink(String url, {LinkProber probe = defaultLinkProber}) async {
|
||||
final uri = Uri.tryParse(url.trim());
|
||||
if (uri == null || !(uri.scheme == 'http' || uri.scheme == 'https') || uri.host.isEmpty) return null;
|
||||
final plain = LinkPreview(title: LinkMedia(url.trim()).label, subtitle: _host(uri));
|
||||
if (!isYouTubeLink(uri)) return plain;
|
||||
final host = _host(uri);
|
||||
// A media file name needs no lookup.
|
||||
if (hasMediaExtension(uri)) return LinkPreview(title: LinkMedia(url.trim()).label, subtitle: host);
|
||||
final LinkProbe p;
|
||||
try {
|
||||
final body = await get(Uri.https('www.youtube.com', '/oembed', {'url': url.trim(), 'format': 'json'}));
|
||||
final j = jsonDecode(body);
|
||||
if (j is! Map || j['title'] is! String) return plain;
|
||||
final author = j['author_name'];
|
||||
return LinkPreview(
|
||||
title: j['title'] as String,
|
||||
subtitle: [_host(uri), if (author is String && author.isNotEmpty) author].join(' · '),
|
||||
thumbnail: j['thumbnail_url'] is String ? j['thumbnail_url'] as String : null,
|
||||
);
|
||||
} catch (_) {
|
||||
return plain;
|
||||
p = await probe(url.trim());
|
||||
} catch (e) {
|
||||
return LinkPreview(title: LinkMedia(url.trim()).label, subtitle: '$host · $e');
|
||||
}
|
||||
final info = p.info;
|
||||
if (!p.isPage) return LinkPreview(title: LinkMedia(url.trim()).label, subtitle: host);
|
||||
if (info == null) {
|
||||
return LinkPreview(title: p.title ?? host, subtitle: '$host · ${notAVideoText(p.ytDlpError)}');
|
||||
}
|
||||
return LinkPreview(
|
||||
title: p.title ?? host,
|
||||
subtitle: [
|
||||
host,
|
||||
if (info.uploader.isNotEmpty) info.uploader,
|
||||
if (info.duration case final d? when d > 0) clockText(d),
|
||||
].join(' · '),
|
||||
thumbnail: info.thumbnail,
|
||||
);
|
||||
}
|
||||
|
||||
/// "today", "yesterday", "3 days ago", "last week", "5 weeks ago", ...
|
||||
@@ -188,21 +189,25 @@ String linkRelayHelp(LinkRelay r) => switch (r) {
|
||||
'while it plays; the link is not shown to anyone.',
|
||||
};
|
||||
|
||||
/// The Link source. [offlineHint] set: the button is off and says why.
|
||||
/// Under Play when nobody else watches.
|
||||
const soloPlayHint = 'Plays just for you. Invite friends any time; it keeps playing.';
|
||||
|
||||
/// The Link source. [solo]: nobody else watches (offline or solo), so the
|
||||
/// button says Play and there are no relay modes (the link plays direct).
|
||||
class LibraryLinkSource extends StatefulWidget {
|
||||
final MediaPickerController controller;
|
||||
final String? offlineHint;
|
||||
final bool solo;
|
||||
/// Puts the link up; returns an error text, or null when sent.
|
||||
final Future<String?> Function(LinkMedia media) onPutUp;
|
||||
final RecentLinks recents;
|
||||
final OEmbedGetter oembed;
|
||||
final LinkProber probe;
|
||||
const LibraryLinkSource({
|
||||
super.key,
|
||||
required this.controller,
|
||||
required this.onPutUp,
|
||||
required this.recents,
|
||||
this.offlineHint,
|
||||
this.oembed = defaultOEmbedGet,
|
||||
this.solo = false,
|
||||
this.probe = defaultLinkProber,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -246,7 +251,7 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
|
||||
if (mounted) setState(() {});
|
||||
return;
|
||||
}
|
||||
final p = await previewLink(url, get: widget.oembed);
|
||||
final p = await previewLink(url, probe: widget.probe);
|
||||
_previews[url] = p;
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
@@ -260,19 +265,19 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
|
||||
|
||||
Future<void> _go() async {
|
||||
final url = _c.link.text.trim();
|
||||
if (url.isEmpty || _busy || widget.offlineHint != null) return;
|
||||
if (url.isEmpty || _busy) return;
|
||||
setState(() {
|
||||
_busy = true;
|
||||
_error = null;
|
||||
});
|
||||
String? error;
|
||||
try {
|
||||
error = await widget.onPutUp(LinkMedia(url, relay: _c.linkRelay));
|
||||
error = await widget.onPutUp(LinkMedia(url, relay: widget.solo ? LinkRelay.direct : _c.linkRelay));
|
||||
} catch (e) {
|
||||
error = '$e';
|
||||
}
|
||||
if (error == null) {
|
||||
final p = _previews[url] ?? await previewLink(url, get: widget.oembed);
|
||||
final p = _previews[url] ?? await previewLink(url, probe: widget.probe);
|
||||
unawaited(widget.recents.add(RecentLink(
|
||||
url: url,
|
||||
title: p?.title ?? LinkMedia(url).label,
|
||||
@@ -291,7 +296,7 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final t = Theme.of(context).textTheme;
|
||||
final hint = widget.offlineHint;
|
||||
final solo = widget.solo;
|
||||
final url = _c.link.text.trim();
|
||||
final preview = url.isEmpty ? null : _previews[url];
|
||||
final loading = url.isNotEmpty && !_previews.containsKey(url);
|
||||
@@ -311,11 +316,11 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
|
||||
);
|
||||
final button = FilledButton.icon(
|
||||
key: const ValueKey('link-put-up'),
|
||||
onPressed: url.isEmpty || _busy || hint != null ? null : _go,
|
||||
onPressed: url.isEmpty || _busy ? null : _go,
|
||||
icon: _busy
|
||||
? const SizedBox.square(dimension: 16, child: CircularProgressIndicator(strokeWidth: 2))
|
||||
: const Icon(Icons.play_arrow),
|
||||
label: Text(hint == null ? 'Put it up' : 'Play'),
|
||||
label: Text(solo ? 'Play' : 'Put it up'),
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize: const Size(0, 56),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
@@ -346,7 +351,7 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
|
||||
Text('Watch from a link', textAlign: TextAlign.center, style: t.headlineSmall),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'A video file, YouTube, a Jellyfin link… Ctrl+V anywhere pastes it here.',
|
||||
'A video file, a page (YouTube, X, …) or a Jellyfin link. Ctrl+V or drop it anywhere to bring it here.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: scheme.onSurfaceVariant),
|
||||
),
|
||||
@@ -360,10 +365,10 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
|
||||
])
|
||||
: Row(children: [Expanded(child: field), const SizedBox(width: 12), button]),
|
||||
),
|
||||
if (hint != null || _error != null) ...[
|
||||
if (solo || _error != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
_error ?? hint!,
|
||||
_error ?? soloPlayHint,
|
||||
key: const ValueKey('link-hint'),
|
||||
textAlign: TextAlign.center,
|
||||
style: t.bodySmall?.copyWith(color: _error != null ? scheme.error : Colors.grey),
|
||||
@@ -379,7 +384,7 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
|
||||
child: _PreviewCard(preview: preview, loading: loading),
|
||||
),
|
||||
),
|
||||
if (hint == null) ...[
|
||||
if (!solo) ...[
|
||||
const SizedBox(height: 28),
|
||||
Text('How the others get it', style: t.titleSmall),
|
||||
const SizedBox(height: 10),
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:signals/signals_flutter.dart';
|
||||
import 'package:sync_relay_core/core.dart' show HelloMedia, JellyfinException, JellyfinItem;
|
||||
|
||||
@@ -17,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
|
||||
@@ -59,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;
|
||||
|
||||
@@ -69,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;
|
||||
@@ -84,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
|
||||
@@ -116,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() {
|
||||
@@ -164,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';
|
||||
@@ -182,39 +181,12 @@ 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)));
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------- paste
|
||||
|
||||
KeyEventResult _onKey(FocusNode node, KeyEvent e) {
|
||||
final ctrl = HardwareKeyboard.instance.isControlPressed || HardwareKeyboard.instance.isMetaPressed;
|
||||
if (e is! KeyDownEvent || !ctrl || e.logicalKey != LogicalKeyboardKey.keyV) {
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
// A focused text field handles its own paste.
|
||||
final inField =
|
||||
FocusManager.instance.primaryFocus?.context?.findAncestorStateOfType<EditableTextState>() != null;
|
||||
if (inField) return KeyEventResult.ignored;
|
||||
unawaited(_pasteLink());
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
/// Ctrl+V outside a field: an http(s) link goes to the Link source.
|
||||
Future<void> _pasteLink() async {
|
||||
String? text;
|
||||
try {
|
||||
text = (await Clipboard.getData(Clipboard.kTextPlain))?.text?.trim();
|
||||
} catch (_) {}
|
||||
if (text == null || !(text.startsWith('http://') || text.startsWith('https://'))) return;
|
||||
_c.link.text = text;
|
||||
_c.source = MediaKind.link;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------- build
|
||||
|
||||
@override
|
||||
@@ -266,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),
|
||||
@@ -280,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))),
|
||||
],
|
||||
@@ -294,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),
|
||||
),
|
||||
@@ -306,10 +292,10 @@ class _LibraryTabState extends State<LibraryTab> {
|
||||
child: AnimatedOpacity(duration: easeTime, curve: ease, opacity: open ? 1 : 0, child: child),
|
||||
);
|
||||
|
||||
// Holds focus so the shell's shortcuts (Ctrl+,) work.
|
||||
return Focus(
|
||||
key: const ValueKey('library-tab'),
|
||||
autofocus: true,
|
||||
onKeyEvent: _onKey,
|
||||
child: LayoutBuilder(builder: (context, box) {
|
||||
if (box.maxWidth >= detailsDockFrom) {
|
||||
return Row(
|
||||
@@ -355,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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,17 +90,17 @@ Future<T?> showPopover<T>(
|
||||
},
|
||||
);
|
||||
|
||||
/// Horizontal lists for mouse users: drag with the mouse, and the plain
|
||||
/// (vertical) wheel moves them sideways. At either end the wheel is left
|
||||
/// to the page, so a row never traps vertical scrolling.
|
||||
/// Horizontal lists for mouse users: drag with the mouse or Shift+wheel;
|
||||
/// the plain wheel stays vertical (the page scrolls under the cursor, not
|
||||
/// the row).
|
||||
class HScroll extends StatefulWidget {
|
||||
final ScrollController? controller;
|
||||
final Widget Function(ScrollController controller) builder;
|
||||
/// Map the plain (vertical) wheel to sideways scrolling. Off where the
|
||||
/// children scroll vertically themselves (the schedule's day columns):
|
||||
/// there only mouse drag and Shift+wheel move sideways.
|
||||
/// Also map the plain (vertical) wheel to sideways scrolling (at either
|
||||
/// end the wheel goes to the page). Off by default: one wheel doing two
|
||||
/// things was confusing.
|
||||
final bool wheel;
|
||||
const HScroll({super.key, this.controller, required this.builder, this.wheel = true});
|
||||
const HScroll({super.key, this.controller, required this.builder, this.wheel = false});
|
||||
|
||||
@override
|
||||
State<HScroll> createState() => _HScrollState();
|
||||
|
||||
@@ -1,18 +1,16 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:signals/signals_flutter.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
import 'package:window_manager/window_manager.dart';
|
||||
|
||||
import '../screens/demo/shell_demo.dart';
|
||||
import '../screens/home/update_indicator.dart';
|
||||
import '../screens/session/jellyfin_widgets.dart' show ViaHostBadge;
|
||||
import '../state/session_state.dart';
|
||||
import '../update/update_controller.dart';
|
||||
import '../widgets/dialogs.dart';
|
||||
import '../widgets/app_keys.dart';
|
||||
import '../widgets/window_buttons.dart';
|
||||
import 'avatar.dart';
|
||||
import 'motion.dart';
|
||||
@@ -29,6 +27,9 @@ enum ShellTab {
|
||||
|
||||
const partyStripHeight = 60.0;
|
||||
|
||||
/// Every avatar in the strip, yours and the party's.
|
||||
const stripAvatarSize = 42.0;
|
||||
|
||||
/// Below this window width the strip shows 3 avatars and no server text,
|
||||
/// so the tabs keep their room.
|
||||
const stripCompactBelow = 1100.0;
|
||||
@@ -38,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;
|
||||
@@ -50,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;
|
||||
@@ -68,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,
|
||||
@@ -82,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: [
|
||||
@@ -90,14 +96,15 @@ class PartyStrip extends StatelessWidget {
|
||||
message: 'Your profile',
|
||||
child: InkWell(
|
||||
key: const ValueKey('strip-profile'),
|
||||
canRequestFocus: false,
|
||||
customBorder: const CircleBorder(),
|
||||
onTap: onProfile,
|
||||
child: StatusAvatar(
|
||||
info: selfAvatarInfo(
|
||||
ready: self?.ready ?? false,
|
||||
host: online && coord.hostFile != null,
|
||||
host: online && !solo && coord.hostFile != null,
|
||||
),
|
||||
size: 32,
|
||||
size: stripAvatarSize,
|
||||
ring: online,
|
||||
),
|
||||
),
|
||||
@@ -126,45 +133,59 @@ class PartyStrip extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
if (kDebugMode)
|
||||
IconButton(
|
||||
tooltip: 'Layout demo (debug builds only)',
|
||||
icon: const Icon(
|
||||
Icons.bug_report_outlined,
|
||||
size: 20,
|
||||
color: Colors.amber,
|
||||
),
|
||||
onPressed: () => Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(builder: (_) => const ShellDemo()),
|
||||
),
|
||||
),
|
||||
UpdateIndicator(controller: updates),
|
||||
const SizedBox(width: 8),
|
||||
if (online) ...[
|
||||
if (onShowPlayer != null)
|
||||
if (online && onShowPlayer != null)
|
||||
IconButton(
|
||||
key: const ValueKey('show-player'),
|
||||
tooltip: 'Show the player',
|
||||
onPressed: onShowPlayer,
|
||||
icon: const Icon(Icons.smart_display_outlined, size: 20),
|
||||
),
|
||||
if (solo) ...[
|
||||
_SoloChip(compact: compact, busy: session.switching.value),
|
||||
const SizedBox(width: 10),
|
||||
if (compact)
|
||||
FilledButton(
|
||||
key: const ValueKey('strip-invite'),
|
||||
onPressed: session.switching.value ? null : onInvite,
|
||||
child: const Text('Invite'),
|
||||
)
|
||||
else
|
||||
FilledButton.icon(
|
||||
key: const ValueKey('strip-invite'),
|
||||
onPressed: session.switching.value ? null : onInvite,
|
||||
icon: const Icon(Icons.group_add_outlined, size: 18),
|
||||
label: const Text('Invite friends'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
OutlinedButton(
|
||||
key: const ValueKey('strip-leave'),
|
||||
onPressed: session.switching.value ? null : onLeave,
|
||||
child: const Text('Stop'),
|
||||
),
|
||||
] else if (online) ...[
|
||||
PartyWatch(
|
||||
builder: (context) => AvatarStack(
|
||||
key: const ValueKey('party-avatars'),
|
||||
members: partyAvatars(coord),
|
||||
shown: compact ? 3 : 5,
|
||||
size: stripAvatarSize,
|
||||
onTap: () => showMembersCard(context),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
if (!compact) _ServerLabel(scheme: scheme),
|
||||
const SizedBox(width: 6),
|
||||
Badge(
|
||||
// Tab toggles the chat while the strip is shown.
|
||||
ChatKey(
|
||||
onToggle: onChat,
|
||||
child: Badge(
|
||||
isLabelVisible: unread > 0,
|
||||
label: Text('$unread'),
|
||||
child: IconButton(
|
||||
key: const ValueKey('strip-chat'),
|
||||
tooltip: 'Chat & activity',
|
||||
tooltip: 'Chat & activity (Tab)',
|
||||
isSelected: chatOpen,
|
||||
onPressed: onChat,
|
||||
icon: const Icon(Icons.chat_bubble_outline, size: 20),
|
||||
@@ -175,6 +196,7 @@ class PartyStrip extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
OutlinedButton(
|
||||
key: const ValueKey('strip-leave'),
|
||||
@@ -182,24 +204,36 @@ class PartyStrip extends StatelessWidget {
|
||||
child: const Text('Leave'),
|
||||
),
|
||||
] else ...[
|
||||
if (!compact)
|
||||
Text(
|
||||
'Offline',
|
||||
style: TextStyle(color: scheme.onSurfaceVariant),
|
||||
if (!compact) ...[
|
||||
Container(
|
||||
width: 7,
|
||||
height: 7,
|
||||
decoration: BoxDecoration(color: scheme.outline, shape: BoxShape.circle),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text('Offline', style: TextStyle(color: scheme.onSurfaceVariant)),
|
||||
const SizedBox(width: 12),
|
||||
FilledButton.tonal(
|
||||
],
|
||||
if (compact) ...[
|
||||
FilledButton(key: const ValueKey('strip-join'), onPressed: onJoin, child: const Text('Join')),
|
||||
const SizedBox(width: 8),
|
||||
FilledButton.tonal(key: const ValueKey('strip-host'), onPressed: onHost, child: const Text('Host')),
|
||||
] else ...[
|
||||
FilledButton.icon(
|
||||
key: const ValueKey('strip-join'),
|
||||
onPressed: onJoin,
|
||||
child: const Text('Join'),
|
||||
icon: const Icon(Icons.login_rounded, size: 18),
|
||||
label: const Text('Join a friend'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
FilledButton(
|
||||
FilledButton.tonalIcon(
|
||||
key: const ValueKey('strip-host'),
|
||||
onPressed: onHost,
|
||||
child: const Text('Host'),
|
||||
icon: const Icon(Icons.podcasts_rounded, size: 18),
|
||||
label: const Text('Host'),
|
||||
),
|
||||
],
|
||||
],
|
||||
const SizedBox(width: 8),
|
||||
if (windowControls) ...[
|
||||
SizedBox(
|
||||
@@ -275,11 +309,54 @@ class _ServerLabel extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
/// "Your server" when hosting, else the address joined.
|
||||
/// "Solo" alone, "Your server" when hosting, else the joined server's
|
||||
/// name (its `--name`), or its address while unknown.
|
||||
String serverName() {
|
||||
final session = SessionState.instance;
|
||||
if (session.solo.value) return 'Solo';
|
||||
if (session.coordinator.hostFile != null) return 'Your server';
|
||||
return session.lastForm.host;
|
||||
final label = session.serverLabel.value;
|
||||
return label.isNotEmpty ? label : session.lastForm.host;
|
||||
}
|
||||
|
||||
/// Solo: just you, on your own; a spinner while it becomes a server.
|
||||
class _SoloChip extends StatelessWidget {
|
||||
final bool compact;
|
||||
final bool busy;
|
||||
const _SoloChip({required this.compact, required this.busy});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
return Tooltip(
|
||||
message: busy
|
||||
? 'Starting your server…'
|
||||
: 'Watching on your own. Nobody can join until you invite friends.',
|
||||
child: Container(
|
||||
key: const ValueKey('solo-chip'),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: scheme.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (busy)
|
||||
const SizedBox.square(dimension: 14, child: CircularProgressIndicator(strokeWidth: 2))
|
||||
else
|
||||
Icon(Icons.person_outline_rounded, size: 16, color: scheme.primary),
|
||||
const SizedBox(width: 6),
|
||||
Text(busy ? 'Inviting…' : 'Solo', style: const TextStyle(fontWeight: FontWeight.w600)),
|
||||
if (!compact && !busy) ...[
|
||||
const SizedBox(width: 6),
|
||||
Text('only you', style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12)),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// One strip tab: label and an underline that grows in when selected.
|
||||
@@ -341,7 +418,8 @@ class StripTab extends StatelessWidget {
|
||||
}
|
||||
|
||||
/// Who is in the server, from the avatars: status, host / relay pills,
|
||||
/// per member force ready and kick, and the address to invite friends.
|
||||
/// per member force ready, nudge and sit out, and the address to invite
|
||||
/// friends.
|
||||
Future<void> showMembersCard(BuildContext context) => showPopover<void>(
|
||||
context,
|
||||
at: const Alignment(0.7, -0.84),
|
||||
@@ -473,6 +551,8 @@ class _MemberTile extends StatelessWidget {
|
||||
final m = member;
|
||||
final status = m.stale
|
||||
? 'reconnecting…'
|
||||
: m.out
|
||||
? 'sitting out'
|
||||
: info.buffering
|
||||
? 'buffering ${m.buffered.toStringAsFixed(1)} s'
|
||||
: m.ready
|
||||
@@ -498,27 +578,30 @@ class _MemberTile extends StatelessWidget {
|
||||
trailing: PopupMenuButton<String>(
|
||||
tooltip: 'More',
|
||||
icon: const Icon(Icons.more_vert, size: 18),
|
||||
onSelected: (v) async {
|
||||
if (v == 'force') {
|
||||
onSelected: (v) {
|
||||
switch (v) {
|
||||
case 'force':
|
||||
coord.forceTarget(m.id, !m.ready);
|
||||
} else if (v == 'kick') {
|
||||
final ok = await showKickConfirmDialog(context, m.name);
|
||||
if (ok == true) coord.kick(m.id);
|
||||
case 'nudge':
|
||||
coord.nudgeMember(m.id);
|
||||
case 'out':
|
||||
coord.sitOut(m.id);
|
||||
case 'back':
|
||||
coord.joinBack();
|
||||
}
|
||||
},
|
||||
itemBuilder: (_) => [
|
||||
if (!m.out)
|
||||
PopupMenuItem(
|
||||
value: 'force',
|
||||
child: Text(m.ready ? 'Force unready' : 'Force ready'),
|
||||
),
|
||||
if (!info.self && !info.host)
|
||||
PopupMenuItem(
|
||||
value: 'kick',
|
||||
child: Text(
|
||||
'Kick',
|
||||
style: TextStyle(color: Theme.of(context).colorScheme.error),
|
||||
),
|
||||
),
|
||||
if (!info.self)
|
||||
const PopupMenuItem(value: 'nudge', child: Text('Nudge')),
|
||||
if (info.self && m.out)
|
||||
const PopupMenuItem(value: 'back', child: Text('Join back'))
|
||||
else if (!m.out)
|
||||
const PopupMenuItem(value: 'out', child: Text('Sit out')),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -11,7 +11,7 @@ import 'avatar.dart';
|
||||
import 'motion.dart';
|
||||
|
||||
/// What the player's status pill says, most pressing first.
|
||||
enum StatusKind { kicked, reconnecting, problem, connecting, finished, loading, you, waiting }
|
||||
enum StatusKind { reconnecting, problem, connecting, finished, loading, you, waiting }
|
||||
|
||||
/// The pill's button: stop reconnecting, retry, or mark yourself ready
|
||||
/// (the whole pill).
|
||||
@@ -59,16 +59,10 @@ String _sentence(String s) => s.isEmpty ? s : s[0].toUpperCase() + s.substring(1
|
||||
StatusView? playerStatusFor({
|
||||
required HealthStatus health,
|
||||
bool reconnecting = false,
|
||||
String? kickReason,
|
||||
StateBroadcast? state,
|
||||
MemberView? self,
|
||||
bool slowConnect = false,
|
||||
}) {
|
||||
if (kickReason != null) {
|
||||
return StatusView(StatusKind.kicked,
|
||||
kickReason.isEmpty ? 'You were removed from the server' : 'You were removed: $kickReason',
|
||||
tone: HealthSeverity.error, short: 'Removed from the server');
|
||||
}
|
||||
if (reconnecting) {
|
||||
return const StatusView(StatusKind.reconnecting, 'Reconnecting…',
|
||||
tone: HealthSeverity.warn, action: StatusAction.cancel);
|
||||
@@ -82,23 +76,28 @@ 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');
|
||||
}
|
||||
// As the server holds playback: someone in the ready set short of the
|
||||
// minimum buffer.
|
||||
final need = r.bufferNeed;
|
||||
for (final m in r.members) {
|
||||
if (m.inSet && m.buffered < RelayConstants.minBufferSecs) {
|
||||
if (m.inSet && m.buffered < need) {
|
||||
final you = self != null && m.id == self.id;
|
||||
final p = (m.buffered / need).clamp(0.0, 1.0);
|
||||
return StatusView(StatusKind.loading, you ? 'Loading the video…' : '${m.name} is loading…',
|
||||
who: [m], self: you, progress: (m.buffered / RelayConstants.minBufferSecs).clamp(0.0, 1.0));
|
||||
who: [m], self: you, progress: p, detail: '${(p * 100).round()}%');
|
||||
}
|
||||
}
|
||||
if (self != null && !self.ready) {
|
||||
return StatusView(StatusKind.you, "Press Space when you're ready",
|
||||
who: [self], self: true, action: StatusAction.ready, short: 'Press Space when ready');
|
||||
who: [self], self: true, action: StatusAction.ready, detail: 'or click here', short: 'Press Space when ready');
|
||||
}
|
||||
final active = [for (final m in r.members) if (!m.stale) m];
|
||||
// Members sitting out are never waited for nor counted.
|
||||
final active = [for (final m in r.members) if (!m.stale && !m.out) m];
|
||||
final others = [
|
||||
for (final m in active)
|
||||
if ((self == null || m.id != self.id) && !(m.inSet && m.ready)) m,
|
||||
@@ -115,7 +114,6 @@ StatusView? playerStatusNow({bool slowConnect = false}) {
|
||||
return playerStatusFor(
|
||||
health: c.health.value,
|
||||
reconnecting: c.reconnecting.value,
|
||||
kickReason: c.kickReason.value,
|
||||
state: c.serverState.value,
|
||||
self: c.selfMember.value,
|
||||
slowConnect: slowConnect,
|
||||
@@ -193,7 +191,7 @@ class _PlayerStatusState extends State<PlayerStatus> {
|
||||
child: GestureDetector(
|
||||
key: const ValueKey('player-status'),
|
||||
onTap: ready ? coord.toggleReady : null,
|
||||
child: _Glass(
|
||||
child: PlayerGlass(
|
||||
child: AnimatedSize(
|
||||
duration: easeTime,
|
||||
curve: ease,
|
||||
@@ -217,9 +215,10 @@ class _PlayerStatusState extends State<PlayerStatus> {
|
||||
}
|
||||
}
|
||||
|
||||
class _Glass extends StatelessWidget {
|
||||
/// Frosted dark glass in a pill: the status pill, the unread chip.
|
||||
class PlayerGlass extends StatelessWidget {
|
||||
final Widget child;
|
||||
const _Glass({required this.child});
|
||||
const PlayerGlass({super.key, required this.child});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -250,16 +249,15 @@ class _StatusLine extends StatelessWidget {
|
||||
final ValueChanged<StatusAction> onAction;
|
||||
const _StatusLine({super.key, required this.view, required this.coord, required this.onAction});
|
||||
|
||||
static const _avatar = 24.0;
|
||||
static const _avatar = 34.0;
|
||||
|
||||
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,
|
||||
size: 18, color: error ? Colors.redAccent.shade100 : Colors.amber);
|
||||
size: 20, color: error ? Colors.redAccent.shade100 : Colors.amber);
|
||||
case StatusKind.reconnecting:
|
||||
return const _Spinner(color: Colors.amber);
|
||||
case StatusKind.connecting:
|
||||
@@ -275,9 +273,26 @@ class _StatusLine extends StatelessWidget {
|
||||
v.self ? selfAvatarInfo(ready: m.ready, host: m.host) : memberAvatarInfo(m, r, coord),
|
||||
];
|
||||
if (infos.isEmpty) return null;
|
||||
return infos.length == 1
|
||||
final avatar = infos.length == 1
|
||||
? StatusAvatar(info: infos.first, size: _avatar, badges: false)
|
||||
: AvatarStack(members: infos, shown: 4, size: _avatar);
|
||||
if (v.progress == null) return avatar;
|
||||
// Loading: the progress runs round the avatar.
|
||||
return Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
avatar,
|
||||
SizedBox.square(
|
||||
dimension: _avatar + 6,
|
||||
child: CircularProgressIndicator(
|
||||
value: v.progress,
|
||||
strokeWidth: 2.5,
|
||||
color: Colors.white,
|
||||
backgroundColor: Colors.white24,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -285,43 +300,45 @@ class _StatusLine extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
final v = view;
|
||||
final lead = _lead();
|
||||
final avatars = v.who.isNotEmpty;
|
||||
final action = v.action == StatusAction.ready ? null : v.action;
|
||||
return Padding(
|
||||
padding: EdgeInsets.fromLTRB(lead == null ? 16 : 6, 6, action == null ? 16 : 6, 6),
|
||||
padding: EdgeInsets.fromLTRB(lead == null ? 18 : (avatars ? 5 : 14), 5, action == null ? 18 : 6, 5),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (lead != null) ...[
|
||||
SizedBox(height: _avatar, child: Center(child: lead)),
|
||||
const SizedBox(width: 8),
|
||||
SizedBox(height: _avatar + 6, child: Center(child: lead)),
|
||||
SizedBox(width: avatars ? 10 : 8),
|
||||
],
|
||||
Flexible(
|
||||
child: Text(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(minHeight: _avatar),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
v.text,
|
||||
key: const ValueKey('player-status-text'),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(color: Colors.white, fontSize: 13, fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
if (v.progress != null) ...[
|
||||
const SizedBox(width: 8),
|
||||
SizedBox.square(
|
||||
dimension: 14,
|
||||
child: CircularProgressIndicator(
|
||||
value: v.progress,
|
||||
strokeWidth: 2,
|
||||
color: Colors.white,
|
||||
backgroundColor: Colors.white24,
|
||||
style: const TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.w600, height: 1.2),
|
||||
),
|
||||
if (v.detail != null)
|
||||
Text(
|
||||
v.detail!,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(color: Colors.white60, fontSize: 12, fontWeight: FontWeight.w500, height: 1.2),
|
||||
),
|
||||
],
|
||||
if (v.detail != null) ...[
|
||||
const SizedBox(width: 8),
|
||||
Text(v.detail!, style: const TextStyle(color: Colors.white54, fontSize: 13, fontWeight: FontWeight.w500)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (action != null) ...[
|
||||
const SizedBox(width: 10),
|
||||
const SizedBox(width: 12),
|
||||
_PillButton(
|
||||
label: action == StatusAction.cancel ? 'Cancel' : 'Retry',
|
||||
onPressed: () => onAction(action),
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart' show ValueListenable;
|
||||
import 'package:flutter/gestures.dart' show kPrimaryMouseButton;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
@@ -10,7 +13,8 @@ import '../controls/player_settings.dart';
|
||||
import '../controls/uosc/buttons.dart';
|
||||
import '../controls/uosc/controls.dart';
|
||||
import '../controls/uosc/timeline.dart';
|
||||
import '../playback/media_marks.dart' show SkipOffer, formatClock;
|
||||
import '../controls/volume.dart';
|
||||
import '../playback/media_marks.dart' show SkipKind, SkipOffer, formatClock;
|
||||
import '../screens/session/jellyfin_widgets.dart';
|
||||
import '../screens/session/player_controls.dart';
|
||||
import '../session/session_coordinator.dart';
|
||||
@@ -18,6 +22,7 @@ import '../shaders/shader_menu.dart';
|
||||
import '../shaders/shader_presets.dart';
|
||||
import '../state/session_state.dart';
|
||||
import '../state/settings.dart';
|
||||
import '../widgets/app_keys.dart';
|
||||
import '../widgets/shown_frame_probe.dart';
|
||||
import '../widgets/text_focus.dart';
|
||||
import '../widgets/window_buttons.dart';
|
||||
@@ -25,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,
|
||||
@@ -96,9 +102,10 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
int _toastSeq = 0;
|
||||
int _shaderNoticeSeen = 0;
|
||||
bool _miniHover = false;
|
||||
// Whether the standard controls are up (tracked like media_kit does:
|
||||
// shown on mount and on mouse movement, hidden 3 s later or on exit).
|
||||
final _standardShown = ValueNotifier(true);
|
||||
// Whether the controls are up: the standard ones tracked like media_kit
|
||||
// does (shown on mount and on mouse movement, hidden 3 s later or on
|
||||
// exit), the uosc ones reporting it.
|
||||
final _controlsShown = ValueNotifier(true);
|
||||
|
||||
SessionCoordinator get _coord => SessionState.instance.coordinator;
|
||||
bool get _full => widget.mode == PlayerMode.full;
|
||||
@@ -109,6 +116,8 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
_coord.jellyfin.ensureLoaded();
|
||||
_coord.playback.shownFrame = _frameProbe.sample;
|
||||
HardwareKeyboard.instance.addHandler(_globalKey);
|
||||
// Tab (AppKeys) reaches the chat through here in fullscreen.
|
||||
AppKeys.addChatToggle(_toggleChat);
|
||||
// Shader messages (preset changes, load failures) become toasts.
|
||||
_shaderNoticeSeen = _coord.playback.shaderNotice.peek()?.id ?? 0;
|
||||
_stopShaderWatch = effect(() {
|
||||
@@ -122,11 +131,12 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
@override
|
||||
void dispose() {
|
||||
HardwareKeyboard.instance.removeHandler(_globalKey);
|
||||
AppKeys.removeChatToggle(_toggleChat);
|
||||
final playback = _coord.playback;
|
||||
if (playback.shownFrame == _frameProbe.sample) playback.shownFrame = null;
|
||||
_stopShaderWatch();
|
||||
_noFocus.dispose();
|
||||
_standardShown.dispose();
|
||||
_controlsShown.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -152,10 +162,18 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
}
|
||||
|
||||
/// The player keys, while the player fills the body and nothing (a
|
||||
/// dialog, a card, a text field) is in front of it.
|
||||
/// 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.
|
||||
@@ -184,11 +202,6 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
_reloadVideo();
|
||||
return true;
|
||||
}
|
||||
if (key == LogicalKeyboardKey.space) {
|
||||
_lastKeyAt = now;
|
||||
coord.toggleReady();
|
||||
return true;
|
||||
}
|
||||
// mpv's playlist keys (< >) and Shift+P / Shift+N: Jellyfin episodes.
|
||||
final shift = HardwareKeyboard.instance.isShiftPressed;
|
||||
if (event.character == '>' || (shift && key == LogicalKeyboardKey.keyN)) {
|
||||
@@ -213,9 +226,9 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
coord.skipForward();
|
||||
return true;
|
||||
}
|
||||
if (key == LogicalKeyboardKey.tab) {
|
||||
_lastKeyAt = now;
|
||||
_toggleChat();
|
||||
if (key == LogicalKeyboardKey.arrowUp || key == LogicalKeyboardKey.arrowDown) {
|
||||
final player = coord.playback.player;
|
||||
player.setVolume(stepVolume(player.state.volume, key == LogicalKeyboardKey.arrowUp ? 5 : -5));
|
||||
return true;
|
||||
}
|
||||
if (key == LogicalKeyboardKey.arrowRight) {
|
||||
@@ -235,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;
|
||||
}
|
||||
@@ -282,9 +296,12 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
_status(),
|
||||
_progressLine(),
|
||||
_jellyfinOverlay(),
|
||||
if (widget.fullscreen) _pausedParty(),
|
||||
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(),
|
||||
],
|
||||
);
|
||||
}),
|
||||
@@ -340,7 +357,7 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
final s = AppSettings.instance;
|
||||
if (s.mpvControls.value || !s.progressLine.value) return const SizedBox.shrink();
|
||||
return ValueListenableBuilder<bool>(
|
||||
valueListenable: _standardShown,
|
||||
valueListenable: _controlsShown,
|
||||
builder: (context, shown, _) => shown
|
||||
? const SizedBox.shrink()
|
||||
: const _MiniSeekBar(key: ValueKey('progress-line-standard'), thin: true),
|
||||
@@ -437,7 +454,7 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
onPressed: coord.playNextEpisode,
|
||||
),
|
||||
),
|
||||
const MaterialDesktopVolumeButton(),
|
||||
VolumeButton(player: playback.player, focusNode: _noFocus),
|
||||
_ReadyControlButton(focusNode: _noFocus),
|
||||
_BarTextButton(
|
||||
focusNode: _noFocus,
|
||||
@@ -504,7 +521,7 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
Widget _controlsSwitch(VideoState state) {
|
||||
return SessionControlsSwitch(
|
||||
material: (_) => ControlsHoverProbe(
|
||||
shown: _standardShown,
|
||||
shown: _controlsShown,
|
||||
hideAfter: _controlsHide,
|
||||
child: DoubleClickListener(
|
||||
onDoubleClick: widget.onToggleFullscreen,
|
||||
@@ -536,12 +553,19 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
);
|
||||
});
|
||||
return UoscControls(
|
||||
shown: _controlsShown,
|
||||
onDoubleTap: widget.onToggleFullscreen,
|
||||
topLeft: const NowPlayingTitle(),
|
||||
top: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
UoscButton(icon: Icons.chat_bubble_outline_rounded, tooltip: 'Chat & activity (Tab)', onPressed: _toggleChat),
|
||||
UoscButton(
|
||||
key: const ValueKey('uosc-chat'),
|
||||
icon: widget.chatOpen ? Icons.chat_bubble_rounded : Icons.chat_bubble_outline_rounded,
|
||||
tooltip: 'Chat & activity (Tab)',
|
||||
badge: widget.unread,
|
||||
onPressed: _toggleChat,
|
||||
),
|
||||
UoscButton(
|
||||
icon: widget.fullscreen ? Icons.fullscreen_exit_rounded : Icons.close_fullscreen_rounded,
|
||||
tooltip: widget.fullscreen ? 'Exit fullscreen (Esc)' : 'Back to the library (keeps playing)',
|
||||
@@ -653,7 +677,8 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
|
||||
/// Jellyfin: the blocked card, the end-of-episode card (countdown) and
|
||||
/// the "Next episode" pill; for any media the skip pill (Skip OP / ED /
|
||||
/// 1:30) above it. Outside the auto-hiding controls, so they stay up.
|
||||
/// 1:30) above it ([SkipPillGate]). Outside the auto-hiding controls, so
|
||||
/// they stay up.
|
||||
Widget _jellyfinOverlay() {
|
||||
final coord = _coord;
|
||||
return Positioned.fill(
|
||||
@@ -695,7 +720,13 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
spacing: 10,
|
||||
children: [
|
||||
if (skip != null) SkipPill(offer: skip, onPressed: () => coord.skip(skip)),
|
||||
if (skip != null)
|
||||
SkipPillGate(
|
||||
key: const ValueKey('skip-gate'),
|
||||
offer: skip,
|
||||
controlsShown: _controlsShown,
|
||||
child: SkipPill(offer: skip, onPressed: () => coord.skip(skip)),
|
||||
),
|
||||
if (showNext) NextEpisodePill(episode: next, onPressed: coord.playNextEpisode),
|
||||
],
|
||||
),
|
||||
@@ -723,6 +754,72 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
}),
|
||||
);
|
||||
|
||||
/// Fullscreen hides the strip and its chat badge: while there is unread
|
||||
/// chat and the controls are down, a small glass chip says so top right.
|
||||
Widget _unreadChip() => Positioned(
|
||||
right: 16,
|
||||
top: 16,
|
||||
child: IgnorePointer(
|
||||
child: ValueListenableBuilder<bool>(
|
||||
valueListenable: _controlsShown,
|
||||
builder: (context, shown, _) {
|
||||
final on = widget.unread > 0 && !widget.chatOpen && !shown;
|
||||
return AnimatedOpacity(
|
||||
duration: easeTime,
|
||||
curve: ease,
|
||||
opacity: on ? 1 : 0,
|
||||
child: PlayerGlass(
|
||||
key: const ValueKey('unread-chip'),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.chat_bubble_rounded, size: 14, color: Colors.white70),
|
||||
const SizedBox(width: 6),
|
||||
Text('${widget.unread}', style: const TextStyle(color: Colors.white, fontSize: 12)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
/// [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() {
|
||||
@@ -762,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),
|
||||
),
|
||||
],
|
||||
@@ -809,6 +906,75 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The skip pill's visibility: up for [firstShown] after an offer
|
||||
/// appears (a new kind: OP, ED, 1:30), then only while the controls are
|
||||
/// up or the mouse is on it; gone with the offer (the parent drops it).
|
||||
class SkipPillGate extends StatefulWidget {
|
||||
static const firstShown = Duration(seconds: 5);
|
||||
final SkipOffer offer;
|
||||
final ValueListenable<bool> controlsShown;
|
||||
final Widget child;
|
||||
const SkipPillGate({super.key, required this.offer, required this.controlsShown, required this.child});
|
||||
|
||||
@override
|
||||
State<SkipPillGate> createState() => _SkipPillGateState();
|
||||
}
|
||||
|
||||
class _SkipPillGateState extends State<SkipPillGate> {
|
||||
SkipKind? _kind;
|
||||
bool _fresh = false;
|
||||
bool _hover = false;
|
||||
Timer? _timer;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_arm();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(SkipPillGate old) {
|
||||
super.didUpdateWidget(old);
|
||||
if (widget.offer.kind != _kind) _arm();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_timer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _arm() {
|
||||
_kind = widget.offer.kind;
|
||||
_fresh = true;
|
||||
_timer?.cancel();
|
||||
_timer = Timer(SkipPillGate.firstShown, () {
|
||||
if (mounted) setState(() => _fresh = false);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// The pill covers the controls' hover area: on it, they may hide while
|
||||
// it stays.
|
||||
return ValueListenableBuilder<bool>(
|
||||
valueListenable: widget.controlsShown,
|
||||
builder: (context, shown, child) {
|
||||
final on = _fresh || shown || _hover;
|
||||
return IgnorePointer(
|
||||
ignoring: !on,
|
||||
child: MouseRegion(
|
||||
onEnter: (_) => setState(() => _hover = true),
|
||||
onExit: (_) => setState(() => _hover = false),
|
||||
child: AnimatedOpacity(duration: easeTime, curve: ease, opacity: on ? 1 : 0, child: child),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: widget.child,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const _shaderKeys = [
|
||||
LogicalKeyboardKey.digit0,
|
||||
LogicalKeyboardKey.digit1,
|
||||
|
||||
@@ -1,13 +1,26 @@
|
||||
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';
|
||||
import '../sfx/sfx.dart';
|
||||
import '../sfx/sound_bank.dart';
|
||||
import '../sfx/synth_sfx.dart';
|
||||
import '../state/session_state.dart';
|
||||
import '../state/settings.dart';
|
||||
import '../update/update_controller.dart';
|
||||
import 'avatar.dart';
|
||||
import 'motion.dart';
|
||||
@@ -73,7 +86,36 @@ class SettingsTab extends StatelessWidget {
|
||||
),
|
||||
];
|
||||
case SettingsSection.network:
|
||||
return const [HostDefaultsPanel(), RecentServersPanel()];
|
||||
return [
|
||||
const HostDefaultsPanel(),
|
||||
const RecentServersPanel(),
|
||||
SettingsPanel(
|
||||
title: 'Watching',
|
||||
child: SignalBuilder(
|
||||
builder: (context) => SwitchListTile(
|
||||
key: const ValueKey('unready-on-swap'),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: const Text('Unready when the video changes'),
|
||||
subtitle: const Text('Otherwise you stay ready and the next video starts once everyone ready has buffered'),
|
||||
value: AppSettings.instance.unreadyOnSwap.value,
|
||||
onChanged: AppSettings.instance.setUnreadyOnSwap,
|
||||
),
|
||||
),
|
||||
),
|
||||
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 [
|
||||
const SettingsHintCard(
|
||||
@@ -85,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;
|
||||
@@ -330,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 {
|
||||
@@ -424,18 +746,129 @@ 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'),
|
||||
],
|
||||
),
|
||||
),
|
||||
SettingsPanel(
|
||||
title: 'Sounds',
|
||||
child: line('No sounds yet', 'The app is silent; only the video makes noise'),
|
||||
),
|
||||
const SettingsPanel(title: 'Sounds', child: SoundSettings()),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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});
|
||||
|
||||
void _preview(SfxKind kind) {
|
||||
final sfx = SessionState.instance.sfx;
|
||||
if (sfx is SynthSfx) unawaited(sfx.preview(kind, readyIndex: 2));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final s = AppSettings.instance;
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final muted = TextStyle(color: scheme.onSurfaceVariant, fontSize: 12);
|
||||
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: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(volume == 0 ? Icons.volume_off_outlined : Icons.volume_down_outlined,
|
||||
color: scheme.onSurfaceVariant),
|
||||
Expanded(
|
||||
child: Slider(
|
||||
key: const ValueKey('sound-volume'),
|
||||
value: volume,
|
||||
onChanged: s.setSoundVolume,
|
||||
onChangeEnd: (_) => _preview(SfxKind.ready),
|
||||
),
|
||||
),
|
||||
SizedBox(width: 44, child: Text('${(volume * 100).round()}%', textAlign: TextAlign.end)),
|
||||
],
|
||||
),
|
||||
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(
|
||||
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||
child: Row(
|
||||
children: [
|
||||
IconButton(
|
||||
key: ValueKey('sound-play-${kind.name}'),
|
||||
tooltip: 'Play',
|
||||
onPressed: volume == 0 ? null : () => _preview(kind),
|
||||
icon: const Icon(Icons.play_arrow_rounded),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [Text(name), Text(help, style: muted)],
|
||||
),
|
||||
),
|
||||
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,
|
||||
),
|
||||
],
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -26,9 +26,11 @@ class StartCard extends StatelessWidget {
|
||||
final episode = now.episode;
|
||||
return LayoutBuilder(builder: (context, box) {
|
||||
final h = box.maxHeight;
|
||||
// Small (the mini player): no cover, a smaller title near the bottom.
|
||||
final small = h < 320;
|
||||
final coverWidth = (h * 0.26).clamp(84.0, 240.0) / 1.5;
|
||||
final titleSize = (h * 0.05).clamp(22.0, 44.0);
|
||||
final side = (box.maxWidth * 0.05).clamp(20.0, 72.0);
|
||||
final titleSize = small ? (h * 0.08).clamp(13.0, 18.0) : (h * 0.05).clamp(22.0, 44.0);
|
||||
final side = small ? 12.0 : (box.maxWidth * 0.05).clamp(20.0, 72.0);
|
||||
return Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
@@ -76,12 +78,14 @@ class StartCard extends StatelessWidget {
|
||||
Positioned(
|
||||
left: side,
|
||||
right: side,
|
||||
bottom: startCardBottom,
|
||||
bottom: small ? 12 : startCardBottom,
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
if (!small) ...[
|
||||
ArtCover(hash: now.posterHash, width: coverWidth),
|
||||
const SizedBox(width: 24),
|
||||
],
|
||||
Expanded(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
|
||||
@@ -34,8 +34,10 @@ ShellToast shellToastFor(ToastItem t, AvatarInfo Function(String name) avatar) {
|
||||
? Icons.login
|
||||
: rest.startsWith('left')
|
||||
? Icons.logout
|
||||
: rest.startsWith('kicked')
|
||||
? Icons.gavel
|
||||
: rest.startsWith('sat you out')
|
||||
? Icons.bedtime
|
||||
: rest.startsWith('nudged')
|
||||
? Icons.notifications_active_outlined
|
||||
: rest.startsWith('changed')
|
||||
? Icons.swap_horiz
|
||||
: Icons.info_outline;
|
||||
@@ -43,10 +45,13 @@ ShellToast shellToastFor(ToastItem t, AvatarInfo Function(String name) avatar) {
|
||||
}
|
||||
|
||||
/// The coordinator's toasts as cards with avatars, bottom-left: each
|
||||
/// slides in, stays [life], then slides out and collapses its gap.
|
||||
/// slides in, stays [life], then slides out and collapses its gap. A click
|
||||
/// on a chat toast opens the chat ([onOpenChat]); the rest let clicks
|
||||
/// through.
|
||||
class PartyToasts extends StatefulWidget {
|
||||
final Duration life;
|
||||
const PartyToasts({super.key, this.life = const Duration(seconds: 5)});
|
||||
final VoidCallback? onOpenChat;
|
||||
const PartyToasts({super.key, this.life = const Duration(seconds: 5), this.onOpenChat});
|
||||
|
||||
@override
|
||||
State<PartyToasts> createState() => _PartyToastsState();
|
||||
@@ -101,13 +106,37 @@ class _PartyToastsState extends State<PartyToasts> {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _open(ShellToast t) {
|
||||
widget.onOpenChat?.call();
|
||||
setState(() => t.leaving = true);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => IgnorePointer(child: ToastColumn(toasts: _shown.reversed.take(6).toList().reversed.toList()));
|
||||
Widget build(BuildContext context) => ToastColumn(
|
||||
toasts: _shown.reversed.take(6).toList().reversed.toList(),
|
||||
onTap: widget.onOpenChat == null ? null : _open,
|
||||
);
|
||||
}
|
||||
|
||||
class ToastColumn extends StatelessWidget {
|
||||
final List<ShellToast> toasts;
|
||||
const ToastColumn({super.key, required this.toasts});
|
||||
/// Clicks on chat toasts; null (or any other toast): clicks pass through.
|
||||
final ValueChanged<ShellToast>? onTap;
|
||||
const ToastColumn({super.key, required this.toasts, this.onTap});
|
||||
|
||||
Widget _card(ShellToast t) {
|
||||
final card = ToastCard(toast: t);
|
||||
final tap = onTap;
|
||||
if (!t.chat || tap == null || t.leaving) return IgnorePointer(child: card);
|
||||
return MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: GestureDetector(
|
||||
key: ValueKey('toast-open-chat-${t.id}'),
|
||||
onTap: () => tap(t),
|
||||
child: card,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -139,7 +168,7 @@ class ToastColumn extends StatelessWidget {
|
||||
duration: easeTime,
|
||||
curve: ease,
|
||||
opacity: t.leaving ? 0 : 1,
|
||||
child: Padding(padding: const EdgeInsets.only(top: 8), child: ToastCard(toast: t)),
|
||||
child: Padding(padding: const EdgeInsets.only(top: 8), child: _card(t)),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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,12 +8,18 @@ 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';
|
||||
import '../widgets/avatar_pipeline.dart';
|
||||
import 'connect_prefs.dart';
|
||||
import 'server_isolate.dart';
|
||||
import 'settings.dart';
|
||||
|
||||
enum AppRoute {
|
||||
home,
|
||||
@@ -140,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)]);
|
||||
@@ -153,19 +159,43 @@ class SessionState {
|
||||
final route = signal(AppRoute.home);
|
||||
final recents = signal<List<RecentServer>>(const []);
|
||||
final status = signal(const HealthStatus(HealthSeverity.info, 'idle'));
|
||||
final SfxService sfx = const NoopSfxService();
|
||||
final SfxService sfx = SynthSfx(
|
||||
volume: () => AppSettings.instance.soundVolume.peek(),
|
||||
// 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();
|
||||
JellyfinAccounts get jellyfin => JellyfinAccounts.instance;
|
||||
/// 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;
|
||||
@@ -225,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…');
|
||||
@@ -247,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),
|
||||
);
|
||||
@@ -270,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,
|
||||
@@ -290,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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -319,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();
|
||||
}
|
||||
@@ -331,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
|
||||
@@ -368,8 +492,6 @@ class SessionState {
|
||||
|
||||
Future<void> stopServer() async {
|
||||
coordinator.hostFile = null;
|
||||
_stopKickWatch?.call();
|
||||
_stopKickWatch = null;
|
||||
final server = _server;
|
||||
_server = null;
|
||||
await server?.stop();
|
||||
@@ -378,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;
|
||||
}
|
||||
@@ -20,6 +20,13 @@ const _keyAutoAdvance = 'autoAdvance';
|
||||
const _keyMpvControls = 'mpvControls';
|
||||
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 }
|
||||
|
||||
@@ -31,6 +38,11 @@ class AppSettings {
|
||||
final autoCheckUpdates = signal(true);
|
||||
/// Jellyfin episodes: at the end, count down 10 s and put up the next.
|
||||
final autoAdvance = signal(true);
|
||||
/// 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);
|
||||
@@ -40,6 +52,17 @@ class AppSettings {
|
||||
/// A 2 px progress line at the bottom of the video while the controls
|
||||
/// are hidden (both designs). Applies live.
|
||||
final progressLine = signal(true);
|
||||
/// App sounds (`sfx/`): 0..1, 0 = silent.
|
||||
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).
|
||||
@@ -76,12 +99,31 @@ class AppSettings {
|
||||
if (auto is bool) autoCheckUpdates.value = auto;
|
||||
final advance = box.get(_keyAutoAdvance);
|
||||
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);
|
||||
if (hideRe is bool) hideReReleases.value = hideRe;
|
||||
final line = box.get(_keyProgressLine);
|
||||
if (line is bool) progressLine.value = line;
|
||||
final vol = box.get(_keySoundVolume);
|
||||
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 ??
|
||||
@@ -119,6 +161,16 @@ class AppSettings {
|
||||
_put(_keyAutoAdvance, on);
|
||||
}
|
||||
|
||||
void setUnreadyOnSwap(bool on) {
|
||||
unreadyOnSwap.value = on;
|
||||
_put(_keyUnreadyOnSwap, on);
|
||||
}
|
||||
|
||||
void setIdleSitsOut(bool on) {
|
||||
idleSitsOut.value = on;
|
||||
_put(_keyIdleSitsOut, on);
|
||||
}
|
||||
|
||||
void setMpvControls(bool on) {
|
||||
mpvControls.value = on;
|
||||
_put(_keyMpvControls, on);
|
||||
@@ -129,6 +181,34 @@ class AppSettings {
|
||||
_put(_keyProgressLine, on);
|
||||
}
|
||||
|
||||
void setSoundVolume(double v) {
|
||||
soundVolume.value = v.clamp(0, 1);
|
||||
_put(_keySoundVolume, soundVolume.value);
|
||||
}
|
||||
|
||||
void setSoundOn(String kind, bool on) {
|
||||
soundsOff.value = Set.unmodifiable(on ? ({...soundsOff.value}..remove(kind)) : {...soundsOff.value, kind});
|
||||
_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);
|
||||
|
||||
@@ -43,6 +43,10 @@ bool localPlayAllowed(StateBroadcast state, String memberId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
/// At `finished`, a player at most this far behind the end keeps playing
|
||||
/// to it.
|
||||
const finishSlackSecs = 3.0;
|
||||
|
||||
class StateDiff {
|
||||
static const debounceWindow = Duration(seconds: 2);
|
||||
|
||||
@@ -73,7 +77,9 @@ class StateDiff {
|
||||
}
|
||||
|
||||
// End of media: everyone stops where they are (at or just before the
|
||||
// end). No seek to the very end and no drift correction.
|
||||
// end). No seek to the very end and no drift correction. A player a
|
||||
// little behind the server's clock plays its last moments out (mpv
|
||||
// stops at the end by itself) instead of being cut short.
|
||||
if (state.transport == 'finished') {
|
||||
final first = _lastEventId == -1;
|
||||
final changed = first ||
|
||||
@@ -87,7 +93,9 @@ class StateDiff {
|
||||
if (!changed) return out;
|
||||
out.add(const RateAction(1.0));
|
||||
if (first) out.add(SeekAction(serverPos > 1 ? serverPos - 1 : 0));
|
||||
if (first || state.position - ownPosition > finishSlackSecs) {
|
||||
out.add(const TransportAction(false));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import 'dart:convert';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
import 'package:web_socket_channel/web_socket_channel.dart';
|
||||
|
||||
enum SyncStatus { idle, connecting, open, kicked, closed, error }
|
||||
enum SyncStatus { idle, connecting, open, closed, error }
|
||||
|
||||
class SyncClient {
|
||||
/// Local clock; injectable so tests can run a client whose clock is off.
|
||||
@@ -23,13 +23,14 @@ class SyncClient {
|
||||
StreamSubscription? _sub;
|
||||
final StreamController<StateBroadcast> _states = StreamController.broadcast();
|
||||
final StreamController<FeedMessage> _feeds = StreamController.broadcast();
|
||||
final StreamController<String> _kicked = StreamController.broadcast();
|
||||
final StreamController<NudgeNotice> _nudges = StreamController.broadcast();
|
||||
final StreamController<String> _errors = StreamController.broadcast();
|
||||
final StreamController<SyncStatus> _status = StreamController.broadcast();
|
||||
|
||||
Stream<StateBroadcast> get states => _states.stream;
|
||||
Stream<FeedMessage> get feeds => _feeds.stream;
|
||||
Stream<String> get kicked => _kicked.stream;
|
||||
/// Someone poked this member (`nudge`).
|
||||
Stream<NudgeNotice> get nudges => _nudges.stream;
|
||||
Stream<String> get errors => _errors.stream;
|
||||
Stream<SyncStatus> get status => _status.stream;
|
||||
|
||||
@@ -153,14 +154,10 @@ class SyncClient {
|
||||
// one toast, one sfx.
|
||||
if (f.action != 'chat') _feeds.add(f);
|
||||
} catch (_) {}
|
||||
case 'kicked':
|
||||
case 'nudge':
|
||||
try {
|
||||
final msg = KickedMessage.fromJson(decoded);
|
||||
_kicked.add(msg.reason);
|
||||
_status.add(SyncStatus.kicked);
|
||||
} catch (_) {
|
||||
_kicked.add('');
|
||||
}
|
||||
_nudges.add(NudgeNotice.fromJson(decoded));
|
||||
} catch (_) {}
|
||||
case 'pong':
|
||||
try {
|
||||
final pong = PongMessage.fromJson(decoded);
|
||||
@@ -255,8 +252,14 @@ class SyncClient {
|
||||
_send(ArtMessage(mediaId: mediaId, poster: poster, banner: banner).toJson());
|
||||
}
|
||||
|
||||
void sendKick(String target, {String reason = ''}) {
|
||||
_send(KickRequest(target: target, reason: reason).toJson());
|
||||
/// Sits the member [target] out, or with [out] false brings this
|
||||
/// member back (only oneself can come back).
|
||||
void sendOut(String target, bool out) {
|
||||
_send(OutIntent(target: target, out: out).toJson());
|
||||
}
|
||||
|
||||
void sendNudge(String target) {
|
||||
_send(NudgeIntent(target: target).toJson());
|
||||
}
|
||||
|
||||
Future<void> disconnect() async {
|
||||
@@ -283,7 +286,7 @@ class SyncClient {
|
||||
await disconnect();
|
||||
await _states.close();
|
||||
await _feeds.close();
|
||||
await _kicked.close();
|
||||
await _nudges.close();
|
||||
await _errors.close();
|
||||
await _status.close();
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@echo off
|
||||
rem Update this install in place (same as clicking Update in the app).
|
||||
"%~dp0watch_together.exe" --upgrade %*
|
||||
"%~dp0watch-together-server.exe" --upgrade %*
|
||||
pause
|
||||
@@ -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.
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../state/session_state.dart';
|
||||
import 'text_focus.dart';
|
||||
|
||||
/// Tab and Shift+Tab never move focus, and Space never presses the
|
||||
/// focused button (the app's [Shortcuts] override): Space is the ready key,
|
||||
/// and a button keeps focus after a click. Text fields still type spaces
|
||||
/// (their own shortcuts come first).
|
||||
Map<ShortcutActivator, Intent> get appShortcuts => {
|
||||
...WidgetsApp.defaultShortcuts,
|
||||
const SingleActivator(LogicalKeyboardKey.tab): const DoNothingIntent(),
|
||||
const SingleActivator(LogicalKeyboardKey.tab, shift: true): const DoNothingIntent(),
|
||||
const SingleActivator(LogicalKeyboardKey.space): const DoNothingIntent(),
|
||||
};
|
||||
|
||||
/// The window's two app-wide keys, ahead of whatever has focus: while in a
|
||||
/// server, Space is ready / not ready and Tab opens or closes the chat,
|
||||
/// on every tab and in every player mode. Not while a text field has
|
||||
/// focus (the chat field handles Tab and Esc itself) nor while a dialog or
|
||||
/// menu is in front of [child].
|
||||
class AppKeys extends StatefulWidget {
|
||||
/// Who toggles the chat, latest first: the strip while it is shown, the
|
||||
/// full player in fullscreen (both hold the shell's toggle).
|
||||
static final _chatToggles = <VoidCallback>[];
|
||||
|
||||
static void addChatToggle(VoidCallback f) => _chatToggles.add(f);
|
||||
static void removeChatToggle(VoidCallback f) => _chatToggles.remove(f);
|
||||
|
||||
/// Space; tests count presses instead of sending them.
|
||||
final VoidCallback? onToggleReady;
|
||||
final Widget child;
|
||||
const AppKeys({super.key, this.onToggleReady, required this.child});
|
||||
|
||||
@override
|
||||
State<AppKeys> createState() => _AppKeysState();
|
||||
}
|
||||
|
||||
class _AppKeysState extends State<AppKeys> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
HardwareKeyboard.instance.addHandler(_key);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
HardwareKeyboard.instance.removeHandler(_key);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
bool _key(KeyEvent event) {
|
||||
if (event is! KeyDownEvent || !mounted) return false;
|
||||
final key = event.logicalKey;
|
||||
if (key != LogicalKeyboardKey.space && key != LogicalKeyboardKey.tab) return false;
|
||||
if (SessionState.instance.route.peek() != AppRoute.session) return false;
|
||||
if (ModalRoute.of(context)?.isCurrent == false) return false;
|
||||
if (textInputFocused()) return false;
|
||||
final keys = HardwareKeyboard.instance;
|
||||
if (keys.isControlPressed || keys.isAltPressed || keys.isMetaPressed) return false;
|
||||
if (key == LogicalKeyboardKey.space) {
|
||||
(widget.onToggleReady ?? SessionState.instance.coordinator.toggleReady)();
|
||||
return true;
|
||||
}
|
||||
final toggles = AppKeys._chatToggles;
|
||||
if (toggles.isEmpty) return false;
|
||||
toggles.last();
|
||||
return true;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => widget.child;
|
||||
}
|
||||
|
||||
/// Registers [onToggle] with [AppKeys] while mounted.
|
||||
class ChatKey extends StatefulWidget {
|
||||
final VoidCallback onToggle;
|
||||
final Widget child;
|
||||
const ChatKey({super.key, required this.onToggle, required this.child});
|
||||
|
||||
@override
|
||||
State<ChatKey> createState() => _ChatKeyState();
|
||||
}
|
||||
|
||||
class _ChatKeyState extends State<ChatKey> {
|
||||
void _toggle() => widget.onToggle();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
AppKeys.addChatToggle(_toggle);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
AppKeys.removeChatToggle(_toggle);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => widget.child;
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -6,12 +6,16 @@
|
||||
|
||||
#include "generated_plugin_registrant.h"
|
||||
|
||||
#include <fox_utils/fox_utils_plugin.h>
|
||||
#include <media_kit_libs_linux/media_kit_libs_linux_plugin.h>
|
||||
#include <media_kit_video/media_kit_video_plugin.h>
|
||||
#include <screen_retriever_linux/screen_retriever_linux_plugin.h>
|
||||
#include <window_manager/window_manager_plugin.h>
|
||||
|
||||
void fl_register_plugins(FlPluginRegistry* registry) {
|
||||
g_autoptr(FlPluginRegistrar) fox_utils_registrar =
|
||||
fl_plugin_registry_get_registrar_for_plugin(registry, "FoxUtilsPlugin");
|
||||
fox_utils_plugin_register_with_registrar(fox_utils_registrar);
|
||||
g_autoptr(FlPluginRegistrar) media_kit_libs_linux_registrar =
|
||||
fl_plugin_registry_get_registrar_for_plugin(registry, "MediaKitLibsLinuxPlugin");
|
||||
media_kit_libs_linux_plugin_register_with_registrar(media_kit_libs_linux_registrar);
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
#
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
fox_utils
|
||||
media_kit_libs_linux
|
||||
media_kit_video
|
||||
screen_retriever_linux
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ packages:
|
||||
source: hosted
|
||||
version: "1.1.1"
|
||||
archive:
|
||||
dependency: "direct main"
|
||||
dependency: transitive
|
||||
description:
|
||||
name: archive
|
||||
sha256: ace891da0862b0e4cabbb064ee3fd87b2728b898949fdb366d83fe98342c9f19
|
||||
@@ -105,6 +105,22 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.15"
|
||||
dio:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: dio
|
||||
sha256: "852ec3b48cc431ac04fff978413c541502b67ffc3e26921e74e3d994694192c1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.11.1"
|
||||
dio_web_adapter:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: dio_web_adapter
|
||||
sha256: "3a1b2cd7be71086f38504956e3ebcd2837288d231ff454bafa78021244102bfc"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.2"
|
||||
fake_async:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -208,6 +224,15 @@ packages:
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
fox_utils:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
path: "."
|
||||
ref: "v0.1.8"
|
||||
resolved-ref: d2435a06b26d4f94c3c3777e6ef006ac84f4eef4
|
||||
url: "https://git.senkochan.moe/Foxyz/fox_utils.git"
|
||||
source: git
|
||||
version: "0.1.8"
|
||||
hive_ce:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -723,14 +748,14 @@ packages:
|
||||
path: "../../packages/core"
|
||||
relative: true
|
||||
source: path
|
||||
version: "0.2.2"
|
||||
version: "1.0.1"
|
||||
sync_relay_server:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
path: "../../packages/server"
|
||||
relative: true
|
||||
source: path
|
||||
version: "0.2.2"
|
||||
version: "1.0.1"
|
||||
synchronized:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@@ -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.0
|
||||
version: 1.1.0
|
||||
environment:
|
||||
sdk: '>=3.13.0-0 <4.0.0'
|
||||
|
||||
@@ -23,8 +23,12 @@ dependencies:
|
||||
hive_ce_flutter: ^2.3.4
|
||||
image: ^4.9.2
|
||||
window_manager: ^0.5.2
|
||||
archive: ^4.0.7
|
||||
crypto: ^3.0.6
|
||||
# Drag & drop and clipboard files/images (vendored desktop_drop + pasteboard).
|
||||
fox_utils:
|
||||
git:
|
||||
url: https://git.senkochan.moe/Foxyz/fox_utils.git
|
||||
ref: v0.1.8
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
@@ -33,3 +37,4 @@ flutter:
|
||||
uses-material-design: true
|
||||
assets:
|
||||
- assets/shaders/anime4k/
|
||||
- assets/icon/
|
||||
@@ -0,0 +1,68 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
import 'package:watch_together/src/shell/app_shell.dart';
|
||||
import 'package:watch_together/src/state/session_state.dart';
|
||||
import 'package:watch_together/src/update/update_controller.dart';
|
||||
import 'package:watch_together/src/widgets/app_keys.dart';
|
||||
import 'package:watch_together/src/widgets/text_focus.dart';
|
||||
|
||||
void main() {
|
||||
final session = SessionState.instance;
|
||||
final coord = session.coordinator;
|
||||
|
||||
tearDown(() {
|
||||
session.route.value = AppRoute.home;
|
||||
coord.serverState.value = null;
|
||||
});
|
||||
|
||||
// Regression: Tab moved focus (out of the chat field onto a button) and
|
||||
// Space, the ready key, then pressed that button.
|
||||
testWidgets('Tab opens and closes chat without moving focus; Space is ready', (tester) async {
|
||||
tester.view.physicalSize = const Size(1280, 800);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.reset);
|
||||
var readies = 0;
|
||||
await tester.pumpWidget(MaterialApp(
|
||||
theme: ThemeData.dark(),
|
||||
shortcuts: appShortcuts,
|
||||
home: AppKeys(
|
||||
onToggleReady: () => readies++,
|
||||
child: AppShell(
|
||||
updates: UpdateController(currentVersion: '0.0.0-dev'),
|
||||
autoUpdateCheck: false,
|
||||
windowControls: false,
|
||||
videoBuilder: (context, controls) => const SizedBox(),
|
||||
),
|
||||
),
|
||||
));
|
||||
coord.serverState.value = StateBroadcast(
|
||||
eventId: 1,
|
||||
transport: 'held',
|
||||
position: 0,
|
||||
serverTime: DateTime.now().toUtc(),
|
||||
mediaId: 0,
|
||||
members: [MemberView(id: 'a', name: 'Alex', ready: false, inSet: false, buffered: 0, stale: false)],
|
||||
);
|
||||
session.route.value = AppRoute.session;
|
||||
await tester.pumpAndSettle();
|
||||
final panel = find.byKey(const ValueKey('chat-panel'));
|
||||
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
|
||||
await tester.pumpAndSettle();
|
||||
expect(panel, findsOneWidget);
|
||||
expect(textInputFocused(), isTrue, reason: 'opening the chat focuses its field');
|
||||
|
||||
// From the field: closes the chat, focus goes back, not to a button.
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
|
||||
await tester.pumpAndSettle();
|
||||
expect(panel, findsNothing);
|
||||
expect(textInputFocused(), isFalse);
|
||||
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.space);
|
||||
await tester.pumpAndSettle();
|
||||
expect(readies, 1);
|
||||
expect(panel, findsNothing, reason: 'Space pressed a focused chat button');
|
||||
});
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import 'package:sync_relay_core/core.dart';
|
||||
import 'package:sync_relay_server/server.dart';
|
||||
import 'package:hive_ce/hive.dart';
|
||||
import 'package:watch_together/src/session/session_coordinator.dart';
|
||||
import 'package:watch_together/src/sfx/sfx.dart';
|
||||
import 'package:watch_together/src/state/session_state.dart';
|
||||
import 'package:watch_together/src/sync/sync_client.dart';
|
||||
|
||||
@@ -214,7 +215,8 @@ void main() {
|
||||
MediaKit.ensureInitialized();
|
||||
final server = SyncServer(const AppConfig(port: 0, secret: 'toast', name: 'toast'));
|
||||
await server.start();
|
||||
final coord = SessionCoordinator();
|
||||
final sounds = _RecordingSfx();
|
||||
final coord = SessionCoordinator(sfx: sounds);
|
||||
final friend = SyncClient();
|
||||
try {
|
||||
await coord.connect(ConnectParams(
|
||||
@@ -227,6 +229,12 @@ void main() {
|
||||
final toasts = coord.toasts.peek();
|
||||
expect(toasts.firstWhere((t) => t.text == 'Bob Jr joined').actor, 'Bob Jr');
|
||||
expect(toasts.firstWhere((t) => t.text == 'Bob Jr: hi there').actor, 'Bob Jr');
|
||||
// Others' chat makes a sound, your own does not.
|
||||
expect(sounds.kinds.where((k) => k == SfxKind.chat), hasLength(1));
|
||||
coord.sendChat('mine');
|
||||
await _poll(() => coord.toasts.peek().any((t) => t.text == 'Bob: mine'), 'own chat');
|
||||
expect(sounds.kinds.where((k) => k == SfxKind.chat), hasLength(1));
|
||||
expect(sounds.kinds, contains(SfxKind.join));
|
||||
} finally {
|
||||
await friend.dispose();
|
||||
await coord.dispose();
|
||||
@@ -235,3 +243,9 @@ void main() {
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
class _RecordingSfx implements SfxService {
|
||||
final kinds = <SfxKind>[];
|
||||
@override
|
||||
Future<void> play(SfxCue cue) async => kinds.add(cue.kind);
|
||||
}
|
||||
@@ -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,
|
||||
@@ -74,6 +74,12 @@ Future<void> openFolder(WidgetTester tester, String name) async {
|
||||
await frames(tester);
|
||||
}
|
||||
|
||||
/// Series start collapsed: a click on the header lists the episodes.
|
||||
Future<void> openSeries(WidgetTester tester, [String title = 'Show']) async {
|
||||
await tester.tap(find.descendant(of: find.byType(SeriesHeader), matching: find.text(title)));
|
||||
await frames(tester);
|
||||
}
|
||||
|
||||
void main() {
|
||||
setUp(() async {
|
||||
tmp = Directory.systemTemp.createTempSync('files_page_');
|
||||
@@ -147,6 +153,9 @@ void main() {
|
||||
expect(find.byType(SeriesHeader), findsOneWidget);
|
||||
expect(find.text('Show'), findsOneWidget);
|
||||
expect(find.text('3 episodes · 1 extra · Grp'), findsOneWidget);
|
||||
// Collapsed until clicked; open for the rest of the session.
|
||||
expect(find.text('E01'), findsNothing);
|
||||
await openSeries(tester);
|
||||
for (final l in ['E01', 'E02', 'E03', 'NCOP1']) {
|
||||
expect(find.text(l), findsOneWidget);
|
||||
}
|
||||
@@ -157,6 +166,13 @@ void main() {
|
||||
// Durations come from the thumbnail.
|
||||
expect(find.text('24:00 · 1 KB'), findsNWidgets(4));
|
||||
expect(find.text('24:00'), findsOneWidget); // card badge
|
||||
|
||||
await openFolder(tester, 'Recent');
|
||||
await openFolder(tester, 'anime');
|
||||
expect(find.text('E01'), findsOneWidget);
|
||||
await openSeries(tester);
|
||||
expect(find.text('E01'), findsNothing);
|
||||
expect(find.byType(VideoCard), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('hidden files are counted and some can be shown', (tester) async {
|
||||
@@ -180,6 +196,7 @@ void main() {
|
||||
store.addFolder(anime);
|
||||
await pumpPage(tester);
|
||||
await openFolder(tester, 'anime');
|
||||
await openSeries(tester);
|
||||
touch('$anime/[Grp] Show - 04.mkv');
|
||||
expect(find.text('E04'), findsNothing);
|
||||
await tester.tap(find.text('Rescan'));
|
||||
@@ -189,23 +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');
|
||||
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>[];
|
||||
@@ -216,6 +216,7 @@ void main() {
|
||||
});
|
||||
expect(find.textContaining('Keep the app open while it plays'), findsOneWidget);
|
||||
await openFolder(tester, 'anime');
|
||||
await openSeries(tester);
|
||||
|
||||
await tester.tap(find.text('E02'));
|
||||
await frames(tester);
|
||||
@@ -242,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',
|
||||
@@ -254,6 +254,7 @@ void main() {
|
||||
));
|
||||
await frames(tester);
|
||||
await openFolder(tester, 'anime');
|
||||
await openSeries(tester);
|
||||
expect(find.text('now playing'), findsOneWidget);
|
||||
expect(find.byIcon(Icons.graphic_eq), findsOneWidget);
|
||||
});
|
||||
@@ -267,6 +268,7 @@ void main() {
|
||||
await tester.tap(find.widgetWithText(InputChip, 'anime'));
|
||||
await frames(tester);
|
||||
expect(find.text('$anime/'), findsOneWidget);
|
||||
await openSeries(tester);
|
||||
expect(find.text('E01'), findsOneWidget);
|
||||
// The chip's x removes the folder, like the sidebar's.
|
||||
await tester.tap(find.byTooltip('Remove folder'));
|
||||
@@ -287,6 +289,7 @@ void main() {
|
||||
expect(tester.takeException(), isNull);
|
||||
await tester.tap(find.text('anime').first);
|
||||
await frames(tester);
|
||||
await openSeries(tester);
|
||||
expect(find.text('E01'), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
await tester.tap(find.text('Show some'));
|
||||
@@ -312,6 +315,7 @@ void main() {
|
||||
return Completer<Thumbnail?>().future; // never finishes
|
||||
});
|
||||
await openFolder(tester, 'big');
|
||||
await openSeries(tester, 'Long Show');
|
||||
expect(find.text('E01'), findsOneWidget);
|
||||
final first = thumbRequests.toSet();
|
||||
expect(first, isNotEmpty);
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:watch_together/src/shell/handoff.dart';
|
||||
|
||||
// What a drop or a paste is: decided from paths and text alone (the file
|
||||
// system stands behind a fake).
|
||||
|
||||
void main() {
|
||||
const fs = {
|
||||
'/v/a.mkv': FileSystemEntityType.file,
|
||||
'/v/b.MP4': FileSystemEntityType.file,
|
||||
'/v/notes.txt': FileSystemEntityType.file,
|
||||
'/v/cover.jpg': FileSystemEntityType.file,
|
||||
'/v/Season 1': FileSystemEntityType.directory,
|
||||
'/v/folder.mkv': FileSystemEntityType.directory,
|
||||
r'C:\Videos\c.mkv': FileSystemEntityType.file,
|
||||
};
|
||||
Handoff of({List<String> paths = const [], String? text}) =>
|
||||
classifyHandoff(paths: paths, text: text, typeOf: (p) => fs[p] ?? FileSystemEntityType.notFound);
|
||||
|
||||
test('videos by extension, in order; others dropped with them are left out', () {
|
||||
final h = of(paths: ['/v/notes.txt', '/v/b.MP4', '/v/Season 1', '/v/a.mkv']);
|
||||
expect((h as HandoffVideos).paths, ['/v/b.MP4', '/v/a.mkv']);
|
||||
});
|
||||
|
||||
test('a folder (even one named like a video), else an image, else nothing', () {
|
||||
expect((of(paths: ['/v/notes.txt', '/v/folder.mkv']) as HandoffFolder).path, '/v/folder.mkv');
|
||||
expect((of(paths: ['/v/cover.jpg']) as HandoffImage).path, '/v/cover.jpg');
|
||||
expect(of(paths: ['/v/notes.txt']), isA<HandoffNothing>());
|
||||
expect(of(paths: ['/gone/x.mkv']), isA<HandoffNothing>());
|
||||
expect(of(), isA<HandoffNothing>());
|
||||
});
|
||||
|
||||
test('pasted text: a link, absolute paths or file URIs; files win over text', () {
|
||||
expect(of(text: ' https://cdn.example/a b'), isA<HandoffNothing>());
|
||||
expect((of(text: ' https://youtu.be/x?t=1 \n') as HandoffLink).url, 'https://youtu.be/x?t=1');
|
||||
expect((of(text: 'HTTP://x.example/v.mp4') as HandoffLink).url, 'HTTP://x.example/v.mp4');
|
||||
expect((of(text: '/v/a.mkv\n/v/b.MP4') as HandoffVideos).paths, ['/v/a.mkv', '/v/b.MP4']);
|
||||
expect((of(text: 'file:///v/Season%201') as HandoffFolder).path, '/v/Season 1');
|
||||
expect((of(text: r'C:\Videos\c.mkv') as HandoffVideos).paths, [r'C:\Videos\c.mkv']);
|
||||
expect(of(text: 'v/a.mkv'), isA<HandoffNothing>(), reason: 'relative');
|
||||
expect(of(text: 'hello'), isA<HandoffNothing>());
|
||||
expect((of(paths: ['/v/Season 1'], text: 'https://x.example') as HandoffFolder).path, '/v/Season 1');
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -501,7 +501,8 @@ void main() {
|
||||
expect(a.jellyfinPlan.peek(), isA<OwnPlay>());
|
||||
expect(a.relaying.peek(), isTrue);
|
||||
expect(b.jellyfinBlocked.peek(), isNull);
|
||||
for (final x in [a, b]) {
|
||||
// Everyone present counts for the gate, Dave too.
|
||||
for (final x in [a, b, d]) {
|
||||
x.toggleReady();
|
||||
}
|
||||
await _poll(() => a.playback.player.state.playing && b.playback.player.state.playing, 'playing');
|
||||
@@ -511,7 +512,7 @@ void main() {
|
||||
expect(jelly.streams('ATOKEN'), isNotEmpty);
|
||||
expect(jelly.calls.where((c) => c.path.startsWith('/Videos/')).every((c) =>
|
||||
_FakeJellyfin.authFields(c.auth)['Token'] == 'ATOKEN'), isTrue);
|
||||
for (final x in [a, b]) {
|
||||
for (final x in [a, b, d]) {
|
||||
x.toggleReady();
|
||||
}
|
||||
|
||||
|
||||
@@ -189,23 +189,21 @@ void main() {
|
||||
expect(decodeRecentLinks(jsonEncode([{'url': ''}, {'url': 'https://a/b.mkv'}])).single.title, 'b.mkv');
|
||||
});
|
||||
|
||||
test('preview: YouTube via oEmbed, other links by file name, failures fall back', () async {
|
||||
Uri? asked;
|
||||
final yt = await previewLink('https://www.youtube.com/watch?v=abc', get: (uri) async {
|
||||
asked = uri;
|
||||
return jsonEncode({'title': 'A video', 'author_name': 'Chan', 'thumbnail_url': 'https://i.ytimg/x.jpg'});
|
||||
});
|
||||
expect(asked!.host, 'www.youtube.com');
|
||||
expect(asked!.path, '/oembed');
|
||||
expect(asked!.queryParameters, {'url': 'https://www.youtube.com/watch?v=abc', 'format': 'json'});
|
||||
expect((yt!.title, yt.subtitle, yt.thumbnail), ('A video', 'youtube.com · Chan', 'https://i.ytimg/x.jpg'));
|
||||
test('preview: a page by yt-dlp, a file by its name, else why not', () async {
|
||||
final yt = await previewLink('https://www.youtube.com/watch?v=abc',
|
||||
probe: (url) async => LinkProbe.page(Uri.parse(url),
|
||||
info: const YtDlpInfo(
|
||||
title: 'A video', uploader: 'Chan', duration: 187, thumbnail: 'https://i.ytimg/x.jpg')));
|
||||
expect((yt!.title, yt.subtitle, yt.thumbnail), ('A video', 'youtube.com · Chan · 3:07', 'https://i.ytimg/x.jpg'));
|
||||
|
||||
final file = await previewLink('https://cdn.example/dir/My%20Movie.mkv?sig=1',
|
||||
get: (_) => throw StateError('no lookup for plain links'));
|
||||
probe: (url) async => LinkProbe.file(Uri.parse(url), 10));
|
||||
expect((file!.title, file.subtitle, file.thumbnail), ('My Movie.mkv', 'cdn.example', null));
|
||||
|
||||
final down = await previewLink('https://youtu.be/abc', get: (_) async => throw Exception('offline'));
|
||||
expect((down!.title, down.subtitle), ('abc', 'youtu.be'));
|
||||
final page = await previewLink('https://example.com/a',
|
||||
probe: (url) async => LinkProbe.page(Uri.parse(url), pageTitle: 'Example', ytDlpError: 'Unsupported URL'));
|
||||
expect((page!.title, page.subtitle),
|
||||
('Example', "example.com · Not a video (and yt-dlp couldn't read it: Unsupported URL)"));
|
||||
expect(await previewLink('not a link'), isNull);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
import 'package:watch_together/src/jellyfin/accounts.dart';
|
||||
@@ -49,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'),
|
||||
),
|
||||
]);
|
||||
@@ -76,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,
|
||||
@@ -104,17 +103,16 @@ Future<Harness> pumpLibrary(
|
||||
key: UniqueKey(),
|
||||
controller: h.controller,
|
||||
accounts: h.accounts,
|
||||
online: online,
|
||||
solo: solo,
|
||||
isHost: isHost,
|
||||
jellyfinStart: start,
|
||||
recentLinks: h.recents,
|
||||
now: () => fixtureNow,
|
||||
oembed: (uri) async => jsonEncode({
|
||||
'title': 'A video',
|
||||
'author_name': 'Chan',
|
||||
}),
|
||||
filesBuilder: (context, {required online, required relayed, required onPlay, nowPlayingPath}) =>
|
||||
FilesStub(online: online, relayed: relayed, onPlay: onPlay),
|
||||
probe: (url) async => url.endsWith('.mkv')
|
||||
? LinkProbe.file(Uri.parse(url), 1)
|
||||
: LinkProbe.page(Uri.parse(url), info: const YtDlpInfo(title: 'A video', uploader: 'Chan')),
|
||||
filesBuilder: (context, {required solo, required relayed, required onPlay, nowPlayingPath}) =>
|
||||
FilesStub(solo: solo, relayed: relayed, onPlay: onPlay),
|
||||
onPutUp: (m) async {
|
||||
h.put.add(m);
|
||||
return null;
|
||||
@@ -154,7 +152,7 @@ void main() {
|
||||
|
||||
await tester.tap(find.text('Files'));
|
||||
await settle(tester, 10);
|
||||
expect(find.text('files online=true relayed=true'), findsOneWidget);
|
||||
expect(find.text('files solo=false relayed=true'), findsOneWidget);
|
||||
await tester.tap(find.byKey(const ValueKey('stub-play')));
|
||||
await settle(tester);
|
||||
expect(h.put, [const FileMedia('/v/a.mkv')]);
|
||||
@@ -249,36 +247,30 @@ 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);
|
||||
});
|
||||
|
||||
testWidgets('link: preview, how the others get it, put up, recent links, Ctrl+V', (tester) async {
|
||||
testWidgets('link: preview, how the others get it, put up, recent links', (tester) async {
|
||||
final h = await pumpLibrary(tester, const Size(1400, 1000), source: MediaKind.link);
|
||||
expect(find.text('How the others get it'), findsOneWidget);
|
||||
const url = 'https://www.youtube.com/watch?v=abc';
|
||||
@@ -304,29 +296,12 @@ void main() {
|
||||
await settle(tester, 10);
|
||||
expect(h.controller.link.text, url);
|
||||
|
||||
// Ctrl+V outside a field, on another source: the link lands in Link.
|
||||
await tester.tap(find.text('Jellyfin'));
|
||||
await settle(tester, 10);
|
||||
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, (call) async {
|
||||
if (call.method == 'Clipboard.getData') return {'text': 'https://cdn.example/b.mkv'};
|
||||
return null;
|
||||
});
|
||||
addTearDown(() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, null));
|
||||
FocusManager.instance.primaryFocus?.unfocus();
|
||||
Focus.of(tester.element(find.byKey(const ValueKey('media-source')))).requestFocus();
|
||||
await tester.pump();
|
||||
await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft);
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.keyV);
|
||||
await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft);
|
||||
await settle(tester, 10);
|
||||
expect(h.controller.source, MediaKind.link);
|
||||
expect(h.controller.link.text, 'https://cdn.example/b.mkv');
|
||||
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 {
|
||||
@@ -344,7 +319,7 @@ void main() {
|
||||
expect(c.selected!.itemId, bsEpisode);
|
||||
expect(c.source, MediaKind.jellyfin);
|
||||
final h = Harness(c, accounts, FakeJellyfin());
|
||||
await pumpLibrary(tester, const Size(1600, 1000), reuse: h, online: false);
|
||||
await pumpLibrary(tester, const Size(1600, 1000), reuse: h, solo: true);
|
||||
expect(find.byKey(const ValueKey('jf-details')), findsOneWidget);
|
||||
expect(find.text('Black Summoner'), findsWidgets);
|
||||
|
||||
|
||||
@@ -111,8 +111,14 @@ void main() {
|
||||
group('skip offers', () {
|
||||
final op = SkipRange(s(30), m(2), SkipSource.chapter);
|
||||
final ed = SkipRange(m(20), m(21, 30), SkipSource.chapter);
|
||||
SkipOffer? at(Duration p, {SkipRange? opening, SkipRange? ending, Duration? duration}) => skipOfferAt(
|
||||
position: p, duration: duration ?? m(24), opening: opening, ending: ending);
|
||||
SkipOffer? at(Duration p,
|
||||
{SkipRange? opening, SkipRange? ending, Duration? duration, bool chaptered = false}) =>
|
||||
skipOfferAt(
|
||||
position: p,
|
||||
duration: duration ?? m(24),
|
||||
opening: opening,
|
||||
ending: ending,
|
||||
chaptered: chaptered);
|
||||
|
||||
test('Skip OP inside the opening, to its end', () {
|
||||
expect(at(s(29), opening: op, ending: ed), isNull);
|
||||
@@ -141,6 +147,9 @@ void main() {
|
||||
expect(at(m(1), ending: ed), SkipOffer(SkipKind.fixed, m(2, 30)));
|
||||
// An opening known (but not playing) means no fallback.
|
||||
expect(at(m(3), opening: op), isNull);
|
||||
// Chapters without an OP chapter: no opening on purpose.
|
||||
expect(at(m(1), chaptered: true), isNull);
|
||||
expect(at(m(20, 10), ending: ed, chaptered: true), SkipOffer(SkipKind.ending, m(21, 30)));
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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,11 +101,14 @@ void main() {
|
||||
expect(v.self, isTrue);
|
||||
final w = playerStatusFor(
|
||||
health: ok,
|
||||
state: held([me, mv('b', 'Bob'), mv('m', 'Mika'), mv('z', 'Zed', stale: true)]),
|
||||
state: held([me, mv('b', 'Bob'), mv('m', 'Mika'), mv('z', 'Zed', stale: true), mv('o', 'Ola', out: true)]),
|
||||
self: me)!;
|
||||
expect(w.kind, StatusKind.waiting);
|
||||
expect(w.text, 'Waiting for Bob and Mika');
|
||||
expect(w.text, 'Waiting for Bob and Mika', reason: 'nobody waits for who sits out');
|
||||
expect(w.detail, '1/3 ready');
|
||||
final outMe = mv('me', 'Me', out: true);
|
||||
expect(playerStatusFor(health: ok, state: held([outMe, mv('b', 'Bob')]), self: outMe), isNull,
|
||||
reason: 'sitting out: the join-back card speaks');
|
||||
expect(w.who.map((m) => m.name), ['Bob', 'Mika']);
|
||||
});
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import 'package:sync_relay_core/core.dart';
|
||||
import 'package:sync_relay_server/server.dart';
|
||||
import 'package:watch_together/src/jellyfin/accounts.dart';
|
||||
import 'package:watch_together/src/jellyfin/track_prefs.dart';
|
||||
import 'package:watch_together/src/playback/yt_dlp_app.dart';
|
||||
import 'package:watch_together/src/screens/home/picked_media.dart';
|
||||
import 'package:watch_together/src/session/session_coordinator.dart';
|
||||
|
||||
@@ -67,7 +68,10 @@ void main() {
|
||||
Future<SessionCoordinator> member(String name) async {
|
||||
final acc = JellyfinAccounts(store: MemoryJellyfinStore());
|
||||
await acc.ensureLoaded(validate: false);
|
||||
final c = SessionCoordinator(jellyfin: acc, trackPrefs: TrackPrefs(store: MemoryJellyfinStore()));
|
||||
final c = SessionCoordinator(
|
||||
jellyfin: acc,
|
||||
trackPrefs: TrackPrefs(store: MemoryJellyfinStore()),
|
||||
ytDlp: AppYtDlp(YtDlp('$_tmp/relay_e2e_no_ytdlp', enabled: false)));
|
||||
all.add(c);
|
||||
await c.connect(ConnectParams(host: '127.0.0.1', port: server.boundPort, secret: 'pw', name: name));
|
||||
await c.playback.player.setVolume(0);
|
||||
@@ -119,7 +123,7 @@ void main() {
|
||||
expect(bob.relaying.peek(), isTrue);
|
||||
await playTogether([alice, bob]);
|
||||
final relay = server.hub.relay!;
|
||||
expect(relay.buffer.bytesToViewers, greaterThan(0), reason: 'alice played through the relay');
|
||||
expect(relay.buffers.single.bytesToViewers, greaterThan(0), reason: 'alice played through the relay');
|
||||
expect(relay.sourceToken, bob.sync.token);
|
||||
|
||||
// The next swap retires bob's source and the buffer.
|
||||
@@ -140,7 +144,7 @@ void main() {
|
||||
await _poll(() => stateMediaRelay(alice.serverState.peek()!)?.source == bob.sync.memberId, 'me state');
|
||||
expect(alice.serverState.peek()!.toJson().toString(), isNot(contains('SECRET')));
|
||||
await playTogether([alice, bob]);
|
||||
expect(server.hub.relay!.buffer.bytesToViewers, greaterThan(0));
|
||||
expect(server.hub.relay!.buffers.single.bytesToViewers, greaterThan(0));
|
||||
|
||||
final before = hits.length;
|
||||
expect(await alice.swapPicked(LinkMedia(link, relay: LinkRelay.server)), isNull);
|
||||
@@ -150,7 +154,7 @@ void main() {
|
||||
await playTogether([alice, bob]);
|
||||
expect(hits.length, greaterThan(before));
|
||||
|
||||
// A page instead of a file is refused up front.
|
||||
// A page yt-dlp cannot read is refused up front.
|
||||
final page = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
|
||||
page.listen((r) {
|
||||
r.response.headers.contentType = ContentType.html;
|
||||
@@ -158,7 +162,7 @@ void main() {
|
||||
r.response.close();
|
||||
});
|
||||
expect(await alice.swapPicked(LinkMedia('http://127.0.0.1:${page.port}/watch?v=x', relay: LinkRelay.me)),
|
||||
contains('cannot pass this link on'));
|
||||
contains('Not a video'));
|
||||
await page.close(force: true);
|
||||
} finally {
|
||||
await origin.close(force: true);
|
||||
|
||||
@@ -208,6 +208,8 @@ void main() {
|
||||
expect(art.banner, jpeg);
|
||||
expect(asked, unorderedEquals(['https://img/xl.jpg', 'https://img/banner.jpg', 'https://img/l.jpg']));
|
||||
expect(await aniListArt(c, 'Nothing Like It.mkv', fetch: (_, _, _) async => jpeg), isNull);
|
||||
// A link tail like ".../video/1" names no show: no AniList search at all.
|
||||
expect(await aniListArt(c, '1', fetch: (_, _, _) async => jpeg), isNull);
|
||||
c.close();
|
||||
});
|
||||
|
||||
|
||||
@@ -63,6 +63,23 @@ void main() {
|
||||
expect(out[2].onJellyfin, isFalse);
|
||||
});
|
||||
|
||||
test('anilist_map: a season split into two AniList entries is on Jellyfin', () {
|
||||
final item = AnimeFlowLibraryItem.fromJson({
|
||||
'jellyfin_series_id': 'jfJ',
|
||||
'name': 'JoJo',
|
||||
'anilist_ids': {'6': 190327},
|
||||
'anilist_map': [
|
||||
{'season': 6, 'anilist_id': 190327, 'first_episode': 1, 'offset': 0},
|
||||
{'season': 6, 'anilist_id': 210482, 'first_episode': 2, 'offset': 1},
|
||||
],
|
||||
});
|
||||
final out = matchSchedule([airing(210482, 3, DateTime(2026, 10, 6, 18))],
|
||||
schedule: const [], library: [item], now: now);
|
||||
expect(out.single.status, ScheduleStatus.inLibrary);
|
||||
expect(out.single.jellyfinSeriesId, 'jfJ');
|
||||
expect(out.single.season, 6);
|
||||
});
|
||||
|
||||
test("AnimeFlow's anilist_episode wins over TVDB numbering (split cour)", () {
|
||||
// S2E13 in TVDB is part 2 (AniList 300) episode 1.
|
||||
final out = matchSchedule(
|
||||
|
||||
@@ -231,7 +231,7 @@ void main() {
|
||||
WidgetTester tester, {
|
||||
Size size = const Size(2560, 1080),
|
||||
ScheduleSnapshot Function()? snap,
|
||||
bool online = true,
|
||||
bool solo = false,
|
||||
JellyfinAccounts? accounts,
|
||||
}) async {
|
||||
toasts.clear();
|
||||
@@ -245,7 +245,7 @@ void main() {
|
||||
theme: ThemeData.dark(),
|
||||
home: Scaffold(
|
||||
body: SchedulePage(
|
||||
online: online,
|
||||
solo: solo,
|
||||
repository: repo,
|
||||
accounts: accounts,
|
||||
onToast: toasts.add,
|
||||
@@ -434,11 +434,11 @@ void main() {
|
||||
await done(tester);
|
||||
});
|
||||
|
||||
testWidgets('offline: Play is disabled', (tester) async {
|
||||
await pumpPage(tester, online: false);
|
||||
testWidgets('solo: the button says Play and works', (tester) async {
|
||||
await pumpPage(tester, solo: true);
|
||||
expect(find.text('Put it up'), findsNothing);
|
||||
final b = widgetOf<ButtonStyleButton>(tester, action(111, 3));
|
||||
expect(b.onPressed, isNull);
|
||||
expect(b.onPressed, isNotNull);
|
||||
expect(find.text('Play'), findsOneWidget);
|
||||
await done(tester);
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -91,4 +91,14 @@ void main() {
|
||||
expect(n.contains('SECRET'), isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
test('unreadyForNewMedia: only a real swap while ready, setting on', () {
|
||||
bool u({bool enabled = true, int last = 2, int id = 3, bool ready = true}) =>
|
||||
unreadyForNewMedia(enabled: enabled, lastMediaId: last, mediaId: id, ready: ready);
|
||||
expect(u(), isTrue);
|
||||
expect(u(enabled: false), isFalse);
|
||||
expect(u(ready: false), isFalse);
|
||||
expect(u(id: 2), isFalse, reason: 'same media');
|
||||
expect(u(last: -1), isFalse, reason: 'first state of a session is no swap');
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:watch_together/src/sfx/sfx.dart';
|
||||
import 'package:watch_together/src/sfx/sound_bank.dart';
|
||||
import 'package:watch_together/src/sfx/synth.dart';
|
||||
|
||||
double _pitch(List<double> x, int from) {
|
||||
final c = <double>[];
|
||||
for (var l = 0; l < 700; l++) {
|
||||
var s = 0.0;
|
||||
for (var i = from; i < from + 2048; i++) {
|
||||
s += x[i] * x[i + l];
|
||||
}
|
||||
c.add(s);
|
||||
}
|
||||
var best = 0.0, lag = 0.0;
|
||||
for (var l = 30; l < 699; l++) {
|
||||
if (c[l] > best && c[l] >= c[l - 1] && c[l] >= c[l + 1]) {
|
||||
best = c[l];
|
||||
lag = l + (c[l - 1] - c[l + 1]) / (2 * (c[l - 1] - 2 * c[l] + c[l + 1]));
|
||||
}
|
||||
}
|
||||
return sampleRate / lag;
|
||||
}
|
||||
|
||||
void main() {
|
||||
test('every sound is described, quiet, short and clean', () {
|
||||
for (final p in SoundPreset.values) {
|
||||
expect(presetInfo[p], isNotNull, reason: p.name);
|
||||
for (final k in SfxKind.values) {
|
||||
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', () {
|
||||
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', () {
|
||||
for (final hz in [293.66, 587.33, 880.0, 1174.66]) {
|
||||
final r = render(SoundSpec([Note(0, hz, 1.0)], room: 0, cutoff: 20000));
|
||||
final cents = 1200 * math.log(_pitch(r, 2000) / hz) / math.ln2;
|
||||
expect(cents.abs(), lessThan(5), reason: '$hz');
|
||||
}
|
||||
});
|
||||
|
||||
test('wav header and length', () {
|
||||
final b = wav(render(soundFor(SfxKind.chat)));
|
||||
expect(String.fromCharCodes(b.sublist(0, 4)), 'RIFF');
|
||||
expect(String.fromCharCodes(b.sublist(8, 12)), 'WAVE');
|
||||
expect(b.length, 44 + render(soundFor(SfxKind.chat)).length * 2);
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
@@ -216,6 +239,31 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
testWidgets('Ctrl+V outside a field: a link asks, then fills Library → Link', (tester) async {
|
||||
await pumpShell(tester, const Size(1280, 800));
|
||||
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, (call) async {
|
||||
if (call.method == 'Clipboard.getData') return {'text': 'https://cdn.example/b.mkv'};
|
||||
return null;
|
||||
});
|
||||
addTearDown(() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, null));
|
||||
// fox_utils' clipboard files: none.
|
||||
const pasteboard = MethodChannel('pasteboard');
|
||||
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(pasteboard, (call) async => const <String>[]);
|
||||
addTearDown(() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(pasteboard, null));
|
||||
await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft);
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.keyV);
|
||||
await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byKey(const ValueKey('handoff-card')), findsOneWidget);
|
||||
expect(find.text('Open in Link'), findsOneWidget); // offline
|
||||
await tester.tap(find.byKey(const ValueKey('handoff-openLink')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byKey(const ValueKey('handoff-card')), findsNothing);
|
||||
final field = tester.widget<TextField>(find.byKey(const ValueKey('link-field')));
|
||||
expect(field.controller!.text, 'https://cdn.example/b.mkv');
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('profile rename goes to the server through the hook', (tester) async {
|
||||
final pushed = <String?>[];
|
||||
final p = Profile.instance;
|
||||
@@ -281,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.
|
||||
|
||||
@@ -158,12 +158,15 @@ void main() {
|
||||
});
|
||||
|
||||
group('finished', () {
|
||||
test('stops in place: pause, no seek to the end, no drift seeks', () {
|
||||
final diff = StateDiff();
|
||||
test('a player just behind plays out; far behind pauses; no seeks', () {
|
||||
final t = DateTime.now().toUtc();
|
||||
diff.diff(_state(eventId: 1, transport: 'playing', position: 100), 100, t);
|
||||
final end = _state(eventId: 2, transport: 'finished', position: 120);
|
||||
final actions = diff.diff(end, 119.4, t);
|
||||
final near = StateDiff()..diff(_state(eventId: 1, transport: 'playing', position: 100), 100, t);
|
||||
final nearActions = near.diff(end, 119.4, t);
|
||||
expect(nearActions.whereType<SeekAction>(), isEmpty);
|
||||
expect(nearActions.whereType<TransportAction>(), isEmpty);
|
||||
final diff = StateDiff()..diff(_state(eventId: 1, transport: 'playing', position: 100), 100, t);
|
||||
final actions = diff.diff(end, 110, t);
|
||||
expect(actions.whereType<SeekAction>(), isEmpty);
|
||||
expect(actions.whereType<TransportAction>().single.playing, isFalse);
|
||||
// Far off and later: still nothing.
|
||||
|
||||