43 Commits
Author SHA1 Message Date
foxyz bbecdcc4d8 Release v1.1.2
ci / core (push) Successful in 33s
release / linux (push) Successful in 4m28s
ci / updater-windows (push) Successful in 4m49s
ci / server (push) Successful in 58s
release / windows (push) Successful in 4m30s
2026-10-10 21:45:57 +03:00
foxyzandClaude Opus 5.5 f1ccf88b06 Roadmap: drop the link-token item (what a user pastes is theirs)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 21:45:57 +03:00
foxyzandClaude Opus 5.5 d3be13ecda Errors: refuse links the probe can't play (with Put it up anyway), plain error texts, and others see who can't play
report.error / state.members[].error (optional, no protocol bump): an
errored member is out of the ready gate until its player opens the media
or the media changes; others see "Bob can't play this: ..." and a mark
on the avatar; your own pill offers Retry (reload).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 21:29:41 +03:00
foxyzandClaude Opus 5.5 1b04e002c2 Player: mpv logs at warn (v only while probing the render mode), 4xx fails at once, load errors show
The session player asked libmpv for every v-level message all session and
media_kit drains them on the UI thread: a retry storm could freeze the
window. ffmpeg reconnects only on 5xx, bounded. A load that fails before
a duration is known reports once ('can't play this: HTTP error 403 ...').

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 21:12:00 +03:00
foxyzandClaude Opus 5.5 f23cc7b311 Schedule: remove the per-user follow (bookmark, Following filter, AnimeFlow follow calls)
Personal tracking will come from the user's AniList lists; AnimeFlow keeps
downloads, availability and requests only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 20:51:42 +03:00
foxyzandClaude Opus 5.5 473c82ec0a Schedule: AnimeFlow's AniList episode number wins outright (JoJo S6E4 no longer claims next week's ep 4)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 20:42:28 +03:00
foxyzandClaude Opus 5.5 e621b392af Dependencies: Flutter upgrade, file_picker 13, flutter_lints 6
ci / core (push) Successful in 28s
ci / server (push) Successful in 57s
ci / updater-windows (push) Successful in 6m20s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 20:16:45 +03:00
foxyzandClaude Opus 5.5 884f208330 Schedule: On Jellyfin only when the episode itself is there; Aired · not on Jellyfin yet otherwise
For shows AnimeFlow doesn't track, the app now looks the episode up in the
linked Jellyfin (one /Shows/{id}/Episodes per series, 3 min memo; TVDB
number via anilist_map offsets). No linked account: a neutral Aired.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 20:15:18 +03:00
foxyz cc45b86271 Release v1.1.1
ci / core (push) Successful in 28s
ci / server (push) Successful in 56s
release / windows (push) Successful in 4m33s
release / linux (push) Successful in 3m44s
ci / updater-windows (push) Successful in 4m25s
2026-10-10 01:01:28 +03:00
foxyzandClaude Opus 5.5 84c5cda668 CI (Windows): fetch git dependencies fresh; the host's cached fox_utils clone was stale
ci / core (push) Successful in 42s
ci / server (push) Successful in 1m17s
ci / updater-windows (push) Successful in 4m26s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 00:41:17 +03:00
foxyzandClaude Opus 5.5 bc96cb13d9 fox_utils 0.1.9: fixes the Windows build (broken since 1.0.1)
ci / core (push) Successful in 28s
ci / updater-windows (push) Failing after 43s
ci / server (push) Successful in 1m10s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 00:32:29 +03:00
foxyzandClaude Opus 5.5 f7f2f0cddd CI: show the verbose Windows build's error lines and tail
ci / core (push) Successful in 28s
ci / server (push) Successful in 55s
ci / updater-windows (push) Failing after 3m55s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 00:23:11 +03:00
foxyzandClaude Opus 5.5 05b06b1fc1 CI: capture the verbose Windows build through cmd (PowerShell 'Stop' aborted on stderr)
ci / core (push) Successful in 28s
ci / server (push) Successful in 58s
ci / updater-windows (push) Failing after 4m5s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 00:13:23 +03:00
foxyzandClaude Opus 5.5 748d58f036 CI: build Windows on every push; a failed Windows build prints the compiler errors
ci / core (push) Successful in 28s
ci / server (push) Successful in 57s
ci / updater-windows (push) Failing after 4m50s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 00:01:02 +03:00
foxyz 1fd4081461 Release v1.1.0
ci / updater-windows (push) Successful in 32s
ci / core (push) Successful in 27s
release / windows (push) Failing after 20s
ci / server (push) Successful in 54s
release / linux (push) Successful in 3m46s
2026-10-09 23:53:53 +03:00
foxyzandClaude Opus 5.5 2d39018a73 One updater for app and server: updater in core; watch-together-server --upgrade / --check-update; update.sh uses the server
The server's version is stamped like the app's (APP_VERSION) when packaged,
so it can compare against the manifest; --upgrade swaps the whole install
folder (app and server) and asks for a restart. update.sh/.bat no longer
need GTK.

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 18:21:22 +03:00
foxyzandClaude Opus 5.5 9ddd85de90 Esc steps out one level: chat, fullscreen, big player to mini, then ask to leave
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 17:41:38 +03:00
foxyzandClaude Opus 5.5 24fc137e4f No buffering rings at the end of short videos; links never guess an AniList show
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 17:23:43 +03:00
foxyzandClaude Opus 5.5 9a4b04672b Schedule: AniList entries split within a season (anilist_map, per-episode AniList id) count as on Jellyfin
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 17:12:43 +03:00
foxyz ff51d52fbb Release v1.0.1
ci / updater-windows (push) Successful in 47s
ci / core (push) Successful in 27s
ci / server (push) Successful in 45s
release / linux (push) Successful in 3m49s
release / windows (push) Failing after 5m53s
2026-10-09 08:18:29 +03:00
foxyzandClaude Opus 5.5 f2272d7980 Space never presses a focused button; chat toasts open the chat; plain wheel always scrolls the page
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 07:52:48 +03:00
foxyzandClaude Opus 5.5 cf80bb2167 End of video plays out, no false 'loading' near/after the end; bigger status pill avatars; start card fits the mini
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 07:31:03 +03:00
foxyzandClaude Opus 5.5 8effa573a0 Protocol 10 presence gate, drop/paste anywhere, quick UI fixes
Ready gate: whoever is in the server while it is not playing counts (joining
while playing does not pause anyone); a swap counts everyone present and
keeps ready flags; the quorum rule is gone. Optional 'Unready when the
video changes'. No 'Skip 1:30' guess when the media has chapters.

Drag & drop and Ctrl+V anywhere (fox_utils): files, folders and links get a
small card asking how to open them. Files series start collapsed.

UI: Tab never moves focus and Space is always ready outside text fields;
chat focuses its field; unread badge on uosc and a chip in fullscreen;
people-only chat with an Activity toggle; skip pills show 5 s then with
the controls; volume up to 150%; own avatar same size as the party.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 07:06:48 +03:00
foxyzandClaude Opus 5.5 5f22be6c20 Roadmap: wishlist placed (ready gate, quick UI, drop/paste, Files, yt-dlp, AniList, ops)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 06:54:08 +03:00
foxyzandClaude Opus 5.5 740273745b Desktop: depend on fox_utils v0.1.7 (git) for drag & drop and clipboard
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 06:32:22 +03:00
foxyzandClaude Opus 5.5 27b4e1dc0c Sounds: full volume by default; everyone ready is the bell alone
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 06:28:53 +03:00
foxyzandClaude Opus 5.5 fce392e425 Sounds: Japanese-house sound set in the app, layout demo removed
Eleven sounds for events that carry news (ready, unready, everyone ready,
join, leave, kick, new video, others' chat, idle warning, connection
lost/back); none for what the video already says. New voices: koto
(Karplus-Strong, bendable), furin chime, rin bowl, bamboo, hyoshigi,
shoji slide, in the Yo scale on D. Played by SynthSfx at the Settings
volume with per-sound switches and previews. The debug layout mock is
gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 06:23:22 +03:00
foxyzandClaude Opus 5.5 d8fd464e33 Sounds: synthesized sound set and a debug sound board
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 05:45:14 +03:00
158 changed files with 10650 additions and 7805 deletions

No files matched your search

+10 -2
View File
@@ -55,5 +55,13 @@ jobs:
run: powershell -NoProfile -ExecutionPolicy Bypass -File tool/setup_flutter_windows.ps1 run: powershell -NoProfile -ExecutionPolicy Bypass -File tool/setup_flutter_windows.ps1
- name: Updater tests - name: Updater tests
shell: powershell shell: powershell
working-directory: apps/desktop working-directory: packages/core
run: flutter test test/updater_test.dart run: |
dart pub get
if ($LASTEXITCODE) { exit 1 }
dart test test/updater_test.dart
# The release build, without publishing: a broken Windows build shows
# here, not only on a tag.
- name: Windows build
shell: powershell
run: powershell -NoProfile -ExecutionPolicy Bypass -File tool/package_windows.ps1
+110 -37
View File
@@ -1,4 +1,8 @@
# watch-together <p align="center">
<img src="apps/desktop/assets/icon/icon_256.png" width="128" height="128" alt="Starsync icon">
</p>
<h1 align="center">Starsync</h1>
Watch a video with friends, in sync, with the quality of a real mpv player. 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; 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: From the Releases page of this repo:
- Linux: `watch-together-<version>-linux-x64.tar.gz`. Extract, run - 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 - Windows: `watch-together-<version>-windows-x64.zip`. Extract, run
`watch_together.exe`. mpv is bundled. New and not yet tried on a real `watch_together.exe`. mpv is bundled. New and not yet tried on a real
Windows desktop; reports welcome. 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, 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 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: 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 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 must run the same protocol version, and a mismatch is refused with a message
telling you to update. 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 ## The window
One window. Along the top, the strip: your avatar (click it for your 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 profile: name, picture with a crop editor, colour, and links to the
settings), the Library / Schedule / Settings tabs, and the party. Offline 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, with a ring for their state (green ready, amber buffering, grey not ready,
dimmed reconnecting; a crown on whoever hosts, a tower on whoever dimmed reconnecting, dimmed with a moon sitting out; a crown on whoever
passes the video on). Click the avatars for the member list (force ready, hosts, a tower on whoever passes the video on). Click the avatars for the
kick, "Copy invite address"; when you host it lists this computer's 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 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 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. count, and Leave. Empty strip space drags the window.
The player fills the window under the strip. Pick a tab and it shrinks to 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 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 corner; double-click or ⤢ makes it big again, × sits you out (below) and
the server, muted) and the strip's screen button brings it back. F or × 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 double-click on the picture goes fullscreen (the strip hides, the party
shows in a corner while paused, the window buttons sit in the player's top shows in a corner while paused, the window buttons sit in the player's top
bar while its controls are up), Esc leaves it. The controls show the bar while its controls are up), Esc leaves it. The controls show the
@@ -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 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 when you're ready", "Waiting for Bob and Mika · 2/3 ready", "Bob is
loading…") or what went wrong, with Retry or Cancel when it helps. Chat and activity open in a panel on the right (chat button or loading…") or what went wrong, with Retry or Cancel when it helps. Chat and activity open in a panel on the right (chat button or
Tab); joins, leaves, chat and other news also pop up bottom left with the Tab), ready to type; it shows messages, and its "Activity" toggle adds
person's avatar. 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 Your name and picture change for everyone in the server right away; the
colour is yours only. 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 from the app
Host in the strip: set a port and an optional password (blank = anyone can 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 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 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 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 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 ## Library
What to watch, from three sources (the switch at the top; Ctrl+V outside What to watch, from three sources (the switch at the top). Drop a video,
a field pastes a link into Link): 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 switch, then one **Continue** row (what you were watching and what comes
next, one card per series, the one you watched most recently first; next, one card per series, the one you watched most recently first;
series not touched for two months drop out) and **Latest in** each series not touched for two months drop out) and **Latest in** each
@@ -98,15 +140,20 @@ a field pastes a link into Link):
details open on the right with "Put it up · S1E3", mark watched / details open on the right with "Put it up · S1E3", mark watched /
unwatched, remove from Continue and the episode list (click an episode unwatched, remove from Continue and the episode list (click an episode
to choose it). Double-click a card or an episode to put it up at once. to choose it). Double-click a card or an episode to put it up at once.
- **Link**: paste an http(s) link. The preview shows what it is (title and - **Link**: paste an http(s) link: a video file, or a web page with a
thumbnail for YouTube, else the file name); in a server, pick how the 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 others get it (everyone fetches it, the server fetches it, or through
you). Your last 12 links are listed under it. you). Your last 12 links are listed under it. A link that does not
- **Files**: video folders you add on this computer, grouped by series, open here (needs cookies, 403, 404, not a video) says why and goes up
or any file through Browse. The others stream it from you. only with "Put it up anyway"; in the player, whoever cannot play it is
named ("Bob can't play this: Not allowed (403)") and nobody waits for them.
- **Files**: video folders you add on this computer, grouped by series
(click a series to list its episodes), or any file through Browse. The others stream it from you.
Put it up and the player opens. Offline the library browses but nothing Put it up and the player opens. Not in a server, the buttons say Play
goes up until you join or host ("Join or host to watch together"). and it plays for you alone ([Watch solo](#getting-started)).
## Dedicated server ## Dedicated server
@@ -125,10 +172,15 @@ then everyone (you included) uses Join.
| `--name` | `server` | name reported by `/health` | | `--name` | `server` | name reported by `/health` |
| `--media` | none | local file to serve as the starting video | | `--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 | | `--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 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 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 [deploy/watch-together.service](deploy/watch-together.service) (copy it to
`/etc/systemd/system/`, edit the flags, `systemctl enable --now watch-together`). `/etc/systemd/system/`, edit the flags, `systemctl enable --now watch-together`).
@@ -149,8 +201,8 @@ of help: Profile; Party & network (the Host card's default port and
password, recent servers with remove); Player (below); Jellyfin & password, recent servers with remove); Player (below); Jellyfin &
integrations (linked Jellyfin servers, below, the AnimeFlow connection: integrations (linked Jellyfin servers, below, the AnimeFlow connection:
"connected as <name>" with what you may do, or why not, and playing the "connected as <name>" with what you may do, or why not, and playing the
next episode automatically); Notifications & sounds (what pops up; there next episode automatically); Notifications & sounds (what pops up; the sound
is nothing to switch yet); Updates; Keyboard shortcuts (read-only); About volume and sound set, and every sound with a preview, its own set and a switch); Updates; Keyboard shortcuts (read-only); About
(the version). (the version).
Player settings live in the player: ⚙ at the right end of its controls Player settings live in the player: ⚙ at the right end of its controls
@@ -162,10 +214,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 overrides it); in the player also the subtitle and audio track. Every
change applies right away. change applies right away.
Keys while the player fills the window: Space = ready / unready, Tab = Anywhere in a server (unless you are typing): Space = ready / unready,
chat & activity, Left/Right = seek 10 s, Shift+Right = skip 1:30, 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 = 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, `<` / `>` 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. 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. Every seek, skip and chapter jump moves everyone.
@@ -174,7 +228,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 shows the time and the chapter title. The chapters button lists them
(current one checked; click to jump). "1:30" skips ahead 1:30. Audio and (current one checked; click to jump). "1:30" skips ahead 1:30. Audio and
Subtitles buttons (with the track count) list tracks as "Japanese · E-AC3 Subtitles buttons (with the track count) list tracks as "Japanese · E-AC3
2.0 · title"; subtitles have Off. 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, The uosc player design (⚙ → Player design, Standard by default,
switches right away): video controls modeled on mpv's uosc theme. A thick switches right away): video controls modeled on mpv's uosc theme. A thick
@@ -189,12 +245,14 @@ the same.
Skipping: during the opening a "Skip OP" button floats in the corner (it 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 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 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 them (needs a segment plugin, e.g. Intro Skipper), else from chapter names
(OP, Opening, Intro / ED, Ending, Credits, Outro). Without an opening, on (OP, Opening, Intro / ED, Ending, Credits, Outro). Without an opening or
anything longer than 20 minutes, "Skip 1:30" floats there during the first any chapters, on anything longer than 20 minutes, "Skip 1:30" floats there
6 minutes. during the first 6 minutes.
## Ports ## Ports
@@ -280,9 +338,24 @@ the others get it:
- Through me: your computer downloads it and passes it on, for links only - Through me: your computer downloads it and passes it on, for links only
you can open. you can open.
The last two need a link to a video file (not a page like YouTube) and Web pages work in all three: the app and the dedicated server each
never show the link to anyone. Your upload has to beat the video's download [yt-dlp](https://github.com/yt-dlp/yt-dlp) by themselves (checked
bitrate: a 25-minute 2 GB episode is about 11 Mbit/s. 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 ## Video quality and shaders
+9
View File
@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256">
<defs><linearGradient id="bg" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#2d2547"/><stop offset="1" stop-color="#14111d"/></linearGradient><linearGradient id="t1" gradientUnits="userSpaceOnUse" x1="50.7" y1="148.7" x2="184.6" y2="71.4"><stop offset="0" stop-color="#f0795a" stop-opacity="0"/><stop offset="0.45" stop-color="#f0795a" stop-opacity="0.85"/><stop offset="1" stop-color="#ffc56b"/></linearGradient><linearGradient id="t2" gradientUnits="userSpaceOnUse" x1="205.3" y1="107.3" x2="71.4" y2="184.6"><stop offset="0" stop-color="#ffc56b" stop-opacity="0"/><stop offset="0.45" stop-color="#ffc56b" stop-opacity="0.85"/><stop offset="1" stop-color="#f0795a"/></linearGradient></defs>
<rect x="8" y="8" width="240" height="240" rx="56" fill="url(#bg)"/>
<circle cx="128" cy="128" r="80" fill="none" stroke="#ffffff" stroke-opacity="0.10" stroke-width="6"/>
<path d="M205.27 107.29 A80 80 0 0 1 71.43 184.57" fill="none" stroke="url(#t2)" stroke-width="7" stroke-linecap="butt"/><path d="M50.73 148.71 A80 80 0 0 1 184.57 71.43" fill="none" stroke="url(#t1)" stroke-width="7" stroke-linecap="butt"/>
<path d="M110 96 L110 160 L162 128 Z" fill="#fff1d6" stroke="#fff1d6" stroke-width="16" stroke-linejoin="round"/>
<path d="M71.4 161.6 Q74.7 181.3 94.4 184.6 Q74.7 187.8 71.4 207.6 Q68.2 187.8 48.4 184.6 Q68.2 181.3 71.4 161.6 Z" fill="#f0795a"/>
<path d="M184.6 40.4 Q188.9 67.1 215.6 71.4 Q188.9 75.8 184.6 102.4 Q180.2 75.8 153.6 71.4 Q180.2 67.1 184.6 40.4 Z" fill="#ffc56b"/>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 732 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

+3 -1
View File
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'src/screens/home/ui.dart'; import 'src/screens/home/ui.dart';
import 'src/shell/app_shell.dart'; import 'src/shell/app_shell.dart';
import 'src/widgets/app_keys.dart';
class WatchTogetherApp extends StatelessWidget { class WatchTogetherApp extends StatelessWidget {
const WatchTogetherApp({super.key}); const WatchTogetherApp({super.key});
@@ -11,7 +12,8 @@ class WatchTogetherApp extends StatelessWidget {
return MaterialApp( return MaterialApp(
title: appName, title: appName,
theme: ThemeData.dark(), theme: ThemeData.dark(),
home: const AppShell(), shortcuts: appShortcuts,
home: const AppKeys(child: AppShell()),
); );
} }
} }
+8 -1
View File
@@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:io'; import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@@ -7,10 +8,12 @@ import 'package:sync_relay_core/core.dart';
import 'package:window_manager/window_manager.dart'; import 'package:window_manager/window_manager.dart';
import 'app.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/screens/home/ui.dart';
import 'src/state/session_state.dart'; import 'src/state/session_state.dart';
import 'src/state/settings.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 { Future<void> main(List<String> args) async {
launchArgs = List.unmodifiable(args); launchArgs = List.unmodifiable(args);
@@ -60,6 +63,10 @@ Future<void> main(List<String> args) async {
debugPrint('load connect form failed: $e\n$st'); debugPrint('load connect form failed: $e\n$st');
} }
MediaKit.ensureInitialized(); 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()); runApp(const WatchTogetherApp());
} }
+46 -18
View File
@@ -10,6 +10,7 @@ import '../../screens/session/player_controls.dart' show TrackPick;
import '../../shaders/shader_menu.dart'; import '../../shaders/shader_menu.dart';
import '../../shaders/shader_presets.dart'; import '../../shaders/shader_presets.dart';
import '../../state/settings.dart'; import '../../state/settings.dart';
import '../volume.dart';
import 'controls.dart'; import 'controls.dart';
import 'menu.dart'; import 'menu.dart';
import 'style.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 { class UoscVolumeButton extends StatefulWidget {
/// 0..100. /// 0..[maxVolume].
final double volume; final double volume;
final ValueChanged<double> onChanged; final ValueChanged<double> onChanged;
const UoscVolumeButton({super.key, required this.volume, required this.onChanged}); const UoscVolumeButton({super.key, required this.volume, required this.onChanged});
@@ -179,29 +181,55 @@ class UoscVolumeButton extends StatefulWidget {
class _UoscVolumeButtonState extends State<UoscVolumeButton> { class _UoscVolumeButtonState extends State<UoscVolumeButton> {
double _beforeMute = 100; double _beforeMute = 100;
bool _hover = false;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final v = widget.volume; final v = widget.volume;
final icon = v <= 0 ? Icons.volume_off_rounded : (v < 50 ? Icons.volume_down_rounded : Icons.volume_up_rounded); final icon = v <= 0 ? Icons.volume_off_rounded : (v < 50 ? Icons.volume_down_rounded : Icons.volume_up_rounded);
return Listener( return MouseRegion(
onPointerSignal: (e) { onEnter: (_) => setState(() => _hover = true),
if (e is PointerScrollEvent && e.scrollDelta.dy != 0) { onExit: (_) => setState(() => _hover = false),
widget.onChanged((v + (e.scrollDelta.dy < 0 ? 5 : -5)).clamp(0.0, 100.0)); child: Listener(
} onPointerSignal: (e) {
}, if (e is PointerScrollEvent && e.scrollDelta.dy != 0) {
child: UoscButton( widget.onChanged(stepVolume(v, e.scrollDelta.dy < 0 ? 5 : -5));
key: const ValueKey('uosc-volume'),
icon: icon,
tooltip: 'Volume ${v.round()} % (scroll to change)',
onPressed: () {
if (v > 0) {
_beforeMute = v;
widget.onChanged(0);
} else {
widget.onChanged(_beforeMute <= 0 ? 100 : _beforeMute);
} }
}, },
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) {
_beforeMute = v;
widget.onChanged(0);
} else {
widget.onChanged(_beforeMute <= 0 ? 100 : _beforeMute);
}
},
),
],
),
), ),
); );
} }
@@ -26,6 +26,8 @@ class UoscControls extends StatefulWidget {
final Widget? topLeft; final Widget? topLeft;
final VoidCallback? onDoubleTap; final VoidCallback? onDoubleTap;
final Duration hideAfter; final Duration hideAfter;
/// Follows whether the controls are up (for overlays outside them).
final ValueNotifier<bool>? shown;
const UoscControls({ const UoscControls({
super.key, super.key,
required this.timeline, required this.timeline,
@@ -35,6 +37,7 @@ class UoscControls extends StatefulWidget {
this.topLeft, this.topLeft,
this.onDoubleTap, this.onDoubleTap,
this.hideAfter = const Duration(seconds: 3), this.hideAfter = const Duration(seconds: 3),
this.shown,
}); });
static const buttonRowHeight = 40.0; static const buttonRowHeight = 40.0;
@@ -58,6 +61,10 @@ class UoscControlsState extends State<UoscControls> {
@override @override
void initState() { void initState() {
super.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(); _hideLater();
} }
@@ -69,7 +76,10 @@ class UoscControlsState extends State<UoscControls> {
/// Show the controls and restart the hide countdown. /// Show the controls and restart the hide countdown.
void wake() { void wake() {
if (!_visible && mounted) setState(() => _visible = true); if (!_visible && mounted) {
setState(() => _visible = true);
widget.shown?.value = true;
}
_hideLater(); _hideLater();
} }
@@ -98,6 +108,7 @@ class UoscControlsState extends State<UoscControls> {
void _hide() { void _hide() {
if (!mounted || _holds > 0 || !_visible) return; if (!mounted || _holds > 0 || !_visible) return;
setState(() => _visible = false); setState(() => _visible = false);
widget.shown?.value = false;
} }
@override @override
+146
View File
@@ -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(),
),
],
),
),
);
},
);
}
}
+144 -143
View File
@@ -10,9 +10,6 @@ import 'thumb.dart';
// The Files page's rows and cards: a series header, an episode row (also // 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. // 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. /// What a play control is doing right now.
enum PlayState { idle, starting, playing } 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)`. /// Cleaned title of a loose video: `Title (2019)`.
String cleanTitle(ParsedName p) => p.year == null ? p.series : '${p.series} (${p.year})'; 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 { class SeriesHeader extends StatelessWidget {
final SeriesGroup group; final SeriesGroup group;
final ThumbCache thumbs; 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -49,34 +46,48 @@ class SeriesHeader extends StatelessWidget {
?first.parsed.group, ?first.parsed.group,
].join(' · '); ].join(' · ');
return Padding( return Padding(
padding: const EdgeInsets.only(top: 18, bottom: 4), padding: const EdgeInsets.only(top: 14),
child: Row( child: InkWell(
children: [ onTap: onTap,
ClipRRect( borderRadius: BorderRadius.circular(10),
borderRadius: BorderRadius.circular(8), child: Padding(
child: ThumbBuilder( padding: const EdgeInsets.symmetric(vertical: 4),
cache: thumbs, child: Row(
path: first.path, children: [
builder: (context, t, _) => ThumbImage(t, width: 96, height: 54), ClipRRect(
), borderRadius: BorderRadius.circular(8),
child: ThumbBuilder(
cache: thumbs,
path: first.path,
builder: (context, t, _) => ThumbImage(t, width: 96, height: 54),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(group.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.titleMedium),
Text(detail,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12)),
],
),
),
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),
],
), ),
const SizedBox(width: 12), ),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(group.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.titleMedium),
Text(detail,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12)),
],
),
),
],
), ),
); );
} }
@@ -92,9 +103,8 @@ class FileRow extends StatelessWidget {
final String? size; final String? size;
final PlayState state; final PlayState state;
final ThumbCache thumbs; final ThumbCache thumbs;
/// Null disables the row ([offline] adds the reason as a tooltip). /// Null disables the row.
final VoidCallback? onTap; final VoidCallback? onTap;
final bool offline;
final bool indent; final bool indent;
final Widget? trailing; final Widget? trailing;
const FileRow({ const FileRow({
@@ -106,7 +116,6 @@ class FileRow extends StatelessWidget {
required this.state, required this.state,
required this.thumbs, required this.thumbs,
required this.onTap, required this.onTap,
required this.offline,
this.indent = true, this.indent = true,
this.trailing, this.trailing,
}); });
@@ -130,62 +139,59 @@ class FileRow extends StatelessWidget {
builder: (context, t, _) { builder: (context, t, _) {
final duration = durationText(t); final duration = durationText(t);
final meta = [?duration, ?size].join(' · '); final meta = [?duration, ?size].join(' · ');
return _offlineTip( return Material(
offline, color: playing ? scheme.primary.withValues(alpha: 0.08) : Colors.transparent,
Material( borderRadius: BorderRadius.circular(10),
color: playing ? scheme.primary.withValues(alpha: 0.08) : Colors.transparent, child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(10),
child: InkWell( child: Padding(
onTap: onTap, padding: EdgeInsets.fromLTRB(indent ? 20 : 4, 5, 8, 5),
borderRadius: BorderRadius.circular(10), child: Row(
child: Padding( children: [
padding: EdgeInsets.fromLTRB(indent ? 20 : 4, 5, 8, 5), ClipRRect(
child: Row( borderRadius: BorderRadius.circular(6),
children: [ child: ThumbImage(t, width: 80, height: 45),
ClipRRect( ),
borderRadius: BorderRadius.circular(6), const SizedBox(width: 10),
child: ThumbImage(t, width: 80, height: 45), SizedBox(width: 20, child: Center(child: icon)),
), const SizedBox(width: 10),
const SizedBox(width: 10), Expanded(
SizedBox(width: 20, child: Center(child: icon)), child: Column(
const SizedBox(width: 10), crossAxisAlignment: CrossAxisAlignment.start,
Expanded( children: [
child: Column( Row(
crossAxisAlignment: CrossAxisAlignment.start, children: [
children: [ Flexible(
Row( child: Text(
children: [ title,
Flexible( maxLines: 1,
child: Text( overflow: TextOverflow.ellipsis,
title, style: TextStyle(
maxLines: 1, fontWeight: FontWeight.w600,
overflow: TextOverflow.ellipsis, color: playing ? scheme.primary : null,
style: TextStyle(
fontWeight: FontWeight.w600,
color: playing ? scheme.primary : null,
),
), ),
), ),
if (playing) ...[ ),
const SizedBox(width: 8), if (playing) ...[
Pill('now playing', scheme.primary), const SizedBox(width: 8),
], Pill('now playing', scheme.primary),
], ],
), ],
Text(subtitle, ),
maxLines: 1, Text(subtitle,
overflow: TextOverflow.ellipsis, maxLines: 1,
style: TextStyle(color: muted, fontSize: 12)), overflow: TextOverflow.ellipsis,
], style: TextStyle(color: muted, fontSize: 12)),
), ],
), ),
if (meta.isNotEmpty) ...[ ),
const SizedBox(width: 12), if (meta.isNotEmpty) ...[
Text(meta, style: TextStyle(color: muted, fontSize: 12)), const SizedBox(width: 12),
], Text(meta, style: TextStyle(color: muted, fontSize: 12)),
?trailing,
], ],
), ?trailing,
],
), ),
), ),
), ),
@@ -205,14 +211,12 @@ class VideoCard extends StatelessWidget {
final PlayState state; final PlayState state;
final ThumbCache thumbs; final ThumbCache thumbs;
final VoidCallback? onTap; final VoidCallback? onTap;
final bool offline;
const VideoCard({ const VideoCard({
super.key, super.key,
required this.video, required this.video,
required this.state, required this.state,
required this.thumbs, required this.thumbs,
required this.onTap, required this.onTap,
required this.offline,
}); });
@override @override
@@ -224,65 +228,62 @@ class VideoCard extends StatelessWidget {
path: video.path, path: video.path,
builder: (context, t, _) { builder: (context, t, _) {
final duration = durationText(t); final duration = durationText(t);
return _offlineTip( return InkWell(
offline, onTap: onTap,
InkWell( borderRadius: BorderRadius.circular(12),
onTap: onTap, child: Column(
borderRadius: BorderRadius.circular(12), crossAxisAlignment: CrossAxisAlignment.start,
child: Column( children: [
crossAxisAlignment: CrossAxisAlignment.start, AspectRatio(
children: [ aspectRatio: 16 / 9,
AspectRatio( child: Container(
aspectRatio: 16 / 9, decoration: BoxDecoration(
child: Container( borderRadius: BorderRadius.circular(12),
decoration: BoxDecoration( border: Border.all(
borderRadius: BorderRadius.circular(12), color: playing ? scheme.primary : Colors.transparent,
border: Border.all( width: 2,
color: playing ? scheme.primary : Colors.transparent,
width: 2,
),
), ),
child: ClipRRect( ),
borderRadius: BorderRadius.circular(10), child: ClipRRect(
child: Stack( borderRadius: BorderRadius.circular(10),
fit: StackFit.expand, child: Stack(
children: [ fit: StackFit.expand,
ThumbImage(t), children: [
if (duration != null) ThumbImage(t),
Positioned(right: 6, bottom: 6, child: _Badge(duration)), if (duration != null)
if (state != PlayState.idle) Positioned(right: 6, bottom: 6, child: _Badge(duration)),
Container( if (state != PlayState.idle)
color: Colors.black38, Container(
alignment: Alignment.center, color: Colors.black38,
child: state == PlayState.playing alignment: Alignment.center,
? const Icon(Icons.graphic_eq, color: Colors.white, size: 32) child: state == PlayState.playing
: const SizedBox.square( ? const Icon(Icons.graphic_eq, color: Colors.white, size: 32)
dimension: 28, : const SizedBox.square(
child: CircularProgressIndicator( dimension: 28,
strokeWidth: 3, color: Colors.white)), child: CircularProgressIndicator(
), strokeWidth: 3, color: Colors.white)),
], ),
), ],
), ),
), ),
), ),
const SizedBox(height: 6), ),
Flexible( const SizedBox(height: 6),
child: Text( Flexible(
cleanTitle(video.parsed), child: Text(
maxLines: 2, cleanTitle(video.parsed),
overflow: TextOverflow.ellipsis, maxLines: 2,
style: TextStyle( overflow: TextOverflow.ellipsis,
fontWeight: FontWeight.w600, style: TextStyle(
fontSize: 13, fontWeight: FontWeight.w600,
color: playing ? scheme.primary : null, fontSize: 13,
), color: playing ? scheme.primary : null,
), ),
), ),
Text(formatSize(video.size), ),
style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12)), Text(formatSize(video.size),
], style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12)),
), ],
), ),
); );
}, },
+55 -33
View File
@@ -17,7 +17,8 @@ export 'thumb.dart' show ThumbnailLoader;
// The Files source: watched folders on the left (chips on top when // The Files source: watched folders on the left (chips on top when
// narrow), the open one on the right grouped into series and loose // 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; // 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. /// Below this width the sidebar turns into chips above the list.
const filesNarrowWidth = 700.0; const filesNarrowWidth = 700.0;
@@ -26,18 +27,29 @@ const filesNarrowWidth = 700.0;
final _scans = <String, FolderScan>{}; final _scans = <String, FolderScan>{};
final _running = <String, Future<FolderScan>>{}; final _running = <String, Future<FolderScan>>{};
String? _lastOpen; 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 @visibleForTesting
void resetFilesPageSession() { void resetFilesPageSession() {
_scans.clear(); _scans.clear();
_running.clear(); _running.clear();
_expanded.clear();
_lastOpen = null; _lastOpen = null;
} }
class FilesPage extends StatefulWidget { class FilesPage extends StatefulWidget {
/// In a session: false disables playing (browsing still works). /// Nobody else watches (offline or solo): no relay notice.
final bool online; final bool solo;
/// The file will stream from this computer through the server. /// The file will stream from this computer through the server.
final bool relayed; final bool relayed;
/// Plays [path] in the session; returns an error text, or null. /// Plays [path] in the session; returns an error text, or null.
@@ -55,7 +67,7 @@ class FilesPage extends StatefulWidget {
const FilesPage({ const FilesPage({
super.key, super.key,
required this.online, this.solo = false,
required this.relayed, required this.relayed,
required this.onPlay, required this.onPlay,
this.nowPlayingPath, this.nowPlayingPath,
@@ -73,22 +85,21 @@ class FilesPage extends StatefulWidget {
/// A scan grouped for display, made once per scan. /// A scan grouped for display, made once per scan.
class _Library { class _Library {
final GroupedVideos grouped; final GroupedVideos grouped;
/// Series rows in order: a header (video null), then its episodes and
/// extras.
final List<(SeriesGroup, VideoFile?)> rows;
final int subtitleFiles; final int subtitleFiles;
_Library(FolderScan scan) _Library(FolderScan scan)
: grouped = groupBySeries(scan.videos), : grouped = groupBySeries(scan.videos),
rows = [], subtitleFiles = {for (final l in scan.subtitles.values) ...l}.length;
subtitleFiles = {for (final l in scan.subtitles.values) ...l}.length {
for (final g in grouped.series) { /// Series rows in order: a header (video null), then, for the series
rows.add((g, null)); /// [open] says, its episodes and extras.
for (final v in [...g.episodes, ...g.extras]) { List<(SeriesGroup, VideoFile?)> rows(bool Function(SeriesGroup g) open) => [
rows.add((g, v)); 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>(); final _libraries = Expando<_Library>();
@@ -107,6 +118,7 @@ class _FilesPageState extends State<FilesPage> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_showRequests.addListener(_shown);
_store.ensureLoaded().then((_) { _store.ensureLoaded().then((_) {
if (!mounted) return; if (!mounted) return;
final open = _open; 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); Future<FolderScan> _runScan(String dir) => (widget.scan ?? scanFolder)(dir);
void _select(String? dir) { void _select(String? dir) {
@@ -187,7 +207,7 @@ class _FilesPageState extends State<FilesPage> {
} }
Future<void> _play(String path) async { Future<void> _play(String path) async {
if (!widget.online || _starting != null) return; if (_starting != null) return;
setState(() { setState(() {
_starting = path; _starting = path;
_error = null; _error = null;
@@ -216,7 +236,7 @@ class _FilesPageState extends State<FilesPage> {
? PlayState.playing ? PlayState.playing
: PlayState.idle; : PlayState.idle;
VoidCallback? _tapOf(String path) => widget.online ? () => _play(path) : null; VoidCallback _tapOf(String path) => () => _play(path);
@override @override
Widget build(BuildContext context) => LayoutBuilder( Widget build(BuildContext context) => LayoutBuilder(
@@ -244,9 +264,7 @@ class _FilesPageState extends State<FilesPage> {
}, },
); );
Widget _browseButton(Widget Function(VoidCallback? onPressed) build) => widget.online Widget _browseButton(Widget Function(VoidCallback? onPressed) build) => build(_browse);
? build(_browse)
: Tooltip(message: offlinePlayHint, child: build(null));
Widget _sidebar(BuildContext context, List<String> folders, String? open) { Widget _sidebar(BuildContext context, List<String> folders, String? open) {
final scheme = Theme.of(context).colorScheme; final scheme = Theme.of(context).colorScheme;
@@ -325,7 +343,7 @@ class _FilesPageState extends State<FilesPage> {
final top = <Widget>[ final top = <Widget>[
_header(context, open), _header(context, open),
if (narrow) _chips(folders, open), if (narrow) _chips(folders, open),
if (widget.online && widget.relayed) const _RelayNotice(), if (!widget.solo && widget.relayed) const _RelayNotice(),
AnimatedSize( AnimatedSize(
duration: easeTime, duration: easeTime,
curve: ease, curve: ease,
@@ -456,7 +474,6 @@ class _FilesPageState extends State<FilesPage> {
state: _stateOf(path), state: _stateOf(path),
thumbs: _thumbs, thumbs: _thumbs,
onTap: _tapOf(path), onTap: _tapOf(path),
offline: !widget.online,
onRemove: () => _store.removeRecent(path), onRemove: () => _store.removeRecent(path),
); );
}, },
@@ -477,6 +494,8 @@ class _FilesPageState extends State<FilesPage> {
if (scan.truncated) 'stopped after $maxScannedFiles files', if (scan.truncated) 'stopped after $maxScannedFiles files',
].join(' · '); ].join(' · ');
final loose = lib.grouped.loose; final loose = lib.grouped.loose;
String seriesId(SeriesGroup g) => '$dir\n${g.key}';
final rows = lib.rows((g) => _expanded.contains(seriesId(g)));
return [ return [
SliverToBoxAdapter( SliverToBoxAdapter(
child: Wrap( 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.', '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( SliverList.builder(
itemCount: lib.rows.length, itemCount: rows.length,
itemBuilder: (context, i) { itemBuilder: (context, i) {
final (group, v) = lib.rows[i]; final (group, v) = rows[i];
if (v == null) { 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( return FileRow(
key: ValueKey(v.path), key: ValueKey(v.path),
@@ -564,7 +591,6 @@ class _FilesPageState extends State<FilesPage> {
state: _stateOf(v.path), state: _stateOf(v.path),
thumbs: _thumbs, thumbs: _thumbs,
onTap: _tapOf(v.path), onTap: _tapOf(v.path),
offline: !widget.online,
); );
}, },
), ),
@@ -600,7 +626,6 @@ class _FilesPageState extends State<FilesPage> {
state: _stateOf(v.path), state: _stateOf(v.path),
thumbs: _thumbs, thumbs: _thumbs,
onTap: _tapOf(v.path), onTap: _tapOf(v.path),
offline: !widget.online,
); );
}, },
); );
@@ -617,7 +642,6 @@ class _RecentRow extends StatefulWidget {
final PlayState state; final PlayState state;
final ThumbCache thumbs; final ThumbCache thumbs;
final VoidCallback? onTap; final VoidCallback? onTap;
final bool offline;
final VoidCallback onRemove; final VoidCallback onRemove;
const _RecentRow({ const _RecentRow({
super.key, super.key,
@@ -625,7 +649,6 @@ class _RecentRow extends StatefulWidget {
required this.state, required this.state,
required this.thumbs, required this.thumbs,
required this.onTap, required this.onTap,
required this.offline,
required this.onRemove, required this.onRemove,
}); });
@@ -658,7 +681,6 @@ class _RecentRowState extends State<_RecentRow> {
state: widget.state, state: widget.state,
thumbs: widget.thumbs, thumbs: widget.thumbs,
onTap: missing ? null : widget.onTap, onTap: missing ? null : widget.onTap,
offline: widget.offline && !missing,
indent: false, indent: false,
trailing: IconButton( trailing: IconButton(
tooltip: 'Remove from recent', tooltip: 'Remove from recent',
+2 -3
View File
@@ -8,8 +8,6 @@ import 'package:hive_ce/hive.dart';
import 'package:sync_relay_core/core.dart'; import 'package:sync_relay_core/core.dart';
import '../screens/home/picked_media.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. // Linked Jellyfin servers, one account per ServerId, in their own Hive box.
// //
@@ -215,7 +213,8 @@ class JellyfinAccounts extends ChangeNotifier {
String get deviceId => _deviceId; String get deviceId => _deviceId;
JellyfinClientInfo get clientInfo => JellyfinClientInfo( JellyfinClientInfo get clientInfo => JellyfinClientInfo(
client: appName, // The old name on purpose: Jellyfin lists devices under it.
client: 'Watch Together',
device: deviceName ?? _hostname(), device: deviceName ?? _hostname(),
deviceId: _deviceId.isEmpty ? 'unknown' : _deviceId, deviceId: _deviceId.isEmpty ? 'unknown' : _deviceId,
version: appVersion, version: appVersion,
@@ -32,16 +32,16 @@ typedef _Data = ({
}); });
/// The selected card's details, at the right of the library: banner, /// The selected card's details, at the right of the library: banner,
/// title, meta, "Put it up · S1E3" (a disabled Play with [offlineHint] /// title, meta, "Put it up · S1E3" ("Play · S1E3" when watching
/// offline), mark watched / unwatched, remove from Continue, overview and, /// [solo]), mark watched / unwatched, remove from Continue, overview and,
/// for a series or an episode, the episode list (click picks which /// for a series or an episode, the episode list (click picks which
/// episode goes up, double-click puts it up). Reloads when the /// episode goes up, double-click puts it up). Reloads when the
/// [controller]'s data version moves. /// [controller]'s data version moves.
class JellyfinDetailsPanel extends StatefulWidget { class JellyfinDetailsPanel extends StatefulWidget {
final BrowseController controller; final BrowseController controller;
final JellyfinSelection selection; final JellyfinSelection selection;
/// Null when online; else why nothing can go up. /// Nobody else watches (offline or solo): the button says Play.
final String? offlineHint; final bool solo;
/// Puts [item] (playable) up; returns an error text, or null when sent. /// Puts [item] (playable) up; returns an error text, or null when sent.
final Future<String?> Function(JellyfinSelection item) onPutUp; final Future<String?> Function(JellyfinSelection item) onPutUp;
final VoidCallback onClose; final VoidCallback onClose;
@@ -51,7 +51,7 @@ class JellyfinDetailsPanel extends StatefulWidget {
required this.selection, required this.selection,
required this.onPutUp, required this.onPutUp,
required this.onClose, required this.onClose,
this.offlineHint, this.solo = false,
}); });
@override @override
@@ -104,7 +104,7 @@ class _JellyfinDetailsPanelState extends State<JellyfinDetailsPanel> {
_cached('episodes:$seriesId:$seasonId', (c) => c.episodes(seriesId, seasonId: seasonId)); _cached('episodes:$seriesId:$seasonId', (c) => c.episodes(seriesId, seasonId: seasonId));
Future<void> _putUp(JellyfinItem target) async { Future<void> _putUp(JellyfinItem target) async {
if (_busy || widget.offlineHint != null) return; if (_busy) return;
setState(() { setState(() {
_busy = true; _busy = true;
_error = null; _error = null;
@@ -253,14 +253,8 @@ class _JellyfinDetailsPanelState extends State<JellyfinDetailsPanel> {
if (shown.communityRating != null) '★ ${shown.communityRating!.toStringAsFixed(1)}', if (shown.communityRating != null) '★ ${shown.communityRating!.toStringAsFixed(1)}',
if (!isSeries && shown.userData.played) 'Watched', if (!isSeries && shown.userData.played) 'Watched',
].join(' · '); ].join(' · ');
final hint = widget.offlineHint; final verb = widget.solo ? 'Play' : 'Put it up';
final label = playable == null final label = playable != null && isSeries ? '$verb · ${playable.episodeLabel ?? playable.name}' : verb;
? (hint == null ? 'Put it up' : 'Play')
: hint != null
? 'Play'
: isSeries
? 'Put it up · ${playable.episodeLabel ?? playable.name}'
: 'Put it up';
final inContinue = playable != null && final inContinue = playable != null &&
(playable.userData.isResumable || _c.continueKeys.contains(continueKey(playable))); (playable.userData.isResumable || _c.continueKeys.contains(continueKey(playable)));
final left = playable == null ? null : timeLeft(playable); final left = playable == null ? null : timeLeft(playable);
@@ -307,7 +301,7 @@ class _JellyfinDetailsPanelState extends State<JellyfinDetailsPanel> {
children: [ children: [
FilledButton.icon( FilledButton.icon(
key: const ValueKey('details-put-up'), key: const ValueKey('details-put-up'),
onPressed: playable == null || hint != null || _busy ? null : () => _putUp(playable), onPressed: playable == null || _busy ? null : () => _putUp(playable),
icon: _busy icon: _busy
? const SizedBox.square(dimension: 16, child: CircularProgressIndicator(strokeWidth: 2)) ? const SizedBox.square(dimension: 16, child: CircularProgressIndicator(strokeWidth: 2))
: const Icon(Icons.play_arrow), : 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), const SizedBox(height: 6),
Text( Text(
_error ?? hint!, _error!,
key: const ValueKey('details-hint'), 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) ...[ if (isSeries && playable != null) ...[
@@ -7,7 +7,7 @@ import 'browser.dart';
import 'sign_in_panel.dart'; import 'sign_in_panel.dart';
/// The Library's Jellyfin source: the library browser once a server is /// 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 { class JellyfinPickerSource extends StatefulWidget {
final JellyfinAccounts accounts; final JellyfinAccounts accounts;
final BrowseController? controller; final BrowseController? controller;
@@ -17,6 +17,8 @@ class JellyfinPickerSource extends StatefulWidget {
final VoidCallback? onOpenIntegrations; final VoidCallback? onOpenIntegrations;
final BrowseStart? start; final BrowseStart? start;
final bool libraryStrip; final bool libraryStrip;
/// Shown while no server is linked.
final WidgetBuilder? empty;
const JellyfinPickerSource({ const JellyfinPickerSource({
super.key, super.key,
this.start, this.start,
@@ -27,6 +29,7 @@ class JellyfinPickerSource extends StatefulWidget {
this.selected, this.selected,
this.onOpenIntegrations, this.onOpenIntegrations,
this.libraryStrip = true, this.libraryStrip = true,
this.empty,
}); });
@override @override
@@ -49,6 +52,7 @@ class _JellyfinPickerSourceState extends State<JellyfinPickerSource> {
if (!a.loaded) { if (!a.loaded) {
return const Center(child: CircularProgressIndicator(strokeWidth: 2)); 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); if (a.isEmpty) return _LinkFirst(accounts: a, onOpenIntegrations: widget.onOpenIntegrations);
return JellyfinBrowser( return JellyfinBrowser(
key: const ValueKey('media-jellyfin'), 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 /// 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 /// 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 /// 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. /// late joiner never saw that swap), so all clients agree.
String? autoAdvanceSwapper(List<MemberView> members, String serverId) { String? autoAdvanceSwapper(List<MemberView> members, String serverId) {
final eligible = [ final eligible = [
for (final m in members) 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; if (eligible.isEmpty) return null;
eligible.sort((a, b) => compareMemberIds(a.id, b.id)); 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), /// 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 /// 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 /// more episode, is the viewer's call). Without any opening info on media
/// longer than [fallbackMinDuration], "Skip 1:30" during the first /// longer than [fallbackMinDuration] and without chapters ([chaptered]: a
/// [fallbackWindow]. Null otherwise. /// chaptered file without an OP chapter has none on purpose), "Skip 1:30"
/// during the first [fallbackWindow]. Null otherwise.
SkipOffer? skipOfferAt({ SkipOffer? skipOfferAt({
required Duration position, required Duration position,
required Duration duration, required Duration duration,
required SkipRange? opening, required SkipRange? opening,
required SkipRange? ending, required SkipRange? ending,
bool chaptered = false,
}) { }) {
bool inside(SkipRange r) => position >= r.start && position < r.end - _skipTail; bool inside(SkipRange r) => position >= r.start && position < r.end - _skipTail;
if (opening != null && inside(opening)) return SkipOffer(SkipKind.opening, opening.end); if (opening != null && inside(opening)) return SkipOffer(SkipKind.opening, opening.end);
if (ending != null && inside(ending)) return SkipOffer(SkipKind.ending, ending.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; final to = position + fallbackSkip;
return SkipOffer(SkipKind.fixed, to < duration ? to : duration); return SkipOffer(SkipKind.fixed, to < duration ? to : duration);
} }
@@ -1,6 +1,8 @@
import 'dart:async'; import 'dart:async';
import 'dart:ffi' show Int8;
import 'dart:io'; import 'dart:io';
import 'package:ffi/ffi.dart' show StringUtf8Pointer, malloc;
import 'package:flutter/foundation.dart' show listEquals; import 'package:flutter/foundation.dart' show listEquals;
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:media_kit/media_kit.dart'; import 'package:media_kit/media_kit.dart';
@@ -16,6 +18,7 @@ import '../sync/state_diff.dart';
import '../sync/user_seek.dart'; import '../sync/user_seek.dart';
import 'frame_watchdog.dart'; import 'frame_watchdog.dart';
import 'media_marks.dart'; import 'media_marks.dart';
import 'yt_dlp_app.dart' show AppYtDlp, ytdlFormat;
/// Owns one mpv [Player] (and its [VideoController]) per session: created /// Owns one mpv [Player] (and its [VideoController]) per session: created
/// by [init] on join, released by [dispose] on leave, swapped by /// by [init] on join, released by [dispose] on leave, swapped by
@@ -32,6 +35,9 @@ class PlaybackController {
/// Back buffer (media_kit sets it to bufferSize too): enough for a short /// Back buffer (media_kit sets it to bufferSize too): enough for a short
/// seek back without holding another 256 MiB. /// seek back without holding another 256 MiB.
static const maxBackBytes = 64 * 1024 * 1024; 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; SyncPlayer? _player;
VideoController? _video; VideoController? _video;
@@ -39,7 +45,7 @@ class PlaybackController {
bool _probing = false; bool _probing = false;
final FrameWatchdog _watchdog = FrameWatchdog(); final FrameWatchdog _watchdog = FrameWatchdog();
StreamSubscription<PlayerLog>? _logSub; StreamSubscription<PlayerLog>? _logSub;
final List<StreamSubscription<Duration>> _timeSubs = []; final List<StreamSubscription<Object?>> _timeSubs = [];
String _chapterJson = ''; String _chapterJson = '';
// `glsl-shaders` as last set on the current player ('' = none). // `glsl-shaders` as last set on the current player ('' = none).
String _shaders = ''; String _shaders = '';
@@ -57,6 +63,52 @@ class PlaybackController {
/// Called once when the watchdog sees a frozen picture. /// Called once when the watchdog sees a frozen picture.
void Function()? onFrozen; void Function()? onFrozen;
/// mpv's yt-dlp hook could not open the current link (its error text).
void Function(String error)? onYtdlError;
/// The current media failed to load (the HTTP error when there was one,
/// e.g. a CDN's 403), at most once per [openUrl].
void Function(String error)? onLoadError;
// Per opened media: the last HTTP error ffmpeg logged, and whether a
// load error was reported.
String? _httpError;
bool _loadErrorSent = false;
/// mpv's log level for this app. media_kit drains mpv's log on the UI
/// thread one line at a time, so a chatty level (ffmpeg retrying a
/// refused request logs every attempt) can starve the UI: warnings and
/// errors only, except while the video output starts ([video]), when
/// the render-mode probe needs mpv's verbose GL init lines.
static const logLevel = 'warn';
/// Points mpv's yt-dlp hook at [path] (the app's managed copy) for web
/// page links, at most 1080p, once per player; its cookies
/// (`ytdl-raw-options`) whenever they changed.
Future<void> useYtDlp(String path) async {
final p = _player;
if (p == null) return;
try {
final dynamic platform = p.platform;
if (!identical(p, _ytdlSetOn)) {
await platform?.setProperty('script-opts', 'ytdl_hook-ytdl_path=$path');
await platform?.setProperty('ytdl-format', ytdlFormat);
_ytdlSetOn = p;
_ytdlRaw = '';
}
final raw = await AppYtDlp.instance.mpvRawOptions();
if (raw != _ytdlRaw && identical(p, _player)) {
await platform?.setProperty('ytdl-raw-options', raw);
_ytdlRaw = raw;
}
} catch (e) {
debugPrint('yt-dlp path not set: $e');
}
}
Player? _ytdlSetOn;
String _ytdlRaw = '';
/// Fingerprint of the picture on screen, installed by the video view /// Fingerprint of the picture on screen, installed by the video view
/// (`ShownFrameProbe.sample`). Without it the watchdog stays idle. /// (`ShownFrameProbe.sample`). Without it the watchdog stays idle.
Future<int?> Function()? shownFrame; Future<int?> Function()? shownFrame;
@@ -128,6 +180,7 @@ class PlaybackController {
final existing = _video; final existing = _video;
if (existing != null) return existing; if (existing != null) return existing;
final hwdec = _hwdecOverride; final hwdec = _hwdecOverride;
_requestLog(player, 'v'); // back to [logLevel] by _detectRenderMode
// media_kit's video output sets hwdec itself on attach ('auto' unless // media_kit's video output sets hwdec itself on attach ('auto' unless
// configured), which used to override SYNC_RELAY_HWDEC. // configured), which used to override SYNC_RELAY_HWDEC.
final video = _video = VideoController( final video = _video = VideoController(
@@ -153,8 +206,7 @@ class PlaybackController {
bufferSize: maxBytes, bufferSize: maxBytes,
libass: true, libass: true,
vo: Platform.environment['SYNC_RELAY_VO'], vo: Platform.environment['SYNC_RELAY_VO'],
// Verbose: the render-mode probe needs mpv's GL init lines. logLevel: MPVLogLevel.warn, // see [logLevel]
logLevel: MPVLogLevel.v,
)) ))
..onUserSeek = (d) => onUserSeek?.call(d); ..onUserSeek = (d) => onUserSeek?.call(d);
_shaders = ''; _shaders = '';
@@ -163,6 +215,7 @@ class PlaybackController {
_logSub = _player!.stream.log.listen(_onLog); _logSub = _player!.stream.log.listen(_onLog);
_watchMarks(_player!); _watchMarks(_player!);
await _pinBufferProps(); await _pinBufferProps();
await _pin('volume-max', '$maxVolume');
await applyShaders(); await applyShaders();
_watchdog.reset(); _watchdog.reset();
_watchTimer = Timer.periodic(const Duration(seconds: 1), (_) => _watch()); _watchTimer = Timer.periodic(const Duration(seconds: 1), (_) => _watch());
@@ -208,8 +261,29 @@ class PlaybackController {
if (hwdec != null) { if (hwdec != null) {
await _pin('hwdec', hwdec); await _pin('hwdec', hwdec);
} }
await _pin('stream-lavf-o', await _pin('stream-lavf-o', streamLavfOptions);
'reconnect_streamed=1,reconnect_on_http_error=1,reconnect_delay_max=4'); }
/// ffmpeg's HTTP options (mpv `stream-lavf-o`, comma form): reconnect
/// after a dropped connection, also on a streamed (growing) relay
/// response, and a server error (5xx) while connecting; a refused request
/// (4xx) fails at once. At most 3 tries per read, 4 s apart at most.
static const streamLavfOptions = 'reconnect_streamed=1,reconnect_on_http_error=5xx,'
'reconnect_delay_max=4,reconnect_max_retries=3';
// mpv_request_log_messages on [p]'s handle (media_kit has no API for it).
static void _requestLog(Player p, String level) {
try {
final dynamic platform = p.platform;
final name = level.toNativeUtf8();
try {
platform.mpv.mpv_request_log_messages(platform.ctx, name.cast<Int8>());
} finally {
malloc.free(name);
}
} catch (e) {
debugPrint('mpv log level $level not set: $e');
}
} }
/// Puts the saved shader choice (Settings, Ctrl+0..6, shader button) on /// Puts the saved shader choice (Settings, Ctrl+0..6, shader button) on
@@ -294,6 +368,31 @@ class PlaybackController {
// an error there while shaders are on is a shader that failed to load // an error there while shaders are on is a shader that failed to load
// or compile (mpv then renders without it). // or compile (mpv then renders without it).
void _onLog(PlayerLog log) { void _onLog(PlayerLog log) {
if (log.prefix == 'ytdl_hook' && (log.level == 'error' || log.level == 'fatal')) {
final text = log.text.trim().replaceFirst(RegExp(r'^ERROR:\s*'), '');
if (text.isNotEmpty) {
_loadErrorSent = true;
onYtdlError?.call(text);
}
return;
}
// ffmpeg logs through one mpv instance per process (the first one
// alive), so the HTTP status is a bonus, not a given.
if (log.prefix == 'ffmpeg') {
final http = RegExp(r'HTTP error \d{3}.*').firstMatch(log.text);
if (http != null) _httpError = http[0]!.trim();
return;
}
// Nothing playable opened (a track of mpv's yt-dlp EDL refused, ...).
if (!_loadErrorSent &&
duration.peek() == Duration.zero &&
((log.prefix == 'cplayer' && log.level == 'fatal') ||
(log.prefix == 'demux' && log.level == 'error'))) {
_loadErrorSent = true;
final http = _httpError;
onLoadError?.call(http == null ? 'the video stream could not be opened' : 'the video server answered $http');
return;
}
if (log.prefix != 'libmpv_render') return; if (log.prefix != 'libmpv_render') return;
if (log.text.contains('suspected software renderer')) { if (log.text.contains('suspected software renderer')) {
_setRenderMode(RenderMode.softwareGl); _setRenderMode(RenderMode.softwareGl);
@@ -324,10 +423,12 @@ class PlaybackController {
Future<void> _detectRenderMode(Player p, VideoController video) async { Future<void> _detectRenderMode(Player p, VideoController video) async {
try { try {
await video.platform.future; await video.platform.future;
await Future.delayed(const Duration(milliseconds: 1500));
} catch (_) { } catch (_) {
return; return;
} finally {
if (identical(p, _player)) _requestLog(p, logLevel);
} }
await Future.delayed(const Duration(milliseconds: 1500));
if (!identical(p, _player) || renderMode.peek() != RenderMode.unknown) { if (!identical(p, _player) || renderMode.peek() != RenderMode.unknown) {
return; return;
} }
@@ -411,6 +512,8 @@ class PlaybackController {
if (!_ready) return; if (!_ready) return;
try { try {
_chapterJson = ''; _chapterJson = '';
_httpError = null;
_loadErrorSent = false;
chapters.value = const []; chapters.value = const [];
tracks.value = const []; tracks.value = const [];
buffered.value = Duration.zero; buffered.value = Duration.zero;
@@ -426,9 +529,18 @@ class PlaybackController {
// track-list observed in mpv (read back as JSON). // track-list observed in mpv (read back as JSON).
void _watchMarks(SyncPlayer p) { void _watchMarks(SyncPlayer p) {
_timeSubs.add(p.stream.position.listen((d) { _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; final ms = d.inMilliseconds;
position.value = Duration(milliseconds: ms - ms % 250); 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) { _timeSubs.add(p.stream.buffer.listen((d) {
final ms = d.inMilliseconds; final ms = d.inMilliseconds;
buffered.value = Duration(milliseconds: ms - ms % 250); 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:path_provider/path_provider.dart';
import 'package:sync_relay_core/core.dart'; import 'package:sync_relay_core/core.dart';
import '../update/updater.dart' show appVersion;
// AniList airing schedule (public GraphQL, no login): every episode airing // AniList airing schedule (public GraphQL, no login): every episode airing
// in a time range, with covers, banners and popularity; and a title search // in a time range, with covers, banners and popularity; and a title search
+29 -13
View File
@@ -5,7 +5,6 @@ import 'dart:io';
import 'package:sync_relay_core/core.dart'; import 'package:sync_relay_core/core.dart';
import '../jellyfin/accounts.dart'; import '../jellyfin/accounts.dart';
import '../update/updater.dart' show appVersion;
// AnimeFlow app API: what the media server tracks, has, downloads and was // AnimeFlow app API: what the media server tracks, has, downloads and was
// asked for. Authenticated with the user's own Jellyfin access token // asked for. Authenticated with the user's own Jellyfin access token
@@ -148,13 +147,17 @@ class AnimeFlowLibraryItem {
final String name; final String name;
/// Set when AnimeFlow tracks it. /// Set when AnimeFlow tracks it.
final int? seriesId; 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; 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({ const AnimeFlowLibraryItem({
required this.jellyfinSeriesId, required this.jellyfinSeriesId,
required this.name, required this.name,
this.seriesId, this.seriesId,
this.anilistIds = const {}, this.anilistIds = const {},
this.anilistMap = const [],
}); });
static AnimeFlowLibraryItem fromJson(Map j) => AnimeFlowLibraryItem( static AnimeFlowLibraryItem fromJson(Map j) => AnimeFlowLibraryItem(
@@ -165,9 +168,30 @@ class AnimeFlowLibraryItem {
for (final e in ((j['anilist_ids'] as Map?) ?? const {}).entries) 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)!, 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 { class AnimeFlowEpisode {
/// 0 = specials. /// 0 = specials.
final int season; final int season;
@@ -206,7 +230,9 @@ class AnimeFlowEpisode {
absolute: _int(j['absolute']), absolute: _int(j['absolute']),
title: _str(j['episode_title']), title: _str(j['episode_title']),
airDate: _time(j['air_date']), 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']), anilistEpisode: _int(j['anilist_episode']),
status: EpisodeStatus.parse(j['status']), status: EpisodeStatus.parse(j['status']),
progress: (j['progress'] as num?)?.toDouble(), progress: (j['progress'] as num?)?.toDouble(),
@@ -222,7 +248,6 @@ class AnimeFlowScheduleItem {
final int? anidbId; final int? anidbId;
final int? tvdbId; final int? tvdbId;
final String? jellyfinSeriesId; final String? jellyfinSeriesId;
final bool followed;
const AnimeFlowScheduleItem({ const AnimeFlowScheduleItem({
required this.episode, required this.episode,
@@ -231,7 +256,6 @@ class AnimeFlowScheduleItem {
this.anidbId, this.anidbId,
this.tvdbId, this.tvdbId,
this.jellyfinSeriesId, this.jellyfinSeriesId,
this.followed = false,
}); });
static AnimeFlowScheduleItem fromJson(Map j) => AnimeFlowScheduleItem( static AnimeFlowScheduleItem fromJson(Map j) => AnimeFlowScheduleItem(
@@ -241,7 +265,6 @@ class AnimeFlowScheduleItem {
anidbId: _int(j['anidb_id']), anidbId: _int(j['anidb_id']),
tvdbId: _int(j['tvdb_id']), tvdbId: _int(j['tvdb_id']),
jellyfinSeriesId: _str(j['jellyfin_series_id']), jellyfinSeriesId: _str(j['jellyfin_series_id']),
followed: j['followed'] == true,
); );
} }
@@ -270,7 +293,6 @@ class AnimeFlowSeries {
final String? jellyfinSeriesId; final String? jellyfinSeriesId;
final SeriesState state; final SeriesState state;
final bool monitored; final bool monitored;
final bool followed;
final int episodesTotal; final int episodesTotal;
final int episodesAvailable; final int episodesAvailable;
final DateTime? nextAirDate; final DateTime? nextAirDate;
@@ -286,7 +308,6 @@ class AnimeFlowSeries {
this.jellyfinSeriesId, this.jellyfinSeriesId,
this.state = SeriesState.releasing, this.state = SeriesState.releasing,
this.monitored = false, this.monitored = false,
this.followed = false,
this.episodesTotal = 0, this.episodesTotal = 0,
this.episodesAvailable = 0, this.episodesAvailable = 0,
this.nextAirDate, this.nextAirDate,
@@ -302,7 +323,6 @@ class AnimeFlowSeries {
jellyfinSeriesId: _str(j['jellyfin_series_id']), jellyfinSeriesId: _str(j['jellyfin_series_id']),
state: SeriesState.parse(j['state']), state: SeriesState.parse(j['state']),
monitored: j['monitored'] == true, monitored: j['monitored'] == true,
followed: j['followed'] == true,
episodesTotal: _int(j['episodes_total']) ?? 0, episodesTotal: _int(j['episodes_total']) ?? 0,
episodesAvailable: _int(j['episodes_available']) ?? 0, episodesAvailable: _int(j['episodes_available']) ?? 0,
nextAirDate: _time(j['next_air_date']), nextAirDate: _time(j['next_air_date']),
@@ -658,10 +678,6 @@ class AnimeFlowClient {
return AnimeFlowSeries.fromJson(j is Map ? j : const {}); return AnimeFlowSeries.fromJson(j is Map ? j : const {});
} }
/// Follow (or unfollow) a series [view].
Future<void> setFollowed(int seriesId, bool followed) =>
_call(followed ? 'PUT' : 'DELETE', '/series/$seriesId/follow');
/// Titles that can be requested [request]. /// Titles that can be requested [request].
Future<List<AnimeFlowSearchHit>> search(String q) async => [ Future<List<AnimeFlowSearchHit>> search(String q) async => [
for (final h in _items(await _call('GET', '/search', query: {'q': q}))) AnimeFlowSearchHit.fromJson(h), for (final h in _items(await _call('GET', '/search', query: {'q': q}))) AnimeFlowSearchHit.fromJson(h),
+100 -23
View File
@@ -17,9 +17,16 @@ enum ScheduleStatus {
unmonitored, unmonitored,
/// AnimeFlow does not track this series (or is unreachable). /// AnimeFlow does not track this series (or is unreachable).
notTracked, notTracked,
/// Not tracked by AnimeFlow, but the series is on Jellyfin (`/library`): /// Not tracked by AnimeFlow, the series is on Jellyfin (`/library`) and
/// the episode may be there; Put it up finds out. /// so is the episode ([confirmOnJellyfin]; [matchSchedule] alone sets
inLibrary; /// it from the series).
inLibrary,
/// Not tracked, the series is on Jellyfin, the episode aired but is not
/// there yet.
notOnJellyfin,
/// Not tracked, the series is on Jellyfin, the episode aired; whether
/// it is there is unknown (no linked account, lookup failed).
aired;
static ScheduleStatus of(EpisodeStatus s) => switch (s) { static ScheduleStatus of(EpisodeStatus s) => switch (s) {
EpisodeStatus.available => available, EpisodeStatus.available => available,
@@ -41,11 +48,14 @@ class ScheduleEntry {
final String? reason; final String? reason;
/// AnimeFlow tracks the series. /// AnimeFlow tracks the series.
final bool tracked; final bool tracked;
final bool followed; /// AnimeFlow's series id (requests).
/// AnimeFlow's series id (follow, requests).
final int? seriesId; final int? seriesId;
/// The AnimeFlow season of the matched episode (requests). /// The AnimeFlow season of the matched episode (requests); untracked
/// on Jellyfin: the TVDB season of the AniList entry.
final int? season; final int? season;
/// Untracked on Jellyfin: the TVDB episode number in [season] (AniList
/// episode + the `anilist_map` run's offset).
final int? jellyfinEpisode;
/// Play target once available. /// Play target once available.
final String? jellyfinSeriesId; final String? jellyfinSeriesId;
/// The series is on Jellyfin (tracked or not). /// The series is on Jellyfin (tracked or not).
@@ -57,12 +67,24 @@ class ScheduleEntry {
this.progress, this.progress,
this.reason, this.reason,
this.tracked = false, this.tracked = false,
this.followed = false,
this.seriesId, this.seriesId,
this.season, this.season,
this.jellyfinEpisode,
this.jellyfinSeriesId, this.jellyfinSeriesId,
}); });
ScheduleEntry withStatus(ScheduleStatus s) => ScheduleEntry(
airing: airing,
status: s,
progress: progress,
reason: reason,
tracked: tracked,
seriesId: seriesId,
season: season,
jellyfinEpisode: jellyfinEpisode,
jellyfinSeriesId: jellyfinSeriesId,
);
@override @override
String toString() => 'ScheduleEntry(${airing.anilistId}#${airing.episode} $status)'; String toString() => 'ScheduleEntry(${airing.anilistId}#${airing.episode} $status)';
} }
@@ -81,8 +103,7 @@ String _day(DateTime d) {
class _SeriesRef { class _SeriesRef {
final int seriesId; final int seriesId;
final String? jellyfinSeriesId; final String? jellyfinSeriesId;
bool followed; _SeriesRef(this.seriesId, this.jellyfinSeriesId);
_SeriesRef(this.seriesId, this.jellyfinSeriesId, this.followed);
} }
/// Merges AniList [airings] with AnimeFlow's [schedule] rows and tracked /// Merges AniList [airings] with AnimeFlow's [schedule] rows and tracked
@@ -98,30 +119,40 @@ List<ScheduleEntry> matchSchedule(
DateTime? now, DateTime? now,
}) { }) {
final at = now ?? DateTime.now(); final at = now ?? DateTime.now();
// AniList id -> (Jellyfin series, its TVDB season) from /library. // AniList id -> (Jellyfin series, its TVDB season, TVDB - AniList
final lib = <int, (String, int)>{}; // episode offset) from /library.
final lib = <int, (String, int, int)>{};
for (final item in library) { for (final item in library) {
if (item.jellyfinSeriesId.isEmpty) continue; if (item.jellyfinSeriesId.isEmpty) continue;
for (final r in item.anilistMap) {
lib[r.anilistId] ??= (item.jellyfinSeriesId, r.season, r.offset);
}
for (final e in item.anilistIds.entries) { for (final e in item.anilistIds.entries) {
lib[e.value] ??= (item.jellyfinSeriesId, e.key); lib[e.value] ??= (item.jellyfinSeriesId, e.key, 0);
} }
} }
final refs = <int, _SeriesRef>{}; final refs = <int, _SeriesRef>{};
for (final s in series) { for (final s in series) {
final id = s.anilistId; final id = s.anilistId;
if (id != null) refs[id] ??= _SeriesRef(s.seriesId, s.jellyfinSeriesId, s.followed); if (id != null) refs[id] ??= _SeriesRef(s.seriesId, s.jellyfinSeriesId);
} }
// AnimeFlow's AniList episode number wins outright: TVDB numbering runs
// on across split entries (JoJo S6E4 is "2nd & 3rd STAGE" ep 3), so a TVDB
// or absolute number, or an air day, is only a guess for AniList entries
// AnimeFlow gives no AniList numbers for.
final byAni = <String, AnimeFlowScheduleItem>{};
final byEp = <String, AnimeFlowScheduleItem>{}; final byEp = <String, AnimeFlowScheduleItem>{};
final byAbs = <String, AnimeFlowScheduleItem>{}; final byAbs = <String, AnimeFlowScheduleItem>{};
final byDay = <String, AnimeFlowScheduleItem>{}; final byDay = <String, AnimeFlowScheduleItem>{};
final numbered = <int>{};
for (final it in schedule) { for (final it in schedule) {
final e = it.episode, id = e.anilistId; final e = it.episode, id = e.anilistId;
if (id == null) continue; if (id == null) continue;
final ref = refs[id] ??= _SeriesRef(it.seriesId, it.jellyfinSeriesId, it.followed); refs[id] ??= _SeriesRef(it.seriesId, it.jellyfinSeriesId);
if (it.followed) ref.followed = true; if (e.anilistEpisode != null) {
// AnimeFlow's AniList episode number first: TVDB numbering can run on numbered.add(id);
// across split cours. byAni.putIfAbsent('$id#${e.anilistEpisode}', () => it);
if (e.anilistEpisode != null) byEp.putIfAbsent('$id#${e.anilistEpisode}', () => it); }
byEp.putIfAbsent('$id#${e.episode}', () => it); byEp.putIfAbsent('$id#${e.episode}', () => it);
if (e.absolute != null) byAbs.putIfAbsent('$id#${e.absolute}', () => it); if (e.absolute != null) byAbs.putIfAbsent('$id#${e.absolute}', () => it);
if (e.airDate != null) byDay.putIfAbsent('$id@${_day(e.airDate!)}', () => it); if (e.airDate != null) byDay.putIfAbsent('$id@${_day(e.airDate!)}', () => it);
@@ -131,7 +162,9 @@ List<ScheduleEntry> matchSchedule(
() { () {
final id = a.anilistId; final id = a.anilistId;
final ref = refs[id]; final ref = refs[id];
final it = byEp['$id#${a.episode}'] ?? byAbs['$id#${a.episode}'] ?? byDay['$id@${_day(a.airingAt)}']; final it = numbered.contains(id)
? byAni['$id#${a.episode}']
: byEp['$id#${a.episode}'] ?? byAbs['$id#${a.episode}'] ?? byDay['$id@${_day(a.airingAt)}'];
if (it != null) { if (it != null) {
final e = it.episode; final e = it.episode;
return ScheduleEntry( return ScheduleEntry(
@@ -140,7 +173,6 @@ List<ScheduleEntry> matchSchedule(
progress: e.status == EpisodeStatus.downloading ? e.progress : null, progress: e.status == EpisodeStatus.downloading ? e.progress : null,
reason: e.status == EpisodeStatus.failed ? e.reason : null, reason: e.status == EpisodeStatus.failed ? e.reason : null,
tracked: true, tracked: true,
followed: it.followed || (ref?.followed ?? false),
seriesId: it.seriesId, seriesId: it.seriesId,
season: e.season, season: e.season,
jellyfinSeriesId: it.jellyfinSeriesId ?? ref?.jellyfinSeriesId ?? lib[id]?.$1, jellyfinSeriesId: it.jellyfinSeriesId ?? ref?.jellyfinSeriesId ?? lib[id]?.$1,
@@ -153,6 +185,7 @@ List<ScheduleEntry> matchSchedule(
airing: a, airing: a,
status: a.airingAt.isAfter(at) ? ScheduleStatus.upcoming : ScheduleStatus.inLibrary, status: a.airingAt.isAfter(at) ? ScheduleStatus.upcoming : ScheduleStatus.inLibrary,
season: onJf.$2, season: onJf.$2,
jellyfinEpisode: a.episode + onJf.$3,
jellyfinSeriesId: onJf.$1, jellyfinSeriesId: onJf.$1,
); );
} }
@@ -160,7 +193,6 @@ List<ScheduleEntry> matchSchedule(
airing: a, airing: a,
status: a.airingAt.isAfter(at) ? ScheduleStatus.upcoming : ScheduleStatus.missing, status: a.airingAt.isAfter(at) ? ScheduleStatus.upcoming : ScheduleStatus.missing,
tracked: true, tracked: true,
followed: ref.followed,
seriesId: ref.seriesId, seriesId: ref.seriesId,
jellyfinSeriesId: ref.jellyfinSeriesId ?? lib[id]?.$1, jellyfinSeriesId: ref.jellyfinSeriesId ?? lib[id]?.$1,
); );
@@ -168,6 +200,27 @@ List<ScheduleEntry> matchSchedule(
]; ];
} }
/// Checks [matchSchedule]'s [ScheduleStatus.inLibrary] entries (series
/// on Jellyfin) against the episodes Jellyfin has: [episodes] maps a
/// Jellyfin series id to its (season, episode) numbers. Found stays
/// inLibrary, absent becomes [ScheduleStatus.notOnJellyfin], a series
/// missing from [episodes] (not looked up) [ScheduleStatus.aired].
List<ScheduleEntry> confirmOnJellyfin(List<ScheduleEntry> entries, Map<String, Set<(int, int)>> episodes) => [
for (final e in entries)
if (e.status != ScheduleStatus.inLibrary)
e
else
switch (episodes[e.jellyfinSeriesId]) {
null => e.withStatus(ScheduleStatus.aired),
final have when have.contains((e.season, e.jellyfinEpisode)) => e,
_ => e.withStatus(ScheduleStatus.notOnJellyfin),
},
];
/// The (season, episode) numbers Jellyfin has of a series; null when it
/// cannot tell (no linked account for that server).
typedef JellyfinEpisodeLookup = Future<Set<(int, int)>?> Function(String jellyfinSeriesId);
/// One loaded window. Errors are display text; [entries] is empty only /// One loaded window. Errors are display text; [entries] is empty only
/// when AniList failed with nothing cached. /// when AniList failed with nothing cached.
class ScheduleSnapshot { class ScheduleSnapshot {
@@ -203,12 +256,15 @@ class ScheduleSnapshot {
/// Loads AniList and AnimeFlow for a window and merges them. /// Loads AniList and AnimeFlow for a window and merges them.
class ScheduleRepository { class ScheduleRepository {
ScheduleRepository({required this.aniList, this.animeFlow, DateTime Function()? now}) ScheduleRepository({required this.aniList, this.animeFlow, this.jellyfinEpisodes, DateTime Function()? now})
: _now = now ?? DateTime.now; : _now = now ?? DateTime.now;
final AniListClient aniList; final AniListClient aniList;
/// Null = not configured (AniList only). /// Null = not configured (AniList only).
final AnimeFlowClient? animeFlow; final AnimeFlowClient? animeFlow;
/// Confirms "on Jellyfin" per episode ([confirmOnJellyfin]); null =
/// never confirmed (aired entries read [ScheduleStatus.aired]).
final JellyfinEpisodeLookup? jellyfinEpisodes;
final DateTime Function() _now; final DateTime Function() _now;
/// Last week's Monday 00:00 local .. 4 weeks later. /// Last week's Monday 00:00 local .. 4 weeks later.
@@ -233,7 +289,8 @@ class ScheduleRepository {
final flow = await flowF; final flow = await flowF;
final airings = [...ani.airings]..sort((a, b) => a.airingAt.compareTo(b.airingAt)); final airings = [...ani.airings]..sort((a, b) => a.airingAt.compareTo(b.airingAt));
final entries = flow.error == null final entries = flow.error == null
? matchSchedule(airings, schedule: flow.schedule, series: flow.series, library: flow.library, now: now) ? await _confirm(
matchSchedule(airings, schedule: flow.schedule, series: flow.series, library: flow.library, now: now))
: [for (final a in airings) untrackedEntry(a, now)]; : [for (final a in airings) untrackedEntry(a, now)];
return ScheduleSnapshot( return ScheduleSnapshot(
from: start, from: start,
@@ -246,6 +303,26 @@ class ScheduleRepository {
); );
} }
/// Looks up the episodes of every series an inLibrary entry is on, in
/// parallel; a failed or impossible lookup leaves that series out.
Future<List<ScheduleEntry>> _confirm(List<ScheduleEntry> entries) async {
final lookup = jellyfinEpisodes;
final ids = {
for (final e in entries)
if (e.status == ScheduleStatus.inLibrary && e.jellyfinSeriesId != null) e.jellyfinSeriesId!,
};
final found = <String, Set<(int, int)>>{};
if (lookup != null) {
await Future.wait([
for (final id in ids)
lookup(id).then((eps) {
if (eps != null) found[id] = eps;
}, onError: (_) {}),
]);
}
return confirmOnJellyfin(entries, found);
}
Future<({AnimeFlowMe? me, List<AnimeFlowScheduleItem> schedule, List<AnimeFlowSeries> series, List<AnimeFlowLibraryItem> library, Object? error})> Future<({AnimeFlowMe? me, List<AnimeFlowScheduleItem> schedule, List<AnimeFlowSeries> series, List<AnimeFlowLibraryItem> library, Object? error})>
_animeFlow(DateTime from, DateTime to, bool force) async { _animeFlow(DateTime from, DateTime to, bool force) async {
final c = animeFlow; final c = animeFlow;
@@ -78,7 +78,7 @@ Future<({int season, int episode})> flowNumbering(AnimeFlowClient? client, Sched
} }
} }
if (ep != null) return (season: ep.season, episode: ep.episode); if (ep != null) return (season: ep.season, episode: ep.episode);
return (season: e.season ?? 1, episode: e.airing.episode); return (season: e.season ?? 1, episode: e.jellyfinEpisode ?? e.airing.episode);
} }
/// The AniDB id of an untracked AniList entry, from AnimeFlow's search: /// The AniDB id of an untracked AniList entry, from AnimeFlow's search:
@@ -112,6 +112,25 @@ JellyfinAccount? jellyfinAccountFor(JellyfinAccounts accounts, AnimeFlowClient?
return accounts.defaultAccount; return accounts.defaultAccount;
} }
/// The (season, episode) numbers of the episodes Jellyfin has of
/// [jellyfinSeriesId], seen by the account AnimeFlow's ids belong to
/// ([jellyfinAccountFor]); null without a linked account. One request per
/// series, memoised a few minutes ([JellyfinAccounts.cached]).
Future<Set<(int, int)>?> jellyfinEpisodeNumbers(
JellyfinAccounts accounts,
AnimeFlowClient? flow,
String jellyfinSeriesId,
) async {
await accounts.ensureLoaded(validate: false);
final account = jellyfinAccountFor(accounts, flow);
final client = account == null ? null : accounts.clientFor(account.serverId);
if (account == null || client == null) return null;
return accounts.cached(account.serverId, 'schedule-episodes:$jellyfinSeriesId', () async => {
for (final x in await client.episodes(jellyfinSeriesId))
if (x.parentIndexNumber != null && x.indexNumber != null) (x.parentIndexNumber!, x.indexNumber!),
});
}
/// The Jellyfin episode of an available [e] as a media pick. Throws /// The Jellyfin episode of an available [e] as a media pick. Throws
/// [ScheduleActionException] (or a client error) when it cannot be found. /// [ScheduleActionException] (or a client error) when it cannot be found.
Future<JellyfinMedia> resolveJellyfinEpisode(JellyfinAccounts accounts, AnimeFlowClient? flow, ScheduleEntry e) async { Future<JellyfinMedia> resolveJellyfinEpisode(JellyfinAccounts accounts, AnimeFlowClient? flow, ScheduleEntry e) async {
@@ -13,8 +13,8 @@ enum CardAction {
none, none,
putUp, putUp,
/// Available, but not in a server: shown disabled with a hint. /// Available, nobody else watching (offline or solo): plays it here.
playOffline, play,
request, request,
requestSeries, requestSeries,
requested, requested,
@@ -49,6 +49,8 @@ String countdownText(DateTime at, DateTime now) {
ScheduleStatus.unmonitored => ('not monitored', Colors.grey, Icons.visibility_off_outlined), ScheduleStatus.unmonitored => ('not monitored', Colors.grey, Icons.visibility_off_outlined),
ScheduleStatus.notTracked => ('not in AnimeFlow', Colors.grey, Icons.public), ScheduleStatus.notTracked => ('not in AnimeFlow', Colors.grey, Icons.public),
ScheduleStatus.inLibrary => ('on Jellyfin', Colors.greenAccent, Icons.video_library_outlined), ScheduleStatus.inLibrary => ('on Jellyfin', Colors.greenAccent, Icons.video_library_outlined),
ScheduleStatus.notOnJellyfin => ('aired · not on Jellyfin yet', Colors.grey, Icons.hourglass_empty),
ScheduleStatus.aired => ('aired', Colors.grey, Icons.done),
}; };
class DayColumn extends StatelessWidget { class DayColumn extends StatelessWidget {
@@ -125,7 +127,6 @@ class AiringCard extends StatelessWidget {
final ScheduleEntry entry; final ScheduleEntry entry;
final DateTime now; final DateTime now;
final bool compact; final bool compact;
final bool followed;
final CardAction action; final CardAction action;
/// Why [action] is disabled (no request scope, not on Jellyfin, ...); /// Why [action] is disabled (no request scope, not on Jellyfin, ...);
@@ -136,20 +137,15 @@ class AiringCard extends StatelessWidget {
final bool busy; final bool busy;
final VoidCallback onAction; final VoidCallback onAction;
/// Null hides the bookmark (untracked series).
final VoidCallback? onFollow;
const AiringCard({ const AiringCard({
super.key, super.key,
required this.entry, required this.entry,
required this.now, required this.now,
required this.compact, required this.compact,
required this.followed,
required this.action, required this.action,
required this.onAction, required this.onAction,
this.disabledHint, this.disabledHint,
this.busy = false, this.busy = false,
this.onFollow,
}); });
String get _id => '${entry.airing.anilistId}-${entry.airing.episode}'; String get _id => '${entry.airing.anilistId}-${entry.airing.episode}';
@@ -162,15 +158,10 @@ class AiringCard extends StatelessWidget {
final spinner = const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2)); final spinner = const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2));
Widget? b = switch (action) { Widget? b = switch (action) {
CardAction.none => null, CardAction.none => null,
CardAction.putUp => FilledButton.tonalIcon( CardAction.putUp || CardAction.play => FilledButton.tonalIcon(
onPressed: enabled ? onAction : null, onPressed: enabled ? onAction : null,
icon: busy ? spinner : const Icon(Icons.play_arrow, size: 18), icon: busy ? spinner : const Icon(Icons.play_arrow, size: 18),
label: const Text('Put it up'), label: Text(action == CardAction.play ? 'Play' : 'Put it up'),
),
CardAction.playOffline => FilledButton.tonalIcon(
onPressed: null,
icon: const Icon(Icons.play_arrow, size: 18),
label: const Text('Play'),
), ),
CardAction.request || CardAction.requestSeries => OutlinedButton.icon( CardAction.request || CardAction.requestSeries => OutlinedButton.icon(
onPressed: enabled ? onAction : null, onPressed: enabled ? onAction : null,
@@ -184,22 +175,6 @@ class AiringCard extends StatelessWidget {
return disabledHint == null ? b : Tooltip(message: disabledHint!, child: b); return disabledHint == null ? b : Tooltip(message: disabledHint!, child: b);
} }
Widget? _follow(BuildContext context, double size) {
final f = onFollow;
if (f == null) return null;
return IconButton(
key: ValueKey('follow-$_id'),
onPressed: f,
tooltip: followed ? 'Stop following' : 'Follow',
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
constraints: BoxConstraints.tightFor(width: size + 10, height: size + 10),
iconSize: size,
color: followed ? Theme.of(context).colorScheme.primary : null,
icon: Icon(followed ? Icons.bookmark : Icons.bookmark_outline),
);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme; final scheme = Theme.of(context).colorScheme;
@@ -216,7 +191,7 @@ class AiringCard extends StatelessWidget {
), ),
), ),
); );
final tap = action == CardAction.putUp && disabledHint == null && !busy ? onAction : null; final tap = (action == CardAction.putUp || action == CardAction.play) && disabledHint == null && !busy ? onAction : null;
if (compact) { if (compact) {
final requestable = action == CardAction.request || action == CardAction.requestSeries; final requestable = action == CardAction.request || action == CardAction.requestSeries;
return Material( return Material(
@@ -249,7 +224,6 @@ class AiringCard extends StatelessWidget {
], ],
), ),
), ),
if (followed && onFollow == null) Icon(Icons.bookmark, size: 14, color: scheme.primary),
if (requestable) if (requestable)
Tooltip( Tooltip(
message: disabledHint ?? (action == CardAction.request ? 'Request' : 'Request series'), message: disabledHint ?? (action == CardAction.request ? 'Request' : 'Request series'),
@@ -273,7 +247,6 @@ class AiringCard extends StatelessWidget {
); );
} }
final button = _button(context); final button = _button(context);
final follow = _follow(context, 16);
// The cover poster leads: it is what people recognise at a glance. // The cover poster leads: it is what people recognise at a glance.
return Material( return Material(
key: ValueKey('card-$_id'), key: ValueKey('card-$_id'),
@@ -343,25 +316,11 @@ class AiringCard extends StatelessWidget {
style: const TextStyle(fontWeight: FontWeight.w600, height: 1.25), style: const TextStyle(fontWeight: FontWeight.w600, height: 1.25),
), ),
const SizedBox(height: 2), const SizedBox(height: 2),
Row( Text(
children: [ 'Episode ${a.episode}',
Flexible( maxLines: 1,
child: Text( overflow: TextOverflow.ellipsis,
'Episode ${a.episode}', style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12),
),
),
const SizedBox(width: 2),
if (follow != null)
follow
else if (followed)
Padding(
padding: const EdgeInsets.only(left: 4),
child: Icon(Icons.bookmark, size: 14, color: scheme.primary),
),
],
), ),
const Spacer(), const Spacer(),
Row( Row(
@@ -18,11 +18,12 @@ import 'schedule_card.dart';
// one thing to do about it (put it up, request it). Refreshes every // one thing to do about it (put it up, request it). Refreshes every
// minute while visible; ETags keep that cheap. // minute while visible; ETags keep that cheap.
enum ScheduleFilter { jellyfin, following, all } enum ScheduleFilter { jellyfin, all }
class SchedulePage extends StatefulWidget { class SchedulePage extends StatefulWidget {
/// In a server: available episodes can be put up. /// Nobody else watches (offline or solo): available episodes say Play
final bool online; /// (offline that starts a solo session).
final bool solo;
/// Puts a Jellyfin episode up in the server; returns an error to show. /// Puts a Jellyfin episode up in the server; returns an error to show.
final Future<String?> Function(JellyfinMedia pick) onPutUp; final Future<String?> Function(JellyfinMedia pick) onPutUp;
@@ -36,7 +37,7 @@ class SchedulePage extends StatefulWidget {
const SchedulePage({ const SchedulePage({
super.key, super.key,
required this.online, this.solo = false,
required this.onPutUp, required this.onPutUp,
required this.onToast, required this.onToast,
this.repository, this.repository,
@@ -74,8 +75,6 @@ class _SchedulePageState extends State<SchedulePage> {
/// The sheet stays built while it slides out. /// The sheet stays built while it slides out.
bool _sheetBuilt = false; bool _sheetBuilt = false;
/// Follow toggles not yet confirmed by a reload (series id -> followed).
final _followOverride = <int, bool>{};
/// Requested in this run: "anilistId#episode" or "series:anilistId". /// Requested in this run: "anilistId#episode" or "series:anilistId".
final _requested = <String>{}; final _requested = <String>{};
@@ -96,18 +95,22 @@ class _SchedulePageState extends State<SchedulePage> {
void initState() { void initState() {
super.initState(); super.initState();
_ownsRepo = widget.repository == null; _ownsRepo = widget.repository == null;
_repo = _repo = widget.repository ?? _ownRepo();
widget.repository ??
ScheduleRepository(
aniList: AniListClient(),
animeFlow: AnimeFlowClient.fromAccounts(JellyfinAccounts.instance),
);
_load(); _load();
_timer = Timer.periodic(SchedulePage.refreshEvery, (_) { _timer = Timer.periodic(SchedulePage.refreshEvery, (_) {
if (mounted && !_refreshing && TickerMode.valuesOf(context).enabled) _load(); if (mounted && !_refreshing && TickerMode.valuesOf(context).enabled) _load();
}); });
} }
ScheduleRepository _ownRepo() {
final flow = AnimeFlowClient.fromAccounts(_accounts);
return ScheduleRepository(
aniList: AniListClient(),
animeFlow: flow,
jellyfinEpisodes: (id) => jellyfinEpisodeNumbers(_accounts, flow, id),
);
}
@override @override
void dispose() { void dispose() {
_timer?.cancel(); _timer?.cancel();
@@ -185,11 +188,8 @@ class _SchedulePageState extends State<SchedulePage> {
animate ? _scroll.animateTo(x, duration: easeTime, curve: ease) : _scroll.jumpTo(x); animate ? _scroll.animateTo(x, duration: easeTime, curve: ease) : _scroll.jumpTo(x);
} }
bool _followed(ScheduleEntry e) => (e.seriesId == null ? null : _followOverride[e.seriesId]) ?? e.followed;
bool _shows(ScheduleEntry e) => switch (_filter) { bool _shows(ScheduleEntry e) => switch (_filter) {
ScheduleFilter.jellyfin => e.tracked || e.onJellyfin, ScheduleFilter.jellyfin => e.tracked || e.onJellyfin,
ScheduleFilter.following => _followed(e),
ScheduleFilter.all => _niche || e.tracked || e.airing.popularity >= SchedulePage.nicheBelow, ScheduleFilter.all => _niche || e.tracked || e.airing.popularity >= SchedulePage.nicheBelow,
}; };
@@ -215,9 +215,9 @@ class _SchedulePageState extends State<SchedulePage> {
(CardAction, String?) _actionFor(ScheduleEntry e) { (CardAction, String?) _actionFor(ScheduleEntry e) {
if (e.status == ScheduleStatus.available || e.status == ScheduleStatus.inLibrary) { if (e.status == ScheduleStatus.available || e.status == ScheduleStatus.inLibrary) {
if (!widget.online) return (CardAction.playOffline, 'Join or host a server to put it up'); final put = widget.solo ? CardAction.play : CardAction.putUp;
if (e.jellyfinSeriesId == null) return (CardAction.putUp, 'Not linked to a Jellyfin series yet'); if (e.jellyfinSeriesId == null) return (put, 'Not linked to a Jellyfin series yet');
return (CardAction.putUp, null); return (put, null);
} }
final me = _me; final me = _me;
final CardAction kind; final CardAction kind;
@@ -242,6 +242,7 @@ class _SchedulePageState extends State<SchedulePage> {
Future<void> _onAction(ScheduleEntry e, CardAction action) async { Future<void> _onAction(ScheduleEntry e, CardAction action) async {
switch (action) { switch (action) {
case CardAction.putUp: case CardAction.putUp:
case CardAction.play:
return _putUp(e); return _putUp(e);
case CardAction.request: case CardAction.request:
case CardAction.requestSeries: case CardAction.requestSeries:
@@ -335,21 +336,6 @@ class _SchedulePageState extends State<SchedulePage> {
}, },
); );
Future<void> _toggleFollow(ScheduleEntry e) async {
final c = _flow, id = e.seriesId;
if (c == null || id == null) return;
final next = !_followed(e);
setState(() => _followOverride[id] = next);
try {
await c.setFollowed(id, next);
await _load();
} catch (err) {
_toast(friendlyError(err));
} finally {
if (mounted) setState(() => _followOverride.remove(id));
}
}
Future<void> _decide(AnimeFlowRequest r, {required bool approve}) async { Future<void> _decide(AnimeFlowRequest r, {required bool approve}) async {
final c = _flow; final c = _flow;
if (c == null || _reqBusy.contains(r.id)) return; if (c == null || _reqBusy.contains(r.id)) return;
@@ -387,11 +373,6 @@ class _SchedulePageState extends State<SchedulePage> {
icon: Icon(Icons.video_library_outlined), icon: Icon(Icons.video_library_outlined),
label: Text('On Jellyfin'), label: Text('On Jellyfin'),
), ),
ButtonSegment(
value: ScheduleFilter.following,
icon: Icon(Icons.bookmark_outline),
label: Text('Following'),
),
ButtonSegment(value: ScheduleFilter.all, icon: Icon(Icons.public), label: Text('All airing')), ButtonSegment(value: ScheduleFilter.all, icon: Icon(Icons.public), label: Text('All airing')),
], ],
selected: {_filter}, selected: {_filter},
@@ -501,7 +482,6 @@ class _SchedulePageState extends State<SchedulePage> {
(byDay[DateTime(l.year, l.month, l.day)] ??= []).add(e); (byDay[DateTime(l.year, l.month, l.day)] ??= []).add(e);
} }
return HScroll( return HScroll(
wheel: false,
controller: _scroll, controller: _scroll,
builder: (c) => ListView.separated( builder: (c) => ListView.separated(
key: const ValueKey('schedule-board'), key: const ValueKey('schedule-board'),
@@ -531,17 +511,15 @@ class _SchedulePageState extends State<SchedulePage> {
Widget _card(ScheduleEntry e, DateTime now) { Widget _card(ScheduleEntry e, DateTime now) {
final (action, hint) = _actionFor(e); 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( return AiringCard(
entry: e, entry: e,
now: now, now: now,
compact: _compact, compact: _compact,
followed: _followed(e),
action: action, action: action,
disabledHint: hint, disabledHint: hint,
busy: busy, busy: busy,
onAction: () => _onAction(e, action), onAction: () => _onAction(e, action),
onFollow: e.tracked && e.seriesId != null && _flow != null ? () => _toggleFollow(e) : null,
); );
} }
@@ -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,
),
),
],
),
),
);
}
}
File diff suppressed because it is too large. Load diff
@@ -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),
],
);
},
);
}
}
File diff suppressed because it is too large. Load diff
@@ -2,9 +2,9 @@ import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:sync_relay_core/core.dart';
import '../../update/update_controller.dart'; import '../../update/update_controller.dart';
import '../../update/updater.dart';
import 'settings_page.dart'; import 'settings_page.dart';
import 'ui.dart'; import 'ui.dart';
import 'update_indicator.dart'; import 'update_indicator.dart';
@@ -23,10 +23,6 @@ class ConnectFlow extends ChangeNotifier {
int _attempt = 0; int _attempt = 0;
bool _disposed = false; bool _disposed = false;
/// Set when an attempt ends in a kick, so the shell shows the removal
/// dialog.
void Function(String reason)? onKicked;
@override @override
void dispose() { void dispose() {
_disposed = true; _disposed = true;
@@ -101,6 +97,7 @@ class ConnectFlow extends ChangeNotifier {
_notify(); _notify();
return; return;
} }
final fromSolo = mode == ConnectMode.host && _session.solo.value;
try { try {
if (mode == ConnectMode.host) { if (mode == ConnectMode.host) {
await _session.host(params, form); await _session.host(params, form);
@@ -110,20 +107,19 @@ class ConnectFlow extends ChangeNotifier {
} catch (e) { } catch (e) {
if (myAttempt != _attempt) return; if (myAttempt != _attempt) return;
busy = null; busy = null;
final kick = _session.coordinator.kickReason.peek(); errors[mode] = CardError('Could not connect', '$e');
if (kick != null && kick.isNotEmpty) {
onKicked?.call(kick);
} else {
errors[mode] = CardError('Could not connect', '$e');
}
_notify(); _notify();
return; return;
} }
if (myAttempt != _attempt) { if (myAttempt != _attempt) {
// Cancelled after it got through: back out. // Cancelled after it got through: back out (an Invite: back to solo).
try { try {
await _session.leave(); if (fromSolo) {
await _session.stopServer(); await _session.cancelInvite();
} else {
await _session.leave();
await _session.stopServer();
}
} catch (_) {} } catch (_) {}
return; return;
} }
@@ -16,7 +16,10 @@ enum LinkRelay { direct, server, me }
final class LinkMedia extends PickedMedia { final class LinkMedia extends PickedMedia {
final String url; final String url;
final LinkRelay relay; final LinkRelay relay;
const LinkMedia(this.url, {this.relay = LinkRelay.direct}); /// Put it up even though this computer's probe found a problem
/// (`LinkProbe.problem`): the server or a friend may still open it.
final bool anyway;
const LinkMedia(this.url, {this.relay = LinkRelay.direct, this.anyway = false});
@override @override
String get label { String get label {
@@ -7,11 +7,12 @@ import '../../update/update_controller.dart';
import 'ui.dart'; import 'ui.dart';
import 'update_indicator.dart'; import 'update_indicator.dart';
/// Player keys, as handled by the session player's key handler (while /// Session keys: Space and Tab anywhere in a server (`AppKeys`), the
/// the player fills the window). /// rest by the session player's key handler (while it fills the window).
const sessionShortcuts = [ const sessionShortcuts = [
('Space', 'Toggle ready'), ('Space', 'Toggle ready'),
('← / →', 'Seek back / forward 10 s'), ('← / →', 'Seek back / forward 10 s'),
('↑ / ↓', 'Volume up / down (up to 150 %)'),
('Shift+→', 'Skip 1:30 (for everyone)'), ('Shift+→', 'Skip 1:30 (for everyone)'),
('PgUp / PgDn', 'Previous / next chapter'), ('PgUp / PgDn', 'Previous / next chapter'),
('F', 'Toggle fullscreen'), ('F', 'Toggle fullscreen'),
@@ -21,7 +22,7 @@ const sessionShortcuts = [
('< or Shift+P', 'Put up the previous Jellyfin episode'), ('< 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+1…6', 'Shaders: Anime4K mode A, B, C, A+A, B+B, C+A'),
('Ctrl+0', 'Shaders off'), ('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 /// One titled card of the Settings tab (rounded Material, so list tiles
+1 -1
View File
@@ -1,7 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
/// Product name (working title). Change it here only. /// 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'; const repoUrl = 'https://git.senkochan.moe/Foxyz/sync_replay_project';
+2 -1
View File
@@ -51,7 +51,8 @@ Future<ArtBytes> jellyfinArt(JellyfinClient client, String itemId,
Future<ArtBytes?> aniListArt(AniListClient aniList, String name, Future<ArtBytes?> aniListArt(AniListClient aniList, String name,
{Future<Uint8List?> Function(Uri url, Map<String, String> headers, int maxBytes) fetch = fetchImage}) async { {Future<Uint8List?> Function(Uri url, Map<String, String> headers, int maxBytes) fetch = fetchImage}) async {
final series = parseReleaseName(name).series.trim(); 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); final match = await aniList.searchMedia(series);
if (match == null) return null; if (match == null) return null;
Future<Uint8List?> get(String? url, int maxBytes) async { Future<Uint8List?> get(String? url, int maxBytes) async {
@@ -1,6 +1,7 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'dart:math';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:signals/signals_flutter.dart'; import 'package:signals/signals_flutter.dart';
@@ -12,6 +13,8 @@ import '../jellyfin/session_media.dart';
import '../jellyfin/track_prefs.dart'; import '../jellyfin/track_prefs.dart';
import '../playback/media_marks.dart'; import '../playback/media_marks.dart';
import '../playback/playback_controller.dart'; import '../playback/playback_controller.dart';
import '../platform/attention.dart';
import '../playback/yt_dlp_app.dart';
import '../schedule/anilist.dart'; import '../schedule/anilist.dart';
import '../screens/home/picked_media.dart'; import '../screens/home/picked_media.dart';
import '../sfx/sfx.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 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. /// `media.relay` of a state, or null when the media is not relayed.
MediaRelay? stateMediaRelay(StateBroadcast state) { MediaRelay? stateMediaRelay(StateBroadcast state) {
@@ -121,7 +135,10 @@ final class _FileOrigin extends _RelayOrigin {
final class _LinkOrigin extends _RelayOrigin { final class _LinkOrigin extends _RelayOrigin {
final String url; 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 /// 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); /// Set by the hosting app: makes a local file the hosted one (File swap);
/// null when this app is not the host. /// null when this app is not the host.
Future<({String name, int size})?> Function(String path)? hostFile; 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(); final SyncClient sync = SyncClient();
/// Member pictures by `avatarHash`, fetched from the server. /// Member pictures by `avatarHash`, fetched from the server.
late final AvatarCache avatars = AvatarCache(sync); late final AvatarCache avatars = AvatarCache(sync);
@@ -162,8 +181,11 @@ class SessionCoordinator {
final feedLog = signal<List<FeedMessage>>([]); final feedLog = signal<List<FeedMessage>>([]);
final toasts = signal<List<ToastItem>>([]); final toasts = signal<List<ToastItem>>([]);
final reconnecting = signal(false); final reconnecting = signal(false);
final kickReason = signal<String?>(null);
final starving = signal<String?>(null); final starving = signal<String?>(null);
/// Why this player cannot open the current media, in plain words
/// ([playErrorText]); empty while it can. It rides in `report.error`, so
/// the server stops waiting for this member and the others see it.
final playerError = signal('');
/// The current media's Jellyfin ref (null for links and files). /// The current media's Jellyfin ref (null for links and files).
final currentRef = signal<JellyfinRef?>(null); final currentRef = signal<JellyfinRef?>(null);
@@ -184,7 +206,7 @@ class SessionCoordinator {
final autoAdvance = signal<AutoAdvance?>(null); final autoAdvance = signal<AutoAdvance?>(null);
/// The skip pill for the current position ([skipOfferAt]: Skip OP, /// 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]. /// is open or at `finished`. Press it with [skip].
late final Computed<SkipOffer?> skipOffer = computed(() { late final Computed<SkipOffer?> skipOffer = computed(() {
final r = serverState.value; final r = serverState.value;
@@ -195,6 +217,7 @@ class SessionCoordinator {
duration: duration, duration: duration,
opening: playback.opening.value, opening: playback.opening.value,
ending: playback.ending.value, ending: playback.ending.value,
chaptered: playback.chapters.value.isNotEmpty,
); );
}); });
@@ -207,14 +230,23 @@ class SessionCoordinator {
return null; 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; Timer? _ticker;
StreamSubscription? _stateSub; StreamSubscription? _stateSub;
StreamSubscription? _feedSub; StreamSubscription? _feedSub;
StreamSubscription? _kickSub; StreamSubscription? _nudgeSub;
StreamSubscription? _errSub; StreamSubscription? _errSub;
StreamSubscription? _statusSub; StreamSubscription? _statusSub;
bool _manualClose = false; 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 _playerReady = false;
bool _wasPlayAllowed = false; bool _wasPlayAllowed = false;
// Last ready flag the server reported for self; restored after a // Last ready flag the server reported for self; restored after a
@@ -223,6 +255,16 @@ class SessionCoordinator {
// Set after a reconnect until the server echoes the restored ready, so // Set after a reconnect until the server echoes the restored ready, so
// the fresh member's ready=false doesn't overwrite [_lastReady]. // the fresh member's ready=false doesn't overwrite [_lastReady].
bool _restoringReady = false; 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. // Bumped by every connect/leave/cancel/retry: stale attempts no-op.
int _epoch = 0; int _epoch = 0;
Timer? _reconnectTimer; Timer? _reconnectTimer;
@@ -242,6 +284,10 @@ class SessionCoordinator {
int _relaySourceMediaId = -1; int _relaySourceMediaId = -1;
/// This member serves the current media's bytes through the server. /// This member serves the current media's bytes through the server.
final relaying = signal<bool>(false); 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 // Server seek applied to the player (event id and when), checked once it
// should have landed. // should have landed.
int? _pendingSeekEvent; int? _pendingSeekEvent;
@@ -260,6 +306,8 @@ class SessionCoordinator {
int _toastId = 0; int _toastId = 0;
ConnectParams? _params; ConnectParams? _params;
bool _everConnected = false; bool _everConnected = false;
/// The connection-lost sound played; the reconnected one answers it once.
bool _lostSounded = false;
Map<String, String>? _openHeaders; Map<String, String>? _openHeaders;
// Jellyfin: the resolved ref, the mediaId it belongs to, a generation // Jellyfin: the resolved ref, the mediaId it belongs to, a generation
@@ -274,10 +322,17 @@ class SessionCoordinator {
int _advanceDoneFor = -1; int _advanceDoneFor = -1;
String _blockedKey = ''; String _blockedKey = '';
bool _jellyfinListening = false; bool _jellyfinListening = false;
// The mediaId [playerError] belongs to, and the player's duration when
// it came: a new duration means the media opened after all.
int _playerErrorFor = -1;
Duration _playerErrorDuration = Duration.zero;
// Members' last `error`, by id, so a new one toasts once.
Map<String, String> _memberErrors = const {};
SessionCoordinator( SessionCoordinator(
{this.sfx = const NoopSfxService(), JellyfinAccounts? jellyfin, TrackPrefs? trackPrefs}) {this.sfx = const NoopSfxService(), JellyfinAccounts? jellyfin, TrackPrefs? trackPrefs, AppYtDlp? ytDlp})
: jellyfin = jellyfin ?? JellyfinAccounts.instance, : ytDlp = ytDlp ?? AppYtDlp.instance,
jellyfin = jellyfin ?? JellyfinAccounts.instance,
trackPrefs = trackPrefs ?? TrackPrefs(); trackPrefs = trackPrefs ?? TrackPrefs();
/// Player lifetime = one session: created here on connect, disposed by /// Player lifetime = one session: created here on connect, disposed by
@@ -287,6 +342,8 @@ class SessionCoordinator {
if (_playerReady) return; if (_playerReady) return;
try { try {
playback.onFrozen = () => unawaited(reloadVideo(auto: true)); playback.onFrozen = () => unawaited(reloadVideo(auto: true));
playback.onYtdlError = _setPlayerError;
playback.onLoadError = _setPlayerError;
playback.onUserSeek = (d) => _userSeek(d.inMicroseconds / 1e6); playback.onUserSeek = (d) => _userSeek(d.inMicroseconds / 1e6);
await playback.init(); await playback.init();
_playerReady = true; _playerReady = true;
@@ -298,6 +355,35 @@ class SessionCoordinator {
} }
} }
void _setPlayerError(String raw) {
debugPrint('player cannot open media $_mediaId: $raw');
_playerErrorFor = _mediaId;
_playerErrorDuration = playback.duration.peek();
playerError.value = playErrorText(raw);
}
/// Clears [playerError] once the media opened (a new duration) or the
/// server moved to another media.
void _checkPlayerError(int mediaId) {
if (playerError.peek().isEmpty) return;
final d = playback.duration.peek();
if (_playerErrorFor != mediaId || (d > Duration.zero && d != _playerErrorDuration)) {
playerError.value = '';
}
}
/// A member's new `error` toasts once (the pill is hidden while playing).
void _toastMemberErrors(StateBroadcast r) {
final now = {for (final m in r.members) if (m.error.isNotEmpty) m.id: m.error};
for (final m in r.members) {
final e = now[m.id];
if (e != null && m.id != sync.memberId && _memberErrors[m.id] != e) {
_pushToast("${m.name} can't play this: $e", actor: m.name);
}
}
_memberErrors = now;
}
bool _reloadingVideo = false; bool _reloadingVideo = false;
/// "Reload video" (button, Ctrl+R, frozen-picture watchdog): fresh /// "Reload video" (button, Ctrl+R, frozen-picture watchdog): fresh
@@ -346,20 +432,26 @@ class SessionCoordinator {
if (epoch != _epoch) throw const ConnectCancelled(); if (epoch != _epoch) throw const ConnectCancelled();
_params = params; _params = params;
_manualClose = false; _manualClose = false;
kickReason.value = null;
_wsHost = params.host; _wsHost = params.host;
_wsPort = params.port; _wsPort = params.port;
diff.reset(); diff.reset();
_mediaId = -1; _mediaId = -1;
_openUrl = null; _openUrl = null;
playerError.value = '';
_memberErrors = const {};
_lastReady = false; _lastReady = false;
_restoringReady = false; _restoringReady = false;
_lastOut = false;
_joinBackAt = null;
_seenMediaId = -1;
_wasPlayAllowed = false; _wasPlayAllowed = false;
_everConnected = false; _everConnected = false;
_lostSounded = false;
_resumeToken = ''; _resumeToken = '';
_seekGate.reset(); _seekGate.reset();
_clearPendingSeek(); _clearPendingSeek();
_lastEventId = -1; _lastEventId = -1;
_lastPick = null;
_resetJellyfin(); _resetJellyfin();
_jfToastShown = false; _jfToastShown = false;
if (!_jellyfinListening) { if (!_jellyfinListening) {
@@ -415,14 +507,15 @@ class SessionCoordinator {
void _subscribe() { void _subscribe() {
_stateSub ??= sync.states.listen(_onState); _stateSub ??= sync.states.listen(_onState);
_feedSub ??= sync.feeds.listen(_onFeed); _feedSub ??= sync.feeds.listen(_onFeed);
_kickSub ??= sync.kicked.listen(_onKicked); _nudgeSub ??= sync.nudges.listen(_onNudge);
_errSub ??= sync.errors.listen((msg) { _errSub ??= sync.errors.listen((msg) {
health.value = HealthStatus(HealthSeverity.warn, msg); health.value = HealthStatus(HealthSeverity.warn, msg);
}); });
_statusSub ??= sync.status.listen((s) { _statusSub ??= sync.status.listen((s) {
if (s == SyncStatus.closed && !_manualClose && kickReason.peek() == null) { if (s == SyncStatus.closed && !_manualClose) {
health.value = const HealthStatus( health.value = const HealthStatus(
HealthSeverity.warn, 'connection lost — retrying…'); HealthSeverity.warn, 'connection lost — retrying…');
_soundLost();
_scheduleReconnect(); _scheduleReconnect();
} }
}); });
@@ -436,22 +529,44 @@ class SessionCoordinator {
diff.reset(); diff.reset();
_opening = true; _opening = true;
try { try {
await _prepareYtDlp(url);
await playback.openUrl(url, headers: headers); await playback.openUrl(url, headers: headers);
health.value = health.value =
const HealthStatus(HealthSeverity.ok, 'connected'); const HealthStatus(HealthSeverity.ok, 'connected');
return true; return true;
} catch (e) { } catch (e) {
debugPrint('open media failed: $e'); debugPrint('open media failed: $e');
health.value = _setPlayerError('$e');
HealthStatus(HealthSeverity.error, 'cannot open media: $e');
return false; return false;
} finally { } finally {
_opening = false; _opening = false;
} }
} }
/// A web link (not this server's): mpv's yt-dlp hook gets the app's
/// managed yt-dlp, downloaded first if needed; a media file name does
/// not wait for that.
Future<void> _prepareYtDlp(String url) async {
final uri = Uri.tryParse(url);
if (uri == null || !(uri.isScheme('http') || uri.isScheme('https'))) return;
if (uri.host == _wsHost && uri.port == _wsPort) return;
try {
final y = await ytDlp.ytDlp;
final ready = y.ensure();
final path = hasMediaExtension(uri)
? y.path
: await ready.timeout(const Duration(seconds: 90), onTimeout: () => null);
if (path != null) await playback.useYtDlp(path);
} catch (e) {
debugPrint('yt-dlp not ready: $e');
}
}
bool _relayWaitShown = false; 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 /// Relayed through a member whose channel is down: say who everyone is
/// waiting for (the buffer still plays what it has). /// waiting for (the buffer still plays what it has).
void _relayStatus(StateBroadcast r) { void _relayStatus(StateBroadcast r) {
@@ -473,21 +588,24 @@ class SessionCoordinator {
} }
} }
String _relayMediaUrlFor(String name, int mediaId) => /// This viewer's `/r/` URL; with tracks, a pieces track's playlist, or
'http://$_wsHost:$_wsPort/r/default/${sync.token}/$mediaId/${Uri.encodeComponent(name)}'; /// 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. /// 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 // Back after a reconnect on the same media: it is still serving (and
// redials by itself). // redials by itself).
if (_relaySource != null && _relaySourceMediaId == mediaId) { if (_relaySource != null && _relaySourceMediaId == mediaId) {
unawaited(bytes.close()); unawaited(media.close());
return; return;
} }
_stopRelaySource(); _stopRelaySource();
final src = RelaySource( 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; _relaySource = src;
_relaySourceMediaId = mediaId; _relaySourceMediaId = mediaId;
relaying.value = true; relaying.value = true;
@@ -536,6 +654,8 @@ class SessionCoordinator {
void _onState(StateBroadcast r) { void _onState(StateBroadcast r) {
_lastStateAt = DateTime.now().toUtc(); _lastStateAt = DateTime.now().toUtc();
serverState.value = r; serverState.value = r;
_toastMemberErrors(r);
_checkPlayerError(r.mediaId);
_maybeSendArt(r); _maybeSendArt(r);
_queuedState = r; _queuedState = r;
if (_applying) return; if (_applying) return;
@@ -559,6 +679,12 @@ class SessionCoordinator {
Future<void> _applyState(StateBroadcast r) async { Future<void> _applyState(StateBroadcast r) async {
_trackSelfReady(r); _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); _seekGate.observe(r, sync.memberId);
final newEvent = r.eventId != _lastEventId; final newEvent = r.eventId != _lastEventId;
_lastEventId = r.eventId; _lastEventId = r.eventId;
@@ -594,12 +720,22 @@ class SessionCoordinator {
memberRelayUrl: relay != null && !mine ? _relayMediaUrlFor(name, r.mediaId) : null); memberRelayUrl: relay != null && !mine ? _relayMediaUrlFor(name, r.mediaId) : null);
} else if (origin is _FileOrigin) { } else if (origin is _FileOrigin) {
await _openMedia(origin.path); 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) { } else if (origin is _LinkOrigin) {
await _openMedia(origin.url); 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) { } 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 { } else {
var url = stateMediaUrl(r); var url = stateMediaUrl(r);
if ((url == null || url.isEmpty) && sync.token.isNotEmpty) { if ((url == null || url.isEmpty) && sync.token.isNotEmpty) {
@@ -615,6 +751,10 @@ class SessionCoordinator {
} }
_seekGate.reset(); _seekGate.reset();
_clearPendingSeek(); _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); _jellyfinTransport(r);
_relayStatus(r); _relayStatus(r);
@@ -664,12 +804,49 @@ class SessionCoordinator {
void _trackSelfReady(StateBroadcast r) { void _trackSelfReady(StateBroadcast r) {
final self = selfMember.peek(); final self = selfMember.peek();
if (self == null) return; 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 (_restoringReady) {
if (self.ready) _restoringReady = false; if (self.ready) _restoringReady = false;
} else { } else {
_lastReady = self.ready; _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) { void _updateStarving(StateBroadcast r, double bufferedAhead) {
@@ -682,7 +859,7 @@ class SessionCoordinator {
return; return;
} }
for (final m in r.members) { for (final m in r.members) {
if (m.inSet && m.buffered < RelayConstants.minBufferSecs) { if (m.inSet && m.buffered < r.bufferNeed) {
starving.value = starving.value =
'waiting for ${m.name} — ${m.buffered.toStringAsFixed(1)} s buffered'; 'waiting for ${m.name} — ${m.buffered.toStringAsFixed(1)} s buffered';
return; return;
@@ -690,14 +867,15 @@ class SessionCoordinator {
} }
final self = selfMember.peek(); final self = selfMember.peek();
if (self != null && self.ready) { 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)) .where((m) => m.id != self.id && !m.stale && !(m.inSet && m.ready))
.map((m) => m.name) .map((m) => m.name)
.toList(); .toList();
if (waiters.isNotEmpty) { if (waiters.isNotEmpty) {
final readyCount = r.members.where((m) => m.ready).length; final readyCount = present.where((m) => m.ready).length;
starving.value = starving.value =
'waiting for ${waiters.join(', ')} ($readyCount/${r.members.length} ready)'; 'waiting for ${waiters.join(', ')} ($readyCount/${present.length} ready)';
return; return;
} }
} }
@@ -726,8 +904,16 @@ class SessionCoordinator {
ToastItem(id: _toastId, text: text, at: DateTime.now().toUtc(), actor: f.actor) 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); 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; var readyIndex = 0;
if (f.action == 'ready') { if (f.action == 'ready') {
readyIndex = serverState.peek()?.members.where((m) => m.ready).length ?? 0; readyIndex = serverState.peek()?.members.where((m) => m.ready).length ?? 0;
@@ -742,8 +928,6 @@ class SessionCoordinator {
return '${f.actor} joined'; return '${f.actor} joined';
case 'leave': case 'leave':
return '${f.actor} left'; return '${f.actor} left';
case 'kick':
return '${f.actor} kicked ${f.target ?? ''}';
case 'swap': case 'swap':
return '${f.actor} changed media'; return '${f.actor} changed media';
default: default:
@@ -751,26 +935,13 @@ class SessionCoordinator {
} }
} }
Future<void> _onKicked(String reason) async { /// Someone poked this member: sound (even when its switch is off; the
kickReason.value = reason; /// volume still applies), a toast, and the window asks for attention.
_manualClose = true; void _onNudge(NudgeNotice n) {
_epoch++; final name = n.name.isEmpty ? 'Someone' : n.name;
_reconnectTimer?.cancel(); _pushToast('$name nudged you', actor: n.name.isEmpty ? null : n.name);
_reconnectTimer = null; unawaited(sfx.play(const SfxCue(SfxKind.nudge)));
reconnecting.value = false; unawaited(requestAttention());
_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');
} }
void _startTicker() { void _startTicker() {
@@ -786,20 +957,25 @@ class SessionCoordinator {
if (sync.token.isNotEmpty) sync.sendPing(); if (sync.token.isNotEmpty) sync.sendPing();
if (sync.token.isNotEmpty && r != null && _playerReady) { if (sync.token.isNotEmpty && r != null && _playerReady) {
// Duration only once the open media is the server's current one. // Duration only once the open media is the server's current one.
final known = !_opening && _openUrl != null && _mediaId == r.mediaId; // Sitting out: still a report (it keeps this member fresh), empty.
final known = !_lastOut && !_opening && _openUrl != null && _mediaId == r.mediaId;
_checkPlayerError(r.mediaId);
final error = playerError.peek();
sync.sendReport( sync.sendReport(
position: playback.positionSecs, position: _lastOut ? 0 : playback.positionSecs,
buffered: playback.bufferedSecsAhead, buffered: _lastOut ? 0 : playback.bufferedSecsAhead,
eventId: r.eventId, eventId: r.eventId,
duration: known ? playback.durationSecs : 0, duration: known ? playback.durationSecs : 0,
mediaId: known ? r.mediaId : 0, mediaId: known ? r.mediaId : 0,
jf: _jfAccessFor(r), jf: _jfAccessFor(r),
error: known && _playerErrorFor == r.mediaId ? error : '',
); );
_checkSeekLanded(r); if (!_lastOut) {
unawaited(_tickJellyfin(r)); _checkSeekLanded(r);
unawaited(_tickJellyfin(r));
}
} }
if (!_manualClose && if (!_manualClose &&
kickReason.peek() == null &&
_lastStateAt != null && _lastStateAt != null &&
now.difference(_lastStateAt!).inSeconds >= 5) { now.difference(_lastStateAt!).inSeconds >= 5) {
if (serverState.peek() != null) { if (serverState.peek() != null) {
@@ -813,15 +989,20 @@ class SessionCoordinator {
if (!reconnecting.peek()) { if (!reconnecting.peek()) {
health.value = const HealthStatus( health.value = const HealthStatus(
HealthSeverity.warn, 'connection lost — retrying…'); HealthSeverity.warn, 'connection lost — retrying…');
_soundLost();
_scheduleReconnect(); _scheduleReconnect();
} }
} }
// No warning when there is nothing to lose (unready), and none while // No warning when there is nothing to lose (unready), and none while
// PLAYING: the ready flag gates nothing then, and watching counts as // PLAYING: the ready flag gates nothing then, and watching counts as
// activity so a pause after a long watch starts a fresh idle clock. // activity so a pause after a long watch starts a fresh idle clock.
final event = _idle.tick(now, if (r != null) _readyAfterJoinBack(r, now);
playing: serverState.peek()?.transport == 'playing', final sitOutWhenIdle = AppSettings.instance.idleSitsOut.peek();
selfReady: selfMember.peek()?.ready ?? false); final event = solo
? IdleEvent.none
: _idle.tick(now,
playing: serverState.peek()?.transport == 'playing',
selfReady: selfMember.peek()?.ready ?? false);
switch (event) { switch (event) {
case IdleEvent.warn: case IdleEvent.warn:
_toastId++; _toastId++;
@@ -829,18 +1010,46 @@ class SessionCoordinator {
...toasts.peek(), ...toasts.peek(),
ToastItem( ToastItem(
id: _toastId, 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) at: now)
]; ];
unawaited(sfx.play(const SfxCue(SfxKind.warn))); unawaited(sfx.play(const SfxCue(SfxKind.warn)));
case IdleEvent.unready: case IdleEvent.unready:
_lastReady = false; _lastReady = false;
sync.sendReady(false, source: 'auto'); if (sitOutWhenIdle) {
sitOut();
} else {
sync.sendReady(false, source: 'auto');
}
case IdleEvent.none: case IdleEvent.none:
break; 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() { void _clearPendingSeek() {
_pendingSeekEvent = null; _pendingSeekEvent = null;
_pendingSeekAt = null; _pendingSeekAt = null;
@@ -907,6 +1116,10 @@ class SessionCoordinator {
void toggleReady() { void toggleReady() {
final self = selfMember.peek(); final self = selfMember.peek();
if (self?.out ?? false) {
joinBack();
return;
}
// Ready but out of the set (dropped out while stale) gates like // Ready but out of the set (dropped out while stale) gates like
// unready, so Space puts self back in instead of unreadying. // unready, so Space puts self back in instead of unreadying.
final next = !((self?.ready ?? false) && (self?.inSet ?? false)); final next = !((self?.ready ?? false) && (self?.inSet ?? false));
@@ -994,12 +1207,43 @@ class SessionCoordinator {
sync.sendProfile(name: name, avatar: avatar); 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(); 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) { 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); _pendingArt = (media: media, after: serverState.peek()?.mediaId ?? 0);
sync.sendSwap(media); sync.sendSwap(media);
} }
@@ -1021,6 +1265,12 @@ class SessionCoordinator {
final ArtBytes? bytes; final ArtBytes? bytes;
if (ref != null && client != null) { if (ref != null && client != null) {
bytes = await jellyfinArt(client, ref.itemId); 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 { } else {
final name = ref == null ? media.name : jellyfinTitle(ref.title, ref.subtitle).$1; final name = ref == null ? media.name : jellyfinTitle(ref.title, ref.subtitle).$1;
bytes = await aniListArt(_aniList ??= AniListClient(), name); bytes = await aniListArt(_aniList ??= AniListClient(), name);
@@ -1039,46 +1289,91 @@ class SessionCoordinator {
/// A pasted link: a recognised Jellyfin item link goes up as a ref /// A pasted link: a recognised Jellyfin item link goes up as a ref
/// (see [mediaForLink]) that this member relays for viewers without an /// (see [mediaForLink]) that this member relays for viewers without an
/// account (with the link's key, which stays here); anything else as the /// account (with the link's key, which stays here); anything else as the
/// link, or relayed per [relay]. Returns an error text, or null when sent. /// link, or relayed per [relay]. A link this computer cannot open (its
Future<String?> swapLink(String url, {LinkRelay relay = LinkRelay.direct}) async { /// probe's [LinkProbe.problem], or unreachable) is refused with the
/// reason in plain words unless [anyway]. Returns an error text, or null
/// when sent.
Future<String?> swapLink(String url, {LinkRelay relay = LinkRelay.direct, bool anyway = false}) async {
markInput(); markInput();
_lastPick = LinkMedia(url, relay: relay, anyway: anyway);
final media = await mediaForLink(jellyfin, url); final media = await mediaForLink(jellyfin, url);
if (media.jellyfin != null) { if (media.jellyfin != null) {
_pendingOrigin = _JellyfinOrigin(url); _pendingOrigin = _JellyfinOrigin(url);
_swap(media.copyWith(relay: const MediaRelay())); _swap(media.copyWith(relay: const MediaRelay()));
return null; return null;
} }
LinkProbe? probe;
String? probeError;
try {
probe = await ytDlp.probe(url).timeout(const Duration(seconds: 45));
} catch (e) {
probeError = '$e';
}
final problem = probe == null ? playErrorText(probeError ?? '') : probe.problem;
if (problem != null && !anyway) return problem;
// A page is named by its title, never its URL tail ("1", "watch").
final uri = Uri.tryParse(url) ?? Uri();
final name = probe?.title ?? (probe != null || hasMediaExtension(uri) || uri.host.isEmpty ? media.name : uri.host);
if (relay == LinkRelay.direct) { if (relay == LinkRelay.direct) {
_swap(media); _swap(HelloMedia(name: name, size: 0, duration: 0, url: url));
return null; return null;
} }
// Relayed: it must be a single file the relay can range-read (not a
// page like YouTube, which only the player can resolve).
final probe = HttpRelayBytes(Uri.parse(url));
final int size;
try {
size = await probe.size().timeout(const Duration(seconds: 15));
} catch (e) {
return relay == LinkRelay.me
? 'cannot pass this link on: $e'
: 'cannot check this link: $e';
} finally {
await probe.close();
}
if (relay == LinkRelay.me) { if (relay == LinkRelay.me) {
_pendingOrigin = _LinkOrigin(url); // This computer reads it: a file, or a page's formats (one file,
_swap(HelloMedia(name: media.name, size: size, duration: 0, relay: const MediaRelay())); // separate video and audio tracks, or segmented playlists).
} else { if (probe == null) return problem;
final relayProblem = probe.relayProblem;
if (relayProblem != null) return relayProblem;
final y = await ytDlp.ytDlp;
final check = LinkRelayMedia(uri, ytDlp: y, probe: probe);
var size = 0;
final List<RelayTrackSpec> specs;
try {
final tracks = await check.open().timeout(const Duration(seconds: 15));
for (final t in tracks) {
// A playlist is read (DRM and live refused) but has no size.
final pieces = t.pieces;
if (pieces != null) {
await pieces.list().timeout(const Duration(seconds: 15));
} else {
size += await t.bytes!.size().timeout(const Duration(seconds: 15));
}
}
specs = relayTrackSpecs(tracks);
} catch (e) {
return 'cannot pass this link on: $e';
} finally {
await check.close();
}
_pendingOrigin = _LinkOrigin(url, probe, y);
_swap(HelloMedia( _swap(HelloMedia(
name: media.name, size: size, duration: 0, url: url, relay: const MediaRelay(fetch: true))); name: name, size: size, duration: 0, relay: MediaRelay(tracks: specs)));
return null;
} }
// The server reads it with its own yt-dlp; only a page this app knows
// it cannot relay (live, nothing a relay can read) is refused here (the server
// may reach what this computer cannot).
if (probe != null && probe.isPage && probe.info != null && probe.relayProblem != null) {
return probe.relayProblem;
}
_swap(HelloMedia(
name: name, size: max(0, probe?.size ?? 0), duration: 0, url: url, relay: const MediaRelay(fetch: true)));
return null; 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, /// A Jellyfin pick (browser, next / previous episode) goes up as a ref,
/// relayed by this member for viewers without an account. /// relayed by this member for viewers without an account.
void swapJellyfin(JellyfinMedia media) { void swapJellyfin(JellyfinMedia media) {
markInput(); markInput();
_lastPick = media;
_pendingOrigin = const _JellyfinOrigin(null); _pendingOrigin = const _JellyfinOrigin(null);
_swap(mediaForPick(media).copyWith(relay: const MediaRelay())); _swap(mediaForPick(media).copyWith(relay: const MediaRelay()));
} }
@@ -1088,6 +1383,7 @@ class SessionCoordinator {
/// when sent. /// when sent.
Future<String?> swapFile(String path) async { Future<String?> swapFile(String path) async {
markInput(); markInput();
_lastPick = FileMedia(path);
final host = hostFile; final host = hostFile;
if (host != null) { if (host != null) {
final file = await host(path); final file = await host(path);
@@ -1106,8 +1402,8 @@ class SessionCoordinator {
/// Any picker selection. /// Any picker selection.
Future<String?> swapPicked(PickedMedia media) async { Future<String?> swapPicked(PickedMedia media) async {
switch (media) { switch (media) {
case LinkMedia(:final url, :final relay): case LinkMedia(:final url, :final relay, :final anyway):
return swapLink(url, relay: relay); return swapLink(url, relay: relay, anyway: anyway);
case JellyfinMedia(): case JellyfinMedia():
swapJellyfin(media); swapJellyfin(media);
case FileMedia(:final path): case FileMedia(:final path):
@@ -1142,11 +1438,11 @@ class SessionCoordinator {
/// results), shown like the session's own. /// results), shown like the session's own.
void showToast(String text) => _pushToast(text); void showToast(String text) => _pushToast(text);
void _pushToast(String text) { void _pushToast(String text, {String? actor}) {
_toastId++; _toastId++;
toasts.value = [ toasts.value = [
...toasts.peek(), ...toasts.peek(),
ToastItem(id: _toastId, text: text, at: DateTime.now().toUtc()) ToastItem(id: _toastId, text: text, at: DateTime.now().toUtc(), actor: actor)
]; ];
} }
@@ -1180,7 +1476,7 @@ class SessionCoordinator {
if (reporter != null && snap != null) unawaited(reporter.stop(snap)); 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 { Future<void> _stopJellyfin() async {
final reporter = _reporter; final reporter = _reporter;
final snap = _snapNow(); final snap = _snapNow();
@@ -1263,10 +1559,10 @@ class SessionCoordinator {
// its own sign-in, else the pasted link (played directly here too). // its own sign-in, else the pasted link (played directly here too).
if (origin != null) { if (origin != null) {
if (plan is OwnPlay) { 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) { } else if (origin.link != null) {
plan = RelayPlay(origin.link!); 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; jellyfinPlan.value = plan;
@@ -1468,7 +1764,7 @@ class SessionCoordinator {
} }
void _scheduleReconnect({bool manual = false}) { void _scheduleReconnect({bool manual = false}) {
if (_manualClose || kickReason.peek() != null || _params == null) return; if (_manualClose || _params == null) return;
if (!manual && !_everConnected) return; if (!manual && !_everConnected) return;
if (reconnecting.peek()) return; if (reconnecting.peek()) return;
reconnecting.value = true; reconnecting.value = true;
@@ -1483,14 +1779,16 @@ class SessionCoordinator {
/// would kill the newer connection and leave a ghost member). /// would kill the newer connection and leave a ghost member).
Future<void> _reconnect(int epoch) async { Future<void> _reconnect(int epoch) async {
_reconnectTimer = null; _reconnectTimer = null;
bool stale() => epoch != _epoch || kickReason.peek() != null; bool stale() => epoch != _epoch;
final params = _params; final params = _params;
if (stale() || params == null) return; if (stale() || params == null) return;
if (_manualClose) { if (_manualClose) {
reconnecting.value = false; reconnecting.value = false;
return; return;
} }
final wantReady = _lastReady; final wasOut = _lastOut;
var wantReady = _lastReady && !wasOut;
final seenBefore = _seenMediaId;
try { try {
health.value = const HealthStatus( health.value = const HealthStatus(
HealthSeverity.info, 'reconnecting…'); HealthSeverity.info, 'reconnecting…');
@@ -1507,6 +1805,10 @@ class SessionCoordinator {
if (stale()) return; if (stale()) return;
reconnecting.value = false; reconnecting.value = false;
_everConnected = true; _everConnected = true;
if (_lostSounded) {
_lostSounded = false;
unawaited(sfx.play(const SfxCue(SfxKind.back)));
}
_resumeToken = sync.token; _resumeToken = sync.token;
_lastStateAt = DateTime.now().toUtc(); _lastStateAt = DateTime.now().toUtc();
health.value = const HealthStatus(HealthSeverity.ok, 'connected'); health.value = const HealthStatus(HealthSeverity.ok, 'connected');
@@ -1517,6 +1819,19 @@ class SessionCoordinator {
if (stale()) return; if (stale()) return;
// The server forgot the dropped member; restore the ready flag it // The server forgot the dropped member; restore the ready flag it
// last reported (not a local guess: others may have forced 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) { if (wantReady) {
_lastReady = true; _lastReady = true;
_restoringReady = true; _restoringReady = true;
@@ -1531,6 +1846,12 @@ class SessionCoordinator {
} }
} }
void _soundLost() {
if (!_everConnected || _lostSounded) return;
_lostSounded = true;
unawaited(sfx.play(const SfxCue(SfxKind.lost)));
}
void _invalidateAttempts() { void _invalidateAttempts() {
_epoch++; _epoch++;
_reconnectTimer?.cancel(); _reconnectTimer?.cancel();
@@ -1556,7 +1877,7 @@ class SessionCoordinator {
} }
void retry() { void retry() {
if (kickReason.peek() != null || _params == null) return; if (_params == null) return;
_invalidateAttempts(); _invalidateAttempts();
_manualClose = false; _manualClose = false;
reconnecting.value = false; reconnecting.value = false;
@@ -1617,7 +1938,7 @@ class SessionCoordinator {
} }
await _stateSub?.cancel(); await _stateSub?.cancel();
await _feedSub?.cancel(); await _feedSub?.cancel();
await _kickSub?.cancel(); await _nudgeSub?.cancel();
await _errSub?.cancel(); await _errSub?.cancel();
await _statusSub?.cancel(); await _statusSub?.cancel();
_aniList?.close(); _aniList?.close();
+23 -44
View File
@@ -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 { enum SfxKind {
/// Someone readies up; pitch climbs with [SfxCue.readyIndex].
ready, ready,
unready, unready,
/// Someone marks everyone ready.
forceReady, forceReady,
forceUnready,
forcePlay,
forceHold,
kick,
join, join,
leave, 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, swap,
seek,
pause,
resume,
chat, chat,
/// Idle warning: auto-unready soon.
warn, warn,
/// The connection to the server dropped / came back.
lost,
back,
/// Someone pokes you for attention; plays only on your end.
nudge,
} }
class SfxCue { class SfxCue {
@@ -23,42 +28,16 @@ class SfxCue {
const SfxCue(this.kind, {this.readyIndex = 0}); const SfxCue(this.kind, {this.readyIndex = 0});
} }
SfxKind? sfxForFeedAction(String action) { SfxKind? sfxForFeedAction(String action) => switch (action) {
switch (action) { 'ready' => SfxKind.ready,
case 'ready': 'unready' => SfxKind.unready,
return SfxKind.ready; 'force-ready' => SfxKind.forceReady,
case 'unready': 'join' => SfxKind.join,
return SfxKind.unready; 'leave' => SfxKind.leave,
case 'force-ready': 'swap' => SfxKind.swap,
return SfxKind.forceReady; 'chat' => SfxKind.chat,
case 'force-unready': _ => null,
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;
}
}
abstract class SfxService { abstract class SfxService {
Future<void> play(SfxCue cue); Future<void> play(SfxCue cue);
+268
View File
@@ -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)'),
};
+473
View File
@@ -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();
}
+88
View File
@@ -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();
}
}
+270 -46
View File
@@ -1,14 +1,19 @@
import 'dart:async'; import 'dart:async';
import 'dart:io' show File;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.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:signals/signals_flutter.dart';
import 'package:window_manager/window_manager.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/accounts.dart';
import '../jellyfin/browse_scope.dart' show BrowseStart; import '../jellyfin/browse_scope.dart' show BrowseStart;
import '../jellyfin/session_media.dart'; import '../jellyfin/session_media.dart';
import '../screens/home/connect_flow.dart'; import '../screens/home/connect_flow.dart';
import '../session/session_coordinator.dart' show ConnectParams;
import '../screens/home/health_probe.dart'; import '../screens/home/health_probe.dart';
import '../screens/home/media_picker.dart'; import '../screens/home/media_picker.dart';
import '../screens/home/profile.dart'; import '../screens/home/profile.dart';
@@ -18,6 +23,7 @@ import '../state/settings.dart';
import '../update/update_controller.dart'; import '../update/update_controller.dart';
import 'chat_panel.dart'; import 'chat_panel.dart';
import 'connect_cards.dart'; import 'connect_cards.dart';
import 'handoff.dart';
import 'library_tab.dart'; import 'library_tab.dart';
import 'motion.dart'; import 'motion.dart';
import 'party_strip.dart'; import 'party_strip.dart';
@@ -26,6 +32,7 @@ import '../schedule/ui/schedule_page.dart';
import 'session_player.dart'; import 'session_player.dart';
import 'settings_tab.dart'; import 'settings_tab.dart';
import 'toasts.dart'; import 'toasts.dart';
import 'welcome.dart';
/// The one window: the party strip over a body that fades through between /// 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 /// 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 /// 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 /// fades the layer out, moves it while invisible and fades it back in, so
/// the video is never mounted twice nor remounted. /// 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 { class AppShell extends StatefulWidget {
final UpdateController? updates; final UpdateController? updates;
final bool autoUpdateCheck; final bool autoUpdateCheck;
@@ -88,6 +98,11 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
Offset? _drag; // mini top-left while dragging Offset? _drag; // mini top-left while dragging
bool _snapping = false; 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(); final _flow = ConnectFlow();
late final _join = JoinFields(SessionState.instance.lastForm); late final _join = JoinFields(SessionState.instance.lastForm);
late final _host = HostFields(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). /// The next media to come up opens full (just joined, or put up here).
bool _autoFull = false; bool _autoFull = false;
int _lastMediaId = -1; int _lastMediaId = -1;
double? _mutedVolume;
bool _dialogOpen = false; bool _dialogOpen = false;
String? _lastKickShown;
final _stops = <void Function()>[]; final _stops = <void Function()>[];
// Rebuild the shell on these flips, not on every state broadcast. // Rebuild the shell on these flips, not on every state broadcast.
final _online = computed(() => SessionState.instance.route.value == AppRoute.session); 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(() => final _hasMedia = computed(() =>
SessionState.instance.route.value == AppRoute.session && SessionState.instance.route.value == AppRoute.session &&
SessionState.instance.coordinator.serverState.value?.media != null); 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. // Reads linked servers and checks their tokens in the background.
_jellyfin.ensureLoaded(); _jellyfin.ensureLoaded();
if (widget.autoUpdateCheck) _autoCheck(); if (widget.autoUpdateCheck) _autoCheck();
_flow.onKicked = _showKicked; HardwareKeyboard.instance.addHandler(_onKey);
final session = SessionState.instance; final session = SessionState.instance;
final coord = session.coordinator; final coord = session.coordinator;
_feedSeen = coord.feedLog.peek().length; _feedSeen = coord.feedLog.peek().length;
@@ -167,17 +185,28 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
if (!_chatOpen) setState(() => _unread += chats); 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(() { _stops.add(effect(() {
final kick = coord.kickReason.value; final out = coord.selfOut.value;
if (kick == null) { if (wasOut && !out && session.route.peek() == AppRoute.session) {
_lastKickShown = null; _later(() {
return; if (_mode == PlayerMode.none) _show(PlayerMode.mini);
});
} }
untracked(() { wasOut = out;
if (session.route.value == AppRoute.session) session.route.value = AppRoute.home; }));
// 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. // The hosted server died mid-session (SessionState leaves): say so.
_stops.add(effect(() { _stops.add(effect(() {
@@ -195,8 +224,10 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
for (final stop in _stops) { for (final stop in _stops) {
stop(); stop();
} }
HardwareKeyboard.instance.removeHandler(_onKey);
_fade.dispose(); _fade.dispose();
_online.dispose(); _online.dispose();
_alone.dispose();
_hasMedia.dispose(); _hasMedia.dispose();
_flow.dispose(); _flow.dispose();
_join.dispose(); _join.dispose();
@@ -227,7 +258,6 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
} }
void _wentOffline() { void _wentOffline() {
_mutedVolume = null;
if (_fullscreen) _toggleFullscreen(); if (_fullscreen) _toggleFullscreen();
setState(() { setState(() {
_chatOpen = false; _chatOpen = false;
@@ -243,7 +273,6 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
Future<void> _show(PlayerMode m) async { Future<void> _show(PlayerMode m) async {
if (m == _mode) return; if (m == _mode) return;
final seq = ++_modeSeq; final seq = ++_modeSeq;
if (m != PlayerMode.none) _unmute();
setState(() { setState(() {
_from = _shown; _from = _shown;
_mode = m; _mode = m;
@@ -267,27 +296,15 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
_show(PlayerMode.mini); _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() { void _closePlayer() {
final playback = SessionState.instance.coordinator.playback; final coord = SessionState.instance.coordinator;
if (playback.alive.peek()) { if (coord.selfOut.peek()) {
try { _show(PlayerMode.none);
_mutedVolume = playback.player.state.volume; } else {
playback.player.setVolume(0); coord.sitOut();
} catch (_) {}
} }
_show(PlayerMode.none);
}
void _unmute() {
final v = _mutedVolume;
if (v == null) return;
_mutedVolume = null;
final playback = SessionState.instance.coordinator.playback;
if (!playback.alive.peek()) return;
try {
playback.player.setVolume(v);
} catch (_) {}
} }
Future<void> _setWindowFullscreen(bool on) async { Future<void> _setWindowFullscreen(bool on) async {
@@ -345,7 +362,8 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
_selectTab(ShellTab.library); _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 { Future<String?> _putUp(PickedMedia media) async {
final session = SessionState.instance; final session = SessionState.instance;
if (media is JellyfinMedia) { if (media is JellyfinMedia) {
@@ -356,6 +374,12 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
} }
unawaited(_jellyfin.rememberPick(media)); unawaited(_jellyfin.rememberPick(media));
} }
if (!_online.peek()) {
final error = await _startSolo();
if (error != null || !mounted) return error;
}
// Alone, a link plays direct: there is nobody to pass it on to.
if (media is LinkMedia && session.solo.peek()) media = LinkMedia(media.url, anyway: media.anyway);
final error = await session.coordinator.swapPicked(media); final error = await session.coordinator.swapPicked(media);
if (error != null || !mounted) return error; if (error != null || !mounted) return error;
final f = session.lastForm; final f = session.lastForm;
@@ -368,9 +392,151 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
return null; 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 // --------------------------------------------------------------- dialogs
/// Leave asks first; a solo session just stops.
Future<void> _confirmLeave() async { Future<void> _confirmLeave() async {
final session = SessionState.instance;
if (session.solo.peek()) {
await session.leave();
return;
}
final ok = await showDialog<bool>( final ok = await showDialog<bool>(
context: context, context: context,
builder: (context) => AlertDialog( builder: (context) => AlertDialog(
@@ -389,12 +555,6 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
if (ok == true) await SessionState.instance.leave(); 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 { Future<void> _alert(String title, String text) async {
if (!mounted || _dialogOpen) return; if (!mounted || _dialogOpen) return;
_dialogOpen = true; _dialogOpen = true;
@@ -426,7 +586,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme; final scheme = Theme.of(context).colorScheme;
return CallbackShortcuts( final shell = CallbackShortcuts(
bindings: { bindings: {
const SingleActivator(LogicalKeyboardKey.comma, control: true): () => _selectTab(ShellTab.settings), 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, backgroundColor: _fullscreen ? Colors.black : scheme.surface,
body: SignalBuilder(builder: (context) { body: SignalBuilder(builder: (context) {
final online = _online.value; final online = _online.value;
final alone = _alone.value;
final hasMedia = _hasMedia.value; final hasMedia = _hasMedia.value;
return Column( return Column(
children: [ children: [
@@ -448,6 +609,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
onJoin: () => _connect(ConnectMode.join), onJoin: () => _connect(ConnectMode.join),
onHost: () => _connect(ConnectMode.host), onHost: () => _connect(ConnectMode.host),
onLeave: _confirmLeave, onLeave: _confirmLeave,
onInvite: () => _connect(ConnectMode.host),
onProfile: () => showProfileCard(context, onOpenSettings: _openSettings, updates: widget.updates), onProfile: () => showProfileCard(context, onOpenSettings: _openSettings, updates: widget.updates),
onShowPlayer: hasMedia && _mode == PlayerMode.none ? () => _show(PlayerMode.mini) : null, onShowPlayer: hasMedia && _mode == PlayerMode.none ? () => _show(PlayerMode.mini) : null,
updates: widget.updates, updates: widget.updates,
@@ -457,7 +619,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
key: const ValueKey('body'), key: const ValueKey('body'),
child: Row( child: Row(
children: [ children: [
Expanded(child: _body(online)), Expanded(child: _body(online, alone)),
AnimatedSize( AnimatedSize(
duration: easeTime, duration: easeTime,
curve: ease, 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 coord = SessionState.instance.coordinator;
final tabBody = switch (_tab) { final tabBody = switch (_tab) {
ShellTab.library => LibraryTab( ShellTab.library => LibraryTab(
key: ValueKey('library-$_libraryGen'), key: ValueKey('library-$_libraryGen'),
controller: _picker, controller: _picker,
accounts: _jellyfin, accounts: _jellyfin,
online: online, solo: alone,
isHost: coord.hostFile != null, // 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, jellyfinStart: _libraryStart,
onPutUp: _putUp, onPutUp: _putUp,
onOpenIntegrations: () => _openSettings(SettingsSection.integrations), onOpenIntegrations: () => _openSettings(SettingsSection.integrations),
), ),
ShellTab.schedule => SchedulePage( ShellTab.schedule => SchedulePage(
online: online, solo: alone,
onPutUp: _putUp, onPutUp: _putUp,
onToast: coord.showToast, onToast: coord.showToast,
), ),
@@ -533,7 +717,11 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
), ),
), ),
if (online && _shown != PlayerMode.none) _playerLayer(area), 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),
],
),
),
),
);
}
}
+42 -6
View File
@@ -17,6 +17,11 @@ class AvatarInfo {
final Color color; final Color color;
final ImageProvider? image; final ImageProvider? image;
final bool ready, buffering, relaying, stale, host, self; final bool ready, buffering, relaying, stale, host, self;
/// Sitting out: dimmed, with an "away" mark.
final bool out;
/// Why their player cannot open the media (`members[].error`): an error
/// mark; empty when it can.
final String error;
const AvatarInfo({ const AvatarInfo({
this.id = '', this.id = '',
required this.name, required this.name,
@@ -28,6 +33,8 @@ class AvatarInfo {
this.stale = false, this.stale = false,
this.host = false, this.host = false,
this.self = false, this.self = false,
this.out = false,
this.error = '',
}); });
} }
@@ -75,7 +82,7 @@ ImageProvider? memberAvatarImage(MemberView m, SessionCoordinator coord) {
} }
/// Your own avatar from the profile (also offline). /// Your own avatar from the profile (also offline).
AvatarInfo selfAvatarInfo({bool ready = false, bool host = false}) { AvatarInfo selfAvatarInfo({bool ready = false, bool host = false, bool out = false, String error = ''}) {
final p = Profile.instance; final p = Profile.instance;
final png = p.picture.value; final png = p.picture.value;
final name = p.name.value; final name = p.name.value;
@@ -86,6 +93,8 @@ AvatarInfo selfAvatarInfo({bool ready = false, bool host = false}) {
ready: ready, ready: ready,
host: host, host: host,
self: true, self: true,
out: out,
error: error,
); );
} }
@@ -100,13 +109,15 @@ AvatarInfo memberAvatarInfo(MemberView m, StateBroadcast? r, SessionCoordinator
color: self ? Profile.instance.color.value : colorForName(m.name), color: self ? Profile.instance.color.value : colorForName(m.name),
image: memberAvatarImage(m, coord), image: memberAvatarImage(m, coord),
ready: m.ready, ready: m.ready,
buffering: !m.stale && m.ready && playing && m.buffered < RelayConstants.minBufferSecs, buffering: !m.stale && m.ready && m.error.isEmpty && playing && m.buffered < r!.bufferNeed,
relaying: relay != null && relay.source.isNotEmpty && relay.source == m.id, relaying: relay != null && relay.source.isNotEmpty && relay.source == m.id,
stale: m.stale, stale: m.stale,
// The state marks the host (`members[].host`); your own app also // The state marks the host (`members[].host`); your own app also
// knows before the first state. // knows before the first state.
host: m.host || (self && coord.hostFile != null), host: m.host || (self && coord.hostFile != null),
self: self, self: self,
out: m.out,
error: m.error,
); );
} }
@@ -132,7 +143,8 @@ class PartyWatch extends StatelessWidget {
/// Circle avatar with a status ring outside it and a gap in the background /// Circle avatar with a status ring outside it and a gap in the background
/// colour, so overlapping neighbours never hide the status. Crown = host, /// colour, so overlapping neighbours never hide the status. Crown = host,
/// tower = passing the video on, spinner = buffering, dimmed = stale. /// tower = passing the video on, spinner = buffering, dimmed = stale,
/// dimmed with a moon = sitting out, red "!" = cannot play the media.
class StatusAvatar extends StatelessWidget { class StatusAvatar extends StatelessWidget {
final AvatarInfo info; final AvatarInfo info;
final double size; final double size;
@@ -176,7 +188,7 @@ class StatusAvatar extends StatelessWidget {
decoration: BoxDecoration(shape: BoxShape.circle, color: bg), decoration: BoxDecoration(shape: BoxShape.circle, color: bg),
padding: EdgeInsets.all(stroke), padding: EdgeInsets.all(stroke),
child: Opacity( child: Opacity(
opacity: info.stale ? 0.4 : 1, opacity: info.stale || info.out ? 0.4 : 1,
child: CircleAvatar( child: CircleAvatar(
radius: size / 2, radius: size / 2,
backgroundColor: info.color, backgroundColor: info.color,
@@ -205,6 +217,28 @@ class StatusAvatar extends StatelessWidget {
), ),
), ),
), ),
if (info.out)
Positioned(
key: const ValueKey('away-mark'),
left: -3,
bottom: -3,
child: Container(
padding: const EdgeInsets.all(2),
decoration: BoxDecoration(shape: BoxShape.circle, color: bg),
child: Icon(Icons.bedtime, size: (badge * 0.8).clamp(10.0, 24.0), color: Colors.blueGrey.shade200),
),
),
if (info.error.isNotEmpty)
Positioned(
key: const ValueKey('error-mark'),
right: -3,
top: -3,
child: Container(
padding: const EdgeInsets.all(1),
decoration: BoxDecoration(shape: BoxShape.circle, color: bg),
child: Icon(Icons.error, size: (badge * 0.8).clamp(10.0, 24.0), color: Colors.redAccent.shade200),
),
),
if (badges && info.relaying) if (badges && info.relaying)
Positioned( Positioned(
right: -3, right: -3,
@@ -240,7 +274,7 @@ class _AvatarStackState extends State<AvatarStack> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final size = widget.size; 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 sorted = [...widget.members]..sort((a, b) => rank(a) - rank(b));
final visible = sorted.take(widget.shown).toList(); final visible = sorted.take(widget.shown).toList();
final extra = sorted.length - visible.length; final extra = sorted.length - visible.length;
@@ -270,7 +304,9 @@ class _AvatarStackState extends State<AvatarStack> {
left: i * step, left: i * step,
top: 0, top: 0,
child: Tooltip( child: Tooltip(
message: visible[i].name, message: visible[i].error.isEmpty
? visible[i].name
: "${visible[i].name} can't play this: ${visible[i].error}",
child: StatusAvatar(info: visible[i], size: size), child: StatusAvatar(info: visible[i], size: size),
), ),
), ),
+109 -36
View File
@@ -1,22 +1,70 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:sync_relay_core/core.dart'; import 'package:sync_relay_core/core.dart';
import '../state/session_state.dart'; import '../state/session_state.dart';
import '../widgets/text_focus.dart';
import 'avatar.dart'; import 'avatar.dart';
/// Side panel: chat with avatars, other activity as quiet lines, and the /// Activity lines (joins, ready, seeks...) between the messages: off by
/// message field. Width is fixed; the shell slides it in and out. /// default, kept for the app's run.
class ChatPanel extends StatelessWidget { 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; static const width = 340.0;
final TextEditingController chat; final TextEditingController chat;
final FocusNode chatFocus; final FocusNode chatFocus;
final VoidCallback onClose; final VoidCallback onClose;
const ChatPanel({super.key, required this.chat, required this.chatFocus, required this.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() { void _send() {
SessionState.instance.coordinator.sendChat(chat.text); SessionState.instance.coordinator.sendChat(widget.chat.text);
chat.clear(); widget.chat.clear();
chatFocus.requestFocus(); 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 @override
@@ -24,7 +72,7 @@ class ChatPanel extends StatelessWidget {
final scheme = Theme.of(context).colorScheme; final scheme = Theme.of(context).colorScheme;
return Container( return Container(
key: const ValueKey('chat-panel'), key: const ValueKey('chat-panel'),
width: width, width: ChatPanel.width,
color: scheme.surfaceContainerLow, color: scheme.surfaceContainerLow,
child: Column( child: Column(
children: [ children: [
@@ -32,41 +80,64 @@ class ChatPanel extends StatelessWidget {
padding: const EdgeInsets.fromLTRB(16, 8, 4, 0), padding: const EdgeInsets.fromLTRB(16, 8, 4, 0),
child: Row( child: Row(
children: [ children: [
const Text('Chat & activity', style: TextStyle(fontWeight: FontWeight.w600)), const Text('Chat', style: TextStyle(fontWeight: FontWeight.w600)),
const Spacer(), 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( Expanded(
child: PartyWatch(builder: (context) { child: ValueListenableBuilder<bool>(
final log = SessionState.instance.coordinator.feedLog.value; valueListenable: showActivity,
if (log.isEmpty) { builder: (context, activity, _) => PartyWatch(builder: (context) {
return Center( final all = SessionState.instance.coordinator.feedLog.value;
child: Text('Nothing yet', style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12)), final log = activity ? all : [for (final f in all) if (f.action == 'chat') f];
if (log.isEmpty) {
return Center(
child: Text(activity ? 'Nothing yet' : 'No messages yet',
style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12)),
);
}
return ListView.builder(
reverse: true,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
itemCount: log.length,
itemBuilder: (context, i) => ActivityRow(item: log[log.length - 1 - i]),
); );
} }),
return ListView.builder( ),
reverse: true,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
itemCount: log.length,
itemBuilder: (context, i) => ActivityRow(item: log[log.length - 1 - i]),
);
}),
), ),
Padding( Padding(
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
child: TextField( child: Focus(
key: const ValueKey('chat-field'), canRequestFocus: false,
controller: chat, skipTraversal: true,
focusNode: chatFocus, onKeyEvent: _fieldKey,
onSubmitted: (_) => _send(), child: TextField(
decoration: InputDecoration( key: const ValueKey('chat-field'),
hintText: 'Message (Enter)', controller: widget.chat,
isDense: true, focusNode: widget.chatFocus,
filled: true, onSubmitted: (_) => _send(),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide.none), decoration: InputDecoration(
suffixIcon: IconButton(tooltip: 'Send', onPressed: _send, icon: const Icon(Icons.send, size: 18)), hintText: 'Message (Enter)',
isDense: true,
filled: true,
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide.none),
suffixIcon: IconButton(tooltip: 'Send', onPressed: _send, icon: const Icon(Icons.send, size: 18)),
),
), ),
), ),
), ),
@@ -161,9 +232,11 @@ class ActivityRow extends StatelessWidget {
return (Icons.swap_horiz, scheme.primary, 'changed the media'); return (Icons.swap_horiz, scheme.primary, 'changed the media');
case 'stale': case 'stale':
return (Icons.cloud_off, Colors.orange, 'lost signal'); return (Icons.cloud_off, Colors.orange, 'lost signal');
case 'kick': case 'out':
final reason = f.reason.isEmpty ? '' : ' · ${f.reason}'; final who = f.target;
return (Icons.gavel, Colors.red, 'kicked ${f.target ?? ''}$reason'); 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: default:
final target = f.target == null || f.target!.isEmpty ? '' : ' ${f.target}'; final target = f.target == null || f.target!.isEmpty ? '' : ' ${f.target}';
return (Icons.info_outline, Colors.grey, '${f.action}$target'); return (Icons.info_outline, Colors.grey, '${f.action}$target');
+26 -10
View File
@@ -41,8 +41,9 @@ class HostFields {
} }
} }
/// The strip's Join / Host buttons: a card over the window. It closes /// The strip's Join / Host buttons (and solo's Invite friends, the Host
/// itself once the attempt got in; a failed one shows its error inside. /// card): a card over the window. It closes itself once the attempt got
/// in; a failed one shows its error inside.
Future<void> showConnectCard( Future<void> showConnectCard(
BuildContext context, { BuildContext context, {
required ConnectMode mode, required ConnectMode mode,
@@ -77,7 +78,9 @@ class _CloseWhenOnlineState extends State<_CloseWhenOnline> {
void initState() { void initState() {
super.initState(); super.initState();
_stop = effect(() { _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((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) Navigator.of(context).maybePop(); if (mounted) Navigator.of(context).maybePop();
}); });
@@ -153,7 +156,7 @@ class JoinCard extends StatelessWidget {
builder: (context, _) { builder: (context, _) {
final busy = flow.busy == ConnectMode.join; final busy = flow.busy == ConnectMode.join;
return _Card( return _Card(
title: 'Join a server', title: 'Join a friend',
children: [ children: [
SignalBuilder(builder: (context) { SignalBuilder(builder: (context) {
final list = [ final list = [
@@ -221,6 +224,13 @@ class JoinCard extends StatelessWidget {
const SizedBox(height: 16), const SizedBox(height: 16),
ConnectButton(flow: flow, mode: ConnectMode.join, label: 'Join', onSubmit: _join, expand: true), ConnectButton(flow: flow, mode: ConnectMode.join, label: 'Join', onSubmit: _join, expand: true),
ConnectFeedback(flow: flow, mode: ConnectMode.join), 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 @override
Widget build(BuildContext context) { 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( return ListenableBuilder(
listenable: flow, listenable: flow,
builder: (context, _) => _Card( builder: (context, _) => _Card(
title: 'Host a server', title: solo ? 'Invite friends' : 'Host a server',
children: [ children: [
AbsorbPointer( AbsorbPointer(
absorbing: flow.busy == ConnectMode.host, absorbing: flow.busy == ConnectMode.host,
@@ -281,13 +294,16 @@ class HostCard extends StatelessWidget {
), ),
), ),
const SizedBox(height: 16), 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), ConnectFeedback(flow: flow, mode: ConnectMode.host),
const Padding( Padding(
padding: EdgeInsets.only(top: 8), padding: const EdgeInsets.only(top: 8),
child: Text( child: Text(
'You pick what to watch afterwards, in the library.', solo
style: TextStyle(color: Colors.grey, fontSize: 12), ? '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),
), ),
), ),
], ],
+192
View File
@@ -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),
],
),
),
),
);
}
}
+99 -61
View File
@@ -1,10 +1,11 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:sync_relay_core/core.dart' show LinkProbe, hasMediaExtension, parseJellyfinLink, playErrorText;
import '../playback/yt_dlp_app.dart';
import '../screens/home/media_picker.dart'; import '../screens/home/media_picker.dart';
import '../state/connect_prefs.dart'; import '../state/connect_prefs.dart';
import 'motion.dart'; import 'motion.dart';
@@ -106,52 +107,61 @@ class LinkPreview {
/// "youtube.com · Channel", or the host. /// "youtube.com · Channel", or the host.
final String subtitle; final String subtitle;
final String? thumbnail; final String? thumbnail;
const LinkPreview({required this.title, required this.subtitle, this.thumbnail}); /// Why this computer cannot open it, in plain words ([LinkProbe.problem]);
/// null when it looks playable.
final String? problem;
const LinkPreview({required this.title, required this.subtitle, this.thumbnail, this.problem});
} }
/// GET [uri] and return the body (oEmbed). /// What a link is (see [AppYtDlp.probe]).
typedef OEmbedGetter = Future<String> Function(Uri uri); typedef LinkProber = Future<LinkProbe> Function(String url);
Future<String> defaultOEmbedGet(Uri uri) async { Future<LinkProbe> defaultLinkProber(String url) => AppYtDlp.instance.probe(url);
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');
}
String _host(Uri uri) => uri.host.startsWith('www.') ? uri.host.substring(4) : uri.host; 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, /// "3:07", "1:02:03".
/// channel and thumbnail; anything else (or a failed lookup) shows the String clockText(double seconds) {
/// file name and host. Null for text that is not an http(s) link. final s = seconds.round();
Future<LinkPreview?> previewLink(String url, {OEmbedGetter get = defaultOEmbedGet}) async { final h = s ~/ 3600, m = s % 3600 ~/ 60, sec = (s % 60).toString().padLeft(2, '0');
return h > 0 ? '$h:${m.toString().padLeft(2, '0')}:$sec' : '$m:$sec';
}
/// The preview of [url]: a web page shows yt-dlp's title, uploader,
/// duration and thumbnail; a media file its file name and host; a link
/// that does not open, why ([LinkPreview.problem]). Null for text that is
/// not an http(s) link.
Future<LinkPreview?> previewLink(String url, {LinkProber probe = defaultLinkProber}) async {
final uri = Uri.tryParse(url.trim()); final uri = Uri.tryParse(url.trim());
if (uri == null || !(uri.scheme == 'http' || uri.scheme == 'https') || uri.host.isEmpty) return null; 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)); final host = _host(uri);
if (!isYouTubeLink(uri)) return plain; // A Jellyfin item goes up as the item (see `mediaForLink`), not the page.
if (parseJellyfinLink(url.trim()) != null) return LinkPreview(title: host, subtitle: '$host · Jellyfin');
final file = hasMediaExtension(uri);
final LinkProbe p;
try { try {
final body = await get(Uri.https('www.youtube.com', '/oembed', {'url': url.trim(), 'format': 'json'})); p = await probe(url.trim());
final j = jsonDecode(body); } catch (e) {
if (j is! Map || j['title'] is! String) return plain; final problem = playErrorText('$e');
final author = j['author_name']; return LinkPreview(title: LinkMedia(url.trim()).label, subtitle: '$host · $problem', problem: problem);
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;
} }
final info = p.info;
if (!p.isPage) return LinkPreview(title: LinkMedia(url.trim()).label, subtitle: host);
final problem = p.problem;
if (info == null || problem != null) {
// A media file name that did not answer as one keeps its name.
final title = file ? LinkMedia(url.trim()).label : p.title ?? host;
return LinkPreview(title: title, subtitle: '$host · ${problem ?? 'Not a video'}', problem: problem);
}
return LinkPreview(
title: p.title ?? host,
subtitle: [
host,
if (info.uploader.isNotEmpty) info.uploader,
if (info.duration case final d? when d > 0) clockText(d),
].join(' · '),
thumbnail: info.thumbnail,
);
} }
/// "today", "yesterday", "3 days ago", "last week", "5 weeks ago", ... /// "today", "yesterday", "3 days ago", "last week", "5 weeks ago", ...
@@ -188,21 +198,25 @@ String linkRelayHelp(LinkRelay r) => switch (r) {
'while it plays; the link is not shown to anyone.', '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 { class LibraryLinkSource extends StatefulWidget {
final MediaPickerController controller; final MediaPickerController controller;
final String? offlineHint; final bool solo;
/// Puts the link up; returns an error text, or null when sent. /// Puts the link up; returns an error text, or null when sent.
final Future<String?> Function(LinkMedia media) onPutUp; final Future<String?> Function(LinkMedia media) onPutUp;
final RecentLinks recents; final RecentLinks recents;
final OEmbedGetter oembed; final LinkProber probe;
const LibraryLinkSource({ const LibraryLinkSource({
super.key, super.key,
required this.controller, required this.controller,
required this.onPutUp, required this.onPutUp,
required this.recents, required this.recents,
this.offlineHint, this.solo = false,
this.oembed = defaultOEmbedGet, this.probe = defaultLinkProber,
}); });
@override @override
@@ -216,6 +230,8 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
Timer? _debounce; Timer? _debounce;
bool _busy = false; bool _busy = false;
String? _error; String? _error;
/// [_error] is the probe's problem: "Put it up anyway" is offered.
bool _canForce = false;
@override @override
void initState() { void initState() {
@@ -237,7 +253,12 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
if (url == _previewUrl) return; if (url == _previewUrl) return;
_debounce?.cancel(); _debounce?.cancel();
_debounce = Timer(const Duration(milliseconds: 350), () => _preview(url)); _debounce = Timer(const Duration(milliseconds: 350), () => _preview(url));
if (mounted) setState(() => _error = null); if (mounted) {
setState(() {
_error = null;
_canForce = false;
});
}
} }
Future<void> _preview(String url) async { Future<void> _preview(String url) async {
@@ -246,7 +267,7 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
if (mounted) setState(() {}); if (mounted) setState(() {});
return; return;
} }
final p = await previewLink(url, get: widget.oembed); final p = await previewLink(url, probe: widget.probe);
_previews[url] = p; _previews[url] = p;
if (mounted) setState(() {}); if (mounted) setState(() {});
} }
@@ -258,21 +279,29 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
} catch (_) {} } catch (_) {}
} }
Future<void> _go() async { Future<void> _go({bool anyway = false}) async {
final url = _c.link.text.trim(); final url = _c.link.text.trim();
if (url.isEmpty || _busy || widget.offlineHint != null) return; if (url.isEmpty || _busy) return;
setState(() { setState(() {
_busy = true; _busy = true;
_error = null; _error = null;
_canForce = false;
}); });
String? error; // What the probe found refuses it here, with "Put it up anyway": the
try { // server or a friend (with cookies) may still open it.
error = await widget.onPutUp(LinkMedia(url, relay: _c.linkRelay)); final problem = anyway ? null : (_previews[url] ?? await previewLink(url, probe: widget.probe))?.problem;
} catch (e) { String? error = problem;
error = '$e'; if (problem == null) {
try {
error = await widget.onPutUp(
LinkMedia(url, relay: widget.solo ? LinkRelay.direct : _c.linkRelay, anyway: anyway));
} catch (e) {
error = '$e';
}
} }
final canForce = problem != null;
if (error == null) { 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( unawaited(widget.recents.add(RecentLink(
url: url, url: url,
title: p?.title ?? LinkMedia(url).label, title: p?.title ?? LinkMedia(url).label,
@@ -284,6 +313,7 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
setState(() { setState(() {
_busy = false; _busy = false;
_error = error; _error = error;
_canForce = canForce;
}); });
} }
@@ -291,7 +321,7 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme; final scheme = Theme.of(context).colorScheme;
final t = Theme.of(context).textTheme; final t = Theme.of(context).textTheme;
final hint = widget.offlineHint; final solo = widget.solo;
final url = _c.link.text.trim(); final url = _c.link.text.trim();
final preview = url.isEmpty ? null : _previews[url]; final preview = url.isEmpty ? null : _previews[url];
final loading = url.isNotEmpty && !_previews.containsKey(url); final loading = url.isNotEmpty && !_previews.containsKey(url);
@@ -311,11 +341,11 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
); );
final button = FilledButton.icon( final button = FilledButton.icon(
key: const ValueKey('link-put-up'), key: const ValueKey('link-put-up'),
onPressed: url.isEmpty || _busy || hint != null ? null : _go, onPressed: url.isEmpty || _busy ? null : _go,
icon: _busy icon: _busy
? const SizedBox.square(dimension: 16, child: CircularProgressIndicator(strokeWidth: 2)) ? const SizedBox.square(dimension: 16, child: CircularProgressIndicator(strokeWidth: 2))
: const Icon(Icons.play_arrow), : const Icon(Icons.play_arrow),
label: Text(hint == null ? 'Put it up' : 'Play'), label: Text(solo ? 'Play' : 'Put it up'),
style: FilledButton.styleFrom( style: FilledButton.styleFrom(
minimumSize: const Size(0, 56), minimumSize: const Size(0, 56),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
@@ -346,7 +376,7 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
Text('Watch from a link', textAlign: TextAlign.center, style: t.headlineSmall), Text('Watch from a link', textAlign: TextAlign.center, style: t.headlineSmall),
const SizedBox(height: 6), const SizedBox(height: 6),
Text( 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, textAlign: TextAlign.center,
style: TextStyle(color: scheme.onSurfaceVariant), style: TextStyle(color: scheme.onSurfaceVariant),
), ),
@@ -360,14 +390,22 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
]) ])
: Row(children: [Expanded(child: field), const SizedBox(width: 12), button]), : Row(children: [Expanded(child: field), const SizedBox(width: 12), button]),
), ),
if (hint != null || _error != null) ...[ if (solo || _error != null) ...[
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
_error ?? hint!, _error ?? soloPlayHint,
key: const ValueKey('link-hint'), key: const ValueKey('link-hint'),
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: t.bodySmall?.copyWith(color: _error != null ? scheme.error : Colors.grey), style: t.bodySmall?.copyWith(color: _error != null ? scheme.error : Colors.grey),
), ),
if (_canForce)
Center(
child: TextButton(
key: const ValueKey('link-put-up-anyway'),
onPressed: _busy ? null : () => _go(anyway: true),
child: Text(solo ? 'Play it anyway' : 'Put it up anyway'),
),
),
], ],
AnimatedSize( AnimatedSize(
duration: easeTime, duration: easeTime,
@@ -379,7 +417,7 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
child: _PreviewCard(preview: preview, loading: loading), child: _PreviewCard(preview: preview, loading: loading),
), ),
), ),
if (hint == null) ...[ if (!solo) ...[
const SizedBox(height: 28), const SizedBox(height: 28),
Text('How the others get it', style: t.titleSmall), Text('How the others get it', style: t.titleSmall),
const SizedBox(height: 10), const SizedBox(height: 10),
@@ -470,9 +508,9 @@ class _PreviewCard extends StatelessWidget {
const SizedBox(height: 4), const SizedBox(height: 4),
Text( Text(
p?.subtitle ?? 'Paste an http(s) address', p?.subtitle ?? 'Paste an http(s) address',
maxLines: 1, maxLines: 2,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Colors.grey), style: TextStyle(color: p?.problem != null ? scheme.error : Colors.grey),
), ),
], ],
), ),
+33 -47
View File
@@ -1,7 +1,6 @@
import 'dart:async'; import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:signals/signals_flutter.dart'; import 'package:signals/signals_flutter.dart';
import 'package:sync_relay_core/core.dart' show HelloMedia, JellyfinException, JellyfinItem; 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 '../state/session_state.dart';
import 'library_link.dart'; import 'library_link.dart';
import 'motion.dart'; import 'motion.dart';
import 'welcome.dart';
/// What putting up a Jellyfin pick sends: its ref (no URL, no token). /// What putting up a Jellyfin pick sends: its ref (no URL, no token).
/// Throws [FormatException] with a user-facing message when the server is /// 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; 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. /// Window width under which the details panel floats over the library.
const detailsDockFrom = 1000.0; const detailsDockFrom = 1000.0;
@@ -69,12 +66,13 @@ const detailsDockFrom = 1000.0;
/// through), the Jellyfin library chips beside it, and the source below. /// through), the Jellyfin library chips beside it, and the source below.
/// Jellyfin cards select on click and open their details in a panel at /// Jellyfin cards select on click and open their details in a panel at
/// the right (docked from [detailsDockFrom], floating over the library /// the right (docked from [detailsDockFrom], floating over the library
/// below it); a double-click or the panel's button puts it up. Offline /// below it); a double-click or the panel's button puts it up. With
/// everything browses and the play buttons say [offlinePutUpHint]. /// 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 { class LibraryTab extends StatefulWidget {
final MediaPickerController controller; final MediaPickerController controller;
final JellyfinAccounts accounts; final JellyfinAccounts accounts;
final bool online; final bool solo;
/// This app hosts the server: its files stream from here directly. /// This app hosts the server: its files stream from here directly.
final bool isHost; final bool isHost;
final BrowseStart? jellyfinStart; final BrowseStart? jellyfinStart;
@@ -84,26 +82,29 @@ class LibraryTab extends StatefulWidget {
final VoidCallback? onOpenIntegrations; final VoidCallback? onOpenIntegrations;
/// Defaults to [RecentLinks.instance]. /// Defaults to [RecentLinks.instance].
final RecentLinks? recentLinks; final RecentLinks? recentLinks;
final OEmbedGetter oembed; final LinkProber probe;
/// The Files source (tests inject one); defaults to [FilesPage]. /// 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; required Future<String?> Function(String path) onPlay, String? nowPlayingPath})? filesBuilder;
/// Clock for the Jellyfin Continue row (tests pin it). /// Clock for the Jellyfin Continue row (tests pin it).
final DateTime Function() now; final DateTime Function() now;
/// Over the source switch (the shell's welcome band while offline).
final Widget? banner;
const LibraryTab({ const LibraryTab({
super.key, super.key,
required this.controller, required this.controller,
required this.accounts, required this.accounts,
required this.online, required this.solo,
required this.isHost, required this.isHost,
required this.onPutUp, required this.onPutUp,
this.onPutUpDone, this.onPutUpDone,
this.jellyfinStart, this.jellyfinStart,
this.onOpenIntegrations, this.onOpenIntegrations,
this.recentLinks, this.recentLinks,
this.oembed = defaultOEmbedGet, this.probe = defaultLinkProber,
this.filesBuilder, this.filesBuilder,
this.now = DateTime.now, this.now = DateTime.now,
this.banner,
}); });
@override @override
@@ -116,7 +117,6 @@ class _LibraryTabState extends State<LibraryTab> {
JellyfinSelection? _shown; JellyfinSelection? _shown;
MediaPickerController get _c => widget.controller; MediaPickerController get _c => widget.controller;
String? get _hint => widget.online ? null : offlinePutUpHint;
@override @override
void initState() { void initState() {
@@ -164,7 +164,6 @@ class _LibraryTabState extends State<LibraryTab> {
/// A Jellyfin card or episode: a series goes up as its next episode. /// A Jellyfin card or episode: a series goes up as its next episode.
Future<String?> _putUpJellyfin(JellyfinSelection sel) async { Future<String?> _putUpJellyfin(JellyfinSelection sel) async {
if (!widget.online) return offlinePutUpHint;
final account = widget.accounts.account(sel.serverId); final account = widget.accounts.account(sel.serverId);
final client = widget.accounts.clientFor(sel.serverId); final client = widget.accounts.clientFor(sel.serverId);
if (account == null || client == null) return 'that Jellyfin server is not linked any more'; 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. /// Double-click on a card: put it up, errors as a snack bar.
Future<void> _doublePutUp(JellyfinSelection sel) async { Future<void> _doublePutUp(JellyfinSelection sel) async {
_c.select(sel); _c.select(sel);
if (!widget.online) return;
final error = await _putUpJellyfin(sel); final error = await _putUpJellyfin(sel);
if (error != null && mounted) { if (error != null && mounted) {
ScaffoldMessenger.maybeOf(context)?.showSnackBar(SnackBar(content: Text(error))); 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 // -------------------------------------------------------------- build
@override @override
@@ -266,13 +238,18 @@ class _LibraryTabState extends State<LibraryTab> {
onOpenIntegrations: widget.onOpenIntegrations, onOpenIntegrations: widget.onOpenIntegrations,
start: widget.jellyfinStart, start: widget.jellyfinStart,
libraryStrip: false, libraryStrip: false,
empty: (context) => NoJellyfinPanel(
accounts: widget.accounts,
onFiles: () => _c.source = MediaKind.file,
onLink: () => _c.source = MediaKind.link,
),
), ),
), ),
MediaKind.link => LibraryLinkSource( MediaKind.link => LibraryLinkSource(
controller: _c, controller: _c,
offlineHint: _hint, solo: widget.solo,
recents: widget.recentLinks ?? RecentLinks.instance, recents: widget.recentLinks ?? RecentLinks.instance,
oembed: widget.oembed, probe: widget.probe,
onPutUp: _put, onPutUp: _put,
), ),
MediaKind.file => _files(context), MediaKind.file => _files(context),
@@ -280,6 +257,15 @@ class _LibraryTabState extends State<LibraryTab> {
final main = Column( final main = Column(
children: [ children: [
AnimatedSize(
duration: easeTime,
curve: ease,
alignment: Alignment.topCenter,
child: AnimatedSwitcher(
duration: easeTime,
child: widget.banner ?? const SizedBox(width: double.infinity),
),
),
header, header,
Expanded(child: fadeThrough(KeyedSubtree(key: ValueKey(source), child: content))), Expanded(child: fadeThrough(KeyedSubtree(key: ValueKey(source), child: content))),
], ],
@@ -294,7 +280,7 @@ class _LibraryTabState extends State<LibraryTab> {
child: JellyfinDetailsPanel( child: JellyfinDetailsPanel(
controller: _browse, controller: _browse,
selection: shown, selection: shown,
offlineHint: _hint, solo: widget.solo,
onPutUp: _putUpJellyfin, onPutUp: _putUpJellyfin,
onClose: () => _c.select(null), 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), child: AnimatedOpacity(duration: easeTime, curve: ease, opacity: open ? 1 : 0, child: child),
); );
// Holds focus so the shell's shortcuts (Ctrl+,) work.
return Focus( return Focus(
key: const ValueKey('library-tab'), key: const ValueKey('library-tab'),
autofocus: true, autofocus: true,
onKeyEvent: _onKey,
child: LayoutBuilder(builder: (context, box) { child: LayoutBuilder(builder: (context, box) {
if (box.maxWidth >= detailsDockFrom) { if (box.maxWidth >= detailsDockFrom) {
return Row( return Row(
@@ -355,14 +341,14 @@ class _LibraryTabState extends State<LibraryTab> {
Future<String?> play(String path) => _put(FileMedia(path)); Future<String?> play(String path) => _put(FileMedia(path));
return SignalBuilder(builder: (context) { return SignalBuilder(builder: (context) {
final session = SessionState.instance; final session = SessionState.instance;
final now = widget.online final now = session.route.value == AppRoute.session
? nowPlayingFile(session.coordinator.serverState.value?.media, session.lastForm.mediaPath) ? nowPlayingFile(session.coordinator.serverState.value?.media, session.lastForm.mediaPath)
: null; : null;
final b = widget.filesBuilder; final b = widget.filesBuilder;
if (b != null) { 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);
}); });
} }
} }
+7 -7
View File
@@ -90,17 +90,17 @@ Future<T?> showPopover<T>(
}, },
); );
/// Horizontal lists for mouse users: drag with the mouse, and the plain /// Horizontal lists for mouse users: drag with the mouse or Shift+wheel;
/// (vertical) wheel moves them sideways. At either end the wheel is left /// the plain wheel stays vertical (the page scrolls under the cursor, not
/// to the page, so a row never traps vertical scrolling. /// the row).
class HScroll extends StatefulWidget { class HScroll extends StatefulWidget {
final ScrollController? controller; final ScrollController? controller;
final Widget Function(ScrollController controller) builder; final Widget Function(ScrollController controller) builder;
/// Map the plain (vertical) wheel to sideways scrolling. Off where the /// Also map the plain (vertical) wheel to sideways scrolling (at either
/// children scroll vertically themselves (the schedule's day columns): /// end the wheel goes to the page). Off by default: one wheel doing two
/// there only mouse drag and Shift+wheel move sideways. /// things was confusing.
final bool wheel; 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 @override
State<HScroll> createState() => _HScrollState(); State<HScroll> createState() => _HScrollState();
+160 -74
View File
@@ -1,18 +1,16 @@
import 'dart:io'; import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:signals/signals_flutter.dart'; import 'package:signals/signals_flutter.dart';
import 'package:sync_relay_core/core.dart'; import 'package:sync_relay_core/core.dart';
import 'package:window_manager/window_manager.dart'; import 'package:window_manager/window_manager.dart';
import '../screens/demo/shell_demo.dart';
import '../screens/home/update_indicator.dart'; import '../screens/home/update_indicator.dart';
import '../screens/session/jellyfin_widgets.dart' show ViaHostBadge; import '../screens/session/jellyfin_widgets.dart' show ViaHostBadge;
import '../state/session_state.dart'; import '../state/session_state.dart';
import '../update/update_controller.dart'; import '../update/update_controller.dart';
import '../widgets/dialogs.dart'; import '../widgets/app_keys.dart';
import '../widgets/window_buttons.dart'; import '../widgets/window_buttons.dart';
import 'avatar.dart'; import 'avatar.dart';
import 'motion.dart'; import 'motion.dart';
@@ -29,6 +27,9 @@ enum ShellTab {
const partyStripHeight = 60.0; 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, /// Below this window width the strip shows 3 avatars and no server text,
/// so the tabs keep their room. /// so the tabs keep their room.
const stripCompactBelow = 1100.0; const stripCompactBelow = 1100.0;
@@ -38,8 +39,9 @@ bool viaHostOf(MemberView m, JellyfinRef? ref) =>
ref != null && m.jf[ref.serverId] == false; ref != null && m.jf[ref.serverId] == false;
/// The 60 px strip over everything: your avatar (profile), the tabs, and /// The 60 px strip over everything: your avatar (profile), the tabs, and
/// the party: offline Join / Host, online the avatars (members card), /// the party: offline Join a friend / Host, solo a "Solo" chip, Invite
/// server and readiness, chat and Leave. Empty space drags the window. /// friends and Stop, online the avatars (members card), server and
/// readiness, chat and Leave. Empty space drags the window.
class PartyStrip extends StatelessWidget { class PartyStrip extends StatelessWidget {
final ShellTab tab; final ShellTab tab;
final ValueChanged<ShellTab> onTab; final ValueChanged<ShellTab> onTab;
@@ -50,6 +52,8 @@ class PartyStrip extends StatelessWidget {
final VoidCallback onHost; final VoidCallback onHost;
final VoidCallback onLeave; final VoidCallback onLeave;
final VoidCallback onProfile; final VoidCallback onProfile;
/// Solo: host from here, keeping what plays.
final VoidCallback? onInvite;
/// Online with something up but the player closed: brings it back. /// Online with something up but the player closed: brings it back.
final VoidCallback? onShowPlayer; final VoidCallback? onShowPlayer;
@@ -68,6 +72,7 @@ class PartyStrip extends StatelessWidget {
required this.onHost, required this.onHost,
required this.onLeave, required this.onLeave,
required this.onProfile, required this.onProfile,
this.onInvite,
this.onShowPlayer, this.onShowPlayer,
this.updates, this.updates,
this.windowControls = true, this.windowControls = true,
@@ -82,6 +87,7 @@ class PartyStrip extends StatelessWidget {
final session = SessionState.instance; final session = SessionState.instance;
final coord = session.coordinator; final coord = session.coordinator;
final online = session.route.value == AppRoute.session; final online = session.route.value == AppRoute.session;
final solo = online && (session.solo.value || session.switching.value);
final self = online ? coord.selfMember.value : null; final self = online ? coord.selfMember.value : null;
return Row( return Row(
children: [ children: [
@@ -90,14 +96,16 @@ class PartyStrip extends StatelessWidget {
message: 'Your profile', message: 'Your profile',
child: InkWell( child: InkWell(
key: const ValueKey('strip-profile'), key: const ValueKey('strip-profile'),
canRequestFocus: false,
customBorder: const CircleBorder(), customBorder: const CircleBorder(),
onTap: onProfile, onTap: onProfile,
child: StatusAvatar( child: StatusAvatar(
info: selfAvatarInfo( info: selfAvatarInfo(
ready: self?.ready ?? false, ready: self?.ready ?? false,
host: online && coord.hostFile != null, host: online && !solo && coord.hostFile != null,
error: online ? coord.playerError.value : '',
), ),
size: 32, size: stripAvatarSize,
ring: online, ring: online,
), ),
), ),
@@ -126,52 +134,67 @@ 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), UpdateIndicator(controller: updates),
const SizedBox(width: 8), const SizedBox(width: 8),
if (online) ...[ if (online && onShowPlayer != null)
if (onShowPlayer != null) IconButton(
IconButton( key: const ValueKey('show-player'),
key: const ValueKey('show-player'), tooltip: 'Show the player',
tooltip: 'Show the player', onPressed: onShowPlayer,
onPressed: onShowPlayer, icon: const Icon(Icons.smart_display_outlined, size: 20),
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( PartyWatch(
builder: (context) => AvatarStack( builder: (context) => AvatarStack(
key: const ValueKey('party-avatars'), key: const ValueKey('party-avatars'),
members: partyAvatars(coord), members: partyAvatars(coord),
shown: compact ? 3 : 5, shown: compact ? 3 : 5,
size: stripAvatarSize,
onTap: () => showMembersCard(context), onTap: () => showMembersCard(context),
), ),
), ),
const SizedBox(width: 10), const SizedBox(width: 10),
if (!compact) _ServerLabel(scheme: scheme), if (!compact) _ServerLabel(scheme: scheme),
const SizedBox(width: 6), const SizedBox(width: 6),
Badge( // Tab toggles the chat while the strip is shown.
isLabelVisible: unread > 0, ChatKey(
label: Text('$unread'), onToggle: onChat,
child: IconButton( child: Badge(
key: const ValueKey('strip-chat'), isLabelVisible: unread > 0,
tooltip: 'Chat & activity', label: Text('$unread'),
isSelected: chatOpen, child: IconButton(
onPressed: onChat, key: const ValueKey('strip-chat'),
icon: const Icon(Icons.chat_bubble_outline, size: 20), tooltip: 'Chat & activity (Tab)',
selectedIcon: Icon( isSelected: chatOpen,
Icons.chat_bubble, onPressed: onChat,
size: 20, icon: const Icon(Icons.chat_bubble_outline, size: 20),
color: scheme.primary, selectedIcon: Icon(
Icons.chat_bubble,
size: 20,
color: scheme.primary,
),
), ),
), ),
), ),
@@ -182,23 +205,35 @@ class PartyStrip extends StatelessWidget {
child: const Text('Leave'), child: const Text('Leave'),
), ),
] else ...[ ] else ...[
if (!compact) if (!compact) ...[
Text( Container(
'Offline', width: 7,
style: TextStyle(color: scheme.onSurfaceVariant), height: 7,
decoration: BoxDecoration(color: scheme.outline, shape: BoxShape.circle),
), ),
const SizedBox(width: 12), const SizedBox(width: 6),
FilledButton.tonal( Text('Offline', style: TextStyle(color: scheme.onSurfaceVariant)),
key: const ValueKey('strip-join'), const SizedBox(width: 12),
onPressed: onJoin, ],
child: const Text('Join'), if (compact) ...[
), FilledButton(key: const ValueKey('strip-join'), onPressed: onJoin, child: const Text('Join')),
const SizedBox(width: 8), const SizedBox(width: 8),
FilledButton( FilledButton.tonal(key: const ValueKey('strip-host'), onPressed: onHost, child: const Text('Host')),
key: const ValueKey('strip-host'), ] else ...[
onPressed: onHost, FilledButton.icon(
child: const Text('Host'), key: const ValueKey('strip-join'),
), onPressed: onJoin,
icon: const Icon(Icons.login_rounded, size: 18),
label: const Text('Join a friend'),
),
const SizedBox(width: 8),
FilledButton.tonalIcon(
key: const ValueKey('strip-host'),
onPressed: onHost,
icon: const Icon(Icons.podcasts_rounded, size: 18),
label: const Text('Host'),
),
],
], ],
const SizedBox(width: 8), const SizedBox(width: 8),
if (windowControls) ...[ if (windowControls) ...[
@@ -275,11 +310,54 @@ class _ServerLabel extends StatelessWidget {
); );
} }
/// "Your server" when hosting, else the address joined. /// "Solo" alone, "Your server" when hosting, else the joined server's
/// name (its `--name`), or its address while unknown.
String serverName() { String serverName() {
final session = SessionState.instance; final session = SessionState.instance;
if (session.solo.value) return 'Solo';
if (session.coordinator.hostFile != null) return 'Your server'; 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. /// One strip tab: label and an underline that grows in when selected.
@@ -341,7 +419,8 @@ class StripTab extends StatelessWidget {
} }
/// Who is in the server, from the avatars: status, host / relay pills, /// 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>( Future<void> showMembersCard(BuildContext context) => showPopover<void>(
context, context,
at: const Alignment(0.7, -0.84), at: const Alignment(0.7, -0.84),
@@ -473,6 +552,10 @@ class _MemberTile extends StatelessWidget {
final m = member; final m = member;
final status = m.stale final status = m.stale
? 'reconnecting…' ? 'reconnecting…'
: m.out
? 'sitting out'
: m.error.isNotEmpty
? "can't play this: ${m.error}"
: info.buffering : info.buffering
? 'buffering ${m.buffered.toStringAsFixed(1)} s' ? 'buffering ${m.buffered.toStringAsFixed(1)} s'
: m.ready : m.ready
@@ -498,27 +581,30 @@ class _MemberTile extends StatelessWidget {
trailing: PopupMenuButton<String>( trailing: PopupMenuButton<String>(
tooltip: 'More', tooltip: 'More',
icon: const Icon(Icons.more_vert, size: 18), icon: const Icon(Icons.more_vert, size: 18),
onSelected: (v) async { onSelected: (v) {
if (v == 'force') { switch (v) {
coord.forceTarget(m.id, !m.ready); case 'force':
} else if (v == 'kick') { coord.forceTarget(m.id, !m.ready);
final ok = await showKickConfirmDialog(context, m.name); case 'nudge':
if (ok == true) coord.kick(m.id); coord.nudgeMember(m.id);
case 'out':
coord.sitOut(m.id);
case 'back':
coord.joinBack();
} }
}, },
itemBuilder: (_) => [ itemBuilder: (_) => [
PopupMenuItem( if (!m.out)
value: 'force',
child: Text(m.ready ? 'Force unready' : 'Force ready'),
),
if (!info.self && !info.host)
PopupMenuItem( PopupMenuItem(
value: 'kick', value: 'force',
child: Text( child: Text(m.ready ? 'Force unready' : 'Force ready'),
'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')),
], ],
), ),
); );
+93 -49
View File
@@ -11,11 +11,12 @@ import 'avatar.dart';
import 'motion.dart'; import 'motion.dart';
/// What the player's status pill says, most pressing first. /// What the player's status pill says, most pressing first.
enum StatusKind { kicked, reconnecting, problem, connecting, finished, loading, you, waiting } enum StatusKind { reconnecting, problem, connecting, finished, loading, you, cannotPlay, waiting }
/// The pill's button: stop reconnecting, retry, or mark yourself ready /// The pill's button: stop reconnecting, retry (reconnect), reload the
/// (the whole pill). /// video (your player could not open it), or mark yourself ready (the
enum StatusAction { cancel, retry, ready } /// whole pill).
enum StatusAction { cancel, retry, reload, ready }
/// One status line: [text], a muted [detail], a coloured [tone] for /// One status line: [text], a muted [detail], a coloured [tone] for
/// problems (warn, error), the members it is about ([who]; [self] for you) /// problems (warn, error), the members it is about ([who]; [self] for you)
@@ -55,24 +56,24 @@ String _sentence(String s) => s.isEmpty ? s : s[0].toUpperCase() + s.substring(1
/// The player's status from the session's signals; null when there is /// The player's status from the session's signals; null when there is
/// nothing to say (playing, connected, everyone ready). [slowConnect]: /// nothing to say (playing, connected, everyone ready). [slowConnect]:
/// connecting has taken over 2 s. /// connecting has taken over 2 s. [playerError]: why your player cannot
/// open the media (the coordinator's `playerError`).
StatusView? playerStatusFor({ StatusView? playerStatusFor({
required HealthStatus health, required HealthStatus health,
bool reconnecting = false, bool reconnecting = false,
String? kickReason,
StateBroadcast? state, StateBroadcast? state,
MemberView? self, MemberView? self,
bool slowConnect = false, bool slowConnect = false,
String playerError = '',
}) { }) {
if (kickReason != null) {
return StatusView(StatusKind.kicked,
kickReason.isEmpty ? 'You were removed from the server' : 'You were removed: $kickReason',
tone: HealthSeverity.error, short: 'Removed from the server');
}
if (reconnecting) { if (reconnecting) {
return const StatusView(StatusKind.reconnecting, 'Reconnecting…', return const StatusView(StatusKind.reconnecting, 'Reconnecting…',
tone: HealthSeverity.warn, action: StatusAction.cancel); tone: HealthSeverity.warn, action: StatusAction.cancel);
} }
if (playerError.isNotEmpty && state?.media != null && !(self?.out ?? false)) {
return StatusView(StatusKind.problem, "Can't play this: $playerError",
tone: HealthSeverity.error, action: StatusAction.reload, short: "Can't play this");
}
if (health.severity == HealthSeverity.warn || health.severity == HealthSeverity.error) { if (health.severity == HealthSeverity.warn || health.severity == HealthSeverity.error) {
return StatusView(StatusKind.problem, _sentence(health.message), return StatusView(StatusKind.problem, _sentence(health.message),
tone: health.severity, action: StatusAction.retry); tone: health.severity, action: StatusAction.retry);
@@ -82,23 +83,44 @@ StatusView? playerStatusFor({
} }
final r = state; final r = state;
if (r == null || r.media == null || r.transport == 'playing') return null; 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') { if (r.transport == 'finished') {
return const StatusView(StatusKind.finished, 'Finished · seek back or pick the next video', short: 'Finished'); return const StatusView(StatusKind.finished, 'Finished · seek back or pick the next video', short: 'Finished');
} }
// Members whose player cannot open it: the server does not wait for
// them; say why instead of "waiting for".
final failed = [
for (final m in r.members)
if (m.error.isNotEmpty && !m.out && (self == null || m.id != self.id)) m,
];
// As the server holds playback: someone in the ready set short of the // As the server holds playback: someone in the ready set short of the
// minimum buffer. // minimum buffer.
final need = r.bufferNeed;
for (final m in r.members) { 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 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…', 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 (failed.isNotEmpty) {
final f = failed.first;
final who = nameList([for (final m in failed) m.name]);
final unready = self != null && !self.ready;
return StatusView(StatusKind.cannotPlay, "$who can't play this: ${f.error}",
who: failed,
short: "$who can't play this",
detail: unready ? "Press Space when you're ready" : null,
action: unready ? StatusAction.ready : null);
}
if (self != null && !self.ready) { if (self != null && !self.ready) {
return StatusView(StatusKind.you, "Press Space when you're 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.error.isEmpty) m];
final others = [ final others = [
for (final m in active) for (final m in active)
if ((self == null || m.id != self.id) && !(m.inSet && m.ready)) m, if ((self == null || m.id != self.id) && !(m.inSet && m.ready)) m,
@@ -115,10 +137,10 @@ StatusView? playerStatusNow({bool slowConnect = false}) {
return playerStatusFor( return playerStatusFor(
health: c.health.value, health: c.health.value,
reconnecting: c.reconnecting.value, reconnecting: c.reconnecting.value,
kickReason: c.kickReason.value,
state: c.serverState.value, state: c.serverState.value,
self: c.selfMember.value, self: c.selfMember.value,
slowConnect: slowConnect, slowConnect: slowConnect,
playerError: c.playerError.value,
); );
} }
@@ -166,6 +188,8 @@ class _PlayerStatusState extends State<PlayerStatus> {
coord.cancelReconnect(); coord.cancelReconnect();
case StatusAction.retry: case StatusAction.retry:
coord.retry(); coord.retry();
case StatusAction.reload:
unawaited(coord.reloadVideo());
case StatusAction.ready: case StatusAction.ready:
coord.toggleReady(); coord.toggleReady();
} }
@@ -193,7 +217,7 @@ class _PlayerStatusState extends State<PlayerStatus> {
child: GestureDetector( child: GestureDetector(
key: const ValueKey('player-status'), key: const ValueKey('player-status'),
onTap: ready ? coord.toggleReady : null, onTap: ready ? coord.toggleReady : null,
child: _Glass( child: PlayerGlass(
child: AnimatedSize( child: AnimatedSize(
duration: easeTime, duration: easeTime,
curve: ease, curve: ease,
@@ -217,9 +241,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; final Widget child;
const _Glass({required this.child}); const PlayerGlass({super.key, required this.child});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -250,16 +275,15 @@ class _StatusLine extends StatelessWidget {
final ValueChanged<StatusAction> onAction; final ValueChanged<StatusAction> onAction;
const _StatusLine({super.key, required this.view, required this.coord, required this.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() { Widget? _lead() {
final v = view; final v = view;
switch (v.kind) { switch (v.kind) {
case StatusKind.kicked:
case StatusKind.problem: case StatusKind.problem:
final error = v.tone == HealthSeverity.error; final error = v.tone == HealthSeverity.error;
return Icon(error ? Icons.error_outline_rounded : Icons.warning_amber_rounded, 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: case StatusKind.reconnecting:
return const _Spinner(color: Colors.amber); return const _Spinner(color: Colors.amber);
case StatusKind.connecting: case StatusKind.connecting:
@@ -268,6 +292,7 @@ class _StatusLine extends StatelessWidget {
return null; return null;
case StatusKind.loading: case StatusKind.loading:
case StatusKind.you: case StatusKind.you:
case StatusKind.cannotPlay:
case StatusKind.waiting: case StatusKind.waiting:
final r = coord.serverState.value; final r = coord.serverState.value;
final infos = [ final infos = [
@@ -275,9 +300,26 @@ class _StatusLine extends StatelessWidget {
v.self ? selfAvatarInfo(ready: m.ready, host: m.host) : memberAvatarInfo(m, r, coord), v.self ? selfAvatarInfo(ready: m.ready, host: m.host) : memberAvatarInfo(m, r, coord),
]; ];
if (infos.isEmpty) return null; if (infos.isEmpty) return null;
return infos.length == 1 final avatar = infos.length == 1
? StatusAvatar(info: infos.first, size: _avatar, badges: false) ? StatusAvatar(info: infos.first, size: _avatar, badges: false)
: AvatarStack(members: infos, shown: 4, size: _avatar); : 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 +327,45 @@ class _StatusLine extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final v = view; final v = view;
final lead = _lead(); final lead = _lead();
final avatars = v.who.isNotEmpty;
final action = v.action == StatusAction.ready ? null : v.action; final action = v.action == StatusAction.ready ? null : v.action;
return Padding( 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( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
if (lead != null) ...[ if (lead != null) ...[
SizedBox(height: _avatar, child: Center(child: lead)), SizedBox(height: _avatar + 6, child: Center(child: lead)),
const SizedBox(width: 8), SizedBox(width: avatars ? 10 : 8),
], ],
Flexible( Flexible(
child: Text( child: ConstrainedBox(
v.text, constraints: const BoxConstraints(minHeight: _avatar),
key: const ValueKey('player-status-text'), child: Column(
maxLines: 1, mainAxisSize: MainAxisSize.min,
overflow: TextOverflow.ellipsis, mainAxisAlignment: MainAxisAlignment.center,
style: const TextStyle(color: Colors.white, fontSize: 13, fontWeight: FontWeight.w600), crossAxisAlignment: CrossAxisAlignment.start,
), children: [
), Text(
if (v.progress != null) ...[ v.text,
const SizedBox(width: 8), key: const ValueKey('player-status-text'),
SizedBox.square( maxLines: 1,
dimension: 14, overflow: TextOverflow.ellipsis,
child: CircularProgressIndicator( style: const TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.w600, height: 1.2),
value: v.progress, ),
strokeWidth: 2, if (v.detail != null)
color: Colors.white, Text(
backgroundColor: Colors.white24, 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) ...[ if (action != null) ...[
const SizedBox(width: 10), const SizedBox(width: 12),
_PillButton( _PillButton(
label: action == StatusAction.cancel ? 'Cancel' : 'Retry', label: action == StatusAction.cancel ? 'Cancel' : 'Retry',
onPressed: () => onAction(action), onPressed: () => onAction(action),
+15 -3
View File
@@ -78,10 +78,11 @@ class _ProfileCardState extends State<ProfileCard> {
final coord = session.coordinator; final coord = session.coordinator;
final online = session.route.value == AppRoute.session; final online = session.route.value == AppRoute.session;
final self = online ? coord.selfMember.value : null; 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 color = _p.color.value;
final png = _p.picture.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( return Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
@@ -166,7 +167,8 @@ class _ProfileCardState extends State<ProfileCard> {
Pill(online ? 'in a server' : 'offline', Pill(online ? 'in a server' : 'offline',
online ? Colors.greenAccent : Colors.grey), online ? Colors.greenAccent : Colors.grey),
if (online) 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(
padding: const EdgeInsets.symmetric(vertical: 6), padding: const EdgeInsets.symmetric(vertical: 6),
child: Column(children: [ 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( _link(
Icons.extension_outlined, Icons.extension_outlined,
'Jellyfin & integrations', 'Jellyfin & integrations',
+190 -24
View File
@@ -1,3 +1,6 @@
import 'dart:async';
import 'package:flutter/foundation.dart' show ValueListenable;
import 'package:flutter/gestures.dart' show kPrimaryMouseButton; import 'package:flutter/gestures.dart' show kPrimaryMouseButton;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
@@ -10,7 +13,8 @@ import '../controls/player_settings.dart';
import '../controls/uosc/buttons.dart'; import '../controls/uosc/buttons.dart';
import '../controls/uosc/controls.dart'; import '../controls/uosc/controls.dart';
import '../controls/uosc/timeline.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/jellyfin_widgets.dart';
import '../screens/session/player_controls.dart'; import '../screens/session/player_controls.dart';
import '../session/session_coordinator.dart'; import '../session/session_coordinator.dart';
@@ -18,6 +22,7 @@ import '../shaders/shader_menu.dart';
import '../shaders/shader_presets.dart'; import '../shaders/shader_presets.dart';
import '../state/session_state.dart'; import '../state/session_state.dart';
import '../state/settings.dart'; import '../state/settings.dart';
import '../widgets/app_keys.dart';
import '../widgets/shown_frame_probe.dart'; import '../widgets/shown_frame_probe.dart';
import '../widgets/text_focus.dart'; import '../widgets/text_focus.dart';
import '../widgets/window_buttons.dart'; import '../widgets/window_buttons.dart';
@@ -25,6 +30,7 @@ import 'avatar.dart';
import 'motion.dart'; import 'motion.dart';
import 'now_playing_title.dart'; import 'now_playing_title.dart';
import 'player_status.dart'; import 'player_status.dart';
import 'sit_out_card.dart';
import 'start_banner.dart'; import 'start_banner.dart';
/// Where the session's video is shown: nowhere, the floating mini player, /// 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 _toastSeq = 0;
int _shaderNoticeSeen = 0; int _shaderNoticeSeen = 0;
bool _miniHover = false; bool _miniHover = false;
// Whether the standard controls are up (tracked like media_kit does: // Whether the controls are up: the standard ones tracked like media_kit
// shown on mount and on mouse movement, hidden 3 s later or on exit). // does (shown on mount and on mouse movement, hidden 3 s later or on
final _standardShown = ValueNotifier(true); // exit), the uosc ones reporting it.
final _controlsShown = ValueNotifier(true);
SessionCoordinator get _coord => SessionState.instance.coordinator; SessionCoordinator get _coord => SessionState.instance.coordinator;
bool get _full => widget.mode == PlayerMode.full; bool get _full => widget.mode == PlayerMode.full;
@@ -109,6 +116,8 @@ class _SessionPlayerState extends State<SessionPlayer> {
_coord.jellyfin.ensureLoaded(); _coord.jellyfin.ensureLoaded();
_coord.playback.shownFrame = _frameProbe.sample; _coord.playback.shownFrame = _frameProbe.sample;
HardwareKeyboard.instance.addHandler(_globalKey); HardwareKeyboard.instance.addHandler(_globalKey);
// Tab (AppKeys) reaches the chat through here in fullscreen.
AppKeys.addChatToggle(_toggleChat);
// Shader messages (preset changes, load failures) become toasts. // Shader messages (preset changes, load failures) become toasts.
_shaderNoticeSeen = _coord.playback.shaderNotice.peek()?.id ?? 0; _shaderNoticeSeen = _coord.playback.shaderNotice.peek()?.id ?? 0;
_stopShaderWatch = effect(() { _stopShaderWatch = effect(() {
@@ -122,11 +131,12 @@ class _SessionPlayerState extends State<SessionPlayer> {
@override @override
void dispose() { void dispose() {
HardwareKeyboard.instance.removeHandler(_globalKey); HardwareKeyboard.instance.removeHandler(_globalKey);
AppKeys.removeChatToggle(_toggleChat);
final playback = _coord.playback; final playback = _coord.playback;
if (playback.shownFrame == _frameProbe.sample) playback.shownFrame = null; if (playback.shownFrame == _frameProbe.sample) playback.shownFrame = null;
_stopShaderWatch(); _stopShaderWatch();
_noFocus.dispose(); _noFocus.dispose();
_standardShown.dispose(); _controlsShown.dispose();
super.dispose(); super.dispose();
} }
@@ -152,10 +162,18 @@ class _SessionPlayerState extends State<SessionPlayer> {
} }
/// The player keys, while the player fills the body and nothing (a /// 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) { 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; 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); final route = ModalRoute.of(context);
if (route != null && !route.isCurrent) { if (route != null && !route.isCurrent) {
// A menu of ours is open: Esc closes it. // A menu of ours is open: Esc closes it.
@@ -184,11 +202,6 @@ class _SessionPlayerState extends State<SessionPlayer> {
_reloadVideo(); _reloadVideo();
return true; return true;
} }
if (key == LogicalKeyboardKey.space) {
_lastKeyAt = now;
coord.toggleReady();
return true;
}
// mpv's playlist keys (< >) and Shift+P / Shift+N: Jellyfin episodes. // mpv's playlist keys (< >) and Shift+P / Shift+N: Jellyfin episodes.
final shift = HardwareKeyboard.instance.isShiftPressed; final shift = HardwareKeyboard.instance.isShiftPressed;
if (event.character == '>' || (shift && key == LogicalKeyboardKey.keyN)) { if (event.character == '>' || (shift && key == LogicalKeyboardKey.keyN)) {
@@ -213,9 +226,9 @@ class _SessionPlayerState extends State<SessionPlayer> {
coord.skipForward(); coord.skipForward();
return true; return true;
} }
if (key == LogicalKeyboardKey.tab) { if (key == LogicalKeyboardKey.arrowUp || key == LogicalKeyboardKey.arrowDown) {
_lastKeyAt = now; final player = coord.playback.player;
_toggleChat(); player.setVolume(stepVolume(player.state.volume, key == LogicalKeyboardKey.arrowUp ? 5 : -5));
return true; return true;
} }
if (key == LogicalKeyboardKey.arrowRight) { if (key == LogicalKeyboardKey.arrowRight) {
@@ -235,7 +248,8 @@ class _SessionPlayerState extends State<SessionPlayer> {
} else if (widget.fullscreen) { } else if (widget.fullscreen) {
widget.onToggleFullscreen(); widget.onToggleFullscreen();
} else { } else {
widget.onLeave(); // One step at a time: big player → mini, then (from mini) leave.
widget.onCollapse();
} }
return true; return true;
} }
@@ -282,9 +296,12 @@ class _SessionPlayerState extends State<SessionPlayer> {
_status(), _status(),
_progressLine(), _progressLine(),
_jellyfinOverlay(), _jellyfinOverlay(),
if (widget.fullscreen) _pausedParty(), if (widget.fullscreen) ...[if (!SessionState.instance.solo.value) _pausedParty(), _unreadChip()],
] else ] else
_miniOverlay(), _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; final s = AppSettings.instance;
if (s.mpvControls.value || !s.progressLine.value) return const SizedBox.shrink(); if (s.mpvControls.value || !s.progressLine.value) return const SizedBox.shrink();
return ValueListenableBuilder<bool>( return ValueListenableBuilder<bool>(
valueListenable: _standardShown, valueListenable: _controlsShown,
builder: (context, shown, _) => shown builder: (context, shown, _) => shown
? const SizedBox.shrink() ? const SizedBox.shrink()
: const _MiniSeekBar(key: ValueKey('progress-line-standard'), thin: true), : const _MiniSeekBar(key: ValueKey('progress-line-standard'), thin: true),
@@ -437,7 +454,7 @@ class _SessionPlayerState extends State<SessionPlayer> {
onPressed: coord.playNextEpisode, onPressed: coord.playNextEpisode,
), ),
), ),
const MaterialDesktopVolumeButton(), VolumeButton(player: playback.player, focusNode: _noFocus),
_ReadyControlButton(focusNode: _noFocus), _ReadyControlButton(focusNode: _noFocus),
_BarTextButton( _BarTextButton(
focusNode: _noFocus, focusNode: _noFocus,
@@ -504,7 +521,7 @@ class _SessionPlayerState extends State<SessionPlayer> {
Widget _controlsSwitch(VideoState state) { Widget _controlsSwitch(VideoState state) {
return SessionControlsSwitch( return SessionControlsSwitch(
material: (_) => ControlsHoverProbe( material: (_) => ControlsHoverProbe(
shown: _standardShown, shown: _controlsShown,
hideAfter: _controlsHide, hideAfter: _controlsHide,
child: DoubleClickListener( child: DoubleClickListener(
onDoubleClick: widget.onToggleFullscreen, onDoubleClick: widget.onToggleFullscreen,
@@ -536,12 +553,19 @@ class _SessionPlayerState extends State<SessionPlayer> {
); );
}); });
return UoscControls( return UoscControls(
shown: _controlsShown,
onDoubleTap: widget.onToggleFullscreen, onDoubleTap: widget.onToggleFullscreen,
topLeft: const NowPlayingTitle(), topLeft: const NowPlayingTitle(),
top: Row( top: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ 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( UoscButton(
icon: widget.fullscreen ? Icons.fullscreen_exit_rounded : Icons.close_fullscreen_rounded, icon: widget.fullscreen ? Icons.fullscreen_exit_rounded : Icons.close_fullscreen_rounded,
tooltip: widget.fullscreen ? 'Exit fullscreen (Esc)' : 'Back to the library (keeps playing)', 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 /// Jellyfin: the blocked card, the end-of-episode card (countdown) and
/// the "Next episode" pill; for any media the skip pill (Skip OP / ED / /// 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() { Widget _jellyfinOverlay() {
final coord = _coord; final coord = _coord;
return Positioned.fill( return Positioned.fill(
@@ -695,7 +720,13 @@ class _SessionPlayerState extends State<SessionPlayer> {
crossAxisAlignment: CrossAxisAlignment.end, crossAxisAlignment: CrossAxisAlignment.end,
spacing: 10, spacing: 10,
children: [ 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), 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 /// Mini: title, expand and close on hover; ready, time and a seekable
/// bar at the bottom. A thin progress line stays while not hovered. /// bar at the bottom. A thin progress line stays while not hovered.
Widget _miniOverlay() { Widget _miniOverlay() {
@@ -762,7 +859,7 @@ class _SessionPlayerState extends State<SessionPlayer> {
IconButton( IconButton(
focusNode: _noFocus, focusNode: _noFocus,
onPressed: widget.onClose, 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), 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 = [ const _shaderKeys = [
LogicalKeyboardKey.digit0, LogicalKeyboardKey.digit0,
LogicalKeyboardKey.digit1, LogicalKeyboardKey.digit1,
+439 -6
View File
@@ -1,13 +1,26 @@
import 'dart:async';
import 'dart:io';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:fox_utils/fox_utils.dart' show DesktopDropX, DropTarget;
import 'package:signals/signals_flutter.dart'; 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 '../controls/player_settings.dart';
import '../jellyfin/accounts.dart'; import '../jellyfin/accounts.dart';
import '../jellyfin/accounts_panel.dart'; import '../jellyfin/accounts_panel.dart';
import '../playback/yt_dlp_app.dart';
import '../schedule/animeflow.dart'; import '../schedule/animeflow.dart';
import '../screens/home/about_page.dart'; import '../screens/home/about_page.dart';
import '../screens/home/settings_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/session_state.dart';
import '../state/settings.dart';
import '../update/update_controller.dart'; import '../update/update_controller.dart';
import 'avatar.dart'; import 'avatar.dart';
import 'motion.dart'; import 'motion.dart';
@@ -73,7 +86,36 @@ class SettingsTab extends StatelessWidget {
), ),
]; ];
case SettingsSection.network: 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: case SettingsSection.player:
return [ return [
const SettingsHintCard( const SettingsHintCard(
@@ -85,6 +127,7 @@ class SettingsTab extends StatelessWidget {
child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: playerSettingRows()), child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: playerSettingRows()),
), ),
const SettingsPanel(title: 'Custom shaders', child: CustomShaderFiles()), const SettingsPanel(title: 'Custom shaders', child: CustomShaderFiles()),
const SettingsPanel(title: 'yt-dlp', child: YtDlpSettings()),
]; ];
case SettingsSection.integrations: case SettingsSection.integrations:
final accounts = jellyfin ?? JellyfinAccounts.instance; 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 /// AnimeFlow (the Schedule tab's source): connected as whom, with which
/// rights, or why not. /// rights, or why not.
class AnimeFlowCard extends StatefulWidget { class AnimeFlowCard extends StatefulWidget {
@@ -424,18 +746,129 @@ class NotificationsInfo extends StatelessWidget {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ 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'), '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('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'), line('App notices', 'Shader changes, a frozen video reloaded, the idle warning'),
], ],
), ),
), ),
SettingsPanel( const SettingsPanel(title: 'Sounds', child: SoundSettings()),
title: 'Sounds',
child: line('No sounds yet', 'The app is silent; only the video makes noise'),
),
], ],
); );
} }
} }
/// 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,
),
],
);
}),
);
}
}
+9 -5
View File
@@ -26,9 +26,11 @@ class StartCard extends StatelessWidget {
final episode = now.episode; final episode = now.episode;
return LayoutBuilder(builder: (context, box) { return LayoutBuilder(builder: (context, box) {
final h = box.maxHeight; 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 coverWidth = (h * 0.26).clamp(84.0, 240.0) / 1.5;
final titleSize = (h * 0.05).clamp(22.0, 44.0); final titleSize = small ? (h * 0.08).clamp(13.0, 18.0) : (h * 0.05).clamp(22.0, 44.0);
final side = (box.maxWidth * 0.05).clamp(20.0, 72.0); final side = small ? 12.0 : (box.maxWidth * 0.05).clamp(20.0, 72.0);
return Stack( return Stack(
fit: StackFit.expand, fit: StackFit.expand,
children: [ children: [
@@ -76,12 +78,14 @@ class StartCard extends StatelessWidget {
Positioned( Positioned(
left: side, left: side,
right: side, right: side,
bottom: startCardBottom, bottom: small ? 12 : startCardBottom,
child: Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.end, crossAxisAlignment: CrossAxisAlignment.end,
children: [ children: [
ArtCover(hash: now.posterHash, width: coverWidth), if (!small) ...[
const SizedBox(width: 24), ArtCover(hash: now.posterHash, width: coverWidth),
const SizedBox(width: 24),
],
Expanded( Expanded(
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
+39 -10
View File
@@ -34,19 +34,24 @@ ShellToast shellToastFor(ToastItem t, AvatarInfo Function(String name) avatar) {
? Icons.login ? Icons.login
: rest.startsWith('left') : rest.startsWith('left')
? Icons.logout ? Icons.logout
: rest.startsWith('kicked') : rest.startsWith('sat you out')
? Icons.gavel ? Icons.bedtime
: rest.startsWith('changed') : rest.startsWith('nudged')
? Icons.swap_horiz ? Icons.notifications_active_outlined
: Icons.info_outline; : rest.startsWith('changed')
? Icons.swap_horiz
: Icons.info_outline;
return ShellToast(id: t.id, who: avatar(n), text: rest, icon: icon); return ShellToast(id: t.id, who: avatar(n), text: rest, icon: icon);
} }
/// The coordinator's toasts as cards with avatars, bottom-left: each /// 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 { class PartyToasts extends StatefulWidget {
final Duration life; 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 @override
State<PartyToasts> createState() => _PartyToastsState(); State<PartyToasts> createState() => _PartyToastsState();
@@ -101,13 +106,37 @@ class _PartyToastsState extends State<PartyToasts> {
super.dispose(); super.dispose();
} }
void _open(ShellToast t) {
widget.onOpenChat?.call();
setState(() => t.leaving = true);
}
@override @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 { class ToastColumn extends StatelessWidget {
final List<ShellToast> toasts; 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -139,7 +168,7 @@ class ToastColumn extends StatelessWidget {
duration: easeTime, duration: easeTime,
curve: ease, curve: ease,
opacity: t.leaving ? 0 : 1, 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)),
), ),
), ),
), ),
+347
View File
@@ -0,0 +1,347 @@
import 'package:flutter/material.dart';
import '../jellyfin/accounts.dart';
import '../jellyfin/sign_in_panel.dart';
import '../screens/home/ui.dart' show appName;
import 'motion.dart';
/// One line under the app name.
const appTagline = 'Watch together, in sync';
/// Window width from which the welcome band puts the three ways beside
/// the app name (else under it).
const welcomeWideFrom = 980.0;
/// Top of the Library while not in a server: the app's name and the three
/// ways in, Join a friend first (what most people came for), then Host and
/// Watch solo. None of them needs Jellyfin.
class WelcomeBand extends StatelessWidget {
final VoidCallback onJoin;
final VoidCallback onHost;
final VoidCallback onSolo;
final VoidCallback? onDismiss;
const WelcomeBand({super.key, required this.onJoin, required this.onHost, required this.onSolo, this.onDismiss});
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final t = Theme.of(context).textTheme;
final hero = Row(
mainAxisSize: MainAxisSize.min,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(14),
child: Image.asset('assets/icon/icon_256.png', width: 52, height: 52, filterQuality: FilterQuality.medium),
),
const SizedBox(width: 14),
Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(appName, style: t.titleLarge?.copyWith(fontWeight: FontWeight.w700, height: 1.1)),
const SizedBox(height: 2),
Text(appTagline, style: TextStyle(color: scheme.onSurfaceVariant)),
if (onDismiss != null)
TextButton(
key: const ValueKey('welcome-dismiss'),
onPressed: onDismiss,
style: TextButton.styleFrom(
padding: EdgeInsets.zero,
minimumSize: const Size(0, 24),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
foregroundColor: scheme.onSurfaceVariant,
textStyle: const TextStyle(fontSize: 12),
),
child: const Text("Don't show again"),
),
],
),
],
);
final ways = [
WayCard(
key: const ValueKey('way-join'),
icon: Icons.login_rounded,
title: 'Join a friend',
caption: 'Got an address from a friend? Join with it. Nothing to set up.',
primary: true,
onTap: onJoin,
),
WayCard(
key: const ValueKey('way-host'),
icon: Icons.podcasts_rounded,
title: 'Host',
caption: 'You have the video. Friends join you and watch it with you.',
onTap: onHost,
),
WayCard(
key: const ValueKey('way-solo'),
icon: Icons.person_outline_rounded,
title: 'Watch solo',
caption: 'Just you: play anything below. Invite friends any time.',
onTap: onSolo,
),
];
Widget row(List<Widget> cards) => IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (final (i, c) in cards.indexed) ...[if (i > 0) const SizedBox(width: 10), Expanded(child: c)],
],
),
);
final band = Container(
key: const ValueKey('welcome-band'),
margin: const EdgeInsets.fromLTRB(20, 16, 20, 0),
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(18),
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [scheme.primary.withValues(alpha: 0.16), scheme.primary.withValues(alpha: 0.03)],
),
border: Border.all(color: scheme.outlineVariant.withValues(alpha: 0.4)),
),
child: LayoutBuilder(builder: (context, box) {
if (box.maxWidth >= welcomeWideFrom - 40) {
return Row(
children: [
Padding(padding: const EdgeInsets.only(left: 4, right: 20), child: hero),
Expanded(child: row(ways)),
],
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(padding: const EdgeInsets.only(left: 4, bottom: 12), child: hero),
if (box.maxWidth >= 520)
row(ways)
else
for (final (i, c) in ways.indexed) ...[if (i > 0) const SizedBox(height: 8), c],
],
);
}),
);
return band;
}
}
/// One way in: a tinted icon, a title and a one-line caption.
class WayCard extends StatefulWidget {
final IconData icon;
final String title;
final String caption;
final bool primary;
final VoidCallback onTap;
const WayCard({
super.key,
required this.icon,
required this.title,
required this.caption,
required this.onTap,
this.primary = false,
});
@override
State<WayCard> createState() => _WayCardState();
}
class _WayCardState extends State<WayCard> {
bool _hover = false;
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final primary = widget.primary;
final bg = primary ? scheme.primaryContainer : scheme.surfaceContainerHigh;
final fg = primary ? scheme.onPrimaryContainer : scheme.onSurface;
return MouseRegion(
onEnter: (_) => setState(() => _hover = true),
onExit: (_) => setState(() => _hover = false),
child: AnimatedScale(
duration: easeTime,
curve: ease,
scale: _hover ? 1.015 : 1,
child: Material(
color: bg,
borderRadius: BorderRadius.circular(14),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: widget.onTap,
child: Padding(
padding: const EdgeInsets.fromLTRB(12, 12, 14, 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: primary ? scheme.primary : scheme.primary.withValues(alpha: 0.14),
borderRadius: BorderRadius.circular(10),
),
child: Icon(widget.icon, size: 20, color: primary ? scheme.onPrimary : scheme.primary),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(widget.title, style: TextStyle(fontWeight: FontWeight.w700, color: fg)),
const SizedBox(height: 2),
Text(widget.caption,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: 12, height: 1.3, color: fg.withValues(alpha: 0.72))),
],
),
),
],
),
),
),
),
),
);
}
}
/// The Library's Jellyfin source with no server linked: what plays with
/// nothing set up (Files, Link), then Jellyfin as an optional extra that
/// guests never need. The sign-in opens in place.
class NoJellyfinPanel extends StatefulWidget {
final JellyfinAccounts accounts;
final VoidCallback onFiles;
final VoidCallback onLink;
const NoJellyfinPanel({super.key, required this.accounts, required this.onFiles, required this.onLink});
@override
State<NoJellyfinPanel> createState() => _NoJellyfinPanelState();
}
class _NoJellyfinPanelState extends State<NoJellyfinPanel> {
bool _signIn = false;
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final t = Theme.of(context).textTheme;
final muted = TextStyle(color: scheme.onSurfaceVariant);
final sources = [
WayCard(
key: const ValueKey('source-files'),
icon: Icons.folder_outlined,
title: 'Files',
caption: 'Videos on this computer. Drop a file or folder anywhere.',
onTap: widget.onFiles,
),
WayCard(
key: const ValueKey('source-link'),
icon: Icons.link,
title: 'Link',
caption: 'YouTube, X, or any video address. Ctrl+V anywhere.',
onTap: widget.onLink,
),
];
return SingleChildScrollView(
key: const ValueKey('media-jellyfin'),
padding: const EdgeInsets.only(bottom: 24),
child: Align(
alignment: Alignment.topLeft,
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 640),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('Pick something to watch', style: t.titleMedium),
const SizedBox(height: 4),
Text('No account needed: these work for everyone.', style: muted),
const SizedBox(height: 12),
LayoutBuilder(
builder: (context, box) => box.maxWidth < 460
? Column(children: [sources[0], const SizedBox(height: 8), sources[1]])
: IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(child: sources[0]),
const SizedBox(width: 10),
Expanded(child: sources[1]),
],
),
),
),
const SizedBox(height: 20),
Material(
key: const ValueKey('jellyfin-optional'),
color: scheme.surfaceContainer,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
side: BorderSide(color: scheme.outlineVariant.withValues(alpha: 0.5)),
),
clipBehavior: Clip.antiAlias,
child: AnimatedSize(
duration: easeTime,
curve: ease,
alignment: Alignment.topCenter,
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Icon(Icons.video_library_outlined, color: scheme.onSurfaceVariant),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Wrap(spacing: 8, crossAxisAlignment: WrapCrossAlignment.center, children: [
const Text('Jellyfin', style: TextStyle(fontWeight: FontWeight.w600)),
Container(
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 1),
decoration: BoxDecoration(
color: scheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(6),
),
child: Text('optional', style: TextStyle(fontSize: 11, color: scheme.onSurfaceVariant)),
),
]),
Text(
'Only if you have a Jellyfin server: browse it here, '
'with progress and next episodes. Joining a friend never needs it.',
style: muted.copyWith(fontSize: 12),
),
],
),
),
],
),
if (!_signIn)
Align(
alignment: Alignment.centerRight,
child: TextButton(
key: const ValueKey('jellyfin-link'),
onPressed: () => setState(() => _signIn = true),
child: const Text('Link a server'),
),
),
if (_signIn) ...[
const SizedBox(height: 14),
JellyfinSignInPanel(accounts: widget.accounts, onCancel: () => setState(() => _signIn = false)),
],
],
),
),
),
),
],
),
),
),
);
}
}
@@ -33,8 +33,10 @@ class ServerIsolate {
/// Spawns the server and waits until it listens. Throws a /// Spawns the server and waits until it listens. Throws a
/// [StateError] with the server's message if it cannot start. /// [StateError] with the server's message if it cannot start.
/// [loopbackOnly]: 127.0.0.1 only (the solo session).
static Future<ServerIsolate> start( static Future<ServerIsolate> start(
AppConfig config, { AppConfig config, {
bool loopbackOnly = false,
required void Function(String line) onLog, required void Function(String line) onLog,
required void Function(String error) onCrash, required void Function(String error) onCrash,
Duration timeout = const Duration(seconds: 15), Duration timeout = const Duration(seconds: 15),
@@ -83,7 +85,7 @@ class ServerIsolate {
final Isolate isolate; final Isolate isolate;
try { try {
isolate = await Isolate.spawn( isolate = await Isolate.spawn(
_serverMain, (config.toMap(), events.sendPort), _serverMain, ({...config.toMap(), if (loopbackOnly) 'loopbackOnly': true}, events.sendPort),
errorsAreFatal: false, errorsAreFatal: false,
onError: errors.sendPort, onError: errors.sendPort,
onExit: exit.sendPort, onExit: exit.sendPort,
+156 -23
View File
@@ -8,12 +8,18 @@ import 'package:sync_relay_core/core.dart';
import '../jellyfin/accounts.dart'; import '../jellyfin/accounts.dart';
import '../jellyfin/session_media.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/sfx.dart';
import '../sfx/sound_bank.dart';
import '../sfx/synth_sfx.dart';
import '../session/health.dart'; import '../session/health.dart';
import '../session/session_coordinator.dart'; import '../session/session_coordinator.dart';
import '../widgets/avatar_pipeline.dart'; import '../widgets/avatar_pipeline.dart';
import 'connect_prefs.dart'; import 'connect_prefs.dart';
import 'server_isolate.dart'; import 'server_isolate.dart';
import 'settings.dart';
enum AppRoute { enum AppRoute {
home, 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 /// 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() { String newHostKey() {
final r = Random.secure(); final r = Random.secure();
return base64Url.encode([for (var i = 0; i < 24; i++) r.nextInt(256)]); 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 route = signal(AppRoute.home);
final recents = signal<List<RecentServer>>(const []); final recents = signal<List<RecentServer>>(const []);
final status = signal(const HealthStatus(HealthSeverity.info, 'idle')); 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); late final SessionCoordinator coordinator = SessionCoordinator(sfx: sfx);
final lastForm = ConnectForm(); final lastForm = ConnectForm();
JellyfinAccounts get jellyfin => JellyfinAccounts.instance; JellyfinAccounts get jellyfin => JellyfinAccounts.instance;
/// Set when the hosted server died mid-session; Home shows it on the /// Set when the hosted server died mid-session; Home shows it on the
/// Host card and clears it. /// Host card and clears it.
final serverCrash = signal<String?>(null); 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; ServerIsolate? _server;
String? _setupCrash; String? _setupCrash;
@visibleForTesting @visibleForTesting
ServerIsolate? get hostedServer => _server; ServerIsolate? get hostedServer => _server;
void Function()? _stopKickWatch;
// Bumped by host/join/cancelPending: a cancelled setup notices at its // Bumped by host/join/cancelPending: a cancelled setup notices at its
// next step and unwinds instead of connecting. // next step and unwinds instead of connecting.
int _op = 0; int _op = 0;
@@ -225,9 +255,102 @@ class SessionState {
await _saveFormQuietly(); 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 { 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; final op = ++_op;
_rememberForm(form); if (form != null) _rememberForm(form);
try { try {
status.value = status.value =
const HealthStatus(HealthSeverity.info, 'starting server…'); const HealthStatus(HealthSeverity.info, 'starting server…');
@@ -247,13 +370,15 @@ class SessionState {
try { try {
server = await ServerIsolate.start( server = await ServerIsolate.start(
AppConfig( AppConfig(
port: params.port, port: soloSession ? 0 : params.port,
secret: params.secret, secret: soloSession ? '' : params.secret,
name: 'server', name: 'server',
mediaPath: params.mediaPath, mediaPath: params.mediaPath,
hostKey: hostKey, hostKey: hostKey,
jellyfin: relay, jellyfin: relay,
ytDlpDir: await _sharedYtDlpDir(),
), ),
loopbackOnly: soloSession,
onLog: (line) => debugPrint('[server] $line'), onLog: (line) => debugPrint('[server] $line'),
onCrash: (cause) => _onServerCrash(server, cause), onCrash: (cause) => _onServerCrash(server, cause),
); );
@@ -270,13 +395,13 @@ class SessionState {
_server = server; _server = server;
coordinator.hostFile = (path) async => coordinator.hostFile = (path) async =>
identical(_server, server) ? server.setFile(path) : null; identical(_server, server) ? server.setFile(path) : null;
status.value = status.value = HealthStatus(
HealthStatus(HealthSeverity.ok, 'hosting on port ${server.port}'); HealthSeverity.ok, soloSession ? 'watching solo' : 'hosting on port ${server.port}');
try { try {
await coordinator.connect(ConnectParams( await coordinator.connect(ConnectParams(
host: '127.0.0.1', host: '127.0.0.1',
port: server.port, port: server.port,
secret: params.secret, secret: soloSession ? '' : params.secret,
name: params.name, name: params.name,
avatar: params.avatar, avatar: params.avatar,
hostKey: hostKey, hostKey: hostKey,
@@ -290,10 +415,11 @@ class SessionState {
if (crash != null && e is! ConnectCancelled) throw ServerCrashed(crash); if (crash != null && e is! ConnectCancelled) throw ServerCrashed(crash);
rethrow; rethrow;
} }
_watchKick(); solo.value = soloSession;
coordinator.solo = soloSession;
route.value = AppRoute.session; route.value = AppRoute.session;
} finally { } finally {
await _saveFormQuietly(); if (form != null) await _saveFormQuietly();
} }
} }
@@ -319,6 +445,11 @@ class SessionState {
await coordinator.connect(params); await coordinator.connect(params);
if (op != _op) throw const ConnectCancelled(); if (op != _op) throw const ConnectCancelled();
route.value = AppRoute.session; 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 { } finally {
await _saveFormQuietly(); await _saveFormQuietly();
} }
@@ -331,16 +462,9 @@ class SessionState {
try { try {
await coordinator.cancelConnect(); await coordinator.cancelConnect();
} catch (_) {} } catch (_) {}
await stopServer(); // Solo becoming hosted: [host] stops what it started and goes back
} // to solo itself.
if (!switching.peek()) 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);
});
} }
/// Saved whatever the outcome: a failed join is exactly when the user /// Saved whatever the outcome: a failed join is exactly when the user
@@ -368,8 +492,6 @@ class SessionState {
Future<void> stopServer() async { Future<void> stopServer() async {
coordinator.hostFile = null; coordinator.hostFile = null;
_stopKickWatch?.call();
_stopKickWatch = null;
final server = _server; final server = _server;
_server = null; _server = null;
await server?.stop(); await server?.stop();
@@ -378,7 +500,18 @@ class SessionState {
Future<void> leave() async { Future<void> leave() async {
await coordinator.leave(); await coordinator.leave();
await stopServer(); await stopServer();
solo.value = false;
coordinator.solo = false;
serverLabel.value = '';
status.value = const HealthStatus(HealthSeverity.info, 'idle'); status.value = const HealthStatus(HealthSeverity.info, 'idle');
route.value = AppRoute.home; 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;
}
+80
View File
@@ -20,6 +20,13 @@ const _keyAutoAdvance = 'autoAdvance';
const _keyMpvControls = 'mpvControls'; const _keyMpvControls = 'mpvControls';
const _keyHideReReleases = 'hideReReleases'; const _keyHideReReleases = 'hideReReleases';
const _keyProgressLine = 'progressLine'; 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 } enum HwdecMode { auto, off, custom }
@@ -31,6 +38,11 @@ class AppSettings {
final autoCheckUpdates = signal(true); final autoCheckUpdates = signal(true);
/// Jellyfin episodes: at the end, count down 10 s and put up the next. /// Jellyfin episodes: at the end, count down 10 s and put up the next.
final autoAdvance = signal(true); 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 /// Player design: the uosc controls (`controls/uosc/`) instead of the
/// standard ones (media_kit's with our bars). Applies live. /// standard ones (media_kit's with our bars). Applies live.
final mpvControls = signal(false); final mpvControls = signal(false);
@@ -40,6 +52,17 @@ class AppSettings {
/// A 2 px progress line at the bottom of the video while the controls /// A 2 px progress line at the bottom of the video while the controls
/// are hidden (both designs). Applies live. /// are hidden (both designs). Applies live.
final progressLine = signal(true); 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 /// Video shaders: one choice, used on join and changed both here and
/// in the session (Ctrl+0..6, the shader button). /// in the session (Ctrl+0..6, the shader button).
@@ -76,12 +99,31 @@ class AppSettings {
if (auto is bool) autoCheckUpdates.value = auto; if (auto is bool) autoCheckUpdates.value = auto;
final advance = box.get(_keyAutoAdvance); final advance = box.get(_keyAutoAdvance);
if (advance is bool) autoAdvance.value = advance; 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); final mpv = box.get(_keyMpvControls);
if (mpv is bool) mpvControls.value = mpv; if (mpv is bool) mpvControls.value = mpv;
final hideRe = box.get(_keyHideReReleases); final hideRe = box.get(_keyHideReReleases);
if (hideRe is bool) hideReReleases.value = hideRe; if (hideRe is bool) hideReReleases.value = hideRe;
final line = box.get(_keyProgressLine); final line = box.get(_keyProgressLine);
if (line is bool) progressLine.value = line; 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); final preset = box.get(_keyShaderPreset);
shaderPreset.value = shaderPreset.value =
ShaderPreset.values.where((p) => p.name == preset).firstOrNull ?? ShaderPreset.values.where((p) => p.name == preset).firstOrNull ??
@@ -119,6 +161,16 @@ class AppSettings {
_put(_keyAutoAdvance, on); _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) { void setMpvControls(bool on) {
mpvControls.value = on; mpvControls.value = on;
_put(_keyMpvControls, on); _put(_keyMpvControls, on);
@@ -129,6 +181,34 @@ class AppSettings {
_put(_keyProgressLine, on); _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) { void setHideReReleases(bool on) {
hideReReleases.value = on; hideReReleases.value = on;
_put(_keyHideReReleases, on); _put(_keyHideReReleases, on);
+10 -2
View File
@@ -43,6 +43,10 @@ bool localPlayAllowed(StateBroadcast state, String memberId) {
return false; return false;
} }
/// At `finished`, a player at most this far behind the end keeps playing
/// to it.
const finishSlackSecs = 3.0;
class StateDiff { class StateDiff {
static const debounceWindow = Duration(seconds: 2); 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 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') { if (state.transport == 'finished') {
final first = _lastEventId == -1; final first = _lastEventId == -1;
final changed = first || final changed = first ||
@@ -87,7 +93,9 @@ class StateDiff {
if (!changed) return out; if (!changed) return out;
out.add(const RateAction(1.0)); out.add(const RateAction(1.0));
if (first) out.add(SeekAction(serverPos > 1 ? serverPos - 1 : 0)); if (first) out.add(SeekAction(serverPos > 1 ? serverPos - 1 : 0));
out.add(const TransportAction(false)); if (first || state.position - ownPosition > finishSlackSecs) {
out.add(const TransportAction(false));
}
return out; return out;
} }
+18 -13
View File
@@ -4,7 +4,7 @@ import 'dart:convert';
import 'package:sync_relay_core/core.dart'; import 'package:sync_relay_core/core.dart';
import 'package:web_socket_channel/web_socket_channel.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 { class SyncClient {
/// Local clock; injectable so tests can run a client whose clock is off. /// Local clock; injectable so tests can run a client whose clock is off.
@@ -23,13 +23,14 @@ class SyncClient {
StreamSubscription? _sub; StreamSubscription? _sub;
final StreamController<StateBroadcast> _states = StreamController.broadcast(); final StreamController<StateBroadcast> _states = StreamController.broadcast();
final StreamController<FeedMessage> _feeds = 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<String> _errors = StreamController.broadcast();
final StreamController<SyncStatus> _status = StreamController.broadcast(); final StreamController<SyncStatus> _status = StreamController.broadcast();
Stream<StateBroadcast> get states => _states.stream; Stream<StateBroadcast> get states => _states.stream;
Stream<FeedMessage> get feeds => _feeds.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<String> get errors => _errors.stream;
Stream<SyncStatus> get status => _status.stream; Stream<SyncStatus> get status => _status.stream;
@@ -153,14 +154,10 @@ class SyncClient {
// one toast, one sfx. // one toast, one sfx.
if (f.action != 'chat') _feeds.add(f); if (f.action != 'chat') _feeds.add(f);
} catch (_) {} } catch (_) {}
case 'kicked': case 'nudge':
try { try {
final msg = KickedMessage.fromJson(decoded); _nudges.add(NudgeNotice.fromJson(decoded));
_kicked.add(msg.reason); } catch (_) {}
_status.add(SyncStatus.kicked);
} catch (_) {
_kicked.add('');
}
case 'pong': case 'pong':
try { try {
final pong = PongMessage.fromJson(decoded); final pong = PongMessage.fromJson(decoded);
@@ -222,6 +219,7 @@ class SyncClient {
double duration = 0, double duration = 0,
int mediaId = 0, int mediaId = 0,
Map<String, bool>? jf, Map<String, bool>? jf,
String error = '',
}) { }) {
final safeBuffered = buffered.isNaN || buffered < 0 ? 0.0 : buffered; final safeBuffered = buffered.isNaN || buffered < 0 ? 0.0 : buffered;
_send( _send(
@@ -232,6 +230,7 @@ class SyncClient {
duration: duration.isFinite && duration > 0 ? duration : 0, duration: duration.isFinite && duration > 0 ? duration : 0,
mediaId: mediaId, mediaId: mediaId,
jf: jf, jf: jf,
error: error,
).toJson(), ).toJson(),
); );
} }
@@ -255,8 +254,14 @@ class SyncClient {
_send(ArtMessage(mediaId: mediaId, poster: poster, banner: banner).toJson()); _send(ArtMessage(mediaId: mediaId, poster: poster, banner: banner).toJson());
} }
void sendKick(String target, {String reason = ''}) { /// Sits the member [target] out, or with [out] false brings this
_send(KickRequest(target: target, reason: reason).toJson()); /// 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 { Future<void> disconnect() async {
@@ -283,7 +288,7 @@ class SyncClient {
await disconnect(); await disconnect();
await _states.close(); await _states.close();
await _feeds.close(); await _feeds.close();
await _kicked.close(); await _nudges.close();
await _errors.close(); await _errors.close();
await _status.close(); await _status.close();
} }
@@ -1,4 +1,4 @@
@echo off @echo off
rem Update this install in place (same as clicking Update in the app). rem Update this install in place (same as clicking Update in the app).
"%~dp0watch_together.exe" --upgrade %* "%~dp0watch-together-server.exe" --upgrade %*
pause pause
+2 -1
View File
@@ -1,3 +1,4 @@
#!/bin/sh #!/bin/sh
# Update this install in place (same as clicking Update in the app). # 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:hive_ce_flutter/hive_flutter.dart';
import 'package:sync_relay_core/core.dart'; import 'package:sync_relay_core/core.dart';
import 'updater.dart';
// In-app update flow behind the Home update indicator. Same code path as // In-app update flow behind the Home update indicator. Same code path as
// `--upgrade` (downloadAndApply); this adds state for the UI and the // `--upgrade` (core's updater, downloadAndApply); this adds state for the
// restart. Check failures are logged only — the banner shows up just when // UI and the restart. Check failures are logged only — the banner shows up just when
// there is something to install. // there is something to install.
final _log = createLogger('update'); final _log = createLogger('update');
@@ -27,6 +25,19 @@ class UpdateStatus {
{this.manifest, this.progress, this.error, this.canInstall = false}); {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); typedef RestartFn = Future<void> Function(String executable, List<String> args);
// Release Hive's box locks before the new instance opens the same boxes. // Release Hive's box locks before the new instance opens the same boxes.
+103
View File
@@ -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;
}
-22
View File
@@ -1,22 +0,0 @@
import 'package:flutter/material.dart';
Future<bool?> showKickConfirmDialog(
BuildContext context, String memberName) {
return showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text('Remove $memberName?'),
content: const Text('They can come straight back in.'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('Remove'),
),
],
),
);
}
@@ -1,7 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:sync_relay_core/core.dart';
import '../update/update_controller.dart'; import '../update/update_controller.dart';
import '../update/updater.dart';
// "Update available" card (Home update indicator dialog). Kicks off the one // "Update available" card (Home update indicator dialog). Kicks off the one
// background check on first build; renders nothing until there is an // background check on first build; renders nothing until there is an
+1 -1
View File
@@ -7,7 +7,7 @@ project(runner LANGUAGES CXX)
set(BINARY_NAME "watch_together") set(BINARY_NAME "watch_together")
# The unique GTK application identifier for this application. See: # The unique GTK application identifier for this application. See:
# https://wiki.gnome.org/HowDoI/ChooseApplicationID # 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 # Explicitly opt in to modern CMake behaviors to avoid warnings with recent
# versions of CMake. # versions of CMake.
@@ -6,12 +6,16 @@
#include "generated_plugin_registrant.h" #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_libs_linux/media_kit_libs_linux_plugin.h>
#include <media_kit_video/media_kit_video_plugin.h> #include <media_kit_video/media_kit_video_plugin.h>
#include <screen_retriever_linux/screen_retriever_linux_plugin.h> #include <screen_retriever_linux/screen_retriever_linux_plugin.h>
#include <window_manager/window_manager_plugin.h> #include <window_manager/window_manager_plugin.h>
void fl_register_plugins(FlPluginRegistry* registry) { 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 = g_autoptr(FlPluginRegistrar) media_kit_libs_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "MediaKitLibsLinuxPlugin"); fl_plugin_registry_get_registrar_for_plugin(registry, "MediaKitLibsLinuxPlugin");
media_kit_libs_linux_plugin_register_with_registrar(media_kit_libs_linux_registrar); media_kit_libs_linux_plugin_register_with_registrar(media_kit_libs_linux_registrar);
@@ -3,6 +3,7 @@
# #
list(APPEND FLUTTER_PLUGIN_LIST list(APPEND FLUTTER_PLUGIN_LIST
fox_utils
media_kit_libs_linux media_kit_libs_linux
media_kit_video media_kit_video
screen_retriever_linux screen_retriever_linux
+50 -2
View File
@@ -10,6 +10,8 @@
struct _MyApplication { struct _MyApplication {
GtkApplication parent_instance; GtkApplication parent_instance;
char** dart_entrypoint_arguments; char** dart_entrypoint_arguments;
// "starsync/window" (lib/src/platform/attention.dart).
FlMethodChannel* window_channel;
}; };
G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION) 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))); 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. // Implements GApplication::activate.
static void my_application_activate(GApplication* application) { static void my_application_activate(GApplication* application) {
MyApplication* self = MY_APPLICATION(application); MyApplication* self = MY_APPLICATION(application);
@@ -45,15 +70,28 @@ static void my_application_activate(GApplication* application) {
if (use_header_bar) { if (use_header_bar) {
GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new()); GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new());
gtk_widget_show(GTK_WIDGET(header_bar)); 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_header_bar_set_show_close_button(header_bar, TRUE);
gtk_window_set_titlebar(window, GTK_WIDGET(header_bar)); gtk_window_set_titlebar(window, GTK_WIDGET(header_bar));
} else { } else {
gtk_window_set_title(window, "watch_together"); gtk_window_set_title(window, "Starsync");
} }
gtk_window_set_default_size(window, 1280, 720); 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(); g_autoptr(FlDartProject) project = fl_dart_project_new();
fl_dart_project_set_dart_entrypoint_arguments( fl_dart_project_set_dart_entrypoint_arguments(
project, self->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)); 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)); 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) { static void my_application_dispose(GObject* object) {
MyApplication* self = MY_APPLICATION(object); MyApplication* self = MY_APPLICATION(object);
g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev); g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev);
g_clear_object(&self->window_channel);
G_OBJECT_CLASS(my_application_parent_class)->dispose(object); G_OBJECT_CLASS(my_application_parent_class)->dispose(object);
} }
+106 -52
View File
@@ -5,18 +5,18 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: android_file_picker name: android_file_picker
sha256: "014c74ab48d452c3252465682375a7fe6ddf56abb908ec361f207a3c4ffb2444" sha256: "537bce9a43bc6fc08670cb68498251acc6eb9134b86a391b43e1f7c863588388"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.1.1" version: "2.0.1"
archive: archive:
dependency: "direct main" dependency: transitive
description: description:
name: archive name: archive
sha256: ace891da0862b0e4cabbb064ee3fd87b2728b898949fdb366d83fe98342c9f19 sha256: "3aa20c6b5617b632a1b7198dcca3d88065d37232485b9a6c6c14dc8f5357d279"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.2.0" version: "4.4.0"
args: args:
dependency: transitive dependency: transitive
description: description:
@@ -61,10 +61,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: code_assets name: code_assets
sha256: cfd4f5f575a49c5f10ca856e9846073f1e6c3ee94912377eea5f6cefc5272941 sha256: "828110d598123b5ea96c00c9f3c72105bf79f8ee36c20a39b26209ade421ec57"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.0.0" version: "2.1.0"
collection: collection:
dependency: transitive dependency: transitive
description: description:
@@ -97,14 +97,38 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.7" version: "3.0.7"
cupertino_ui:
dependency: transitive
description:
name: cupertino_ui
sha256: ea0b8170b40a86262b2fc21ac0625303f52df209dbf6fccc2b98b27eff44f6ce
url: "https://pub.dev"
source: hosted
version: "1.1.2"
dbus: dbus:
dependency: transitive dependency: transitive
description: description:
name: dbus name: dbus
sha256: a48d5da28e89bd02196e80d81ed8d7954923d00a0f4a68cc20b575038f023383 sha256: f7db8504e715b835f5ebe5fe74604ccd961d504c73389a5d8e81bafc2e2f3612
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.15" version: "0.8.0"
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: fake_async:
dependency: transitive dependency: transitive
description: description:
@@ -114,7 +138,7 @@ packages:
source: hosted source: hosted
version: "1.3.3" version: "1.3.3"
ffi: ffi:
dependency: transitive dependency: "direct main"
description: description:
name: ffi name: ffi
sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45"
@@ -133,42 +157,42 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: file_picker name: file_picker
sha256: "9be6aac79508dbcf8dac80a4fb20f27ce6f910837ca27e3670b9295995cc0110" sha256: "98c0b156b6380ba55bc767a14e2e6b3feb246e713ad40092424596fa79005bea"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "12.3.0" version: "13.1.0"
file_picker_darwin: file_picker_darwin:
dependency: transitive dependency: transitive
description: description:
name: file_picker_darwin name: file_picker_darwin
sha256: "59fa5394cfa5b6dc8bf491630cb17077b77b844adbb840df371e7be5355d6cf6" sha256: "3df1dc8e881de9ffd3953c9e482ac5d6e1ffe1e7f7b231b5695543e251ab7918"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.2.0" version: "2.1.3"
file_picker_linux: file_picker_linux:
dependency: transitive dependency: transitive
description: description:
name: file_picker_linux name: file_picker_linux
sha256: bd52ff1e0048f29df95f913c55ad2991c72791d93e6c42241912c4bdee946cb9 sha256: c6562fc7d426db2fb85ea66fe2627953d2709932df5584ceaacf0aa5e39cf4da
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.1.0" version: "2.0.2"
file_picker_platform_interface: file_picker_platform_interface:
dependency: transitive dependency: transitive
description: description:
name: file_picker_platform_interface name: file_picker_platform_interface
sha256: "0355558fd9af6da499d18e333d6b3beb44b0bf19e00933fccd15d18eb8d0e9ca" sha256: bbdc085a6f168e63f147e9ce8988f79fa0800a58e7f25a48f762c60837107ba5
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.4.0" version: "4.0.0"
file_picker_web: file_picker_web:
dependency: transitive dependency: transitive
description: description:
name: file_picker_web name: file_picker_web
sha256: "935560a9d29fa6f006f2855addebb88e88d438e13627d4cc24187033c106f227" sha256: "32af8262aca907b02b8970b32328b0312db0b42ba6f930be6d27fde179d59b7d"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.1.0" version: "4.1.0"
fixnum: fixnum:
dependency: transitive dependency: transitive
description: description:
@@ -194,10 +218,15 @@ packages:
dependency: "direct dev" dependency: "direct dev"
description: description:
name: flutter_lints name: flutter_lints
sha256: "5398f14efa795ffb7a33e9b6a08798b26a180edac4ad7db3f231e40f82ce11e1" sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "5.0.0" version: "6.0.0"
flutter_localizations:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
flutter_test: flutter_test:
dependency: "direct dev" dependency: "direct dev"
description: flutter description: flutter
@@ -208,22 +237,31 @@ packages:
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" version: "0.0.0"
fox_utils:
dependency: "direct main"
description:
path: "."
ref: "v0.1.9"
resolved-ref: ef96868b87e55935792f392dd5881d49bc672ce5
url: "https://git.senkochan.moe/Foxyz/fox_utils.git"
source: git
version: "0.1.9"
hive_ce: hive_ce:
dependency: "direct main" dependency: "direct main"
description: description:
name: hive_ce name: hive_ce
sha256: "8e9980e68643afb1e765d3af32b47996552a64e190d03faf622cea07c1294418" sha256: "85d43209e2b4903a95d65d054407ea9ad4989d17b4d47e71732070d41b427c87"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.19.3" version: "2.20.2"
hive_ce_flutter: hive_ce_flutter:
dependency: "direct main" dependency: "direct main"
description: description:
name: hive_ce_flutter name: hive_ce_flutter
sha256: "2677e95a333ff15af43ccd06af7eb7abbf1a4f154ea071997f3de4346cae913a" sha256: "231c5f025a44f4a555b4e7417d1468027cc32d9471b00543bd5bce72464a1009"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.3.4" version: "2.4.0"
hooks: hooks:
dependency: transitive dependency: transitive
description: description:
@@ -252,10 +290,18 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: image name: image
sha256: "1976370a4df3091bb0f72409c187ad1f9132a818bc6b95ca59c0bae1c75c688e" sha256: "0be525eb917b032c7c9c980597c508a255814a858bd8d9abfc9c57e04f3ebf89"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.9.2" version: "4.10.2"
intl:
dependency: transitive
description:
name: intl
sha256: "1ca20c894b1717686a2319b8548763d812bc0aabdac580420a44c5178c57a867"
url: "https://pub.dev"
source: hosted
version: "0.20.3"
isolate_channel: isolate_channel:
dependency: transitive dependency: transitive
description: description:
@@ -268,18 +314,18 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: jni name: jni
sha256: f038e58b4dc2c9037f50e233175086337e0b305e356d28211bf55f21c504cbd3 sha256: "6b9ca0602fef0230e83149afbe3f9f8227e94f2bbe84a1da4f14d699742f402d"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.0.3" version: "1.1.0"
jni_flutter: jni_flutter:
dependency: transitive dependency: transitive
description: description:
name: jni_flutter name: jni_flutter
sha256: b2310cdd4c18c65c081ab141a41efa94aa26c65431803703ece51996f174f351 sha256: "0dd33d67797560f66e961a6053882d4df54bd345bf063b8fe7ea133f7ad8b191"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.0.3" version: "1.0.4+1"
jni_util: jni_util:
dependency: transitive dependency: transitive
description: description:
@@ -324,10 +370,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: lints name: lints
sha256: c35bb79562d980e9a453fc715854e1ed39e24e7d0297a880ef54e17f9874a9d7 sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "5.1.1" version: "6.1.0"
logging: logging:
dependency: transitive dependency: transitive
description: description:
@@ -352,6 +398,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.13.0" version: "0.13.0"
material_ui:
dependency: transitive
description:
name: material_ui
sha256: "98ab9eda579ecae623e4b376c603035c5def78ce706f735f30bd632af5814695"
url: "https://pub.dev"
source: hosted
version: "1.6.0"
media_kit: media_kit:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -436,10 +490,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: objective_c name: objective_c
sha256: ad56fd53a78ff6b1472fa59ff2a4e8b8ccabafc586fc263a1dfad0b99b5553e3 sha256: "5c80c2ad6e2c0397de5db38c6d653e69616a3397e13aa7633bf39eb668f2041b"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "9.6.0" version: "9.6.2"
package_config: package_config:
dependency: transitive dependency: transitive
description: description:
@@ -452,10 +506,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: package_info_plus name: package_info_plus
sha256: "127e1751e37ffb2ff4658beeaca77bad0c27bf5f932bd3a501c2296926d4b481" sha256: "73ef2cd5c11a7548f9b7f6fc2a4f6113de2e64aa2a70de63feede95548822908"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "10.2.1" version: "10.2.2"
package_info_plus_platform_interface: package_info_plus_platform_interface:
dependency: transitive dependency: transitive
description: description:
@@ -524,10 +578,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: petitparser name: petitparser
sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675" sha256: "2f1564aadb14215e478563b5406cb80729d022158c01dc50c96013c2f7eb4790"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "7.0.2" version: "7.1.0"
platform: platform:
dependency: transitive dependency: transitive
description: description:
@@ -723,14 +777,14 @@ packages:
path: "../../packages/core" path: "../../packages/core"
relative: true relative: true
source: path source: path
version: "0.2.2" version: "1.1.1"
sync_relay_server: sync_relay_server:
dependency: "direct main" dependency: "direct main"
description: description:
path: "../../packages/server" path: "../../packages/server"
relative: true relative: true
source: path source: path
version: "0.2.2" version: "1.1.1"
synchronized: synchronized:
dependency: transitive dependency: transitive
description: description:
@@ -791,10 +845,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: vector_math name: vector_math
sha256: f36f9f3be64c6198714492bb455c11056e33e2f85d9a0b676a48301e44fdcf47 sha256: "92b9910f66ed1057fd4da7b040ae7c74cafacf885bdc81be496928d5049b032d"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.4.2" version: "2.4.3"
vm_service: vm_service:
dependency: transitive dependency: transitive
description: description:
@@ -807,10 +861,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: wakelock_plus name: wakelock_plus
sha256: "22b3e7e937721de70e63c85e7139f4ac781dc22863b9262431a53ac030eb074b" sha256: "9a91139ee876c24a9ebe1484e4b9b7923b07d368566a5e0b2301870b0f5029c8"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.8.0" version: "1.8.1"
wakelock_plus_platform_interface: wakelock_plus_platform_interface:
dependency: transitive dependency: transitive
description: description:
@@ -863,10 +917,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: windows_file_picker name: windows_file_picker
sha256: "62e6e6e115231d1d1d71c7b5883b4091ce3e0190e49548dfb0ff8cba8d91ad96" sha256: "18452aa293de3b6961c6d05b0e71b4be7bb90c67635abdd3aa5c4e75dd875072"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.3.0" version: "2.0.1"
xdg_directories: xdg_directories:
dependency: transitive dependency: transitive
description: description:
@@ -879,10 +933,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: xml name: xml
sha256: "67f0aff7be013d107995e9b75bf4e7f2c3ef2dfdb2c8e68024bba0a7fd5756a4" sha256: "0fe8cc98946def31cbcbc8734aa4067031e6f65ea3a78a508060bfed2200bfeb"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "7.0.1" version: "7.1.0"
yaml: yaml:
dependency: transitive dependency: transitive
description: description:
@@ -892,5 +946,5 @@ packages:
source: hosted source: hosted
version: "3.1.4" version: "3.1.4"
sdks: sdks:
dart: ">=3.13.0-0 <4.0.0" dart: ">=3.13.0 <4.0.0"
flutter: ">=3.44.0" flutter: ">=3.47.0"
+10 -4
View File
@@ -1,7 +1,7 @@
name: watch_together name: watch_together
description: Watch-together desktop app (working title, repo sync_relay_project). One binary. description: Watch-together desktop app (working title, repo sync_relay_project). One binary.
publish_to: none publish_to: none
version: 1.0.0 version: 1.1.2
environment: environment:
sdk: '>=3.13.0-0 <4.0.0' sdk: '>=3.13.0-0 <4.0.0'
@@ -18,18 +18,24 @@ dependencies:
path: ../../packages/core path: ../../packages/core
sync_relay_server: sync_relay_server:
path: ../../packages/server path: ../../packages/server
file_picker: ^12.3.0 file_picker: ^13.1.0
hive_ce: ^2.19.3 hive_ce: ^2.19.3
hive_ce_flutter: ^2.3.4 hive_ce_flutter: ^2.3.4
image: ^4.9.2 image: ^4.9.2
window_manager: ^0.5.2 window_manager: ^0.5.2
archive: ^4.0.7
crypto: ^3.0.6 crypto: ^3.0.6
ffi: ^2.1.0
# Drag & drop and clipboard files/images (vendored desktop_drop + pasteboard).
fox_utils:
git:
url: https://git.senkochan.moe/Foxyz/fox_utils.git
ref: v0.1.9
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
sdk: flutter sdk: flutter
flutter_lints: ^5.0.0 flutter_lints: ^6.0.0
flutter: flutter:
uses-material-design: true uses-material-design: true
assets: assets:
- assets/shaders/anime4k/ - assets/shaders/anime4k/
- assets/icon/
+68
View File
@@ -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');
});
}
+15 -1
View File
@@ -7,6 +7,7 @@ import 'package:sync_relay_core/core.dart';
import 'package:sync_relay_server/server.dart'; import 'package:sync_relay_server/server.dart';
import 'package:hive_ce/hive.dart'; import 'package:hive_ce/hive.dart';
import 'package:watch_together/src/session/session_coordinator.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/state/session_state.dart';
import 'package:watch_together/src/sync/sync_client.dart'; import 'package:watch_together/src/sync/sync_client.dart';
@@ -214,7 +215,8 @@ void main() {
MediaKit.ensureInitialized(); MediaKit.ensureInitialized();
final server = SyncServer(const AppConfig(port: 0, secret: 'toast', name: 'toast')); final server = SyncServer(const AppConfig(port: 0, secret: 'toast', name: 'toast'));
await server.start(); await server.start();
final coord = SessionCoordinator(); final sounds = _RecordingSfx();
final coord = SessionCoordinator(sfx: sounds);
final friend = SyncClient(); final friend = SyncClient();
try { try {
await coord.connect(ConnectParams( await coord.connect(ConnectParams(
@@ -227,6 +229,12 @@ void main() {
final toasts = coord.toasts.peek(); 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 joined').actor, 'Bob Jr');
expect(toasts.firstWhere((t) => t.text == 'Bob Jr: hi there').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 { } finally {
await friend.dispose(); await friend.dispose();
await coord.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);
}
+24 -20
View File
@@ -44,7 +44,7 @@ Future<void> frames(WidgetTester tester, [int n = 10]) async {
Future<void> pumpPage( Future<void> pumpPage(
WidgetTester tester, { WidgetTester tester, {
Size size = const Size(1280, 800), Size size = const Size(1280, 800),
bool online = true, bool solo = false,
bool relayed = false, bool relayed = false,
Future<String?> Function(String path)? onPlay, Future<String?> Function(String path)? onPlay,
ThumbnailLoader? thumbnails, ThumbnailLoader? thumbnails,
@@ -56,7 +56,7 @@ Future<void> pumpPage(
await tester.pumpWidget(MaterialApp( await tester.pumpWidget(MaterialApp(
home: Scaffold( home: Scaffold(
body: FilesPage( body: FilesPage(
online: online, solo: solo,
relayed: relayed, relayed: relayed,
onPlay: onPlay ?? (_) async => null, onPlay: onPlay ?? (_) async => null,
thumbnails: thumbnails ?? fakeThumb, thumbnails: thumbnails ?? fakeThumb,
@@ -74,6 +74,12 @@ Future<void> openFolder(WidgetTester tester, String name) async {
await frames(tester); 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() { void main() {
setUp(() async { setUp(() async {
tmp = Directory.systemTemp.createTempSync('files_page_'); tmp = Directory.systemTemp.createTempSync('files_page_');
@@ -147,6 +153,9 @@ void main() {
expect(find.byType(SeriesHeader), findsOneWidget); expect(find.byType(SeriesHeader), findsOneWidget);
expect(find.text('Show'), findsOneWidget); expect(find.text('Show'), findsOneWidget);
expect(find.text('3 episodes · 1 extra · Grp'), 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']) { for (final l in ['E01', 'E02', 'E03', 'NCOP1']) {
expect(find.text(l), findsOneWidget); expect(find.text(l), findsOneWidget);
} }
@@ -157,6 +166,13 @@ void main() {
// Durations come from the thumbnail. // Durations come from the thumbnail.
expect(find.text('24:00 · 1 KB'), findsNWidgets(4)); expect(find.text('24:00 · 1 KB'), findsNWidgets(4));
expect(find.text('24:00'), findsOneWidget); // card badge 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 { testWidgets('hidden files are counted and some can be shown', (tester) async {
@@ -180,6 +196,7 @@ void main() {
store.addFolder(anime); store.addFolder(anime);
await pumpPage(tester); await pumpPage(tester);
await openFolder(tester, 'anime'); await openFolder(tester, 'anime');
await openSeries(tester);
touch('$anime/[Grp] Show - 04.mkv'); touch('$anime/[Grp] Show - 04.mkv');
expect(find.text('E04'), findsNothing); expect(find.text('E04'), findsNothing);
await tester.tap(find.text('Rescan')); await tester.tap(find.text('Rescan'));
@@ -189,23 +206,6 @@ void main() {
expect(find.text('4 episodes · 1 extra · Grp'), findsOneWidget); 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 { testWidgets('a play goes to onPlay and into Recent', (tester) async {
store.addFolder(anime); store.addFolder(anime);
final played = <String>[]; final played = <String>[];
@@ -216,6 +216,7 @@ void main() {
}); });
expect(find.textContaining('Keep the app open while it plays'), findsOneWidget); expect(find.textContaining('Keep the app open while it plays'), findsOneWidget);
await openFolder(tester, 'anime'); await openFolder(tester, 'anime');
await openSeries(tester);
await tester.tap(find.text('E02')); await tester.tap(find.text('E02'));
await frames(tester); await frames(tester);
@@ -242,7 +243,6 @@ void main() {
await tester.pumpWidget(MaterialApp( await tester.pumpWidget(MaterialApp(
home: Scaffold( home: Scaffold(
body: FilesPage( body: FilesPage(
online: true,
relayed: false, relayed: false,
onPlay: (_) async => null, onPlay: (_) async => null,
nowPlayingPath: '$anime/[Grp] Show - 03.mkv', nowPlayingPath: '$anime/[Grp] Show - 03.mkv',
@@ -254,6 +254,7 @@ void main() {
)); ));
await frames(tester); await frames(tester);
await openFolder(tester, 'anime'); await openFolder(tester, 'anime');
await openSeries(tester);
expect(find.text('now playing'), findsOneWidget); expect(find.text('now playing'), findsOneWidget);
expect(find.byIcon(Icons.graphic_eq), findsOneWidget); expect(find.byIcon(Icons.graphic_eq), findsOneWidget);
}); });
@@ -267,6 +268,7 @@ void main() {
await tester.tap(find.widgetWithText(InputChip, 'anime')); await tester.tap(find.widgetWithText(InputChip, 'anime'));
await frames(tester); await frames(tester);
expect(find.text('$anime/'), findsOneWidget); expect(find.text('$anime/'), findsOneWidget);
await openSeries(tester);
expect(find.text('E01'), findsOneWidget); expect(find.text('E01'), findsOneWidget);
// The chip's x removes the folder, like the sidebar's. // The chip's x removes the folder, like the sidebar's.
await tester.tap(find.byTooltip('Remove folder')); await tester.tap(find.byTooltip('Remove folder'));
@@ -287,6 +289,7 @@ void main() {
expect(tester.takeException(), isNull); expect(tester.takeException(), isNull);
await tester.tap(find.text('anime').first); await tester.tap(find.text('anime').first);
await frames(tester); await frames(tester);
await openSeries(tester);
expect(find.text('E01'), findsOneWidget); expect(find.text('E01'), findsOneWidget);
expect(tester.takeException(), isNull); expect(tester.takeException(), isNull);
await tester.tap(find.text('Show some')); await tester.tap(find.text('Show some'));
@@ -312,6 +315,7 @@ void main() {
return Completer<Thumbnail?>().future; // never finishes return Completer<Thumbnail?>().future; // never finishes
}); });
await openFolder(tester, 'big'); await openFolder(tester, 'big');
await openSeries(tester, 'Long Show');
expect(find.text('E01'), findsOneWidget); expect(find.text('E01'), findsOneWidget);
final first = thumbRequests.toSet(); final first = thumbRequests.toSet();
expect(first, isNotEmpty); expect(first, isNotEmpty);
+46
View File
@@ -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');
});
}
+16 -11
View File
@@ -8,6 +8,7 @@ import 'package:sync_relay_core/core.dart';
import 'package:watch_together/src/screens/home/health_probe.dart'; import 'package:watch_together/src/screens/home/health_probe.dart';
import 'package:watch_together/src/screens/home/media_picker.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/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/session_state.dart';
import 'package:watch_together/src/state/settings.dart'; import 'package:watch_together/src/state/settings.dart';
import 'package:watch_together/src/update/update_controller.dart'; import 'package:watch_together/src/update/update_controller.dart';
@@ -230,24 +231,28 @@ void main() {
expect(find.byKey(const ValueKey('join-row')), findsOneWidget); 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 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(); await tester.pumpAndSettle();
expect(find.byType(FilesPage), findsOneWidget); expect(find.byType(FilesPage), findsOneWidget);
await tester.tap(find.text('Link')); await tester.tap(find.text('Link'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.text('Watch from a link'), findsOneWidget); expect(find.text('Watch from a link'), findsOneWidget);
// Offline nothing goes up. // Alone: Play, no relay modes.
expect(find.text('Join or host to watch together'), findsOneWidget); expect(find.text(soloPlayHint), findsOneWidget);
expect(find.text('How the others get it'), findsNothing); 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); expect(tester.takeException(), isNull);
}); });
+3 -2
View File
@@ -501,7 +501,8 @@ void main() {
expect(a.jellyfinPlan.peek(), isA<OwnPlay>()); expect(a.jellyfinPlan.peek(), isA<OwnPlay>());
expect(a.relaying.peek(), isTrue); expect(a.relaying.peek(), isTrue);
expect(b.jellyfinBlocked.peek(), isNull); 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(); x.toggleReady();
} }
await _poll(() => a.playback.player.state.playing && b.playback.player.state.playing, 'playing'); 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.streams('ATOKEN'), isNotEmpty);
expect(jelly.calls.where((c) => c.path.startsWith('/Videos/')).every((c) => expect(jelly.calls.where((c) => c.path.startsWith('/Videos/')).every((c) =>
_FakeJellyfin.authFields(c.auth)['Token'] == 'ATOKEN'), isTrue); _FakeJellyfin.authFields(c.auth)['Token'] == 'ATOKEN'), isTrue);
for (final x in [a, b]) { for (final x in [a, b, d]) {
x.toggleReady(); x.toggleReady();
} }
+14 -13
View File
@@ -189,23 +189,24 @@ void main() {
expect(decodeRecentLinks(jsonEncode([{'url': ''}, {'url': 'https://a/b.mkv'}])).single.title, 'b.mkv'); expect(decodeRecentLinks(jsonEncode([{'url': ''}, {'url': 'https://a/b.mkv'}])).single.title, 'b.mkv');
}); });
test('preview: YouTube via oEmbed, other links by file name, failures fall back', () async { test('preview: a page by yt-dlp, a file by its name, else why not', () async {
Uri? asked; final yt = await previewLink('https://www.youtube.com/watch?v=abc',
final yt = await previewLink('https://www.youtube.com/watch?v=abc', get: (uri) async { probe: (url) async => LinkProbe.page(Uri.parse(url),
asked = uri; info: const YtDlpInfo(
return jsonEncode({'title': 'A video', 'author_name': 'Chan', 'thumbnail_url': 'https://i.ytimg/x.jpg'}); 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'));
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'));
final file = await previewLink('https://cdn.example/dir/My%20Movie.mkv?sig=1', 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)); 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')); final page = await previewLink('https://example.com/a',
expect((down!.title, down.subtitle), ('abc', 'youtu.be')); probe: (url) async => LinkProbe.page(Uri.parse(url), pageTitle: 'Example', ytDlpError: 'Unsupported URL'));
expect((page!.title, page.subtitle, page.problem),
('Example', 'example.com · Not a video page yt-dlp knows', 'Not a video page yt-dlp knows'));
final refused = await previewLink('https://cdn.example/b.mp4',
probe: (url) async => LinkProbe.page(Uri.parse(url), ytDlpError: 'odd', status: 403));
expect((refused!.title, refused.problem), ('b.mp4', 'Not allowed (403)'));
expect(await previewLink('not a link'), isNull); expect(await previewLink('not a link'), isNull);
}); });
+37 -48
View File
@@ -1,7 +1,6 @@
import 'dart:convert'; import 'dart:convert';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:sync_relay_core/core.dart'; import 'package:sync_relay_core/core.dart';
import 'package:watch_together/src/jellyfin/accounts.dart'; import 'package:watch_together/src/jellyfin/accounts.dart';
@@ -49,16 +48,16 @@ FakeJellyfin libraryFake() {
} }
class FilesStub extends StatelessWidget { class FilesStub extends StatelessWidget {
final bool online, relayed; final bool solo, relayed;
final Future<String?> Function(String path) onPlay; 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 @override
Widget build(BuildContext context) => Column(children: [ Widget build(BuildContext context) => Column(children: [
Text('files online=$online relayed=$relayed'), Text('files solo=$solo relayed=$relayed'),
TextButton( TextButton(
key: const ValueKey('stub-play'), key: const ValueKey('stub-play'),
onPressed: online ? () => onPlay('/v/a.mkv') : null, onPressed: () => onPlay('/v/a.mkv'),
child: const Text('play'), child: const Text('play'),
), ),
]); ]);
@@ -76,7 +75,7 @@ class Harness {
Future<Harness> pumpLibrary( Future<Harness> pumpLibrary(
WidgetTester tester, WidgetTester tester,
Size size, { Size size, {
bool online = true, bool solo = false,
bool isHost = false, bool isHost = false,
MediaKind source = MediaKind.jellyfin, MediaKind source = MediaKind.jellyfin,
Harness? reuse, Harness? reuse,
@@ -104,17 +103,18 @@ Future<Harness> pumpLibrary(
key: UniqueKey(), key: UniqueKey(),
controller: h.controller, controller: h.controller,
accounts: h.accounts, accounts: h.accounts,
online: online, solo: solo,
isHost: isHost, isHost: isHost,
jellyfinStart: start, jellyfinStart: start,
recentLinks: h.recents, recentLinks: h.recents,
now: () => fixtureNow, now: () => fixtureNow,
oembed: (uri) async => jsonEncode({ probe: (url) async => url.endsWith('.mkv')
'title': 'A video', ? LinkProbe.file(Uri.parse(url), 1)
'author_name': 'Chan', : url.contains('bilibili')
}), ? LinkProbe.page(Uri.parse(url), ytDlpError: 'HTTP Error 412: Precondition Failed')
filesBuilder: (context, {required online, required relayed, required onPlay, nowPlayingPath}) => : LinkProbe.page(Uri.parse(url), info: const YtDlpInfo(title: 'A video', uploader: 'Chan')),
FilesStub(online: online, relayed: relayed, onPlay: onPlay), filesBuilder: (context, {required solo, required relayed, required onPlay, nowPlayingPath}) =>
FilesStub(solo: solo, relayed: relayed, onPlay: onPlay),
onPutUp: (m) async { onPutUp: (m) async {
h.put.add(m); h.put.add(m);
return null; return null;
@@ -154,7 +154,7 @@ void main() {
await tester.tap(find.text('Files')); await tester.tap(find.text('Files'));
await settle(tester, 10); 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 tester.tap(find.byKey(const ValueKey('stub-play')));
await settle(tester); await settle(tester);
expect(h.put, [const FileMedia('/v/a.mkv')]); expect(h.put, [const FileMedia('/v/a.mkv')]);
@@ -249,36 +249,30 @@ void main() {
expect(tester.takeException(), isNull); expect(tester.takeException(), isNull);
}); });
testWidgets('offline: everything browses, nothing goes up', (tester) async { 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), online: false); final h = await pumpLibrary(tester, const Size(1600, 1000), solo: true);
await tester.tap(arifuretaCard()); await tester.tap(arifuretaCard());
await settle(tester, 10); await settle(tester, 10);
final go = find.byKey(const ValueKey('details-put-up')); final go = find.byKey(const ValueKey('details-put-up'));
expect(tester.widget<ButtonStyleButton>(go).onPressed, isNull); expect(find.descendant(of: go, matching: find.textContaining('Play')), findsOneWidget);
expect(find.descendant(of: go, matching: find.text('Play')), findsOneWidget); await tester.tap(go);
expect(find.text(offlinePutUpHint), findsOneWidget); await settle(tester, 10);
await doubleTap(tester, arifuretaCard()); expect(h.put.single, isA<JellyfinMedia>());
expect(h.put, isEmpty);
await tester.tap(find.text('Link')); await tester.tap(find.text('Link'));
await settle(tester, 10); await settle(tester, 10);
h.controller.linkRelay = LinkRelay.me;
await tester.enterText(find.byKey(const ValueKey('link-field')), 'https://cdn.example/a.mkv'); await tester.enterText(find.byKey(const ValueKey('link-field')), 'https://cdn.example/a.mkv');
await settle(tester, 10); 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('How the others get it'), findsNothing);
expect(find.text('a.mkv'), findsOneWidget, reason: 'the preview still works'); expect(find.text(soloPlayHint), findsOneWidget);
await tester.tap(find.byKey(const ValueKey('link-put-up')));
await tester.tap(find.text('Files'));
await settle(tester, 10); await settle(tester, 10);
expect(find.text('files online=false relayed=true'), findsOneWidget); expect(h.put.last, const LinkMedia('https://cdn.example/a.mkv'));
expect(h.put, isEmpty);
expect(tester.takeException(), isNull); 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); final h = await pumpLibrary(tester, const Size(1400, 1000), source: MediaKind.link);
expect(find.text('How the others get it'), findsOneWidget); expect(find.text('How the others get it'), findsOneWidget);
const url = 'https://www.youtube.com/watch?v=abc'; const url = 'https://www.youtube.com/watch?v=abc';
@@ -304,29 +298,24 @@ void main() {
await settle(tester, 10); await settle(tester, 10);
expect(h.controller.link.text, url); expect(h.controller.link.text, url);
// Ctrl+V outside a field, on another source: the link lands in Link. // A link the probe cannot open: the reason, not put up; "anyway" is.
await tester.tap(find.text('Jellyfin')); const bili = 'https://www.bilibili.com/video/BV1';
await tester.enterText(find.byKey(const ValueKey('link-field')), bili);
await settle(tester, 10); await settle(tester, 10);
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, (call) async { await tester.tap(find.byKey(const ValueKey('link-put-up')));
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); await settle(tester, 10);
expect(h.controller.source, MediaKind.link); expect(find.text(needsCookiesText), findsOneWidget);
expect(h.controller.link.text, 'https://cdn.example/b.mkv'); expect(h.put, hasLength(1));
await tester.tap(find.byKey(const ValueKey('link-put-up-anyway')));
await settle(tester, 10);
expect((h.put.last as LinkMedia).anyway, isTrue);
expect(tester.takeException(), isNull); expect(tester.takeException(), isNull);
}); });
testWidgets('hosting: files stream from here; now-playing file passes through', (tester) async { 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); 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 { testWidgets('the last pick comes back selected; "Change video" opens on its series', (tester) async {
@@ -344,7 +333,7 @@ void main() {
expect(c.selected!.itemId, bsEpisode); expect(c.selected!.itemId, bsEpisode);
expect(c.source, MediaKind.jellyfin); expect(c.source, MediaKind.jellyfin);
final h = Harness(c, accounts, FakeJellyfin()); 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.byKey(const ValueKey('jf-details')), findsOneWidget);
expect(find.text('Black Summoner'), findsWidgets); expect(find.text('Black Summoner'), findsWidgets);
+11 -2
View File
@@ -111,8 +111,14 @@ void main() {
group('skip offers', () { group('skip offers', () {
final op = SkipRange(s(30), m(2), SkipSource.chapter); final op = SkipRange(s(30), m(2), SkipSource.chapter);
final ed = SkipRange(m(20), m(21, 30), SkipSource.chapter); final ed = SkipRange(m(20), m(21, 30), SkipSource.chapter);
SkipOffer? at(Duration p, {SkipRange? opening, SkipRange? ending, Duration? duration}) => skipOfferAt( SkipOffer? at(Duration p,
position: p, duration: duration ?? m(24), opening: opening, ending: ending); {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', () { test('Skip OP inside the opening, to its end', () {
expect(at(s(29), opening: op, ending: ed), isNull); 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))); expect(at(m(1), ending: ed), SkipOffer(SkipKind.fixed, m(2, 30)));
// An opening known (but not playing) means no fallback. // An opening known (but not playing) means no fallback.
expect(at(m(3), opening: op), isNull); 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)));
}); });
}); });
+2 -2
View File
@@ -296,10 +296,10 @@ void main() {
findsOneWidget); findsOneWidget);
expect(find.descendant(of: find.byKey(const ValueKey('member-m2')), matching: find.text('host')), expect(find.descendant(of: find.byKey(const ValueKey('member-m2')), matching: find.text('host')),
findsNothing); 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.tap(find.descendant(of: find.byKey(const ValueKey('member-m1')), matching: find.byTooltip('More')));
await tester.pumpAndSettle(); 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 { testWidgets('hosting: Copy invite address lists this machine\'s addresses', (tester) async {
+23 -15
View File
@@ -26,8 +26,9 @@ final png = Uint8List.fromList(const [
0x42, 0x60, 0x82, 0x42, 0x60, 0x82,
]); ]);
MemberView mv(String id, String name, {bool ready = false, bool? inSet, double buffered = 30, bool stale = false}) => MemberView mv(String id, String name,
MemberView(id: id, name: name, ready: ready, inSet: inSet ?? ready, buffered: buffered, stale: stale); {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 held(List<MemberView> members, {String transport = 'held', double position = 0, int mediaId = 1}) =>
StateBroadcast( StateBroadcast(
@@ -55,17 +56,8 @@ void main() {
group('status', () { group('status', () {
final me = mv('me', 'Me', ready: true); final me = mv('me', 'Me', ready: true);
test('problems first: removed, reconnecting, health', () { test('problems first: reconnecting, health', () {
var v = playerStatusFor( var v = playerStatusFor(health: const HealthStatus(HealthSeverity.warn, 'connection lost — retrying…'),
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…'),
reconnecting: true); reconnecting: true);
expect(v!.text, 'Reconnecting…'); expect(v!.text, 'Reconnecting…');
expect(v.action, StatusAction.cancel); expect(v.action, StatusAction.cancel);
@@ -109,14 +101,30 @@ void main() {
expect(v.self, isTrue); expect(v.self, isTrue);
final w = playerStatusFor( final w = playerStatusFor(
health: ok, 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)!; self: me)!;
expect(w.kind, StatusKind.waiting); 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'); 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']); expect(w.who.map((m) => m.name), ['Bob', 'Mika']);
}); });
test("can't play: yours with Retry (reload); others' instead of waiting", () {
var v = playerStatusFor(health: ok, state: held([me]), self: me, playerError: 'Not allowed (403)')!;
expect((v.kind, v.text, v.action), (StatusKind.problem, "Can't play this: Not allowed (403)", StatusAction.reload));
final bob = MemberView(id: 'b', name: 'Bob', ready: false, inSet: false, buffered: 0, stale: false,
error: 'Not allowed (403)');
v = playerStatusFor(health: ok, state: held([me, bob, mv('m', 'Mika', ready: true)]), self: me)!;
expect((v.kind, v.text, v.action), (StatusKind.cannotPlay, "Bob can't play this: Not allowed (403)", null));
expect(v.who.single.id, 'b');
final notMe = mv('me', 'Me');
v = playerStatusFor(health: ok, state: held([notMe, bob]), self: notMe)!;
expect((v.kind, v.action), (StatusKind.cannotPlay, StatusAction.ready));
});
test('names read as a sentence', () { test('names read as a sentence', () {
expect(nameList(['Bob']), 'Bob'); expect(nameList(['Bob']), 'Bob');
expect(nameList(['Bob', 'Mika']), 'Bob and Mika'); expect(nameList(['Bob', 'Mika']), 'Bob and Mika');
+9 -5
View File
@@ -7,6 +7,7 @@ import 'package:sync_relay_core/core.dart';
import 'package:sync_relay_server/server.dart'; import 'package:sync_relay_server/server.dart';
import 'package:watch_together/src/jellyfin/accounts.dart'; import 'package:watch_together/src/jellyfin/accounts.dart';
import 'package:watch_together/src/jellyfin/track_prefs.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/screens/home/picked_media.dart';
import 'package:watch_together/src/session/session_coordinator.dart'; import 'package:watch_together/src/session/session_coordinator.dart';
@@ -67,7 +68,10 @@ void main() {
Future<SessionCoordinator> member(String name) async { Future<SessionCoordinator> member(String name) async {
final acc = JellyfinAccounts(store: MemoryJellyfinStore()); final acc = JellyfinAccounts(store: MemoryJellyfinStore());
await acc.ensureLoaded(validate: false); 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); all.add(c);
await c.connect(ConnectParams(host: '127.0.0.1', port: server.boundPort, secret: 'pw', name: name)); await c.connect(ConnectParams(host: '127.0.0.1', port: server.boundPort, secret: 'pw', name: name));
await c.playback.player.setVolume(0); await c.playback.player.setVolume(0);
@@ -119,7 +123,7 @@ void main() {
expect(bob.relaying.peek(), isTrue); expect(bob.relaying.peek(), isTrue);
await playTogether([alice, bob]); await playTogether([alice, bob]);
final relay = server.hub.relay!; 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); expect(relay.sourceToken, bob.sync.token);
// The next swap retires bob's source and the buffer. // 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'); await _poll(() => stateMediaRelay(alice.serverState.peek()!)?.source == bob.sync.memberId, 'me state');
expect(alice.serverState.peek()!.toJson().toString(), isNot(contains('SECRET'))); expect(alice.serverState.peek()!.toJson().toString(), isNot(contains('SECRET')));
await playTogether([alice, bob]); 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; final before = hits.length;
expect(await alice.swapPicked(LinkMedia(link, relay: LinkRelay.server)), isNull); expect(await alice.swapPicked(LinkMedia(link, relay: LinkRelay.server)), isNull);
@@ -150,7 +154,7 @@ void main() {
await playTogether([alice, bob]); await playTogether([alice, bob]);
expect(hits.length, greaterThan(before)); 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); final page = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
page.listen((r) { page.listen((r) {
r.response.headers.contentType = ContentType.html; r.response.headers.contentType = ContentType.html;
@@ -158,7 +162,7 @@ void main() {
r.response.close(); r.response.close();
}); });
expect(await alice.swapPicked(LinkMedia('http://127.0.0.1:${page.port}/watch?v=x', relay: LinkRelay.me)), 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); await page.close(force: true);
} finally { } finally {
await origin.close(force: true); await origin.close(force: true);
@@ -208,6 +208,8 @@ void main() {
expect(art.banner, jpeg); expect(art.banner, jpeg);
expect(asked, unorderedEquals(['https://img/xl.jpg', 'https://img/banner.jpg', 'https://img/l.jpg'])); 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); 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(); c.close();
}); });
+3 -11
View File
@@ -29,7 +29,6 @@ final seriesJson = {
'jellyfin_series_id': 'jf7', 'jellyfin_series_id': 'jf7',
'state': 'released', 'state': 'released',
'monitored': true, 'monitored': true,
'followed': true,
'episodes_total': 24, 'episodes_total': 24,
'episodes_available': 20, 'episodes_available': 20,
'next_air_date': null, 'next_air_date': null,
@@ -110,7 +109,7 @@ void main() {
'to': req.uri.queryParameters['to'], 'to': req.uri.queryParameters['to'],
'items': [ 'items': [
{...episodeJson, 'series_id': 7, 'title': 'Show', 'anidb_id': 70, 'tvdb_id': 700, {...episodeJson, 'series_id': 7, 'title': 'Show', 'anidb_id': 70, 'tvdb_id': 700,
'jellyfin_series_id': 'jf7', 'followed': true}, 'jellyfin_series_id': 'jf7'},
], ],
}, etag: 'W/"s1"'); }, etag: 'W/"s1"');
}, },
@@ -126,7 +125,6 @@ void main() {
expect(it.anidbId, 70); expect(it.anidbId, 70);
expect(it.tvdbId, 700); expect(it.tvdbId, 700);
expect(it.jellyfinSeriesId, 'jf7'); expect(it.jellyfinSeriesId, 'jf7');
expect(it.followed, isTrue);
final e = it.episode; final e = it.episode;
expect((e.season, e.episode, e.absolute, e.title, e.anilistId), (2, 3, 15, 'Three', 222)); expect((e.season, e.episode, e.absolute, e.title, e.anilistId), (2, 3, 15, 'Three', 222));
expect(e.airDate, DateTime.utc(2026, 10, 8, 19, 36, 32)); expect(e.airDate, DateTime.utc(2026, 10, 8, 19, 36, 32));
@@ -146,7 +144,7 @@ void main() {
expect(server.seen, isEmpty); expect(server.seen, isEmpty);
}); });
test('/series, /series/{id} with episodes, follow/unfollow', () async { test('/series, /series/{id} with episodes', () async {
await serve({ await serve({
'GET /series': (req, res) async => json(res, {'items': [seriesJson]}, etag: 'W/"a"'), 'GET /series': (req, res) async => json(res, {'items': [seriesJson]}, etag: 'W/"a"'),
'GET /series/7': (req, res) async => json(res, { 'GET /series/7': (req, res) async => json(res, {
@@ -156,24 +154,18 @@ void main() {
{'season': 1, 'episode': 1, 'status': 'failed', 'reason': 'No release'}, {'season': 1, 'episode': 1, 'status': 'failed', 'reason': 'No release'},
], ],
}), }),
'PUT /series/7/follow': (req, res) async => res.statusCode = 204,
'DELETE /series/7/follow': (req, res) async => res.statusCode = 204,
}); });
final c = client(); final c = client();
final s = (await c.series()).single; final s = (await c.series()).single;
expect(s.seriesId, 7); expect(s.seriesId, 7);
expect(s.anilistId, 111); expect(s.anilistId, 111);
expect(s.state, SeriesState.released); expect(s.state, SeriesState.released);
expect(s.monitored && s.followed, isTrue); expect(s.monitored, isTrue);
expect((s.episodesTotal, s.episodesAvailable, s.nextAirDate), (24, 20, null)); expect((s.episodesTotal, s.episodesAvailable, s.nextAirDate), (24, 20, null));
final d = await c.seriesDetail(7); final d = await c.seriesDetail(7);
expect(d.episodes.map((e) => e.season), [0, 1]); expect(d.episodes.map((e) => e.season), [0, 1]);
expect(d.episodes.last.status, EpisodeStatus.failed); expect(d.episodes.last.status, EpisodeStatus.failed);
expect(d.episodes.last.reason, 'No release'); expect(d.episodes.last.reason, 'No release');
await c.setFollowed(7, true);
await c.setFollowed(7, false);
expect(server.seen.skip(2).map((r) => '${r.method} ${r.uri.path}'),
['PUT /api/app/v1/series/7/follow', 'DELETE /api/app/v1/series/7/follow']);
}); });
test('/search and the three request bodies', () async { test('/search and the three request bodies', () async {
+67 -7
View File
@@ -20,7 +20,7 @@ Airing airing(int id, int ep, DateTime local) => Airing(
); );
AnimeFlowScheduleItem item(int? anilistId, int ep, EpisodeStatus status, AnimeFlowScheduleItem item(int? anilistId, int ep, EpisodeStatus status,
{int season = 1, int? absolute, DateTime? air, int seriesId = 7, bool followed = false, {int season = 1, int? absolute, DateTime? air, int seriesId = 7,
double? progress, String? reason}) => double? progress, String? reason}) =>
AnimeFlowScheduleItem( AnimeFlowScheduleItem(
episode: AnimeFlowEpisode( episode: AnimeFlowEpisode(
@@ -36,7 +36,6 @@ AnimeFlowScheduleItem item(int? anilistId, int ep, EpisodeStatus status,
seriesId: seriesId, seriesId: seriesId,
title: 'Show', title: 'Show',
jellyfinSeriesId: 'jf$seriesId', jellyfinSeriesId: 'jf$seriesId',
followed: followed,
); );
void main() { void main() {
@@ -63,6 +62,53 @@ void main() {
expect(out[2].onJellyfin, isFalse); expect(out[2].onJellyfin, isFalse);
}); });
test('anilist_map: a season split into two AniList entries is on Jellyfin', () {
final item = AnimeFlowLibraryItem.fromJson({
'jellyfin_series_id': 'jfJ',
'name': 'JoJo',
'anilist_ids': {'6': 190327},
'anilist_map': [
{'season': 6, 'anilist_id': 190327, 'first_episode': 1, 'offset': 0},
{'season': 6, 'anilist_id': 210482, 'first_episode': 2, 'offset': 1},
],
});
final out = matchSchedule([airing(210482, 3, DateTime(2026, 10, 6, 18))],
schedule: const [], library: [item], now: now);
expect(out.single.status, ScheduleStatus.inLibrary);
expect(out.single.jellyfinSeriesId, 'jfJ');
expect(out.single.season, 6);
});
test('confirmOnJellyfin: the episode (with the run offset) must be on Jellyfin', () {
final lib = AnimeFlowLibraryItem.fromJson({
'jellyfin_series_id': 'jfJ',
'name': 'JoJo',
'anilist_map': [
{'season': 6, 'anilist_id': 210482, 'first_episode': 13, 'offset': 12},
],
});
final matched = matchSchedule(
[
airing(210482, 1, DateTime(2026, 10, 1, 18)), // S6E13: there
airing(210482, 2, DateTime(2026, 10, 8, 11)), // S6E14: not yet
airing(172192, 3, DateTime(2026, 10, 6, 18)), // series not looked up
],
schedule: const [],
library: [
lib,
const AnimeFlowLibraryItem(jellyfinSeriesId: 'jfR', name: 'Returner', anilistIds: {2: 172192}),
],
now: now,
);
expect(matched.map((e) => e.jellyfinEpisode), [13, 14, 3]);
final out = confirmOnJellyfin(matched, {
'jfJ': {(6, 12), (6, 13), (1, 14)},
});
expect(out.map((e) => e.status),
[ScheduleStatus.inLibrary, ScheduleStatus.notOnJellyfin, ScheduleStatus.aired]);
expect(out[1].jellyfinSeriesId, 'jfJ');
});
test("AnimeFlow's anilist_episode wins over TVDB numbering (split cour)", () { test("AnimeFlow's anilist_episode wins over TVDB numbering (split cour)", () {
// S2E13 in TVDB is part 2 (AniList 300) episode 1. // S2E13 in TVDB is part 2 (AniList 300) episode 1.
final out = matchSchedule( final out = matchSchedule(
@@ -82,13 +128,28 @@ void main() {
expect(out.single.season, 2); expect(out.single.season, 2);
}); });
test('a TVDB number never claims another AniList episode (JoJo S6E4 = 2nd STAGE ep 3)', () {
AnimeFlowScheduleItem row(int tvdbEp, int aniEp, EpisodeStatus st) => AnimeFlowScheduleItem(
episode: AnimeFlowEpisode(season: 6, episode: tvdbEp, anilistId: 210482, anilistEpisode: aniEp, status: st),
seriesId: 40,
title: 'JoJo',
jellyfinSeriesId: 'jfJ',
);
final out = matchSchedule(
[airing(210482, 3, DateTime(2026, 10, 9, 11)), airing(210482, 4, DateTime(2026, 10, 16, 11))],
schedule: [row(4, 3, EpisodeStatus.available), row(5, 4, EpisodeStatus.upcoming)],
now: now,
);
expect(out.map((e) => e.status), [ScheduleStatus.available, ScheduleStatus.upcoming]);
});
test('by AniList id + episode, per season ids', () { test('by AniList id + episode, per season ids', () {
// One AnimeFlow series (7) spans two AniList entries: S1 = 100, S2 = 200. // One AnimeFlow series (7) spans two AniList entries: S1 = 100, S2 = 200.
final out = matchSchedule( final out = matchSchedule(
[airing(100, 3, DateTime(2026, 10, 1, 20)), airing(200, 3, DateTime(2026, 10, 9, 20))], [airing(100, 3, DateTime(2026, 10, 1, 20)), airing(200, 3, DateTime(2026, 10, 9, 20))],
schedule: [ schedule: [
item(100, 3, EpisodeStatus.available, season: 1), item(100, 3, EpisodeStatus.available, season: 1),
item(200, 3, EpisodeStatus.downloading, season: 2, progress: 0.4, followed: true), item(200, 3, EpisodeStatus.downloading, season: 2, progress: 0.4),
], ],
series: [const AnimeFlowSeries(seriesId: 7, title: 'Show', anilistId: 100, jellyfinSeriesId: 'jf7')], series: [const AnimeFlowSeries(seriesId: 7, title: 'Show', anilistId: 100, jellyfinSeriesId: 'jf7')],
now: now, now: now,
@@ -96,7 +157,6 @@ void main() {
expect(out.map((e) => e.status), [ScheduleStatus.available, ScheduleStatus.downloading]); expect(out.map((e) => e.status), [ScheduleStatus.available, ScheduleStatus.downloading]);
expect(out.map((e) => e.season), [1, 2]); expect(out.map((e) => e.season), [1, 2]);
expect(out[1].progress, 0.4); expect(out[1].progress, 0.4);
expect(out[1].followed, isTrue);
expect(out.every((e) => e.tracked && e.seriesId == 7 && e.jellyfinSeriesId == 'jf7'), isTrue); expect(out.every((e) => e.tracked && e.seriesId == 7 && e.jellyfinSeriesId == 'jf7'), isTrue);
}); });
@@ -143,11 +203,11 @@ void main() {
final out = matchSchedule( final out = matchSchedule(
[airing(500, 2, DateTime(2026, 10, 7, 20)), airing(500, 3, DateTime(2026, 10, 14, 20))], [airing(500, 2, DateTime(2026, 10, 7, 20)), airing(500, 3, DateTime(2026, 10, 14, 20))],
schedule: const [], schedule: const [],
series: [const AnimeFlowSeries(seriesId: 9, title: 'S', anilistId: 500, followed: true)], series: [const AnimeFlowSeries(seriesId: 9, title: 'S', anilistId: 500)],
now: now, now: now,
); );
expect(out.map((e) => e.status), [ScheduleStatus.missing, ScheduleStatus.upcoming]); expect(out.map((e) => e.status), [ScheduleStatus.missing, ScheduleStatus.upcoming]);
expect(out.every((e) => e.followed && e.seriesId == 9), isTrue); expect(out.every((e) => e.seriesId == 9), isTrue);
}); });
test('progress only while downloading, reason only when failed', () { test('progress only while downloading, reason only when failed', () {
@@ -209,7 +269,7 @@ void main() {
return json(res, { return json(res, {
'items': [ 'items': [
{'season': 1, 'episode': 3, 'anilist_id': 100, 'status': 'available', 'series_id': 7, {'season': 1, 'episode': 3, 'anilist_id': 100, 'status': 'available', 'series_id': 7,
'title': 'Show', 'followed': false}, 'title': 'Show'},
], ],
}); });
case '/flow/series': case '/flow/series':
+53 -23
View File
@@ -8,6 +8,7 @@ import 'package:watch_together/src/jellyfin/accounts.dart';
import 'package:watch_together/src/schedule/anilist.dart'; import 'package:watch_together/src/schedule/anilist.dart';
import 'package:watch_together/src/schedule/animeflow.dart'; import 'package:watch_together/src/schedule/animeflow.dart';
import 'package:watch_together/src/schedule/schedule_model.dart'; import 'package:watch_together/src/schedule/schedule_model.dart';
import 'package:watch_together/src/schedule/ui/schedule_actions.dart';
import 'package:watch_together/src/schedule/ui/schedule_page.dart'; import 'package:watch_together/src/schedule/ui/schedule_page.dart';
import 'package:watch_together/src/screens/home/picked_media.dart'; import 'package:watch_together/src/screens/home/picked_media.dart';
@@ -87,7 +88,6 @@ List<ScheduleEntry> entries() => [
airing: airing(111, 'Avail Show', 3, day(0, 9)), airing: airing(111, 'Avail Show', 3, day(0, 9)),
status: ScheduleStatus.available, status: ScheduleStatus.available,
tracked: true, tracked: true,
followed: true,
seriesId: 7, seriesId: 7,
season: 1, season: 1,
jellyfinSeriesId: 'jf7', jellyfinSeriesId: 'jf7',
@@ -200,8 +200,6 @@ void main() {
}); });
case ('GET', '/search'): case ('GET', '/search'):
json(res, {'items': searchHits}); json(res, {'items': searchHits});
case ('PUT' || 'DELETE', '/series/7/follow'):
res.statusCode = 204;
case ('POST', '/requests/1/approve' || '/requests/2/reject'): case ('POST', '/requests/1/approve' || '/requests/2/reject'):
json(res, {}); json(res, {});
default: default:
@@ -231,7 +229,7 @@ void main() {
WidgetTester tester, { WidgetTester tester, {
Size size = const Size(2560, 1080), Size size = const Size(2560, 1080),
ScheduleSnapshot Function()? snap, ScheduleSnapshot Function()? snap,
bool online = true, bool solo = false,
JellyfinAccounts? accounts, JellyfinAccounts? accounts,
}) async { }) async {
toasts.clear(); toasts.clear();
@@ -245,7 +243,7 @@ void main() {
theme: ThemeData.dark(), theme: ThemeData.dark(),
home: Scaffold( home: Scaffold(
body: SchedulePage( body: SchedulePage(
online: online, solo: solo,
repository: repo, repository: repo,
accounts: accounts, accounts: accounts,
onToast: toasts.add, onToast: toasts.add,
@@ -297,13 +295,9 @@ void main() {
await done(tester); await done(tester);
}); });
testWidgets('filters: following, all airing, niche too, compact', (tester) async { testWidgets('filters: all airing, niche too, compact', (tester) async {
await pumpPage(tester); await pumpPage(tester);
await tester.tap(find.text('Following')); expect(find.text('Following'), findsNothing);
await tester.pump();
expect(card(111, 3), findsOneWidget);
expect(card(222, 5), findsNothing);
await tester.tap(find.text('All airing')); await tester.tap(find.text('All airing'));
await tester.pump(); await tester.pump();
expect(card(444, 1), findsOneWidget); expect(card(444, 1), findsOneWidget);
@@ -397,15 +391,6 @@ void main() {
await done(tester); await done(tester);
}); });
testWidgets('follow toggles through AnimeFlow', (tester) async {
final repo = await pumpPage(tester);
await tester.tap(find.byKey(const ValueKey('follow-111-3')));
await io(tester);
expect(server.seen.where((r) => r.method == 'DELETE' && r.uri.path.endsWith('/series/7/follow')), hasLength(1));
expect(repo.forced.length, greaterThanOrEqualTo(2)); // reloaded
await done(tester);
});
testWidgets('put it up resolves the Jellyfin episode', (tester) async { testWidgets('put it up resolves the Jellyfin episode', (tester) async {
final jf = FakeJf(); final jf = FakeJf();
final accounts = JellyfinAccounts( final accounts = JellyfinAccounts(
@@ -434,11 +419,56 @@ void main() {
await done(tester); await done(tester);
}); });
testWidgets('offline: Play is disabled', (tester) async { testWidgets('untracked on Jellyfin: episode lookup, aired-not-there has no Play', (tester) async {
await pumpPage(tester, online: false); final jf = FakeJf();
final accounts = JellyfinAccounts(
store: MemoryJellyfinStore({
'accounts': jsonEncode([
{
'serverId': 'srv',
'serverName': 'Home',
'urls': ['http://jf.test'],
'userId': 'uid',
'userName': 'me',
'token': 'tok',
},
]),
}),
httpFactory: () => jf,
deviceName: 'Test',
);
final eps = await tester.runAsync(() => jellyfinEpisodeNumbers(accounts, flow, 'jf7'));
expect(eps, {(1, 2), (1, 3)});
await tester.runAsync(() => jellyfinEpisodeNumbers(accounts, flow, 'jf7'));
expect(jf.seen.where((u) => u.path.endsWith('/Episodes')), hasLength(1), reason: 'memoised');
final none = JellyfinAccounts(store: MemoryJellyfinStore(), deviceName: 'Test');
expect(await tester.runAsync(() => jellyfinEpisodeNumbers(none, flow, 'jf7')), isNull);
await pumpPage(
tester,
snap: () => snapshot(
list: [
ScheduleEntry(
airing: airing(777, 'Clover', 2, day(0, 9)),
status: ScheduleStatus.notOnJellyfin,
season: 1,
jellyfinEpisode: 2,
jellyfinSeriesId: 'jfC',
),
],
),
);
expect(card(777, 2), findsOneWidget);
expect(find.text('aired · not on Jellyfin yet'), findsOneWidget);
expect(action(777, 2), findsNothing);
await done(tester);
});
testWidgets('solo: the button says Play and works', (tester) async {
await pumpPage(tester, solo: true);
expect(find.text('Put it up'), findsNothing); expect(find.text('Put it up'), findsNothing);
final b = widgetOf<ButtonStyleButton>(tester, action(111, 3)); final b = widgetOf<ButtonStyleButton>(tester, action(111, 3));
expect(b.onPressed, isNull); expect(b.onPressed, isNotNull);
expect(find.text('Play'), findsOneWidget); expect(find.text('Play'), findsOneWidget);
await done(tester); await done(tester);
}); });
+49 -7
View File
@@ -8,6 +8,7 @@ import 'package:hive_ce/hive.dart';
import 'package:media_kit/media_kit.dart'; import 'package:media_kit/media_kit.dart';
import 'package:sync_relay_core/core.dart'; import 'package:sync_relay_core/core.dart';
import 'package:watch_together/src/jellyfin/accounts.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/session/session_coordinator.dart';
import 'package:watch_together/src/state/server_isolate.dart'; import 'package:watch_together/src/state/server_isolate.dart';
import 'package:watch_together/src/state/session_state.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 { timeout: const Timeout(Duration(minutes: 2)), () async {
MediaKit.ensureInitialized(); MediaKit.ensureInitialized();
Hive.init( Hive.init(
@@ -131,16 +132,16 @@ void main() {
final hostId = ((state['members'] as List) final hostId = ((state['members'] as List)
.cast<Map>() .cast<Map>()
.firstWhere((m) => m['name'] == 'host'))['id']; .firstWhere((m) => m['name'] == 'host'))['id'];
friend.ws.add(jsonEncode(KickRequest(target: '$hostId').toJson())); // Anyone may sit the host out; its player unloads, the server serves on.
final err = await friend.next('err'); friend.ws.add(jsonEncode(OutIntent(target: '$hostId', out: true).toJson()));
expect(err['message'], "can't kick the host"); 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) final friendId = ((state['members'] as List)
.cast<Map>() .cast<Map>()
.firstWhere((m) => m['name'] == 'friend'))['id']; .firstWhere((m) => m['name'] == 'friend'))['id'];
session.coordinator.kick('$friendId'); session.coordinator.nudgeMember('$friendId');
await friend.next('kicked'); expect((await friend.next('nudge'))['name'], 'host');
session.hostedServer!.isolate.kill(priority: Isolate.immediate); session.hostedServer!.isolate.kill(priority: Isolate.immediate);
await _poll(() => session.route.peek() == AppRoute.home, 'leave after crash'); await _poll(() => session.route.peek() == AppRoute.home, 'leave after crash');
@@ -218,4 +219,45 @@ void main() {
if (file.existsSync()) file.deleteSync(); 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();
}
});
} }
+10
View File
@@ -91,4 +91,14 @@ void main() {
expect(n.contains('SECRET'), isFalse); 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');
});
} }
Loaded 100 of 158 files, more files were not shown because too many files have changed in this diff. Show more