Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bbecdcc4d8 | ||
|
|
f1ccf88b06 | ||
|
|
d3be13ecda | ||
|
|
1b04e002c2 | ||
|
|
f23cc7b311 | ||
|
|
473c82ec0a | ||
|
|
e621b392af | ||
|
|
884f208330 | ||
|
|
cc45b86271 | ||
|
|
84c5cda668 | ||
|
|
bc96cb13d9 | ||
|
|
f7f2f0cddd | ||
|
|
05b06b1fc1 | ||
|
|
748d58f036 | ||
|
|
1fd4081461 | ||
|
|
2d39018a73 | ||
|
|
059b7dd306 | ||
|
|
0115d54ab8 | ||
|
|
fe5d6c3b3f | ||
|
|
05f9e794a9 | ||
|
|
75065f90cd | ||
|
|
e0db5cc655 | ||
|
|
8186fbb5d3 | ||
|
|
8b81da4069 | ||
|
|
dbfd351ee4 | ||
|
|
a4be24a2a5 | ||
|
|
b038706dff | ||
|
|
b2b77a6b3f | ||
|
|
d002ff6941 | ||
|
|
151ec95289 | ||
|
|
745717a4a0 | ||
|
|
9ddd85de90 | ||
|
|
24fc137e4f | ||
|
|
9a4b04672b | ||
|
|
ff51d52fbb | ||
|
|
f2272d7980 | ||
|
|
cf80bb2167 | ||
|
|
8effa573a0 | ||
|
|
5f22be6c20 | ||
|
|
740273745b | ||
|
|
27b4e1dc0c | ||
|
|
fce392e425 | ||
|
|
d8fd464e33 | ||
|
|
0c38528520 | ||
|
|
b534c8c7ea | ||
|
|
d092ea371a | ||
|
|
99cda7ed04 | ||
|
|
d29c12e4f0 | ||
|
|
f790470935 | ||
|
|
2eb6840ec4 | ||
|
|
677ece81c4 | ||
|
|
caa0765890 | ||
|
|
c76fee70dc | ||
|
|
6f136bfc4d | ||
|
|
e7f33b5dc3 |
No files matched your search
@@ -55,5 +55,13 @@ jobs:
|
||||
run: powershell -NoProfile -ExecutionPolicy Bypass -File tool/setup_flutter_windows.ps1
|
||||
- name: Updater tests
|
||||
shell: powershell
|
||||
working-directory: apps/desktop
|
||||
run: flutter test test/updater_test.dart
|
||||
working-directory: packages/core
|
||||
run: |
|
||||
dart pub get
|
||||
if ($LASTEXITCODE) { exit 1 }
|
||||
dart test test/updater_test.dart
|
||||
# The release build, without publishing: a broken Windows build shows
|
||||
# here, not only on a tag.
|
||||
- name: Windows build
|
||||
shell: powershell
|
||||
run: powershell -NoProfile -ExecutionPolicy Bypass -File tool/package_windows.ps1
|
||||
@@ -1,4 +1,8 @@
|
||||
# watch-together
|
||||
<p align="center">
|
||||
<img src="apps/desktop/assets/icon/icon_256.png" width="128" height="128" alt="Starsync icon">
|
||||
</p>
|
||||
|
||||
<h1 align="center">Starsync</h1>
|
||||
|
||||
Watch a video with friends, in sync, with the quality of a real mpv player.
|
||||
Pick from your Jellyfin library, paste any direct video link, or pick a file on your own disk;
|
||||
@@ -22,33 +26,134 @@ How it fits together: [docs/HOW-IT-WORKS.md](docs/HOW-IT-WORKS.md).
|
||||
From the Releases page of this repo:
|
||||
|
||||
- Linux: `watch-together-<version>-linux-x64.tar.gz`. Extract, run
|
||||
`watch_together`. Needs mpv's library installed (`mpv` or `libmpv2`).
|
||||
`watch_together`. On start it adds itself to your app launcher (and its
|
||||
icon), so the window shows its own icon on Wayland. Needs mpv's library installed (`mpv` or `libmpv2`).
|
||||
- Windows: `watch-together-<version>-windows-x64.zip`. Extract, run
|
||||
`watch_together.exe`. mpv is bundled. New and not yet tried on a real
|
||||
Windows desktop; reports welcome.
|
||||
|
||||
Update from the app: when a newer version exists, an update indicator
|
||||
appears in Home's top bar 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
|
||||
by you. Settings has Check now and an auto-check toggle. From a terminal:
|
||||
`watch_together --upgrade`, or the shipped `update.sh` / `update.bat`. A
|
||||
`./watch-together-server --upgrade` (no display needed, so it works on a
|
||||
server box too), `watch_together --upgrade`, or the shipped `update.sh` /
|
||||
`update.bat` (which run the first). Each updates the whole folder, app and
|
||||
server; restart whichever is running to use the new version. A
|
||||
release with a newer protocol is a required update: everyone on a server
|
||||
must run the same protocol version, and a mismatch is refused with a message
|
||||
telling you to update.
|
||||
|
||||
## Getting started
|
||||
|
||||
Three ways in, all at the top of the Library when you are not in a server.
|
||||
None of them needs Jellyfin or an account.
|
||||
|
||||
- **Join a friend** (most people): your friend sends you an address and,
|
||||
maybe, a password. See [Join](#join).
|
||||
- **Host**: you have the video and friends watch it with you. See
|
||||
[Host from the app](#host-from-the-app).
|
||||
- **Watch solo**: just play something from the Library. It plays for you
|
||||
alone, with everything a server has (Jellyfin progress, next episode,
|
||||
skip buttons); nothing is reachable from outside this computer. Invite
|
||||
friends in the strip turns it into a hosted server (the Host card's port
|
||||
and password) and the video carries on from where it was; Stop ends it.
|
||||
|
||||
## The window
|
||||
|
||||
One window. Along the top, the strip: your avatar (click it for your
|
||||
profile: name, picture with a crop editor, colour, and links to the
|
||||
settings), the Library / Schedule / Settings tabs, and the party. Offline
|
||||
the party side has Join a friend and Host; watching solo it says Solo,
|
||||
with Invite friends and Stop; in a server it shows everyone's avatar
|
||||
with a ring for their state (green ready, amber buffering, grey not ready,
|
||||
dimmed reconnecting, dimmed with a moon sitting out; a crown on whoever
|
||||
hosts, a tower on whoever passes the video on). Click the avatars for the
|
||||
member list (force ready, Nudge, Sit out, "Copy invite address"; when you host it lists this computer's
|
||||
network addresses with the port, and friends outside your network need
|
||||
that port forwarded or a server they can all reach, like a VPS). Beside them the server and "ready n/m", the chat button with unread
|
||||
count, and Leave. Empty strip space drags the window.
|
||||
|
||||
The player fills the window under the strip. Pick a tab and it shrinks to
|
||||
a mini player in a corner: drag it anywhere and it snaps to the nearest
|
||||
corner; double-click or ⤢ makes it big again, × sits you out (below) and
|
||||
× again closes it; the strip's screen button brings it back. F or
|
||||
double-click on the picture goes fullscreen (the strip hides, the party
|
||||
shows in a corner while paused, the window buttons sit in the player's top
|
||||
bar while its controls are up), Esc leaves it. The controls show the
|
||||
show's cover, title and episode top left (guests get the cover too); before
|
||||
a video first plays, its banner fills the picture until playback starts.
|
||||
One pill above the controls says what playback waits for ("Press Space
|
||||
when you're ready", "Waiting for Bob and Mika · 2/3 ready", "Bob is
|
||||
loading…") or what went wrong, with Retry or Cancel when it helps. Chat and activity open in a panel on the right (chat button or
|
||||
Tab), ready to type; it shows messages, and its "Activity" toggle adds
|
||||
joins, ready, seeks and the like between them. Joins, leaves, chat and
|
||||
other news also pop up bottom left with the person's avatar; click a message to open the chat. Unread chat
|
||||
shows as a count on the chat buttons and, in fullscreen with the controls
|
||||
hidden, as a small chip top right.
|
||||
|
||||
Your name and picture change for everyone in the server right away; the
|
||||
colour is yours only.
|
||||
|
||||
**Sitting out.** Step away without leaving: Sit out (your profile, your
|
||||
row in the member list, or × on the mini player) unloads the video on your
|
||||
end, and nobody waits for you any more; you stay in chat. Anyone can sit
|
||||
anyone out, the host too ("Alice sat you out" tells you who). The player
|
||||
then shows the cover, title and where the others are; Join back (or Space)
|
||||
loads the video there and readies you. Settings → Party & network → "When idle,
|
||||
sit out instead of just unready" does it after 10 idle minutes.
|
||||
|
||||
**Nudge.** Nudge in the member list pokes someone, sitting out or not:
|
||||
only they hear it (a sound, a pop-up, and the taskbar entry asks for
|
||||
attention when the window is in the background).
|
||||
|
||||
## Host from the app
|
||||
|
||||
Home, Host tab. Pick what to watch (optional): Jellyfin (browse your linked
|
||||
servers, see below), Link (paste an http(s) URL; Ctrl+V outside a field
|
||||
pastes it in) or File (path or Browse). In the column beside it set a port and an optional password (blank =
|
||||
anyone can join), then Host. Your name and avatar come from your profile
|
||||
(the chip at the top left). The app starts a
|
||||
server inside itself and joins it; you are the host and nobody can kick you.
|
||||
Leaving or closing the app stops the server. Friends need to reach your machine: open
|
||||
the TCP port on your router (or use a LAN or VPN address). Friends open nothing.
|
||||
Host in the strip: set a port and an optional password (blank = anyone can
|
||||
join), then Host. The app starts a server inside itself and joins it; you
|
||||
are the host (the crown). Nothing is up yet: pick what to
|
||||
watch in the Library tab (below) and put it up. Leaving or
|
||||
closing the app stops the server. Friends need to reach your machine: open
|
||||
the TCP port on your router (or use a LAN or VPN address). Friends open
|
||||
nothing. Ports you hosted on before are one click away in the Host card.
|
||||
|
||||
If you pick a local file, the server streams it to everyone from your disk.
|
||||
Nothing is uploaded or stored anywhere.
|
||||
If you put up a local file, the server streams it to everyone from your
|
||||
disk. Nothing is uploaded or stored anywhere.
|
||||
|
||||
## Library
|
||||
|
||||
What to watch, from three sources (the switch at the top). Drop a video,
|
||||
a folder or a link anywhere on the window, or press Ctrl+V outside a
|
||||
text field, and a small card asks how to open it (put it up, add the
|
||||
folder to Files, or fill Link).
|
||||
|
||||
- **Jellyfin**: your linked servers (see below); with none linked it
|
||||
points to Files and Link and offers Jellyfin as optional. Library chips beside the
|
||||
switch, then one **Continue** row (what you were watching and what comes
|
||||
next, one card per series, the one you watched most recently first;
|
||||
series not touched for two months drop out) and **Latest in** each
|
||||
library (one card per series: "S1E1" or "3 new"; Show all opens the
|
||||
library newest first). Re-uploads (added long after they aired) are
|
||||
hidden from Latest; the row's ⋮ menu turns that off. Search (/), library
|
||||
grids (sort, unwatched, favourites) and series pages sit behind them.
|
||||
Click a card to select it: its
|
||||
details open on the right with "Put it up · S1E3", mark watched /
|
||||
unwatched, remove from Continue and the episode list (click an episode
|
||||
to choose it). Double-click a card or an episode to put it up at once.
|
||||
- **Link**: paste an http(s) link: a video file, or a web page with a
|
||||
video (YouTube, X, and whatever else yt-dlp knows). The preview shows
|
||||
what it is (a page's title, uploader, length and thumbnail, else the
|
||||
file name); in a server, pick how the
|
||||
others get it (everyone fetches it, the server fetches it, or through
|
||||
you). Your last 12 links are listed under it. A link that does not
|
||||
open here (needs cookies, 403, 404, not a video) says why and goes up
|
||||
only with "Put it up anyway"; in the player, whoever cannot play it is
|
||||
named ("Bob can't play this: Not allowed (403)") and nobody waits for them.
|
||||
- **Files**: video folders you add on this computer, grouped by series
|
||||
(click a series to list its episodes), or any file through Browse. The others stream it from you.
|
||||
|
||||
Put it up and the player opens. Not in a server, the buttons say Play
|
||||
and it plays for you alone ([Watch solo](#getting-started)).
|
||||
|
||||
## Dedicated server
|
||||
|
||||
@@ -67,64 +172,87 @@ then everyone (you included) uses Join.
|
||||
| `--name` | `server` | name reported by `/health` |
|
||||
| `--media` | none | local file to serve as the starting video |
|
||||
| `--advertise-host` | none | host put in file URLs instead of the one each viewer dialled |
|
||||
| `--config` | none | YAML or JSON file with the same settings (`port`, `secret`, `name`, `mediaPath`, `advertiseHost`); replaces the flags |
|
||||
| `--[no-]yt-dlp` | on | read web pages (YouTube, X, …) with yt-dlp when the server fetches a link |
|
||||
| `--yt-dlp-dir` | next to the binary | writable folder for the server's yt-dlp (systemd: the unit's `StateDirectory`) |
|
||||
| `--yt-dlp-cookies` | none | cookies.txt or cookie-extension JSON export for that yt-dlp (sites that refuse guests); copied into the yt-dlp folder at start, removed from it when not given. **An account login**: keep it private, use a throwaway account for YouTube |
|
||||
| `--config` | none | YAML or JSON file with the same settings (`port`, `secret`, `name`, `mediaPath`, `advertiseHost`, `ytDlp`, `ytDlpDir`, `ytDlpCookies`); replaces the flags |
|
||||
| `--upgrade` | | update the install folder to the latest release ([Install and update](#install-and-update)), then exit |
|
||||
| `--check-update` | | print whether a newer release exists, then exit |
|
||||
|
||||
It prints `READY port=... name=...` once listening and stops cleanly on
|
||||
Ctrl+C or SIGTERM. A dedicated server has no host: anyone can kick anyone.
|
||||
Ctrl+C or SIGTERM. A dedicated server has no host.
|
||||
To run it as a service, unpack the tarball to `/opt/watch-together` and use
|
||||
[deploy/watch-together.service](deploy/watch-together.service) (copy it to
|
||||
`/etc/systemd/system/`, edit the flags, `systemctl enable --now watch-together`).
|
||||
|
||||
## Join
|
||||
|
||||
Home, Join tab. Enter the host's address, port, and the password if there
|
||||
is one, then Enter or Join. Your name and avatar come from your profile (top
|
||||
bar chip or Settings). Servers you got into appear as up to 6 Recent server
|
||||
tiles with live status (online and how many are watching, or offline, plus
|
||||
"needs update" when the server runs another protocol): click one to rejoin,
|
||||
x to forget it. Ctrl+1 / Ctrl+2 switch between Join and Host.
|
||||
Join in the strip. Servers you got into are listed first (up to 6), with
|
||||
live status (online and how many are watching, or offline, plus "needs
|
||||
update" when the server runs another protocol): click one to rejoin, x to
|
||||
forget it. Or enter the host's address, port, and the password if there
|
||||
is one, then Enter or Join. Your name and picture come from your profile
|
||||
(your avatar at the top left).
|
||||
|
||||
## Settings
|
||||
|
||||
Profile, hardware decoding (auto, off or custom; the env var
|
||||
`SYNC_RELAY_HWDEC` overrides it; applies on the next join or Reload video),
|
||||
"Experimental: mpv-style controls" (below), updates, playing the next
|
||||
Jellyfin episode automatically, video shaders (see below), and the
|
||||
keyboard shortcuts (read-only).
|
||||
Integrations links Jellyfin servers (below). About shows the version.
|
||||
The Settings tab (Ctrl+,) has a page per topic, each setting with a line
|
||||
of help: Profile; Party & network (the Host card's default port and
|
||||
password, recent servers with remove); Player (below); Jellyfin &
|
||||
integrations (linked Jellyfin servers, below, the AnimeFlow connection:
|
||||
"connected as <name>" with what you may do, or why not, and playing the
|
||||
next episode automatically); Notifications & sounds (what pops up; the sound
|
||||
volume and sound set, and every sound with a preview, its own set and a switch); Updates; Keyboard shortcuts (read-only); About
|
||||
(the version).
|
||||
|
||||
Keys in a session: Space = ready / unready, Tab = side panel (people, log,
|
||||
chat, change video), Left/Right = seek 10 s, Shift+Right = skip 1:30,
|
||||
Player settings live in the player: ⚙ at the right end of its controls
|
||||
(both designs) opens them, and Settings → Player has the same ones (plus
|
||||
the custom shader files). Player design: Standard or uosc (below);
|
||||
progress line when hidden (on by default: a 2 px line at the bottom of
|
||||
the video while the controls are hidden); upscaling shader (see below);
|
||||
hardware decoding (auto, off or custom; the env var `SYNC_RELAY_HWDEC`
|
||||
overrides it); in the player also the subtitle and audio track. Every
|
||||
change applies right away.
|
||||
|
||||
Anywhere in a server (unless you are typing): Space = ready / unready,
|
||||
Tab = chat & activity (Tab never moves between buttons). Keys while the
|
||||
player fills the window: Left/Right = seek 10 s, Up/Down = volume, Shift+Right = skip 1:30,
|
||||
PgUp/PgDn = previous / next chapter (as in mpv), F = fullscreen, Esc =
|
||||
close panel or ask to leave, Ctrl+R = reload video, `<` / `>` or Shift+P /
|
||||
Shift+N = previous / next Jellyfin episode, Ctrl+1..6 = Anime4K mode A, B,
|
||||
C, A+A, B+B, C+A, Ctrl+0 = shaders off. Every seek, skip and chapter jump
|
||||
moves everyone.
|
||||
close the chat, then fullscreen, then shrink the player to mini; from the
|
||||
mini it asks to leave, Ctrl+R = reload video,
|
||||
`<` / `>` or Shift+P / Shift+N = previous / next Jellyfin episode,
|
||||
Ctrl+1..6 = Anime4K mode A, B, C, A+A, B+B, C+A, Ctrl+0 = shaders off.
|
||||
Every seek, skip and chapter jump moves everyone.
|
||||
|
||||
Controls: the seek bar marks chapter starts with a diamond; hovering it
|
||||
shows the time and the chapter title. The chapters button lists them
|
||||
(current one checked; click to jump). "1:30" skips ahead 1:30. Audio and
|
||||
Subtitles buttons (with the track count) list tracks as "Japanese · E-AC3
|
||||
2.0 · title"; subtitles have Off.
|
||||
2.0 · title"; subtitles have Off. Volume (hover the speaker, scroll on
|
||||
it, or Up/Down) goes to 150 % like mpv: past the tick at 100 % the bar
|
||||
turns amber and mpv amplifies.
|
||||
|
||||
Experimental mpv-style controls (Settings → Player, off by default,
|
||||
The uosc player design (⚙ → Player design, Standard by default,
|
||||
switches right away): video controls modeled on mpv's uosc theme. A thick
|
||||
timeline across the bottom shows the played part, the cached part ahead
|
||||
(hatched), chapter starts as diamonds and the time; hovering it shows the
|
||||
chapter title and the time there. With the controls hidden it stays as a
|
||||
thin line. Above it flat buttons: ≡ menu, Subtitles and Audio (with the
|
||||
thin line (unless "Progress line when hidden" is off). Above it flat buttons: ≡ menu, Subtitles and Audio (with the
|
||||
track count), the shader badge (the active Anime4K mode, e.g. "A HQ"),
|
||||
ready, chapters, 1:30, previous / next episode, change video, people,
|
||||
volume and fullscreen. Keys and skip buttons work the same.
|
||||
ready, chapters, 1:30, previous / next episode, change video, volume, ⚙
|
||||
and fullscreen; the title top left, chat and back-to-library top right. Keys and skip buttons work
|
||||
the same.
|
||||
|
||||
Skipping: during the opening a "Skip OP" button floats in the corner (it
|
||||
jumps to the end of the opening), during the ending "Skip ED" (to the end
|
||||
of the ending only; what comes after is yours to skip). The opening and
|
||||
of the ending only; what comes after is yours to skip). Any skip button
|
||||
(also "Skip 1:30") shows for 5 seconds when it comes up, then only while
|
||||
the controls are up (move the mouse). The opening and
|
||||
ending come from Jellyfin's intro / outro segments when your Jellyfin has
|
||||
them (needs a segment plugin, e.g. Intro Skipper), else from chapter names
|
||||
(OP, Opening, Intro / ED, Ending, Credits, Outro). Without an opening, on
|
||||
anything longer than 20 minutes, "Skip 1:30" floats there during the first
|
||||
6 minutes.
|
||||
(OP, Opening, Intro / ED, Ending, Credits, Outro). Without an opening or
|
||||
any chapters, on anything longer than 20 minutes, "Skip 1:30" floats there
|
||||
during the first 6 minutes.
|
||||
|
||||
## Ports
|
||||
|
||||
@@ -137,29 +265,27 @@ Only the host needs the port reachable.
|
||||
|
||||
## Jellyfin
|
||||
|
||||
Integrations > Jellyfin > Add server: type the address, then sign in with
|
||||
Settings > Jellyfin & integrations > Add server: type the address, then sign in with
|
||||
Quick Connect (the app shows a code; approve it in Jellyfin under Profile >
|
||||
Quick Connect) or your username and password. Several servers can be
|
||||
linked; each shows its status, and a star picks the default. Sign out
|
||||
revokes the app's sign-in on the server. If a sign-in expires the server
|
||||
shows "Reconnect" instead of disappearing.
|
||||
|
||||
The Host tab's Jellyfin source is a library browser: Continue watching (with
|
||||
The Library's Jellyfin source is a library browser: Continue watching (with
|
||||
time left), Next up and the latest per library on the start page; library
|
||||
grids with sort (name, date added, release date, rating, random) and
|
||||
Unwatched / Favorites filters; series pages with seasons, episode progress
|
||||
and a "Next up" button; details with the file's tracks; search. Clicking a
|
||||
movie or episode picks it; the button in the top right enlarges the browser
|
||||
and moves port, password and Host into a bar below it. On wide windows a
|
||||
side pane shows details of whatever is under the mouse.
|
||||
movie or episode picks it. On wide windows a side pane shows details of
|
||||
whatever is under the mouse.
|
||||
|
||||
Keys in the browser: arrows move, Enter picks or opens, I = details,
|
||||
Esc / Backspace / Alt+Left = back (Esc first clears a search), / or Ctrl+F =
|
||||
search.
|
||||
|
||||
Host from the library: pick a movie or episode and Host. Only the item
|
||||
goes to the server (which Jellyfin server and which item), never a link or
|
||||
your sign-in. Pasting a Jellyfin link (web page or stream link) does the
|
||||
Putting up a movie or episode sends only the item to the server (which
|
||||
Jellyfin server and which item), never a link or your sign-in. Pasting a Jellyfin link (web page or stream link) does the
|
||||
same when the app can reach that Jellyfin; otherwise it is shared as a
|
||||
plain link.
|
||||
|
||||
@@ -169,13 +295,16 @@ Watching:
|
||||
your watched state, Continue watching and resume point follow what you
|
||||
saw (reported on start, every 10 s, on pause, resume, seek and track
|
||||
change, and when it stops).
|
||||
- Friends without an account watch through the host: the server fetches
|
||||
the file with the host's sign-in and passes it on (only the current item,
|
||||
only to people in the session). They show "via host" in the member list.
|
||||
A dedicated server relays when started with `--jellyfin <file>` (see
|
||||
[docs/ARCHITECTURE.md](docs/ARCHITECTURE.md), Jellyfin relay).
|
||||
- Without either, the app says "This needs a Jellyfin account on ..." with
|
||||
a button to link one; it plays as soon as you are signed in.
|
||||
- Friends without an account watch through the server: whoever put the
|
||||
item up passes it on automatically, with their own sign-in, which never
|
||||
leaves their computer (only the current item, only to people in the
|
||||
session). They show "via host" in the member list (click the avatars). When the app hosts,
|
||||
or a dedicated server has its own login (`--jellyfin <file>`, see
|
||||
[docs/ARCHITECTURE.md](docs/ARCHITECTURE.md), Jellyfin relay), the server
|
||||
fetches it itself instead.
|
||||
- If nobody can pass it on (the person who put it up left), the app says
|
||||
"No Jellyfin account here for ..." with a button to link one; it plays as
|
||||
soon as you are signed in.
|
||||
|
||||
Episodes: the ⏮ / ⏭ buttons next to the controls (or `<` / `>`,
|
||||
Shift+P / Shift+N) put up the previous or next episode for everyone. From
|
||||
@@ -192,9 +321,41 @@ Your choice is remembered per series (and as a default) and the closest
|
||||
match (language, then title, then codec and channels) is selected on the
|
||||
next episode. This is per person; nobody else is affected.
|
||||
|
||||
"Change video" in the session panel (Tab) opens the same picker as the Host
|
||||
tab: the library starts on the series you are watching with the current
|
||||
season, else where you left it. File is offered to the host only.
|
||||
"Change video" in the player's controls opens the Library tab (the player
|
||||
goes mini): the library starts on the series you are watching with the
|
||||
current season, else where you left it.
|
||||
|
||||
Anyone can put up a file from their own computer, also on a dedicated
|
||||
server: it streams from your computer through the server to everyone (the
|
||||
server keeps a few minutes in memory, so seeks and skipping the opening
|
||||
are quick). Keep the app open while it plays. For a link you choose how
|
||||
the others get it:
|
||||
- Everyone fetches it (default): each app plays the link itself.
|
||||
- Server fetches it: the server downloads it once for everyone, for links
|
||||
that are slow or blocked for some. The server must be able to reach it,
|
||||
and it must have a password (an open server refuses: anyone could make
|
||||
it fetch addresses only it can reach).
|
||||
- Through me: your computer downloads it and passes it on, for links only
|
||||
you can open.
|
||||
|
||||
Web pages work in all three: the app and the dedicated server each
|
||||
download [yt-dlp](https://github.com/yt-dlp/yt-dlp) by themselves (checked
|
||||
against the release's checksums, updated once a day; Settings → Player
|
||||
shows its version and has "Update now"), so there is nothing to install.
|
||||
Sites that refuse guests (Bilibili answers "HTTP Error 412", YouTube may
|
||||
ask to sign in) need cookies: Settings → Player → yt-dlp → Cookies reads
|
||||
them from a browser, or from a cookies.txt or a cookie extension's JSON
|
||||
export (picked or dropped; every site in it is kept). They stay on this
|
||||
computer and are used by its yt-dlp only (your own playback, "through
|
||||
me", and a server you host). Cookies log yt-dlp in as you on those
|
||||
sites: keep the file private, and for YouTube consider a throwaway
|
||||
account (Google may flag accounts used with yt-dlp).
|
||||
YouTube works in all three too, and so do pages and links that only
|
||||
offer streaming playlists (HLS `.m3u8`, DASH `.mpd`, the HLS of X videos).
|
||||
The last two never show the link to anyone; they can't pass on live
|
||||
streams or DRM-protected video (use "Everyone fetches it" for live).
|
||||
Your upload has to beat the video's bitrate: a 25-minute 2 GB episode is
|
||||
about 11 Mbit/s.
|
||||
|
||||
## Video quality and shaders
|
||||
|
||||
@@ -206,8 +367,9 @@ C (480p), A+A, B+B, C+A, each as HQ (strong GPUs, GTX 1080 and up) or Fast
|
||||
|
||||
Pick one with the ✨ button in the player's control bar (also in
|
||||
fullscreen) or Ctrl+0..6; the change applies live and is saved, so the next
|
||||
session starts with it. Settings → Video quality & shaders sets the default,
|
||||
HQ or Fast (used by Ctrl+1..6) and the custom files. Shaders cost GPU time
|
||||
session starts with it. The player's ⚙ and Settings → Player set it too,
|
||||
with HQ or Fast (used by Ctrl+1..6); Settings → Player holds the custom
|
||||
files. Shaders cost GPU time
|
||||
every frame and need hardware video rendering: on media_kit's software
|
||||
fallback they do nothing, and the app says so. A shader that is missing or
|
||||
fails to load shows a toast and switches shaders off.
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256">
|
||||
<defs><linearGradient id="bg" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#2d2547"/><stop offset="1" stop-color="#14111d"/></linearGradient><linearGradient id="t1" gradientUnits="userSpaceOnUse" x1="50.7" y1="148.7" x2="184.6" y2="71.4"><stop offset="0" stop-color="#f0795a" stop-opacity="0"/><stop offset="0.45" stop-color="#f0795a" stop-opacity="0.85"/><stop offset="1" stop-color="#ffc56b"/></linearGradient><linearGradient id="t2" gradientUnits="userSpaceOnUse" x1="205.3" y1="107.3" x2="71.4" y2="184.6"><stop offset="0" stop-color="#ffc56b" stop-opacity="0"/><stop offset="0.45" stop-color="#ffc56b" stop-opacity="0.85"/><stop offset="1" stop-color="#f0795a"/></linearGradient></defs>
|
||||
<rect x="8" y="8" width="240" height="240" rx="56" fill="url(#bg)"/>
|
||||
<circle cx="128" cy="128" r="80" fill="none" stroke="#ffffff" stroke-opacity="0.10" stroke-width="6"/>
|
||||
<path d="M205.27 107.29 A80 80 0 0 1 71.43 184.57" fill="none" stroke="url(#t2)" stroke-width="7" stroke-linecap="butt"/><path d="M50.73 148.71 A80 80 0 0 1 184.57 71.43" fill="none" stroke="url(#t1)" stroke-width="7" stroke-linecap="butt"/>
|
||||
<path d="M110 96 L110 160 L162 128 Z" fill="#fff1d6" stroke="#fff1d6" stroke-width="16" stroke-linejoin="round"/>
|
||||
<path d="M71.4 161.6 Q74.7 181.3 94.4 184.6 Q74.7 187.8 71.4 207.6 Q68.2 187.8 48.4 184.6 Q68.2 181.3 71.4 161.6 Z" fill="#f0795a"/>
|
||||
<path d="M184.6 40.4 Q188.9 67.1 215.6 71.4 Q188.9 75.8 184.6 102.4 Q180.2 75.8 153.6 71.4 Q180.2 67.1 184.6 40.4 Z" fill="#ffc56b"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 7.3 KiB |
|
After Width: | Height: | Size: 732 B |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
@@ -1,28 +1,19 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:signals/signals_flutter.dart';
|
||||
|
||||
import 'src/screens/home/home_screen.dart';
|
||||
import 'src/screens/home/ui.dart';
|
||||
import 'src/screens/session_screen.dart';
|
||||
import 'src/state/session_state.dart';
|
||||
import 'src/shell/app_shell.dart';
|
||||
import 'src/widgets/app_keys.dart';
|
||||
|
||||
class WatchTogetherApp extends StatelessWidget {
|
||||
const WatchTogetherApp({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final session = SessionState.instance;
|
||||
return MaterialApp(
|
||||
title: appName,
|
||||
theme: ThemeData.dark(),
|
||||
home: SignalBuilder(
|
||||
builder: (context) {
|
||||
return switch (session.route.value) {
|
||||
AppRoute.home => const HomeScreen(),
|
||||
AppRoute.session => const SessionScreen(),
|
||||
};
|
||||
},
|
||||
),
|
||||
shortcuts: appShortcuts,
|
||||
home: const AppKeys(child: AppShell()),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
@@ -7,10 +8,12 @@ import 'package:sync_relay_core/core.dart';
|
||||
import 'package:window_manager/window_manager.dart';
|
||||
|
||||
import 'app.dart';
|
||||
import 'src/playback/yt_dlp_app.dart';
|
||||
import 'src/platform/desktop_entry.dart';
|
||||
import 'src/screens/home/ui.dart';
|
||||
import 'src/state/session_state.dart';
|
||||
import 'src/state/settings.dart';
|
||||
import 'src/update/updater.dart';
|
||||
import 'src/update/update_controller.dart' show launchArgs;
|
||||
|
||||
Future<void> main(List<String> args) async {
|
||||
launchArgs = List.unmodifiable(args);
|
||||
@@ -60,6 +63,10 @@ Future<void> main(List<String> args) async {
|
||||
debugPrint('load connect form failed: $e\n$st');
|
||||
}
|
||||
MediaKit.ensureInitialized();
|
||||
// yt-dlp for web page links: downloaded on first run, kept current.
|
||||
AppYtDlp.instance.warmUp();
|
||||
// Linux: the launcher entry and icon Wayland needs (release installs).
|
||||
unawaited(installDesktopEntry());
|
||||
runApp(const WatchTogetherApp());
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Follows whether media_kit's desktop controls around [child] are up,
|
||||
/// by the same rule they use: up on mount and on any mouse movement,
|
||||
/// down [hideAfter] later or when the mouse leaves. media_kit does not
|
||||
/// expose its visibility; this lets the player draw a progress line while
|
||||
/// they are down. Never takes input.
|
||||
class ControlsHoverProbe extends StatefulWidget {
|
||||
final ValueNotifier<bool> shown;
|
||||
final Duration hideAfter;
|
||||
final Widget child;
|
||||
const ControlsHoverProbe({super.key, required this.shown, required this.hideAfter, required this.child});
|
||||
|
||||
@override
|
||||
State<ControlsHoverProbe> createState() => _ControlsHoverProbeState();
|
||||
}
|
||||
|
||||
class _ControlsHoverProbeState extends State<ControlsHoverProbe> {
|
||||
Timer? _timer;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Not during this build: the line listening to [shown] may be built.
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) _wake();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_timer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _wake() {
|
||||
widget.shown.value = true;
|
||||
_timer?.cancel();
|
||||
_timer = Timer(widget.hideAfter, _hide);
|
||||
}
|
||||
|
||||
void _hide() {
|
||||
_timer?.cancel();
|
||||
if (mounted) widget.shown.value = false;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => MouseRegion(
|
||||
opaque: false,
|
||||
hitTestBehavior: HitTestBehavior.translucent,
|
||||
onEnter: (_) => _wake(),
|
||||
onHover: (_) => _wake(),
|
||||
onExit: (_) => _hide(),
|
||||
child: widget.child,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:signals/signals_flutter.dart';
|
||||
import 'package:sync_relay_core/core.dart' show JellyfinStreamType;
|
||||
|
||||
import '../jellyfin/track_prefs.dart' show MpvTrack;
|
||||
import '../playback/playback_controller.dart' show PlaybackController, RenderMode;
|
||||
import '../shaders/shader_presets.dart';
|
||||
import '../state/session_state.dart';
|
||||
import '../shell/motion.dart';
|
||||
import '../state/settings.dart';
|
||||
import 'uosc/buttons.dart';
|
||||
import 'uosc/controls.dart';
|
||||
|
||||
// Everything about the player, defined once: the rows of the ⚙ card in
|
||||
// both control designs and of Settings → Player. Each row reads and
|
||||
// writes [AppSettings] and applies to a running player right away.
|
||||
|
||||
/// One setting: a label, one line of help under it, the control at the
|
||||
/// right ([below]: an extra line under the row, e.g. a text field).
|
||||
class SettingRow extends StatelessWidget {
|
||||
final String label;
|
||||
final String? help;
|
||||
final Widget control;
|
||||
final Widget? below;
|
||||
const SettingRow({super.key, required this.label, this.help, required this.control, this.below});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final muted = Theme.of(context).colorScheme.onSurfaceVariant;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(label),
|
||||
if (help != null) Text(help!, style: TextStyle(color: muted, fontSize: 12)),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
control,
|
||||
],
|
||||
),
|
||||
?below,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
DropdownButton<T> _dropdown<T>({
|
||||
required Key key,
|
||||
required T value,
|
||||
required List<(T, String)> items,
|
||||
required ValueChanged<T> onChanged,
|
||||
bool expanded = false,
|
||||
}) =>
|
||||
DropdownButton<T>(
|
||||
key: key,
|
||||
value: value,
|
||||
isExpanded: expanded,
|
||||
underline: const SizedBox.shrink(),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
items: [
|
||||
for (final (v, label) in items)
|
||||
DropdownMenuItem(value: v, child: Text(label, maxLines: 1, overflow: TextOverflow.ellipsis)),
|
||||
],
|
||||
onChanged: (v) {
|
||||
if (v != null) onChanged(v);
|
||||
},
|
||||
);
|
||||
|
||||
/// Standard (our bars over media_kit's controls) or uosc. Live.
|
||||
class PlayerDesignSetting extends StatelessWidget {
|
||||
const PlayerDesignSetting({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final s = AppSettings.instance;
|
||||
return SignalBuilder(
|
||||
builder: (context) => SettingRow(
|
||||
label: 'Player design',
|
||||
help: 'uosc is modeled on mpv: a thick timeline with chapters and what is cached',
|
||||
control: _dropdown<bool>(
|
||||
key: const ValueKey('player-design'),
|
||||
value: s.mpvControls.value,
|
||||
items: const [(false, 'Standard'), (true, 'uosc')],
|
||||
onChanged: s.setMpvControls,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The 2 px line at the bottom while the controls are hidden. Live.
|
||||
class ProgressLineSetting extends StatelessWidget {
|
||||
const ProgressLineSetting({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final s = AppSettings.instance;
|
||||
return SignalBuilder(
|
||||
builder: (context) => SettingRow(
|
||||
label: 'Progress line when hidden',
|
||||
help: 'A thin bar at the bottom of the video while the controls are hidden',
|
||||
control: Switch(
|
||||
key: const ValueKey('progress-line'),
|
||||
value: s.progressLine.value,
|
||||
onChanged: s.setProgressLine,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Saves a shader choice (also used on the next join) and puts it on the
|
||||
/// running player, if any; the player announces the result.
|
||||
void applyShaderChoice(ShaderPreset preset, ShaderQuality? quality) {
|
||||
final s = AppSettings.instance;
|
||||
if (quality != null) s.setShaderQuality(quality);
|
||||
s.setShaderPreset(preset);
|
||||
final coord = SessionState.instance.coordinator;
|
||||
coord.markInput();
|
||||
coord.playback.applyShaders(announce: true);
|
||||
}
|
||||
|
||||
/// Anime4K preset (or custom files) and HQ / Fast. Live.
|
||||
class ShaderPresetSetting extends StatelessWidget {
|
||||
/// The running player's render mode (in the player); else the last one.
|
||||
final ReadonlySignal<RenderMode>? renderMode;
|
||||
const ShaderPresetSetting({super.key, this.renderMode});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final s = AppSettings.instance;
|
||||
return SignalBuilder(builder: (context) {
|
||||
final preset = s.shaderPreset.value;
|
||||
final quality = s.shaderQuality.value;
|
||||
final hasCustom = s.customShaders.value.isNotEmpty;
|
||||
final mode = (renderMode ?? PlaybackController.lastRenderMode).value;
|
||||
final warning = switch (mode) {
|
||||
RenderMode.software => 'Software rendering: shaders have no effect',
|
||||
RenderMode.softwareGl => 'OpenGL on the CPU: shaders will be slow',
|
||||
_ => null,
|
||||
};
|
||||
return SettingRow(
|
||||
label: 'Upscaling shader',
|
||||
help: 'Anime4K sharpens anime on your GPU, only you see it. '
|
||||
'A for 1080p, B for 720p, C for 480p · Ctrl+1…6, Ctrl+0 off',
|
||||
control: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_dropdown<ShaderPreset>(
|
||||
key: const ValueKey('shader-preset'),
|
||||
value: preset,
|
||||
items: [
|
||||
for (final p in ShaderPreset.values)
|
||||
if (p != ShaderPreset.custom || hasCustom || preset == p) (p, p.label),
|
||||
],
|
||||
onChanged: (p) => applyShaderChoice(p, null),
|
||||
),
|
||||
if (preset.isAnime4k) ...[
|
||||
const SizedBox(width: 8),
|
||||
_dropdown<ShaderQuality>(
|
||||
key: const ValueKey('shader-quality'),
|
||||
value: quality,
|
||||
items: [for (final q in ShaderQuality.values) (q, q.label)],
|
||||
onChanged: (q) => applyShaderChoice(preset, q),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
below: warning == null
|
||||
? null
|
||||
: Text(warning, style: const TextStyle(fontSize: 12, color: Colors.orange)),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Puts the hwdec setting on the running player (mpv switches decoders
|
||||
/// on the fly). `SYNC_RELAY_HWDEC` wins, as at player start.
|
||||
Future<void> applyHwdecLive() async {
|
||||
if ((Platform.environment['SYNC_RELAY_HWDEC'] ?? '').isNotEmpty) return;
|
||||
final playback = SessionState.instance.coordinator.playback;
|
||||
if (!playback.alive.peek()) return;
|
||||
try {
|
||||
final dynamic platform = playback.player.platform;
|
||||
await platform?.setProperty('hwdec', AppSettings.instance.hwdec.peek());
|
||||
} catch (e) {
|
||||
debugPrint('live hwdec failed: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// mpv's `hwdec`: auto, off, or a custom value. Live.
|
||||
class HwdecSetting extends StatefulWidget {
|
||||
const HwdecSetting({super.key});
|
||||
|
||||
@override
|
||||
State<HwdecSetting> createState() => _HwdecSettingState();
|
||||
}
|
||||
|
||||
class _HwdecSettingState extends State<HwdecSetting> {
|
||||
final _s = AppSettings.instance;
|
||||
late final _custom = TextEditingController(text: _s.hwdecCustom.peek());
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_custom.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SignalBuilder(builder: (context) {
|
||||
final mode = _s.hwdecMode.value;
|
||||
return SettingRow(
|
||||
label: 'Hardware decoding',
|
||||
help: 'Decodes video on the GPU. Turn it off if the picture glitches or stays black',
|
||||
control: _dropdown<HwdecMode>(
|
||||
key: const ValueKey('hwdec'),
|
||||
value: mode,
|
||||
items: const [(HwdecMode.auto, 'Auto'), (HwdecMode.off, 'Off'), (HwdecMode.custom, 'Custom')],
|
||||
onChanged: (m) {
|
||||
_s.setHwdecMode(m);
|
||||
applyHwdecLive();
|
||||
},
|
||||
),
|
||||
below: mode != HwdecMode.custom
|
||||
? null
|
||||
: Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: TextField(
|
||||
controller: _custom,
|
||||
decoration: const InputDecoration(
|
||||
isDense: true,
|
||||
labelText: 'mpv hwdec value (Enter applies)',
|
||||
hintText: 'e.g. vaapi, nvdec, d3d11va, auto-safe',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
onChanged: _s.setHwdecCustom,
|
||||
onSubmitted: (_) => applyHwdecLive(),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Audio or subtitle track of the running player (in the player only).
|
||||
/// The pick is remembered for the next Jellyfin item.
|
||||
class TrackSetting extends StatelessWidget {
|
||||
final JellyfinStreamType type;
|
||||
const TrackSetting({super.key, required this.type});
|
||||
|
||||
bool get _audio => type == JellyfinStreamType.audio;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final coord = SessionState.instance.coordinator;
|
||||
return SignalBuilder(builder: (context) {
|
||||
final list = [
|
||||
for (final t in coord.playback.tracks.value)
|
||||
if (t.type == type) t,
|
||||
];
|
||||
// -1: subtitles off.
|
||||
final selected = list.where((t) => t.selected).firstOrNull?.id ?? (_audio ? null : -1);
|
||||
final items = <(int, String)>[
|
||||
if (!_audio) (-1, 'Off'),
|
||||
for (final MpvTrack t in list) (t.id, t.label),
|
||||
];
|
||||
return SettingRow(
|
||||
label: _audio ? 'Audio' : 'Subtitles',
|
||||
help: 'Remembered for the next episode',
|
||||
control: SizedBox(
|
||||
width: 190,
|
||||
child: items.isEmpty || selected == null
|
||||
? Text(items.isEmpty ? 'None' : 'Pick one…',
|
||||
key: ValueKey(_audio ? 'audio-track' : 'subtitle-track'), textAlign: TextAlign.end)
|
||||
: _dropdown<int>(
|
||||
key: ValueKey(_audio ? 'audio-track' : 'subtitle-track'),
|
||||
value: selected,
|
||||
expanded: true,
|
||||
items: items,
|
||||
onChanged: (id) {
|
||||
coord.markInput();
|
||||
if (id == -1) {
|
||||
coord.selectTrack(subtitlesOff: true);
|
||||
} else if (_audio) {
|
||||
coord.selectTrack(aid: id);
|
||||
} else {
|
||||
coord.selectTrack(sid: id);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// The player rows of Settings → Player and the ⚙ card, in order.
|
||||
List<Widget> playerSettingRows({ReadonlySignal<RenderMode>? renderMode}) => [
|
||||
const PlayerDesignSetting(),
|
||||
const ProgressLineSetting(),
|
||||
ShaderPresetSetting(renderMode: renderMode),
|
||||
const HwdecSetting(),
|
||||
];
|
||||
|
||||
/// The ⚙ card: every player setting, plus the running player's tracks.
|
||||
class PlayerSettingsCard extends StatelessWidget {
|
||||
final ReadonlySignal<RenderMode>? renderMode;
|
||||
/// Show the audio / subtitle pickers (a player is running).
|
||||
final bool tracks;
|
||||
const PlayerSettingsCard({super.key, this.renderMode, this.tracks = true});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
key: const ValueKey('player-settings-card'),
|
||||
elevation: 12,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: SizedBox(
|
||||
width: 420,
|
||||
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: 4),
|
||||
...playerSettingRows(renderMode: renderMode),
|
||||
if (tracks) ...[
|
||||
const Divider(),
|
||||
const TrackSetting(type: JellyfinStreamType.subtitle),
|
||||
const TrackSetting(type: JellyfinStreamType.audio),
|
||||
],
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Custom shader files: Settings → Player',
|
||||
style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant, fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens the ⚙ card over the player, near the controls' right end. The
|
||||
/// uosc controls stay up while it is open.
|
||||
Future<void> showPlayerSettings(BuildContext context, {ReadonlySignal<RenderMode>? renderMode}) {
|
||||
Future<void> open() => showPopover<void>(
|
||||
context,
|
||||
at: const Alignment(0.96, 0.5),
|
||||
child: PlayerSettingsCard(renderMode: renderMode),
|
||||
);
|
||||
final controls = UoscControls.maybeOf(context);
|
||||
return controls == null ? open() : controls.holdDuring(open);
|
||||
}
|
||||
|
||||
/// ⚙ in the standard controls' bottom bar.
|
||||
class PlayerSettingsButton extends StatelessWidget {
|
||||
final FocusNode? focusNode;
|
||||
final ReadonlySignal<RenderMode>? renderMode;
|
||||
const PlayerSettingsButton({super.key, this.focusNode, this.renderMode});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return IconButton(
|
||||
key: const ValueKey('player-settings'),
|
||||
focusNode: focusNode,
|
||||
tooltip: 'Player settings',
|
||||
color: Colors.white,
|
||||
iconSize: 20,
|
||||
visualDensity: VisualDensity.compact,
|
||||
icon: const Icon(Icons.tune),
|
||||
onPressed: () => showPlayerSettings(context, renderMode: renderMode),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// ⚙ in the uosc controls' button row.
|
||||
class UoscPlayerSettingsButton extends StatelessWidget {
|
||||
final ReadonlySignal<RenderMode>? renderMode;
|
||||
const UoscPlayerSettingsButton({super.key, this.renderMode});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return UoscButton(
|
||||
key: const ValueKey('uosc-player-settings'),
|
||||
icon: Icons.tune_rounded,
|
||||
tooltip: 'Player settings',
|
||||
onPressed: () => showPlayerSettings(context, renderMode: renderMode),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import '../../screens/session/player_controls.dart' show TrackPick;
|
||||
import '../../shaders/shader_menu.dart';
|
||||
import '../../shaders/shader_presets.dart';
|
||||
import '../../state/settings.dart';
|
||||
import '../volume.dart';
|
||||
import 'controls.dart';
|
||||
import 'menu.dart';
|
||||
import 'style.dart';
|
||||
@@ -166,9 +167,10 @@ class _UoscBadgeButtonState extends State<UoscBadgeButton> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Volume: click mutes / unmutes, the wheel steps 5 %.
|
||||
/// Volume: click mutes / unmutes, hovering shows a [VolumeBar] up to
|
||||
/// [maxVolume], the wheel steps 5 %.
|
||||
class UoscVolumeButton extends StatefulWidget {
|
||||
/// 0..100.
|
||||
/// 0..[maxVolume].
|
||||
final double volume;
|
||||
final ValueChanged<double> onChanged;
|
||||
const UoscVolumeButton({super.key, required this.volume, required this.onChanged});
|
||||
@@ -179,20 +181,43 @@ class UoscVolumeButton extends StatefulWidget {
|
||||
|
||||
class _UoscVolumeButtonState extends State<UoscVolumeButton> {
|
||||
double _beforeMute = 100;
|
||||
bool _hover = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final v = widget.volume;
|
||||
final icon = v <= 0 ? Icons.volume_off_rounded : (v < 50 ? Icons.volume_down_rounded : Icons.volume_up_rounded);
|
||||
return Listener(
|
||||
return MouseRegion(
|
||||
onEnter: (_) => setState(() => _hover = true),
|
||||
onExit: (_) => setState(() => _hover = false),
|
||||
child: Listener(
|
||||
onPointerSignal: (e) {
|
||||
if (e is PointerScrollEvent && e.scrollDelta.dy != 0) {
|
||||
widget.onChanged((v + (e.scrollDelta.dy < 0 ? 5 : -5)).clamp(0.0, 100.0));
|
||||
widget.onChanged(stepVolume(v, e.scrollDelta.dy < 0 ? 5 : -5));
|
||||
}
|
||||
},
|
||||
child: UoscButton(
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
AnimatedSize(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
child: _hover
|
||||
? Padding(
|
||||
padding: const EdgeInsets.only(left: 6, right: 2),
|
||||
child: VolumeBar(
|
||||
key: const ValueKey('uosc-volume-bar'),
|
||||
volume: v,
|
||||
onChanged: widget.onChanged,
|
||||
color: UoscStyle.text,
|
||||
trackColor: UoscStyle.dim.withValues(alpha: 0.5),
|
||||
),
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
),
|
||||
UoscButton(
|
||||
key: const ValueKey('uosc-volume'),
|
||||
icon: icon,
|
||||
color: v > 100 ? boostColor : null,
|
||||
tooltip: 'Volume ${v.round()} % (scroll to change)',
|
||||
onPressed: () {
|
||||
if (v > 0) {
|
||||
@@ -203,6 +228,9 @@ class _UoscVolumeButtonState extends State<UoscVolumeButton> {
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,12 +5,13 @@ import 'package:signals/signals_flutter.dart';
|
||||
|
||||
import '../../state/settings.dart';
|
||||
|
||||
// The experimental mpv-style (uosc) controls frame: a full-width timeline
|
||||
// at the bottom with a row of flat buttons above it, window buttons top
|
||||
// right. Mouse movement shows them; after [UoscControls.hideAfter] without
|
||||
// any they fade out, the timeline shrinks to a thin line and the cursor
|
||||
// hides. Everything lives here because media_kit's fullscreen route
|
||||
// renders only the video and its controls.
|
||||
// The mpv-style (uosc) controls frame: a full-width timeline at the
|
||||
// bottom with a row of flat buttons above it, the title top left and a
|
||||
// corner widget top right.
|
||||
// Mouse movement shows them; after [UoscControls.hideAfter] without any
|
||||
// they fade out, the timeline shrinks to a 2 px line (gone when "Progress
|
||||
// line when hidden" is off) and the cursor hides. Our own frame: no
|
||||
// media_kit controls state to reach into.
|
||||
|
||||
class UoscControls extends StatefulWidget {
|
||||
/// The timeline; [expanded] is false while the controls are hidden.
|
||||
@@ -18,18 +19,25 @@ class UoscControls extends StatefulWidget {
|
||||
/// Buttons left and right of the row above the timeline.
|
||||
final List<Widget> left;
|
||||
final List<Widget> right;
|
||||
/// Top-right corner (window buttons), shown with the controls.
|
||||
/// Top-right corner (chat, back to the library), shown with the controls.
|
||||
final Widget? top;
|
||||
/// Top left (what is playing), shown with the controls; gets the width
|
||||
/// [top] leaves.
|
||||
final Widget? topLeft;
|
||||
final VoidCallback? onDoubleTap;
|
||||
final Duration hideAfter;
|
||||
/// Follows whether the controls are up (for overlays outside them).
|
||||
final ValueNotifier<bool>? shown;
|
||||
const UoscControls({
|
||||
super.key,
|
||||
required this.timeline,
|
||||
this.left = const [],
|
||||
this.right = const [],
|
||||
this.top,
|
||||
this.topLeft,
|
||||
this.onDoubleTap,
|
||||
this.hideAfter = const Duration(seconds: 3),
|
||||
this.shown,
|
||||
});
|
||||
|
||||
static const buttonRowHeight = 40.0;
|
||||
@@ -53,6 +61,10 @@ class UoscControlsState extends State<UoscControls> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Not during this build: an overlay listening to [shown] may be built.
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) widget.shown?.value = true;
|
||||
});
|
||||
_hideLater();
|
||||
}
|
||||
|
||||
@@ -64,7 +76,10 @@ class UoscControlsState extends State<UoscControls> {
|
||||
|
||||
/// Show the controls and restart the hide countdown.
|
||||
void wake() {
|
||||
if (!_visible && mounted) setState(() => _visible = true);
|
||||
if (!_visible && mounted) {
|
||||
setState(() => _visible = true);
|
||||
widget.shown?.value = true;
|
||||
}
|
||||
_hideLater();
|
||||
}
|
||||
|
||||
@@ -93,6 +108,7 @@ class UoscControlsState extends State<UoscControls> {
|
||||
void _hide() {
|
||||
if (!mounted || _holds > 0 || !_visible) return;
|
||||
setState(() => _visible = false);
|
||||
widget.shown?.value = false;
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -136,13 +152,54 @@ class UoscControlsState extends State<UoscControls> {
|
||||
),
|
||||
),
|
||||
),
|
||||
if (widget.top != null)
|
||||
if (widget.topLeft != null)
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
height: 120,
|
||||
child: IgnorePointer(
|
||||
child: AnimatedOpacity(
|
||||
duration: fade,
|
||||
opacity: visible ? 1 : 0,
|
||||
child: const DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [Color(0x8C000000), Color(0x00000000)],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (widget.top != null || widget.topLeft != null)
|
||||
Positioned(
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: IgnorePointer(
|
||||
key: const ValueKey('uosc-top'),
|
||||
ignoring: !visible,
|
||||
child: AnimatedOpacity(duration: fade, opacity: visible ? 1 : 0, child: widget.top),
|
||||
child: AnimatedOpacity(
|
||||
duration: fade,
|
||||
opacity: visible ? 1 : 0,
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
|
||||
child: widget.topLeft == null
|
||||
? const SizedBox.shrink()
|
||||
: Align(alignment: AlignmentDirectional.centerStart, child: widget.topLeft),
|
||||
),
|
||||
),
|
||||
?widget.top,
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
@@ -168,7 +225,19 @@ class UoscControlsState extends State<UoscControls> {
|
||||
),
|
||||
),
|
||||
),
|
||||
widget.timeline(context, visible),
|
||||
// Hidden: the collapsed timeline is the progress line,
|
||||
// unless the player settings turn it off.
|
||||
SignalBuilder(
|
||||
builder: (context) {
|
||||
final line = AppSettings.instance.progressLine.value;
|
||||
return AnimatedOpacity(
|
||||
key: const ValueKey('uosc-timeline-wrap'),
|
||||
duration: fade,
|
||||
opacity: visible || line ? 1 : 0,
|
||||
child: widget.timeline(context, visible),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -178,9 +247,9 @@ class UoscControlsState extends State<UoscControls> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The session's controls: media_kit's ([material]) or, with Settings →
|
||||
/// "Experimental: mpv-style controls", the uosc ones ([mpv]). Switches
|
||||
/// live when the setting changes.
|
||||
/// The session's controls: the standard ones ([material]) or, with the
|
||||
/// "Player design" setting on uosc, the uosc ones ([mpv]). Switches live
|
||||
/// when the setting changes.
|
||||
class SessionControlsSwitch extends StatelessWidget {
|
||||
final WidgetBuilder material;
|
||||
final WidgetBuilder mpv;
|
||||
|
||||
@@ -59,7 +59,7 @@ class UoscTimeline extends StatefulWidget {
|
||||
});
|
||||
|
||||
static const height = 30.0;
|
||||
static const collapsedHeight = 3.0;
|
||||
static const collapsedHeight = 2.0;
|
||||
|
||||
@override
|
||||
State<UoscTimeline> createState() => _UoscTimelineState();
|
||||
|
||||
@@ -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(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:hive_ce/hive.dart';
|
||||
import 'package:signals/signals_flutter.dart';
|
||||
|
||||
// Files source state, one Hive box: the folders the user added and the
|
||||
// local files played last. Same shape as AppSettings: signals start empty,
|
||||
// take the stored values once [FolderStore.ensureLoaded] finishes, and
|
||||
// mutators write through.
|
||||
|
||||
const filesBoxName = 'files';
|
||||
|
||||
const _keyFolders = 'folders';
|
||||
const _keyRecent = 'recent';
|
||||
|
||||
/// Recent files kept, most recent first.
|
||||
const maxRecentFiles = 30;
|
||||
|
||||
class FolderStore {
|
||||
static final FolderStore instance = FolderStore();
|
||||
|
||||
/// Watched folders, in the order they were added.
|
||||
final folders = signal<List<String>>(const []);
|
||||
/// Local files played, most recent first, no duplicates.
|
||||
final recent = signal<List<String>>(const []);
|
||||
|
||||
Future<void>? _load;
|
||||
Box? _box;
|
||||
|
||||
Future<void> ensureLoaded() => _load ??= _loadNow();
|
||||
|
||||
Future<void> _loadNow() async {
|
||||
try {
|
||||
final box = _box = await Hive.openBox(filesBoxName);
|
||||
final f = box.get(_keyFolders);
|
||||
if (f is List) folders.value = List.unmodifiable(f.whereType<String>());
|
||||
final r = box.get(_keyRecent);
|
||||
if (r is List) {
|
||||
recent.value = List.unmodifiable(
|
||||
r.whereType<String>().toSet().take(maxRecentFiles));
|
||||
}
|
||||
} catch (e, st) {
|
||||
debugPrint('load files store failed: $e\n$st');
|
||||
}
|
||||
}
|
||||
|
||||
/// False when [dir] is already watched.
|
||||
bool addFolder(String dir) {
|
||||
if (folders.value.contains(dir)) return false;
|
||||
_setFolders([...folders.value, dir]);
|
||||
return true;
|
||||
}
|
||||
|
||||
void removeFolder(String dir) =>
|
||||
_setFolders([...folders.value.where((d) => d != dir)]);
|
||||
|
||||
/// Moves [path] to the top of [recent].
|
||||
void addRecent(String path) => _setRecent(
|
||||
[path, ...recent.value.where((p) => p != path)].take(maxRecentFiles));
|
||||
|
||||
void removeRecent(String path) =>
|
||||
_setRecent(recent.value.where((p) => p != path));
|
||||
|
||||
void clearRecent() => _setRecent(const []);
|
||||
|
||||
void _setFolders(List<String> list) {
|
||||
folders.value = List.unmodifiable(list);
|
||||
_put(_keyFolders, [...list]);
|
||||
}
|
||||
|
||||
void _setRecent(Iterable<String> list) {
|
||||
recent.value = List.unmodifiable(list);
|
||||
_put(_keyRecent, [...recent.value]);
|
||||
}
|
||||
|
||||
Future<void> _put(String key, Object value) async {
|
||||
try {
|
||||
final box = _box ??
|
||||
(Hive.isBoxOpen(filesBoxName) ? Hive.box(filesBoxName) : null);
|
||||
await box?.put(key, value);
|
||||
} catch (e, st) {
|
||||
debugPrint('save files store $key failed: $e\n$st');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
// Release file names -> series / season / episode, anitomy-style but small:
|
||||
// enough to group a downloads folder into shows. Unknown shapes fall back to
|
||||
// the cleaned name with no episode, never to a wrong number.
|
||||
|
||||
/// What a release file name says.
|
||||
class ParsedName {
|
||||
/// Title without group, season marker, episode, year or release tags.
|
||||
final String series;
|
||||
final int? season;
|
||||
final int? episode;
|
||||
/// Release group: leading `[Group]` or scene-style trailing `-GRP`.
|
||||
final String? group;
|
||||
/// Release year, taken out of [series] (movies, `Title (2019)`).
|
||||
final int? year;
|
||||
/// Non-episode marker (`NCOP1`, `NCED`, `OVA 2`, `Special`): such files
|
||||
/// carry no [episode] so they never sort among the real ones.
|
||||
final String? extra;
|
||||
|
||||
const ParsedName(
|
||||
this.series, {
|
||||
this.season,
|
||||
this.episode,
|
||||
this.group,
|
||||
this.year,
|
||||
this.extra,
|
||||
});
|
||||
|
||||
/// `S01E03`, `E25`, the [extra] marker, `S02` for a season pack, else ''.
|
||||
String get label {
|
||||
final s = season == null ? '' : 'S${season.toString().padLeft(2, '0')}';
|
||||
if (episode != null) return '${s}E${episode.toString().padLeft(2, '0')}';
|
||||
return extra ?? s;
|
||||
}
|
||||
|
||||
/// Grouping key: case, punctuation and spacing do not split a series.
|
||||
String get seriesKey => normalizeSeries(series);
|
||||
|
||||
@override
|
||||
String toString() => 'ParsedName($series, s=$season, e=$episode, '
|
||||
'group=$group, year=$year, extra=$extra)';
|
||||
}
|
||||
|
||||
/// Lowercase letters and digits of [s], single spaces between words.
|
||||
String normalizeSeries(String s) => s
|
||||
.toLowerCase()
|
||||
.replaceAll(RegExp(r"['’`]"), '')
|
||||
.replaceAll(RegExp(r'[^\p{L}\p{N}]+', unicode: true), ' ')
|
||||
.trim();
|
||||
|
||||
// Release tags: the title ends where the first of these starts.
|
||||
final _junk = RegExp(
|
||||
r'\b(?:2160p|1440p|1080[pi]|720p|576p|480p|4K|UHD|[xh]\.?26[45]|HEVC|AVC|'
|
||||
r'AV1|VP9|WEB(?:-?DL|-?Rip)?|BD(?:Rip|Remux)?|Blu-?Ray|BRRip|DVD(?:Rip)?|'
|
||||
r'HDTV|HDR(?:10)?|DV|REMUX|AAC(?:\d\.\d)?|FLAC|Opus|DTS(?:-HD)?|E?-?AC-?3|'
|
||||
r'DDP?\d?|TrueHD|Atmos|10-?bit|8-?bit|Hi10P?|PROPER|REPACK|Dual[ -]Audio|'
|
||||
r'Multi(?:ple)?[ -]Subs?(?:titles?)?|AMZN|NF|CR|DSNP|HMAX|ATVP|'
|
||||
r'Batch|Complete)\b',
|
||||
caseSensitive: false,
|
||||
);
|
||||
final _ext = RegExp(r'\.(?=[a-z0-9]*[a-z])[a-z0-9]{2,4}$', caseSensitive: false);
|
||||
final _leadingGroup = RegExp(r'^\s*\[([^\]]+)\]');
|
||||
final _sceneGroup = RegExp(r'-([A-Za-z0-9]+)$');
|
||||
final _bracketYear = RegExp(r'[(\[]((?:19|20)\d{2})[)\]]');
|
||||
final _brackets = RegExp(r'\[[^\]]*\]|\([^)]*\)|\{[^}]*\}|【[^】]*】');
|
||||
// S01E02, S01.E02, S1 E2; a batch `S01E01-E02` / `S01E01E02` keeps the first.
|
||||
final _sxe = RegExp(r'\bS(\d{1,2})[ .]?E(\d{1,4})(?:v\d)?(?!\d)',
|
||||
caseSensitive: false);
|
||||
final _nxn = RegExp(r'\b(\d{1,2})x(\d{2,3})\b');
|
||||
// Anime: `Title - 25`, `- 03v2`, `- 01-02` (batch keeps the first).
|
||||
final _dashNumber = RegExp(r'\s[-–]\s(\d{1,4})(?:v\d)?(?=$|[\s\-~.])');
|
||||
final _episodeWord = RegExp(
|
||||
r'\b(?:Episode|Ep\.?|E)\s?(\d{1,4})(?:v\d)?\b',
|
||||
caseSensitive: false,
|
||||
);
|
||||
// Extras: unambiguous words anywhere, short ones only right after the dash.
|
||||
final _extraAnywhere = RegExp(
|
||||
r'(?:^|\s)(NCOP|NCED|OVA|OAD|ONA)\s?(\d{1,2})?(?:v\d)?(?=$|\s)',
|
||||
caseSensitive: false,
|
||||
);
|
||||
final _extraAfterDash = RegExp(
|
||||
r'\s[-–]\s(OP|ED|SP|Specials?|Extras?|Recap|Preview|PV|Menu)\s?(\d{1,2})?'
|
||||
r'(?:v\d)?(?=$|\s)',
|
||||
caseSensitive: false,
|
||||
);
|
||||
final _seasonInTitle = RegExp(
|
||||
r'\s(?:S(\d{1,2})|Season\s?(\d{1,2})|(\d{1,2})(?:st|nd|rd|th)\s+Season)\s*$',
|
||||
caseSensitive: false,
|
||||
);
|
||||
final _trailingYear = RegExp(r'\s((?:19|20)\d{2})$');
|
||||
|
||||
/// Parses a file name (with or without the extension, no directories).
|
||||
ParsedName parseReleaseName(String fileName) {
|
||||
var s = fileName.replaceFirst(_ext, '').trim();
|
||||
final original = s;
|
||||
String? group = _leadingGroup.firstMatch(s)?.group(1)?.trim();
|
||||
int? year;
|
||||
final by = _bracketYear.firstMatch(s);
|
||||
if (by != null) year = int.parse(by.group(1)!);
|
||||
s = s.replaceAll(_brackets, ' ');
|
||||
|
||||
// Dots and underscores as separators (scene style), unless the name
|
||||
// already has spaces and only the odd `Dr. Stone` dot.
|
||||
final dotted = !s.contains(' ') || RegExp(r'\w\.\w').allMatches(s).length > 2;
|
||||
if (dotted) {
|
||||
// `...H264-GRP`, but not `...WEB-DL`.
|
||||
final tags = _junk.allMatches(s);
|
||||
if (group == null && tags.isNotEmpty && tags.last.end < s.length) {
|
||||
final g = _sceneGroup.firstMatch(s);
|
||||
if (g != null) {
|
||||
group = g.group(1);
|
||||
s = s.substring(0, g.start);
|
||||
}
|
||||
}
|
||||
s = s.replaceAll('.', ' ');
|
||||
}
|
||||
s = s.replaceAll('_', ' ').replaceAll(RegExp(r'\s+'), ' ').trim();
|
||||
|
||||
// Release tags end the interesting part.
|
||||
final junk = _junk.firstMatch(s);
|
||||
if (junk != null && junk.start > 0) s = s.substring(0, junk.start).trim();
|
||||
|
||||
int? season, episode;
|
||||
String? extra;
|
||||
var cut = s.length;
|
||||
final extraMatch =
|
||||
_extraAfterDash.firstMatch(s) ?? _extraAnywhere.firstMatch(s);
|
||||
final sxe = _sxe.firstMatch(s);
|
||||
if (extraMatch != null) {
|
||||
final word = extraMatch.group(1)!;
|
||||
final upper = word.length <= 4 ? word.toUpperCase() : _title(word);
|
||||
// `OVA - 02`: the number may follow the dash instead.
|
||||
final n = extraMatch.group(2) ??
|
||||
_dashNumber.firstMatch(s.substring(extraMatch.end))?.group(1);
|
||||
extra = n == null
|
||||
? upper
|
||||
: (upper.startsWith('NC') || upper.length <= 2)
|
||||
? '$upper${int.parse(n)}'
|
||||
: '$upper ${int.parse(n)}';
|
||||
cut = extraMatch.start;
|
||||
if (sxe != null && sxe.start < cut) {
|
||||
season = int.parse(sxe.group(1)!);
|
||||
cut = sxe.start;
|
||||
}
|
||||
} else if (sxe != null) {
|
||||
season = int.parse(sxe.group(1)!);
|
||||
episode = int.parse(sxe.group(2)!);
|
||||
cut = sxe.start;
|
||||
} else {
|
||||
final nxn = _nxn.firstMatch(s);
|
||||
if (nxn != null) {
|
||||
season = int.parse(nxn.group(1)!);
|
||||
episode = int.parse(nxn.group(2)!);
|
||||
cut = nxn.start;
|
||||
} else {
|
||||
final m = _dashNumber.firstMatch(s) ?? _episodeWord.firstMatch(s);
|
||||
if (m != null) {
|
||||
episode = int.parse(m.group(1)!);
|
||||
cut = m.start;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var series = s.substring(0, cut);
|
||||
// Season pack or season marker kept in the title: `Title S2`,
|
||||
// `Title Season 2`, `Title 2nd Season`, `Title.S01.1080p`.
|
||||
final sIn = _seasonInTitle.firstMatch(series);
|
||||
if (sIn != null) {
|
||||
season ??= int.parse(sIn.group(1) ?? sIn.group(2) ?? sIn.group(3)!);
|
||||
series = series.substring(0, sIn.start);
|
||||
}
|
||||
series = series.replaceAll(RegExp(r'[\s\-–:,~]+$'), '').trim();
|
||||
// A trailing year is the release year, unless it is the whole title or
|
||||
// too far ahead to be one (`Blade Runner 2049`).
|
||||
final ty = _trailingYear.firstMatch(series);
|
||||
if (ty != null) {
|
||||
final y = int.parse(ty.group(1)!);
|
||||
if (y <= DateTime.now().year + 1) {
|
||||
year ??= y;
|
||||
series = series.substring(0, ty.start).trim();
|
||||
}
|
||||
}
|
||||
series = series.replaceAll(RegExp(r'[\s\-–:,~]+$'), '').trim();
|
||||
if (series.isEmpty) {
|
||||
series = original.replaceAll(RegExp(r'[._]+'), ' ').trim();
|
||||
}
|
||||
return ParsedName(
|
||||
series,
|
||||
season: season,
|
||||
episode: episode,
|
||||
group: group,
|
||||
year: year,
|
||||
extra: extra,
|
||||
);
|
||||
}
|
||||
|
||||
String _title(String w) => w[0].toUpperCase() + w.substring(1).toLowerCase();
|
||||
@@ -0,0 +1,257 @@
|
||||
import 'dart:io';
|
||||
import 'dart:isolate';
|
||||
|
||||
import 'name_parser.dart';
|
||||
|
||||
// Folder scan for the Files source: videos with their parsed names,
|
||||
// sidecar subtitles, everything else counted and hidden. The walk is
|
||||
// synchronous I/O, so it runs in its own isolate.
|
||||
|
||||
const videoExtensions = {
|
||||
'mkv', 'mp4', 'webm', 'avi', 'mov', 'm4v', 'ts', 'm2ts', 'wmv', 'flv', //
|
||||
};
|
||||
const subtitleExtensions = {'ass', 'ssa', 'srt', 'vtt', 'sup'};
|
||||
|
||||
/// Files looked at per scan; past this the result is [FolderScan.truncated].
|
||||
const maxScannedFiles = 20000;
|
||||
|
||||
/// Samples and trailers under this size are hidden next to the real thing.
|
||||
const _sampleMaxBytes = 300 << 20;
|
||||
|
||||
enum FileKind { video, subtitle, other }
|
||||
|
||||
FileKind kindOf(String path) {
|
||||
final ext = _extension(_baseName(path));
|
||||
if (videoExtensions.contains(ext)) return FileKind.video;
|
||||
if (subtitleExtensions.contains(ext)) return FileKind.subtitle;
|
||||
return FileKind.other;
|
||||
}
|
||||
|
||||
class VideoFile {
|
||||
final String path;
|
||||
final int size;
|
||||
final ParsedName parsed;
|
||||
final DateTime modified;
|
||||
const VideoFile(this.path, this.size, this.parsed, this.modified);
|
||||
|
||||
String get name => _baseName(path);
|
||||
}
|
||||
|
||||
class FolderScan {
|
||||
final List<VideoFile> videos;
|
||||
/// Non-video files and samples (subtitles and dot-entries not counted).
|
||||
final int hiddenCount;
|
||||
/// Up to 6 of the hidden file names, for "and N other files" hints.
|
||||
final List<String> hiddenExamples;
|
||||
/// Video path -> subtitle files next to it with the same base name
|
||||
/// (`Ep 01.mkv` -> `Ep 01.srt`, `Ep 01.en.ass`).
|
||||
final Map<String, List<String>> subtitles;
|
||||
/// Stopped after [maxScannedFiles] files.
|
||||
final bool truncated;
|
||||
|
||||
const FolderScan({
|
||||
required this.videos,
|
||||
required this.hiddenCount,
|
||||
required this.hiddenExamples,
|
||||
required this.subtitles,
|
||||
required this.truncated,
|
||||
});
|
||||
|
||||
static const empty = FolderScan(
|
||||
videos: [],
|
||||
hiddenCount: 0,
|
||||
hiddenExamples: [],
|
||||
subtitles: {},
|
||||
truncated: false,
|
||||
);
|
||||
}
|
||||
|
||||
/// Scans [dir] recursively off the UI isolate. Symlinks are not followed,
|
||||
/// hidden (dot) files and folders are skipped, unreadable subfolders are
|
||||
/// left out; never throws (a missing folder is [FolderScan.empty]).
|
||||
Future<FolderScan> scanFolder(String dir, {int maxFiles = maxScannedFiles}) =>
|
||||
Isolate.run(() => scanFolderSync(dir, maxFiles: maxFiles))
|
||||
.catchError((Object _) => FolderScan.empty);
|
||||
|
||||
/// The walk behind [scanFolder]; blocks, so call it from an isolate.
|
||||
FolderScan scanFolderSync(String dir, {int maxFiles = maxScannedFiles}) {
|
||||
final videos = <VideoFile>[];
|
||||
final subs = <String>[];
|
||||
final examples = <String>[];
|
||||
var hidden = 0, seen = 0;
|
||||
var truncated = false;
|
||||
final pending = [Directory(dir)];
|
||||
walk:
|
||||
while (pending.isNotEmpty) {
|
||||
final List<FileSystemEntity> entries;
|
||||
try {
|
||||
entries = pending.removeLast().listSync(followLinks: false);
|
||||
} catch (_) {
|
||||
continue;
|
||||
}
|
||||
entries.sort((a, b) => a.path.compareTo(b.path));
|
||||
for (final e in entries) {
|
||||
final name = _baseName(e.path);
|
||||
if (name.startsWith('.')) continue;
|
||||
if (e is Directory) {
|
||||
pending.add(e);
|
||||
continue;
|
||||
}
|
||||
if (e is! File) continue; // links
|
||||
if (++seen > maxFiles) {
|
||||
truncated = true;
|
||||
break walk;
|
||||
}
|
||||
final kind = kindOf(name);
|
||||
if (kind == FileKind.subtitle) {
|
||||
subs.add(e.path);
|
||||
continue;
|
||||
}
|
||||
FileStat? stat;
|
||||
if (kind == FileKind.video) {
|
||||
try {
|
||||
stat = e.statSync();
|
||||
} catch (_) {}
|
||||
}
|
||||
final size = stat?.size ?? 0;
|
||||
final lower = name.toLowerCase();
|
||||
final sample = kind == FileKind.video &&
|
||||
(lower.contains('sample') || lower.contains('trailer')) &&
|
||||
size < _sampleMaxBytes;
|
||||
if (kind != FileKind.video || sample) {
|
||||
hidden++;
|
||||
if (examples.length < 6) examples.add(name);
|
||||
continue;
|
||||
}
|
||||
videos.add(VideoFile(e.path, size, parseReleaseName(name),
|
||||
stat?.modified ?? DateTime.fromMillisecondsSinceEpoch(0)));
|
||||
}
|
||||
}
|
||||
return FolderScan(
|
||||
videos: videos,
|
||||
hiddenCount: hidden,
|
||||
hiddenExamples: examples,
|
||||
subtitles: _sidecars(videos, subs),
|
||||
truncated: truncated,
|
||||
);
|
||||
}
|
||||
|
||||
/// Subtitles in the same folder whose name is the video's base name,
|
||||
/// optionally followed by `.lang` / `.forced` style suffixes.
|
||||
Map<String, List<String>> _sidecars(List<VideoFile> videos, List<String> subs) {
|
||||
final byStem = <String, List<String>>{};
|
||||
for (final s in subs) {
|
||||
final stem = _stripExtension(s);
|
||||
byStem.putIfAbsent(stem, () => []).add(s);
|
||||
// `Ep 01.en.ass`, `Ep 01.en.forced.ass`: also index the shorter stems.
|
||||
var shorter = stem;
|
||||
for (var i = 0; i < 2; i++) {
|
||||
final dot = shorter.lastIndexOf('.');
|
||||
if (dot <= shorter.lastIndexOf(RegExp(r'[/\\]')) + 1) break;
|
||||
final suffix = shorter.substring(dot + 1);
|
||||
// Language / flag tags only (en, eng, pt-BR, forced, sdh): a suffix
|
||||
// with digits is part of the name (`Show.S01E01.srt` is not `Show`'s).
|
||||
if (!RegExp(r'^[A-Za-z]{2,8}(-[A-Za-z]{2,4})?$').hasMatch(suffix)) break;
|
||||
shorter = shorter.substring(0, dot);
|
||||
byStem.putIfAbsent(shorter, () => []).add(s);
|
||||
}
|
||||
}
|
||||
return {
|
||||
for (final v in videos)
|
||||
if (byStem[_stripExtension(v.path)] case final list?)
|
||||
v.path: (list.toSet().toList()..sort()),
|
||||
};
|
||||
}
|
||||
|
||||
/// One show: its episodes in season/episode order plus its extras
|
||||
/// (NCOP, OVA...) in name order.
|
||||
class SeriesGroup {
|
||||
/// Display title: the most common spelling among the files.
|
||||
final String title;
|
||||
final String key;
|
||||
final List<VideoFile> episodes;
|
||||
final List<VideoFile> extras;
|
||||
const SeriesGroup(this.title, this.key, this.episodes, this.extras);
|
||||
|
||||
/// Distinct seasons, ascending; files without one count as season 1.
|
||||
List<int> get seasons =>
|
||||
({for (final e in episodes) e.parsed.season ?? 1}.toList()..sort());
|
||||
}
|
||||
|
||||
/// [groupBySeries] result: shows, and videos that are not episodes.
|
||||
class GroupedVideos {
|
||||
final List<SeriesGroup> series;
|
||||
final List<VideoFile> loose;
|
||||
const GroupedVideos(this.series, this.loose);
|
||||
}
|
||||
|
||||
/// Groups videos with an episode number (or an extra marker) by
|
||||
/// [ParsedName.seriesKey]; the rest (movies, clips) stay loose, by name.
|
||||
/// An extra whose series has no episodes in the list is loose too.
|
||||
GroupedVideos groupBySeries(Iterable<VideoFile> videos) {
|
||||
final episodes = <String, List<VideoFile>>{};
|
||||
final extras = <String, List<VideoFile>>{};
|
||||
final loose = <VideoFile>[];
|
||||
for (final v in videos) {
|
||||
final p = v.parsed;
|
||||
final key = p.seriesKey;
|
||||
if (p.episode != null && key.isNotEmpty) {
|
||||
episodes.putIfAbsent(key, () => []).add(v);
|
||||
} else if (p.extra != null && key.isNotEmpty) {
|
||||
extras.putIfAbsent(key, () => []).add(v);
|
||||
} else {
|
||||
loose.add(v);
|
||||
}
|
||||
}
|
||||
for (final e in extras.entries) {
|
||||
if (!episodes.containsKey(e.key)) loose.addAll(e.value);
|
||||
}
|
||||
int byName(VideoFile a, VideoFile b) =>
|
||||
a.name.toLowerCase().compareTo(b.name.toLowerCase());
|
||||
final groups = [
|
||||
for (final e in episodes.entries)
|
||||
SeriesGroup(
|
||||
_commonTitle(e.value),
|
||||
e.key,
|
||||
e.value..sort(_byEpisode),
|
||||
(extras[e.key] ?? [])..sort(byName),
|
||||
),
|
||||
]..sort((a, b) => a.title.toLowerCase().compareTo(b.title.toLowerCase()));
|
||||
return GroupedVideos(groups, loose..sort(byName));
|
||||
}
|
||||
|
||||
int _byEpisode(VideoFile a, VideoFile b) {
|
||||
final pa = a.parsed, pb = b.parsed;
|
||||
final s = (pa.season ?? 1).compareTo(pb.season ?? 1);
|
||||
if (s != 0) return s;
|
||||
final e = pa.episode!.compareTo(pb.episode!);
|
||||
if (e != 0) return e;
|
||||
// Same episode twice (v2, another group): newest release first.
|
||||
return b.name.compareTo(a.name);
|
||||
}
|
||||
|
||||
String _commonTitle(List<VideoFile> files) {
|
||||
final counts = <String, int>{};
|
||||
for (final f in files) {
|
||||
counts.update(f.parsed.series, (n) => n + 1, ifAbsent: () => 1);
|
||||
}
|
||||
return counts.entries
|
||||
.reduce((a, b) => b.value > a.value ? b : a)
|
||||
.key;
|
||||
}
|
||||
|
||||
String _baseName(String path) {
|
||||
final i = path.lastIndexOf(RegExp(r'[/\\]'));
|
||||
return i < 0 ? path : path.substring(i + 1);
|
||||
}
|
||||
|
||||
String _extension(String name) {
|
||||
final dot = name.lastIndexOf('.');
|
||||
return dot <= 0 ? '' : name.substring(dot + 1).toLowerCase();
|
||||
}
|
||||
|
||||
String _stripExtension(String path) {
|
||||
final dot = path.lastIndexOf('.');
|
||||
final sep = path.lastIndexOf(RegExp(r'[/\\]'));
|
||||
return dot > sep + 1 ? path.substring(0, dot) : path;
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:media_kit/media_kit.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
// Poster frames for the Files source, made by mpv itself (no ffmpeg on
|
||||
// users' machines; Windows ships only libmpv). A hidden player per file
|
||||
// with `vo=null`: mpv still keeps the last decoded frame, so
|
||||
// `screenshot-to-file` encodes it with mpv's own JPEG writer. `start=25%`
|
||||
// makes the first decoded frame the one we want, `vf=scale` keeps it
|
||||
// small. Results (and failures) are cached on disk by path+size+mtime.
|
||||
|
||||
class Thumbnail {
|
||||
/// JPEG on disk, at most [thumbnailWidth] wide.
|
||||
final String imagePath;
|
||||
final Duration duration;
|
||||
const Thumbnail(this.imagePath, this.duration);
|
||||
}
|
||||
|
||||
const thumbnailWidth = 480;
|
||||
|
||||
/// Where thumbnails are cached; swapped in tests.
|
||||
Future<Directory> Function() thumbnailCacheRoot = getApplicationCacheDirectory;
|
||||
|
||||
/// Generations running at once: each is a full decoder.
|
||||
const _maxConcurrent = 2;
|
||||
const _timeout = Duration(seconds: 20);
|
||||
|
||||
int _running = 0;
|
||||
final _waiting = <Completer<void>>[];
|
||||
final _inFlight = <String, Future<Thumbnail?>>{};
|
||||
|
||||
/// The cached or freshly made thumbnail of the video at [path], or null
|
||||
/// when it can't be made (not a video, unreadable, timed out, cancelled).
|
||||
/// Never throws. [cancelled] is checked while queued and before the
|
||||
/// expensive steps: a scrolled-away tile returns null without decoding.
|
||||
Future<Thumbnail?> thumbnailFor(String path, {bool Function()? cancelled}) {
|
||||
final existing = _inFlight[path];
|
||||
if (existing != null) return existing;
|
||||
final f = _thumbnailFor(path, cancelled ?? () => false).catchError((
|
||||
Object e,
|
||||
StackTrace st,
|
||||
) {
|
||||
debugPrint('thumbnail $path failed: $e\n$st');
|
||||
return null;
|
||||
});
|
||||
_inFlight[path] = f;
|
||||
f.whenComplete(() => _inFlight.remove(path));
|
||||
return f;
|
||||
}
|
||||
|
||||
/// Cache key: the same file at the same size and mtime reuses its frame.
|
||||
String thumbnailKey(String path, int size, DateTime modified) => sha1
|
||||
.convert(utf8.encode('$path|$size|${modified.millisecondsSinceEpoch}'))
|
||||
.toString();
|
||||
|
||||
Future<Thumbnail?> _thumbnailFor(String path, bool Function() cancelled) async {
|
||||
final stat = await File(path).stat();
|
||||
if (stat.type != FileSystemEntityType.file) return null;
|
||||
final dir = Directory(
|
||||
'${(await thumbnailCacheRoot()).path}${Platform.pathSeparator}thumbnails');
|
||||
await dir.create(recursive: true);
|
||||
final base =
|
||||
'${dir.path}${Platform.pathSeparator}${thumbnailKey(path, stat.size, stat.modified)}';
|
||||
final cached = await _readCache(base);
|
||||
if (cached != null) return cached.$1;
|
||||
|
||||
if (cancelled()) return null;
|
||||
await _acquire();
|
||||
try {
|
||||
if (cancelled()) return null;
|
||||
final made = await _generate(path, '$base.jpg', cancelled)
|
||||
.timeout(_timeout, onTimeout: () => null);
|
||||
if (made == null) {
|
||||
// Remember broken files, not cancellations.
|
||||
if (!cancelled()) await File('$base.json').writeAsString('{}');
|
||||
return null;
|
||||
}
|
||||
await File('$base.json')
|
||||
.writeAsString(jsonEncode({'ms': made.duration.inMilliseconds}));
|
||||
return made;
|
||||
} finally {
|
||||
_release();
|
||||
}
|
||||
}
|
||||
|
||||
/// (thumbnail or null for a remembered failure), or null when not cached.
|
||||
Future<(Thumbnail?,)?> _readCache(String base) async {
|
||||
try {
|
||||
final meta = File('$base.json');
|
||||
if (!await meta.exists()) return null;
|
||||
final ms = (jsonDecode(await meta.readAsString()) as Map)['ms'];
|
||||
if (ms is! int) return (null,);
|
||||
final jpg = File('$base.jpg');
|
||||
if (!await jpg.exists()) return null;
|
||||
return (Thumbnail(jpg.path, Duration(milliseconds: ms)),);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _acquire() async {
|
||||
if (_running < _maxConcurrent) {
|
||||
_running++;
|
||||
return;
|
||||
}
|
||||
final c = Completer<void>();
|
||||
_waiting.add(c);
|
||||
await c.future; // the releaser hands its slot over
|
||||
}
|
||||
|
||||
void _release() {
|
||||
if (_waiting.isNotEmpty) {
|
||||
_waiting.removeAt(0).complete();
|
||||
} else {
|
||||
_running--;
|
||||
}
|
||||
}
|
||||
|
||||
Future<Thumbnail?> _generate(
|
||||
String path,
|
||||
String out,
|
||||
bool Function() cancelled,
|
||||
) async {
|
||||
final player = Player(
|
||||
configuration: const PlayerConfiguration(
|
||||
vo: 'null',
|
||||
muted: true,
|
||||
osc: false,
|
||||
libass: false,
|
||||
title: 'thumbnail',
|
||||
bufferSize: 4 << 20,
|
||||
),
|
||||
);
|
||||
try {
|
||||
final mpv = player.platform as NativePlayer;
|
||||
for (final (k, v) in [
|
||||
('ao', 'null'),
|
||||
('aid', 'no'),
|
||||
('sid', 'no'),
|
||||
('vid', 'auto'), // media_kit starts with vid=no
|
||||
('hwdec', 'no'),
|
||||
('cache', 'no'),
|
||||
('hr-seek', 'no'), // nearest keyframe is fine for a poster
|
||||
('start', '25%'),
|
||||
('vf', 'scale=w=$thumbnailWidth:h=-2'),
|
||||
('screenshot-format', 'jpg'),
|
||||
('screenshot-jpeg-quality', '85'),
|
||||
('screenshot-tag-colorspace', 'no'),
|
||||
]) {
|
||||
await mpv.setProperty(k, v);
|
||||
}
|
||||
await player.open(Media(path), play: false);
|
||||
final target = File(out);
|
||||
final deadline = DateTime.now().add(_timeout);
|
||||
while (DateTime.now().isBefore(deadline)) {
|
||||
if (cancelled()) return null;
|
||||
// Unreadable or no video (with aid=no, audio-only stops too): mpv
|
||||
// drops the file and goes idle at once.
|
||||
if (await mpv.getProperty('idle-active') == 'yes') return null;
|
||||
// A frame is up once the video has a size and the seek is done.
|
||||
final w = await mpv.getProperty('video-params/w');
|
||||
final seeking = await mpv.getProperty('seeking');
|
||||
if (w.isNotEmpty && w != '0' && seeking != 'yes') {
|
||||
await mpv.command(['screenshot-to-file', out, 'video']);
|
||||
if (await target.exists() && await target.length() > 0) {
|
||||
final d = double.tryParse(await mpv.getProperty('duration')) ?? 0;
|
||||
return Thumbnail(out, Duration(milliseconds: (d * 1000).round()));
|
||||
}
|
||||
}
|
||||
await Future.delayed(const Duration(milliseconds: 50));
|
||||
}
|
||||
return null;
|
||||
} finally {
|
||||
await player.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../shell/avatar.dart';
|
||||
import '../name_parser.dart';
|
||||
import '../scanner.dart';
|
||||
import 'thumb.dart';
|
||||
|
||||
// The Files page's rows and cards: a series header, an episode row (also
|
||||
// used for Recent), a loose-video card, and the empty states.
|
||||
|
||||
/// What a play control is doing right now.
|
||||
enum PlayState { idle, starting, playing }
|
||||
|
||||
/// Last path segment, ignoring trailing separators (`/media/anime/`).
|
||||
String baseName(String path) {
|
||||
final parts = path.split(RegExp(r'[/\\]')).where((p) => p.isNotEmpty).toList();
|
||||
return parts.isEmpty ? path : parts.last;
|
||||
}
|
||||
|
||||
/// Name of the folder [path] is in.
|
||||
String parentName(String path) => baseName(File(path).parent.path);
|
||||
|
||||
/// Cleaned title of a loose video: `Title (2019)`.
|
||||
String cleanTitle(ParsedName p) => p.year == null ? p.series : '${p.series} (${p.year})';
|
||||
|
||||
class SeriesHeader extends StatelessWidget {
|
||||
final SeriesGroup group;
|
||||
final ThumbCache thumbs;
|
||||
/// Its episodes are listed below; [onTap] opens or closes them.
|
||||
final bool expanded;
|
||||
final VoidCallback? onTap;
|
||||
const SeriesHeader(
|
||||
{super.key, required this.group, required this.thumbs, this.expanded = true, this.onTap});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final first = group.episodes.first;
|
||||
final n = group.episodes.length;
|
||||
final extras = group.extras.length;
|
||||
final detail = [
|
||||
'$n ${n == 1 ? 'episode' : 'episodes'}',
|
||||
if (extras > 0) '$extras ${extras == 1 ? 'extra' : 'extras'}',
|
||||
?first.parsed.group,
|
||||
].join(' · ');
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 14),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
ClipRRect(
|
||||
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),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// One file as a row: an episode under its series header, or a Recent
|
||||
/// entry ([title] given, [indent] off).
|
||||
class FileRow extends StatelessWidget {
|
||||
final String path;
|
||||
final String title;
|
||||
final String subtitle;
|
||||
/// Size text; null while unknown.
|
||||
final String? size;
|
||||
final PlayState state;
|
||||
final ThumbCache thumbs;
|
||||
/// Null disables the row.
|
||||
final VoidCallback? onTap;
|
||||
final bool indent;
|
||||
final Widget? trailing;
|
||||
const FileRow({
|
||||
super.key,
|
||||
required this.path,
|
||||
required this.title,
|
||||
required this.subtitle,
|
||||
required this.size,
|
||||
required this.state,
|
||||
required this.thumbs,
|
||||
required this.onTap,
|
||||
this.indent = true,
|
||||
this.trailing,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final playing = state == PlayState.playing;
|
||||
final enabled = onTap != null;
|
||||
final muted = scheme.onSurfaceVariant;
|
||||
final icon = switch (state) {
|
||||
PlayState.starting => const SizedBox.square(
|
||||
dimension: 16, child: CircularProgressIndicator(strokeWidth: 2)),
|
||||
PlayState.playing => Icon(Icons.graphic_eq, size: 20, color: scheme.primary),
|
||||
PlayState.idle => Icon(Icons.play_circle_outline,
|
||||
size: 20, color: enabled ? scheme.primary : scheme.onSurface.withValues(alpha: 0.38)),
|
||||
};
|
||||
return ThumbBuilder(
|
||||
cache: thumbs,
|
||||
path: path,
|
||||
builder: (context, t, _) {
|
||||
final duration = durationText(t);
|
||||
final meta = [?duration, ?size].join(' · ');
|
||||
return Material(
|
||||
color: playing ? scheme.primary.withValues(alpha: 0.08) : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: Padding(
|
||||
padding: EdgeInsets.fromLTRB(indent ? 20 : 4, 5, 8, 5),
|
||||
child: Row(
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: ThumbImage(t, width: 80, height: 45),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
SizedBox(width: 20, child: Center(child: icon)),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: playing ? scheme.primary : null,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (playing) ...[
|
||||
const SizedBox(width: 8),
|
||||
Pill('now playing', scheme.primary),
|
||||
],
|
||||
],
|
||||
),
|
||||
Text(subtitle,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(color: muted, fontSize: 12)),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (meta.isNotEmpty) ...[
|
||||
const SizedBox(width: 12),
|
||||
Text(meta, style: TextStyle(color: muted, fontSize: 12)),
|
||||
],
|
||||
?trailing,
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Text under a [VideoCard]'s 16:9 frame (title up to two lines, size).
|
||||
const videoCardTextHeight = 66.0;
|
||||
|
||||
/// A loose video (movie, clip): 16:9 frame with its duration, then the
|
||||
/// cleaned title and size.
|
||||
class VideoCard extends StatelessWidget {
|
||||
final VideoFile video;
|
||||
final PlayState state;
|
||||
final ThumbCache thumbs;
|
||||
final VoidCallback? onTap;
|
||||
const VideoCard({
|
||||
super.key,
|
||||
required this.video,
|
||||
required this.state,
|
||||
required this.thumbs,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final playing = state == PlayState.playing;
|
||||
return ThumbBuilder(
|
||||
cache: thumbs,
|
||||
path: video.path,
|
||||
builder: (context, t, _) {
|
||||
final duration = durationText(t);
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
AspectRatio(
|
||||
aspectRatio: 16 / 9,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: playing ? scheme.primary : Colors.transparent,
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
ThumbImage(t),
|
||||
if (duration != null)
|
||||
Positioned(right: 6, bottom: 6, child: _Badge(duration)),
|
||||
if (state != PlayState.idle)
|
||||
Container(
|
||||
color: Colors.black38,
|
||||
alignment: Alignment.center,
|
||||
child: state == PlayState.playing
|
||||
? const Icon(Icons.graphic_eq, color: Colors.white, size: 32)
|
||||
: const SizedBox.square(
|
||||
dimension: 28,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 3, color: Colors.white)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Flexible(
|
||||
child: Text(
|
||||
cleanTitle(video.parsed),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 13,
|
||||
color: playing ? scheme.primary : null,
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(formatSize(video.size),
|
||||
style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12)),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Badge extends StatelessWidget {
|
||||
final String text;
|
||||
const _Badge(this.text);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1),
|
||||
decoration: BoxDecoration(color: Colors.black87, borderRadius: BorderRadius.circular(4)),
|
||||
child: Text(text, style: const TextStyle(color: Colors.white, fontSize: 11)),
|
||||
);
|
||||
}
|
||||
|
||||
class FilesEmpty extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String title;
|
||||
final String detail;
|
||||
final Widget? action;
|
||||
const FilesEmpty(this.icon, this.title, this.detail, {super.key, this.action});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 60, bottom: 20),
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(icon, size: 44, color: scheme.onSurfaceVariant),
|
||||
const SizedBox(height: 10),
|
||||
Text(title, style: Theme.of(context).textTheme.titleMedium, textAlign: TextAlign.center),
|
||||
const SizedBox(height: 4),
|
||||
Text(detail, style: TextStyle(color: scheme.onSurfaceVariant), textAlign: TextAlign.center),
|
||||
if (action != null) ...[const SizedBox(height: 14), action!],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,749 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:signals/signals_flutter.dart';
|
||||
|
||||
import '../../shell/motion.dart';
|
||||
import '../folder_store.dart';
|
||||
import '../name_parser.dart';
|
||||
import '../scanner.dart';
|
||||
import 'file_tiles.dart';
|
||||
import 'thumb.dart';
|
||||
|
||||
export 'thumb.dart' show ThumbnailLoader;
|
||||
|
||||
// The Files source: watched folders on the left (chips on top when
|
||||
// narrow), the open one on the right grouped into series and loose
|
||||
// videos, or Recent. A folder is scanned when first opened and on Rescan;
|
||||
// scans are kept for the app session. Series start collapsed; the ones
|
||||
// opened stay open for the app session. Playing goes through [onPlay].
|
||||
|
||||
/// Below this width the sidebar turns into chips above the list.
|
||||
const filesNarrowWidth = 700.0;
|
||||
|
||||
/// Scans kept for the app session, by folder.
|
||||
final _scans = <String, FolderScan>{};
|
||||
final _running = <String, Future<FolderScan>>{};
|
||||
String? _lastOpen;
|
||||
/// Series opened this session, by `folder\nseries key`.
|
||||
final _expanded = <String>{};
|
||||
final _showRequests = ValueNotifier(0);
|
||||
|
||||
/// Opens [dir] (null: Recent) in the Files page: now if one is showing,
|
||||
/// else when it next opens.
|
||||
void showFilesFolder(String? dir) {
|
||||
_lastOpen = dir;
|
||||
_showRequests.value++;
|
||||
}
|
||||
|
||||
/// Forgets the session's scans, open series and the open folder.
|
||||
@visibleForTesting
|
||||
void resetFilesPageSession() {
|
||||
_scans.clear();
|
||||
_running.clear();
|
||||
_expanded.clear();
|
||||
_lastOpen = null;
|
||||
}
|
||||
|
||||
class FilesPage extends StatefulWidget {
|
||||
/// Nobody else watches (offline or solo): no relay notice.
|
||||
final bool solo;
|
||||
/// The file will stream from this computer through the server.
|
||||
final bool relayed;
|
||||
/// Plays [path] in the session; returns an error text, or null.
|
||||
final Future<String?> Function(String path) onPlay;
|
||||
final String? nowPlayingPath;
|
||||
/// Defaults to [thumbnailFor].
|
||||
final ThumbnailLoader? thumbnails;
|
||||
/// Defaults to [scanFolder].
|
||||
final Future<FolderScan> Function(String dir)? scan;
|
||||
/// Defaults to [FolderStore.instance].
|
||||
final FolderStore? store;
|
||||
/// Default: the system folder / file dialogs.
|
||||
final Future<String?> Function()? pickFolder;
|
||||
final Future<String?> Function()? pickVideo;
|
||||
|
||||
const FilesPage({
|
||||
super.key,
|
||||
this.solo = false,
|
||||
required this.relayed,
|
||||
required this.onPlay,
|
||||
this.nowPlayingPath,
|
||||
this.thumbnails,
|
||||
this.scan,
|
||||
this.store,
|
||||
this.pickFolder,
|
||||
this.pickVideo,
|
||||
});
|
||||
|
||||
@override
|
||||
State<FilesPage> createState() => _FilesPageState();
|
||||
}
|
||||
|
||||
/// A scan grouped for display, made once per scan.
|
||||
class _Library {
|
||||
final GroupedVideos grouped;
|
||||
final int subtitleFiles;
|
||||
|
||||
_Library(FolderScan scan)
|
||||
: grouped = groupBySeries(scan.videos),
|
||||
subtitleFiles = {for (final l in scan.subtitles.values) ...l}.length;
|
||||
|
||||
/// Series rows in order: a header (video null), then, for the series
|
||||
/// [open] says, its episodes and extras.
|
||||
List<(SeriesGroup, VideoFile?)> rows(bool Function(SeriesGroup g) open) => [
|
||||
for (final g in grouped.series) ...[
|
||||
(g, null),
|
||||
if (open(g))
|
||||
for (final v in [...g.episodes, ...g.extras]) (g, v),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
final _libraries = Expando<_Library>();
|
||||
|
||||
class _FilesPageState extends State<FilesPage> {
|
||||
late final ThumbCache _thumbs =
|
||||
widget.thumbnails == null ? ThumbCache.shared : ThumbCache(widget.thumbnails!);
|
||||
FolderStore get _store => widget.store ?? FolderStore.instance;
|
||||
|
||||
/// Null: Recent.
|
||||
String? _open = _lastOpen;
|
||||
bool _showHidden = false;
|
||||
String? _starting;
|
||||
String? _error;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_showRequests.addListener(_shown);
|
||||
_store.ensureLoaded().then((_) {
|
||||
if (!mounted) return;
|
||||
final open = _open;
|
||||
if (open != null && !_store.folders.value.contains(open)) {
|
||||
_select(null);
|
||||
} else if (open != null) {
|
||||
_scan(open);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_showRequests.removeListener(_shown);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _shown() => _select(_lastOpen);
|
||||
|
||||
Future<FolderScan> _runScan(String dir) => (widget.scan ?? scanFolder)(dir);
|
||||
|
||||
void _select(String? dir) {
|
||||
setState(() {
|
||||
_open = _lastOpen = dir;
|
||||
_showHidden = false;
|
||||
});
|
||||
if (dir != null && !_scans.containsKey(dir)) _scan(dir);
|
||||
}
|
||||
|
||||
Future<void> _scan(String dir, {bool rescan = false}) async {
|
||||
var f = _running[dir];
|
||||
if (f == null || rescan) {
|
||||
f = _running[dir] = _runScan(dir);
|
||||
if (rescan) _thumbs.forget([for (final v in _scans[dir]?.videos ?? const <VideoFile>[]) v.path]);
|
||||
}
|
||||
if (mounted) setState(() {});
|
||||
FolderScan result;
|
||||
try {
|
||||
result = await f;
|
||||
} catch (_) {
|
||||
result = FolderScan.empty;
|
||||
}
|
||||
if (_running[dir] == f) {
|
||||
_running.remove(dir);
|
||||
_scans[dir] = result;
|
||||
}
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
|
||||
Future<void> _addFolder() async {
|
||||
String? dir;
|
||||
try {
|
||||
dir = await (widget.pickFolder ??
|
||||
() => FilePicker.getDirectoryPath(dialogTitle: 'Add a folder with videos'))();
|
||||
} catch (e) {
|
||||
_showError('Folder dialog unavailable (${e.runtimeType})');
|
||||
}
|
||||
if (dir == null || !mounted) return;
|
||||
_store.addFolder(dir);
|
||||
_select(dir);
|
||||
}
|
||||
|
||||
Future<void> _browse() async {
|
||||
String? path;
|
||||
try {
|
||||
path = await (widget.pickVideo ?? _pickVideo)();
|
||||
} catch (e) {
|
||||
_showError('File dialog unavailable (${e.runtimeType})');
|
||||
}
|
||||
if (path != null && mounted) await _play(path);
|
||||
}
|
||||
|
||||
static Future<String?> _pickVideo() async {
|
||||
final files = await FilePicker.pickFiles(
|
||||
dialogTitle: 'Pick a video',
|
||||
type: FileType.custom,
|
||||
allowedExtensions: videoExtensions.toList(),
|
||||
);
|
||||
return files.isEmpty ? null : files.first.path;
|
||||
}
|
||||
|
||||
void _removeFolder(String dir) {
|
||||
_store.removeFolder(dir);
|
||||
_scans.remove(dir);
|
||||
if (_open == dir) _select(null);
|
||||
}
|
||||
|
||||
Future<void> _play(String path) async {
|
||||
if (_starting != null) return;
|
||||
setState(() {
|
||||
_starting = path;
|
||||
_error = null;
|
||||
});
|
||||
String? error;
|
||||
try {
|
||||
error = await widget.onPlay(path);
|
||||
} catch (e) {
|
||||
error = '$e';
|
||||
}
|
||||
if (error == null) _store.addRecent(path);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_starting = null;
|
||||
_error = error;
|
||||
});
|
||||
}
|
||||
|
||||
void _showError(String text) {
|
||||
if (mounted) setState(() => _error = text);
|
||||
}
|
||||
|
||||
PlayState _stateOf(String path) => path == _starting
|
||||
? PlayState.starting
|
||||
: path == widget.nowPlayingPath
|
||||
? PlayState.playing
|
||||
: PlayState.idle;
|
||||
|
||||
VoidCallback _tapOf(String path) => () => _play(path);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => LayoutBuilder(
|
||||
builder: (context, box) {
|
||||
final narrow = box.maxWidth < filesNarrowWidth;
|
||||
return SignalBuilder(builder: (context) {
|
||||
final folders = _store.folders.value;
|
||||
final recent = _store.recent.value;
|
||||
final open = _open != null && folders.contains(_open) ? _open : null;
|
||||
final main = fadeThrough(
|
||||
KeyedSubtree(
|
||||
key: ValueKey(open ?? ''),
|
||||
child: _main(context, narrow, folders, recent, open),
|
||||
),
|
||||
);
|
||||
if (narrow) return main;
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(width: 260, child: _sidebar(context, folders, open)),
|
||||
Expanded(child: main),
|
||||
],
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
Widget _browseButton(Widget Function(VoidCallback? onPressed) build) => build(_browse);
|
||||
|
||||
Widget _sidebar(BuildContext context, List<String> folders, String? open) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final shape = RoundedRectangleBorder(borderRadius: BorderRadius.circular(10));
|
||||
return 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: shape,
|
||||
leading: const Icon(Icons.history, size: 18),
|
||||
title: const Text('Recent'),
|
||||
onTap: () => _select(null),
|
||||
),
|
||||
for (final f in folders)
|
||||
ListTile(
|
||||
key: ValueKey('folder:$f'),
|
||||
dense: true,
|
||||
selected: open == f,
|
||||
shape: shape,
|
||||
leading: const Icon(Icons.folder_outlined, size: 18),
|
||||
title: Text(baseName(f), maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
subtitle: switch (_folderStatus(f)) {
|
||||
final s? => Text(s, style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 11)),
|
||||
null => null,
|
||||
},
|
||||
trailing: IconButton(
|
||||
tooltip: 'Remove folder',
|
||||
icon: const Icon(Icons.close, size: 16),
|
||||
onPressed: () => _removeFolder(f),
|
||||
),
|
||||
onTap: () => _select(f),
|
||||
),
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: TextButton.icon(
|
||||
onPressed: _addFolder,
|
||||
icon: const Icon(Icons.add, size: 18),
|
||||
label: const Text('Add folder'),
|
||||
),
|
||||
),
|
||||
const Divider(),
|
||||
_browseButton((onPressed) => OutlinedButton.icon(
|
||||
onPressed: onPressed,
|
||||
icon: const Icon(Icons.folder_open, size: 18),
|
||||
label: const Text('Browse…'),
|
||||
)),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'The system file picker, for anything outside your folders.',
|
||||
style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 11),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// "scanning…", "N videos", or null before the first scan.
|
||||
String? _folderStatus(String dir) {
|
||||
if (_running.containsKey(dir)) return 'scanning…';
|
||||
final n = _scans[dir]?.videos.length;
|
||||
return n == null ? null : '$n ${n == 1 ? 'video' : 'videos'}';
|
||||
}
|
||||
|
||||
Widget _main(
|
||||
BuildContext context,
|
||||
bool narrow,
|
||||
List<String> folders,
|
||||
List<String> recent,
|
||||
String? open,
|
||||
) {
|
||||
final scan = open == null ? null : _scans[open];
|
||||
final scanning = open != null && _running.containsKey(open);
|
||||
final top = <Widget>[
|
||||
_header(context, open),
|
||||
if (narrow) _chips(folders, open),
|
||||
if (!widget.solo && widget.relayed) const _RelayNotice(),
|
||||
AnimatedSize(
|
||||
duration: easeTime,
|
||||
curve: ease,
|
||||
alignment: Alignment.topLeft,
|
||||
child: _error == null
|
||||
? const SizedBox(width: double.infinity)
|
||||
: _ErrorBanner(_error!, onClose: () => setState(() => _error = null)),
|
||||
),
|
||||
];
|
||||
final slivers = <Widget>[
|
||||
SliverList.list(children: top),
|
||||
if (open == null)
|
||||
..._recent(context, recent)
|
||||
else if (scan == null)
|
||||
const SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(top: 60),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
)
|
||||
else
|
||||
..._folder(context, open, scan, scanning),
|
||||
];
|
||||
return CustomScrollView(
|
||||
slivers: [
|
||||
SliverPadding(
|
||||
padding: EdgeInsets.fromLTRB(narrow ? 20 : 12, 20, 20, 20),
|
||||
sliver: SliverMainAxisGroup(slivers: slivers),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _header(BuildContext context, String? open) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
return 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),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.headlineSmall,
|
||||
),
|
||||
if (open != null)
|
||||
Text(
|
||||
open,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _chips(List<String> folders, String? open) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 14),
|
||||
child: Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
ChoiceChip(
|
||||
label: const Text('Recent'),
|
||||
selected: open == null,
|
||||
onSelected: (_) => _select(null),
|
||||
),
|
||||
for (final f in folders)
|
||||
// InputChip: selectable like the others, plus the remove x
|
||||
// the sidebar has.
|
||||
InputChip(
|
||||
key: ValueKey('chip:$f'),
|
||||
label: Text(baseName(f)),
|
||||
selected: open == f,
|
||||
onSelected: (_) => _select(f),
|
||||
onDeleted: () => _removeFolder(f),
|
||||
deleteButtonTooltipMessage: 'Remove folder',
|
||||
),
|
||||
ActionChip(
|
||||
avatar: const Icon(Icons.add, size: 16),
|
||||
label: const Text('Folder'),
|
||||
onPressed: _addFolder,
|
||||
),
|
||||
_browseButton((onPressed) => ActionChip(
|
||||
avatar: const Icon(Icons.folder_open, size: 16),
|
||||
label: const Text('Browse…'),
|
||||
onPressed: onPressed,
|
||||
)),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
List<Widget> _recent(BuildContext context, List<String> recent) {
|
||||
if (recent.isEmpty) {
|
||||
return [
|
||||
SliverToBoxAdapter(
|
||||
child: FilesEmpty(
|
||||
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'),
|
||||
),
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
return [
|
||||
SliverList.builder(
|
||||
itemCount: recent.length,
|
||||
itemBuilder: (context, i) {
|
||||
final path = recent[i];
|
||||
return _RecentRow(
|
||||
key: ValueKey(path),
|
||||
path: path,
|
||||
state: _stateOf(path),
|
||||
thumbs: _thumbs,
|
||||
onTap: _tapOf(path),
|
||||
onRemove: () => _store.removeRecent(path),
|
||||
);
|
||||
},
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
List<Widget> _folder(BuildContext context, String dir, FolderScan scan, bool scanning) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final lib = _libraries[scan] ??= _Library(scan);
|
||||
final series = lib.grouped.series.length;
|
||||
String count(int n, String one, String many) => '$n ${n == 1 ? one : many}';
|
||||
final summary = [
|
||||
count(scan.videos.length, 'video', 'videos') + (series > 0 ? ' in $series series' : ''),
|
||||
if (scan.hiddenCount > 0)
|
||||
'${count(scan.hiddenCount, 'other file', 'other files')} hidden',
|
||||
if (lib.subtitleFiles > 0) count(lib.subtitleFiles, 'subtitle file', 'subtitle files'),
|
||||
if (scan.truncated) 'stopped after $maxScannedFiles files',
|
||||
].join(' · ');
|
||||
final loose = lib.grouped.loose;
|
||||
String seriesId(SeriesGroup g) => '$dir\n${g.key}';
|
||||
final rows = lib.rows((g) => _expanded.contains(seriesId(g)));
|
||||
return [
|
||||
SliverToBoxAdapter(
|
||||
child: Wrap(
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.filter_alt_outlined, size: 16, color: scheme.onSurfaceVariant),
|
||||
const SizedBox(width: 6),
|
||||
Text(summary, style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12)),
|
||||
if (scan.hiddenCount > 0) ...[
|
||||
const SizedBox(width: 8),
|
||||
TextButton(
|
||||
onPressed: () => setState(() => _showHidden = !_showHidden),
|
||||
child: Text(_showHidden ? 'Hide them' : 'Show some'),
|
||||
),
|
||||
],
|
||||
TextButton.icon(
|
||||
onPressed: scanning ? null : () => _scan(dir, rescan: true),
|
||||
icon: scanning
|
||||
? const SizedBox.square(
|
||||
dimension: 14, child: CircularProgressIndicator(strokeWidth: 2))
|
||||
: const Icon(Icons.refresh, size: 16),
|
||||
label: Text(scanning ? 'Scanning…' : 'Rescan'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: AnimatedSize(
|
||||
duration: easeTime,
|
||||
curve: ease,
|
||||
alignment: Alignment.topLeft,
|
||||
child: !_showHidden
|
||||
? 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, maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
trailing: Text(
|
||||
kindOf(f) == FileKind.video ? 'sample' : 'not a video',
|
||||
style: const TextStyle(fontSize: 12),
|
||||
),
|
||||
),
|
||||
if (scan.hiddenCount > scan.hiddenExamples.length)
|
||||
ListTile(
|
||||
dense: true,
|
||||
enabled: false,
|
||||
title: Text('and ${scan.hiddenCount - scan.hiddenExamples.length} more'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (scan.videos.isEmpty)
|
||||
SliverToBoxAdapter(
|
||||
child: Directory(dir).existsSync()
|
||||
? const FilesEmpty(
|
||||
Icons.search_off,
|
||||
'No videos here',
|
||||
'Only video files are listed (mkv, mp4, webm…).',
|
||||
)
|
||||
: const FilesEmpty(
|
||||
Icons.folder_off_outlined,
|
||||
'Folder not found',
|
||||
'It may have been moved, renamed or be on a drive that is not connected.',
|
||||
),
|
||||
),
|
||||
// Series: files whose names carry an episode number; a header
|
||||
// click opens or closes its episodes.
|
||||
SliverList.builder(
|
||||
itemCount: rows.length,
|
||||
itemBuilder: (context, i) {
|
||||
final (group, v) = rows[i];
|
||||
if (v == null) {
|
||||
final id = seriesId(group);
|
||||
return SeriesHeader(
|
||||
key: ValueKey('series:${group.key}'),
|
||||
group: group,
|
||||
thumbs: _thumbs,
|
||||
expanded: _expanded.contains(id),
|
||||
onTap: () => setState(() => _expanded.contains(id) ? _expanded.remove(id) : _expanded.add(id)),
|
||||
);
|
||||
}
|
||||
return FileRow(
|
||||
key: ValueKey(v.path),
|
||||
path: v.path,
|
||||
title: v.parsed.label.isEmpty ? v.parsed.series : v.parsed.label,
|
||||
subtitle: v.name,
|
||||
size: formatSize(v.size),
|
||||
state: _stateOf(v.path),
|
||||
thumbs: _thumbs,
|
||||
onTap: _tapOf(v.path),
|
||||
);
|
||||
},
|
||||
),
|
||||
// Everything else (movies, clips) as a thumbnail grid.
|
||||
if (loose.isNotEmpty) ...[
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 22, bottom: 10),
|
||||
child: Text(
|
||||
series == 0 ? 'Videos' : 'Other videos',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
),
|
||||
),
|
||||
SliverLayoutBuilder(builder: (context, c) {
|
||||
const gap = 12.0;
|
||||
final w = c.crossAxisExtent;
|
||||
final cols = (w / 240).floor().clamp(2, 8);
|
||||
final cardW = (w - gap * (cols - 1)) / cols;
|
||||
return SliverGrid.builder(
|
||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: cols,
|
||||
mainAxisSpacing: gap,
|
||||
crossAxisSpacing: gap,
|
||||
mainAxisExtent: cardW * 9 / 16 + videoCardTextHeight,
|
||||
),
|
||||
itemCount: loose.length,
|
||||
itemBuilder: (context, i) {
|
||||
final v = loose[i];
|
||||
return VideoCard(
|
||||
key: ValueKey(v.path),
|
||||
video: v,
|
||||
state: _stateOf(v.path),
|
||||
thumbs: _thumbs,
|
||||
onTap: _tapOf(v.path),
|
||||
);
|
||||
},
|
||||
);
|
||||
}),
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/// A Recent entry: `Series · S01E03` over the file name, or the cleaned
|
||||
/// title over its folder; greyed when the file is gone.
|
||||
class _RecentRow extends StatefulWidget {
|
||||
final String path;
|
||||
final PlayState state;
|
||||
final ThumbCache thumbs;
|
||||
final VoidCallback? onTap;
|
||||
final VoidCallback onRemove;
|
||||
const _RecentRow({
|
||||
super.key,
|
||||
required this.path,
|
||||
required this.state,
|
||||
required this.thumbs,
|
||||
required this.onTap,
|
||||
required this.onRemove,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_RecentRow> createState() => _RecentRowState();
|
||||
}
|
||||
|
||||
class _RecentRowState extends State<_RecentRow> {
|
||||
FileStat? _stat;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
File(widget.path).stat().then((s) {
|
||||
if (mounted) setState(() => _stat = s);
|
||||
}, onError: (Object _) {});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final path = widget.path;
|
||||
final p = parseReleaseName(baseName(path));
|
||||
final missing = _stat?.type == FileSystemEntityType.notFound;
|
||||
final episode = p.episode != null || p.extra != null;
|
||||
return FileRow(
|
||||
path: path,
|
||||
title: episode ? '${p.series} · ${p.label}' : cleanTitle(p),
|
||||
subtitle: missing ? 'File not found' : (episode ? baseName(path) : parentName(path)),
|
||||
size: _stat == null || missing ? null : formatSize(_stat!.size),
|
||||
state: widget.state,
|
||||
thumbs: widget.thumbs,
|
||||
onTap: missing ? null : widget.onTap,
|
||||
indent: false,
|
||||
trailing: IconButton(
|
||||
tooltip: 'Remove from recent',
|
||||
icon: const Icon(Icons.close, size: 16),
|
||||
onPressed: widget.onRemove,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RelayNotice extends StatelessWidget {
|
||||
const _RelayNotice();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => 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.',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _ErrorBanner extends StatelessWidget {
|
||||
final String text;
|
||||
final VoidCallback onClose;
|
||||
const _ErrorBanner(this.text, {required this.onClose});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
return Container(
|
||||
padding: const EdgeInsets.fromLTRB(10, 4, 4, 4),
|
||||
margin: const EdgeInsets.only(bottom: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: scheme.errorContainer,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.error_outline, size: 18, color: scheme.onErrorContainer),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(child: Text(text, style: TextStyle(color: scheme.onErrorContainer))),
|
||||
IconButton(
|
||||
tooltip: 'Dismiss',
|
||||
icon: Icon(Icons.close, size: 16, color: scheme.onErrorContainer),
|
||||
onPressed: onClose,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../shell/motion.dart';
|
||||
import '../thumbnails.dart';
|
||||
|
||||
// Lazy poster frames for the Files page. A tile asks for its frame only
|
||||
// after it has stayed on screen for a moment, so a fast scroll requests
|
||||
// nothing; tiles sharing a file share one request, and the request is
|
||||
// cancelled once no tile on screen wants it any more.
|
||||
|
||||
/// [thumbnailFor]'s shape; swapped in tests.
|
||||
typedef ThumbnailLoader = Future<Thumbnail?> Function(String path, {bool Function()? cancelled});
|
||||
|
||||
/// A tile waits this long on screen before asking for its frame.
|
||||
const thumbRequestDelay = Duration(milliseconds: 120);
|
||||
|
||||
/// Results of a [ThumbnailLoader] and who is waiting for them.
|
||||
class ThumbCache {
|
||||
final ThumbnailLoader load;
|
||||
ThumbCache(this.load);
|
||||
|
||||
final _done = <String, Thumbnail?>{};
|
||||
final _pending = <String, Future<Thumbnail?>>{};
|
||||
final _waiting = <String, int>{};
|
||||
|
||||
/// Shared by every page using [thumbnailFor].
|
||||
static final shared = ThumbCache(thumbnailFor);
|
||||
|
||||
bool has(String path) => _done.containsKey(path);
|
||||
Thumbnail? cached(String path) => _done[path];
|
||||
|
||||
/// Pair every call with one [release].
|
||||
Future<Thumbnail?> request(String path) {
|
||||
_waiting.update(path, (n) => n + 1, ifAbsent: () => 1);
|
||||
if (_done.containsKey(path)) return Future.value(_done[path]);
|
||||
return _pending[path] ??= _start(path);
|
||||
}
|
||||
|
||||
void release(String path) {
|
||||
final n = (_waiting[path] ?? 0) - 1;
|
||||
if (n > 0) {
|
||||
_waiting[path] = n;
|
||||
} else {
|
||||
_waiting.remove(path);
|
||||
}
|
||||
}
|
||||
|
||||
/// Drops results, so the next request asks the loader again (rescan).
|
||||
void forget(Iterable<String> paths) {
|
||||
for (final p in paths) {
|
||||
_done.remove(p);
|
||||
}
|
||||
}
|
||||
|
||||
Future<Thumbnail?> _start(String path) {
|
||||
var gaveUp = false;
|
||||
return load(path, cancelled: () => gaveUp = !_waiting.containsKey(path))
|
||||
.catchError((Object _) => null)
|
||||
.then((t) {
|
||||
if (t == null && gaveUp) {
|
||||
// Cancelled: not a result. Someone may have come back meanwhile.
|
||||
if (_waiting.containsKey(path)) return _start(path);
|
||||
_pending.remove(path);
|
||||
return null;
|
||||
}
|
||||
_pending.remove(path);
|
||||
_done[path] = t;
|
||||
return t;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds with the frame of [path] (null while loading or when there is
|
||||
/// none); [loaded] is false until the request finished.
|
||||
class ThumbBuilder extends StatefulWidget {
|
||||
final ThumbCache cache;
|
||||
final String path;
|
||||
final Widget Function(BuildContext context, Thumbnail? thumb, bool loaded) builder;
|
||||
const ThumbBuilder({super.key, required this.cache, required this.path, required this.builder});
|
||||
|
||||
@override
|
||||
State<ThumbBuilder> createState() => _ThumbBuilderState();
|
||||
}
|
||||
|
||||
class _ThumbBuilderState extends State<ThumbBuilder> {
|
||||
Thumbnail? _thumb;
|
||||
bool _loaded = false;
|
||||
Timer? _delay;
|
||||
// The request held right now: its path, and a token so a late answer to
|
||||
// an older request is ignored.
|
||||
String? _holding;
|
||||
Object? _token;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_begin();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(ThumbBuilder old) {
|
||||
super.didUpdateWidget(old);
|
||||
if (old.path != widget.path || old.cache != widget.cache) {
|
||||
_end(old.cache);
|
||||
_begin();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_end(widget.cache);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _begin() {
|
||||
final cache = widget.cache, path = widget.path;
|
||||
_loaded = cache.has(path);
|
||||
_thumb = cache.cached(path);
|
||||
if (_loaded) return;
|
||||
_delay = Timer(thumbRequestDelay, () {
|
||||
_delay = null;
|
||||
_holding = path;
|
||||
final token = _token = Object();
|
||||
cache.request(path).then((t) {
|
||||
if (_token != token) return;
|
||||
_holding = _token = null;
|
||||
cache.release(path);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_thumb = t;
|
||||
_loaded = true;
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
void _end(ThumbCache cache) {
|
||||
_delay?.cancel();
|
||||
_delay = null;
|
||||
final held = _holding;
|
||||
_holding = _token = null;
|
||||
if (held != null) cache.release(held);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => widget.builder(context, _thumb, _loaded);
|
||||
}
|
||||
|
||||
/// The frame, fading in over a placeholder.
|
||||
class ThumbImage extends StatelessWidget {
|
||||
final Thumbnail? thumb;
|
||||
final double? width, height;
|
||||
const ThumbImage(this.thumb, {super.key, 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,
|
||||
alignment: Alignment.center,
|
||||
child: Icon(Icons.movie_outlined, color: scheme.onSurfaceVariant, size: 20),
|
||||
);
|
||||
final t = thumb;
|
||||
if (t == null) return placeholder;
|
||||
final dpr = MediaQuery.devicePixelRatioOf(context);
|
||||
return Stack(
|
||||
fit: width == null ? StackFit.expand : StackFit.loose,
|
||||
children: [
|
||||
placeholder,
|
||||
Image.file(
|
||||
File(t.imagePath),
|
||||
width: width,
|
||||
height: height,
|
||||
fit: BoxFit.cover,
|
||||
cacheWidth: width == null ? null : (width! * dpr).round(),
|
||||
gaplessPlayback: true,
|
||||
errorBuilder: (_, _, _) => const SizedBox.shrink(),
|
||||
frameBuilder: (context, child, frame, sync) => sync
|
||||
? child
|
||||
: AnimatedOpacity(
|
||||
opacity: frame == null ? 0 : 1,
|
||||
duration: easeTime,
|
||||
curve: ease,
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// `1:02:03` / `24:05`.
|
||||
String formatDuration(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)}';
|
||||
}
|
||||
|
||||
/// `1.4 GB` / `350 MB` / `12 KB`.
|
||||
String formatSize(int bytes) {
|
||||
if (bytes >= 1 << 30) return '${(bytes / (1 << 30)).toStringAsFixed(1)} GB';
|
||||
if (bytes >= 1 << 20) return '${(bytes / (1 << 20)).toStringAsFixed(0)} MB';
|
||||
return '${(bytes / 1024).ceil()} KB';
|
||||
}
|
||||
|
||||
/// The frame's duration, or null when unknown (no frame, 0 length).
|
||||
String? durationText(Thumbnail? t) =>
|
||||
t == null || t.duration <= Duration.zero ? null : formatDuration(t.duration);
|
||||
@@ -8,8 +8,6 @@ import 'package:hive_ce/hive.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
|
||||
import '../screens/home/picked_media.dart';
|
||||
import '../screens/home/ui.dart' show appName;
|
||||
import '../update/updater.dart' show appVersion;
|
||||
|
||||
// Linked Jellyfin servers, one account per ServerId, in their own Hive box.
|
||||
//
|
||||
@@ -24,6 +22,7 @@ const _keyAccounts = 'accounts';
|
||||
const _keyDefault = 'defaultServer';
|
||||
const _keyLastPick = 'lastPick';
|
||||
const _keyBrowsePrefix = 'browse:';
|
||||
const _keyDismissedPrefix = 'dismissed:';
|
||||
|
||||
/// One signed-in user on one server.
|
||||
class JellyfinAccount {
|
||||
@@ -207,14 +206,15 @@ class JellyfinAccounts extends ChangeNotifier {
|
||||
/// Last failure text for an offline account.
|
||||
String? errorOf(String serverId) => _errors[serverId];
|
||||
|
||||
/// The Host tab's last Jellyfin pick, restored on the next start.
|
||||
/// The last Jellyfin pick put up, restored in the Library on the next start.
|
||||
JellyfinMedia? get lastPick => _lastPick;
|
||||
|
||||
/// Stable per install: Jellyfin keys devices and tokens by it.
|
||||
String get deviceId => _deviceId;
|
||||
|
||||
JellyfinClientInfo get clientInfo => JellyfinClientInfo(
|
||||
client: appName,
|
||||
// The old name on purpose: Jellyfin lists devices under it.
|
||||
client: 'Watch Together',
|
||||
device: deviceName ?? _hostname(),
|
||||
deviceId: _deviceId.isEmpty ? 'unknown' : _deviceId,
|
||||
version: appVersion,
|
||||
@@ -484,6 +484,38 @@ class JellyfinAccounts extends ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
/// Continue-row entries removed by hand on [serverId] (key -> when), so a
|
||||
/// next-up series (which Jellyfin cannot drop) stays out until it is
|
||||
/// played again. Keys are `continueKey`s.
|
||||
Map<String, DateTime> continueDismissed(String serverId) {
|
||||
try {
|
||||
final v = _store.get('$_keyDismissedPrefix$serverId');
|
||||
final j = v is String ? _tryJson(v) : null;
|
||||
if (j is! Map) return const {};
|
||||
return {
|
||||
for (final e in j.entries)
|
||||
if (e.key is String && e.value is int)
|
||||
e.key as String: DateTime.fromMillisecondsSinceEpoch(e.value as int, isUtc: true),
|
||||
};
|
||||
} catch (_) {
|
||||
return const {};
|
||||
}
|
||||
}
|
||||
|
||||
/// Keeps [key] out of the Continue row on [serverId] until it is played
|
||||
/// after [at] (default now). The newest 200 are kept.
|
||||
Future<void> dismissContinue(String serverId, String key, {DateTime? at}) async {
|
||||
final all = {...continueDismissed(serverId), key: (at ?? DateTime.now()).toUtc()};
|
||||
final newest = all.entries.toList()..sort((a, b) => b.value.compareTo(a.value));
|
||||
try {
|
||||
await _store.put('$_keyDismissedPrefix$serverId', jsonEncode({
|
||||
for (final e in newest.take(200)) e.key: e.value.millisecondsSinceEpoch,
|
||||
}));
|
||||
} catch (e) {
|
||||
debugPrint('save dismissed continue failed: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------- cache
|
||||
|
||||
/// Short-lived memo for browse calls, so going back does not refetch.
|
||||
|
||||
@@ -74,7 +74,7 @@ class _JellyfinAccountsPanelState extends State<JellyfinAccountsPanel> {
|
||||
Text('Jellyfin', style: t.titleMedium),
|
||||
const SizedBox(height: 4),
|
||||
const Text(
|
||||
'Browse your libraries in the Host tab and put up movies and episodes. '
|
||||
'Browse your libraries in the Library tab and put up movies and episodes. '
|
||||
'Sign in with Quick Connect or your password; several servers are fine.',
|
||||
style: TextStyle(color: Colors.grey),
|
||||
),
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
|
||||
@@ -20,14 +23,17 @@ final class HomeRoute extends BrowseRoute {
|
||||
String get key => 'home';
|
||||
}
|
||||
|
||||
/// A library or folder grid.
|
||||
/// A library or folder grid, optionally opened with a sort ("Show all"
|
||||
/// next to Latest opens it newest first).
|
||||
final class LibraryRoute extends BrowseRoute {
|
||||
final JellyfinItem folder;
|
||||
const LibraryRoute(this.folder);
|
||||
/// A `LibrarySort` name; null = by name.
|
||||
final String? sort;
|
||||
const LibraryRoute(this.folder, {this.sort});
|
||||
@override
|
||||
String get label => folder.name;
|
||||
@override
|
||||
String get key => 'lib:${folder.id}';
|
||||
String get key => sort == null ? 'lib:${folder.id}' : 'lib:${folder.id}:$sort';
|
||||
}
|
||||
|
||||
final class SeriesRoute extends BrowseRoute {
|
||||
@@ -41,18 +47,6 @@ final class SeriesRoute extends BrowseRoute {
|
||||
String get key => 'series:${series.id}';
|
||||
}
|
||||
|
||||
/// Movie / episode / video detail.
|
||||
final class DetailRoute extends BrowseRoute {
|
||||
final JellyfinItem item;
|
||||
const DetailRoute(this.item);
|
||||
@override
|
||||
String get label => item.kind == JellyfinItemKind.episode
|
||||
? (item.episodeLabel ?? item.name)
|
||||
: item.name;
|
||||
@override
|
||||
String get key => 'item:${item.id}';
|
||||
}
|
||||
|
||||
/// A route stack as JSON for [JellyfinAccounts.saveBrowseLocation]: only
|
||||
/// ids, names and the fields the pages start from (each page loads the
|
||||
/// full item itself).
|
||||
@@ -60,18 +54,17 @@ List<Map<String, Object?>> encodeBrowseStack(List<BrowseRoute> stack) => [
|
||||
for (final r in stack)
|
||||
switch (r) {
|
||||
HomeRoute() => {'r': 'home'},
|
||||
LibraryRoute(:final folder) => {'r': 'lib', ..._itemJson(folder)},
|
||||
LibraryRoute(:final folder, :final sort) => {'r': 'lib', ..._itemJson(folder), 'sort': ?sort},
|
||||
SeriesRoute(:final series, :final seasonId) => {
|
||||
'r': 'series',
|
||||
..._itemJson(series),
|
||||
'seasonId': ?seasonId,
|
||||
},
|
||||
DetailRoute(:final item) => {'r': 'item', ..._itemJson(item)},
|
||||
}
|
||||
];
|
||||
|
||||
/// Inverse of [encodeBrowseStack]; always starts with Home. Null when
|
||||
/// [raw] is not a stack.
|
||||
/// [raw] is not a stack. Entries it no longer knows are skipped.
|
||||
List<BrowseRoute>? decodeBrowseStack(Object? raw) {
|
||||
if (raw is! List) return null;
|
||||
final out = <BrowseRoute>[const HomeRoute()];
|
||||
@@ -79,11 +72,10 @@ List<BrowseRoute>? decodeBrowseStack(Object? raw) {
|
||||
if (e is! Map) return null;
|
||||
final item = _itemFrom(e);
|
||||
final route = switch (e['r']) {
|
||||
'home' => null,
|
||||
'lib' when item != null => LibraryRoute(item),
|
||||
'lib' when item != null =>
|
||||
LibraryRoute(item, sort: e['sort'] is String ? e['sort'] as String : null),
|
||||
'series' when item != null =>
|
||||
SeriesRoute(item, seasonId: e['seasonId'] is String ? e['seasonId'] as String : null),
|
||||
'item' when item != null => DetailRoute(item),
|
||||
_ => null,
|
||||
};
|
||||
if (route != null && out.every((r) => r.key != route.key)) out.add(route);
|
||||
@@ -141,46 +133,181 @@ class BrowseStart {
|
||||
}
|
||||
}
|
||||
|
||||
/// The card selected in the library (details panel): an item on a server.
|
||||
/// [item] is what the card had, when known (the panel loads it in full).
|
||||
class JellyfinSelection {
|
||||
final String serverId;
|
||||
final String itemId;
|
||||
final JellyfinItem? item;
|
||||
const JellyfinSelection(this.serverId, this.itemId, [this.item]);
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
other is JellyfinSelection && other.serverId == serverId && other.itemId == itemId;
|
||||
@override
|
||||
int get hashCode => Object.hash(serverId, itemId);
|
||||
}
|
||||
|
||||
/// Browser location shared by the browser, the library chips in the
|
||||
/// Library header and the details panel: the server shown, its route
|
||||
/// stack (saved per server on every move), a data version that bumps after
|
||||
/// watched-state changes (rows reload), and the keys in the Continue row.
|
||||
class BrowseController extends ChangeNotifier {
|
||||
final JellyfinAccounts accounts;
|
||||
/// Clock for the Continue row's age cut-off (tests pin it).
|
||||
final DateTime Function() now;
|
||||
BrowseController(this.accounts, {this.now = DateTime.now});
|
||||
|
||||
String? _serverId;
|
||||
List<BrowseRoute> _stack = const [HomeRoute()];
|
||||
bool _started = false;
|
||||
int _version = 0;
|
||||
Set<String> _continueKeys = const {};
|
||||
|
||||
List<BrowseRoute> get stack => _stack;
|
||||
int get version => _version;
|
||||
/// [continueKey]s of the cards in the Continue row now.
|
||||
Set<String> get continueKeys => _continueKeys;
|
||||
|
||||
JellyfinAccount? get account =>
|
||||
(_serverId == null ? null : accounts.account(_serverId!)) ?? accounts.defaultAccount;
|
||||
String? get serverId => account?.serverId;
|
||||
|
||||
/// First use (accounts loaded): [start], else the server of [serverHint]
|
||||
/// at its remembered location, else the default server's.
|
||||
void start({BrowseStart? start, String? serverHint}) {
|
||||
if (_started) return;
|
||||
_started = true;
|
||||
if (start != null && accounts.account(start.serverId) != null) {
|
||||
_serverId = start.serverId;
|
||||
_stack = start.stack.isEmpty ? const [HomeRoute()] : start.stack;
|
||||
_remember();
|
||||
} else {
|
||||
_serverId = serverHint != null && accounts.account(serverHint) != null
|
||||
? serverHint
|
||||
: accounts.defaultServerId;
|
||||
_stack = _restored(_serverId);
|
||||
}
|
||||
}
|
||||
|
||||
List<BrowseRoute> _restored(String? serverId) =>
|
||||
(serverId == null ? null : decodeBrowseStack(accounts.browseLocation(serverId))) ??
|
||||
const [HomeRoute()];
|
||||
|
||||
void _remember() {
|
||||
final id = serverId;
|
||||
if (id != null) unawaited(accounts.saveBrowseLocation(id, encodeBrowseStack(_stack)));
|
||||
}
|
||||
|
||||
void switchServer(String serverId) {
|
||||
if (serverId == this.serverId) return;
|
||||
_serverId = serverId;
|
||||
_stack = _restored(serverId);
|
||||
_continueKeys = const {};
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void open(BrowseRoute route) {
|
||||
if (_stack.last.key == route.key) return;
|
||||
_stack = [..._stack, route];
|
||||
_remember();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Home, then [route] (library chips: a library is a top-level place).
|
||||
void openTop(BrowseRoute route) {
|
||||
_stack = [const HomeRoute(), route];
|
||||
_remember();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
bool back() {
|
||||
if (_stack.length <= 1) return false;
|
||||
_stack = _stack.sublist(0, _stack.length - 1);
|
||||
_remember();
|
||||
notifyListeners();
|
||||
return true;
|
||||
}
|
||||
|
||||
void popTo(int index) {
|
||||
_stack = _stack.sublist(0, index + 1);
|
||||
_remember();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Watched state changed on the server: drop its cached lists and let
|
||||
/// the rows reload.
|
||||
void dataChanged() {
|
||||
final id = serverId;
|
||||
if (id != null) accounts.invalidate(id);
|
||||
_version++;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
set continueKeys(Set<String> keys) {
|
||||
if (setEquals(keys, _continueKeys)) return;
|
||||
_continueKeys = keys;
|
||||
// Set while building a row: tell listeners after the frame.
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!_disposed) notifyListeners();
|
||||
});
|
||||
}
|
||||
|
||||
bool _disposed = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_disposed = true;
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// What every browser page needs, provided by [JellyfinBrowser].
|
||||
class BrowseScope extends InheritedWidget {
|
||||
final JellyfinAccounts accounts;
|
||||
final JellyfinAccount account;
|
||||
final JellyfinClient client;
|
||||
/// Item id of the current pick on this server, highlighted everywhere.
|
||||
final BrowseController controller;
|
||||
/// Item id of the selected card on this server, highlighted everywhere.
|
||||
final String? selectedItemId;
|
||||
final ValueChanged<JellyfinItem> onPick;
|
||||
final ValueChanged<BrowseRoute> onOpen;
|
||||
/// Hover / focus: the inspector pane follows it.
|
||||
final ValueChanged<JellyfinItem> onPeek;
|
||||
/// Click: select (details panel).
|
||||
final ValueChanged<JellyfinItem> onSelect;
|
||||
/// Double-click: put it up (a series: its next episode).
|
||||
final ValueChanged<JellyfinItem> onPutUp;
|
||||
/// Home shows the library chips itself (the Library tab has them in its
|
||||
/// header instead).
|
||||
final bool libraryStrip;
|
||||
|
||||
const BrowseScope({
|
||||
super.key,
|
||||
required this.accounts,
|
||||
required this.account,
|
||||
required this.client,
|
||||
required this.controller,
|
||||
required this.selectedItemId,
|
||||
required this.onPick,
|
||||
required this.onOpen,
|
||||
required this.onPeek,
|
||||
required this.onSelect,
|
||||
required this.onPutUp,
|
||||
this.libraryStrip = true,
|
||||
required super.child,
|
||||
});
|
||||
|
||||
static BrowseScope of(BuildContext context) =>
|
||||
context.dependOnInheritedWidgetOfExactType<BrowseScope>()!;
|
||||
|
||||
void onOpen(BrowseRoute route) => controller.open(route);
|
||||
|
||||
/// Cached browse call on this server.
|
||||
Future<T> load<T>(String key, Future<T> Function(JellyfinClient c) fn) =>
|
||||
accounts.cached(account.serverId, key, () => fn(client));
|
||||
|
||||
/// Click / Enter on a card: playable items are picked, containers open.
|
||||
/// Click / Enter on a card: series, movies and episodes are selected,
|
||||
/// seasons open their series, libraries and folders open.
|
||||
void activate(JellyfinItem item) {
|
||||
if (isPlayable(item)) {
|
||||
onPick(item);
|
||||
if (isSelectable(item)) {
|
||||
onSelect(item);
|
||||
return;
|
||||
}
|
||||
switch (item.kind) {
|
||||
case JellyfinItemKind.series:
|
||||
onOpen(SeriesRoute(item));
|
||||
case JellyfinItemKind.season:
|
||||
final sid = item.seriesId;
|
||||
if (sid != null) {
|
||||
@@ -193,7 +320,7 @@ class BrowseScope extends InheritedWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens the series of an episode (or the item itself if it has none).
|
||||
/// Opens the series of an episode.
|
||||
void openSeriesOf(JellyfinItem episode) {
|
||||
final sid = episode.seriesId;
|
||||
if (sid == null) return;
|
||||
@@ -206,23 +333,28 @@ class BrowseScope extends InheritedWidget {
|
||||
bool updateShouldNotify(BrowseScope old) =>
|
||||
old.selectedItemId != selectedItemId ||
|
||||
old.account != account ||
|
||||
old.client != client;
|
||||
old.client != client ||
|
||||
old.libraryStrip != libraryStrip;
|
||||
}
|
||||
|
||||
/// Runs [load] once (again on [retry]) and shows [builder] with the result,
|
||||
/// a spinner meanwhile, and a compact error with Retry on failure.
|
||||
/// Runs [load] once (again on [retry] and when [version] changes) and
|
||||
/// shows [builder] with the result, a spinner meanwhile, and a compact
|
||||
/// error with Retry on failure. A reload for a new [version] keeps the old
|
||||
/// result on screen until the new one arrives.
|
||||
class Loader<T> extends StatefulWidget {
|
||||
final Future<T> Function() load;
|
||||
final Widget Function(BuildContext context, T data) builder;
|
||||
final Widget? loading;
|
||||
/// When false the error collapses to nothing (optional sections).
|
||||
final bool showError;
|
||||
final Object? version;
|
||||
const Loader({
|
||||
super.key,
|
||||
required this.load,
|
||||
required this.builder,
|
||||
this.loading,
|
||||
this.showError = true,
|
||||
this.version,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -231,22 +363,31 @@ class Loader<T> extends StatefulWidget {
|
||||
|
||||
class _LoaderState<T> extends State<Loader<T>> {
|
||||
late Future<T> _future = widget.load();
|
||||
(T,)? _last;
|
||||
|
||||
void _retry() => setState(() => _future = widget.load());
|
||||
|
||||
@override
|
||||
void didUpdateWidget(Loader<T> old) {
|
||||
super.didUpdateWidget(old);
|
||||
if (old.version != widget.version) _future = widget.load();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FutureBuilder<T>(
|
||||
future: _future,
|
||||
builder: (context, snap) {
|
||||
if (snap.hasData) return widget.builder(context, snap.data as T);
|
||||
if (snap.hasData || (snap.connectionState == ConnectionState.done && !snap.hasError)) {
|
||||
_last = (snap.data as T,);
|
||||
return widget.builder(context, snap.data as T);
|
||||
}
|
||||
if (snap.hasError) {
|
||||
if (!widget.showError) return const SizedBox.shrink();
|
||||
return LoadError(error: snap.error!, onRetry: _retry);
|
||||
}
|
||||
if (snap.connectionState == ConnectionState.done) {
|
||||
return widget.builder(context, snap.data as T);
|
||||
}
|
||||
final last = _last;
|
||||
if (last != null) return widget.builder(context, last.$1);
|
||||
return widget.loading ??
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(32),
|
||||
|
||||
@@ -2,52 +2,51 @@ import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
|
||||
import '../screens/home/picked_media.dart';
|
||||
import 'accounts.dart';
|
||||
import 'browse_scope.dart';
|
||||
import 'detail_page.dart';
|
||||
import 'format.dart';
|
||||
import 'home_page.dart';
|
||||
import 'inspector.dart';
|
||||
import 'library_page.dart';
|
||||
import 'search_results.dart';
|
||||
import 'series_page.dart';
|
||||
import 'sign_in_panel.dart';
|
||||
|
||||
/// Browser width from which the inspector pane shows on the right.
|
||||
const inspectorFrom = 1400.0;
|
||||
const inspectorWidth = 340.0;
|
||||
|
||||
/// Library browser for the linked Jellyfin servers: server switcher,
|
||||
/// breadcrumbs, search, and a back stack of pages (Home rows, library
|
||||
/// grids, series, details). Pages below the top stay alive, so going back
|
||||
/// keeps scroll position and loaded pages.
|
||||
/// grids, series). Pages below the top stay alive, so going back keeps
|
||||
/// scroll position and loaded pages. Where it is lives in a
|
||||
/// [BrowseController] (shared with the Library header's library chips and
|
||||
/// the details panel; one of its own when none is given).
|
||||
///
|
||||
/// Keyboard: arrows move between cards, Enter picks / opens, I opens
|
||||
/// details, Esc / Backspace / Alt+Left go back, / or Ctrl+F searches.
|
||||
/// A click on a card selects it ([onSelect]; libraries and folders open),
|
||||
/// a double-click puts it up ([onPutUp]).
|
||||
///
|
||||
/// Keyboard: arrows move between cards, Enter selects / opens, Esc /
|
||||
/// Backspace / Alt+Left go back, / or Ctrl+F searches.
|
||||
class JellyfinBrowser extends StatefulWidget {
|
||||
final JellyfinAccounts accounts;
|
||||
final JellyfinMedia? selected;
|
||||
final ValueChanged<JellyfinMedia> onPick;
|
||||
/// Shown when given: toggles a bigger browser (the host hides its form).
|
||||
final VoidCallback? onToggleExpanded;
|
||||
final bool expanded;
|
||||
final BrowseController? controller;
|
||||
/// Highlighted card.
|
||||
final JellyfinSelection? selected;
|
||||
final ValueChanged<JellyfinSelection> onSelect;
|
||||
final ValueChanged<JellyfinSelection> onPutUp;
|
||||
final VoidCallback? onOpenIntegrations;
|
||||
final Duration searchDebounce;
|
||||
/// Opens here instead of the remembered location (the series of the
|
||||
/// episode playing, in the session's swap panel).
|
||||
/// episode playing, after "Change video").
|
||||
final BrowseStart? start;
|
||||
/// Library chips on Home (off when the Library header shows them).
|
||||
final bool libraryStrip;
|
||||
const JellyfinBrowser({
|
||||
this.start,
|
||||
super.key,
|
||||
required this.accounts,
|
||||
required this.onPick,
|
||||
required this.onSelect,
|
||||
required this.onPutUp,
|
||||
this.controller,
|
||||
this.selected,
|
||||
this.onToggleExpanded,
|
||||
this.expanded = false,
|
||||
this.onOpenIntegrations,
|
||||
this.libraryStrip = true,
|
||||
this.searchDebounce = const Duration(milliseconds: 300),
|
||||
});
|
||||
|
||||
@@ -56,45 +55,23 @@ class JellyfinBrowser extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _JellyfinBrowserState extends State<JellyfinBrowser> {
|
||||
String? _serverId;
|
||||
List<BrowseRoute> _stack = const [HomeRoute()];
|
||||
BrowseController? _own;
|
||||
final _search = TextEditingController();
|
||||
final _searchFocus = FocusNode(debugLabel: 'jf-search');
|
||||
final _body = FocusScopeNode(debugLabel: 'jf-body');
|
||||
final _peek = ValueNotifier<JellyfinItem?>(null);
|
||||
String _term = '';
|
||||
Timer? _debounce;
|
||||
int _refresh = 0;
|
||||
|
||||
JellyfinAccounts get _accounts => widget.accounts;
|
||||
BrowseController get _c => widget.controller ?? (_own ??= BrowseController(widget.accounts));
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final start = widget.start;
|
||||
if (start != null && _accounts.account(start.serverId) != null) {
|
||||
_serverId = start.serverId;
|
||||
_stack = start.stack.isEmpty ? const [HomeRoute()] : start.stack;
|
||||
_remember();
|
||||
} else {
|
||||
_serverId = widget.selected != null && _accounts.account(widget.selected!.serverId) != null
|
||||
? widget.selected!.serverId
|
||||
: _accounts.defaultServerId;
|
||||
_stack = _restored(_serverId);
|
||||
}
|
||||
_c.start(start: widget.start, serverHint: widget.selected?.serverId);
|
||||
_accounts.addListener(_changed);
|
||||
}
|
||||
|
||||
/// The remembered location on [serverId], else Home.
|
||||
List<BrowseRoute> _restored(String? serverId) =>
|
||||
(serverId == null ? null : decodeBrowseStack(_accounts.browseLocation(serverId))) ??
|
||||
const [HomeRoute()];
|
||||
|
||||
/// Every navigation is saved per server (Hive), so the next browser
|
||||
/// (Host tab, swap panel, next start) opens where this one was.
|
||||
void _remember() {
|
||||
final id = _account?.serverId;
|
||||
if (id != null) unawaited(_accounts.saveBrowseLocation(id, encodeBrowseStack(_stack)));
|
||||
_c.addListener(_moved);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -104,16 +81,22 @@ class _JellyfinBrowserState extends State<JellyfinBrowser> {
|
||||
old.accounts.removeListener(_changed);
|
||||
widget.accounts.addListener(_changed);
|
||||
}
|
||||
final was = old.controller ?? _own;
|
||||
if (was != _c) {
|
||||
was?.removeListener(_moved);
|
||||
_c.addListener(_moved);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_accounts.removeListener(_changed);
|
||||
_c.removeListener(_moved);
|
||||
_own?.dispose();
|
||||
_debounce?.cancel();
|
||||
_search.dispose();
|
||||
_searchFocus.dispose();
|
||||
_body.dispose();
|
||||
_peek.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -121,44 +104,33 @@ class _JellyfinBrowserState extends State<JellyfinBrowser> {
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
|
||||
JellyfinAccount? get _account =>
|
||||
(_serverId == null ? null : _accounts.account(_serverId!)) ?? _accounts.defaultAccount;
|
||||
|
||||
void _switchServer(String serverId) {
|
||||
if (serverId == _account?.serverId) return;
|
||||
/// Any move (here, from the library chips or a server switch) ends a
|
||||
/// search.
|
||||
int _depth = 0;
|
||||
String? _server;
|
||||
void _moved() {
|
||||
if (!mounted) return;
|
||||
final moved = _c.stack.length != _depth || _c.serverId != _server;
|
||||
_depth = _c.stack.length;
|
||||
_server = _c.serverId;
|
||||
setState(() {
|
||||
_serverId = serverId;
|
||||
_stack = _restored(serverId);
|
||||
_peek.value = null;
|
||||
_clearSearch();
|
||||
if (moved) _clearSearch();
|
||||
});
|
||||
}
|
||||
|
||||
JellyfinAccount? get _account => _c.account;
|
||||
|
||||
// ------------------------------------------------------------- navigation
|
||||
|
||||
void _open(BrowseRoute route) {
|
||||
setState(() {
|
||||
_clearSearch();
|
||||
if (_stack.last.key != route.key) _stack = [..._stack, route];
|
||||
});
|
||||
_remember();
|
||||
_body.requestFocus();
|
||||
}
|
||||
|
||||
bool _back() {
|
||||
if (_stack.length <= 1) return false;
|
||||
setState(() => _stack = _stack.sublist(0, _stack.length - 1));
|
||||
_remember();
|
||||
_body.requestFocus();
|
||||
return true;
|
||||
final ok = _c.back();
|
||||
if (ok) _body.requestFocus();
|
||||
return ok;
|
||||
}
|
||||
|
||||
void _popTo(int index) {
|
||||
setState(() {
|
||||
_clearSearch();
|
||||
_stack = _stack.sublist(0, index + 1);
|
||||
});
|
||||
_remember();
|
||||
setState(_clearSearch);
|
||||
_c.popTo(index);
|
||||
_body.requestFocus();
|
||||
}
|
||||
|
||||
@@ -175,12 +147,6 @@ class _JellyfinBrowserState extends State<JellyfinBrowser> {
|
||||
_term = '';
|
||||
}
|
||||
|
||||
void _pick(JellyfinItem item) {
|
||||
final account = _account;
|
||||
if (account == null) return;
|
||||
widget.onPick(pickFor(account, item));
|
||||
}
|
||||
|
||||
KeyEventResult _onKey(FocusNode node, KeyEvent e) {
|
||||
if (e is! KeyDownEvent && e is! KeyRepeatEvent) return KeyEventResult.ignored;
|
||||
final keys = HardwareKeyboard.instance;
|
||||
@@ -217,10 +183,9 @@ class _JellyfinBrowserState extends State<JellyfinBrowser> {
|
||||
|
||||
Widget _page(BrowseRoute r) => switch (r) {
|
||||
HomeRoute() => const JellyfinHomePage(),
|
||||
LibraryRoute(:final folder) => JellyfinLibraryPage(folder: folder),
|
||||
LibraryRoute(:final folder, :final sort) => JellyfinLibraryPage(folder: folder, sort: sort),
|
||||
SeriesRoute(:final series, :final seasonId) =>
|
||||
JellyfinSeriesPage(series: series, seasonId: seasonId),
|
||||
DetailRoute(:final item) => JellyfinDetailPage(item: item),
|
||||
};
|
||||
|
||||
@override
|
||||
@@ -233,15 +198,16 @@ class _JellyfinBrowserState extends State<JellyfinBrowser> {
|
||||
final status = _accounts.statusOf(account.serverId);
|
||||
final sel = widget.selected;
|
||||
final searching = _term.isNotEmpty;
|
||||
final stack = _c.stack;
|
||||
|
||||
final pages = Stack(
|
||||
children: [
|
||||
for (var i = 0; i < _stack.length; i++)
|
||||
for (var i = 0; i < stack.length; i++)
|
||||
Visibility(
|
||||
key: ValueKey('$_refresh:${account.serverId}:$i:${_stack[i].key}'),
|
||||
visible: !searching && i == _stack.length - 1,
|
||||
key: ValueKey('$_refresh:${account.serverId}:$i:${stack[i].key}'),
|
||||
visible: !searching && i == stack.length - 1,
|
||||
maintainState: true,
|
||||
child: _page(_stack[i]),
|
||||
child: _page(stack[i]),
|
||||
),
|
||||
if (searching) JellyfinSearchResults(key: ValueKey('$_refresh:$_term'), term: _term),
|
||||
],
|
||||
@@ -251,10 +217,11 @@ class _JellyfinBrowserState extends State<JellyfinBrowser> {
|
||||
accounts: _accounts,
|
||||
account: account,
|
||||
client: client,
|
||||
controller: _c,
|
||||
selectedItemId: sel != null && sel.serverId == account.serverId ? sel.itemId : null,
|
||||
onPick: _pick,
|
||||
onOpen: _open,
|
||||
onPeek: (item) => _peek.value = item,
|
||||
onSelect: (item) => widget.onSelect(JellyfinSelection(account.serverId, item.id, item)),
|
||||
onPutUp: (item) => widget.onPutUp(JellyfinSelection(account.serverId, item.id, item)),
|
||||
libraryStrip: widget.libraryStrip,
|
||||
child: Focus(
|
||||
onKeyEvent: _onKey,
|
||||
child: Column(
|
||||
@@ -265,21 +232,7 @@ class _JellyfinBrowserState extends State<JellyfinBrowser> {
|
||||
if (status == JellyfinAccountStatus.expired || status == JellyfinAccountStatus.offline)
|
||||
_StatusBanner(accounts: _accounts, account: account, status: status),
|
||||
const SizedBox(height: 12),
|
||||
Expanded(
|
||||
child: LayoutBuilder(builder: (context, box) {
|
||||
final inspector = box.maxWidth >= inspectorFrom;
|
||||
final body = FocusScope(node: _body, child: pages);
|
||||
if (!inspector) return body;
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Expanded(child: body),
|
||||
const SizedBox(width: 16),
|
||||
SizedBox(width: inspectorWidth, child: JellyfinInspector(item: _peek)),
|
||||
],
|
||||
);
|
||||
}),
|
||||
),
|
||||
Expanded(child: FocusScope(node: _body, child: pages)),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -289,9 +242,10 @@ class _JellyfinBrowserState extends State<JellyfinBrowser> {
|
||||
Widget _header(BuildContext context, JellyfinAccount account) {
|
||||
final theme = Theme.of(context);
|
||||
final t = theme.textTheme;
|
||||
final stack = _c.stack;
|
||||
final crumbs = <Widget>[];
|
||||
for (var i = 0; i < _stack.length; i++) {
|
||||
final last = i == _stack.length - 1 && _term.isEmpty;
|
||||
for (var i = 0; i < stack.length; i++) {
|
||||
final last = i == stack.length - 1 && _term.isEmpty;
|
||||
if (i > 0) {
|
||||
crumbs.add(const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 2),
|
||||
@@ -299,7 +253,7 @@ class _JellyfinBrowserState extends State<JellyfinBrowser> {
|
||||
));
|
||||
}
|
||||
final label = Text(
|
||||
_stack[i].label,
|
||||
stack[i].label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: t.titleSmall?.copyWith(
|
||||
@@ -328,26 +282,28 @@ class _JellyfinBrowserState extends State<JellyfinBrowser> {
|
||||
style: t.titleSmall?.copyWith(fontWeight: FontWeight.w600))));
|
||||
}
|
||||
|
||||
return LayoutBuilder(builder: (context, box) {
|
||||
final narrow = box.maxWidth < 640;
|
||||
return Row(
|
||||
children: [
|
||||
_ServerSwitcher(
|
||||
accounts: _accounts,
|
||||
current: account,
|
||||
onSelect: _switchServer,
|
||||
compact: narrow,
|
||||
onSelect: _c.switchServer,
|
||||
onOpenIntegrations: widget.onOpenIntegrations,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
IconButton(
|
||||
tooltip: 'Back (Esc)',
|
||||
visualDensity: VisualDensity.compact,
|
||||
onPressed: _stack.length > 1 ? _back : null,
|
||||
onPressed: stack.length > 1 ? _back : null,
|
||||
icon: const Icon(Icons.arrow_back, size: 20),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Expanded(child: Row(children: crumbs)),
|
||||
const SizedBox(width: 8),
|
||||
SizedBox(
|
||||
width: 260,
|
||||
width: narrow ? 170 : 260,
|
||||
child: TextField(
|
||||
key: const ValueKey('jf-search'),
|
||||
controller: _search,
|
||||
@@ -385,16 +341,9 @@ class _JellyfinBrowserState extends State<JellyfinBrowser> {
|
||||
},
|
||||
icon: const Icon(Icons.refresh, size: 20),
|
||||
),
|
||||
if (widget.onToggleExpanded != null)
|
||||
IconButton(
|
||||
key: const ValueKey('jf-expand'),
|
||||
tooltip: widget.expanded ? 'Show the host form' : 'Bigger browser',
|
||||
visualDensity: VisualDensity.compact,
|
||||
onPressed: widget.onToggleExpanded,
|
||||
icon: Icon(widget.expanded ? Icons.close_fullscreen : Icons.open_in_full, size: 18),
|
||||
),
|
||||
],
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -404,11 +353,14 @@ class _ServerSwitcher extends StatelessWidget {
|
||||
final JellyfinAccount current;
|
||||
final ValueChanged<String> onSelect;
|
||||
final VoidCallback? onOpenIntegrations;
|
||||
/// Narrow: the name is shorter.
|
||||
final bool compact;
|
||||
const _ServerSwitcher({
|
||||
required this.accounts,
|
||||
required this.current,
|
||||
required this.onSelect,
|
||||
this.onOpenIntegrations,
|
||||
this.compact = false,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -420,7 +372,7 @@ class _ServerSwitcher extends StatelessWidget {
|
||||
StatusDot(status: accounts.statusOf(current.serverId)),
|
||||
const SizedBox(width: 8),
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 200),
|
||||
constraints: BoxConstraints(maxWidth: compact ? 110 : 200),
|
||||
child: Text(current.displayName,
|
||||
maxLines: 1, overflow: TextOverflow.ellipsis, style: t.titleSmall),
|
||||
),
|
||||
|
||||
@@ -2,12 +2,12 @@ import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
|
||||
import 'browse_scope.dart';
|
||||
import 'format.dart';
|
||||
import 'images.dart';
|
||||
import '../shell/motion.dart' show HScroll;
|
||||
|
||||
enum CardShape {
|
||||
/// 2:3 poster (movies, series, latest).
|
||||
@@ -49,20 +49,29 @@ IconData kindIcon(JellyfinItem item) => switch (item.kind) {
|
||||
_ => Icons.movie_outlined,
|
||||
};
|
||||
|
||||
/// Two clicks on the same card within this time put it up.
|
||||
const doubleClickTime = Duration(milliseconds: 350);
|
||||
|
||||
/// Poster or thumb card: image with progress bar and state badges, title
|
||||
/// and one meta line. Click / Enter activates (pick or open), hover and
|
||||
/// focus feed the inspector, "i" or the hover button opens the details.
|
||||
/// and one meta line. Click / Enter selects it (details panel; libraries
|
||||
/// and folders open), a second click soon after puts it up. [display]
|
||||
/// gives the picture and title when they are not [item]'s own (a series
|
||||
/// card for a new episode), [subtitle] replaces the meta line.
|
||||
class MediaCard extends StatefulWidget {
|
||||
final JellyfinItem item;
|
||||
final JellyfinItem? display;
|
||||
final String? subtitle;
|
||||
final CardShape shape;
|
||||
/// Rows mixing series (Continue watching, Next up): series name on top,
|
||||
/// "S2E3 · 12 min left" below.
|
||||
/// Rows mixing series (Continue): series name on top, "S2E3 · 12 min
|
||||
/// left" below.
|
||||
final bool seriesFirst;
|
||||
final bool autofocus;
|
||||
const MediaCard({
|
||||
super.key,
|
||||
required this.item,
|
||||
required this.shape,
|
||||
this.display,
|
||||
this.subtitle,
|
||||
this.seriesFirst = false,
|
||||
this.autofocus = false,
|
||||
});
|
||||
@@ -74,18 +83,20 @@ class MediaCard extends StatefulWidget {
|
||||
class _MediaCardState extends State<MediaCard> {
|
||||
bool _hover = false;
|
||||
bool _focus = false;
|
||||
DateTime? _lastTap;
|
||||
|
||||
JellyfinItem get item => widget.item;
|
||||
|
||||
void _details(BrowseScope scope) => scope.onOpen(DetailRoute(item));
|
||||
|
||||
KeyEventResult _onKey(BrowseScope scope, KeyEvent e) {
|
||||
if (e is! KeyDownEvent) return KeyEventResult.ignored;
|
||||
if (e.logicalKey == LogicalKeyboardKey.keyI && isPlayable(item)) {
|
||||
_details(scope);
|
||||
return KeyEventResult.handled;
|
||||
void _tap(BrowseScope scope) {
|
||||
final now = DateTime.now();
|
||||
final last = _lastTap;
|
||||
_lastTap = now;
|
||||
if (last != null && now.difference(last) < doubleClickTime && isSelectable(item)) {
|
||||
_lastTap = null;
|
||||
scope.onPutUp(item);
|
||||
return;
|
||||
}
|
||||
return KeyEventResult.ignored;
|
||||
scope.activate(item);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -93,10 +104,11 @@ class _MediaCardState extends State<MediaCard> {
|
||||
final scope = BrowseScope.of(context);
|
||||
final theme = Theme.of(context);
|
||||
final scheme = theme.colorScheme;
|
||||
final shown = widget.display ?? item;
|
||||
final selected = scope.selectedItemId == item.id;
|
||||
final played = item.userData.played;
|
||||
final progress = progressOf(item);
|
||||
final unplayed = item.userData.unplayedItemCount;
|
||||
final unplayed = shown.userData.unplayedItemCount;
|
||||
final ring = selected
|
||||
? scheme.primary
|
||||
: _focus
|
||||
@@ -106,20 +118,21 @@ class _MediaCardState extends State<MediaCard> {
|
||||
final image = AspectRatio(
|
||||
aspectRatio: widget.shape.aspect,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 120),
|
||||
duration: const Duration(milliseconds: 160),
|
||||
curve: Curves.fastEaseInToSlowEaseOut,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: ring, width: 2),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(color: ring, width: 3),
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
borderRadius: BorderRadius.circular(7),
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
JellyfinImage(
|
||||
client: scope.client,
|
||||
image: cardImage(item, widget.shape),
|
||||
icon: kindIcon(item),
|
||||
image: cardImage(shown, widget.shape),
|
||||
icon: kindIcon(shown),
|
||||
),
|
||||
if (_hover || _focus)
|
||||
const ColoredBox(color: Color(0x22FFFFFF)),
|
||||
@@ -141,7 +154,7 @@ class _MediaCardState extends State<MediaCard> {
|
||||
? _Badge(color: scheme.primary, child: Icon(Icons.check, size: 14, color: scheme.onPrimary))
|
||||
: played
|
||||
? const _Badge(color: Color(0xCC1B5E20), child: Icon(Icons.check, size: 14, color: Colors.white))
|
||||
: unplayed != null && unplayed > 0 && !isPlayable(item)
|
||||
: unplayed != null && unplayed > 0 && !isPlayable(shown) && widget.subtitle == null
|
||||
? _Badge(
|
||||
color: scheme.primaryContainer,
|
||||
child: Text('$unplayed',
|
||||
@@ -151,52 +164,30 @@ class _MediaCardState extends State<MediaCard> {
|
||||
color: scheme.onPrimaryContainer)))
|
||||
: const SizedBox.shrink(),
|
||||
),
|
||||
if (_hover && isPlayable(item))
|
||||
Positioned(
|
||||
top: 4,
|
||||
left: 4,
|
||||
child: IconButton.filledTonal(
|
||||
key: ValueKey('card-info-${item.id}'),
|
||||
tooltip: 'Details (I)',
|
||||
visualDensity: VisualDensity.compact,
|
||||
iconSize: 16,
|
||||
onPressed: () => _details(scope),
|
||||
icon: const Icon(Icons.info_outline),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final title = cardTitle(item, seriesFirst: widget.seriesFirst);
|
||||
final sub = widget.seriesFirst && item.kind == JellyfinItemKind.episode
|
||||
final title = widget.display != null ? shown.name : cardTitle(item, seriesFirst: widget.seriesFirst);
|
||||
final sub = widget.subtitle ??
|
||||
(widget.seriesFirst && item.kind == JellyfinItemKind.episode
|
||||
? [cardSubtitle(item), item.name].where((s) => s.isNotEmpty).join(' · ')
|
||||
: cardSubtitle(item);
|
||||
return Focus(
|
||||
canRequestFocus: false,
|
||||
skipTraversal: true,
|
||||
onKeyEvent: (_, e) => _onKey(scope, e),
|
||||
child: Semantics(
|
||||
: cardSubtitle(item));
|
||||
return Semantics(
|
||||
button: true,
|
||||
selected: selected,
|
||||
label: title,
|
||||
child: InkWell(
|
||||
key: ValueKey('card-${item.id}'),
|
||||
autofocus: widget.autofocus,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
focusColor: Colors.transparent,
|
||||
hoverColor: Colors.transparent,
|
||||
onTap: () => scope.activate(item),
|
||||
onHover: (h) {
|
||||
setState(() => _hover = h);
|
||||
if (h) scope.onPeek(item);
|
||||
},
|
||||
onFocusChange: (f) {
|
||||
setState(() => _focus = f);
|
||||
if (f) scope.onPeek(item);
|
||||
},
|
||||
onTap: () => _tap(scope),
|
||||
onHover: (h) => setState(() => _hover = h),
|
||||
onFocusChange: (f) => setState(() => _focus = f),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
@@ -212,7 +203,7 @@ class _MediaCardState extends State<MediaCard> {
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: selected ? scheme.primary : null)),
|
||||
Text(sub,
|
||||
maxLines: 1,
|
||||
@@ -224,7 +215,6 @@ class _MediaCardState extends State<MediaCard> {
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -275,18 +265,25 @@ class CardGridDelegate extends SliverGridDelegate {
|
||||
bool shouldRelayout(CardGridDelegate old) => old.shape != shape || old.minWidth != minWidth;
|
||||
}
|
||||
|
||||
/// A titled horizontal row of cards sized to fill the width; arrows (on
|
||||
/// hover) page through it. Lazy: only visible cards are built.
|
||||
/// One card of a [MediaRow]: see [MediaCard].
|
||||
typedef CardEntry = ({JellyfinItem item, JellyfinItem? display, String? subtitle});
|
||||
|
||||
List<CardEntry> cardEntries(Iterable<JellyfinItem> items) =>
|
||||
[for (final i in items) (item: i, display: null, subtitle: null)];
|
||||
|
||||
/// A titled horizontal row of cards sized to fill the width; mouse drag
|
||||
/// and the wheel move it ([HScroll]), arrows (on hover) page through it.
|
||||
/// Lazy: only visible cards are built.
|
||||
class MediaRow extends StatefulWidget {
|
||||
final String title;
|
||||
final List<JellyfinItem> items;
|
||||
final List<CardEntry> entries;
|
||||
final CardShape shape;
|
||||
final bool seriesFirst;
|
||||
final Widget? trailing;
|
||||
const MediaRow({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.items,
|
||||
required this.entries,
|
||||
required this.shape,
|
||||
this.seriesFirst = false,
|
||||
this.trailing,
|
||||
@@ -315,47 +312,41 @@ class _MediaRowState extends State<MediaRow> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = Theme.of(context).textTheme;
|
||||
final entries = widget.entries;
|
||||
return MouseRegion(
|
||||
onEnter: (_) => setState(() => _hover = true),
|
||||
onExit: (_) => setState(() => _hover = false),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(widget.title, style: t.titleMedium),
|
||||
const SizedBox(width: 8),
|
||||
Text('${widget.items.length}', style: t.bodySmall?.copyWith(color: Colors.grey)),
|
||||
const Spacer(),
|
||||
?widget.trailing,
|
||||
],
|
||||
),
|
||||
),
|
||||
SectionHeader(widget.title, count: '${entries.length}', trailing: widget.trailing),
|
||||
LayoutBuilder(builder: (context, box) {
|
||||
final fit = fitColumns(box.maxWidth, widget.shape.minWidth);
|
||||
final h = fit.itemWidth / widget.shape.aspect + cardTextHeight;
|
||||
final overflow = widget.items.length > fit.cols;
|
||||
final overflow = entries.length > fit.cols;
|
||||
return SizedBox(
|
||||
height: h,
|
||||
child: Stack(
|
||||
children: [
|
||||
ListView.separated(
|
||||
HScroll(
|
||||
controller: _scroll,
|
||||
builder: (c) => ListView.separated(
|
||||
controller: c,
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: widget.items.length,
|
||||
itemCount: entries.length,
|
||||
separatorBuilder: (_, _) => const SizedBox(width: cardGap),
|
||||
itemBuilder: (context, i) => SizedBox(
|
||||
width: fit.itemWidth,
|
||||
child: MediaCard(
|
||||
item: widget.items[i],
|
||||
item: entries[i].item,
|
||||
display: entries[i].display,
|
||||
subtitle: entries[i].subtitle,
|
||||
shape: widget.shape,
|
||||
seriesFirst: widget.seriesFirst,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (overflow && _hover) ...[
|
||||
_PageArrow(left: true, height: h - cardTextHeight, onTap: () => _page(-1, box.maxWidth)),
|
||||
_PageArrow(left: false, height: h - cardTextHeight, onTap: () => _page(1, box.maxWidth)),
|
||||
@@ -410,12 +401,19 @@ class SectionHeader extends StatelessWidget {
|
||||
padding: const EdgeInsets.only(bottom: 8, top: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(title, style: t.titleMedium),
|
||||
Expanded(
|
||||
child: Row(
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(title, maxLines: 1, overflow: TextOverflow.ellipsis, style: t.titleMedium),
|
||||
),
|
||||
if (count != null) ...[
|
||||
const SizedBox(width: 8),
|
||||
Text(count!, style: t.bodySmall?.copyWith(color: Colors.grey)),
|
||||
],
|
||||
const Spacer(),
|
||||
],
|
||||
),
|
||||
),
|
||||
?trailing,
|
||||
],
|
||||
),
|
||||
|
||||
@@ -1,180 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
|
||||
import 'browse_scope.dart';
|
||||
import 'format.dart';
|
||||
import 'images.dart';
|
||||
|
||||
/// Movie / episode detail: art, title, meta, overview, the pick button and
|
||||
/// what is in the file (video, audio and subtitle tracks).
|
||||
class JellyfinDetailPage extends StatelessWidget {
|
||||
final JellyfinItem item;
|
||||
const JellyfinDetailPage({super.key, required this.item});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scope = BrowseScope.of(context);
|
||||
return Loader<JellyfinItem>(
|
||||
key: ValueKey('detail-${item.id}-${scope.account.serverId}'),
|
||||
load: () => scope.load('item:${item.id}', (c) => c.item(item.id)),
|
||||
builder: (context, full) => _Detail(item: full),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Detail extends StatelessWidget {
|
||||
final JellyfinItem item;
|
||||
const _Detail({required this.item});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scope = BrowseScope.of(context);
|
||||
final theme = Theme.of(context);
|
||||
final t = theme.textTheme;
|
||||
final episode = item.kind == JellyfinItemKind.episode;
|
||||
final selected = scope.selectedItemId == item.id;
|
||||
final meta = [
|
||||
?item.episodeLabel,
|
||||
if (item.productionYear != null) '${item.productionYear}',
|
||||
if (item.runTime != null) formatRuntime(item.runTime!),
|
||||
?timeLeft(item),
|
||||
if (item.officialRating != null) item.officialRating!,
|
||||
if (item.communityRating != null) '★ ${item.communityRating!.toStringAsFixed(1)}',
|
||||
if (item.userData.played) 'Watched',
|
||||
].join(' · ');
|
||||
|
||||
final art = episode
|
||||
? AspectRatio(
|
||||
aspectRatio: 16 / 9,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: JellyfinImage(
|
||||
client: scope.client,
|
||||
image: item.ownImage(JellyfinImageType.primary) ??
|
||||
item.image(JellyfinImageType.backdrop),
|
||||
icon: Icons.tv_outlined),
|
||||
),
|
||||
)
|
||||
: AspectRatio(
|
||||
aspectRatio: 2 / 3,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: JellyfinImage(
|
||||
client: scope.client, image: item.image(JellyfinImageType.primary)),
|
||||
),
|
||||
);
|
||||
|
||||
final info = Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (episode && item.seriesName != null)
|
||||
TextButton.icon(
|
||||
key: const ValueKey('detail-series'),
|
||||
style: TextButton.styleFrom(padding: EdgeInsets.zero),
|
||||
onPressed: () => scope.openSeriesOf(item),
|
||||
icon: const Icon(Icons.live_tv_outlined, size: 18),
|
||||
label: Text(item.seriesName!, style: t.titleMedium),
|
||||
),
|
||||
SelectableText(item.name, style: t.headlineSmall),
|
||||
const SizedBox(height: 6),
|
||||
Text(meta, style: t.bodyMedium?.copyWith(color: Colors.grey)),
|
||||
if (progressOf(item) > 0) ...[
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(
|
||||
width: 240,
|
||||
child: LinearProgressIndicator(value: progressOf(item), minHeight: 4)),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
FilledButton.icon(
|
||||
key: const ValueKey('detail-pick'),
|
||||
onPressed: () => scope.onPick(item),
|
||||
icon: Icon(selected ? Icons.check : Icons.play_arrow),
|
||||
label: Text(selected ? 'Picked' : 'Pick to watch'),
|
||||
),
|
||||
if (item.overview != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 760),
|
||||
child: SelectableText(item.overview!, style: t.bodyMedium),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
_Tracks(item: item),
|
||||
],
|
||||
);
|
||||
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.only(bottom: 24),
|
||||
child: LayoutBuilder(builder: (context, box) {
|
||||
final wide = box.maxWidth >= 760;
|
||||
final artWidth = episode ? (box.maxWidth * 0.32).clamp(280.0, 480.0) : 240.0;
|
||||
if (!wide) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Align(
|
||||
alignment: Alignment.topLeft,
|
||||
child: SizedBox(width: episode ? 320 : 180, child: art)),
|
||||
const SizedBox(height: 16),
|
||||
info,
|
||||
],
|
||||
);
|
||||
}
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(width: artWidth, child: art),
|
||||
const SizedBox(width: 24),
|
||||
Expanded(child: info),
|
||||
],
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// What the file holds, one line per track kind.
|
||||
class _Tracks extends StatelessWidget {
|
||||
final JellyfinItem item;
|
||||
const _Tracks({required this.item});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = Theme.of(context).textTheme;
|
||||
final source = item.mediaSources.firstOrNull;
|
||||
final streams = source?.mediaStreams.isNotEmpty == true ? source!.mediaStreams : item.mediaStreams;
|
||||
if (streams.isEmpty) return const SizedBox.shrink();
|
||||
String names(JellyfinStreamType type) => streams
|
||||
.where((s) => s.type == type)
|
||||
.map((s) => s.displayTitle ?? [s.language, s.codec].whereType<String>().join(' '))
|
||||
.join(' · ');
|
||||
final rows = [
|
||||
('Video', names(JellyfinStreamType.video)),
|
||||
('Audio', names(JellyfinStreamType.audio)),
|
||||
('Subtitles', names(JellyfinStreamType.subtitle)),
|
||||
if (source?.container != null || source?.size != null)
|
||||
('File', [
|
||||
?source?.container,
|
||||
if (source?.size != null) '${(source!.size! / (1 << 30)).toStringAsFixed(2)} GB',
|
||||
].join(' · ')),
|
||||
].where((r) => r.$2.isNotEmpty);
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
for (final (label, value) in rows)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 6),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 84,
|
||||
child: Text(label, style: t.bodySmall?.copyWith(color: Colors.grey))),
|
||||
Expanded(child: Text(value, style: t.bodySmall)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,514 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
|
||||
import '../shell/motion.dart';
|
||||
import 'accounts.dart';
|
||||
import 'browse_scope.dart';
|
||||
import 'cards.dart' show doubleClickTime, kindIcon;
|
||||
import 'format.dart';
|
||||
import 'home_rows.dart' show continueKey;
|
||||
import 'images.dart';
|
||||
|
||||
/// Width of the details panel (docked or floating).
|
||||
const detailsPanelWidth = 400.0;
|
||||
|
||||
/// What putting up [item] plays: itself when playable; for a series its
|
||||
/// next episode (resume point or next up), else its first unwatched, else
|
||||
/// its first episode. Null when there is nothing to play.
|
||||
Future<JellyfinItem?> playableFor(JellyfinClient c, JellyfinItem item) async {
|
||||
if (isPlayable(item)) return item;
|
||||
if (item.kind != JellyfinItemKind.series) return null;
|
||||
final next = await c.nextUp(seriesId: item.id, limit: 1, enableResumable: true);
|
||||
if (next.isNotEmpty) return next.first;
|
||||
final eps = await c.episodes(item.id);
|
||||
return eps.where((e) => !e.userData.played).firstOrNull ?? eps.firstOrNull;
|
||||
}
|
||||
|
||||
typedef _Data = ({
|
||||
JellyfinItem item,
|
||||
JellyfinItem? series,
|
||||
List<JellyfinItem> seasons,
|
||||
JellyfinItem? next,
|
||||
});
|
||||
|
||||
/// The selected card's details, at the right of the library: banner,
|
||||
/// title, meta, "Put it up · S1E3" ("Play · S1E3" when watching
|
||||
/// [solo]), mark watched / unwatched, remove from Continue, overview and,
|
||||
/// for a series or an episode, the episode list (click picks which
|
||||
/// episode goes up, double-click puts it up). Reloads when the
|
||||
/// [controller]'s data version moves.
|
||||
class JellyfinDetailsPanel extends StatefulWidget {
|
||||
final BrowseController controller;
|
||||
final JellyfinSelection selection;
|
||||
/// Nobody else watches (offline or solo): the button says Play.
|
||||
final bool solo;
|
||||
/// Puts [item] (playable) up; returns an error text, or null when sent.
|
||||
final Future<String?> Function(JellyfinSelection item) onPutUp;
|
||||
final VoidCallback onClose;
|
||||
const JellyfinDetailsPanel({
|
||||
super.key,
|
||||
required this.controller,
|
||||
required this.selection,
|
||||
required this.onPutUp,
|
||||
required this.onClose,
|
||||
this.solo = false,
|
||||
});
|
||||
|
||||
@override
|
||||
State<JellyfinDetailsPanel> createState() => _JellyfinDetailsPanelState();
|
||||
}
|
||||
|
||||
class _JellyfinDetailsPanelState extends State<JellyfinDetailsPanel> {
|
||||
String? _seasonId;
|
||||
String? _targetId;
|
||||
bool _busy = false;
|
||||
String? _error;
|
||||
DateTime? _lastTap;
|
||||
String? _lastTapId;
|
||||
|
||||
BrowseController get _c => widget.controller;
|
||||
JellyfinAccounts get _accounts => _c.accounts;
|
||||
String get _serverId => widget.selection.serverId;
|
||||
JellyfinClient? get _client => _accounts.clientFor(_serverId);
|
||||
|
||||
Future<T> _cached<T>(String key, Future<T> Function(JellyfinClient c) fn) {
|
||||
final c = _client;
|
||||
if (c == null) return Future.error(const JellyfinException('that Jellyfin server is not linked any more'));
|
||||
return _accounts.cached(_serverId, key, () => fn(c));
|
||||
}
|
||||
|
||||
Future<_Data> _load() async {
|
||||
await _accounts.ensureLoaded();
|
||||
final id = widget.selection.itemId;
|
||||
final item = await _cached('item:$id', (c) => c.item(id));
|
||||
final sid = item.kind == JellyfinItemKind.series ? item.id : item.seriesId;
|
||||
if (sid == null || (item.kind != JellyfinItemKind.series && item.kind != JellyfinItemKind.episode)) {
|
||||
return (item: item, series: null, seasons: const <JellyfinItem>[], next: null);
|
||||
}
|
||||
final r = await Future.wait<Object?>([
|
||||
item.kind == JellyfinItemKind.series ? Future.value(item) : _cached('item:$sid', (c) => c.item(sid)),
|
||||
_cached('seasons:$sid', (c) => c.seasons(sid)),
|
||||
_cached('next:$sid', (c) => c.nextUp(seriesId: sid, limit: 1, enableResumable: true))
|
||||
.then<List<JellyfinItem>?>((v) => v, onError: (_) => null),
|
||||
]);
|
||||
final next = r[2] as List<JellyfinItem>?;
|
||||
return (
|
||||
item: item,
|
||||
series: r[0] as JellyfinItem,
|
||||
seasons: r[1] as List<JellyfinItem>,
|
||||
next: next == null || next.isEmpty ? null : next.first,
|
||||
);
|
||||
}
|
||||
|
||||
Future<List<JellyfinItem>> _episodes(String seriesId, String seasonId) =>
|
||||
_cached('episodes:$seriesId:$seasonId', (c) => c.episodes(seriesId, seasonId: seasonId));
|
||||
|
||||
Future<void> _putUp(JellyfinItem target) async {
|
||||
if (_busy) return;
|
||||
setState(() {
|
||||
_busy = true;
|
||||
_error = null;
|
||||
});
|
||||
String? error;
|
||||
try {
|
||||
error = await widget.onPutUp(JellyfinSelection(_serverId, target.id, target));
|
||||
} catch (e) {
|
||||
error = '$e';
|
||||
}
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_busy = false;
|
||||
_error = error;
|
||||
});
|
||||
}
|
||||
|
||||
/// Runs a watched-state change, then lets every row reload.
|
||||
Future<void> _change(Future<void> Function(JellyfinClient c) action) async {
|
||||
final c = _client;
|
||||
if (c == null || _busy) return;
|
||||
setState(() {
|
||||
_busy = true;
|
||||
_error = null;
|
||||
});
|
||||
String? error;
|
||||
try {
|
||||
await action(c);
|
||||
} on JellyfinException catch (e) {
|
||||
if (e.isUnauthorized) _accounts.markExpired(_serverId);
|
||||
error = e.isUnauthorized ? 'Jellyfin sign-in expired' : e.message;
|
||||
} catch (e) {
|
||||
error = '$e';
|
||||
}
|
||||
_c.dataChanged();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_busy = false;
|
||||
_error = error;
|
||||
});
|
||||
}
|
||||
|
||||
void _toggleWatched(JellyfinItem t) =>
|
||||
_change((c) => t.userData.played ? c.markUnplayed(t.id) : c.markPlayed(t.id));
|
||||
|
||||
void _removeFromContinue(JellyfinItem t) => _change((c) async {
|
||||
if (t.userData.isResumable) await c.clearResumePosition(t.id);
|
||||
await _accounts.dismissContinue(_serverId, continueKey(t));
|
||||
});
|
||||
|
||||
void _tapEpisode(JellyfinItem e) {
|
||||
final now = DateTime.now();
|
||||
final again = _lastTapId == e.id && _lastTap != null && now.difference(_lastTap!) < doubleClickTime;
|
||||
_lastTap = again ? null : now;
|
||||
_lastTapId = e.id;
|
||||
setState(() => _targetId = e.id);
|
||||
if (again) _putUp(e);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
return Material(
|
||||
key: const ValueKey('jf-details'),
|
||||
color: scheme.surfaceContainerLow,
|
||||
child: SizedBox(
|
||||
width: detailsPanelWidth,
|
||||
child: ListenableBuilder(
|
||||
listenable: _c,
|
||||
builder: (context, _) => Loader<_Data>(
|
||||
key: ValueKey('details-${widget.selection.serverId}-${widget.selection.itemId}'),
|
||||
version: _c.version,
|
||||
load: _load,
|
||||
loading: Stack(children: [
|
||||
const Center(child: CircularProgressIndicator(strokeWidth: 2)),
|
||||
Positioned(right: 4, top: 4, child: _closeButton()),
|
||||
]),
|
||||
builder: (context, d) => _body(context, d),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _closeButton() => IconButton(
|
||||
key: const ValueKey('details-close'),
|
||||
tooltip: 'Close',
|
||||
onPressed: widget.onClose,
|
||||
icon: const Icon(Icons.close),
|
||||
);
|
||||
|
||||
Widget _body(BuildContext context, _Data d) {
|
||||
final series = d.series;
|
||||
if (series == null) return _view(context, d, null, null);
|
||||
final preferred = d.item.kind == JellyfinItemKind.episode ? d.item : null;
|
||||
final baseTarget = preferred ?? d.next;
|
||||
final seasons = d.seasons;
|
||||
final seasonId = (_seasonId != null && seasons.any((s) => s.id == _seasonId))
|
||||
? _seasonId
|
||||
: seasons.any((s) => s.id == baseTarget?.seasonId)
|
||||
? baseTarget!.seasonId
|
||||
: (seasons.where((s) => !s.userData.played).firstOrNull ?? seasons.firstOrNull)?.id;
|
||||
if (seasonId == null) return _view(context, d, baseTarget, const []);
|
||||
return Loader<List<JellyfinItem>>(
|
||||
key: ValueKey('details-episodes-$seasonId'),
|
||||
version: _c.version,
|
||||
load: () => _episodes(series.id, seasonId),
|
||||
loading: _view(context, d, baseTarget, null),
|
||||
builder: (context, eps) {
|
||||
final chosen = _targetId == null
|
||||
? null
|
||||
: eps.where((e) => e.id == _targetId).firstOrNull ??
|
||||
(baseTarget?.id == _targetId ? baseTarget : null);
|
||||
final fresh = baseTarget == null ? null : eps.where((e) => e.id == baseTarget.id).firstOrNull;
|
||||
final target = chosen ??
|
||||
fresh ??
|
||||
baseTarget ??
|
||||
eps.where((e) => !e.userData.played).firstOrNull ??
|
||||
eps.firstOrNull;
|
||||
return _view(context, d, target, eps, seasonId: seasonId);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// [episodes] null = loading, empty = none (or not a series).
|
||||
Widget _view(BuildContext context, _Data d, JellyfinItem? target, List<JellyfinItem>? episodes,
|
||||
{String? seasonId}) {
|
||||
final theme = Theme.of(context);
|
||||
final t = theme.textTheme;
|
||||
final scheme = theme.colorScheme;
|
||||
final client = _client;
|
||||
final shown = d.series ?? d.item;
|
||||
final isSeries = d.series != null;
|
||||
final playable = isSeries ? target : (isPlayable(d.item) ? d.item : null);
|
||||
final banner = shown.image(JellyfinImageType.backdrop) ??
|
||||
shown.image(JellyfinImageType.thumb) ??
|
||||
(d.item.kind == JellyfinItemKind.episode ? d.item.ownImage(JellyfinImageType.primary) : null) ??
|
||||
shown.image(JellyfinImageType.primary);
|
||||
final eps = shown.recursiveItemCount;
|
||||
final meta = [
|
||||
if (shown.productionYear != null) '${shown.productionYear}',
|
||||
if (isSeries && d.seasons.length > 1) '${d.seasons.length} seasons',
|
||||
if (isSeries && eps != null && eps > 0) '$eps episodes',
|
||||
if (!isSeries && shown.runTime != null) formatRuntime(shown.runTime!),
|
||||
?shown.officialRating,
|
||||
if (shown.communityRating != null) '★ ${shown.communityRating!.toStringAsFixed(1)}',
|
||||
if (!isSeries && shown.userData.played) 'Watched',
|
||||
].join(' · ');
|
||||
final verb = widget.solo ? 'Play' : 'Put it up';
|
||||
final label = playable != null && isSeries ? '$verb · ${playable.episodeLabel ?? playable.name}' : verb;
|
||||
final inContinue = playable != null &&
|
||||
(playable.userData.isResumable || _c.continueKeys.contains(continueKey(playable)));
|
||||
final left = playable == null ? null : timeLeft(playable);
|
||||
|
||||
return ListView(
|
||||
key: const ValueKey('details-list'),
|
||||
padding: EdgeInsets.zero,
|
||||
children: [
|
||||
SizedBox(
|
||||
height: 180,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
if (client != null)
|
||||
JellyfinImage(client: client, image: banner, alignment: Alignment.topCenter, icon: kindIcon(shown))
|
||||
else
|
||||
ColoredBox(color: scheme.surfaceContainerHighest),
|
||||
DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [Colors.transparent, scheme.surfaceContainerLow],
|
||||
stops: const [0.55, 1],
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(right: 4, top: 4, child: _closeButton()),
|
||||
],
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 4, 16, 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SelectableText(shown.name, style: t.titleLarge),
|
||||
if (meta.isNotEmpty) Text(meta, style: t.bodyMedium?.copyWith(color: Colors.grey)),
|
||||
const SizedBox(height: 12),
|
||||
Wrap(
|
||||
spacing: 4,
|
||||
runSpacing: 4,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
FilledButton.icon(
|
||||
key: const ValueKey('details-put-up'),
|
||||
onPressed: playable == null || _busy ? null : () => _putUp(playable),
|
||||
icon: _busy
|
||||
? const SizedBox.square(dimension: 16, child: CircularProgressIndicator(strokeWidth: 2))
|
||||
: const Icon(Icons.play_arrow),
|
||||
label: Text(label, overflow: TextOverflow.ellipsis),
|
||||
),
|
||||
if (playable != null)
|
||||
IconButton(
|
||||
key: const ValueKey('details-watched'),
|
||||
onPressed: _busy ? null : () => _toggleWatched(playable),
|
||||
tooltip: playable.userData.played ? 'Mark unwatched' : 'Mark watched',
|
||||
icon: Icon(playable.userData.played ? Icons.check_circle : Icons.check_circle_outline),
|
||||
),
|
||||
if (inContinue)
|
||||
IconButton(
|
||||
key: const ValueKey('details-remove-continue'),
|
||||
onPressed: _busy ? null : () => _removeFromContinue(playable),
|
||||
tooltip: 'Remove from Continue',
|
||||
icon: const Icon(Icons.remove_circle_outline),
|
||||
),
|
||||
if (isSeries)
|
||||
IconButton(
|
||||
key: const ValueKey('details-open-series'),
|
||||
tooltip: 'Open the series page',
|
||||
onPressed: () => _c.open(SeriesRoute(d.series!, seasonId: seasonId)),
|
||||
icon: const Icon(Icons.open_in_new),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (_error != null) ...[
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
_error!,
|
||||
key: const ValueKey('details-hint'),
|
||||
style: t.bodySmall?.copyWith(color: scheme.error),
|
||||
),
|
||||
],
|
||||
if (isSeries && playable != null) ...[
|
||||
const SizedBox(height: 10),
|
||||
Text(
|
||||
[playable.episodeLabel ?? '', playable.name].where((s) => s.isNotEmpty).join(' · '),
|
||||
style: t.titleSmall,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
if (left != null) ...[
|
||||
const SizedBox(height: 6),
|
||||
Row(children: [
|
||||
SizedBox(width: 160, child: LinearProgressIndicator(value: progressOf(playable!), minHeight: 3)),
|
||||
const SizedBox(width: 8),
|
||||
Text(left, style: t.bodySmall?.copyWith(color: Colors.grey)),
|
||||
]),
|
||||
],
|
||||
if (shown.overview != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(shown.overview!, style: t.bodyMedium),
|
||||
],
|
||||
if (!isSeries) ...[
|
||||
const SizedBox(height: 12),
|
||||
_Tracks(item: d.item),
|
||||
],
|
||||
if (isSeries) ...[
|
||||
const SizedBox(height: 16),
|
||||
_episodesHeader(context, d, seasonId),
|
||||
if (episodes == null)
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(24),
|
||||
child: Center(child: CircularProgressIndicator(strokeWidth: 2)),
|
||||
)
|
||||
else if (episodes.isEmpty)
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 12),
|
||||
child: Text('No episodes', style: TextStyle(color: Colors.grey)),
|
||||
)
|
||||
else
|
||||
for (final e in episodes) _episodeRow(context, e, target?.id == e.id),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _episodesHeader(BuildContext context, _Data d, String? seasonId) {
|
||||
final seasons = d.seasons;
|
||||
return Row(
|
||||
children: [
|
||||
const Expanded(child: Text('Episodes', style: TextStyle(fontWeight: FontWeight.w600))),
|
||||
if (seasons.length > 1)
|
||||
DropdownButton<String>(
|
||||
key: const ValueKey('details-season'),
|
||||
value: seasonId,
|
||||
isDense: true,
|
||||
underline: const SizedBox.shrink(),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
items: [
|
||||
for (final s in seasons)
|
||||
DropdownMenuItem(value: s.id, child: Text(s.name, overflow: TextOverflow.ellipsis)),
|
||||
],
|
||||
onChanged: (id) => setState(() => _seasonId = id),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _episodeRow(BuildContext context, JellyfinItem e, bool isTarget) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final t = Theme.of(context).textTheme;
|
||||
final client = _client;
|
||||
final sub = e.userData.played ? 'watched' : timeLeft(e) ?? (e.runTime == null ? '' : formatRuntime(e.runTime!));
|
||||
return Material(
|
||||
color: isTarget ? scheme.primary.withValues(alpha: 0.10) : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: InkWell(
|
||||
key: ValueKey('details-episode-${e.id}'),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
onTap: () => _tapEpisode(e),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 5, horizontal: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: SizedBox(
|
||||
width: 64,
|
||||
height: 36,
|
||||
child: client == null
|
||||
? ColoredBox(color: scheme.surfaceContainerHighest)
|
||||
: JellyfinImage(
|
||||
client: client,
|
||||
image: e.ownImage(JellyfinImageType.primary) ?? e.image(JellyfinImageType.thumb),
|
||||
icon: Icons.tv_outlined,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
e.indexNumber == null ? e.name : 'E${e.indexNumber} · ${e.name}',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: t.bodyMedium?.copyWith(
|
||||
color: isTarget ? scheme.primary : e.userData.played ? Colors.grey : null,
|
||||
fontWeight: isTarget ? FontWeight.w600 : null,
|
||||
),
|
||||
),
|
||||
if (sub.isNotEmpty) Text(sub, style: t.bodySmall?.copyWith(color: Colors.grey)),
|
||||
],
|
||||
),
|
||||
),
|
||||
AnimatedOpacity(
|
||||
opacity: isTarget ? 1 : 0,
|
||||
duration: easeTime,
|
||||
child: Icon(Icons.play_circle, color: scheme.primary),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// What the file holds, one line per track kind.
|
||||
class _Tracks extends StatelessWidget {
|
||||
final JellyfinItem item;
|
||||
const _Tracks({required this.item});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = Theme.of(context).textTheme;
|
||||
final source = item.mediaSources.firstOrNull;
|
||||
final streams = source?.mediaStreams.isNotEmpty == true ? source!.mediaStreams : item.mediaStreams;
|
||||
if (streams.isEmpty) return const SizedBox.shrink();
|
||||
String names(JellyfinStreamType type) => streams
|
||||
.where((s) => s.type == type)
|
||||
.map((s) => s.displayTitle ?? [s.language, s.codec].whereType<String>().join(' '))
|
||||
.join(' · ');
|
||||
final rows = [
|
||||
('Video', names(JellyfinStreamType.video)),
|
||||
('Audio', names(JellyfinStreamType.audio)),
|
||||
('Subtitles', names(JellyfinStreamType.subtitle)),
|
||||
if (source?.container != null || source?.size != null)
|
||||
('File', [
|
||||
?source?.container,
|
||||
if (source?.size != null) '${(source!.size! / (1 << 30)).toStringAsFixed(2)} GB',
|
||||
].join(' · ')),
|
||||
].where((r) => r.$2.isNotEmpty);
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
for (final (label, value) in rows)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 6),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(width: 76, child: Text(label, style: t.bodySmall?.copyWith(color: Colors.grey))),
|
||||
Expanded(child: Text(value, style: t.bodySmall)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,9 @@ bool isPlayable(JellyfinItem item) => switch (item.kind) {
|
||||
_ => !item.isFolder && item.mediaType == 'Video',
|
||||
};
|
||||
|
||||
/// Cards that select (details panel) on click: playable items and series.
|
||||
bool isSelectable(JellyfinItem item) => isPlayable(item) || item.kind == JellyfinItemKind.series;
|
||||
|
||||
/// Line under a card title: "S2E3 · 12 min left", "2019 · 24 eps", ...
|
||||
String cardSubtitle(JellyfinItem item, {bool withSeries = false}) {
|
||||
final parts = <String>[];
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:signals/signals_flutter.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
|
||||
import '../state/settings.dart';
|
||||
import 'browse_scope.dart';
|
||||
import 'cards.dart';
|
||||
import 'home_rows.dart';
|
||||
|
||||
/// Libraries that hold videos; music, books, photos and playlists are left
|
||||
/// out of the browser.
|
||||
@@ -19,9 +22,15 @@ IconData libraryIcon(JellyfinItem view) => switch (view.collectionType) {
|
||||
_ => Icons.folder_outlined,
|
||||
};
|
||||
|
||||
/// Start page: library shortcuts, then Continue watching, Next up and the
|
||||
/// latest additions of each library. Each row loads on its own, so a slow
|
||||
/// library does not hold up the rest.
|
||||
/// The video libraries of the scope's server (cached).
|
||||
Future<List<JellyfinItem>> videoLibraries(BrowseScope scope) =>
|
||||
scope.load('views', (c) => c.userViews()).then((all) => all.where(isVideoLibrary).toList());
|
||||
|
||||
/// Start page: (library shortcuts,) one Continue row (resume points and
|
||||
/// next up merged per series, see [mergeContinue]), then the newest
|
||||
/// additions of each library as series cards ([latestCards]). Each row
|
||||
/// loads on its own, so a slow library does not hold up the rest; rows
|
||||
/// reload when watched state changes ([BrowseController.dataChanged]).
|
||||
class JellyfinHomePage extends StatelessWidget {
|
||||
const JellyfinHomePage({super.key});
|
||||
|
||||
@@ -30,58 +39,49 @@ class JellyfinHomePage extends StatelessWidget {
|
||||
final scope = BrowseScope.of(context);
|
||||
return Loader<List<JellyfinItem>>(
|
||||
key: ValueKey('views-${scope.account.serverId}'),
|
||||
load: () => scope.load('views', (c) => c.userViews()),
|
||||
builder: (context, all) {
|
||||
final views = all.where(isVideoLibrary).toList();
|
||||
load: () => videoLibraries(scope),
|
||||
builder: (context, views) => ListenableBuilder(
|
||||
listenable: scope.controller,
|
||||
builder: (context, _) {
|
||||
final v = scope.controller.version;
|
||||
return ListView(
|
||||
key: const PageStorageKey('jf-home'),
|
||||
padding: const EdgeInsets.fromLTRB(0, 4, 0, 24),
|
||||
children: [
|
||||
_LibraryStrip(views: views),
|
||||
if (scope.libraryStrip) ...[
|
||||
LibraryStrip(views: views),
|
||||
const SizedBox(height: 20),
|
||||
_Section(
|
||||
key: const ValueKey('row-resume'),
|
||||
title: 'Continue watching',
|
||||
shape: CardShape.thumb,
|
||||
seriesFirst: true,
|
||||
load: () => scope.load('resume', (c) => c.resume(limit: 30)),
|
||||
),
|
||||
_Section(
|
||||
key: const ValueKey('row-nextup'),
|
||||
title: 'Next up',
|
||||
shape: CardShape.thumb,
|
||||
seriesFirst: true,
|
||||
load: () => scope.load('nextup', (c) => c.nextUp(limit: 30)),
|
||||
),
|
||||
for (final v in views)
|
||||
_Section(
|
||||
key: ValueKey('row-latest-${v.id}'),
|
||||
title: 'Latest in ${v.name}',
|
||||
shape: v.collectionType == 'homevideos' ? CardShape.thumb : CardShape.poster,
|
||||
load: () => scope.load('latest:${v.id}', (c) => c.latest(v.id, limit: 30)),
|
||||
trailing: TextButton(
|
||||
onPressed: () => scope.onOpen(LibraryRoute(v)),
|
||||
child: const Text('Show all'),
|
||||
),
|
||||
),
|
||||
] else if (views.isEmpty)
|
||||
const _NoLibraries(),
|
||||
_ContinueRow(version: v),
|
||||
for (final view in views) _LatestRow(key: ValueKey('row-latest-${view.id}'), view: view, version: v),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LibraryStrip extends StatelessWidget {
|
||||
class _NoLibraries extends StatelessWidget {
|
||||
const _NoLibraries();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => const Padding(
|
||||
padding: EdgeInsets.only(bottom: 20),
|
||||
child: Text('No video libraries on this server for your user.', style: TextStyle(color: Colors.grey)),
|
||||
);
|
||||
}
|
||||
|
||||
/// The libraries as chips; a click opens one.
|
||||
class LibraryStrip extends StatelessWidget {
|
||||
final List<JellyfinItem> views;
|
||||
const _LibraryStrip({required this.views});
|
||||
const LibraryStrip({super.key, required this.views});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scope = BrowseScope.of(context);
|
||||
if (views.isEmpty) {
|
||||
return const Text('No video libraries on this server for your user.',
|
||||
style: TextStyle(color: Colors.grey));
|
||||
}
|
||||
if (views.isEmpty) return const _NoLibraries();
|
||||
return Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
@@ -92,47 +92,117 @@ class _LibraryStrip extends StatelessWidget {
|
||||
avatar: Icon(libraryIcon(v), size: 18),
|
||||
label: Text(v.name),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
onPressed: () => scope.onOpen(LibraryRoute(v)),
|
||||
onPressed: () => scope.controller.openTop(LibraryRoute(v)),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Section extends StatelessWidget {
|
||||
final String title;
|
||||
final CardShape shape;
|
||||
final bool seriesFirst;
|
||||
final Future<List<JellyfinItem>> Function() load;
|
||||
final Widget? trailing;
|
||||
const _Section({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.shape,
|
||||
required this.load,
|
||||
this.seriesFirst = false,
|
||||
this.trailing,
|
||||
});
|
||||
class _ContinueRow extends StatelessWidget {
|
||||
final int version;
|
||||
const _ContinueRow({required this.version});
|
||||
|
||||
static const title = 'Continue';
|
||||
|
||||
Future<List<JellyfinItem>> _load(BrowseScope scope) async {
|
||||
final lists = await Future.wait([
|
||||
scope.load('resume', (c) => c.resume(limit: 40)),
|
||||
scope.load('nextup', (c) => c.nextUp(limit: 40)),
|
||||
// Only for ordering: a failure leaves the row as it is.
|
||||
scope
|
||||
.load('played', (c) => c.recentlyPlayed(limit: 150))
|
||||
.catchError((Object _) => const <JellyfinItem>[]),
|
||||
]);
|
||||
return mergeContinue(
|
||||
resume: lists[0],
|
||||
nextUp: lists[1],
|
||||
recentlyPlayed: lists[2],
|
||||
dismissed: scope.accounts.continueDismissed(scope.account.serverId),
|
||||
now: scope.controller.now(),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scope = BrowseScope.of(context);
|
||||
return Loader<List<JellyfinItem>>(
|
||||
load: load,
|
||||
version: version,
|
||||
load: () => _load(scope),
|
||||
showError: false,
|
||||
loading: _RowSkeleton(title: title, shape: shape),
|
||||
builder: (context, items) => items.isEmpty
|
||||
? const SizedBox.shrink()
|
||||
: Padding(
|
||||
loading: const _RowSkeleton(title: title, shape: CardShape.thumb),
|
||||
builder: (context, items) {
|
||||
scope.controller.continueKeys = {for (final i in items) continueKey(i)};
|
||||
if (items.isEmpty) return const SizedBox.shrink();
|
||||
return Padding(
|
||||
key: const ValueKey('row-continue'),
|
||||
padding: const EdgeInsets.only(bottom: 24),
|
||||
child: MediaRow(
|
||||
title: title,
|
||||
items: items,
|
||||
entries: cardEntries(items),
|
||||
shape: CardShape.thumb,
|
||||
seriesFirst: true,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LatestRow extends StatelessWidget {
|
||||
final JellyfinItem view;
|
||||
final int version;
|
||||
const _LatestRow({super.key, required this.view, required this.version});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scope = BrowseScope.of(context);
|
||||
final title = 'Latest in ${view.name}';
|
||||
final shape = view.collectionType == 'homevideos' ? CardShape.thumb : CardShape.poster;
|
||||
return Loader<List<JellyfinItem>>(
|
||||
version: version,
|
||||
load: () => scope.load('latest:${view.id}', (c) => c.latest(view.id, limit: 100, groupItems: false)),
|
||||
showError: false,
|
||||
loading: _RowSkeleton(title: title, shape: shape),
|
||||
builder: (context, items) => SignalBuilder(builder: (context) {
|
||||
final hide = AppSettings.instance.hideReReleases.value;
|
||||
final cards = latestCards(items, hideReReleases: hide);
|
||||
if (cards.isEmpty) return const SizedBox.shrink();
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 24),
|
||||
child: MediaRow(
|
||||
key: ValueKey('latest-${view.id}'),
|
||||
title: title,
|
||||
entries: [for (final c in cards) (item: c.item, display: c.display, subtitle: c.subtitle)],
|
||||
shape: shape,
|
||||
seriesFirst: seriesFirst,
|
||||
trailing: trailing,
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextButton(
|
||||
key: ValueKey('show-all-${view.id}'),
|
||||
// Newest first, like the row.
|
||||
onPressed: () => scope.onOpen(LibraryRoute(view, sort: 'added')),
|
||||
child: const Text('Show all'),
|
||||
),
|
||||
PopupMenuButton<void>(
|
||||
key: ValueKey('latest-menu-${view.id}'),
|
||||
tooltip: 'Row options',
|
||||
icon: const Icon(Icons.more_vert, size: 18),
|
||||
itemBuilder: (context) => [
|
||||
CheckedPopupMenuItem(
|
||||
key: const ValueKey('hide-rereleases'),
|
||||
checked: hide,
|
||||
onTap: () => AppSettings.instance.setHideReReleases(!hide),
|
||||
child: const Text('Hide re-releases in Latest'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
|
||||
import 'format.dart';
|
||||
|
||||
// What the Jellyfin home rows show, as pure functions over the API lists
|
||||
// (tested without widgets).
|
||||
|
||||
/// Series not watched for longer than this leave the Continue row, unless
|
||||
/// nothing newer is left.
|
||||
const continueMaxAge = Duration(days: 60);
|
||||
|
||||
/// Added this long after it first aired: a re-release ([isReRelease]).
|
||||
const reReleaseAge = Duration(days: 30);
|
||||
|
||||
/// One Continue card per series: an episode's series, else the item itself.
|
||||
String continueKey(JellyfinItem item) => item.seriesId ?? item.id;
|
||||
|
||||
DateTime? _later(DateTime? a, DateTime? b) =>
|
||||
a == null ? b : b == null || !b.isAfter(a) ? a : b;
|
||||
|
||||
/// The Continue row: Continue watching ([resume]) and Next up ([nextUp])
|
||||
/// merged into one card per series, the series watched most recently first.
|
||||
/// A series shows its resume item when that was the last thing played in
|
||||
/// it, else its next-up episode. When a series was last played comes from
|
||||
/// the resume points and from [recentlyPlayed] (finished episodes, which
|
||||
/// next-up items do not carry). Series untouched for [maxAge] are left out
|
||||
/// unless nothing else is left; [dismissed] (key -> when) hides a series
|
||||
/// until it is played again after that.
|
||||
List<JellyfinItem> mergeContinue({
|
||||
required List<JellyfinItem> resume,
|
||||
required List<JellyfinItem> nextUp,
|
||||
List<JellyfinItem> recentlyPlayed = const [],
|
||||
Map<String, DateTime> dismissed = const {},
|
||||
required DateTime now,
|
||||
Duration maxAge = continueMaxAge,
|
||||
}) {
|
||||
final finished = <String, DateTime>{};
|
||||
for (final p in recentlyPlayed) {
|
||||
final at = p.userData.lastPlayedDate;
|
||||
if (at != null) finished[continueKey(p)] = _later(finished[continueKey(p)], at)!;
|
||||
}
|
||||
final resumeBy = <String, JellyfinItem>{};
|
||||
final nextBy = <String, JellyfinItem>{};
|
||||
final order = <String>[];
|
||||
for (final r in resume) {
|
||||
final k = continueKey(r);
|
||||
final had = resumeBy[k];
|
||||
if (had == null) order.add(k);
|
||||
final at = r.userData.lastPlayedDate;
|
||||
if (had == null || (at != null && at.isAfter(had.userData.lastPlayedDate ?? DateTime(0)))) {
|
||||
resumeBy[k] = r;
|
||||
}
|
||||
}
|
||||
for (final n in nextUp) {
|
||||
final k = continueKey(n);
|
||||
if (nextBy.containsKey(k)) continue;
|
||||
nextBy[k] = n;
|
||||
if (!resumeBy.containsKey(k)) order.add(k);
|
||||
}
|
||||
|
||||
final cards = <({JellyfinItem item, DateTime? at, int index})>[];
|
||||
for (final (i, k) in order.indexed) {
|
||||
final r = resumeBy[k], n = nextBy[k], done = finished[k];
|
||||
final resumedAt = r?.userData.lastPlayedDate;
|
||||
final JellyfinItem item;
|
||||
if (r != null && (n == null || (resumedAt != null && (done == null || !resumedAt.isBefore(done))))) {
|
||||
item = r;
|
||||
} else {
|
||||
item = n!;
|
||||
}
|
||||
final at = _later(resumedAt, done);
|
||||
final hiddenAt = dismissed[k];
|
||||
if (hiddenAt != null && (at == null || !at.isAfter(hiddenAt))) continue;
|
||||
cards.add((item: item, at: at, index: i));
|
||||
}
|
||||
cards.sort((a, b) {
|
||||
if (a.at == null || b.at == null) {
|
||||
return a.at == b.at ? a.index.compareTo(b.index) : (a.at == null ? 1 : -1);
|
||||
}
|
||||
final c = b.at!.compareTo(a.at!);
|
||||
return c != 0 ? c : a.index.compareTo(b.index);
|
||||
});
|
||||
final fresh = [for (final c in cards) if (c.at != null && now.difference(c.at!) <= maxAge) c.item];
|
||||
return fresh.isNotEmpty ? fresh : [for (final c in cards) c.item];
|
||||
}
|
||||
|
||||
/// Added to the library long ([age]) after it first aired: a re-upload or
|
||||
/// an old season added late, not news. Unknown dates are never re-releases.
|
||||
bool isReRelease(JellyfinItem item, {Duration age = reReleaseAge}) {
|
||||
final aired = item.premiereDate, added = item.dateCreated;
|
||||
return aired != null && added != null && added.difference(aired) > age;
|
||||
}
|
||||
|
||||
/// One card of a "Latest in X" row: [item] is what selecting or putting it
|
||||
/// up acts on, [display] gives the picture and title, [subtitle] the line
|
||||
/// under it.
|
||||
class LatestCard {
|
||||
final JellyfinItem item;
|
||||
final JellyfinItem display;
|
||||
final String subtitle;
|
||||
const LatestCard(this.item, this.display, this.subtitle);
|
||||
}
|
||||
|
||||
/// A series to show for [episode]: its id, name and poster.
|
||||
JellyfinItem seriesOf(JellyfinItem episode) => JellyfinItem(
|
||||
id: episode.seriesId!,
|
||||
name: episode.seriesName ?? episode.name,
|
||||
type: JellyfinItemType.series,
|
||||
isFolder: true,
|
||||
imageTags: {
|
||||
if (episode.seriesPrimaryImageTag != null)
|
||||
JellyfinImageType.primary.apiName: episode.seriesPrimaryImageTag!,
|
||||
},
|
||||
parentBackdropItemId: episode.parentBackdropItemId,
|
||||
parentBackdropImageTags: episode.parentBackdropImageTags,
|
||||
);
|
||||
|
||||
/// "Latest in X" from the library's newest items (ungrouped, newest
|
||||
/// first): episodes become one card per series (its poster and name,
|
||||
/// "S1E1" for a single new episode, else "N new"), in order of the newest
|
||||
/// episode; movies and other items stay as they are. With [hideReReleases]
|
||||
/// items that [isReRelease] are left out first.
|
||||
List<LatestCard> latestCards(List<JellyfinItem> items, {bool hideReReleases = true}) {
|
||||
final kept = hideReReleases ? items.where((i) => !isReRelease(i)) : items;
|
||||
final groups = <String, List<JellyfinItem>>{};
|
||||
final out = <Object>[]; // a JellyfinItem, or a series id (group)
|
||||
for (final i in kept) {
|
||||
final sid = i.seriesId;
|
||||
if (i.kind == JellyfinItemKind.episode && sid != null) {
|
||||
final g = groups[sid];
|
||||
if (g == null) {
|
||||
groups[sid] = [i];
|
||||
out.add(sid);
|
||||
} else {
|
||||
g.add(i);
|
||||
}
|
||||
} else {
|
||||
out.add(i);
|
||||
}
|
||||
}
|
||||
return [
|
||||
for (final o in out)
|
||||
if (o is JellyfinItem)
|
||||
LatestCard(
|
||||
o,
|
||||
o,
|
||||
o.kind == JellyfinItemKind.series && (o.childCount ?? 0) > 1
|
||||
? '${o.childCount} new'
|
||||
: cardSubtitle(o),
|
||||
)
|
||||
else
|
||||
_seriesCard(groups[o]!),
|
||||
];
|
||||
}
|
||||
|
||||
LatestCard _seriesCard(List<JellyfinItem> eps) {
|
||||
final series = seriesOf(eps.first);
|
||||
if (eps.length == 1) {
|
||||
final e = eps.single;
|
||||
return LatestCard(e, series, e.episodeLabel ?? e.name);
|
||||
}
|
||||
return LatestCard(series, series, '${eps.length} new');
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
|
||||
import 'browse_scope.dart';
|
||||
import 'format.dart';
|
||||
import 'images.dart';
|
||||
|
||||
/// Side pane on wide windows: details of whatever card is hovered or
|
||||
/// focused, so browsing needs no extra clicks. Pick / Open act on it.
|
||||
class JellyfinInspector extends StatelessWidget {
|
||||
final ValueListenable<JellyfinItem?> item;
|
||||
const JellyfinInspector({super.key, required this.item});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
return DecoratedBox(
|
||||
key: const ValueKey('jf-inspector'),
|
||||
decoration: BoxDecoration(
|
||||
color: scheme.surfaceContainerLow,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: scheme.outlineVariant.withValues(alpha: 0.4)),
|
||||
),
|
||||
child: ValueListenableBuilder<JellyfinItem?>(
|
||||
valueListenable: item,
|
||||
builder: (context, it, _) => it == null
|
||||
? const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(24),
|
||||
child: Text('Hover or focus a title to see its details here.',
|
||||
textAlign: TextAlign.center, style: TextStyle(color: Colors.grey)),
|
||||
),
|
||||
)
|
||||
: _Body(item: it),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Body extends StatelessWidget {
|
||||
final JellyfinItem item;
|
||||
const _Body({required this.item});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scope = BrowseScope.of(context);
|
||||
final t = Theme.of(context).textTheme;
|
||||
final playable = isPlayable(item);
|
||||
final selected = scope.selectedItemId == item.id;
|
||||
final episode = item.kind == JellyfinItemKind.episode;
|
||||
final art = episode
|
||||
? item.ownImage(JellyfinImageType.primary) ?? item.image(JellyfinImageType.backdrop)
|
||||
: item.image(JellyfinImageType.backdrop) ?? item.image(JellyfinImageType.thumb);
|
||||
final meta = [
|
||||
if (item.productionYear != null) '${item.productionYear}',
|
||||
if (playable && item.runTime != null) formatRuntime(item.runTime!),
|
||||
if (item.officialRating != null) item.officialRating!,
|
||||
if (item.communityRating != null) '★ ${item.communityRating!.toStringAsFixed(1)}',
|
||||
if (!playable && (item.userData.unplayedItemCount ?? 0) > 0)
|
||||
'${item.userData.unplayedItemCount} unwatched',
|
||||
if (item.userData.played) 'Watched',
|
||||
].join(' · ');
|
||||
return ListView(
|
||||
padding: EdgeInsets.zero,
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(12)),
|
||||
child: AspectRatio(
|
||||
aspectRatio: 16 / 9,
|
||||
child: JellyfinImage(client: scope.client, image: art),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (episode && item.seriesName != null)
|
||||
Text(item.seriesName!, style: t.labelLarge?.copyWith(color: Colors.grey)),
|
||||
Text(
|
||||
episode && item.episodeLabel != null ? '${item.episodeLabel} ${item.name}' : item.name,
|
||||
style: t.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(meta, style: t.bodySmall?.copyWith(color: Colors.grey)),
|
||||
if (timeLeft(item) != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
LinearProgressIndicator(value: progressOf(item), minHeight: 3),
|
||||
const SizedBox(height: 4),
|
||||
Text(timeLeft(item)!, style: t.bodySmall),
|
||||
],
|
||||
const SizedBox(height: 12),
|
||||
Wrap(spacing: 8, runSpacing: 8, children: [
|
||||
if (playable)
|
||||
FilledButton.icon(
|
||||
onPressed: () => scope.onPick(item),
|
||||
icon: Icon(selected ? Icons.check : Icons.play_arrow, size: 18),
|
||||
label: Text(selected ? 'Picked' : 'Pick'),
|
||||
)
|
||||
else
|
||||
FilledButton.tonalIcon(
|
||||
onPressed: () => scope.activate(item),
|
||||
icon: const Icon(Icons.arrow_forward, size: 18),
|
||||
label: const Text('Open'),
|
||||
),
|
||||
if (playable)
|
||||
OutlinedButton(
|
||||
onPressed: () => scope.onOpen(DetailRoute(item)),
|
||||
child: const Text('Details'),
|
||||
),
|
||||
if (episode && item.seriesId != null)
|
||||
OutlinedButton(
|
||||
onPressed: () => scope.openSeriesOf(item),
|
||||
child: const Text('Series'),
|
||||
),
|
||||
]),
|
||||
if (item.overview != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(item.overview!, style: t.bodyMedium),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -39,15 +39,18 @@ const libraryPageSize = 100;
|
||||
/// pages load as they come near the viewport.
|
||||
class JellyfinLibraryPage extends StatefulWidget {
|
||||
final JellyfinItem folder;
|
||||
const JellyfinLibraryPage({super.key, required this.folder});
|
||||
/// A [LibrarySort] name to start with (in its natural direction).
|
||||
final String? sort;
|
||||
const JellyfinLibraryPage({super.key, required this.folder, this.sort});
|
||||
|
||||
@override
|
||||
State<JellyfinLibraryPage> createState() => _JellyfinLibraryPageState();
|
||||
}
|
||||
|
||||
class _JellyfinLibraryPageState extends State<JellyfinLibraryPage> {
|
||||
LibrarySort _sort = LibrarySort.name;
|
||||
bool _descending = false;
|
||||
late LibrarySort _sort =
|
||||
LibrarySort.values.where((s) => s.name == widget.sort).firstOrNull ?? LibrarySort.name;
|
||||
late bool _descending = _sort.descending;
|
||||
bool _unplayed = false;
|
||||
bool _favorites = false;
|
||||
|
||||
|
||||
@@ -1,31 +1,35 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../screens/home/picked_media.dart';
|
||||
import '../screens/home/ui.dart';
|
||||
import 'accounts.dart';
|
||||
import 'browse_scope.dart';
|
||||
import 'browser.dart';
|
||||
import 'sign_in_panel.dart';
|
||||
|
||||
/// The media picker's Jellyfin source: the library browser once a server
|
||||
/// is linked, else an inline add-server panel.
|
||||
/// The Library's Jellyfin source: the library browser once a server is
|
||||
/// linked, else [empty] (default: an inline add-server panel).
|
||||
class JellyfinPickerSource extends StatefulWidget {
|
||||
final JellyfinAccounts accounts;
|
||||
final JellyfinMedia? selected;
|
||||
final ValueChanged<JellyfinMedia> onPick;
|
||||
final BrowseController? controller;
|
||||
final JellyfinSelection? selected;
|
||||
final ValueChanged<JellyfinSelection> onSelect;
|
||||
final ValueChanged<JellyfinSelection> onPutUp;
|
||||
final VoidCallback? onOpenIntegrations;
|
||||
final VoidCallback? onToggleExpanded;
|
||||
final bool expanded;
|
||||
final BrowseStart? start;
|
||||
final bool libraryStrip;
|
||||
/// Shown while no server is linked.
|
||||
final WidgetBuilder? empty;
|
||||
const JellyfinPickerSource({
|
||||
super.key,
|
||||
this.start,
|
||||
required this.accounts,
|
||||
required this.onPick,
|
||||
required this.onSelect,
|
||||
required this.onPutUp,
|
||||
this.controller,
|
||||
this.selected,
|
||||
this.onOpenIntegrations,
|
||||
this.onToggleExpanded,
|
||||
this.expanded = false,
|
||||
this.libraryStrip = true,
|
||||
this.empty,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -48,16 +52,18 @@ class _JellyfinPickerSourceState extends State<JellyfinPickerSource> {
|
||||
if (!a.loaded) {
|
||||
return const Center(child: CircularProgressIndicator(strokeWidth: 2));
|
||||
}
|
||||
if (a.isEmpty && widget.empty != null) return widget.empty!(context);
|
||||
if (a.isEmpty) return _LinkFirst(accounts: a, onOpenIntegrations: widget.onOpenIntegrations);
|
||||
return JellyfinBrowser(
|
||||
key: const ValueKey('media-jellyfin'),
|
||||
accounts: a,
|
||||
controller: widget.controller,
|
||||
selected: widget.selected,
|
||||
onPick: widget.onPick,
|
||||
onSelect: widget.onSelect,
|
||||
onPutUp: widget.onPutUp,
|
||||
onOpenIntegrations: widget.onOpenIntegrations,
|
||||
onToggleExpanded: widget.onToggleExpanded,
|
||||
expanded: widget.expanded,
|
||||
start: widget.start,
|
||||
libraryStrip: widget.libraryStrip,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
|
||||
import 'browse_scope.dart';
|
||||
import 'cards.dart' show doubleClickTime;
|
||||
import 'format.dart';
|
||||
import 'images.dart';
|
||||
|
||||
@@ -60,6 +60,7 @@ class _JellyfinSeriesPageState extends State<JellyfinSeriesPage> {
|
||||
final scope = BrowseScope.of(context);
|
||||
return Loader<_SeriesData>(
|
||||
key: ValueKey('series-${widget.series.id}-${scope.account.serverId}'),
|
||||
version: scope.controller.version,
|
||||
load: () => _load(scope),
|
||||
builder: (context, d) {
|
||||
final seasonId = (_seasonId != null && d.seasons.any((s) => s.id == _seasonId))
|
||||
@@ -99,6 +100,7 @@ class _JellyfinSeriesPageState extends State<JellyfinSeriesPage> {
|
||||
key: ValueKey('episodes-$seasonId'),
|
||||
seriesId: widget.series.id,
|
||||
seasonId: seasonId,
|
||||
version: scope.controller.version,
|
||||
),
|
||||
const SliverToBoxAdapter(child: SizedBox(height: 24)),
|
||||
],
|
||||
@@ -218,7 +220,7 @@ class _SeriesHeaderState extends State<_SeriesHeader> {
|
||||
Wrap(spacing: 8, runSpacing: 8, children: [
|
||||
FilledButton.icon(
|
||||
key: const ValueKey('series-next'),
|
||||
onPressed: next == null ? null : () => scope.onPick(next),
|
||||
onPressed: next == null ? null : () => scope.onSelect(next),
|
||||
icon: Icon(selectedNext ? Icons.check : Icons.play_arrow),
|
||||
label: Text(nextLabel, overflow: TextOverflow.ellipsis),
|
||||
),
|
||||
@@ -242,7 +244,9 @@ class _SeriesHeaderState extends State<_SeriesHeader> {
|
||||
class _EpisodeList extends StatefulWidget {
|
||||
final String seriesId;
|
||||
final String seasonId;
|
||||
const _EpisodeList({super.key, required this.seriesId, required this.seasonId});
|
||||
final int version;
|
||||
const _EpisodeList(
|
||||
{super.key, required this.seriesId, required this.seasonId, required this.version});
|
||||
|
||||
@override
|
||||
State<_EpisodeList> createState() => _EpisodeListState();
|
||||
@@ -250,6 +254,13 @@ class _EpisodeList extends StatefulWidget {
|
||||
|
||||
class _EpisodeListState extends State<_EpisodeList> {
|
||||
Future<List<JellyfinItem>>? _future;
|
||||
List<JellyfinItem>? _last;
|
||||
|
||||
@override
|
||||
void didUpdateWidget(_EpisodeList old) {
|
||||
super.didUpdateWidget(old);
|
||||
if (old.version != widget.version) _future = null;
|
||||
}
|
||||
|
||||
void _load(BrowseScope scope) => _future = scope.load('episodes:${widget.seriesId}:${widget.seasonId}',
|
||||
(c) => c.episodes(widget.seriesId, seasonId: widget.seasonId));
|
||||
@@ -265,7 +276,8 @@ class _EpisodeListState extends State<_EpisodeList> {
|
||||
return SliverToBoxAdapter(
|
||||
child: LoadError(error: snap.error!, onRetry: () => setState(() => _load(scope))));
|
||||
}
|
||||
final eps = snap.data;
|
||||
final eps = snap.data ?? _last;
|
||||
if (snap.data != null) _last = snap.data;
|
||||
if (eps == null) {
|
||||
return const SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
@@ -302,7 +314,8 @@ class _EpisodeListState extends State<_EpisodeList> {
|
||||
const episodeTileHeight = 6 + 176 * 9 / 16 + 6 + 2;
|
||||
|
||||
/// One episode: thumbnail with progress, number + title, runtime / air
|
||||
/// date, two lines of overview. Click picks it; I opens details.
|
||||
/// date, two lines of overview. Click selects it (details panel), a second
|
||||
/// click soon after puts it up.
|
||||
class EpisodeTile extends StatefulWidget {
|
||||
final JellyfinItem episode;
|
||||
const EpisodeTile({super.key, required this.episode});
|
||||
@@ -314,6 +327,19 @@ class EpisodeTile extends StatefulWidget {
|
||||
class _EpisodeTileState extends State<EpisodeTile> {
|
||||
bool _focus = false;
|
||||
bool _hover = false;
|
||||
DateTime? _lastTap;
|
||||
|
||||
void _tap(BrowseScope scope) {
|
||||
final now = DateTime.now();
|
||||
final last = _lastTap;
|
||||
_lastTap = now;
|
||||
if (last != null && now.difference(last) < doubleClickTime) {
|
||||
_lastTap = null;
|
||||
scope.onPutUp(widget.episode);
|
||||
} else {
|
||||
scope.onSelect(widget.episode);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -332,17 +358,7 @@ class _EpisodeTileState extends State<EpisodeTile> {
|
||||
if (e.premiereDate != null)
|
||||
'${e.premiereDate!.year}-${e.premiereDate!.month.toString().padLeft(2, '0')}-${e.premiereDate!.day.toString().padLeft(2, '0')}',
|
||||
].join(' · ');
|
||||
return Focus(
|
||||
canRequestFocus: false,
|
||||
skipTraversal: true,
|
||||
onKeyEvent: (_, k) {
|
||||
if (k is KeyDownEvent && k.logicalKey == LogicalKeyboardKey.keyI) {
|
||||
scope.onOpen(DetailRoute(e));
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
return KeyEventResult.ignored;
|
||||
},
|
||||
child: Material(
|
||||
return Material(
|
||||
color: selected
|
||||
? scheme.primaryContainer.withValues(alpha: 0.35)
|
||||
: _hover || _focus
|
||||
@@ -358,15 +374,9 @@ class _EpisodeTileState extends State<EpisodeTile> {
|
||||
child: InkWell(
|
||||
key: ValueKey('episode-${e.id}'),
|
||||
focusColor: Colors.transparent,
|
||||
onTap: () => scope.onPick(e),
|
||||
onHover: (h) {
|
||||
setState(() => _hover = h);
|
||||
if (h) scope.onPeek(e);
|
||||
},
|
||||
onFocusChange: (f) {
|
||||
setState(() => _focus = f);
|
||||
if (f) scope.onPeek(e);
|
||||
},
|
||||
onTap: () => _tap(scope),
|
||||
onHover: (h) => setState(() => _hover = h),
|
||||
onFocusChange: (f) => setState(() => _focus = f),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(6),
|
||||
child: Row(
|
||||
@@ -414,9 +424,7 @@ class _EpisodeTileState extends State<EpisodeTile> {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
Text(
|
||||
e.indexNumber == null ? e.name : '${e.indexNumber}. ${e.name}',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
@@ -424,17 +432,6 @@ class _EpisodeTileState extends State<EpisodeTile> {
|
||||
color: selected ? scheme.primary : played ? Colors.grey : null,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_hover)
|
||||
IconButton(
|
||||
tooltip: 'Details (I)',
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints.tightFor(width: 24, height: 22),
|
||||
iconSize: 18,
|
||||
onPressed: () => scope.onOpen(DetailRoute(e)),
|
||||
icon: const Icon(Icons.info_outline),
|
||||
),
|
||||
]),
|
||||
Text(meta, style: t.bodySmall?.copyWith(color: Colors.grey)),
|
||||
if (e.overview != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
@@ -450,7 +447,6 @@ class _EpisodeTileState extends State<EpisodeTile> {
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -201,7 +201,8 @@ final class OwnPlay extends JellyfinPlan {
|
||||
bool get jfAccess => true;
|
||||
}
|
||||
|
||||
/// Through the sync server's relay (`/j/...`).
|
||||
/// Through the server: its own Jellyfin login (`/j/...`) or the member
|
||||
/// who put it up (`/r/...`).
|
||||
final class RelayPlay extends JellyfinPlan {
|
||||
final String url;
|
||||
const RelayPlay(this.url);
|
||||
@@ -210,7 +211,7 @@ final class RelayPlay extends JellyfinPlan {
|
||||
bool get jfAccess => false;
|
||||
}
|
||||
|
||||
/// Neither: no (usable) account here and the server cannot relay.
|
||||
/// Neither: no (usable) account here and nothing relays it.
|
||||
final class BlockedPlay extends JellyfinPlan {
|
||||
final String serverId;
|
||||
/// Known only when this install has (had) an account there.
|
||||
@@ -224,20 +225,22 @@ final class BlockedPlay extends JellyfinPlan {
|
||||
|
||||
String get message {
|
||||
final where = serverName == null || serverName!.isEmpty ? 'that Jellyfin server' : serverName!;
|
||||
final base = 'This needs a Jellyfin account on $where (or the host can relay)';
|
||||
return problem == null ? base : '$base — $problem';
|
||||
final base = 'No Jellyfin account here for $where, and nobody is passing it on';
|
||||
return problem == null ? base : '$base ($problem)';
|
||||
}
|
||||
}
|
||||
|
||||
/// Picks how to play [ref]: this install's account first (PlaybackInfo
|
||||
/// with its own user; a 401 marks the account expired), else the relay
|
||||
/// when the server offers it ([relay] = `state.relay`, [relayUrl] this
|
||||
/// member's `/j/...` URL), else [BlockedPlay].
|
||||
/// with its own user; a 401 marks the account expired), else the server's
|
||||
/// own Jellyfin login ([relay] = `state.relay`, [relayUrl] this member's
|
||||
/// `/j/...` URL), else the member who put it up ([memberRelayUrl], the
|
||||
/// `/r/...` URL when `media.relay` is set), else [BlockedPlay].
|
||||
Future<JellyfinPlan> resolveJellyfinRef(
|
||||
JellyfinAccounts accounts,
|
||||
JellyfinRef ref, {
|
||||
required bool relay,
|
||||
required String? relayUrl,
|
||||
String? memberRelayUrl,
|
||||
Duration timeout = const Duration(seconds: 15),
|
||||
}) async {
|
||||
await accounts.ensureLoaded();
|
||||
@@ -282,6 +285,7 @@ Future<JellyfinPlan> resolveJellyfinRef(
|
||||
}
|
||||
}
|
||||
if (relay && relayUrl != null && relayUrl.isNotEmpty) return RelayPlay(relayUrl);
|
||||
if (memberRelayUrl != null && memberRelayUrl.isNotEmpty) return RelayPlay(memberRelayUrl);
|
||||
return BlockedPlay(serverId: ref.serverId, serverName: account?.displayName, problem: problem);
|
||||
}
|
||||
|
||||
@@ -328,13 +332,13 @@ Duration nextPromptStart(JellyfinItem? item, Duration runtime, {Duration? ending
|
||||
|
||||
/// Who performs the auto-advance swap, so several counting clients do not
|
||||
/// swap twice: the lowest member id among members that play the item with
|
||||
/// their own account (`jf[serverId] == true`, not stale). Only what every
|
||||
/// their own account (`jf[serverId] == true`, not stale, not out). Only what every
|
||||
/// client sees in `state` decides (who put the item up is not in it: a
|
||||
/// late joiner never saw that swap), so all clients agree.
|
||||
String? autoAdvanceSwapper(List<MemberView> members, String serverId) {
|
||||
final eligible = [
|
||||
for (final m in members)
|
||||
if (!m.stale && m.jf[serverId] == true) m
|
||||
if (!m.stale && !m.out && m.jf[serverId] == true) m
|
||||
];
|
||||
if (eligible.isEmpty) return null;
|
||||
eligible.sort((a, b) => compareMemberIds(a.id, b.id));
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
/// The runner's window channel (linux/runner/my_application.cc,
|
||||
/// windows/runner/flutter_window.cpp).
|
||||
const _window = MethodChannel('starsync/window');
|
||||
|
||||
/// Asks the desktop to point at the app's window (taskbar flash, urgency
|
||||
/// hint) when it is not focused; nothing when it is. The runner clears it
|
||||
/// once the window gets focus. Quietly nothing where the runner lacks it.
|
||||
Future<void> requestWindowAttention() async {
|
||||
try {
|
||||
await _window.invokeMethod<void>('attention');
|
||||
} on MissingPluginException {
|
||||
// Tests, other platforms.
|
||||
} catch (e) {
|
||||
debugPrint('window attention: $e');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:sync_relay_core/core.dart' show findInstallRoot;
|
||||
|
||||
/// The app's id: GTK's application id, so the Wayland `app_id` the
|
||||
/// compositor matches to [desktopFileName] for the window's icon.
|
||||
const appId = 'moe.senkochan.watch_together';
|
||||
const desktopFileName = '$appId.desktop';
|
||||
|
||||
/// Linux release installs (an unpacked tarball) carry no launcher entry, so
|
||||
/// Wayland shows a generic icon. On start: write the `.desktop` file into
|
||||
/// `~/.local/share/applications` (Exec = this binary) and the icon into
|
||||
/// the hicolor theme, only when something changed (a moved install, a new
|
||||
/// icon). Never in debug runs; failures only log.
|
||||
Future<void> installDesktopEntry({String? executable, String? dataHome, String? home}) async {
|
||||
if (!Platform.isLinux || kDebugMode) return;
|
||||
try {
|
||||
final exe = executable ?? Platform.resolvedExecutable;
|
||||
final root = findInstallRoot(exe, windows: false);
|
||||
if (root == null) return;
|
||||
final share = dataHome ?? Platform.environment['XDG_DATA_HOME'] ??
|
||||
'${home ?? Platform.environment['HOME']}/.local/share';
|
||||
final icons = '${root.path}/data/flutter_assets/assets/icon';
|
||||
for (final size in const [16, 32, 48, 64, 128, 256, 512]) {
|
||||
final src = File('$icons/icon_$size.png');
|
||||
if (!src.existsSync()) continue;
|
||||
final dst = File('$share/icons/hicolor/${size}x$size/apps/$appId.png');
|
||||
if (dst.existsSync() && dst.lengthSync() == src.lengthSync()) continue;
|
||||
await dst.parent.create(recursive: true);
|
||||
await src.copy(dst.path);
|
||||
}
|
||||
final entry = desktopEntryText(exe);
|
||||
final file = File('$share/applications/$desktopFileName');
|
||||
if (file.existsSync() && file.readAsStringSync() == entry) return;
|
||||
await file.parent.create(recursive: true);
|
||||
await file.writeAsString(entry);
|
||||
} catch (e) {
|
||||
debugPrint('desktop entry: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
String desktopEntryText(String exe) => '''[Desktop Entry]
|
||||
Type=Application
|
||||
Name=Starsync
|
||||
Comment=Watch videos in sync with friends
|
||||
Exec="$exe"
|
||||
Icon=$appId
|
||||
StartupWMClass=$appId
|
||||
Categories=AudioVideo;Video;Player;Network;
|
||||
Terminal=false
|
||||
''';
|
||||
@@ -243,18 +243,20 @@ const _skipTail = Duration(seconds: 1);
|
||||
/// The skip pill at [position]: inside the opening "Skip OP" (to its end),
|
||||
/// inside the ending "Skip ED" (to its end only: what follows, preview or
|
||||
/// more episode, is the viewer's call). Without any opening info on media
|
||||
/// longer than [fallbackMinDuration], "Skip 1:30" during the first
|
||||
/// [fallbackWindow]. Null otherwise.
|
||||
/// longer than [fallbackMinDuration] and without chapters ([chaptered]: a
|
||||
/// chaptered file without an OP chapter has none on purpose), "Skip 1:30"
|
||||
/// during the first [fallbackWindow]. Null otherwise.
|
||||
SkipOffer? skipOfferAt({
|
||||
required Duration position,
|
||||
required Duration duration,
|
||||
required SkipRange? opening,
|
||||
required SkipRange? ending,
|
||||
bool chaptered = false,
|
||||
}) {
|
||||
bool inside(SkipRange r) => position >= r.start && position < r.end - _skipTail;
|
||||
if (opening != null && inside(opening)) return SkipOffer(SkipKind.opening, opening.end);
|
||||
if (ending != null && inside(ending)) return SkipOffer(SkipKind.ending, ending.end);
|
||||
if (opening == null && duration > fallbackMinDuration && position < fallbackWindow) {
|
||||
if (opening == null && !chaptered && duration > fallbackMinDuration && position < fallbackWindow) {
|
||||
final to = position + fallbackSkip;
|
||||
return SkipOffer(SkipKind.fixed, to < duration ? to : duration);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import 'dart:async';
|
||||
import 'dart:ffi' show Int8;
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:ffi/ffi.dart' show StringUtf8Pointer, malloc;
|
||||
import 'package:flutter/foundation.dart' show listEquals;
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:media_kit/media_kit.dart';
|
||||
@@ -16,6 +18,7 @@ import '../sync/state_diff.dart';
|
||||
import '../sync/user_seek.dart';
|
||||
import 'frame_watchdog.dart';
|
||||
import 'media_marks.dart';
|
||||
import 'yt_dlp_app.dart' show AppYtDlp, ytdlFormat;
|
||||
|
||||
/// Owns one mpv [Player] (and its [VideoController]) per session: created
|
||||
/// by [init] on join, released by [dispose] on leave, swapped by
|
||||
@@ -32,6 +35,9 @@ class PlaybackController {
|
||||
/// Back buffer (media_kit sets it to bufferSize too): enough for a short
|
||||
/// seek back without holding another 256 MiB.
|
||||
static const maxBackBytes = 64 * 1024 * 1024;
|
||||
/// mpv's `volume-max` (its default is 130): the volume controls go
|
||||
/// this far, past 100 % amplified.
|
||||
static const maxVolume = 150;
|
||||
|
||||
SyncPlayer? _player;
|
||||
VideoController? _video;
|
||||
@@ -39,7 +45,7 @@ class PlaybackController {
|
||||
bool _probing = false;
|
||||
final FrameWatchdog _watchdog = FrameWatchdog();
|
||||
StreamSubscription<PlayerLog>? _logSub;
|
||||
final List<StreamSubscription<Duration>> _timeSubs = [];
|
||||
final List<StreamSubscription<Object?>> _timeSubs = [];
|
||||
String _chapterJson = '';
|
||||
// `glsl-shaders` as last set on the current player ('' = none).
|
||||
String _shaders = '';
|
||||
@@ -57,6 +63,52 @@ class PlaybackController {
|
||||
/// Called once when the watchdog sees a frozen picture.
|
||||
void Function()? onFrozen;
|
||||
|
||||
/// mpv's yt-dlp hook could not open the current link (its error text).
|
||||
void Function(String error)? onYtdlError;
|
||||
|
||||
/// The current media failed to load (the HTTP error when there was one,
|
||||
/// e.g. a CDN's 403), at most once per [openUrl].
|
||||
void Function(String error)? onLoadError;
|
||||
|
||||
// Per opened media: the last HTTP error ffmpeg logged, and whether a
|
||||
// load error was reported.
|
||||
String? _httpError;
|
||||
bool _loadErrorSent = false;
|
||||
|
||||
/// mpv's log level for this app. media_kit drains mpv's log on the UI
|
||||
/// thread one line at a time, so a chatty level (ffmpeg retrying a
|
||||
/// refused request logs every attempt) can starve the UI: warnings and
|
||||
/// errors only, except while the video output starts ([video]), when
|
||||
/// the render-mode probe needs mpv's verbose GL init lines.
|
||||
static const logLevel = 'warn';
|
||||
|
||||
/// Points mpv's yt-dlp hook at [path] (the app's managed copy) for web
|
||||
/// page links, at most 1080p, once per player; its cookies
|
||||
/// (`ytdl-raw-options`) whenever they changed.
|
||||
Future<void> useYtDlp(String path) async {
|
||||
final p = _player;
|
||||
if (p == null) return;
|
||||
try {
|
||||
final dynamic platform = p.platform;
|
||||
if (!identical(p, _ytdlSetOn)) {
|
||||
await platform?.setProperty('script-opts', 'ytdl_hook-ytdl_path=$path');
|
||||
await platform?.setProperty('ytdl-format', ytdlFormat);
|
||||
_ytdlSetOn = p;
|
||||
_ytdlRaw = '';
|
||||
}
|
||||
final raw = await AppYtDlp.instance.mpvRawOptions();
|
||||
if (raw != _ytdlRaw && identical(p, _player)) {
|
||||
await platform?.setProperty('ytdl-raw-options', raw);
|
||||
_ytdlRaw = raw;
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('yt-dlp path not set: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Player? _ytdlSetOn;
|
||||
String _ytdlRaw = '';
|
||||
|
||||
/// Fingerprint of the picture on screen, installed by the video view
|
||||
/// (`ShownFrameProbe.sample`). Without it the watchdog stays idle.
|
||||
Future<int?> Function()? shownFrame;
|
||||
@@ -66,7 +118,7 @@ class PlaybackController {
|
||||
/// Seeks from the video controls (seek bar) go here instead of to mpv.
|
||||
void Function(Duration position)? onUserSeek;
|
||||
|
||||
/// Latest shader message for the user (the session screen toasts it).
|
||||
/// Latest shader message for the user (the session player toasts it).
|
||||
final shaderNotice = signal<ShaderNotice?>(null);
|
||||
|
||||
/// How media_kit renders the current player's video (shaders need
|
||||
@@ -128,6 +180,7 @@ class PlaybackController {
|
||||
final existing = _video;
|
||||
if (existing != null) return existing;
|
||||
final hwdec = _hwdecOverride;
|
||||
_requestLog(player, 'v'); // back to [logLevel] by _detectRenderMode
|
||||
// media_kit's video output sets hwdec itself on attach ('auto' unless
|
||||
// configured), which used to override SYNC_RELAY_HWDEC.
|
||||
final video = _video = VideoController(
|
||||
@@ -153,8 +206,7 @@ class PlaybackController {
|
||||
bufferSize: maxBytes,
|
||||
libass: true,
|
||||
vo: Platform.environment['SYNC_RELAY_VO'],
|
||||
// Verbose: the render-mode probe needs mpv's GL init lines.
|
||||
logLevel: MPVLogLevel.v,
|
||||
logLevel: MPVLogLevel.warn, // see [logLevel]
|
||||
))
|
||||
..onUserSeek = (d) => onUserSeek?.call(d);
|
||||
_shaders = '';
|
||||
@@ -163,6 +215,7 @@ class PlaybackController {
|
||||
_logSub = _player!.stream.log.listen(_onLog);
|
||||
_watchMarks(_player!);
|
||||
await _pinBufferProps();
|
||||
await _pin('volume-max', '$maxVolume');
|
||||
await applyShaders();
|
||||
_watchdog.reset();
|
||||
_watchTimer = Timer.periodic(const Duration(seconds: 1), (_) => _watch());
|
||||
@@ -208,8 +261,29 @@ class PlaybackController {
|
||||
if (hwdec != null) {
|
||||
await _pin('hwdec', hwdec);
|
||||
}
|
||||
await _pin('stream-lavf-o',
|
||||
'reconnect_streamed=1,reconnect_on_http_error=1,reconnect_delay_max=4');
|
||||
await _pin('stream-lavf-o', streamLavfOptions);
|
||||
}
|
||||
|
||||
/// ffmpeg's HTTP options (mpv `stream-lavf-o`, comma form): reconnect
|
||||
/// after a dropped connection, also on a streamed (growing) relay
|
||||
/// response, and a server error (5xx) while connecting; a refused request
|
||||
/// (4xx) fails at once. At most 3 tries per read, 4 s apart at most.
|
||||
static const streamLavfOptions = 'reconnect_streamed=1,reconnect_on_http_error=5xx,'
|
||||
'reconnect_delay_max=4,reconnect_max_retries=3';
|
||||
|
||||
// mpv_request_log_messages on [p]'s handle (media_kit has no API for it).
|
||||
static void _requestLog(Player p, String level) {
|
||||
try {
|
||||
final dynamic platform = p.platform;
|
||||
final name = level.toNativeUtf8();
|
||||
try {
|
||||
platform.mpv.mpv_request_log_messages(platform.ctx, name.cast<Int8>());
|
||||
} finally {
|
||||
malloc.free(name);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('mpv log level $level not set: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Puts the saved shader choice (Settings, Ctrl+0..6, shader button) on
|
||||
@@ -294,6 +368,31 @@ class PlaybackController {
|
||||
// an error there while shaders are on is a shader that failed to load
|
||||
// or compile (mpv then renders without it).
|
||||
void _onLog(PlayerLog log) {
|
||||
if (log.prefix == 'ytdl_hook' && (log.level == 'error' || log.level == 'fatal')) {
|
||||
final text = log.text.trim().replaceFirst(RegExp(r'^ERROR:\s*'), '');
|
||||
if (text.isNotEmpty) {
|
||||
_loadErrorSent = true;
|
||||
onYtdlError?.call(text);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// ffmpeg logs through one mpv instance per process (the first one
|
||||
// alive), so the HTTP status is a bonus, not a given.
|
||||
if (log.prefix == 'ffmpeg') {
|
||||
final http = RegExp(r'HTTP error \d{3}.*').firstMatch(log.text);
|
||||
if (http != null) _httpError = http[0]!.trim();
|
||||
return;
|
||||
}
|
||||
// Nothing playable opened (a track of mpv's yt-dlp EDL refused, ...).
|
||||
if (!_loadErrorSent &&
|
||||
duration.peek() == Duration.zero &&
|
||||
((log.prefix == 'cplayer' && log.level == 'fatal') ||
|
||||
(log.prefix == 'demux' && log.level == 'error'))) {
|
||||
_loadErrorSent = true;
|
||||
final http = _httpError;
|
||||
onLoadError?.call(http == null ? 'the video stream could not be opened' : 'the video server answered $http');
|
||||
return;
|
||||
}
|
||||
if (log.prefix != 'libmpv_render') return;
|
||||
if (log.text.contains('suspected software renderer')) {
|
||||
_setRenderMode(RenderMode.softwareGl);
|
||||
@@ -324,10 +423,12 @@ class PlaybackController {
|
||||
Future<void> _detectRenderMode(Player p, VideoController video) async {
|
||||
try {
|
||||
await video.platform.future;
|
||||
await Future.delayed(const Duration(milliseconds: 1500));
|
||||
} catch (_) {
|
||||
return;
|
||||
} finally {
|
||||
if (identical(p, _player)) _requestLog(p, logLevel);
|
||||
}
|
||||
await Future.delayed(const Duration(milliseconds: 1500));
|
||||
if (!identical(p, _player) || renderMode.peek() != RenderMode.unknown) {
|
||||
return;
|
||||
}
|
||||
@@ -411,6 +512,8 @@ class PlaybackController {
|
||||
if (!_ready) return;
|
||||
try {
|
||||
_chapterJson = '';
|
||||
_httpError = null;
|
||||
_loadErrorSent = false;
|
||||
chapters.value = const [];
|
||||
tracks.value = const [];
|
||||
buffered.value = Duration.zero;
|
||||
@@ -426,9 +529,18 @@ class PlaybackController {
|
||||
// track-list observed in mpv (read back as JSON).
|
||||
void _watchMarks(SyncPlayer p) {
|
||||
_timeSubs.add(p.stream.position.listen((d) {
|
||||
// At the end the bar shows full: mpv's last position is the last
|
||||
// frame's start (and the 250 ms steps round down), short of duration.
|
||||
if (p.state.completed && duration.value > Duration.zero) {
|
||||
position.value = duration.value;
|
||||
return;
|
||||
}
|
||||
final ms = d.inMilliseconds;
|
||||
position.value = Duration(milliseconds: ms - ms % 250);
|
||||
}));
|
||||
_timeSubs.add(p.stream.completed.listen((done) {
|
||||
if (done && duration.value > Duration.zero) position.value = duration.value;
|
||||
}));
|
||||
_timeSubs.add(p.stream.buffer.listen((d) {
|
||||
final ms = d.inMilliseconds;
|
||||
buffered.value = Duration(milliseconds: ms - ms % 250);
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
|
||||
/// mpv's `ytdl-format` for links every player opens itself.
|
||||
const ytdlFormat = 'bv*[height<=1080]+ba/b';
|
||||
|
||||
/// The app's yt-dlp (see [YtDlp]): in the app-support folder, shared with
|
||||
/// the hosted server. [probe] remembers what pasted links are for a while,
|
||||
/// so the Link preview and the swap ask once.
|
||||
class AppYtDlp {
|
||||
static final instance = AppYtDlp();
|
||||
|
||||
/// [ytDlp]: a given one (tests); default in the app-support folder.
|
||||
AppYtDlp([YtDlp? ytDlp]) : _ytDlp = ytDlp == null ? null : Future.value(ytDlp);
|
||||
|
||||
Future<YtDlp>? _ytDlp;
|
||||
Future<YtDlp> get ytDlp => _ytDlp ??= () async {
|
||||
final base = await getApplicationSupportDirectory();
|
||||
return YtDlp(dir(base.path));
|
||||
}();
|
||||
|
||||
static String dir(String appSupport) => '$appSupport${Platform.pathSeparator}yt-dlp';
|
||||
|
||||
/// Gets yt-dlp ready in the background (download on first run, a daily
|
||||
/// update check afterwards).
|
||||
void warmUp() => unawaited(ytDlp.then((y) => y.ensure()).catchError((_) => null));
|
||||
|
||||
/// mpv's `ytdl-raw-options` for the cookies in the yt-dlp folder ('' for
|
||||
/// none; see [YtDlpCookies]).
|
||||
Future<String> mpvRawOptions() async {
|
||||
try {
|
||||
return (await ytDlp).cookies.mpvRawOptions();
|
||||
} catch (_) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
final _probes = <String, (DateTime, Future<LinkProbe>)>{};
|
||||
static const _probeTtl = Duration(minutes: 10);
|
||||
|
||||
Future<LinkProbe> probe(String url) {
|
||||
final now = DateTime.now();
|
||||
_probes.removeWhere((_, v) => now.difference(v.$1) > _probeTtl);
|
||||
final hit = _probes[url];
|
||||
if (hit != null) return hit.$2;
|
||||
final f = ytDlp.then((y) => probeLink(Uri.parse(url), ytDlp: y));
|
||||
_probes[url] = (now, f);
|
||||
f.catchError((Object _) {
|
||||
_probes.remove(url);
|
||||
return LinkProbe.file(Uri(), -1);
|
||||
});
|
||||
return f;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,391 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:ui' show Color;
|
||||
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
|
||||
|
||||
// AniList airing schedule (public GraphQL, no login): every episode airing
|
||||
// in a time range, with covers, banners and popularity; and a title search
|
||||
// (cover + banner of a show, for the media art of files and links).
|
||||
//
|
||||
// AniList allows ~90 requests/min (sometimes degraded to 30), so the raw
|
||||
// pages are cached: in memory for 10 min (tab switches) and on disk for an
|
||||
// hour (restarts), keyed by range start. A 429 waits out Retry-After once;
|
||||
// if AniList still refuses (or is unreachable), stale cached data is
|
||||
// returned with its fetch time.
|
||||
|
||||
final _log = createLogger('anilist');
|
||||
|
||||
const aniListDefaultUrl = 'https://graphql.anilist.co';
|
||||
|
||||
/// One episode airing, as AniList announces it.
|
||||
class Airing {
|
||||
final int anilistId;
|
||||
/// English title, else romaji.
|
||||
final String title;
|
||||
final String titleRomaji;
|
||||
final int episode;
|
||||
/// UTC.
|
||||
final DateTime airingAt;
|
||||
final String? cover;
|
||||
final String? banner;
|
||||
/// The cover's dominant color, if AniList has one.
|
||||
final Color? color;
|
||||
final int popularity;
|
||||
|
||||
const Airing({
|
||||
required this.anilistId,
|
||||
required this.title,
|
||||
required this.titleRomaji,
|
||||
required this.episode,
|
||||
required this.airingAt,
|
||||
this.cover,
|
||||
this.banner,
|
||||
this.color,
|
||||
this.popularity = 0,
|
||||
});
|
||||
|
||||
/// Null for adult entries, music videos and malformed rows.
|
||||
static Airing? fromJson(Object? j) {
|
||||
if (j is! Map) return null;
|
||||
final m = j['media'], at = j['airingAt'], ep = j['episode'];
|
||||
if (m is! Map || at is! int || ep is! int) return null;
|
||||
final id = m['id'];
|
||||
if (id is! int || m['isAdult'] == true || m['format'] == 'MUSIC') return null;
|
||||
final t = m['title'] is Map ? m['title'] as Map : const {};
|
||||
final romaji = t['romaji'] is String ? t['romaji'] as String : '';
|
||||
final english = t['english'] is String ? t['english'] as String : '';
|
||||
final cov = m['coverImage'] is Map ? m['coverImage'] as Map : const {};
|
||||
return Airing(
|
||||
anilistId: id,
|
||||
title: english.isNotEmpty ? english : (romaji.isNotEmpty ? romaji : '?'),
|
||||
titleRomaji: romaji,
|
||||
episode: ep,
|
||||
airingAt: DateTime.fromMillisecondsSinceEpoch(at * 1000, isUtc: true),
|
||||
cover: cov['large'] is String ? cov['large'] as String : null,
|
||||
banner: m['bannerImage'] is String ? m['bannerImage'] as String : null,
|
||||
color: parseHexColor(cov['color']),
|
||||
popularity: m['popularity'] is int ? m['popularity'] as int : 0,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() => 'Airing($anilistId "$title" ep $episode @ $airingAt)';
|
||||
}
|
||||
|
||||
/// An anime found by title ([AniListClient.searchMedia]).
|
||||
class AniListMedia {
|
||||
final int id;
|
||||
/// English title, else romaji.
|
||||
final String title;
|
||||
/// Cover (portrait) URL: extraLarge, else large.
|
||||
final String? cover;
|
||||
/// The smaller cover, for when [cover] is too big to send on.
|
||||
final String? coverSmall;
|
||||
final String? banner;
|
||||
const AniListMedia({required this.id, required this.title, this.cover, this.coverSmall, this.banner});
|
||||
|
||||
static AniListMedia? fromJson(Object? m) {
|
||||
if (m is! Map || m['id'] is! int) return null;
|
||||
final t = m['title'] is Map ? m['title'] as Map : const {};
|
||||
final romaji = t['romaji'] is String ? t['romaji'] as String : '';
|
||||
final english = t['english'] is String ? t['english'] as String : '';
|
||||
final cov = m['coverImage'] is Map ? m['coverImage'] as Map : const {};
|
||||
final large = cov['large'] is String ? cov['large'] as String : null;
|
||||
return AniListMedia(
|
||||
id: m['id'] as int,
|
||||
title: english.isNotEmpty ? english : romaji,
|
||||
cover: cov['extraLarge'] is String ? cov['extraLarge'] as String : large,
|
||||
coverSmall: large,
|
||||
banner: m['bannerImage'] is String ? m['bannerImage'] as String : null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// "#e4a15d" -> opaque [Color]; anything else -> null.
|
||||
Color? parseHexColor(Object? s) {
|
||||
if (s is! String || s.length != 7 || !s.startsWith('#')) return null;
|
||||
final v = int.tryParse(s.substring(1), radix: 16);
|
||||
return v == null ? null : Color(0xff000000 | v);
|
||||
}
|
||||
|
||||
class AniListException implements Exception {
|
||||
final int? statusCode;
|
||||
final String message;
|
||||
const AniListException(this.message, {this.statusCode});
|
||||
|
||||
bool get isRateLimited => statusCode == 429;
|
||||
|
||||
@override
|
||||
String toString() => 'AniListException: $message';
|
||||
}
|
||||
|
||||
/// The airings of one range. [staleSince] is set when AniList refused and
|
||||
/// older cached data (fetched then) is returned instead.
|
||||
class AniListSchedule {
|
||||
final List<Airing> airings;
|
||||
final DateTime fetchedAt;
|
||||
final DateTime? staleSince;
|
||||
const AniListSchedule(this.airings, this.fetchedAt, {this.staleSince});
|
||||
|
||||
bool get isStale => staleSince != null;
|
||||
}
|
||||
|
||||
class _Cached {
|
||||
final DateTime fetched;
|
||||
final int from;
|
||||
final int to;
|
||||
final List<Object?> raw;
|
||||
const _Cached(this.fetched, this.from, this.to, this.raw);
|
||||
}
|
||||
|
||||
class AniListClient {
|
||||
AniListClient({
|
||||
String url = aniListDefaultUrl,
|
||||
HttpClient? http,
|
||||
Future<Directory?> Function()? cacheDir,
|
||||
this.memoryTtl = const Duration(minutes: 10),
|
||||
this.diskTtl = const Duration(hours: 1),
|
||||
Future<void> Function(Duration)? delay,
|
||||
DateTime Function()? now,
|
||||
String? userAgent,
|
||||
}) : url = Uri.parse(url),
|
||||
_http = http ?? HttpClient(),
|
||||
_ownsHttp = http == null,
|
||||
_cacheDir = cacheDir ?? _defaultCacheDir,
|
||||
_delay = delay ?? Future<void>.delayed,
|
||||
_now = now ?? DateTime.now,
|
||||
userAgent = userAgent ?? 'watch-together/$appVersion';
|
||||
|
||||
final Uri url;
|
||||
final Duration memoryTtl;
|
||||
final Duration diskTtl;
|
||||
/// AniList 403s some default agents.
|
||||
final String userAgent;
|
||||
final HttpClient _http;
|
||||
final bool _ownsHttp;
|
||||
final Future<Directory?> Function() _cacheDir;
|
||||
final Future<void> Function(Duration) _delay;
|
||||
final DateTime Function() _now;
|
||||
|
||||
/// Pages per range at most (50 airings each).
|
||||
static const maxPages = 40;
|
||||
static const perPage = 50;
|
||||
|
||||
static const query = r'''
|
||||
query($from:Int,$to:Int,$page:Int){Page(page:$page,perPage:50){pageInfo{hasNextPage}
|
||||
airingSchedules(airingAt_greater:$from,airingAt_lesser:$to,sort:TIME){airingAt episode
|
||||
media{id title{romaji english} coverImage{large color} bannerImage isAdult popularity format}}}}''';
|
||||
|
||||
final _mem = <int, _Cached>{};
|
||||
final _inflight = <String, Future<AniListSchedule>>{};
|
||||
|
||||
static Future<Directory?> _defaultCacheDir() async {
|
||||
try {
|
||||
return await getApplicationCacheDirectory();
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Every airing in [from, to), cached; see the file comment. [force]
|
||||
/// skips the fresh caches (stale data is still the fallback).
|
||||
Future<AniListSchedule> schedule(DateTime from, DateTime to, {bool force = false}) {
|
||||
final f = from.millisecondsSinceEpoch ~/ 1000, t = to.millisecondsSinceEpoch ~/ 1000;
|
||||
final k = '$f-$t-$force';
|
||||
return _inflight[k] ??= _schedule(f, t, force).whenComplete(() {
|
||||
_inflight.remove(k);
|
||||
});
|
||||
}
|
||||
|
||||
Future<AniListSchedule> _schedule(int from, int to, bool force) async {
|
||||
final now = _now();
|
||||
final mem = _mem[from];
|
||||
final memHit = mem != null && mem.to == to ? mem : null;
|
||||
if (!force && memHit != null && now.difference(memHit.fetched) < memoryTtl) {
|
||||
return _result(memHit);
|
||||
}
|
||||
final disk = await _readDisk(from, to);
|
||||
if (!force && disk != null && now.difference(disk.fetched) < diskTtl) {
|
||||
_mem[from] = disk;
|
||||
return _result(disk);
|
||||
}
|
||||
try {
|
||||
final raw = await _fetch(from, to);
|
||||
final c = _mem[from] = _Cached(_now(), from, to, raw);
|
||||
await _writeDisk(c);
|
||||
return _result(c);
|
||||
} catch (e) {
|
||||
final stale = [memHit, disk].whereType<_Cached>().fold<_Cached?>(
|
||||
null, (a, b) => a == null || b.fetched.isAfter(a.fetched) ? b : a);
|
||||
if (stale == null) rethrow;
|
||||
_log.warning('AniList failed, using cache from ${stale.fetched}: $e');
|
||||
return _result(stale, stale: true);
|
||||
}
|
||||
}
|
||||
|
||||
AniListSchedule _result(_Cached c, {bool stale = false}) => AniListSchedule(
|
||||
[for (final r in c.raw) ?Airing.fromJson(r)],
|
||||
c.fetched,
|
||||
staleSince: stale ? c.fetched : null,
|
||||
);
|
||||
|
||||
Future<List<Object?>> _fetch(int from, int to) async {
|
||||
final out = <Object?>[];
|
||||
for (var page = 1; page <= maxPages; page++) {
|
||||
final data = await _page(from, to, page);
|
||||
final rows = data['airingSchedules'];
|
||||
if (rows is List) out.addAll(rows);
|
||||
final info = data['pageInfo'];
|
||||
if (info is! Map || info['hasNextPage'] != true) break;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
Future<Map> _page(int from, int to, int page) async {
|
||||
final (data, status, j) = await _graphql(query, {'from': from - 1, 'to': to, 'page': page});
|
||||
final p = data?['Page'];
|
||||
if (p is! Map) {
|
||||
throw AniListException('AniList: bad response${_errorText(j)}', statusCode: status);
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
/// One GraphQL request: `data` (null when missing), status and body. A
|
||||
/// 429 waits out Retry-After once; other non-200s throw.
|
||||
Future<(Map?, int, Object?)> _graphql(String query, Map<String, Object?> variables) async {
|
||||
for (var attempt = 0;; attempt++) {
|
||||
final (status, body, retryAfter) = await _post(jsonEncode({'query': query, 'variables': variables}));
|
||||
if (status == 429 && attempt == 0) {
|
||||
final wait = retryAfter ?? 60;
|
||||
_log.info('AniList rate limited, retrying in ${wait}s');
|
||||
await _delay(Duration(seconds: wait.clamp(1, 65)));
|
||||
continue;
|
||||
}
|
||||
Object? j;
|
||||
try {
|
||||
j = jsonDecode(body);
|
||||
} catch (_) {}
|
||||
if (status != 200) {
|
||||
throw AniListException('AniList HTTP $status${_errorText(j)}', statusCode: status);
|
||||
}
|
||||
final data = j is Map && j['data'] is Map ? j['data'] as Map : null;
|
||||
return (data, status, j);
|
||||
}
|
||||
}
|
||||
|
||||
static const searchQuery = r'''
|
||||
query($search:String){Media(search:$search,type:ANIME,isAdult:false){id title{romaji english}
|
||||
coverImage{extraLarge large} bannerImage}}''';
|
||||
|
||||
final _search = <String, Future<AniListMedia?>>{};
|
||||
|
||||
/// AniList's best anime match for [title], or null when there is none.
|
||||
/// One request per title for this client's life (a failure is not kept,
|
||||
/// so a later call asks again).
|
||||
Future<AniListMedia?> searchMedia(String title) {
|
||||
final key = title.trim();
|
||||
if (key.isEmpty) return Future.value(null);
|
||||
return _search[key] ??= _searchMedia(key).catchError((Object e) {
|
||||
_search.remove(key);
|
||||
throw e;
|
||||
});
|
||||
}
|
||||
|
||||
Future<AniListMedia?> _searchMedia(String title) async {
|
||||
try {
|
||||
final (data, _, _) = await _graphql(searchQuery, {'search': title});
|
||||
return AniListMedia.fromJson(data?['Media']);
|
||||
} on AniListException catch (e) {
|
||||
// No match is a 404 with "Not Found." in errors.
|
||||
if (e.statusCode == 404) return null;
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
static String _errorText(Object? j) {
|
||||
final errs = j is Map ? j['errors'] : null;
|
||||
if (errs is! List || errs.isEmpty) return '';
|
||||
final m = errs.first is Map ? (errs.first as Map)['message'] : null;
|
||||
return m is String ? ': $m' : '';
|
||||
}
|
||||
|
||||
Future<(int, String, int?)> _post(String body) async {
|
||||
try {
|
||||
final req = await _http.postUrl(url).timeout(const Duration(seconds: 15));
|
||||
req.headers
|
||||
..contentType = ContentType.json
|
||||
..set(HttpHeaders.acceptHeader, 'application/json')
|
||||
..set(HttpHeaders.userAgentHeader, userAgent);
|
||||
req.write(body);
|
||||
final res = await req.close().timeout(const Duration(seconds: 20));
|
||||
final text = await res.transform(const Utf8Decoder(allowMalformed: true)).join();
|
||||
return (
|
||||
res.statusCode,
|
||||
text,
|
||||
int.tryParse(res.headers.value(HttpHeaders.retryAfterHeader) ?? ''),
|
||||
);
|
||||
} on TimeoutException {
|
||||
throw const AniListException('AniList timed out');
|
||||
} on IOException catch (e) {
|
||||
throw AniListException('AniList unreachable: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- disk
|
||||
|
||||
Future<File?> _file(int from) async {
|
||||
final dir = await _cacheDir();
|
||||
return dir == null ? null : File('${dir.path}/anilist-schedule-$from.json');
|
||||
}
|
||||
|
||||
Future<_Cached?> _readDisk(int from, int to) async {
|
||||
try {
|
||||
final f = await _file(from);
|
||||
if (f == null || !await f.exists()) return null;
|
||||
final j = jsonDecode(await f.readAsString());
|
||||
if (j is! Map || j['from'] != from || j['to'] != to) return null;
|
||||
final fetched = j['fetched'], raw = j['raw'];
|
||||
if (fetched is! int || raw is! List) return null;
|
||||
return _Cached(DateTime.fromMillisecondsSinceEpoch(fetched), from, to, raw);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _writeDisk(_Cached c) async {
|
||||
try {
|
||||
final f = await _file(c.from);
|
||||
if (f == null) return;
|
||||
await f.parent.create(recursive: true);
|
||||
await f.writeAsString(jsonEncode({
|
||||
'fetched': c.fetched.millisecondsSinceEpoch,
|
||||
'from': c.from,
|
||||
'to': c.to,
|
||||
'raw': c.raw,
|
||||
}));
|
||||
// Older ranges are useless once a day has passed.
|
||||
await for (final e in f.parent.list()) {
|
||||
final name = e.uri.pathSegments.last;
|
||||
if (e is File &&
|
||||
e.path != f.path &&
|
||||
name.startsWith('anilist-schedule-') &&
|
||||
_now().difference(await e.lastModified()) > const Duration(days: 1)) {
|
||||
await e.delete();
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
_log.warning('AniList cache write failed: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Drops the memory cache (the disk cache stays as the stale fallback).
|
||||
void clearMemory() => _mem.clear();
|
||||
|
||||
void close() {
|
||||
if (_ownsHttp) _http.close(force: true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,744 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
|
||||
import '../jellyfin/accounts.dart';
|
||||
|
||||
// AnimeFlow app API: what the media server tracks, has, downloads and was
|
||||
// asked for. Authenticated with the user's own Jellyfin access token
|
||||
// (X-Emby-Token) on the Jellyfin server next to it.
|
||||
//
|
||||
// Errors are JSON {"detail","code"} ([AnimeFlowException]); nginx in front
|
||||
// may answer 429 with HTML (5 req/s, burst 20), retried with backoff.
|
||||
// /schedule, /series, /series/{id} and /requests send weak ETags: the last
|
||||
// body is kept and revalidated with If-None-Match. Times are UTC ISO
|
||||
// ("2026-10-08T19:36:32Z"). Scopes can change at runtime, so /me is cached
|
||||
// for a minute at most.
|
||||
|
||||
final _log = createLogger('animeflow');
|
||||
|
||||
const animeFlowDefaultBase = 'https://anime.senkochan.moe/api/app/v1';
|
||||
|
||||
// ---------------------------------------------------------------- errors
|
||||
|
||||
enum AnimeFlowErrorCode {
|
||||
missingToken('missing_token'),
|
||||
badToken('bad_token'),
|
||||
revoked('revoked'),
|
||||
missingScope('missing_scope'),
|
||||
ownAccount('own_account'),
|
||||
jellyfinUnreachable('jellyfin_unreachable'),
|
||||
searchUnavailable('search_unavailable'),
|
||||
invalidRequest('invalid_request'),
|
||||
invalidTime('invalid_time'),
|
||||
invalidRange('invalid_range'),
|
||||
notFound('not_found'),
|
||||
seriesNotFound('series_not_found'),
|
||||
episodeNotFound('episode_not_found'),
|
||||
requestNotFound('request_not_found'),
|
||||
userNotFound('user_not_found'),
|
||||
alreadyTracked('already_tracked'),
|
||||
alreadyAvailable('already_available'),
|
||||
alreadyRequested('already_requested'),
|
||||
invalidState('invalid_state'),
|
||||
/// nginx 429 (client side, no JSON body).
|
||||
rateLimited('rate_limited'),
|
||||
/// No answer: DNS, connect, timeout (client side).
|
||||
network('network'),
|
||||
/// Anything not in this list.
|
||||
unknown('unknown');
|
||||
|
||||
const AnimeFlowErrorCode(this.wire);
|
||||
final String wire;
|
||||
|
||||
static AnimeFlowErrorCode parse(Object? s) =>
|
||||
values.where((c) => c.wire == s).firstOrNull ?? unknown;
|
||||
|
||||
/// Best guess from the status alone (body was not JSON).
|
||||
static AnimeFlowErrorCode forStatus(int status) => switch (status) {
|
||||
401 => badToken,
|
||||
403 => missingScope,
|
||||
404 => notFound,
|
||||
409 => invalidState,
|
||||
400 => invalidRequest,
|
||||
429 => rateLimited,
|
||||
503 => jellyfinUnreachable,
|
||||
_ => unknown,
|
||||
};
|
||||
}
|
||||
|
||||
class AnimeFlowException implements Exception {
|
||||
/// HTTP status; 0 when no answer came.
|
||||
final int statusCode;
|
||||
final AnimeFlowErrorCode code;
|
||||
final String detail;
|
||||
const AnimeFlowException(this.statusCode, this.code, this.detail);
|
||||
|
||||
/// Sign in again (token missing, wrong or expired).
|
||||
bool get isUnauthorized => statusCode == 401;
|
||||
/// Revoked, missing scope or own account.
|
||||
bool get isForbidden => statusCode == 403;
|
||||
bool get isNotFound => statusCode == 404;
|
||||
bool get isConflict => statusCode == 409;
|
||||
bool get isUnavailable => statusCode == 503 || code == AnimeFlowErrorCode.network;
|
||||
bool get isRateLimited => code == AnimeFlowErrorCode.rateLimited;
|
||||
|
||||
@override
|
||||
String toString() => 'AnimeFlowException($statusCode ${code.wire}): $detail';
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- models
|
||||
|
||||
DateTime? _time(Object? v) => v is String ? DateTime.tryParse(v)?.toUtc() : null;
|
||||
int? _int(Object? v) => v is int ? v : (v is num ? v.toInt() : null);
|
||||
String? _str(Object? v) => v is String ? v : null;
|
||||
List<Map> _items(Object? j) =>
|
||||
j is Map && j['items'] is List ? (j['items'] as List).whereType<Map>().toList() : const [];
|
||||
|
||||
/// Formats [d] as AnimeFlow wants it: "2026-10-08T19:36:32Z".
|
||||
String animeFlowTime(DateTime d) => '${d.toUtc().toIso8601String().split('.').first.replaceAll('Z', '')}Z';
|
||||
|
||||
enum AnimeFlowScope {
|
||||
view,
|
||||
request,
|
||||
manage;
|
||||
|
||||
static Set<AnimeFlowScope> parse(Object? j) => {
|
||||
if (j is List)
|
||||
for (final s in j) ...values.where((v) => v.name == s),
|
||||
};
|
||||
}
|
||||
|
||||
class AnimeFlowMe {
|
||||
final String userId;
|
||||
final String name;
|
||||
final Set<AnimeFlowScope> scopes;
|
||||
const AnimeFlowMe({required this.userId, required this.name, required this.scopes});
|
||||
|
||||
bool can(AnimeFlowScope s) => scopes.contains(s);
|
||||
|
||||
static AnimeFlowMe fromJson(Object? j) {
|
||||
final m = j is Map ? j : const {};
|
||||
return AnimeFlowMe(
|
||||
userId: _str(m['user_id']) ?? '',
|
||||
name: _str(m['name']) ?? '',
|
||||
scopes: AnimeFlowScope.parse(m['scopes']),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
enum EpisodeStatus {
|
||||
upcoming,
|
||||
missing,
|
||||
downloading,
|
||||
available,
|
||||
unmonitored,
|
||||
failed;
|
||||
|
||||
/// Unknown values read as upcoming.
|
||||
static EpisodeStatus parse(Object? s) => values.where((v) => v.name == s).firstOrNull ?? upcoming;
|
||||
}
|
||||
|
||||
/// A series on Jellyfin (`GET /library`).
|
||||
class AnimeFlowLibraryItem {
|
||||
final String jellyfinSeriesId;
|
||||
final String name;
|
||||
/// Set when AnimeFlow tracks it.
|
||||
final int? seriesId;
|
||||
/// AniList entry per TVDB season (the first, when a season spans more).
|
||||
final Map<int, int> anilistIds;
|
||||
/// Every AniList entry per season with its episode run (`anilist_map`):
|
||||
/// a season AniList splits (one cour as two entries) has several rows.
|
||||
final List<AniListRun> anilistMap;
|
||||
const AnimeFlowLibraryItem({
|
||||
required this.jellyfinSeriesId,
|
||||
required this.name,
|
||||
this.seriesId,
|
||||
this.anilistIds = const {},
|
||||
this.anilistMap = const [],
|
||||
});
|
||||
|
||||
static AnimeFlowLibraryItem fromJson(Map j) => AnimeFlowLibraryItem(
|
||||
jellyfinSeriesId: _str(j['jellyfin_series_id']) ?? '',
|
||||
name: _str(j['name']) ?? '',
|
||||
seriesId: _int(j['series_id']),
|
||||
anilistIds: {
|
||||
for (final e in ((j['anilist_ids'] as Map?) ?? const {}).entries)
|
||||
if (int.tryParse('${e.key}') case final season? when _int(e.value) != null) season: _int(e.value)!,
|
||||
},
|
||||
anilistMap: [
|
||||
for (final r in (j['anilist_map'] as List?) ?? const [])
|
||||
if (r is Map && _int(r['season']) != null && _int(r['anilist_id']) != null)
|
||||
AniListRun(
|
||||
season: _int(r['season'])!,
|
||||
anilistId: _int(r['anilist_id'])!,
|
||||
firstEpisode: _int(r['first_episode']) ?? 1,
|
||||
offset: _int(r['offset']) ?? 0,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// One AniList entry's run within a TVDB season: TVDB episode e (from
|
||||
/// [firstEpisode] to the next run of that season) is AniList episode
|
||||
/// e - [offset].
|
||||
class AniListRun {
|
||||
final int season;
|
||||
final int anilistId;
|
||||
final int firstEpisode;
|
||||
final int offset;
|
||||
const AniListRun({required this.season, required this.anilistId, this.firstEpisode = 1, this.offset = 0});
|
||||
}
|
||||
|
||||
class AnimeFlowEpisode {
|
||||
/// 0 = specials.
|
||||
final int season;
|
||||
final int episode;
|
||||
final int? absolute;
|
||||
final String? title;
|
||||
final DateTime? airDate;
|
||||
/// The AniList entry of this episode's season (one AnimeFlow series may
|
||||
/// span several AniList entries).
|
||||
final int? anilistId;
|
||||
/// The episode number within [anilistId] when AnimeFlow knows it (split
|
||||
/// cours, absolute numbering); prefer it over [episode] for matching.
|
||||
final int? anilistEpisode;
|
||||
final EpisodeStatus status;
|
||||
/// 0..1, only while downloading.
|
||||
final double? progress;
|
||||
/// Only when failed.
|
||||
final String? reason;
|
||||
|
||||
const AnimeFlowEpisode({
|
||||
required this.season,
|
||||
required this.episode,
|
||||
this.absolute,
|
||||
this.title,
|
||||
this.airDate,
|
||||
this.anilistId,
|
||||
this.anilistEpisode,
|
||||
required this.status,
|
||||
this.progress,
|
||||
this.reason,
|
||||
});
|
||||
|
||||
static AnimeFlowEpisode fromJson(Map j) => AnimeFlowEpisode(
|
||||
season: _int(j['season']) ?? 0,
|
||||
episode: _int(j['episode']) ?? 0,
|
||||
absolute: _int(j['absolute']),
|
||||
title: _str(j['episode_title']),
|
||||
airDate: _time(j['air_date']),
|
||||
// The episode's own entry (a split season rolls into its sequel),
|
||||
// else the series' one.
|
||||
anilistId: _int(j['episode_anilist_id']) ?? _int(j['anilist_id']),
|
||||
anilistEpisode: _int(j['anilist_episode']),
|
||||
status: EpisodeStatus.parse(j['status']),
|
||||
progress: (j['progress'] as num?)?.toDouble(),
|
||||
reason: _str(j['reason']),
|
||||
);
|
||||
}
|
||||
|
||||
/// A /schedule row: one episode plus its series.
|
||||
class AnimeFlowScheduleItem {
|
||||
final AnimeFlowEpisode episode;
|
||||
final int seriesId;
|
||||
final String title;
|
||||
final int? anidbId;
|
||||
final int? tvdbId;
|
||||
final String? jellyfinSeriesId;
|
||||
|
||||
const AnimeFlowScheduleItem({
|
||||
required this.episode,
|
||||
required this.seriesId,
|
||||
required this.title,
|
||||
this.anidbId,
|
||||
this.tvdbId,
|
||||
this.jellyfinSeriesId,
|
||||
});
|
||||
|
||||
static AnimeFlowScheduleItem fromJson(Map j) => AnimeFlowScheduleItem(
|
||||
episode: AnimeFlowEpisode.fromJson(j),
|
||||
seriesId: _int(j['series_id']) ?? 0,
|
||||
title: _str(j['title']) ?? '',
|
||||
anidbId: _int(j['anidb_id']),
|
||||
tvdbId: _int(j['tvdb_id']),
|
||||
jellyfinSeriesId: _str(j['jellyfin_series_id']),
|
||||
);
|
||||
}
|
||||
|
||||
class AnimeFlowScheduleWindow {
|
||||
final DateTime? from;
|
||||
final DateTime? to;
|
||||
final List<AnimeFlowScheduleItem> items;
|
||||
const AnimeFlowScheduleWindow({this.from, this.to, required this.items});
|
||||
}
|
||||
|
||||
enum SeriesState {
|
||||
releasing,
|
||||
released;
|
||||
|
||||
static SeriesState parse(Object? s) => s == 'released' ? released : releasing;
|
||||
}
|
||||
|
||||
class AnimeFlowSeries {
|
||||
final int seriesId;
|
||||
final String title;
|
||||
final int? anidbId;
|
||||
final int? tvdbId;
|
||||
/// The series' (first) AniList entry; later seasons may have others, see
|
||||
/// [AnimeFlowEpisode.anilistId].
|
||||
final int? anilistId;
|
||||
final String? jellyfinSeriesId;
|
||||
final SeriesState state;
|
||||
final bool monitored;
|
||||
final int episodesTotal;
|
||||
final int episodesAvailable;
|
||||
final DateTime? nextAirDate;
|
||||
/// Only from [AnimeFlowClient.seriesDetail].
|
||||
final List<AnimeFlowEpisode> episodes;
|
||||
|
||||
const AnimeFlowSeries({
|
||||
required this.seriesId,
|
||||
required this.title,
|
||||
this.anidbId,
|
||||
this.tvdbId,
|
||||
this.anilistId,
|
||||
this.jellyfinSeriesId,
|
||||
this.state = SeriesState.releasing,
|
||||
this.monitored = false,
|
||||
this.episodesTotal = 0,
|
||||
this.episodesAvailable = 0,
|
||||
this.nextAirDate,
|
||||
this.episodes = const [],
|
||||
});
|
||||
|
||||
static AnimeFlowSeries fromJson(Map j) => AnimeFlowSeries(
|
||||
seriesId: _int(j['series_id']) ?? 0,
|
||||
title: _str(j['title']) ?? '',
|
||||
anidbId: _int(j['anidb_id']),
|
||||
tvdbId: _int(j['tvdb_id']),
|
||||
anilistId: _int(j['anilist_id']),
|
||||
jellyfinSeriesId: _str(j['jellyfin_series_id']),
|
||||
state: SeriesState.parse(j['state']),
|
||||
monitored: j['monitored'] == true,
|
||||
episodesTotal: _int(j['episodes_total']) ?? 0,
|
||||
episodesAvailable: _int(j['episodes_available']) ?? 0,
|
||||
nextAirDate: _time(j['next_air_date']),
|
||||
episodes: [
|
||||
if (j['episodes'] is List)
|
||||
for (final e in (j['episodes'] as List).whereType<Map>()) AnimeFlowEpisode.fromJson(e),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
class AnimeFlowSearchHit {
|
||||
final int anidbId;
|
||||
final String title;
|
||||
final int? anilistId;
|
||||
/// Set when AnimeFlow already tracks it.
|
||||
final int? seriesId;
|
||||
const AnimeFlowSearchHit({required this.anidbId, required this.title, this.anilistId, this.seriesId});
|
||||
|
||||
static AnimeFlowSearchHit fromJson(Map j) => AnimeFlowSearchHit(
|
||||
anidbId: _int(j['anidb_id']) ?? 0,
|
||||
title: _str(j['title']) ?? '',
|
||||
anilistId: _int(j['anilist_id']),
|
||||
seriesId: _int(j['series_id']),
|
||||
);
|
||||
}
|
||||
|
||||
enum RequestKind {
|
||||
series,
|
||||
season,
|
||||
episode;
|
||||
|
||||
static RequestKind parse(Object? s) => values.where((v) => v.name == s).firstOrNull ?? series;
|
||||
}
|
||||
|
||||
enum RequestStatus {
|
||||
/// Briefly, before auto-approval or a manager decides.
|
||||
pending,
|
||||
approved,
|
||||
searching,
|
||||
done,
|
||||
rejected,
|
||||
failed;
|
||||
|
||||
static RequestStatus parse(Object? s) => values.where((v) => v.name == s).firstOrNull ?? pending;
|
||||
}
|
||||
|
||||
class AnimeFlowRequest {
|
||||
final int id;
|
||||
final RequestKind kind;
|
||||
final RequestStatus status;
|
||||
final String title;
|
||||
final int? anidbId;
|
||||
final int? seriesId;
|
||||
final int? season;
|
||||
final int? episode;
|
||||
final String? reason;
|
||||
final DateTime? createdAt;
|
||||
final DateTime? updatedAt;
|
||||
/// Only with scope=all.
|
||||
final String? userId;
|
||||
final String? userName;
|
||||
|
||||
const AnimeFlowRequest({
|
||||
required this.id,
|
||||
required this.kind,
|
||||
required this.status,
|
||||
required this.title,
|
||||
this.anidbId,
|
||||
this.seriesId,
|
||||
this.season,
|
||||
this.episode,
|
||||
this.reason,
|
||||
this.createdAt,
|
||||
this.updatedAt,
|
||||
this.userId,
|
||||
this.userName,
|
||||
});
|
||||
|
||||
static AnimeFlowRequest fromJson(Map j) => AnimeFlowRequest(
|
||||
id: _int(j['id']) ?? 0,
|
||||
kind: RequestKind.parse(j['kind']),
|
||||
status: RequestStatus.parse(j['status']),
|
||||
title: _str(j['title']) ?? '',
|
||||
anidbId: _int(j['anidb_id']),
|
||||
seriesId: _int(j['series_id']),
|
||||
season: _int(j['season']),
|
||||
episode: _int(j['episode']),
|
||||
reason: _str(j['reason']),
|
||||
createdAt: _time(j['created_at']),
|
||||
updatedAt: _time(j['updated_at']),
|
||||
userId: _str(j['user_id']),
|
||||
userName: _str(j['user_name']),
|
||||
);
|
||||
}
|
||||
|
||||
class AnimeFlowUser {
|
||||
/// The Jellyfin user id.
|
||||
final String userId;
|
||||
final String name;
|
||||
final Set<AnimeFlowScope> scopes;
|
||||
final bool revoked;
|
||||
final DateTime? lastSeenAt;
|
||||
const AnimeFlowUser({
|
||||
required this.userId,
|
||||
required this.name,
|
||||
required this.scopes,
|
||||
this.revoked = false,
|
||||
this.lastSeenAt,
|
||||
});
|
||||
|
||||
static AnimeFlowUser fromJson(Map j) => AnimeFlowUser(
|
||||
userId: _str(j['user_id']) ?? '',
|
||||
name: _str(j['name']) ?? '',
|
||||
scopes: AnimeFlowScope.parse(j['scopes']),
|
||||
revoked: j['revoked'] == true,
|
||||
lastSeenAt: _time(j['last_seen_at']),
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- client
|
||||
|
||||
class _Etagged {
|
||||
final String etag;
|
||||
final Object? body;
|
||||
const _Etagged(this.etag, this.body);
|
||||
}
|
||||
|
||||
class AnimeFlowClient {
|
||||
/// [token] wins over [tokenProvider]; with neither, every call fails with
|
||||
/// [AnimeFlowErrorCode.missingToken].
|
||||
AnimeFlowClient({
|
||||
String baseUrl = animeFlowDefaultBase,
|
||||
this._token,
|
||||
this._tokenProvider,
|
||||
HttpClient? http,
|
||||
Future<void> Function(Duration)? delay,
|
||||
DateTime Function()? now,
|
||||
this.meTtl = const Duration(seconds: 60),
|
||||
String? userAgent,
|
||||
}) : baseUrl = baseUrl.endsWith('/') ? baseUrl.substring(0, baseUrl.length - 1) : baseUrl,
|
||||
_http = http ?? HttpClient(),
|
||||
_ownsHttp = http == null,
|
||||
_delay = delay ?? Future<void>.delayed,
|
||||
_now = now ?? DateTime.now,
|
||||
userAgent = userAgent ?? 'watch-together/$appVersion';
|
||||
|
||||
/// Authenticated with the linked Jellyfin account on the same domain
|
||||
/// (see [tokenFor]), looked up on every call so a re-link applies.
|
||||
factory AnimeFlowClient.fromAccounts(
|
||||
JellyfinAccounts accounts, {
|
||||
String baseUrl = animeFlowDefaultBase,
|
||||
HttpClient? http,
|
||||
}) =>
|
||||
AnimeFlowClient(
|
||||
baseUrl: baseUrl,
|
||||
http: http,
|
||||
tokenProvider: () async {
|
||||
await accounts.ensureLoaded(validate: false);
|
||||
return tokenFor(accounts.accounts, baseUrl);
|
||||
},
|
||||
);
|
||||
|
||||
final String baseUrl;
|
||||
final Duration meTtl;
|
||||
final String userAgent;
|
||||
final String? _token;
|
||||
final Future<String?> Function()? _tokenProvider;
|
||||
final HttpClient _http;
|
||||
final bool _ownsHttp;
|
||||
final Future<void> Function(Duration) _delay;
|
||||
final DateTime Function() _now;
|
||||
|
||||
/// Request bodies above this are refused by the server.
|
||||
static const maxBodyBytes = 16 * 1024;
|
||||
/// Retries after an nginx 429 (backoff 1 s, 2 s unless Retry-After).
|
||||
static const rateLimitRetries = 2;
|
||||
/// The longest /schedule window.
|
||||
static const maxScheduleRange = Duration(days: 45);
|
||||
|
||||
String? _lastToken;
|
||||
final _etags = <String, _Etagged>{};
|
||||
({DateTime at, AnimeFlowMe me})? _me;
|
||||
|
||||
/// The token of the linked Jellyfin account whose host shares
|
||||
/// [animeFlowBase]'s parent domain (anime.example.org -> example.org,
|
||||
/// jellyfin.example.org, ...).
|
||||
static String? tokenFor(Iterable<JellyfinAccount> accounts, String animeFlowBase) {
|
||||
final host = Uri.tryParse(animeFlowBase)?.host.toLowerCase() ?? '';
|
||||
if (host.isEmpty) return null;
|
||||
final labels = host.split('.');
|
||||
final parent = labels.length > 2 ? labels.skip(1).join('.') : host;
|
||||
bool matches(String url) {
|
||||
final h = Uri.tryParse(url)?.host.toLowerCase() ?? '';
|
||||
return h.isNotEmpty && (h == parent || h.endsWith('.$parent'));
|
||||
}
|
||||
|
||||
for (final a in accounts) {
|
||||
if (a.urls.any(matches)) return a.token;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<String> _authToken() async {
|
||||
final t = _token ?? await _tokenProvider?.call();
|
||||
if (t == null || t.isEmpty) {
|
||||
throw const AnimeFlowException(
|
||||
401, AnimeFlowErrorCode.missingToken, 'No linked Jellyfin account for AnimeFlow');
|
||||
}
|
||||
if (t != _lastToken) {
|
||||
// Another user: nothing cached belongs to them.
|
||||
_lastToken = t;
|
||||
_etags.clear();
|
||||
_me = null;
|
||||
}
|
||||
return t;
|
||||
}
|
||||
|
||||
Uri _uri(String path, [Map<String, String>? query]) =>
|
||||
Uri.parse('$baseUrl$path').replace(queryParameters: query);
|
||||
|
||||
/// Sends one call; JSON body or null (204). [etag]: revalidate the last
|
||||
/// body of this URL.
|
||||
Future<Object?> _call(String method, String path,
|
||||
{Map<String, String>? query, Object? body, bool etag = false}) async {
|
||||
final token = await _authToken();
|
||||
final uri = _uri(path, query);
|
||||
final key = uri.toString();
|
||||
List<int>? bytes;
|
||||
if (body != null) {
|
||||
bytes = utf8.encode(jsonEncode(body));
|
||||
if (bytes.length > maxBodyBytes) {
|
||||
throw const AnimeFlowException(400, AnimeFlowErrorCode.invalidRequest, 'Request body too large');
|
||||
}
|
||||
}
|
||||
for (var attempt = 0;; attempt++) {
|
||||
final cached = etag ? _etags[key] : null;
|
||||
final (status, text, retryAfter, tag) = await _send(method, uri, token, bytes, cached?.etag);
|
||||
if (status == 304 && cached != null) return cached.body;
|
||||
if (status >= 200 && status < 300) {
|
||||
final j = text.trim().isEmpty ? null : _decode(text, status);
|
||||
if (etag && tag != null && tag.isNotEmpty) _etags[key] = _Etagged(tag, j);
|
||||
return j;
|
||||
}
|
||||
final err = _error(status, text);
|
||||
if (err.isRateLimited && attempt < rateLimitRetries) {
|
||||
final wait = retryAfter != null ? Duration(seconds: retryAfter.clamp(1, 30)) : Duration(seconds: 1 << attempt);
|
||||
_log.info('AnimeFlow rate limited on $path, retrying in ${wait.inSeconds}s');
|
||||
await _delay(wait);
|
||||
continue;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
static Object? _decode(String text, int status) {
|
||||
try {
|
||||
return jsonDecode(text);
|
||||
} catch (_) {
|
||||
throw AnimeFlowException(status, AnimeFlowErrorCode.unknown, 'Response is not JSON');
|
||||
}
|
||||
}
|
||||
|
||||
static AnimeFlowException _error(int status, String text) {
|
||||
Object? j;
|
||||
try {
|
||||
j = jsonDecode(text);
|
||||
} catch (_) {}
|
||||
if (j is Map && j['code'] is String) {
|
||||
final code = AnimeFlowErrorCode.parse(j['code']);
|
||||
return AnimeFlowException(status, code == AnimeFlowErrorCode.unknown ? AnimeFlowErrorCode.forStatus(status) : code,
|
||||
_str(j['detail']) ?? 'HTTP $status');
|
||||
}
|
||||
// Not ours: nginx (HTML 429/502), a proxy, ...
|
||||
return AnimeFlowException(status, AnimeFlowErrorCode.forStatus(status),
|
||||
status == 429 ? 'Too many requests, try again shortly' : 'HTTP $status');
|
||||
}
|
||||
|
||||
Future<(int, String, int?, String?)> _send(
|
||||
String method, Uri uri, String token, List<int>? body, String? ifNoneMatch) async {
|
||||
try {
|
||||
final req = await _http.openUrl(method, uri).timeout(const Duration(seconds: 15));
|
||||
req.headers
|
||||
..set(HttpHeaders.acceptHeader, 'application/json')
|
||||
..set(HttpHeaders.userAgentHeader, userAgent)
|
||||
..set('X-Emby-Token', token);
|
||||
if (ifNoneMatch != null) req.headers.set(HttpHeaders.ifNoneMatchHeader, ifNoneMatch);
|
||||
if (body != null) {
|
||||
req.headers.contentType = ContentType.json;
|
||||
req.contentLength = body.length;
|
||||
req.add(body);
|
||||
}
|
||||
final res = await req.close().timeout(const Duration(seconds: 20));
|
||||
final text = await res.transform(const Utf8Decoder(allowMalformed: true)).join();
|
||||
return (
|
||||
res.statusCode,
|
||||
text,
|
||||
int.tryParse(res.headers.value(HttpHeaders.retryAfterHeader) ?? ''),
|
||||
res.headers.value(HttpHeaders.etagHeader),
|
||||
);
|
||||
} on TimeoutException {
|
||||
throw const AnimeFlowException(0, AnimeFlowErrorCode.network, 'AnimeFlow timed out');
|
||||
} on IOException catch (e) {
|
||||
throw AnimeFlowException(0, AnimeFlowErrorCode.network, 'AnimeFlow unreachable: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ endpoints
|
||||
|
||||
/// Who the token belongs to and what they may do. Cached for [meTtl]
|
||||
/// (scopes change at runtime).
|
||||
Future<AnimeFlowMe> me({bool force = false}) async {
|
||||
await _authToken();
|
||||
final c = _me;
|
||||
if (!force && c != null && _now().difference(c.at) < meTtl) return c.me;
|
||||
final me = AnimeFlowMe.fromJson(await _call('GET', '/me'));
|
||||
_me = (at: _now(), me: me);
|
||||
return me;
|
||||
}
|
||||
|
||||
/// Episodes airing in [from, to) (at most 45 days) [view].
|
||||
Future<AnimeFlowScheduleWindow> schedule(DateTime from, DateTime to) async {
|
||||
if (!from.isBefore(to) || to.difference(from) > maxScheduleRange) {
|
||||
throw const AnimeFlowException(400, AnimeFlowErrorCode.invalidRange, 'from must be before to, at most 45 days');
|
||||
}
|
||||
final j = await _call('GET', '/schedule',
|
||||
query: {'from': animeFlowTime(from), 'to': animeFlowTime(to)}, etag: true);
|
||||
final m = j is Map ? j : const {};
|
||||
return AnimeFlowScheduleWindow(
|
||||
from: _time(m['from']),
|
||||
to: _time(m['to']),
|
||||
items: [for (final e in _items(j)) AnimeFlowScheduleItem.fromJson(e)],
|
||||
);
|
||||
}
|
||||
|
||||
/// Every tracked series [view].
|
||||
Future<List<AnimeFlowSeries>> series() async =>
|
||||
[for (final s in _items(await _call('GET', '/series', etag: true))) AnimeFlowSeries.fromJson(s)];
|
||||
|
||||
/// Every series on Jellyfin (as the caller sees it), tracked by
|
||||
/// AnimeFlow or not [view]. Empty on servers without the endpoint.
|
||||
Future<List<AnimeFlowLibraryItem>> library() async {
|
||||
try {
|
||||
return [for (final s in _items(await _call('GET', '/library', etag: true))) AnimeFlowLibraryItem.fromJson(s)];
|
||||
} on AnimeFlowException catch (e) {
|
||||
if (e.isNotFound) return const [];
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// One series with all its episodes [view].
|
||||
Future<AnimeFlowSeries> seriesDetail(int seriesId) async {
|
||||
final j = await _call('GET', '/series/$seriesId', etag: true);
|
||||
return AnimeFlowSeries.fromJson(j is Map ? j : const {});
|
||||
}
|
||||
|
||||
/// Titles that can be requested [request].
|
||||
Future<List<AnimeFlowSearchHit>> search(String q) async => [
|
||||
for (final h in _items(await _call('GET', '/search', query: {'q': q}))) AnimeFlowSearchHit.fromJson(h),
|
||||
];
|
||||
|
||||
/// Asks for a whole new series by its AniDB id [request].
|
||||
Future<AnimeFlowRequest> requestSeries(int anidbId) => _request({'anidb_id': anidbId});
|
||||
|
||||
/// Asks for one season of a tracked series [request].
|
||||
Future<AnimeFlowRequest> requestSeason(int seriesId, int season) =>
|
||||
_request({'series_id': seriesId, 'season': season});
|
||||
|
||||
/// Asks for one episode of a tracked series [request].
|
||||
Future<AnimeFlowRequest> requestEpisode(int seriesId, int season, int episode) =>
|
||||
_request({'series_id': seriesId, 'season': season, 'episode': episode});
|
||||
|
||||
Future<AnimeFlowRequest> _request(Map<String, Object> body) async {
|
||||
final j = await _call('POST', '/requests', body: body);
|
||||
return AnimeFlowRequest.fromJson(j is Map ? j : const {});
|
||||
}
|
||||
|
||||
/// Own requests, newest first [view]; [all] = everyone's, with the user
|
||||
/// [manage].
|
||||
Future<List<AnimeFlowRequest>> requests({bool all = false}) async => [
|
||||
for (final r in _items(await _call('GET', '/requests', query: all ? {'scope': 'all'} : null, etag: true)))
|
||||
AnimeFlowRequest.fromJson(r),
|
||||
];
|
||||
|
||||
/// [manage]. Returns the updated request when the server sends one.
|
||||
Future<AnimeFlowRequest?> approve(int requestId) async =>
|
||||
_maybeRequest(await _call('POST', '/requests/$requestId/approve'));
|
||||
|
||||
/// [manage].
|
||||
Future<AnimeFlowRequest?> reject(int requestId, {String? reason}) async => _maybeRequest(
|
||||
await _call('POST', '/requests/$requestId/reject', body: {'reason': ?reason}));
|
||||
|
||||
static AnimeFlowRequest? _maybeRequest(Object? j) =>
|
||||
j is Map && j['id'] != null ? AnimeFlowRequest.fromJson(j) : null;
|
||||
|
||||
/// [manage].
|
||||
Future<List<AnimeFlowUser>> users() async =>
|
||||
[for (final u in _items(await _call('GET', '/users'))) AnimeFlowUser.fromJson(u)];
|
||||
|
||||
/// Sets a user's scopes (and revocation) [manage]. Returns the updated
|
||||
/// user when the server sends one.
|
||||
Future<AnimeFlowUser?> setScopes(String jellyfinUserId, Set<AnimeFlowScope> scopes, {bool? revoked}) async {
|
||||
final j = await _call('PUT', '/users/${Uri.encodeComponent(jellyfinUserId)}/scopes', body: {
|
||||
'scopes': [for (final s in AnimeFlowScope.values) if (scopes.contains(s)) s.name],
|
||||
'revoked': ?revoked,
|
||||
});
|
||||
_me = null; // could be our own
|
||||
return j is Map && j['user_id'] != null ? AnimeFlowUser.fromJson(j) : null;
|
||||
}
|
||||
|
||||
/// Forgets cached bodies and /me (e.g. on a manual refresh).
|
||||
void clearCache() {
|
||||
_etags.clear();
|
||||
_me = null;
|
||||
}
|
||||
|
||||
void close() {
|
||||
if (_ownsHttp) _http.close(force: true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'anilist.dart';
|
||||
import 'animeflow.dart';
|
||||
|
||||
// The Schedule tab's data: AniList says what airs when, AnimeFlow says
|
||||
// what the media server has of it. [matchSchedule] merges the two and
|
||||
// [ScheduleRepository] loads a window of both, degrading to AniList alone
|
||||
// when AnimeFlow is unreachable or not linked.
|
||||
|
||||
enum ScheduleStatus {
|
||||
available,
|
||||
downloading,
|
||||
upcoming,
|
||||
missing,
|
||||
failed,
|
||||
unmonitored,
|
||||
/// AnimeFlow does not track this series (or is unreachable).
|
||||
notTracked,
|
||||
/// Not tracked by AnimeFlow, the series is on Jellyfin (`/library`) and
|
||||
/// so is the episode ([confirmOnJellyfin]; [matchSchedule] alone sets
|
||||
/// it from the series).
|
||||
inLibrary,
|
||||
/// Not tracked, the series is on Jellyfin, the episode aired but is not
|
||||
/// there yet.
|
||||
notOnJellyfin,
|
||||
/// Not tracked, the series is on Jellyfin, the episode aired; whether
|
||||
/// it is there is unknown (no linked account, lookup failed).
|
||||
aired;
|
||||
|
||||
static ScheduleStatus of(EpisodeStatus s) => switch (s) {
|
||||
EpisodeStatus.available => available,
|
||||
EpisodeStatus.downloading => downloading,
|
||||
EpisodeStatus.upcoming => upcoming,
|
||||
EpisodeStatus.missing => missing,
|
||||
EpisodeStatus.failed => failed,
|
||||
EpisodeStatus.unmonitored => unmonitored,
|
||||
};
|
||||
}
|
||||
|
||||
/// One airing with what AnimeFlow knows about it.
|
||||
class ScheduleEntry {
|
||||
final Airing airing;
|
||||
final ScheduleStatus status;
|
||||
/// 0..1 while downloading.
|
||||
final double? progress;
|
||||
/// Why it failed.
|
||||
final String? reason;
|
||||
/// AnimeFlow tracks the series.
|
||||
final bool tracked;
|
||||
/// AnimeFlow's series id (requests).
|
||||
final int? seriesId;
|
||||
/// The AnimeFlow season of the matched episode (requests); untracked
|
||||
/// on Jellyfin: the TVDB season of the AniList entry.
|
||||
final int? season;
|
||||
/// Untracked on Jellyfin: the TVDB episode number in [season] (AniList
|
||||
/// episode + the `anilist_map` run's offset).
|
||||
final int? jellyfinEpisode;
|
||||
/// Play target once available.
|
||||
final String? jellyfinSeriesId;
|
||||
/// The series is on Jellyfin (tracked or not).
|
||||
bool get onJellyfin => jellyfinSeriesId != null;
|
||||
|
||||
const ScheduleEntry({
|
||||
required this.airing,
|
||||
required this.status,
|
||||
this.progress,
|
||||
this.reason,
|
||||
this.tracked = false,
|
||||
this.seriesId,
|
||||
this.season,
|
||||
this.jellyfinEpisode,
|
||||
this.jellyfinSeriesId,
|
||||
});
|
||||
|
||||
ScheduleEntry withStatus(ScheduleStatus s) => ScheduleEntry(
|
||||
airing: airing,
|
||||
status: s,
|
||||
progress: progress,
|
||||
reason: reason,
|
||||
tracked: tracked,
|
||||
seriesId: seriesId,
|
||||
season: season,
|
||||
jellyfinEpisode: jellyfinEpisode,
|
||||
jellyfinSeriesId: jellyfinSeriesId,
|
||||
);
|
||||
|
||||
@override
|
||||
String toString() => 'ScheduleEntry(${airing.anilistId}#${airing.episode} $status)';
|
||||
}
|
||||
|
||||
/// Without AnimeFlow: future airings are upcoming, past ones unknown.
|
||||
ScheduleEntry untrackedEntry(Airing a, DateTime now) => ScheduleEntry(
|
||||
airing: a,
|
||||
status: a.airingAt.isAfter(now) ? ScheduleStatus.upcoming : ScheduleStatus.notTracked,
|
||||
);
|
||||
|
||||
String _day(DateTime d) {
|
||||
final l = d.toLocal();
|
||||
return '${l.year}-${l.month}-${l.day}';
|
||||
}
|
||||
|
||||
class _SeriesRef {
|
||||
final int seriesId;
|
||||
final String? jellyfinSeriesId;
|
||||
_SeriesRef(this.seriesId, this.jellyfinSeriesId);
|
||||
}
|
||||
|
||||
/// Merges AniList [airings] with AnimeFlow's [schedule] rows and tracked
|
||||
/// [series]. Per airing: the episode with the same AniList id (that
|
||||
/// episode's season's entry) and episode number, else absolute number,
|
||||
/// else the same local air day; unmatched airings of a tracked AniList id
|
||||
/// are missing once aired, upcoming before; the rest are not tracked.
|
||||
List<ScheduleEntry> matchSchedule(
|
||||
List<Airing> airings, {
|
||||
required List<AnimeFlowScheduleItem> schedule,
|
||||
List<AnimeFlowSeries> series = const [],
|
||||
List<AnimeFlowLibraryItem> library = const [],
|
||||
DateTime? now,
|
||||
}) {
|
||||
final at = now ?? DateTime.now();
|
||||
// AniList id -> (Jellyfin series, its TVDB season, TVDB - AniList
|
||||
// episode offset) from /library.
|
||||
final lib = <int, (String, int, int)>{};
|
||||
for (final item in library) {
|
||||
if (item.jellyfinSeriesId.isEmpty) continue;
|
||||
for (final r in item.anilistMap) {
|
||||
lib[r.anilistId] ??= (item.jellyfinSeriesId, r.season, r.offset);
|
||||
}
|
||||
for (final e in item.anilistIds.entries) {
|
||||
lib[e.value] ??= (item.jellyfinSeriesId, e.key, 0);
|
||||
}
|
||||
}
|
||||
final refs = <int, _SeriesRef>{};
|
||||
for (final s in series) {
|
||||
final id = s.anilistId;
|
||||
if (id != null) refs[id] ??= _SeriesRef(s.seriesId, s.jellyfinSeriesId);
|
||||
}
|
||||
// AnimeFlow's AniList episode number wins outright: TVDB numbering runs
|
||||
// on across split entries (JoJo S6E4 is "2nd & 3rd STAGE" ep 3), so a TVDB
|
||||
// or absolute number, or an air day, is only a guess for AniList entries
|
||||
// AnimeFlow gives no AniList numbers for.
|
||||
final byAni = <String, AnimeFlowScheduleItem>{};
|
||||
final byEp = <String, AnimeFlowScheduleItem>{};
|
||||
final byAbs = <String, AnimeFlowScheduleItem>{};
|
||||
final byDay = <String, AnimeFlowScheduleItem>{};
|
||||
final numbered = <int>{};
|
||||
for (final it in schedule) {
|
||||
final e = it.episode, id = e.anilistId;
|
||||
if (id == null) continue;
|
||||
refs[id] ??= _SeriesRef(it.seriesId, it.jellyfinSeriesId);
|
||||
if (e.anilistEpisode != null) {
|
||||
numbered.add(id);
|
||||
byAni.putIfAbsent('$id#${e.anilistEpisode}', () => it);
|
||||
}
|
||||
byEp.putIfAbsent('$id#${e.episode}', () => it);
|
||||
if (e.absolute != null) byAbs.putIfAbsent('$id#${e.absolute}', () => it);
|
||||
if (e.airDate != null) byDay.putIfAbsent('$id@${_day(e.airDate!)}', () => it);
|
||||
}
|
||||
return [
|
||||
for (final a in airings)
|
||||
() {
|
||||
final id = a.anilistId;
|
||||
final ref = refs[id];
|
||||
final it = numbered.contains(id)
|
||||
? byAni['$id#${a.episode}']
|
||||
: byEp['$id#${a.episode}'] ?? byAbs['$id#${a.episode}'] ?? byDay['$id@${_day(a.airingAt)}'];
|
||||
if (it != null) {
|
||||
final e = it.episode;
|
||||
return ScheduleEntry(
|
||||
airing: a,
|
||||
status: ScheduleStatus.of(e.status),
|
||||
progress: e.status == EpisodeStatus.downloading ? e.progress : null,
|
||||
reason: e.status == EpisodeStatus.failed ? e.reason : null,
|
||||
tracked: true,
|
||||
seriesId: it.seriesId,
|
||||
season: e.season,
|
||||
jellyfinSeriesId: it.jellyfinSeriesId ?? ref?.jellyfinSeriesId ?? lib[id]?.$1,
|
||||
);
|
||||
}
|
||||
if (ref == null) {
|
||||
final onJf = lib[id];
|
||||
if (onJf == null) return ScheduleEntry(airing: a, status: ScheduleStatus.notTracked);
|
||||
return ScheduleEntry(
|
||||
airing: a,
|
||||
status: a.airingAt.isAfter(at) ? ScheduleStatus.upcoming : ScheduleStatus.inLibrary,
|
||||
season: onJf.$2,
|
||||
jellyfinEpisode: a.episode + onJf.$3,
|
||||
jellyfinSeriesId: onJf.$1,
|
||||
);
|
||||
}
|
||||
return ScheduleEntry(
|
||||
airing: a,
|
||||
status: a.airingAt.isAfter(at) ? ScheduleStatus.upcoming : ScheduleStatus.missing,
|
||||
tracked: true,
|
||||
seriesId: ref.seriesId,
|
||||
jellyfinSeriesId: ref.jellyfinSeriesId ?? lib[id]?.$1,
|
||||
);
|
||||
}(),
|
||||
];
|
||||
}
|
||||
|
||||
/// Checks [matchSchedule]'s [ScheduleStatus.inLibrary] entries (series
|
||||
/// on Jellyfin) against the episodes Jellyfin has: [episodes] maps a
|
||||
/// Jellyfin series id to its (season, episode) numbers. Found stays
|
||||
/// inLibrary, absent becomes [ScheduleStatus.notOnJellyfin], a series
|
||||
/// missing from [episodes] (not looked up) [ScheduleStatus.aired].
|
||||
List<ScheduleEntry> confirmOnJellyfin(List<ScheduleEntry> entries, Map<String, Set<(int, int)>> episodes) => [
|
||||
for (final e in entries)
|
||||
if (e.status != ScheduleStatus.inLibrary)
|
||||
e
|
||||
else
|
||||
switch (episodes[e.jellyfinSeriesId]) {
|
||||
null => e.withStatus(ScheduleStatus.aired),
|
||||
final have when have.contains((e.season, e.jellyfinEpisode)) => e,
|
||||
_ => e.withStatus(ScheduleStatus.notOnJellyfin),
|
||||
},
|
||||
];
|
||||
|
||||
/// The (season, episode) numbers Jellyfin has of a series; null when it
|
||||
/// cannot tell (no linked account for that server).
|
||||
typedef JellyfinEpisodeLookup = Future<Set<(int, int)>?> Function(String jellyfinSeriesId);
|
||||
|
||||
/// One loaded window. Errors are display text; [entries] is empty only
|
||||
/// when AniList failed with nothing cached.
|
||||
class ScheduleSnapshot {
|
||||
final DateTime from;
|
||||
final DateTime to;
|
||||
/// Sorted by air time.
|
||||
final List<ScheduleEntry> entries;
|
||||
/// AniList failed and nothing was cached.
|
||||
final String? aniListError;
|
||||
/// AniList failed; [entries] come from the cache fetched then.
|
||||
final DateTime? aniListStaleSince;
|
||||
/// AnimeFlow is unreachable, refused or not linked: statuses are
|
||||
/// AniList-only (upcoming / not tracked).
|
||||
final String? animeFlowError;
|
||||
/// The structured AnimeFlow failure behind [animeFlowError].
|
||||
final AnimeFlowException? animeFlowException;
|
||||
/// Who we are on AnimeFlow (null when it failed).
|
||||
final AnimeFlowMe? me;
|
||||
|
||||
const ScheduleSnapshot({
|
||||
required this.from,
|
||||
required this.to,
|
||||
required this.entries,
|
||||
this.aniListError,
|
||||
this.aniListStaleSince,
|
||||
this.animeFlowError,
|
||||
this.animeFlowException,
|
||||
this.me,
|
||||
});
|
||||
|
||||
bool get hasAnimeFlow => me != null;
|
||||
}
|
||||
|
||||
/// Loads AniList and AnimeFlow for a window and merges them.
|
||||
class ScheduleRepository {
|
||||
ScheduleRepository({required this.aniList, this.animeFlow, this.jellyfinEpisodes, DateTime Function()? now})
|
||||
: _now = now ?? DateTime.now;
|
||||
|
||||
final AniListClient aniList;
|
||||
/// Null = not configured (AniList only).
|
||||
final AnimeFlowClient? animeFlow;
|
||||
/// Confirms "on Jellyfin" per episode ([confirmOnJellyfin]); null =
|
||||
/// never confirmed (aired entries read [ScheduleStatus.aired]).
|
||||
final JellyfinEpisodeLookup? jellyfinEpisodes;
|
||||
final DateTime Function() _now;
|
||||
|
||||
/// Last week's Monday 00:00 local .. 4 weeks later.
|
||||
static ({DateTime from, DateTime to}) defaultWindow(DateTime now) {
|
||||
final l = now.toLocal();
|
||||
final from = DateTime(l.year, l.month, l.day - (l.weekday - 1) - 7);
|
||||
return (from: from, to: DateTime(from.year, from.month, from.day + 28));
|
||||
}
|
||||
|
||||
Future<ScheduleSnapshot> load({DateTime? from, DateTime? to, bool force = false}) async {
|
||||
final w = defaultWindow(_now());
|
||||
final start = from ?? w.from, end = to ?? w.to;
|
||||
final flowF = _animeFlow(start, end, force);
|
||||
final AniListSchedule ani;
|
||||
try {
|
||||
ani = await aniList.schedule(start, end, force: force);
|
||||
} catch (e) {
|
||||
unawaited(flowF.then((_) {}, onError: (_) {}));
|
||||
return ScheduleSnapshot(from: start, to: end, entries: const [], aniListError: _text(e));
|
||||
}
|
||||
final now = _now();
|
||||
final flow = await flowF;
|
||||
final airings = [...ani.airings]..sort((a, b) => a.airingAt.compareTo(b.airingAt));
|
||||
final entries = flow.error == null
|
||||
? await _confirm(
|
||||
matchSchedule(airings, schedule: flow.schedule, series: flow.series, library: flow.library, now: now))
|
||||
: [for (final a in airings) untrackedEntry(a, now)];
|
||||
return ScheduleSnapshot(
|
||||
from: start,
|
||||
to: end,
|
||||
entries: entries,
|
||||
aniListStaleSince: ani.staleSince,
|
||||
animeFlowError: flow.error == null ? null : _text(flow.error!),
|
||||
animeFlowException: flow.error is AnimeFlowException ? flow.error as AnimeFlowException : null,
|
||||
me: flow.me,
|
||||
);
|
||||
}
|
||||
|
||||
/// Looks up the episodes of every series an inLibrary entry is on, in
|
||||
/// parallel; a failed or impossible lookup leaves that series out.
|
||||
Future<List<ScheduleEntry>> _confirm(List<ScheduleEntry> entries) async {
|
||||
final lookup = jellyfinEpisodes;
|
||||
final ids = {
|
||||
for (final e in entries)
|
||||
if (e.status == ScheduleStatus.inLibrary && e.jellyfinSeriesId != null) e.jellyfinSeriesId!,
|
||||
};
|
||||
final found = <String, Set<(int, int)>>{};
|
||||
if (lookup != null) {
|
||||
await Future.wait([
|
||||
for (final id in ids)
|
||||
lookup(id).then((eps) {
|
||||
if (eps != null) found[id] = eps;
|
||||
}, onError: (_) {}),
|
||||
]);
|
||||
}
|
||||
return confirmOnJellyfin(entries, found);
|
||||
}
|
||||
|
||||
Future<({AnimeFlowMe? me, List<AnimeFlowScheduleItem> schedule, List<AnimeFlowSeries> series, List<AnimeFlowLibraryItem> library, Object? error})>
|
||||
_animeFlow(DateTime from, DateTime to, bool force) async {
|
||||
final c = animeFlow;
|
||||
if (c == null) {
|
||||
return (me: null, schedule: const <AnimeFlowScheduleItem>[], series: const <AnimeFlowSeries>[],
|
||||
library: const <AnimeFlowLibraryItem>[],
|
||||
error: 'AnimeFlow is not configured');
|
||||
}
|
||||
try {
|
||||
if (force) c.clearCache();
|
||||
final me = await c.me();
|
||||
final (sched, series) = await (c.schedule(from, to), c.series()).wait;
|
||||
// The library only adds "on Jellyfin" for untracked shows: its
|
||||
// failure never fails the schedule.
|
||||
List<AnimeFlowLibraryItem> library = const [];
|
||||
try {
|
||||
library = await c.library();
|
||||
} catch (_) {}
|
||||
return (me: me, schedule: sched.items, series: series, library: library, error: null);
|
||||
} on ParallelWaitError catch (e) {
|
||||
final errs = e.errors as (AsyncError?, AsyncError?);
|
||||
return (
|
||||
me: null,
|
||||
schedule: const <AnimeFlowScheduleItem>[],
|
||||
series: const <AnimeFlowSeries>[],
|
||||
library: const <AnimeFlowLibraryItem>[],
|
||||
error: (errs.$1 ?? errs.$2)!.error,
|
||||
);
|
||||
} catch (e) {
|
||||
return (me: null, schedule: const <AnimeFlowScheduleItem>[], series: const <AnimeFlowSeries>[], library: const <AnimeFlowLibraryItem>[], error: e);
|
||||
}
|
||||
}
|
||||
|
||||
static String _text(Object e) => switch (e) {
|
||||
AnimeFlowException(:final code, :final detail) => switch (code) {
|
||||
AnimeFlowErrorCode.missingToken => 'Link the Jellyfin server to see AnimeFlow statuses',
|
||||
AnimeFlowErrorCode.badToken => 'AnimeFlow: sign in to Jellyfin again',
|
||||
AnimeFlowErrorCode.revoked => 'AnimeFlow: access revoked',
|
||||
AnimeFlowErrorCode.rateLimited => 'AnimeFlow: too many requests, try again shortly',
|
||||
_ => 'AnimeFlow: $detail',
|
||||
},
|
||||
AniListException(:final message) => message,
|
||||
_ => '$e',
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../shell/avatar.dart';
|
||||
import '../animeflow.dart';
|
||||
|
||||
// The Schedule page's side sheet: AnimeFlow requests (everyone's for the
|
||||
// owner, who can retry failed ones and decline the rest; own otherwise).
|
||||
|
||||
Color requestColor(RequestStatus s) => switch (s) {
|
||||
RequestStatus.pending || RequestStatus.searching => Colors.amber,
|
||||
RequestStatus.approved => Colors.lightBlueAccent,
|
||||
RequestStatus.rejected || RequestStatus.failed => Colors.redAccent,
|
||||
RequestStatus.done => Colors.greenAccent,
|
||||
};
|
||||
|
||||
String requestWhat(AnimeFlowRequest r) => switch (r.kind) {
|
||||
RequestKind.series => r.title,
|
||||
RequestKind.season => '${r.title} · season ${r.season ?? '?'}',
|
||||
RequestKind.episode => '${r.title} · S${r.season ?? '?'}E${r.episode ?? '?'}',
|
||||
};
|
||||
|
||||
class RequestsSheet extends StatelessWidget {
|
||||
/// Scope manage: everyone's requests, Retry and Decline.
|
||||
final bool owner;
|
||||
|
||||
/// Null while loading.
|
||||
final List<AnimeFlowRequest>? requests;
|
||||
final String? error;
|
||||
|
||||
/// Our own name, for requests without a user name.
|
||||
final String selfName;
|
||||
|
||||
/// Requests with a call in flight.
|
||||
final Set<int> busy;
|
||||
final void Function(AnimeFlowRequest r) onRetry;
|
||||
final void Function(AnimeFlowRequest r) onDecline;
|
||||
final VoidCallback onClose;
|
||||
|
||||
const RequestsSheet({
|
||||
super.key,
|
||||
required this.owner,
|
||||
required this.requests,
|
||||
required this.selfName,
|
||||
required this.onRetry,
|
||||
required this.onDecline,
|
||||
required this.onClose,
|
||||
this.error,
|
||||
this.busy = const {},
|
||||
});
|
||||
|
||||
Widget _tile(BuildContext context, AnimeFlowRequest r) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final who = r.userName ?? (selfName.isEmpty ? 'you' : selfName);
|
||||
final canRetry = owner && r.status == RequestStatus.failed;
|
||||
final canDecline =
|
||||
owner &&
|
||||
(r.status == RequestStatus.pending ||
|
||||
r.status == RequestStatus.approved ||
|
||||
r.status == RequestStatus.searching);
|
||||
final inFlight = busy.contains(r.id);
|
||||
return Padding(
|
||||
key: ValueKey('request-${r.id}'),
|
||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
StatusAvatar(
|
||||
info: AvatarInfo(name: who, color: colorForName(who)),
|
||||
size: 32,
|
||||
ring: false,
|
||||
badges: false,
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(requestWhat(r), style: const TextStyle(fontWeight: FontWeight.w600)),
|
||||
const SizedBox(height: 2),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
Text(who, style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12)),
|
||||
Pill(r.status.name, requestColor(r.status)),
|
||||
],
|
||||
),
|
||||
if (r.reason != null && r.reason!.isNotEmpty)
|
||||
Text(r.reason!, style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 11)),
|
||||
if (canRetry || canDecline)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 6),
|
||||
child: Row(
|
||||
children: [
|
||||
if (canRetry)
|
||||
FilledButton.tonal(
|
||||
key: ValueKey('retry-${r.id}'),
|
||||
onPressed: inFlight ? null : () => onRetry(r),
|
||||
child: const Text('Retry'),
|
||||
),
|
||||
if (canDecline)
|
||||
TextButton(
|
||||
key: ValueKey('decline-${r.id}'),
|
||||
onPressed: inFlight ? null : () => onDecline(r),
|
||||
child: const Text('Decline'),
|
||||
),
|
||||
if (inFlight) ...[
|
||||
const SizedBox(width: 8),
|
||||
const SizedBox(width: 14, height: 14, child: CircularProgressIndicator(strokeWidth: 2)),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final reqs = requests;
|
||||
return Material(
|
||||
color: scheme.surfaceContainerLow,
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(16, 10, 8, 16),
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
owner ? 'Requests' : 'My requests',
|
||||
style: const TextStyle(fontWeight: FontWeight.w700, fontSize: 16),
|
||||
),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
key: const ValueKey('requests-close'),
|
||||
onPressed: onClose,
|
||||
tooltip: 'Close',
|
||||
icon: const Icon(Icons.close, size: 18),
|
||||
),
|
||||
],
|
||||
),
|
||||
Text(
|
||||
owner
|
||||
? 'Everyone\'s requests. AnimeFlow approves them automatically; you can decline or retry.'
|
||||
: 'AnimeFlow downloads requests automatically; they appear on Jellyfin when done.',
|
||||
style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (error != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Text(error!, style: TextStyle(color: scheme.error, fontSize: 12)),
|
||||
),
|
||||
if (reqs == null && error == null)
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(top: 24),
|
||||
child: Center(child: SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2))),
|
||||
)
|
||||
else if (reqs != null && reqs.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 24),
|
||||
child: Center(
|
||||
child: Text('No requests yet', style: TextStyle(color: scheme.onSurfaceVariant)),
|
||||
),
|
||||
),
|
||||
for (final r in reqs ?? const <AnimeFlowRequest>[]) _tile(context, r),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
|
||||
import '../../jellyfin/accounts.dart';
|
||||
import '../../jellyfin/format.dart' show pickFor;
|
||||
import '../../screens/home/picked_media.dart';
|
||||
import '../anilist.dart';
|
||||
import '../animeflow.dart';
|
||||
import '../schedule_model.dart';
|
||||
|
||||
// What the Schedule page's buttons do beyond one client call: friendly
|
||||
// AnimeFlow errors, finding AnimeFlow's own numbering of an airing, the
|
||||
// AniDB id of an untracked AniList entry, and the Jellyfin episode to put
|
||||
// up.
|
||||
|
||||
/// A failure meant for the user as is.
|
||||
class ScheduleActionException implements Exception {
|
||||
final String message;
|
||||
const ScheduleActionException(this.message);
|
||||
@override
|
||||
String toString() => message;
|
||||
}
|
||||
|
||||
/// [e] as one short sentence for a toast.
|
||||
String friendlyError(Object e) => switch (e) {
|
||||
ScheduleActionException(:final message) => message,
|
||||
AnimeFlowException(:final code, :final detail) => switch (code) {
|
||||
AnimeFlowErrorCode.alreadyRequested => 'Already requested',
|
||||
AnimeFlowErrorCode.alreadyTracked => 'AnimeFlow already tracks this series',
|
||||
AnimeFlowErrorCode.alreadyAvailable => 'Already on Jellyfin',
|
||||
AnimeFlowErrorCode.missingScope => 'The owner has not allowed you to do that on AnimeFlow',
|
||||
AnimeFlowErrorCode.revoked => 'Your AnimeFlow access was revoked',
|
||||
AnimeFlowErrorCode.missingToken => 'Link the Jellyfin server to use AnimeFlow',
|
||||
AnimeFlowErrorCode.badToken => 'AnimeFlow: sign in to Jellyfin again',
|
||||
AnimeFlowErrorCode.ownAccount => 'You cannot change your own account',
|
||||
AnimeFlowErrorCode.searchUnavailable => 'AniDB search is unavailable right now',
|
||||
AnimeFlowErrorCode.seriesNotFound => 'AnimeFlow does not know this series',
|
||||
AnimeFlowErrorCode.episodeNotFound => 'AnimeFlow does not know this episode',
|
||||
AnimeFlowErrorCode.requestNotFound => 'That request is gone',
|
||||
AnimeFlowErrorCode.notFound => 'AnimeFlow: not found',
|
||||
AnimeFlowErrorCode.invalidState => 'That cannot be done in its current state',
|
||||
AnimeFlowErrorCode.rateLimited => 'AnimeFlow is busy, try again shortly',
|
||||
AnimeFlowErrorCode.network => 'AnimeFlow is unreachable',
|
||||
AnimeFlowErrorCode.jellyfinUnreachable => 'AnimeFlow cannot reach Jellyfin right now',
|
||||
_ => 'AnimeFlow: $detail',
|
||||
},
|
||||
JellyfinException(:final message) => 'Jellyfin: $message',
|
||||
_ => '$e',
|
||||
};
|
||||
|
||||
bool _sameDay(DateTime a, DateTime b) {
|
||||
final x = a.toLocal(), y = b.toLocal();
|
||||
return x.year == y.year && x.month == y.month && x.day == y.day;
|
||||
}
|
||||
|
||||
/// AnimeFlow's episode for [e] (its season and numbering), from the
|
||||
/// series detail: same AniList id and episode, else absolute number, else
|
||||
/// the same air day. Null when the entry is not tracked or nothing fits.
|
||||
Future<AnimeFlowEpisode?> flowEpisodeFor(AnimeFlowClient client, ScheduleEntry e) async {
|
||||
final id = e.seriesId;
|
||||
if (id == null) return null;
|
||||
final eps = (await client.seriesDetail(id)).episodes;
|
||||
final a = e.airing;
|
||||
final mine = eps.where((x) => x.anilistId == null || x.anilistId == a.anilistId).toList();
|
||||
return mine.where((x) => x.anilistId == a.anilistId && x.episode == a.episode).firstOrNull ??
|
||||
mine.where((x) => x.absolute == a.episode).firstOrNull ??
|
||||
mine.where((x) => x.airDate != null && _sameDay(x.airDate!, a.airingAt)).firstOrNull;
|
||||
}
|
||||
|
||||
/// The (season, episode) AnimeFlow uses for [e]; falls back to the
|
||||
/// matched season and AniList's number when the detail has nothing.
|
||||
Future<({int season, int episode})> flowNumbering(AnimeFlowClient? client, ScheduleEntry e) async {
|
||||
AnimeFlowEpisode? ep;
|
||||
if (client != null) {
|
||||
try {
|
||||
ep = await flowEpisodeFor(client, e);
|
||||
} on AnimeFlowException catch (err) {
|
||||
if (err.isUnauthorized || err.isForbidden) rethrow;
|
||||
}
|
||||
}
|
||||
if (ep != null) return (season: ep.season, episode: ep.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 hit with the same AniList id, searched by English then romaji
|
||||
/// title. Otherwise every hit seen, for the user to choose from.
|
||||
Future<({AnimeFlowSearchHit? match, List<AnimeFlowSearchHit> hits})> findOnAniDb(
|
||||
AnimeFlowClient client,
|
||||
Airing a,
|
||||
) async {
|
||||
final hits = <AnimeFlowSearchHit>[];
|
||||
final queries = {a.title, if (a.titleRomaji.isNotEmpty) a.titleRomaji};
|
||||
for (final q in queries) {
|
||||
final found = await client.search(q);
|
||||
final match = found.where((h) => h.anilistId == a.anilistId).firstOrNull;
|
||||
if (match != null) return (match: match, hits: found);
|
||||
for (final h in found) {
|
||||
if (hits.every((x) => x.anidbId != h.anidbId)) hits.add(h);
|
||||
}
|
||||
}
|
||||
return (match: null, hits: hits);
|
||||
}
|
||||
|
||||
/// The linked Jellyfin account AnimeFlow's series ids belong to: the one
|
||||
/// AnimeFlow authenticates with, else the default.
|
||||
JellyfinAccount? jellyfinAccountFor(JellyfinAccounts accounts, AnimeFlowClient? flow) {
|
||||
if (flow != null) {
|
||||
final token = AnimeFlowClient.tokenFor(accounts.accounts, flow.baseUrl);
|
||||
final a = accounts.accounts.where((a) => token != null && a.token == token).firstOrNull;
|
||||
if (a != null) return a;
|
||||
}
|
||||
return accounts.defaultAccount;
|
||||
}
|
||||
|
||||
/// The (season, episode) numbers of the episodes Jellyfin has of
|
||||
/// [jellyfinSeriesId], seen by the account AnimeFlow's ids belong to
|
||||
/// ([jellyfinAccountFor]); null without a linked account. One request per
|
||||
/// series, memoised a few minutes ([JellyfinAccounts.cached]).
|
||||
Future<Set<(int, int)>?> jellyfinEpisodeNumbers(
|
||||
JellyfinAccounts accounts,
|
||||
AnimeFlowClient? flow,
|
||||
String jellyfinSeriesId,
|
||||
) async {
|
||||
await accounts.ensureLoaded(validate: false);
|
||||
final account = jellyfinAccountFor(accounts, flow);
|
||||
final client = account == null ? null : accounts.clientFor(account.serverId);
|
||||
if (account == null || client == null) return null;
|
||||
return accounts.cached(account.serverId, 'schedule-episodes:$jellyfinSeriesId', () async => {
|
||||
for (final x in await client.episodes(jellyfinSeriesId))
|
||||
if (x.parentIndexNumber != null && x.indexNumber != null) (x.parentIndexNumber!, x.indexNumber!),
|
||||
});
|
||||
}
|
||||
|
||||
/// The Jellyfin episode of an available [e] as a media pick. Throws
|
||||
/// [ScheduleActionException] (or a client error) when it cannot be found.
|
||||
Future<JellyfinMedia> resolveJellyfinEpisode(JellyfinAccounts accounts, AnimeFlowClient? flow, ScheduleEntry e) async {
|
||||
final seriesId = e.jellyfinSeriesId;
|
||||
if (seriesId == null) throw const ScheduleActionException('This series is not on Jellyfin yet');
|
||||
await accounts.ensureLoaded(validate: false);
|
||||
final account = jellyfinAccountFor(accounts, flow);
|
||||
final client = account == null ? null : accounts.clientFor(account.serverId);
|
||||
if (account == null || client == null) {
|
||||
throw const ScheduleActionException('Link the Jellyfin server first (Settings)');
|
||||
}
|
||||
final n = await flowNumbering(flow, e);
|
||||
final List<JellyfinItem> eps;
|
||||
try {
|
||||
eps = await client.episodes(seriesId);
|
||||
} on JellyfinException catch (err) {
|
||||
if (err.isUnauthorized) accounts.markExpired(account.serverId);
|
||||
rethrow;
|
||||
}
|
||||
final a = e.airing;
|
||||
final item =
|
||||
eps.where((x) => x.parentIndexNumber == n.season && x.indexNumber == n.episode).firstOrNull ??
|
||||
eps.where((x) => x.premiereDate != null && _sameDay(x.premiereDate!, a.airingAt)).singleOrNull ??
|
||||
eps.where((x) => x.indexNumber == a.episode).singleOrNull;
|
||||
if (item == null) {
|
||||
throw ScheduleActionException('Episode ${a.episode} of ${a.title} is not on Jellyfin yet');
|
||||
}
|
||||
return pickFor(account, item);
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../schedule_model.dart';
|
||||
|
||||
// One day of the Schedule board and its airing cards: cover poster on the
|
||||
// left (air time on it, download progress under it), title, episode and
|
||||
// status on the right, one full-width action below. Compact mode is a
|
||||
// slim row.
|
||||
|
||||
/// What a card's action does, decided by the page.
|
||||
enum CardAction {
|
||||
none,
|
||||
putUp,
|
||||
|
||||
/// Available, nobody else watching (offline or solo): plays it here.
|
||||
play,
|
||||
request,
|
||||
requestSeries,
|
||||
requested,
|
||||
}
|
||||
|
||||
String _two(int n) => n.toString().padLeft(2, '0');
|
||||
|
||||
String clockText(DateTime t) {
|
||||
final l = t.toLocal();
|
||||
return '${_two(l.hour)}:${_two(l.minute)}';
|
||||
}
|
||||
|
||||
String countdownText(DateTime at, DateTime now) {
|
||||
final d = at.difference(now);
|
||||
if (d.isNegative) return 'aired';
|
||||
if (d.inDays > 0) return 'in ${d.inDays}d ${d.inHours % 24}h';
|
||||
if (d.inHours > 0) return 'in ${d.inHours}h ${d.inMinutes % 60}m';
|
||||
return 'in ${d.inMinutes}m';
|
||||
}
|
||||
|
||||
/// Status line: text, colour, icon.
|
||||
(String, Color, IconData) statusBadge(ScheduleEntry e, DateTime now) => switch (e.status) {
|
||||
ScheduleStatus.available => ('on Jellyfin', Colors.greenAccent, Icons.check_circle),
|
||||
ScheduleStatus.downloading => (
|
||||
e.progress == null ? 'downloading' : 'downloading ${(e.progress! * 100).round()}%',
|
||||
Colors.lightBlueAccent,
|
||||
Icons.downloading,
|
||||
),
|
||||
ScheduleStatus.upcoming => (countdownText(e.airing.airingAt, now), Colors.amber, Icons.schedule),
|
||||
ScheduleStatus.missing => ('missing', Colors.orangeAccent, Icons.report_gmailerrorred),
|
||||
ScheduleStatus.failed => ('failed${e.reason == null ? '' : ' · ${e.reason}'}', Colors.redAccent, Icons.error_outline),
|
||||
ScheduleStatus.unmonitored => ('not monitored', Colors.grey, Icons.visibility_off_outlined),
|
||||
ScheduleStatus.notTracked => ('not in AnimeFlow', Colors.grey, Icons.public),
|
||||
ScheduleStatus.inLibrary => ('on Jellyfin', Colors.greenAccent, Icons.video_library_outlined),
|
||||
ScheduleStatus.notOnJellyfin => ('aired · not on Jellyfin yet', Colors.grey, Icons.hourglass_empty),
|
||||
ScheduleStatus.aired => ('aired', Colors.grey, Icons.done),
|
||||
};
|
||||
|
||||
class DayColumn extends StatelessWidget {
|
||||
final DateTime day;
|
||||
final bool today, past;
|
||||
final List<Widget> cards;
|
||||
final bool compact;
|
||||
const DayColumn({
|
||||
super.key,
|
||||
required this.day,
|
||||
required this.today,
|
||||
required this.past,
|
||||
required this.cards,
|
||||
required this.compact,
|
||||
});
|
||||
|
||||
static const _wd = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: today ? scheme.primary.withValues(alpha: 0.06) : scheme.surfaceContainerLow.withValues(alpha: 0.5),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: today ? Border.all(color: scheme.primary.withValues(alpha: 0.5)) : null,
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 10, 12, 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
today ? 'Today' : _wd[day.weekday - 1],
|
||||
style: TextStyle(fontWeight: FontWeight.w700, color: today ? scheme.primary : null),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text('${day.day}.${day.month}.', style: TextStyle(color: scheme.onSurfaceVariant)),
|
||||
const Spacer(),
|
||||
if (cards.isNotEmpty)
|
||||
Text('${cards.length}', style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12)),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Opacity(
|
||||
opacity: past ? 0.85 : 1,
|
||||
child: cards.isEmpty
|
||||
? Center(
|
||||
child: Text('—', style: TextStyle(color: scheme.onSurfaceVariant)),
|
||||
)
|
||||
// The board's mouse drag is sideways only: a column scrolls by
|
||||
// wheel (or touch), never by dragging the mouse.
|
||||
: ScrollConfiguration(
|
||||
behavior: ScrollConfiguration.of(context)
|
||||
.copyWith(dragDevices: {...PointerDeviceKind.values}..remove(PointerDeviceKind.mouse)),
|
||||
child: ListView.separated(
|
||||
padding: const EdgeInsets.fromLTRB(8, 0, 8, 10),
|
||||
itemCount: cards.length,
|
||||
separatorBuilder: (_, _) => SizedBox(height: compact ? 6 : 10),
|
||||
itemBuilder: (context, i) => cards[i],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class AiringCard extends StatelessWidget {
|
||||
final ScheduleEntry entry;
|
||||
final DateTime now;
|
||||
final bool compact;
|
||||
final CardAction action;
|
||||
|
||||
/// Why [action] is disabled (no request scope, not on Jellyfin, ...);
|
||||
/// null = enabled.
|
||||
final String? disabledHint;
|
||||
|
||||
/// A call for this card is in flight.
|
||||
final bool busy;
|
||||
final VoidCallback onAction;
|
||||
|
||||
const AiringCard({
|
||||
super.key,
|
||||
required this.entry,
|
||||
required this.now,
|
||||
required this.compact,
|
||||
required this.action,
|
||||
required this.onAction,
|
||||
this.disabledHint,
|
||||
this.busy = false,
|
||||
});
|
||||
|
||||
String get _id => '${entry.airing.anilistId}-${entry.airing.episode}';
|
||||
|
||||
Widget _image(String? url, Widget fallback) =>
|
||||
url == null ? fallback : Image.network(url, fit: BoxFit.cover, errorBuilder: (_, _, _) => fallback);
|
||||
|
||||
Widget? _button(BuildContext context) {
|
||||
final enabled = disabledHint == null && !busy;
|
||||
final spinner = const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2));
|
||||
Widget? b = switch (action) {
|
||||
CardAction.none => null,
|
||||
CardAction.putUp || CardAction.play => FilledButton.tonalIcon(
|
||||
onPressed: enabled ? onAction : null,
|
||||
icon: busy ? spinner : const Icon(Icons.play_arrow, size: 18),
|
||||
label: Text(action == CardAction.play ? 'Play' : 'Put it up'),
|
||||
),
|
||||
CardAction.request || CardAction.requestSeries => OutlinedButton.icon(
|
||||
onPressed: enabled ? onAction : null,
|
||||
icon: busy ? spinner : const Icon(Icons.download, size: 18),
|
||||
label: Text(action == CardAction.request ? 'Request' : 'Request series'),
|
||||
),
|
||||
CardAction.requested => const OutlinedButton(onPressed: null, child: Text('Requested')),
|
||||
};
|
||||
if (b == null) return null;
|
||||
b = KeyedSubtree(key: ValueKey('action-$_id'), child: b);
|
||||
return disabledHint == null ? b : Tooltip(message: disabledHint!, child: b);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final a = entry.airing;
|
||||
final (label, color, icon) = statusBadge(entry, now);
|
||||
final time = clockText(a.airingAt);
|
||||
final tintColor = a.color ?? Colors.blueGrey;
|
||||
final tint = DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [tintColor.withValues(alpha: 0.85), tintColor.withValues(alpha: 0.35)],
|
||||
),
|
||||
),
|
||||
);
|
||||
final tap = (action == CardAction.putUp || action == CardAction.play) && disabledHint == null && !busy ? onAction : null;
|
||||
if (compact) {
|
||||
final requestable = action == CardAction.request || action == CardAction.requestSeries;
|
||||
return Material(
|
||||
key: ValueKey('card-$_id'),
|
||||
color: scheme.surfaceContainer,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
onTap: tap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(6),
|
||||
child: Row(
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: SizedBox(width: 34, height: 48, child: _image(a.cover, tint)),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
a.title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 13),
|
||||
),
|
||||
Text('$time · E${a.episode}', style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 11)),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (requestable)
|
||||
Tooltip(
|
||||
message: disabledHint ?? (action == CardAction.request ? 'Request' : 'Request series'),
|
||||
child: IconButton(
|
||||
key: ValueKey('action-$_id'),
|
||||
onPressed: disabledHint == null && !busy ? onAction : null,
|
||||
visualDensity: VisualDensity.compact,
|
||||
iconSize: 18,
|
||||
icon: Icon(Icons.download, color: color),
|
||||
),
|
||||
)
|
||||
else
|
||||
Tooltip(
|
||||
message: label,
|
||||
child: Icon(icon, size: 16, color: color),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
final button = _button(context);
|
||||
// The cover poster leads: it is what people recognise at a glance.
|
||||
return Material(
|
||||
key: ValueKey('card-$_id'),
|
||||
color: scheme.surfaceContainer,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: InkWell(
|
||||
onTap: tap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: SizedBox(
|
||||
width: 84,
|
||||
height: 120,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
_image(a.cover, tint),
|
||||
Positioned(
|
||||
left: 4,
|
||||
top: 4,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withValues(alpha: 0.7),
|
||||
borderRadius: BorderRadius.circular(5),
|
||||
),
|
||||
child: Text(
|
||||
time,
|
||||
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w700, fontSize: 12),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (entry.status == ScheduleStatus.downloading)
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: LinearProgressIndicator(
|
||||
value: entry.progress,
|
||||
minHeight: 3,
|
||||
color: Colors.lightBlueAccent,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: SizedBox(
|
||||
height: 120,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
a.title,
|
||||
maxLines: 3,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(fontWeight: FontWeight.w600, height: 1.25),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'Episode ${a.episode}',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12),
|
||||
),
|
||||
const Spacer(),
|
||||
Row(
|
||||
children: [
|
||||
Icon(icon, size: 14, color: color),
|
||||
const SizedBox(width: 4),
|
||||
Expanded(
|
||||
child: Text(
|
||||
label,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(color: color, fontSize: 12),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (button != null) ...[const SizedBox(height: 8), SizedBox(width: double.infinity, child: button)],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,564 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../jellyfin/accounts.dart';
|
||||
import '../../screens/home/picked_media.dart';
|
||||
import '../../shell/avatar.dart';
|
||||
import '../../shell/motion.dart';
|
||||
import '../anilist.dart';
|
||||
import '../animeflow.dart';
|
||||
import '../schedule_model.dart';
|
||||
import 'requests_sheet.dart';
|
||||
import 'schedule_actions.dart';
|
||||
import 'schedule_card.dart';
|
||||
|
||||
// The Schedule tab: one column per day from last week's Monday to four
|
||||
// weeks out (AniList), each airing with what AnimeFlow has of it, and the
|
||||
// one thing to do about it (put it up, request it). Refreshes every
|
||||
// minute while visible; ETags keep that cheap.
|
||||
|
||||
enum ScheduleFilter { jellyfin, all }
|
||||
|
||||
class SchedulePage extends StatefulWidget {
|
||||
/// Nobody else watches (offline or solo): available episodes say Play
|
||||
/// (offline that starts a solo session).
|
||||
final bool solo;
|
||||
|
||||
/// Puts a Jellyfin episode up in the server; returns an error to show.
|
||||
final Future<String?> Function(JellyfinMedia pick) onPutUp;
|
||||
final void Function(String message) onToast;
|
||||
|
||||
/// Null = the page makes (and closes) its own.
|
||||
final ScheduleRepository? repository;
|
||||
|
||||
/// Linked Jellyfin servers for Put it up; null = [JellyfinAccounts.instance].
|
||||
final JellyfinAccounts? accounts;
|
||||
|
||||
const SchedulePage({
|
||||
super.key,
|
||||
this.solo = false,
|
||||
required this.onPutUp,
|
||||
required this.onToast,
|
||||
this.repository,
|
||||
this.accounts,
|
||||
});
|
||||
|
||||
static const columnWidth = 252.0;
|
||||
static const columnGap = 12.0;
|
||||
static const refreshEvery = Duration(seconds: 60);
|
||||
|
||||
/// "All airing" hides entries below this AniList popularity unless
|
||||
/// "Niche too" is on (tracked ones always show).
|
||||
static const nicheBelow = 5000;
|
||||
static const sheetWidth = 360.0;
|
||||
|
||||
@override
|
||||
State<SchedulePage> createState() => _SchedulePageState();
|
||||
}
|
||||
|
||||
class _SchedulePageState extends State<SchedulePage> {
|
||||
late final ScheduleRepository _repo;
|
||||
late final bool _ownsRepo;
|
||||
ScheduleSnapshot? _snap;
|
||||
bool _refreshing = false;
|
||||
int _gen = 0;
|
||||
Timer? _timer;
|
||||
final _scroll = ScrollController();
|
||||
bool _jumped = false;
|
||||
|
||||
ScheduleFilter _filter = ScheduleFilter.jellyfin;
|
||||
bool _compact = false;
|
||||
bool _niche = false;
|
||||
bool _sheet = false;
|
||||
|
||||
/// The sheet stays built while it slides out.
|
||||
bool _sheetBuilt = false;
|
||||
|
||||
|
||||
/// Requested in this run: "anilistId#episode" or "series:anilistId".
|
||||
final _requested = <String>{};
|
||||
|
||||
/// Cards with a call in flight (same keys as [_requested], "play:...").
|
||||
final _busy = <String>{};
|
||||
|
||||
List<AnimeFlowRequest>? _requests;
|
||||
String? _requestsError;
|
||||
final _reqBusy = <int>{};
|
||||
|
||||
JellyfinAccounts get _accounts => widget.accounts ?? JellyfinAccounts.instance;
|
||||
AnimeFlowClient? get _flow => _repo.animeFlow;
|
||||
AnimeFlowMe? get _me => _snap?.me;
|
||||
bool get _owner => _me?.can(AnimeFlowScope.manage) ?? false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_ownsRepo = widget.repository == null;
|
||||
_repo = widget.repository ?? _ownRepo();
|
||||
_load();
|
||||
_timer = Timer.periodic(SchedulePage.refreshEvery, (_) {
|
||||
if (mounted && !_refreshing && TickerMode.valuesOf(context).enabled) _load();
|
||||
});
|
||||
}
|
||||
|
||||
ScheduleRepository _ownRepo() {
|
||||
final flow = AnimeFlowClient.fromAccounts(_accounts);
|
||||
return ScheduleRepository(
|
||||
aniList: AniListClient(),
|
||||
animeFlow: flow,
|
||||
jellyfinEpisodes: (id) => jellyfinEpisodeNumbers(_accounts, flow, id),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_timer?.cancel();
|
||||
_scroll.dispose();
|
||||
if (_ownsRepo) {
|
||||
_repo.aniList.close();
|
||||
_repo.animeFlow?.close();
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _toast(String s) {
|
||||
if (mounted) widget.onToast(s);
|
||||
}
|
||||
|
||||
Future<void> _load({bool force = false}) async {
|
||||
final gen = ++_gen;
|
||||
if (_snap != null) setState(() => _refreshing = true);
|
||||
final snap = await _repo.load(force: force);
|
||||
if (!mounted || gen != _gen) return;
|
||||
final first = _snap == null;
|
||||
setState(() {
|
||||
_snap = snap;
|
||||
_refreshing = false;
|
||||
// Without AnimeFlow nothing is "on Jellyfin": start on everything.
|
||||
if (first && !snap.hasAnimeFlow) _filter = ScheduleFilter.all;
|
||||
});
|
||||
if (!_jumped && snap.entries.isNotEmpty) {
|
||||
_jumped = true;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _jumpToday());
|
||||
}
|
||||
await _loadRequests();
|
||||
}
|
||||
|
||||
Future<void> _loadRequests() async {
|
||||
final c = _flow, me = _me;
|
||||
if (c == null || me == null) {
|
||||
if (_requests != null || _requestsError != null) {
|
||||
setState(() {
|
||||
_requests = null;
|
||||
_requestsError = null;
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
try {
|
||||
final reqs = await c.requests(all: me.can(AnimeFlowScope.manage));
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_requests = reqs;
|
||||
_requestsError = null;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) setState(() => _requestsError = friendlyError(e));
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- layout
|
||||
|
||||
int _todayIndex(DateTime from) {
|
||||
final now = DateTime.now();
|
||||
final today = DateTime(now.year, now.month, now.day);
|
||||
final f = DateTime(from.year, from.month, from.day);
|
||||
return (today.difference(f).inHours / 24).round();
|
||||
}
|
||||
|
||||
void _jumpToday({bool animate = false}) {
|
||||
final snap = _snap;
|
||||
if (!_scroll.hasClients || snap == null) return;
|
||||
final i = _todayIndex(snap.from.toLocal());
|
||||
final x = (i * (SchedulePage.columnWidth + SchedulePage.columnGap))
|
||||
.clamp(0.0, _scroll.position.maxScrollExtent)
|
||||
.toDouble();
|
||||
animate ? _scroll.animateTo(x, duration: easeTime, curve: ease) : _scroll.jumpTo(x);
|
||||
}
|
||||
|
||||
bool _shows(ScheduleEntry e) => switch (_filter) {
|
||||
ScheduleFilter.jellyfin => e.tracked || e.onJellyfin,
|
||||
ScheduleFilter.all => _niche || e.tracked || e.airing.popularity >= SchedulePage.nicheBelow,
|
||||
};
|
||||
|
||||
static String _key(ScheduleEntry e) =>
|
||||
e.tracked ? '${e.airing.anilistId}#${e.airing.episode}' : 'series:${e.airing.anilistId}';
|
||||
|
||||
static const _active = {RequestStatus.pending, RequestStatus.approved, RequestStatus.searching};
|
||||
|
||||
bool _isRequested(ScheduleEntry e) {
|
||||
if (_requested.contains(_key(e))) return true;
|
||||
final a = e.airing;
|
||||
for (final r in _requests ?? const <AnimeFlowRequest>[]) {
|
||||
if (!_active.contains(r.status)) continue;
|
||||
if (e.tracked) {
|
||||
if (r.kind == RequestKind.episode && r.seriesId == e.seriesId && r.episode == a.episode) return true;
|
||||
if (r.kind == RequestKind.season && r.seriesId == e.seriesId && r.season == e.season) return true;
|
||||
} else if (r.kind == RequestKind.series && (r.title == a.title || r.title == a.titleRomaji)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
(CardAction, String?) _actionFor(ScheduleEntry e) {
|
||||
if (e.status == ScheduleStatus.available || e.status == ScheduleStatus.inLibrary) {
|
||||
final put = widget.solo ? CardAction.play : CardAction.putUp;
|
||||
if (e.jellyfinSeriesId == null) return (put, 'Not linked to a Jellyfin series yet');
|
||||
return (put, null);
|
||||
}
|
||||
final me = _me;
|
||||
final CardAction kind;
|
||||
if (e.tracked) {
|
||||
kind = e.status == ScheduleStatus.missing || e.status == ScheduleStatus.failed
|
||||
? CardAction.request
|
||||
: CardAction.none;
|
||||
} else {
|
||||
// On Jellyfin already (owner added it by hand): nothing to request.
|
||||
kind = me != null && !e.onJellyfin ? CardAction.requestSeries : CardAction.none;
|
||||
}
|
||||
if (kind == CardAction.none) return (kind, null);
|
||||
if (_isRequested(e)) return (CardAction.requested, null);
|
||||
if (me == null || !me.can(AnimeFlowScope.request)) {
|
||||
return (kind, 'Requests need the owner to allow them for you on AnimeFlow');
|
||||
}
|
||||
return (kind, null);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- actions
|
||||
|
||||
Future<void> _onAction(ScheduleEntry e, CardAction action) async {
|
||||
switch (action) {
|
||||
case CardAction.putUp:
|
||||
case CardAction.play:
|
||||
return _putUp(e);
|
||||
case CardAction.request:
|
||||
case CardAction.requestSeries:
|
||||
return _request(e);
|
||||
default:
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _putUp(ScheduleEntry e) async {
|
||||
final key = 'play:${_key(e)}';
|
||||
if (_busy.contains(key)) return;
|
||||
setState(() => _busy.add(key));
|
||||
try {
|
||||
final pick = await resolveJellyfinEpisode(_accounts, _flow, e);
|
||||
final err = await widget.onPutUp(pick);
|
||||
if (err != null) _toast(err);
|
||||
} catch (err) {
|
||||
_toast(friendlyError(err));
|
||||
} finally {
|
||||
if (mounted) setState(() => _busy.remove(key));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _request(ScheduleEntry e) async {
|
||||
final c = _flow;
|
||||
if (c == null) return;
|
||||
final key = _key(e);
|
||||
if (_busy.contains(key)) return;
|
||||
setState(() => _busy.add(key));
|
||||
final a = e.airing;
|
||||
try {
|
||||
final seriesId = e.seriesId;
|
||||
if (e.tracked && seriesId != null) {
|
||||
final n = await flowNumbering(c, e);
|
||||
await c.requestEpisode(seriesId, n.season, n.episode);
|
||||
_requested.add(key);
|
||||
_toast('Requested ${a.title}, episode ${a.episode}');
|
||||
} else {
|
||||
final found = await findOnAniDb(c, a);
|
||||
final hit = found.match ?? (mounted ? await _chooseHit(a, found.hits) : null);
|
||||
if (hit == null) return;
|
||||
await c.requestSeries(hit.anidbId);
|
||||
_requested.add(key);
|
||||
_toast('Requested ${hit.title}');
|
||||
}
|
||||
unawaited(_loadRequests());
|
||||
} on AnimeFlowException catch (err) {
|
||||
if (err.code == AnimeFlowErrorCode.alreadyRequested) _requested.add(key);
|
||||
_toast(friendlyError(err));
|
||||
} catch (err) {
|
||||
_toast(friendlyError(err));
|
||||
} finally {
|
||||
if (mounted) setState(() => _busy.remove(key));
|
||||
}
|
||||
}
|
||||
|
||||
/// No search hit has [a]'s AniList id: let the user pick one (or learn
|
||||
/// that AniDB does not know it).
|
||||
Future<AnimeFlowSearchHit?> _chooseHit(Airing a, List<AnimeFlowSearchHit> hits) => showDialog<AnimeFlowSearchHit>(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
return AlertDialog(
|
||||
key: const ValueKey('anidb-choose'),
|
||||
title: Text(hits.isEmpty ? 'Not found on AniDB' : 'Which one is "${a.title}"?'),
|
||||
content: SizedBox(
|
||||
width: 420,
|
||||
child: hits.isEmpty
|
||||
? Text(
|
||||
'AnimeFlow can only add series AniDB knows, and its search found nothing for this title.',
|
||||
style: TextStyle(color: scheme.onSurfaceVariant),
|
||||
)
|
||||
: ListView(
|
||||
shrinkWrap: true,
|
||||
children: [
|
||||
for (final h in hits)
|
||||
ListTile(
|
||||
key: ValueKey('anidb-${h.anidbId}'),
|
||||
title: Text(h.title),
|
||||
subtitle: Text('AniDB ${h.anidbId}${h.seriesId != null ? ' · already on AnimeFlow' : ''}'),
|
||||
onTap: () => Navigator.of(context).pop(h),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.of(context).pop(), child: Text(hits.isEmpty ? 'OK' : 'Cancel')),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Future<void> _decide(AnimeFlowRequest r, {required bool approve}) async {
|
||||
final c = _flow;
|
||||
if (c == null || _reqBusy.contains(r.id)) return;
|
||||
setState(() => _reqBusy.add(r.id));
|
||||
try {
|
||||
approve ? await c.approve(r.id) : await c.reject(r.id);
|
||||
_toast(approve ? 'Retrying ${r.title}' : 'Declined ${r.title}');
|
||||
await _loadRequests();
|
||||
} catch (err) {
|
||||
_toast(friendlyError(err));
|
||||
} finally {
|
||||
if (mounted) setState(() => _reqBusy.remove(r.id));
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- build
|
||||
|
||||
Widget _header(BuildContext context) {
|
||||
final snap = _snap;
|
||||
final me = _me;
|
||||
final stale = snap?.aniListStaleSince;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 16, 20, 8),
|
||||
child: Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 8,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
SegmentedButton<ScheduleFilter>(
|
||||
key: const ValueKey('schedule-filter'),
|
||||
showSelectedIcon: false,
|
||||
segments: const [
|
||||
ButtonSegment(
|
||||
value: ScheduleFilter.jellyfin,
|
||||
icon: Icon(Icons.video_library_outlined),
|
||||
label: Text('On Jellyfin'),
|
||||
),
|
||||
ButtonSegment(value: ScheduleFilter.all, icon: Icon(Icons.public), label: Text('All airing')),
|
||||
],
|
||||
selected: {_filter},
|
||||
onSelectionChanged: (s) => setState(() => _filter = s.first),
|
||||
),
|
||||
FilterChip(
|
||||
key: const ValueKey('schedule-compact'),
|
||||
label: const Text('Compact'),
|
||||
selected: _compact,
|
||||
onSelected: (v) => setState(() => _compact = v),
|
||||
),
|
||||
if (_filter == ScheduleFilter.all)
|
||||
FilterChip(
|
||||
key: const ValueKey('schedule-niche'),
|
||||
label: const Text('Niche too'),
|
||||
selected: _niche,
|
||||
onSelected: (v) => setState(() => _niche = v),
|
||||
),
|
||||
TextButton.icon(
|
||||
key: const ValueKey('schedule-today'),
|
||||
onPressed: () => _jumpToday(animate: true),
|
||||
icon: const Icon(Icons.today, size: 18),
|
||||
label: const Text('Today'),
|
||||
),
|
||||
IconButton(
|
||||
key: const ValueKey('schedule-refresh'),
|
||||
tooltip: 'Refresh',
|
||||
onPressed: _refreshing || snap == null ? null : () => _load(force: true),
|
||||
icon: _refreshing
|
||||
? const SizedBox(width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2))
|
||||
: const Icon(Icons.refresh, size: 20),
|
||||
),
|
||||
Tooltip(
|
||||
message: me == null ? (snap?.animeFlowError ?? 'AnimeFlow is not available') : '',
|
||||
child: OutlinedButton.icon(
|
||||
key: const ValueKey('schedule-requests'),
|
||||
onPressed: me == null
|
||||
? null
|
||||
: () => setState(() {
|
||||
_sheet = !_sheet;
|
||||
if (_sheet) _sheetBuilt = true;
|
||||
}),
|
||||
icon: const Icon(Icons.inbox_outlined, size: 18),
|
||||
label: Text(_owner ? 'Requests' : 'My requests'),
|
||||
),
|
||||
),
|
||||
if (stale != null)
|
||||
Pill(
|
||||
key: const ValueKey('schedule-stale'),
|
||||
'AniList busy · showing data from ${clockText(stale)}',
|
||||
Colors.amber,
|
||||
),
|
||||
if (me != null)
|
||||
Pill(
|
||||
key: const ValueKey('schedule-animeflow'),
|
||||
'AnimeFlow · ${me.name} · ${[for (final s in AnimeFlowScope.values)
|
||||
if (me.can(s)) s.name].join(', ')}',
|
||||
Colors.greenAccent.shade400,
|
||||
)
|
||||
else if (snap?.animeFlowError != null)
|
||||
Pill(key: const ValueKey('schedule-animeflow'), snap!.animeFlowError!, Colors.orangeAccent),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _board(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final snap = _snap;
|
||||
if (snap == null) return const Center(child: CircularProgressIndicator());
|
||||
if (snap.aniListError != null && snap.entries.isEmpty) {
|
||||
return Center(
|
||||
key: const ValueKey('schedule-error'),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.cloud_off, color: scheme.error),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Could not load the schedule: ${snap.aniListError}',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: scheme.error),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
FilledButton.tonal(
|
||||
onPressed: _refreshing ? null : () => _load(force: true),
|
||||
child: const Text('Try again'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
final now = DateTime.now();
|
||||
final today = DateTime(now.year, now.month, now.day);
|
||||
final from = snap.from.toLocal();
|
||||
final start = DateTime(from.year, from.month, from.day);
|
||||
final to = snap.to.toLocal();
|
||||
final count = (DateTime(to.year, to.month, to.day).difference(start).inHours / 24).round().clamp(1, 60);
|
||||
final days = [for (var d = 0; d < count; d++) DateTime(start.year, start.month, start.day + d)];
|
||||
final byDay = <DateTime, List<ScheduleEntry>>{};
|
||||
for (final e in snap.entries) {
|
||||
if (!_shows(e)) continue;
|
||||
final l = e.airing.airingAt.toLocal();
|
||||
(byDay[DateTime(l.year, l.month, l.day)] ??= []).add(e);
|
||||
}
|
||||
return HScroll(
|
||||
controller: _scroll,
|
||||
builder: (c) => ListView.separated(
|
||||
key: const ValueKey('schedule-board'),
|
||||
controller: c,
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.fromLTRB(20, 4, 20, 16),
|
||||
itemCount: days.length,
|
||||
separatorBuilder: (_, _) => const SizedBox(width: SchedulePage.columnGap),
|
||||
itemBuilder: (context, i) {
|
||||
final d = days[i];
|
||||
final items = byDay[d] ?? const <ScheduleEntry>[];
|
||||
return SizedBox(
|
||||
key: ValueKey('day-${d.year}-${d.month}-${d.day}'),
|
||||
width: SchedulePage.columnWidth,
|
||||
child: DayColumn(
|
||||
day: d,
|
||||
today: d == today,
|
||||
past: d.isBefore(today),
|
||||
compact: _compact,
|
||||
cards: [for (final e in items) _card(e, now)],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _card(ScheduleEntry e, DateTime now) {
|
||||
final (action, hint) = _actionFor(e);
|
||||
final busy = _busy.contains(action == CardAction.putUp || action == CardAction.play ? 'play:${_key(e)}' : _key(e));
|
||||
return AiringCard(
|
||||
entry: e,
|
||||
now: now,
|
||||
compact: _compact,
|
||||
action: action,
|
||||
disabledHint: hint,
|
||||
busy: busy,
|
||||
onAction: () => _onAction(e, action),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
children: [
|
||||
_header(context),
|
||||
Expanded(child: _board(context)),
|
||||
],
|
||||
),
|
||||
),
|
||||
ClipRect(
|
||||
child: AnimatedContainer(
|
||||
duration: easeTime,
|
||||
curve: ease,
|
||||
width: _sheet ? SchedulePage.sheetWidth : 0,
|
||||
child: OverflowBox(
|
||||
alignment: Alignment.centerLeft,
|
||||
minWidth: SchedulePage.sheetWidth,
|
||||
maxWidth: SchedulePage.sheetWidth,
|
||||
child: _sheetBuilt && _me != null
|
||||
? RequestsSheet(
|
||||
key: const ValueKey('requests-sheet'),
|
||||
owner: _owner,
|
||||
requests: _requests,
|
||||
error: _requestsError,
|
||||
selfName: _me?.name ?? '',
|
||||
busy: _reqBusy,
|
||||
onRetry: (r) => _decide(r, approve: true),
|
||||
onDecline: (r) => _decide(r, approve: false),
|
||||
onClose: () => setState(() => _sheet = false),
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -2,9 +2,10 @@ import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
|
||||
import '../../update/update_controller.dart';
|
||||
import '../../update/updater.dart';
|
||||
import 'settings_page.dart';
|
||||
import 'ui.dart';
|
||||
import 'update_indicator.dart';
|
||||
|
||||
@@ -25,9 +26,10 @@ Future<bool> openInBrowser(String url) async {
|
||||
}
|
||||
}
|
||||
|
||||
class AboutPage extends StatelessWidget {
|
||||
/// Name, version, update state and the source link (Settings → About).
|
||||
class AboutPanel extends StatelessWidget {
|
||||
final UpdateController? updates;
|
||||
const AboutPage({super.key, this.updates});
|
||||
const AboutPanel({super.key, this.updates});
|
||||
|
||||
Future<void> _open(BuildContext context) async {
|
||||
final messenger = ScaffoldMessenger.maybeOf(context);
|
||||
@@ -40,11 +42,8 @@ class AboutPage extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = Theme.of(context).textTheme;
|
||||
return PageScroll(
|
||||
maxWidth: 680,
|
||||
children: [
|
||||
const PageTitle('About'),
|
||||
Panel(
|
||||
return SettingsPanel(
|
||||
title: 'About',
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
@@ -73,8 +72,6 @@ class AboutPage extends StatelessWidget {
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,8 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:sync_relay_core/core.dart' show HelloMedia;
|
||||
|
||||
import '../../session/session_coordinator.dart';
|
||||
import '../../state/session_state.dart';
|
||||
import '../../widgets/avatar_pipeline.dart';
|
||||
import '../../widgets/media_input.dart';
|
||||
import 'profile.dart';
|
||||
|
||||
/// What a card shows under its fields after a failed attempt.
|
||||
class CardError {
|
||||
@@ -15,7 +11,7 @@ class CardError {
|
||||
const CardError(this.title, this.message);
|
||||
}
|
||||
|
||||
/// Host/Join submit state for the two Home cards. One attempt at a time;
|
||||
/// Host/Join submit state for the strip's two connect cards. One attempt at a time;
|
||||
/// [cancel] abandons it via [SessionState.cancelPending].
|
||||
class ConnectFlow extends ChangeNotifier {
|
||||
ConnectFlow({SessionState? session}) : _session = session ?? SessionState.instance;
|
||||
@@ -27,9 +23,6 @@ class ConnectFlow extends ChangeNotifier {
|
||||
int _attempt = 0;
|
||||
bool _disposed = false;
|
||||
|
||||
/// Set when an attempt ends in a kick, so Home shows the removal dialog.
|
||||
void Function(String reason)? onKicked;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_disposed = true;
|
||||
@@ -54,61 +47,38 @@ class ConnectFlow extends ChangeNotifier {
|
||||
}
|
||||
|
||||
/// Validates and builds the params; throws [FormatException] with a
|
||||
/// user-facing message.
|
||||
static Future<ConnectParams> buildParams(ConnectMode mode, ConnectForm f) async {
|
||||
/// user-facing message. [avatar] is the base64 picture (the profile's).
|
||||
/// Hosting puts nothing up: what to watch is picked in the library.
|
||||
static ConnectParams buildParams(ConnectMode mode, ConnectForm f, {String avatar = ''}) {
|
||||
final rawPort = mode == ConnectMode.host ? f.hostPort : f.port;
|
||||
final port = int.tryParse(rawPort.trim());
|
||||
if (port == null || port <= 0 || port > 65535) {
|
||||
throw const FormatException('port must be 1–65535');
|
||||
}
|
||||
if (f.name.trim().isEmpty) {
|
||||
throw const FormatException('set your display name first (click your profile at the top)');
|
||||
throw const FormatException('set your display name first (click your avatar at the top left)');
|
||||
}
|
||||
if (mode == ConnectMode.join && f.host.trim().isEmpty) {
|
||||
throw const FormatException('enter the server address');
|
||||
}
|
||||
String avatar = '';
|
||||
try {
|
||||
avatar = await encodeAvatar(f.avatarPath);
|
||||
} on FormatException catch (e) {
|
||||
throw FormatException(e.message);
|
||||
} catch (_) {
|
||||
throw const FormatException('could not process that avatar — pick a PNG, JPG or WebP photo');
|
||||
}
|
||||
if (mode == ConnectMode.host &&
|
||||
f.mediaPath.isNotEmpty &&
|
||||
!await File(f.mediaPath).exists()) {
|
||||
throw const FormatException('media file not found — check the path or pick again with Browse');
|
||||
}
|
||||
return ConnectParams(
|
||||
host: f.host.trim(),
|
||||
port: port,
|
||||
secret: mode == ConnectMode.host ? f.hostSecret : f.secret,
|
||||
name: f.name.trim(),
|
||||
avatar: avatar,
|
||||
mediaPath: mode == ConnectMode.host ? f.mediaPath : '',
|
||||
linkUrl: mode == ConnectMode.host ? f.linkUrl : '',
|
||||
);
|
||||
}
|
||||
|
||||
/// [edit] fills the card's fields into a copy of the last form, so the
|
||||
/// other card's saved values survive. [media] (host only) is put up
|
||||
/// instead of the form's link or file (a Jellyfin ref; the pick itself is
|
||||
/// remembered in the Jellyfin box, not the form).
|
||||
Future<void> submit(ConnectMode mode, void Function(ConnectForm f) edit,
|
||||
{HelloMedia? media}) async {
|
||||
/// other card's saved values survive.
|
||||
Future<void> submit(ConnectMode mode, void Function(ConnectForm f) edit) async {
|
||||
if (busy != null) return;
|
||||
final myAttempt = ++_attempt;
|
||||
final form = _session.lastForm.copy();
|
||||
edit(form);
|
||||
form.name = form.name.trim();
|
||||
form.host = form.host.trim();
|
||||
if (mode == ConnectMode.host) {
|
||||
final parsed = parseMediaInput(form.linkUrl.isNotEmpty ? form.linkUrl : form.mediaPath);
|
||||
form
|
||||
..mediaPath = parsed.mediaPath
|
||||
..linkUrl = parsed.linkUrl;
|
||||
}
|
||||
busy = mode;
|
||||
errors[mode] = null;
|
||||
notices[mode] = null;
|
||||
@@ -116,8 +86,9 @@ class ConnectFlow extends ChangeNotifier {
|
||||
|
||||
ConnectParams params;
|
||||
try {
|
||||
params = await buildParams(mode, form);
|
||||
if (media != null && mode == ConnectMode.host) params = params.withMedia(media);
|
||||
final profile = Profile.instance;
|
||||
await profile.ensureLoaded();
|
||||
params = buildParams(mode, form, avatar: profile.avatarBase64());
|
||||
} catch (e) {
|
||||
if (myAttempt != _attempt) return;
|
||||
busy = null;
|
||||
@@ -126,6 +97,7 @@ class ConnectFlow extends ChangeNotifier {
|
||||
_notify();
|
||||
return;
|
||||
}
|
||||
final fromSolo = mode == ConnectMode.host && _session.solo.value;
|
||||
try {
|
||||
if (mode == ConnectMode.host) {
|
||||
await _session.host(params, form);
|
||||
@@ -135,20 +107,19 @@ class ConnectFlow extends ChangeNotifier {
|
||||
} catch (e) {
|
||||
if (myAttempt != _attempt) return;
|
||||
busy = null;
|
||||
final kick = _session.coordinator.kickReason.peek();
|
||||
if (kick != null && kick.isNotEmpty) {
|
||||
onKicked?.call(kick);
|
||||
} else {
|
||||
errors[mode] = CardError('Could not connect', '$e');
|
||||
}
|
||||
_notify();
|
||||
return;
|
||||
}
|
||||
if (myAttempt != _attempt) {
|
||||
// Cancelled after it got through: back out.
|
||||
// Cancelled after it got through: back out (an Invite: back to solo).
|
||||
try {
|
||||
if (fromSolo) {
|
||||
await _session.cancelInvite();
|
||||
} else {
|
||||
await _session.leave();
|
||||
await _session.stopServer();
|
||||
}
|
||||
} catch (_) {}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,175 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:signals/signals_flutter.dart';
|
||||
|
||||
import '../../jellyfin/accounts.dart';
|
||||
import '../../state/session_state.dart';
|
||||
import '../../state/settings.dart';
|
||||
import '../../update/update_controller.dart';
|
||||
import 'about_page.dart';
|
||||
import 'connect_flow.dart';
|
||||
import 'health_probe.dart';
|
||||
import 'host_tab.dart';
|
||||
import 'integrations_page.dart';
|
||||
import 'join_tab.dart';
|
||||
import 'profile.dart';
|
||||
import 'settings_page.dart';
|
||||
import 'top_bar.dart';
|
||||
import 'update_indicator.dart';
|
||||
|
||||
/// Everything outside a session: one top bar (profile, Join/Host tabs,
|
||||
/// Settings/Integrations/About, window buttons) over a full-width body.
|
||||
class HomeScreen extends StatelessWidget {
|
||||
const HomeScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const Scaffold(body: HomeShell(windowControls: true));
|
||||
}
|
||||
}
|
||||
|
||||
/// [HomeScreen]'s content. Tests pump it with [windowControls] off (the
|
||||
/// drag area and window buttons need window_manager) and a fake [healthGet].
|
||||
class HomeShell extends StatefulWidget {
|
||||
final UpdateController? updates;
|
||||
final bool autoUpdateCheck;
|
||||
final bool windowControls;
|
||||
final HealthGetter healthGet;
|
||||
/// Linked Jellyfin servers; defaults to [JellyfinAccounts.instance].
|
||||
final JellyfinAccounts? jellyfin;
|
||||
const HomeShell({
|
||||
super.key,
|
||||
this.updates,
|
||||
this.autoUpdateCheck = true,
|
||||
this.windowControls = false,
|
||||
this.healthGet = defaultHealthGet,
|
||||
this.jellyfin,
|
||||
});
|
||||
|
||||
@override
|
||||
State<HomeShell> createState() => _HomeShellState();
|
||||
}
|
||||
|
||||
class _HomeShellState extends State<HomeShell> {
|
||||
HomeView _view = HomeView.join;
|
||||
final _flow = ConnectFlow();
|
||||
late final _join = JoinFields(SessionState.instance.lastForm);
|
||||
late final _jellyfin = widget.jellyfin ?? JellyfinAccounts.instance;
|
||||
late final _host = HostFields(SessionState.instance.lastForm, jellyfin: _jellyfin);
|
||||
late final void Function() _stopKickWatch;
|
||||
late final void Function() _stopCrashWatch;
|
||||
bool _dialogOpen = false;
|
||||
String? _lastKickShown;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
Profile.instance.ensureLoaded();
|
||||
// Reads linked servers and checks their tokens in the background.
|
||||
_jellyfin.ensureLoaded();
|
||||
if (widget.autoUpdateCheck) _autoCheck();
|
||||
_flow.onKicked = _showKickedDialog;
|
||||
// Back from a session that ended in a kick: say so.
|
||||
_stopKickWatch = effect(() {
|
||||
final kick = SessionState.instance.coordinator.kickReason.value;
|
||||
if (kick == null || kick.isEmpty) {
|
||||
_lastKickShown = null;
|
||||
return;
|
||||
}
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _showKickedDialog(kick));
|
||||
});
|
||||
// Back from a session whose hosted server crashed: say so on the Host tab.
|
||||
_stopCrashWatch = effect(() {
|
||||
final crash = SessionState.instance.serverCrash.value;
|
||||
if (crash == null) return;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
SessionState.instance.serverCrash.value = null;
|
||||
if (!mounted) return;
|
||||
_flow.showError(ConnectMode.host, CardError('Server crashed', crash));
|
||||
setState(() => _view = HomeView.host);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_stopKickWatch();
|
||||
_stopCrashWatch();
|
||||
_flow.dispose();
|
||||
_join.dispose();
|
||||
_host.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _autoCheck() async {
|
||||
final s = AppSettings.instance;
|
||||
await s.ensureLoaded();
|
||||
if (s.autoCheckUpdates.peek()) await runUpdateCheck(widget.updates);
|
||||
}
|
||||
|
||||
Future<void> _showKickedDialog(String reason) async {
|
||||
if (!mounted || _dialogOpen || reason == _lastKickShown) return;
|
||||
_lastKickShown = reason;
|
||||
_dialogOpen = true;
|
||||
try {
|
||||
await showDialog<void>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('You were removed'),
|
||||
content: Text(reason.isEmpty ? 'The host removed you from the server.' : reason),
|
||||
actions: [TextButton(onPressed: () => Navigator.pop(context), child: const Text('OK'))],
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
_dialogOpen = false;
|
||||
}
|
||||
}
|
||||
|
||||
void _select(HomeView v) => setState(() => _view = v);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
// Only the visible view is mounted: the Join tab polls /health only
|
||||
// while shown. Form state lives here, so nothing is lost on switch.
|
||||
final body = switch (_view) {
|
||||
HomeView.join =>
|
||||
JoinTab(flow: _flow, fields: _join, healthGet: widget.healthGet),
|
||||
HomeView.host => HostTab(
|
||||
flow: _flow,
|
||||
fields: _host,
|
||||
onOpenIntegrations: () => _select(HomeView.integrations),
|
||||
),
|
||||
HomeView.settings => SettingsPage(updates: widget.updates),
|
||||
HomeView.integrations => IntegrationsPage(jellyfin: _jellyfin),
|
||||
HomeView.about => AboutPage(updates: widget.updates),
|
||||
};
|
||||
return CallbackShortcuts(
|
||||
bindings: {
|
||||
const SingleActivator(LogicalKeyboardKey.digit1, control: true): () =>
|
||||
_select(HomeView.join),
|
||||
const SingleActivator(LogicalKeyboardKey.digit2, control: true): () =>
|
||||
_select(HomeView.host),
|
||||
const SingleActivator(LogicalKeyboardKey.comma, control: true): () =>
|
||||
_select(HomeView.settings),
|
||||
},
|
||||
child: FocusScope(
|
||||
autofocus: true,
|
||||
child: Column(
|
||||
children: [
|
||||
HomeTopBar(
|
||||
view: _view,
|
||||
onSelect: _select,
|
||||
updates: widget.updates,
|
||||
windowControls: widget.windowControls,
|
||||
),
|
||||
Divider(height: 1, thickness: 1, color: scheme.outlineVariant.withValues(alpha: 0.4)),
|
||||
Expanded(
|
||||
child: KeyedSubtree(key: ValueKey(_view), child: body),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,392 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:signals/signals_flutter.dart';
|
||||
import 'package:sync_relay_core/core.dart' show HelloMedia;
|
||||
|
||||
import '../../jellyfin/accounts.dart';
|
||||
import '../../jellyfin/session_media.dart';
|
||||
import '../../state/session_state.dart';
|
||||
import 'connect_flow.dart';
|
||||
import 'connect_status.dart';
|
||||
import 'media_picker.dart';
|
||||
import 'ui.dart';
|
||||
|
||||
/// Host form state, owned by the Home shell so it survives tab switches.
|
||||
class HostFields {
|
||||
HostFields(ConnectForm f, {JellyfinAccounts? jellyfin})
|
||||
: jellyfin = jellyfin ?? JellyfinAccounts.instance,
|
||||
port = TextEditingController(text: f.hostPort),
|
||||
secret = TextEditingController(text: f.hostSecret),
|
||||
media = MediaPickerController(mediaPath: f.mediaPath, linkUrl: f.linkUrl) {
|
||||
// The last Jellyfin pick lives in the Jellyfin box (the connect form
|
||||
// only knows links and files); restore it once that box is read.
|
||||
final noMedia = f.mediaPath.isEmpty && f.linkUrl.isEmpty;
|
||||
this.jellyfin.ensureLoaded().then((_) {
|
||||
final pick = this.jellyfin.lastPick;
|
||||
if (_disposed || pick == null || media.jellyfin != null) return;
|
||||
if (this.jellyfin.account(pick.serverId) == null) return;
|
||||
if (noMedia) {
|
||||
media.pickJellyfin(pick);
|
||||
} else {
|
||||
final source = media.source;
|
||||
media.pickJellyfin(pick);
|
||||
media.source = source;
|
||||
}
|
||||
});
|
||||
}
|
||||
final JellyfinAccounts jellyfin;
|
||||
final TextEditingController port;
|
||||
final TextEditingController secret;
|
||||
final MediaPickerController media;
|
||||
/// Jellyfin browser enlarged: the server column shrinks to a bar.
|
||||
final browserExpanded = ValueNotifier(false);
|
||||
bool _disposed = false;
|
||||
|
||||
void dispose() {
|
||||
_disposed = true;
|
||||
port.dispose();
|
||||
secret.dispose();
|
||||
media.dispose();
|
||||
browserExpanded.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// What hosting a picked Jellyfin item sends: its ref (no URL, no token).
|
||||
/// Throws [FormatException] with a user-facing message when the server is
|
||||
/// not linked any more or its sign-in expired.
|
||||
HelloMedia hostJellyfinMedia(JellyfinAccounts accounts, JellyfinMedia media) {
|
||||
if (accounts.account(media.serverId) == null) {
|
||||
throw const FormatException('that Jellyfin server is not linked any more — pick again');
|
||||
}
|
||||
if (accounts.statusOf(media.serverId) == JellyfinAccountStatus.expired) {
|
||||
throw const FormatException('Jellyfin sign-in expired — reconnect it in Integrations');
|
||||
}
|
||||
return mediaForPick(media);
|
||||
}
|
||||
|
||||
/// Below this width the server column moves under the media picker.
|
||||
const hostStackBelow = 860.0;
|
||||
const _columnWidth = 340.0;
|
||||
|
||||
/// Media picker as the main area; port/password/Host in a narrow column.
|
||||
class HostTab extends StatelessWidget {
|
||||
final ConnectFlow flow;
|
||||
final HostFields fields;
|
||||
final VoidCallback? onOpenIntegrations;
|
||||
const HostTab(
|
||||
{super.key, required this.flow, required this.fields, this.onOpenIntegrations});
|
||||
|
||||
void _host() {
|
||||
if (flow.busy != null) return;
|
||||
final media = fields.media.selection;
|
||||
HelloMedia? jellyfinMedia;
|
||||
if (media is JellyfinMedia) {
|
||||
try {
|
||||
jellyfinMedia = hostJellyfinMedia(fields.jellyfin, media);
|
||||
} on FormatException catch (e) {
|
||||
flow.showError(ConnectMode.host, CardError('Check the form', e.message));
|
||||
return;
|
||||
}
|
||||
fields.jellyfin.rememberPick(media);
|
||||
}
|
||||
flow.submit(
|
||||
ConnectMode.host,
|
||||
(f) => f
|
||||
..hostPort = fields.port.text.trim()
|
||||
..hostSecret = fields.secret.text
|
||||
..mediaPath = media is FileMedia ? media.path : ''
|
||||
..linkUrl = media is LinkMedia ? media.url : '',
|
||||
media: jellyfinMedia,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = Theme.of(context).textTheme;
|
||||
return ListenableBuilder(
|
||||
listenable: Listenable.merge([flow, fields.media, fields.browserExpanded]),
|
||||
builder: (context, _) {
|
||||
final busy = flow.busy == ConnectMode.host;
|
||||
final jellyfin = fields.media.source == MediaKind.jellyfin;
|
||||
final expanded = jellyfin && fields.browserExpanded.value;
|
||||
Widget picker({required bool fill}) {
|
||||
final p = MediaPicker(
|
||||
controller: fields.media,
|
||||
enabled: !busy,
|
||||
autofocus: true,
|
||||
onSubmit: _host,
|
||||
onOpenIntegrations: onOpenIntegrations,
|
||||
jellyfin: fields.jellyfin,
|
||||
expanded: expanded,
|
||||
onToggleExpanded: () => fields.browserExpanded.value = !fields.browserExpanded.value,
|
||||
);
|
||||
return Column(
|
||||
key: const ValueKey('host-picker'),
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text('What to watch', style: t.titleMedium),
|
||||
const SizedBox(height: 12),
|
||||
fill ? Expanded(child: p) : p,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
final column = _ServerColumn(flow: flow, fields: fields, onHost: _host);
|
||||
return LayoutBuilder(builder: (context, box) {
|
||||
final stacked = box.maxWidth < hostStackBelow;
|
||||
// Jellyfin browser: fills the height (lazy grids need a bounded
|
||||
// viewport); the form sits beside it or, enlarged, in a bar below.
|
||||
if (expanded) {
|
||||
return Padding(
|
||||
padding: kPagePadding,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Expanded(child: picker(fill: true)),
|
||||
const SizedBox(height: 12),
|
||||
_HostBar(flow: flow, fields: fields, onHost: _host),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
if (jellyfin && !stacked) {
|
||||
return Padding(
|
||||
padding: kPagePadding,
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Expanded(child: picker(fill: true)),
|
||||
const SizedBox(width: 24),
|
||||
SizedBox(
|
||||
width: _columnWidth,
|
||||
child: SingleChildScrollView(child: column),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
return SingleChildScrollView(
|
||||
padding: kPagePadding,
|
||||
child: stacked
|
||||
? Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
picker(fill: false),
|
||||
const SizedBox(height: 24),
|
||||
Align(
|
||||
alignment: Alignment.topLeft,
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: kFieldMaxWidth),
|
||||
child: column,
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(child: picker(fill: false)),
|
||||
const SizedBox(width: 24),
|
||||
SizedBox(width: _columnWidth, child: column),
|
||||
],
|
||||
),
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ServerColumn extends StatelessWidget {
|
||||
final ConnectFlow flow;
|
||||
final HostFields fields;
|
||||
final VoidCallback onHost;
|
||||
const _ServerColumn({required this.flow, required this.fields, required this.onHost});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final t = theme.textTheme;
|
||||
return Panel(
|
||||
key: const ValueKey('host-column'),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.cast, size: 20, color: theme.colorScheme.primary),
|
||||
const SizedBox(width: 10),
|
||||
Text('Your server', style: t.titleMedium),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text('Runs on this computer. Friends need this port reachable.',
|
||||
style: t.bodySmall?.copyWith(color: Colors.grey)),
|
||||
const SizedBox(height: kGap),
|
||||
AbsorbPointer(
|
||||
absorbing: flow.busy == ConnectMode.host,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
spacing: 12,
|
||||
children: [
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: SizedBox(
|
||||
width: 120,
|
||||
child: TextField(
|
||||
key: const ValueKey('host-port'),
|
||||
controller: fields.port,
|
||||
decoration: fieldDecoration('Port'),
|
||||
keyboardType: TextInputType.number,
|
||||
onChanged: (_) => flow.clearError(ConnectMode.host),
|
||||
onSubmitted: (_) => onHost(),
|
||||
),
|
||||
),
|
||||
),
|
||||
ListenableBuilder(
|
||||
listenable: fields.secret,
|
||||
builder: (context, _) => TextField(
|
||||
key: const ValueKey('host-password'),
|
||||
controller: fields.secret,
|
||||
obscureText: true,
|
||||
decoration: fieldDecoration('Password').copyWith(
|
||||
helperText:
|
||||
fields.secret.text.isEmpty ? 'No password — anyone can join' : null,
|
||||
),
|
||||
onSubmitted: (_) => onHost(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
ListenableBuilder(
|
||||
listenable: fields.media,
|
||||
builder: (context, _) => Padding(
|
||||
padding: const EdgeInsets.only(top: 12),
|
||||
child: Text(mediaSummary(fields.media.selection),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: t.bodySmall?.copyWith(color: Colors.grey)),
|
||||
),
|
||||
),
|
||||
ConnectFeedback(flow: flow, mode: ConnectMode.host),
|
||||
const SizedBox(height: kGap),
|
||||
ConnectButton(
|
||||
flow: flow, mode: ConnectMode.host, label: 'Host', onSubmit: onHost, expand: true),
|
||||
_HostedRecents(fields: fields, enabled: flow.busy == null),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
String mediaSummary(PickedMedia? s) =>
|
||||
s == null ? 'No media selected (optional).' : 'Playing: ${s.label}';
|
||||
|
||||
/// Enlarged-browser mode: what is picked, port, password and Host in one
|
||||
/// row under the browser.
|
||||
class _HostBar extends StatelessWidget {
|
||||
final ConnectFlow flow;
|
||||
final HostFields fields;
|
||||
final VoidCallback onHost;
|
||||
const _HostBar({required this.flow, required this.fields, required this.onHost});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = Theme.of(context).textTheme;
|
||||
return Panel(
|
||||
key: const ValueKey('host-bar'),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.cast, size: 20, color: Theme.of(context).colorScheme.primary),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(mediaSummary(fields.media.selection),
|
||||
maxLines: 1, overflow: TextOverflow.ellipsis, style: t.bodyMedium),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
AbsorbPointer(
|
||||
absorbing: flow.busy == ConnectMode.host,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 100,
|
||||
child: TextField(
|
||||
controller: fields.port,
|
||||
decoration: fieldDecoration('Port'),
|
||||
keyboardType: TextInputType.number,
|
||||
onChanged: (_) => flow.clearError(ConnectMode.host),
|
||||
onSubmitted: (_) => onHost(),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
SizedBox(
|
||||
width: 200,
|
||||
child: TextField(
|
||||
controller: fields.secret,
|
||||
obscureText: true,
|
||||
decoration: fieldDecoration('Password', hint: 'none = open'),
|
||||
onSubmitted: (_) => onHost(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
ConnectButton(flow: flow, mode: ConnectMode.host, label: 'Host', onSubmit: onHost),
|
||||
],
|
||||
),
|
||||
ConnectFeedback(flow: flow, mode: ConnectMode.host),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Ports hosted before: one click refills port and password.
|
||||
class _HostedRecents extends StatelessWidget {
|
||||
final HostFields fields;
|
||||
final bool enabled;
|
||||
const _HostedRecents({required this.fields, required this.enabled});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SignalBuilder(builder: (context) {
|
||||
final list = [
|
||||
for (final r in SessionState.instance.recents.value)
|
||||
if (r.hosted) r
|
||||
];
|
||||
if (list.isEmpty) return const SizedBox.shrink();
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: kGap),
|
||||
child: Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
for (final r in list)
|
||||
InputChip(
|
||||
key: ValueKey('recent-${r.key}'),
|
||||
avatar: Icon(r.secret.isEmpty ? Icons.history : Icons.lock_outline, size: 16),
|
||||
label: Text('Port ${r.port}'),
|
||||
tooltip: 'Use port ${r.port} again',
|
||||
isEnabled: enabled,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
onPressed: () {
|
||||
fields.port.text = r.port;
|
||||
fields.secret.text = r.secret;
|
||||
},
|
||||
deleteButtonTooltipMessage: 'Forget',
|
||||
onDeleted: () => SessionState.instance.forgetServer(r),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../jellyfin/accounts.dart';
|
||||
import '../../jellyfin/accounts_panel.dart';
|
||||
import 'ui.dart';
|
||||
|
||||
class IntegrationsPage extends StatelessWidget {
|
||||
/// Defaults to [JellyfinAccounts.instance].
|
||||
final JellyfinAccounts? jellyfin;
|
||||
const IntegrationsPage({super.key, this.jellyfin});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return PageScroll(
|
||||
maxWidth: 820,
|
||||
children: [
|
||||
const PageTitle('Integrations',
|
||||
subtitle: 'Connect media libraries to pick what to watch.'),
|
||||
JellyfinAccountsPanel(accounts: jellyfin ?? JellyfinAccounts.instance),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,388 +0,0 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:signals/signals_flutter.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
|
||||
import '../../state/session_state.dart';
|
||||
import 'connect_flow.dart';
|
||||
import 'connect_status.dart';
|
||||
import 'health_probe.dart';
|
||||
import 'ui.dart';
|
||||
|
||||
/// Join form text, owned by the Home shell so it survives tab switches.
|
||||
class JoinFields {
|
||||
JoinFields(ConnectForm f)
|
||||
: host = TextEditingController(text: f.host),
|
||||
port = TextEditingController(text: f.port),
|
||||
secret = TextEditingController(text: f.secret);
|
||||
final TextEditingController host;
|
||||
final TextEditingController port;
|
||||
final TextEditingController secret;
|
||||
|
||||
void dispose() {
|
||||
host.dispose();
|
||||
port.dispose();
|
||||
secret.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// Smallest recent-server tile; the grid adds columns as the window widens.
|
||||
const _minTileWidth = 300.0;
|
||||
|
||||
/// One compact address/port/password row, then recent servers with live
|
||||
/// status, as a grid that fills the width.
|
||||
class JoinTab extends StatelessWidget {
|
||||
final ConnectFlow flow;
|
||||
final JoinFields fields;
|
||||
final HealthGetter healthGet;
|
||||
const JoinTab(
|
||||
{super.key, required this.flow, required this.fields, required this.healthGet});
|
||||
|
||||
void _join() => flow.submit(ConnectMode.join, (f) {
|
||||
f
|
||||
..host = fields.host.text
|
||||
..port = fields.port.text.trim()
|
||||
..secret = fields.secret.text;
|
||||
});
|
||||
|
||||
void _joinRecent(RecentServer r) {
|
||||
fields.host.text = r.host;
|
||||
fields.port.text = r.port;
|
||||
fields.secret.text = r.secret;
|
||||
_join();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = Theme.of(context).textTheme;
|
||||
return ListenableBuilder(
|
||||
listenable: flow,
|
||||
builder: (context, _) => SingleChildScrollView(
|
||||
padding: kPagePadding,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text('Join a friend’s server', style: t.titleMedium),
|
||||
const SizedBox(height: 12),
|
||||
AbsorbPointer(
|
||||
absorbing: flow.busy == ConnectMode.join,
|
||||
child: _joinRow(),
|
||||
),
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: kFieldMaxWidth),
|
||||
child: ConnectFeedback(flow: flow, mode: ConnectMode.join),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
Text('Recent servers', style: t.titleMedium),
|
||||
const SizedBox(height: 12),
|
||||
SignalBuilder(builder: (context) {
|
||||
final list = [
|
||||
for (final r in SessionState.instance.recents.value)
|
||||
if (!r.hosted) r
|
||||
];
|
||||
return RecentServersGrid(
|
||||
servers: list,
|
||||
enabled: flow.busy == null,
|
||||
healthGet: healthGet,
|
||||
onJoin: _joinRecent,
|
||||
onForget: SessionState.instance.forgetServer,
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _joinRow() {
|
||||
return Wrap(
|
||||
key: const ValueKey('join-row'),
|
||||
spacing: 12,
|
||||
runSpacing: 12,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 280,
|
||||
child: TextField(
|
||||
key: const ValueKey('join-address'),
|
||||
controller: fields.host,
|
||||
decoration: fieldDecoration('Address', hint: 'IP or hostname'),
|
||||
onChanged: (_) => flow.clearError(ConnectMode.join),
|
||||
onSubmitted: (_) => _join(),
|
||||
),
|
||||
),
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 6),
|
||||
child: Text(':', style: TextStyle(fontSize: 18, color: Colors.grey)),
|
||||
),
|
||||
SizedBox(
|
||||
width: 96,
|
||||
child: TextField(
|
||||
key: const ValueKey('join-port'),
|
||||
controller: fields.port,
|
||||
decoration: fieldDecoration('Port'),
|
||||
keyboardType: TextInputType.number,
|
||||
onSubmitted: (_) => _join(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
SizedBox(
|
||||
width: 220,
|
||||
child: TextField(
|
||||
key: const ValueKey('join-password'),
|
||||
controller: fields.secret,
|
||||
obscureText: true,
|
||||
decoration: fieldDecoration('Password', hint: 'If the server has one'),
|
||||
onSubmitted: (_) => _join(),
|
||||
),
|
||||
),
|
||||
ConnectButton(flow: flow, mode: ConnectMode.join, label: 'Join', onSubmit: _join),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Recent servers as tiles; polls each one's `/health` every
|
||||
/// [healthPollEvery] while mounted (i.e. while the Join tab is visible).
|
||||
class RecentServersGrid extends StatefulWidget {
|
||||
final List<RecentServer> servers;
|
||||
final bool enabled;
|
||||
final HealthGetter healthGet;
|
||||
final ValueChanged<RecentServer> onJoin;
|
||||
final ValueChanged<RecentServer> onForget;
|
||||
const RecentServersGrid({
|
||||
super.key,
|
||||
required this.servers,
|
||||
required this.enabled,
|
||||
required this.healthGet,
|
||||
required this.onJoin,
|
||||
required this.onForget,
|
||||
});
|
||||
|
||||
@override
|
||||
State<RecentServersGrid> createState() => _RecentServersGridState();
|
||||
}
|
||||
|
||||
class _RecentServersGridState extends State<RecentServersGrid> {
|
||||
final _health = <String, ServerHealth>{};
|
||||
final _inFlight = <String>{};
|
||||
Timer? _timer;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_pollAll();
|
||||
_timer = Timer.periodic(healthPollEvery, (_) => _pollAll());
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(RecentServersGrid old) {
|
||||
super.didUpdateWidget(old);
|
||||
for (final r in widget.servers) {
|
||||
if (!_health.containsKey(r.key)) _poll(r);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_timer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _pollAll() {
|
||||
for (final r in widget.servers) {
|
||||
_poll(r);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _poll(RecentServer r) async {
|
||||
if (!_inFlight.add(r.key)) return;
|
||||
try {
|
||||
final h = await probeHealth(r.host, r.port, widget.healthGet);
|
||||
if (mounted) setState(() => _health[r.key] = h);
|
||||
} finally {
|
||||
_inFlight.remove(r.key);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (widget.servers.isEmpty) return const _EmptyRecents();
|
||||
return LayoutBuilder(builder: (context, box) {
|
||||
final cols = ((box.maxWidth + kGap) / (_minTileWidth + kGap)).floor().clamp(1, 64);
|
||||
final width = (box.maxWidth - kGap * (cols - 1)) / cols;
|
||||
return Wrap(
|
||||
spacing: kGap,
|
||||
runSpacing: kGap,
|
||||
children: [
|
||||
for (final r in widget.servers)
|
||||
SizedBox(
|
||||
width: width,
|
||||
child: _RecentTile(
|
||||
key: ValueKey('recent-${r.key}'),
|
||||
server: r,
|
||||
health: _health[r.key],
|
||||
enabled: widget.enabled,
|
||||
onJoin: () => widget.onJoin(r),
|
||||
onForget: () => widget.onForget(r),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class _RecentTile extends StatelessWidget {
|
||||
final RecentServer server;
|
||||
/// Null until the first probe answers.
|
||||
final ServerHealth? health;
|
||||
final bool enabled;
|
||||
final VoidCallback onJoin;
|
||||
final VoidCallback onForget;
|
||||
const _RecentTile({
|
||||
super.key,
|
||||
required this.server,
|
||||
required this.health,
|
||||
required this.enabled,
|
||||
required this.onJoin,
|
||||
required this.onForget,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final scheme = theme.colorScheme;
|
||||
final t = theme.textTheme;
|
||||
final h = health;
|
||||
final address = '${server.host}:${server.port}';
|
||||
final title = h != null && h.name.isNotEmpty ? h.name : server.host;
|
||||
final (status, statusColor) = switch (h) {
|
||||
null => ('Checking…', Colors.grey),
|
||||
ServerHealth(online: true) => (
|
||||
'● online · ${h.viewers} watching',
|
||||
Colors.greenAccent.shade400
|
||||
),
|
||||
_ => ('○ offline', Colors.grey),
|
||||
};
|
||||
return Material(
|
||||
color: scheme.surfaceContainer,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(kRadius),
|
||||
side: BorderSide(color: scheme.outlineVariant.withValues(alpha: 0.5)),
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: InkWell(
|
||||
onTap: enabled ? onJoin : null,
|
||||
child: Tooltip(
|
||||
message: 'Join $address',
|
||||
waitDuration: const Duration(milliseconds: 600),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 10, 6, 14),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.dns_outlined, size: 20, color: scheme.primary),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(title,
|
||||
maxLines: 1, overflow: TextOverflow.ellipsis, style: t.titleMedium),
|
||||
),
|
||||
if (server.secret.isNotEmpty)
|
||||
const Tooltip(
|
||||
message: 'Password saved',
|
||||
child: Icon(Icons.lock_outline, size: 16, color: Colors.grey),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Forget',
|
||||
visualDensity: VisualDensity.compact,
|
||||
iconSize: 18,
|
||||
onPressed: onForget,
|
||||
icon: const Icon(Icons.close),
|
||||
),
|
||||
],
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 30, right: 10),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(address,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: t.bodySmall?.copyWith(color: Colors.grey)),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(status,
|
||||
key: const ValueKey('recent-status'),
|
||||
style: t.bodyMedium?.copyWith(color: statusColor)),
|
||||
),
|
||||
if (h != null && h.needsUpdate)
|
||||
Tooltip(
|
||||
message: 'Server protocol ${h.proto}, this app '
|
||||
'${RelayConstants.protoVersion} — update so both match',
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: scheme.errorContainer,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text('needs update',
|
||||
style: t.labelSmall
|
||||
?.copyWith(color: scheme.onErrorContainer)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _EmptyRecents extends StatelessWidget {
|
||||
const _EmptyRecents();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = Theme.of(context).textTheme;
|
||||
return Panel(
|
||||
key: const ValueKey('recents-empty'),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.history, size: 28, color: Colors.grey),
|
||||
const SizedBox(width: kGap),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('No recent servers yet', style: t.titleSmall),
|
||||
const SizedBox(height: 2),
|
||||
Text('Servers you join show up here with live status; click one to rejoin.',
|
||||
style: t.bodySmall?.copyWith(color: Colors.grey)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,34 +1,25 @@
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../../jellyfin/accounts.dart';
|
||||
import '../../jellyfin/browse_scope.dart' show BrowseStart;
|
||||
import '../../jellyfin/picker_source.dart';
|
||||
import '../../jellyfin/browse_scope.dart' show JellyfinSelection;
|
||||
import '../../widgets/media_input.dart';
|
||||
import 'picked_media.dart';
|
||||
import 'ui.dart';
|
||||
|
||||
export 'picked_media.dart';
|
||||
|
||||
const videoExtensions = ['mp4', 'mkv', 'webm', 'avi', 'mov', 'm4v', 'ts'];
|
||||
|
||||
/// The Library's sources.
|
||||
enum MediaKind { jellyfin, link, file }
|
||||
|
||||
/// State of a [MediaPicker], owned by whoever hosts it (Home's Host tab,
|
||||
/// later the session's swap panel) so it survives rebuilds.
|
||||
/// State of the Library tab, owned by the shell so it survives rebuilds
|
||||
/// and tab switches: the source shown, the link field and its relay mode,
|
||||
/// the selected Jellyfin card (details panel) and the last Jellyfin pick.
|
||||
class MediaPickerController extends ChangeNotifier {
|
||||
MediaPickerController(
|
||||
{String mediaPath = '', String linkUrl = '', JellyfinMedia? jellyfin, MediaKind? source})
|
||||
: link = TextEditingController(text: linkUrl),
|
||||
file = TextEditingController(text: mediaPath),
|
||||
_jellyfin = jellyfin,
|
||||
_source = source ??
|
||||
(linkUrl.isNotEmpty
|
||||
? MediaKind.link
|
||||
: mediaPath.isEmpty && jellyfin != null
|
||||
? MediaKind.jellyfin
|
||||
: MediaKind.file) {
|
||||
_selected = jellyfin == null ? null : JellyfinSelection(jellyfin.serverId, jellyfin.itemId),
|
||||
_source = source ?? MediaKind.jellyfin {
|
||||
link.addListener(notifyListeners);
|
||||
file.addListener(notifyListeners);
|
||||
}
|
||||
@@ -36,18 +27,33 @@ class MediaPickerController extends ChangeNotifier {
|
||||
final TextEditingController link;
|
||||
final TextEditingController file;
|
||||
MediaKind _source;
|
||||
String? _notice;
|
||||
LinkRelay _linkRelay = LinkRelay.direct;
|
||||
JellyfinMedia? _jellyfin;
|
||||
JellyfinSelection? _selected;
|
||||
|
||||
/// The Jellyfin item picked in the browser (kept while another source
|
||||
/// is shown).
|
||||
LinkRelay get linkRelay => _linkRelay;
|
||||
set linkRelay(LinkRelay v) {
|
||||
if (v == _linkRelay) return;
|
||||
_linkRelay = v;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// The last Jellyfin item picked (restored from the last put-up).
|
||||
JellyfinMedia? get jellyfin => _jellyfin;
|
||||
|
||||
/// Picks a Jellyfin item and switches to the Jellyfin source.
|
||||
/// Picks a Jellyfin item: selects its card and switches to Jellyfin.
|
||||
void pickJellyfin(JellyfinMedia? media) {
|
||||
_jellyfin = media;
|
||||
_selected = media == null ? null : JellyfinSelection(media.serverId, media.itemId);
|
||||
_source = MediaKind.jellyfin;
|
||||
_notice = null;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// The selected Jellyfin card (details panel open), or null.
|
||||
JellyfinSelection? get selected => _selected;
|
||||
void select(JellyfinSelection? s) {
|
||||
if (s == _selected && s?.item == _selected?.item) return;
|
||||
_selected = s;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@@ -55,23 +61,15 @@ class MediaPickerController extends ChangeNotifier {
|
||||
set source(MediaKind v) {
|
||||
if (v == _source) return;
|
||||
_source = v;
|
||||
_notice = null;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// One-line hint under the fields (e.g. the file dialog failed).
|
||||
String? get notice => _notice;
|
||||
set notice(String? v) {
|
||||
_notice = v;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// What the visible source has selected; null = nothing.
|
||||
/// What the Link or File source holds; for Jellyfin the last pick.
|
||||
PickedMedia? get selection {
|
||||
switch (_source) {
|
||||
case MediaKind.link:
|
||||
final url = link.text.trim();
|
||||
return url.isEmpty ? null : LinkMedia(url);
|
||||
return url.isEmpty ? null : LinkMedia(url, relay: _linkRelay);
|
||||
case MediaKind.file:
|
||||
final path = file.text.trim();
|
||||
return path.isEmpty ? null : FileMedia(path);
|
||||
@@ -99,258 +97,3 @@ class MediaPickerController extends ChangeNotifier {
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// Source segments (Jellyfin / Link / File) and the matching input.
|
||||
/// Ctrl+V anywhere in the picker outside a text field pastes a link or path.
|
||||
class MediaPicker extends StatefulWidget {
|
||||
final MediaPickerController controller;
|
||||
final ValueChanged<PickedMedia?>? onChanged;
|
||||
/// Enter in a field.
|
||||
final VoidCallback? onSubmit;
|
||||
final VoidCallback? onOpenIntegrations;
|
||||
final bool enabled;
|
||||
final bool autofocus;
|
||||
/// Defaults to [JellyfinAccounts.instance].
|
||||
final JellyfinAccounts? jellyfin;
|
||||
/// Jellyfin source only: the browser's "bigger" toggle.
|
||||
final VoidCallback? onToggleExpanded;
|
||||
final bool expanded;
|
||||
/// Where the Jellyfin browser opens instead of its remembered location.
|
||||
final BrowseStart? jellyfinStart;
|
||||
/// False hides the File source (only the host can serve a file).
|
||||
final bool allowFile;
|
||||
/// Shown under the Link field instead of the default help.
|
||||
final String? linkHelp;
|
||||
const MediaPicker({
|
||||
super.key,
|
||||
this.jellyfinStart,
|
||||
this.allowFile = true,
|
||||
this.linkHelp,
|
||||
required this.controller,
|
||||
this.onChanged,
|
||||
this.onSubmit,
|
||||
this.onOpenIntegrations,
|
||||
this.enabled = true,
|
||||
this.autofocus = false,
|
||||
this.jellyfin,
|
||||
this.onToggleExpanded,
|
||||
this.expanded = false,
|
||||
});
|
||||
|
||||
@override
|
||||
State<MediaPicker> createState() => _MediaPickerState();
|
||||
}
|
||||
|
||||
class _MediaPickerState extends State<MediaPicker> {
|
||||
MediaPickerController get _c => widget.controller;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_c.addListener(_changed);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(MediaPicker old) {
|
||||
super.didUpdateWidget(old);
|
||||
if (old.controller != widget.controller) {
|
||||
old.controller.removeListener(_changed);
|
||||
widget.controller.addListener(_changed);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_c.removeListener(_changed);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _changed() {
|
||||
widget.onChanged?.call(_c.selection);
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
|
||||
Future<String?> _clipboard() async {
|
||||
try {
|
||||
return (await Clipboard.getData(Clipboard.kTextPlain))?.text?.trim();
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _pasteAnywhere() async {
|
||||
final text = await _clipboard();
|
||||
if (text != null && text.isNotEmpty) _c.pasteText(text);
|
||||
}
|
||||
|
||||
Future<void> _pasteLink() async {
|
||||
final text = await _clipboard();
|
||||
if (text != null && text.isNotEmpty) _c.link.text = text;
|
||||
}
|
||||
|
||||
Future<void> _browse() async {
|
||||
try {
|
||||
final files = await FilePicker.pickFiles(
|
||||
dialogTitle: 'Pick media file',
|
||||
type: FileType.custom,
|
||||
allowedExtensions: videoExtensions,
|
||||
);
|
||||
final path = files.isEmpty ? null : files.first.path;
|
||||
if (path != null) {
|
||||
_c.file.text = path;
|
||||
_c.notice = null;
|
||||
}
|
||||
} catch (e) {
|
||||
_c.notice = 'File dialog unavailable (${e.runtimeType}) — paste the path instead';
|
||||
}
|
||||
}
|
||||
|
||||
KeyEventResult _onKey(FocusNode node, KeyEvent e) {
|
||||
final ctrl = HardwareKeyboard.instance.isControlPressed ||
|
||||
HardwareKeyboard.instance.isMetaPressed;
|
||||
if (!widget.enabled || 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;
|
||||
_pasteAnywhere();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final source = _c.source;
|
||||
final Widget content = switch (source) {
|
||||
MediaKind.jellyfin => JellyfinPickerSource(
|
||||
accounts: widget.jellyfin ?? JellyfinAccounts.instance,
|
||||
selected: _c.jellyfin,
|
||||
onPick: _c.pickJellyfin,
|
||||
onOpenIntegrations: widget.onOpenIntegrations,
|
||||
onToggleExpanded: widget.onToggleExpanded,
|
||||
expanded: widget.expanded,
|
||||
start: widget.jellyfinStart,
|
||||
),
|
||||
MediaKind.link => _FieldRow(
|
||||
key: const ValueKey('media-link'),
|
||||
field: TextField(
|
||||
controller: _c.link,
|
||||
autofocus: widget.autofocus,
|
||||
decoration: fieldDecoration('Video link', hint: 'https://…/movie.mkv'),
|
||||
keyboardType: TextInputType.url,
|
||||
onSubmitted: (_) => widget.onSubmit?.call(),
|
||||
),
|
||||
button: OutlinedButton.icon(
|
||||
onPressed: _pasteLink,
|
||||
icon: const Icon(Icons.content_paste, size: 18),
|
||||
label: const Text('Paste'),
|
||||
),
|
||||
help: widget.linkHelp ??
|
||||
'A direct http(s) link to a video file; everyone streams it from there. '
|
||||
'Jellyfin links are shared as the item, without your sign-in.',
|
||||
notice: _c.notice,
|
||||
),
|
||||
MediaKind.file => _FieldRow(
|
||||
key: const ValueKey('media-file'),
|
||||
field: TextField(
|
||||
controller: _c.file,
|
||||
decoration: fieldDecoration('Video file', hint: 'Path to a video on this computer'),
|
||||
onSubmitted: (_) => widget.onSubmit?.call(),
|
||||
),
|
||||
button: OutlinedButton.icon(
|
||||
onPressed: _browse,
|
||||
icon: const Icon(Icons.folder_open, size: 18),
|
||||
label: const Text('Browse'),
|
||||
),
|
||||
help: 'Friends stream it from you while you watch. Optional — you can host without media.',
|
||||
notice: _c.notice,
|
||||
),
|
||||
};
|
||||
final segments = Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: SegmentedButton<MediaKind>(
|
||||
key: const ValueKey('media-source'),
|
||||
showSelectedIcon: false,
|
||||
segments: [
|
||||
const ButtonSegment(
|
||||
value: MediaKind.jellyfin,
|
||||
icon: Icon(Icons.video_library_outlined),
|
||||
label: Text('Jellyfin')),
|
||||
const ButtonSegment(value: MediaKind.link, icon: Icon(Icons.link), label: Text('Link')),
|
||||
const ButtonSegment(
|
||||
value: MediaKind.file,
|
||||
icon: Icon(Icons.insert_drive_file_outlined),
|
||||
label: Text('File')),
|
||||
].where((s) => widget.allowFile || s.value != MediaKind.file).toList(),
|
||||
selected: {source},
|
||||
onSelectionChanged: (s) => _c.source = s.first,
|
||||
),
|
||||
);
|
||||
return Focus(
|
||||
autofocus: widget.autofocus,
|
||||
onKeyEvent: _onKey,
|
||||
child: AbsorbPointer(
|
||||
absorbing: !widget.enabled,
|
||||
// The browser fills the height it is given; in an unbounded
|
||||
// (scrolling) parent it gets a fixed one.
|
||||
child: LayoutBuilder(builder: (context, box) {
|
||||
final fill = source == MediaKind.jellyfin;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: fill && box.hasBoundedHeight ? MainAxisSize.max : MainAxisSize.min,
|
||||
children: [
|
||||
segments,
|
||||
const SizedBox(height: kGap),
|
||||
if (!fill)
|
||||
content
|
||||
else if (box.hasBoundedHeight)
|
||||
Expanded(child: content)
|
||||
else
|
||||
SizedBox(height: jellyfinPickerHeight, child: content),
|
||||
],
|
||||
);
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Height of the Jellyfin browser when the picker sits in a scroll view.
|
||||
const jellyfinPickerHeight = 640.0;
|
||||
|
||||
class _FieldRow extends StatelessWidget {
|
||||
final Widget field;
|
||||
final Widget button;
|
||||
final String help;
|
||||
final String? notice;
|
||||
const _FieldRow(
|
||||
{super.key, required this.field, required this.button, required this.help, this.notice});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final small = Theme.of(context).textTheme.bodySmall?.copyWith(color: Colors.grey);
|
||||
return Align(
|
||||
alignment: Alignment.topLeft,
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: kFieldMaxWidth + 140),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(child: field),
|
||||
const SizedBox(width: 12),
|
||||
button,
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(notice ?? help, style: small),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -7,10 +7,19 @@ sealed class PickedMedia {
|
||||
String get label;
|
||||
}
|
||||
|
||||
/// A direct http(s) link everyone streams from.
|
||||
/// How the others get a link: each fetches it ([direct]), the server
|
||||
/// fetches it once for everyone ([server]), or this computer does and
|
||||
/// passes it on through the server ([me]; for links only you can reach).
|
||||
enum LinkRelay { direct, server, me }
|
||||
|
||||
/// An http(s) link.
|
||||
final class LinkMedia extends PickedMedia {
|
||||
final String url;
|
||||
const LinkMedia(this.url);
|
||||
final LinkRelay relay;
|
||||
/// Put it up even though this computer's probe found a problem
|
||||
/// (`LinkProbe.problem`): the server or a friend may still open it.
|
||||
final bool anyway;
|
||||
const LinkMedia(this.url, {this.relay = LinkRelay.direct, this.anyway = false});
|
||||
|
||||
@override
|
||||
String get label {
|
||||
@@ -25,14 +34,15 @@ final class LinkMedia extends PickedMedia {
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => other is LinkMedia && other.url == url;
|
||||
bool operator ==(Object other) => other is LinkMedia && other.url == url && other.relay == relay;
|
||||
@override
|
||||
int get hashCode => url.hashCode;
|
||||
int get hashCode => Object.hash(url, relay);
|
||||
@override
|
||||
String toString() => 'LinkMedia($url)';
|
||||
}
|
||||
|
||||
/// A file on this computer; friends stream it from the host.
|
||||
/// A file on this computer; the others stream it from here (the host's
|
||||
/// app serves it itself, anyone else's goes through the server's relay).
|
||||
final class FileMedia extends PickedMedia {
|
||||
final String path;
|
||||
const FileMedia(this.path);
|
||||
|
||||
@@ -1,285 +1,131 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:signals/signals_flutter.dart';
|
||||
|
||||
import '../../state/connect_prefs.dart';
|
||||
import '../../state/session_state.dart';
|
||||
import '../../widgets/avatar_pipeline.dart';
|
||||
import 'ui.dart';
|
||||
|
||||
/// Display name + avatar, shared by the Home profile row and Settings.
|
||||
/// Backed by [SessionState.lastForm]; every change is persisted. The
|
||||
/// avatar goes through [encodeAvatar] (the same shrink the server gets)
|
||||
/// before its path is stored, and the encoded bytes are the preview.
|
||||
/// Colours offered in the profile card; the first is the default.
|
||||
const profileColors = <Color>[
|
||||
Colors.deepPurple,
|
||||
Colors.indigo,
|
||||
Colors.teal,
|
||||
Colors.pink,
|
||||
Colors.orange,
|
||||
Colors.blueGrey,
|
||||
];
|
||||
|
||||
const _keyPicture = 'avatarPng';
|
||||
const _keyColor = 'profileColor';
|
||||
|
||||
/// Sends a mid-session name/picture change (null = unchanged, empty
|
||||
/// avatar = removed). Swappable so tests can watch it.
|
||||
typedef ProfilePush = void Function({String? name, String? avatar});
|
||||
|
||||
/// The one place a profile change reaches the server.
|
||||
void pushProfileToSession({String? name, String? avatar}) {
|
||||
final session = SessionState.instance;
|
||||
if (session.route.peek() != AppRoute.session) return;
|
||||
session.coordinator.updateProfile(name: name, avatar: avatar);
|
||||
}
|
||||
|
||||
/// Display name, picture and colour of this app's user. Backed by
|
||||
/// [SessionState.lastForm] (name) and the connect-form box (the cropped
|
||||
/// 256 px PNG and the colour); every change is persisted, and while in a
|
||||
/// server it is pushed through [push] so everyone sees it at once.
|
||||
class Profile {
|
||||
static final Profile instance = Profile._();
|
||||
Profile._();
|
||||
|
||||
final name = signal('');
|
||||
final preview = signal<Uint8List?>(null);
|
||||
/// The cropped PNG; null shows the initial on [color].
|
||||
final picture = signal<Uint8List?>(null);
|
||||
final color = signal<Color>(profileColors.first);
|
||||
final error = signal<String?>(null);
|
||||
final picking = signal(false);
|
||||
ProfilePush push = pushProfileToSession;
|
||||
bool _loaded = false;
|
||||
Timer? _nameTimer;
|
||||
|
||||
void ensureLoaded() {
|
||||
/// Reads the stored profile once (the app shell starts it). Old installs
|
||||
/// stored a picture path: it is shrunk through [encodeAvatar] once and
|
||||
/// kept as bytes. A caller during the first read does not wait for it
|
||||
/// (no shared Future: tests run each in its own zone).
|
||||
Future<void> ensureLoaded() async {
|
||||
if (_loaded) return;
|
||||
_loaded = true;
|
||||
final f = SessionState.instance.lastForm;
|
||||
name.value = f.name;
|
||||
_refreshPreview(f.avatarPath);
|
||||
await _load();
|
||||
}
|
||||
|
||||
Future<void> _refreshPreview(String path) async {
|
||||
Future<void> _load() async {
|
||||
final form = SessionState.instance.lastForm;
|
||||
name.value = form.name;
|
||||
try {
|
||||
final b64 = await encodeAvatar(path);
|
||||
preview.value = b64.isEmpty ? null : base64Decode(b64);
|
||||
} catch (_) {
|
||||
preview.value = null;
|
||||
final box = connectFormBoxIfOpen() ?? await openConnectFormBox();
|
||||
final png = box.get(_keyPicture);
|
||||
final c = box.get(_keyColor);
|
||||
if (c is int) color.value = Color(c);
|
||||
if (png is Uint8List && png.isNotEmpty) {
|
||||
picture.value = png;
|
||||
} else if (form.avatarPath.isNotEmpty) {
|
||||
final b64 = await encodeAvatar(form.avatarPath);
|
||||
if (b64.isNotEmpty) picture.value = base64Decode(b64);
|
||||
form.avatarPath = '';
|
||||
await _save();
|
||||
}
|
||||
} catch (e) {
|
||||
// No store (tests) or an unreadable old picture: start without.
|
||||
debugPrint('profile load: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// The picture as sent in hello and profile updates; '' when none.
|
||||
String avatarBase64() {
|
||||
final png = picture.peek();
|
||||
return png == null ? '' : avatarWireBase64(png);
|
||||
}
|
||||
|
||||
void setName(String value) {
|
||||
name.value = value.trim();
|
||||
SessionState.instance.lastForm.name = value.trim();
|
||||
_save();
|
||||
final v = value.trim();
|
||||
if (v.isEmpty || v == name.peek()) return;
|
||||
name.value = v;
|
||||
SessionState.instance.lastForm.name = v;
|
||||
unawaited(_save());
|
||||
// Typing renames once it settles, not on every key.
|
||||
_nameTimer?.cancel();
|
||||
_nameTimer = Timer(const Duration(milliseconds: 600), () => push(name: name.peek()));
|
||||
}
|
||||
|
||||
Future<void> pickAvatar() async {
|
||||
if (picking.peek()) return;
|
||||
picking.value = true;
|
||||
Future<void> setPicture(Uint8List? png) async {
|
||||
picture.value = png;
|
||||
error.value = null;
|
||||
await _save();
|
||||
try {
|
||||
final List<PlatformFile> files;
|
||||
try {
|
||||
files = await FilePicker.pickFiles(
|
||||
dialogTitle: 'Pick avatar image',
|
||||
type: FileType.custom,
|
||||
allowedExtensions: const ['png', 'jpg', 'jpeg', 'webp'],
|
||||
);
|
||||
} catch (_) {
|
||||
error.value = 'file dialog unavailable — try again';
|
||||
return;
|
||||
}
|
||||
final path = files.isEmpty ? null : files.first.path;
|
||||
if (path == null) return;
|
||||
final String b64;
|
||||
try {
|
||||
b64 = await encodeAvatar(path);
|
||||
push(avatar: png == null ? '' : avatarWireBase64(png));
|
||||
} on FormatException catch (e) {
|
||||
error.value = e.message;
|
||||
return;
|
||||
} catch (_) {
|
||||
error.value = 'could not read that image — pick a PNG, JPG or WebP photo';
|
||||
return;
|
||||
}
|
||||
SessionState.instance.lastForm.avatarPath = path;
|
||||
preview.value = b64.isEmpty ? null : base64Decode(b64);
|
||||
await _save();
|
||||
} finally {
|
||||
picking.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> clearAvatar() async {
|
||||
SessionState.instance.lastForm.avatarPath = '';
|
||||
preview.value = null;
|
||||
error.value = null;
|
||||
Future<void> setColor(Color c) async {
|
||||
color.value = c;
|
||||
await _save();
|
||||
}
|
||||
|
||||
Future<void> _save() async {
|
||||
try {
|
||||
await SessionState.instance.saveForm();
|
||||
final box = connectFormBoxIfOpen() ?? await openConnectFormBox();
|
||||
final png = picture.peek();
|
||||
await box.putAll({
|
||||
_keyPicture: png ?? Uint8List(0),
|
||||
_keyColor: color.peek().toARGB32(),
|
||||
});
|
||||
} catch (_) {
|
||||
error.value = 'could not save profile — try again';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Avatar circle with the initial as fallback.
|
||||
class ProfileAvatar extends StatelessWidget {
|
||||
final double radius;
|
||||
const ProfileAvatar({super.key, this.radius = 20});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final p = Profile.instance;
|
||||
return SignalBuilder(builder: (context) {
|
||||
final img = p.preview.value;
|
||||
final n = p.name.value;
|
||||
return CircleAvatar(
|
||||
radius: radius,
|
||||
backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
backgroundImage: img != null ? MemoryImage(img) : null,
|
||||
child: img != null
|
||||
? null
|
||||
: p.picking.value
|
||||
? SizedBox(
|
||||
width: radius * 0.8,
|
||||
height: radius * 0.8,
|
||||
child: const CircularProgressIndicator(strokeWidth: 2))
|
||||
: Text(n.isEmpty ? '?' : n[0].toUpperCase(),
|
||||
style: TextStyle(fontSize: radius * 0.8)),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Avatar (tap to change) + name field. Used inline in Settings and in
|
||||
/// the Home profile dialog.
|
||||
class ProfileEditor extends StatefulWidget {
|
||||
final bool autofocus;
|
||||
final VoidCallback? onSubmitted;
|
||||
const ProfileEditor({super.key, this.autofocus = false, this.onSubmitted});
|
||||
|
||||
@override
|
||||
State<ProfileEditor> createState() => _ProfileEditorState();
|
||||
}
|
||||
|
||||
class _ProfileEditorState extends State<ProfileEditor> {
|
||||
late final _name = TextEditingController(text: Profile.instance.name.peek());
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_name.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final p = Profile.instance;
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Tooltip(
|
||||
message: 'Change avatar',
|
||||
child: InkWell(
|
||||
customBorder: const CircleBorder(),
|
||||
onTap: p.pickAvatar,
|
||||
child: const ProfileAvatar(radius: 36),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: kGap),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextField(
|
||||
controller: _name,
|
||||
autofocus: widget.autofocus,
|
||||
decoration: fieldDecoration('Display name'),
|
||||
onChanged: p.setName,
|
||||
onSubmitted: (_) => widget.onSubmitted?.call(),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
SignalBuilder(builder: (context) {
|
||||
final err = p.error.value;
|
||||
return Wrap(
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: p.pickAvatar,
|
||||
child: const Text('Change avatar')),
|
||||
if (p.preview.value != null)
|
||||
TextButton(
|
||||
onPressed: p.clearAvatar,
|
||||
child: const Text('Remove avatar')),
|
||||
if (err != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 8),
|
||||
child: Text(err,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Theme.of(context).colorScheme.error)),
|
||||
),
|
||||
],
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> showProfileDialog(BuildContext context) {
|
||||
return showDialog<void>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(kRadius)),
|
||||
title: const Text('Profile'),
|
||||
content: SizedBox(
|
||||
width: 380,
|
||||
child: ProfileEditor(
|
||||
autofocus: true,
|
||||
onSubmitted: () => Navigator.pop(context),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(context), child: const Text('Done')),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Home's top-left: avatar + name, click to edit.
|
||||
class ProfileChip extends StatelessWidget {
|
||||
/// One line and a small avatar, for the Home top bar.
|
||||
final bool dense;
|
||||
/// Just the avatar, for very narrow windows.
|
||||
final bool avatarOnly;
|
||||
const ProfileChip({super.key, this.dense = false, this.avatarOnly = false});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final p = Profile.instance;
|
||||
return Tooltip(
|
||||
message: 'Edit profile',
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(kRadius),
|
||||
onTap: () => showProfileDialog(context),
|
||||
child: avatarOnly
|
||||
? const Padding(padding: EdgeInsets.all(4), child: ProfileAvatar(radius: 14))
|
||||
: Padding(
|
||||
padding: dense
|
||||
? const EdgeInsets.fromLTRB(4, 4, 10, 4)
|
||||
: const EdgeInsets.fromLTRB(6, 6, 14, 6),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ProfileAvatar(radius: dense ? 14 : 20),
|
||||
SizedBox(width: dense ? 8 : 12),
|
||||
SignalBuilder(builder: (context) {
|
||||
final n = p.name.value;
|
||||
final t = Theme.of(context).textTheme;
|
||||
final name = Text(n.isEmpty ? 'Set your name' : n,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: (dense ? t.titleSmall : t.titleMedium)
|
||||
?.copyWith(color: n.isEmpty ? Colors.grey : null));
|
||||
if (dense) {
|
||||
return ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 200), child: name);
|
||||
}
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
name,
|
||||
Text('Edit profile',
|
||||
style: t.bodySmall?.copyWith(color: Colors.grey)),
|
||||
],
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,136 +2,90 @@ import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:signals/signals_flutter.dart';
|
||||
|
||||
import '../../playback/playback_controller.dart';
|
||||
import '../../shaders/shader_presets.dart';
|
||||
import '../../state/settings.dart';
|
||||
import '../../update/update_controller.dart';
|
||||
import 'profile.dart';
|
||||
import 'ui.dart';
|
||||
import 'update_indicator.dart';
|
||||
|
||||
/// In-session keys, as handled by the session screen's global key handler.
|
||||
/// Session keys: Space and Tab anywhere in a server (`AppKeys`), the
|
||||
/// rest by the session player's key handler (while it fills the window).
|
||||
const sessionShortcuts = [
|
||||
('Space', 'Toggle ready'),
|
||||
('← / →', 'Seek back / forward 10 s'),
|
||||
('↑ / ↓', 'Volume up / down (up to 150 %)'),
|
||||
('Shift+→', 'Skip 1:30 (for everyone)'),
|
||||
('PgUp / PgDn', 'Previous / next chapter'),
|
||||
('F', 'Toggle fullscreen'),
|
||||
('Tab', 'Open / close the people & chat panel'),
|
||||
('Tab', 'Open / close chat & activity'),
|
||||
('Ctrl+R', 'Reload the video'),
|
||||
('> or Shift+N', 'Put up the next 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+0', 'Shaders off'),
|
||||
('Esc', 'Close panel or fullscreen, else ask to leave'),
|
||||
('Esc', 'Close the panel or fullscreen, shrink the player, then ask to leave'),
|
||||
];
|
||||
|
||||
class SettingsPage extends StatelessWidget {
|
||||
final UpdateController? updates;
|
||||
const SettingsPage({super.key, this.updates});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return PageScroll(
|
||||
maxWidth: 680,
|
||||
children: [
|
||||
const PageTitle('Settings'),
|
||||
const _Section(title: 'Profile', child: ProfileEditor()),
|
||||
const _Section(
|
||||
title: 'Player',
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [_HwdecSetting(), SizedBox(height: 12), _MpvControlsSetting()],
|
||||
),
|
||||
),
|
||||
const _Section(title: 'Jellyfin', child: _AutoAdvanceSetting()),
|
||||
_Section(title: 'Updates', child: _UpdateSettings(controller: updates)),
|
||||
const _Section(title: 'Keyboard shortcuts', child: _ShortcutList()),
|
||||
const _Section(title: 'Video quality & shaders', child: _ShaderSettings()),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Section extends StatelessWidget {
|
||||
/// One titled card of the Settings tab (rounded Material, so list tiles
|
||||
/// paint their ripple on it).
|
||||
class SettingsPanel extends StatelessWidget {
|
||||
final String title;
|
||||
final Widget child;
|
||||
const _Section({required this.title, required this.child});
|
||||
const SettingsPanel({super.key, required this.title, required this.child});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: kGap),
|
||||
child: Panel(
|
||||
child: Material(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerLow,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(18, 14, 18, 12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [SectionLabel(title), child],
|
||||
children: [
|
||||
Text(title, style: const TextStyle(fontWeight: FontWeight.w700)),
|
||||
const SizedBox(height: 6),
|
||||
child,
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _HwdecSetting extends StatefulWidget {
|
||||
const _HwdecSetting();
|
||||
|
||||
@override
|
||||
State<_HwdecSetting> createState() => _HwdecSettingState();
|
||||
}
|
||||
|
||||
class _HwdecSettingState extends State<_HwdecSetting> {
|
||||
final _s = AppSettings.instance;
|
||||
late final _custom = TextEditingController(text: _s.hwdecCustom.peek());
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_custom.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
/// A tinted hint card at the top of a Settings page.
|
||||
class SettingsHintCard extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String text;
|
||||
const SettingsHintCard({super.key, required this.icon, required this.text});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SignalBuilder(builder: (context) {
|
||||
final mode = _s.hwdecMode.value;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: kGap),
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: scheme.primary.withValues(alpha: 0.08),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Text('Hardware decoding'),
|
||||
const SizedBox(height: 4),
|
||||
const Text(
|
||||
'Applies on the next join or Reload video (Ctrl+R). Turn it off if video '
|
||||
'shows glitches or a black picture.',
|
||||
style: TextStyle(fontSize: 12, color: Colors.grey),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
SegmentedButton<HwdecMode>(
|
||||
showSelectedIcon: false,
|
||||
segments: const [
|
||||
ButtonSegment(value: HwdecMode.auto, label: Text('Auto')),
|
||||
ButtonSegment(value: HwdecMode.off, label: Text('Off')),
|
||||
ButtonSegment(value: HwdecMode.custom, label: Text('Custom')),
|
||||
Icon(icon, color: scheme.primary),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(child: Text(text)),
|
||||
],
|
||||
selected: {mode},
|
||||
onSelectionChanged: (s) => _s.setHwdecMode(s.first),
|
||||
),
|
||||
if (mode == HwdecMode.custom)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 12),
|
||||
child: TextField(
|
||||
controller: _custom,
|
||||
decoration: fieldDecoration('mpv hwdec value',
|
||||
hint: 'e.g. vaapi, nvdec, d3d11va, auto-safe'),
|
||||
onChanged: _s.setHwdecCustom,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class _ShaderSettings extends StatelessWidget {
|
||||
const _ShaderSettings();
|
||||
/// The .glsl files of the "Custom shaders" preset, in mpv order.
|
||||
class CustomShaderFiles extends StatelessWidget {
|
||||
const CustomShaderFiles({super.key});
|
||||
|
||||
Future<void> _addFiles() async {
|
||||
final List<PlatformFile> files;
|
||||
@@ -156,65 +110,18 @@ class _ShaderSettings extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final s = AppSettings.instance;
|
||||
final muted = TextStyle(fontSize: 12, color: Theme.of(context).colorScheme.onSurfaceVariant);
|
||||
return SignalBuilder(builder: (context) {
|
||||
final preset = s.shaderPreset.value;
|
||||
final quality = s.shaderQuality.value;
|
||||
final custom = s.customShaders.value;
|
||||
final mode = PlaybackController.lastRenderMode.value;
|
||||
const hint = TextStyle(fontSize: 12, color: Colors.grey);
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Anime4K upscales and sharpens anime on your GPU, for you only. '
|
||||
'Mode A suits 1080p sources, B 720p, C 480p; A+A, B+B and C+A add '
|
||||
'a second pass. HQ is for strong GPUs (GTX 1080 and up), Fast for '
|
||||
'weaker ones. Costs GPU time every frame and only works with '
|
||||
'hardware video rendering. In the player: the ✨ button or '
|
||||
'Ctrl+1…6, Ctrl+0 for off; changes apply live and are saved here.',
|
||||
style: hint,
|
||||
),
|
||||
if (mode == RenderMode.software || mode == RenderMode.softwareGl)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: Text(
|
||||
mode == RenderMode.software
|
||||
? 'The last session used software rendering: shaders had no effect there.'
|
||||
: 'The last session rendered OpenGL on the CPU: shaders will be slow.',
|
||||
style: const TextStyle(fontSize: 12, color: Colors.orange),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
const Text('Shaders'),
|
||||
const SizedBox(width: 16),
|
||||
DropdownButton<ShaderPreset>(
|
||||
value: preset,
|
||||
onChanged: (p) {
|
||||
if (p != null) s.setShaderPreset(p);
|
||||
},
|
||||
items: [
|
||||
for (final p in ShaderPreset.values)
|
||||
DropdownMenuItem(value: p, child: Text(p.label)),
|
||||
],
|
||||
Expanded(
|
||||
child: Text('Your own mpv shaders for the "Custom shaders" choice; they run in this order.',
|
||||
style: muted),
|
||||
),
|
||||
const Spacer(),
|
||||
SegmentedButton<ShaderQuality>(
|
||||
showSelectedIcon: false,
|
||||
segments: [
|
||||
for (final q in ShaderQuality.values)
|
||||
ButtonSegment(value: q, label: Text(q.label)),
|
||||
],
|
||||
selected: {quality},
|
||||
onSelectionChanged: (q) => s.setShaderQuality(q.first),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
const Expanded(child: Text('Custom shaders (run in this order)')),
|
||||
TextButton.icon(
|
||||
onPressed: _addFiles,
|
||||
icon: const Icon(Icons.add, size: 18),
|
||||
@@ -223,7 +130,7 @@ class _ShaderSettings extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
if (custom.isEmpty)
|
||||
const Text('None picked.', style: hint)
|
||||
Text('None picked.', style: muted)
|
||||
else
|
||||
for (final (i, path) in custom.indexed)
|
||||
Row(
|
||||
@@ -231,15 +138,13 @@ class _ShaderSettings extends StatelessWidget {
|
||||
Expanded(
|
||||
child: Tooltip(
|
||||
message: path,
|
||||
child: Text(path.split(RegExp(r'[/\\]')).last,
|
||||
overflow: TextOverflow.ellipsis),
|
||||
child: Text(path.split(RegExp(r'[/\\]')).last, overflow: TextOverflow.ellipsis),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Remove',
|
||||
icon: const Icon(Icons.close, size: 18),
|
||||
onPressed: () =>
|
||||
s.setCustomShaders([...custom]..removeAt(i)),
|
||||
onPressed: () => s.setCustomShaders([...custom]..removeAt(i)),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -249,9 +154,9 @@ class _ShaderSettings extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _UpdateSettings extends StatelessWidget {
|
||||
class UpdateSettings extends StatelessWidget {
|
||||
final UpdateController? controller;
|
||||
const _UpdateSettings({this.controller});
|
||||
const UpdateSettings({super.key, this.controller});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -290,8 +195,10 @@ class _UpdateSettings extends StatelessWidget {
|
||||
const SizedBox(height: 8),
|
||||
SignalBuilder(
|
||||
builder: (context) => SwitchListTile(
|
||||
key: const ValueKey('auto-check-updates'),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: const Text('Check automatically on start'),
|
||||
title: const Text('Check for updates on start'),
|
||||
subtitle: const Text('Looks for a new version when the app opens; nothing installs without asking'),
|
||||
value: s.autoCheckUpdates.value,
|
||||
onChanged: s.setAutoCheckUpdates,
|
||||
),
|
||||
@@ -301,28 +208,8 @@ class _UpdateSettings extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _MpvControlsSetting extends StatelessWidget {
|
||||
const _MpvControlsSetting();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final s = AppSettings.instance;
|
||||
return SignalBuilder(
|
||||
builder: (context) => SwitchListTile(
|
||||
key: const ValueKey('mpv-controls'),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: const Text('Experimental: mpv-style controls'),
|
||||
subtitle: const Text('Video controls modeled on mpv\'s uosc theme: a thick timeline with '
|
||||
'chapters and the cached range, flat buttons and menus. Applies right away.'),
|
||||
value: s.mpvControls.value,
|
||||
onChanged: s.setMpvControls,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AutoAdvanceSetting extends StatelessWidget {
|
||||
const _AutoAdvanceSetting();
|
||||
class AutoAdvanceSetting extends StatelessWidget {
|
||||
const AutoAdvanceSetting({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -332,8 +219,8 @@ class _AutoAdvanceSetting extends StatelessWidget {
|
||||
key: const ValueKey('auto-advance'),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: const Text('Play the next episode automatically'),
|
||||
subtitle: const Text('When an episode ends, a 10 s countdown puts up the next one; '
|
||||
'anyone can cancel it on their screen.'),
|
||||
subtitle: const Text('When a Jellyfin episode ends, a 10 s countdown puts up the next one; '
|
||||
'anyone can cancel it'),
|
||||
value: s.autoAdvance.value,
|
||||
onChanged: s.setAutoAdvance,
|
||||
),
|
||||
@@ -341,8 +228,28 @@ class _AutoAdvanceSetting extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _ShortcutList extends StatelessWidget {
|
||||
const _ShortcutList();
|
||||
class HideReReleasesSetting extends StatelessWidget {
|
||||
const HideReReleasesSetting({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final s = AppSettings.instance;
|
||||
return SignalBuilder(
|
||||
builder: (context) => SwitchListTile(
|
||||
key: const ValueKey('hide-re-releases'),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: const Text('Hide re-releases in Latest'),
|
||||
subtitle: const Text('Skips episodes added more than 30 days after they aired '
|
||||
'(re-uploads, upgrades); also in each Latest row\'s ⋮ menu'),
|
||||
value: s.hideReReleases.value,
|
||||
onChanged: s.setHideReReleases,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ShortcutList extends StatelessWidget {
|
||||
const ShortcutList({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
||||
@@ -1,151 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:window_manager/window_manager.dart';
|
||||
|
||||
import '../../update/update_controller.dart';
|
||||
import '../../widgets/window_buttons.dart';
|
||||
import 'profile.dart';
|
||||
import 'update_indicator.dart';
|
||||
|
||||
/// What the Home body shows: the two tabs, or one of the side pages.
|
||||
enum HomeView { join, host, settings, integrations, about }
|
||||
|
||||
const homeTopBarHeight = 48.0;
|
||||
|
||||
/// Below this width the bar drops the profile name and tab icons.
|
||||
const _compactBelow = 760.0;
|
||||
|
||||
/// Home's single top bar: profile, Join/Host tabs, update pill, page
|
||||
/// buttons and (in the real window) the drag area and window buttons.
|
||||
class HomeTopBar extends StatelessWidget {
|
||||
final HomeView view;
|
||||
final ValueChanged<HomeView> onSelect;
|
||||
final UpdateController? updates;
|
||||
/// Off in tests: the drag area and window buttons need window_manager.
|
||||
final bool windowControls;
|
||||
const HomeTopBar({
|
||||
super.key,
|
||||
required this.view,
|
||||
required this.onSelect,
|
||||
this.updates,
|
||||
this.windowControls = false,
|
||||
});
|
||||
|
||||
static const _pages = [
|
||||
(HomeView.settings, Icons.settings_outlined, Icons.settings, 'Settings'),
|
||||
(HomeView.integrations, Icons.extension_outlined, Icons.extension, 'Integrations'),
|
||||
(HomeView.about, Icons.info_outline, Icons.info, 'About'),
|
||||
];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return LayoutBuilder(
|
||||
builder: (context, box) => _bar(context, compact: box.maxWidth < _compactBelow));
|
||||
}
|
||||
|
||||
Widget _bar(BuildContext context, {required bool compact}) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final row = Row(
|
||||
children: [
|
||||
const SizedBox(width: 12),
|
||||
ProfileChip(dense: true, avatarOnly: compact),
|
||||
SizedBox(width: compact ? 8 : 20),
|
||||
_Tab(
|
||||
key: const ValueKey('tab-join'),
|
||||
label: 'Join',
|
||||
icon: compact ? null : Icons.login,
|
||||
selected: view == HomeView.join,
|
||||
onTap: () => onSelect(HomeView.join),
|
||||
),
|
||||
_Tab(
|
||||
key: const ValueKey('tab-host'),
|
||||
label: 'Host',
|
||||
icon: compact ? null : Icons.cast,
|
||||
selected: view == HomeView.host,
|
||||
onTap: () => onSelect(HomeView.host),
|
||||
),
|
||||
const Spacer(),
|
||||
UpdateIndicator(controller: updates),
|
||||
const SizedBox(width: 8),
|
||||
for (final (v, icon, selectedIcon, label) in _pages)
|
||||
IconButton(
|
||||
tooltip: label,
|
||||
isSelected: view == v,
|
||||
icon: Icon(icon, size: 20),
|
||||
selectedIcon: Icon(selectedIcon, size: 20, color: scheme.primary),
|
||||
onPressed: () => onSelect(v),
|
||||
),
|
||||
if (windowControls) ...[
|
||||
const SizedBox(width: 4),
|
||||
SizedBox(
|
||||
height: 20,
|
||||
child: VerticalDivider(width: 12, color: scheme.outlineVariant),
|
||||
),
|
||||
const WindowButtons(),
|
||||
] else
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
);
|
||||
final bar = SizedBox(height: homeTopBarHeight, child: row);
|
||||
return ColoredBox(
|
||||
color: scheme.surfaceContainerLowest,
|
||||
// Empty bar space drags the window; the controls on top take their
|
||||
// own clicks without waiting out the drag area's double-tap.
|
||||
child: windowControls
|
||||
? Stack(children: [
|
||||
const Positioned.fill(child: DragToMoveArea(child: SizedBox.expand())),
|
||||
bar,
|
||||
])
|
||||
: bar,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Tab extends StatelessWidget {
|
||||
final String label;
|
||||
final IconData? icon;
|
||||
final bool selected;
|
||||
final VoidCallback onTap;
|
||||
const _Tab(
|
||||
{super.key,
|
||||
required this.label,
|
||||
this.icon,
|
||||
required this.selected,
|
||||
required this.onTap});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final color = selected ? scheme.primary : scheme.onSurfaceVariant;
|
||||
return Semantics(
|
||||
selected: selected,
|
||||
button: true,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Container(
|
||||
height: homeTopBarHeight,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: selected ? scheme.primary : Colors.transparent, width: 2),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (icon != null) ...[
|
||||
Icon(icon, size: 18, color: color),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
Text(label,
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
color: color, fontWeight: selected ? FontWeight.w600 : null)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Product name (working title). Change it here only.
|
||||
const appName = 'Watch Together';
|
||||
const appName = 'Starsync';
|
||||
|
||||
const repoUrl = 'https://git.senkochan.moe/Foxyz/sync_replay_project';
|
||||
|
||||
const kRadius = 12.0;
|
||||
const kGap = 16.0;
|
||||
const kPagePadding = EdgeInsets.fromLTRB(28, 20, 28, 28);
|
||||
|
||||
/// Rounded panel used by every Home/Settings section.
|
||||
/// Rounded panel used by every Settings section.
|
||||
class Panel extends StatelessWidget {
|
||||
final Widget child;
|
||||
final EdgeInsetsGeometry padding;
|
||||
@@ -38,33 +37,6 @@ class Panel extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// Page heading + optional subtitle, same spacing everywhere.
|
||||
class PageTitle extends StatelessWidget {
|
||||
final String title;
|
||||
final String? subtitle;
|
||||
const PageTitle(this.title, {super.key, this.subtitle});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = Theme.of(context).textTheme;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: kGap),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(title, style: t.headlineSmall),
|
||||
if (subtitle != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: Text(subtitle!,
|
||||
style: t.bodyMedium?.copyWith(color: Colors.grey)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Small grey heading inside a panel.
|
||||
class SectionLabel extends StatelessWidget {
|
||||
final String text;
|
||||
@@ -87,7 +59,7 @@ class SectionLabel extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// Text field style shared by the connect cards.
|
||||
/// Outlined text field style (settings, Jellyfin sign-in).
|
||||
InputDecoration fieldDecoration(String label, {String? hint, Widget? suffix}) =>
|
||||
InputDecoration(
|
||||
labelText: label,
|
||||
@@ -97,31 +69,6 @@ InputDecoration fieldDecoration(String label, {String? hint, Widget? suffix}) =>
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
|
||||
);
|
||||
|
||||
/// Single-item scrollable page body, width-capped so it doesn't sprawl
|
||||
/// on wide windows.
|
||||
class PageScroll extends StatelessWidget {
|
||||
final List<Widget> children;
|
||||
final double maxWidth;
|
||||
const PageScroll({super.key, required this.children, this.maxWidth = 920});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SingleChildScrollView(
|
||||
padding: kPagePadding,
|
||||
child: Align(
|
||||
alignment: Alignment.topLeft,
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(maxWidth: maxWidth),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: children,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Widest a single text field gets, however wide the window.
|
||||
const kFieldMaxWidth = 720.0;
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import '../../update/update_controller.dart';
|
||||
import '../../widgets/update_banner.dart';
|
||||
import 'ui.dart';
|
||||
|
||||
/// Compact "Update vX" pill for Home's top-right; hidden until there is
|
||||
/// Compact "Update vX" pill for the party strip; hidden until there is
|
||||
/// something to install. Click opens the full [UpdateBanner].
|
||||
class UpdateIndicator extends StatelessWidget {
|
||||
final UpdateController? controller;
|
||||
|
||||
@@ -4,7 +4,7 @@ import '../../jellyfin/session_media.dart';
|
||||
import '../../session/session_coordinator.dart' show AutoAdvance, autoAdvanceSecs;
|
||||
import 'player_controls.dart' show FloatingPill;
|
||||
|
||||
// Jellyfin pieces of the session screen. Plain widgets fed by the
|
||||
// Jellyfin pieces of the session player. Plain widgets fed by the
|
||||
// coordinator's signals, so tests can pump them without the player.
|
||||
|
||||
/// Previous / next episode in the video controls. Nothing when there is
|
||||
@@ -144,7 +144,9 @@ class JellyfinBlockedCard extends StatelessWidget {
|
||||
const SizedBox(height: 10),
|
||||
Text(blocked.message, textAlign: TextAlign.center, style: t.titleSmall),
|
||||
const SizedBox(height: 6),
|
||||
Text('Link your own account in Integrations, or ask the host to relay it.',
|
||||
Text(
|
||||
'Whoever put it up passes it on automatically while their app is open; '
|
||||
'if they left, ask them to put it up again. Or link your own account in Integrations.',
|
||||
textAlign: TextAlign.center, style: t.bodySmall?.copyWith(color: Colors.grey)),
|
||||
const SizedBox(height: 14),
|
||||
Wrap(
|
||||
|
||||
@@ -1,136 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../jellyfin/accounts.dart';
|
||||
import '../../jellyfin/browse_scope.dart' show BrowseStart;
|
||||
import '../home/media_picker.dart';
|
||||
|
||||
/// "Change video" in a session: the same [MediaPicker] as Home's Host tab
|
||||
/// (Jellyfin browser, Link, File) and a button that puts the pick up for
|
||||
/// everyone. File is offered to the host only (the file streams from the
|
||||
/// host's disk). [onSwap] returns an error text, or null when sent.
|
||||
class SwapPanel extends StatefulWidget {
|
||||
final MediaPickerController controller;
|
||||
final JellyfinAccounts accounts;
|
||||
final bool isHost;
|
||||
final BrowseStart? jellyfinStart;
|
||||
final Future<String?> Function(PickedMedia media) onSwap;
|
||||
final VoidCallback onClose;
|
||||
final VoidCallback? onOpenIntegrations;
|
||||
const SwapPanel({
|
||||
super.key,
|
||||
required this.controller,
|
||||
required this.accounts,
|
||||
required this.isHost,
|
||||
required this.onSwap,
|
||||
required this.onClose,
|
||||
this.jellyfinStart,
|
||||
this.onOpenIntegrations,
|
||||
});
|
||||
|
||||
@override
|
||||
State<SwapPanel> createState() => _SwapPanelState();
|
||||
}
|
||||
|
||||
class _SwapPanelState extends State<SwapPanel> {
|
||||
bool _busy = false;
|
||||
String? _error;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final c = widget.controller;
|
||||
if (!widget.isHost && c.source == MediaKind.file) c.source = MediaKind.link;
|
||||
}
|
||||
|
||||
Future<void> _swap() async {
|
||||
final media = widget.controller.selection;
|
||||
if (media == null || _busy) return;
|
||||
setState(() {
|
||||
_busy = true;
|
||||
_error = null;
|
||||
});
|
||||
String? error;
|
||||
try {
|
||||
error = await widget.onSwap(media);
|
||||
} catch (e) {
|
||||
error = '$e';
|
||||
}
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_busy = false;
|
||||
_error = error;
|
||||
});
|
||||
if (error == null) widget.onClose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = Theme.of(context).textTheme;
|
||||
return Material(
|
||||
key: const ValueKey('swap-panel'),
|
||||
elevation: 12,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 12, 12, 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(child: Text('Change video', style: t.titleLarge)),
|
||||
IconButton(
|
||||
tooltip: 'Close (Esc)', icon: const Icon(Icons.close), onPressed: widget.onClose),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: MediaPicker(
|
||||
controller: widget.controller,
|
||||
jellyfin: widget.accounts,
|
||||
enabled: !_busy,
|
||||
allowFile: widget.isHost,
|
||||
jellyfinStart: widget.jellyfinStart,
|
||||
onOpenIntegrations: widget.onOpenIntegrations,
|
||||
onSubmit: _swap,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
ListenableBuilder(
|
||||
listenable: widget.controller,
|
||||
builder: (context, _) {
|
||||
final sel = widget.controller.selection;
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
_error ?? (sel == null ? 'Pick something to watch' : 'Put up: ${sel.label}'),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: t.bodyMedium?.copyWith(
|
||||
color: _error != null ? Theme.of(context).colorScheme.error : null),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
FilledButton.icon(
|
||||
key: const ValueKey('swap-go'),
|
||||
onPressed: sel == null || _busy ? null : _swap,
|
||||
icon: _busy
|
||||
? const SizedBox(
|
||||
width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2))
|
||||
: const Icon(Icons.swap_horiz),
|
||||
label: const Text('Put it up for everyone'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
|
||||
import '../files/name_parser.dart';
|
||||
import '../schedule/anilist.dart';
|
||||
|
||||
// Art for media this client swapped in (`art`, see PROTOCOL.md "Media
|
||||
// art"): Jellyfin images with this install's account, else an AniList
|
||||
// title search. Everything here fails quietly to "no art".
|
||||
|
||||
/// Poster and banner bytes; either may be null.
|
||||
typedef ArtBytes = ({Uint8List? poster, Uint8List? banner});
|
||||
|
||||
/// Series poster (the item's own for movies) and series/item backdrop,
|
||||
/// else its Banner image, scaled by Jellyfin to fit the caps.
|
||||
Future<ArtBytes> jellyfinArt(JellyfinClient client, String itemId,
|
||||
{Future<Uint8List?> Function(Uri url, Map<String, String> headers, int maxBytes) fetch = fetchImage}) async {
|
||||
final item = await client.item(itemId).timeout(const Duration(seconds: 10));
|
||||
final seriesPoster = item.seriesId != null && item.seriesPrimaryImageTag != null
|
||||
? JellyfinImageRef(item.seriesId!, JellyfinImageType.primary, item.seriesPrimaryImageTag!)
|
||||
: null;
|
||||
final poster = seriesPoster ?? item.image(JellyfinImageType.primary);
|
||||
var banner = item.image(JellyfinImageType.backdrop);
|
||||
if (banner == null) {
|
||||
var owner = item;
|
||||
if (item.seriesId != null && item.ownImage(JellyfinImageType.banner) == null) {
|
||||
try {
|
||||
owner = await client.item(item.seriesId!).timeout(const Duration(seconds: 10));
|
||||
} catch (_) {}
|
||||
}
|
||||
banner = owner.ownImage(JellyfinImageType.banner);
|
||||
}
|
||||
final headers = client.authHeaders;
|
||||
final (p, b) = await (
|
||||
poster == null
|
||||
? Future<Uint8List?>.value()
|
||||
: fetch(client.imageUrl(poster, maxHeight: 450, quality: 85), headers, RelayConstants.maxPosterBytes),
|
||||
banner == null
|
||||
? Future<Uint8List?>.value()
|
||||
: fetch(client.imageUrl(banner, maxWidth: 1280, quality: 85), headers, RelayConstants.maxBannerBytes),
|
||||
).wait;
|
||||
return (poster: p, banner: b);
|
||||
}
|
||||
|
||||
/// The series of release name [name] on AniList: cover and banner; null
|
||||
/// when the name says nothing or AniList knows no match.
|
||||
Future<ArtBytes?> aniListArt(AniListClient aniList, String name,
|
||||
{Future<Uint8List?> Function(Uri url, Map<String, String> headers, int maxBytes) fetch = fetchImage}) async {
|
||||
final series = parseReleaseName(name).series.trim();
|
||||
// "1", "2026-10-09": nothing a show could be called.
|
||||
if (!RegExp(r'\p{L}', unicode: true).hasMatch(series)) return null;
|
||||
final match = await aniList.searchMedia(series);
|
||||
if (match == null) return null;
|
||||
Future<Uint8List?> get(String? url, int maxBytes) async {
|
||||
final uri = url == null ? null : Uri.tryParse(url);
|
||||
return uri == null ? null : fetch(uri, const {}, maxBytes);
|
||||
}
|
||||
|
||||
final (cover, banner) = await (
|
||||
get(match.cover, RelayConstants.maxPosterBytes),
|
||||
get(match.banner, RelayConstants.maxBannerBytes),
|
||||
).wait;
|
||||
final poster = cover ?? (match.coverSmall == match.cover ? null : await get(match.coverSmall, RelayConstants.maxPosterBytes));
|
||||
return (poster: poster, banner: banner);
|
||||
}
|
||||
|
||||
/// GET [url]: the bytes when 200, at most [maxBytes] and a known image
|
||||
/// type (see `avatarImageType`); else null.
|
||||
Future<Uint8List?> fetchImage(Uri url, Map<String, String> headers, int maxBytes) async {
|
||||
final client = HttpClient()..connectionTimeout = const Duration(seconds: 10);
|
||||
try {
|
||||
final req = await client.getUrl(url);
|
||||
headers.forEach(req.headers.set);
|
||||
final resp = await req.close().timeout(const Duration(seconds: 15));
|
||||
if (resp.statusCode != 200 || resp.contentLength > maxBytes) {
|
||||
await resp.drain<void>().catchError((_) {});
|
||||
return null;
|
||||
}
|
||||
final builder = BytesBuilder(copy: false);
|
||||
await for (final chunk in resp.timeout(const Duration(seconds: 15))) {
|
||||
builder.add(chunk);
|
||||
if (builder.length > maxBytes) return null;
|
||||
}
|
||||
final bytes = builder.takeBytes();
|
||||
return avatarImageType(bytes) == null ? null : bytes;
|
||||
} catch (e) {
|
||||
debugPrint('art: fetch failed: $e');
|
||||
return null;
|
||||
} finally {
|
||||
client.close(force: true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/// What the player shows about the current media: a title, an episode line
|
||||
/// and art. Art is by content hash (`state.media.art`), fetched by everyone
|
||||
/// (guests included) from the server they dialled; '' when there is none.
|
||||
class NowPlaying {
|
||||
/// Series name for episodes, else the movie, file or link name.
|
||||
final String title;
|
||||
/// "S2 E4 · Episode title", "E12", or null (movies, links).
|
||||
final String? episode;
|
||||
/// Portrait cover (2:3) and wide banner; '' when none.
|
||||
final String posterHash;
|
||||
final String bannerHash;
|
||||
const NowPlaying({required this.title, this.episode, this.posterHash = '', this.bannerHash = ''});
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
other is NowPlaying &&
|
||||
other.title == title &&
|
||||
other.episode == episode &&
|
||||
other.posterHash == posterHash &&
|
||||
other.bannerHash == bannerHash;
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(title, episode, posterHash, bannerHash);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import '../files/name_parser.dart';
|
||||
import '../jellyfin/session_media.dart';
|
||||
import 'now_playing.dart';
|
||||
|
||||
// Title and episode line of `state.media` (pure; see [NowPlaying]).
|
||||
|
||||
// "S1E6" (pickFor) or "S01E02".
|
||||
final _episodeLabel = RegExp(r'^S(\d{1,3})\s?E(\d{1,4})$', caseSensitive: false);
|
||||
final _year = RegExp(r'^(?:19|20)\d{2}$');
|
||||
final _ext = RegExp(r'\.(?=[a-z0-9]*[a-z])[a-z0-9]{2,4}$', caseSensitive: false);
|
||||
|
||||
/// What the player shows for [media] (`state.media`); null with nothing up.
|
||||
NowPlaying? nowPlayingOf(Map<String, Object?>? media) {
|
||||
if (media == null) return null;
|
||||
final name = media['name'] is String ? media['name'] as String : '';
|
||||
final art = media['art'] is Map ? media['art'] as Map : const {};
|
||||
final poster = art['poster'] is String ? art['poster'] as String : '';
|
||||
final banner = art['banner'] is String ? art['banner'] as String : '';
|
||||
final ref = refOfStateMedia(media);
|
||||
final (title, episode) = ref != null
|
||||
? jellyfinTitle(ref.title.isNotEmpty ? ref.title : name, ref.subtitle)
|
||||
: fileTitle(name);
|
||||
return NowPlaying(title: title, episode: episode, posterHash: poster, bannerHash: banner);
|
||||
}
|
||||
|
||||
/// Jellyfin: [name] is the item's, [subtitle] "Series · S1E6" (or
|
||||
/// "Series - S01E02") for episodes, the year for movies. Episodes give the
|
||||
/// series and "S1 E6 · Name"; anything else the name alone.
|
||||
(String, String?) jellyfinTitle(String name, String? subtitle) {
|
||||
final sub = subtitle?.trim() ?? '';
|
||||
if (sub.isEmpty || _year.hasMatch(sub)) return (name, null);
|
||||
// The label is after the last separator (or the whole subtitle).
|
||||
final cut = RegExp(r' [·-] ').allMatches(sub).lastOrNull;
|
||||
final m = _episodeLabel.firstMatch(cut == null ? sub : sub.substring(cut.end).trim());
|
||||
final series = m == null ? sub : (cut == null ? '' : sub.substring(0, cut.start).trim());
|
||||
final label = m == null ? null : 'S${int.parse(m.group(1)!)} E${int.parse(m.group(2)!)}';
|
||||
if (series.isEmpty) return (name, label);
|
||||
final episode = [?label, if (name.isNotEmpty) name].join(' · ');
|
||||
return (series, episode.isEmpty ? null : episode);
|
||||
}
|
||||
|
||||
/// Files and links: the release name's series and "S1 E3", "E25" or its
|
||||
/// extra marker (NCOP1, OVA 2); the name minus extension when it says
|
||||
/// nothing better.
|
||||
(String, String?) fileTitle(String name) {
|
||||
final p = parseReleaseName(name);
|
||||
final series = p.series.trim();
|
||||
if (series.isEmpty) return (name.replaceFirst(_ext, ''), null);
|
||||
final String? episode;
|
||||
if (p.episode != null) {
|
||||
episode = p.season != null ? 'S${p.season} E${p.episode}' : 'E${p.episode}';
|
||||
} else {
|
||||
episode = p.extra;
|
||||
}
|
||||
return (series, episode);
|
||||
}
|
||||
@@ -1,20 +1,25 @@
|
||||
/// The app's sounds. Only events that carry news get one: no sound for
|
||||
/// what the video itself makes obvious (start, pause, end, seeks).
|
||||
enum SfxKind {
|
||||
/// Someone readies up; pitch climbs with [SfxCue.readyIndex].
|
||||
ready,
|
||||
unready,
|
||||
/// Someone marks everyone ready.
|
||||
forceReady,
|
||||
forceUnready,
|
||||
forcePlay,
|
||||
forceHold,
|
||||
kick,
|
||||
join,
|
||||
leave,
|
||||
stale,
|
||||
/// Someone sat you out (played by the session, not from a feed action).
|
||||
kick,
|
||||
/// A new video or episode is put up.
|
||||
swap,
|
||||
seek,
|
||||
pause,
|
||||
resume,
|
||||
chat,
|
||||
/// Idle warning: auto-unready soon.
|
||||
warn,
|
||||
/// The connection to the server dropped / came back.
|
||||
lost,
|
||||
back,
|
||||
/// Someone pokes you for attention; plays only on your end.
|
||||
nudge,
|
||||
}
|
||||
|
||||
class SfxCue {
|
||||
@@ -23,42 +28,16 @@ class SfxCue {
|
||||
const SfxCue(this.kind, {this.readyIndex = 0});
|
||||
}
|
||||
|
||||
SfxKind? sfxForFeedAction(String action) {
|
||||
switch (action) {
|
||||
case 'ready':
|
||||
return SfxKind.ready;
|
||||
case 'unready':
|
||||
return SfxKind.unready;
|
||||
case 'force-ready':
|
||||
return SfxKind.forceReady;
|
||||
case 'force-unready':
|
||||
return SfxKind.forceUnready;
|
||||
case 'force-play':
|
||||
return SfxKind.forcePlay;
|
||||
case 'force-hold':
|
||||
return SfxKind.forceHold;
|
||||
case 'kick':
|
||||
return SfxKind.kick;
|
||||
case 'join':
|
||||
return SfxKind.join;
|
||||
case 'leave':
|
||||
return SfxKind.leave;
|
||||
case 'stale':
|
||||
return SfxKind.stale;
|
||||
case 'swap':
|
||||
return SfxKind.swap;
|
||||
case 'seek':
|
||||
return SfxKind.seek;
|
||||
case 'pause':
|
||||
return SfxKind.pause;
|
||||
case 'resume':
|
||||
return SfxKind.resume;
|
||||
case 'chat':
|
||||
return SfxKind.chat;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
SfxKind? sfxForFeedAction(String action) => switch (action) {
|
||||
'ready' => SfxKind.ready,
|
||||
'unready' => SfxKind.unready,
|
||||
'force-ready' => SfxKind.forceReady,
|
||||
'join' => SfxKind.join,
|
||||
'leave' => SfxKind.leave,
|
||||
'swap' => SfxKind.swap,
|
||||
'chat' => SfxKind.chat,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
abstract class SfxService {
|
||||
Future<void> play(SfxCue cue);
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
import 'sfx.dart';
|
||||
import 'synth.dart';
|
||||
|
||||
// The app's sounds come in presets; each action can take its sound from any
|
||||
// of them. Every preset is quiet, short and on D, so sounds from different
|
||||
// presets still agree when they overlap.
|
||||
// - Cosmic: star bells, stardust, bubbles, soft pads, a space tail.
|
||||
// - Abyss: deep space and its dread. Far beacons, nebula washes, slow
|
||||
// beating clusters and tritone sags, on D phrygian, with a vast tail.
|
||||
// - Wood & glass: plucked strings, glass bells, wood knocks, a short room.
|
||||
|
||||
enum SoundPreset { cosmic, abyss, woodAndGlass }
|
||||
|
||||
/// Shown in Settings: name and feel.
|
||||
const presetInfo = {
|
||||
SoundPreset.cosmic: ('Cosmic', 'Star bells, stardust and a soft space tail'),
|
||||
SoundPreset.abyss: ('Abyss', 'Deep space: far beacons, nebula, a vast dark tail'),
|
||||
SoundPreset.woodAndGlass: ('Wood & glass', 'Plucked strings, glass bells and wood, close and dry'),
|
||||
};
|
||||
|
||||
/// The preset used when nothing else is chosen.
|
||||
const defaultPreset = SoundPreset.abyss;
|
||||
|
||||
const _d3 = -19, _ab3 = -13;
|
||||
const _d4 = -7, _eb4 = -6, _ab4 = -1, _a4 = 0, _b4 = 2;
|
||||
const _d5 = 5, _eb5 = 6, _e5 = 7, _f5 = 8, _fs5 = 9, _g5 = 10, _a5 = 12, _bb5 = 13, _b5 = 14;
|
||||
const _c6 = 15, _cs6 = 16, _d6 = 17, _e6 = 19, _f6 = 20, _fs6 = 21, _g6 = 22, _a6 = 24, _b6 = 26;
|
||||
|
||||
double _p(int s) => semi(s);
|
||||
|
||||
/// The ready sound's answer climbs with each person ready.
|
||||
const _cosmicLadder = [_a5, _b5, _d6, _e6, _fs6, _a6];
|
||||
const _abyssLadder = [_f5, _g5, _a5, _c6, _d6, _f6];
|
||||
const _woodLadder = [_g5, _a5, _b5, _d6, _e6, _g6];
|
||||
|
||||
SoundSpec readySound(int readyIndex, {SoundPreset preset = defaultPreset}) =>
|
||||
soundFor(SfxKind.ready, readyIndex: readyIndex, preset: preset);
|
||||
|
||||
SoundSpec soundFor(SfxKind kind, {int readyIndex = 1, SoundPreset preset = defaultPreset}) {
|
||||
final i = (readyIndex - 1).clamp(0, 5);
|
||||
return switch (preset) {
|
||||
SoundPreset.cosmic => _cosmic(kind, i),
|
||||
SoundPreset.abyss => _abyss(kind, i),
|
||||
SoundPreset.woodAndGlass => _wood(kind, i),
|
||||
};
|
||||
}
|
||||
|
||||
SoundSpec _cosmic(SfxKind kind, int i) => switch (kind) {
|
||||
SfxKind.ready => SoundSpec([
|
||||
Note(0, _p(_d4), 1.0, voice: Voice.pad, gain: 0.3),
|
||||
Note(0, _p(_d5), 1.2, voice: Voice.star, gain: 0.8, bright: 0.4),
|
||||
Note(0.09, _p(_cosmicLadder[i]), 1.5, voice: Voice.star, bright: 0.5),
|
||||
], peakDb: -17, room: 0.3),
|
||||
// The same bells, dimmed and stepping down.
|
||||
SfxKind.unready => SoundSpec([
|
||||
Note(0, _p(_fs5), 0.6, voice: Voice.star, gain: 0.8, bright: 0.15),
|
||||
Note(0.1, _p(_d5), 0.8, voice: Voice.star, bright: 0.1),
|
||||
], cutoff: 3500, peakDb: -20, room: 0.25),
|
||||
// Just the bell, left hanging in space: playback is the rest of the cue.
|
||||
SfxKind.forceReady => SoundSpec([
|
||||
Note(0, _p(_d6), 1.8, voice: Voice.star, bright: 0.7),
|
||||
], cutoff: 7000, peakDb: -18, room: 0.35, tail: 1.6),
|
||||
// A star arriving: dust rising into a bell.
|
||||
SfxKind.join => SoundSpec([
|
||||
Note(0, _p(_d6), 1.0, voice: Voice.dust, to: _p(_a6), gain: 0.5),
|
||||
Note(0.15, _p(_a5), 1.4, voice: Voice.star, bright: 0.35, gain: 0.6),
|
||||
], cutoff: 7000, peakDb: -19, room: 0.3),
|
||||
// Dust drifting down, a low bell going out.
|
||||
SfxKind.leave => SoundSpec([
|
||||
Note(0, _p(_a6), 0.9, voice: Voice.dust, to: _p(_d6), gain: 0.5),
|
||||
Note(0.1, _p(_d5), 1.3, voice: Voice.star, bright: 0.2, gain: 0.5),
|
||||
], cutoff: 5000, peakDb: -22, room: 0.3),
|
||||
// Falling into the dark: a deep tone sinking an octave under a sagging pad.
|
||||
SfxKind.kick => SoundSpec([
|
||||
Note(0, _p(_d4), 1.0, voice: Voice.sub, to: _p(_d3)),
|
||||
Note(0, _p(_a4), 0.9, voice: Voice.pad, to: _p(_d4), gain: 0.4),
|
||||
], cutoff: 3000, peakDb: -19, room: 0.25),
|
||||
// Something new opens up: a rush of air, then a wide chord blooms.
|
||||
SfxKind.swap => SoundSpec([
|
||||
Note(0, 300, 0.6, voice: Voice.slide, to: 2400, gain: 0.35),
|
||||
Note(0.35, _p(_d4), 1.4, voice: Voice.pad, gain: 0.5, bright: 0.3),
|
||||
Note(0.35, _p(_a4), 1.4, voice: Voice.pad, gain: 0.5, bright: 0.3),
|
||||
Note(0.35, _p(_e5), 1.4, voice: Voice.pad, gain: 0.4, bright: 0.3),
|
||||
Note(0.38, _p(_fs6), 1.2, voice: Voice.star, bright: 0.3, gain: 0.3),
|
||||
], peakDb: -18, room: 0.3),
|
||||
// Two bubbles rising: bloop-bloop, the second higher.
|
||||
SfxKind.chat => SoundSpec([
|
||||
Note(0, _p(_a5), 0.18, voice: Voice.drop, gain: 0.8),
|
||||
Note(0.08, _p(_e6), 0.22, voice: Voice.drop),
|
||||
], peakDb: -19, room: 0.15, tail: 1.0),
|
||||
// Something huge drifting past: a slow, deep swell.
|
||||
SfxKind.warn => SoundSpec([
|
||||
Note(0, _p(_d4), 2.0, voice: Voice.pad),
|
||||
Note(0, _p(_a4), 2.0, voice: Voice.pad, gain: 0.5),
|
||||
Note(0.3, _p(_d3), 1.7, voice: Voice.sub, gain: 0.4),
|
||||
], cutoff: 3000, peakDb: -20, room: 0.2, tail: 1.0),
|
||||
// The signal fading: a chord sagging down and dimming.
|
||||
SfxKind.lost => SoundSpec([
|
||||
Note(0, _p(_a4), 1.2, voice: Voice.pad, to: _p(_d4), gain: 0.7),
|
||||
Note(0, _p(_e5), 1.2, voice: Voice.pad, to: _p(_a4), gain: 0.4),
|
||||
], cutoff: 2500, peakDb: -19, room: 0.3, tail: 1.2),
|
||||
// Re-acquired: a bell gliding up into tune, landing with dust.
|
||||
SfxKind.back => SoundSpec([
|
||||
Note(0, _p(_a4), 0.6, voice: Voice.star, to: _p(_d5), bright: 0.3, gain: 0.5),
|
||||
Note(0.22, _p(_a5), 1.2, voice: Voice.star, bright: 0.5),
|
||||
Note(0.25, _p(_d6), 0.6, voice: Voice.dust, to: _p(_a6), gain: 0.3),
|
||||
], peakDb: -18, room: 0.25),
|
||||
// A poke: two quick bright bubbles flicking up, a glint on the second.
|
||||
// High and dry so it cuts through a video and stays a patter when spammed.
|
||||
SfxKind.nudge => SoundSpec([
|
||||
Note(0, _p(_d6), 0.12, voice: Voice.drop, to: _p(_fs6), gain: 0.8),
|
||||
Note(0.09, _p(_a6), 0.16, voice: Voice.drop),
|
||||
Note(0.09, _p(_cs6) * 2, 0.3, voice: Voice.star, bright: 0.6, gain: 0.2),
|
||||
], cutoff: 7000, peakDb: -18, room: 0.05, tail: 0.6),
|
||||
};
|
||||
|
||||
SoundSpec _abyss(SfxKind kind, int i) => switch (kind) {
|
||||
// A beacon pinging far out, the void humming under it.
|
||||
SfxKind.ready => SoundSpec([
|
||||
Note(0, _p(_d3), 1.2, voice: Voice.pad, gain: 0.35),
|
||||
Note(0, _p(_d5), 1.4, voice: Voice.star, gain: 0.8, bright: 0.25),
|
||||
Note(0.12, _p(_abyssLadder[i]), 1.6, voice: Voice.star, bright: 0.3),
|
||||
], cutoff: 5000, peakDb: -18, room: 0.4, tail: 1.8),
|
||||
// The beacon answering itself a tritone down, dimmer.
|
||||
SfxKind.unready => SoundSpec([
|
||||
Note(0, _p(_a4), 0.7, voice: Voice.star, gain: 0.8, bright: 0.1),
|
||||
Note(0.12, _p(_eb4), 0.9, voice: Voice.star, bright: 0.1),
|
||||
], cutoff: 2500, peakDb: -21, room: 0.35, tail: 1.6),
|
||||
// One bell, huge and very far away.
|
||||
SfxKind.forceReady => SoundSpec([
|
||||
Note(0, _p(_d5), 1.7, voice: Voice.star, bright: 0.5),
|
||||
], cutoff: 5000, peakDb: -18, room: 0.45, tail: 1.8),
|
||||
// Something emerging from the nebula: a dark wash brightening into a bell.
|
||||
SfxKind.join => SoundSpec([
|
||||
Note(0, 150, 1.2, voice: Voice.slide, to: 900, gain: 0.4),
|
||||
Note(0.05, _p(_d4), 1.2, voice: Voice.pad, gain: 0.3),
|
||||
Note(0.3, _p(_a5), 1.3, voice: Voice.star, bright: 0.3, gain: 0.5),
|
||||
], cutoff: 4500, peakDb: -19, room: 0.4, tail: 1.6),
|
||||
// Swallowed back into it: the wash darkening over a fading bell.
|
||||
SfxKind.leave => SoundSpec([
|
||||
Note(0, 900, 1.1, voice: Voice.slide, to: 150, gain: 0.4),
|
||||
Note(0.1, _p(_d5), 1.2, voice: Voice.star, bright: 0.15, gain: 0.4),
|
||||
], cutoff: 3500, peakDb: -22, room: 0.4, tail: 1.6),
|
||||
// Pulled under: a deep fall with a tritone sagging over it.
|
||||
SfxKind.kick => SoundSpec([
|
||||
Note(0, _p(_d4), 1.3, voice: Voice.sub, to: _p(_d3)),
|
||||
Note(0, _p(_ab4), 1.2, voice: Voice.pad, to: _p(_d4), gain: 0.35),
|
||||
], cutoff: 2000, peakDb: -19, room: 0.35, tail: 1.6),
|
||||
// A door into the dark: low air, then an unresolved chord opens.
|
||||
SfxKind.swap => SoundSpec([
|
||||
Note(0, 120, 0.8, voice: Voice.slide, to: 1200, gain: 0.35),
|
||||
Note(0.45, _p(_d4), 1.3, voice: Voice.pad, gain: 0.5, bright: 0.2),
|
||||
Note(0.45, _p(_a4), 1.3, voice: Voice.pad, gain: 0.45, bright: 0.2),
|
||||
Note(0.45, _p(_eb5), 1.3, voice: Voice.pad, gain: 0.3, bright: 0.2),
|
||||
Note(0.5, _p(_bb5), 1.1, voice: Voice.star, bright: 0.2, gain: 0.25),
|
||||
], cutoff: 4000, peakDb: -19, room: 0.35, tail: 1.4),
|
||||
// Bubbles rising from deep water.
|
||||
SfxKind.chat => SoundSpec([
|
||||
Note(0, _p(_d5), 0.25, voice: Voice.drop, gain: 0.8),
|
||||
Note(0.1, _p(_a5), 0.3, voice: Voice.drop),
|
||||
], cutoff: 4500, peakDb: -19, room: 0.3, tail: 1.4),
|
||||
// Something vast drawing near: a slow swell of two notes rubbing.
|
||||
SfxKind.warn => SoundSpec([
|
||||
Note(0, _p(_d4), 2.0, voice: Voice.pad),
|
||||
Note(0, _p(_eb4), 2.0, voice: Voice.pad, gain: 0.35),
|
||||
Note(0.3, _p(_d3), 1.7, voice: Voice.sub, gain: 0.45),
|
||||
], cutoff: 2200, peakDb: -20, room: 0.25, tail: 1.0),
|
||||
// The signal drowning: a chord sagging by a tritone into static.
|
||||
SfxKind.lost => SoundSpec([
|
||||
Note(0, _p(_a4), 1.3, voice: Voice.pad, to: _p(_eb4), gain: 0.7),
|
||||
Note(0, _p(_d4), 1.3, voice: Voice.pad, to: _p(_ab3), gain: 0.5),
|
||||
Note(0.2, 600, 1.0, voice: Voice.slide, to: 200, gain: 0.2),
|
||||
], cutoff: 2000, peakDb: -19, room: 0.35, tail: 1.4),
|
||||
// Found again: a bell climbing out of the tritone into tune.
|
||||
SfxKind.back => SoundSpec([
|
||||
Note(0, _p(_ab4), 0.6, voice: Voice.star, to: _p(_d5), bright: 0.25, gain: 0.5),
|
||||
Note(0.25, _p(_a5), 1.3, voice: Voice.star, bright: 0.4),
|
||||
Note(0.28, _p(_d5), 0.6, voice: Voice.dust, to: _p(_a5), gain: 0.25),
|
||||
], cutoff: 5000, peakDb: -18, room: 0.35, tail: 1.6),
|
||||
// A poke from the dark: two quick bubbles flicking up, a pale glint.
|
||||
// Still high and fairly dry so it cuts through a video when spammed.
|
||||
SfxKind.nudge => SoundSpec([
|
||||
Note(0, _p(_a5), 0.13, voice: Voice.drop, to: _p(_c6), gain: 0.8),
|
||||
Note(0.1, _p(_d6), 0.18, voice: Voice.drop),
|
||||
Note(0.1, _p(_a6), 0.35, voice: Voice.star, bright: 0.5, gain: 0.2),
|
||||
], cutoff: 6000, peakDb: -18, room: 0.1, tail: 1.0),
|
||||
};
|
||||
|
||||
SoundSpec _wood(SfxKind kind, int i) => switch (kind) {
|
||||
SfxKind.ready => SoundSpec([
|
||||
Note(0, _p(_d5), 0.9, gain: 0.75, bright: 0.55),
|
||||
Note(0.085, _p(_woodLadder[i]), 1.1, bright: 0.65),
|
||||
], peakDb: -17, room: 0.14, tail: 0.5),
|
||||
// The same strings, muted and stepping down.
|
||||
SfxKind.unready => SoundSpec([
|
||||
Note(0, _p(_a5), 0.35, gain: 0.8, bright: 0.2),
|
||||
Note(0.09, _p(_g5), 0.45, bright: 0.15),
|
||||
], cutoff: 3800, peakDb: -20, room: 0.1, tail: 0.5),
|
||||
// Just the bell: playback starting is the rest of the cue.
|
||||
SfxKind.forceReady => SoundSpec([
|
||||
Note(0, _p(_d6) * 2, 1.6, voice: Voice.chime),
|
||||
], cutoff: 7000, peakDb: -18, room: 0.18, tail: 0.5),
|
||||
// Wind through glass: three uneven tinkles.
|
||||
SfxKind.join => SoundSpec([
|
||||
Note(0, _p(_a6), 1.3, voice: Voice.chime, gain: 0.8),
|
||||
Note(0.11, _p(_d6) * 2, 1.2, voice: Voice.chime, gain: 0.55),
|
||||
Note(0.26, _p(_e6) * 2, 1.4, voice: Voice.chime, gain: 0.45),
|
||||
], cutoff: 7000, peakDb: -19, room: 0.18, tail: 0.5),
|
||||
// The wind settling: two softer, lower tinkles.
|
||||
SfxKind.leave => SoundSpec([
|
||||
Note(0, _p(_b6), 1.0, voice: Voice.chime, gain: 0.6),
|
||||
Note(0.17, _p(_e6), 1.3, voice: Voice.chime, gain: 0.5),
|
||||
], cutoff: 5000, peakDb: -22, room: 0.18, tail: 0.5),
|
||||
// Two hardwood clacks, then a low muted string.
|
||||
SfxKind.kick => SoundSpec([
|
||||
Note(0, 1900, 0.09, voice: Voice.clack),
|
||||
Note(0.16, 1750, 0.11, voice: Voice.clack, gain: 0.9),
|
||||
Note(0.3, _p(_d4), 0.7, bright: 0.15, gain: 0.7),
|
||||
], cutoff: 5000, peakDb: -19, room: 0.12, tail: 0.5),
|
||||
// The door slides open on something new: air, then a fifth.
|
||||
SfxKind.swap => SoundSpec([
|
||||
Note(0, 500, 0.42, voice: Voice.slide, to: 1700, gain: 0.5),
|
||||
Note(0.33, _p(_d5), 1.1, bright: 0.6, gain: 0.8),
|
||||
Note(0.36, _p(_a5), 1.1, bright: 0.6, gain: 0.7),
|
||||
], peakDb: -18, tail: 0.5),
|
||||
// A hollow wooden tok-tok, the second higher.
|
||||
SfxKind.chat => SoundSpec([
|
||||
Note(0, _p(_g5), 0.13, voice: Voice.bamboo, gain: 0.85),
|
||||
Note(0.075, _p(_d6), 0.16, voice: Voice.bamboo),
|
||||
], cutoff: 5500, peakDb: -19, room: 0.1, tail: 0.5),
|
||||
// A bowl: one soft strike that hums.
|
||||
SfxKind.warn => SoundSpec([
|
||||
Note(0, _p(_d5), 2.6, voice: Voice.rin),
|
||||
], cutoff: 4500, peakDb: -20, room: 0.1, tail: 0.5),
|
||||
// A string going slack: plucked, its pitch sagging away.
|
||||
SfxKind.lost => SoundSpec([
|
||||
Note(0, _p(_a5), 1.1, to: _p(_d5), bright: 0.4),
|
||||
Note(0.05, _p(_d5), 1.0, to: _p(_a4), bright: 0.3, gain: 0.6),
|
||||
], cutoff: 3500, peakDb: -19, tail: 0.5),
|
||||
// Re-tuned: a quick bend up that lands, and a tinkle.
|
||||
SfxKind.back => SoundSpec([
|
||||
Note(0, _p(_b4), 0.9, to: _p(_d5), bright: 0.5),
|
||||
Note(0.2, _p(_a5), 1.0, bright: 0.6, gain: 0.7),
|
||||
Note(0.3, _p(_d6) * 2, 1.0, voice: Voice.chime, gain: 0.2),
|
||||
], peakDb: -18, tail: 0.5),
|
||||
// Two quick wooden taps, the second flicking up, with a glint.
|
||||
SfxKind.nudge => SoundSpec([
|
||||
Note(0, _p(_a5), 0.12, voice: Voice.bamboo, gain: 0.8),
|
||||
Note(0.09, _p(_e6), 0.16, voice: Voice.bamboo),
|
||||
Note(0.09, _p(_b6), 0.35, voice: Voice.chime, gain: 0.3),
|
||||
], cutoff: 6500, peakDb: -18, room: 0.04, tail: 0.5),
|
||||
};
|
||||
|
||||
/// Shown in Settings → Notifications & sounds: name and when it plays.
|
||||
const soundInfo = {
|
||||
SfxKind.ready: ('Ready', 'Someone presses Space; climbs with each ready person'),
|
||||
SfxKind.unready: ('Not ready', 'Someone takes their ready back'),
|
||||
SfxKind.forceReady: ('Everyone ready', 'Someone marks everyone ready'),
|
||||
SfxKind.join: ('Joined', 'Someone joins the server'),
|
||||
SfxKind.leave: ('Left', 'Someone leaves'),
|
||||
SfxKind.kick: ('Sat out', 'Someone sits you out'),
|
||||
SfxKind.swap: ('New video', 'A different video or episode is put up'),
|
||||
SfxKind.chat: ('Message', 'A chat message from someone else'),
|
||||
SfxKind.warn: ('Idle warning', 'Auto-unready soon unless you press Space'),
|
||||
SfxKind.lost: ('Connection lost', 'The server stopped answering; reconnecting'),
|
||||
SfxKind.back: ('Reconnected', 'Back in the server after a drop'),
|
||||
SfxKind.nudge: ('Nudge', 'Someone nudges you; always plays (volume still applies)'),
|
||||
};
|
||||
@@ -0,0 +1,473 @@
|
||||
import 'dart:math' as math;
|
||||
import 'dart:typed_data';
|
||||
|
||||
// A tiny offline synthesizer for UI sounds. The current palette is
|
||||
// spacious: FM star bells, bubble drops, slow pads, a sub that falls away,
|
||||
// stardust and a long dark space tail. Older voices (plucked string, glass
|
||||
// and bowl bells, wood) stay available. Renders to 16-bit mono WAV bytes.
|
||||
// Pure Dart.
|
||||
|
||||
const sampleRate = 44100;
|
||||
|
||||
enum Voice {
|
||||
/// Plucked string; [Note.to] bends it (the delay line stretches).
|
||||
koto,
|
||||
/// Small glass bell: inharmonic partials, a slow shimmer.
|
||||
chime,
|
||||
/// Singing bowl: a soft strike and a long beating hum.
|
||||
rin,
|
||||
/// Hollow bamboo knock, pitched.
|
||||
bamboo,
|
||||
/// Two hardwood blocks struck together.
|
||||
clack,
|
||||
/// Breathy air sweeping up (a sliding door); [Note.hz]→[Note.to] is the
|
||||
/// sweep of its band.
|
||||
slide,
|
||||
/// FM bell: strikes glassy, settles to a pure hanging tone; [Note.bright]
|
||||
/// scales the strike, [Note.to] glides it.
|
||||
star,
|
||||
/// A bubble: the pitch rises into the note fast; [Note.to] flicks it on.
|
||||
drop,
|
||||
/// Slow detuned swell (in over a third of its length, then out);
|
||||
/// [Note.bright] adds overtones, [Note.to] glides it.
|
||||
pad,
|
||||
/// Deep round tone that falls from [Note.hz] to [Note.to].
|
||||
sub,
|
||||
/// A scatter of tiny pings on the scale above [Note.hz], drifting
|
||||
/// towards [Note.to].
|
||||
dust,
|
||||
}
|
||||
|
||||
class Note {
|
||||
final double at;
|
||||
final double hz;
|
||||
final double? to;
|
||||
/// Length (s) to about -40 dB.
|
||||
final double len;
|
||||
final double gain;
|
||||
final Voice voice;
|
||||
/// Koto only: 0 = dark/muted pluck, 1 = bright.
|
||||
final double bright;
|
||||
const Note(this.at, this.hz, this.len,
|
||||
{this.to, this.gain = 1, this.voice = Voice.koto, this.bright = 0.6});
|
||||
}
|
||||
|
||||
class SoundSpec {
|
||||
final List<Note> notes;
|
||||
/// Gentle low-pass cutoff (Hz) over the mix.
|
||||
final double cutoff;
|
||||
/// Peak level after normalising, dBFS (quiet by design).
|
||||
final double peakDb;
|
||||
/// Space tail mix (0..1).
|
||||
final double room;
|
||||
/// Space tail decay to -60 dB (s); the sound keeps about half of it.
|
||||
final double tail;
|
||||
const SoundSpec(this.notes,
|
||||
{this.cutoff = 6000, this.peakDb = -17, this.room = 0.14, this.tail = 1.4});
|
||||
|
||||
double get length =>
|
||||
notes.fold<double>(0, (m, n) => math.max(m, n.at + n.len)) + (room > 0 ? tail * 0.55 : 0.02);
|
||||
}
|
||||
|
||||
/// Semitones from A4 (440 Hz) to Hz.
|
||||
double semi(num n) => 440 * math.pow(2, n / 12).toDouble();
|
||||
|
||||
Float64List render(SoundSpec spec, {int seed = 7}) {
|
||||
final total = (spec.length * sampleRate).ceil();
|
||||
final out = Float64List(total);
|
||||
final rnd = math.Random(seed);
|
||||
for (final n in spec.notes) {
|
||||
final start = (n.at * sampleRate).round();
|
||||
if (start >= total) continue;
|
||||
final s = switch (n.voice) {
|
||||
Voice.koto => _koto(n, rnd),
|
||||
Voice.chime => _bell(n, const [(1.0, 1.0, 1.0), (2.76, 0.45, 1.9), (5.40, 0.2, 3.2), (8.93, 0.08, 4.5)], 1.6, 0.003),
|
||||
Voice.rin => _bell(n, const [(1.0, 1.0, 1.0), (2.71, 0.35, 1.5), (5.15, 0.12, 2.4)], 2.4, 0.012),
|
||||
Voice.bamboo => _bamboo(n, rnd),
|
||||
Voice.clack => _clack(n, rnd),
|
||||
Voice.slide => _slide(n, rnd),
|
||||
Voice.star => _star(n),
|
||||
Voice.drop => _drop(n),
|
||||
Voice.pad => _pad(n, rnd),
|
||||
Voice.sub => _sub(n),
|
||||
Voice.dust => _dust(n, rnd),
|
||||
};
|
||||
for (var i = 0; i < s.length && start + i < total; i++) {
|
||||
out[start + i] += n.gain * s[i];
|
||||
}
|
||||
}
|
||||
_lowpass(out, spec.cutoff);
|
||||
if (spec.room > 0) _space(out, spec.room, spec.tail);
|
||||
// 1 ms in, 10 ms out: no clicks at either end.
|
||||
final fadeIn = (0.001 * sampleRate).round();
|
||||
for (var i = 0; i < fadeIn && i < total; i++) {
|
||||
out[i] *= i / fadeIn;
|
||||
}
|
||||
final fade = (0.01 * sampleRate).round();
|
||||
for (var i = 0; i < fade && i < total; i++) {
|
||||
out[total - 1 - i] *= i / fade;
|
||||
}
|
||||
var peak = 0.0;
|
||||
for (final v in out) {
|
||||
peak = math.max(peak, v.abs());
|
||||
}
|
||||
if (peak > 0) {
|
||||
final g = math.pow(10, spec.peakDb / 20) / peak;
|
||||
for (var i = 0; i < total; i++) {
|
||||
out[i] *= g;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
int _n(double secs) => (secs * sampleRate).round();
|
||||
double _smooth(double t) => t * t * (3 - 2 * t);
|
||||
|
||||
/// Karplus-Strong with a fractional, movable delay: a noise burst in a
|
||||
/// loop that loses a little of its highs each pass.
|
||||
Float64List _koto(Note n, math.Random rnd) {
|
||||
final len = _n(n.len);
|
||||
final out = Float64List(len);
|
||||
final maxPeriod = sampleRate / math.min(n.hz, n.to ?? n.hz) + 4;
|
||||
final buf = Float64List(maxPeriod.ceil() + 4);
|
||||
final size = buf.length;
|
||||
// Excitation: noise, smoothed more for a darker pluck.
|
||||
final period0 = sampleRate / n.hz;
|
||||
var prev = 0.0;
|
||||
final smooth = 1 - (0.25 + 0.7 * n.bright);
|
||||
for (var i = 0; i < period0.ceil(); i++) {
|
||||
final x = rnd.nextDouble() * 2 - 1;
|
||||
prev = smooth * prev + (1 - smooth) * x;
|
||||
buf[i % size] = prev;
|
||||
}
|
||||
// Loop gain so the note reaches -40 dB at len.
|
||||
final loss = math.pow(0.01, 1 / (n.len * n.hz)).toDouble();
|
||||
var w = period0.ceil();
|
||||
var last = 0.0;
|
||||
for (var i = 0; i < len; i++) {
|
||||
final t = i / len;
|
||||
final hz = n.to == null ? n.hz : n.hz * math.pow(n.to! / n.hz, _smooth(math.min(1, t * 1.6)));
|
||||
// The two-point average below delays half a sample: take it off.
|
||||
final period = sampleRate / hz - 0.5;
|
||||
final r = w - period;
|
||||
final ri = r.floor();
|
||||
final f = r - ri;
|
||||
final a = buf[(ri % size + size) % size];
|
||||
final b = buf[((ri + 1) % size + size) % size];
|
||||
final y = a + (b - a) * f;
|
||||
// Two-point average = the string's damping; the 0.5 mix keeps it bright.
|
||||
final v = loss * (0.5 * y + 0.5 * last);
|
||||
last = y;
|
||||
buf[w % size] = v;
|
||||
w++;
|
||||
// A 3 ms attack takes the click off the pluck.
|
||||
out[i] = v * (i < 132 ? i / 132 : 1);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/// Inharmonic partials (ratio, amplitude, decay multiplier), each doubled
|
||||
/// with a slightly detuned twin so the tail shimmers.
|
||||
Float64List _bell(Note n, List<(double, double, double)> partials, double beatHz, double attackSecs) {
|
||||
final len = _n(n.len);
|
||||
final out = Float64List(len);
|
||||
final k = math.log(100) / len;
|
||||
final attack = _n(attackSecs);
|
||||
for (final (ratio, amp, decay) in partials) {
|
||||
final f1 = n.hz * ratio, f2 = f1 + beatHz * ratio;
|
||||
for (var i = 0; i < len; i++) {
|
||||
final env = math.exp(-k * decay * i) * (i < attack ? _smooth(i / attack) : 1);
|
||||
final ph = 2 * math.pi * i / sampleRate;
|
||||
out[i] += amp * env * 0.5 * (math.sin(ph * f1) + math.sin(ph * f2));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/// A hollow tube: a pitched thump that drops into its note, an overtone
|
||||
/// that dies fast, and a short click of air.
|
||||
Float64List _bamboo(Note n, math.Random rnd) {
|
||||
final len = _n(n.len);
|
||||
final out = Float64List(len);
|
||||
final k = math.log(100) / len;
|
||||
var ph = 0.0, ph2 = 0.0;
|
||||
for (var i = 0; i < len; i++) {
|
||||
final drop = math.exp(-i / _n(0.012));
|
||||
final hz = n.hz * (1 + 0.5 * drop);
|
||||
ph += 2 * math.pi * hz / sampleRate;
|
||||
ph2 += 2 * math.pi * hz * 2.31 / sampleRate;
|
||||
out[i] = math.exp(-k * i) * (math.sin(ph) + 0.35 * math.sin(ph2) * math.exp(-k * 3 * i));
|
||||
}
|
||||
final click = _bandNoise(_n(0.006), n.hz * 3, 2.0, rnd);
|
||||
for (var i = 0; i < click.length && i < len; i++) {
|
||||
out[i] += 0.5 * click[i] * (1 - i / click.length);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/// Hardwood: two resonances excited by a very short noise burst.
|
||||
Float64List _clack(Note n, math.Random rnd) {
|
||||
final len = _n(n.len);
|
||||
final out = Float64List(len);
|
||||
final burst = Float64List(len);
|
||||
for (var i = 0; i < _n(0.002) && i < len; i++) {
|
||||
burst[i] = rnd.nextDouble() * 2 - 1;
|
||||
}
|
||||
for (final (f, q, g) in [(n.hz, 18.0, 1.0), (n.hz * 1.47, 14.0, 0.6), (n.hz * 2.6, 10.0, 0.25)]) {
|
||||
final r = _biquadBand(burst, f, q);
|
||||
for (var i = 0; i < len; i++) {
|
||||
out[i] += g * r[i];
|
||||
}
|
||||
}
|
||||
final k = math.log(100) / len;
|
||||
for (var i = 0; i < len; i++) {
|
||||
out[i] *= math.exp(-k * i);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/// Air through a band that sweeps from [Note.hz] to [Note.to], swelling in
|
||||
/// and out (a paper door sliding along its track).
|
||||
Float64List _slide(Note n, math.Random rnd) {
|
||||
final len = _n(n.len);
|
||||
final noise = Float64List(len);
|
||||
for (var i = 0; i < len; i++) {
|
||||
noise[i] = rnd.nextDouble() * 2 - 1;
|
||||
}
|
||||
final out = Float64List(len);
|
||||
// Sweep in short blocks (a fixed band per block is plenty).
|
||||
const block = 256;
|
||||
var z1 = 0.0, z2 = 0.0;
|
||||
for (var b0 = 0; b0 < len; b0 += block) {
|
||||
final t = b0 / len;
|
||||
final f = n.hz * math.pow((n.to ?? n.hz) / n.hz, t);
|
||||
final c = _bandCoeffs(f.toDouble(), 2.5);
|
||||
for (var i = b0; i < math.min(len, b0 + block); i++) {
|
||||
final y = c.$1 * noise[i] + z1;
|
||||
z1 = c.$2 * noise[i] - c.$4 * y + z2;
|
||||
z2 = c.$3 * noise[i] - c.$5 * y;
|
||||
final tt = i / len;
|
||||
// Rises, holds, eases off: sin^2 swell with a tail.
|
||||
final env = math.pow(math.sin(math.pi * math.min(1, tt * 1.25)), 2).toDouble();
|
||||
// The track's grain: a slow flutter.
|
||||
final grain = 1 + 0.25 * math.sin(2 * math.pi * 23 * i / sampleRate);
|
||||
out[i] = y * env * grain;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
Float64List _bandNoise(int len, double f, double q, math.Random rnd) {
|
||||
final x = Float64List(len);
|
||||
for (var i = 0; i < len; i++) {
|
||||
x[i] = rnd.nextDouble() * 2 - 1;
|
||||
}
|
||||
return _biquadBand(x, f, q);
|
||||
}
|
||||
|
||||
/// RBJ band-pass (constant 0 dB peak): (b0, b1, b2, a1, a2), b1 = 0.
|
||||
(double, double, double, double, double) _bandCoeffs(double f, double q) {
|
||||
final w = 2 * math.pi * math.min(f, sampleRate * 0.45) / sampleRate;
|
||||
final alpha = math.sin(w) / (2 * q);
|
||||
final a0 = 1 + alpha;
|
||||
return (alpha / a0, 0, -alpha / a0, -2 * math.cos(w) / a0, (1 - alpha) / a0);
|
||||
}
|
||||
|
||||
Float64List _biquadBand(Float64List x, double f, double q) {
|
||||
final c = _bandCoeffs(f, q);
|
||||
final y = Float64List(x.length);
|
||||
var z1 = 0.0, z2 = 0.0;
|
||||
for (var i = 0; i < x.length; i++) {
|
||||
final v = c.$1 * x[i] + z1;
|
||||
z1 = c.$2 * x[i] - c.$4 * v + z2;
|
||||
z2 = c.$3 * x[i] - c.$5 * v;
|
||||
y[i] = v;
|
||||
}
|
||||
return y;
|
||||
}
|
||||
|
||||
/// One pole, twice: a soft 12 dB/oct roll-off.
|
||||
void _lowpass(Float64List out, double cutoff) {
|
||||
final a = math.exp(-2 * math.pi * cutoff / sampleRate);
|
||||
for (var pass = 0; pass < 2; pass++) {
|
||||
var y = 0.0;
|
||||
for (var i = 0; i < out.length; i++) {
|
||||
y = (1 - a) * out[i] + a * y;
|
||||
out[i] = y;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Space: four damped feedback combs and two allpasses (a dark, smooth
|
||||
/// tail decaying to -60 dB in [tail] s) after a short pre-delay.
|
||||
void _space(Float64List out, double mix, double tail) {
|
||||
const combs = [0.0297, 0.0371, 0.0411, 0.0437];
|
||||
const damp = 0.35;
|
||||
final len = out.length;
|
||||
final pre = _n(0.018);
|
||||
final dry = Float64List(len);
|
||||
for (var i = pre; i < len; i++) {
|
||||
dry[i] = out[i - pre];
|
||||
}
|
||||
final wet = Float64List(len);
|
||||
for (final d in combs) {
|
||||
final delay = _n(d);
|
||||
final g = math.pow(0.001, d / tail).toDouble();
|
||||
final y = Float64List(len);
|
||||
var lp = 0.0;
|
||||
for (var i = 0; i < len; i++) {
|
||||
final back = i >= delay ? y[i - delay] : 0.0;
|
||||
lp = back * (1 - damp) + lp * damp;
|
||||
y[i] = dry[i] + g * lp;
|
||||
wet[i] += y[i] / combs.length;
|
||||
}
|
||||
}
|
||||
for (final d in const [0.005, 0.0017]) {
|
||||
final delay = _n(d);
|
||||
final x = Float64List.fromList(wet);
|
||||
for (var i = 0; i < len; i++) {
|
||||
final xd = i >= delay ? x[i - delay] : 0.0;
|
||||
final yd = i >= delay ? wet[i - delay] : 0.0;
|
||||
wet[i] = -0.5 * x[i] + xd + 0.5 * yd;
|
||||
}
|
||||
}
|
||||
for (var i = 0; i < len; i++) {
|
||||
out[i] += mix * wet[i];
|
||||
}
|
||||
}
|
||||
|
||||
/// [Note.hz] gliding to [Note.to] over the first ~60% of the note.
|
||||
double _glide(Note n, double t) =>
|
||||
n.to == null ? n.hz : n.hz * math.pow(n.to! / n.hz, _smooth(math.min(1, t * 1.6))).toDouble();
|
||||
|
||||
Float64List _star(Note n) {
|
||||
final len = _n(n.len);
|
||||
final out = Float64List(len);
|
||||
final k = math.log(100) / len;
|
||||
final attack = _n(0.004);
|
||||
final index0 = 0.3 + 3.0 * n.bright;
|
||||
final idxTau = _n(0.09 + 0.12 * n.bright);
|
||||
const ratio = 3.5;
|
||||
var c1 = 0.0, c2 = 0.0, m1 = 0.0, m2 = 0.0;
|
||||
for (var i = 0; i < len; i++) {
|
||||
final hz = _glide(n, i / len);
|
||||
final w = 2 * math.pi * hz / sampleRate;
|
||||
c1 += w;
|
||||
c2 += w * 1.0017;
|
||||
m1 += w * ratio;
|
||||
m2 += w * ratio * 1.0017;
|
||||
final idx = index0 * math.exp(-i / idxTau);
|
||||
final env = math.exp(-k * i) * (i < attack ? _smooth(i / attack) : 1);
|
||||
out[i] = env * 0.5 * (math.sin(c1 + idx * math.sin(m1)) + math.sin(c2 + idx * math.sin(m2)));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
Float64List _drop(Note n) {
|
||||
final len = _n(n.len);
|
||||
final out = Float64List(len);
|
||||
final k = math.log(100) / len;
|
||||
final attack = _n(0.002);
|
||||
final riseTau = _n(0.016);
|
||||
var ph = 0.0;
|
||||
for (var i = 0; i < len; i++) {
|
||||
final t = i / len;
|
||||
final base = n.to == null ? n.hz : n.hz * math.pow(n.to! / n.hz, _smooth(math.min(1, t * 2.5)));
|
||||
final hz = base * (0.62 + 0.38 * (1 - math.exp(-i / riseTau)));
|
||||
ph += 2 * math.pi * hz / sampleRate;
|
||||
final env = math.exp(-k * i) * (i < attack ? _smooth(i / attack) : 1);
|
||||
out[i] = env * (math.sin(ph) + 0.15 * math.sin(2 * ph));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
Float64List _pad(Note n, math.Random rnd) {
|
||||
final len = _n(n.len);
|
||||
final out = Float64List(len);
|
||||
const detune = [-9.0, -3.0, 4.0, 10.0];
|
||||
final ph = [for (var j = 0; j < detune.length + 1; j++) rnd.nextDouble() * 2 * math.pi];
|
||||
final ratios = [for (final c in detune) math.pow(2, c / 1200).toDouble(), 0.5];
|
||||
final amps = [for (final _ in detune) 1.0, 0.45];
|
||||
const a = 0.33;
|
||||
for (var i = 0; i < len; i++) {
|
||||
final t = i / len;
|
||||
final env = t < a ? _smooth(t / a) : math.pow(1 - _smooth((t - a) / (1 - a)), 2).toDouble();
|
||||
final vib = 1 + 0.002 * math.sin(2 * math.pi * 0.3 * i / sampleRate);
|
||||
final hz = _glide(n, t) * vib;
|
||||
var v = 0.0;
|
||||
for (var j = 0; j < ratios.length; j++) {
|
||||
ph[j] += 2 * math.pi * hz * ratios[j] / sampleRate;
|
||||
v += amps[j] * (math.sin(ph[j]) + n.bright * (0.25 * math.sin(2 * ph[j]) + 0.1 * math.sin(3 * ph[j])));
|
||||
}
|
||||
out[i] = env * v / ratios.length;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
Float64List _sub(Note n) {
|
||||
final len = _n(n.len);
|
||||
final out = Float64List(len);
|
||||
final k = math.log(100) / len;
|
||||
final attack = _n(0.025);
|
||||
var ph = 0.0;
|
||||
for (var i = 0; i < len; i++) {
|
||||
final t = i / len;
|
||||
final hz = n.to == null ? n.hz : n.hz * math.pow(n.to! / n.hz, _smooth(t));
|
||||
ph += 2 * math.pi * hz / sampleRate;
|
||||
final env = math.exp(-k * i) * (i < attack ? _smooth(i / attack) : 1);
|
||||
// Overtones so it still reads on small speakers.
|
||||
out[i] = env * (math.sin(ph) + 0.35 * math.sin(2 * ph) + 0.12 * math.sin(3 * ph));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
Float64List _dust(Note n, math.Random rnd) {
|
||||
final len = _n(n.len);
|
||||
final out = Float64List(len);
|
||||
const steps = [0, 2, 4, 7, 9, 12, 14];
|
||||
final count = math.max(4, (n.len * 14).round());
|
||||
for (var g = 0; g < count; g++) {
|
||||
final tg = (g + rnd.nextDouble() * 0.6) / count * 0.75;
|
||||
final start = _n(tg * n.len);
|
||||
final centre = n.to == null ? n.hz : n.hz * math.pow(n.to! / n.hz, tg);
|
||||
final f = centre * math.pow(2, steps[rnd.nextInt(steps.length)] / 12);
|
||||
final glen = math.min(_n(0.1 + 0.15 * rnd.nextDouble()), len - start);
|
||||
if (glen <= 0) continue;
|
||||
final amp = (1 - tg) * (0.5 + 0.5 * rnd.nextDouble());
|
||||
final k = math.log(100) / glen;
|
||||
final w = 2 * math.pi * f / sampleRate;
|
||||
for (var i = 0; i < glen; i++) {
|
||||
out[start + i] += amp * math.exp(-k * i) * (i < 88 ? i / 88 : 1) * math.sin(w * i);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/// 16-bit PCM mono WAV.
|
||||
Uint8List wav(Float64List samples) {
|
||||
final data = samples.length * 2;
|
||||
final b = ByteData(44 + data);
|
||||
void str(int o, String s) {
|
||||
for (var i = 0; i < s.length; i++) {
|
||||
b.setUint8(o + i, s.codeUnitAt(i));
|
||||
}
|
||||
}
|
||||
|
||||
str(0, 'RIFF');
|
||||
b.setUint32(4, 36 + data, Endian.little);
|
||||
str(8, 'WAVE');
|
||||
str(12, 'fmt ');
|
||||
b.setUint32(16, 16, Endian.little);
|
||||
b.setUint16(20, 1, Endian.little);
|
||||
b.setUint16(22, 1, Endian.little);
|
||||
b.setUint32(24, sampleRate, Endian.little);
|
||||
b.setUint32(28, sampleRate * 2, Endian.little);
|
||||
b.setUint16(32, 2, Endian.little);
|
||||
b.setUint16(34, 16, Endian.little);
|
||||
str(36, 'data');
|
||||
b.setUint32(40, data, Endian.little);
|
||||
for (var i = 0; i < samples.length; i++) {
|
||||
b.setInt16(44 + i * 2, (samples[i].clamp(-1.0, 1.0) * 32767).round(), Endian.little);
|
||||
}
|
||||
return b.buffer.asUint8List();
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:media_kit/media_kit.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
import 'sfx.dart';
|
||||
import 'sound_bank.dart';
|
||||
import 'synth.dart';
|
||||
|
||||
/// Plays [SoundSpec]s: each is rendered once to a WAV in the temp dir and
|
||||
/// played on a small pool of audio-only mpv players (so two sounds can
|
||||
/// overlap). [volume] (0..1) is read per play and applied on a square
|
||||
/// curve (ears are log); [enabled] filters cues.
|
||||
class SynthSfx implements SfxService {
|
||||
final double Function() volume;
|
||||
final bool Function(SfxKind kind) enabled;
|
||||
/// Which sound set each sound plays from.
|
||||
final SoundPreset Function(SfxKind kind) presetFor;
|
||||
SynthSfx({required this.volume, bool Function(SfxKind kind)? enabled, SoundPreset Function(SfxKind kind)? presetFor})
|
||||
: enabled = enabled ?? ((_) => true),
|
||||
presetFor = presetFor ?? ((_) => defaultPreset);
|
||||
|
||||
static const _poolSize = 3;
|
||||
final List<Player> _pool = [];
|
||||
var _next = 0;
|
||||
final Map<String, Future<String>> _files = {};
|
||||
Directory? _dir;
|
||||
|
||||
@override
|
||||
Future<void> play(SfxCue cue) async {
|
||||
if (!enabled(cue.kind)) return;
|
||||
final preset = presetFor(cue.kind);
|
||||
await playSpec('${preset.name}-${cue.kind.name}-${cue.readyIndex}',
|
||||
soundFor(cue.kind, readyIndex: cue.readyIndex, preset: preset));
|
||||
}
|
||||
|
||||
/// Plays [kind] even when it is switched off (Settings' preview), always
|
||||
/// freshly rendered: an edited spec plays after a hot reload.
|
||||
Future<void> preview(SfxKind kind, {int readyIndex = 1, SoundPreset? preset}) {
|
||||
final p = preset ?? presetFor(kind);
|
||||
final key = '${p.name}-${kind.name}-$readyIndex';
|
||||
_files.remove(key);
|
||||
return playSpec(key, soundFor(kind, readyIndex: readyIndex, preset: p));
|
||||
}
|
||||
|
||||
/// Plays [spec], cached under [key] (same key = same sound).
|
||||
Future<void> playSpec(String key, SoundSpec spec) async {
|
||||
final v = volume();
|
||||
if (v <= 0) return;
|
||||
try {
|
||||
final path = await (_files[key] ??= _write(key, spec));
|
||||
final player = await _player();
|
||||
await player.setVolume(100 * v * v);
|
||||
await player.open(Media(path));
|
||||
} catch (e) {
|
||||
_files.remove(key);
|
||||
debugPrint('sfx $key: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<String> _write(String key, SoundSpec spec) async {
|
||||
_dir ??= await Directory('${(await getTemporaryDirectory()).path}/watch_together_sfx').create(recursive: true);
|
||||
final f = File('${_dir!.path}/$key.wav');
|
||||
await f.writeAsBytes(wav(render(spec)));
|
||||
return f.path;
|
||||
}
|
||||
|
||||
Future<Player> _player() async {
|
||||
if (_pool.length < _poolSize) {
|
||||
final p = Player(
|
||||
configuration: const PlayerConfiguration(vo: 'null', osc: false, libass: false, title: 'sfx'),
|
||||
);
|
||||
await (p.platform as NativePlayer).setProperty('audio-display', 'no');
|
||||
_pool.add(p);
|
||||
return p;
|
||||
}
|
||||
return _pool[_next++ % _poolSize];
|
||||
}
|
||||
|
||||
Future<void> dispose() async {
|
||||
for (final p in _pool) {
|
||||
await p.dispose();
|
||||
}
|
||||
_pool.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,845 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io' show File;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:fox_utils/fox_utils.dart' show ClipboardX, DesktopDropX, DropTarget;
|
||||
import 'package:signals/signals_flutter.dart';
|
||||
import 'package:window_manager/window_manager.dart';
|
||||
|
||||
import '../files/folder_store.dart';
|
||||
import '../files/ui/files_page.dart' show showFilesFolder;
|
||||
import '../jellyfin/accounts.dart';
|
||||
import '../jellyfin/browse_scope.dart' show BrowseStart;
|
||||
import '../jellyfin/session_media.dart';
|
||||
import '../screens/home/connect_flow.dart';
|
||||
import '../session/session_coordinator.dart' show ConnectParams;
|
||||
import '../screens/home/health_probe.dart';
|
||||
import '../screens/home/media_picker.dart';
|
||||
import '../screens/home/profile.dart';
|
||||
import '../screens/home/update_indicator.dart';
|
||||
import '../state/session_state.dart';
|
||||
import '../state/settings.dart';
|
||||
import '../update/update_controller.dart';
|
||||
import 'chat_panel.dart';
|
||||
import 'connect_cards.dart';
|
||||
import 'handoff.dart';
|
||||
import 'library_tab.dart';
|
||||
import 'motion.dart';
|
||||
import 'party_strip.dart';
|
||||
import 'profile_card.dart';
|
||||
import '../schedule/ui/schedule_page.dart';
|
||||
import 'session_player.dart';
|
||||
import 'settings_tab.dart';
|
||||
import 'toasts.dart';
|
||||
import 'welcome.dart';
|
||||
|
||||
/// The one window: the party strip over a body that fades through between
|
||||
/// the tabs and the full player, the floating mini player, the chat panel
|
||||
/// and the toasts. Online/offline follows [SessionState.route].
|
||||
///
|
||||
/// The player ([SessionPlayer], the only `Video`) lives in one layer of
|
||||
/// the body: full it fills the body (fullscreen: the window, strip
|
||||
/// hidden), mini it is a 384x216 box snapped to a corner. A mode change
|
||||
/// fades the layer out, moves it while invisible and fades it back in, so
|
||||
/// the video is never mounted twice nor remounted.
|
||||
///
|
||||
/// A drop anywhere on the window or Ctrl+V outside a text field is a
|
||||
/// [Handoff]: a card near the pointer asks how to open it.
|
||||
class AppShell extends StatefulWidget {
|
||||
final UpdateController? updates;
|
||||
final bool autoUpdateCheck;
|
||||
/// Off in tests: the drag area, window buttons and fullscreen need
|
||||
/// window_manager.
|
||||
final bool windowControls;
|
||||
final HealthGetter healthGet;
|
||||
/// Linked Jellyfin servers; defaults to [JellyfinAccounts.instance].
|
||||
final JellyfinAccounts? jellyfin;
|
||||
/// Stand-in for media_kit's `Video` (tests).
|
||||
final VideoViewBuilder? videoBuilder;
|
||||
const AppShell({
|
||||
super.key,
|
||||
this.updates,
|
||||
this.autoUpdateCheck = true,
|
||||
this.windowControls = true,
|
||||
this.healthGet = defaultHealthGet,
|
||||
this.jellyfin,
|
||||
this.videoBuilder,
|
||||
});
|
||||
|
||||
@override
|
||||
State<AppShell> createState() => AppShellState();
|
||||
}
|
||||
|
||||
class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin {
|
||||
static const miniSize = Size(384, 216);
|
||||
static const _miniMargin = 16.0;
|
||||
|
||||
ShellTab _tab = ShellTab.library;
|
||||
SettingsSection _section = SettingsSection.profile;
|
||||
|
||||
/// Where the player is going ([_mode]) and where it is drawn ([_shown]):
|
||||
/// they differ for the first part of a change, while it fades out.
|
||||
PlayerMode _mode = PlayerMode.none;
|
||||
PlayerMode _shown = PlayerMode.none;
|
||||
PlayerMode _from = PlayerMode.none;
|
||||
int _modeSeq = 0;
|
||||
late final AnimationController _fade = AnimationController(vsync: this, value: 1);
|
||||
|
||||
bool _fullscreen = false;
|
||||
bool _chatOpen = false;
|
||||
int _unread = 0;
|
||||
int _feedSeen = 0;
|
||||
final _chat = TextEditingController();
|
||||
final _chatFocus = FocusNode();
|
||||
final _playerKey = GlobalKey();
|
||||
|
||||
Alignment _corner = Alignment.bottomRight;
|
||||
Offset? _drag; // mini top-left while dragging
|
||||
bool _snapping = false;
|
||||
|
||||
/// Last pointer position in the window (where a paste's card opens).
|
||||
Offset? _pointer;
|
||||
bool _dragging = false;
|
||||
bool _handoffOpen = false;
|
||||
|
||||
final _flow = ConnectFlow();
|
||||
late final _join = JoinFields(SessionState.instance.lastForm);
|
||||
late final _host = HostFields(SessionState.instance.lastForm);
|
||||
late final JellyfinAccounts _jellyfin = widget.jellyfin ?? JellyfinAccounts.instance;
|
||||
late MediaPickerController _picker = _newPicker();
|
||||
BrowseStart? _libraryStart;
|
||||
int _libraryGen = 0;
|
||||
|
||||
/// The next media to come up opens full (just joined, or put up here).
|
||||
bool _autoFull = false;
|
||||
int _lastMediaId = -1;
|
||||
bool _dialogOpen = false;
|
||||
final _stops = <void Function()>[];
|
||||
// Rebuild the shell on these flips, not on every state broadcast.
|
||||
final _online = computed(() => SessionState.instance.route.value == AppRoute.session);
|
||||
/// Nobody else watches: offline, solo, or solo becoming a server.
|
||||
final _alone = computed(() {
|
||||
final s = SessionState.instance;
|
||||
return s.route.value != AppRoute.session || s.solo.value || s.switching.value;
|
||||
});
|
||||
final _hasMedia = computed(() =>
|
||||
SessionState.instance.route.value == AppRoute.session &&
|
||||
SessionState.instance.coordinator.serverState.value?.media != null);
|
||||
|
||||
PlayerMode get mode => _mode;
|
||||
PlayerMode get shownMode => _shown;
|
||||
bool get fullscreen => _fullscreen;
|
||||
|
||||
MediaPickerController _newPicker({JellyfinMedia? jellyfin, MediaKind? source}) {
|
||||
final f = SessionState.instance.lastForm;
|
||||
final c = MediaPickerController(mediaPath: f.mediaPath, linkUrl: f.linkUrl, jellyfin: jellyfin, source: source);
|
||||
if (jellyfin == null) unawaited(restoreLastPick(c, _jellyfin, () => mounted && identical(c, _picker)));
|
||||
return c;
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
unawaited(Profile.instance.ensureLoaded());
|
||||
// Reads linked servers and checks their tokens in the background.
|
||||
_jellyfin.ensureLoaded();
|
||||
if (widget.autoUpdateCheck) _autoCheck();
|
||||
HardwareKeyboard.instance.addHandler(_onKey);
|
||||
final session = SessionState.instance;
|
||||
final coord = session.coordinator;
|
||||
_feedSeen = coord.feedLog.peek().length;
|
||||
_stops.add(effect(() {
|
||||
final online = session.route.value == AppRoute.session;
|
||||
// Set now, not later: the media effect below may run first in this
|
||||
// same flush, and its scheduled show reads these.
|
||||
_autoFull = online;
|
||||
if (!online) _lastMediaId = -1;
|
||||
untracked(() => _later(() => online ? _wentOnline() : _wentOffline()));
|
||||
}));
|
||||
// Something comes up while the player is closed: show it (full right
|
||||
// after joining or a put-up of ours, else as the mini player).
|
||||
_stops.add(effect(() {
|
||||
final r = coord.serverState.value;
|
||||
final online = session.route.value == AppRoute.session;
|
||||
if (!online || r == null || r.media == null) return;
|
||||
if (r.mediaId == _lastMediaId) return;
|
||||
_lastMediaId = r.mediaId;
|
||||
_later(() {
|
||||
if (_mode == PlayerMode.none) {
|
||||
_show(_autoFull ? PlayerMode.full : PlayerMode.mini);
|
||||
} else if (_autoFull) {
|
||||
_show(PlayerMode.full);
|
||||
}
|
||||
_autoFull = false;
|
||||
});
|
||||
}));
|
||||
// Chat that arrives while the panel is closed.
|
||||
_stops.add(effect(() {
|
||||
final log = coord.feedLog.value;
|
||||
final fresh = log.length < _feedSeen ? log : log.sublist(_feedSeen);
|
||||
_feedSeen = log.length;
|
||||
final chats = fresh.where((f) => f.action == 'chat').length;
|
||||
if (chats == 0) return;
|
||||
untracked(() => _later(() {
|
||||
if (!_chatOpen) setState(() => _unread += chats);
|
||||
}));
|
||||
}));
|
||||
// Back from sitting out with the player closed: show it (the media
|
||||
// plays again).
|
||||
var wasOut = false;
|
||||
_stops.add(effect(() {
|
||||
final out = coord.selfOut.value;
|
||||
if (wasOut && !out && session.route.peek() == AppRoute.session) {
|
||||
_later(() {
|
||||
if (_mode == PlayerMode.none) _show(PlayerMode.mini);
|
||||
});
|
||||
}
|
||||
wasOut = out;
|
||||
}));
|
||||
// Solo becoming a hosted server: the player closes while the media
|
||||
// moves over and comes back the way it was once it is up again.
|
||||
_stops.add(effect(() {
|
||||
if (!session.switching.value) return;
|
||||
_later(() {
|
||||
_lastMediaId = -1;
|
||||
_autoFull = _mode == PlayerMode.full;
|
||||
if (_fullscreen) _toggleFullscreen();
|
||||
_show(PlayerMode.none);
|
||||
});
|
||||
}));
|
||||
// The hosted server died mid-session (SessionState leaves): say so.
|
||||
_stops.add(effect(() {
|
||||
final crash = session.serverCrash.value;
|
||||
if (crash == null) return;
|
||||
_later(() {
|
||||
session.serverCrash.value = null;
|
||||
_alert('Server crashed', crash);
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
for (final stop in _stops) {
|
||||
stop();
|
||||
}
|
||||
HardwareKeyboard.instance.removeHandler(_onKey);
|
||||
_fade.dispose();
|
||||
_online.dispose();
|
||||
_alone.dispose();
|
||||
_hasMedia.dispose();
|
||||
_flow.dispose();
|
||||
_join.dispose();
|
||||
_host.dispose();
|
||||
_picker.dispose();
|
||||
_chat.dispose();
|
||||
_chatFocus.dispose();
|
||||
if (_fullscreen) _setWindowFullscreen(false);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// Runs [f] after the current signal notification / build.
|
||||
void _later(VoidCallback f) => scheduleMicrotask(() {
|
||||
if (mounted) f();
|
||||
});
|
||||
|
||||
Future<void> _autoCheck() async {
|
||||
final s = AppSettings.instance;
|
||||
await s.ensureLoaded();
|
||||
if (s.autoCheckUpdates.peek()) await runUpdateCheck(widget.updates);
|
||||
}
|
||||
|
||||
void _wentOnline() {
|
||||
_unread = 0;
|
||||
_feedSeen = SessionState.instance.coordinator.feedLog.peek().length;
|
||||
// Hosting starts with nothing up: the library is where to pick it.
|
||||
setState(() => _tab = ShellTab.library);
|
||||
}
|
||||
|
||||
void _wentOffline() {
|
||||
if (_fullscreen) _toggleFullscreen();
|
||||
setState(() {
|
||||
_chatOpen = false;
|
||||
_unread = 0;
|
||||
});
|
||||
_show(PlayerMode.none);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------- player mode
|
||||
|
||||
/// Fade through to [m]: the player layer (and, when full is involved,
|
||||
/// the tabs) fades out, moves while invisible, then fades in.
|
||||
Future<void> _show(PlayerMode m) async {
|
||||
if (m == _mode) return;
|
||||
final seq = ++_modeSeq;
|
||||
setState(() {
|
||||
_from = _shown;
|
||||
_mode = m;
|
||||
});
|
||||
if (m == PlayerMode.full) FocusManager.instance.primaryFocus?.unfocus();
|
||||
if (_shown != PlayerMode.none || m == PlayerMode.full) {
|
||||
await _fade.animateTo(0,
|
||||
duration: fadeThroughTime * fadeOutPart, curve: Curves.easeIn);
|
||||
} else {
|
||||
_fade.value = 0;
|
||||
}
|
||||
if (!mounted || seq != _modeSeq) return;
|
||||
setState(() => _shown = m);
|
||||
await _fade.animateTo(1, duration: fadeThroughTime * (1 - fadeOutPart), curve: ease);
|
||||
}
|
||||
|
||||
void _expand() => _show(PlayerMode.full);
|
||||
|
||||
void _collapse() {
|
||||
if (_fullscreen) _toggleFullscreen();
|
||||
_show(PlayerMode.mini);
|
||||
}
|
||||
|
||||
/// The player's ×: sit out (stay in the server, nothing loaded; the
|
||||
/// player shows the join-back card). Once out, × closes the player.
|
||||
void _closePlayer() {
|
||||
final coord = SessionState.instance.coordinator;
|
||||
if (coord.selfOut.peek()) {
|
||||
_show(PlayerMode.none);
|
||||
} else {
|
||||
coord.sitOut();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _setWindowFullscreen(bool on) async {
|
||||
if (!widget.windowControls) return;
|
||||
try {
|
||||
await windowManager.setFullScreen(on);
|
||||
} catch (_) {
|
||||
// No window manager.
|
||||
}
|
||||
}
|
||||
|
||||
void _toggleFullscreen() {
|
||||
final on = !_fullscreen;
|
||||
setState(() => _fullscreen = on);
|
||||
if (on && _mode != PlayerMode.full) _show(PlayerMode.full);
|
||||
_setWindowFullscreen(on);
|
||||
}
|
||||
|
||||
void _selectTab(ShellTab t) {
|
||||
if (_fullscreen) _toggleFullscreen();
|
||||
setState(() => _tab = t);
|
||||
if (_mode == PlayerMode.full) _show(PlayerMode.mini);
|
||||
}
|
||||
|
||||
void _openSettings(SettingsSection s) {
|
||||
setState(() => _section = s);
|
||||
_selectTab(ShellTab.settings);
|
||||
}
|
||||
|
||||
void _toggleChat() => setState(() {
|
||||
_chatOpen = !_chatOpen;
|
||||
_unread = 0;
|
||||
if (!_chatOpen) _chatFocus.unfocus();
|
||||
});
|
||||
|
||||
// --------------------------------------------------------------- library
|
||||
|
||||
/// "Change video": the library, opened on the series of the episode
|
||||
/// playing (season selected, episode highlighted), else where it was.
|
||||
void _changeVideo() {
|
||||
final coord = SessionState.instance.coordinator;
|
||||
coord.markInput();
|
||||
final ref = coord.currentRef.peek();
|
||||
final pick = ref == null ? null : pickForRef(_jellyfin, ref);
|
||||
final plan = coord.jellyfinPlan.peek();
|
||||
final old = _picker;
|
||||
_picker = pick == null
|
||||
? _newPicker()
|
||||
: _newPicker(jellyfin: pick, source: MediaKind.jellyfin);
|
||||
old.dispose();
|
||||
_libraryStart = ref != null && plan is OwnPlay && plan.item != null
|
||||
? BrowseStart.seriesOf(ref.serverId, plan.item!)
|
||||
: null;
|
||||
_libraryGen++;
|
||||
_selectTab(ShellTab.library);
|
||||
}
|
||||
|
||||
/// Library → "Put it up for everyone"; the player opens full. Offline
|
||||
/// it starts a solo session first (just you, on this computer).
|
||||
Future<String?> _putUp(PickedMedia media) async {
|
||||
final session = SessionState.instance;
|
||||
if (media is JellyfinMedia) {
|
||||
try {
|
||||
putUpJellyfinMedia(_jellyfin, media);
|
||||
} on FormatException catch (e) {
|
||||
return e.message;
|
||||
}
|
||||
unawaited(_jellyfin.rememberPick(media));
|
||||
}
|
||||
if (!_online.peek()) {
|
||||
final error = await _startSolo();
|
||||
if (error != null || !mounted) return error;
|
||||
}
|
||||
// Alone, a link plays direct: there is nobody to pass it on to.
|
||||
if (media is LinkMedia && session.solo.peek()) media = LinkMedia(media.url, anyway: media.anyway);
|
||||
final error = await session.coordinator.swapPicked(media);
|
||||
if (error != null || !mounted) return error;
|
||||
final f = session.lastForm;
|
||||
f
|
||||
..linkUrl = media is LinkMedia ? media.url : f.linkUrl
|
||||
..mediaPath = media is FileMedia ? media.path : f.mediaPath;
|
||||
unawaited(session.saveForm().catchError((Object _) {}));
|
||||
_autoFull = true;
|
||||
_show(PlayerMode.full);
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Starts a solo session; returns an error text, or null.
|
||||
Future<String?> _startSolo() async {
|
||||
final profile = Profile.instance;
|
||||
try {
|
||||
await profile.ensureLoaded();
|
||||
final name = profile.name.peek().trim();
|
||||
await SessionState.instance.startSolo(ConnectParams(
|
||||
host: '127.0.0.1',
|
||||
port: 0,
|
||||
secret: '',
|
||||
name: name.isEmpty ? 'Me' : name,
|
||||
avatar: profile.avatarBase64(),
|
||||
));
|
||||
return null;
|
||||
} catch (e) {
|
||||
return "couldn't start playing: $e";
|
||||
}
|
||||
}
|
||||
|
||||
/// Library's "Watch solo": somewhere to pick from (Jellyfin when linked,
|
||||
/// else Files).
|
||||
void _watchSolo() => _openLibrary(_jellyfin.isEmpty ? MediaKind.file : MediaKind.jellyfin);
|
||||
|
||||
// --------------------------------------------------------------- handoff
|
||||
|
||||
/// Nothing (a dialog, a popover) is over the shell.
|
||||
bool get _onTop => !_handoffOpen && (ModalRoute.of(context)?.isCurrent ?? true);
|
||||
|
||||
/// Ctrl+V outside a text field (a focused field pastes on its own).
|
||||
bool _onKey(KeyEvent e) {
|
||||
if (e is! KeyDownEvent || e.logicalKey != LogicalKeyboardKey.keyV) return false;
|
||||
final keys = HardwareKeyboard.instance;
|
||||
if (!(keys.isControlPressed || keys.isMetaPressed) || keys.isShiftPressed || keys.isAltPressed) return false;
|
||||
if (!mounted || !_onTop) return false;
|
||||
final inField =
|
||||
FocusManager.instance.primaryFocus?.context?.findAncestorStateOfType<EditableTextState>() != null;
|
||||
if (inField) return false;
|
||||
unawaited(_paste());
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Clipboard files first, then text (a link or absolute paths). A pasted
|
||||
/// image has no taker yet, so the clipboard's image data is not read.
|
||||
Future<void> _paste() async {
|
||||
var files = const <String>[];
|
||||
String? text;
|
||||
try {
|
||||
files = await ClipboardX.readFiles();
|
||||
} catch (_) {}
|
||||
if (files.isEmpty) {
|
||||
try {
|
||||
text = await ClipboardX.readText();
|
||||
} catch (_) {}
|
||||
}
|
||||
await _handOff(classifyHandoff(paths: files, text: text), _pointer);
|
||||
}
|
||||
|
||||
Future<void> _handOff(Handoff item, Offset? at) async {
|
||||
if (!mounted || !_onTop || item is HandoffNothing || item is HandoffImage) return;
|
||||
final size = MediaQuery.sizeOf(context);
|
||||
final align = at == null || size.isEmpty
|
||||
? Alignment.center
|
||||
: Alignment((at.dx / size.width * 2 - 1).clamp(-1, 1), (at.dy / size.height * 2 - 1).clamp(-1, 1));
|
||||
_handoffOpen = true;
|
||||
final HandoffAction? action;
|
||||
try {
|
||||
action = await showPopover<HandoffAction>(context, at: align, child: HandoffCard(item: item, solo: _alone.peek()));
|
||||
} finally {
|
||||
_handoffOpen = false;
|
||||
}
|
||||
if (action == null || !mounted) return;
|
||||
switch ((item, action)) {
|
||||
case (HandoffVideos(:final paths), HandoffAction.putUp):
|
||||
final error = await _putUp(FileMedia(paths.first));
|
||||
if (error != null) {
|
||||
SessionState.instance.coordinator.showToast(error);
|
||||
} else {
|
||||
await FolderStore.instance.ensureLoaded();
|
||||
FolderStore.instance.addRecent(paths.first);
|
||||
}
|
||||
case (HandoffVideos(:final paths), HandoffAction.addFolder):
|
||||
await _addFolder(File(paths.first).parent.path);
|
||||
case (HandoffFolder(:final path), _):
|
||||
await _addFolder(path);
|
||||
case (HandoffLink(:final url), _):
|
||||
_picker.link.text = url;
|
||||
_openLibrary(MediaKind.link);
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _addFolder(String dir) async {
|
||||
await FolderStore.instance.ensureLoaded();
|
||||
FolderStore.instance.addFolder(dir);
|
||||
_openFiles(dir);
|
||||
}
|
||||
|
||||
/// Library → Files on [dir] (null: Recent).
|
||||
void _openFiles(String? dir) {
|
||||
showFilesFolder(dir);
|
||||
_openLibrary(MediaKind.file);
|
||||
}
|
||||
|
||||
void _openLibrary(MediaKind source) {
|
||||
if (!mounted) return;
|
||||
_picker.source = source;
|
||||
_selectTab(ShellTab.library);
|
||||
}
|
||||
|
||||
// Off on Settings: its own drop targets (cookies) take the files there.
|
||||
Widget _dropTarget(Widget child) => DropTarget(
|
||||
enable: _tab != ShellTab.settings,
|
||||
onDragEntered: (_) => setState(() => _dragging = _onTop),
|
||||
onDragExited: (_) => setState(() => _dragging = false),
|
||||
onDragDone: (d) {
|
||||
setState(() => _dragging = false);
|
||||
_handOff(classifyHandoff(paths: DesktopDropX.pathsOf(d.files)), d.localPosition);
|
||||
},
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
child,
|
||||
Positioned.fill(
|
||||
child: IgnorePointer(
|
||||
child: AnimatedOpacity(
|
||||
duration: easeTime,
|
||||
curve: ease,
|
||||
opacity: _dragging ? 1 : 0,
|
||||
child: const _DropOverlay(),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
// --------------------------------------------------------------- dialogs
|
||||
|
||||
/// Leave asks first; a solo session just stops.
|
||||
Future<void> _confirmLeave() async {
|
||||
final session = SessionState.instance;
|
||||
if (session.solo.peek()) {
|
||||
await session.leave();
|
||||
return;
|
||||
}
|
||||
final ok = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Leave server?'),
|
||||
content: const Text('You can rejoin right away.'),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Cancel')),
|
||||
TextButton(
|
||||
key: const ValueKey('confirm-leave'),
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child: const Text('Leave'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (ok == true) await SessionState.instance.leave();
|
||||
}
|
||||
|
||||
Future<void> _alert(String title, String text) async {
|
||||
if (!mounted || _dialogOpen) return;
|
||||
_dialogOpen = true;
|
||||
try {
|
||||
await showDialog<void>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(title),
|
||||
content: Text(text),
|
||||
actions: [TextButton(onPressed: () => Navigator.pop(context), child: const Text('OK'))],
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
_dialogOpen = false;
|
||||
}
|
||||
}
|
||||
|
||||
void _connect(ConnectMode mode) => showConnectCard(
|
||||
context,
|
||||
mode: mode,
|
||||
flow: _flow,
|
||||
join: _join,
|
||||
host: _host,
|
||||
healthGet: widget.healthGet,
|
||||
);
|
||||
|
||||
// ----------------------------------------------------------------- build
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final shell = CallbackShortcuts(
|
||||
bindings: {
|
||||
const SingleActivator(LogicalKeyboardKey.comma, control: true): () => _selectTab(ShellTab.settings),
|
||||
},
|
||||
child: Scaffold(
|
||||
backgroundColor: _fullscreen ? Colors.black : scheme.surface,
|
||||
body: SignalBuilder(builder: (context) {
|
||||
final online = _online.value;
|
||||
final alone = _alone.value;
|
||||
final hasMedia = _hasMedia.value;
|
||||
return Column(
|
||||
children: [
|
||||
if (!_fullscreen)
|
||||
PartyStrip(
|
||||
key: const ValueKey('strip'),
|
||||
tab: _tab,
|
||||
onTab: _selectTab,
|
||||
chatOpen: _chatOpen,
|
||||
unread: _unread,
|
||||
onChat: _toggleChat,
|
||||
onJoin: () => _connect(ConnectMode.join),
|
||||
onHost: () => _connect(ConnectMode.host),
|
||||
onLeave: _confirmLeave,
|
||||
onInvite: () => _connect(ConnectMode.host),
|
||||
onProfile: () => showProfileCard(context, onOpenSettings: _openSettings, updates: widget.updates),
|
||||
onShowPlayer: hasMedia && _mode == PlayerMode.none ? () => _show(PlayerMode.mini) : null,
|
||||
updates: widget.updates,
|
||||
windowControls: widget.windowControls,
|
||||
),
|
||||
Expanded(
|
||||
key: const ValueKey('body'),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(child: _body(online, alone)),
|
||||
AnimatedSize(
|
||||
duration: easeTime,
|
||||
curve: ease,
|
||||
alignment: Alignment.centerLeft,
|
||||
child: _chatOpen && online
|
||||
? ChatPanel(chat: _chat, chatFocus: _chatFocus, onClose: _toggleChat)
|
||||
: const SizedBox(height: double.infinity),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}),
|
||||
),
|
||||
);
|
||||
return Listener(
|
||||
onPointerHover: (e) => _pointer = e.localPosition,
|
||||
onPointerDown: (e) => _pointer = e.localPosition,
|
||||
child: _dropTarget(shell),
|
||||
);
|
||||
}
|
||||
|
||||
/// No Jellyfin account, no server joined or hosted yet, band not
|
||||
/// dismissed: the welcome band explains the three ways in.
|
||||
bool get _newcomer =>
|
||||
!AppSettings.instance.welcomeDismissed.value &&
|
||||
_jellyfin.isEmpty &&
|
||||
SessionState.instance.recents.value.isEmpty;
|
||||
|
||||
Widget _body(bool online, bool alone) {
|
||||
final coord = SessionState.instance.coordinator;
|
||||
final tabBody = switch (_tab) {
|
||||
ShellTab.library => LibraryTab(
|
||||
key: ValueKey('library-$_libraryGen'),
|
||||
controller: _picker,
|
||||
accounts: _jellyfin,
|
||||
solo: alone,
|
||||
// Offline, a put-up starts a solo session, which hosts.
|
||||
isHost: !online || coord.hostFile != null,
|
||||
// Only for newcomers: the strip has Join and Host for everyone else.
|
||||
banner: online || !_newcomer
|
||||
? null
|
||||
: WelcomeBand(
|
||||
onJoin: () => _connect(ConnectMode.join),
|
||||
onHost: () => _connect(ConnectMode.host),
|
||||
onSolo: _watchSolo,
|
||||
onDismiss: () => setState(AppSettings.instance.dismissWelcome),
|
||||
),
|
||||
jellyfinStart: _libraryStart,
|
||||
onPutUp: _putUp,
|
||||
onOpenIntegrations: () => _openSettings(SettingsSection.integrations),
|
||||
),
|
||||
ShellTab.schedule => SchedulePage(
|
||||
solo: alone,
|
||||
onPutUp: _putUp,
|
||||
onToast: coord.showToast,
|
||||
),
|
||||
ShellTab.settings => SettingsTab(
|
||||
section: _section,
|
||||
onSection: (s) => setState(() => _section = s),
|
||||
onEditProfile: () => showProfileCard(context, onOpenSettings: _openSettings, updates: widget.updates),
|
||||
updates: widget.updates,
|
||||
jellyfin: _jellyfin,
|
||||
),
|
||||
};
|
||||
final full = _shown == PlayerMode.full;
|
||||
final fullInvolved = _mode == PlayerMode.full || _from == PlayerMode.full;
|
||||
return LayoutBuilder(builder: (context, box) {
|
||||
final area = box.biggest;
|
||||
return Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: AnimatedBuilder(
|
||||
animation: _fade,
|
||||
builder: (context, child) {
|
||||
// The tabs take part in the fade only when the full player
|
||||
// comes or goes; a mini player appearing leaves them be.
|
||||
final v = full ? 0.0 : (fullInvolved ? _fade.value : 1.0);
|
||||
return Opacity(
|
||||
opacity: v,
|
||||
child: Transform.scale(scale: 0.985 + 0.015 * v, child: child),
|
||||
);
|
||||
},
|
||||
child: IgnorePointer(
|
||||
ignoring: full,
|
||||
child: ExcludeFocus(
|
||||
excluding: full,
|
||||
child: TickerMode(
|
||||
enabled: !full,
|
||||
child: fadeThrough(KeyedSubtree(key: ValueKey(_tab), child: tabBody)),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (online && _shown != PlayerMode.none) _playerLayer(area),
|
||||
Positioned(left: 16, bottom: _fullscreen ? 90 : 16, child: PartyToasts(
|
||||
onOpenChat: () {
|
||||
if (!_chatOpen) _toggleChat();
|
||||
},
|
||||
)),
|
||||
],
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Offset _cornerPos(Size area, Alignment a, Size size) => Offset(
|
||||
a.x < 0 ? _miniMargin : (area.width - size.width - _miniMargin).clamp(0, double.infinity),
|
||||
a.y < 0 ? _miniMargin : (area.height - size.height - _miniMargin).clamp(0, double.infinity),
|
||||
);
|
||||
|
||||
Widget _playerLayer(Size area) {
|
||||
final mini = _shown == PlayerMode.mini;
|
||||
final size = mini
|
||||
? Size(miniSize.width.clamp(0, area.width), miniSize.height.clamp(0, area.height))
|
||||
: area;
|
||||
final pos = mini ? (_drag ?? _cornerPos(area, _corner, size)) : Offset.zero;
|
||||
return AnimatedPositioned(
|
||||
key: const ValueKey('player-layer'),
|
||||
duration: _snapping ? easeTime : Duration.zero,
|
||||
curve: ease,
|
||||
left: pos.dx,
|
||||
top: pos.dy,
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
onEnd: () => _snapping = false,
|
||||
child: AnimatedBuilder(
|
||||
animation: _fade,
|
||||
builder: (context, child) {
|
||||
final v = _fade.value;
|
||||
return IgnorePointer(
|
||||
ignoring: v < 0.5 || _shown != _mode,
|
||||
child: Opacity(
|
||||
opacity: v,
|
||||
child: Transform.scale(scale: mini ? 0.97 + 0.03 * v : 0.985 + 0.015 * v, child: child),
|
||||
),
|
||||
);
|
||||
},
|
||||
// Same widgets in both modes (only their settings change), so the
|
||||
// player below never moves in the element tree.
|
||||
child: GestureDetector(
|
||||
onPanStart: mini ? (_) => setState(() => _drag = pos) : null,
|
||||
onPanUpdate: mini ? (d) => setState(() => _drag = _drag! + d.delta) : null,
|
||||
onPanEnd: mini
|
||||
? (_) => setState(() {
|
||||
final c = _drag! + Offset(size.width / 2, size.height / 2);
|
||||
_corner = Alignment(c.dx < area.width / 2 ? -1 : 1, c.dy < area.height / 2 ? -1 : 1);
|
||||
_drag = null;
|
||||
_snapping = true;
|
||||
})
|
||||
: null,
|
||||
child: DoubleClickListener(
|
||||
onDoubleClick: mini ? _expand : () {},
|
||||
skipTop: 40,
|
||||
skipBottom: 60,
|
||||
child: Material(
|
||||
elevation: mini ? 16 : 0,
|
||||
color: Colors.black,
|
||||
borderRadius: BorderRadius.circular(mini ? 14 : 0),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: KeyedSubtree(
|
||||
key: _playerKey,
|
||||
child: SessionPlayer(
|
||||
mode: _shown,
|
||||
fullscreen: _fullscreen,
|
||||
chatOpen: _chatOpen,
|
||||
unread: _unread,
|
||||
onExpand: _expand,
|
||||
onCollapse: _collapse,
|
||||
onClose: _closePlayer,
|
||||
onToggleFullscreen: _toggleFullscreen,
|
||||
onToggleChat: _toggleChat,
|
||||
onChangeVideo: _changeVideo,
|
||||
onOpenIntegrations: () => _openSettings(SettingsSection.integrations),
|
||||
onLeave: _confirmLeave,
|
||||
videoBuilder: widget.videoBuilder,
|
||||
windowControls: widget.windowControls,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:signals/signals_flutter.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
|
||||
import '../screens/home/profile.dart';
|
||||
import '../session/session_coordinator.dart';
|
||||
import '../state/session_state.dart';
|
||||
import 'motion.dart';
|
||||
|
||||
/// What an avatar shows: who, their picture or initial on [color], and
|
||||
/// the status the ring and badges carry.
|
||||
class AvatarInfo {
|
||||
final String id;
|
||||
final String name;
|
||||
final Color color;
|
||||
final ImageProvider? image;
|
||||
final bool ready, buffering, relaying, stale, host, self;
|
||||
/// Sitting out: dimmed, with an "away" mark.
|
||||
final bool out;
|
||||
/// Why their player cannot open the media (`members[].error`): an error
|
||||
/// mark; empty when it can.
|
||||
final String error;
|
||||
const AvatarInfo({
|
||||
this.id = '',
|
||||
required this.name,
|
||||
required this.color,
|
||||
this.image,
|
||||
this.ready = false,
|
||||
this.buffering = false,
|
||||
this.relaying = false,
|
||||
this.stale = false,
|
||||
this.host = false,
|
||||
this.self = false,
|
||||
this.out = false,
|
||||
this.error = '',
|
||||
});
|
||||
}
|
||||
|
||||
/// Stable colour for someone else (colours are not on the wire).
|
||||
Color colorForName(String name) {
|
||||
const palette = [
|
||||
Colors.teal,
|
||||
Colors.orange,
|
||||
Colors.pink,
|
||||
Colors.blue,
|
||||
Colors.green,
|
||||
Colors.indigo,
|
||||
Colors.amber,
|
||||
Colors.cyan,
|
||||
Colors.red,
|
||||
Colors.deepPurple,
|
||||
];
|
||||
var h = 0;
|
||||
for (final c in name.codeUnits) {
|
||||
h = (h * 31 + c) & 0x7fffffff;
|
||||
}
|
||||
return palette[h % palette.length];
|
||||
}
|
||||
|
||||
final _images = <Object, MemoryImage>{};
|
||||
|
||||
MemoryImage _memo(Object key, Uint8List bytes) {
|
||||
final hit = _images[key];
|
||||
if (hit != null && identical(hit.bytes, bytes)) return hit;
|
||||
if (_images.length > 64) _images.clear();
|
||||
return _images[key] = MemoryImage(bytes);
|
||||
}
|
||||
|
||||
/// The one lookup for a member's picture: your own from the profile (no
|
||||
/// round trip), anyone else's from the session's avatar cache by hash.
|
||||
/// Callers rebuild on [SessionCoordinator.avatars] and [Profile.picture]
|
||||
/// (see [PartyWatch]).
|
||||
ImageProvider? memberAvatarImage(MemberView m, SessionCoordinator coord) {
|
||||
if (m.id.isNotEmpty && m.id == coord.sync.memberId) {
|
||||
final own = Profile.instance.picture.value;
|
||||
if (own != null) return _memo('self', own);
|
||||
}
|
||||
final bytes = coord.avatars.bytesFor(m.avatarHash);
|
||||
return bytes == null ? null : _memo(m.avatarHash, bytes);
|
||||
}
|
||||
|
||||
/// Your own avatar from the profile (also offline).
|
||||
AvatarInfo selfAvatarInfo({bool ready = false, bool host = false, bool out = false, String error = ''}) {
|
||||
final p = Profile.instance;
|
||||
final png = p.picture.value;
|
||||
final name = p.name.value;
|
||||
return AvatarInfo(
|
||||
name: name.isEmpty ? '?' : name,
|
||||
color: p.color.value,
|
||||
image: png == null ? null : _memo('self', png),
|
||||
ready: ready,
|
||||
host: host,
|
||||
self: true,
|
||||
out: out,
|
||||
error: error,
|
||||
);
|
||||
}
|
||||
|
||||
/// [m] as the party strip, toasts and the chat show it.
|
||||
AvatarInfo memberAvatarInfo(MemberView m, StateBroadcast? r, SessionCoordinator coord) {
|
||||
final self = m.id.isNotEmpty && m.id == coord.sync.memberId;
|
||||
final relay = r == null ? null : stateMediaRelay(r);
|
||||
final playing = r?.media != null;
|
||||
return AvatarInfo(
|
||||
id: m.id,
|
||||
name: m.name,
|
||||
color: self ? Profile.instance.color.value : colorForName(m.name),
|
||||
image: memberAvatarImage(m, coord),
|
||||
ready: m.ready,
|
||||
buffering: !m.stale && m.ready && m.error.isEmpty && playing && m.buffered < r!.bufferNeed,
|
||||
relaying: relay != null && relay.source.isNotEmpty && relay.source == m.id,
|
||||
stale: m.stale,
|
||||
// The state marks the host (`members[].host`); your own app also
|
||||
// knows before the first state.
|
||||
host: m.host || (self && coord.hostFile != null),
|
||||
self: self,
|
||||
out: m.out,
|
||||
error: m.error,
|
||||
);
|
||||
}
|
||||
|
||||
/// Everyone in the server, from the latest state.
|
||||
List<AvatarInfo> partyAvatars([SessionCoordinator? coord]) {
|
||||
final c = coord ?? SessionState.instance.coordinator;
|
||||
final r = c.serverState.value;
|
||||
return [for (final m in r?.members ?? const <MemberView>[]) memberAvatarInfo(m, r, c)];
|
||||
}
|
||||
|
||||
/// Rebuilds [builder] when the party state, your profile or a member's
|
||||
/// picture changes.
|
||||
class PartyWatch extends StatelessWidget {
|
||||
final WidgetBuilder builder;
|
||||
const PartyWatch({super.key, required this.builder});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => ListenableBuilder(
|
||||
listenable: SessionState.instance.coordinator.avatars,
|
||||
builder: (context, _) => SignalBuilder(builder: (context) => builder(context)),
|
||||
);
|
||||
}
|
||||
|
||||
/// Circle avatar with a status ring outside it and a gap in the background
|
||||
/// colour, so overlapping neighbours never hide the status. Crown = host,
|
||||
/// tower = passing the video on, spinner = buffering, dimmed = stale,
|
||||
/// dimmed with a moon = sitting out, red "!" = cannot play the media.
|
||||
class StatusAvatar extends StatelessWidget {
|
||||
final AvatarInfo info;
|
||||
final double size;
|
||||
|
||||
/// Host crown and relay tower; off where the context already says it.
|
||||
final bool badges;
|
||||
|
||||
/// Ring colour when not ready (the profile shows none at all).
|
||||
final bool ring;
|
||||
const StatusAvatar({super.key, required this.info, required this.size, this.badges = true, this.ring = true});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final bg = Theme.of(context).colorScheme.surfaceContainerLowest;
|
||||
final ringColor = !ring
|
||||
? bg
|
||||
: info.stale
|
||||
? Colors.grey.shade700
|
||||
: info.buffering
|
||||
? Colors.amber
|
||||
: info.ready
|
||||
? Colors.greenAccent.shade400
|
||||
: Colors.grey;
|
||||
final badge = size * 0.42;
|
||||
// Ring and gap scale with the avatar (2 px at strip size).
|
||||
final stroke = (size * 0.055).clamp(2.0, 4.0);
|
||||
final initial = info.name.isEmpty ? '?' : info.name.characters.first.toUpperCase();
|
||||
return SizedBox(
|
||||
width: size,
|
||||
height: size,
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
// Fills the box: a non-positioned Stack child only gets loose
|
||||
// constraints, and CircleAvatar then falls back to 40 px.
|
||||
Positioned.fill(
|
||||
child: Container(
|
||||
decoration: BoxDecoration(shape: BoxShape.circle, color: ringColor),
|
||||
padding: EdgeInsets.all(stroke),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(shape: BoxShape.circle, color: bg),
|
||||
padding: EdgeInsets.all(stroke),
|
||||
child: Opacity(
|
||||
opacity: info.stale || info.out ? 0.4 : 1,
|
||||
child: CircleAvatar(
|
||||
radius: size / 2,
|
||||
backgroundColor: info.color,
|
||||
backgroundImage: info.image,
|
||||
child: info.image != null
|
||||
? null
|
||||
: Text(initial, style: TextStyle(fontSize: size * 0.38, color: Colors.white)),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (info.buffering)
|
||||
Positioned.fill(child: CircularProgressIndicator(strokeWidth: stroke, color: ringColor)),
|
||||
if (badges && info.host)
|
||||
Positioned(
|
||||
left: -4,
|
||||
top: -6,
|
||||
child: Transform.rotate(
|
||||
angle: -0.4,
|
||||
child: Icon(
|
||||
Icons.workspace_premium,
|
||||
size: badge,
|
||||
color: Colors.amber,
|
||||
shadows: const [Shadow(blurRadius: 3, color: Colors.black)],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (info.out)
|
||||
Positioned(
|
||||
key: const ValueKey('away-mark'),
|
||||
left: -3,
|
||||
bottom: -3,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(2),
|
||||
decoration: BoxDecoration(shape: BoxShape.circle, color: bg),
|
||||
child: Icon(Icons.bedtime, size: (badge * 0.8).clamp(10.0, 24.0), color: Colors.blueGrey.shade200),
|
||||
),
|
||||
),
|
||||
if (info.error.isNotEmpty)
|
||||
Positioned(
|
||||
key: const ValueKey('error-mark'),
|
||||
right: -3,
|
||||
top: -3,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(1),
|
||||
decoration: BoxDecoration(shape: BoxShape.circle, color: bg),
|
||||
child: Icon(Icons.error, size: (badge * 0.8).clamp(10.0, 24.0), color: Colors.redAccent.shade200),
|
||||
),
|
||||
),
|
||||
if (badges && info.relaying)
|
||||
Positioned(
|
||||
right: -3,
|
||||
bottom: -3,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(2),
|
||||
decoration: BoxDecoration(shape: BoxShape.circle, color: bg),
|
||||
child: Icon(Icons.cell_tower, size: badge * 0.8, color: Colors.lightBlueAccent),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Overlapping avatars, host first then ready (the stack itself reads as
|
||||
/// progress); they spread apart on hover. "+N" past [shown].
|
||||
class AvatarStack extends StatefulWidget {
|
||||
final List<AvatarInfo> members;
|
||||
final VoidCallback? onTap;
|
||||
final int shown;
|
||||
final double size;
|
||||
const AvatarStack({super.key, required this.members, this.onTap, this.shown = 5, this.size = 42});
|
||||
|
||||
@override
|
||||
State<AvatarStack> createState() => _AvatarStackState();
|
||||
}
|
||||
|
||||
class _AvatarStackState extends State<AvatarStack> {
|
||||
bool _hover = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final size = widget.size;
|
||||
int rank(AvatarInfo m) => m.host ? 0 : (m.out ? 3 : m.ready ? 1 : 2);
|
||||
final sorted = [...widget.members]..sort((a, b) => rank(a) - rank(b));
|
||||
final visible = sorted.take(widget.shown).toList();
|
||||
final extra = sorted.length - visible.length;
|
||||
final step = _hover ? size + 6 : size * 0.62;
|
||||
final count = visible.length + (extra > 0 ? 1 : 0);
|
||||
final width = count == 0 ? 0.0 : step * (count - 1) + size;
|
||||
return MouseRegion(
|
||||
onEnter: (_) => setState(() => _hover = true),
|
||||
onExit: (_) => setState(() => _hover = false),
|
||||
cursor: widget.onTap == null ? MouseCursor.defer : SystemMouseCursors.click,
|
||||
child: GestureDetector(
|
||||
onTap: widget.onTap,
|
||||
child: AnimatedContainer(
|
||||
key: const ValueKey('avatar-stack'),
|
||||
duration: easeTime,
|
||||
curve: ease,
|
||||
width: width,
|
||||
height: size,
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
for (var i = visible.length - 1; i >= 0; i--)
|
||||
AnimatedPositioned(
|
||||
key: ValueKey('stack-${visible[i].id}-${visible[i].name}'),
|
||||
duration: easeTime,
|
||||
curve: ease,
|
||||
left: i * step,
|
||||
top: 0,
|
||||
child: Tooltip(
|
||||
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),
|
||||
),
|
||||
),
|
||||
if (extra > 0)
|
||||
AnimatedPositioned(
|
||||
duration: easeTime,
|
||||
curve: ease,
|
||||
left: visible.length * step,
|
||||
top: 0,
|
||||
child: CircleAvatar(
|
||||
radius: size / 2,
|
||||
child: Text('+$extra', style: const TextStyle(fontSize: 12)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Small coloured label ("host", "passing the video on").
|
||||
class Pill extends StatelessWidget {
|
||||
final String text;
|
||||
final Color color;
|
||||
const Pill(this.text, this.color, {super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Text(text, style: TextStyle(color: color, fontSize: 10, fontWeight: FontWeight.w600)),
|
||||
);
|
||||
}
|
||||
|
||||
/// Someone by display name (feed lines carry names, not ids): from the
|
||||
/// latest state when still there, else their initial on a stable colour.
|
||||
AvatarInfo avatarForName(String name, [SessionCoordinator? coord]) {
|
||||
final c = coord ?? SessionState.instance.coordinator;
|
||||
final r = c.serverState.value;
|
||||
for (final m in r?.members ?? const <MemberView>[]) {
|
||||
if (m.name == name) return memberAvatarInfo(m, r, c);
|
||||
}
|
||||
return AvatarInfo(name: name, color: colorForName(name));
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
|
||||
import '../state/session_state.dart';
|
||||
import '../widgets/text_focus.dart';
|
||||
import 'avatar.dart';
|
||||
|
||||
/// Activity lines (joins, ready, seeks...) between the messages: off by
|
||||
/// default, kept for the app's run.
|
||||
final showActivity = ValueNotifier(false);
|
||||
|
||||
/// Side panel: chat with avatars, other activity as quiet lines (behind
|
||||
/// the "Activity" toggle), and the message field. Width is fixed; the
|
||||
/// shell slides it in and out. The field takes focus on open; Tab and Esc
|
||||
/// in it close the panel.
|
||||
class ChatPanel extends StatefulWidget {
|
||||
static const width = 340.0;
|
||||
final TextEditingController chat;
|
||||
final FocusNode chatFocus;
|
||||
final VoidCallback onClose;
|
||||
const ChatPanel({super.key, required this.chat, required this.chatFocus, required this.onClose});
|
||||
|
||||
@override
|
||||
State<ChatPanel> createState() => _ChatPanelState();
|
||||
}
|
||||
|
||||
class _ChatPanelState extends State<ChatPanel> {
|
||||
// Where focus was before the field took it; it goes back there on close
|
||||
// (else a field behind could take it, and Space with it).
|
||||
FocusNode? _before;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
final before = FocusManager.instance.primaryFocus;
|
||||
if (before != widget.chatFocus) _before = before;
|
||||
widget.chatFocus.requestFocus();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
final before = _before;
|
||||
if (before != null && before.context != null && before.canRequestFocus && !textInputFocused(before)) {
|
||||
before.requestFocus();
|
||||
} else if (textInputFocused()) {
|
||||
FocusManager.instance.primaryFocus?.unfocus();
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _send() {
|
||||
SessionState.instance.coordinator.sendChat(widget.chat.text);
|
||||
widget.chat.clear();
|
||||
widget.chatFocus.requestFocus();
|
||||
}
|
||||
|
||||
KeyEventResult _fieldKey(FocusNode node, KeyEvent e) {
|
||||
if (e is! KeyDownEvent) return KeyEventResult.ignored;
|
||||
if (e.logicalKey != LogicalKeyboardKey.tab && e.logicalKey != LogicalKeyboardKey.escape) {
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
widget.onClose();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
return Container(
|
||||
key: const ValueKey('chat-panel'),
|
||||
width: ChatPanel.width,
|
||||
color: scheme.surfaceContainerLow,
|
||||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 4, 0),
|
||||
child: Row(
|
||||
children: [
|
||||
const Text('Chat', style: TextStyle(fontWeight: FontWeight.w600)),
|
||||
const Spacer(),
|
||||
ValueListenableBuilder<bool>(
|
||||
valueListenable: showActivity,
|
||||
builder: (context, on, _) => FilterChip(
|
||||
key: const ValueKey('chat-activity'),
|
||||
label: const Text('Activity', style: TextStyle(fontSize: 12)),
|
||||
tooltip: 'Show joins, ready, seeks and other activity',
|
||||
selected: on,
|
||||
showCheckmark: false,
|
||||
visualDensity: VisualDensity.compact,
|
||||
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
onSelected: (v) => showActivity.value = v,
|
||||
),
|
||||
),
|
||||
IconButton(tooltip: 'Close (Tab)', onPressed: widget.onClose, icon: const Icon(Icons.close, size: 18)),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: ValueListenableBuilder<bool>(
|
||||
valueListenable: showActivity,
|
||||
builder: (context, activity, _) => PartyWatch(builder: (context) {
|
||||
final all = SessionState.instance.coordinator.feedLog.value;
|
||||
final log = activity ? all : [for (final f in all) if (f.action == 'chat') f];
|
||||
if (log.isEmpty) {
|
||||
return Center(
|
||||
child: Text(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]),
|
||||
);
|
||||
}),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Focus(
|
||||
canRequestFocus: false,
|
||||
skipTraversal: true,
|
||||
onKeyEvent: _fieldKey,
|
||||
child: TextField(
|
||||
key: const ValueKey('chat-field'),
|
||||
controller: widget.chat,
|
||||
focusNode: widget.chatFocus,
|
||||
onSubmitted: (_) => _send(),
|
||||
decoration: InputDecoration(
|
||||
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)),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// One feed line: a chat message with the sender's avatar, anything else
|
||||
/// as an icon and "who did what".
|
||||
class ActivityRow extends StatelessWidget {
|
||||
final FeedMessage item;
|
||||
const ActivityRow({super.key, required this.item});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final time = _clock(item.at);
|
||||
if (item.action == 'chat') {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
StatusAvatar(info: avatarForName(item.actor), size: 34, badges: false),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(item.actor,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(fontWeight: FontWeight.w700)),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(time, style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 11)),
|
||||
],
|
||||
),
|
||||
SelectableText(item.reason),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
final (icon, color, text) = item.actor == 'server' ? serverFeedStyle(item) : feedStyle(item, scheme);
|
||||
final line = item.actor == 'server' ? text : '${item.actor} $text';
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
const SizedBox(width: 8),
|
||||
Icon(icon, size: 14, color: color),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(child: Text(line, style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12))),
|
||||
Text(time, style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 11)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Icon, colour and wording of a member's feed action.
|
||||
(IconData, Color, String) feedStyle(FeedMessage f, ColorScheme scheme) {
|
||||
switch (f.action) {
|
||||
case 'join':
|
||||
return (Icons.login, Colors.green, 'joined');
|
||||
case 'leave':
|
||||
final reason = f.reason == 'silent too long' ? ' · timed out' : (f.reason.isEmpty ? '' : ' · ${f.reason}');
|
||||
return (Icons.logout, Colors.grey, 'left$reason');
|
||||
case 'ready':
|
||||
return (Icons.check_circle, Colors.green, 'is ready');
|
||||
case 'unready':
|
||||
return (Icons.radio_button_unchecked, Colors.grey, "isn't ready");
|
||||
case 'force-ready':
|
||||
return (Icons.how_to_reg, Colors.orange, 'marked ${f.target ?? ''} ready');
|
||||
case 'force-unready':
|
||||
return (Icons.how_to_reg, Colors.orange, 'marked ${f.target ?? ''} not ready');
|
||||
case 'force-play':
|
||||
return (Icons.play_arrow, Colors.green, 'started playback for everyone');
|
||||
case 'force-hold':
|
||||
return (Icons.pause, Colors.orange, 'held playback for everyone');
|
||||
case 'seek':
|
||||
return (Icons.fast_forward, scheme.primary, 'seeked');
|
||||
case 'swap':
|
||||
return (Icons.swap_horiz, scheme.primary, 'changed the media');
|
||||
case 'stale':
|
||||
return (Icons.cloud_off, Colors.orange, 'lost signal');
|
||||
case 'out':
|
||||
final who = f.target;
|
||||
return (Icons.bedtime, Colors.blueGrey, who == null || who.isEmpty ? 'is sitting out' : 'sat $who out');
|
||||
case 'back':
|
||||
return (Icons.login, Colors.green, 'joined back');
|
||||
default:
|
||||
final target = f.target == null || f.target!.isEmpty ? '' : ' ${f.target}';
|
||||
return (Icons.info_outline, Colors.grey, '${f.action}$target');
|
||||
}
|
||||
}
|
||||
|
||||
/// The server's own lines (pause / resume).
|
||||
(IconData, Color, String) serverFeedStyle(FeedMessage f) => switch (f.action) {
|
||||
'resume' => (Icons.play_arrow, Colors.green, 'Playback resumed'),
|
||||
_ => (Icons.pause, Colors.orange, 'Playback paused · waiting for readiness'),
|
||||
};
|
||||
|
||||
String _clock(DateTime at) {
|
||||
final l = at.toLocal();
|
||||
String two(int n) => n.toString().padLeft(2, '0');
|
||||
return '${two(l.hour)}:${two(l.minute)}';
|
||||
}
|
||||
@@ -0,0 +1,505 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:signals/signals_flutter.dart';
|
||||
import 'package:sync_relay_core/core.dart' show RelayConstants;
|
||||
|
||||
import '../screens/home/connect_flow.dart';
|
||||
import '../screens/home/connect_status.dart';
|
||||
import '../screens/home/health_probe.dart';
|
||||
import '../state/session_state.dart';
|
||||
import 'motion.dart';
|
||||
|
||||
/// Join card text, owned by the shell so it survives the card closing.
|
||||
class JoinFields {
|
||||
JoinFields(ConnectForm f)
|
||||
: host = TextEditingController(text: f.host),
|
||||
port = TextEditingController(text: f.port),
|
||||
secret = TextEditingController(text: f.secret);
|
||||
final TextEditingController host;
|
||||
final TextEditingController port;
|
||||
final TextEditingController secret;
|
||||
|
||||
void dispose() {
|
||||
host.dispose();
|
||||
port.dispose();
|
||||
secret.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// Host card text, owned by the shell so it survives the card closing.
|
||||
class HostFields {
|
||||
HostFields(ConnectForm f)
|
||||
: port = TextEditingController(text: f.hostPort),
|
||||
secret = TextEditingController(text: f.hostSecret);
|
||||
final TextEditingController port;
|
||||
final TextEditingController secret;
|
||||
|
||||
void dispose() {
|
||||
port.dispose();
|
||||
secret.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// The strip's Join / Host buttons (and solo's Invite friends, the Host
|
||||
/// card): a card over the window. It closes itself once the attempt got
|
||||
/// in; a failed one shows its error inside.
|
||||
Future<void> showConnectCard(
|
||||
BuildContext context, {
|
||||
required ConnectMode mode,
|
||||
required ConnectFlow flow,
|
||||
required JoinFields join,
|
||||
required HostFields host,
|
||||
HealthGetter healthGet = defaultHealthGet,
|
||||
}) =>
|
||||
showPopover<void>(
|
||||
context,
|
||||
dim: true,
|
||||
at: const Alignment(0.8, -0.75),
|
||||
child: _CloseWhenOnline(
|
||||
child: mode == ConnectMode.host
|
||||
? HostCard(flow: flow, fields: host)
|
||||
: JoinCard(flow: flow, fields: join, healthGet: healthGet),
|
||||
),
|
||||
);
|
||||
|
||||
class _CloseWhenOnline extends StatefulWidget {
|
||||
final Widget child;
|
||||
const _CloseWhenOnline({required this.child});
|
||||
|
||||
@override
|
||||
State<_CloseWhenOnline> createState() => _CloseWhenOnlineState();
|
||||
}
|
||||
|
||||
class _CloseWhenOnlineState extends State<_CloseWhenOnline> {
|
||||
late final void Function() _stop;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_stop = effect(() {
|
||||
final s = SessionState.instance;
|
||||
// Solo is in a session too: wait until it is a hosted server.
|
||||
if (s.route.value != AppRoute.session || s.solo.value || s.switching.value) return;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) Navigator.of(context).maybePop();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_stop();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => widget.child;
|
||||
}
|
||||
|
||||
/// Card frame shared by Join and Host.
|
||||
class _Card extends StatelessWidget {
|
||||
final String title;
|
||||
final List<Widget> children;
|
||||
const _Card({required this.title, required this.children});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Material(
|
||||
elevation: 12,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
child: SizedBox(
|
||||
width: 360,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(title, style: Theme.of(context).textTheme.titleMedium),
|
||||
const SizedBox(height: 12),
|
||||
...children,
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const _dense = InputDecoration(isDense: true);
|
||||
|
||||
/// Recent servers as one-click rows (with live status), then address and
|
||||
/// password.
|
||||
class JoinCard extends StatelessWidget {
|
||||
final ConnectFlow flow;
|
||||
final JoinFields fields;
|
||||
final HealthGetter healthGet;
|
||||
const JoinCard({super.key, required this.flow, required this.fields, this.healthGet = defaultHealthGet});
|
||||
|
||||
void _join() => flow.submit(ConnectMode.join, (f) {
|
||||
f
|
||||
..host = fields.host.text
|
||||
..port = fields.port.text.trim()
|
||||
..secret = fields.secret.text;
|
||||
});
|
||||
|
||||
void _joinRecent(RecentServer r) {
|
||||
fields.host.text = r.host;
|
||||
fields.port.text = r.port;
|
||||
fields.secret.text = r.secret;
|
||||
_join();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListenableBuilder(
|
||||
listenable: flow,
|
||||
builder: (context, _) {
|
||||
final busy = flow.busy == ConnectMode.join;
|
||||
return _Card(
|
||||
title: 'Join a friend',
|
||||
children: [
|
||||
SignalBuilder(builder: (context) {
|
||||
final list = [
|
||||
for (final r in SessionState.instance.recents.value)
|
||||
if (!r.hosted) r
|
||||
];
|
||||
if (list.isEmpty) return const SizedBox.shrink();
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const Text('Recent', style: TextStyle(color: Colors.grey)),
|
||||
RecentServerRows(
|
||||
servers: list,
|
||||
enabled: flow.busy == null,
|
||||
healthGet: healthGet,
|
||||
onJoin: _joinRecent,
|
||||
onForget: SessionState.instance.forgetServer,
|
||||
),
|
||||
const Divider(),
|
||||
],
|
||||
);
|
||||
}),
|
||||
AbsorbPointer(
|
||||
absorbing: busy,
|
||||
child: Column(
|
||||
key: const ValueKey('join-row'),
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
key: const ValueKey('join-address'),
|
||||
controller: fields.host,
|
||||
autofocus: true,
|
||||
decoration: _dense.copyWith(labelText: 'Address', hintText: 'IP or hostname'),
|
||||
onChanged: (_) => flow.clearError(ConnectMode.join),
|
||||
onSubmitted: (_) => _join(),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
SizedBox(
|
||||
width: 72,
|
||||
child: TextField(
|
||||
key: const ValueKey('join-port'),
|
||||
controller: fields.port,
|
||||
decoration: _dense.copyWith(labelText: 'Port'),
|
||||
keyboardType: TextInputType.number,
|
||||
onSubmitted: (_) => _join(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextField(
|
||||
key: const ValueKey('join-password'),
|
||||
controller: fields.secret,
|
||||
obscureText: true,
|
||||
decoration: _dense.copyWith(labelText: 'Password (optional)'),
|
||||
onSubmitted: (_) => _join(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ConnectButton(flow: flow, mode: ConnectMode.join, label: 'Join', onSubmit: _join, expand: true),
|
||||
ConnectFeedback(flow: flow, mode: ConnectMode.join),
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(top: 8),
|
||||
child: Text(
|
||||
'Your friend gives you the address and password. No account or Jellyfin needed.',
|
||||
style: TextStyle(color: Colors.grey, fontSize: 12),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Port and password; what to watch is picked afterwards, in the library.
|
||||
class HostCard extends StatelessWidget {
|
||||
final ConnectFlow flow;
|
||||
final HostFields fields;
|
||||
const HostCard({super.key, required this.flow, required this.fields});
|
||||
|
||||
void _host() => flow.submit(
|
||||
ConnectMode.host,
|
||||
(f) => f
|
||||
..hostPort = fields.port.text.trim()
|
||||
..hostSecret = fields.secret.text,
|
||||
);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// From a solo session this is "Invite friends": same server settings,
|
||||
// and what plays carries over.
|
||||
final solo = SessionState.instance.solo.peek();
|
||||
return ListenableBuilder(
|
||||
listenable: flow,
|
||||
builder: (context, _) => _Card(
|
||||
title: solo ? 'Invite friends' : 'Host a server',
|
||||
children: [
|
||||
AbsorbPointer(
|
||||
absorbing: flow.busy == ConnectMode.host,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
TextField(
|
||||
key: const ValueKey('host-port'),
|
||||
controller: fields.port,
|
||||
autofocus: true,
|
||||
decoration: _dense.copyWith(labelText: 'Port', hintText: '8080'),
|
||||
keyboardType: TextInputType.number,
|
||||
onChanged: (_) => flow.clearError(ConnectMode.host),
|
||||
onSubmitted: (_) => _host(),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
ListenableBuilder(
|
||||
listenable: fields.secret,
|
||||
builder: (context, _) => TextField(
|
||||
key: const ValueKey('host-password'),
|
||||
controller: fields.secret,
|
||||
obscureText: true,
|
||||
decoration: _dense.copyWith(
|
||||
labelText: 'Password (optional)',
|
||||
helperText: fields.secret.text.isEmpty ? 'No password — anyone can join' : null,
|
||||
),
|
||||
onSubmitted: (_) => _host(),
|
||||
),
|
||||
),
|
||||
_HostedRecents(fields: fields, enabled: flow.busy == null),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ConnectButton(
|
||||
flow: flow, mode: ConnectMode.host, label: solo ? 'Start hosting' : 'Host', onSubmit: _host, expand: true),
|
||||
ConnectFeedback(flow: flow, mode: ConnectMode.host),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: Text(
|
||||
solo
|
||||
? 'Your video keeps playing from where it is; friends join with your address.'
|
||||
: 'You pick what to watch afterwards, in the library.',
|
||||
style: const TextStyle(color: Colors.grey, fontSize: 12),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Ports hosted before: one click refills port and password.
|
||||
class _HostedRecents extends StatelessWidget {
|
||||
final HostFields fields;
|
||||
final bool enabled;
|
||||
const _HostedRecents({required this.fields, required this.enabled});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SignalBuilder(builder: (context) {
|
||||
final list = [
|
||||
for (final r in SessionState.instance.recents.value)
|
||||
if (r.hosted) r
|
||||
];
|
||||
if (list.isEmpty) return const SizedBox.shrink();
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 12),
|
||||
child: Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
for (final r in list)
|
||||
InputChip(
|
||||
key: ValueKey('recent-${r.key}'),
|
||||
avatar: Icon(r.secret.isEmpty ? Icons.history : Icons.lock_outline, size: 16),
|
||||
label: Text('Port ${r.port}'),
|
||||
tooltip: 'Use port ${r.port} again',
|
||||
isEnabled: enabled,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
onPressed: () {
|
||||
fields.port.text = r.port;
|
||||
fields.secret.text = r.secret;
|
||||
},
|
||||
deleteButtonTooltipMessage: 'Forget',
|
||||
onDeleted: () => SessionState.instance.forgetServer(r),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Recent servers as rows; polls each one's `/health` every
|
||||
/// [healthPollEvery] while mounted (i.e. while the Join card is open).
|
||||
class RecentServerRows extends StatefulWidget {
|
||||
final List<RecentServer> servers;
|
||||
final bool enabled;
|
||||
final HealthGetter healthGet;
|
||||
final ValueChanged<RecentServer> onJoin;
|
||||
final ValueChanged<RecentServer> onForget;
|
||||
const RecentServerRows({
|
||||
super.key,
|
||||
required this.servers,
|
||||
required this.enabled,
|
||||
required this.healthGet,
|
||||
required this.onJoin,
|
||||
required this.onForget,
|
||||
});
|
||||
|
||||
@override
|
||||
State<RecentServerRows> createState() => _RecentServerRowsState();
|
||||
}
|
||||
|
||||
class _RecentServerRowsState extends State<RecentServerRows> {
|
||||
final _health = <String, ServerHealth>{};
|
||||
final _inFlight = <String>{};
|
||||
Timer? _timer;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_pollAll();
|
||||
_timer = Timer.periodic(healthPollEvery, (_) => _pollAll());
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(RecentServerRows old) {
|
||||
super.didUpdateWidget(old);
|
||||
for (final r in widget.servers) {
|
||||
if (!_health.containsKey(r.key)) _poll(r);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_timer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _pollAll() {
|
||||
for (final r in widget.servers) {
|
||||
_poll(r);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _poll(RecentServer r) async {
|
||||
if (!_inFlight.add(r.key)) return;
|
||||
try {
|
||||
final h = await probeHealth(r.host, r.port, widget.healthGet);
|
||||
if (mounted) setState(() => _health[r.key] = h);
|
||||
} finally {
|
||||
_inFlight.remove(r.key);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
for (final r in widget.servers)
|
||||
_RecentRow(
|
||||
key: ValueKey('recent-${r.key}'),
|
||||
server: r,
|
||||
health: _health[r.key],
|
||||
enabled: widget.enabled,
|
||||
onJoin: () => widget.onJoin(r),
|
||||
onForget: () => widget.onForget(r),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RecentRow extends StatelessWidget {
|
||||
final RecentServer server;
|
||||
/// Null until the first probe answers.
|
||||
final ServerHealth? health;
|
||||
final bool enabled;
|
||||
final VoidCallback onJoin;
|
||||
final VoidCallback onForget;
|
||||
const _RecentRow({
|
||||
super.key,
|
||||
required this.server,
|
||||
required this.health,
|
||||
required this.enabled,
|
||||
required this.onJoin,
|
||||
required this.onForget,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final h = health;
|
||||
final address = '${server.host}:${server.port}';
|
||||
final title = h != null && h.name.isNotEmpty ? h.name : server.host;
|
||||
final (status, statusColor) = switch (h) {
|
||||
null => ('checking…', Colors.grey),
|
||||
ServerHealth(online: true) => ('● online · ${h.viewers} watching', Colors.greenAccent.shade400),
|
||||
_ => ('○ offline', Colors.grey),
|
||||
};
|
||||
return ListTile(
|
||||
dense: true,
|
||||
contentPadding: const EdgeInsets.only(left: 4),
|
||||
enabled: enabled,
|
||||
leading: Icon(server.secret.isEmpty ? Icons.history : Icons.lock_outline, size: 18),
|
||||
title: Text(title, maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
subtitle: Wrap(
|
||||
spacing: 6,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
Text(address, style: const TextStyle(color: Colors.grey)),
|
||||
Text(status, key: const ValueKey('recent-status'), style: TextStyle(color: statusColor)),
|
||||
if (h != null && h.needsUpdate)
|
||||
Tooltip(
|
||||
message: 'Server protocol ${h.proto}, this app '
|
||||
'${RelayConstants.protoVersion} — update so both match',
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
|
||||
decoration: BoxDecoration(color: scheme.errorContainer, borderRadius: BorderRadius.circular(6)),
|
||||
child: Text('needs update', style: TextStyle(fontSize: 11, color: scheme.onErrorContainer)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
IconButton(
|
||||
tooltip: 'Forget',
|
||||
visualDensity: VisualDensity.compact,
|
||||
iconSize: 16,
|
||||
onPressed: onForget,
|
||||
icon: const Icon(Icons.close),
|
||||
),
|
||||
const Icon(Icons.login, size: 18),
|
||||
],
|
||||
),
|
||||
onTap: onJoin,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,659 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:sync_relay_core/core.dart' show LinkProbe, hasMediaExtension, parseJellyfinLink, playErrorText;
|
||||
|
||||
import '../playback/yt_dlp_app.dart';
|
||||
import '../screens/home/media_picker.dart';
|
||||
import '../state/connect_prefs.dart';
|
||||
import 'motion.dart';
|
||||
|
||||
// The Library's Link source: a big paste field, a preview of what the link
|
||||
// is, how the others get it (in a server), and the recent links.
|
||||
|
||||
const maxRecentLinks = 12;
|
||||
const _keyRecentLinks = 'recentLinks';
|
||||
|
||||
/// A link put up before, newest first in [RecentLinks].
|
||||
class RecentLink {
|
||||
final String url;
|
||||
final String title;
|
||||
final String? thumbnail;
|
||||
final DateTime at;
|
||||
const RecentLink({required this.url, required this.title, this.thumbnail, required this.at});
|
||||
|
||||
Map<String, Object?> toJson() =>
|
||||
{'url': url, 'title': title, 'thumb': ?thumbnail, 'at': at.millisecondsSinceEpoch};
|
||||
|
||||
static RecentLink? fromJson(Object? j) {
|
||||
if (j is! Map) return null;
|
||||
final url = j['url'], title = j['title'], at = j['at'], thumb = j['thumb'];
|
||||
if (url is! String || url.isEmpty) return null;
|
||||
return RecentLink(
|
||||
url: url,
|
||||
title: title is String && title.isNotEmpty ? title : LinkMedia(url).label,
|
||||
thumbnail: thumb is String && thumb.isNotEmpty ? thumb : null,
|
||||
at: DateTime.fromMillisecondsSinceEpoch(at is int ? at : 0),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// [link] first, its older copy dropped, at most [maxRecentLinks].
|
||||
List<RecentLink> pushRecentLink(List<RecentLink> list, RecentLink link) =>
|
||||
[link, ...list.where((l) => l.url != link.url)].take(maxRecentLinks).toList();
|
||||
|
||||
String encodeRecentLinks(List<RecentLink> list) => jsonEncode([for (final l in list) l.toJson()]);
|
||||
|
||||
List<RecentLink> decodeRecentLinks(Object? raw) {
|
||||
try {
|
||||
final j = raw is String ? jsonDecode(raw) : raw;
|
||||
if (j is! List) return const [];
|
||||
return [for (final e in j) ?RecentLink.fromJson(e)].take(maxRecentLinks).toList();
|
||||
} catch (_) {
|
||||
return const [];
|
||||
}
|
||||
}
|
||||
|
||||
/// The recent links, kept in the `connect_form` box next to the last link.
|
||||
class RecentLinks {
|
||||
static final RecentLinks instance = RecentLinks();
|
||||
|
||||
RecentLinks({Future<Object?> Function()? read, Future<void> Function(String value)? write})
|
||||
: _read = read ?? _hiveRead,
|
||||
_write = write ?? _hiveWrite;
|
||||
|
||||
final Future<Object?> Function() _read;
|
||||
final Future<void> Function(String value) _write;
|
||||
final links = ValueNotifier<List<RecentLink>>(const []);
|
||||
Future<void>? _load;
|
||||
|
||||
static Future<Object?> _hiveRead() async =>
|
||||
(connectFormBoxIfOpen() ?? await openConnectFormBox()).get(_keyRecentLinks);
|
||||
|
||||
static Future<void> _hiveWrite(String value) async =>
|
||||
(connectFormBoxIfOpen() ?? await openConnectFormBox()).put(_keyRecentLinks, value);
|
||||
|
||||
Future<void> ensureLoaded() => _load ??= () async {
|
||||
try {
|
||||
final read = decodeRecentLinks(await _read());
|
||||
// Something added meanwhile stays on top.
|
||||
var all = read;
|
||||
for (final l in links.value.reversed) {
|
||||
all = pushRecentLink(all, l);
|
||||
}
|
||||
links.value = all;
|
||||
} catch (e) {
|
||||
debugPrint('load recent links failed: $e');
|
||||
}
|
||||
}();
|
||||
|
||||
Future<void> add(RecentLink link) async {
|
||||
links.value = pushRecentLink(links.value, link);
|
||||
try {
|
||||
await _write(encodeRecentLinks(links.value));
|
||||
} catch (e) {
|
||||
debugPrint('save recent links failed: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- preview
|
||||
|
||||
/// What a pasted link is, for the preview card and the recent list.
|
||||
class LinkPreview {
|
||||
final String title;
|
||||
/// "youtube.com · Channel", or the host.
|
||||
final String subtitle;
|
||||
final String? 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});
|
||||
}
|
||||
|
||||
/// What a link is (see [AppYtDlp.probe]).
|
||||
typedef LinkProber = Future<LinkProbe> Function(String url);
|
||||
|
||||
Future<LinkProbe> defaultLinkProber(String url) => AppYtDlp.instance.probe(url);
|
||||
|
||||
String _host(Uri uri) => uri.host.startsWith('www.') ? uri.host.substring(4) : uri.host;
|
||||
|
||||
/// "3:07", "1:02:03".
|
||||
String clockText(double seconds) {
|
||||
final s = seconds.round();
|
||||
final h = s ~/ 3600, m = s % 3600 ~/ 60, sec = (s % 60).toString().padLeft(2, '0');
|
||||
return h > 0 ? '$h:${m.toString().padLeft(2, '0')}:$sec' : '$m:$sec';
|
||||
}
|
||||
|
||||
/// The preview of [url]: a web page shows yt-dlp's title, uploader,
|
||||
/// duration and thumbnail; a media file its file name and host; a link
|
||||
/// that does not open, why ([LinkPreview.problem]). Null for text that is
|
||||
/// not an http(s) link.
|
||||
Future<LinkPreview?> previewLink(String url, {LinkProber probe = defaultLinkProber}) async {
|
||||
final uri = Uri.tryParse(url.trim());
|
||||
if (uri == null || !(uri.scheme == 'http' || uri.scheme == 'https') || uri.host.isEmpty) return null;
|
||||
final host = _host(uri);
|
||||
// A Jellyfin item goes up as the item (see `mediaForLink`), not the page.
|
||||
if (parseJellyfinLink(url.trim()) != null) return LinkPreview(title: host, subtitle: '$host · Jellyfin');
|
||||
final file = hasMediaExtension(uri);
|
||||
final LinkProbe p;
|
||||
try {
|
||||
p = await probe(url.trim());
|
||||
} catch (e) {
|
||||
final problem = playErrorText('$e');
|
||||
return LinkPreview(title: LinkMedia(url.trim()).label, subtitle: '$host · $problem', problem: problem);
|
||||
}
|
||||
final info = p.info;
|
||||
if (!p.isPage) return LinkPreview(title: LinkMedia(url.trim()).label, subtitle: host);
|
||||
final problem = p.problem;
|
||||
if (info == null || problem != null) {
|
||||
// A media file name that did not answer as one keeps its name.
|
||||
final title = file ? LinkMedia(url.trim()).label : p.title ?? host;
|
||||
return LinkPreview(title: title, subtitle: '$host · ${problem ?? 'Not a video'}', problem: problem);
|
||||
}
|
||||
return LinkPreview(
|
||||
title: p.title ?? host,
|
||||
subtitle: [
|
||||
host,
|
||||
if (info.uploader.isNotEmpty) info.uploader,
|
||||
if (info.duration case final d? when d > 0) clockText(d),
|
||||
].join(' · '),
|
||||
thumbnail: info.thumbnail,
|
||||
);
|
||||
}
|
||||
|
||||
/// "today", "yesterday", "3 days ago", "last week", "5 weeks ago", ...
|
||||
String agoText(DateTime at, DateTime now) {
|
||||
final d = now.difference(at).inDays;
|
||||
if (d < 1) return 'today';
|
||||
if (d < 2) return 'yesterday';
|
||||
if (d < 7) return '$d days ago';
|
||||
if (d < 14) return 'last week';
|
||||
if (d < 60) return '${d ~/ 7} weeks ago';
|
||||
if (d < 365) return '${d ~/ 30} months ago';
|
||||
return 'over a year ago';
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------- widget
|
||||
|
||||
const _modes = [
|
||||
(LinkRelay.direct, Icons.groups_outlined, 'Everyone fetches it', 'Each app plays the link itself.'),
|
||||
(
|
||||
LinkRelay.server,
|
||||
Icons.dns_outlined,
|
||||
'Server fetches it',
|
||||
'Downloaded once by the server. For links slow or blocked for some.'
|
||||
),
|
||||
(LinkRelay.me, Icons.cell_tower, 'Through me', 'Your computer passes it on. For links only you can open.'),
|
||||
];
|
||||
|
||||
String linkRelayHelp(LinkRelay r) => switch (r) {
|
||||
LinkRelay.direct => 'Everyone streams the link themselves. Jellyfin links are shared as the item, '
|
||||
'without your sign-in.',
|
||||
LinkRelay.server => 'The server downloads it once and streams it to everyone. It must be able to '
|
||||
'reach the link; the link is not shown to anyone.',
|
||||
LinkRelay.me => 'This computer downloads it and passes it on through the server. Keep the app open '
|
||||
'while it plays; the link is not shown to anyone.',
|
||||
};
|
||||
|
||||
/// Under Play when nobody else watches.
|
||||
const soloPlayHint = 'Plays just for you. Invite friends any time; it keeps playing.';
|
||||
|
||||
/// The Link source. [solo]: nobody else watches (offline or solo), so the
|
||||
/// button says Play and there are no relay modes (the link plays direct).
|
||||
class LibraryLinkSource extends StatefulWidget {
|
||||
final MediaPickerController controller;
|
||||
final bool solo;
|
||||
/// Puts the link up; returns an error text, or null when sent.
|
||||
final Future<String?> Function(LinkMedia media) onPutUp;
|
||||
final RecentLinks recents;
|
||||
final LinkProber probe;
|
||||
const LibraryLinkSource({
|
||||
super.key,
|
||||
required this.controller,
|
||||
required this.onPutUp,
|
||||
required this.recents,
|
||||
this.solo = false,
|
||||
this.probe = defaultLinkProber,
|
||||
});
|
||||
|
||||
@override
|
||||
State<LibraryLinkSource> createState() => _LibraryLinkSourceState();
|
||||
}
|
||||
|
||||
class _LibraryLinkSourceState extends State<LibraryLinkSource> {
|
||||
MediaPickerController get _c => widget.controller;
|
||||
final _previews = <String, LinkPreview?>{};
|
||||
String _previewUrl = '';
|
||||
Timer? _debounce;
|
||||
bool _busy = false;
|
||||
String? _error;
|
||||
/// [_error] is the probe's problem: "Put it up anyway" is offered.
|
||||
bool _canForce = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
widget.recents.ensureLoaded();
|
||||
_c.link.addListener(_typed);
|
||||
_typed();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_debounce?.cancel();
|
||||
_c.link.removeListener(_typed);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _typed() {
|
||||
final url = _c.link.text.trim();
|
||||
if (url == _previewUrl) return;
|
||||
_debounce?.cancel();
|
||||
_debounce = Timer(const Duration(milliseconds: 350), () => _preview(url));
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_error = null;
|
||||
_canForce = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _preview(String url) async {
|
||||
_previewUrl = url;
|
||||
if (url.isEmpty || _previews.containsKey(url)) {
|
||||
if (mounted) setState(() {});
|
||||
return;
|
||||
}
|
||||
final p = await previewLink(url, probe: widget.probe);
|
||||
_previews[url] = p;
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
|
||||
Future<void> _paste() async {
|
||||
try {
|
||||
final text = (await Clipboard.getData(Clipboard.kTextPlain))?.text?.trim();
|
||||
if (text != null && text.isNotEmpty) _c.link.text = text;
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
Future<void> _go({bool anyway = false}) async {
|
||||
final url = _c.link.text.trim();
|
||||
if (url.isEmpty || _busy) return;
|
||||
setState(() {
|
||||
_busy = true;
|
||||
_error = null;
|
||||
_canForce = false;
|
||||
});
|
||||
// What the probe found refuses it here, with "Put it up anyway": the
|
||||
// server or a friend (with cookies) may still open it.
|
||||
final problem = anyway ? null : (_previews[url] ?? await previewLink(url, probe: widget.probe))?.problem;
|
||||
String? error = problem;
|
||||
if (problem == null) {
|
||||
try {
|
||||
error = await widget.onPutUp(
|
||||
LinkMedia(url, relay: widget.solo ? LinkRelay.direct : _c.linkRelay, anyway: anyway));
|
||||
} catch (e) {
|
||||
error = '$e';
|
||||
}
|
||||
}
|
||||
final canForce = problem != null;
|
||||
if (error == null) {
|
||||
final p = _previews[url] ?? await previewLink(url, probe: widget.probe);
|
||||
unawaited(widget.recents.add(RecentLink(
|
||||
url: url,
|
||||
title: p?.title ?? LinkMedia(url).label,
|
||||
thumbnail: p?.thumbnail,
|
||||
at: DateTime.now(),
|
||||
)));
|
||||
}
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_busy = false;
|
||||
_error = error;
|
||||
_canForce = canForce;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final t = Theme.of(context).textTheme;
|
||||
final solo = widget.solo;
|
||||
final url = _c.link.text.trim();
|
||||
final preview = url.isEmpty ? null : _previews[url];
|
||||
final loading = url.isNotEmpty && !_previews.containsKey(url);
|
||||
final field = TextField(
|
||||
key: const ValueKey('link-field'),
|
||||
controller: _c.link,
|
||||
style: const TextStyle(fontSize: 16),
|
||||
keyboardType: TextInputType.url,
|
||||
onSubmitted: (_) => _go(),
|
||||
decoration: InputDecoration(
|
||||
hintText: 'https://…',
|
||||
prefixIcon: const Icon(Icons.link),
|
||||
suffixIcon: IconButton(onPressed: _paste, tooltip: 'Paste', icon: const Icon(Icons.content_paste)),
|
||||
filled: true,
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
|
||||
),
|
||||
);
|
||||
final button = FilledButton.icon(
|
||||
key: const ValueKey('link-put-up'),
|
||||
onPressed: url.isEmpty || _busy ? null : _go,
|
||||
icon: _busy
|
||||
? const SizedBox.square(dimension: 16, child: CircularProgressIndicator(strokeWidth: 2))
|
||||
: const Icon(Icons.play_arrow),
|
||||
label: Text(solo ? 'Play' : 'Put it up'),
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize: const Size(0, 56),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
),
|
||||
);
|
||||
|
||||
return SingleChildScrollView(
|
||||
key: const ValueKey('media-link'),
|
||||
padding: const EdgeInsets.fromLTRB(24, 32, 24, 32),
|
||||
child: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 780),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Center(
|
||||
child: Container(
|
||||
width: 64,
|
||||
height: 64,
|
||||
decoration: BoxDecoration(
|
||||
color: scheme.primary.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
),
|
||||
child: Icon(Icons.link, size: 32, color: scheme.primary),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
Text('Watch from a link', textAlign: TextAlign.center, style: t.headlineSmall),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'A video file, a page (YouTube, X, …) or a Jellyfin link. Ctrl+V or drop it anywhere to bring it here.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: scheme.onSurfaceVariant),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
LayoutBuilder(
|
||||
builder: (context, box) => box.maxWidth < 460
|
||||
? Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [
|
||||
field,
|
||||
const SizedBox(height: 10),
|
||||
button,
|
||||
])
|
||||
: Row(children: [Expanded(child: field), const SizedBox(width: 12), button]),
|
||||
),
|
||||
if (solo || _error != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
_error ?? soloPlayHint,
|
||||
key: const ValueKey('link-hint'),
|
||||
textAlign: TextAlign.center,
|
||||
style: t.bodySmall?.copyWith(color: _error != null ? scheme.error : Colors.grey),
|
||||
),
|
||||
if (_canForce)
|
||||
Center(
|
||||
child: TextButton(
|
||||
key: const ValueKey('link-put-up-anyway'),
|
||||
onPressed: _busy ? null : () => _go(anyway: true),
|
||||
child: Text(solo ? 'Play it anyway' : 'Put it up anyway'),
|
||||
),
|
||||
),
|
||||
],
|
||||
AnimatedSize(
|
||||
duration: easeTime,
|
||||
curve: ease,
|
||||
child: url.isEmpty
|
||||
? const SizedBox(width: double.infinity)
|
||||
: Padding(
|
||||
padding: const EdgeInsets.only(top: 14),
|
||||
child: _PreviewCard(preview: preview, loading: loading),
|
||||
),
|
||||
),
|
||||
if (!solo) ...[
|
||||
const SizedBox(height: 28),
|
||||
Text('How the others get it', style: t.titleSmall),
|
||||
const SizedBox(height: 10),
|
||||
LayoutBuilder(builder: (context, box) {
|
||||
final cards = [
|
||||
for (final m in _modes)
|
||||
_ModeCard(
|
||||
key: ValueKey('link-mode-${m.$1.name}'),
|
||||
icon: m.$2,
|
||||
title: m.$3,
|
||||
sub: m.$4,
|
||||
selected: _c.linkRelay == m.$1,
|
||||
onTap: () => setState(() => _c.linkRelay = m.$1),
|
||||
),
|
||||
];
|
||||
if (box.maxWidth < 560) {
|
||||
return Column(children: [
|
||||
for (final (i, c) in cards.indexed) ...[if (i > 0) const SizedBox(height: 8), c],
|
||||
]);
|
||||
}
|
||||
return IntrinsicHeight(
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
for (final (i, c) in cards.indexed) ...[
|
||||
if (i > 0) const SizedBox(width: 10),
|
||||
Expanded(child: c),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}),
|
||||
const SizedBox(height: 8),
|
||||
Text(linkRelayHelp(_c.linkRelay), style: t.bodySmall?.copyWith(color: Colors.grey)),
|
||||
],
|
||||
ValueListenableBuilder<List<RecentLink>>(
|
||||
valueListenable: widget.recents.links,
|
||||
builder: (context, recents, _) => recents.isEmpty
|
||||
? const SizedBox.shrink()
|
||||
: Padding(
|
||||
padding: const EdgeInsets.only(top: 32),
|
||||
child: _RecentGrid(recents: recents, onPick: (l) => _c.link.text = l.url),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PreviewCard extends StatelessWidget {
|
||||
final LinkPreview? preview;
|
||||
final bool loading;
|
||||
const _PreviewCard({required this.preview, required this.loading});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final p = preview;
|
||||
return Container(
|
||||
key: const ValueKey('link-preview'),
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(color: scheme.surfaceContainerLow, borderRadius: BorderRadius.circular(16)),
|
||||
child: Row(
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: SizedBox(width: 160, height: 90, child: _Thumb(url: p?.thumbnail)),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: loading
|
||||
? const Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: SizedBox.square(dimension: 18, child: CircularProgressIndicator(strokeWidth: 2)),
|
||||
)
|
||||
: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
p?.title ?? 'Not a link',
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 16),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
p?.subtitle ?? 'Paste an http(s) address',
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(color: p?.problem != null ? scheme.error : Colors.grey),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Thumb extends StatelessWidget {
|
||||
final String? url;
|
||||
const _Thumb({required this.url});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final fallback = ColoredBox(
|
||||
color: scheme.surfaceContainerHighest,
|
||||
child: const Center(child: Icon(Icons.movie_outlined, color: Colors.grey)),
|
||||
);
|
||||
final u = url;
|
||||
if (u == null) return fallback;
|
||||
return Image.network(u, fit: BoxFit.cover, errorBuilder: (_, _, _) => fallback);
|
||||
}
|
||||
}
|
||||
|
||||
class _ModeCard extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String title, sub;
|
||||
final bool selected;
|
||||
final VoidCallback onTap;
|
||||
const _ModeCard({
|
||||
super.key,
|
||||
required this.icon,
|
||||
required this.title,
|
||||
required this.sub,
|
||||
required this.selected,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
return Semantics(
|
||||
selected: selected,
|
||||
button: true,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
child: AnimatedContainer(
|
||||
duration: easeTime,
|
||||
curve: ease,
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: selected ? scheme.primary.withValues(alpha: 0.10) : scheme.surfaceContainerLow,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: selected ? scheme.primary : Colors.transparent, width: 2),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(icon, color: selected ? scheme.primary : scheme.onSurfaceVariant),
|
||||
const SizedBox(height: 8),
|
||||
Text(title, style: TextStyle(fontWeight: FontWeight.w600, color: selected ? scheme.primary : null)),
|
||||
const SizedBox(height: 2),
|
||||
Text(sub,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(color: Colors.grey, fontSize: 12)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RecentGrid extends StatelessWidget {
|
||||
final List<RecentLink> recents;
|
||||
final ValueChanged<RecentLink> onPick;
|
||||
const _RecentGrid({required this.recents, required this.onPick});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = Theme.of(context).textTheme;
|
||||
final now = DateTime.now();
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text('Recent', style: t.titleSmall),
|
||||
const SizedBox(height: 10),
|
||||
LayoutBuilder(builder: (context, box) {
|
||||
const gap = 12.0;
|
||||
final cols = box.maxWidth > 600 ? 3 : 2;
|
||||
final w = (box.maxWidth - gap * (cols - 1)) / cols;
|
||||
return Wrap(
|
||||
spacing: gap,
|
||||
runSpacing: gap,
|
||||
children: [
|
||||
for (final l in recents)
|
||||
SizedBox(
|
||||
width: w,
|
||||
child: Tooltip(
|
||||
message: l.url,
|
||||
waitDuration: const Duration(milliseconds: 600),
|
||||
child: InkWell(
|
||||
key: ValueKey('recent-link-${l.url}'),
|
||||
onTap: () => onPick(l),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
AspectRatio(
|
||||
aspectRatio: 16 / 9,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: _Thumb(url: l.thumbnail),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(l.title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(fontWeight: FontWeight.w600)),
|
||||
Text(
|
||||
[_host(Uri.tryParse(l.url) ?? Uri()), agoText(l.at, now)]
|
||||
.where((s) => s.isNotEmpty)
|
||||
.join(' · '),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(color: Colors.grey, fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,408 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:signals/signals_flutter.dart';
|
||||
import 'package:sync_relay_core/core.dart' show HelloMedia, JellyfinException, JellyfinItem;
|
||||
|
||||
import '../files/ui/files_page.dart';
|
||||
import '../jellyfin/accounts.dart';
|
||||
import '../jellyfin/browse_scope.dart';
|
||||
import '../jellyfin/details_panel.dart';
|
||||
import '../jellyfin/format.dart' show pickFor;
|
||||
import '../jellyfin/home_page.dart' show libraryIcon, isVideoLibrary;
|
||||
import '../jellyfin/picker_source.dart';
|
||||
import '../jellyfin/session_media.dart';
|
||||
import '../screens/home/media_picker.dart';
|
||||
import '../state/session_state.dart';
|
||||
import 'library_link.dart';
|
||||
import 'motion.dart';
|
||||
import 'welcome.dart';
|
||||
|
||||
/// What putting up a Jellyfin pick sends: its ref (no URL, no token).
|
||||
/// Throws [FormatException] with a user-facing message when the server is
|
||||
/// not linked any more or its sign-in expired.
|
||||
HelloMedia putUpJellyfinMedia(JellyfinAccounts accounts, JellyfinMedia media) {
|
||||
if (accounts.account(media.serverId) == null) {
|
||||
throw const FormatException('that Jellyfin server is not linked any more — pick again');
|
||||
}
|
||||
if (accounts.statusOf(media.serverId) == JellyfinAccountStatus.expired) {
|
||||
throw const FormatException('Jellyfin sign-in expired — reconnect it in Settings → Jellyfin & integrations');
|
||||
}
|
||||
return mediaForPick(media);
|
||||
}
|
||||
|
||||
/// Brings back the last Jellyfin pick (kept in the Jellyfin box) once the
|
||||
/// accounts are read, unless something else was picked meanwhile or
|
||||
/// [alive] says the picker is gone.
|
||||
Future<void> restoreLastPick(
|
||||
MediaPickerController c, JellyfinAccounts accounts, bool Function() alive) async {
|
||||
final hadMedia = c.selection != null;
|
||||
try {
|
||||
await accounts.ensureLoaded();
|
||||
} catch (_) {
|
||||
return;
|
||||
}
|
||||
final pick = accounts.lastPick;
|
||||
if (!alive() || pick == null || c.jellyfin != null) return;
|
||||
if (accounts.account(pick.serverId) == null) return;
|
||||
final source = c.source;
|
||||
c.pickJellyfin(pick);
|
||||
if (hadMedia) c.source = source;
|
||||
}
|
||||
|
||||
/// The file playing now, when it is the one put up from here last: the
|
||||
/// server's current media (state JSON) is a plain file (no link, no
|
||||
/// Jellyfin ref) named like [lastPath].
|
||||
String? nowPlayingFile(Map<String, Object?>? media, String lastPath) {
|
||||
if (media == null || media['url'] != null || media['jellyfin'] != null || lastPath.isEmpty) return null;
|
||||
final name = lastPath.split(RegExp(r'[/\\]')).lastWhere((p) => p.isNotEmpty, orElse: () => lastPath);
|
||||
return name == media['name'] ? lastPath : null;
|
||||
}
|
||||
|
||||
/// Window width under which the details panel floats over the library.
|
||||
const detailsDockFrom = 1000.0;
|
||||
|
||||
/// The Library tab: a source switch (Jellyfin / Link / Files, fading
|
||||
/// through), the Jellyfin library chips beside it, and the source below.
|
||||
/// Jellyfin cards select on click and open their details in a panel at
|
||||
/// the right (docked from [detailsDockFrom], floating over the library
|
||||
/// below it); a double-click or the panel's button puts it up. With
|
||||
/// nobody else watching ([solo]: offline or a solo session) the buttons
|
||||
/// say Play and putting something up starts (or uses) a solo session.
|
||||
class LibraryTab extends StatefulWidget {
|
||||
final MediaPickerController controller;
|
||||
final JellyfinAccounts accounts;
|
||||
final bool solo;
|
||||
/// This app hosts the server: its files stream from here directly.
|
||||
final bool isHost;
|
||||
final BrowseStart? jellyfinStart;
|
||||
/// Puts the pick up; returns an error text, or null when sent.
|
||||
final Future<String?> Function(PickedMedia media) onPutUp;
|
||||
final VoidCallback? onPutUpDone;
|
||||
final VoidCallback? onOpenIntegrations;
|
||||
/// Defaults to [RecentLinks.instance].
|
||||
final RecentLinks? recentLinks;
|
||||
final LinkProber probe;
|
||||
/// The Files source (tests inject one); defaults to [FilesPage].
|
||||
final Widget Function(BuildContext context, {required bool solo, required bool relayed,
|
||||
required Future<String?> Function(String path) onPlay, String? nowPlayingPath})? filesBuilder;
|
||||
/// Clock for the Jellyfin Continue row (tests pin it).
|
||||
final DateTime Function() now;
|
||||
/// Over the source switch (the shell's welcome band while offline).
|
||||
final Widget? banner;
|
||||
const LibraryTab({
|
||||
super.key,
|
||||
required this.controller,
|
||||
required this.accounts,
|
||||
required this.solo,
|
||||
required this.isHost,
|
||||
required this.onPutUp,
|
||||
this.onPutUpDone,
|
||||
this.jellyfinStart,
|
||||
this.onOpenIntegrations,
|
||||
this.recentLinks,
|
||||
this.probe = defaultLinkProber,
|
||||
this.filesBuilder,
|
||||
this.now = DateTime.now,
|
||||
this.banner,
|
||||
});
|
||||
|
||||
@override
|
||||
State<LibraryTab> createState() => _LibraryTabState();
|
||||
}
|
||||
|
||||
class _LibraryTabState extends State<LibraryTab> {
|
||||
late final BrowseController _browse = BrowseController(widget.accounts, now: widget.now);
|
||||
/// What the closing panel keeps showing while it slides out.
|
||||
JellyfinSelection? _shown;
|
||||
|
||||
MediaPickerController get _c => widget.controller;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_c.addListener(_changed);
|
||||
_browse.addListener(_changed);
|
||||
_shown = _c.selected;
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(LibraryTab old) {
|
||||
super.didUpdateWidget(old);
|
||||
if (old.controller != widget.controller) {
|
||||
old.controller.removeListener(_changed);
|
||||
widget.controller.addListener(_changed);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_c.removeListener(_changed);
|
||||
_browse.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _changed() {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
if (_c.selected != null) _shown = _c.selected;
|
||||
});
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- put up
|
||||
|
||||
Future<String?> _put(PickedMedia media) async {
|
||||
String? error;
|
||||
try {
|
||||
error = await widget.onPutUp(media);
|
||||
} catch (e) {
|
||||
error = '$e';
|
||||
}
|
||||
if (error == null) widget.onPutUpDone?.call();
|
||||
return error;
|
||||
}
|
||||
|
||||
/// A Jellyfin card or episode: a series goes up as its next episode.
|
||||
Future<String?> _putUpJellyfin(JellyfinSelection sel) async {
|
||||
final account = widget.accounts.account(sel.serverId);
|
||||
final client = widget.accounts.clientFor(sel.serverId);
|
||||
if (account == null || client == null) return 'that Jellyfin server is not linked any more';
|
||||
JellyfinItem? item = sel.item;
|
||||
try {
|
||||
item ??= await client.item(sel.itemId);
|
||||
item = await playableFor(client, item);
|
||||
} on JellyfinException catch (e) {
|
||||
return e.message;
|
||||
}
|
||||
if (item == null) return 'nothing to play in there';
|
||||
return _put(pickFor(account, item));
|
||||
}
|
||||
|
||||
/// Double-click on a card: put it up, errors as a snack bar.
|
||||
Future<void> _doublePutUp(JellyfinSelection sel) async {
|
||||
_c.select(sel);
|
||||
final error = await _putUpJellyfin(sel);
|
||||
if (error != null && mounted) {
|
||||
ScaffoldMessenger.maybeOf(context)?.showSnackBar(SnackBar(content: Text(error)));
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------- build
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final source = _c.source;
|
||||
final header = Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 16, 20, 0),
|
||||
child: LayoutBuilder(builder: (context, box) {
|
||||
// Narrow: icons only, so the chips keep some room.
|
||||
final labels = box.maxWidth >= 620;
|
||||
ButtonSegment<MediaKind> seg(MediaKind k, IconData icon, String label) => ButtonSegment(
|
||||
value: k, icon: Icon(icon), label: labels ? Text(label) : null, tooltip: labels ? null : label);
|
||||
return Row(
|
||||
children: [
|
||||
SegmentedButton<MediaKind>(
|
||||
key: const ValueKey('media-source'),
|
||||
showSelectedIcon: false,
|
||||
segments: [
|
||||
seg(MediaKind.jellyfin, Icons.video_library_outlined, 'Jellyfin'),
|
||||
seg(MediaKind.link, Icons.link, 'Link'),
|
||||
seg(MediaKind.file, Icons.folder_outlined, 'Files'),
|
||||
],
|
||||
selected: {source},
|
||||
onSelectionChanged: (s) => _c.source = s.first,
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: AnimatedSwitcher(
|
||||
duration: easeTime,
|
||||
child: source == MediaKind.jellyfin
|
||||
? _LibraryChips(key: const ValueKey('chips'), controller: _browse)
|
||||
: const SizedBox(height: 40),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
final Widget content = switch (source) {
|
||||
MediaKind.jellyfin => Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 12, 20, 0),
|
||||
child: JellyfinPickerSource(
|
||||
accounts: widget.accounts,
|
||||
controller: _browse,
|
||||
selected: _c.selected,
|
||||
onSelect: _c.select,
|
||||
onPutUp: _doublePutUp,
|
||||
onOpenIntegrations: widget.onOpenIntegrations,
|
||||
start: widget.jellyfinStart,
|
||||
libraryStrip: false,
|
||||
empty: (context) => NoJellyfinPanel(
|
||||
accounts: widget.accounts,
|
||||
onFiles: () => _c.source = MediaKind.file,
|
||||
onLink: () => _c.source = MediaKind.link,
|
||||
),
|
||||
),
|
||||
),
|
||||
MediaKind.link => LibraryLinkSource(
|
||||
controller: _c,
|
||||
solo: widget.solo,
|
||||
recents: widget.recentLinks ?? RecentLinks.instance,
|
||||
probe: widget.probe,
|
||||
onPutUp: _put,
|
||||
),
|
||||
MediaKind.file => _files(context),
|
||||
};
|
||||
|
||||
final main = Column(
|
||||
children: [
|
||||
AnimatedSize(
|
||||
duration: easeTime,
|
||||
curve: ease,
|
||||
alignment: Alignment.topCenter,
|
||||
child: AnimatedSwitcher(
|
||||
duration: easeTime,
|
||||
child: widget.banner ?? const SizedBox(width: double.infinity),
|
||||
),
|
||||
),
|
||||
header,
|
||||
Expanded(child: fadeThrough(KeyedSubtree(key: ValueKey(source), child: content))),
|
||||
],
|
||||
);
|
||||
|
||||
final open = source == MediaKind.jellyfin && _c.selected != null;
|
||||
final shown = _shown;
|
||||
final details = shown == null
|
||||
? const SizedBox.shrink()
|
||||
: fadeThrough(KeyedSubtree(
|
||||
key: ValueKey('${shown.serverId}/${shown.itemId}'),
|
||||
child: JellyfinDetailsPanel(
|
||||
controller: _browse,
|
||||
selection: shown,
|
||||
solo: widget.solo,
|
||||
onPutUp: _putUpJellyfin,
|
||||
onClose: () => _c.select(null),
|
||||
),
|
||||
));
|
||||
Widget slide(Widget child) => AnimatedSlide(
|
||||
duration: easeTime,
|
||||
curve: ease,
|
||||
offset: open ? Offset.zero : const Offset(1, 0),
|
||||
child: AnimatedOpacity(duration: easeTime, curve: ease, opacity: open ? 1 : 0, child: child),
|
||||
);
|
||||
|
||||
// Holds focus so the shell's shortcuts (Ctrl+,) work.
|
||||
return Focus(
|
||||
key: const ValueKey('library-tab'),
|
||||
autofocus: true,
|
||||
child: LayoutBuilder(builder: (context, box) {
|
||||
if (box.maxWidth >= detailsDockFrom) {
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(child: main),
|
||||
ClipRect(
|
||||
child: AnimatedContainer(
|
||||
duration: easeTime,
|
||||
curve: ease,
|
||||
width: open ? detailsPanelWidth : 0,
|
||||
child: OverflowBox(
|
||||
alignment: Alignment.centerLeft,
|
||||
minWidth: detailsPanelWidth,
|
||||
maxWidth: detailsPanelWidth,
|
||||
child: slide(details),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
return Stack(
|
||||
children: [
|
||||
Positioned.fill(child: main),
|
||||
Positioned(
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
width: detailsPanelWidth,
|
||||
child: IgnorePointer(
|
||||
ignoring: !open,
|
||||
child: slide(Material(elevation: 16, child: details)),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _files(BuildContext context) {
|
||||
final relayed = !widget.isHost;
|
||||
Future<String?> play(String path) => _put(FileMedia(path));
|
||||
return SignalBuilder(builder: (context) {
|
||||
final session = SessionState.instance;
|
||||
final now = session.route.value == AppRoute.session
|
||||
? nowPlayingFile(session.coordinator.serverState.value?.media, session.lastForm.mediaPath)
|
||||
: null;
|
||||
final b = widget.filesBuilder;
|
||||
if (b != null) {
|
||||
return b(context, solo: widget.solo, relayed: relayed, onPlay: play, nowPlayingPath: now);
|
||||
}
|
||||
return FilesPage(solo: widget.solo, relayed: relayed, onPlay: play, nowPlayingPath: now);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// The current server's video libraries as chips in the Library header; a
|
||||
/// click opens one (the chip of the library open is selected).
|
||||
class _LibraryChips extends StatelessWidget {
|
||||
final BrowseController controller;
|
||||
const _LibraryChips({super.key, required this.controller});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListenableBuilder(
|
||||
listenable: Listenable.merge([controller, controller.accounts]),
|
||||
builder: (context, _) {
|
||||
final accounts = controller.accounts;
|
||||
final account = accounts.loaded ? controller.account : null;
|
||||
final client = account == null ? null : accounts.clientFor(account.serverId);
|
||||
if (account == null || client == null) return const SizedBox(height: 40);
|
||||
final stack = controller.stack;
|
||||
final openId = stack.length > 1 && stack[1] is LibraryRoute ? (stack[1] as LibraryRoute).folder.id : null;
|
||||
return FutureBuilder<List<JellyfinItem>>(
|
||||
future: accounts.cached(account.serverId, 'views', client.userViews),
|
||||
builder: (context, snap) {
|
||||
final views = (snap.data ?? const <JellyfinItem>[]).where(isVideoLibrary).toList();
|
||||
return SizedBox(
|
||||
height: 40,
|
||||
child: HScroll(
|
||||
builder: (c) => ListView.separated(
|
||||
controller: c,
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: views.length,
|
||||
separatorBuilder: (_, _) => const SizedBox(width: 8),
|
||||
itemBuilder: (context, i) {
|
||||
final v = views[i];
|
||||
return Center(
|
||||
child: ChoiceChip(
|
||||
key: ValueKey('library-${v.id}'),
|
||||
avatar: Icon(libraryIcon(v), size: 16),
|
||||
showCheckmark: false,
|
||||
label: Text(v.name),
|
||||
selected: v.id == openId,
|
||||
onSelected: (_) => v.id == openId
|
||||
? controller.popTo(0)
|
||||
: controller.openTop(LibraryRoute(v)),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
// The shell's shared motion: one curve and one duration for everything
|
||||
// that moves, a fade-through for views that replace each other, popovers
|
||||
// that grow from where they open, and mouse-friendly horizontal lists.
|
||||
|
||||
const ease = Curves.fastEaseInToSlowEaseOut;
|
||||
const easeTime = Duration(milliseconds: 320);
|
||||
|
||||
/// Fade-through length; the old view is gone after [fadeOutPart] of it.
|
||||
const fadeThroughTime = Duration(milliseconds: 360);
|
||||
const fadeOutPart = 0.35;
|
||||
|
||||
/// Fade through (Material motion): the old view is gone after the first
|
||||
/// 35%, then the new one fades and scales in; they never overlap. Key
|
||||
/// [child] by what it shows so a change of key runs the transition.
|
||||
/// [expand] false: sized by the views (at [alignment]) instead of filling
|
||||
/// the box, for unbounded places (a row, a pill).
|
||||
Widget fadeThrough(Widget child,
|
||||
{Key? key, bool expand = true, AlignmentGeometry alignment = AlignmentDirectional.topStart}) =>
|
||||
AnimatedSwitcher(
|
||||
key: key,
|
||||
duration: fadeThroughTime,
|
||||
layoutBuilder: (current, previous) => Stack(
|
||||
fit: expand ? StackFit.expand : StackFit.loose,
|
||||
alignment: alignment,
|
||||
children: [...previous, ?current],
|
||||
),
|
||||
transitionBuilder: (c, anim) {
|
||||
if (c.key == child.key) {
|
||||
// Incoming (anim 0 -> 1): wait, then fade and scale up.
|
||||
final a = CurvedAnimation(
|
||||
parent: anim,
|
||||
curve: const Interval(fadeOutPart, 1, curve: ease),
|
||||
);
|
||||
return FadeTransition(
|
||||
opacity: a,
|
||||
child: ScaleTransition(scale: Tween(begin: 0.985, end: 1.0).animate(a), child: c),
|
||||
);
|
||||
}
|
||||
// Outgoing (anim 1 -> 0): fully faded by the time it reaches 0.65.
|
||||
// Same widget shape as the incoming branch, or the old view would
|
||||
// remount (and lose its state) while it fades.
|
||||
return FadeTransition(
|
||||
opacity: CurvedAnimation(
|
||||
parent: anim,
|
||||
curve: const Interval(1 - fadeOutPart, 1, curve: Curves.easeIn),
|
||||
),
|
||||
child: ScaleTransition(scale: const AlwaysStoppedAnimation(1.0), child: c),
|
||||
);
|
||||
},
|
||||
child: child,
|
||||
);
|
||||
|
||||
/// A card over the window that fades and scales in from [at] (window
|
||||
/// alignment) instead of popping; a click outside or Esc closes it. Short
|
||||
/// windows: the card scrolls instead of overflowing.
|
||||
Future<T?> showPopover<T>(
|
||||
BuildContext context, {
|
||||
required Alignment at,
|
||||
required Widget child,
|
||||
bool dim = false,
|
||||
}) =>
|
||||
showGeneralDialog<T>(
|
||||
context: context,
|
||||
barrierDismissible: true,
|
||||
barrierLabel: 'close',
|
||||
barrierColor: dim ? Colors.black26 : Colors.transparent,
|
||||
transitionDuration: easeTime,
|
||||
pageBuilder: (context, _, _) => SafeArea(
|
||||
child: Align(
|
||||
alignment: at,
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(maxHeight: MediaQuery.sizeOf(context).height - 32),
|
||||
child: SingleChildScrollView(child: child),
|
||||
),
|
||||
),
|
||||
),
|
||||
transitionBuilder: (context, anim, _, child) {
|
||||
final a = CurvedAnimation(parent: anim, curve: ease, reverseCurve: Curves.easeIn);
|
||||
return FadeTransition(
|
||||
opacity: a,
|
||||
child: ScaleTransition(
|
||||
scale: Tween(begin: 0.96, end: 1.0).animate(a),
|
||||
alignment: at,
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
/// Horizontal lists for mouse users: drag with the mouse or Shift+wheel;
|
||||
/// the plain wheel stays vertical (the page scrolls under the cursor, not
|
||||
/// the row).
|
||||
class HScroll extends StatefulWidget {
|
||||
final ScrollController? controller;
|
||||
final Widget Function(ScrollController controller) builder;
|
||||
/// Also map the plain (vertical) wheel to sideways scrolling (at either
|
||||
/// end the wheel goes to the page). Off by default: one wheel doing two
|
||||
/// things was confusing.
|
||||
final bool wheel;
|
||||
const HScroll({super.key, this.controller, required this.builder, this.wheel = false});
|
||||
|
||||
@override
|
||||
State<HScroll> createState() => _HScrollState();
|
||||
}
|
||||
|
||||
class _HScrollState extends State<HScroll> {
|
||||
ScrollController? _own;
|
||||
ScrollController get _c => widget.controller ?? (_own ??= ScrollController());
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_own?.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _wheel(PointerSignalEvent e) {
|
||||
if (e is! PointerScrollEvent) return;
|
||||
final dy = e.scrollDelta.dy;
|
||||
if (dy == 0 || e.scrollDelta.dx != 0 || !_c.hasClients) return;
|
||||
final p = _c.position;
|
||||
final target = (p.pixels + dy * 1.5).clamp(p.minScrollExtent, p.maxScrollExtent);
|
||||
if (target == p.pixels) return; // at the end: let the page scroll
|
||||
GestureBinding.instance.pointerSignalResolver.register(e, (_) {
|
||||
_c.animateTo(target, duration: const Duration(milliseconds: 140), curve: Curves.easeOutCubic);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => ScrollConfiguration(
|
||||
behavior: ScrollConfiguration.of(context)
|
||||
.copyWith(dragDevices: PointerDeviceKind.values.toSet()),
|
||||
child: widget.wheel ? Listener(onPointerSignal: _wheel, child: widget.builder(_c)) : widget.builder(_c),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:signals/signals_flutter.dart';
|
||||
|
||||
import '../session/now_playing.dart';
|
||||
import '../state/session_state.dart';
|
||||
import 'motion.dart';
|
||||
|
||||
/// Tests: art bytes by hash instead of the session's cache.
|
||||
@visibleForTesting
|
||||
Uint8List? Function(String hash)? debugArtBytes;
|
||||
|
||||
/// The one lookup for media art (cover, banner) by content hash: null
|
||||
/// when there is none or it has not arrived yet (listeners of
|
||||
/// [artChanges] hear when it lands).
|
||||
Uint8List? artBytes(String hash) {
|
||||
if (hash.isEmpty) return null;
|
||||
final debug = debugArtBytes;
|
||||
if (debug != null) return debug(hash);
|
||||
return SessionState.instance.coordinator.art.bytesFor(hash);
|
||||
}
|
||||
|
||||
/// Notifies when art arrives.
|
||||
Listenable artChanges() => SessionState.instance.coordinator.art;
|
||||
|
||||
final _images = <String, MemoryImage>{};
|
||||
|
||||
/// [hash]'s art as an image, the same provider while the bytes are the
|
||||
/// same (so a rebuild never decodes again).
|
||||
ImageProvider? artImage(String hash) {
|
||||
final bytes = artBytes(hash);
|
||||
if (bytes == null) return null;
|
||||
final hit = _images[hash];
|
||||
if (hit != null && identical(hit.bytes, bytes)) return hit;
|
||||
if (_images.length > 16) _images.clear();
|
||||
return _images[hash] = MemoryImage(bytes);
|
||||
}
|
||||
|
||||
/// [image] fading in (ease) once decoded; nothing before.
|
||||
Widget fadeInImage(ImageProvider image, {BoxFit fit = BoxFit.cover}) => Image(
|
||||
image: image,
|
||||
fit: fit,
|
||||
gaplessPlayback: true,
|
||||
frameBuilder: (context, child, frame, sync) => sync
|
||||
? child
|
||||
: AnimatedOpacity(opacity: frame == null ? 0 : 1, duration: easeTime, curve: ease, child: child),
|
||||
);
|
||||
|
||||
/// The portrait cover (2:3) of the current media, a quiet tile with a
|
||||
/// film icon until (or unless) its art arrives.
|
||||
class ArtCover extends StatelessWidget {
|
||||
final String hash;
|
||||
final double width;
|
||||
const ArtCover({super.key, required this.hash, this.width = 40});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final image = artImage(hash);
|
||||
final r = BorderRadius.circular(width >= 80 ? 10 : 6);
|
||||
return Container(
|
||||
width: width,
|
||||
height: width * 1.5,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: r,
|
||||
boxShadow: const [BoxShadow(color: Color(0x66000000), blurRadius: 10, offset: Offset(0, 3))],
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: r,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
DecoratedBox(
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [Color(0xFF2A2D35), Color(0xFF17191E)],
|
||||
),
|
||||
),
|
||||
child: Icon(Icons.movie_outlined, color: Colors.white38, size: width * 0.45),
|
||||
),
|
||||
if (image != null) fadeInImage(image),
|
||||
DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: r,
|
||||
border: Border.all(color: Colors.white12),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const _shadow = [Shadow(color: Color(0x99000000), blurRadius: 8)];
|
||||
|
||||
/// Title and episode line of [now] (with its cover unless [cover] is
|
||||
/// off), ellipsized; cross-fades when the media changes.
|
||||
class NowPlayingBlock extends StatelessWidget {
|
||||
final NowPlaying? now;
|
||||
final bool cover;
|
||||
/// Smaller type (the mini player).
|
||||
final bool compact;
|
||||
const NowPlayingBlock({super.key, required this.now, this.cover = true, this.compact = false});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final n = now;
|
||||
final Widget child;
|
||||
if (n == null) {
|
||||
child = const SizedBox.shrink(key: ValueKey('now-playing-none'));
|
||||
} else {
|
||||
final episode = n.episode;
|
||||
child = Row(
|
||||
key: ValueKey('now-playing:${n.title}\n${episode ?? ''}'),
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (cover) ...[ArtCover(hash: n.posterHash), const SizedBox(width: 12)],
|
||||
Flexible(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
n.title,
|
||||
key: const ValueKey('now-playing-title'),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontSize: compact ? 13 : 16,
|
||||
shadows: _shadow),
|
||||
),
|
||||
if (episode != null && episode.isNotEmpty)
|
||||
Text(
|
||||
episode,
|
||||
key: const ValueKey('now-playing-episode'),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(color: Colors.white70, fontSize: compact ? 11 : 13, shadows: _shadow),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
return fadeThrough(child, expand: false, alignment: AlignmentDirectional.centerStart);
|
||||
}
|
||||
}
|
||||
|
||||
/// [NowPlayingBlock] of the session's current media.
|
||||
class NowPlayingTitle extends StatelessWidget {
|
||||
final bool cover;
|
||||
final bool compact;
|
||||
const NowPlayingTitle({super.key, this.cover = true, this.compact = false});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => ListenableBuilder(
|
||||
listenable: artChanges(),
|
||||
builder: (context, _) => SignalBuilder(
|
||||
builder: (context) => NowPlayingBlock(
|
||||
now: SessionState.instance.coordinator.nowPlaying.value,
|
||||
cover: cover,
|
||||
compact: compact,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,686 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:signals/signals_flutter.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
import 'package:window_manager/window_manager.dart';
|
||||
|
||||
import '../screens/home/update_indicator.dart';
|
||||
import '../screens/session/jellyfin_widgets.dart' show ViaHostBadge;
|
||||
import '../state/session_state.dart';
|
||||
import '../update/update_controller.dart';
|
||||
import '../widgets/app_keys.dart';
|
||||
import '../widgets/window_buttons.dart';
|
||||
import 'avatar.dart';
|
||||
import 'motion.dart';
|
||||
|
||||
/// The shell's tabs, in strip order.
|
||||
enum ShellTab {
|
||||
library('Library'),
|
||||
schedule('Schedule'),
|
||||
settings('Settings');
|
||||
|
||||
final String label;
|
||||
const ShellTab(this.label);
|
||||
}
|
||||
|
||||
const partyStripHeight = 60.0;
|
||||
|
||||
/// Every avatar in the strip, yours and the party's.
|
||||
const stripAvatarSize = 42.0;
|
||||
|
||||
/// Below this window width the strip shows 3 avatars and no server text,
|
||||
/// so the tabs keep their room.
|
||||
const stripCompactBelow = 1100.0;
|
||||
|
||||
/// The member plays the current Jellyfin item through the host's relay.
|
||||
bool viaHostOf(MemberView m, JellyfinRef? ref) =>
|
||||
ref != null && m.jf[ref.serverId] == false;
|
||||
|
||||
/// The 60 px strip over everything: your avatar (profile), the tabs, and
|
||||
/// the party: offline Join a friend / Host, solo a "Solo" chip, Invite
|
||||
/// friends and Stop, online the avatars (members card), server and
|
||||
/// readiness, chat and Leave. Empty space drags the window.
|
||||
class PartyStrip extends StatelessWidget {
|
||||
final ShellTab tab;
|
||||
final ValueChanged<ShellTab> onTab;
|
||||
final bool chatOpen;
|
||||
final int unread;
|
||||
final VoidCallback onChat;
|
||||
final VoidCallback onJoin;
|
||||
final VoidCallback onHost;
|
||||
final VoidCallback onLeave;
|
||||
final VoidCallback onProfile;
|
||||
/// Solo: host from here, keeping what plays.
|
||||
final VoidCallback? onInvite;
|
||||
|
||||
/// Online with something up but the player closed: brings it back.
|
||||
final VoidCallback? onShowPlayer;
|
||||
final UpdateController? updates;
|
||||
|
||||
/// Off in tests: the drag area and window buttons need window_manager.
|
||||
final bool windowControls;
|
||||
const PartyStrip({
|
||||
super.key,
|
||||
required this.tab,
|
||||
required this.onTab,
|
||||
required this.chatOpen,
|
||||
required this.unread,
|
||||
required this.onChat,
|
||||
required this.onJoin,
|
||||
required this.onHost,
|
||||
required this.onLeave,
|
||||
required this.onProfile,
|
||||
this.onInvite,
|
||||
this.onShowPlayer,
|
||||
this.updates,
|
||||
this.windowControls = true,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final compact = MediaQuery.sizeOf(context).width < stripCompactBelow;
|
||||
final row = SignalBuilder(
|
||||
builder: (context) {
|
||||
final session = SessionState.instance;
|
||||
final coord = session.coordinator;
|
||||
final online = session.route.value == AppRoute.session;
|
||||
final solo = online && (session.solo.value || session.switching.value);
|
||||
final self = online ? coord.selfMember.value : null;
|
||||
return Row(
|
||||
children: [
|
||||
const SizedBox(width: 16),
|
||||
Tooltip(
|
||||
message: 'Your profile',
|
||||
child: InkWell(
|
||||
key: const ValueKey('strip-profile'),
|
||||
canRequestFocus: false,
|
||||
customBorder: const CircleBorder(),
|
||||
onTap: onProfile,
|
||||
child: StatusAvatar(
|
||||
info: selfAvatarInfo(
|
||||
ready: self?.ready ?? false,
|
||||
host: online && !solo && coord.hostFile != null,
|
||||
error: online ? coord.playerError.value : '',
|
||||
),
|
||||
size: stripAvatarSize,
|
||||
ring: online,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: HScroll(
|
||||
builder: (c) => SingleChildScrollView(
|
||||
controller: c,
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: [
|
||||
for (final t in ShellTab.values)
|
||||
StripTab(
|
||||
key: ValueKey('tab-${t.name}'),
|
||||
label: t.label,
|
||||
selected: t == tab,
|
||||
compact: compact,
|
||||
onTap: () => onTab(t),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
UpdateIndicator(controller: updates),
|
||||
const SizedBox(width: 8),
|
||||
if (online && onShowPlayer != null)
|
||||
IconButton(
|
||||
key: const ValueKey('show-player'),
|
||||
tooltip: 'Show the player',
|
||||
onPressed: onShowPlayer,
|
||||
icon: const Icon(Icons.smart_display_outlined, size: 20),
|
||||
),
|
||||
if (solo) ...[
|
||||
_SoloChip(compact: compact, busy: session.switching.value),
|
||||
const SizedBox(width: 10),
|
||||
if (compact)
|
||||
FilledButton(
|
||||
key: const ValueKey('strip-invite'),
|
||||
onPressed: session.switching.value ? null : onInvite,
|
||||
child: const Text('Invite'),
|
||||
)
|
||||
else
|
||||
FilledButton.icon(
|
||||
key: const ValueKey('strip-invite'),
|
||||
onPressed: session.switching.value ? null : onInvite,
|
||||
icon: const Icon(Icons.group_add_outlined, size: 18),
|
||||
label: const Text('Invite friends'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
OutlinedButton(
|
||||
key: const ValueKey('strip-leave'),
|
||||
onPressed: session.switching.value ? null : onLeave,
|
||||
child: const Text('Stop'),
|
||||
),
|
||||
] else if (online) ...[
|
||||
PartyWatch(
|
||||
builder: (context) => AvatarStack(
|
||||
key: const ValueKey('party-avatars'),
|
||||
members: partyAvatars(coord),
|
||||
shown: compact ? 3 : 5,
|
||||
size: stripAvatarSize,
|
||||
onTap: () => showMembersCard(context),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
if (!compact) _ServerLabel(scheme: scheme),
|
||||
const SizedBox(width: 6),
|
||||
// Tab toggles the chat while the strip is shown.
|
||||
ChatKey(
|
||||
onToggle: onChat,
|
||||
child: Badge(
|
||||
isLabelVisible: unread > 0,
|
||||
label: Text('$unread'),
|
||||
child: IconButton(
|
||||
key: const ValueKey('strip-chat'),
|
||||
tooltip: 'Chat & activity (Tab)',
|
||||
isSelected: chatOpen,
|
||||
onPressed: onChat,
|
||||
icon: const Icon(Icons.chat_bubble_outline, size: 20),
|
||||
selectedIcon: Icon(
|
||||
Icons.chat_bubble,
|
||||
size: 20,
|
||||
color: scheme.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
OutlinedButton(
|
||||
key: const ValueKey('strip-leave'),
|
||||
onPressed: onLeave,
|
||||
child: const Text('Leave'),
|
||||
),
|
||||
] else ...[
|
||||
if (!compact) ...[
|
||||
Container(
|
||||
width: 7,
|
||||
height: 7,
|
||||
decoration: BoxDecoration(color: scheme.outline, shape: BoxShape.circle),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text('Offline', style: TextStyle(color: scheme.onSurfaceVariant)),
|
||||
const SizedBox(width: 12),
|
||||
],
|
||||
if (compact) ...[
|
||||
FilledButton(key: const ValueKey('strip-join'), onPressed: onJoin, child: const Text('Join')),
|
||||
const SizedBox(width: 8),
|
||||
FilledButton.tonal(key: const ValueKey('strip-host'), onPressed: onHost, child: const Text('Host')),
|
||||
] else ...[
|
||||
FilledButton.icon(
|
||||
key: const ValueKey('strip-join'),
|
||||
onPressed: onJoin,
|
||||
icon: const Icon(Icons.login_rounded, size: 18),
|
||||
label: const Text('Join a friend'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
FilledButton.tonalIcon(
|
||||
key: const ValueKey('strip-host'),
|
||||
onPressed: onHost,
|
||||
icon: const Icon(Icons.podcasts_rounded, size: 18),
|
||||
label: const Text('Host'),
|
||||
),
|
||||
],
|
||||
],
|
||||
const SizedBox(width: 8),
|
||||
if (windowControls) ...[
|
||||
SizedBox(
|
||||
height: 20,
|
||||
child: VerticalDivider(width: 12, color: scheme.outlineVariant),
|
||||
),
|
||||
const WindowButtons(),
|
||||
] else
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
return DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: scheme.surfaceContainerLowest,
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: scheme.outlineVariant.withValues(alpha: 0.4),
|
||||
),
|
||||
),
|
||||
),
|
||||
child: SizedBox(
|
||||
height: partyStripHeight,
|
||||
child: Stack(
|
||||
children: [
|
||||
// Empty strip space drags the window; the controls on top take
|
||||
// their own clicks.
|
||||
if (windowControls)
|
||||
const Positioned.fill(
|
||||
child: DragToMoveArea(child: SizedBox.expand()),
|
||||
),
|
||||
Positioned.fill(child: row),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Server name and "ready n/m".
|
||||
class _ServerLabel extends StatelessWidget {
|
||||
final ColorScheme scheme;
|
||||
const _ServerLabel({required this.scheme});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => SignalBuilder(
|
||||
builder: (context) {
|
||||
final coord = SessionState.instance.coordinator;
|
||||
final r = coord.serverState.value;
|
||||
final members = r?.members ?? const <MemberView>[];
|
||||
final ready = members.where((m) => m.ready).length;
|
||||
return Column(
|
||||
key: const ValueKey('server-label'),
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
serverName(),
|
||||
style: const TextStyle(fontWeight: FontWeight.w600, height: 1.1),
|
||||
),
|
||||
Text(
|
||||
r == null ? 'connecting…' : 'ready $ready/${members.length}',
|
||||
style: TextStyle(
|
||||
color: scheme.onSurfaceVariant,
|
||||
fontSize: 12,
|
||||
height: 1.2,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// "Solo" alone, "Your server" when hosting, else the joined server's
|
||||
/// name (its `--name`), or its address while unknown.
|
||||
String serverName() {
|
||||
final session = SessionState.instance;
|
||||
if (session.solo.value) return 'Solo';
|
||||
if (session.coordinator.hostFile != null) return 'Your server';
|
||||
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.
|
||||
class StripTab extends StatelessWidget {
|
||||
final String label;
|
||||
final bool selected;
|
||||
final bool compact;
|
||||
final VoidCallback onTap;
|
||||
const StripTab({
|
||||
super.key,
|
||||
required this.label,
|
||||
required this.selected,
|
||||
required this.onTap,
|
||||
this.compact = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
return Semantics(
|
||||
selected: selected,
|
||||
button: true,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: compact ? 8 : 12),
|
||||
child: SizedBox(
|
||||
height: partyStripHeight,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontWeight: selected ? FontWeight.w700 : FontWeight.w500,
|
||||
color: selected ? scheme.primary : scheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
AnimatedContainer(
|
||||
duration: easeTime,
|
||||
curve: ease,
|
||||
height: 3,
|
||||
width: selected ? 22 : 0,
|
||||
decoration: BoxDecoration(
|
||||
color: scheme.primary,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Who is in the server, from the avatars: status, host / relay pills,
|
||||
/// per member force ready, nudge and sit out, and the address to invite
|
||||
/// friends.
|
||||
Future<void> showMembersCard(BuildContext context) => showPopover<void>(
|
||||
context,
|
||||
at: const Alignment(0.7, -0.84),
|
||||
child: const MembersCard(),
|
||||
);
|
||||
|
||||
/// "ip:port" for each of [addresses] friends could dial: IPv4, neither
|
||||
/// loopback nor link-local, private (LAN) ranges first, no repeats.
|
||||
List<String> inviteAddresses(Iterable<InternetAddress> addresses, String port) {
|
||||
bool private(List<int> b) =>
|
||||
b[0] == 10 || (b[0] == 192 && b[1] == 168) || (b[0] == 172 && b[1] >= 16 && b[1] < 32);
|
||||
final usable = [
|
||||
for (final a in addresses)
|
||||
if (a.type == InternetAddressType.IPv4 &&
|
||||
!a.isLoopback &&
|
||||
!a.isLinkLocal &&
|
||||
!(a.rawAddress[0] == 169 && a.rawAddress[1] == 254))
|
||||
a,
|
||||
];
|
||||
final ordered = [
|
||||
...usable.where((a) => private(a.rawAddress)),
|
||||
...usable.where((a) => !private(a.rawAddress)),
|
||||
];
|
||||
return {for (final a in ordered) '${a.address}:$port'}.toList();
|
||||
}
|
||||
|
||||
/// This machine's [inviteAddresses] (empty when the interfaces can't be
|
||||
/// listed).
|
||||
Future<List<String>> lanInviteAddresses(String port) async {
|
||||
try {
|
||||
final interfaces = await NetworkInterface.list(type: InternetAddressType.IPv4);
|
||||
return inviteAddresses([for (final i in interfaces) ...i.addresses], port);
|
||||
} catch (e) {
|
||||
debugPrint('list network interfaces failed: $e');
|
||||
return const [];
|
||||
}
|
||||
}
|
||||
|
||||
class MembersCard extends StatelessWidget {
|
||||
/// This machine's invite addresses for a port (tests pass fixed ones).
|
||||
final Future<List<String>> Function(String port) lanAddresses;
|
||||
const MembersCard({super.key, this.lanAddresses = lanInviteAddresses});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
return Material(
|
||||
key: const ValueKey('members-card'),
|
||||
elevation: 12,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
child: SizedBox(
|
||||
width: 340,
|
||||
child: PartyWatch(
|
||||
builder: (context) {
|
||||
final session = SessionState.instance;
|
||||
final coord = session.coordinator;
|
||||
final r = coord.serverState.value;
|
||||
final hosting = coord.hostFile != null;
|
||||
final address = hosting
|
||||
? null
|
||||
: '${session.lastForm.host}:${session.lastForm.port}';
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 16, 20, 8),
|
||||
child: Text(
|
||||
['In the server', ?address].join(' · '),
|
||||
style: const TextStyle(fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
for (final m in r?.members ?? const <MemberView>[])
|
||||
_MemberTile(
|
||||
member: m,
|
||||
info: memberAvatarInfo(m, r, coord),
|
||||
viaHost: viaHostOf(m, coord.currentRef.value),
|
||||
),
|
||||
if (r == null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Text(
|
||||
'connecting…',
|
||||
style: TextStyle(color: scheme.onSurfaceVariant),
|
||||
),
|
||||
),
|
||||
const Divider(),
|
||||
if (address != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 0, 12, 8),
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: TextButton.icon(
|
||||
key: const ValueKey('copy-invite'),
|
||||
onPressed: () =>
|
||||
Clipboard.setData(ClipboardData(text: address)),
|
||||
icon: const Icon(Icons.copy, size: 16),
|
||||
label: const Text('Copy invite address'),
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
_HostInvite(
|
||||
port: session.lastForm.hostPort,
|
||||
lanAddresses: lanAddresses,
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MemberTile extends StatelessWidget {
|
||||
final MemberView member;
|
||||
final AvatarInfo info;
|
||||
final bool viaHost;
|
||||
const _MemberTile({
|
||||
required this.member,
|
||||
required this.info,
|
||||
required this.viaHost,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final coord = SessionState.instance.coordinator;
|
||||
final m = member;
|
||||
final status = m.stale
|
||||
? 'reconnecting…'
|
||||
: m.out
|
||||
? 'sitting out'
|
||||
: m.error.isNotEmpty
|
||||
? "can't play this: ${m.error}"
|
||||
: info.buffering
|
||||
? 'buffering ${m.buffered.toStringAsFixed(1)} s'
|
||||
: m.ready
|
||||
? 'ready'
|
||||
: 'not ready';
|
||||
return ListTile(
|
||||
key: ValueKey('member-${m.id}'),
|
||||
dense: true,
|
||||
contentPadding: const EdgeInsets.only(left: 20, right: 8),
|
||||
leading: StatusAvatar(info: info, size: 36, badges: false),
|
||||
title: Wrap(
|
||||
spacing: 6,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
Text(info.self ? '${m.name} (you)' : m.name),
|
||||
if (info.host) const Pill('host', Colors.amber),
|
||||
if (info.relaying)
|
||||
const Pill('passing the video on', Colors.lightBlueAccent),
|
||||
if (viaHost) const ViaHostBadge(),
|
||||
],
|
||||
),
|
||||
subtitle: Text(status),
|
||||
trailing: PopupMenuButton<String>(
|
||||
tooltip: 'More',
|
||||
icon: const Icon(Icons.more_vert, size: 18),
|
||||
onSelected: (v) {
|
||||
switch (v) {
|
||||
case 'force':
|
||||
coord.forceTarget(m.id, !m.ready);
|
||||
case 'nudge':
|
||||
coord.nudgeMember(m.id);
|
||||
case 'out':
|
||||
coord.sitOut(m.id);
|
||||
case 'back':
|
||||
coord.joinBack();
|
||||
}
|
||||
},
|
||||
itemBuilder: (_) => [
|
||||
if (!m.out)
|
||||
PopupMenuItem(
|
||||
value: 'force',
|
||||
child: Text(m.ready ? 'Force unready' : 'Force ready'),
|
||||
),
|
||||
if (!info.self)
|
||||
const PopupMenuItem(value: 'nudge', child: Text('Nudge')),
|
||||
if (info.self && m.out)
|
||||
const PopupMenuItem(value: 'back', child: Text('Join back'))
|
||||
else if (!m.out)
|
||||
const PopupMenuItem(value: 'out', child: Text('Sit out')),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Hosting: the addresses friends on this network can join, the first
|
||||
/// one behind "Copy invite address", and what friends elsewhere need.
|
||||
class _HostInvite extends StatefulWidget {
|
||||
final String port;
|
||||
final Future<List<String>> Function(String port) lanAddresses;
|
||||
const _HostInvite({required this.port, required this.lanAddresses});
|
||||
|
||||
@override
|
||||
State<_HostInvite> createState() => _HostInviteState();
|
||||
}
|
||||
|
||||
class _HostInviteState extends State<_HostInvite> {
|
||||
late final Future<List<String>> _addresses = widget.lanAddresses(widget.port);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final muted = Theme.of(context).colorScheme.onSurfaceVariant;
|
||||
return FutureBuilder<List<String>>(
|
||||
future: _addresses,
|
||||
builder: (context, snap) {
|
||||
final list = snap.data ?? const <String>[];
|
||||
void copy(String a) => Clipboard.setData(ClipboardData(text: a));
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 0, 12, 12),
|
||||
child: Column(
|
||||
key: const ValueKey('host-invite'),
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
TextButton.icon(
|
||||
key: const ValueKey('copy-invite'),
|
||||
onPressed: list.isEmpty ? null : () => copy(list.first),
|
||||
icon: const Icon(Icons.copy, size: 16),
|
||||
label: const Text('Copy invite address'),
|
||||
),
|
||||
if (snap.connectionState != ConnectionState.done)
|
||||
const SizedBox.shrink()
|
||||
else if (list.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Text('No network address found', style: TextStyle(color: muted)),
|
||||
)
|
||||
else
|
||||
for (final a in list)
|
||||
InkWell(
|
||||
key: ValueKey('invite-$a'),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
onTap: () => copy(a),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(child: Text(a, style: const TextStyle(fontFamily: 'monospace'))),
|
||||
Icon(Icons.copy, size: 14, color: muted),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(8, 6, 8, 0),
|
||||
child: Text(
|
||||
'Works for friends on your network. Friends elsewhere need port '
|
||||
'${widget.port} forwarded on your router, or a server they can '
|
||||
'all reach (like a VPS).',
|
||||
style: TextStyle(color: muted, fontSize: 12),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,410 @@
|
||||
import 'dart:async';
|
||||
import 'dart:ui' show ImageFilter;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
|
||||
import '../session/health.dart';
|
||||
import '../session/session_coordinator.dart';
|
||||
import '../state/session_state.dart';
|
||||
import 'avatar.dart';
|
||||
import 'motion.dart';
|
||||
|
||||
/// What the player's status pill says, most pressing first.
|
||||
enum StatusKind { reconnecting, problem, connecting, finished, loading, you, cannotPlay, waiting }
|
||||
|
||||
/// The pill's button: stop reconnecting, retry (reconnect), reload the
|
||||
/// video (your player could not open it), or mark yourself ready (the
|
||||
/// whole pill).
|
||||
enum StatusAction { cancel, retry, reload, ready }
|
||||
|
||||
/// One status line: [text], a muted [detail], a coloured [tone] for
|
||||
/// problems (warn, error), the members it is about ([who]; [self] for you)
|
||||
/// and a loading [progress] (0..1).
|
||||
class StatusView {
|
||||
final StatusKind kind;
|
||||
final String text;
|
||||
final String? detail;
|
||||
final HealthSeverity? tone;
|
||||
final List<MemberView> who;
|
||||
final bool self;
|
||||
final double? progress;
|
||||
final StatusAction? action;
|
||||
/// The mini player's shorter line.
|
||||
final String short;
|
||||
const StatusView(
|
||||
this.kind,
|
||||
this.text, {
|
||||
this.detail,
|
||||
this.tone,
|
||||
this.who = const [],
|
||||
this.self = false,
|
||||
this.progress,
|
||||
this.action,
|
||||
String? short,
|
||||
}) : short = short ?? text;
|
||||
}
|
||||
|
||||
/// "Bob", "Bob and Mika", "Bob, Mika and Cleo", "Bob, Mika and 3 others".
|
||||
String nameList(List<String> names) {
|
||||
if (names.length <= 1) return names.join();
|
||||
if (names.length <= 3) return '${names.sublist(0, names.length - 1).join(', ')} and ${names.last}';
|
||||
return '${names[0]}, ${names[1]} and ${names.length - 2} others';
|
||||
}
|
||||
|
||||
String _sentence(String s) => s.isEmpty ? s : s[0].toUpperCase() + s.substring(1);
|
||||
|
||||
/// The player's status from the session's signals; null when there is
|
||||
/// nothing to say (playing, connected, everyone ready). [slowConnect]:
|
||||
/// connecting has taken over 2 s. [playerError]: why your player cannot
|
||||
/// open the media (the coordinator's `playerError`).
|
||||
StatusView? playerStatusFor({
|
||||
required HealthStatus health,
|
||||
bool reconnecting = false,
|
||||
StateBroadcast? state,
|
||||
MemberView? self,
|
||||
bool slowConnect = false,
|
||||
String playerError = '',
|
||||
}) {
|
||||
if (reconnecting) {
|
||||
return const StatusView(StatusKind.reconnecting, 'Reconnecting…',
|
||||
tone: HealthSeverity.warn, action: StatusAction.cancel);
|
||||
}
|
||||
if (playerError.isNotEmpty && state?.media != null && !(self?.out ?? false)) {
|
||||
return StatusView(StatusKind.problem, "Can't play this: $playerError",
|
||||
tone: HealthSeverity.error, action: StatusAction.reload, short: "Can't play this");
|
||||
}
|
||||
if (health.severity == HealthSeverity.warn || health.severity == HealthSeverity.error) {
|
||||
return StatusView(StatusKind.problem, _sentence(health.message),
|
||||
tone: health.severity, action: StatusAction.retry);
|
||||
}
|
||||
if (slowConnect && health.severity == HealthSeverity.info && health.message.startsWith('connecting')) {
|
||||
return const StatusView(StatusKind.connecting, 'Connecting…');
|
||||
}
|
||||
final r = state;
|
||||
if (r == null || r.media == null || r.transport == 'playing') return null;
|
||||
// Sitting out: the join-back card says it; nobody waits for you.
|
||||
if (self != null && self.out) return null;
|
||||
if (r.transport == 'finished') {
|
||||
return const StatusView(StatusKind.finished, 'Finished · seek back or pick the next video', short: 'Finished');
|
||||
}
|
||||
// Members whose player cannot open it: the server does not wait for
|
||||
// them; say why instead of "waiting for".
|
||||
final failed = [
|
||||
for (final m in r.members)
|
||||
if (m.error.isNotEmpty && !m.out && (self == null || m.id != self.id)) m,
|
||||
];
|
||||
// As the server holds playback: someone in the ready set short of the
|
||||
// minimum buffer.
|
||||
final need = r.bufferNeed;
|
||||
for (final m in r.members) {
|
||||
if (m.inSet && m.buffered < need) {
|
||||
final you = self != null && m.id == self.id;
|
||||
final p = (m.buffered / need).clamp(0.0, 1.0);
|
||||
return StatusView(StatusKind.loading, you ? 'Loading the video…' : '${m.name} is loading…',
|
||||
who: [m], self: you, progress: p, detail: '${(p * 100).round()}%');
|
||||
}
|
||||
}
|
||||
if (failed.isNotEmpty) {
|
||||
final f = failed.first;
|
||||
final who = nameList([for (final m in failed) m.name]);
|
||||
final unready = self != null && !self.ready;
|
||||
return StatusView(StatusKind.cannotPlay, "$who can't play this: ${f.error}",
|
||||
who: failed,
|
||||
short: "$who can't play this",
|
||||
detail: unready ? "Press Space when you're ready" : null,
|
||||
action: unready ? StatusAction.ready : null);
|
||||
}
|
||||
if (self != null && !self.ready) {
|
||||
return StatusView(StatusKind.you, "Press Space when you're ready",
|
||||
who: [self], self: true, action: StatusAction.ready, detail: 'or click here', short: 'Press Space when ready');
|
||||
}
|
||||
// Members sitting out are never waited for nor counted.
|
||||
final active = [for (final m in r.members) if (!m.stale && !m.out && m.error.isEmpty) m];
|
||||
final others = [
|
||||
for (final m in active)
|
||||
if ((self == null || m.id != self.id) && !(m.inSet && m.ready)) m,
|
||||
];
|
||||
if (others.isEmpty) return null;
|
||||
final ready = active.where((m) => m.ready).length;
|
||||
return StatusView(StatusKind.waiting, 'Waiting for ${nameList([for (final m in others) m.name])}',
|
||||
who: others, detail: '$ready/${active.length} ready');
|
||||
}
|
||||
|
||||
/// [playerStatusFor] from the session (tracked: call inside a builder).
|
||||
StatusView? playerStatusNow({bool slowConnect = false}) {
|
||||
final c = SessionState.instance.coordinator;
|
||||
return playerStatusFor(
|
||||
health: c.health.value,
|
||||
reconnecting: c.reconnecting.value,
|
||||
state: c.serverState.value,
|
||||
self: c.selfMember.value,
|
||||
slowConnect: slowConnect,
|
||||
playerError: c.playerError.value,
|
||||
);
|
||||
}
|
||||
|
||||
/// The player's one status pill (bottom centre): frosted dark glass with
|
||||
/// one short line, the members it is about and at most one button. Fades
|
||||
/// in and out; a different kind of status fades through, the pill resizing
|
||||
/// around it.
|
||||
class PlayerStatus extends StatefulWidget {
|
||||
const PlayerStatus({super.key});
|
||||
|
||||
static const connectGrace = Duration(seconds: 2);
|
||||
|
||||
@override
|
||||
State<PlayerStatus> createState() => _PlayerStatusState();
|
||||
}
|
||||
|
||||
class _PlayerStatusState extends State<PlayerStatus> {
|
||||
Timer? _connectTimer;
|
||||
bool _slow = false;
|
||||
StatusView? _last;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_connectTimer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
// "Connecting…" only once it takes a while.
|
||||
void _trackConnecting(HealthStatus h) {
|
||||
final connecting = h.severity == HealthSeverity.info && h.message.startsWith('connecting');
|
||||
if (!connecting) {
|
||||
_connectTimer?.cancel();
|
||||
_connectTimer = null;
|
||||
_slow = false;
|
||||
} else if (_connectTimer == null && !_slow) {
|
||||
_connectTimer = Timer(PlayerStatus.connectGrace, () {
|
||||
if (mounted) setState(() => _slow = true);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _act(SessionCoordinator coord, StatusAction a) {
|
||||
switch (a) {
|
||||
case StatusAction.cancel:
|
||||
coord.cancelReconnect();
|
||||
case StatusAction.retry:
|
||||
coord.retry();
|
||||
case StatusAction.reload:
|
||||
unawaited(coord.reloadVideo());
|
||||
case StatusAction.ready:
|
||||
coord.toggleReady();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return PartyWatch(builder: (context) {
|
||||
final coord = SessionState.instance.coordinator;
|
||||
_trackConnecting(coord.health.value);
|
||||
final view = playerStatusNow(slowConnect: _slow);
|
||||
if (view != null) _last = view;
|
||||
final shown = _last;
|
||||
final ready = view?.action == StatusAction.ready;
|
||||
return AnimatedOpacity(
|
||||
duration: easeTime,
|
||||
curve: ease,
|
||||
opacity: view == null ? 0 : 1,
|
||||
child: IgnorePointer(
|
||||
ignoring: view == null,
|
||||
child: shown == null
|
||||
? const SizedBox.shrink()
|
||||
: MouseRegion(
|
||||
cursor: ready ? SystemMouseCursors.click : MouseCursor.defer,
|
||||
child: GestureDetector(
|
||||
key: const ValueKey('player-status'),
|
||||
onTap: ready ? coord.toggleReady : null,
|
||||
child: PlayerGlass(
|
||||
child: AnimatedSize(
|
||||
duration: easeTime,
|
||||
curve: ease,
|
||||
child: fadeThrough(
|
||||
_StatusLine(
|
||||
key: ValueKey(shown.kind),
|
||||
view: shown,
|
||||
coord: coord,
|
||||
onAction: (a) => _act(coord, a),
|
||||
),
|
||||
expand: false,
|
||||
alignment: Alignment.center,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Frosted dark glass in a pill: the status pill, the unread chip.
|
||||
class PlayerGlass extends StatelessWidget {
|
||||
final Widget child;
|
||||
const PlayerGlass({super.key, required this.child});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final r = BorderRadius.circular(999);
|
||||
return ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 560),
|
||||
child: ClipRRect(
|
||||
borderRadius: r,
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: 12, sigmaY: 12),
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withValues(alpha: 0.55),
|
||||
borderRadius: r,
|
||||
border: Border.all(color: Colors.white12, width: 0.5),
|
||||
),
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StatusLine extends StatelessWidget {
|
||||
final StatusView view;
|
||||
final SessionCoordinator coord;
|
||||
final ValueChanged<StatusAction> onAction;
|
||||
const _StatusLine({super.key, required this.view, required this.coord, required this.onAction});
|
||||
|
||||
static const _avatar = 34.0;
|
||||
|
||||
Widget? _lead() {
|
||||
final v = view;
|
||||
switch (v.kind) {
|
||||
case StatusKind.problem:
|
||||
final error = v.tone == HealthSeverity.error;
|
||||
return Icon(error ? Icons.error_outline_rounded : Icons.warning_amber_rounded,
|
||||
size: 20, color: error ? Colors.redAccent.shade100 : Colors.amber);
|
||||
case StatusKind.reconnecting:
|
||||
return const _Spinner(color: Colors.amber);
|
||||
case StatusKind.connecting:
|
||||
return const _Spinner(color: Colors.white70);
|
||||
case StatusKind.finished:
|
||||
return null;
|
||||
case StatusKind.loading:
|
||||
case StatusKind.you:
|
||||
case StatusKind.cannotPlay:
|
||||
case StatusKind.waiting:
|
||||
final r = coord.serverState.value;
|
||||
final infos = [
|
||||
for (final m in v.who)
|
||||
v.self ? selfAvatarInfo(ready: m.ready, host: m.host) : memberAvatarInfo(m, r, coord),
|
||||
];
|
||||
if (infos.isEmpty) return null;
|
||||
final avatar = infos.length == 1
|
||||
? StatusAvatar(info: infos.first, size: _avatar, badges: false)
|
||||
: AvatarStack(members: infos, shown: 4, size: _avatar);
|
||||
if (v.progress == null) return avatar;
|
||||
// Loading: the progress runs round the avatar.
|
||||
return Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
avatar,
|
||||
SizedBox.square(
|
||||
dimension: _avatar + 6,
|
||||
child: CircularProgressIndicator(
|
||||
value: v.progress,
|
||||
strokeWidth: 2.5,
|
||||
color: Colors.white,
|
||||
backgroundColor: Colors.white24,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final v = view;
|
||||
final lead = _lead();
|
||||
final avatars = v.who.isNotEmpty;
|
||||
final action = v.action == StatusAction.ready ? null : v.action;
|
||||
return Padding(
|
||||
padding: EdgeInsets.fromLTRB(lead == null ? 18 : (avatars ? 5 : 14), 5, action == null ? 18 : 6, 5),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (lead != null) ...[
|
||||
SizedBox(height: _avatar + 6, child: Center(child: lead)),
|
||||
SizedBox(width: avatars ? 10 : 8),
|
||||
],
|
||||
Flexible(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(minHeight: _avatar),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
v.text,
|
||||
key: const ValueKey('player-status-text'),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.w600, height: 1.2),
|
||||
),
|
||||
if (v.detail != null)
|
||||
Text(
|
||||
v.detail!,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(color: Colors.white60, fontSize: 12, fontWeight: FontWeight.w500, height: 1.2),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (action != null) ...[
|
||||
const SizedBox(width: 12),
|
||||
_PillButton(
|
||||
label: action == StatusAction.cancel ? 'Cancel' : 'Retry',
|
||||
onPressed: () => onAction(action),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Spinner extends StatelessWidget {
|
||||
final Color color;
|
||||
const _Spinner({required this.color});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) =>
|
||||
SizedBox.square(dimension: 14, child: CircularProgressIndicator(strokeWidth: 2, color: color));
|
||||
}
|
||||
|
||||
class _PillButton extends StatelessWidget {
|
||||
final String label;
|
||||
final VoidCallback onPressed;
|
||||
const _PillButton({required this.label, required this.onPressed});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => TextButton(
|
||||
key: ValueKey('player-status-$label'),
|
||||
onPressed: onPressed,
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: Colors.white,
|
||||
backgroundColor: Colors.white12,
|
||||
shape: const StadiumBorder(),
|
||||
minimumSize: const Size(0, 26),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
visualDensity: VisualDensity.compact,
|
||||
textStyle: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600),
|
||||
),
|
||||
child: Text(label),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,562 @@
|
||||
import 'dart:io';
|
||||
import 'dart:math' as math;
|
||||
import 'dart:typed_data';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:signals/signals_flutter.dart';
|
||||
|
||||
import '../jellyfin/accounts.dart';
|
||||
import '../screens/home/profile.dart';
|
||||
import '../state/session_state.dart';
|
||||
import '../update/update_controller.dart';
|
||||
import 'avatar.dart';
|
||||
import 'motion.dart';
|
||||
import 'settings_tab.dart' show SettingsSection;
|
||||
|
||||
/// Your profile, from your avatar in the strip: name and picture apply
|
||||
/// live (pushed to the server while in one), colour is yours only; links
|
||||
/// to the app-wide pages.
|
||||
Future<void> showProfileCard(
|
||||
BuildContext context, {
|
||||
required ValueChanged<SettingsSection> onOpenSettings,
|
||||
UpdateController? updates,
|
||||
}) =>
|
||||
showPopover<void>(
|
||||
context,
|
||||
at: const Alignment(-0.97, -0.84),
|
||||
child: ProfileCard(onOpenSettings: onOpenSettings, updates: updates),
|
||||
);
|
||||
|
||||
class ProfileCard extends StatefulWidget {
|
||||
final ValueChanged<SettingsSection> onOpenSettings;
|
||||
final UpdateController? updates;
|
||||
const ProfileCard({super.key, required this.onOpenSettings, this.updates});
|
||||
|
||||
@override
|
||||
State<ProfileCard> createState() => _ProfileCardState();
|
||||
}
|
||||
|
||||
class _ProfileCardState extends State<ProfileCard> {
|
||||
final _p = Profile.instance;
|
||||
late final _name = TextEditingController(text: _p.name.peek());
|
||||
bool _hoverPic = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_name.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _open(SettingsSection s) {
|
||||
Navigator.of(context).maybePop();
|
||||
widget.onOpenSettings(s);
|
||||
}
|
||||
|
||||
Future<void> _changePicture() => showPopover<void>(
|
||||
context,
|
||||
at: Alignment.center,
|
||||
dim: true,
|
||||
child: CropPicker(onUse: _p.setPicture),
|
||||
);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final small = TextStyle(color: scheme.onSurfaceVariant, fontSize: 12);
|
||||
return Material(
|
||||
elevation: 12,
|
||||
color: scheme.surfaceContainer,
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: SizedBox(
|
||||
width: 360,
|
||||
child: SignalBuilder(builder: (context) {
|
||||
final session = SessionState.instance;
|
||||
final coord = session.coordinator;
|
||||
final online = session.route.value == AppRoute.session;
|
||||
final self = online ? coord.selfMember.value : null;
|
||||
final hosting = online && !session.solo.value && coord.hostFile != null;
|
||||
final color = _p.color.value;
|
||||
final png = _p.picture.value;
|
||||
final out = self?.out ?? false;
|
||||
final me = selfAvatarInfo(ready: self?.ready ?? false, host: hosting, out: out);
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Header tinted with your colour.
|
||||
AnimatedContainer(
|
||||
duration: easeTime,
|
||||
curve: ease,
|
||||
padding: const EdgeInsets.fromLTRB(20, 22, 20, 18),
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [color.withValues(alpha: 0.55), color.withValues(alpha: 0.08)],
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
onEnter: (_) => setState(() => _hoverPic = true),
|
||||
onExit: (_) => setState(() => _hoverPic = false),
|
||||
child: GestureDetector(
|
||||
key: const ValueKey('profile-picture'),
|
||||
onTap: _changePicture,
|
||||
child: SizedBox(
|
||||
width: 88,
|
||||
height: 88,
|
||||
child: Stack(
|
||||
children: [
|
||||
StatusAvatar(info: me, size: 88, badges: false, ring: online),
|
||||
Positioned.fill(
|
||||
child: AnimatedOpacity(
|
||||
duration: easeTime,
|
||||
curve: ease,
|
||||
opacity: _hoverPic ? 1 : 0,
|
||||
child: Container(
|
||||
margin: const EdgeInsets.all(4),
|
||||
decoration: const BoxDecoration(shape: BoxShape.circle, color: Colors.black54),
|
||||
child: const Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.photo_camera_outlined, color: Colors.white, size: 22),
|
||||
Text('Change', style: TextStyle(color: Colors.white, fontSize: 11)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
TextField(
|
||||
key: const ValueKey('profile-name'),
|
||||
controller: _name,
|
||||
maxLength: 32,
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w700),
|
||||
decoration: const InputDecoration(
|
||||
isDense: true,
|
||||
counterText: '',
|
||||
border: InputBorder.none,
|
||||
hintText: 'Your name',
|
||||
suffixIcon: Icon(Icons.edit_outlined, size: 16),
|
||||
suffixIconConstraints: BoxConstraints(minWidth: 20),
|
||||
),
|
||||
onChanged: _p.setName,
|
||||
onSubmitted: (_) => Navigator.of(context).maybePop(),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Wrap(
|
||||
spacing: 6,
|
||||
runSpacing: 4,
|
||||
children: [
|
||||
if (hosting) const Pill('host', Colors.amber),
|
||||
Pill(online ? 'in a server' : 'offline',
|
||||
online ? Colors.greenAccent : Colors.grey),
|
||||
if (online)
|
||||
Pill(out ? 'sitting out' : (self?.ready ?? false) ? 'ready' : 'not ready',
|
||||
Colors.lightBlueAccent),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 14, 20, 4),
|
||||
child: Row(
|
||||
children: [
|
||||
Text('Colour', style: small),
|
||||
const Spacer(),
|
||||
for (final c in profileColors)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 8),
|
||||
child: InkWell(
|
||||
customBorder: const CircleBorder(),
|
||||
onTap: () => _p.setColor(c),
|
||||
child: AnimatedContainer(
|
||||
duration: easeTime,
|
||||
curve: ease,
|
||||
width: 24,
|
||||
height: 24,
|
||||
decoration: BoxDecoration(
|
||||
color: c,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: color.toARGB32() == c.toARGB32() ? scheme.onSurface : Colors.transparent,
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (png != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 4, 8, 0),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.image_outlined, size: 16, color: scheme.onSurfaceVariant),
|
||||
const SizedBox(width: 6),
|
||||
Text('PNG · ${(png.length / 1024).toStringAsFixed(0)} KB', style: small),
|
||||
const Spacer(),
|
||||
TextButton(onPressed: () => _p.setPicture(null), child: const Text('Remove picture')),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (_p.error.value case final err?)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 4, 20, 0),
|
||||
child: Text(err, style: TextStyle(color: scheme.error, fontSize: 12)),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 6, 20, 0),
|
||||
child: Text(
|
||||
online
|
||||
? 'Name and picture update for everyone in the server right away. Colour is yours only.'
|
||||
: 'Others see your name and picture when you join. Colour is yours only.',
|
||||
style: small,
|
||||
),
|
||||
),
|
||||
const Padding(padding: EdgeInsets.fromLTRB(12, 12, 12, 0), child: Divider(height: 1)),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||
child: Column(children: [
|
||||
if (self != null)
|
||||
_link(
|
||||
out ? Icons.login_rounded : Icons.event_seat_outlined,
|
||||
out ? 'Join back' : 'Sit out',
|
||||
trailing: Text(out ? 'Space' : 'stay in the server', style: small),
|
||||
onTap: () {
|
||||
out ? coord.joinBack() : coord.sitOut();
|
||||
Navigator.of(context).maybePop();
|
||||
},
|
||||
),
|
||||
_link(
|
||||
Icons.extension_outlined,
|
||||
'Jellyfin & integrations',
|
||||
trailing: ListenableBuilder(
|
||||
listenable: JellyfinAccounts.instance,
|
||||
builder: (context, _) {
|
||||
final n = JellyfinAccounts.instance.accounts.length;
|
||||
return Text(n == 0 ? 'none' : '$n linked', style: small);
|
||||
},
|
||||
),
|
||||
onTap: () => _open(SettingsSection.integrations),
|
||||
),
|
||||
_link(Icons.settings_outlined, 'Settings', onTap: () => _open(SettingsSection.profile)),
|
||||
ValueListenableBuilder(
|
||||
valueListenable: (widget.updates ?? UpdateController.instance).status,
|
||||
builder: (context, s, _) {
|
||||
final m = s.manifest;
|
||||
final available = s.phase != UpdatePhase.idle && m != null;
|
||||
return _link(
|
||||
Icons.system_update_alt,
|
||||
available ? 'Update to ${m.version}' : 'Updates',
|
||||
trailing: available ? Pill('new', Colors.greenAccent.shade400) : null,
|
||||
onTap: () => _open(SettingsSection.updates),
|
||||
);
|
||||
},
|
||||
),
|
||||
_link(Icons.info_outline, 'About', onTap: () => _open(SettingsSection.about)),
|
||||
]),
|
||||
),
|
||||
],
|
||||
);
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _link(IconData icon, String label, {Widget? trailing, required VoidCallback onTap}) => ListTile(
|
||||
dense: true,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
leading: Icon(icon, size: 20),
|
||||
title: Text(label),
|
||||
trailing: trailing,
|
||||
onTap: onTap,
|
||||
);
|
||||
}
|
||||
|
||||
/// Avatar crop: pick a file, drag to move, scroll or slide to zoom; live
|
||||
/// previews at the sizes it is shown. "Use this" renders a 256 px PNG.
|
||||
class CropPicker extends StatefulWidget {
|
||||
final Future<void> Function(Uint8List png) onUse;
|
||||
|
||||
/// Opens the file dialog right away (that is why the picker opened).
|
||||
final bool autoPick;
|
||||
const CropPicker({super.key, required this.onUse, this.autoPick = true});
|
||||
|
||||
@override
|
||||
State<CropPicker> createState() => _CropPickerState();
|
||||
}
|
||||
|
||||
class _CropPickerState extends State<CropPicker> {
|
||||
static const _view = 300.0, _out = 256;
|
||||
ui.Image? _img;
|
||||
double _zoom = 1; // 1 = the image just covers the circle
|
||||
Offset _pan = Offset.zero; // in viewport pixels
|
||||
String? _error;
|
||||
bool _busy = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
if (widget.autoPick) WidgetsBinding.instance.addPostFrameCallback((_) => _pick());
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_img?.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _pick() async {
|
||||
final List<PlatformFile> files;
|
||||
try {
|
||||
files = await FilePicker.pickFiles(
|
||||
dialogTitle: 'Pick a picture',
|
||||
type: FileType.custom,
|
||||
allowedExtensions: const ['png', 'jpg', 'jpeg', 'webp', 'gif', 'bmp'],
|
||||
);
|
||||
} catch (e) {
|
||||
if (mounted) setState(() => _error = 'file dialog unavailable — try again');
|
||||
return;
|
||||
}
|
||||
final path = files.isEmpty ? null : files.first.path;
|
||||
if (path == null) return;
|
||||
try {
|
||||
final codec = await ui.instantiateImageCodec(await File(path).readAsBytes());
|
||||
final frame = await codec.getNextFrame();
|
||||
if (!mounted) return;
|
||||
_img?.dispose();
|
||||
setState(() {
|
||||
_img = frame.image;
|
||||
_zoom = 1;
|
||||
_pan = Offset.zero;
|
||||
_error = null;
|
||||
});
|
||||
} catch (e) {
|
||||
if (mounted) setState(() => _error = 'could not read that image');
|
||||
}
|
||||
}
|
||||
|
||||
double get _scale {
|
||||
final img = _img!;
|
||||
return math.max(_view / img.width, _view / img.height) * _zoom;
|
||||
}
|
||||
|
||||
/// Keeps the circle covered: the image edge never shows inside it.
|
||||
Offset _clamp(Offset p) {
|
||||
final img = _img!;
|
||||
final mx = (img.width * _scale - _view) / 2, my = (img.height * _scale - _view) / 2;
|
||||
return Offset(p.dx.clamp(-mx, mx), p.dy.clamp(-my, my));
|
||||
}
|
||||
|
||||
void _setZoom(double z) => setState(() {
|
||||
_zoom = z.clamp(1.0, 6.0);
|
||||
_pan = _clamp(_pan);
|
||||
});
|
||||
|
||||
Future<void> _use() async {
|
||||
final img = _img;
|
||||
if (img == null) return;
|
||||
setState(() => _busy = true);
|
||||
final rec = ui.PictureRecorder();
|
||||
final canvas = Canvas(rec);
|
||||
canvas.scale(_out / _view);
|
||||
CropPainter(img, _scale, _pan).paint(canvas, const Size(_view, _view));
|
||||
final out = await rec.endRecording().toImage(_out, _out);
|
||||
final png = await out.toByteData(format: ui.ImageByteFormat.png);
|
||||
out.dispose();
|
||||
if (!mounted || png == null) return;
|
||||
await widget.onUse(png.buffer.asUint8List());
|
||||
if (mounted) Navigator.of(context).maybePop();
|
||||
}
|
||||
|
||||
Widget _preview(double size) => ClipOval(
|
||||
child: SizedBox(
|
||||
width: size,
|
||||
height: size,
|
||||
child: CustomPaint(painter: CropPainter(_img!, _scale * size / _view, _pan * (size / _view))),
|
||||
),
|
||||
);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final img = _img;
|
||||
const caption = TextStyle(fontSize: 11, color: Colors.grey);
|
||||
final editor = img == null
|
||||
? InkWell(
|
||||
customBorder: const CircleBorder(),
|
||||
onTap: _pick,
|
||||
child: Container(
|
||||
width: _view,
|
||||
height: _view,
|
||||
decoration: BoxDecoration(shape: BoxShape.circle, color: scheme.surfaceContainerHighest),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.add_photo_alternate_outlined, size: 48, color: scheme.onSurfaceVariant),
|
||||
const SizedBox(height: 8),
|
||||
Text(_error ?? 'Choose a picture', style: TextStyle(color: scheme.onSurfaceVariant)),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
: Listener(
|
||||
onPointerSignal: (e) {
|
||||
if (e is PointerScrollEvent) _setZoom(_zoom * (e.scrollDelta.dy > 0 ? 0.93 : 1.07));
|
||||
},
|
||||
child: GestureDetector(
|
||||
onPanUpdate: (d) => setState(() => _pan = _clamp(_pan + d.delta)),
|
||||
child: MouseRegion(
|
||||
cursor: SystemMouseCursors.move,
|
||||
// The dimmed surroundings stay inside this rounded square.
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
child: SizedBox(
|
||||
width: _view,
|
||||
height: _view,
|
||||
child: Stack(
|
||||
children: [
|
||||
// The whole image, dimmed outside the circle.
|
||||
Positioned.fill(
|
||||
child: Opacity(
|
||||
opacity: 0.3,
|
||||
child: CustomPaint(painter: CropPainter(img, _scale, _pan, clipToView: false)),
|
||||
),
|
||||
),
|
||||
ClipOval(
|
||||
child: CustomPaint(size: const Size(_view, _view), painter: CropPainter(img, _scale, _pan)),
|
||||
),
|
||||
IgnorePointer(
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: scheme.primary, width: 2),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
return Material(
|
||||
elevation: 16,
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
child: SizedBox(
|
||||
width: 560,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text('Your picture', style: Theme.of(context).textTheme.titleMedium),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
img == null ? 'PNG, JPG, WebP, GIF or BMP' : 'Drag to move · scroll to zoom',
|
||||
style: const TextStyle(color: Colors.grey, fontSize: 12),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
editor,
|
||||
if (img != null) ...[
|
||||
const SizedBox(width: 24),
|
||||
Column(
|
||||
children: [
|
||||
_preview(88),
|
||||
const SizedBox(height: 4),
|
||||
const Text('profile', style: caption),
|
||||
const SizedBox(height: 14),
|
||||
_preview(42),
|
||||
const SizedBox(height: 4),
|
||||
const Text('party', style: caption),
|
||||
const SizedBox(height: 14),
|
||||
_preview(24),
|
||||
const SizedBox(height: 4),
|
||||
const Text('chat', style: caption),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
if (img != null)
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.zoom_out, size: 18),
|
||||
Expanded(child: Slider(value: _zoom, min: 1, max: 6, onChanged: _setZoom)),
|
||||
const Icon(Icons.zoom_in, size: 18),
|
||||
],
|
||||
)
|
||||
else
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
TextButton.icon(
|
||||
onPressed: _pick,
|
||||
icon: const Icon(Icons.folder_open, size: 18),
|
||||
label: Text(img == null ? 'Choose…' : 'Other file…'),
|
||||
),
|
||||
const Spacer(),
|
||||
TextButton(onPressed: () => Navigator.of(context).maybePop(), child: const Text('Cancel')),
|
||||
const SizedBox(width: 8),
|
||||
FilledButton(onPressed: img == null || _busy ? null : _use, child: const Text('Use this')),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Draws [img] scaled by [scale], centred in the box and moved by [pan].
|
||||
class CropPainter extends CustomPainter {
|
||||
final ui.Image img;
|
||||
final double scale;
|
||||
final Offset pan;
|
||||
final bool clipToView;
|
||||
CropPainter(this.img, this.scale, this.pan, {this.clipToView = true});
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
if (clipToView) canvas.clipRect(Offset.zero & size);
|
||||
final w = img.width * scale, h = img.height * scale;
|
||||
final dst = Rect.fromLTWH((size.width - w) / 2 + pan.dx, (size.height - h) / 2 + pan.dy, w, h);
|
||||
canvas.drawImageRect(
|
||||
img,
|
||||
Rect.fromLTWH(0, 0, img.width.toDouble(), img.height.toDouble()),
|
||||
dst,
|
||||
Paint()..filterQuality = FilterQuality.high,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(CropPainter old) => old.img != img || old.scale != scale || old.pan != pan;
|
||||
}
|
||||
@@ -0,0 +1,874 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:fox_utils/fox_utils.dart' show DesktopDropX, DropTarget;
|
||||
import 'package:signals/signals_flutter.dart';
|
||||
import 'package:sync_relay_core/core.dart'
|
||||
show CookieBrowser, CookieFile, NetscapeCookie, YtDlp, YtDlpCookies, cookieBrowsers, parseCookies;
|
||||
|
||||
import '../controls/player_settings.dart';
|
||||
import '../jellyfin/accounts.dart';
|
||||
import '../jellyfin/accounts_panel.dart';
|
||||
import '../playback/yt_dlp_app.dart';
|
||||
import '../schedule/animeflow.dart';
|
||||
import '../screens/home/about_page.dart';
|
||||
import '../screens/home/settings_page.dart';
|
||||
import '../sfx/sfx.dart';
|
||||
import '../sfx/sound_bank.dart';
|
||||
import '../sfx/synth_sfx.dart';
|
||||
import '../state/session_state.dart';
|
||||
import '../state/settings.dart';
|
||||
import '../update/update_controller.dart';
|
||||
import 'avatar.dart';
|
||||
import 'motion.dart';
|
||||
|
||||
/// The Settings tab's pages, in nav order.
|
||||
enum SettingsSection {
|
||||
profile(Icons.person_outline, 'Profile'),
|
||||
network(Icons.hub_outlined, 'Party & network'),
|
||||
player(Icons.play_circle_outline, 'Player'),
|
||||
integrations(Icons.extension_outlined, 'Jellyfin & integrations'),
|
||||
notifications(Icons.notifications_none, 'Notifications & sounds'),
|
||||
updates(Icons.system_update_alt, 'Updates'),
|
||||
shortcuts(Icons.keyboard_outlined, 'Keyboard shortcuts'),
|
||||
about(Icons.info_outline, 'About');
|
||||
|
||||
final IconData icon;
|
||||
final String label;
|
||||
const SettingsSection(this.icon, this.label);
|
||||
}
|
||||
|
||||
/// App-wide settings under a left nav (chips on top when narrow), each
|
||||
/// page a column of cards; the shell owns [section] so the profile card
|
||||
/// can open a page.
|
||||
class SettingsTab extends StatelessWidget {
|
||||
final SettingsSection section;
|
||||
final ValueChanged<SettingsSection> onSection;
|
||||
final VoidCallback? onEditProfile;
|
||||
final UpdateController? updates;
|
||||
/// Defaults to [JellyfinAccounts.instance].
|
||||
final JellyfinAccounts? jellyfin;
|
||||
/// AnimeFlow's `/me` for the integrations page (tests pass a fake).
|
||||
final Future<AnimeFlowMe> Function()? animeFlowMe;
|
||||
const SettingsTab({
|
||||
super.key,
|
||||
required this.section,
|
||||
required this.onSection,
|
||||
this.onEditProfile,
|
||||
this.updates,
|
||||
this.jellyfin,
|
||||
this.animeFlowMe,
|
||||
});
|
||||
|
||||
List<Widget> _page(BuildContext context) {
|
||||
switch (section) {
|
||||
case SettingsSection.profile:
|
||||
return [
|
||||
SettingsPanel(
|
||||
title: 'You',
|
||||
child: SignalBuilder(
|
||||
builder: (context) {
|
||||
final me = selfAvatarInfo();
|
||||
return ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: StatusAvatar(info: me, size: 48, badges: false, ring: false),
|
||||
title: Text(me.name == '?' ? 'No name yet' : me.name),
|
||||
subtitle: const Text('Name, picture and colour: click your avatar in the top left'),
|
||||
trailing: onEditProfile == null
|
||||
? null
|
||||
: TextButton(onPressed: onEditProfile, child: const Text('Edit')),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
];
|
||||
case SettingsSection.network:
|
||||
return [
|
||||
const HostDefaultsPanel(),
|
||||
const RecentServersPanel(),
|
||||
SettingsPanel(
|
||||
title: 'Watching',
|
||||
child: SignalBuilder(
|
||||
builder: (context) => SwitchListTile(
|
||||
key: const ValueKey('unready-on-swap'),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: const Text('Unready when the video changes'),
|
||||
subtitle: const Text('Otherwise you stay ready and the next video starts once everyone ready has buffered'),
|
||||
value: AppSettings.instance.unreadyOnSwap.value,
|
||||
onChanged: AppSettings.instance.setUnreadyOnSwap,
|
||||
),
|
||||
),
|
||||
),
|
||||
SettingsPanel(
|
||||
title: 'When idle',
|
||||
child: SignalBuilder(
|
||||
builder: (context) => SwitchListTile(
|
||||
key: const ValueKey('idle-sits-out'),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: const Text('When idle, sit out instead of just unready'),
|
||||
subtitle: const Text('After 10 min away while ready and held, your player unloads the video until you join back (Space)'),
|
||||
value: AppSettings.instance.idleSitsOut.value,
|
||||
onChanged: AppSettings.instance.setIdleSitsOut,
|
||||
),
|
||||
),
|
||||
),
|
||||
];
|
||||
case SettingsSection.player:
|
||||
return [
|
||||
const SettingsHintCard(
|
||||
icon: Icons.tune,
|
||||
text: "Player settings are also in the player's ⚙, at the right end of its controls.",
|
||||
),
|
||||
SettingsPanel(
|
||||
title: 'Player',
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: playerSettingRows()),
|
||||
),
|
||||
const SettingsPanel(title: 'Custom shaders', child: CustomShaderFiles()),
|
||||
const SettingsPanel(title: 'yt-dlp', child: YtDlpSettings()),
|
||||
];
|
||||
case SettingsSection.integrations:
|
||||
final accounts = jellyfin ?? JellyfinAccounts.instance;
|
||||
return [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 16),
|
||||
child: JellyfinAccountsPanel(accounts: accounts),
|
||||
),
|
||||
AnimeFlowCard(
|
||||
me: animeFlowMe ??
|
||||
() async {
|
||||
final client = AnimeFlowClient.fromAccounts(accounts);
|
||||
try {
|
||||
return await client.me();
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
},
|
||||
),
|
||||
const SettingsPanel(title: 'Jellyfin playback', child: AutoAdvanceSetting()),
|
||||
const SettingsPanel(title: 'Jellyfin library', child: HideReReleasesSetting()),
|
||||
];
|
||||
case SettingsSection.notifications:
|
||||
return const [NotificationsInfo()];
|
||||
case SettingsSection.updates:
|
||||
return [SettingsPanel(title: 'Updates', child: UpdateSettings(controller: updates))];
|
||||
case SettingsSection.shortcuts:
|
||||
return const [SettingsPanel(title: 'Keyboard shortcuts', child: ShortcutList())];
|
||||
case SettingsSection.about:
|
||||
return [AboutPanel(updates: updates)];
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
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 (final s in SettingsSection.values)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: ChoiceChip(
|
||||
key: ValueKey('settings-${s.name}'),
|
||||
avatar: Icon(s.icon, size: 18),
|
||||
showCheckmark: false,
|
||||
label: Text(s.label),
|
||||
selected: section == s,
|
||||
onSelected: (_) => onSection(s),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
: SizedBox(
|
||||
width: 250,
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(16, 20, 8, 20),
|
||||
children: [
|
||||
for (final s in SettingsSection.values)
|
||||
ListTile(
|
||||
key: ValueKey('settings-${s.name}'),
|
||||
selected: section == s,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
leading: Icon(s.icon, size: 20),
|
||||
title: Text(s.label),
|
||||
onTap: () => onSection(s),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
final page = fadeThrough(
|
||||
KeyedSubtree(
|
||||
key: ValueKey(section),
|
||||
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(section.label, style: Theme.of(context).textTheme.headlineSmall),
|
||||
const SizedBox(height: 14),
|
||||
..._page(context),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
return narrow
|
||||
? Column(children: [nav, Expanded(child: page)])
|
||||
: Row(children: [nav, Expanded(child: page)]);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Party & network: what the Host card starts with.
|
||||
class HostDefaultsPanel extends StatefulWidget {
|
||||
const HostDefaultsPanel({super.key});
|
||||
|
||||
@override
|
||||
State<HostDefaultsPanel> createState() => _HostDefaultsPanelState();
|
||||
}
|
||||
|
||||
class _HostDefaultsPanelState extends State<HostDefaultsPanel> {
|
||||
final _form = SessionState.instance.lastForm;
|
||||
late final _port = TextEditingController(text: _form.hostPort);
|
||||
late final _secret = TextEditingController(text: _form.hostSecret);
|
||||
bool _show = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_port.dispose();
|
||||
_secret.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
static String? portProblem(String v) {
|
||||
final n = int.tryParse(v.trim());
|
||||
return n == null || n < 1 || n > 65535 ? 'A number from 1 to 65535' : null;
|
||||
}
|
||||
|
||||
Future<void> _save() async {
|
||||
try {
|
||||
await SessionState.instance.saveForm();
|
||||
} catch (e) {
|
||||
debugPrint('save host defaults failed: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SettingsPanel(
|
||||
title: 'Hosting',
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
SettingRow(
|
||||
label: 'Default port',
|
||||
help: 'Where your server listens when you host; friends dial it',
|
||||
control: SizedBox(
|
||||
width: 120,
|
||||
child: TextField(
|
||||
key: const ValueKey('host-port'),
|
||||
controller: _port,
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: InputDecoration(isDense: true, errorText: portProblem(_port.text)),
|
||||
onChanged: (v) {
|
||||
setState(() {});
|
||||
if (portProblem(v) != null) return;
|
||||
_form.hostPort = v.trim();
|
||||
_save();
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
SettingRow(
|
||||
label: 'Password',
|
||||
help: 'Asked of everyone joining your server; blank lets in anyone who reaches the port',
|
||||
control: SizedBox(
|
||||
width: 220,
|
||||
child: TextField(
|
||||
key: const ValueKey('host-secret'),
|
||||
controller: _secret,
|
||||
obscureText: !_show,
|
||||
decoration: InputDecoration(
|
||||
isDense: true,
|
||||
hintText: 'none',
|
||||
suffixIcon: IconButton(
|
||||
tooltip: _show ? 'Hide' : 'Show',
|
||||
icon: Icon(_show ? Icons.visibility_off : Icons.visibility, size: 18),
|
||||
onPressed: () => setState(() => _show = !_show),
|
||||
),
|
||||
),
|
||||
onChanged: (v) {
|
||||
_form.hostSecret = v;
|
||||
_save();
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Party & network: the servers Join offers in one click.
|
||||
class RecentServersPanel extends StatelessWidget {
|
||||
const RecentServersPanel({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final session = SessionState.instance;
|
||||
final muted = Theme.of(context).colorScheme.onSurfaceVariant;
|
||||
return SettingsPanel(
|
||||
title: 'Recent servers',
|
||||
child: SignalBuilder(builder: (context) {
|
||||
final list = session.recents.value;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text('Join and Host offer these in one click; remove the ones you no longer use',
|
||||
style: TextStyle(color: muted, fontSize: 12)),
|
||||
if (list.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: Text('None yet.', style: TextStyle(color: muted)),
|
||||
),
|
||||
for (final r in list)
|
||||
ListTile(
|
||||
key: ValueKey('recent-${r.key}'),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: Icon(r.hosted ? Icons.cast_outlined : Icons.dns_outlined),
|
||||
title: Text(r.hosted ? 'Hosted on port ${r.port}' : '${r.host}:${r.port}'),
|
||||
subtitle: r.secret.isEmpty ? null : const Text('password saved'),
|
||||
trailing: IconButton(
|
||||
tooltip: 'Remove',
|
||||
icon: const Icon(Icons.close, size: 18),
|
||||
onPressed: () => session.forgetServer(r),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The app's yt-dlp (web page links): its version, "Update now", and the
|
||||
/// last error.
|
||||
class YtDlpSettings extends StatefulWidget {
|
||||
/// Defaults to [AppYtDlp.instance]'s.
|
||||
final Future<YtDlp>? ytDlp;
|
||||
const YtDlpSettings({super.key, this.ytDlp});
|
||||
|
||||
@override
|
||||
State<YtDlpSettings> createState() => _YtDlpSettingsState();
|
||||
}
|
||||
|
||||
class _YtDlpSettingsState extends State<YtDlpSettings> {
|
||||
late final Future<YtDlp> _y = widget.ytDlp ?? AppYtDlp.instance.ytDlp;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
return FutureBuilder<YtDlp>(
|
||||
future: _y,
|
||||
builder: (context, snap) {
|
||||
final y = snap.data;
|
||||
if (y == null) return const SizedBox.shrink();
|
||||
return StreamBuilder<void>(
|
||||
stream: y.changes,
|
||||
builder: (context, _) {
|
||||
final err = y.lastError;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: const Icon(Icons.travel_explore),
|
||||
title: Text(y.busy
|
||||
? 'Downloading…'
|
||||
: y.version == null
|
||||
? 'Not downloaded yet'
|
||||
: 'Version ${y.version}'),
|
||||
subtitle: err == null
|
||||
? null
|
||||
: Text(err, key: const ValueKey('ytdlp-error'), style: TextStyle(color: scheme.error)),
|
||||
trailing: TextButton(
|
||||
onPressed: y.busy
|
||||
? null
|
||||
: () => y.path == null ? y.ensure() : y.update(force: true),
|
||||
child: const Text('Update now'),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'Plays links to web pages (YouTube, X, …) in all three link modes. '
|
||||
'Downloaded and kept up to date by the app; checked once a day.',
|
||||
style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
YtDlpCookieSettings(cookies: y.cookies),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// yt-dlp's cookies (sites that refuse guests): from a browser, or a
|
||||
/// cookies.txt / JSON export picked or dropped, filtered by site into the
|
||||
/// private copy in the yt-dlp folder (see [YtDlpCookies]).
|
||||
class YtDlpCookieSettings extends StatefulWidget {
|
||||
final YtDlpCookies cookies;
|
||||
const YtDlpCookieSettings({super.key, required this.cookies});
|
||||
|
||||
@override
|
||||
State<YtDlpCookieSettings> createState() => _YtDlpCookieSettingsState();
|
||||
}
|
||||
|
||||
class _YtDlpCookieSettingsState extends State<YtDlpCookieSettings> {
|
||||
late var _source = widget.cookies.read();
|
||||
String? _error;
|
||||
bool _dragging = false;
|
||||
|
||||
void _reload([String? error]) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_source = widget.cookies.read();
|
||||
_error = error;
|
||||
});
|
||||
}
|
||||
|
||||
String get _summary => switch (_source) {
|
||||
CookieFile f => '${f.count} cookie${f.count == 1 ? '' : 's'} for '
|
||||
'${f.sites.length <= 3 ? f.sites.join(', ') : '${f.sites.length} sites'}',
|
||||
CookieBrowser b => 'From ${b.browser}${b.profile.isEmpty ? '' : ' (profile ${b.profile})'}',
|
||||
_ => 'No cookies: yt-dlp visits sites as a guest',
|
||||
};
|
||||
|
||||
Future<void> _pickFile() async {
|
||||
final List<PlatformFile> files;
|
||||
try {
|
||||
files = await FilePicker.pickFiles(
|
||||
dialogTitle: 'Pick a cookies.txt or a JSON cookie export',
|
||||
type: FileType.custom,
|
||||
allowedExtensions: const ['txt', 'json'],
|
||||
);
|
||||
} catch (_) {
|
||||
_reload('file dialog unavailable — try again');
|
||||
return;
|
||||
}
|
||||
final path = files.firstOrNull?.path;
|
||||
if (path != null) await _importFile(path);
|
||||
}
|
||||
|
||||
Future<void> _importFile(String path) async {
|
||||
final List<NetscapeCookie> all;
|
||||
try {
|
||||
all = parseCookies(await File(path).readAsString());
|
||||
} on FormatException catch (e) {
|
||||
_reload("Can't read that file: ${e.message}");
|
||||
return;
|
||||
} on FileSystemException catch (e) {
|
||||
_reload("Can't read that file: ${e.message}");
|
||||
return;
|
||||
}
|
||||
if (!mounted) return;
|
||||
// Every site in the file: an export is "my logins", not a pick list.
|
||||
try {
|
||||
await widget.cookies.saveFile(all);
|
||||
_reload();
|
||||
} catch (e) {
|
||||
_reload('Could not keep the cookies: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _pickBrowser() async {
|
||||
final current = _source;
|
||||
final picked = await showDialog<CookieBrowser>(
|
||||
context: context,
|
||||
builder: (_) => _CookieBrowserDialog(initial: current is CookieBrowser ? current : null),
|
||||
);
|
||||
if (picked == null) return;
|
||||
try {
|
||||
await widget.cookies.saveBrowser(picked.browser, picked.profile);
|
||||
_reload();
|
||||
} catch (e) {
|
||||
_reload('Could not save: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _remove() async {
|
||||
try {
|
||||
await widget.cookies.remove();
|
||||
_reload();
|
||||
} catch (e) {
|
||||
_reload('Could not remove the cookies: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
return DropTarget(
|
||||
onDragEntered: (_) => setState(() => _dragging = true),
|
||||
onDragExited: (_) => setState(() => _dragging = false),
|
||||
onDragDone: (d) {
|
||||
setState(() => _dragging = false);
|
||||
final path = DesktopDropX.pathsOf(d.files).firstOrNull;
|
||||
if (path != null) _importFile(path);
|
||||
},
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: _dragging ? scheme.primary : scheme.outlineVariant),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: const Icon(Icons.cookie_outlined),
|
||||
title: const Text('Cookies'),
|
||||
subtitle: Text(_summary, key: const ValueKey('ytdlp-cookies-summary')),
|
||||
),
|
||||
if (_error != null)
|
||||
Text(_error!, key: const ValueKey('ytdlp-cookies-error'), style: TextStyle(color: scheme.error)),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
OutlinedButton.icon(
|
||||
onPressed: _pickBrowser,
|
||||
icon: const Icon(Icons.public),
|
||||
label: const Text('From a browser…'),
|
||||
),
|
||||
OutlinedButton.icon(
|
||||
onPressed: _pickFile,
|
||||
icon: const Icon(Icons.file_open_outlined),
|
||||
label: const Text('From a file…'),
|
||||
),
|
||||
if (_source != null)
|
||||
TextButton(
|
||||
key: const ValueKey('ytdlp-cookies-remove'),
|
||||
onPressed: _remove,
|
||||
child: const Text('Remove cookies'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'For sites that refuse guests (Bilibili, YouTube asking to sign in). Drop a cookies.txt or a '
|
||||
'cookie-extension JSON export here, or pick one; all of it is kept, on this computer only. '
|
||||
'Cookies log yt-dlp in as you on those sites. Keep the file private; for YouTube consider a '
|
||||
'throwaway account (Google may flag accounts used with yt-dlp).',
|
||||
style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CookieBrowserDialog extends StatefulWidget {
|
||||
final CookieBrowser? initial;
|
||||
const _CookieBrowserDialog({this.initial});
|
||||
|
||||
@override
|
||||
State<_CookieBrowserDialog> createState() => _CookieBrowserDialogState();
|
||||
}
|
||||
|
||||
class _CookieBrowserDialogState extends State<_CookieBrowserDialog> {
|
||||
late var _browser = widget.initial?.browser ?? cookieBrowsers.first;
|
||||
late final _profile = TextEditingController(text: widget.initial?.profile ?? '');
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_profile.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: const Text('Cookies from a browser'),
|
||||
content: SizedBox(
|
||||
width: 380,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
DropdownButtonFormField<String>(
|
||||
initialValue: _browser,
|
||||
decoration: const InputDecoration(labelText: 'Browser'),
|
||||
items: [for (final b in cookieBrowsers) DropdownMenuItem(value: b, child: Text(b))],
|
||||
onChanged: (v) => setState(() => _browser = v ?? _browser),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: _profile,
|
||||
decoration: const InputDecoration(labelText: 'Profile (optional)', hintText: 'the default profile'),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'yt-dlp reads them each time it opens a link (the browser may need to be closed on Windows).',
|
||||
style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant, fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel')),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(context, CookieBrowser(_browser, _profile.text.trim())),
|
||||
child: const Text('Use'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// AnimeFlow (the Schedule tab's source): connected as whom, with which
|
||||
/// rights, or why not.
|
||||
class AnimeFlowCard extends StatefulWidget {
|
||||
final Future<AnimeFlowMe> Function() me;
|
||||
const AnimeFlowCard({super.key, required this.me});
|
||||
|
||||
@override
|
||||
State<AnimeFlowCard> createState() => _AnimeFlowCardState();
|
||||
}
|
||||
|
||||
class _AnimeFlowCardState extends State<AnimeFlowCard> {
|
||||
late Future<AnimeFlowMe> _me = widget.me();
|
||||
|
||||
static String scopeText(Set<AnimeFlowScope> scopes) => [
|
||||
if (scopes.contains(AnimeFlowScope.view)) 'can view',
|
||||
if (scopes.contains(AnimeFlowScope.request)) 'request',
|
||||
if (scopes.contains(AnimeFlowScope.manage)) 'approve',
|
||||
].join(', ');
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final muted = Theme.of(context).colorScheme.onSurfaceVariant;
|
||||
return SettingsPanel(
|
||||
title: 'AnimeFlow',
|
||||
child: FutureBuilder<AnimeFlowMe>(
|
||||
future: _me,
|
||||
builder: (context, snap) {
|
||||
final String status;
|
||||
final Color color;
|
||||
if (snap.connectionState != ConnectionState.done) {
|
||||
status = 'checking…';
|
||||
color = muted;
|
||||
} else if (snap.hasError) {
|
||||
final e = snap.error;
|
||||
status = e is AnimeFlowException ? e.detail : '$e';
|
||||
color = Theme.of(context).colorScheme.error;
|
||||
} else {
|
||||
final me = snap.data!;
|
||||
final scopes = scopeText(me.scopes);
|
||||
status = ['connected as ${me.name.isEmpty ? me.userId : me.name}', if (scopes.isNotEmpty) scopes]
|
||||
.join(' · ');
|
||||
color = Colors.greenAccent.shade400;
|
||||
}
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: const Icon(Icons.downloading_outlined),
|
||||
title: Text(Uri.parse(animeFlowDefaultBase).host),
|
||||
subtitle: Text(status, key: const ValueKey('animeflow-status'), style: TextStyle(color: color)),
|
||||
trailing: TextButton(
|
||||
onPressed: snap.connectionState == ConnectionState.done
|
||||
? () => setState(() => _me = widget.me())
|
||||
: null,
|
||||
child: const Text('Check again'),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'Powers the Schedule tab: what is downloaded, downloading or missing, and requests. '
|
||||
'Signs in with your Jellyfin account on the same domain.',
|
||||
style: TextStyle(color: muted, fontSize: 12),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// What pops up and what makes a sound. No switches: there is nothing to
|
||||
/// choose yet.
|
||||
class NotificationsInfo extends StatelessWidget {
|
||||
const NotificationsInfo({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final muted = TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant, fontSize: 12);
|
||||
Widget line(String title, String help) => Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [Text(title), Text(help, style: muted)],
|
||||
),
|
||||
);
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
SettingsPanel(
|
||||
title: 'Pop-ups',
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
line('Joins, leaves, video changes and chat',
|
||||
'Pop up bottom left for 5 s, with the person\'s picture'),
|
||||
line('Sat out or nudged by someone', 'Only on your end, with their picture'),
|
||||
line('Ready, seeks and pauses', 'Only in Chat & activity (Tab), never as a pop-up'),
|
||||
line('App notices', 'Shader changes, a frozen video reloaded, the idle warning'),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SettingsPanel(title: 'Sounds', child: SoundSettings()),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Volume and the sound set, then every sound with a preview button, its
|
||||
/// own set (or the overall one) and its own switch.
|
||||
class SoundSettings extends StatelessWidget {
|
||||
const SoundSettings({super.key});
|
||||
|
||||
void _preview(SfxKind kind) {
|
||||
final sfx = SessionState.instance.sfx;
|
||||
if (sfx is SynthSfx) unawaited(sfx.preview(kind, readyIndex: 2));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final s = AppSettings.instance;
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final muted = TextStyle(color: scheme.onSurfaceVariant, fontSize: 12);
|
||||
return SignalBuilder(builder: (context) {
|
||||
final volume = s.soundVolume.value;
|
||||
final off = s.soundsOff.value;
|
||||
final overall = s.soundPreset.value;
|
||||
final per = s.soundPresetFor.value;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(volume == 0 ? Icons.volume_off_outlined : Icons.volume_down_outlined,
|
||||
color: scheme.onSurfaceVariant),
|
||||
Expanded(
|
||||
child: Slider(
|
||||
key: const ValueKey('sound-volume'),
|
||||
value: volume,
|
||||
onChanged: s.setSoundVolume,
|
||||
onChangeEnd: (_) => _preview(SfxKind.ready),
|
||||
),
|
||||
),
|
||||
SizedBox(width: 44, child: Text('${(volume * 100).round()}%', textAlign: TextAlign.end)),
|
||||
],
|
||||
),
|
||||
Text('Soft cues for people and readiness; the video itself covers play, pause and the end. '
|
||||
'Letting go of the slider plays the ready sound.', style: muted),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
const Expanded(child: Text('Sound set')),
|
||||
DropdownButton<String>(
|
||||
key: const ValueKey('sound-preset'),
|
||||
value: overall,
|
||||
onChanged: (v) {
|
||||
if (v == null) return;
|
||||
s.setSoundPreset(v);
|
||||
_preview(SfxKind.ready);
|
||||
},
|
||||
items: [
|
||||
for (final MapEntry(key: p, value: (name, _)) in presetInfo.entries)
|
||||
DropdownMenuItem(value: p.name, child: Text(name)),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
Text(presetInfo.entries.where((e) => e.key.name == overall).firstOrNull?.value.$2 ?? '', style: muted),
|
||||
const SizedBox(height: 8),
|
||||
for (final MapEntry(key: kind, value: (name, help)) in soundInfo.entries)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||
child: Row(
|
||||
children: [
|
||||
IconButton(
|
||||
key: ValueKey('sound-play-${kind.name}'),
|
||||
tooltip: 'Play',
|
||||
onPressed: volume == 0 ? null : () => _preview(kind),
|
||||
icon: const Icon(Icons.play_arrow_rounded),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [Text(name), Text(help, style: muted)],
|
||||
),
|
||||
),
|
||||
DropdownButton<String?>(
|
||||
key: ValueKey('sound-preset-${kind.name}'),
|
||||
value: per[kind.name],
|
||||
underline: const SizedBox.shrink(),
|
||||
style: muted,
|
||||
onChanged: (v) {
|
||||
s.setSoundPresetFor(kind.name, v);
|
||||
if (volume > 0) _preview(kind);
|
||||
},
|
||||
items: [
|
||||
const DropdownMenuItem<String?>(value: null, child: Text('Same as set')),
|
||||
for (final MapEntry(key: p, value: (name, _)) in presetInfo.entries)
|
||||
DropdownMenuItem<String?>(value: p.name, child: Text(name)),
|
||||
],
|
||||
),
|
||||
// A nudge always plays (an explicit poke): no switch,
|
||||
// its place kept so the rows line up.
|
||||
Visibility.maintain(
|
||||
visible: kind != SfxKind.nudge,
|
||||
child: Switch(
|
||||
key: ValueKey('sound-on-${kind.name}'),
|
||||
value: !off.contains(kind.name),
|
||||
onChanged: volume == 0 || kind == SfxKind.nudge ? null : (on) => s.setSoundOn(kind.name, on),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:signals/signals_flutter.dart';
|
||||
|
||||
import '../playback/media_marks.dart' show formatClock;
|
||||
import '../state/session_state.dart';
|
||||
import '../sync/state_diff.dart' show serverPositionAt;
|
||||
import 'now_playing_title.dart';
|
||||
import 'start_banner.dart';
|
||||
|
||||
/// In place of the video while self sits out: the media's start card
|
||||
/// (cover, title, episode), where the others are ("12:34 / 24:00",
|
||||
/// playing or paused) and "Join back" (or Space). [compact]: the mini
|
||||
/// player.
|
||||
class SitOutCard extends StatefulWidget {
|
||||
final bool compact;
|
||||
const SitOutCard({super.key, this.compact = false});
|
||||
|
||||
@override
|
||||
State<SitOutCard> createState() => _SitOutCardState();
|
||||
}
|
||||
|
||||
class _SitOutCardState extends State<SitOutCard> {
|
||||
// The position moves on while playing: redraw each second.
|
||||
late final Timer _tick;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_tick = Timer.periodic(const Duration(seconds: 1), (_) {
|
||||
if (mounted) setState(() {});
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_tick.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final coord = SessionState.instance.coordinator;
|
||||
final small = widget.compact;
|
||||
return ListenableBuilder(
|
||||
listenable: artChanges(),
|
||||
builder: (context, _) => SignalBuilder(builder: (context) {
|
||||
final r = coord.serverState.value;
|
||||
final now = coord.nowPlaying.value;
|
||||
final playing = r?.transport == 'playing';
|
||||
final duration = (r?.media?['duration'] as num?)?.toDouble() ?? 0;
|
||||
final at = r == null ? 0.0 : serverPositionAt(r, coord.sync.serverNow());
|
||||
Duration secs(double s) => Duration(milliseconds: (s * 1000).round());
|
||||
final where = r?.media == null
|
||||
? 'Nothing is up'
|
||||
: duration > 0
|
||||
? '${formatClock(secs(at.clamp(0, duration)))} / ${formatClock(secs(duration))}'
|
||||
: formatClock(secs(at));
|
||||
final text = TextStyle(color: Colors.white, fontSize: small ? 12 : 15);
|
||||
final panel = Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text("You're sitting out", style: text.copyWith(fontWeight: FontWeight.w700, fontSize: small ? 13 : 20)),
|
||||
SizedBox(height: small ? 4 : 8),
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (r?.media != null) ...[
|
||||
Icon(playing ? Icons.play_arrow_rounded : Icons.pause_rounded,
|
||||
color: Colors.white70, size: small ? 14 : 18),
|
||||
const SizedBox(width: 4),
|
||||
],
|
||||
Text(where, key: const ValueKey('sit-out-position'), style: text.copyWith(color: Colors.white70)),
|
||||
],
|
||||
),
|
||||
SizedBox(height: small ? 8 : 16),
|
||||
FilledButton.icon(
|
||||
key: const ValueKey('join-back'),
|
||||
onPressed: coord.joinBack,
|
||||
icon: Icon(Icons.login_rounded, size: small ? 16 : 20),
|
||||
label: Text(small ? 'Join back' : 'Join back (Space)'),
|
||||
style: small
|
||||
? FilledButton.styleFrom(
|
||||
visualDensity: VisualDensity.compact,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
],
|
||||
);
|
||||
return Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
if (now != null) StartCard(now: now) else const ColoredBox(color: Color(0xFF0C0D11)),
|
||||
// Small: the card's title sits at the bottom, so the panel goes up top.
|
||||
Align(
|
||||
alignment: small ? const Alignment(0, -0.35) : Alignment.center,
|
||||
child: panel,
|
||||
),
|
||||
],
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
import 'dart:ui' show ImageFilter;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:signals/signals_flutter.dart';
|
||||
|
||||
import '../session/now_playing.dart';
|
||||
import '../state/session_state.dart';
|
||||
import 'motion.dart';
|
||||
import 'now_playing_title.dart';
|
||||
|
||||
/// Space kept free at the bottom of the start card for the controls and
|
||||
/// the status pill.
|
||||
const startCardBottom = 176.0;
|
||||
|
||||
/// The start card of [now]: its banner (cover-fit, darkened), else a soft
|
||||
/// blur of the cover, else dark; the cover, a big title and the episode
|
||||
/// line bottom left, above the controls.
|
||||
class StartCard extends StatelessWidget {
|
||||
final NowPlaying now;
|
||||
const StartCard({super.key, required this.now});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final banner = artImage(now.bannerHash);
|
||||
final cover = banner == null ? artImage(now.posterHash) : null;
|
||||
final episode = now.episode;
|
||||
return LayoutBuilder(builder: (context, box) {
|
||||
final h = box.maxHeight;
|
||||
// Small (the mini player): no cover, a smaller title near the bottom.
|
||||
final small = h < 320;
|
||||
final coverWidth = (h * 0.26).clamp(84.0, 240.0) / 1.5;
|
||||
final titleSize = small ? (h * 0.08).clamp(13.0, 18.0) : (h * 0.05).clamp(22.0, 44.0);
|
||||
final side = small ? 12.0 : (box.maxWidth * 0.05).clamp(20.0, 72.0);
|
||||
return Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
const ColoredBox(color: Color(0xFF0C0D11)),
|
||||
if (banner != null)
|
||||
fadeInImage(banner)
|
||||
else if (cover != null)
|
||||
Opacity(
|
||||
opacity: 0.5,
|
||||
child: ImageFiltered(
|
||||
imageFilter: ImageFilter.blur(sigmaX: 48, sigmaY: 48, tileMode: TileMode.decal),
|
||||
child: Transform.scale(scale: 1.3, child: fadeInImage(cover)),
|
||||
),
|
||||
)
|
||||
else
|
||||
const DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
gradient: RadialGradient(
|
||||
center: Alignment(-0.7, 0.5),
|
||||
radius: 1.2,
|
||||
colors: [Color(0xFF23262E), Color(0xFF0C0D11)],
|
||||
),
|
||||
),
|
||||
),
|
||||
const DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
stops: [0, 0.35, 1],
|
||||
colors: [Color(0x55000000), Color(0x22000000), Color(0xE6000000)],
|
||||
),
|
||||
),
|
||||
),
|
||||
const DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.centerLeft,
|
||||
end: Alignment.centerRight,
|
||||
stops: [0, 0.65],
|
||||
colors: [Color(0x99000000), Color(0x00000000)],
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
left: side,
|
||||
right: side,
|
||||
bottom: small ? 12 : startCardBottom,
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
if (!small) ...[
|
||||
ArtCover(hash: now.posterHash, width: coverWidth),
|
||||
const SizedBox(width: 24),
|
||||
],
|
||||
Expanded(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
now.title,
|
||||
key: const ValueKey('start-card-title'),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: titleSize,
|
||||
fontWeight: FontWeight.w800,
|
||||
height: 1.1,
|
||||
shadows: const [Shadow(color: Color(0x99000000), blurRadius: 16)],
|
||||
),
|
||||
),
|
||||
if (episode != null && episode.isNotEmpty) ...[
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
episode,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(color: Colors.white70, fontSize: titleSize * 0.42),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// [StartCard] over the picture until playback starts on this media
|
||||
/// (the server plays, or its position passed 1 s); then it fades out and
|
||||
/// stays gone for that media, whatever happens later. Never takes input.
|
||||
class StartBanner extends StatefulWidget {
|
||||
const StartBanner({super.key});
|
||||
|
||||
@override
|
||||
State<StartBanner> createState() => _StartBannerState();
|
||||
}
|
||||
|
||||
class _StartBannerState extends State<StartBanner> {
|
||||
int? _mediaId;
|
||||
bool _started = false;
|
||||
// Faded out: the card leaves the tree (and its images with it).
|
||||
bool _gone = true;
|
||||
NowPlaying? _last;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final coord = SessionState.instance.coordinator;
|
||||
return IgnorePointer(
|
||||
child: ListenableBuilder(
|
||||
listenable: artChanges(),
|
||||
builder: (context, _) => SignalBuilder(builder: (context) {
|
||||
final r = coord.serverState.value;
|
||||
final now = coord.nowPlaying.value;
|
||||
if (r != null && r.mediaId != _mediaId) {
|
||||
_mediaId = r.mediaId;
|
||||
_started = false;
|
||||
}
|
||||
if (r != null && (r.transport == 'playing' || r.position >= 1)) _started = true;
|
||||
final show = now != null && r?.media != null && !_started;
|
||||
if (show) {
|
||||
_last = now;
|
||||
_gone = false;
|
||||
}
|
||||
final last = _last;
|
||||
if (last == null || _gone) return const SizedBox.shrink();
|
||||
return AnimatedOpacity(
|
||||
key: const ValueKey('start-banner'),
|
||||
opacity: show ? 1 : 0,
|
||||
duration: easeTime,
|
||||
curve: ease,
|
||||
onEnd: () {
|
||||
if (!show && mounted) setState(() => _gone = true);
|
||||
},
|
||||
child: StartCard(now: last),
|
||||
);
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:signals/signals_flutter.dart';
|
||||
|
||||
import '../session/session_coordinator.dart' show ToastItem;
|
||||
import '../state/session_state.dart';
|
||||
import 'avatar.dart';
|
||||
import 'motion.dart';
|
||||
|
||||
/// One toast on screen: who (null = the app itself), what, and whether it
|
||||
/// is on its way out.
|
||||
class ShellToast {
|
||||
final int id;
|
||||
final AvatarInfo? who;
|
||||
final IconData? icon;
|
||||
final String text;
|
||||
final bool chat;
|
||||
bool leaving = false;
|
||||
ShellToast({required this.id, required this.text, this.who, this.icon, this.chat = false});
|
||||
}
|
||||
|
||||
/// Splits a coordinator toast into who and what: feed and chat toasts
|
||||
/// name their [ToastItem.actor] and start with it ("Bob joined", "Bob:
|
||||
/// hi"); the rest are the app's.
|
||||
ShellToast shellToastFor(ToastItem t, AvatarInfo Function(String name) avatar) {
|
||||
final n = t.actor;
|
||||
if (n == null || n.isEmpty) return ShellToast(id: t.id, text: t.text, icon: Icons.info_outline);
|
||||
if (t.text.startsWith('$n: ')) {
|
||||
return ShellToast(id: t.id, who: avatar(n), text: t.text.substring(n.length + 2), chat: true);
|
||||
}
|
||||
final rest = t.text.startsWith('$n ') ? t.text.substring(n.length + 1) : t.text;
|
||||
final icon = rest.startsWith('joined')
|
||||
? Icons.login
|
||||
: rest.startsWith('left')
|
||||
? Icons.logout
|
||||
: rest.startsWith('sat you out')
|
||||
? Icons.bedtime
|
||||
: rest.startsWith('nudged')
|
||||
? Icons.notifications_active_outlined
|
||||
: rest.startsWith('changed')
|
||||
? Icons.swap_horiz
|
||||
: Icons.info_outline;
|
||||
return ShellToast(id: t.id, who: avatar(n), text: rest, icon: icon);
|
||||
}
|
||||
|
||||
/// The coordinator's toasts as cards with avatars, bottom-left: each
|
||||
/// slides in, stays [life], then slides out and collapses its gap. A click
|
||||
/// on a chat toast opens the chat ([onOpenChat]); the rest let clicks
|
||||
/// through.
|
||||
class PartyToasts extends StatefulWidget {
|
||||
final Duration life;
|
||||
final VoidCallback? onOpenChat;
|
||||
const PartyToasts({super.key, this.life = const Duration(seconds: 5), this.onOpenChat});
|
||||
|
||||
@override
|
||||
State<PartyToasts> createState() => _PartyToastsState();
|
||||
}
|
||||
|
||||
class _PartyToastsState extends State<PartyToasts> {
|
||||
final _shown = <ShellToast>[];
|
||||
final _timers = <Timer>[];
|
||||
Set<int> _seen = {};
|
||||
late final void Function() _stop;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final coord = SessionState.instance.coordinator;
|
||||
// What was there before this mounted is old news.
|
||||
_seen = {for (final t in coord.toasts.peek()) t.id};
|
||||
_stop = effect(() {
|
||||
final items = coord.toasts.value;
|
||||
final fresh = [for (final t in items) if (!_seen.contains(t.id)) t];
|
||||
_seen = {for (final t in items) t.id};
|
||||
if (fresh.isEmpty) return;
|
||||
// Only new toasts re-run this, not the party they mention.
|
||||
untracked(() {
|
||||
for (final t in fresh) {
|
||||
_add(shellToastFor(t, (n) => avatarForName(n, coord)));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
void _add(ShellToast t) {
|
||||
// Not from inside the effect's build-time notification.
|
||||
scheduleMicrotask(() {
|
||||
if (!mounted) return;
|
||||
setState(() => _shown.add(t));
|
||||
_timers.add(Timer(widget.life, () {
|
||||
if (mounted) setState(() => t.leaving = true);
|
||||
}));
|
||||
_timers.add(Timer(widget.life + easeTime, () {
|
||||
if (mounted) setState(() => _shown.remove(t));
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_stop();
|
||||
for (final t in _timers) {
|
||||
t.cancel();
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _open(ShellToast t) {
|
||||
widget.onOpenChat?.call();
|
||||
setState(() => t.leaving = true);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => ToastColumn(
|
||||
toasts: _shown.reversed.take(6).toList().reversed.toList(),
|
||||
onTap: widget.onOpenChat == null ? null : _open,
|
||||
);
|
||||
}
|
||||
|
||||
class ToastColumn extends StatelessWidget {
|
||||
final List<ShellToast> toasts;
|
||||
/// Clicks on chat toasts; null (or any other toast): clicks pass through.
|
||||
final ValueChanged<ShellToast>? onTap;
|
||||
const ToastColumn({super.key, required this.toasts, this.onTap});
|
||||
|
||||
Widget _card(ShellToast t) {
|
||||
final card = ToastCard(toast: t);
|
||||
final tap = onTap;
|
||||
if (!t.chat || tap == null || t.leaving) return IgnorePointer(child: card);
|
||||
return MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: GestureDetector(
|
||||
key: ValueKey('toast-open-chat-${t.id}'),
|
||||
onTap: () => tap(t),
|
||||
child: card,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
for (final t in toasts)
|
||||
TweenAnimationBuilder<double>(
|
||||
key: ValueKey(t.id),
|
||||
tween: Tween(begin: 0, end: 1),
|
||||
duration: easeTime,
|
||||
curve: ease,
|
||||
builder: (context, v, child) => Opacity(
|
||||
opacity: v,
|
||||
child: Transform.translate(offset: Offset(-24 * (1 - v), 0), child: child),
|
||||
),
|
||||
// Leaving: slide back out, fade, and collapse the gap it leaves.
|
||||
child: AnimatedAlign(
|
||||
duration: easeTime,
|
||||
curve: ease,
|
||||
alignment: Alignment.topLeft,
|
||||
heightFactor: t.leaving ? 0 : 1,
|
||||
child: AnimatedSlide(
|
||||
duration: easeTime,
|
||||
curve: ease,
|
||||
offset: t.leaving ? const Offset(-0.25, 0) : Offset.zero,
|
||||
child: AnimatedOpacity(
|
||||
duration: easeTime,
|
||||
curve: ease,
|
||||
opacity: t.leaving ? 0 : 1,
|
||||
child: Padding(padding: const EdgeInsets.only(top: 8), child: _card(t)),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ToastCard extends StatelessWidget {
|
||||
final ShellToast toast;
|
||||
const ToastCard({super.key, required this.toast});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final who = toast.who;
|
||||
return Material(
|
||||
elevation: 8,
|
||||
color: scheme.surfaceContainerHigh.withValues(alpha: 0.95),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 380),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(10, 10, 16, 10),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: who == null ? CrossAxisAlignment.center : CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (who != null)
|
||||
StatusAvatar(info: who, size: 38, badges: false)
|
||||
else
|
||||
CircleAvatar(
|
||||
radius: 19,
|
||||
backgroundColor: scheme.primary.withValues(alpha: 0.15),
|
||||
child: Icon(toast.icon ?? Icons.info_outline, size: 18, color: scheme.primary),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Flexible(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (who != null) ...[
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(who.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(fontWeight: FontWeight.w700, fontSize: 15)),
|
||||
),
|
||||
if (toast.icon != null) ...[
|
||||
const SizedBox(width: 6),
|
||||
Icon(toast.icon, size: 16, color: scheme.primary),
|
||||
],
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
],
|
||||
Text(
|
||||
toast.text,
|
||||
style: TextStyle(
|
||||
fontSize: toast.chat ? 15 : 14,
|
||||
color: toast.chat || who == null ? scheme.onSurface : scheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../jellyfin/accounts.dart';
|
||||
import '../jellyfin/sign_in_panel.dart';
|
||||
import '../screens/home/ui.dart' show appName;
|
||||
import 'motion.dart';
|
||||
|
||||
/// One line under the app name.
|
||||
const appTagline = 'Watch together, in sync';
|
||||
|
||||
/// Window width from which the welcome band puts the three ways beside
|
||||
/// the app name (else under it).
|
||||
const welcomeWideFrom = 980.0;
|
||||
|
||||
/// Top of the Library while not in a server: the app's name and the three
|
||||
/// ways in, Join a friend first (what most people came for), then Host and
|
||||
/// Watch solo. None of them needs Jellyfin.
|
||||
class WelcomeBand extends StatelessWidget {
|
||||
final VoidCallback onJoin;
|
||||
final VoidCallback onHost;
|
||||
final VoidCallback onSolo;
|
||||
final VoidCallback? onDismiss;
|
||||
const WelcomeBand({super.key, required this.onJoin, required this.onHost, required this.onSolo, this.onDismiss});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final t = Theme.of(context).textTheme;
|
||||
final hero = Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
child: Image.asset('assets/icon/icon_256.png', width: 52, height: 52, filterQuality: FilterQuality.medium),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(appName, style: t.titleLarge?.copyWith(fontWeight: FontWeight.w700, height: 1.1)),
|
||||
const SizedBox(height: 2),
|
||||
Text(appTagline, style: TextStyle(color: scheme.onSurfaceVariant)),
|
||||
if (onDismiss != null)
|
||||
TextButton(
|
||||
key: const ValueKey('welcome-dismiss'),
|
||||
onPressed: onDismiss,
|
||||
style: TextButton.styleFrom(
|
||||
padding: EdgeInsets.zero,
|
||||
minimumSize: const Size(0, 24),
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
foregroundColor: scheme.onSurfaceVariant,
|
||||
textStyle: const TextStyle(fontSize: 12),
|
||||
),
|
||||
child: const Text("Don't show again"),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
final ways = [
|
||||
WayCard(
|
||||
key: const ValueKey('way-join'),
|
||||
icon: Icons.login_rounded,
|
||||
title: 'Join a friend',
|
||||
caption: 'Got an address from a friend? Join with it. Nothing to set up.',
|
||||
primary: true,
|
||||
onTap: onJoin,
|
||||
),
|
||||
WayCard(
|
||||
key: const ValueKey('way-host'),
|
||||
icon: Icons.podcasts_rounded,
|
||||
title: 'Host',
|
||||
caption: 'You have the video. Friends join you and watch it with you.',
|
||||
onTap: onHost,
|
||||
),
|
||||
WayCard(
|
||||
key: const ValueKey('way-solo'),
|
||||
icon: Icons.person_outline_rounded,
|
||||
title: 'Watch solo',
|
||||
caption: 'Just you: play anything below. Invite friends any time.',
|
||||
onTap: onSolo,
|
||||
),
|
||||
];
|
||||
Widget row(List<Widget> cards) => IntrinsicHeight(
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
for (final (i, c) in cards.indexed) ...[if (i > 0) const SizedBox(width: 10), Expanded(child: c)],
|
||||
],
|
||||
),
|
||||
);
|
||||
final band = Container(
|
||||
key: const ValueKey('welcome-band'),
|
||||
margin: const EdgeInsets.fromLTRB(20, 16, 20, 0),
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [scheme.primary.withValues(alpha: 0.16), scheme.primary.withValues(alpha: 0.03)],
|
||||
),
|
||||
border: Border.all(color: scheme.outlineVariant.withValues(alpha: 0.4)),
|
||||
),
|
||||
child: LayoutBuilder(builder: (context, box) {
|
||||
if (box.maxWidth >= welcomeWideFrom - 40) {
|
||||
return Row(
|
||||
children: [
|
||||
Padding(padding: const EdgeInsets.only(left: 4, right: 20), child: hero),
|
||||
Expanded(child: row(ways)),
|
||||
],
|
||||
);
|
||||
}
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(padding: const EdgeInsets.only(left: 4, bottom: 12), child: hero),
|
||||
if (box.maxWidth >= 520)
|
||||
row(ways)
|
||||
else
|
||||
for (final (i, c) in ways.indexed) ...[if (i > 0) const SizedBox(height: 8), c],
|
||||
],
|
||||
);
|
||||
}),
|
||||
);
|
||||
return band;
|
||||
}
|
||||
}
|
||||
|
||||
/// One way in: a tinted icon, a title and a one-line caption.
|
||||
class WayCard extends StatefulWidget {
|
||||
final IconData icon;
|
||||
final String title;
|
||||
final String caption;
|
||||
final bool primary;
|
||||
final VoidCallback onTap;
|
||||
const WayCard({
|
||||
super.key,
|
||||
required this.icon,
|
||||
required this.title,
|
||||
required this.caption,
|
||||
required this.onTap,
|
||||
this.primary = false,
|
||||
});
|
||||
|
||||
@override
|
||||
State<WayCard> createState() => _WayCardState();
|
||||
}
|
||||
|
||||
class _WayCardState extends State<WayCard> {
|
||||
bool _hover = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final primary = widget.primary;
|
||||
final bg = primary ? scheme.primaryContainer : scheme.surfaceContainerHigh;
|
||||
final fg = primary ? scheme.onPrimaryContainer : scheme.onSurface;
|
||||
return MouseRegion(
|
||||
onEnter: (_) => setState(() => _hover = true),
|
||||
onExit: (_) => setState(() => _hover = false),
|
||||
child: AnimatedScale(
|
||||
duration: easeTime,
|
||||
curve: ease,
|
||||
scale: _hover ? 1.015 : 1,
|
||||
child: Material(
|
||||
color: bg,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: InkWell(
|
||||
onTap: widget.onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 12, 14, 12),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
color: primary ? scheme.primary : scheme.primary.withValues(alpha: 0.14),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Icon(widget.icon, size: 20, color: primary ? scheme.onPrimary : scheme.primary),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(widget.title, style: TextStyle(fontWeight: FontWeight.w700, color: fg)),
|
||||
const SizedBox(height: 2),
|
||||
Text(widget.caption,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(fontSize: 12, height: 1.3, color: fg.withValues(alpha: 0.72))),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The Library's Jellyfin source with no server linked: what plays with
|
||||
/// nothing set up (Files, Link), then Jellyfin as an optional extra that
|
||||
/// guests never need. The sign-in opens in place.
|
||||
class NoJellyfinPanel extends StatefulWidget {
|
||||
final JellyfinAccounts accounts;
|
||||
final VoidCallback onFiles;
|
||||
final VoidCallback onLink;
|
||||
const NoJellyfinPanel({super.key, required this.accounts, required this.onFiles, required this.onLink});
|
||||
|
||||
@override
|
||||
State<NoJellyfinPanel> createState() => _NoJellyfinPanelState();
|
||||
}
|
||||
|
||||
class _NoJellyfinPanelState extends State<NoJellyfinPanel> {
|
||||
bool _signIn = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final t = Theme.of(context).textTheme;
|
||||
final muted = TextStyle(color: scheme.onSurfaceVariant);
|
||||
final sources = [
|
||||
WayCard(
|
||||
key: const ValueKey('source-files'),
|
||||
icon: Icons.folder_outlined,
|
||||
title: 'Files',
|
||||
caption: 'Videos on this computer. Drop a file or folder anywhere.',
|
||||
onTap: widget.onFiles,
|
||||
),
|
||||
WayCard(
|
||||
key: const ValueKey('source-link'),
|
||||
icon: Icons.link,
|
||||
title: 'Link',
|
||||
caption: 'YouTube, X, or any video address. Ctrl+V anywhere.',
|
||||
onTap: widget.onLink,
|
||||
),
|
||||
];
|
||||
return SingleChildScrollView(
|
||||
key: const ValueKey('media-jellyfin'),
|
||||
padding: const EdgeInsets.only(bottom: 24),
|
||||
child: Align(
|
||||
alignment: Alignment.topLeft,
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 640),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text('Pick something to watch', style: t.titleMedium),
|
||||
const SizedBox(height: 4),
|
||||
Text('No account needed: these work for everyone.', style: muted),
|
||||
const SizedBox(height: 12),
|
||||
LayoutBuilder(
|
||||
builder: (context, box) => box.maxWidth < 460
|
||||
? Column(children: [sources[0], const SizedBox(height: 8), sources[1]])
|
||||
: IntrinsicHeight(
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Expanded(child: sources[0]),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(child: sources[1]),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Material(
|
||||
key: const ValueKey('jellyfin-optional'),
|
||||
color: scheme.surfaceContainer,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
side: BorderSide(color: scheme.outlineVariant.withValues(alpha: 0.5)),
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: AnimatedSize(
|
||||
duration: easeTime,
|
||||
curve: ease,
|
||||
alignment: Alignment.topCenter,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.video_library_outlined, color: scheme.onSurfaceVariant),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Wrap(spacing: 8, crossAxisAlignment: WrapCrossAlignment.center, children: [
|
||||
const Text('Jellyfin', style: TextStyle(fontWeight: FontWeight.w600)),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 1),
|
||||
decoration: BoxDecoration(
|
||||
color: scheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Text('optional', style: TextStyle(fontSize: 11, color: scheme.onSurfaceVariant)),
|
||||
),
|
||||
]),
|
||||
Text(
|
||||
'Only if you have a Jellyfin server: browse it here, '
|
||||
'with progress and next episodes. Joining a friend never needs it.',
|
||||
style: muted.copyWith(fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (!_signIn)
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: TextButton(
|
||||
key: const ValueKey('jellyfin-link'),
|
||||
onPressed: () => setState(() => _signIn = true),
|
||||
child: const Text('Link a server'),
|
||||
),
|
||||
),
|
||||
if (_signIn) ...[
|
||||
const SizedBox(height: 14),
|
||||
JellyfinSignInPanel(accounts: widget.accounts, onCancel: () => setState(() => _signIn = false)),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -33,8 +33,10 @@ class ServerIsolate {
|
||||
|
||||
/// Spawns the server and waits until it listens. Throws a
|
||||
/// [StateError] with the server's message if it cannot start.
|
||||
/// [loopbackOnly]: 127.0.0.1 only (the solo session).
|
||||
static Future<ServerIsolate> start(
|
||||
AppConfig config, {
|
||||
bool loopbackOnly = false,
|
||||
required void Function(String line) onLog,
|
||||
required void Function(String error) onCrash,
|
||||
Duration timeout = const Duration(seconds: 15),
|
||||
@@ -83,7 +85,7 @@ class ServerIsolate {
|
||||
final Isolate isolate;
|
||||
try {
|
||||
isolate = await Isolate.spawn(
|
||||
_serverMain, (config.toMap(), events.sendPort),
|
||||
_serverMain, ({...config.toMap(), if (loopbackOnly) 'loopbackOnly': true}, events.sendPort),
|
||||
errorsAreFatal: false,
|
||||
onError: errors.sendPort,
|
||||
onExit: exit.sendPort,
|
||||
|
||||
@@ -8,12 +8,18 @@ import 'package:sync_relay_core/core.dart';
|
||||
|
||||
import '../jellyfin/accounts.dart';
|
||||
import '../jellyfin/session_media.dart';
|
||||
import '../screens/home/health_probe.dart';
|
||||
import '../screens/home/picked_media.dart';
|
||||
import '../playback/yt_dlp_app.dart';
|
||||
import '../sfx/sfx.dart';
|
||||
import '../sfx/sound_bank.dart';
|
||||
import '../sfx/synth_sfx.dart';
|
||||
import '../session/health.dart';
|
||||
import '../session/session_coordinator.dart';
|
||||
import '../widgets/avatar_pipeline.dart';
|
||||
import 'connect_prefs.dart';
|
||||
import 'server_isolate.dart';
|
||||
import 'settings.dart';
|
||||
|
||||
enum AppRoute {
|
||||
home,
|
||||
@@ -140,7 +146,7 @@ class ServerCrashed implements Exception {
|
||||
}
|
||||
|
||||
/// A fresh per-hosting key: the app's own client sends it in hello to be
|
||||
/// recognised as the (unkickable) host.
|
||||
/// recognised as the host (`members[].host`).
|
||||
String newHostKey() {
|
||||
final r = Random.secure();
|
||||
return base64Url.encode([for (var i = 0; i < 24; i++) r.nextInt(256)]);
|
||||
@@ -153,19 +159,43 @@ class SessionState {
|
||||
final route = signal(AppRoute.home);
|
||||
final recents = signal<List<RecentServer>>(const []);
|
||||
final status = signal(const HealthStatus(HealthSeverity.info, 'idle'));
|
||||
final SfxService sfx = const NoopSfxService();
|
||||
final SfxService sfx = SynthSfx(
|
||||
volume: () => AppSettings.instance.soundVolume.peek(),
|
||||
// A nudge is an explicit poke: it ignores its switch (not the volume).
|
||||
enabled: (k) => k == SfxKind.nudge || !AppSettings.instance.soundsOff.peek().contains(k.name),
|
||||
presetFor: soundPresetFor,
|
||||
);
|
||||
late final SessionCoordinator coordinator = SessionCoordinator(sfx: sfx);
|
||||
final lastForm = ConnectForm();
|
||||
JellyfinAccounts get jellyfin => JellyfinAccounts.instance;
|
||||
/// Set when the hosted server died mid-session; Home shows it on the
|
||||
/// Host card and clears it.
|
||||
final serverCrash = signal<String?>(null);
|
||||
/// In a solo session: this app's own server on 127.0.0.1, only you in
|
||||
/// it. Hosting from it ([host]) keeps what plays and where.
|
||||
final solo = signal(false);
|
||||
/// The joined server's own name (`/health`), '' until known or when it
|
||||
/// only has the default name.
|
||||
final serverLabel = signal('');
|
||||
/// A solo session is becoming a hosted one: the player has nothing open
|
||||
/// until the media is up again on the new server.
|
||||
final switching = signal(false);
|
||||
ConnectParams? _soloParams;
|
||||
|
||||
/// The app's yt-dlp folder for the hosted server; '' (the server's own
|
||||
/// default) when the app-support folder is not available.
|
||||
static Future<String> _sharedYtDlpDir() async {
|
||||
try {
|
||||
return (await AppYtDlp.instance.ytDlp).dir;
|
||||
} catch (_) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
ServerIsolate? _server;
|
||||
String? _setupCrash;
|
||||
|
||||
@visibleForTesting
|
||||
ServerIsolate? get hostedServer => _server;
|
||||
void Function()? _stopKickWatch;
|
||||
// Bumped by host/join/cancelPending: a cancelled setup notices at its
|
||||
// next step and unwinds instead of connecting.
|
||||
int _op = 0;
|
||||
@@ -225,9 +255,102 @@ class SessionState {
|
||||
await _saveFormQuietly();
|
||||
}
|
||||
|
||||
/// Hosts a server friends can join. From a solo session it keeps the
|
||||
/// media and position: the solo server goes, the new one gets the same
|
||||
/// pick, seeked to where it was; if hosting fails, solo comes back.
|
||||
Future<void> host(ConnectParams params, ConnectForm form) async {
|
||||
if (!solo.peek()) return _host(params, form: form);
|
||||
final carry = _invited = _carry();
|
||||
switching.value = true;
|
||||
try {
|
||||
await coordinator.leave();
|
||||
await stopServer();
|
||||
solo.value = false;
|
||||
coordinator.solo = false;
|
||||
try {
|
||||
await _host(params, form: form);
|
||||
} catch (_) {
|
||||
// Not hosted (failed or cancelled): back to solo, same media.
|
||||
try {
|
||||
await _host(_soloParams ?? params, soloSession: true);
|
||||
if (carry != null) await _resume(carry);
|
||||
} catch (e) {
|
||||
debugPrint('back to solo failed: $e');
|
||||
await leave();
|
||||
}
|
||||
rethrow;
|
||||
}
|
||||
try {
|
||||
if (carry != null) await _resume(carry);
|
||||
} catch (e) {
|
||||
coordinator.showToast("couldn't put it up again: $e");
|
||||
}
|
||||
} finally {
|
||||
switching.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// What an Invite from solo carried over, for [cancelInvite].
|
||||
({PickedMedia pick, double position})? _invited;
|
||||
|
||||
/// Invite cancelled after the hosted server already came up: back to
|
||||
/// solo with the same media, not out of the session.
|
||||
Future<void> cancelInvite() async {
|
||||
final carry = _invited;
|
||||
switching.value = true;
|
||||
try {
|
||||
await coordinator.leave();
|
||||
await stopServer();
|
||||
await _host(_soloParams!, soloSession: true);
|
||||
if (carry != null) await _resume(carry);
|
||||
} catch (e) {
|
||||
debugPrint('back to solo failed: $e');
|
||||
await leave();
|
||||
} finally {
|
||||
switching.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Watch alone: the same hosted path, on 127.0.0.1 and a free port, no
|
||||
/// password, nothing put up yet. Nobody else can reach it.
|
||||
Future<void> startSolo(ConnectParams params) async {
|
||||
if (route.peek() == AppRoute.session) return;
|
||||
_soloParams = params;
|
||||
await _host(params, soloSession: true);
|
||||
}
|
||||
|
||||
({PickedMedia pick, double position})? _carry() {
|
||||
final pick = coordinator.lastPick;
|
||||
if (pick == null || coordinator.serverState.peek()?.media == null) return null;
|
||||
final pos = coordinator.playback.positionSecs;
|
||||
return (pick: pick, position: pos.isFinite && pos > 0 ? pos : 0.0);
|
||||
}
|
||||
|
||||
/// Puts [carry] up on the server just joined and seeks to its position
|
||||
/// once the swap shows in a state.
|
||||
Future<void> _resume(({PickedMedia pick, double position}) carry) async {
|
||||
final before = coordinator.serverState.peek()?.mediaId ?? 0;
|
||||
final error = await coordinator.swapPicked(carry.pick);
|
||||
if (error != null) throw StateError(error);
|
||||
if (carry.position < 1) return;
|
||||
final landed = Completer<void>();
|
||||
final stop = effect(() {
|
||||
final r = coordinator.serverState.value;
|
||||
if (r != null && r.media != null && r.mediaId > before && !landed.isCompleted) landed.complete();
|
||||
});
|
||||
try {
|
||||
await landed.future.timeout(const Duration(seconds: 20));
|
||||
coordinator.sync.sendSeek(carry.position);
|
||||
} on TimeoutException {
|
||||
debugPrint('resume: the media did not come up; not seeking');
|
||||
} finally {
|
||||
stop();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _host(ConnectParams params, {ConnectForm? form, bool soloSession = false}) async {
|
||||
final op = ++_op;
|
||||
_rememberForm(form);
|
||||
if (form != null) _rememberForm(form);
|
||||
try {
|
||||
status.value =
|
||||
const HealthStatus(HealthSeverity.info, 'starting server…');
|
||||
@@ -247,13 +370,15 @@ class SessionState {
|
||||
try {
|
||||
server = await ServerIsolate.start(
|
||||
AppConfig(
|
||||
port: params.port,
|
||||
secret: params.secret,
|
||||
port: soloSession ? 0 : params.port,
|
||||
secret: soloSession ? '' : params.secret,
|
||||
name: 'server',
|
||||
mediaPath: params.mediaPath,
|
||||
hostKey: hostKey,
|
||||
jellyfin: relay,
|
||||
ytDlpDir: await _sharedYtDlpDir(),
|
||||
),
|
||||
loopbackOnly: soloSession,
|
||||
onLog: (line) => debugPrint('[server] $line'),
|
||||
onCrash: (cause) => _onServerCrash(server, cause),
|
||||
);
|
||||
@@ -270,13 +395,13 @@ class SessionState {
|
||||
_server = server;
|
||||
coordinator.hostFile = (path) async =>
|
||||
identical(_server, server) ? server.setFile(path) : null;
|
||||
status.value =
|
||||
HealthStatus(HealthSeverity.ok, 'hosting on port ${server.port}');
|
||||
status.value = HealthStatus(
|
||||
HealthSeverity.ok, soloSession ? 'watching solo' : 'hosting on port ${server.port}');
|
||||
try {
|
||||
await coordinator.connect(ConnectParams(
|
||||
host: '127.0.0.1',
|
||||
port: server.port,
|
||||
secret: params.secret,
|
||||
secret: soloSession ? '' : params.secret,
|
||||
name: params.name,
|
||||
avatar: params.avatar,
|
||||
hostKey: hostKey,
|
||||
@@ -290,10 +415,11 @@ class SessionState {
|
||||
if (crash != null && e is! ConnectCancelled) throw ServerCrashed(crash);
|
||||
rethrow;
|
||||
}
|
||||
_watchKick();
|
||||
solo.value = soloSession;
|
||||
coordinator.solo = soloSession;
|
||||
route.value = AppRoute.session;
|
||||
} finally {
|
||||
await _saveFormQuietly();
|
||||
if (form != null) await _saveFormQuietly();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -319,6 +445,11 @@ class SessionState {
|
||||
await coordinator.connect(params);
|
||||
if (op != _op) throw const ConnectCancelled();
|
||||
route.value = AppRoute.session;
|
||||
serverLabel.value = '';
|
||||
unawaited(probeHealth(params.host, '${params.port}', defaultHealthGet).then((h) {
|
||||
// 'server' is the dedicated server's default --name: not a name.
|
||||
if (op == _op && h.online && h.name.isNotEmpty && h.name != 'server') serverLabel.value = h.name;
|
||||
}).catchError((Object _) {}));
|
||||
} finally {
|
||||
await _saveFormQuietly();
|
||||
}
|
||||
@@ -331,16 +462,9 @@ class SessionState {
|
||||
try {
|
||||
await coordinator.cancelConnect();
|
||||
} catch (_) {}
|
||||
await stopServer();
|
||||
}
|
||||
|
||||
/// A kicked host must not keep serving.
|
||||
void _watchKick() {
|
||||
_stopKickWatch?.call();
|
||||
_stopKickWatch = effect(() {
|
||||
// Microtask: stopServer disposes this effect, not from inside it.
|
||||
if (coordinator.kickReason.value != null) scheduleMicrotask(stopServer);
|
||||
});
|
||||
// Solo becoming hosted: [host] stops what it started and goes back
|
||||
// to solo itself.
|
||||
if (!switching.peek()) await stopServer();
|
||||
}
|
||||
|
||||
/// Saved whatever the outcome: a failed join is exactly when the user
|
||||
@@ -368,8 +492,6 @@ class SessionState {
|
||||
|
||||
Future<void> stopServer() async {
|
||||
coordinator.hostFile = null;
|
||||
_stopKickWatch?.call();
|
||||
_stopKickWatch = null;
|
||||
final server = _server;
|
||||
_server = null;
|
||||
await server?.stop();
|
||||
@@ -378,7 +500,18 @@ class SessionState {
|
||||
Future<void> leave() async {
|
||||
await coordinator.leave();
|
||||
await stopServer();
|
||||
solo.value = false;
|
||||
coordinator.solo = false;
|
||||
serverLabel.value = '';
|
||||
status.value = const HealthStatus(HealthSeverity.info, 'idle');
|
||||
route.value = AppRoute.home;
|
||||
}
|
||||
}
|
||||
|
||||
/// The sound set [kind] plays from: its own pick in Settings, else the
|
||||
/// overall one.
|
||||
SoundPreset soundPresetFor(SfxKind kind) {
|
||||
final s = AppSettings.instance;
|
||||
final name = s.soundPresetFor.peek()[kind.name] ?? s.soundPreset.peek();
|
||||
return SoundPreset.values.where((p) => p.name == name).firstOrNull ?? defaultPreset;
|
||||
}
|
||||
@@ -18,6 +18,15 @@ const _keyShaderQuality = 'shaderQuality';
|
||||
const _keyCustomShaders = 'customShaders';
|
||||
const _keyAutoAdvance = 'autoAdvance';
|
||||
const _keyMpvControls = 'mpvControls';
|
||||
const _keyHideReReleases = 'hideReReleases';
|
||||
const _keyProgressLine = 'progressLine';
|
||||
const _keySoundVolume = 'soundVolume';
|
||||
const _keySoundsOff = 'soundsOff';
|
||||
const _keySoundPreset = 'soundPreset';
|
||||
const _keyWelcomeDismissed = 'welcomeDismissed';
|
||||
const _keySoundPresetFor = 'soundPresetFor';
|
||||
const _keyUnreadyOnSwap = 'unreadyOnSwap';
|
||||
const _keyIdleSitsOut = 'idleSitsOut';
|
||||
|
||||
enum HwdecMode { auto, off, custom }
|
||||
|
||||
@@ -29,9 +38,31 @@ class AppSettings {
|
||||
final autoCheckUpdates = signal(true);
|
||||
/// Jellyfin episodes: at the end, count down 10 s and put up the next.
|
||||
final autoAdvance = signal(true);
|
||||
/// Experimental: the session uses the mpv-style (uosc) controls
|
||||
/// (`controls/uosc/`) instead of media_kit's. Applies live.
|
||||
/// Session: send ready=false when the video changes (a swap keeps the
|
||||
/// server's ready flags).
|
||||
final unreadyOnSwap = signal(false);
|
||||
/// Session: the idle auto-unready sits this member out instead.
|
||||
final idleSitsOut = signal(false);
|
||||
/// Player design: the uosc controls (`controls/uosc/`) instead of the
|
||||
/// standard ones (media_kit's with our bars). Applies live.
|
||||
final mpvControls = signal(false);
|
||||
/// Library: leave out of "Latest in X" what was added long after it
|
||||
/// first aired (re-uploads, old seasons added late).
|
||||
final hideReReleases = signal(true);
|
||||
/// A 2 px progress line at the bottom of the video while the controls
|
||||
/// are hidden (both designs). Applies live.
|
||||
final progressLine = signal(true);
|
||||
/// App sounds (`sfx/`): 0..1, 0 = silent.
|
||||
final soundVolume = signal(1.0);
|
||||
/// `SfxKind` names switched off one by one.
|
||||
final soundsOff = signal<Set<String>>(const {});
|
||||
/// The sound set (`SoundPreset` name) every sound uses unless
|
||||
/// [soundPresetFor] picks another one for it.
|
||||
final soundPreset = signal('cosmic');
|
||||
/// The offline welcome band's × was pressed: never show it again.
|
||||
final welcomeDismissed = signal(false);
|
||||
/// `SfxKind` name -> `SoundPreset` name, per-sound overrides.
|
||||
final soundPresetFor = signal<Map<String, String>>(const {});
|
||||
|
||||
/// Video shaders: one choice, used on join and changed both here and
|
||||
/// in the session (Ctrl+0..6, the shader button).
|
||||
@@ -68,8 +99,31 @@ class AppSettings {
|
||||
if (auto is bool) autoCheckUpdates.value = auto;
|
||||
final advance = box.get(_keyAutoAdvance);
|
||||
if (advance is bool) autoAdvance.value = advance;
|
||||
final unready = box.get(_keyUnreadyOnSwap);
|
||||
if (unready is bool) unreadyOnSwap.value = unready;
|
||||
final idleOut = box.get(_keyIdleSitsOut);
|
||||
if (idleOut is bool) idleSitsOut.value = idleOut;
|
||||
final mpv = box.get(_keyMpvControls);
|
||||
if (mpv is bool) mpvControls.value = mpv;
|
||||
final hideRe = box.get(_keyHideReReleases);
|
||||
if (hideRe is bool) hideReReleases.value = hideRe;
|
||||
final line = box.get(_keyProgressLine);
|
||||
if (line is bool) progressLine.value = line;
|
||||
final vol = box.get(_keySoundVolume);
|
||||
if (vol is num) soundVolume.value = vol.toDouble().clamp(0, 1);
|
||||
final off = box.get(_keySoundsOff);
|
||||
if (off is List) soundsOff.value = Set.unmodifiable(off.whereType<String>());
|
||||
final welcomeOff = box.get(_keyWelcomeDismissed);
|
||||
if (welcomeOff is bool) welcomeDismissed.value = welcomeOff;
|
||||
final soundSet = box.get(_keySoundPreset);
|
||||
if (soundSet is String) soundPreset.value = soundSet;
|
||||
final per = box.get(_keySoundPresetFor);
|
||||
if (per is Map) {
|
||||
soundPresetFor.value = Map.unmodifiable({
|
||||
for (final e in per.entries)
|
||||
if (e.key is String && e.value is String) e.key as String: e.value as String,
|
||||
});
|
||||
}
|
||||
final preset = box.get(_keyShaderPreset);
|
||||
shaderPreset.value =
|
||||
ShaderPreset.values.where((p) => p.name == preset).firstOrNull ??
|
||||
@@ -107,11 +161,59 @@ class AppSettings {
|
||||
_put(_keyAutoAdvance, on);
|
||||
}
|
||||
|
||||
void setUnreadyOnSwap(bool on) {
|
||||
unreadyOnSwap.value = on;
|
||||
_put(_keyUnreadyOnSwap, on);
|
||||
}
|
||||
|
||||
void setIdleSitsOut(bool on) {
|
||||
idleSitsOut.value = on;
|
||||
_put(_keyIdleSitsOut, on);
|
||||
}
|
||||
|
||||
void setMpvControls(bool on) {
|
||||
mpvControls.value = on;
|
||||
_put(_keyMpvControls, on);
|
||||
}
|
||||
|
||||
void setProgressLine(bool on) {
|
||||
progressLine.value = 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) {
|
||||
hideReReleases.value = on;
|
||||
_put(_keyHideReReleases, on);
|
||||
}
|
||||
|
||||
void setShaderPreset(ShaderPreset preset) {
|
||||
shaderPreset.value = preset;
|
||||
_put(_keyShaderPreset, preset.name);
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
|
||||
import 'sync_client.dart';
|
||||
|
||||
/// Member pictures by content hash (`state.members[].avatarHash`), from `/a/`.
|
||||
class AvatarCache extends PictureCache {
|
||||
AvatarCache(super.sync, {super.fetch})
|
||||
: super(route: 'a', keep: 32, live: (s) => [for (final m in s.members) m.avatarHash]);
|
||||
}
|
||||
|
||||
/// The current media's art by content hash (`state.media.art`), from `/i/`.
|
||||
class ArtCache extends PictureCache {
|
||||
ArtCache(super.sync, {super.fetch}) : super(route: 'i', keep: 8, live: artHashesOf);
|
||||
|
||||
/// Poster and banner hashes of [s]'s media.
|
||||
static List<String> artHashesOf(StateBroadcast s) {
|
||||
final art = s.media?['art'];
|
||||
if (art is! Map) return const [];
|
||||
return [for (final v in art.values) if (v is String) v];
|
||||
}
|
||||
}
|
||||
|
||||
/// Pictures by content hash, fetched once from `/<route>/<sid>/<token>/<hash>`
|
||||
/// on the host the client dialled and kept in memory. A hash names fixed
|
||||
/// bytes, so entries never go stale; listeners are notified when one
|
||||
/// arrives. Hashes in each state ([live]) are fetched ahead of use.
|
||||
class PictureCache extends ChangeNotifier {
|
||||
final SyncClient sync;
|
||||
/// First path segment of the picture route: 'a' (avatars), 'i' (art).
|
||||
final String route;
|
||||
/// Above this many pictures, ones not in the latest state go.
|
||||
final int keep;
|
||||
/// Fetches one picture; null on any failure. Injectable for tests.
|
||||
final Future<Uint8List?> Function(Uri url) _fetch;
|
||||
final Map<String, Uint8List> _bytes = {};
|
||||
final Set<String> _inFlight = {};
|
||||
final Map<String, DateTime> _failedAt = {};
|
||||
StreamSubscription? _sub;
|
||||
bool _disposed = false;
|
||||
|
||||
static const _retryAfter = Duration(seconds: 10);
|
||||
|
||||
PictureCache(this.sync,
|
||||
{required this.route,
|
||||
required this.keep,
|
||||
required Iterable<String> Function(StateBroadcast state) live,
|
||||
Future<Uint8List?> Function(Uri url)? fetch})
|
||||
: _fetch = fetch ?? _httpGet {
|
||||
_sub = sync.states.listen((s) {
|
||||
final hashes = {for (final h in live(s)) if (h.isNotEmpty) h};
|
||||
if (_bytes.length > keep) _bytes.removeWhere((h, _) => !hashes.contains(h));
|
||||
hashes.forEach(_want);
|
||||
});
|
||||
}
|
||||
|
||||
/// The picture for [hash], or null (none, or not here yet: it is fetched
|
||||
/// and listeners hear when it lands).
|
||||
Uint8List? bytesFor(String hash) {
|
||||
if (hash.isEmpty) return null;
|
||||
final hit = _bytes[hash];
|
||||
if (hit == null) _want(hash);
|
||||
return hit;
|
||||
}
|
||||
|
||||
void _want(String hash) {
|
||||
if (_bytes.containsKey(hash) || _inFlight.contains(hash)) return;
|
||||
final failed = _failedAt[hash];
|
||||
if (failed != null && DateTime.now().difference(failed) < _retryAfter) return;
|
||||
final ws = sync.wsUri;
|
||||
final token = sync.token;
|
||||
if (ws == null || token.isEmpty) return;
|
||||
final url = Uri(
|
||||
scheme: ws.scheme == 'wss' ? 'https' : 'http',
|
||||
host: ws.host,
|
||||
port: ws.port,
|
||||
pathSegments: [route, 'default', token, hash]);
|
||||
_inFlight.add(hash);
|
||||
unawaited(_fetch(url).then((bytes) {
|
||||
_inFlight.remove(hash);
|
||||
if (_disposed) return;
|
||||
if (bytes == null || bytes.isEmpty) {
|
||||
_failedAt[hash] = DateTime.now();
|
||||
return;
|
||||
}
|
||||
_failedAt.remove(hash);
|
||||
_bytes[hash] = bytes;
|
||||
notifyListeners();
|
||||
}));
|
||||
}
|
||||
|
||||
static Future<Uint8List?> _httpGet(Uri url) async {
|
||||
final client = HttpClient()..connectionTimeout = const Duration(seconds: 10);
|
||||
try {
|
||||
final resp = await (await client.getUrl(url)).close().timeout(const Duration(seconds: 15));
|
||||
if (resp.statusCode != 200) {
|
||||
await resp.drain<void>();
|
||||
return null;
|
||||
}
|
||||
final builder = BytesBuilder(copy: false);
|
||||
await for (final chunk in resp.timeout(const Duration(seconds: 15))) {
|
||||
builder.add(chunk);
|
||||
}
|
||||
return builder.takeBytes();
|
||||
} catch (_) {
|
||||
return null;
|
||||
} finally {
|
||||
client.close(force: true);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_disposed = true;
|
||||
unawaited(_sub?.cancel());
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,10 @@ bool localPlayAllowed(StateBroadcast state, String memberId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
/// At `finished`, a player at most this far behind the end keeps playing
|
||||
/// to it.
|
||||
const finishSlackSecs = 3.0;
|
||||
|
||||
class StateDiff {
|
||||
static const debounceWindow = Duration(seconds: 2);
|
||||
|
||||
@@ -73,7 +77,9 @@ class StateDiff {
|
||||
}
|
||||
|
||||
// End of media: everyone stops where they are (at or just before the
|
||||
// end). No seek to the very end and no drift correction.
|
||||
// end). No seek to the very end and no drift correction. A player a
|
||||
// little behind the server's clock plays its last moments out (mpv
|
||||
// stops at the end by itself) instead of being cut short.
|
||||
if (state.transport == 'finished') {
|
||||
final first = _lastEventId == -1;
|
||||
final changed = first ||
|
||||
@@ -87,7 +93,9 @@ class StateDiff {
|
||||
if (!changed) return out;
|
||||
out.add(const RateAction(1.0));
|
||||
if (first) out.add(SeekAction(serverPos > 1 ? serverPos - 1 : 0));
|
||||
if (first || state.position - ownPosition > finishSlackSecs) {
|
||||
out.add(const TransportAction(false));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import 'dart:convert';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
import 'package:web_socket_channel/web_socket_channel.dart';
|
||||
|
||||
enum SyncStatus { idle, connecting, open, kicked, closed, error }
|
||||
enum SyncStatus { idle, connecting, open, closed, error }
|
||||
|
||||
class SyncClient {
|
||||
/// Local clock; injectable so tests can run a client whose clock is off.
|
||||
@@ -23,16 +23,19 @@ class SyncClient {
|
||||
StreamSubscription? _sub;
|
||||
final StreamController<StateBroadcast> _states = StreamController.broadcast();
|
||||
final StreamController<FeedMessage> _feeds = StreamController.broadcast();
|
||||
final StreamController<String> _kicked = StreamController.broadcast();
|
||||
final StreamController<NudgeNotice> _nudges = StreamController.broadcast();
|
||||
final StreamController<String> _errors = StreamController.broadcast();
|
||||
final StreamController<SyncStatus> _status = StreamController.broadcast();
|
||||
|
||||
Stream<StateBroadcast> get states => _states.stream;
|
||||
Stream<FeedMessage> get feeds => _feeds.stream;
|
||||
Stream<String> get kicked => _kicked.stream;
|
||||
/// Someone poked this member (`nudge`).
|
||||
Stream<NudgeNotice> get nudges => _nudges.stream;
|
||||
Stream<String> get errors => _errors.stream;
|
||||
Stream<SyncStatus> get status => _status.stream;
|
||||
|
||||
/// The `/sync` URL last dialled; its host serves `/a/...` and the media.
|
||||
Uri? wsUri;
|
||||
String token = '';
|
||||
String memberId = '';
|
||||
String? mediaUrl;
|
||||
@@ -53,6 +56,7 @@ class SyncClient {
|
||||
}) async {
|
||||
await disconnect();
|
||||
clockSync.reset();
|
||||
this.wsUri = wsUri;
|
||||
_status.add(SyncStatus.connecting);
|
||||
final ch = WebSocketChannel.connect(wsUri);
|
||||
unawaited(ch.ready.then((_) {}, onError: (_) {}));
|
||||
@@ -150,14 +154,10 @@ class SyncClient {
|
||||
// one toast, one sfx.
|
||||
if (f.action != 'chat') _feeds.add(f);
|
||||
} catch (_) {}
|
||||
case 'kicked':
|
||||
case 'nudge':
|
||||
try {
|
||||
final msg = KickedMessage.fromJson(decoded);
|
||||
_kicked.add(msg.reason);
|
||||
_status.add(SyncStatus.kicked);
|
||||
} catch (_) {
|
||||
_kicked.add('');
|
||||
}
|
||||
_nudges.add(NudgeNotice.fromJson(decoded));
|
||||
} catch (_) {}
|
||||
case 'pong':
|
||||
try {
|
||||
final pong = PongMessage.fromJson(decoded);
|
||||
@@ -219,6 +219,7 @@ class SyncClient {
|
||||
double duration = 0,
|
||||
int mediaId = 0,
|
||||
Map<String, bool>? jf,
|
||||
String error = '',
|
||||
}) {
|
||||
final safeBuffered = buffered.isNaN || buffered < 0 ? 0.0 : buffered;
|
||||
_send(
|
||||
@@ -229,6 +230,7 @@ class SyncClient {
|
||||
duration: duration.isFinite && duration > 0 ? duration : 0,
|
||||
mediaId: mediaId,
|
||||
jf: jf,
|
||||
error: error,
|
||||
).toJson(),
|
||||
);
|
||||
}
|
||||
@@ -240,8 +242,26 @@ class SyncClient {
|
||||
_send(ChatMessage(text: clean).toJson());
|
||||
}
|
||||
|
||||
void sendKick(String target, {String reason = ''}) {
|
||||
_send(KickRequest(target: target, reason: reason).toJson());
|
||||
/// Mid-session name/picture change; null leaves it, an empty [avatar]
|
||||
/// (base64 PNG) removes the picture.
|
||||
void sendProfile({String? name, String? avatar}) {
|
||||
if (name == null && avatar == null) return;
|
||||
_send(ProfileIntent(name: name, avatar: avatar).toJson());
|
||||
}
|
||||
|
||||
/// Art for media [mediaId] (base64 picture bytes; null = none).
|
||||
void sendArt(int mediaId, {String? poster, String? banner}) {
|
||||
_send(ArtMessage(mediaId: mediaId, poster: poster, banner: banner).toJson());
|
||||
}
|
||||
|
||||
/// Sits the member [target] out, or with [out] false brings this
|
||||
/// member back (only oneself can come back).
|
||||
void sendOut(String target, bool out) {
|
||||
_send(OutIntent(target: target, out: out).toJson());
|
||||
}
|
||||
|
||||
void sendNudge(String target) {
|
||||
_send(NudgeIntent(target: target).toJson());
|
||||
}
|
||||
|
||||
Future<void> disconnect() async {
|
||||
@@ -268,7 +288,7 @@ class SyncClient {
|
||||
await disconnect();
|
||||
await _states.close();
|
||||
await _feeds.close();
|
||||
await _kicked.close();
|
||||
await _nudges.close();
|
||||
await _errors.close();
|
||||
await _status.close();
|
||||
}
|
||||
|
||||