Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
49fee1ca19 | ||
|
|
c91d57633f | ||
|
|
22ce40a488 | ||
|
|
07276ce020 | ||
|
|
4c8e40769a | ||
|
|
211ba65e5e | ||
|
|
fe4749ec7f | ||
|
|
51af830160 | ||
|
|
d9d7881747 | ||
|
|
1c915067aa | ||
|
|
0924309abf | ||
|
|
cf41a13528 | ||
|
|
9bbc594048 | ||
|
|
741b258ac1 | ||
|
|
a87ee98faa | ||
|
|
25b19f2d6a | ||
|
|
8970f2694b | ||
|
|
72a3b9adac | ||
|
|
bf9fc193b3 | ||
|
|
4d00b7455e | ||
|
|
629be0d9ed | ||
|
|
130c934b9b | ||
|
|
9f2586b7ff | ||
|
|
51c1c5ab78 | ||
|
|
f4ca02c3cf | ||
|
|
eac45746ce | ||
|
|
a52133b313 | ||
|
|
a1b270fd4e | ||
|
|
7e98ebd1db | ||
|
|
0b0c67acb7 | ||
|
|
622df58295 | ||
|
|
df6f6ef893 | ||
|
|
ea607c1a52 | ||
|
|
e537f9d5f8 | ||
|
|
cf59251449 | ||
|
|
ef562342e2 | ||
|
|
3d81342725 | ||
|
|
b949951ef9 | ||
|
|
fd8064611a | ||
|
|
959eed38a4 | ||
|
|
bcc6d4bac8 | ||
|
|
4526fe86d9 | ||
|
|
8e82a1f7be | ||
|
|
4d74cee67d | ||
|
|
d4952db497 | ||
|
|
50b2b000a4 | ||
|
|
197fe113a1 | ||
|
|
7487b4deb1 | ||
|
|
4ab37c55cf | ||
|
|
572104f49e | ||
|
|
a407d3cbb0 | ||
|
|
1ec4fc8b13 | ||
|
|
9572e8457d | ||
|
|
d8585fb78e | ||
|
|
e52a7633a0 | ||
|
|
c3fe8b682d | ||
|
|
1508d2f804 | ||
|
|
86eb30f498 | ||
|
|
ddd7a21195 | ||
|
|
4275abe470 | ||
|
|
cf2805d3bd | ||
|
|
52f1c481a6 | ||
|
|
771b961b32 | ||
|
|
6b10cae4e6 | ||
|
|
95cd668cfb | ||
|
|
2a7b553597 | ||
|
|
27b4ec2c96 | ||
|
|
ab133dd82f | ||
|
|
1b0a3db439 | ||
|
|
6d388483ef | ||
|
|
bbecdcc4d8 | ||
|
|
f1ccf88b06 | ||
|
|
d3be13ecda | ||
|
|
1b04e002c2 | ||
|
|
f23cc7b311 | ||
|
|
473c82ec0a | ||
|
|
e621b392af | ||
|
|
884f208330 | ||
|
|
cc45b86271 | ||
|
|
84c5cda668 | ||
|
|
bc96cb13d9 | ||
|
|
f7f2f0cddd | ||
|
|
05b06b1fc1 | ||
|
|
748d58f036 | ||
|
|
1fd4081461 | ||
|
|
2d39018a73 | ||
|
|
059b7dd306 | ||
|
|
0115d54ab8 | ||
|
|
fe5d6c3b3f | ||
|
|
05f9e794a9 | ||
|
|
75065f90cd | ||
|
|
e0db5cc655 | ||
|
|
8186fbb5d3 | ||
|
|
8b81da4069 | ||
|
|
dbfd351ee4 | ||
|
|
a4be24a2a5 | ||
|
|
b038706dff | ||
|
|
b2b77a6b3f | ||
|
|
d002ff6941 | ||
|
|
151ec95289 | ||
|
|
745717a4a0 | ||
|
|
9ddd85de90 | ||
|
|
24fc137e4f | ||
|
|
9a4b04672b | ||
|
|
ff51d52fbb | ||
|
|
f2272d7980 | ||
|
|
cf80bb2167 | ||
|
|
8effa573a0 | ||
|
|
5f22be6c20 | ||
|
|
740273745b | ||
|
|
27b4e1dc0c | ||
|
|
fce392e425 | ||
|
|
d8fd464e33 | ||
|
|
0c38528520 | ||
|
|
b534c8c7ea | ||
|
|
d092ea371a | ||
|
|
99cda7ed04 | ||
|
|
d29c12e4f0 | ||
|
|
f790470935 | ||
|
|
2eb6840ec4 | ||
|
|
677ece81c4 | ||
|
|
caa0765890 | ||
|
|
c76fee70dc | ||
|
|
6f136bfc4d | ||
|
|
e7f33b5dc3 | ||
|
|
9c5733719b | ||
|
|
50bcc2e1a2 | ||
|
|
2c21cfde11 | ||
|
|
e6f243d6a6 | ||
|
|
8845007cbf |
No files matched your search
@@ -1,6 +1,9 @@
|
||||
name: ci
|
||||
on:
|
||||
# Branches only: a release tag points at a commit CI already checked on
|
||||
# main, and release.yml builds it.
|
||||
push:
|
||||
branches: ['**']
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
@@ -32,7 +35,48 @@ jobs:
|
||||
- run: dart test
|
||||
working-directory: packages/server
|
||||
# Compiling the dedicated server proves it stays Flutter-free.
|
||||
- run: dart compile exe bin/server.dart -o build/watch-together-server
|
||||
- run: mkdir -p build && dart compile exe bin/server.dart -o build/starsync-server
|
||||
working-directory: packages/server
|
||||
- run: build/watch-together-server --help
|
||||
- run: build/starsync-server --help
|
||||
working-directory: packages/server
|
||||
|
||||
# The updater's Windows apply path (running exe parked as .old) only
|
||||
# means something on real NTFS, and the close smoke run needs the real
|
||||
# build. Runner notes: see release.yml.
|
||||
updater-windows:
|
||||
# Not for a "Release v…" commit: release.yml builds it on the same
|
||||
# (only) Windows runner, which this job would hold up.
|
||||
if: ${{ !startsWith(github.event.head_commit.message, 'Release v') }}
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
shell: powershell
|
||||
run: |
|
||||
git init -q .
|
||||
git remote add origin "$env:GITHUB_SERVER_URL/$env:GITHUB_REPOSITORY.git"
|
||||
git fetch -q origin "$env:GITHUB_SHA"
|
||||
if ($LASTEXITCODE) { exit 1 }
|
||||
git checkout -q --force FETCH_HEAD
|
||||
- name: Setup Flutter
|
||||
shell: powershell
|
||||
run: powershell -NoProfile -ExecutionPolicy Bypass -File tool/setup_flutter_windows.ps1
|
||||
- name: Updater tests
|
||||
shell: powershell
|
||||
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
|
||||
# The close smoke run on that build (P32): start, open a short clip in
|
||||
# a player with a video output, close; it must exit within 45 s and
|
||||
# leave no process. A runner that cannot show a window skips with a
|
||||
# warning. Here rather than in a job of its own: one build, one runner.
|
||||
- name: Smoke (start, open, close)
|
||||
shell: powershell
|
||||
timeout-minutes: 3
|
||||
run: powershell -NoProfile -ExecutionPolicy Bypass -File tool/smoke_windows.ps1 -Limit 45
|
||||
@@ -30,16 +30,48 @@ jobs:
|
||||
- name: Build tarball
|
||||
run: tool/package_linux.sh
|
||||
|
||||
- name: Upload tarball artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: linux-tarball
|
||||
path: dist/*.tar.gz
|
||||
|
||||
- name: Publish release
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||
run: tool/release_publish.sh "${{ github.ref_name }}"
|
||||
run: |
|
||||
VER="${GITHUB_REF_NAME#v}"
|
||||
dart tool/release_publish.dart "$GITHUB_REF_NAME" "dist/starsync-$VER-linux-x64.tar.gz"
|
||||
|
||||
windows:
|
||||
# yuuka-windows: host-mode runner (no containers), runs as SYSTEM, has
|
||||
# git + VS 2022 Build Tools but no bash/python — PowerShell and Dart
|
||||
# only. Checkout is plain git: that host's cached actions/checkout
|
||||
# clone was corrupt, and no `uses:` means no action cache to rot.
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
shell: powershell
|
||||
run: |
|
||||
git init -q .
|
||||
git remote add origin "$env:GITHUB_SERVER_URL/$env:GITHUB_REPOSITORY.git"
|
||||
git fetch -q --tags origin "$env:GITHUB_SHA"
|
||||
if ($LASTEXITCODE) { exit 1 }
|
||||
git checkout -q --force FETCH_HEAD
|
||||
|
||||
- name: Setup Flutter
|
||||
shell: powershell
|
||||
run: powershell -NoProfile -ExecutionPolicy Bypass -File tool/setup_flutter_windows.ps1
|
||||
|
||||
- name: Build zip
|
||||
shell: powershell
|
||||
run: powershell -NoProfile -ExecutionPolicy Bypass -File tool/package_windows.ps1
|
||||
|
||||
- name: Publish release
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
shell: powershell
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||
run: |
|
||||
$Ver = $env:GITHUB_REF_NAME.TrimStart('v')
|
||||
dart tool/release_publish.dart $env:GITHUB_REF_NAME "dist/starsync-$Ver-windows-x64.zip"
|
||||
if ($LASTEXITCODE) { exit 1 }
|
||||
@@ -17,6 +17,7 @@ coverage/
|
||||
# Build artifacts that should never be committed
|
||||
*.tar.gz
|
||||
*.zip
|
||||
/dist/
|
||||
app.*.symbols
|
||||
app.*.map.json
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -7,11 +11,11 @@ everyone's play, pause and seek stay together.
|
||||
Why not Jellyfin SyncPlay: it runs in the browser and its sync is poor.
|
||||
This is a desktop app that drives mpv.
|
||||
|
||||
Name: `watch-together` is a placeholder and the final name is undecided
|
||||
(`sync-relay` is the working name). It appears in file names and the
|
||||
update manifest until that is settled.
|
||||
Up to 1.2.x the app was called `watch_together` (its files
|
||||
`watch-together-*`); the repository and the update manifest keep their old
|
||||
addresses for now.
|
||||
|
||||
- Desktop only (Linux now, Windows planned), always mpv. No web clients.
|
||||
- Desktop only (Linux and Windows), always mpv. No web clients.
|
||||
- No transcoding. Everyone plays the original file.
|
||||
- Friends trust model: no accounts, no roles. A blank password means an open server.
|
||||
|
||||
@@ -19,40 +23,284 @@ How it fits together: [docs/HOW-IT-WORKS.md](docs/HOW-IT-WORKS.md).
|
||||
|
||||
## Install and update
|
||||
|
||||
Download the latest `watch-together-<version>-linux-x64.tar.gz` from the
|
||||
Releases page of this repo, extract it, run `watch_together`.
|
||||
From the Releases page of this repo:
|
||||
|
||||
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,
|
||||
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 `update.sh` (shipped in the tarball). A
|
||||
- Linux: `starsync-<version>-linux-x64.tar.gz`. Extract, run
|
||||
`starsync`. 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: `starsync-<version>-windows-x64.zip`. Extract, run
|
||||
`starsync.exe`. mpv is bundled. New and not yet tried on a real
|
||||
Windows desktop; reports welcome.
|
||||
|
||||
The folder also holds `watch_together(.exe)` and
|
||||
`watch-together-server(.exe)`, the names up to 1.2.x: they start the new
|
||||
programs, so an update from 1.2.x restarts fine and old shortcuts and
|
||||
service files keep working.
|
||||
|
||||
Your data (settings, sign-ins, folders, logs, yt-dlp and its cookies) lives
|
||||
in `~/.local/share/dev.foxyz.starsync` on Linux and
|
||||
`%APPDATA%\foxyz\Starsync` on Windows. The first start after 1.2.x copies
|
||||
it over from the old places (`~/.local/share/moe.senkochan.watch_together`
|
||||
or `%APPDATA%\com.example\watch_together`, and the app's files in your
|
||||
Documents folder) and leaves the originals where they were.
|
||||
|
||||
Update from the app: the app looks for a new version at start and every
|
||||
few hours, and when one exists a band across the top of the window says so
|
||||
(never over a playing video). "Update and restart" downloads,
|
||||
verifies, applies in place and restarts; "Later" hides it until the next
|
||||
check; the install folder must be writable by you. The version you run is
|
||||
at the bottom of every Settings page (and in the Settings tab's tooltip). Settings has Check now and an auto-check toggle. From a terminal:
|
||||
`./starsync-server --upgrade` (no display needed, so it works on a
|
||||
server box too), `./starsync --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.
|
||||
|
||||
## Host from the app
|
||||
## Getting started
|
||||
|
||||
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.
|
||||
The app always runs its own server, quietly: your server. When it starts
|
||||
you are on it, alone, and it is a plain player. Three ways in, at the top
|
||||
of the Library the first time; none needs Jellyfin or an account.
|
||||
|
||||
If you pick a local file, the server streams it to everyone from your disk.
|
||||
Nothing is uploaded or stored anywhere.
|
||||
- **Join a friend** (most people): your friend sends you an invite (an
|
||||
address) and, maybe, a password. See [Join](#join).
|
||||
- **Invite friends**: you have the video and friends watch it with you.
|
||||
See [Your server and Invite](#your-server-and-invite).
|
||||
- **Watch on your own**: just play something from the Library, with
|
||||
everything a server has (Jellyfin progress, next episode, skip buttons).
|
||||
Nobody else can reach your server until you invite them; inviting never
|
||||
interrupts what plays.
|
||||
|
||||
## 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. Alone
|
||||
on your server it has just Join a friend and Invite (and "Sharing" while
|
||||
your server is open to friends); with others there it shows everyone's avatar
|
||||
with a ring for their state (green ready, amber buffering, grey not ready,
|
||||
a little faded joining, 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 (each member's app version, amber when it differs from yours,
|
||||
and the server's at the top; force ready, Nudge, Sit out; on your
|
||||
server the invite with Copy invite and Stop sharing, on a friend's "Copy
|
||||
invite address"). Beside them the server and "ready n/m" (who is sitting out or
|
||||
reconnecting does not count), the chat button with unread
|
||||
count, and Invite on your server or Leave on a friend's. 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 (a web video: its thumbnail and
|
||||
name; guests get the art too; right-click to copy the title, or the link
|
||||
when you have it, or open it in the browser or Jellyfin); before
|
||||
a video first plays, its banner fills the picture until playback starts.
|
||||
One pill bottom centre, just 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 (select text to copy it, or
|
||||
right-click for Copy message and, with a link in it, Copy link / Open in
|
||||
browser), and its "Activity" toggle adds
|
||||
joins, ready, seeks and the like between them. Joins, leaves, someone
|
||||
losing connection and coming back, video changes, additions to Up next
|
||||
(the title, its picture and who added it), chat and problems also pop up
|
||||
bottom left with the person's avatar, above the player's controls (never
|
||||
on them); ready, seeks and pauses stay in the activity. Click a message
|
||||
to open the chat (Settings → Notifications & sounds → Chat messages: All
|
||||
by default, or Only when paused, or Off; messages always reach the chat,
|
||||
with their sound). Watching alone, nothing pops up or sounds about the
|
||||
party (there is nobody); the app's own notices still show.
|
||||
|
||||
**While sitting out or in the background.** Sitting out, only chat,
|
||||
nudges, "starting now" (the video starts for the others) and Up next
|
||||
reach you; joins and leaves too if you turn on Settings → Notifications &
|
||||
sounds → "Joins and leaves too". With the window in the background or
|
||||
minimised, the same ones also come as desktop notifications (Linux's
|
||||
notification center, Windows' toasts); clicking one brings the window
|
||||
up, and a chat one opens the chat. Unread chat shows as a count on the chat
|
||||
buttons and, in fullscreen with the controls hidden and the video paused,
|
||||
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, in the background while the others
|
||||
play on (nobody is paused for you).
|
||||
|
||||
**Auto sit-out.** Idle for 5 minutes while nothing plays, or the window
|
||||
minimised for 10 minutes (playing or not), sits you out by itself, so
|
||||
nobody waits for you; you are back as soon as you return (a key, the
|
||||
mouse, the window's focus). It never happens when you are alone, and an
|
||||
away you chose (or someone else's) stays until you join back. Settings →
|
||||
Party & network → "Auto sit-out" turns it off.
|
||||
|
||||
**Reconnects.** A bad connection does not throw you out: the video keeps
|
||||
playing, the pill says "Connection unstable…" and then "Reconnecting…",
|
||||
and the app comes back on its own as the same member (ready, sitting out,
|
||||
your Up next items and the video you pass on all kept) for up to 10
|
||||
minutes; the others see you dimmed, "reconnecting…". Only a drop of a
|
||||
few seconds or more makes the lost / back sounds. Leave and closing the
|
||||
app tell the others at once; Update and restart keeps your place for the
|
||||
restarted app. Opening Starsync a second time (another window with the
|
||||
same profile) takes your place over, and the first one says so and stops.
|
||||
|
||||
**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).
|
||||
|
||||
## Your server and Invite
|
||||
|
||||
Your server starts with the app and stops when it closes. Only this app
|
||||
can reach it until you press **Invite** (in the strip, or in the member
|
||||
list): that opens it on your network at the port from Settings → Party &
|
||||
network (default 8080, with the password set there; blank = anyone can
|
||||
join) and copies an invite ("Join me on Starsync: 192.168.1.20:8080 ·
|
||||
Starsync 1.3.0") to send to friends. Nothing restarts: what plays and Up
|
||||
next carry on, and you are the host (the crown). The invite picks this
|
||||
computer's home-network address (not a VPN or proxy one, nor a Docker or
|
||||
VM bridge). Friends elsewhere need the TCP port forwarded on your router
|
||||
(or a VPN address, or a server they can all reach, like a VPS). Friends
|
||||
open nothing. **Stop sharing** (in the member list) closes it again:
|
||||
everyone else leaves (their app goes back to their own server and says
|
||||
why), you and Up next stay.
|
||||
|
||||
If you put up a local file, your server streams it to everyone from your
|
||||
disk. Nothing is uploaded or stored anywhere.
|
||||
|
||||
Joining a friend leaves your server for theirs; it stays up meanwhile,
|
||||
and leaving theirs brings you back to it as you left it.
|
||||
|
||||
## 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 it to
|
||||
Up next, 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.
|
||||
Right-click a card or an episode to copy its title or open it in
|
||||
Jellyfin's web page (the details panel has that button too).
|
||||
- **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 (right-click one to copy
|
||||
or open it). A link seen before shows its preview at once, without
|
||||
asking the site again. 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**: your own library from the video folders you add on this
|
||||
computer (the **Library** shows them all together, each folder on its
|
||||
own too), or any file through Browse. Files are sorted into shows and
|
||||
movies by their names and folders: one show split over several folders
|
||||
is one show, OP/ED and specials go under its **Extras**, a folder like
|
||||
"Show (Season 3 - Alicization)" gives the season and its name. Shows
|
||||
are looked up on AniList for their proper name, cover and banner. A
|
||||
show's page has its seasons and episodes and a button for the next one
|
||||
after what you last watched here; when episodes are missing on the way
|
||||
it says so ("66–107 aren't in your folders — skip to 108?"). An
|
||||
episode you put up continues with the next one at its end (see Up
|
||||
next). Matched
|
||||
the wrong show? **Wrong show?** searches AniList and pins the right one
|
||||
for the show everywhere or just one folder; **Episode numbers…** says
|
||||
which episode the first file is when the files count differently. Both
|
||||
stay through rescans. The others stream what you put up from you, and
|
||||
see its proper name too.
|
||||
|
||||
Put it up and the player opens. With nobody else in the server, the
|
||||
buttons say Play and a link plays straight from the site.
|
||||
|
||||
### Up next (the queue)
|
||||
|
||||
Anything can wait its turn instead of going up
|
||||
now: **Add to queue** on the Link page (in the way you picked), or
|
||||
right-click a Jellyfin card or episode, a video in Files, a recent link
|
||||
or an episode on the Schedule for **Add to queue** / **Play next**; the
|
||||
Jellyfin details panel has a queue button (right-click it to play next),
|
||||
and the card for a dropped or pasted video or link has both. A link is
|
||||
checked first, like Put it up. Every app keeps the next item ready,
|
||||
its first seconds already loaded, so skipping to it starts at once.
|
||||
For a link passed on, a member's file or a link the server fetches,
|
||||
the server starts getting the next item while the current one plays
|
||||
(from the app of whoever added it, or by fetching the link itself), so
|
||||
everyone has its start before it goes up; whoever added it keeps the
|
||||
app open for that. Everyone shares one Up next and anyone can change it; what
|
||||
you added stays when you leave.
|
||||
|
||||
In the player, Up next sits in the top bar, right of the title: the
|
||||
next item's picture and title (or what continues the episode). Rest the mouse on
|
||||
it for a larger preview (who added it, its length, whether it is ready
|
||||
to start); ⏭ plays it now, a click opens the queue: drag rows to reorder, hover a
|
||||
row to play it now (▶) or remove it (×), right-click it for more (copy its
|
||||
title or link, open it in the browser or in Jellyfin). To add, press
|
||||
Ctrl+V with the queue open (a copied link or video file goes in at once,
|
||||
its title and picture showing while it is checked), type one after
|
||||
**+ Add link**, or drop a link or a video on it. When a video ends, a card shows
|
||||
what is up next with a 10 s countdown everyone sees, and it plays;
|
||||
Cancel stops it for everyone, and so does seeking back. `>` or Shift+N
|
||||
plays it at once. Under the queue, **Continues with** shows what
|
||||
continues the episode playing: its next episode (a Jellyfin episode's,
|
||||
or a file's next one in your Files library, saying which numbers it
|
||||
skips), added by whoever put the episode up, and greyed because it
|
||||
plays only once Up next is empty; **Clear** drops it for everyone. The
|
||||
further episodes of a Jellyfin series show under it; **+** adds one.
|
||||
|
||||
A link you pass on ("through me") or a file of yours can only be played
|
||||
by your app: if you left when its turn comes, it is skipped ("Skipped …:
|
||||
Foxyz left"). If you are only gone for a moment, the countdown waits for
|
||||
you. A Jellyfin item needs someone with an account on that server. Up
|
||||
next is kept by the server while it runs. If the server restarts, the
|
||||
apps put back what was playing, where it was, and Up next with it (a
|
||||
link the server fetched has to be added again).
|
||||
|
||||
## Schedule
|
||||
|
||||
The week's airing anime, one column per day (AniList), each episode
|
||||
marked with what the Jellyfin server has of it when AnimeFlow is
|
||||
connected (Settings, Jellyfin & integrations): **on Jellyfin** (Put it
|
||||
up, right-click to queue), **downloading**, **waiting for release**
|
||||
(aired in Japan, AnimeFlow downloads it by itself once a release
|
||||
appears; with when AnimeFlow last searched, or **release rejected** when
|
||||
it found releases it won't take, the reason on hover), **failed**, **not monitored** (Request asks AnimeFlow for it),
|
||||
or the time left until it airs. A show AnimeFlow does not follow can be asked for with **Request
|
||||
series**. Episodes you already watched on Jellyfin carry a check on the
|
||||
cover; one you started shows how far you got.
|
||||
|
||||
## Dedicated server
|
||||
|
||||
For an always-on box (no display needed): the release tarball also contains
|
||||
`watch-together-server`, a small standalone server without the app. Run it,
|
||||
`starsync-server`, a small standalone server without the app. Run it,
|
||||
then everyone (you included) uses Join.
|
||||
|
||||
```
|
||||
./watch-together-server --port 8080 --secret <password>
|
||||
./starsync-server --port 8080 --secret <password>
|
||||
```
|
||||
|
||||
| Flag | Default | Meaning |
|
||||
@@ -62,64 +310,134 @@ 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 |
|
||||
| `--log-dir` | `logs` next to `--config`, else next to the binary (systemd: the unit's `StateDirectory`) | folder for the log files (`server-YYYY-MM-DD.log` and the session log `session-YYYY-MM-DD.log`, kept 7 days; passwords, tokens and cookies left out) |
|
||||
| `--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.
|
||||
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`).
|
||||
Ctrl+C or SIGTERM. A dedicated server has no host.
|
||||
To run it as a service, unpack the tarball to `/opt/starsync` and use
|
||||
[deploy/starsync.service](deploy/starsync.service) (copy it to
|
||||
`/etc/systemd/system/`, edit the flags, `systemctl enable --now starsync`).
|
||||
A service set up before the rename (`/opt/watch-together`, running
|
||||
`watch-together-server`) keeps working as it is.
|
||||
|
||||
Something not playing? Settings → About → **Copy diagnostics** puts the
|
||||
app's recent log (passwords, tokens and cookies left out) on the
|
||||
clipboard: paste it to whoever helps you, from both sides if you can.
|
||||
|
||||
While you are in a server, the app also sends its log to that server, which
|
||||
merges everyone's lines and its own into one session log
|
||||
(`session-YYYY-MM-DD.log` next to the server's logs; your own server
|
||||
keeps it in the app's logs folder), so a problem can be read afterwards from every
|
||||
side. Chat is never logged anywhere, nor passwords, tokens and cookies.
|
||||
When something goes wrong, press **Mark a problem** (Ctrl+Shift+B, also in
|
||||
the player's ⚙ and Settings → About) and add a few words if you like: a
|
||||
marker with your name goes in the session log at that moment. In a server,
|
||||
Copy diagnostics can add the session log of the last 6 hours (any member
|
||||
may read it). Don't want to share? Settings → About → **Share diagnostics
|
||||
with the server** off: your log stays on your machine (the server only
|
||||
notes that you don't share; a problem you mark still goes in).
|
||||
|
||||
## 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 a friend 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 its version and yours do not match): click one
|
||||
to rejoin, x to forget it. Or paste the invite (or the address and port)
|
||||
and the password if there is one, then Enter or Join. Before connecting
|
||||
the app checks the server's version: when they do not match it says who
|
||||
has to update ("Bob's server runs Starsync 1.2.1 — they need to
|
||||
update"), and offers the update when it is you. Your name and picture
|
||||
come from your profile (your avatar at the top left). Leave goes back to
|
||||
your own server.
|
||||
|
||||
## 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 (your server's port and password for
|
||||
Invite, 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, chat
|
||||
pop-ups, what reaches you while sitting out or in the background; 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, also at the bottom of every Settings page and in the
|
||||
Settings tab's tooltip; and the credits, e.g. that the Minimal player design is inspired by uosc).
|
||||
|
||||
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 too: ⚙ → Player settings, at the
|
||||
right of its controls, and Settings → Player has the same ones (plus the
|
||||
controls' layout and the custom shader files). Player design: Standard or
|
||||
Minimal (below); progress line when hidden (on by default: a thin line at
|
||||
the bottom of the video while the controls are hidden); upscaling shader
|
||||
(see below); hardware decoding (auto, off or custom; auto is mpv's
|
||||
`auto-copy` on Windows, `auto` elsewhere; the env var
|
||||
`SYNC_RELAY_HWDEC` overrides it); in the player also the subtitle and
|
||||
audio track. Settings → Player → Controls: time display (elapsed / total
|
||||
or remaining), controls size (normal or large), hide the controls after
|
||||
2, 3 or 5 s, and which optional buttons sit in the bar (chapter buttons,
|
||||
subtitles, audio, chat; hidden ones are still in ⚙). Every change
|
||||
applies right away.
|
||||
|
||||
Anywhere in a server (unless you are typing): Space = ready / unready,
|
||||
Tab = chat & activity (Tab never moves between buttons). Anywhere:
|
||||
Ctrl+Shift+B = mark a problem in the session log. 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.
|
||||
Controls: both designs have the same buttons in the same places, only
|
||||
the look differs. Bottom left: play / pause first (it is your ready, like
|
||||
Space: ✓ while you are ready and the others are not), previous / next
|
||||
episode (when there is one; next plays Up next first), previous / next
|
||||
chapter (when the video has chapters), volume and the time (click it to
|
||||
switch between elapsed / total and the time left). Bottom right:
|
||||
Subtitles and Audio (with the track count; tracks listed as "Japanese ·
|
||||
E-AC3 2.0 · title", subtitles with Off), ⚙, chat and fullscreen last.
|
||||
Everything else is in ⚙: chapters (current one checked; click to jump),
|
||||
Skip 1:30, upscaling shader, change video, copy link, reload video,
|
||||
mark everyone ready, mark a problem, player settings and leave. The top
|
||||
bar has the title, Up next and back to the library (in fullscreen: exit
|
||||
fullscreen and the window buttons). Volume (scroll anywhere on the
|
||||
video, hover the speaker, or Up/Down) goes to 150 % like mpv: past the
|
||||
tick at 100 % the bar turns amber and mpv amplifies; a scroll or key
|
||||
shows the volume for a moment. Lists and menus under the mouse scroll
|
||||
as usual.
|
||||
|
||||
Experimental mpv-style controls (Settings → Player, off 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
|
||||
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.
|
||||
Standard (the default): a seek bar that marks chapter starts with a
|
||||
diamond (hovering it shows the time and the chapter title) over a row
|
||||
of round buttons. Minimal (⚙ → Player settings → Player design, switches
|
||||
right away): a translucent timeline across the bottom tints what has
|
||||
played, stripes the part not downloaded yet, marks chapter starts with
|
||||
dots (click one to jump), shows the elapsed time left and the total or
|
||||
the time left right (click it to switch) and, while less than a minute
|
||||
is buffered, the buffered seconds next to the position; hovering it shows
|
||||
the time there and the chapter title. With the controls hidden it stays
|
||||
as a thin line (unless "Progress line when hidden" is off). Its buttons
|
||||
sit in two glass capsules above it. Minimal's timeline and controls are
|
||||
inspired by [uosc](https://github.com/tomasklaen/uosc) by tomasklaen
|
||||
(LGPL-2.1).
|
||||
|
||||
Skipping: during the opening a "Skip OP" button floats in the corner (it
|
||||
Skipping: during the opening a "Skip OP" button shows bottom right, just
|
||||
above the controls (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). "Skip OP" and
|
||||
"Skip 1:30" show for 5 seconds when they come up, then only while the
|
||||
controls are up (move the mouse); "Skip ED" and "Next episode" show only
|
||||
while the controls are up. 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
|
||||
|
||||
@@ -128,33 +446,32 @@ anything longer than 20 minutes, "Skip 1:30" floats there during the first
|
||||
| Sync WebSocket and `/health` | TCP 8080 | plain `ws://`, no TLS |
|
||||
| Local file streaming | same port | path `/m/...`, needs a session token |
|
||||
|
||||
Only the host needs the port reachable.
|
||||
Only the one who invites needs the port reachable; until Invite your
|
||||
server listens on 127.0.0.1 and a free port only.
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -163,33 +480,93 @@ Watching:
|
||||
directly with their own sign-in. Jellyfin tracks progress per account:
|
||||
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.
|
||||
change, and when it stops). Watched means you were there while most
|
||||
of it played (or while half of it played and it reached the ending),
|
||||
not where it stopped: time you sat out does not count, skipping around
|
||||
does not count, opening a watched episode to check a moment keeps it
|
||||
watched, and a quick peek leaves no resume point. Files and links you
|
||||
watch are remembered the same way on your computer (a show's next
|
||||
episode in Files follows them).
|
||||
- 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 server" in the member list (click the avatars). On your
|
||||
own server (with your sign-in on that item's Jellyfin, whichever of
|
||||
your linked servers it is), or a dedicated server with 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 `<` / `>`,
|
||||
Episodes: the ⏮ / ⏭ buttons next to play (or `<` / `>`,
|
||||
Shift+P / Shift+N) put up the previous or next episode for everyone. From
|
||||
the ending (when it is known, see Skipping above; else in the last two
|
||||
minutes) a "Next episode" button floats in the corner. When an
|
||||
episode ends, a card shows what is next with Play next; with "Play the
|
||||
next episode automatically" (Settings, on by default) it counts down 10 s
|
||||
first, and Cancel stops it on your screen (seeking back stops it for
|
||||
everyone). Anyone with an account can put the next one up; friends
|
||||
watching via the host cannot.
|
||||
minutes) a "Next episode" button floats in the corner while the controls
|
||||
are up. With "Play the next episode automatically" (Settings, on by
|
||||
default), the episode you put up continues with the next one: when it
|
||||
ends, everyone sees a 10 s countdown, and anyone can cancel it (see Up
|
||||
next). Anyone with an account can put the next one up; friends watching
|
||||
via the host cannot.
|
||||
|
||||
Audio and subtitles: the Audio and Subtitles buttons in the controls pick tracks.
|
||||
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 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. A link goes up "Through
|
||||
me": your computer opens it and passes it on, so it plays for everyone
|
||||
whenever it plays for you (you can't tell ahead which sites want cookies,
|
||||
and YouTube may start asking any day). Under "Other ways" there are two more:
|
||||
- Everyone fetches it: 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).
|
||||
|
||||
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). When a page shows
|
||||
no video, the message says which cookies were used: none, your file
|
||||
(and whether it has any for that site), or your browser's (and how many
|
||||
it has for that site), so you know whether to sign in there or add
|
||||
cookies.
|
||||
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).
|
||||
Through me and Server fetches it 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.
|
||||
|
||||
The member list (click the avatars) shows where the video comes from on
|
||||
top: who passes it on, or that the server fetches it, everyone plays the
|
||||
link, or everyone streams it from Jellyfin, with how many watch through
|
||||
the server and, for a relay, how fast it arrives against what the video
|
||||
needs (the bar's mark; past it is keeping up). It turns amber when the
|
||||
source is too slow or its connection dropped ("Bob's connection dropped —
|
||||
playing what the server has (2 min)"), red when it left (the others play
|
||||
out what the server already has; the player's pill says the same and a
|
||||
pop-up tells you once), and whoever passes it on is told once when their
|
||||
upload is too slow. Each member's line says how they get it
|
||||
("via server" with their speed, or "direct") after a dot for their
|
||||
buffer: green enough to play on, amber low, red empty. A red dot on one
|
||||
member while the source keeps up is their own connection.
|
||||
|
||||
## Video quality and shaders
|
||||
|
||||
@@ -199,10 +576,11 @@ affected. The app ships [Anime4K](https://github.com/bloc97/Anime4K) v4.0.1
|
||||
C (480p), A+A, B+B, C+A, each as HQ (strong GPUs, GTX 1080 and up) or Fast
|
||||
(the guide's low-end set). "Custom shaders" runs your own `.glsl` files.
|
||||
|
||||
Pick one with the ✨ button in the player's control bar (also in
|
||||
Pick one with ⚙ → Upscaling shader in the player (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. ⚙ → Player settings 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.
|
||||
@@ -212,9 +590,9 @@ fails to load shows a toast and switches shaders off.
|
||||
- "host offline - check address and port": wrong address, or the port is not open.
|
||||
- "wrong server password": password mismatch.
|
||||
- "host runs a different version": update the app on both sides.
|
||||
- Picture frozen but sound fine: Ctrl+R reloads the video; the app also does it automatically.
|
||||
- Picture frozen but sound fine: Ctrl+R reloads the video.
|
||||
- Everyone waits at "waiting for X": X has not buffered 4 seconds yet or has not pressed Space.
|
||||
- No Windows build yet.
|
||||
- Windows: the build is new; if something is off there, say so.
|
||||
|
||||
For contributors: [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md),
|
||||
[docs/ARCHITECTURE.md](docs/ARCHITECTURE.md), [docs/PROTOCOL.md](docs/PROTOCOL.md).
|
||||
@@ -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,22 @@
|
||||
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';
|
||||
import 'src/widgets/app_menu.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(),
|
||||
};
|
||||
},
|
||||
),
|
||||
// Material's own menus (the player's track menus, sort) wear the
|
||||
// app menu's surface.
|
||||
theme: AppMenuStyle.theme(ThemeData.dark()),
|
||||
shortcuts: appShortcuts,
|
||||
home: const AppKeys(child: AppShell()),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,22 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hive_ce_flutter/hive_flutter.dart';
|
||||
import 'package:media_kit/media_kit.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
import 'package:window_manager/window_manager.dart';
|
||||
|
||||
import 'app.dart';
|
||||
import 'src/app/app_graph.dart';
|
||||
import 'src/files/thumbnails.dart' show thumbnailPlayers;
|
||||
import 'src/platform/app_log.dart';
|
||||
import 'src/platform/app_shutdown.dart' show closeAppWith;
|
||||
import 'src/platform/desktop/desktop_app_dirs.dart';
|
||||
import 'src/platform/desktop_entry.dart';
|
||||
import 'src/platform/smoke.dart';
|
||||
import 'src/screens/home/ui.dart';
|
||||
import 'src/state/session_state.dart';
|
||||
import 'src/session/app_events.dart' show UpdateAvailable;
|
||||
import 'src/state/settings.dart';
|
||||
import 'src/update/updater.dart';
|
||||
import 'src/update/update_controller.dart' show UpdateController, UpdatePhase, launchArgs;
|
||||
|
||||
Future<void> main(List<String> args) async {
|
||||
launchArgs = List.unmodifiable(args);
|
||||
@@ -37,47 +43,59 @@ Future<void> main(List<String> args) async {
|
||||
),
|
||||
);
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
await windowManager.ensureInitialized();
|
||||
await windowManager.setPreventClose(true);
|
||||
windowManager.addListener(_CloseGuard());
|
||||
const windowOptions = WindowOptions(
|
||||
size: Size(1280, 720),
|
||||
minimumSize: Size(160, 90),
|
||||
center: true,
|
||||
backgroundColor: Color(0xFF000000),
|
||||
titleBarStyle: TitleBarStyle.hidden,
|
||||
title: appName,
|
||||
);
|
||||
windowManager.waitUntilReadyToShow(windowOptions, () async {
|
||||
await windowManager.show();
|
||||
await windowManager.focus();
|
||||
});
|
||||
await Hive.initFlutter();
|
||||
final dirs = await const DesktopAppDirs().resolve();
|
||||
await startAppLogging(dirs);
|
||||
Hive.init(dirs.hive);
|
||||
await AppSettings.instance.ensureLoaded();
|
||||
// The composition root: every system, made once.
|
||||
final graph = AppGraph.build();
|
||||
// Sounds stay off after an audio-device crash (see SoundEngine.crashMark).
|
||||
graph.sfx.engine.crashMark = '${dirs.support}${Platform.pathSeparator}audio-init.pending';
|
||||
final ports = graph.ports;
|
||||
// Close button / Alt+F4: the orderly close ([shutDownApp]), so others
|
||||
// see "left", your server shuts down and no player outlives the engine.
|
||||
final close = closeAppWith(graph);
|
||||
await ports.shell.start(
|
||||
title: dirs.profile == null ? appName : '$appName [${dirs.profile}]', onCloseRequest: () => close());
|
||||
// Focus, minimise and restore feed auto sit-out and the Notifier (in
|
||||
// the background, notices go out as system notifications).
|
||||
ports.shell.watchActivity((a) {
|
||||
graph.coordinator.windowActivity(a);
|
||||
graph.notifier.window = a;
|
||||
});
|
||||
try {
|
||||
await SessionState.instance.loadForm();
|
||||
await graph.forms.load();
|
||||
} catch (e, st) {
|
||||
debugPrint('load connect form failed: $e\n$st');
|
||||
}
|
||||
MediaKit.ensureInitialized();
|
||||
runApp(const WatchTogetherApp());
|
||||
}
|
||||
|
||||
/// Close button / Alt+F4: leave gracefully first (others see "left", a
|
||||
/// hosted server shuts down), bounded so a hung socket can't keep the
|
||||
/// window open.
|
||||
class _CloseGuard with WindowListener {
|
||||
bool _closing = false;
|
||||
|
||||
@override
|
||||
Future<void> onWindowClose() async {
|
||||
if (_closing) return;
|
||||
_closing = true;
|
||||
try {
|
||||
await SessionState.instance.leave().timeout(const Duration(seconds: 2));
|
||||
} catch (e) {
|
||||
debugPrint('leave on close failed: $e');
|
||||
// The library's matches, pins and offsets, and the watch history
|
||||
// (device-owned).
|
||||
unawaited(graph.library.ensureLoaded());
|
||||
unawaited(graph.watch.history.ensureLoaded());
|
||||
ports.players.init();
|
||||
thumbnailPlayers = ports.players;
|
||||
// yt-dlp for web page links: downloaded on first run, kept current.
|
||||
ports.links.warmUp();
|
||||
// Linux: the launcher entry and icon Wayland needs (release installs).
|
||||
unawaited(installDesktopEntry());
|
||||
// A found update is an event too (the band shows it).
|
||||
final updates = UpdateController.instance;
|
||||
updates.status.addListener(() {
|
||||
final s = updates.status.value;
|
||||
final version = s.manifest?.version;
|
||||
if (s.phase == UpdatePhase.available && version != null) {
|
||||
graph.notifier.notify(UpdateAvailable(version));
|
||||
}
|
||||
await windowManager.destroy();
|
||||
});
|
||||
runApp(AppScope(graph: graph, child: const WatchTogetherApp()));
|
||||
// CI's close smoke run: open a file, play, close (see smoke.dart).
|
||||
final smoke = smokeMedia;
|
||||
if (smoke != null) {
|
||||
unawaited(WidgetsBinding.instance.endOfFrame.then((_) => runSmoke(graph, smoke)));
|
||||
}
|
||||
// Home: your own server, quietly (only this app can reach it until
|
||||
// Invite).
|
||||
unawaited(graph.navigation.start());
|
||||
// The sound device opens once the window is up, not on the first sound.
|
||||
Future.delayed(const Duration(seconds: 2), graph.sfx.warmUp);
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import '../jellyfin/accounts.dart';
|
||||
import '../library/library_store.dart';
|
||||
import '../library/local_next.dart';
|
||||
import '../library/show_matcher.dart';
|
||||
import '../platform/app_log.dart' show appLogDir;
|
||||
import '../platform/desktop/desktop_ports.dart';
|
||||
import '../platform/ports/ports.dart';
|
||||
import '../schedule/anilist.dart';
|
||||
import '../schedule/animeflow.dart';
|
||||
import '../screens/home/health_probe.dart';
|
||||
import '../session/connection.dart' show Connection;
|
||||
import '../session/identity.dart' show ClientIdentity, Profile;
|
||||
import '../session/navigation.dart';
|
||||
import '../session/notifier.dart';
|
||||
import '../session/session_coordinator.dart';
|
||||
import '../session/your_server.dart';
|
||||
import '../sfx/sfx.dart';
|
||||
import '../sfx/sound_bank.dart';
|
||||
import '../sfx/synth_sfx.dart';
|
||||
import '../state/connect_prefs.dart';
|
||||
import '../state/settings.dart';
|
||||
import '../watch/watch_history.dart';
|
||||
import '../watch/watch_tracker.dart';
|
||||
|
||||
/// The composition root: builds the app's systems once (in `main`), wires
|
||||
/// them through their constructors and disposes them. No logic and no
|
||||
/// signals of its own. Widgets reach it through [AppScope].
|
||||
class AppGraph {
|
||||
/// The platform (window, links, folders, yt-dlp, notifications).
|
||||
final Ports ports;
|
||||
|
||||
/// Owns the app's audio device ([SynthSfx.dispose] on close).
|
||||
final SynthSfx sfx;
|
||||
|
||||
/// Every toast, sound, system notification and activity-log line.
|
||||
final Notifier notifier;
|
||||
|
||||
/// The link to whichever server this app is in.
|
||||
final Connection connection;
|
||||
|
||||
final SessionCoordinator coordinator;
|
||||
|
||||
/// The connect form and recent servers.
|
||||
final ConnectStore forms;
|
||||
|
||||
/// This app's own server, alive for the whole run.
|
||||
final YourServer yourServer;
|
||||
|
||||
/// Home (your server) or a friend's.
|
||||
final Navigation navigation;
|
||||
|
||||
/// The library's matches (L2): local shows to AniList, pins, offsets.
|
||||
final ShowMatcher library;
|
||||
|
||||
/// Watch tracking and the device's watch history (D3).
|
||||
final WatchTracker watch;
|
||||
|
||||
AppGraph._({
|
||||
required this.ports,
|
||||
required this.sfx,
|
||||
required this.notifier,
|
||||
required this.connection,
|
||||
required this.coordinator,
|
||||
required this.forms,
|
||||
required this.yourServer,
|
||||
required this.navigation,
|
||||
required this.library,
|
||||
required this.watch,
|
||||
});
|
||||
|
||||
/// The desktop app's graph. Tests pass their own [ports], [identity]
|
||||
/// (default: the install's stored one), [healthGet] and server starter.
|
||||
factory AppGraph.build({
|
||||
Ports? ports,
|
||||
ClientIdentity? identity,
|
||||
HealthGetter healthGet = defaultHealthGet,
|
||||
ServerStarter? serverStarter,
|
||||
JellyfinAccounts? jellyfin,
|
||||
ShowMatcher? library,
|
||||
WatchHistory? history,
|
||||
}) {
|
||||
final p = ports ?? desktopPorts();
|
||||
final settings = AppSettings.instance;
|
||||
final sfx = SynthSfx(
|
||||
volume: () => settings.soundVolume.peek(),
|
||||
// A nudge is an explicit poke: it ignores its switch (not the volume).
|
||||
enabled: (k) => k == SfxKind.nudge || !settings.soundsOff.peek().contains(k.name),
|
||||
presetFor: soundPresetFor,
|
||||
);
|
||||
final notifier = Notifier(
|
||||
sfx: sfx,
|
||||
attention: p.shell.requestAttention,
|
||||
system: p.notifications,
|
||||
focusWindow: p.shell.focus,
|
||||
prefs: () => settings.notifyPrefs,
|
||||
);
|
||||
final connection = Connection(notify: notifier.notify, identity: identity ?? ClientIdentity.stored());
|
||||
// Device-owned: the watch history box.
|
||||
final watch = WatchTracker(history: history ?? WatchHistory());
|
||||
final coordinator = SessionCoordinator(
|
||||
notifier: notifier,
|
||||
connection: connection,
|
||||
ytDlp: p.links,
|
||||
jellyfin: jellyfin,
|
||||
players: p.players,
|
||||
settings: settings,
|
||||
watch: watch);
|
||||
// Device-owned: the library box; AnimeFlow's runs when signed in.
|
||||
final matcher = library ??
|
||||
ShowMatcher(
|
||||
store: HiveLibraryStore(),
|
||||
aniList: AniListClient(),
|
||||
flow: AnimeFlowLookup(AnimeFlowClient.fromAccounts(coordinator.jellyfin)),
|
||||
);
|
||||
coordinator
|
||||
..fileRef = matcher.refForPath
|
||||
..nextFile = LocalNextFinder(matcher: matcher, scan: p.folders.scan).nextAfter;
|
||||
final forms = ConnectStore();
|
||||
final yourServer = YourServer(
|
||||
forms: forms,
|
||||
jellyfin: jellyfin,
|
||||
// The app's yt-dlp folder; '' (the server's own default) when the
|
||||
// app-support folder is not available.
|
||||
ytDlpDir: () async {
|
||||
try {
|
||||
return (await p.links.ytDlp).dir;
|
||||
} catch (_) {
|
||||
return '';
|
||||
}
|
||||
},
|
||||
logDir: () => appLogDir,
|
||||
starter: serverStarter,
|
||||
);
|
||||
final navigation = Navigation(
|
||||
coordinator: coordinator,
|
||||
yourServer: yourServer,
|
||||
forms: forms,
|
||||
healthGet: healthGet,
|
||||
);
|
||||
// The profile keeps its name in the connect form and pushes changes
|
||||
// to whichever server this app is in.
|
||||
Profile.instance
|
||||
..forms = forms
|
||||
..push = ({String? name, String? avatar}) {
|
||||
if (navigation.online.peek()) coordinator.updateProfile(name: name, avatar: avatar);
|
||||
};
|
||||
return AppGraph._(
|
||||
ports: p,
|
||||
sfx: sfx,
|
||||
notifier: notifier,
|
||||
connection: connection,
|
||||
coordinator: coordinator,
|
||||
forms: forms,
|
||||
yourServer: yourServer,
|
||||
navigation: navigation,
|
||||
library: matcher,
|
||||
watch: watch,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> dispose() async {
|
||||
navigation.dispose();
|
||||
await coordinator.dispose();
|
||||
await watch.dispose();
|
||||
await yourServer.stop();
|
||||
await sfx.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// 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;
|
||||
}
|
||||
|
||||
/// Hands the [AppGraph] to every widget below: `AppScope.of(context)`.
|
||||
/// The graph never changes, so reading it registers no dependency (safe
|
||||
/// in `initState`).
|
||||
class AppScope extends InheritedWidget {
|
||||
final AppGraph graph;
|
||||
const AppScope({super.key, required this.graph, required super.child});
|
||||
|
||||
static AppGraph of(BuildContext context) {
|
||||
final scope = context.getInheritedWidgetOfExactType<AppScope>();
|
||||
assert(scope != null, 'no AppScope above this widget');
|
||||
return scope!.graph;
|
||||
}
|
||||
|
||||
@override
|
||||
bool updateShouldNotify(AppScope oldWidget) => !identical(graph, oldWidget.graph);
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:sync_relay_core/core.dart' show JellyfinStreamType;
|
||||
|
||||
import '../jellyfin/track_prefs.dart' show MpvTrack;
|
||||
import '../playback/media_marks.dart';
|
||||
import '../shaders/shader_menu.dart';
|
||||
import '../shell/mark_problem.dart';
|
||||
import '../state/settings.dart';
|
||||
import '../widgets/app_menu.dart';
|
||||
import '../widgets/copy_open.dart';
|
||||
import 'frame.dart';
|
||||
import 'model/controls_layout.dart';
|
||||
import 'model/controls_model.dart';
|
||||
import 'player_settings.dart';
|
||||
|
||||
// The controls' menus, shared by both skins: the track and chapter lists
|
||||
// and the ⚙ menu, all on the app's menu (`showAppMenu`), anchored on the
|
||||
// button that opens them. Each keeps the controls up while open.
|
||||
|
||||
/// How many tracks of [type] there are.
|
||||
int trackCount(List<MpvTrack> tracks, JellyfinStreamType type) => tracks.where((t) => t.type == type).length;
|
||||
|
||||
/// The audio or subtitle tracks ("Japanese · E-AC3 2.0 · title"), mpv's
|
||||
/// selection checked; subtitles start with Off.
|
||||
List<AppMenuItem<TrackPick>> trackMenuItems(JellyfinStreamType type, List<MpvTrack> tracks) {
|
||||
final audio = type == JellyfinStreamType.audio;
|
||||
final list = tracks.where((t) => t.type == type).toList();
|
||||
return [
|
||||
if (!audio)
|
||||
AppMenuItem(
|
||||
key: const ValueKey('track-off'),
|
||||
value: (aid: null, sid: null, subtitlesOff: true),
|
||||
label: 'Off',
|
||||
checked: !list.any((t) => t.selected),
|
||||
),
|
||||
if (audio && list.isEmpty)
|
||||
const AppMenuItem(value: (aid: null, sid: null, subtitlesOff: false), label: 'None', enabled: false),
|
||||
for (final t in list)
|
||||
AppMenuItem(
|
||||
key: ValueKey('track-${t.type.name}-${t.id}'),
|
||||
value: audio ? (aid: t.id, sid: null, subtitlesOff: false) : (aid: null, sid: t.id, subtitlesOff: false),
|
||||
label: t.label,
|
||||
checked: t.selected,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
/// Chapters with their start times, the playing one checked.
|
||||
List<AppMenuItem<MediaChapter>> chapterMenuItems(List<MediaChapter> chapters, int? current) => [
|
||||
for (final c in chapters)
|
||||
AppMenuItem(
|
||||
key: ValueKey('chapter-${c.index}'),
|
||||
value: c,
|
||||
label: c.title.isEmpty ? 'Chapter ${c.index + 1}' : c.title,
|
||||
hint: formatClock(c.start),
|
||||
checked: c.index == current,
|
||||
),
|
||||
];
|
||||
|
||||
Future<T?> _menu<T>(BuildContext context, PlayerControlsModel model, List<AppMenuItem<T>> items) async {
|
||||
model.menusOpen++;
|
||||
try {
|
||||
return await showAppMenu<T>(context, items: items);
|
||||
} finally {
|
||||
model.menusOpen--;
|
||||
}
|
||||
}
|
||||
|
||||
/// The audio or subtitle list; a pick is applied (and remembered).
|
||||
Future<void> openTrackMenu(BuildContext context, PlayerControlsModel model, JellyfinStreamType type) async {
|
||||
final pick = await _menu(context, model, trackMenuItems(type, model.tracks.peek()));
|
||||
if (pick != null) model.pickTrack(pick);
|
||||
}
|
||||
|
||||
/// The chapter list; a pick jumps there for everyone.
|
||||
Future<void> openChapterMenu(BuildContext context, PlayerControlsModel model) async {
|
||||
final pick = await _menu(context, model, chapterMenuItems(model.chapters.peek(), model.currentChapter.peek()));
|
||||
if (pick != null) model.pickChapter(pick);
|
||||
}
|
||||
|
||||
/// The ⚙ menu: every action that is not a bar button.
|
||||
Future<void> openControlsMenu(BuildContext context, PlayerControlsModel model) async {
|
||||
final s = AppSettings.instance;
|
||||
final shader = shaderShortLabel(s.shaderPreset.peek(), s.shaderQuality.peek()) ?? 'Off';
|
||||
final pick = await _menu(context, model, [
|
||||
for (final a in model.menu())
|
||||
AppMenuItem(
|
||||
key: ValueKey('controls-menu-${a.name}'),
|
||||
value: a,
|
||||
label: a.label,
|
||||
hint: a == ControlAction.shaders ? shader : a.hint,
|
||||
),
|
||||
]);
|
||||
if (pick != null && context.mounted) await runControlAction(context, model, pick);
|
||||
}
|
||||
|
||||
/// Runs a ⚙ menu action; [context] anchors what it opens next.
|
||||
Future<void> runControlAction(BuildContext context, PlayerControlsModel model, ControlAction action) async {
|
||||
switch (action) {
|
||||
case ControlAction.chapters:
|
||||
await openChapterMenu(context, model);
|
||||
case ControlAction.subtitles:
|
||||
await openTrackMenu(context, model, JellyfinStreamType.subtitle);
|
||||
case ControlAction.audio:
|
||||
await openTrackMenu(context, model, JellyfinStreamType.audio);
|
||||
case ControlAction.skipForward:
|
||||
model.skipForward();
|
||||
case ControlAction.shaders:
|
||||
model.menusOpen++;
|
||||
try {
|
||||
await ControlsFrame.holdWhile(
|
||||
context,
|
||||
() => showShaderMenu(context, renderMode: model.renderMode, onPick: model.pickShader),
|
||||
);
|
||||
} finally {
|
||||
model.menusOpen--;
|
||||
}
|
||||
case ControlAction.chat:
|
||||
model.toggleChat();
|
||||
case ControlAction.changeVideo:
|
||||
model.changeVideo();
|
||||
case ControlAction.copyLink:
|
||||
final link = model.link();
|
||||
if (link != null) await copyToClipboard(context, link, done: 'Link copied');
|
||||
case ControlAction.reload:
|
||||
model.reload();
|
||||
case ControlAction.markAllReady:
|
||||
model.markAllReady();
|
||||
case ControlAction.markProblem:
|
||||
await ControlsFrame.holdWhile(context, () => showMarkProblem(context));
|
||||
case ControlAction.playerSettings:
|
||||
await showPlayerSettings(context, renderMode: model.renderMode);
|
||||
case ControlAction.leave:
|
||||
model.leave();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// What keeps the player's controls up besides the mouse on them: the
|
||||
/// mouse on a floating element over the picture (a pill, a card, the
|
||||
/// status pill: [ControlsHoldRegion]) or an open popover. While anything
|
||||
/// holds, the controls stay up; on release they hide on their usual
|
||||
/// countdown. Both skins follow it (through `ControlsFrame`).
|
||||
class ControlsHold extends ChangeNotifier {
|
||||
final _by = <Object>{};
|
||||
bool _told = false;
|
||||
bool _queued = false;
|
||||
bool _disposed = false;
|
||||
|
||||
bool get held => _told;
|
||||
|
||||
/// [who] holds ([on]) or lets go. Listeners hear it in a microtask, never
|
||||
/// during a build or a dispose.
|
||||
void set(Object who, bool on) {
|
||||
if (on ? !_by.add(who) : !_by.remove(who)) return;
|
||||
if (_queued) return;
|
||||
_queued = true;
|
||||
scheduleMicrotask(() {
|
||||
_queued = false;
|
||||
if (_disposed) return;
|
||||
final now = _by.isNotEmpty;
|
||||
if (now == _told) return;
|
||||
_told = now;
|
||||
notifyListeners();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_disposed = true;
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// The mouse on [child] counts as the mouse on the controls: it holds
|
||||
/// [hold] while it is there.
|
||||
class ControlsHoldRegion extends StatefulWidget {
|
||||
final ControlsHold? hold;
|
||||
final Widget child;
|
||||
const ControlsHoldRegion({super.key, required this.hold, required this.child});
|
||||
|
||||
@override
|
||||
State<ControlsHoldRegion> createState() => _ControlsHoldRegionState();
|
||||
}
|
||||
|
||||
class _ControlsHoldRegionState extends State<ControlsHoldRegion> {
|
||||
@override
|
||||
void didUpdateWidget(ControlsHoldRegion oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.hold != widget.hold) oldWidget.hold?.set(this, false);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
widget.hold?.set(this, false);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final hold = widget.hold;
|
||||
if (hold == null) return widget.child;
|
||||
return MouseRegion(
|
||||
onEnter: (_) => hold.set(this, true),
|
||||
onExit: (_) => hold.set(this, false),
|
||||
child: widget.child,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/gestures.dart' show kPrimaryMouseButton;
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'controls_probe.dart' show ControlsHold;
|
||||
|
||||
// The frame both control skins live in: when the controls are up, and the
|
||||
// double-click on the picture. Mouse movement shows them; [hideAfter]
|
||||
// without any (or the mouse leaving) hides them and the cursor. Holds keep
|
||||
// them up: a seek drag (no hover events arrive), an open menu (a route
|
||||
// takes the mouse), and the session's [ControlsHold] (the mouse on a
|
||||
// floating pill or card, the Up next popover).
|
||||
|
||||
class ControlsFrame extends StatefulWidget {
|
||||
/// Draws the controls; [visible] false while they are hidden.
|
||||
final Widget Function(BuildContext context, bool visible) builder;
|
||||
final VoidCallback? onDoubleClick;
|
||||
|
||||
/// Bands at the top and bottom where a double-click is the bars', not
|
||||
/// the picture's.
|
||||
final double barTop;
|
||||
final double barBottom;
|
||||
final Duration hideAfter;
|
||||
|
||||
/// Follows whether the controls are up (for overlays outside them).
|
||||
final ValueNotifier<bool>? shown;
|
||||
|
||||
/// Keeps them up while held.
|
||||
final ControlsHold? holdBy;
|
||||
const ControlsFrame({
|
||||
super.key,
|
||||
required this.builder,
|
||||
this.onDoubleClick,
|
||||
this.barTop = 0,
|
||||
this.barBottom = 0,
|
||||
this.hideAfter = const Duration(seconds: 3),
|
||||
this.shown,
|
||||
this.holdBy,
|
||||
});
|
||||
|
||||
/// The frame around [context], for widgets that keep it up (a drag, a
|
||||
/// menu).
|
||||
static ControlsFrameState? maybeOf(BuildContext context) => context.findAncestorStateOfType<ControlsFrameState>();
|
||||
|
||||
/// Runs [open] (a menu, a popover) with the controls around [context]
|
||||
/// held up, if there are any.
|
||||
static Future<T> holdWhile<T>(BuildContext context, Future<T> Function() open) {
|
||||
final frame = maybeOf(context);
|
||||
return frame == null ? open() : frame.holdDuring(open);
|
||||
}
|
||||
|
||||
/// The fade of the controls.
|
||||
static const fade = Duration(milliseconds: 150);
|
||||
|
||||
@override
|
||||
State<ControlsFrame> createState() => ControlsFrameState();
|
||||
}
|
||||
|
||||
class ControlsFrameState extends State<ControlsFrame> {
|
||||
bool _visible = true;
|
||||
int _holds = 0;
|
||||
bool _heldBy = false;
|
||||
Timer? _timer;
|
||||
|
||||
bool get visible => _visible;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
widget.holdBy?.addListener(_holdByChanged);
|
||||
// Not during this build: an overlay listening to [shown] may be built.
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) widget.shown?.value = true;
|
||||
});
|
||||
_hideLater();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(ControlsFrame oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.holdBy != widget.holdBy) {
|
||||
oldWidget.holdBy?.removeListener(_holdByChanged);
|
||||
widget.holdBy?.addListener(_holdByChanged);
|
||||
_holdByChanged();
|
||||
}
|
||||
if (oldWidget.hideAfter != widget.hideAfter && _visible) _hideLater();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
widget.holdBy?.removeListener(_holdByChanged);
|
||||
_timer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _holdByChanged() {
|
||||
final on = widget.holdBy?.held ?? false;
|
||||
if (on == _heldBy || !mounted) return;
|
||||
_heldBy = on;
|
||||
hold(on);
|
||||
}
|
||||
|
||||
/// Show the controls and restart the hide countdown.
|
||||
void wake() {
|
||||
if (!_visible && mounted) {
|
||||
setState(() => _visible = true);
|
||||
widget.shown?.value = true;
|
||||
}
|
||||
_hideLater();
|
||||
}
|
||||
|
||||
/// Keep the controls up while [on] (paired calls).
|
||||
void hold(bool on) {
|
||||
_holds = on ? _holds + 1 : (_holds - 1).clamp(0, 1 << 20);
|
||||
wake();
|
||||
}
|
||||
|
||||
/// Runs [open] (a menu) with the controls held up.
|
||||
Future<T> holdDuring<T>(Future<T> Function() open) async {
|
||||
hold(true);
|
||||
try {
|
||||
return await open();
|
||||
} finally {
|
||||
if (mounted) hold(false);
|
||||
}
|
||||
}
|
||||
|
||||
void _hideLater() {
|
||||
_timer?.cancel();
|
||||
_timer = Timer(widget.hideAfter, _hide);
|
||||
}
|
||||
|
||||
void _hide() {
|
||||
if (!mounted || _holds > 0 || !_visible) return;
|
||||
setState(() => _visible = false);
|
||||
widget.shown?.value = false;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MouseRegion(
|
||||
cursor: _visible ? SystemMouseCursors.basic : SystemMouseCursors.none,
|
||||
onEnter: (_) => wake(),
|
||||
onHover: (_) => wake(),
|
||||
onExit: (_) {
|
||||
_timer?.cancel();
|
||||
_hide();
|
||||
},
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
if (widget.onDoubleClick != null)
|
||||
Positioned.fill(
|
||||
child: DoubleClickListener(
|
||||
onDoubleClick: widget.onDoubleClick!,
|
||||
skipTop: _visible ? widget.barTop : 0,
|
||||
skipBottom: _visible ? widget.barBottom : 0,
|
||||
child: const ColoredBox(color: Color(0x00000000), child: SizedBox.expand()),
|
||||
),
|
||||
),
|
||||
widget.builder(context, _visible),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Double-click on the picture (not on the bars) without joining the
|
||||
/// gesture arena, so single clicks on buttons stay instant.
|
||||
class DoubleClickListener extends StatefulWidget {
|
||||
final VoidCallback onDoubleClick;
|
||||
final double skipTop, skipBottom;
|
||||
final Widget child;
|
||||
const DoubleClickListener({
|
||||
super.key,
|
||||
required this.onDoubleClick,
|
||||
this.skipTop = 0,
|
||||
this.skipBottom = 0,
|
||||
required this.child,
|
||||
});
|
||||
|
||||
@override
|
||||
State<DoubleClickListener> createState() => _DoubleClickListenerState();
|
||||
}
|
||||
|
||||
class _DoubleClickListenerState extends State<DoubleClickListener> {
|
||||
DateTime? _last;
|
||||
Offset? _lastAt;
|
||||
|
||||
void _down(PointerDownEvent e) {
|
||||
if (e.buttons != kPrimaryMouseButton) return;
|
||||
final h = context.size?.height ?? 0;
|
||||
final y = e.localPosition.dy;
|
||||
if (y < widget.skipTop || y > h - widget.skipBottom) {
|
||||
_last = null;
|
||||
return;
|
||||
}
|
||||
final now = DateTime.now();
|
||||
final last = _last, at = _lastAt;
|
||||
if (last != null &&
|
||||
at != null &&
|
||||
now.difference(last) < const Duration(milliseconds: 400) &&
|
||||
(e.localPosition - at).distance < 24) {
|
||||
_last = null;
|
||||
widget.onDoubleClick();
|
||||
return;
|
||||
}
|
||||
_last = now;
|
||||
_lastAt = e.localPosition;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) =>
|
||||
Listener(behavior: HitTestBehavior.translucent, onPointerDown: _down, child: widget.child);
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:signals/signals_flutter.dart';
|
||||
import 'package:sync_relay_core/core.dart' show JellyfinStreamType;
|
||||
|
||||
import '../../queue/queue_ui.dart';
|
||||
import '../../shell/now_playing_title.dart';
|
||||
import '../../state/settings.dart';
|
||||
import '../control_menus.dart';
|
||||
import '../controls_probe.dart' show ControlsHold;
|
||||
import '../frame.dart';
|
||||
import '../model/controls_layout.dart';
|
||||
import '../model/controls_model.dart';
|
||||
import '../model/controls_prefs.dart';
|
||||
import '../volume.dart';
|
||||
import 'style.dart';
|
||||
import 'timeline.dart';
|
||||
|
||||
// The Minimal skin (inspired by mpv's uosc): a timeline band across the
|
||||
// bottom that carries the clock, and above it the buttons in two glass
|
||||
// capsules, in the layout's order. Top: what plays, Up next and the window
|
||||
// controls. Hidden, the band shrinks to a thin progress line (unless
|
||||
// switched off).
|
||||
|
||||
class MinimalControls extends StatelessWidget {
|
||||
final PlayerControlsModel model;
|
||||
final bool visible;
|
||||
final double scale;
|
||||
final ControlsHold? hold;
|
||||
final Widget? windowButtons;
|
||||
const MinimalControls({
|
||||
super.key,
|
||||
required this.model,
|
||||
required this.visible,
|
||||
this.scale = 1,
|
||||
this.hold,
|
||||
this.windowButtons,
|
||||
});
|
||||
|
||||
static const _rowGap = 8.0;
|
||||
|
||||
/// The button row's height at [scale] (a capsule).
|
||||
static double rowHeight(double scale) => MinimalStyle.button * scale + 2 * MinimalStyle.capsulePad;
|
||||
|
||||
/// Buttons and timeline at [scale].
|
||||
static double bottomHeight(double scale) => rowHeight(scale) + _rowGap + MinimalTimeline.heightFor(scale);
|
||||
|
||||
/// The top bar's height.
|
||||
static const topHeight = 72.0;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
const fade = ControlsFrame.fade;
|
||||
Widget shown(Widget child, {Key? key}) => IgnorePointer(
|
||||
key: key,
|
||||
ignoring: !visible,
|
||||
child: AnimatedOpacity(duration: fade, opacity: visible ? 1 : 0, child: child),
|
||||
);
|
||||
return Stack(
|
||||
children: [
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
child: shown(
|
||||
key: const ValueKey('minimal-top'),
|
||||
DecoratedBox(
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [Color(0x99000000), Color(0x00000000)],
|
||||
),
|
||||
),
|
||||
child: SizedBox(
|
||||
height: topHeight + 24,
|
||||
child: Padding(padding: const EdgeInsets.fromLTRB(16, 0, 10, 24), child: _top()),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
shown(
|
||||
key: const ValueKey('minimal-buttons'),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 0, 12, _rowGap),
|
||||
child: SizedBox(
|
||||
height: rowHeight(scale),
|
||||
child: SignalBuilder(
|
||||
builder: (context) {
|
||||
final s = model.snapshot.value;
|
||||
final layout = model.bar.value;
|
||||
// The clock lives on the timeline.
|
||||
List<Widget> cluster(List<BarControl> cs) => [
|
||||
for (final c in cs)
|
||||
if (c != BarControl.time) _control(context, c, s),
|
||||
];
|
||||
return Row(
|
||||
children: [
|
||||
MinimalCapsule(scale: scale, children: cluster(layout.left)),
|
||||
const Spacer(),
|
||||
MinimalCapsule(scale: scale, children: cluster(layout.right)),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
SignalBuilder(
|
||||
builder: (context) {
|
||||
final line = AppSettings.instance.progressLine.value;
|
||||
return AnimatedOpacity(
|
||||
key: const ValueKey('minimal-timeline-wrap'),
|
||||
duration: fade,
|
||||
opacity: visible || line ? 1 : 0,
|
||||
child: MinimalTimeline(
|
||||
position: model.position.value,
|
||||
duration: model.duration.value,
|
||||
buffered: model.buffered.value,
|
||||
chapters: model.chapters.value,
|
||||
expanded: visible,
|
||||
remaining: model.settings.timeDisplay.value == TimeDisplay.remaining,
|
||||
scale: scale,
|
||||
onSeek: model.seek,
|
||||
onToggleTime: model.toggleTimeDisplay,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _top() {
|
||||
final m = model;
|
||||
return Row(
|
||||
children: [
|
||||
const Expanded(
|
||||
child: Align(alignment: AlignmentDirectional.centerStart, child: NowPlayingTitle()),
|
||||
),
|
||||
UpNextButton(queue: m.queue, look: UpNextLook.minimal, hold: hold),
|
||||
const SizedBox(width: 4),
|
||||
SignalBuilder(
|
||||
builder: (context) {
|
||||
final full = m.fullscreen.value;
|
||||
return MinimalButton(
|
||||
key: const ValueKey('minimal-collapse'),
|
||||
icon: full ? Icons.fullscreen_exit_rounded : Icons.close_fullscreen_rounded,
|
||||
tooltip: full ? 'Exit fullscreen (Esc)' : 'Back to the library (keeps playing)',
|
||||
onPressed: full ? m.toggleFullscreen : m.collapse,
|
||||
);
|
||||
},
|
||||
),
|
||||
?windowButtons,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _control(BuildContext context, BarControl c, ControlsSnapshot s) {
|
||||
final m = model;
|
||||
final key = ValueKey('control-${c.name}');
|
||||
final enabled = controlEnabled(c, s);
|
||||
Widget button(VoidCallback? onPressed, {int? badge, bool accent = false}) => MinimalButton(
|
||||
key: key,
|
||||
icon: controlIcon(c, s),
|
||||
tooltip: controlTooltip(c, s),
|
||||
badge: badge,
|
||||
accent: accent,
|
||||
scale: scale,
|
||||
onPressed: enabled ? onPressed : null,
|
||||
);
|
||||
switch (c) {
|
||||
case BarControl.playPause:
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(right: 4 * scale),
|
||||
child: button(m.togglePlay, accent: true),
|
||||
);
|
||||
case BarControl.previous:
|
||||
return button(m.previous);
|
||||
case BarControl.next:
|
||||
return button(m.next);
|
||||
case BarControl.chapterPrevious:
|
||||
return button(() => m.stepChapter(-1));
|
||||
case BarControl.chapterNext:
|
||||
return button(() => m.stepChapter(1));
|
||||
case BarControl.volume:
|
||||
return SignalBuilder(
|
||||
key: key,
|
||||
builder: (context) => VolumeControl(
|
||||
volume: m.volume.value,
|
||||
onChanged: m.setVolume,
|
||||
onToggleMute: m.toggleMute,
|
||||
sliderColor: Theme.of(context).colorScheme.primary,
|
||||
trackColor: MinimalStyle.dim.withValues(alpha: 0.4),
|
||||
button: (context, i, color, tip, onPressed) =>
|
||||
MinimalButton(icon: i, tooltip: tip, color: color, scale: scale, onPressed: onPressed),
|
||||
),
|
||||
);
|
||||
case BarControl.time:
|
||||
return const SizedBox.shrink();
|
||||
case BarControl.subtitles || BarControl.audio:
|
||||
final type = c == BarControl.audio ? JellyfinStreamType.audio : JellyfinStreamType.subtitle;
|
||||
return SignalBuilder(
|
||||
key: key,
|
||||
builder: (context) => Builder(
|
||||
builder: (context) => MinimalButton(
|
||||
icon: controlIcon(c, s),
|
||||
tooltip: controlTooltip(c, s),
|
||||
badge: trackCount(m.tracks.value, type),
|
||||
scale: scale,
|
||||
onPressed: () => openTrackMenu(context, m, type),
|
||||
),
|
||||
),
|
||||
);
|
||||
case BarControl.settings:
|
||||
return Builder(
|
||||
key: key,
|
||||
builder: (context) => MinimalButton(
|
||||
icon: controlIcon(c, s),
|
||||
tooltip: controlTooltip(c, s),
|
||||
scale: scale,
|
||||
onPressed: () => openControlsMenu(context, m),
|
||||
),
|
||||
);
|
||||
case BarControl.chat:
|
||||
return SignalBuilder(
|
||||
key: key,
|
||||
builder: (context) => MinimalButton(
|
||||
icon: controlIcon(c, s),
|
||||
tooltip: controlTooltip(c, s),
|
||||
badge: m.unread.value,
|
||||
scale: scale,
|
||||
onPressed: m.toggleChat,
|
||||
),
|
||||
);
|
||||
case BarControl.fullscreen:
|
||||
return button(m.toggleFullscreen);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../widgets/app_menu.dart' show AppMenuStyle;
|
||||
|
||||
// The Minimal skin's look: the app's glass and accent (the theme's primary)
|
||||
// on a thick timeline band, with soft rounded buttons in two glass capsules.
|
||||
|
||||
abstract final class MinimalStyle {
|
||||
/// The timeline band (no blur: it spans the width over the picture).
|
||||
static const band = Color(0xCC0E0E12);
|
||||
|
||||
/// The capsules holding the buttons: the player pills' glass.
|
||||
static const capsule = AppMenuStyle.pillGlass;
|
||||
static const hover = Color(0x1FFFFFFF);
|
||||
static const text = Colors.white;
|
||||
static const dim = Color(0xB3FFFFFF);
|
||||
static const radius = 10.0;
|
||||
static const fontSize = 13.0;
|
||||
|
||||
/// The tooltips and the timeline's hover tip.
|
||||
static const tip = Color(0xEB18181C);
|
||||
|
||||
/// A button's box at scale 1.
|
||||
static const button = 38.0;
|
||||
|
||||
/// The capsules' padding around the buttons.
|
||||
static const capsulePad = 4.0;
|
||||
}
|
||||
|
||||
/// The Minimal skin's tooltip: the app's dark glass colour, above.
|
||||
class MinimalTooltip extends StatelessWidget {
|
||||
final String message;
|
||||
final Widget child;
|
||||
const MinimalTooltip({super.key, required this.message, required this.child});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (message.isEmpty) return child;
|
||||
return Tooltip(
|
||||
message: message,
|
||||
preferBelow: false,
|
||||
verticalOffset: 28,
|
||||
waitDuration: const Duration(milliseconds: 300),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
|
||||
decoration: BoxDecoration(
|
||||
color: MinimalStyle.tip,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: AppMenuStyle.border, width: 0.5),
|
||||
),
|
||||
textStyle: const TextStyle(color: MinimalStyle.text, fontSize: 12),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A Minimal button: a white icon on a rounded square that lights on hover
|
||||
/// ([accent]: filled with the theme's primary, the play button), an
|
||||
/// optional [label] and a count [badge]. Never takes focus.
|
||||
class MinimalButton extends StatefulWidget {
|
||||
final IconData icon;
|
||||
final String tooltip;
|
||||
final VoidCallback? onPressed;
|
||||
final String? label;
|
||||
final int? badge;
|
||||
final bool accent;
|
||||
final Color? color;
|
||||
final double scale;
|
||||
const MinimalButton({
|
||||
super.key,
|
||||
required this.icon,
|
||||
required this.tooltip,
|
||||
required this.onPressed,
|
||||
this.label,
|
||||
this.badge,
|
||||
this.accent = false,
|
||||
this.color,
|
||||
this.scale = 1,
|
||||
});
|
||||
|
||||
@override
|
||||
State<MinimalButton> createState() => _MinimalButtonState();
|
||||
}
|
||||
|
||||
class _MinimalButtonState extends State<MinimalButton> {
|
||||
bool _hover = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final w = widget;
|
||||
final primary = Theme.of(context).colorScheme.primary;
|
||||
final onPrimary = Theme.of(context).colorScheme.onPrimary;
|
||||
final enabled = w.onPressed != null;
|
||||
final size = MinimalStyle.button * w.scale;
|
||||
final fg = !enabled
|
||||
? MinimalStyle.dim.withValues(alpha: 0.35)
|
||||
: w.accent
|
||||
? onPrimary
|
||||
: (w.color ?? MinimalStyle.text);
|
||||
final bg = w.accent
|
||||
? (_hover ? Color.lerp(primary, Colors.white, 0.18)! : primary)
|
||||
: (_hover && enabled ? MinimalStyle.hover : Colors.transparent);
|
||||
final badge = w.badge;
|
||||
return MinimalTooltip(
|
||||
message: w.tooltip,
|
||||
child: MouseRegion(
|
||||
cursor: enabled ? SystemMouseCursors.click : SystemMouseCursors.basic,
|
||||
onEnter: (_) => setState(() => _hover = true),
|
||||
onExit: (_) => setState(() => _hover = false),
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: w.onPressed,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 120),
|
||||
height: size,
|
||||
constraints: BoxConstraints(minWidth: size),
|
||||
padding: EdgeInsets.symmetric(horizontal: w.label == null ? 0 : 10 * w.scale),
|
||||
decoration: BoxDecoration(
|
||||
color: bg,
|
||||
borderRadius: BorderRadius.circular(w.accent ? size / 2 : MinimalStyle.radius * w.scale),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
Icon(w.icon, color: fg, size: (w.accent ? 26 : 22) * w.scale),
|
||||
if (badge != null && badge > 0)
|
||||
Positioned(
|
||||
right: -7,
|
||||
top: -5,
|
||||
child: Container(
|
||||
key: const ValueKey('minimal-badge'),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
decoration: BoxDecoration(color: primary, borderRadius: BorderRadius.circular(999)),
|
||||
child: Text(
|
||||
'$badge',
|
||||
style: TextStyle(color: onPrimary, fontSize: 9, height: 1.35, fontWeight: FontWeight.w700),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (w.label != null) ...[
|
||||
SizedBox(width: 6 * w.scale),
|
||||
Text(
|
||||
w.label!,
|
||||
style: TextStyle(
|
||||
color: fg,
|
||||
fontSize: MinimalStyle.fontSize * w.scale,
|
||||
fontFeatures: const [FontFeature.tabularFigures()],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A glass capsule around a row of Minimal buttons.
|
||||
class MinimalCapsule extends StatelessWidget {
|
||||
final List<Widget> children;
|
||||
final double scale;
|
||||
const MinimalCapsule({super.key, required this.children, this.scale = 1});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final r = BorderRadius.circular(MinimalStyle.radius * scale + MinimalStyle.capsulePad);
|
||||
return DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: MinimalStyle.capsule,
|
||||
borderRadius: r,
|
||||
border: Border.all(color: AppMenuStyle.border, width: 0.5),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(MinimalStyle.capsulePad),
|
||||
child: Row(mainAxisSize: MainAxisSize.min, spacing: 2, children: children),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,579 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../playback/media_marks.dart';
|
||||
import '../frame.dart';
|
||||
import 'style.dart';
|
||||
|
||||
// The Minimal skin's timeline: a band across the bottom with the played
|
||||
// part tinted in the accent and the position as a line, the part not
|
||||
// downloaded yet striped, chapter starts as dots on the top edge (click one
|
||||
// to jump), the elapsed time left and the total or the time left right,
|
||||
// the buffered seconds by the position while under a minute, and a tip
|
||||
// with the time (and the chapter title) under the mouse. Hidden, it shrinks
|
||||
// to a thin progress line.
|
||||
//
|
||||
// Inspired by the timeline of uosc (github.com/tomasklaen/uosc, LGPL-2.1,
|
||||
// by tomasklaen): the striped unbuffered part, the remaining time and the
|
||||
// thin line when hidden come from there. The drawing, geometry and values
|
||||
// here are our own.
|
||||
|
||||
/// Sizes at scale 1.
|
||||
abstract final class MinimalTimelineSpec {
|
||||
/// The band's height while the controls are up.
|
||||
static const size = 36.0;
|
||||
|
||||
/// What is left of it while they are hidden.
|
||||
static const lineSize = 3.0;
|
||||
|
||||
/// The played part's accent tint.
|
||||
static const played = 0.4;
|
||||
|
||||
/// The position line.
|
||||
static const lineWidth = 2.0;
|
||||
static const fontSize = 15.0;
|
||||
|
||||
/// The times' inset from the ends.
|
||||
static const inset = 12.0;
|
||||
|
||||
/// A chapter dot's radius (doubled-ish while hovered).
|
||||
static const dot = 3.5;
|
||||
|
||||
/// The cache label shows while less than this is buffered ahead.
|
||||
static const bufferedThreshold = Duration(seconds: 60);
|
||||
|
||||
/// Collapse / grow.
|
||||
static const animation = Duration(milliseconds: 120);
|
||||
}
|
||||
|
||||
/// Fraction of [total] at [d], 0..1 (0 without a duration).
|
||||
double timelineFraction(Duration d, Duration total) {
|
||||
final t = total.inMicroseconds;
|
||||
if (t <= 0) return 0;
|
||||
return (d.inMicroseconds / t).clamp(0.0, 1.0);
|
||||
}
|
||||
|
||||
/// Where the part not downloaded yet starts (it runs to the end), as a
|
||||
/// fraction; null when nothing known is missing. We know only the end of
|
||||
/// mpv's cache ahead ([buffered]); a cache within half a second of the end
|
||||
/// leaves nothing.
|
||||
double? uncachedFrom({required Duration position, required Duration buffered, required Duration duration}) {
|
||||
if (duration <= Duration.zero || buffered <= Duration.zero) return null;
|
||||
final from = buffered > position ? buffered : position;
|
||||
if (duration - from <= const Duration(milliseconds: 500)) return null;
|
||||
return timelineFraction(from, duration);
|
||||
}
|
||||
|
||||
/// The cache label by the position ("12s"): the seconds buffered ahead,
|
||||
/// while under [MinimalTimelineSpec.bufferedThreshold] and the cache does
|
||||
/// not reach the end.
|
||||
String? cacheLabel({required Duration position, required Duration buffered, required Duration duration}) {
|
||||
if (duration <= Duration.zero || buffered <= Duration.zero) return null;
|
||||
if (duration - buffered <= const Duration(milliseconds: 500)) return null;
|
||||
final ahead = buffered > position ? buffered - position : Duration.zero;
|
||||
if (ahead >= MinimalTimelineSpec.bufferedThreshold) return null;
|
||||
return '${(ahead.inMilliseconds / 1000).round()}s';
|
||||
}
|
||||
|
||||
/// The band's height while it animates between hidden (0) and shown (1).
|
||||
double minimalBandSize(double visibility, double scale) =>
|
||||
MinimalTimelineSpec.lineSize +
|
||||
((MinimalTimelineSpec.size * scale - MinimalTimelineSpec.lineSize) * visibility).roundToDouble();
|
||||
|
||||
/// The Minimal timeline. [expanded] false (the controls hidden) shrinks it,
|
||||
/// animated, to the progress line. Seeks ([onSeek]) on press, on every drag
|
||||
/// move and on release; a press on a hovered chapter dot jumps to that
|
||||
/// chapter, one on the right-hand time switches it ([onToggleTime]).
|
||||
class MinimalTimeline extends StatefulWidget {
|
||||
final Duration position;
|
||||
final Duration duration;
|
||||
|
||||
/// End of the cached range.
|
||||
final Duration buffered;
|
||||
final List<MediaChapter> chapters;
|
||||
final bool expanded;
|
||||
|
||||
/// The right-hand time: the time left instead of the total.
|
||||
final bool remaining;
|
||||
final double scale;
|
||||
final ValueChanged<Duration> onSeek;
|
||||
final VoidCallback? onToggleTime;
|
||||
const MinimalTimeline({
|
||||
super.key,
|
||||
required this.position,
|
||||
required this.duration,
|
||||
this.buffered = Duration.zero,
|
||||
this.chapters = const [],
|
||||
this.expanded = true,
|
||||
this.remaining = true,
|
||||
this.scale = 1,
|
||||
required this.onSeek,
|
||||
this.onToggleTime,
|
||||
});
|
||||
|
||||
static double heightFor(double scale) => MinimalTimelineSpec.size * scale;
|
||||
static const collapsedHeight = MinimalTimelineSpec.lineSize;
|
||||
|
||||
@override
|
||||
State<MinimalTimeline> createState() => _MinimalTimelineState();
|
||||
}
|
||||
|
||||
class _MinimalTimelineState extends State<MinimalTimeline> {
|
||||
Offset? _hover;
|
||||
bool _dragging = false;
|
||||
double _dragFraction = 0;
|
||||
Duration? _lastSent;
|
||||
// A released seek, shown until the position catches up (else the line
|
||||
// jumps back to the old position for a moment).
|
||||
double? _pending;
|
||||
Timer? _pendingTimer;
|
||||
|
||||
double get _scale => widget.scale;
|
||||
Duration _at(double fraction) => widget.duration * fraction.clamp(0.0, 1.0);
|
||||
|
||||
void _send(Duration d) {
|
||||
if (d == _lastSent) return;
|
||||
_lastSent = d;
|
||||
widget.onSeek(d);
|
||||
}
|
||||
|
||||
void _drag(bool on) {
|
||||
if (_dragging == on) return;
|
||||
setState(() => _dragging = on);
|
||||
ControlsFrame.maybeOf(context)?.hold(on);
|
||||
}
|
||||
|
||||
void _settle(double fraction) {
|
||||
_pending = fraction;
|
||||
_pendingTimer?.cancel();
|
||||
_pendingTimer = Timer(const Duration(seconds: 2), () {
|
||||
if (mounted) setState(() => _pending = null);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(MinimalTimeline old) {
|
||||
super.didUpdateWidget(old);
|
||||
if (!widget.expanded && old.expanded) _hover = null;
|
||||
final p = _pending;
|
||||
if (p != null && widget.duration > Duration.zero) {
|
||||
final off = (widget.position - _at(p)).abs();
|
||||
if (off < const Duration(milliseconds: 1500)) {
|
||||
_pending = null;
|
||||
_pendingTimer?.cancel();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_pendingTimer?.cancel();
|
||||
// A drag cut short by a rebuild must not keep the controls up.
|
||||
if (_dragging) ControlsFrame.maybeOf(context)?.hold(false);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
double _dotRadius(bool hovered) => MinimalTimelineSpec.dot * _scale * (hovered ? 1.7 : 1);
|
||||
|
||||
/// The chapter whose dot is within reach of [at].
|
||||
int? _hoveredChapter(double width, List<MediaChapter> marks, Offset? at) {
|
||||
if (at == null || _dragging) return null;
|
||||
final reach = _dotRadius(false) * 2.5;
|
||||
int? best;
|
||||
var bestD = double.infinity;
|
||||
for (var i = 0; i < marks.length; i++) {
|
||||
final cx = width * timelineFraction(marks[i].start, widget.duration);
|
||||
final d = (Offset(cx, 0) - at).distance;
|
||||
if (d <= reach && d < bestD) {
|
||||
best = i;
|
||||
bestD = d;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
String? _rightText(Duration shown) {
|
||||
final d = widget.duration;
|
||||
if (d <= Duration.zero) return null;
|
||||
if (!widget.remaining) return formatClock(d);
|
||||
final left = d - shown;
|
||||
return '-${formatClock(left.isNegative ? Duration.zero : left)}';
|
||||
}
|
||||
|
||||
// The painter's text takes the app's font (a TextPainter does not
|
||||
// inherit it).
|
||||
String? _font;
|
||||
|
||||
TextStyle get _timeStyle => TextStyle(
|
||||
fontFamily: _font,
|
||||
color: MinimalStyle.text,
|
||||
fontSize: MinimalTimelineSpec.fontSize * _scale,
|
||||
height: 1,
|
||||
fontWeight: FontWeight.w500,
|
||||
fontFeatures: const [FontFeature.tabularFigures()],
|
||||
);
|
||||
|
||||
/// Where the right-hand time sits (a press there switches it).
|
||||
bool _onRightTime(Offset at, double width, String? text) {
|
||||
if (text == null || widget.onToggleTime == null) return false;
|
||||
final tp = TextPainter(
|
||||
text: TextSpan(text: text, style: _timeStyle),
|
||||
textDirection: TextDirection.ltr,
|
||||
)..layout();
|
||||
return at.dx >= width - MinimalTimelineSpec.inset * _scale - tp.width - 6;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
_font = DefaultTextStyle.of(context).style.fontFamily ?? Theme.of(context).textTheme.bodyMedium?.fontFamily;
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) => TweenAnimationBuilder<double>(
|
||||
tween: Tween(end: widget.expanded ? 1 : 0),
|
||||
duration: MinimalTimelineSpec.animation,
|
||||
builder: (context, visibility, _) => _band(context, constraints.maxWidth, visibility),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _band(BuildContext context, double width, double visibility) {
|
||||
final expanded = widget.expanded;
|
||||
final duration = widget.duration;
|
||||
final size = minimalBandSize(visibility, _scale);
|
||||
final progress = _dragging ? _dragFraction : (_pending ?? timelineFraction(widget.position, duration));
|
||||
final shown = _at(progress);
|
||||
final marks = duration > Duration.zero ? widget.chapters : const <MediaChapter>[];
|
||||
final hover = expanded ? _hover : null;
|
||||
final hovered = _hoveredChapter(width, marks, hover);
|
||||
final hoverFraction = hovered != null
|
||||
? timelineFraction(marks[hovered].start, duration)
|
||||
: (hover == null || duration <= Duration.zero || width <= 0 ? null : (hover.dx / width).clamp(0.0, 1.0));
|
||||
final hoverAt = hoverFraction == null ? null : _at(hoverFraction);
|
||||
final cursorX = hoverFraction == null ? null : width * hoverFraction;
|
||||
final hoverChapter = hoverAt == null ? null : chapterIndexAt(widget.chapters, hoverAt);
|
||||
final hoverTitle = hoverChapter == null ? '' : widget.chapters[hoverChapter].title;
|
||||
// Text fades out before it would overflow the shrinking band.
|
||||
final minText = MinimalTimelineSpec.fontSize * _scale;
|
||||
final textOpacity = ((size - minText) / (minText / 2)).clamp(0.0, 1.0);
|
||||
final right = _rightText(shown);
|
||||
final primary = Theme.of(context).colorScheme.primary;
|
||||
final painter = MinimalTimelinePainter(
|
||||
progress: progress,
|
||||
visibility: visibility,
|
||||
accent: primary,
|
||||
scale: _scale,
|
||||
uncached: uncachedFrom(position: widget.position, buffered: widget.buffered, duration: duration),
|
||||
chapters: [for (final c in marks) timelineFraction(c.start, duration)],
|
||||
hoveredChapter: hovered,
|
||||
hoverX: hovered == null ? cursorX : null,
|
||||
textOpacity: textOpacity,
|
||||
elapsed: duration > Duration.zero ? formatClock(shown) : null,
|
||||
right: right,
|
||||
cache: cacheLabel(position: widget.position, buffered: widget.buffered, duration: duration),
|
||||
timeStyle: _timeStyle,
|
||||
);
|
||||
final bar = SizedBox(
|
||||
key: const ValueKey('minimal-timeline'),
|
||||
width: width,
|
||||
height: size,
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: RepaintBoundary(child: CustomPaint(painter: painter)),
|
||||
),
|
||||
if (hoverAt != null && cursorX != null)
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: size + 8,
|
||||
height: 120,
|
||||
child: IgnorePointer(
|
||||
child: CustomMultiChildLayout(
|
||||
delegate: _TipLayout(x: cursorX),
|
||||
children: [
|
||||
LayoutId(
|
||||
id: #tip,
|
||||
child: _HoverTip(time: formatClock(hoverAt), chapter: hoverTitle, scale: _scale),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (!expanded) return IgnorePointer(child: bar);
|
||||
Offset clampTo(Offset p) => Offset(p.dx.clamp(0.0, width), p.dy);
|
||||
return MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
onEnter: (e) => setState(() => _hover = clampTo(e.localPosition)),
|
||||
onHover: (e) => setState(() => _hover = clampTo(e.localPosition)),
|
||||
onExit: (_) {
|
||||
if (!_dragging) setState(() => _hover = null);
|
||||
},
|
||||
child: Listener(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onPointerDown: (e) {
|
||||
if (width <= 0) return;
|
||||
final at = clampTo(e.localPosition);
|
||||
if (_onRightTime(at, width, right)) {
|
||||
widget.onToggleTime?.call();
|
||||
return;
|
||||
}
|
||||
if (duration <= Duration.zero) return;
|
||||
final chapter = _hoveredChapter(width, marks, at);
|
||||
_lastSent = null;
|
||||
if (chapter != null) {
|
||||
_settle(timelineFraction(marks[chapter].start, duration));
|
||||
_send(marks[chapter].start);
|
||||
setState(() {});
|
||||
return;
|
||||
}
|
||||
_pending = null;
|
||||
_dragFraction = (at.dx / width).clamp(0.0, 1.0);
|
||||
_drag(true);
|
||||
_send(_at(_dragFraction));
|
||||
},
|
||||
onPointerMove: (e) {
|
||||
if (!_dragging) return;
|
||||
final at = clampTo(e.localPosition);
|
||||
setState(() {
|
||||
_hover = at;
|
||||
_dragFraction = (at.dx / width).clamp(0.0, 1.0);
|
||||
});
|
||||
_send(_at(_dragFraction));
|
||||
},
|
||||
onPointerUp: (e) {
|
||||
if (!_dragging) return;
|
||||
_send(_at(_dragFraction));
|
||||
_settle(_dragFraction);
|
||||
final p = e.localPosition;
|
||||
if (p.dx < 0 || p.dx > width || p.dy < 0 || p.dy > size) _hover = null;
|
||||
_drag(false);
|
||||
},
|
||||
onPointerCancel: (_) => _drag(false),
|
||||
child: bar,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _HoverTip extends StatelessWidget {
|
||||
final String time;
|
||||
final String chapter;
|
||||
final double scale;
|
||||
const _HoverTip({required this.time, required this.chapter, required this.scale});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
constraints: const BoxConstraints(maxWidth: 360),
|
||||
padding: EdgeInsets.symmetric(horizontal: 10 * scale, vertical: 5 * scale),
|
||||
decoration: BoxDecoration(
|
||||
color: MinimalStyle.tip,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: const Color(0x1FFFFFFF), width: 0.5),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (chapter.isNotEmpty)
|
||||
Text(
|
||||
chapter,
|
||||
key: const ValueKey('minimal-hover-chapter'),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(color: MinimalStyle.text, fontSize: 13 * scale, fontWeight: FontWeight.w600),
|
||||
),
|
||||
Text(
|
||||
time,
|
||||
key: const ValueKey('minimal-hover-time'),
|
||||
style: TextStyle(
|
||||
color: chapter.isEmpty ? MinimalStyle.text : MinimalStyle.dim,
|
||||
fontSize: 13 * scale,
|
||||
fontFeatures: const [FontFeature.tabularFigures()],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The hover tip centred on [x], kept inside the band's ends.
|
||||
class _TipLayout extends MultiChildLayoutDelegate {
|
||||
final double x;
|
||||
_TipLayout({required this.x});
|
||||
|
||||
@override
|
||||
void performLayout(Size size) {
|
||||
const gap = 4.0;
|
||||
final tip = layoutChild(#tip, BoxConstraints.loose(size));
|
||||
final left = (x - tip.width / 2).clamp(gap, math.max(gap, size.width - gap - tip.width)).toDouble();
|
||||
positionChild(#tip, Offset(left, size.height - tip.height));
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRelayout(_TipLayout old) => old.x != x;
|
||||
}
|
||||
|
||||
/// Paints the band: background, the played tint and the (white) position
|
||||
/// line, the stripes over the part not downloaded, chapter marks, the
|
||||
/// times, the hover line.
|
||||
class MinimalTimelinePainter extends CustomPainter {
|
||||
/// The playing position, 0..1.
|
||||
final double progress;
|
||||
|
||||
/// 0 hidden .. 1 shown.
|
||||
final double visibility;
|
||||
final Color accent;
|
||||
final double scale;
|
||||
|
||||
/// Start of the part not downloaded (to the end), as a fraction.
|
||||
final double? uncached;
|
||||
|
||||
/// Chapter starts, as fractions.
|
||||
final List<double> chapters;
|
||||
final int? hoveredChapter;
|
||||
final double? hoverX;
|
||||
final double textOpacity;
|
||||
final String? elapsed;
|
||||
final String? right;
|
||||
final String? cache;
|
||||
final TextStyle timeStyle;
|
||||
const MinimalTimelinePainter({
|
||||
required this.progress,
|
||||
this.visibility = 1,
|
||||
this.accent = Colors.white,
|
||||
this.scale = 1,
|
||||
this.uncached,
|
||||
this.chapters = const [],
|
||||
this.hoveredChapter,
|
||||
this.hoverX,
|
||||
this.textOpacity = 1,
|
||||
this.elapsed,
|
||||
this.right,
|
||||
this.cache,
|
||||
this.timeStyle = const TextStyle(fontSize: MinimalTimelineSpec.fontSize),
|
||||
});
|
||||
|
||||
/// x of the position line's centre.
|
||||
double lineX(double width) {
|
||||
final lw = MinimalTimelineSpec.lineWidth * scale;
|
||||
return (width * progress).clamp(lw / 2, math.max(lw / 2, width - lw / 2));
|
||||
}
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final w = size.width, h = size.height;
|
||||
final x = lineX(w);
|
||||
final band = Offset.zero & size;
|
||||
// The same colours shown and hidden: hidden, it is the band shrunk to
|
||||
// a line, so growing and shrinking never switches colour.
|
||||
canvas.drawRect(band, Paint()..color = MinimalStyle.band);
|
||||
canvas.drawRect(Rect.fromLTRB(0, 0, x, h), Paint()..color = accent.withValues(alpha: MinimalTimelineSpec.played));
|
||||
_stripes(canvas, w, h);
|
||||
_chapters(canvas, w, h);
|
||||
if (visibility > 0) {
|
||||
final lw = MinimalTimelineSpec.lineWidth * scale;
|
||||
canvas.drawRect(
|
||||
Rect.fromLTRB(x - lw / 2, 0, x + lw / 2, h),
|
||||
Paint()..color = Colors.white.withValues(alpha: visibility),
|
||||
);
|
||||
}
|
||||
if (textOpacity > 0) _times(canvas, w, h, x);
|
||||
final hx = hoverX;
|
||||
if (hx != null) {
|
||||
canvas.drawRect(Rect.fromLTRB(hx - 0.5, 0, hx + 0.5, h), Paint()..color = Colors.white.withValues(alpha: 0.45));
|
||||
}
|
||||
}
|
||||
|
||||
/// Thin 45° stripes over the part not downloaded yet.
|
||||
void _stripes(Canvas canvas, double w, double h) {
|
||||
final from = uncached;
|
||||
if (from == null) return;
|
||||
final ax = (w * from).floorToDouble();
|
||||
if (ax >= w) return;
|
||||
final period = 7.0 * scale;
|
||||
canvas.save();
|
||||
canvas.clipRect(Rect.fromLTRB(ax, 0, w, h));
|
||||
final paint = Paint()
|
||||
// Stronger on the line, where only a few pixels of each show.
|
||||
..color = Colors.white.withValues(alpha: 0.12 + 0.22 * (1 - visibility))
|
||||
..strokeWidth = 1.5 * scale;
|
||||
for (var c = (ax / period).floorToDouble() * period - h; c <= w; c += period) {
|
||||
canvas.drawLine(Offset(c, h), Offset(c + h, 0), paint);
|
||||
}
|
||||
canvas.restore();
|
||||
}
|
||||
|
||||
void _chapters(Canvas canvas, double w, double h) {
|
||||
final cut = Paint()..color = Colors.black.withValues(alpha: 0.3);
|
||||
for (final f in chapters) {
|
||||
if (f <= 0) continue;
|
||||
final cx = w * f;
|
||||
canvas.drawRect(Rect.fromLTRB(cx - 0.5, 0, cx + 0.5, h), cut);
|
||||
}
|
||||
if (visibility <= 0) return;
|
||||
final r = MinimalTimelineSpec.dot * scale * visibility;
|
||||
void dot(double f, double radius, Color fill) {
|
||||
final c = Offset(w * f, 0);
|
||||
canvas.drawCircle(c, radius + 1, Paint()..color = Colors.black.withValues(alpha: 0.7));
|
||||
canvas.drawCircle(c, radius, Paint()..color = fill);
|
||||
}
|
||||
|
||||
// Not at 0: the band's start says that already.
|
||||
for (var i = 0; i < chapters.length; i++) {
|
||||
if (i != hoveredChapter && chapters[i] > 0) dot(chapters[i], r, Colors.white.withValues(alpha: 0.9));
|
||||
}
|
||||
final hv = hoveredChapter;
|
||||
if (hv != null && hv < chapters.length) dot(chapters[hv], r * 1.7, accent);
|
||||
}
|
||||
|
||||
TextPainter _text(String s, TextStyle style) => TextPainter(
|
||||
text: TextSpan(text: s, style: style),
|
||||
textDirection: TextDirection.ltr,
|
||||
)..layout();
|
||||
|
||||
/// White text with a soft shadow, so it reads over the accent tint too.
|
||||
void _label(Canvas canvas, String s, TextStyle style, Offset at) {
|
||||
final o = textOpacity;
|
||||
_text(
|
||||
s,
|
||||
style.copyWith(
|
||||
color: (style.color ?? Colors.white).withValues(alpha: o),
|
||||
shadows: [Shadow(color: Colors.black.withValues(alpha: 0.8 * o), blurRadius: 3)],
|
||||
),
|
||||
).paint(canvas, at);
|
||||
}
|
||||
|
||||
void _times(Canvas canvas, double w, double h, double lineX) {
|
||||
final inset = MinimalTimelineSpec.inset * scale;
|
||||
final fs = timeStyle.fontSize ?? MinimalTimelineSpec.fontSize;
|
||||
final cy = h / 2;
|
||||
final e = elapsed, r = right;
|
||||
final ew = e == null ? 0.0 : _text(e, timeStyle).width;
|
||||
final rw = r == null ? 0.0 : _text(r, timeStyle).width;
|
||||
final c = cache;
|
||||
if (c != null) {
|
||||
final cs = timeStyle.copyWith(fontSize: fs * 0.8, color: MinimalStyle.dim);
|
||||
final cw = _text(c, cs).width;
|
||||
const margin = 6.0;
|
||||
var x = lineX + margin;
|
||||
final minX = inset + margin + ew, maxX = w - inset - margin - rw;
|
||||
if (x < minX) {
|
||||
x = minX;
|
||||
} else if (x + cw > maxX) {
|
||||
x = maxX - cw;
|
||||
}
|
||||
_label(canvas, c, cs, Offset(x, cy - fs * 0.4));
|
||||
}
|
||||
if (e != null) _label(canvas, e, timeStyle, Offset(inset, cy - fs / 2));
|
||||
if (r != null) _label(canvas, r, timeStyle, Offset(w - inset - rw, cy - fs / 2));
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(MinimalTimelinePainter old) => true;
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
import 'package:flutter/material.dart' show IconData, Icons;
|
||||
|
||||
import '../../playback/media_marks.dart' show formatClock;
|
||||
import 'controls_prefs.dart';
|
||||
|
||||
// The controls layout, defined once for both skins: which controls the
|
||||
// bottom bar has, in which order, which actions sit in the ⚙ menu instead,
|
||||
// and their icons, tooltips and labels. Pure: everything here is a
|
||||
// function of a [ControlsSnapshot] and the user's choices, so tests cover
|
||||
// it without a player.
|
||||
|
||||
/// What playback does as this viewer sees it. In a server "play" is being
|
||||
/// ready: the video plays once everyone is.
|
||||
enum PlayState {
|
||||
/// The video plays (the button pauses: you go unready).
|
||||
playing,
|
||||
|
||||
/// You are ready; the others are not yet (the button unreadies you).
|
||||
waiting,
|
||||
|
||||
/// You are not ready (the button readies you).
|
||||
paused,
|
||||
|
||||
/// The media ended.
|
||||
finished,
|
||||
}
|
||||
|
||||
/// A control in the bottom bar.
|
||||
enum BarControl {
|
||||
playPause,
|
||||
|
||||
/// Previous episode.
|
||||
previous,
|
||||
|
||||
/// Up next's first item, else the next episode.
|
||||
next,
|
||||
chapterPrevious,
|
||||
chapterNext,
|
||||
volume,
|
||||
time,
|
||||
subtitles,
|
||||
audio,
|
||||
|
||||
/// ⚙: everything that is not a bar button.
|
||||
settings,
|
||||
chat,
|
||||
fullscreen,
|
||||
}
|
||||
|
||||
/// An action in the ⚙ menu.
|
||||
enum ControlAction {
|
||||
chapters('Chapters…', null),
|
||||
subtitles('Subtitles…', null),
|
||||
audio('Audio…', null),
|
||||
skipForward('Skip 1:30', 'Shift+Right'),
|
||||
shaders('Upscaling shader…', 'Ctrl+0…6'),
|
||||
chat('Chat & activity', 'Tab'),
|
||||
changeVideo('Change video', null),
|
||||
copyLink('Copy link', null),
|
||||
reload('Reload video', 'Ctrl+R'),
|
||||
markAllReady('Mark everyone ready', null),
|
||||
markProblem('Mark a problem', 'Ctrl+Shift+B'),
|
||||
playerSettings('Player settings…', null),
|
||||
leave('Leave server', null);
|
||||
|
||||
final String label;
|
||||
final String? hint;
|
||||
const ControlAction(this.label, this.hint);
|
||||
}
|
||||
|
||||
/// The bar's order, left cluster then right cluster. The play button is
|
||||
/// always first and fullscreen always last; controls that do not apply
|
||||
/// (no episode, no chapters) or that the user hid are left out, the rest
|
||||
/// keep their order.
|
||||
abstract final class ControlsLayout {
|
||||
static const left = [
|
||||
BarControl.playPause,
|
||||
BarControl.previous,
|
||||
BarControl.next,
|
||||
BarControl.chapterPrevious,
|
||||
BarControl.chapterNext,
|
||||
BarControl.volume,
|
||||
BarControl.time,
|
||||
];
|
||||
static const right = [
|
||||
BarControl.subtitles,
|
||||
BarControl.audio,
|
||||
BarControl.settings,
|
||||
BarControl.chat,
|
||||
BarControl.fullscreen,
|
||||
];
|
||||
|
||||
/// The ⚙ menu's order.
|
||||
static const menu = ControlAction.values;
|
||||
}
|
||||
|
||||
/// What the layout depends on, at one moment.
|
||||
class ControlsSnapshot {
|
||||
final PlayState play;
|
||||
|
||||
/// The previous episode's label; null: none.
|
||||
final String? previousLabel;
|
||||
|
||||
/// Up next's first item's title, else the next episode's label; null:
|
||||
/// nothing next.
|
||||
final String? nextLabel;
|
||||
|
||||
/// [nextLabel] is Up next (not an episode).
|
||||
final bool nextIsUpNext;
|
||||
final bool hasChapters;
|
||||
|
||||
/// The media's length is known (seeks and skips make sense).
|
||||
final bool hasDuration;
|
||||
|
||||
/// Watching alone (no one to mark ready).
|
||||
final bool alone;
|
||||
|
||||
/// A link to copy (`SessionCoordinator.currentLink`).
|
||||
final bool hasLink;
|
||||
final bool fullscreen;
|
||||
final bool chatOpen;
|
||||
const ControlsSnapshot({
|
||||
this.play = PlayState.paused,
|
||||
this.previousLabel,
|
||||
this.nextLabel,
|
||||
this.nextIsUpNext = false,
|
||||
this.hasChapters = false,
|
||||
this.hasDuration = false,
|
||||
this.alone = false,
|
||||
this.hasLink = false,
|
||||
this.fullscreen = false,
|
||||
this.chatOpen = false,
|
||||
});
|
||||
}
|
||||
|
||||
/// The bar's two clusters for [s], in [ControlsLayout] order.
|
||||
({List<BarControl> left, List<BarControl> right}) barControls(ControlsSnapshot s, Set<OptionalControl> hidden) {
|
||||
bool on(BarControl c) => switch (c) {
|
||||
BarControl.previous => s.previousLabel != null,
|
||||
BarControl.next => s.nextLabel != null,
|
||||
BarControl.chapterPrevious || BarControl.chapterNext => s.hasChapters && !hidden.contains(OptionalControl.chapters),
|
||||
BarControl.subtitles => !hidden.contains(OptionalControl.subtitles),
|
||||
BarControl.audio => !hidden.contains(OptionalControl.audio),
|
||||
BarControl.chat => !hidden.contains(OptionalControl.chat),
|
||||
_ => true,
|
||||
};
|
||||
return (
|
||||
left: [
|
||||
for (final c in ControlsLayout.left)
|
||||
if (on(c)) c,
|
||||
],
|
||||
right: [
|
||||
for (final c in ControlsLayout.right)
|
||||
if (on(c)) c,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// The ⚙ menu for [s]: every action that applies, minus those the bar
|
||||
/// already shows.
|
||||
List<ControlAction> menuActions(ControlsSnapshot s, Set<OptionalControl> hidden) => [
|
||||
for (final a in ControlsLayout.menu)
|
||||
if (switch (a) {
|
||||
ControlAction.chapters => s.hasChapters,
|
||||
ControlAction.subtitles => hidden.contains(OptionalControl.subtitles),
|
||||
ControlAction.audio => hidden.contains(OptionalControl.audio),
|
||||
ControlAction.skipForward => s.hasDuration,
|
||||
ControlAction.chat => hidden.contains(OptionalControl.chat),
|
||||
ControlAction.copyLink => s.hasLink,
|
||||
ControlAction.markAllReady => !s.alone,
|
||||
_ => true,
|
||||
})
|
||||
a,
|
||||
];
|
||||
|
||||
/// Whether a bar control does something now (a skin dims it otherwise).
|
||||
bool controlEnabled(BarControl c, ControlsSnapshot s) => switch (c) {
|
||||
BarControl.chapterPrevious || BarControl.chapterNext => s.hasChapters && s.hasDuration,
|
||||
BarControl.previous => s.previousLabel != null,
|
||||
BarControl.next => s.nextLabel != null,
|
||||
_ => true,
|
||||
};
|
||||
|
||||
/// The icon of a bar control (volume and the clock draw their own).
|
||||
IconData controlIcon(BarControl c, ControlsSnapshot s) => switch (c) {
|
||||
BarControl.playPause => switch (s.play) {
|
||||
PlayState.playing => Icons.pause_rounded,
|
||||
// Ready: the check says so; a click unreadies.
|
||||
PlayState.waiting => Icons.check_rounded,
|
||||
PlayState.finished => Icons.replay_rounded,
|
||||
PlayState.paused => Icons.play_arrow_rounded,
|
||||
},
|
||||
BarControl.previous => Icons.skip_previous_rounded,
|
||||
BarControl.next => Icons.skip_next_rounded,
|
||||
BarControl.chapterPrevious => Icons.chevron_left_rounded,
|
||||
BarControl.chapterNext => Icons.chevron_right_rounded,
|
||||
BarControl.volume => Icons.volume_up_rounded,
|
||||
BarControl.time => Icons.schedule_rounded,
|
||||
BarControl.subtitles => Icons.subtitles_outlined,
|
||||
BarControl.audio => Icons.graphic_eq_rounded,
|
||||
BarControl.settings => Icons.settings_outlined,
|
||||
BarControl.chat => s.chatOpen ? Icons.chat_bubble_rounded : Icons.chat_bubble_outline_rounded,
|
||||
BarControl.fullscreen => s.fullscreen ? Icons.fullscreen_exit_rounded : Icons.fullscreen_rounded,
|
||||
};
|
||||
|
||||
/// The tooltip of a bar control, with its key.
|
||||
String controlTooltip(BarControl c, ControlsSnapshot s) => switch (c) {
|
||||
BarControl.playPause => switch (s.play) {
|
||||
PlayState.playing => 'Pause for everyone (Space)',
|
||||
PlayState.waiting => 'Ready, waiting for the others (Space: not ready)',
|
||||
PlayState.paused => s.alone ? 'Play (Space)' : 'Ready: play when everyone is (Space)',
|
||||
PlayState.finished => 'Ready (Space)',
|
||||
},
|
||||
BarControl.previous => 'Previous: ${s.previousLabel ?? ''} (< or Shift+P)',
|
||||
BarControl.next => '${s.nextIsUpNext ? 'Up next' : 'Next'}: ${s.nextLabel ?? ''} (> or Shift+N)',
|
||||
BarControl.chapterPrevious => 'Previous chapter (PgUp)',
|
||||
BarControl.chapterNext => 'Next chapter (PgDn)',
|
||||
BarControl.volume => 'Volume (scroll, ↑ / ↓)',
|
||||
BarControl.time => 'Elapsed / remaining (click to switch)',
|
||||
BarControl.subtitles => 'Subtitles',
|
||||
BarControl.audio => 'Audio',
|
||||
BarControl.settings => 'Settings and more',
|
||||
BarControl.chat => 'Chat & activity (Tab)',
|
||||
BarControl.fullscreen => s.fullscreen ? 'Exit fullscreen (F, Esc)' : 'Fullscreen (F)',
|
||||
};
|
||||
|
||||
/// The bar's clock: "6:00 / 24:00", or the time left ("-18:00"); "0:00"
|
||||
/// parts while the length is unknown.
|
||||
String clockText(Duration position, Duration duration, TimeDisplay display) {
|
||||
if (display == TimeDisplay.remaining && duration > Duration.zero) {
|
||||
final left = duration - position;
|
||||
return '-${formatClock(left.isNegative ? Duration.zero : left)}';
|
||||
}
|
||||
return '${formatClock(position)} / ${formatClock(duration)}';
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:signals/signals_flutter.dart';
|
||||
|
||||
import '../../jellyfin/track_prefs.dart' show MpvTrack;
|
||||
import '../../playback/media_marks.dart';
|
||||
import '../../playback/playback_controller.dart';
|
||||
import '../../queue/queue_controller.dart';
|
||||
import '../../session/session_coordinator.dart';
|
||||
import '../../shaders/shader_presets.dart';
|
||||
import '../../state/settings.dart';
|
||||
import '../player_settings.dart' show applyShaderChoice;
|
||||
import '../volume.dart' as vol;
|
||||
import 'controls_layout.dart';
|
||||
|
||||
/// A track pick from a track menu: audio id, subtitle id, or off.
|
||||
typedef TrackPick = ({int? aid, int? sid, bool subtitlesOff});
|
||||
|
||||
/// The player controls, independent of how they look: what the controls
|
||||
/// show (state, as signals) and what they do (actions), wired to the
|
||||
/// session once. Both skins only draw this and call its actions, so a
|
||||
/// control added here shows in both. Built by the session player per
|
||||
/// mount; [fullscreen], [chatOpen] and [unread] are the shell's, passed in.
|
||||
class PlayerControlsModel {
|
||||
final SessionCoordinator coord;
|
||||
final ReadonlySignal<bool> fullscreen;
|
||||
final ReadonlySignal<bool> chatOpen;
|
||||
final ReadonlySignal<int> unread;
|
||||
final VoidCallback _toggleFullscreen;
|
||||
final VoidCallback _toggleChat;
|
||||
final VoidCallback _changeVideo;
|
||||
final VoidCallback _collapse;
|
||||
final VoidCallback _leave;
|
||||
|
||||
PlayerControlsModel({
|
||||
required this.coord,
|
||||
required this.fullscreen,
|
||||
required this.chatOpen,
|
||||
required this.unread,
|
||||
required VoidCallback onToggleFullscreen,
|
||||
required VoidCallback onToggleChat,
|
||||
required VoidCallback onChangeVideo,
|
||||
required VoidCallback onCollapse,
|
||||
required VoidCallback onLeave,
|
||||
}) : _toggleFullscreen = onToggleFullscreen,
|
||||
_toggleChat = onToggleChat,
|
||||
_changeVideo = onChangeVideo,
|
||||
_collapse = onCollapse,
|
||||
_leave = onLeave;
|
||||
|
||||
PlaybackController get playback => coord.playback;
|
||||
QueueController get queue => coord.queue;
|
||||
AppSettings get settings => AppSettings.instance;
|
||||
|
||||
// ---------------------------------------------------------------- state
|
||||
|
||||
ReadonlySignal<Duration> get position => playback.position;
|
||||
ReadonlySignal<Duration> get duration => playback.duration;
|
||||
|
||||
/// End of the cached range ahead of the position.
|
||||
ReadonlySignal<Duration> get buffered => playback.buffered;
|
||||
ReadonlySignal<List<MediaChapter>> get chapters => playback.chapters;
|
||||
ReadonlySignal<int?> get currentChapter => playback.currentChapter;
|
||||
ReadonlySignal<List<MpvTrack>> get tracks => playback.tracks;
|
||||
|
||||
/// 0..`maxVolume`.
|
||||
ReadonlySignal<double> get volume => playback.volume;
|
||||
ReadonlySignal<SkipOffer?> get skipOffer => coord.skipOffer;
|
||||
ReadonlySignal<RenderMode> get renderMode => playback.renderMode;
|
||||
|
||||
/// Play / pause as the session sees it ([PlayState]).
|
||||
late final Computed<PlayState> play = computed(() {
|
||||
final r = coord.serverState.value;
|
||||
final self = coord.selfMember.value;
|
||||
return switch (r?.transport) {
|
||||
'playing' => PlayState.playing,
|
||||
'finished' => PlayState.finished,
|
||||
_ => (self?.ready ?? false) && (self?.inSet ?? false) ? PlayState.waiting : PlayState.paused,
|
||||
};
|
||||
});
|
||||
|
||||
/// Everything the layout depends on (not the position: that changes
|
||||
/// four times a second and only the timeline needs it).
|
||||
late final Computed<ControlsSnapshot> snapshot = computed(() {
|
||||
final queued = queue.items.value.firstOrNull;
|
||||
final continues = queue.continues.value;
|
||||
final next = coord.nextEpisode.value;
|
||||
return ControlsSnapshot(
|
||||
play: play.value,
|
||||
previousLabel: coord.previousEpisode.value?.label,
|
||||
// Up next's first item, else the next episode, else what continues
|
||||
// this (a file's next episode).
|
||||
nextLabel: queued?.title ?? next?.label ?? continues?.title,
|
||||
nextIsUpNext: queued != null,
|
||||
hasChapters: chapters.value.isNotEmpty,
|
||||
hasDuration: duration.value > Duration.zero,
|
||||
alone: coord.alone.value,
|
||||
// Not a signal: read again whenever the state changes.
|
||||
hasLink: coord.serverState.value != null && coord.currentLink() != null,
|
||||
fullscreen: fullscreen.value,
|
||||
chatOpen: chatOpen.value,
|
||||
);
|
||||
});
|
||||
|
||||
/// The bar's clusters, in the layout's order.
|
||||
late final Computed<({List<BarControl> left, List<BarControl> right})> bar = computed(
|
||||
() => barControls(snapshot.value, settings.hiddenControls.value),
|
||||
);
|
||||
|
||||
/// What the ⚙ menu lists now.
|
||||
List<ControlAction> menu() => menuActions(snapshot.peek(), settings.hiddenControls.peek());
|
||||
|
||||
/// Ticks on each wheel / key volume step: the skins flash the volume.
|
||||
final volumeFlash = ValueNotifier(0);
|
||||
|
||||
/// A menu of the controls is open (the player's Esc closes it).
|
||||
int menusOpen = 0;
|
||||
|
||||
void dispose() => volumeFlash.dispose();
|
||||
|
||||
// -------------------------------------------------------------- actions
|
||||
|
||||
/// Space: ready / not ready (being ready is "play" in a server).
|
||||
void togglePlay() => coord.toggleReady();
|
||||
|
||||
/// A seek bar's press or drag; through `player.seek`, the user-seek
|
||||
/// path, so everyone follows.
|
||||
void seek(Duration to) => playback.userSeek(to);
|
||||
|
||||
/// Arrow keys: ±[secs], relative to a seek still in flight.
|
||||
void nudge(double secs) => coord.nudge(secs);
|
||||
|
||||
/// +1:30 for everyone.
|
||||
void skipForward() => coord.skipForward();
|
||||
|
||||
/// A skip pill (Skip OP / ED / 1:30).
|
||||
void skip(SkipOffer offer) => coord.skip(offer);
|
||||
|
||||
/// PgDn (+1) / PgUp (-1). False when there is no chapter to go to.
|
||||
bool stepChapter(int delta) => coord.stepChapter(delta);
|
||||
|
||||
void pickChapter(MediaChapter c) => coord.seekTo(c.start.inMicroseconds / 1e6);
|
||||
|
||||
/// What plays next (Up next's first item, else the continuation), else
|
||||
/// the next episode. False when neither.
|
||||
bool next() {
|
||||
if (queue.head.peek() != null) {
|
||||
queue.skip();
|
||||
return true;
|
||||
}
|
||||
if (coord.nextEpisode.peek() == null) return false;
|
||||
coord.playNextEpisode();
|
||||
return true;
|
||||
}
|
||||
|
||||
/// The previous episode. False without one.
|
||||
bool previous() {
|
||||
if (coord.previousEpisode.peek() == null) return false;
|
||||
coord.playPreviousEpisode();
|
||||
return true;
|
||||
}
|
||||
|
||||
void playNextEpisode() => coord.playNextEpisode();
|
||||
|
||||
void setVolume(double v) => playback.setVolume(v.clamp(0.0, vol.maxVolume));
|
||||
|
||||
/// One volume step up (+1) or down (-1) from the wheel or ↑ / ↓;
|
||||
/// flashes the volume readout.
|
||||
void stepVolume(int step) {
|
||||
setVolume(vol.stepVolume(volume.peek(), step * vol.volumeStep));
|
||||
volumeFlash.value++;
|
||||
}
|
||||
|
||||
double _beforeMute = 100;
|
||||
|
||||
/// The speaker: mute, or back to the volume before.
|
||||
void toggleMute() {
|
||||
final v = volume.peek();
|
||||
if (v > 0) {
|
||||
_beforeMute = v;
|
||||
setVolume(0);
|
||||
} else {
|
||||
setVolume(_beforeMute <= 0 ? 100 : _beforeMute);
|
||||
}
|
||||
}
|
||||
|
||||
void pickTrack(TrackPick p) => coord.selectTrack(aid: p.aid, sid: p.sid, subtitlesOff: p.subtitlesOff);
|
||||
|
||||
/// Saved as the choice and applied live; a null quality keeps the
|
||||
/// saved one.
|
||||
void pickShader(ShaderPreset preset, ShaderQuality? quality) => applyShaderChoice(coord, preset, quality);
|
||||
|
||||
void toggleTimeDisplay() => settings.setTimeDisplay(settings.timeDisplay.peek().toggled);
|
||||
|
||||
void toggleFullscreen() => _toggleFullscreen();
|
||||
|
||||
void toggleChat() {
|
||||
coord.markInput();
|
||||
_toggleChat();
|
||||
}
|
||||
|
||||
void changeVideo() => _changeVideo();
|
||||
|
||||
/// Back to the library (keeps playing in the mini player).
|
||||
void collapse() => _collapse();
|
||||
|
||||
/// Asks before leaving the server.
|
||||
void leave() => _leave();
|
||||
|
||||
void reload() {
|
||||
coord.markInput();
|
||||
coord.reloadVideo();
|
||||
}
|
||||
|
||||
void markAllReady() => coord.forcePlayAll();
|
||||
|
||||
String? link() => coord.currentLink();
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
// The player controls' user choices (Settings → Player, stored in
|
||||
// `AppSettings`). Plain values: the layout spec and both skins read them.
|
||||
|
||||
/// The controls' look. Both skins draw the same controls model in the same
|
||||
/// order ([ControlsLayout]); only the look differs.
|
||||
enum ControlsSkin {
|
||||
/// Material-style bar: a seek bar over a row of round icon buttons.
|
||||
standard('Standard'),
|
||||
|
||||
/// The app's own timeline skin (inspired by mpv's uosc): a thick band
|
||||
/// with the buffered part, chapters and times, flat glass buttons.
|
||||
minimal('Minimal');
|
||||
|
||||
final String label;
|
||||
const ControlsSkin(this.label);
|
||||
|
||||
/// A stored name; the old `mpvControls` flag (true: the uosc design)
|
||||
/// when there is none.
|
||||
static ControlsSkin fromStored(Object? name, {Object? legacyMpvControls}) {
|
||||
for (final s in values) {
|
||||
if (s.name == name) return s;
|
||||
}
|
||||
return legacyMpvControls == true ? minimal : standard;
|
||||
}
|
||||
}
|
||||
|
||||
/// The bar's clock: "6:00 / 24:00" or the time left ("-18:00").
|
||||
enum TimeDisplay {
|
||||
elapsed('Elapsed / total'),
|
||||
remaining('Remaining');
|
||||
|
||||
final String label;
|
||||
const TimeDisplay(this.label);
|
||||
|
||||
TimeDisplay get toggled => this == elapsed ? remaining : elapsed;
|
||||
}
|
||||
|
||||
/// Bar and button size.
|
||||
enum ControlsSize {
|
||||
normal('Normal', 1.0),
|
||||
large('Large', 1.25);
|
||||
|
||||
final String label;
|
||||
final double scale;
|
||||
const ControlsSize(this.label, this.scale);
|
||||
}
|
||||
|
||||
/// Bar buttons the user may hide; hidden ones are still in ⚙.
|
||||
enum OptionalControl {
|
||||
chapters('Chapter buttons', 'Previous / next chapter (PgUp / PgDn)'),
|
||||
subtitles('Subtitles', 'The subtitle track list'),
|
||||
audio('Audio', 'The audio track list'),
|
||||
chat('Chat', 'Chat & activity (Tab)');
|
||||
|
||||
final String label;
|
||||
final String help;
|
||||
const OptionalControl(this.label, this.help);
|
||||
}
|
||||
|
||||
/// The auto-hide delays offered (seconds).
|
||||
const controlsHideChoices = [2, 3, 5];
|
||||
|
||||
/// The default auto-hide delay (seconds).
|
||||
const defaultControlsHideSecs = 3;
|
||||
@@ -0,0 +1,468 @@
|
||||
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 '../session/session_coordinator.dart' show SessionCoordinator;
|
||||
import '../shaders/shader_presets.dart';
|
||||
import '../app/app_graph.dart';
|
||||
import '../shell/mark_problem.dart';
|
||||
import '../shell/motion.dart';
|
||||
import '../state/settings.dart';
|
||||
import '../widgets/app_menu.dart';
|
||||
import 'frame.dart';
|
||||
import 'model/controls_prefs.dart';
|
||||
|
||||
// Everything about the player, defined once: the rows of the player
|
||||
// settings card (⚙ → Player settings, both skins) 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,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
AppSelect<T> _dropdown<T>({
|
||||
required Key key,
|
||||
required T value,
|
||||
required List<(T, String)> items,
|
||||
required ValueChanged<T> onChanged,
|
||||
bool expanded = false,
|
||||
}) =>
|
||||
AppSelect<T>(key: key, value: value, items: items, onChanged: onChanged, expanded: expanded);
|
||||
|
||||
/// The controls' skin: Standard or Minimal. 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: 'Minimal has a thick timeline with chapters and what is downloaded yet',
|
||||
control: _dropdown<ControlsSkin>(
|
||||
key: const ValueKey('player-design'),
|
||||
value: s.controlsSkin.value,
|
||||
items: [for (final k in ControlsSkin.values) (k, k.label)],
|
||||
onChanged: s.setControlsSkin,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The bar's clock: elapsed / total or the time left. Live.
|
||||
class TimeDisplaySetting extends StatelessWidget {
|
||||
const TimeDisplaySetting({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final s = AppSettings.instance;
|
||||
return SignalBuilder(
|
||||
builder: (context) => SettingRow(
|
||||
label: 'Time display',
|
||||
help: 'Also switched by a click on the time',
|
||||
control: _dropdown<TimeDisplay>(
|
||||
key: const ValueKey('time-display'),
|
||||
value: s.timeDisplay.value,
|
||||
items: [for (final t in TimeDisplay.values) (t, t.label)],
|
||||
onChanged: s.setTimeDisplay,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Normal or large bar and buttons. Live.
|
||||
class ControlsSizeSetting extends StatelessWidget {
|
||||
const ControlsSizeSetting({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final s = AppSettings.instance;
|
||||
return SignalBuilder(
|
||||
builder: (context) => SettingRow(
|
||||
label: 'Controls size',
|
||||
help: 'Large makes the bar and its buttons a quarter bigger',
|
||||
control: _dropdown<ControlsSize>(
|
||||
key: const ValueKey('controls-size'),
|
||||
value: s.controlsSize.value,
|
||||
items: [for (final z in ControlsSize.values) (z, z.label)],
|
||||
onChanged: s.setControlsSize,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// How long the controls stay up without mouse movement. Live.
|
||||
class HideDelaySetting extends StatelessWidget {
|
||||
const HideDelaySetting({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final s = AppSettings.instance;
|
||||
return SignalBuilder(
|
||||
builder: (context) => SettingRow(
|
||||
label: 'Hide the controls after',
|
||||
help: 'Without mouse movement over the video',
|
||||
control: _dropdown<int>(
|
||||
key: const ValueKey('controls-hide'),
|
||||
value: s.controlsHideSecs.value,
|
||||
items: [for (final secs in controlsHideChoices) (secs, '$secs s')],
|
||||
onChanged: s.setControlsHideSecs,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// One optional bar button on or off (off: it is still in ⚙). Live.
|
||||
class BarButtonSetting extends StatelessWidget {
|
||||
final OptionalControl control;
|
||||
const BarButtonSetting({super.key, required this.control});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final s = AppSettings.instance;
|
||||
return SignalBuilder(
|
||||
builder: (context) => SettingRow(
|
||||
label: control.label,
|
||||
help: control.help,
|
||||
control: Switch(
|
||||
key: ValueKey('bar-${control.name}'),
|
||||
value: !s.hiddenControls.value.contains(control),
|
||||
onChanged: (on) => s.setControlShown(control, on),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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(SessionCoordinator coord, ShaderPreset preset, ShaderQuality? quality) {
|
||||
final s = AppSettings.instance;
|
||||
if (quality != null) s.setShaderQuality(quality);
|
||||
s.setShaderPreset(preset);
|
||||
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 ??
|
||||
context.getInheritedWidgetOfExactType<AppScope>()?.graph.ports.players.renderMode)
|
||||
?.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(AppScope.of(context).coordinator, 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(AppScope.of(context).coordinator, 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(PlaybackController playback) async {
|
||||
if ((Platform.environment['SYNC_RELAY_HWDEC'] ?? '').isNotEmpty) return;
|
||||
final p = playback.current;
|
||||
if (p == null) return;
|
||||
try {
|
||||
await p.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(AppScope.of(context).coordinator.playback);
|
||||
},
|
||||
),
|
||||
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(AppScope.of(context).coordinator.playback),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 = AppScope.of(context).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 controls' own choices (Settings → Player → Controls).
|
||||
List<Widget> controlsSettingRows() => [
|
||||
const TimeDisplaySetting(),
|
||||
const ControlsSizeSetting(),
|
||||
const HideDelaySetting(),
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(top: 10, bottom: 2),
|
||||
child: Text('Buttons in the bar (hidden ones stay in ⚙)', style: TextStyle(fontWeight: FontWeight.w600)),
|
||||
),
|
||||
for (final c in OptionalControl.values) BarButtonSetting(control: c),
|
||||
];
|
||||
|
||||
/// The player rows of Settings → Player and the player settings card, in
|
||||
/// order.
|
||||
List<Widget> playerSettingRows({ReadonlySignal<RenderMode>? renderMode}) => [
|
||||
const PlayerDesignSetting(),
|
||||
const ProgressLineSetting(),
|
||||
ShaderPresetSetting(renderMode: renderMode),
|
||||
const HwdecSetting(),
|
||||
];
|
||||
|
||||
/// The player settings 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) {
|
||||
// The app's glass, like the Up next popover and the menus.
|
||||
return AppGlass(
|
||||
key: const ValueKey('player-settings-card'),
|
||||
child: SizedBox(
|
||||
width: 420,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 10),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const Text('Player', style: TextStyle(fontWeight: FontWeight.w600, fontSize: 14)),
|
||||
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),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Controls layout and custom shaders: Settings → Player',
|
||||
style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant, fontSize: 12),
|
||||
),
|
||||
),
|
||||
const MarkProblemButton(),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens the player settings card over the player, near the controls'
|
||||
/// right end; the controls stay up while it is open.
|
||||
Future<void> showPlayerSettings(BuildContext context, {ReadonlySignal<RenderMode>? renderMode}) =>
|
||||
ControlsFrame.holdWhile(
|
||||
context,
|
||||
() => showPopover<void>(
|
||||
context,
|
||||
at: const Alignment(0.96, 0.5),
|
||||
child: PlayerSettingsCard(renderMode: renderMode),
|
||||
),
|
||||
);
|
||||
@@ -0,0 +1,83 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:signals/signals_flutter.dart';
|
||||
|
||||
import '../state/settings.dart';
|
||||
import 'controls_probe.dart' show ControlsHold;
|
||||
import 'frame.dart';
|
||||
import 'minimal/minimal_controls.dart';
|
||||
import 'model/controls_model.dart';
|
||||
import 'model/controls_prefs.dart';
|
||||
import 'standard/standard_controls.dart';
|
||||
|
||||
// The session's controls: one [ControlsFrame] (when they are up) around the
|
||||
// skin the settings pick, both drawing the same [PlayerControlsModel].
|
||||
// Skin, size and the hide delay follow the settings live.
|
||||
|
||||
/// The bottom controls' height in [skin] at [size] (the player's floating
|
||||
/// pills sit just above it).
|
||||
double controlsBottomHeight(ControlsSkin skin, ControlsSize size) => switch (skin) {
|
||||
ControlsSkin.standard => StandardControls.bottomHeight(size.scale),
|
||||
ControlsSkin.minimal => MinimalControls.bottomHeight(size.scale),
|
||||
};
|
||||
|
||||
/// The top bar's height in [skin].
|
||||
double controlsTopHeight(ControlsSkin skin) => switch (skin) {
|
||||
ControlsSkin.standard => StandardControls.topHeight,
|
||||
ControlsSkin.minimal => MinimalControls.topHeight,
|
||||
};
|
||||
|
||||
class SessionControls extends StatelessWidget {
|
||||
final PlayerControlsModel model;
|
||||
|
||||
/// Follows whether the controls are up.
|
||||
final ValueNotifier<bool> shown;
|
||||
final ControlsHold hold;
|
||||
final VoidCallback onDoubleClick;
|
||||
final Widget? windowButtons;
|
||||
const SessionControls({
|
||||
super.key,
|
||||
required this.model,
|
||||
required this.shown,
|
||||
required this.hold,
|
||||
required this.onDoubleClick,
|
||||
this.windowButtons,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final s = AppSettings.instance;
|
||||
return SignalBuilder(
|
||||
builder: (context) {
|
||||
final skin = s.controlsSkin.value;
|
||||
final size = s.controlsSize.value;
|
||||
final scale = size.scale;
|
||||
return ControlsFrame(
|
||||
// A new frame per skin: each starts up, like a fresh mount.
|
||||
key: ValueKey(skin),
|
||||
shown: shown,
|
||||
holdBy: hold,
|
||||
hideAfter: Duration(seconds: s.controlsHideSecs.value),
|
||||
onDoubleClick: onDoubleClick,
|
||||
barTop: controlsTopHeight(skin),
|
||||
barBottom: controlsBottomHeight(skin, size),
|
||||
builder: (context, visible) => switch (skin) {
|
||||
ControlsSkin.standard => StandardControls(
|
||||
model: model,
|
||||
visible: visible,
|
||||
scale: scale,
|
||||
hold: hold,
|
||||
windowButtons: windowButtons,
|
||||
),
|
||||
ControlsSkin.minimal => MinimalControls(
|
||||
model: model,
|
||||
visible: visible,
|
||||
scale: scale,
|
||||
hold: hold,
|
||||
windowButtons: windowButtons,
|
||||
),
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../playback/media_marks.dart';
|
||||
import '../frame.dart';
|
||||
|
||||
// The Standard skin's seek bar: a Material-style track with the played and
|
||||
// buffered parts, chapter marks and a hover tip.
|
||||
|
||||
/// The seek bar: played / buffered track, a diamond at each chapter start
|
||||
/// (not the one at 0), and on hover the time with the chapter title above
|
||||
/// it. Like media_kit's bar it reports a seek on every drag move and on
|
||||
/// release ([onSeek]); the session routes those through the user-seek path
|
||||
/// (debounced, everyone follows).
|
||||
class ChapterSeekBar extends StatefulWidget {
|
||||
final Duration position;
|
||||
final Duration duration;
|
||||
final Duration buffer;
|
||||
final List<MediaChapter> chapters;
|
||||
final ValueChanged<Duration> onSeek;
|
||||
|
||||
/// The bar's height (its hit area); the track is centred in it.
|
||||
final double height;
|
||||
const ChapterSeekBar({
|
||||
super.key,
|
||||
required this.position,
|
||||
required this.duration,
|
||||
this.buffer = Duration.zero,
|
||||
this.chapters = const [],
|
||||
required this.onSeek,
|
||||
this.height = ChapterSeekBar.defaultHeight,
|
||||
});
|
||||
|
||||
static const defaultHeight = 28.0;
|
||||
|
||||
@override
|
||||
State<ChapterSeekBar> createState() => _ChapterSeekBarState();
|
||||
}
|
||||
|
||||
class _ChapterSeekBarState extends State<ChapterSeekBar> {
|
||||
double? _hoverX;
|
||||
bool _dragging = false;
|
||||
double _dragFraction = 0;
|
||||
|
||||
double _fraction(Duration d) {
|
||||
final total = widget.duration.inMicroseconds;
|
||||
if (total <= 0) return 0;
|
||||
return (d.inMicroseconds / total).clamp(0.0, 1.0);
|
||||
}
|
||||
|
||||
Duration _at(double fraction) => widget.duration * fraction.clamp(0.0, 1.0);
|
||||
|
||||
// A drag sends no hover events: the controls are held up through it.
|
||||
void _drag(bool on) {
|
||||
if (_dragging == on) return;
|
||||
setState(() => _dragging = on);
|
||||
ControlsFrame.maybeOf(context)?.hold(on);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
if (_dragging) ControlsFrame.maybeOf(context)?.hold(false);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _seekTo(double x, double width) {
|
||||
if (widget.duration <= Duration.zero || width <= 0) return;
|
||||
_dragFraction = (x / width).clamp(0.0, 1.0);
|
||||
widget.onSeek(_at(_dragFraction));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final primary = Theme.of(context).colorScheme.primary;
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final width = constraints.maxWidth;
|
||||
final active = _hoverX != null || _dragging;
|
||||
final barHeight = active ? 5.0 : 3.0;
|
||||
final played = _dragging ? _dragFraction : _fraction(widget.position);
|
||||
final mid = widget.height / 2;
|
||||
final hoverX = _hoverX;
|
||||
final hoverAt = hoverX == null || width <= 0 ? null : _at(hoverX / width);
|
||||
final hoverChapter = hoverAt == null ? null : chapterIndexAt(widget.chapters, hoverAt);
|
||||
final hoverTitle = hoverChapter == null ? '' : widget.chapters[hoverChapter].title;
|
||||
return MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
onEnter: (e) => setState(() => _hoverX = e.localPosition.dx.clamp(0, width)),
|
||||
onHover: (e) => setState(() => _hoverX = e.localPosition.dx.clamp(0, width)),
|
||||
onExit: (_) => setState(() => _hoverX = null),
|
||||
child: Listener(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onPointerDown: (e) {
|
||||
_dragFraction = width <= 0 ? 0 : (e.localPosition.dx / width).clamp(0.0, 1.0);
|
||||
_drag(true);
|
||||
},
|
||||
onPointerMove: (e) {
|
||||
setState(() => _hoverX = e.localPosition.dx.clamp(0, width));
|
||||
_seekTo(e.localPosition.dx, width);
|
||||
},
|
||||
onPointerUp: (e) {
|
||||
_seekTo(e.localPosition.dx, width);
|
||||
_drag(false);
|
||||
},
|
||||
onPointerCancel: (_) => _drag(false),
|
||||
child: SizedBox(
|
||||
key: const ValueKey('seek-bar'),
|
||||
width: width,
|
||||
height: widget.height,
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: mid - barHeight / 2,
|
||||
height: barHeight,
|
||||
child: Container(color: Colors.white24),
|
||||
),
|
||||
Positioned(
|
||||
left: 0,
|
||||
top: mid - barHeight / 2,
|
||||
height: barHeight,
|
||||
width: width * _fraction(widget.buffer),
|
||||
child: Container(color: Colors.white24),
|
||||
),
|
||||
if (hoverX != null && !_dragging)
|
||||
Positioned(
|
||||
left: 0,
|
||||
top: mid - barHeight / 2,
|
||||
height: barHeight,
|
||||
width: hoverX,
|
||||
child: Container(color: Colors.white24),
|
||||
),
|
||||
Positioned(
|
||||
left: 0,
|
||||
top: mid - barHeight / 2,
|
||||
height: barHeight,
|
||||
width: width * played,
|
||||
child: Container(color: primary),
|
||||
),
|
||||
for (final c in widget.chapters)
|
||||
if (c.start > Duration.zero && widget.duration > Duration.zero)
|
||||
Positioned(
|
||||
key: ValueKey('chapter-mark-${c.index}'),
|
||||
left: width * _fraction(c.start) - 4,
|
||||
top: mid - 4,
|
||||
width: 8,
|
||||
height: 8,
|
||||
child: IgnorePointer(
|
||||
child: Transform.rotate(
|
||||
angle: math.pi / 4,
|
||||
child: Container(
|
||||
margin: const EdgeInsets.all(1),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
border: Border.all(color: Colors.black54, width: 0.8),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (active)
|
||||
Positioned(
|
||||
left: width * played - 6,
|
||||
top: mid - 6,
|
||||
width: 12,
|
||||
height: 12,
|
||||
child: IgnorePointer(
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(color: primary, shape: BoxShape.circle),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (hoverAt != null)
|
||||
Positioned(
|
||||
// Kept clear of the window edges (the tip is centred).
|
||||
left: hoverX!.clamp(math.min(60.0, width / 2), math.max(width - 60, width / 2)),
|
||||
bottom: widget.height - 2,
|
||||
child: IgnorePointer(
|
||||
child: FractionalTranslation(
|
||||
translation: const Offset(-0.5, 0),
|
||||
child: _HoverTip(title: hoverTitle, time: formatClock(hoverAt)),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _HoverTip extends StatelessWidget {
|
||||
final String title;
|
||||
final String time;
|
||||
const _HoverTip({required this.title, required this.time});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
constraints: const BoxConstraints(maxWidth: 320),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(color: Colors.black.withValues(alpha: 0.8), borderRadius: BorderRadius.circular(6)),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (title.isNotEmpty)
|
||||
Text(
|
||||
title,
|
||||
key: const ValueKey('seek-hover-chapter'),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(color: Colors.white, fontSize: 12, fontWeight: FontWeight.w600),
|
||||
),
|
||||
Text(
|
||||
time,
|
||||
key: const ValueKey('seek-hover-time'),
|
||||
style: const TextStyle(color: Colors.white70, fontSize: 11),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:signals/signals_flutter.dart';
|
||||
import 'package:sync_relay_core/core.dart' show JellyfinStreamType;
|
||||
|
||||
import '../../queue/queue_ui.dart';
|
||||
import '../../shell/now_playing_title.dart';
|
||||
import '../../state/settings.dart';
|
||||
import '../control_menus.dart';
|
||||
import '../controls_probe.dart' show ControlsHold;
|
||||
import '../frame.dart';
|
||||
import '../model/controls_layout.dart';
|
||||
import '../model/controls_model.dart';
|
||||
import '../volume.dart';
|
||||
import 'seek_bar.dart';
|
||||
|
||||
// The Standard skin: Material-style bars over the picture. Top: what plays,
|
||||
// Up next and the window controls; bottom: the seek bar (chapter marks,
|
||||
// hover time) over a row of round icon buttons in the layout's order.
|
||||
// Hidden, a thin progress line stays (unless switched off).
|
||||
|
||||
class StandardControls extends StatelessWidget {
|
||||
final PlayerControlsModel model;
|
||||
final bool visible;
|
||||
final double scale;
|
||||
final ControlsHold? hold;
|
||||
|
||||
/// Right of the top bar's own buttons (the window buttons in fullscreen).
|
||||
final Widget? windowButtons;
|
||||
const StandardControls({
|
||||
super.key,
|
||||
required this.model,
|
||||
required this.visible,
|
||||
this.scale = 1,
|
||||
this.hold,
|
||||
this.windowButtons,
|
||||
});
|
||||
|
||||
/// The top bar: room for the cover (2:3, 60 px tall).
|
||||
static const topHeight = 72.0;
|
||||
static const _rowHeight = 56.0;
|
||||
|
||||
/// The bottom bar (seek bar and buttons) at [scale].
|
||||
static double bottomHeight(double scale) => (_rowHeight + ChapterSeekBar.defaultHeight) * scale;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
const fade = ControlsFrame.fade;
|
||||
Widget bar(Widget child) => IgnorePointer(
|
||||
ignoring: !visible,
|
||||
child: AnimatedOpacity(duration: fade, opacity: visible ? 1 : 0, child: child),
|
||||
);
|
||||
// No button here takes focus (Space is the ready key); the top bar's
|
||||
// Up next keeps its own (its popover has a text field).
|
||||
return Stack(
|
||||
children: [
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
child: bar(
|
||||
DecoratedBox(
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [Color(0x99000000), Color(0x00000000)],
|
||||
),
|
||||
),
|
||||
child: SizedBox(
|
||||
key: const ValueKey('standard-top'),
|
||||
height: topHeight,
|
||||
child: Padding(padding: const EdgeInsets.only(left: 16, right: 8), child: _top()),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: bar(
|
||||
DecoratedBox(
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [Color(0x00000000), Color(0xB3000000)],
|
||||
),
|
||||
),
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(top: 24 * scale),
|
||||
child: SizedBox(
|
||||
key: const ValueKey('standard-bottom'),
|
||||
height: bottomHeight(scale),
|
||||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: SignalBuilder(
|
||||
builder: (context) => ChapterSeekBar(
|
||||
position: model.position.value,
|
||||
duration: model.duration.value,
|
||||
buffer: model.buffered.value,
|
||||
chapters: model.chapters.value,
|
||||
height: ChapterSeekBar.defaultHeight * scale,
|
||||
onSeek: model.seek,
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: ExcludeFocus(
|
||||
child: SignalBuilder(
|
||||
builder: (context) {
|
||||
final s = model.snapshot.value;
|
||||
final layout = model.bar.value;
|
||||
return Row(
|
||||
key: const ValueKey('standard-buttons'),
|
||||
children: [
|
||||
for (final c in layout.left) _control(context, c, s),
|
||||
const Spacer(),
|
||||
for (final c in layout.right) _control(context, c, s),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Hidden: the thin progress line.
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: IgnorePointer(
|
||||
child: SignalBuilder(
|
||||
builder: (context) {
|
||||
if (visible || !AppSettings.instance.progressLine.value) return const SizedBox.shrink();
|
||||
final d = model.duration.value, p = model.position.value;
|
||||
final v = d.inMilliseconds == 0 ? 0.0 : (p.inMilliseconds / d.inMilliseconds).clamp(0.0, 1.0);
|
||||
return LinearProgressIndicator(
|
||||
key: const ValueKey('progress-line-standard'),
|
||||
value: v,
|
||||
minHeight: 2,
|
||||
backgroundColor: Colors.transparent,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _top() {
|
||||
final m = model;
|
||||
return Row(
|
||||
children: [
|
||||
const Expanded(child: NowPlayingTitle()),
|
||||
UpNextButton(queue: m.queue, look: UpNextLook.standard, hold: hold),
|
||||
SignalBuilder(
|
||||
builder: (context) {
|
||||
final full = m.fullscreen.value;
|
||||
return ExcludeFocus(
|
||||
child: _IconButton(
|
||||
key: const ValueKey('standard-collapse'),
|
||||
icon: full ? Icons.fullscreen_exit_rounded : Icons.close_fullscreen_rounded,
|
||||
tooltip: full ? 'Exit fullscreen (Esc)' : 'Back to the library (keeps playing)',
|
||||
onPressed: full ? m.toggleFullscreen : m.collapse,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
?windowButtons,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _control(BuildContext context, BarControl c, ControlsSnapshot s) {
|
||||
final m = model;
|
||||
final key = ValueKey('control-${c.name}');
|
||||
final enabled = controlEnabled(c, s);
|
||||
Widget icon(VoidCallback? onPressed, {int badge = 0, double size = 24, Color? color}) => _IconButton(
|
||||
key: key,
|
||||
icon: controlIcon(c, s),
|
||||
tooltip: controlTooltip(c, s),
|
||||
badge: badge,
|
||||
size: size * scale,
|
||||
color: color,
|
||||
onPressed: enabled ? onPressed : null,
|
||||
);
|
||||
switch (c) {
|
||||
case BarControl.playPause:
|
||||
final waiting = s.play == PlayState.waiting;
|
||||
return icon(m.togglePlay, size: 30, color: waiting ? Theme.of(context).colorScheme.primary : null);
|
||||
case BarControl.previous:
|
||||
return icon(m.previous);
|
||||
case BarControl.next:
|
||||
return icon(m.next);
|
||||
case BarControl.chapterPrevious:
|
||||
return icon(() => m.stepChapter(-1));
|
||||
case BarControl.chapterNext:
|
||||
return icon(() => m.stepChapter(1));
|
||||
case BarControl.volume:
|
||||
return SignalBuilder(
|
||||
key: key,
|
||||
builder: (context) => VolumeControl(
|
||||
volume: m.volume.value,
|
||||
onChanged: m.setVolume,
|
||||
onToggleMute: m.toggleMute,
|
||||
button: (context, i, color, tip, onPressed) =>
|
||||
_IconButton(icon: i, tooltip: tip, color: color, size: 24 * scale, onPressed: onPressed),
|
||||
),
|
||||
);
|
||||
case BarControl.time:
|
||||
return SignalBuilder(
|
||||
key: key,
|
||||
builder: (context) => _Clock(
|
||||
text: clockText(m.position.value, m.duration.value, m.settings.timeDisplay.value),
|
||||
tooltip: controlTooltip(c, s),
|
||||
scale: scale,
|
||||
onPressed: m.toggleTimeDisplay,
|
||||
),
|
||||
);
|
||||
case BarControl.subtitles || BarControl.audio:
|
||||
final type = c == BarControl.audio ? JellyfinStreamType.audio : JellyfinStreamType.subtitle;
|
||||
return SignalBuilder(
|
||||
key: key,
|
||||
builder: (context) => Builder(
|
||||
builder: (context) => _IconButton(
|
||||
icon: controlIcon(c, s),
|
||||
tooltip: controlTooltip(c, s),
|
||||
badge: trackCount(m.tracks.value, type),
|
||||
size: 22 * scale,
|
||||
onPressed: () => openTrackMenu(context, m, type),
|
||||
),
|
||||
),
|
||||
);
|
||||
case BarControl.settings:
|
||||
return Builder(
|
||||
key: key,
|
||||
builder: (context) => _IconButton(
|
||||
icon: controlIcon(c, s),
|
||||
tooltip: controlTooltip(c, s),
|
||||
size: 22 * scale,
|
||||
onPressed: () => openControlsMenu(context, m),
|
||||
),
|
||||
);
|
||||
case BarControl.chat:
|
||||
return SignalBuilder(
|
||||
key: key,
|
||||
builder: (context) => _IconButton(
|
||||
icon: controlIcon(c, s),
|
||||
tooltip: controlTooltip(c, s),
|
||||
badge: m.unread.value,
|
||||
size: 22 * scale,
|
||||
onPressed: m.toggleChat,
|
||||
),
|
||||
);
|
||||
case BarControl.fullscreen:
|
||||
return icon(m.toggleFullscreen, size: 26);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A round white icon button with an optional count badge (the Material
|
||||
/// look of media_kit's bar).
|
||||
class _IconButton extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String tooltip;
|
||||
final VoidCallback? onPressed;
|
||||
final int badge;
|
||||
final double size;
|
||||
final Color? color;
|
||||
const _IconButton({
|
||||
super.key,
|
||||
required this.icon,
|
||||
required this.tooltip,
|
||||
required this.onPressed,
|
||||
this.badge = 0,
|
||||
this.size = 24,
|
||||
this.color,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
return IconButton(
|
||||
tooltip: tooltip,
|
||||
iconSize: size,
|
||||
color: color ?? Colors.white,
|
||||
disabledColor: Colors.white38,
|
||||
onPressed: onPressed,
|
||||
icon: Badge(
|
||||
isLabelVisible: badge > 0,
|
||||
backgroundColor: scheme.primary,
|
||||
textColor: scheme.onPrimary,
|
||||
label: Text('$badge'),
|
||||
child: Icon(icon),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The clock: "6:00 / 24:00" or "-18:00"; a click switches.
|
||||
class _Clock extends StatelessWidget {
|
||||
final String text;
|
||||
final String tooltip;
|
||||
final double scale;
|
||||
final VoidCallback onPressed;
|
||||
const _Clock({required this.text, required this.tooltip, required this.scale, required this.onPressed});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Tooltip(
|
||||
message: tooltip,
|
||||
waitDuration: const Duration(milliseconds: 500),
|
||||
child: TextButton(
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: Colors.white,
|
||||
padding: EdgeInsets.symmetric(horizontal: 10 * scale),
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
),
|
||||
onPressed: onPressed,
|
||||
child: Text(
|
||||
text,
|
||||
style: TextStyle(fontSize: 14 * scale, fontFeatures: const [FontFeature.tabularFigures()]),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,334 +0,0 @@
|
||||
import 'package:flutter/gestures.dart';
|
||||
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/media_marks.dart';
|
||||
import '../../playback/playback_controller.dart' show RenderMode;
|
||||
import '../../screens/session/player_controls.dart' show TrackPick;
|
||||
import '../../shaders/shader_menu.dart';
|
||||
import '../../shaders/shader_presets.dart';
|
||||
import '../../state/settings.dart';
|
||||
import 'controls.dart';
|
||||
import 'menu.dart';
|
||||
import 'style.dart';
|
||||
|
||||
/// Flat uosc button: a white icon (optionally a [label] beside it and a
|
||||
/// small count [badge] in the corner), a faint highlight on hover, a dark
|
||||
/// tooltip. Never takes focus (the session's keys are global).
|
||||
class UoscButton extends StatefulWidget {
|
||||
final IconData icon;
|
||||
final String tooltip;
|
||||
final VoidCallback? onPressed;
|
||||
final String? label;
|
||||
final int? badge;
|
||||
/// Tint for an "on" state (ready, shaders on).
|
||||
final Color? color;
|
||||
const UoscButton({
|
||||
super.key,
|
||||
required this.icon,
|
||||
required this.tooltip,
|
||||
required this.onPressed,
|
||||
this.label,
|
||||
this.badge,
|
||||
this.color,
|
||||
});
|
||||
|
||||
@override
|
||||
State<UoscButton> createState() => _UoscButtonState();
|
||||
}
|
||||
|
||||
class _UoscButtonState extends State<UoscButton> {
|
||||
bool _hover = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final w = widget;
|
||||
final color = w.onPressed == null ? UoscStyle.dim.withValues(alpha: 0.4) : (w.color ?? UoscStyle.text);
|
||||
final badge = w.badge;
|
||||
return UoscTooltip(
|
||||
message: w.tooltip,
|
||||
child: MouseRegion(
|
||||
cursor: w.onPressed == null ? SystemMouseCursors.basic : SystemMouseCursors.click,
|
||||
onEnter: (_) => setState(() => _hover = true),
|
||||
onExit: (_) => setState(() => _hover = false),
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: w.onPressed,
|
||||
child: Container(
|
||||
height: UoscControls.buttonRowHeight,
|
||||
constraints: const BoxConstraints(minWidth: UoscControls.buttonRowHeight),
|
||||
padding: EdgeInsets.symmetric(horizontal: w.label == null ? 0 : 8),
|
||||
decoration: BoxDecoration(
|
||||
color: _hover && w.onPressed != null ? UoscStyle.hover : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(UoscStyle.radius),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
Icon(w.icon, color: color, size: 22),
|
||||
if (badge != null && badge > 0)
|
||||
Positioned(
|
||||
right: -8,
|
||||
bottom: -5,
|
||||
child: Container(
|
||||
key: const ValueKey('uosc-badge'),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: UoscStyle.text,
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
child: Text('$badge',
|
||||
style: const TextStyle(
|
||||
color: Colors.black, fontSize: 9, height: 1.3, fontWeight: FontWeight.w700)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (w.label != null) ...[
|
||||
const SizedBox(width: 6),
|
||||
Text(w.label!, style: TextStyle(color: color, fontSize: UoscStyle.fontSize)),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Badge-style text button ("HQ" in uosc): a small outlined box with a
|
||||
/// short label, filled when [active].
|
||||
class UoscBadgeButton extends StatefulWidget {
|
||||
final String text;
|
||||
final String tooltip;
|
||||
final bool active;
|
||||
final VoidCallback onPressed;
|
||||
const UoscBadgeButton({
|
||||
super.key,
|
||||
required this.text,
|
||||
required this.tooltip,
|
||||
required this.active,
|
||||
required this.onPressed,
|
||||
});
|
||||
|
||||
@override
|
||||
State<UoscBadgeButton> createState() => _UoscBadgeButtonState();
|
||||
}
|
||||
|
||||
class _UoscBadgeButtonState extends State<UoscBadgeButton> {
|
||||
bool _hover = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final w = widget;
|
||||
return UoscTooltip(
|
||||
message: w.tooltip,
|
||||
child: MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
onEnter: (_) => setState(() => _hover = true),
|
||||
onExit: (_) => setState(() => _hover = false),
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: w.onPressed,
|
||||
child: Container(
|
||||
height: UoscControls.buttonRowHeight,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: _hover ? UoscStyle.hover : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(UoscStyle.radius),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1),
|
||||
decoration: BoxDecoration(
|
||||
color: w.active ? UoscStyle.text : Colors.transparent,
|
||||
border: Border.all(color: w.active ? UoscStyle.text : UoscStyle.dim, width: 1.4),
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
child: Text(w.text,
|
||||
style: TextStyle(
|
||||
color: w.active ? Colors.black : UoscStyle.dim,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: 0.3)),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Volume: click mutes / unmutes, the wheel steps 5 %.
|
||||
class UoscVolumeButton extends StatefulWidget {
|
||||
/// 0..100.
|
||||
final double volume;
|
||||
final ValueChanged<double> onChanged;
|
||||
const UoscVolumeButton({super.key, required this.volume, required this.onChanged});
|
||||
|
||||
@override
|
||||
State<UoscVolumeButton> createState() => _UoscVolumeButtonState();
|
||||
}
|
||||
|
||||
class _UoscVolumeButtonState extends State<UoscVolumeButton> {
|
||||
double _beforeMute = 100;
|
||||
|
||||
@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(
|
||||
onPointerSignal: (e) {
|
||||
if (e is PointerScrollEvent && e.scrollDelta.dy != 0) {
|
||||
widget.onChanged((v + (e.scrollDelta.dy < 0 ? 5 : -5)).clamp(0.0, 100.0));
|
||||
}
|
||||
},
|
||||
child: UoscButton(
|
||||
key: const ValueKey('uosc-volume'),
|
||||
icon: icon,
|
||||
tooltip: 'Volume ${v.round()} % (scroll to change)',
|
||||
onPressed: () {
|
||||
if (v > 0) {
|
||||
_beforeMute = v;
|
||||
widget.onChanged(0);
|
||||
} else {
|
||||
widget.onChanged(_beforeMute <= 0 ? 100 : _beforeMute);
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Subtitles or audio: the icon with the track count; the menu lists the
|
||||
/// tracks (mpv's selection active).
|
||||
class UoscTracksButton extends StatelessWidget {
|
||||
final JellyfinStreamType type;
|
||||
final List<MpvTrack> tracks;
|
||||
final void Function(TrackPick pick) onPick;
|
||||
final ValueChanged<bool>? onMenuOpen;
|
||||
const UoscTracksButton({super.key, required this.type, required this.tracks, required this.onPick, this.onMenuOpen});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final audio = type == JellyfinStreamType.audio;
|
||||
final count = tracks.where((t) => t.type == type).length;
|
||||
return UoscButton(
|
||||
key: ValueKey(audio ? 'uosc-audio' : 'uosc-subtitles'),
|
||||
icon: audio ? Icons.graphic_eq_rounded : Icons.subtitles_outlined,
|
||||
tooltip: audio ? 'Audio' : 'Subtitles',
|
||||
badge: count,
|
||||
onPressed: () async {
|
||||
onMenuOpen?.call(true);
|
||||
final pick = await showUoscMenu<TrackPick>(context,
|
||||
title: audio ? 'Audio' : 'Subtitles', items: trackMenuItems(type, tracks));
|
||||
onMenuOpen?.call(false);
|
||||
if (pick != null) onPick(pick);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Chapters: the list with start times, the playing one active; a pick
|
||||
/// jumps there. Nothing without chapters.
|
||||
class UoscChaptersButton extends StatelessWidget {
|
||||
final List<MediaChapter> chapters;
|
||||
final int? current;
|
||||
final Duration duration;
|
||||
final ValueChanged<MediaChapter> onPick;
|
||||
final ValueChanged<bool>? onMenuOpen;
|
||||
const UoscChaptersButton({
|
||||
super.key,
|
||||
required this.chapters,
|
||||
required this.current,
|
||||
required this.duration,
|
||||
required this.onPick,
|
||||
this.onMenuOpen,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (chapters.isEmpty) return const SizedBox.shrink();
|
||||
return UoscButton(
|
||||
key: const ValueKey('uosc-chapters'),
|
||||
icon: Icons.bookmarks_outlined,
|
||||
tooltip: 'Chapters (PgUp / PgDn)',
|
||||
onPressed: () async {
|
||||
onMenuOpen?.call(true);
|
||||
final pick = await showUoscMenu<MediaChapter>(context,
|
||||
title: 'Chapters', items: chapterMenuItems(chapters, current, duration));
|
||||
onMenuOpen?.call(false);
|
||||
if (pick != null) onPick(pick);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Shaders as uosc's quality badge: the active Anime4K preset ("A HQ"),
|
||||
/// "4K" dimmed when off. Opens the shader menu.
|
||||
class UoscShaderButton extends StatelessWidget {
|
||||
final ReadonlySignal<RenderMode> renderMode;
|
||||
final void Function(ShaderPreset preset, ShaderQuality? quality) onPick;
|
||||
final ValueChanged<bool>? onMenuOpen;
|
||||
const UoscShaderButton({super.key, required this.renderMode, required this.onPick, this.onMenuOpen});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final s = AppSettings.instance;
|
||||
return SignalBuilder(builder: (context) {
|
||||
final preset = s.shaderPreset.value;
|
||||
final quality = s.shaderQuality.value;
|
||||
final short = shaderShortLabel(preset, quality);
|
||||
return Builder(
|
||||
builder: (context) => UoscBadgeButton(
|
||||
key: const ValueKey('uosc-shaders'),
|
||||
text: short ?? '4K',
|
||||
active: short != null,
|
||||
tooltip: short == null
|
||||
? 'Shaders: off (Ctrl+1..6)'
|
||||
: 'Shaders: ${shaderLabel(preset, quality)} (Ctrl+0 off)',
|
||||
onPressed: () {
|
||||
final controls = UoscControls.maybeOf(context);
|
||||
Future<void> open() =>
|
||||
showShaderMenu(context, renderMode: renderMode, onPick: onPick, onMenuOpen: onMenuOpen);
|
||||
controls == null ? open() : controls.holdDuring(open);
|
||||
},
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// One entry of the ≡ menu.
|
||||
typedef UoscAction = ({String title, String? hint, VoidCallback run});
|
||||
|
||||
/// ≡: the session's actions as a menu.
|
||||
class UoscMainMenuButton extends StatelessWidget {
|
||||
final List<UoscAction> actions;
|
||||
final ValueChanged<bool>? onMenuOpen;
|
||||
const UoscMainMenuButton({super.key, required this.actions, this.onMenuOpen});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return UoscButton(
|
||||
key: const ValueKey('uosc-menu-button'),
|
||||
icon: Icons.menu_rounded,
|
||||
tooltip: 'Menu',
|
||||
onPressed: () async {
|
||||
onMenuOpen?.call(true);
|
||||
final pick = await showUoscMenu<UoscAction>(context, items: [
|
||||
for (final a in actions) UoscMenuItem(value: a, title: a.title, hint: a.hint),
|
||||
]);
|
||||
onMenuOpen?.call(false);
|
||||
pick?.run();
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,196 +0,0 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
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.
|
||||
|
||||
class UoscControls extends StatefulWidget {
|
||||
/// The timeline; [expanded] is false while the controls are hidden.
|
||||
final Widget Function(BuildContext context, bool expanded) timeline;
|
||||
/// 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.
|
||||
final Widget? top;
|
||||
final VoidCallback? onDoubleTap;
|
||||
final Duration hideAfter;
|
||||
const UoscControls({
|
||||
super.key,
|
||||
required this.timeline,
|
||||
this.left = const [],
|
||||
this.right = const [],
|
||||
this.top,
|
||||
this.onDoubleTap,
|
||||
this.hideAfter = const Duration(seconds: 3),
|
||||
});
|
||||
|
||||
static const buttonRowHeight = 40.0;
|
||||
|
||||
/// The controls around [context], for widgets that need them kept up
|
||||
/// (a timeline drag, an open menu).
|
||||
static UoscControlsState? maybeOf(BuildContext context) =>
|
||||
context.findAncestorStateOfType<UoscControlsState>();
|
||||
|
||||
@override
|
||||
State<UoscControls> createState() => UoscControlsState();
|
||||
}
|
||||
|
||||
class UoscControlsState extends State<UoscControls> {
|
||||
bool _visible = true;
|
||||
int _holds = 0;
|
||||
Timer? _timer;
|
||||
|
||||
bool get visible => _visible;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_hideLater();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_timer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// Show the controls and restart the hide countdown.
|
||||
void wake() {
|
||||
if (!_visible && mounted) setState(() => _visible = true);
|
||||
_hideLater();
|
||||
}
|
||||
|
||||
/// Keep the controls up while [on] (paired calls): a drag sends no
|
||||
/// hover events and a menu route takes the mouse.
|
||||
void hold(bool on) {
|
||||
_holds = on ? _holds + 1 : (_holds - 1).clamp(0, 1 << 20);
|
||||
wake();
|
||||
}
|
||||
|
||||
/// Runs [open] (a menu) with the controls held up.
|
||||
Future<T> holdDuring<T>(Future<T> Function() open) async {
|
||||
hold(true);
|
||||
try {
|
||||
return await open();
|
||||
} finally {
|
||||
if (mounted) hold(false);
|
||||
}
|
||||
}
|
||||
|
||||
void _hideLater() {
|
||||
_timer?.cancel();
|
||||
_timer = Timer(widget.hideAfter, _hide);
|
||||
}
|
||||
|
||||
void _hide() {
|
||||
if (!mounted || _holds > 0 || !_visible) return;
|
||||
setState(() => _visible = false);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final visible = _visible;
|
||||
const fade = Duration(milliseconds: 150);
|
||||
return MouseRegion(
|
||||
cursor: visible ? SystemMouseCursors.basic : SystemMouseCursors.none,
|
||||
onEnter: (_) => wake(),
|
||||
onHover: (_) => wake(),
|
||||
onExit: (_) {
|
||||
_timer?.cancel();
|
||||
_hide();
|
||||
},
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onDoubleTap: widget.onDoubleTap,
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
height: 160,
|
||||
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(0x00000000), Color(0x8C000000)],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (widget.top != null)
|
||||
Positioned(
|
||||
top: 0,
|
||||
right: 0,
|
||||
child: IgnorePointer(
|
||||
ignoring: !visible,
|
||||
child: AnimatedOpacity(duration: fade, opacity: visible ? 1 : 0, child: widget.top),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
IgnorePointer(
|
||||
key: const ValueKey('uosc-buttons'),
|
||||
ignoring: !visible,
|
||||
child: AnimatedOpacity(
|
||||
duration: fade,
|
||||
opacity: visible ? 1 : 0,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(6, 0, 6, 4),
|
||||
child: SizedBox(
|
||||
height: UoscControls.buttonRowHeight,
|
||||
child: Row(children: [...widget.left, const Spacer(), ...widget.right]),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
widget.timeline(context, visible),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
class SessionControlsSwitch extends StatelessWidget {
|
||||
final WidgetBuilder material;
|
||||
final WidgetBuilder mpv;
|
||||
const SessionControlsSwitch({super.key, required this.material, required this.mpv});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SignalBuilder(
|
||||
builder: (context) =>
|
||||
AppSettings.instance.mpvControls.value ? mpv(context) : material(context),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,249 +0,0 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:sync_relay_core/core.dart' show JellyfinStreamType;
|
||||
|
||||
import '../../jellyfin/track_prefs.dart' show MpvTrack;
|
||||
import '../../playback/media_marks.dart';
|
||||
import '../../screens/session/player_controls.dart' show TrackPick;
|
||||
import 'controls.dart';
|
||||
import 'style.dart';
|
||||
|
||||
/// One row of a uosc menu: [title], a dim [hint] on the right (a time, a
|
||||
/// key), [active] = the current one (highlighted).
|
||||
class UoscMenuItem<T> {
|
||||
final T value;
|
||||
final String title;
|
||||
final String? hint;
|
||||
final bool active;
|
||||
final bool enabled;
|
||||
const UoscMenuItem({
|
||||
required this.value,
|
||||
required this.title,
|
||||
this.hint,
|
||||
this.active = false,
|
||||
this.enabled = true,
|
||||
});
|
||||
}
|
||||
|
||||
/// A flat dark menu over the controls, above the widget of [context] (the
|
||||
/// button that opens it), with an optional [title] row. Resolves to the
|
||||
/// picked value, or null when dismissed (click outside, Esc). Keeps the
|
||||
/// controls up while open.
|
||||
Future<T?> showUoscMenu<T>(
|
||||
BuildContext context, {
|
||||
String? title,
|
||||
required List<UoscMenuItem<T>> items,
|
||||
}) {
|
||||
final navigator = Navigator.of(context);
|
||||
final overlay = navigator.overlay!.context.findRenderObject() as RenderBox;
|
||||
final box = context.findRenderObject() as RenderBox;
|
||||
final anchor = Rect.fromPoints(
|
||||
box.localToGlobal(Offset.zero, ancestor: overlay),
|
||||
box.localToGlobal(box.size.bottomRight(Offset.zero), ancestor: overlay),
|
||||
);
|
||||
Future<T?> open() => navigator.push<T>(_UoscMenuRoute<T>(anchor: anchor, title: title, items: items));
|
||||
final controls = UoscControls.maybeOf(context);
|
||||
return controls == null ? open() : controls.holdDuring(open);
|
||||
}
|
||||
|
||||
class _UoscMenuRoute<T> extends PopupRoute<T> {
|
||||
final Rect anchor;
|
||||
final String? title;
|
||||
final List<UoscMenuItem<T>> items;
|
||||
_UoscMenuRoute({required this.anchor, required this.title, required this.items});
|
||||
|
||||
@override
|
||||
Color? get barrierColor => null;
|
||||
|
||||
@override
|
||||
bool get barrierDismissible => true;
|
||||
|
||||
@override
|
||||
String? get barrierLabel => 'Close menu';
|
||||
|
||||
@override
|
||||
Duration get transitionDuration => Duration.zero;
|
||||
|
||||
@override
|
||||
Widget buildPage(BuildContext context, Animation<double> animation, Animation<double> secondaryAnimation) {
|
||||
return LayoutBuilder(builder: (context, constraints) {
|
||||
const width = 360.0;
|
||||
final screen = constraints.biggest;
|
||||
final left = anchor.left.clamp(8.0, math.max(8.0, screen.width - width - 8));
|
||||
final maxHeight = math.max(120.0, anchor.top - 16);
|
||||
return Stack(
|
||||
children: [
|
||||
Positioned(
|
||||
left: left.toDouble(),
|
||||
bottom: screen.height - anchor.top + 6,
|
||||
width: math.min(width, screen.width - 16),
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(maxHeight: maxHeight),
|
||||
child: UoscMenuPanel<T>(
|
||||
title: title,
|
||||
items: items,
|
||||
onPick: (v) => Navigator.of(context).pop(v),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// The menu's panel alone (tests pump it without a route).
|
||||
class UoscMenuPanel<T> extends StatefulWidget {
|
||||
final String? title;
|
||||
final List<UoscMenuItem<T>> items;
|
||||
final ValueChanged<T> onPick;
|
||||
const UoscMenuPanel({super.key, this.title, required this.items, required this.onPick});
|
||||
|
||||
static const rowHeight = 36.0;
|
||||
|
||||
@override
|
||||
State<UoscMenuPanel<T>> createState() => _UoscMenuPanelState<T>();
|
||||
}
|
||||
|
||||
class _UoscMenuPanelState<T> extends State<UoscMenuPanel<T>> {
|
||||
// Long lists open on the current row.
|
||||
late final _scroll = ScrollController(
|
||||
initialScrollOffset: math.max(0, widget.items.indexWhere((i) => i.active) - 3) * UoscMenuPanel.rowHeight);
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scroll.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final title = widget.title;
|
||||
return Material(
|
||||
key: const ValueKey('uosc-menu'),
|
||||
color: UoscStyle.panel,
|
||||
borderRadius: BorderRadius.circular(UoscStyle.radius * 2),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (title != null)
|
||||
Container(
|
||||
height: 34,
|
||||
alignment: Alignment.center,
|
||||
decoration: const BoxDecoration(border: Border(bottom: BorderSide(color: Color(0x22FFFFFF)))),
|
||||
child: Text(title,
|
||||
style: const TextStyle(color: UoscStyle.dim, fontSize: UoscStyle.fontSize, fontWeight: FontWeight.w600)),
|
||||
),
|
||||
Flexible(
|
||||
child: ListView.builder(
|
||||
shrinkWrap: true,
|
||||
padding: const EdgeInsets.all(4),
|
||||
controller: _scroll,
|
||||
itemCount: widget.items.length,
|
||||
itemExtent: UoscMenuPanel.rowHeight,
|
||||
itemBuilder: (context, i) => _Row<T>(item: widget.items[i], onPick: widget.onPick),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Row<T> extends StatefulWidget {
|
||||
final UoscMenuItem<T> item;
|
||||
final ValueChanged<T> onPick;
|
||||
const _Row({super.key, required this.item, required this.onPick});
|
||||
|
||||
@override
|
||||
State<_Row<T>> createState() => _RowState<T>();
|
||||
}
|
||||
|
||||
class _RowState<T> extends State<_Row<T>> {
|
||||
bool _hover = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final item = widget.item;
|
||||
final color = item.active
|
||||
? UoscStyle.fill
|
||||
: _hover && item.enabled
|
||||
? UoscStyle.hover
|
||||
: Colors.transparent;
|
||||
final textColor = item.enabled ? UoscStyle.text : UoscStyle.dim.withValues(alpha: 0.4);
|
||||
return MouseRegion(
|
||||
cursor: item.enabled ? SystemMouseCursors.click : SystemMouseCursors.basic,
|
||||
onEnter: (_) => setState(() => _hover = true),
|
||||
onExit: (_) => setState(() => _hover = false),
|
||||
child: GestureDetector(
|
||||
key: ValueKey('uosc-menu-item-${item.title}'),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: item.enabled ? () => widget.onPick(item.value) : null,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10),
|
||||
decoration: BoxDecoration(color: color, borderRadius: BorderRadius.circular(UoscStyle.radius)),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(item.title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: textColor,
|
||||
fontSize: UoscStyle.fontSize,
|
||||
fontWeight: item.active ? FontWeight.w600 : FontWeight.normal)),
|
||||
),
|
||||
if (item.hint != null) ...[
|
||||
const SizedBox(width: 12),
|
||||
Text(item.hint!,
|
||||
style: TextStyle(
|
||||
color: item.active ? UoscStyle.text : UoscStyle.dim,
|
||||
fontSize: 12,
|
||||
fontFeatures: const [FontFeature.tabularFigures()])),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Chapters with their start times, the playing one active.
|
||||
List<UoscMenuItem<MediaChapter>> chapterMenuItems(List<MediaChapter> chapters, int? current, Duration duration) {
|
||||
final withHours = duration >= const Duration(hours: 1);
|
||||
return [
|
||||
for (final c in chapters)
|
||||
UoscMenuItem(
|
||||
value: c,
|
||||
title: c.title.isEmpty ? 'Chapter ${c.index + 1}' : c.title,
|
||||
hint: formatUoscTime(c.start, withHours: withHours),
|
||||
active: c.index == current,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
/// The audio or subtitle tracks ("Japanese · E-AC3 2.0 · title"), mpv's
|
||||
/// selection active; subtitles start with Off.
|
||||
List<UoscMenuItem<TrackPick>> trackMenuItems(JellyfinStreamType type, List<MpvTrack> tracks) {
|
||||
final audio = type == JellyfinStreamType.audio;
|
||||
final list = tracks.where((t) => t.type == type).toList();
|
||||
return [
|
||||
if (!audio)
|
||||
UoscMenuItem(
|
||||
value: (aid: null, sid: null, subtitlesOff: true),
|
||||
title: 'Off',
|
||||
active: !list.any((t) => t.selected),
|
||||
),
|
||||
for (final t in list)
|
||||
UoscMenuItem(
|
||||
value: audio ? (aid: t.id, sid: null, subtitlesOff: false) : (aid: null, sid: t.id, subtitlesOff: false),
|
||||
title: t.label,
|
||||
hint: '#${t.id}',
|
||||
active: t.selected,
|
||||
),
|
||||
];
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
// Look of the experimental mpv-style controls, after the uosc theme as the
|
||||
// owner runs it: flat black panels, white icons, a muted blue fill.
|
||||
|
||||
abstract final class UoscStyle {
|
||||
/// Timeline background and the boxes on it.
|
||||
static const bar = Color(0xCC000000);
|
||||
/// Played part of the timeline; also the active menu row.
|
||||
static const fill = Color(0xFF4C6A8E);
|
||||
/// Menus and tooltips.
|
||||
static const panel = Color(0xF0101010);
|
||||
static const hover = Color(0x24FFFFFF);
|
||||
static const text = Colors.white;
|
||||
static const dim = Color(0xA6FFFFFF);
|
||||
static const radius = 4.0;
|
||||
static const fontSize = 13.0;
|
||||
}
|
||||
|
||||
/// uosc's clock: "01:51", "1:02:03" style but zero-padded ("01:02:03")
|
||||
/// once [withHours] (the media is an hour or longer).
|
||||
String formatUoscTime(Duration d, {bool withHours = false}) {
|
||||
final t = d.isNegative ? Duration.zero : d;
|
||||
String two(int n) => n.toString().padLeft(2, '0');
|
||||
final h = t.inHours, m = t.inMinutes % 60, s = t.inSeconds % 60;
|
||||
return withHours || h > 0 ? '${two(h)}:${two(m)}:${two(s)}' : '${two(m)}:${two(s)}';
|
||||
}
|
||||
|
||||
/// Dark rounded hover tip ("Subtitles"), shown above its child.
|
||||
class UoscTooltip extends StatelessWidget {
|
||||
final String message;
|
||||
final Widget child;
|
||||
const UoscTooltip({super.key, required this.message, required this.child});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (message.isEmpty) return child;
|
||||
return Tooltip(
|
||||
message: message,
|
||||
preferBelow: false,
|
||||
verticalOffset: 26,
|
||||
waitDuration: const Duration(milliseconds: 250),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: UoscStyle.panel,
|
||||
borderRadius: BorderRadius.circular(UoscStyle.radius),
|
||||
),
|
||||
textStyle: const TextStyle(color: UoscStyle.text, fontSize: 12),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A small dark box with one line of text (the timeline's times, the
|
||||
/// hover chapter title).
|
||||
class UoscBox extends StatelessWidget {
|
||||
final String text;
|
||||
final Color color;
|
||||
final TextStyle style;
|
||||
const UoscBox(
|
||||
this.text, {
|
||||
super.key,
|
||||
this.color = UoscStyle.panel,
|
||||
this.style = const TextStyle(color: UoscStyle.text, fontSize: UoscStyle.fontSize),
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
constraints: const BoxConstraints(maxWidth: 360),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 3),
|
||||
decoration: BoxDecoration(color: color, borderRadius: BorderRadius.circular(UoscStyle.radius)),
|
||||
child: Text(text,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: style.copyWith(fontFeatures: const [FontFeature.tabularFigures()])),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,287 +0,0 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../playback/media_marks.dart';
|
||||
import 'controls.dart';
|
||||
import 'style.dart';
|
||||
|
||||
/// Fraction of [total] at [d], 0..1 (0 without a duration).
|
||||
double timelineFraction(Duration d, Duration total) {
|
||||
final t = total.inMicroseconds;
|
||||
if (t <= 0) return 0;
|
||||
return (d.inMicroseconds / t).clamp(0.0, 1.0);
|
||||
}
|
||||
|
||||
/// The cached-ahead part of a timeline of [size]: from the playing
|
||||
/// position to the end of mpv's cache. Null when nothing is cached ahead.
|
||||
Rect? cacheRect(Size size, {required Duration position, required Duration buffered, required Duration duration}) {
|
||||
final from = timelineFraction(position, duration) * size.width;
|
||||
final to = timelineFraction(buffered, duration) * size.width;
|
||||
if (to - from < 0.5) return null;
|
||||
return Rect.fromLTRB(from, 0, to, size.height);
|
||||
}
|
||||
|
||||
/// The hatching of [r]: 45° strokes from bottom-left to top-right,
|
||||
/// [spacing] apart along x, across all of [r] (the painter clips them).
|
||||
/// The first starts half a step in, so none just grazes the corner.
|
||||
List<(Offset, Offset)> hatchLines(Rect r, {double spacing = 6}) {
|
||||
if (r.isEmpty || spacing <= 0) return const [];
|
||||
final h = r.height;
|
||||
return [
|
||||
for (var x = r.left - h + spacing / 2; x < r.right; x += spacing) (Offset(x, r.bottom), Offset(x + h, r.top)),
|
||||
];
|
||||
}
|
||||
|
||||
/// The uosc timeline: full width, elapsed part filled, the cached range
|
||||
/// ahead hatched, chapter starts as white diamonds on the top edge, the
|
||||
/// current time in a box at the left and the duration at the right.
|
||||
/// Hover shows the chapter title over the hover time. When the controls
|
||||
/// hide ([expanded] false) it shrinks to a thin line with chapter ticks.
|
||||
/// Like the media_kit bar it reports a seek on every drag move and on
|
||||
/// release ([onSeek]); the session sends those through the user-seek path.
|
||||
class UoscTimeline extends StatefulWidget {
|
||||
final Duration position;
|
||||
final Duration duration;
|
||||
/// End of the cached range ([PlaybackController.buffered]).
|
||||
final Duration buffered;
|
||||
final List<MediaChapter> chapters;
|
||||
final bool expanded;
|
||||
final ValueChanged<Duration> onSeek;
|
||||
const UoscTimeline({
|
||||
super.key,
|
||||
required this.position,
|
||||
required this.duration,
|
||||
this.buffered = Duration.zero,
|
||||
this.chapters = const [],
|
||||
this.expanded = true,
|
||||
required this.onSeek,
|
||||
});
|
||||
|
||||
static const height = 30.0;
|
||||
static const collapsedHeight = 3.0;
|
||||
|
||||
@override
|
||||
State<UoscTimeline> createState() => _UoscTimelineState();
|
||||
}
|
||||
|
||||
class _UoscTimelineState extends State<UoscTimeline> {
|
||||
double? _hoverX;
|
||||
bool _dragging = false;
|
||||
double _dragFraction = 0;
|
||||
|
||||
Duration _at(double fraction) => widget.duration * fraction.clamp(0.0, 1.0);
|
||||
|
||||
void _seekTo(double x, double width) {
|
||||
if (widget.duration <= Duration.zero || width <= 0) return;
|
||||
_dragFraction = (x / width).clamp(0.0, 1.0);
|
||||
widget.onSeek(_at(_dragFraction));
|
||||
}
|
||||
|
||||
void _drag(bool on) {
|
||||
if (_dragging == on) return;
|
||||
setState(() => _dragging = on);
|
||||
UoscControls.maybeOf(context)?.hold(on);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(UoscTimeline old) {
|
||||
super.didUpdateWidget(old);
|
||||
if (!widget.expanded && old.expanded) _hoverX = null;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
// A drag cut short by a rebuild must not keep the controls up.
|
||||
if (_dragging) UoscControls.maybeOf(context)?.hold(false);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return LayoutBuilder(builder: (context, constraints) {
|
||||
final width = constraints.maxWidth;
|
||||
final expanded = widget.expanded;
|
||||
final h = expanded ? UoscTimeline.height : UoscTimeline.collapsedHeight;
|
||||
final played = _dragging ? _dragFraction : timelineFraction(widget.position, widget.duration);
|
||||
final hoverX = expanded ? _hoverX : null;
|
||||
final hoverAt = hoverX == null || width <= 0 ? null : _at(hoverX / width);
|
||||
final hoverChapter = hoverAt == null ? null : chapterIndexAt(widget.chapters, hoverAt);
|
||||
final hoverTitle = hoverChapter == null ? '' : widget.chapters[hoverChapter].title;
|
||||
final withHours = widget.duration >= const Duration(hours: 1);
|
||||
final marks = [
|
||||
for (final c in widget.chapters)
|
||||
if (c.start > Duration.zero && widget.duration > Duration.zero) c,
|
||||
];
|
||||
final bar = SizedBox(
|
||||
key: const ValueKey('uosc-timeline'),
|
||||
width: width,
|
||||
height: h,
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: CustomPaint(
|
||||
painter: UoscTimelinePainter(
|
||||
played: played,
|
||||
cache: (size) => cacheRect(size,
|
||||
position: widget.position, buffered: widget.buffered, duration: widget.duration),
|
||||
cacheKey: (widget.position, widget.buffered, widget.duration),
|
||||
hoverX: _dragging ? null : hoverX,
|
||||
hatched: expanded,
|
||||
),
|
||||
),
|
||||
),
|
||||
for (final c in marks)
|
||||
if (expanded)
|
||||
Positioned(
|
||||
key: ValueKey('uosc-chapter-${c.index}'),
|
||||
left: width * timelineFraction(c.start, widget.duration) - 4,
|
||||
top: -4,
|
||||
width: 8,
|
||||
height: 8,
|
||||
child: IgnorePointer(
|
||||
child: Transform.rotate(
|
||||
angle: math.pi / 4,
|
||||
child: Container(
|
||||
margin: const EdgeInsets.all(1.2),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
border: Border.all(color: Colors.black54, width: 0.6),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
Positioned(
|
||||
key: ValueKey('uosc-chapter-tick-${c.index}'),
|
||||
left: width * timelineFraction(c.start, widget.duration) - 1,
|
||||
top: -2,
|
||||
width: 2,
|
||||
height: h + 2,
|
||||
child: const IgnorePointer(child: ColoredBox(color: Colors.white70)),
|
||||
),
|
||||
if (expanded) ...[
|
||||
Positioned(
|
||||
left: 4,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
child: IgnorePointer(
|
||||
child: Center(
|
||||
child: UoscBox(formatUoscTime(_at(played), withHours: withHours),
|
||||
key: const ValueKey('uosc-time'), color: UoscStyle.bar),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
right: 4,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
child: IgnorePointer(
|
||||
child: Center(
|
||||
child: UoscBox(formatUoscTime(widget.duration, withHours: withHours),
|
||||
key: const ValueKey('uosc-duration'), color: UoscStyle.bar),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
if (hoverAt != null)
|
||||
Positioned(
|
||||
// Kept clear of the window edges (the tip is centred).
|
||||
left: hoverX!.clamp(math.min(60.0, width / 2), math.max(width - 60, width / 2)),
|
||||
bottom: h + 8,
|
||||
child: IgnorePointer(
|
||||
child: FractionalTranslation(
|
||||
translation: const Offset(-0.5, 0),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
spacing: 4,
|
||||
children: [
|
||||
if (hoverTitle.isNotEmpty) UoscBox(hoverTitle, key: const ValueKey('uosc-hover-chapter')),
|
||||
UoscBox(formatUoscTime(hoverAt, withHours: withHours),
|
||||
key: const ValueKey('uosc-hover-time')),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (!expanded) return IgnorePointer(child: bar);
|
||||
return MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
onEnter: (e) => setState(() => _hoverX = e.localPosition.dx.clamp(0, width)),
|
||||
onHover: (e) => setState(() => _hoverX = e.localPosition.dx.clamp(0, width)),
|
||||
onExit: (_) => setState(() => _hoverX = null),
|
||||
child: Listener(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onPointerDown: (e) {
|
||||
_dragFraction = width <= 0 ? 0 : (e.localPosition.dx / width).clamp(0.0, 1.0);
|
||||
_drag(true);
|
||||
},
|
||||
onPointerMove: (e) {
|
||||
setState(() => _hoverX = e.localPosition.dx.clamp(0, width));
|
||||
_seekTo(e.localPosition.dx, width);
|
||||
},
|
||||
onPointerUp: (e) {
|
||||
_seekTo(e.localPosition.dx, width);
|
||||
_drag(false);
|
||||
},
|
||||
onPointerCancel: (_) => _drag(false),
|
||||
child: bar,
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Background, cached range (hatched, or a faint band when collapsed),
|
||||
/// the played part and the hover line.
|
||||
class UoscTimelinePainter extends CustomPainter {
|
||||
final double played;
|
||||
final Rect? Function(Size size) cache;
|
||||
/// What [cache] depends on (repaint when it changes).
|
||||
final Object cacheKey;
|
||||
final double? hoverX;
|
||||
final bool hatched;
|
||||
const UoscTimelinePainter({
|
||||
required this.played,
|
||||
required this.cache,
|
||||
required this.cacheKey,
|
||||
this.hoverX,
|
||||
this.hatched = true,
|
||||
});
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final all = Offset.zero & size;
|
||||
canvas.drawRect(all, Paint()..color = UoscStyle.bar);
|
||||
final c = cache(size);
|
||||
if (c != null) {
|
||||
if (hatched) {
|
||||
canvas.save();
|
||||
canvas.clipRect(c);
|
||||
final stroke = Paint()
|
||||
..color = const Color(0x40FFFFFF)
|
||||
..strokeWidth = 1.5;
|
||||
for (final (a, b) in hatchLines(c)) {
|
||||
canvas.drawLine(a, b, stroke);
|
||||
}
|
||||
canvas.restore();
|
||||
} else {
|
||||
canvas.drawRect(c, Paint()..color = const Color(0x40FFFFFF));
|
||||
}
|
||||
}
|
||||
canvas.drawRect(Rect.fromLTWH(0, 0, size.width * played, size.height), Paint()..color = UoscStyle.fill);
|
||||
final x = hoverX;
|
||||
if (x != null) {
|
||||
canvas.drawRect(Rect.fromLTWH(x - 0.5, 0, 1, size.height), Paint()..color = Colors.white70);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(UoscTimelinePainter old) =>
|
||||
old.played != played || old.cacheKey != cacheKey || old.hoverX != hoverX || old.hatched != hatched;
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart' show ValueListenable;
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../playback/playback_controller.dart' show PlaybackController;
|
||||
import '../widgets/app_menu.dart' show AppGlass;
|
||||
|
||||
/// 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);
|
||||
|
||||
/// One wheel notch or arrow key.
|
||||
const volumeStep = 5.0;
|
||||
|
||||
/// [volume] moved by [delta] (wheel, keys), kept within 0..[maxVolume].
|
||||
double stepVolume(double volume, double delta) => (volume + delta).clamp(0.0, maxVolume);
|
||||
|
||||
/// Turns wheel deltas into volume steps: a mouse notch (a big delta) is
|
||||
/// one step, small smooth-scroll deltas add up to one. Mostly horizontal
|
||||
/// scrolls (Shift+wheel, sideways pans) are no steps.
|
||||
class WheelSteps {
|
||||
// Flutter makes a notch 53 px on Linux, 60 on Windows (3 lines).
|
||||
static const _notch = 50.0;
|
||||
double _sum = 0;
|
||||
|
||||
/// Whether [e] is a vertical scroll (the only kind that steps).
|
||||
static bool vertical(PointerSignalEvent e) =>
|
||||
e is PointerScrollEvent && e.scrollDelta.dy != 0 && e.scrollDelta.dy.abs() > e.scrollDelta.dx.abs();
|
||||
|
||||
/// +1 louder (wheel up), -1 quieter, 0 not a step yet.
|
||||
int take(PointerScrollEvent e) {
|
||||
final dy = e.scrollDelta.dy;
|
||||
if (dy.abs() >= _notch) {
|
||||
_sum = 0;
|
||||
return dy < 0 ? 1 : -1;
|
||||
}
|
||||
if (_sum != 0 && _sum.sign != dy.sign) _sum = 0;
|
||||
_sum += dy;
|
||||
if (_sum.abs() < _notch) return 0;
|
||||
final step = _sum < 0 ? 1 : -1;
|
||||
_sum = 0;
|
||||
return step;
|
||||
}
|
||||
|
||||
/// Claims [e] if it is a vertical scroll that nothing under the mouse
|
||||
/// claimed first (a list, a menu), then calls [onStep] per step.
|
||||
void handle(PointerSignalEvent e, ValueChanged<int> onStep) {
|
||||
if (!vertical(e)) return;
|
||||
GestureBinding.instance.pointerSignalResolver.register(e, (event) {
|
||||
final step = take(event as PointerScrollEvent);
|
||||
if (step != 0) onStep(step);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// The wheel anywhere over [child] steps the volume ([onStep]: +1 / -1),
|
||||
/// unless something under the mouse scrolls (it claims the event first)
|
||||
/// or [enabled] is off.
|
||||
class VolumeWheel extends StatefulWidget {
|
||||
final ValueChanged<int> onStep;
|
||||
final bool enabled;
|
||||
final Widget child;
|
||||
const VolumeWheel({super.key, required this.onStep, this.enabled = true, required this.child});
|
||||
|
||||
@override
|
||||
State<VolumeWheel> createState() => _VolumeWheelState();
|
||||
}
|
||||
|
||||
class _VolumeWheelState extends State<VolumeWheel> {
|
||||
final _steps = WheelSteps();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Listener(
|
||||
onPointerSignal: (e) {
|
||||
if (widget.enabled) _steps.handle(e, widget.onStep);
|
||||
},
|
||||
child: widget.child,
|
||||
);
|
||||
}
|
||||
|
||||
/// Shows [builder] for a second after each [trigger] tick (a wheel or
|
||||
/// key volume step), fading in and out; ignores the mouse.
|
||||
class VolumeFlash extends StatefulWidget {
|
||||
final ValueListenable<int> trigger;
|
||||
final WidgetBuilder builder;
|
||||
const VolumeFlash({super.key, required this.trigger, required this.builder});
|
||||
|
||||
/// How long the readout stays after the last step.
|
||||
static const hold = Duration(seconds: 1);
|
||||
|
||||
@override
|
||||
State<VolumeFlash> createState() => _VolumeFlashState();
|
||||
}
|
||||
|
||||
class _VolumeFlashState extends State<VolumeFlash> {
|
||||
bool _on = false;
|
||||
Timer? _timer;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
widget.trigger.addListener(_flash);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(VolumeFlash old) {
|
||||
super.didUpdateWidget(old);
|
||||
if (old.trigger != widget.trigger) {
|
||||
old.trigger.removeListener(_flash);
|
||||
widget.trigger.addListener(_flash);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
widget.trigger.removeListener(_flash);
|
||||
_timer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _flash() {
|
||||
if (!mounted) return;
|
||||
setState(() => _on = true);
|
||||
_timer?.cancel();
|
||||
_timer = Timer(VolumeFlash.hold, () {
|
||||
if (mounted) setState(() => _on = false);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => IgnorePointer(
|
||||
child: AnimatedOpacity(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
opacity: _on ? 1 : 0,
|
||||
child: _on || _timer != null ? widget.builder(context) : const SizedBox.shrink(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// The volume readout for [VolumeFlash] (both skins): a glass pill
|
||||
/// (the player's pill look) with the icon, a [VolumeBar] and the
|
||||
/// percentage.
|
||||
class VolumeReadout extends StatelessWidget {
|
||||
final double volume;
|
||||
const VolumeReadout({super.key, required this.volume});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final v = volume.clamp(0.0, maxVolume);
|
||||
return AppGlass.pill(
|
||||
key: const ValueKey('volume-readout'),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 4, 14, 4),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(volumeIcon(v), size: 20, color: v > 100 ? boostColor : Colors.white),
|
||||
const SizedBox(width: 8),
|
||||
VolumeBar(volume: v, onChanged: (_) {}, width: 96),
|
||||
const SizedBox(width: 10),
|
||||
SizedBox(
|
||||
width: 44,
|
||||
child: Text(
|
||||
'${v.round()} %',
|
||||
textAlign: TextAlign.right,
|
||||
style: const TextStyle(color: Colors.white, fontSize: 13, fontFeatures: [FontFeature.tabularFigures()]),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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;
|
||||
}
|
||||
|
||||
/// The speaker icon for [volume].
|
||||
IconData volumeIcon(double volume) =>
|
||||
volume <= 0 ? Icons.volume_off_rounded : (volume < 50 ? Icons.volume_down_rounded : Icons.volume_up_rounded);
|
||||
|
||||
/// Builds a skin's button for the [VolumeControl].
|
||||
typedef VolumeButtonBuilder = Widget Function(
|
||||
BuildContext context,
|
||||
IconData icon,
|
||||
Color? color,
|
||||
String tooltip,
|
||||
VoidCallback onPressed,
|
||||
);
|
||||
|
||||
/// The bar's volume, in both skins: the skin's speaker button (click
|
||||
/// mutes / unmutes), a [VolumeBar] sliding out on hover, the wheel on it
|
||||
/// stepping [volumeStep] (claimed, so the player's wheel skips it).
|
||||
class VolumeControl extends StatefulWidget {
|
||||
/// 0..[maxVolume].
|
||||
final double volume;
|
||||
final ValueChanged<double> onChanged;
|
||||
final VoidCallback onToggleMute;
|
||||
final VolumeButtonBuilder button;
|
||||
final Color sliderColor;
|
||||
final Color trackColor;
|
||||
final double sliderWidth;
|
||||
const VolumeControl({
|
||||
super.key,
|
||||
required this.volume,
|
||||
required this.onChanged,
|
||||
required this.onToggleMute,
|
||||
required this.button,
|
||||
this.sliderColor = Colors.white,
|
||||
this.trackColor = Colors.white30,
|
||||
this.sliderWidth = 72,
|
||||
});
|
||||
|
||||
@override
|
||||
State<VolumeControl> createState() => _VolumeControlState();
|
||||
}
|
||||
|
||||
class _VolumeControlState extends State<VolumeControl> {
|
||||
final _steps = WheelSteps();
|
||||
bool _hover = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final v = widget.volume;
|
||||
return MouseRegion(
|
||||
onEnter: (_) => setState(() => _hover = true),
|
||||
onExit: (_) => setState(() => _hover = false),
|
||||
child: Listener(
|
||||
onPointerSignal: (e) => _steps.handle(e, (step) => widget.onChanged(stepVolume(v, step * volumeStep))),
|
||||
child: Row(
|
||||
key: const ValueKey('volume-control'),
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
widget.button(
|
||||
context,
|
||||
volumeIcon(v),
|
||||
v > 100 ? boostColor : null,
|
||||
'Volume ${v.round()} % (scroll, ↑ / ↓)',
|
||||
widget.onToggleMute,
|
||||
),
|
||||
AnimatedSize(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
child: _hover
|
||||
? Padding(
|
||||
padding: const EdgeInsets.only(left: 4, right: 8),
|
||||
child: VolumeBar(
|
||||
key: const ValueKey('volume-bar'),
|
||||
volume: v,
|
||||
onChanged: widget.onChanged,
|
||||
width: widget.sliderWidth,
|
||||
color: widget.sliderColor,
|
||||
trackColor: widget.trackColor,
|
||||
),
|
||||
)
|
||||
: 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,152 @@
|
||||
import 'dart:io';
|
||||
import 'dart:isolate';
|
||||
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
|
||||
export 'package:sync_relay_core/core.dart' show FileKind, LocalFile, kindOf, subtitleExtensions, videoExtensions;
|
||||
|
||||
// Folder scan for the Files source: the videos (grouped into shows by
|
||||
// core's `groupLocal`, L1), sidecar subtitles, everything else counted
|
||||
// and hidden. The walk is synchronous I/O, so it runs in its own isolate.
|
||||
|
||||
/// 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;
|
||||
|
||||
class FolderScan {
|
||||
final List<LocalFile> 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 = <LocalFile>[];
|
||||
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(LocalFile(e.path, size, 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<LocalFile> 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()),
|
||||
};
|
||||
}
|
||||
|
||||
String _baseName(String path) {
|
||||
final i = path.lastIndexOf(RegExp(r'[/\\]'));
|
||||
return i < 0 ? path : path.substring(i + 1);
|
||||
}
|
||||
|
||||
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,200 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
import '../platform/desktop/media_kit_host.dart' show MediaKitHost;
|
||||
import '../platform/ports/player_host.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;
|
||||
|
||||
/// The players thumbnails are made with (the app's host, set in `main`).
|
||||
PlayerHost thumbnailPlayers = MediaKitHost()..init();
|
||||
|
||||
/// 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?>>{};
|
||||
final _players = <HostedPlayer>{};
|
||||
bool _closed = false;
|
||||
|
||||
/// App close: no new thumbnails, running ones stop at their next check
|
||||
/// and dispose their player. Players still up after [deadline] are
|
||||
/// disposed here, so no mpv outlives the engine.
|
||||
Future<void> cancelAllThumbnails({
|
||||
Duration deadline = const Duration(milliseconds: 800),
|
||||
}) async {
|
||||
_closed = true;
|
||||
try {
|
||||
await Future.wait(_inFlight.values.toList()).timeout(deadline);
|
||||
} catch (_) {}
|
||||
for (final p in _players.toList()) {
|
||||
_players.remove(p);
|
||||
try {
|
||||
await thumbnailPlayers.dispose(p).timeout(deadline);
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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}) {
|
||||
if (_closed) return Future.value(null);
|
||||
final existing = _inFlight[path];
|
||||
if (existing != null) return existing;
|
||||
final f = _thumbnailFor(path, () => _closed || (cancelled?.call() ?? 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 = await thumbnailPlayers.create(PlayerRole.thumbnail);
|
||||
_players.add(player);
|
||||
try {
|
||||
final mpv = player;
|
||||
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(path);
|
||||
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 {
|
||||
// [cancelAllThumbnails] may have taken it already.
|
||||
if (_players.remove(player)) await thumbnailPlayers.dispose(player);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
|
||||
import '../../shell/avatar.dart';
|
||||
import 'thumb.dart';
|
||||
|
||||
// The Files page's rows and cards: 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})';
|
||||
|
||||
/// One file as a row: an episode on a show's page, or a Recent entry
|
||||
/// ([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 LocalEpisode 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.file.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,978 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:signals/signals_flutter.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
|
||||
import '../../library/show_matcher.dart';
|
||||
import '../../shell/motion.dart';
|
||||
import '../../platform/ports/folder_access.dart';
|
||||
import '../../app/app_graph.dart';
|
||||
import '../../watch/watch_history.dart';
|
||||
import '../../widgets/app_menu.dart';
|
||||
import '../folder_store.dart';
|
||||
import '../scanner.dart';
|
||||
import 'file_tiles.dart';
|
||||
import 'library_view.dart';
|
||||
import 'thumb.dart';
|
||||
|
||||
export 'thumb.dart' show ThumbnailLoader;
|
||||
|
||||
// The Files source as a library: the watched folders on the left (chips
|
||||
// on top when narrow): the Library (every folder together), Recent, then
|
||||
// each folder. A library is shows and movies as poster cards, grouped by
|
||||
// the library's L1 and named by its L2 match, plus the videos that are
|
||||
// neither; a show opens its own page. Folders are scanned when first
|
||||
// needed and on Rescan; scans, the open place and the open show are kept
|
||||
// for the app session. Playing goes through [onPlay].
|
||||
|
||||
/// Below this width the sidebar turns into chips above the list.
|
||||
const filesNarrowWidth = 700.0;
|
||||
|
||||
/// The place that shows every watched folder together.
|
||||
const libraryPlace = '\u0000library';
|
||||
|
||||
/// Scans kept for the app session, by folder.
|
||||
final _scans = <String, FolderScan>{};
|
||||
final _running = <String, Future<FolderScan>>{};
|
||||
String? _lastOpen = libraryPlace;
|
||||
/// The show open in each place (by show key) for the app session.
|
||||
final _openShows = <String, String>{};
|
||||
final _showRequests = ValueNotifier(0);
|
||||
|
||||
/// Opens [dir] (null: Recent; [libraryPlace]: the library) 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 shows and the open place.
|
||||
@visibleForTesting
|
||||
void resetFilesPageSession() {
|
||||
_scans.clear();
|
||||
_running.clear();
|
||||
_openShows.clear();
|
||||
_lastOpen = libraryPlace;
|
||||
}
|
||||
|
||||
class FilesPage extends StatefulWidget {
|
||||
/// Nobody else is in the server: no relay notice.
|
||||
final bool alone;
|
||||
/// 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;
|
||||
/// Adds [path] to Up next (right-click on a video); null: no server.
|
||||
final Future<String?> Function(String path, {bool next})? onAddToQueue;
|
||||
final String? nowPlayingPath;
|
||||
/// Defaults to [thumbnailFor].
|
||||
final ThumbnailLoader? thumbnails;
|
||||
/// Defaults to [FolderAccess.scan].
|
||||
final Future<FolderScan> Function(String dir)? scan;
|
||||
/// Defaults to [FolderStore.instance].
|
||||
final FolderStore? store;
|
||||
/// Defaults to the app's (`AppGraph.library`).
|
||||
final ShowMatcher? matcher;
|
||||
/// Default: the system folder / file dialogs.
|
||||
final Future<String?> Function()? pickFolder;
|
||||
final Future<String?> Function()? pickVideo;
|
||||
|
||||
const FilesPage({
|
||||
super.key,
|
||||
this.alone = false,
|
||||
required this.relayed,
|
||||
required this.onPlay,
|
||||
this.onAddToQueue,
|
||||
this.nowPlayingPath,
|
||||
this.thumbnails,
|
||||
this.scan,
|
||||
this.store,
|
||||
this.matcher,
|
||||
this.pickFolder,
|
||||
this.pickVideo,
|
||||
});
|
||||
|
||||
@override
|
||||
State<FilesPage> createState() => _FilesPageState();
|
||||
}
|
||||
|
||||
/// A place's scans grouped (L1), made once per set of scans.
|
||||
class _Library {
|
||||
final List<FolderScan> scans;
|
||||
final LocalLibrary lib;
|
||||
final Map<String, LocalEpisode> byPath;
|
||||
|
||||
_Library(this.scans, List<String> roots)
|
||||
: lib = groupLocal([for (final s in scans) ...s.videos], roots: roots),
|
||||
byPath = {} {
|
||||
for (final e in lib.files) {
|
||||
byPath[e.path] = e;
|
||||
}
|
||||
}
|
||||
|
||||
int get videos => scans.fold(0, (n, s) => n + s.videos.length);
|
||||
int get hidden => scans.fold(0, (n, s) => n + s.hiddenCount);
|
||||
int get subtitleFiles => {
|
||||
for (final s in scans)
|
||||
for (final l in s.subtitles.values) ...l
|
||||
}.length;
|
||||
bool get truncated => scans.any((s) => s.truncated);
|
||||
}
|
||||
|
||||
class _FilesPageState extends State<FilesPage> {
|
||||
FolderAccess get _folders => AppScope.of(context).ports.folders;
|
||||
ShowMatcher get _matcher => widget.matcher ?? AppScope.of(context).library;
|
||||
|
||||
late final ThumbCache _thumbs =
|
||||
widget.thumbnails == null ? ThumbCache.shared : ThumbCache(widget.thumbnails!);
|
||||
FolderStore get _store => widget.store ?? FolderStore.instance;
|
||||
WatchHistory get _history => AppScope.of(context).watch.history;
|
||||
|
||||
/// Null: Recent; [libraryPlace]: every folder.
|
||||
String? _open = _lastOpen;
|
||||
bool _showHidden = false;
|
||||
String? _starting;
|
||||
String? _error;
|
||||
final _libs = <String, _Library>{};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_showRequests.addListener(_shown);
|
||||
unawaited(_matcher.ensureLoaded());
|
||||
_store.ensureLoaded().then((_) {
|
||||
if (!mounted) return;
|
||||
final open = _open;
|
||||
if (open != null && open != libraryPlace && !_store.folders.value.contains(open)) {
|
||||
_select(libraryPlace);
|
||||
} else {
|
||||
_scanFor(open);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_showRequests.removeListener(_shown);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _shown() => _select(_lastOpen);
|
||||
|
||||
Future<FolderScan> _runScan(String dir) => (widget.scan ?? _folders.scan)(dir);
|
||||
|
||||
/// The folders a place needs scanned.
|
||||
List<String> _foldersOf(String? place) => place == null
|
||||
? const []
|
||||
: place == libraryPlace
|
||||
? _store.folders.peek()
|
||||
: [place];
|
||||
|
||||
void _scanFor(String? place) {
|
||||
for (final f in _foldersOf(place)) {
|
||||
if (!_scans.containsKey(f) && !_running.containsKey(f)) _scan(f);
|
||||
}
|
||||
}
|
||||
|
||||
void _select(String? place) {
|
||||
setState(() {
|
||||
_open = _lastOpen = place;
|
||||
_showHidden = false;
|
||||
});
|
||||
_scanFor(place);
|
||||
}
|
||||
|
||||
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 <LocalFile>[]) 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(() {});
|
||||
}
|
||||
|
||||
/// The grouped library of [place], once its folders are scanned (the
|
||||
/// library: those scanned so far).
|
||||
_Library? _libraryOf(String place) {
|
||||
final folders = _foldersOf(place);
|
||||
final scans = [for (final f in folders) ?_scans[f]];
|
||||
if (place != libraryPlace && scans.isEmpty) return null;
|
||||
final known = _libs[place];
|
||||
if (known != null && known.scans.length == scans.length && _same(known.scans, scans)) return known;
|
||||
final lib = _libs[place] = _Library(scans, folders);
|
||||
_matcher.remember(lib.lib);
|
||||
return lib;
|
||||
}
|
||||
|
||||
static bool _same(List<FolderScan> a, List<FolderScan> b) {
|
||||
for (var i = 0; i < a.length; i++) {
|
||||
if (!identical(a[i], b[i])) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<void> _addFolder() async {
|
||||
String? dir;
|
||||
try {
|
||||
dir = await (widget.pickFolder ??
|
||||
() => _folders.pickFolder(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);
|
||||
}
|
||||
|
||||
Future<String?> _pickVideo() async {
|
||||
final files = await _folders.pickFiles(dialogTitle: 'Pick a video', extensions: videoExtensions.toList());
|
||||
return files.firstOrNull;
|
||||
}
|
||||
|
||||
void _removeFolder(String dir) {
|
||||
_store.removeFolder(dir);
|
||||
_scans.remove(dir);
|
||||
if (_open == dir) _select(libraryPlace);
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
/// Right-click on a video ([context]: its tile): Put it up, Add to
|
||||
/// queue, Play next.
|
||||
Future<void> _menu(BuildContext context, String path) async {
|
||||
final add = widget.onAddToQueue;
|
||||
if (add == null) return;
|
||||
final pick = await showAppMenu<int>(context, items: [
|
||||
AppMenuItem(value: 0, label: widget.alone ? 'Play' : 'Put it up'),
|
||||
const AppMenuItem(value: 1, label: 'Add to queue'),
|
||||
const AppMenuItem(value: 2, label: 'Play next'),
|
||||
]);
|
||||
if (pick == null || !mounted) return;
|
||||
if (pick == 0) return _play(path);
|
||||
String? error;
|
||||
try {
|
||||
error = await add(path, next: pick == 2);
|
||||
} catch (e) {
|
||||
error = '$e';
|
||||
}
|
||||
if (error != null) _showError(error);
|
||||
}
|
||||
|
||||
/// [child] with the right-click menu (when there is a server).
|
||||
Widget _menuable(String path, Widget child) => widget.onAddToQueue == null
|
||||
? child
|
||||
: KeyedSubtree(
|
||||
key: ValueKey('menu:$path'),
|
||||
child: Builder(
|
||||
builder: (context) => GestureDetector(onSecondaryTap: () => _menu(context, path), child: child)));
|
||||
|
||||
late final _actions = FilesActions(
|
||||
play: (p) => unawaited(_play(p)),
|
||||
stateOf: _stateOf,
|
||||
menuable: _menuable,
|
||||
thumbs: _thumbs,
|
||||
);
|
||||
|
||||
/// The last episode of [show] watched here (the watch history: the
|
||||
/// newest one watched to the end), else the last one opened (Recent), by
|
||||
/// key.
|
||||
String? _lastPlayed(_Library lib, LocalShow show) {
|
||||
for (final (key, entry) in _history.entries) {
|
||||
if (!entry.played || !key.value.startsWith('file:')) continue;
|
||||
final e = lib.byPath[key.value.substring(5)];
|
||||
if (e != null && e.showKey == show.key && e.kind == LocalKind.episode) return e.key;
|
||||
}
|
||||
for (final p in _store.recent.value) {
|
||||
final e = lib.byPath[p];
|
||||
if (e != null && e.showKey == show.key && e.kind == LocalKind.episode) return e.key;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@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;
|
||||
_matcher.revision.value;
|
||||
_history.revision.value;
|
||||
final open = _open == null || _open == libraryPlace || folders.contains(_open) ? _open : libraryPlace;
|
||||
final showKey = open == null ? null : _openShows[open];
|
||||
final main = fadeThrough(
|
||||
KeyedSubtree(
|
||||
key: ValueKey('${open ?? ''}\n${showKey ?? ''}'),
|
||||
child: _main(context, narrow, folders, recent, open, showKey),
|
||||
),
|
||||
);
|
||||
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));
|
||||
Widget? status(String? s) =>
|
||||
s == null ? null : Text(s, style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 11));
|
||||
return ListView(
|
||||
padding: const EdgeInsets.fromLTRB(20, 20, 8, 20),
|
||||
children: [
|
||||
ListTile(
|
||||
key: const ValueKey('place:library'),
|
||||
dense: true,
|
||||
selected: open == libraryPlace,
|
||||
shape: shape,
|
||||
leading: const Icon(Icons.video_library_outlined, size: 18),
|
||||
title: const Text('Library'),
|
||||
subtitle: status(_libraryStatus(folders)),
|
||||
onTap: () => _select(libraryPlace),
|
||||
),
|
||||
ListTile(
|
||||
key: const ValueKey('place:recent'),
|
||||
dense: true,
|
||||
selected: open == null,
|
||||
shape: shape,
|
||||
leading: const Icon(Icons.history, size: 18),
|
||||
title: const Text('Recent'),
|
||||
onTap: () => _select(null),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const Text('Folders', style: TextStyle(fontWeight: FontWeight.w600)),
|
||||
const SizedBox(height: 8),
|
||||
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: status(_folderStatus(f)),
|
||||
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'}';
|
||||
}
|
||||
|
||||
String? _libraryStatus(List<String> folders) {
|
||||
if (folders.any(_running.containsKey)) return 'scanning…';
|
||||
if (folders.isEmpty || folders.any((f) => !_scans.containsKey(f))) return null;
|
||||
final n = _libraryOf(libraryPlace)?.lib.shows.length ?? 0;
|
||||
return '$n ${n == 1 ? 'show' : 'shows'}';
|
||||
}
|
||||
|
||||
List<Widget> _notices() => [
|
||||
if (!widget.alone && 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)),
|
||||
),
|
||||
];
|
||||
|
||||
Widget _main(
|
||||
BuildContext context,
|
||||
bool narrow,
|
||||
List<String> folders,
|
||||
List<String> recent,
|
||||
String? open,
|
||||
String? showKey,
|
||||
) {
|
||||
final lib = open == null ? null : _libraryOf(open);
|
||||
final show = showKey == null ? null : lib?.lib.show(showKey);
|
||||
if (open != null && lib != null && show != null) {
|
||||
return ShowPage(
|
||||
key: ValueKey('show:$open:${show.key}'),
|
||||
show: show,
|
||||
matcher: _matcher,
|
||||
actions: _actions,
|
||||
lastPlayed: _lastPlayed(lib, show),
|
||||
backLabel: open == libraryPlace ? 'Library' : baseName(open),
|
||||
onBack: () => setState(() => _openShows.remove(open)),
|
||||
top: [if (narrow) _chips(folders, open), ..._notices()],
|
||||
);
|
||||
}
|
||||
final scanning = _foldersOf(open).any(_running.containsKey);
|
||||
// A folder added elsewhere (a drop, the handoff) joins the library.
|
||||
if (open == libraryPlace && folders.any((f) => !_scans.containsKey(f) && !_running.containsKey(f))) {
|
||||
scheduleMicrotask(() {
|
||||
if (mounted) _scanFor(libraryPlace);
|
||||
});
|
||||
}
|
||||
final top = <Widget>[
|
||||
_header(context, open),
|
||||
if (narrow) _chips(folders, open),
|
||||
..._notices(),
|
||||
];
|
||||
final slivers = <Widget>[
|
||||
SliverList.list(children: top),
|
||||
if (open == null)
|
||||
..._recent(context, recent)
|
||||
else if (open == libraryPlace && folders.isEmpty)
|
||||
SliverToBoxAdapter(
|
||||
child: FilesEmpty(
|
||||
Icons.video_library_outlined,
|
||||
'Your library is empty',
|
||||
'Add a folder with your videos: shows, seasons and movies are found by their names.',
|
||||
action: FilledButton.icon(
|
||||
onPressed: _addFolder,
|
||||
icon: const Icon(Icons.create_new_folder_outlined),
|
||||
label: const Text('Add folder'),
|
||||
),
|
||||
),
|
||||
)
|
||||
else if (lib == null)
|
||||
const SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(top: 60),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
)
|
||||
else
|
||||
..._library(context, open, lib, 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;
|
||||
final (icon, title, sub) = switch (open) {
|
||||
null => (Icons.history, 'Recent', null),
|
||||
libraryPlace => (Icons.video_library, 'Library', null),
|
||||
final dir => (Icons.folder, baseName(dir), dir),
|
||||
};
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 14),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(icon, color: scheme.primary),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.headlineSmall,
|
||||
),
|
||||
if (sub != null)
|
||||
Text(
|
||||
sub,
|
||||
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('Library'),
|
||||
selected: open == libraryPlace,
|
||||
onSelected: (_) => _select(libraryPlace),
|
||||
),
|
||||
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 _menuable(
|
||||
path,
|
||||
_RecentRow(
|
||||
key: ValueKey(path),
|
||||
path: path,
|
||||
matcher: _matcher,
|
||||
state: _stateOf(path),
|
||||
thumbs: _thumbs,
|
||||
onTap: _tapOf(path),
|
||||
onRemove: () => _store.removeRecent(path),
|
||||
));
|
||||
},
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
Widget _section(BuildContext context, String title, {String? count}) => Padding(
|
||||
padding: const EdgeInsets.only(top: 22, bottom: 10),
|
||||
child: Row(children: [
|
||||
Text(title, style: Theme.of(context).textTheme.titleMedium),
|
||||
if (count != null) ...[
|
||||
const SizedBox(width: 8),
|
||||
Text(count, style: Theme.of(context).textTheme.bodySmall?.copyWith(color: Colors.grey)),
|
||||
],
|
||||
]),
|
||||
);
|
||||
|
||||
List<Widget> _library(BuildContext context, String place, _Library lib, bool scanning) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final l = lib.lib;
|
||||
final folderScan = place == libraryPlace ? null : _scans[place];
|
||||
String count(int n, String one, String many) => '$n ${n == 1 ? one : many}';
|
||||
final summary = [
|
||||
count(lib.videos, 'video', 'videos') +
|
||||
(l.shows.isEmpty ? '' : ' in ${count(l.shows.length, 'show', 'shows')}'),
|
||||
if (lib.hidden > 0) '${count(lib.hidden, 'other file', 'other files')} hidden',
|
||||
if (lib.subtitleFiles > 0) count(lib.subtitleFiles, 'subtitle file', 'subtitle files'),
|
||||
if (lib.truncated) 'stopped after $maxScannedFiles files',
|
||||
].join(' · ');
|
||||
final folders = _foldersOf(place);
|
||||
final movies = l.movies;
|
||||
final other = l.other;
|
||||
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 (folderScan != null && folderScan.hiddenCount > 0) ...[
|
||||
const SizedBox(width: 8),
|
||||
TextButton(
|
||||
onPressed: () => setState(() => _showHidden = !_showHidden),
|
||||
child: Text(_showHidden ? 'Hide them' : 'Show some'),
|
||||
),
|
||||
],
|
||||
TextButton.icon(
|
||||
onPressed: scanning
|
||||
? null
|
||||
: () {
|
||||
for (final f in folders) {
|
||||
_scan(f, rescan: true);
|
||||
}
|
||||
},
|
||||
icon: scanning
|
||||
? const SizedBox.square(dimension: 14, child: CircularProgressIndicator(strokeWidth: 2))
|
||||
: const Icon(Icons.refresh, size: 16),
|
||||
label: Text(scanning ? 'Scanning…' : 'Rescan'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (folderScan != null)
|
||||
SliverToBoxAdapter(
|
||||
child: AnimatedSize(
|
||||
duration: easeTime,
|
||||
curve: ease,
|
||||
alignment: Alignment.topLeft,
|
||||
child: !_showHidden
|
||||
? const SizedBox(width: double.infinity)
|
||||
: Column(
|
||||
children: [
|
||||
for (final f in folderScan.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 (folderScan.hiddenCount > folderScan.hiddenExamples.length)
|
||||
ListTile(
|
||||
dense: true,
|
||||
enabled: false,
|
||||
title: Text('and ${folderScan.hiddenCount - folderScan.hiddenExamples.length} more'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (lib.videos == 0)
|
||||
SliverToBoxAdapter(
|
||||
child: place == libraryPlace || _folders.folderExists(place)
|
||||
? 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.',
|
||||
),
|
||||
),
|
||||
if (l.shows.isNotEmpty) ...[
|
||||
SliverToBoxAdapter(child: _section(context, 'Shows', count: '${l.shows.length}')),
|
||||
SliverGrid.builder(
|
||||
gridDelegate: posterGrid(),
|
||||
itemCount: l.shows.length,
|
||||
itemBuilder: (context, i) => _showCard(place, lib, l.shows[i]),
|
||||
),
|
||||
],
|
||||
if (movies.isNotEmpty) ...[
|
||||
SliverToBoxAdapter(child: _section(context, 'Movies', count: '${movies.length}')),
|
||||
SliverGrid.builder(
|
||||
gridDelegate: posterGrid(),
|
||||
itemCount: movies.length,
|
||||
itemBuilder: (context, i) => _movieCard(movies[i]),
|
||||
),
|
||||
],
|
||||
if (other.isNotEmpty) ...[
|
||||
SliverToBoxAdapter(
|
||||
child: _section(context, l.shows.isEmpty && movies.isEmpty ? 'Videos' : 'Other videos'),
|
||||
),
|
||||
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: other.length,
|
||||
itemBuilder: (context, i) {
|
||||
final v = other[i];
|
||||
return _menuable(
|
||||
v.path,
|
||||
VideoCard(
|
||||
key: ValueKey(v.path),
|
||||
video: v,
|
||||
state: _stateOf(v.path),
|
||||
thumbs: _thumbs,
|
||||
onTap: _tapOf(v.path),
|
||||
));
|
||||
},
|
||||
);
|
||||
}),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
Widget _showCard(String place, _Library lib, LocalShow show) {
|
||||
final m = _matcher.showMatch(show);
|
||||
final last = _lastPlayed(lib, show);
|
||||
final String subtitle;
|
||||
if (last == null) {
|
||||
final n = show.episodeCount;
|
||||
subtitle = '$n ${n == 1 ? 'episode' : 'episodes'}';
|
||||
} else {
|
||||
final next = show.nextAfter(last);
|
||||
subtitle = next == null ? 'Watched to the end' : 'Next: ${episodeNumber(next.episode)}';
|
||||
}
|
||||
final playing = widget.nowPlayingPath != null && show.files.any((e) => e.path == widget.nowPlayingPath);
|
||||
return PosterCard(
|
||||
key: ValueKey('showcard:${show.key}'),
|
||||
title: m?.entry.title ?? show.title,
|
||||
subtitle: subtitle,
|
||||
coverUrl: m?.entry.cover,
|
||||
framePath: show.episodes.firstOrNull?.path,
|
||||
thumbs: _thumbs,
|
||||
playing: playing,
|
||||
onShown: () => _matcher.want(show),
|
||||
onTap: () => setState(() => _openShows[place] = show.key),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _movieCard(LocalEpisode movie) {
|
||||
final m = _matcher.seasonMatch(movie);
|
||||
return _menuable(
|
||||
movie.path,
|
||||
PosterCard(
|
||||
key: ValueKey('moviecard:${movie.path}'),
|
||||
title: m?.entry.title ?? (movie.parsed.year == null ? movie.title : '${movie.title} (${movie.parsed.year})'),
|
||||
subtitle: [
|
||||
if (movie.movieNumber != null) movie.label,
|
||||
if (m?.entry.year != null) '${m!.entry.year}' else if (movie.parsed.year != null) '${movie.parsed.year}',
|
||||
formatSize(movie.file.size),
|
||||
].join(' · '),
|
||||
coverUrl: m?.entry.cover,
|
||||
framePath: movie.path,
|
||||
thumbs: _thumbs,
|
||||
playing: _stateOf(movie.path) == PlayState.playing,
|
||||
onShown: () => _matcher.wantMovie(movie),
|
||||
onTap: _tapOf(movie.path),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// A Recent entry: `Show · E02` over the file name (the library's names
|
||||
/// when it knows the show), or the cleaned title over its folder; greyed
|
||||
/// when the file is gone.
|
||||
class _RecentRow extends StatefulWidget {
|
||||
final String path;
|
||||
final ShowMatcher matcher;
|
||||
final PlayState state;
|
||||
final ThumbCache thumbs;
|
||||
final VoidCallback? onTap;
|
||||
final VoidCallback onRemove;
|
||||
const _RecentRow({
|
||||
super.key,
|
||||
required this.path,
|
||||
required this.matcher,
|
||||
required this.state,
|
||||
required this.thumbs,
|
||||
required this.onTap,
|
||||
required this.onRemove,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_RecentRow> createState() => _RecentRowState();
|
||||
}
|
||||
|
||||
class _RecentRowState extends State<_RecentRow> {
|
||||
FolderAccess get _folders => AppScope.of(context).ports.folders;
|
||||
|
||||
FileStat? _stat;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_folders.stat(widget.path).then((s) {
|
||||
if (mounted) setState(() => _stat = s);
|
||||
}, onError: (Object _) {});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final path = widget.path;
|
||||
final e = widget.matcher.episodeAt(path) ??
|
||||
readLocalFile(LocalFile(path, 0, DateTime.fromMillisecondsSinceEpoch(0)));
|
||||
final missing = _stat?.type == FileSystemEntityType.notFound;
|
||||
final episode = e.kind == LocalKind.episode || e.kind == LocalKind.extra;
|
||||
final name = widget.matcher.seasonMatch(e)?.entry.title ?? e.title;
|
||||
return FileRow(
|
||||
path: path,
|
||||
title: episode ? '$name · ${e.label}' : (e.kind == LocalKind.other ? cleanTitle(e.parsed) : name),
|
||||
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,712 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:signals/signals_flutter.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
|
||||
import '../../library/show_matcher.dart';
|
||||
import '../../schedule/anilist.dart' show AniListEntry;
|
||||
import '../../shell/avatar.dart' show Pill;
|
||||
import 'file_tiles.dart';
|
||||
import 'thumb.dart';
|
||||
|
||||
// The Files library: shows and movies as poster cards (like the Jellyfin
|
||||
// browser's), a show's own page (banner, the next episode with what is
|
||||
// missing on the way, seasons, extras, movies) and the corrections
|
||||
// ("Wrong show?", "Episode numbers…"). Names and art come from the
|
||||
// library's AniList match when there is one, else from the files.
|
||||
|
||||
/// What the page hands its views: playing, the right-click menu, the
|
||||
/// thumbnails, what plays now.
|
||||
class FilesActions {
|
||||
final void Function(String path) play;
|
||||
final PlayState Function(String path) stateOf;
|
||||
final Widget Function(String path, Widget child) menuable;
|
||||
final ThumbCache thumbs;
|
||||
const FilesActions({required this.play, required this.stateOf, required this.menuable, required this.thumbs});
|
||||
}
|
||||
|
||||
/// "66–107 aren't in your folders", "66 isn't in your folders".
|
||||
String missingText(EpisodeGap gap) =>
|
||||
gap.first == gap.last ? '${gap.label} isn\'t in your folders' : '${gap.label} aren\'t in your folders';
|
||||
|
||||
/// The next-episode prompt: "66–107 aren't in your folders — skip to
|
||||
/// 108?", or at the start "1–55 … — start at 56?"; null when nothing is
|
||||
/// skipped.
|
||||
String? skipPrompt(LocalNext next, {required bool started}) {
|
||||
final gap = next.skipped;
|
||||
if (gap == null) return null;
|
||||
final n = next.episode.episode;
|
||||
return started ? '${missingText(gap)} — skip to $n?' : '${missingText(gap)} — start at $n?';
|
||||
}
|
||||
|
||||
/// A show's label for an episode: `56`, `S3 E1`.
|
||||
String episodeNumber(LocalEpisode e) =>
|
||||
e.season == null ? '${e.episode}' : 'S${e.season} E${e.episode}';
|
||||
|
||||
/// The 2:3 picture of a show or movie: its AniList cover, else a frame of
|
||||
/// [framePath].
|
||||
class PosterImage extends StatelessWidget {
|
||||
final String? url;
|
||||
final String? framePath;
|
||||
final ThumbCache thumbs;
|
||||
const PosterImage({super.key, this.url, this.framePath, required this.thumbs});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final frame = framePath == null
|
||||
? const ThumbImage(null)
|
||||
: ThumbBuilder(cache: thumbs, path: framePath!, builder: (context, t, _) => ThumbImage(t));
|
||||
final u = url;
|
||||
if (u == null) return frame;
|
||||
return Image.network(
|
||||
u,
|
||||
fit: BoxFit.cover,
|
||||
gaplessPlayback: true,
|
||||
errorBuilder: (_, _, _) => frame,
|
||||
loadingBuilder: (context, child, progress) => progress == null ? child : frame,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A show (or movie) as a poster card: picture, title, one line under it.
|
||||
class PosterCard extends StatefulWidget {
|
||||
final String title;
|
||||
final String subtitle;
|
||||
final String? coverUrl;
|
||||
final String? framePath;
|
||||
final ThumbCache thumbs;
|
||||
final VoidCallback onTap;
|
||||
final bool playing;
|
||||
/// Called once when the card is first built (the library matches what
|
||||
/// is on screen).
|
||||
final VoidCallback? onShown;
|
||||
const PosterCard({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.subtitle,
|
||||
required this.thumbs,
|
||||
required this.onTap,
|
||||
this.coverUrl,
|
||||
this.framePath,
|
||||
this.playing = false,
|
||||
this.onShown,
|
||||
});
|
||||
|
||||
@override
|
||||
State<PosterCard> createState() => _PosterCardState();
|
||||
}
|
||||
|
||||
class _PosterCardState extends State<PosterCard> {
|
||||
bool _hover = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
widget.onShown?.call();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final t = Theme.of(context).textTheme;
|
||||
return InkWell(
|
||||
onTap: widget.onTap,
|
||||
onHover: (h) => setState(() => _hover = h),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: widget.playing ? scheme.primary : (_hover ? scheme.onSurfaceVariant : Colors.transparent),
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: PosterImage(url: widget.coverUrl, framePath: widget.framePath, thumbs: widget.thumbs),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(widget.title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: t.titleSmall?.copyWith(color: widget.playing ? scheme.primary : null)),
|
||||
Text(widget.subtitle,
|
||||
maxLines: 1, overflow: TextOverflow.ellipsis, style: t.bodySmall?.copyWith(color: Colors.grey)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Title and meta lines under a [PosterCard]'s picture.
|
||||
const posterTextHeight = 46.0;
|
||||
|
||||
/// Poster cards in a grid: about 170 px wide each.
|
||||
SliverGridDelegate posterGrid() => const _PosterGrid();
|
||||
|
||||
class _PosterGrid extends SliverGridDelegate {
|
||||
const _PosterGrid();
|
||||
|
||||
@override
|
||||
SliverGridLayout getLayout(SliverConstraints constraints) {
|
||||
const gap = 12.0;
|
||||
final w = constraints.crossAxisExtent;
|
||||
final cols = (w / 170).floor().clamp(2, 12);
|
||||
final cardW = (w - gap * (cols - 1)) / cols;
|
||||
return SliverGridRegularTileLayout(
|
||||
crossAxisCount: cols,
|
||||
mainAxisStride: cardW * 1.5 + posterTextHeight + gap,
|
||||
crossAxisStride: cardW + gap,
|
||||
childMainAxisExtent: cardW * 1.5 + posterTextHeight,
|
||||
childCrossAxisExtent: cardW,
|
||||
reverseCrossAxis: false,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRelayout(covariant SliverGridDelegate oldDelegate) => false;
|
||||
}
|
||||
|
||||
/// One show's page: header (banner, proper name, what the files are,
|
||||
/// the match), the next episode with what is missing on the way, the
|
||||
/// season's episodes with their holes, extras and the show's movies.
|
||||
class ShowPage extends StatefulWidget {
|
||||
final LocalShow show;
|
||||
final ShowMatcher matcher;
|
||||
final FilesActions actions;
|
||||
/// The last episode of this show played here ([LocalEpisode.key]).
|
||||
final String? lastPlayed;
|
||||
final String backLabel;
|
||||
final VoidCallback onBack;
|
||||
/// Notices above the page (the relay notice, errors).
|
||||
final List<Widget> top;
|
||||
const ShowPage({
|
||||
super.key,
|
||||
required this.show,
|
||||
required this.matcher,
|
||||
required this.actions,
|
||||
required this.lastPlayed,
|
||||
required this.backLabel,
|
||||
required this.onBack,
|
||||
this.top = const [],
|
||||
});
|
||||
|
||||
@override
|
||||
State<ShowPage> createState() => _ShowPageState();
|
||||
}
|
||||
|
||||
class _ShowPageState extends State<ShowPage> {
|
||||
int? _season;
|
||||
bool _seasonPicked = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
widget.matcher.want(widget.show);
|
||||
}
|
||||
|
||||
LocalShow get show => widget.show;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => SignalBuilder(builder: (context) {
|
||||
widget.matcher.revision.value;
|
||||
final busy = widget.matcher.busy.value.contains(show.key);
|
||||
final m = widget.matcher.showMatch(show);
|
||||
final next = show.nextAfter(widget.lastPlayed);
|
||||
final seasons = show.seasons;
|
||||
if (!_seasonPicked) _season = next?.episode.season ?? seasons.firstOrNull?.number;
|
||||
final season = show.season(_season) ?? seasons.firstOrNull;
|
||||
return CustomScrollView(
|
||||
key: PageStorageKey('show:${show.key}'),
|
||||
slivers: [
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 12, 20, 24),
|
||||
sliver: SliverMainAxisGroup(slivers: [
|
||||
SliverToBoxAdapter(
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: TextButton.icon(
|
||||
onPressed: widget.onBack,
|
||||
icon: const Icon(Icons.arrow_back, size: 18),
|
||||
label: Text(widget.backLabel),
|
||||
),
|
||||
),
|
||||
),
|
||||
SliverList.list(children: widget.top),
|
||||
SliverToBoxAdapter(child: _header(context, m, next, busy)),
|
||||
if (seasons.length > 1)
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 14),
|
||||
child: Wrap(spacing: 8, runSpacing: 8, children: [
|
||||
for (final s in seasons)
|
||||
ChoiceChip(
|
||||
key: ValueKey('season:${s.number}'),
|
||||
label: Text(s.title),
|
||||
selected: identical(s, season),
|
||||
onSelected: (_) => setState(() {
|
||||
_season = s.number;
|
||||
_seasonPicked = true;
|
||||
}),
|
||||
),
|
||||
]),
|
||||
),
|
||||
),
|
||||
if (season != null) ..._episodes(context, season),
|
||||
if (show.extras.isNotEmpty) ...[
|
||||
_section(context, 'Extras', show.extras.length),
|
||||
SliverList.list(children: [for (final e in show.extras) _row(e, e.label, e.name)]),
|
||||
],
|
||||
if (show.movies.isNotEmpty) ...[
|
||||
_section(context, show.movies.length == 1 ? 'Movie' : 'Movies', show.movies.length),
|
||||
SliverList.list(children: [for (final e in show.movies) _row(e, e.label, e.name)]),
|
||||
],
|
||||
]),
|
||||
),
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
Widget _section(BuildContext context, String title, int n) => SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 22, bottom: 6),
|
||||
child: Row(children: [
|
||||
Text(title, style: Theme.of(context).textTheme.titleMedium),
|
||||
const SizedBox(width: 8),
|
||||
Text('$n', style: Theme.of(context).textTheme.bodySmall?.copyWith(color: Colors.grey)),
|
||||
]),
|
||||
),
|
||||
);
|
||||
|
||||
Widget _row(LocalEpisode e, String title, String subtitle) => widget.actions.menuable(
|
||||
e.path,
|
||||
FileRow(
|
||||
key: ValueKey(e.path),
|
||||
path: e.path,
|
||||
title: title,
|
||||
subtitle: subtitle,
|
||||
size: formatSize(e.file.size),
|
||||
state: widget.actions.stateOf(e.path),
|
||||
thumbs: widget.actions.thumbs,
|
||||
onTap: () => widget.actions.play(e.path),
|
||||
));
|
||||
|
||||
List<Widget> _episodes(BuildContext context, LocalSeason season) {
|
||||
final rows = <Widget>[];
|
||||
var expect = 1;
|
||||
for (final e in season.episodes) {
|
||||
final n = e.episode!;
|
||||
final gap = season.gaps.where((g) => g.first >= expect && g.last < n).firstOrNull;
|
||||
if (gap != null) rows.add(_GapRow(missingText(gap)));
|
||||
if (n >= expect) expect = n + 1;
|
||||
final offset = widget.matcher.offset(show.key, season.number);
|
||||
final shown = n + offset;
|
||||
final name = widget.matcher.episodeTitle(e);
|
||||
// Like the Jellyfin browser's rows: "12. Name", else "Episode 12".
|
||||
final title = name == null ? 'Episode $shown' : '$shown. $name';
|
||||
rows.add(_row(e, title, e.name));
|
||||
}
|
||||
final tail = season.gaps.where((g) => g.first >= expect).firstOrNull;
|
||||
if (tail != null) rows.add(_GapRow(missingText(tail)));
|
||||
return [
|
||||
_section(context, season.title, season.distinct.length),
|
||||
SliverList.list(children: rows),
|
||||
];
|
||||
}
|
||||
|
||||
Widget _header(BuildContext context, SeasonMatch? m, LocalNext? next, bool busy) {
|
||||
final theme = Theme.of(context);
|
||||
final t = theme.textTheme;
|
||||
final scheme = theme.colorScheme;
|
||||
final entry = m?.entry;
|
||||
final title = entry?.title ?? show.title;
|
||||
final count = show.episodeCount;
|
||||
final meta = [
|
||||
if (show.seasons.length > 1) '${show.seasons.length} seasons',
|
||||
'$count ${count == 1 ? 'episode' : 'episodes'}',
|
||||
if (show.extras.isNotEmpty) '${show.extras.length} ${show.extras.length == 1 ? 'extra' : 'extras'}',
|
||||
if (show.movies.isNotEmpty) '${show.movies.length} ${show.movies.length == 1 ? 'movie' : 'movies'}',
|
||||
?show.group,
|
||||
if (show.folders.length > 1) 'in ${show.folders.length} folders',
|
||||
].join(' · ');
|
||||
final String matchLine;
|
||||
if (entry != null) {
|
||||
final what = [
|
||||
if (entry.format.isNotEmpty) entry.format.replaceAll('_', ' '),
|
||||
if (entry.year != null) '${entry.year}',
|
||||
if (entry.episodes != null) '${entry.episodes} eps',
|
||||
].join(', ');
|
||||
matchLine = [
|
||||
'AniList: ${entry.romaji.isNotEmpty && entry.romaji != title ? entry.romaji : entry.title}'
|
||||
'${what.isEmpty ? '' : ' ($what)'}',
|
||||
if (title != show.title) 'files say "${show.title}"',
|
||||
].join(' · ');
|
||||
} else {
|
||||
matchLine = busy ? 'Looking it up on AniList…' : 'Not matched on AniList';
|
||||
}
|
||||
final started = widget.lastPlayed != null;
|
||||
final prompt = next == null ? null : skipPrompt(next, started: started);
|
||||
final nextLabel = next == null
|
||||
? 'Watched to the end'
|
||||
: next.skipped != null
|
||||
? (started ? 'Skip to ${next.episode.episode}' : 'Start at ${next.episode.episode}')
|
||||
: '${started ? 'Next' : 'Play'}: ${episodeNumber(next.episode)}';
|
||||
return LayoutBuilder(builder: (context, box) {
|
||||
final h = (box.maxWidth * 0.28).clamp(220.0, 340.0);
|
||||
final cover = entry?.cover;
|
||||
return ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: SizedBox(
|
||||
height: h,
|
||||
child: Stack(fit: StackFit.expand, children: [
|
||||
PosterImage(
|
||||
url: entry?.banner ?? entry?.cover,
|
||||
framePath: show.episodes.firstOrNull?.path,
|
||||
thumbs: widget.actions.thumbs),
|
||||
DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.centerLeft,
|
||||
end: Alignment.centerRight,
|
||||
colors: [
|
||||
scheme.surface.withValues(alpha: 0.96),
|
||||
scheme.surface.withValues(alpha: 0.8),
|
||||
scheme.surface.withValues(alpha: 0.15),
|
||||
],
|
||||
stops: const [0, 0.5, 1],
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
if (cover != null && box.maxWidth > 560) ...[
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: SizedBox(
|
||||
width: (h - 40) / 1.5,
|
||||
child: PosterImage(url: cover, thumbs: widget.actions.thumbs),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 18),
|
||||
],
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Row(children: [
|
||||
Flexible(
|
||||
child: Text(title,
|
||||
maxLines: 2, overflow: TextOverflow.ellipsis, style: t.headlineMedium),
|
||||
),
|
||||
if (m?.pinned != null) ...[
|
||||
const SizedBox(width: 8),
|
||||
Pill(m!.pinned == PinScope.folder ? 'pinned for this folder' : 'pinned', scheme.primary),
|
||||
],
|
||||
]),
|
||||
const SizedBox(height: 6),
|
||||
Text(meta, style: t.bodyMedium?.copyWith(color: Colors.grey)),
|
||||
const SizedBox(height: 2),
|
||||
Text(matchLine,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: t.bodySmall?.copyWith(color: scheme.onSurfaceVariant)),
|
||||
if (prompt != null) ...[
|
||||
const SizedBox(height: 10),
|
||||
Row(children: [
|
||||
Icon(Icons.info_outline, size: 16, color: scheme.tertiary),
|
||||
const SizedBox(width: 6),
|
||||
Flexible(child: Text(prompt, style: t.bodyMedium?.copyWith(color: scheme.tertiary))),
|
||||
]),
|
||||
],
|
||||
const SizedBox(height: 12),
|
||||
Wrap(spacing: 8, runSpacing: 8, children: [
|
||||
FilledButton.icon(
|
||||
key: const ValueKey('show-next'),
|
||||
onPressed: next == null ? null : () => widget.actions.play(next.episode.path),
|
||||
icon: const Icon(Icons.play_arrow),
|
||||
label: Text(nextLabel),
|
||||
),
|
||||
OutlinedButton.icon(
|
||||
onPressed: () => showWrongShowDialog(context, show, widget.matcher),
|
||||
icon: const Icon(Icons.manage_search, size: 18),
|
||||
label: const Text('Wrong show?'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => showEpisodeNumbersDialog(context, show, widget.matcher),
|
||||
child: const Text('Episode numbers…'),
|
||||
),
|
||||
]),
|
||||
]),
|
||||
),
|
||||
),
|
||||
]),
|
||||
),
|
||||
]),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class _GapRow extends StatelessWidget {
|
||||
final String text;
|
||||
const _GapRow(this.text);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 6, 8, 6),
|
||||
child: Row(children: [
|
||||
Icon(Icons.more_vert, size: 18, color: scheme.onSurfaceVariant),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(text,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(color: scheme.onSurfaceVariant, fontStyle: FontStyle.italic, fontSize: 13)),
|
||||
),
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// "Wrong show?": search AniList, pick the right entry, pin it for the
|
||||
/// show everywhere or for one of its folders.
|
||||
Future<void> showWrongShowDialog(BuildContext context, LocalShow show, ShowMatcher matcher) =>
|
||||
showDialog<void>(context: context, builder: (context) => WrongShowDialog(show: show, matcher: matcher));
|
||||
|
||||
class WrongShowDialog extends StatefulWidget {
|
||||
final LocalShow show;
|
||||
final ShowMatcher matcher;
|
||||
const WrongShowDialog({super.key, required this.show, required this.matcher});
|
||||
|
||||
@override
|
||||
State<WrongShowDialog> createState() => _WrongShowDialogState();
|
||||
}
|
||||
|
||||
class _WrongShowDialogState extends State<WrongShowDialog> {
|
||||
late final _query = TextEditingController(text: widget.show.title.replaceAll(RegExp(r'[._]+'), ' '));
|
||||
List<AniListEntry>? _hits;
|
||||
String? _error;
|
||||
bool _searching = false;
|
||||
/// Null: the whole show; else one of its folders.
|
||||
String? _folder;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_search();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_query.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _search() async {
|
||||
setState(() {
|
||||
_searching = true;
|
||||
_error = null;
|
||||
});
|
||||
try {
|
||||
final hits = await widget.matcher.search(_query.text);
|
||||
if (mounted) setState(() => _hits = hits);
|
||||
} catch (e) {
|
||||
if (mounted) setState(() => _error = 'AniList did not answer ($e)');
|
||||
} finally {
|
||||
if (mounted) setState(() => _searching = false);
|
||||
}
|
||||
}
|
||||
|
||||
void _pin(AniListEntry e) {
|
||||
final folder = _folder;
|
||||
folder == null ? widget.matcher.pinShow(widget.show.key, e) : widget.matcher.pinFolder(folder, e);
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final show = widget.show;
|
||||
final m = widget.matcher;
|
||||
final showPin = m.showPin(show.key);
|
||||
final folderPins = [for (final f in show.folders) if (m.folderPin(f)?.folder == f) f];
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
return AlertDialog(
|
||||
title: const Text('Wrong show?'),
|
||||
content: SizedBox(
|
||||
width: 520,
|
||||
child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text('Search AniList and pick what "${show.title}" really is. '
|
||||
'A pin wins over the automatic match and survives rescans.',
|
||||
style: TextStyle(color: scheme.onSurfaceVariant)),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: _query,
|
||||
autofocus: true,
|
||||
decoration: InputDecoration(
|
||||
isDense: true,
|
||||
border: const OutlineInputBorder(),
|
||||
hintText: 'Title on AniList',
|
||||
suffixIcon: IconButton(tooltip: 'Search', icon: const Icon(Icons.search), onPressed: _search),
|
||||
),
|
||||
onSubmitted: (_) => _search(),
|
||||
),
|
||||
if (show.folders.length > 1 || folderPins.isNotEmpty) ...[
|
||||
const SizedBox(height: 10),
|
||||
DropdownButton<String?>(
|
||||
value: _folder,
|
||||
isExpanded: true,
|
||||
items: [
|
||||
const DropdownMenuItem(value: null, child: Text('For this show everywhere')),
|
||||
for (final f in show.folders)
|
||||
DropdownMenuItem(value: f, child: Text('Only the folder "${baseName(f)}"', overflow: TextOverflow.ellipsis)),
|
||||
],
|
||||
onChanged: (v) => setState(() => _folder = v),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 8),
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxHeight: 340),
|
||||
child: _searching && _hits == null
|
||||
? const Padding(padding: EdgeInsets.all(24), child: Center(child: CircularProgressIndicator()))
|
||||
: _error != null
|
||||
? Text(_error!, style: TextStyle(color: scheme.error))
|
||||
: (_hits ?? const []).isEmpty
|
||||
? const Padding(padding: EdgeInsets.all(12), child: Text('Nothing found'))
|
||||
: ListView(shrinkWrap: true, children: [
|
||||
for (final e in _hits!)
|
||||
ListTile(
|
||||
key: ValueKey('hit:${e.id}'),
|
||||
dense: true,
|
||||
leading: SizedBox(
|
||||
width: 32,
|
||||
height: 48,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: e.coverSmall == null
|
||||
? const ColoredBox(color: Colors.black26)
|
||||
: Image.network(e.coverSmall!,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, _, _) => const ColoredBox(color: Colors.black26)),
|
||||
),
|
||||
),
|
||||
title: Text(e.title, maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
subtitle: Text([
|
||||
if (e.romaji.isNotEmpty && e.romaji != e.title) e.romaji,
|
||||
if (e.format.isNotEmpty) e.format.replaceAll('_', ' '),
|
||||
if (e.year != null) '${e.year}',
|
||||
if (e.episodes != null) '${e.episodes} eps',
|
||||
].join(' · '), maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
onTap: () => _pin(e),
|
||||
),
|
||||
]),
|
||||
),
|
||||
]),
|
||||
),
|
||||
actions: [
|
||||
if (showPin != null)
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
m.unpinShow(show.key);
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: const Text('Remove the pin'),
|
||||
),
|
||||
for (final f in folderPins)
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
m.unpinFolder(f);
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: Text('Unpin "${baseName(f)}"'),
|
||||
),
|
||||
TextButton(onPressed: () => Navigator.of(context).pop(), child: const Text('Cancel')),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// "Episode numbers…": which episode the first file of each season is
|
||||
/// ("these files are episodes N..M").
|
||||
Future<void> showEpisodeNumbersDialog(BuildContext context, LocalShow show, ShowMatcher matcher) =>
|
||||
showDialog<void>(context: context, builder: (context) => _EpisodeNumbersDialog(show: show, matcher: matcher));
|
||||
|
||||
class _EpisodeNumbersDialog extends StatefulWidget {
|
||||
final LocalShow show;
|
||||
final ShowMatcher matcher;
|
||||
const _EpisodeNumbersDialog({required this.show, required this.matcher});
|
||||
|
||||
@override
|
||||
State<_EpisodeNumbersDialog> createState() => _EpisodeNumbersDialogState();
|
||||
}
|
||||
|
||||
class _EpisodeNumbersDialogState extends State<_EpisodeNumbersDialog> {
|
||||
late final _fields = {
|
||||
for (final s in widget.show.seasons)
|
||||
s.number: TextEditingController(
|
||||
text: '${s.distinct.first.episode! + widget.matcher.offset(widget.show.key, s.number)}'),
|
||||
};
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
for (final c in _fields.values) {
|
||||
c.dispose();
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _save() {
|
||||
for (final s in widget.show.seasons) {
|
||||
final first = s.distinct.first.episode!;
|
||||
final want = int.tryParse(_fields[s.number]!.text.trim());
|
||||
if (want != null && want > 0) widget.matcher.setOffset(widget.show.key, s.number, want - first);
|
||||
}
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => AlertDialog(
|
||||
title: const Text('Episode numbers'),
|
||||
content: SizedBox(
|
||||
width: 420,
|
||||
child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
const Text('When the files count differently from AniList, say which episode the first file is; '
|
||||
'the others follow.'),
|
||||
const SizedBox(height: 12),
|
||||
for (final s in widget.show.seasons)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Row(children: [
|
||||
Expanded(
|
||||
child: Text('${s.title}: the file numbered ${s.distinct.first.episode} is episode',
|
||||
maxLines: 2),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
SizedBox(
|
||||
width: 72,
|
||||
child: TextField(
|
||||
key: ValueKey('offset:${s.number}'),
|
||||
controller: _fields[s.number],
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: const InputDecoration(isDense: true, border: OutlineInputBorder()),
|
||||
),
|
||||
),
|
||||
]),
|
||||
),
|
||||
]),
|
||||
),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.of(context).pop(), child: const Text('Cancel')),
|
||||
FilledButton(onPressed: _save, child: const Text('Save')),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -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 {
|
||||
@@ -195,6 +194,13 @@ class JellyfinAccounts extends ChangeNotifier {
|
||||
JellyfinAccount? account(String serverId) =>
|
||||
_accounts.where((a) => a.serverId == serverId).firstOrNull;
|
||||
|
||||
/// [itemId]'s page in the web UI of linked server [serverId] ("Open in
|
||||
/// Jellyfin"); null when that server is not linked here.
|
||||
String? webUrlOf(String serverId, String itemId) {
|
||||
final a = account(serverId);
|
||||
return a == null ? null : jellyfinWebUrl(a.baseUrl, itemId, serverId: serverId);
|
||||
}
|
||||
|
||||
/// The chosen default, else the first linked server.
|
||||
JellyfinAccount? get defaultAccount =>
|
||||
(_defaultServerId == null ? null : account(_defaultServerId!)) ?? _accounts.firstOrNull;
|
||||
@@ -207,14 +213,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 +491,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.
|
||||
@@ -511,6 +550,17 @@ class JellyfinAccounts extends ChangeNotifier {
|
||||
/// Drops cached browse results (refresh button, after a pick).
|
||||
void invalidate(String serverId) => _clearMemo(serverId);
|
||||
|
||||
final _watched = StreamController<String>.broadcast();
|
||||
|
||||
/// Server ids whose watched state this app just changed (a stop report):
|
||||
/// their cached lists are gone, and browse views reload.
|
||||
Stream<String> get watched => _watched.stream;
|
||||
|
||||
void watchedChanged(String serverId) {
|
||||
_clearMemo(serverId);
|
||||
_watched.add(serverId);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_disposed = true;
|
||||
|
||||
@@ -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,8 +1,13 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
|
||||
import 'accounts.dart';
|
||||
import 'format.dart';
|
||||
import '../widgets/app_menu.dart';
|
||||
import '../widgets/copy_open.dart';
|
||||
|
||||
/// One level of the library browser's back stack.
|
||||
sealed class BrowseRoute {
|
||||
@@ -20,14 +25,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 +49,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 +56,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 +74,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 +135,228 @@ 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}) {
|
||||
// Back from watching: the rows show what was just watched.
|
||||
_watchedSub = accounts.watched.listen((id) {
|
||||
if (!_disposed && id == serverId) dataChanged();
|
||||
});
|
||||
}
|
||||
|
||||
late final StreamSubscription<String> _watchedSub;
|
||||
|
||||
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;
|
||||
unawaited(_watchedSub.cancel());
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// An item's title to copy: an episode with its series ("Show · Name").
|
||||
String jellyfinCopyTitle(JellyfinItem item) {
|
||||
final series = item.seriesName;
|
||||
return item.kind == JellyfinItemKind.episode && series != null && series.isNotEmpty
|
||||
? '$series · ${item.name}'
|
||||
: item.name;
|
||||
}
|
||||
|
||||
/// 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;
|
||||
/// Adds it to Up next (with next: right after what plays); null: no
|
||||
/// server (the cards' menu then only copies and opens).
|
||||
final void Function(JellyfinItem item, {bool next})? onAddToQueue;
|
||||
/// 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.onAddToQueue,
|
||||
this.libraryStrip = true,
|
||||
required super.child,
|
||||
});
|
||||
|
||||
static BrowseScope of(BuildContext context) =>
|
||||
context.dependOnInheritedWidgetOfExactType<BrowseScope>()!;
|
||||
|
||||
void onOpen(BrowseRoute route) => controller.open(route);
|
||||
|
||||
/// Right-click on a card ([context]: the card): Put it up, Add to queue,
|
||||
/// Play next (playable, in a server), Copy title, Open in Jellyfin.
|
||||
Future<void> cardMenu(BuildContext context, JellyfinItem item) async {
|
||||
final queue = isSelectable(item) ? onAddToQueue : null;
|
||||
final pick = await showAppMenu<int>(context, items: [
|
||||
if (queue != null) ...const [
|
||||
AppMenuItem(value: 0, label: 'Put it up'),
|
||||
AppMenuItem(value: 1, label: 'Add to queue'),
|
||||
AppMenuItem(value: 2, label: 'Play next'),
|
||||
],
|
||||
const AppMenuItem(value: 3, label: ReuseText.copyTitle),
|
||||
const AppMenuItem(value: 4, label: ReuseText.openInJellyfin),
|
||||
]);
|
||||
switch (pick) {
|
||||
case 0:
|
||||
onPutUp(item);
|
||||
case 1:
|
||||
queue!(item);
|
||||
case 2:
|
||||
queue!(item, next: true);
|
||||
case 3:
|
||||
if (context.mounted) await copyToClipboard(context, jellyfinCopyTitle(item), done: 'Title copied');
|
||||
case 4:
|
||||
if (context.mounted) await openLink(context, jellyfinWebUrl(client.baseUrl, item.id, serverId: account.serverId));
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 +369,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 +382,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 +412,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,54 @@ 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;
|
||||
/// Right-click on a card: Add to queue / Play next; null: no server.
|
||||
final void Function(JellyfinSelection item, {bool next})? onAddToQueue;
|
||||
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.onAddToQueue,
|
||||
this.controller,
|
||||
this.selected,
|
||||
this.onToggleExpanded,
|
||||
this.expanded = false,
|
||||
this.onOpenIntegrations,
|
||||
this.libraryStrip = true,
|
||||
this.searchDebounce = const Duration(milliseconds: 300),
|
||||
});
|
||||
|
||||
@@ -56,45 +58,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 +84,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 +107,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 +150,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 +186,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 +201,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 +220,15 @@ 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)),
|
||||
onAddToQueue: widget.onAddToQueue == null
|
||||
? null
|
||||
: (item, {next = false}) =>
|
||||
widget.onAddToQueue!(JellyfinSelection(account.serverId, item.id, item), next: next),
|
||||
libraryStrip: widget.libraryStrip,
|
||||
child: Focus(
|
||||
onKeyEvent: _onKey,
|
||||
child: Column(
|
||||
@@ -265,21 +239,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 +249,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 +260,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,73 +289,68 @@ class _JellyfinBrowserState extends State<JellyfinBrowser> {
|
||||
style: t.titleSmall?.copyWith(fontWeight: FontWeight.w600))));
|
||||
}
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
_ServerSwitcher(
|
||||
accounts: _accounts,
|
||||
current: account,
|
||||
onSelect: _switchServer,
|
||||
onOpenIntegrations: widget.onOpenIntegrations,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
IconButton(
|
||||
tooltip: 'Back (Esc)',
|
||||
visualDensity: VisualDensity.compact,
|
||||
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,
|
||||
child: TextField(
|
||||
key: const ValueKey('jf-search'),
|
||||
controller: _search,
|
||||
focusNode: _searchFocus,
|
||||
onChanged: _onSearchChanged,
|
||||
onSubmitted: (v) {
|
||||
_debounce?.cancel();
|
||||
setState(() => _term = v.trim());
|
||||
},
|
||||
decoration: InputDecoration(
|
||||
isDense: true,
|
||||
hintText: 'Search ( / )',
|
||||
prefixIcon: const Icon(Icons.search, size: 18),
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
|
||||
suffixIcon: ListenableBuilder(
|
||||
listenable: _search,
|
||||
builder: (context, _) => _search.text.isEmpty
|
||||
? const SizedBox.shrink()
|
||||
: IconButton(
|
||||
tooltip: 'Clear (Esc)',
|
||||
iconSize: 16,
|
||||
onPressed: () => setState(_clearSearch),
|
||||
icon: const Icon(Icons.close),
|
||||
),
|
||||
return LayoutBuilder(builder: (context, box) {
|
||||
final narrow = box.maxWidth < 640;
|
||||
return Row(
|
||||
children: [
|
||||
_ServerSwitcher(
|
||||
accounts: _accounts,
|
||||
current: account,
|
||||
compact: narrow,
|
||||
onSelect: _c.switchServer,
|
||||
onOpenIntegrations: widget.onOpenIntegrations,
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Back (Esc)',
|
||||
visualDensity: VisualDensity.compact,
|
||||
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: narrow ? 170 : 260,
|
||||
child: TextField(
|
||||
key: const ValueKey('jf-search'),
|
||||
controller: _search,
|
||||
focusNode: _searchFocus,
|
||||
onChanged: _onSearchChanged,
|
||||
onSubmitted: (v) {
|
||||
_debounce?.cancel();
|
||||
setState(() => _term = v.trim());
|
||||
},
|
||||
decoration: InputDecoration(
|
||||
isDense: true,
|
||||
hintText: 'Search ( / )',
|
||||
prefixIcon: const Icon(Icons.search, size: 18),
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
|
||||
suffixIcon: ListenableBuilder(
|
||||
listenable: _search,
|
||||
builder: (context, _) => _search.text.isEmpty
|
||||
? const SizedBox.shrink()
|
||||
: IconButton(
|
||||
tooltip: 'Clear (Esc)',
|
||||
iconSize: 16,
|
||||
onPressed: () => setState(_clearSearch),
|
||||
icon: const Icon(Icons.close),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Refresh',
|
||||
visualDensity: VisualDensity.compact,
|
||||
onPressed: () {
|
||||
_accounts.invalidate(account.serverId);
|
||||
setState(() => _refresh++);
|
||||
},
|
||||
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',
|
||||
tooltip: 'Refresh',
|
||||
visualDensity: VisualDensity.compact,
|
||||
onPressed: widget.onToggleExpanded,
|
||||
icon: Icon(widget.expanded ? Icons.close_fullscreen : Icons.open_in_full, size: 18),
|
||||
onPressed: () {
|
||||
_accounts.invalidate(account.serverId);
|
||||
setState(() => _refresh++);
|
||||
},
|
||||
icon: const Icon(Icons.refresh, size: 20),
|
||||
),
|
||||
],
|
||||
);
|
||||
],
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -404,11 +360,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 +379,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,78 +164,56 @@ 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
|
||||
? [cardSubtitle(item), item.name].where((s) => s.isNotEmpty).join(' · ')
|
||||
: cardSubtitle(item);
|
||||
return Focus(
|
||||
canRequestFocus: false,
|
||||
skipTraversal: true,
|
||||
onKeyEvent: (_, e) => _onKey(scope, e),
|
||||
child: Semantics(
|
||||
button: true,
|
||||
selected: selected,
|
||||
label: title,
|
||||
child: InkWell(
|
||||
key: ValueKey('card-${item.id}'),
|
||||
autofocus: widget.autofocus,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
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);
|
||||
},
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
image,
|
||||
const SizedBox(height: 6),
|
||||
SizedBox(
|
||||
height: cardTextHeight - 6,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
color: selected ? scheme.primary : null)),
|
||||
Text(sub,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.bodySmall?.copyWith(color: Colors.grey)),
|
||||
],
|
||||
),
|
||||
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 Semantics(
|
||||
button: true,
|
||||
selected: selected,
|
||||
label: title,
|
||||
child: InkWell(
|
||||
key: ValueKey('card-${item.id}'),
|
||||
autofocus: widget.autofocus,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
focusColor: Colors.transparent,
|
||||
hoverColor: Colors.transparent,
|
||||
onTap: () => _tap(scope),
|
||||
onSecondaryTap: () => scope.cardMenu(context, item),
|
||||
onHover: (h) => setState(() => _hover = h),
|
||||
onFocusChange: (f) => setState(() => _focus = f),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
image,
|
||||
const SizedBox(height: 6),
|
||||
SizedBox(
|
||||
height: cardTextHeight - 6,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: selected ? scheme.primary : null)),
|
||||
Text(sub,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.bodySmall?.copyWith(color: Colors.grey)),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -275,18 +266,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,44 +313,38 @@ 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,
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: widget.items.length,
|
||||
separatorBuilder: (_, _) => const SizedBox(width: cardGap),
|
||||
itemBuilder: (context, i) => SizedBox(
|
||||
width: fit.itemWidth,
|
||||
child: MediaCard(
|
||||
item: widget.items[i],
|
||||
shape: widget.shape,
|
||||
seriesFirst: widget.seriesFirst,
|
||||
builder: (c) => ListView.separated(
|
||||
controller: c,
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: entries.length,
|
||||
separatorBuilder: (_, _) => const SizedBox(width: cardGap),
|
||||
itemBuilder: (context, i) => SizedBox(
|
||||
width: fit.itemWidth,
|
||||
child: MediaCard(
|
||||
item: entries[i].item,
|
||||
display: entries[i].display,
|
||||
subtitle: entries[i].subtitle,
|
||||
shape: widget.shape,
|
||||
seriesFirst: widget.seriesFirst,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -410,12 +402,19 @@ class SectionHeader extends StatelessWidget {
|
||||
padding: const EdgeInsets.only(bottom: 8, top: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(title, style: t.titleMedium),
|
||||
if (count != null) ...[
|
||||
const SizedBox(width: 8),
|
||||
Text(count!, style: t.bodySmall?.copyWith(color: Colors.grey)),
|
||||
],
|
||||
const Spacer(),
|
||||
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)),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
?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,584 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
|
||||
import '../shell/motion.dart';
|
||||
import '../widgets/app_menu.dart';
|
||||
import '../widgets/copy_open.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
|
||||
/// [alone]), 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 is in the server: the button says Play.
|
||||
final bool alone;
|
||||
/// Puts [item] (playable) up; returns an error text, or null when sent.
|
||||
final Future<String?> Function(JellyfinSelection item) onPutUp;
|
||||
/// Adds [item] to Up next (with next: right after what plays); null: no
|
||||
/// server, no queue actions.
|
||||
final Future<String?> Function(JellyfinSelection item, {bool next})? onAddToQueue;
|
||||
final VoidCallback onClose;
|
||||
const JellyfinDetailsPanel({
|
||||
super.key,
|
||||
required this.controller,
|
||||
required this.selection,
|
||||
required this.onPutUp,
|
||||
this.onAddToQueue,
|
||||
required this.onClose,
|
||||
this.alone = 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;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _queue(JellyfinItem target, {bool next = false}) async {
|
||||
final add = widget.onAddToQueue;
|
||||
if (add == null || _busy) return;
|
||||
setState(() {
|
||||
_busy = true;
|
||||
_error = null;
|
||||
});
|
||||
String? error;
|
||||
try {
|
||||
error = await add(JellyfinSelection(_serverId, target.id, target), next: next);
|
||||
} catch (e) {
|
||||
error = '$e';
|
||||
}
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_busy = false;
|
||||
_error = error;
|
||||
});
|
||||
}
|
||||
|
||||
/// Right-click on an episode ([context]: its row): Put it up, Add to
|
||||
/// queue, Play next (in a server), Copy title, Open in Jellyfin.
|
||||
Future<void> _episodeMenu(BuildContext context, JellyfinItem e) async {
|
||||
final queue = widget.onAddToQueue != null;
|
||||
if (queue) setState(() => _targetId = e.id);
|
||||
final client = _client;
|
||||
final pick = await showAppMenu<int>(context, items: [
|
||||
if (queue) ...[
|
||||
AppMenuItem(value: 0, label: widget.alone ? 'Play' : 'Put it up'),
|
||||
const AppMenuItem(value: 1, label: 'Add to queue'),
|
||||
const AppMenuItem(value: 2, label: 'Play next'),
|
||||
],
|
||||
const AppMenuItem(value: 3, label: ReuseText.copyTitle),
|
||||
if (client != null) const AppMenuItem(value: 4, label: ReuseText.openInJellyfin),
|
||||
]);
|
||||
switch (pick) {
|
||||
case 0:
|
||||
await _putUp(e);
|
||||
case 1:
|
||||
await _queue(e);
|
||||
case 2:
|
||||
await _queue(e, next: true);
|
||||
case 3:
|
||||
if (context.mounted) await copyToClipboard(context, jellyfinCopyTitle(e), done: 'Title copied');
|
||||
case 4:
|
||||
if (context.mounted) await openLink(context, jellyfinWebUrl(client!.baseUrl, e.id, serverId: _serverId));
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.alone ? '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 && widget.onAddToQueue != null)
|
||||
GestureDetector(
|
||||
onSecondaryTap: _busy ? null : () => _queue(playable, next: true),
|
||||
child: IconButton(
|
||||
key: const ValueKey('details-add-queue'),
|
||||
onPressed: _busy ? null : () => _queue(playable),
|
||||
tooltip: 'Add to queue (right-click: play next)',
|
||||
icon: const Icon(Icons.playlist_add_rounded),
|
||||
),
|
||||
),
|
||||
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 (client != null)
|
||||
IconButton(
|
||||
key: const ValueKey('details-open-jellyfin'),
|
||||
tooltip: ReuseText.openInJellyfin,
|
||||
onPressed: () => openLink(context, jellyfinWebUrl(client.baseUrl, shown.id, serverId: _serverId)),
|
||||
icon: const Icon(Icons.open_in_browser_rounded),
|
||||
),
|
||||
],
|
||||
),
|
||||
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)
|
||||
Flexible(
|
||||
child: AppSelect<String?>(
|
||||
key: const ValueKey('details-season'),
|
||||
value: seasonId,
|
||||
items: [for (final s in seasons) (s.id, s.name)],
|
||||
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),
|
||||
// The row's own context: its menu opens at the row.
|
||||
child: Builder(builder: (context) => InkWell(
|
||||
key: ValueKey('details-episode-${e.id}'),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
onTap: () => _tapEpisode(e),
|
||||
onSecondaryTap: () => _episodeMenu(context, 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)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
|
||||
import '../library/media_refs.dart';
|
||||
import '../screens/home/picked_media.dart';
|
||||
import 'accounts.dart';
|
||||
|
||||
@@ -33,6 +34,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>[];
|
||||
@@ -86,5 +90,6 @@ JellyfinMedia pickFor(JellyfinAccount account, JellyfinItem item) {
|
||||
title: item.name,
|
||||
subtitle: subtitle,
|
||||
runtime: item.runTime,
|
||||
ref: jellyfinMediaRef(item),
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,12 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:signals/signals_flutter.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
|
||||
import '../state/settings.dart';
|
||||
import '../widgets/app_menu.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 +23,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 +40,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();
|
||||
return ListView(
|
||||
key: const PageStorageKey('jf-home'),
|
||||
padding: const EdgeInsets.fromLTRB(0, 4, 0, 24),
|
||||
children: [
|
||||
_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'),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
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: [
|
||||
if (scope.libraryStrip) ...[
|
||||
LibraryStrip(views: views),
|
||||
const SizedBox(height: 20),
|
||||
] 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,46 +93,116 @@ 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: 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,
|
||||
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) => items.isEmpty
|
||||
? const SizedBox.shrink()
|
||||
: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 24),
|
||||
child: MediaRow(
|
||||
title: title,
|
||||
items: items,
|
||||
shape: shape,
|
||||
seriesFirst: seriesFirst,
|
||||
trailing: trailing,
|
||||
),
|
||||
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,
|
||||
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'),
|
||||
),
|
||||
AppMenuButton<bool>(
|
||||
key: ValueKey('latest-menu-${view.id}'),
|
||||
tooltip: 'Row options',
|
||||
items: () => [
|
||||
AppMenuItem(
|
||||
key: const ValueKey('hide-rereleases'),
|
||||
value: !hide,
|
||||
checked: hide,
|
||||
label: 'Hide re-releases in Latest',
|
||||
),
|
||||
],
|
||||
onSelected: AppSettings.instance.setHideReReleases,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,37 @@
|
||||
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 void Function(JellyfinSelection item, {bool next})? onAddToQueue;
|
||||
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.onAddToQueue,
|
||||
this.controller,
|
||||
this.selected,
|
||||
this.onOpenIntegrations,
|
||||
this.onToggleExpanded,
|
||||
this.expanded = false,
|
||||
this.libraryStrip = true,
|
||||
this.empty,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -48,16 +54,19 @@ 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,
|
||||
onAddToQueue: widget.onAddToQueue,
|
||||
onOpenIntegrations: widget.onOpenIntegrations,
|
||||
onToggleExpanded: widget.onToggleExpanded,
|
||||
expanded: widget.expanded,
|
||||
start: widget.start,
|
||||
libraryStrip: widget.libraryStrip,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
@@ -24,6 +25,15 @@ class PlayerSnapshot {
|
||||
/// go out one after another; a failure is logged and handed to [onError]
|
||||
/// (the session shows one quiet toast) and never touches playback. A 401
|
||||
/// calls [onUnauthorized] and ends reporting.
|
||||
///
|
||||
/// Watched or not is [WatchSession]'s rule (time In, D3), not Jellyfin's
|
||||
/// position percentages (see ARCHITECTURE.md "Watch tracking"): the
|
||||
/// session's watch tracker feeds [watch] and this reporter only reads its
|
||||
/// verdict. An item played before ([playedBefore]) stays played
|
||||
/// (re-marked after Playing, Progress without a position, Stopped without
|
||||
/// one, then marked played again); otherwise Progress leaves the position
|
||||
/// out past [_noPositionFrom] of the runtime so Jellyfin cannot mark it
|
||||
/// played on its own, and Stopped carries what the rule says.
|
||||
class JellyfinProgressReporter {
|
||||
final JellyfinClient client;
|
||||
final String itemId;
|
||||
@@ -34,20 +44,44 @@ class JellyfinProgressReporter {
|
||||
final void Function(Object error)? onError;
|
||||
final VoidCallback? onUnauthorized;
|
||||
|
||||
/// What this viewer watched of the item while it was open here (fed by
|
||||
/// the watch tracker, not by this reporter).
|
||||
final WatchSession watch;
|
||||
|
||||
JellyfinProgressReporter({
|
||||
required this.client,
|
||||
required this.itemId,
|
||||
required this.source,
|
||||
required this.playSessionId,
|
||||
bool playedBefore = false,
|
||||
int resumeTicksBefore = 0,
|
||||
this.interval = const Duration(seconds: 10),
|
||||
this.onError,
|
||||
this.onUnauthorized,
|
||||
this.onStopped,
|
||||
WatchSession? watch,
|
||||
DateTime Function()? clock,
|
||||
}) : _clock = clock ?? DateTime.now;
|
||||
}) : _clock = clock ?? DateTime.now,
|
||||
_keepPlayed = playedBefore,
|
||||
_runtime = (source.runTimeTicks ?? 0) / jellyfinTicksPerSecond,
|
||||
watch = (watch ?? WatchSession())
|
||||
..playedBefore = playedBefore
|
||||
..resumeBefore = resumeTicksBefore / jellyfinTicksPerSecond;
|
||||
|
||||
/// Past this share of the runtime Progress carries no position: Jellyfin
|
||||
/// marks an item played past its "max resume" share (90 % by default).
|
||||
static const _noPositionFrom = 0.85;
|
||||
|
||||
/// After Stopped (and a mark played) went out: the item's watched
|
||||
/// state on the server may differ from what lists show.
|
||||
final void Function()? onStopped;
|
||||
|
||||
Future<void> _chain = Future.value();
|
||||
bool _playing = false;
|
||||
bool _dead = false;
|
||||
bool _keepPlayed;
|
||||
double _runtime;
|
||||
double? _endingStart;
|
||||
PlayerSnapshot? _last;
|
||||
DateTime? _lastAt;
|
||||
DateTime? _lastSentAt;
|
||||
@@ -55,13 +89,23 @@ class JellyfinProgressReporter {
|
||||
/// Between [start] and [stop].
|
||||
bool get active => _playing && !_dead;
|
||||
|
||||
/// The item counts as played here (before, or by the rule at a stop).
|
||||
bool get keepsPlayed => _keepPlayed;
|
||||
|
||||
/// The player's duration (used when Jellyfin gave no runtime) and where
|
||||
/// the ending starts (Outro segment / ED chapter), seconds.
|
||||
void timeline({double duration = 0, double? endingStart}) {
|
||||
if ((source.runTimeTicks ?? 0) <= 0 && duration > 0) _runtime = duration;
|
||||
_endingStart = endingStart;
|
||||
}
|
||||
|
||||
/// The report body for [s]: ticks, pause, and mpv's aid/sid mapped to
|
||||
/// Jellyfin stream indices (-1 = subtitles off).
|
||||
JellyfinPlaybackReport reportFor(PlayerSnapshot s) => JellyfinPlaybackReport(
|
||||
JellyfinPlaybackReport reportFor(PlayerSnapshot s, {bool withPosition = true}) => JellyfinPlaybackReport(
|
||||
itemId: itemId,
|
||||
mediaSourceId: source.id,
|
||||
playSessionId: playSessionId,
|
||||
positionTicks: (s.position * jellyfinTicksPerSecond).round(),
|
||||
positionTicks: withPosition ? _ticks(s.position) : null,
|
||||
isPaused: s.paused,
|
||||
audioStreamIndex: s.aid == null
|
||||
? null
|
||||
@@ -71,13 +115,23 @@ class JellyfinProgressReporter {
|
||||
: source.jellyfinIndexForMpvTrack(JellyfinStreamType.subtitle, s.sid!),
|
||||
);
|
||||
|
||||
static int _ticks(double secs) => (secs * jellyfinTicksPerSecond).round();
|
||||
|
||||
/// Progress may carry the position: not for a played item, not without
|
||||
/// a runtime and not near the end (either would let Jellyfin decide).
|
||||
bool _progressPosition(double position) =>
|
||||
!_keepPlayed && _runtime > 0 && position < _noPositionFrom * _runtime;
|
||||
|
||||
/// Playing: once the media is open. Again after [stop] (played to the
|
||||
/// end, then someone seeked back) starts reporting anew.
|
||||
/// end, then someone seeked back) starts reporting anew. A played item
|
||||
/// is marked played again right after (Jellyfin up to 10.11 clears it
|
||||
/// on Playing).
|
||||
void start(PlayerSnapshot s) {
|
||||
if (_playing || _dead) return;
|
||||
_playing = true;
|
||||
_mark(s);
|
||||
_send('Playing', () => client.reportPlaybackStart(reportFor(s)));
|
||||
if (_keepPlayed) _send('played', () => client.markPlayed(itemId));
|
||||
}
|
||||
|
||||
/// Progress when due (see the class doc).
|
||||
@@ -99,16 +153,43 @@ class JellyfinProgressReporter {
|
||||
_lastAt = now;
|
||||
if (!due) return;
|
||||
_lastSentAt = now;
|
||||
_send('Progress', () => client.reportPlaybackProgress(reportFor(s)));
|
||||
final report = reportFor(s, withPosition: _progressPosition(s.position));
|
||||
_send('Progress', () => client.reportPlaybackProgress(report));
|
||||
}
|
||||
|
||||
/// Stopped (swap, leave, kick, window close, played to the end).
|
||||
/// Stopped (swap, leave, kick, window close, played to the end), as
|
||||
/// [WatchSession.verdict] says: played (or kept played) -> no position,
|
||||
/// then marked played; a peek -> the resume point from before; part way
|
||||
/// -> the position, held under [_noPositionFrom] of the runtime.
|
||||
/// Completes when it and everything before it went out.
|
||||
Future<void> stop(PlayerSnapshot s) {
|
||||
if (!_playing || _dead) return _chain;
|
||||
_playing = false;
|
||||
_send('Stopped', () => client.reportPlaybackStopped(reportFor(s)));
|
||||
return _chain;
|
||||
final v = _keepPlayed
|
||||
? const WatchVerdict(WatchOutcome.keepPlayed)
|
||||
: watch.verdict(runtime: _runtime, endingStart: _endingStart);
|
||||
debugPrint('jellyfin $itemId: $v (${watch.credited.round()} s In of ${_runtime.round()} s)');
|
||||
final int? ticks;
|
||||
switch (v.outcome) {
|
||||
case WatchOutcome.played || WatchOutcome.keepPlayed:
|
||||
_keepPlayed = true;
|
||||
ticks = null;
|
||||
case WatchOutcome.peek:
|
||||
ticks = _ticks(watch.resumeBefore);
|
||||
case WatchOutcome.partial:
|
||||
final at = v.resumeAt ?? s.position;
|
||||
ticks = _ticks(_runtime > 0 ? math.min(at, _noPositionFrom * _runtime) : at);
|
||||
}
|
||||
final report = JellyfinPlaybackReport(
|
||||
itemId: itemId, mediaSourceId: source.id, playSessionId: playSessionId, positionTicks: ticks);
|
||||
_send('Stopped', () => client.reportPlaybackStopped(report));
|
||||
if (ticks == null) {
|
||||
_send('played', () => client.markPlayed(itemId));
|
||||
// Marking played keeps a resume point Progress left (Continue would
|
||||
// still list it, "3 min left"): clear it.
|
||||
_send('resume cleared', () => client.clearResumePosition(itemId));
|
||||
}
|
||||
return _chain = _chain.then((_) => onStopped?.call());
|
||||
}
|
||||
|
||||
void _mark(PlayerSnapshot s) {
|
||||
|
||||
@@ -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,122 +358,93 @@ 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(
|
||||
color: selected
|
||||
? scheme.primaryContainer.withValues(alpha: 0.35)
|
||||
: _hover || _focus
|
||||
? scheme.surfaceContainerHigh
|
||||
: Colors.transparent,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
side: BorderSide(
|
||||
color: selected ? scheme.primary : _focus ? scheme.onSurface : Colors.transparent,
|
||||
width: 1.5),
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
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);
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(6),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 176,
|
||||
child: AspectRatio(
|
||||
aspectRatio: 16 / 9,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: Stack(fit: StackFit.expand, children: [
|
||||
JellyfinImage(
|
||||
client: scope.client,
|
||||
image: e.ownImage(JellyfinImageType.primary) ??
|
||||
e.image(JellyfinImageType.thumb) ??
|
||||
e.image(JellyfinImageType.backdrop),
|
||||
icon: Icons.tv_outlined,
|
||||
return Material(
|
||||
color: selected
|
||||
? scheme.primaryContainer.withValues(alpha: 0.35)
|
||||
: _hover || _focus
|
||||
? scheme.surfaceContainerHigh
|
||||
: Colors.transparent,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
side: BorderSide(
|
||||
color: selected ? scheme.primary : _focus ? scheme.onSurface : Colors.transparent,
|
||||
width: 1.5),
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: InkWell(
|
||||
key: ValueKey('episode-${e.id}'),
|
||||
focusColor: Colors.transparent,
|
||||
onTap: () => _tap(scope),
|
||||
onSecondaryTap: () => scope.cardMenu(context, widget.episode),
|
||||
onHover: (h) => setState(() => _hover = h),
|
||||
onFocusChange: (f) => setState(() => _focus = f),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(6),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 176,
|
||||
child: AspectRatio(
|
||||
aspectRatio: 16 / 9,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: Stack(fit: StackFit.expand, children: [
|
||||
JellyfinImage(
|
||||
client: scope.client,
|
||||
image: e.ownImage(JellyfinImageType.primary) ??
|
||||
e.image(JellyfinImageType.thumb) ??
|
||||
e.image(JellyfinImageType.backdrop),
|
||||
icon: Icons.tv_outlined,
|
||||
),
|
||||
if (progress > 0)
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: LinearProgressIndicator(
|
||||
value: progress, minHeight: 3, backgroundColor: Colors.black54),
|
||||
),
|
||||
if (progress > 0)
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: LinearProgressIndicator(
|
||||
value: progress, minHeight: 3, backgroundColor: Colors.black54),
|
||||
if (played || selected)
|
||||
Positioned(
|
||||
top: 4,
|
||||
right: 4,
|
||||
child: CircleAvatar(
|
||||
radius: 10,
|
||||
backgroundColor: selected ? scheme.primary : const Color(0xCC1B5E20),
|
||||
child: Icon(Icons.check,
|
||||
size: 13, color: selected ? scheme.onPrimary : Colors.white),
|
||||
),
|
||||
if (played || selected)
|
||||
Positioned(
|
||||
top: 4,
|
||||
right: 4,
|
||||
child: CircleAvatar(
|
||||
radius: 10,
|
||||
backgroundColor: selected ? scheme.primary : const Color(0xCC1B5E20),
|
||||
child: Icon(Icons.check,
|
||||
size: 13, color: selected ? scheme.onPrimary : Colors.white),
|
||||
),
|
||||
),
|
||||
]),
|
||||
),
|
||||
]),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
e.indexNumber == null ? e.name : '${e.indexNumber}. ${e.name}',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: t.titleSmall?.copyWith(
|
||||
color: selected ? scheme.primary : played ? Colors.grey : null,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
e.indexNumber == null ? e.name : '${e.indexNumber}. ${e.name}',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: t.titleSmall?.copyWith(
|
||||
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),
|
||||
Text(e.overview!,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: t.bodySmall?.copyWith(color: scheme.onSurfaceVariant)),
|
||||
],
|
||||
Text(meta, style: t.bodySmall?.copyWith(color: Colors.grey)),
|
||||
if (e.overview != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(e.overview!,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: t.bodySmall?.copyWith(color: scheme.onSurfaceVariant)),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -6,6 +6,7 @@ import 'package:sync_relay_core/core.dart';
|
||||
import '../screens/home/picked_media.dart';
|
||||
import 'accounts.dart';
|
||||
import 'format.dart';
|
||||
import '../library/media_refs.dart';
|
||||
|
||||
// Jellyfin media in a session: turning picks and pasted links into refs
|
||||
// (the wire carries only `{serverId, itemId, ...}`), the hosted server's
|
||||
@@ -27,7 +28,8 @@ HelloMedia mediaForPick(JellyfinMedia m) => HelloMedia(
|
||||
name: m.title.isEmpty ? 'Jellyfin item' : m.title,
|
||||
size: 0,
|
||||
duration: 0,
|
||||
jellyfin: refForPick(m));
|
||||
jellyfin: refForPick(m),
|
||||
ref: m.ref ?? jellyfinPickRef(m.title, m.subtitle));
|
||||
|
||||
/// The pick a ref stands for on a linked server (for highlighting it in
|
||||
/// the browser); null when this install has no account there.
|
||||
@@ -77,7 +79,8 @@ final _sourceId = RegExp(r'^[A-Za-z0-9-]{1,64}$');
|
||||
/// is one.
|
||||
Future<HelloMedia> mediaForLink(JellyfinAccounts accounts, String url,
|
||||
{Duration timeout = const Duration(seconds: 6)}) async {
|
||||
final plain = HelloMedia(name: linkName(url), size: 0, duration: 0, url: url);
|
||||
final name = linkName(url);
|
||||
final plain = HelloMedia(name: name, size: 0, duration: 0, url: url, ref: linkMediaRef(name));
|
||||
final link = parseJellyfinLink(url);
|
||||
if (link == null) return plain;
|
||||
await accounts.ensureLoaded();
|
||||
@@ -102,9 +105,11 @@ Future<HelloMedia> mediaForLink(JellyfinAccounts accounts, String url,
|
||||
);
|
||||
final account = accounts.account(serverId);
|
||||
final client = accounts.clientFor(serverId);
|
||||
MediaRef? what;
|
||||
if (account != null && client != null) {
|
||||
try {
|
||||
final pick = pickFor(account, await client.item(link.itemId).timeout(timeout));
|
||||
what = pick.ref;
|
||||
ref = refForPick(JellyfinMedia(
|
||||
serverId: serverId,
|
||||
baseUrl: account.baseUrl,
|
||||
@@ -119,7 +124,11 @@ Future<HelloMedia> mediaForLink(JellyfinAccounts accounts, String url,
|
||||
}
|
||||
}
|
||||
return HelloMedia(
|
||||
name: ref.title.isEmpty ? 'Jellyfin item' : ref.title, size: 0, duration: 0, jellyfin: ref);
|
||||
name: ref.title.isEmpty ? 'Jellyfin item' : ref.title,
|
||||
size: 0,
|
||||
duration: 0,
|
||||
jellyfin: ref,
|
||||
ref: what ?? jellyfinPickRef(ref.title, ref.subtitle ?? ''));
|
||||
}
|
||||
|
||||
/// Display name for a link: last path segment, decoded, never the query
|
||||
@@ -138,27 +147,28 @@ String linkName(String url) {
|
||||
return uri.host.isNotEmpty ? uri.host : 'link';
|
||||
}
|
||||
|
||||
/// The hosted server's relay login: the account for [serverId] (the
|
||||
/// hosted item's server), else the default account, when its token is not
|
||||
/// known to be expired. The device fields are exactly the ones this app
|
||||
/// sends with that token ([JellyfinAccounts.clientInfo]): Jellyfin rewrites
|
||||
/// a token's device from the request header, so the relay must not differ.
|
||||
JellyfinCredential? relayCredentialFor(JellyfinAccounts accounts, {String? serverId}) {
|
||||
bool usable(JellyfinAccount? a) =>
|
||||
a != null && accounts.statusOf(a.serverId) != JellyfinAccountStatus.expired;
|
||||
var a = serverId == null ? null : accounts.account(serverId);
|
||||
if (!usable(a)) a = accounts.defaultAccount;
|
||||
if (!usable(a)) return null;
|
||||
/// The hosted server's relay logins: one per linked Jellyfin account
|
||||
/// whose token is not known to be expired (the default account first),
|
||||
/// so `/j` relays an item with its own server's login. The device fields
|
||||
/// are exactly the ones this app sends with those tokens
|
||||
/// ([JellyfinAccounts.clientInfo]): Jellyfin rewrites a token's device
|
||||
/// from the request header, so the relay must not differ.
|
||||
List<JellyfinCredential> relayCredentialsFor(JellyfinAccounts accounts) {
|
||||
final info = accounts.clientInfo;
|
||||
return JellyfinCredential(
|
||||
baseUrl: a!.baseUrl,
|
||||
serverId: a.serverId,
|
||||
token: a.token,
|
||||
client: info.client,
|
||||
device: info.device,
|
||||
deviceId: info.deviceId,
|
||||
version: info.version,
|
||||
);
|
||||
final first = accounts.defaultAccount;
|
||||
return [
|
||||
for (final a in [?first, ...accounts.accounts.where((a) => a.serverId != first?.serverId)])
|
||||
if (accounts.statusOf(a.serverId) != JellyfinAccountStatus.expired)
|
||||
JellyfinCredential(
|
||||
baseUrl: a.baseUrl,
|
||||
serverId: a.serverId,
|
||||
token: a.token,
|
||||
client: info.client,
|
||||
device: info.device,
|
||||
deviceId: info.deviceId,
|
||||
version: info.version,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
/// `/j/...` URL of the current Jellyfin media on the host this client
|
||||
@@ -201,7 +211,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 +221,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 +235,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 +295,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);
|
||||
}
|
||||
|
||||
@@ -314,10 +328,11 @@ Future<({EpisodeLink? previous, EpisodeLink? next})> findAdjacentEpisodes(OwnPla
|
||||
}
|
||||
}
|
||||
|
||||
/// When the floating "Next episode" pill appears: at [endingStart] (the
|
||||
/// Where the "Next episode" pill's window starts: at [endingStart] (the
|
||||
/// ED: Jellyfin Outro segment or ED-titled chapter, see `endingRange`)
|
||||
/// when one is known inside [runtime], else the last two minutes of
|
||||
/// [runtime] (all of it for shorter media).
|
||||
/// [runtime] (all of it for shorter media). The pill shows only while the
|
||||
/// controls are up (`PillGate`), so a guessed window costs nothing.
|
||||
Duration nextPromptStart(JellyfinItem? item, Duration runtime, {Duration? endingStart}) {
|
||||
if (endingStart != null && endingStart > Duration.zero && endingStart < runtime) {
|
||||
return endingStart;
|
||||
@@ -325,26 +340,3 @@ Duration nextPromptStart(JellyfinItem? item, Duration runtime, {Duration? ending
|
||||
const lead = Duration(minutes: 2);
|
||||
return runtime > lead ? runtime - lead : Duration.zero;
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// 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 (eligible.isEmpty) return null;
|
||||
eligible.sort((a, b) => compareMemberIds(a.id, b.id));
|
||||
return eligible.first.id;
|
||||
}
|
||||
|
||||
/// "m2" before "m10": numeric suffix order, then plain text.
|
||||
int compareMemberIds(String a, String b) {
|
||||
final na = int.tryParse(a.replaceFirst(RegExp(r'^\D+'), ''));
|
||||
final nb = int.tryParse(b.replaceFirst(RegExp(r'^\D+'), ''));
|
||||
if (na != null && nb != null && na != nb) return na.compareTo(nb);
|
||||
return a.compareTo(b);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:hive_ce/hive.dart';
|
||||
|
||||
// Where the library's matches live (device-owned, one Hive box): AniList
|
||||
// entries seen, automatic matches, the user's pins and episode offsets.
|
||||
// Plain JSON-ish values under string keys; reads are synchronous once
|
||||
// loaded, writes go through.
|
||||
|
||||
const libraryBoxName = 'library';
|
||||
|
||||
abstract interface class LibraryStore {
|
||||
/// Loads the stored values (once).
|
||||
Future<void> ensureLoaded();
|
||||
Object? get(String key);
|
||||
void put(String key, Object value);
|
||||
void delete(String key);
|
||||
|
||||
/// Every key starting with [prefix].
|
||||
Iterable<String> keys(String prefix);
|
||||
}
|
||||
|
||||
/// For tests and as the fallback when Hive is not available.
|
||||
class MemoryLibraryStore implements LibraryStore {
|
||||
final Map<String, Object> values;
|
||||
MemoryLibraryStore([Map<String, Object>? values]) : values = values ?? {};
|
||||
|
||||
@override
|
||||
Future<void> ensureLoaded() async {}
|
||||
@override
|
||||
Object? get(String key) => values[key];
|
||||
@override
|
||||
void put(String key, Object value) => values[key] = value;
|
||||
@override
|
||||
void delete(String key) => values.remove(key);
|
||||
@override
|
||||
Iterable<String> keys(String prefix) => values.keys.where((k) => k.startsWith(prefix)).toList();
|
||||
}
|
||||
|
||||
/// The `library` box; until it opens (or when it cannot) it acts as an
|
||||
/// empty memory store, so matching still works for the run.
|
||||
class HiveLibraryStore implements LibraryStore {
|
||||
Box? _box;
|
||||
final _memory = MemoryLibraryStore();
|
||||
Future<void>? _load;
|
||||
|
||||
@override
|
||||
Future<void> ensureLoaded() => _load ??= () async {
|
||||
try {
|
||||
final box = _box = await Hive.openBox(libraryBoxName);
|
||||
// Writes made before the box opened.
|
||||
for (final e in _memory.values.entries) {
|
||||
await box.put(e.key, e.value);
|
||||
}
|
||||
_memory.values.clear();
|
||||
} catch (e, st) {
|
||||
debugPrint('open library store failed: $e\n$st');
|
||||
}
|
||||
}();
|
||||
|
||||
@override
|
||||
Object? get(String key) => _box?.get(key) ?? _memory.get(key);
|
||||
|
||||
@override
|
||||
void put(String key, Object value) {
|
||||
final box = _box;
|
||||
if (box == null) return _memory.put(key, value);
|
||||
box.put(key, value).catchError((Object e) => debugPrint('library store put $key: $e'));
|
||||
}
|
||||
|
||||
@override
|
||||
void delete(String key) {
|
||||
_memory.delete(key);
|
||||
_box?.delete(key).catchError((Object e) => debugPrint('library store delete $key: $e'));
|
||||
}
|
||||
|
||||
@override
|
||||
Iterable<String> keys(String prefix) => [
|
||||
...?_box?.keys.whereType<String>().where((k) => k.startsWith(prefix)),
|
||||
..._memory.keys(prefix),
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
|
||||
import '../files/scanner.dart' show FolderScan;
|
||||
import 'show_matcher.dart';
|
||||
|
||||
// The next episode of a local show (the library's `nextAfter`, numbers
|
||||
// skipped on the way included): what continues a file this app put up
|
||||
// (see ARCHITECTURE.md "What's next").
|
||||
|
||||
/// [path]'s next episode: from the library the Files page saw this run
|
||||
/// ([ShowMatcher.showAt]), else from the file's own folder read now (one
|
||||
/// [scan], grouped by `groupLocal` with that folder's name as a hint). Null when there is none (the last
|
||||
/// episode, not an episode, an unreadable folder).
|
||||
class LocalNextFinder {
|
||||
final ShowMatcher matcher;
|
||||
final Future<FolderScan> Function(String dir) scan;
|
||||
LocalNextFinder({required this.matcher, required this.scan});
|
||||
|
||||
Future<LocalNext?> nextAfter(String path) async {
|
||||
var show = matcher.showAt(path);
|
||||
if (show == null) {
|
||||
final dir = File(path).parent.path;
|
||||
final found = await scan(dir);
|
||||
// The folder itself is a hint (a bare `01.mkv` in `Bleach`).
|
||||
final lib = groupLocal(found.videos, roots: [Directory(dir).parent.path]);
|
||||
show = lib.shows.where((s) => s.files.any((e) => e.path == path)).firstOrNull;
|
||||
}
|
||||
final at = show?.files.where((e) => e.path == path).firstOrNull;
|
||||
if (show == null || at == null || at.kind != LocalKind.episode) return null;
|
||||
return show.nextAfter(at.key);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
|
||||
// `media.ref` (PROTOCOL.md "Media identity") for each source: what a media
|
||||
// is, independent of where its bytes come from. Local files get theirs
|
||||
// from the library (`ShowMatcher.refFor`, L1 + L2); these are the other
|
||||
// two: Jellyfin items by their provider ids, links best effort from their
|
||||
// name.
|
||||
|
||||
/// A Jellyfin episode, movie or video: kind, series (or movie) title,
|
||||
/// season and episode as Jellyfin numbers them, and the AniList / TVDB ids
|
||||
/// its metadata plugins set ([series]' when given: an episode's own ids
|
||||
/// are the episode's, not the show's). Null for anything that is not
|
||||
/// played (series, seasons, folders).
|
||||
MediaRef? jellyfinMediaRef(JellyfinItem item, {JellyfinItem? series}) {
|
||||
final kind = switch (item.kind) {
|
||||
JellyfinItemKind.episode => 'episode',
|
||||
JellyfinItemKind.movie || JellyfinItemKind.video => 'movie',
|
||||
_ => null,
|
||||
};
|
||||
if (kind == null) return null;
|
||||
final episode = kind == 'episode';
|
||||
// An episode's Tvdb id names the episode; the show's comes from the
|
||||
// series item. AniList ids are only ever set on shows and movies.
|
||||
final owner = episode ? series : item;
|
||||
final special = episode && item.parentIndexNumber == 0;
|
||||
return MediaRef(
|
||||
kind: special ? 'extra' : kind,
|
||||
title: episode ? (item.seriesName ?? series?.name ?? item.name) : item.name,
|
||||
anilistId: owner?.providerId('AniList'),
|
||||
tvdbId: owner?.providerId('Tvdb'),
|
||||
season: episode && !special ? item.parentIndexNumber : null,
|
||||
ep: episode && !special ? item.indexNumber : null,
|
||||
);
|
||||
}
|
||||
|
||||
final _sxe = RegExp(r'S(\d{1,3})\s?E(\d{1,4})$', caseSensitive: false);
|
||||
|
||||
/// A picked Jellyfin item from its card's lines alone ([title], and
|
||||
/// [subtitle] "Series · S1E6" for episodes, the year for movies): kind,
|
||||
/// title and numbers, no ids.
|
||||
MediaRef jellyfinPickRef(String title, String subtitle) {
|
||||
final sub = subtitle.trim();
|
||||
final cut = sub.lastIndexOf(' · ');
|
||||
final label = cut < 0 ? sub : sub.substring(cut + 3).trim();
|
||||
final m = _sxe.firstMatch(label);
|
||||
if (m == null) return MediaRef(kind: 'movie', title: title);
|
||||
final season = int.parse(m.group(1)!);
|
||||
return MediaRef(
|
||||
kind: season == 0 ? 'extra' : 'episode',
|
||||
title: cut < 0 ? title : sub.substring(0, cut).trim(),
|
||||
season: season == 0 ? null : season,
|
||||
ep: season == 0 ? null : int.parse(m.group(2)!),
|
||||
);
|
||||
}
|
||||
|
||||
/// A link, best effort: its name read like a release name (`Show - 05`
|
||||
/// is an episode, `OP1` an extra); anything else a movie of that title.
|
||||
MediaRef linkMediaRef(String name) {
|
||||
final p = parseReleaseName(name);
|
||||
final title = p.series.trim().isEmpty ? name.trim() : p.series.trim();
|
||||
return switch (p.kind) {
|
||||
ReleaseKind.episode => MediaRef(
|
||||
kind: 'episode',
|
||||
title: title,
|
||||
season: p.season,
|
||||
ep: p.season == null ? null : p.episode,
|
||||
absolute: p.season == null ? p.episode : null),
|
||||
ReleaseKind.extra => MediaRef(kind: 'extra', title: title, season: p.season),
|
||||
ReleaseKind.movie || ReleaseKind.other => MediaRef(kind: 'movie', title: title),
|
||||
};
|
||||
}
|
||||
|
||||
/// The episode line a ref gives (`S3 E13`, `E108`), or null (movies;
|
||||
/// extras keep the name's own marker).
|
||||
String? refEpisodeLine(MediaRef ref) {
|
||||
if (ref.kind != 'episode') return null;
|
||||
if (ref.season != null && ref.ep != null) return 'S${ref.season} E${ref.ep}';
|
||||
final n = ref.absolute ?? ref.ep ?? ref.anilistEp;
|
||||
return n == null ? null : 'E$n';
|
||||
}
|
||||
@@ -0,0 +1,666 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart' show debugPrint, visibleForTesting;
|
||||
import 'package:signals/signals.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
|
||||
import '../schedule/anilist.dart';
|
||||
import '../schedule/animeflow.dart';
|
||||
import 'library_store.dart';
|
||||
|
||||
// L2 of the library (ARCHITECTURE.md "Media identity"): local shows (L1)
|
||||
// matched to AniList, device-owned and cached. A season of a show is
|
||||
// searched once (title and synonyms, the season's number or name as
|
||||
// hints) and the answer kept by show key, season and season name; its
|
||||
// episodes map onto AniList entries along the run (a season AniList
|
||||
// splits into cours continues on the SEQUEL entry), or onto AnimeFlow's
|
||||
// `anilist_map` when signed in. The user's pins (a show everywhere, or one
|
||||
// folder) and episode offsets override everything and survive rescans.
|
||||
// Requests are lazy (only for shows on screen), one at a time and spaced
|
||||
// out for AniList's rate limit; everything answers offline from the cache.
|
||||
|
||||
/// Where AnimeFlow places an AniList entry: the TVDB season runs of its
|
||||
/// series ([AniListRun]) and the series' TVDB id.
|
||||
class FlowShow {
|
||||
final List<AniListRun> runs;
|
||||
final int? tvdbId;
|
||||
const FlowShow(this.runs, {this.tvdbId});
|
||||
}
|
||||
|
||||
/// AnimeFlow's knowledge of a show, when signed in.
|
||||
abstract interface class FlowLookup {
|
||||
/// The series [anilistId] belongs to; null when AnimeFlow has none (or
|
||||
/// cannot be asked).
|
||||
Future<FlowShow?> forAniList(int anilistId);
|
||||
}
|
||||
|
||||
/// [FlowLookup] over the AnimeFlow app API (`/library`, `/series`), read
|
||||
/// once per app run.
|
||||
class AnimeFlowLookup implements FlowLookup {
|
||||
final AnimeFlowClient client;
|
||||
AnimeFlowLookup(this.client);
|
||||
|
||||
Future<(List<AnimeFlowLibraryItem>, List<AnimeFlowSeries>)>? _load;
|
||||
|
||||
Future<(List<AnimeFlowLibraryItem>, List<AnimeFlowSeries>)> _fetch() async =>
|
||||
(await client.library(), await client.series());
|
||||
|
||||
@override
|
||||
Future<FlowShow?> forAniList(int anilistId) async {
|
||||
final load = _load ??= _fetch();
|
||||
final (List<AnimeFlowLibraryItem>, List<AnimeFlowSeries>) data;
|
||||
try {
|
||||
data = await load;
|
||||
} catch (e) {
|
||||
_load = null;
|
||||
return null;
|
||||
}
|
||||
final (library, series) = data;
|
||||
for (final item in library) {
|
||||
final has = item.anilistMap.any((r) => r.anilistId == anilistId) || item.anilistIds.values.contains(anilistId);
|
||||
if (!has) continue;
|
||||
final s = series
|
||||
.where((s) =>
|
||||
(item.seriesId != null && s.seriesId == item.seriesId) ||
|
||||
(s.jellyfinSeriesId != null && s.jellyfinSeriesId == item.jellyfinSeriesId))
|
||||
.firstOrNull;
|
||||
return FlowShow(item.anilistMap, tvdbId: s?.tvdbId);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// What a pin covers.
|
||||
enum PinScope { show, folder }
|
||||
|
||||
/// The AniList entry a season (or a movie, or an extra's show) is.
|
||||
class SeasonMatch {
|
||||
final AniListEntry entry;
|
||||
|
||||
/// Set by the user ("Wrong show?"), not by the search.
|
||||
final PinScope? pinned;
|
||||
const SeasonMatch(this.entry, {this.pinned});
|
||||
}
|
||||
|
||||
/// An episode on AniList.
|
||||
class EpisodeTarget {
|
||||
final int anilistId;
|
||||
final int? anilistEp;
|
||||
const EpisodeTarget(this.anilistId, this.anilistEp);
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => other is EpisodeTarget && other.anilistId == anilistId && other.anilistEp == anilistEp;
|
||||
@override
|
||||
int get hashCode => Object.hash(anilistId, anilistEp);
|
||||
@override
|
||||
String toString() => 'EpisodeTarget($anilistId, $anilistEp)';
|
||||
}
|
||||
|
||||
/// The library's L2: see the file comment. Made by the composition root.
|
||||
class ShowMatcher {
|
||||
ShowMatcher({
|
||||
required this.store,
|
||||
required this.aniList,
|
||||
this.flow,
|
||||
this.spacing = const Duration(milliseconds: 800),
|
||||
this.retryNoneAfter = const Duration(days: 3),
|
||||
DateTime Function()? now,
|
||||
Future<void> Function(Duration)? delay,
|
||||
}) : _now = now ?? DateTime.now,
|
||||
_delay = delay ?? Future<void>.delayed;
|
||||
|
||||
final LibraryStore store;
|
||||
final AniListLookup aniList;
|
||||
final FlowLookup? flow;
|
||||
|
||||
/// Between two AniList requests.
|
||||
final Duration spacing;
|
||||
|
||||
/// A search that found nothing is asked again after this.
|
||||
final Duration retryNoneAfter;
|
||||
final DateTime Function() _now;
|
||||
final Future<void> Function(Duration) _delay;
|
||||
|
||||
/// Bumps whenever something new is known (a match, an entry, a pin).
|
||||
final revision = signal(0);
|
||||
|
||||
/// Shows being matched right now, by show key.
|
||||
final busy = signal<Set<String>>(const {});
|
||||
|
||||
// Files seen in scans this run, for refs of files put up.
|
||||
final _byPath = <String, LocalEpisode>{};
|
||||
final _showByPath = <String, LocalShow>{};
|
||||
final _entries = <int, AniListEntry>{};
|
||||
final _flows = <int, FlowShow?>{};
|
||||
final _jobs = <String, Future<void> Function()>{};
|
||||
final _failed = <String, DateTime>{};
|
||||
bool _pumping = false;
|
||||
DateTime? _lastRequest;
|
||||
DateTime? _pausedUntil;
|
||||
|
||||
/// Loads the cache (once); reads before it finishes see nothing.
|
||||
Future<void> ensureLoaded() => store.ensureLoaded().then((_) => _bump());
|
||||
|
||||
// ------------------------------------------------------------- keys
|
||||
|
||||
static String matchKey(String showKey, int? season, String? seasonName) =>
|
||||
'$showKey|${season ?? ''}|${normalizeSeries(seasonName ?? '')}';
|
||||
|
||||
static String _movieKey(LocalEpisode e) => '${e.showKey}|movie|${e.movieNumber ?? ''}';
|
||||
|
||||
String _keyOf(LocalEpisode e) =>
|
||||
e.kind == LocalKind.movie ? _movieKey(e) : matchKey(e.showKey, e.season, e.seasonName);
|
||||
|
||||
// ------------------------------------------------------------- reads
|
||||
|
||||
/// Remembers [library]'s files, so [refForPath] and [showAt] know
|
||||
/// their show.
|
||||
void remember(LocalLibrary library) {
|
||||
for (final e in library.files) {
|
||||
_byPath[e.path] = e;
|
||||
}
|
||||
for (final s in library.shows) {
|
||||
for (final e in s.files) {
|
||||
_showByPath[e.path] = s;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The show the file at [path] belongs to, as seen in a scan this run.
|
||||
LocalShow? showAt(String path) => _showByPath[path];
|
||||
|
||||
AniListEntry? entry(int id) {
|
||||
final known = _entries[id];
|
||||
if (known != null) return known;
|
||||
final raw = store.get('entry:$id');
|
||||
final e = raw is Map ? AniListEntry.fromJson(raw['e']) : null;
|
||||
if (e != null) _entries[id] = e;
|
||||
return e;
|
||||
}
|
||||
|
||||
/// The user's pin for [showKey] (everywhere), if any.
|
||||
int? showPin(String showKey) => _int(store.get('pin:show:$showKey'));
|
||||
|
||||
/// The pin of [folder] or the nearest folder above it, if any.
|
||||
({String folder, int id})? folderPin(String folder) {
|
||||
var f = folder;
|
||||
while (f.isNotEmpty) {
|
||||
final id = _int(store.get('pin:folder:$f'));
|
||||
if (id != null) return (folder: f, id: id);
|
||||
final cut = f.lastIndexOf(RegExp(r'[/\\]'));
|
||||
if (cut <= 0) break;
|
||||
f = f.substring(0, cut);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// "These files are episodes N..": added to every file's number of
|
||||
/// [showKey]'s season [season].
|
||||
int offset(String showKey, int? season) => _int(store.get('offset:$showKey|${season ?? ''}')) ?? 0;
|
||||
|
||||
/// The entry [e]'s season (or movie, or the show of an extra) is: a
|
||||
/// folder pin, else the show's pin, else the search's answer.
|
||||
SeasonMatch? seasonMatch(LocalEpisode e) {
|
||||
if (e.kind == LocalKind.other) return null;
|
||||
final fp = folderPin(e.folder);
|
||||
if (fp != null) {
|
||||
final en = entry(fp.id);
|
||||
return en == null ? null : SeasonMatch(en, pinned: PinScope.folder);
|
||||
}
|
||||
final sp = showPin(e.showKey);
|
||||
if (sp != null) {
|
||||
final en = entry(sp);
|
||||
return en == null ? null : SeasonMatch(en, pinned: PinScope.show);
|
||||
}
|
||||
final auto = store.get('auto:${_keyOf(e)}');
|
||||
final id = auto is Map ? _int(auto['id']) : null;
|
||||
if (id == null) {
|
||||
// An extra of no matched season: the show's first one.
|
||||
if (e.kind == LocalKind.extra && e.season != null) {
|
||||
return seasonMatch(LocalEpisode(
|
||||
file: e.file, parsed: e.parsed, kind: LocalKind.extra, showKey: e.showKey, title: e.title, extra: e.extra));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
final en = entry(id);
|
||||
return en == null ? null : SeasonMatch(en);
|
||||
}
|
||||
|
||||
/// What the show is as a whole: its first season's match (else a
|
||||
/// movie's or an extra's).
|
||||
SeasonMatch? showMatch(LocalShow show) {
|
||||
for (final e in [
|
||||
for (final s in show.seasons) s.episodes.first,
|
||||
...show.movies,
|
||||
...show.extras,
|
||||
]) {
|
||||
final m = seasonMatch(e);
|
||||
if (m != null) return m;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// The proper name of [show]: its match's title, else as found.
|
||||
String titleOf(LocalShow show) => showMatch(show)?.entry.title ?? show.title;
|
||||
|
||||
/// The AniList entry and episode [e] is; for extras and movies only the
|
||||
/// entry. Null before a match.
|
||||
EpisodeTarget? target(LocalEpisode e) {
|
||||
final m = seasonMatch(e);
|
||||
if (m == null) return null;
|
||||
if (e.kind != LocalKind.episode) return EpisodeTarget(m.entry.id, null);
|
||||
final n = e.episode! + offset(e.showKey, e.season);
|
||||
if (n < 1) return EpisodeTarget(m.entry.id, null);
|
||||
final fs = _flowOf(m.entry.id);
|
||||
if (fs != null && e.season != null) {
|
||||
final runs = fs.runs.where((r) => r.season == e.season && r.firstEpisode <= n).toList()
|
||||
..sort((a, b) => b.firstEpisode.compareTo(a.firstEpisode));
|
||||
if (runs.isNotEmpty) return EpisodeTarget(runs.first.anilistId, n - runs.first.offset);
|
||||
}
|
||||
// Along the run: past an entry's episodes, its sequel's.
|
||||
var cur = m.entry;
|
||||
var k = n;
|
||||
for (var hop = 0; hop < 30; hop++) {
|
||||
final count = cur.episodes;
|
||||
final next = cur.sequel;
|
||||
if (count == null || k <= count || next == null) break;
|
||||
k -= count;
|
||||
final nx = entry(next);
|
||||
if (nx == null) {
|
||||
_wantEntry(next);
|
||||
return EpisodeTarget(next, k);
|
||||
}
|
||||
cur = nx;
|
||||
}
|
||||
return EpisodeTarget(cur.id, k);
|
||||
}
|
||||
|
||||
/// AniList's title of [e]'s episode, when it knows one.
|
||||
String? episodeTitle(LocalEpisode e) {
|
||||
final t = target(e);
|
||||
final ep = t?.anilistEp;
|
||||
if (t == null || ep == null) return null;
|
||||
return entry(t.anilistId)?.episodeTitles[ep];
|
||||
}
|
||||
|
||||
/// What [e] is on the wire (`media.ref`): the match when there is one,
|
||||
/// else what L1 read (kind, title, numbers).
|
||||
MediaRef refFor(LocalEpisode e) {
|
||||
final m = seasonMatch(e);
|
||||
final t = target(e);
|
||||
final n = e.episode == null ? null : e.episode! + offset(e.showKey, e.season);
|
||||
final tvdb = m == null ? null : _flowOf(m.entry.id)?.tvdbId;
|
||||
return MediaRef(
|
||||
kind: switch (e.kind) {
|
||||
LocalKind.episode => 'episode',
|
||||
LocalKind.extra => 'extra',
|
||||
LocalKind.movie || LocalKind.other => 'movie',
|
||||
},
|
||||
title: m?.entry.title ?? e.title,
|
||||
anilistId: t?.anilistId ?? m?.entry.id,
|
||||
anilistEp: t?.anilistEp,
|
||||
tvdbId: tvdb,
|
||||
season: e.kind == LocalKind.episode ? e.season : null,
|
||||
ep: e.kind == LocalKind.episode && e.season != null ? n : null,
|
||||
absolute: e.kind == LocalKind.episode && e.season == null ? n : null,
|
||||
);
|
||||
}
|
||||
|
||||
/// [refFor] the file at [path]: as seen in a scan this run, else read
|
||||
/// from its name and folder alone.
|
||||
MediaRef refForPath(String path) =>
|
||||
refFor(_byPath[path] ?? readLocalFile(LocalFile(path, 0, DateTime.fromMillisecondsSinceEpoch(0))));
|
||||
|
||||
/// The library's reading of [path] (null: not seen in a scan this run).
|
||||
LocalEpisode? episodeAt(String path) => _byPath[path];
|
||||
|
||||
// ------------------------------------------------------------- matching
|
||||
|
||||
/// Matches [show]'s seasons and movies that have no answer yet, in the
|
||||
/// background (see the file comment).
|
||||
void want(LocalShow show) {
|
||||
final seen = <String>{};
|
||||
for (final e in [
|
||||
for (final s in show.seasons) s.episodes.first,
|
||||
...show.movies,
|
||||
if (show.seasons.isEmpty) ...show.extras.take(1),
|
||||
]) {
|
||||
final key = _keyOf(e);
|
||||
if (!seen.add(key)) continue;
|
||||
final pinned = folderPin(e.folder)?.id ?? showPin(e.showKey);
|
||||
if (pinned != null) {
|
||||
if (entry(pinned) == null) _wantEntry(pinned);
|
||||
continue;
|
||||
}
|
||||
_wantMatch(key, show, e);
|
||||
}
|
||||
final m = showMatch(show);
|
||||
if (m != null) _wantFlow(m.entry.id);
|
||||
}
|
||||
|
||||
/// [want] for each standalone movie of [library] and each of [shows].
|
||||
void wantMovie(LocalEpisode movie) {
|
||||
if (movie.kind != LocalKind.movie) return;
|
||||
_wantMatch(_movieKey(movie), null, movie);
|
||||
}
|
||||
|
||||
void _wantMatch(String key, LocalShow? show, LocalEpisode e) {
|
||||
final auto = store.get('auto:$key');
|
||||
if (auto is Map) {
|
||||
final id = _int(auto['id']);
|
||||
if (id != null) {
|
||||
if (entry(id) == null) _wantEntry(id);
|
||||
return;
|
||||
}
|
||||
final at = _int(auto['at']) ?? 0;
|
||||
if (_now().difference(DateTime.fromMillisecondsSinceEpoch(at)) < retryNoneAfter) return;
|
||||
}
|
||||
if (_recentlyFailed(key)) return;
|
||||
_enqueue('match:$key', () => _match(key, show?.title ?? e.title, e));
|
||||
_setBusy(e.showKey, true);
|
||||
}
|
||||
|
||||
void _wantEntry(int id) {
|
||||
if (_recentlyFailed('entry:$id')) return;
|
||||
_enqueue('entry:$id', () async {
|
||||
final en = await _ask(() => aniList.anime(id));
|
||||
if (en != null) _keepEntry(en);
|
||||
});
|
||||
}
|
||||
|
||||
void _wantFlow(int id) {
|
||||
final f = flow;
|
||||
if (f == null || _flows.containsKey(id)) return;
|
||||
final stored = store.get('flow:$id');
|
||||
if (stored is Map) {
|
||||
_flows[id] = _flowFromJson(stored);
|
||||
final at = _int(stored['at']) ?? 0;
|
||||
if (_now().difference(DateTime.fromMillisecondsSinceEpoch(at)) < const Duration(days: 1)) return;
|
||||
} else {
|
||||
_flows[id] = null;
|
||||
}
|
||||
unawaited(f.forAniList(id).then((fs) {
|
||||
if (fs == null) return;
|
||||
_flows[id] = fs;
|
||||
store.put('flow:$id', {
|
||||
'at': _now().millisecondsSinceEpoch,
|
||||
'tvdb': fs.tvdbId,
|
||||
'runs': [
|
||||
for (final r in fs.runs) [r.season, r.anilistId, r.firstEpisode, r.offset]
|
||||
],
|
||||
});
|
||||
_bump();
|
||||
}).catchError((Object e) => debugPrint('library: animeflow lookup $id: $e')));
|
||||
}
|
||||
|
||||
FlowShow? _flowOf(int id) {
|
||||
if (!_flows.containsKey(id)) {
|
||||
final stored = store.get('flow:$id');
|
||||
if (stored is Map) _flows[id] = _flowFromJson(stored);
|
||||
}
|
||||
return _flows[id];
|
||||
}
|
||||
|
||||
static FlowShow? _flowFromJson(Map j) {
|
||||
final runs = <AniListRun>[];
|
||||
final raw = j['runs'];
|
||||
if (raw is List) {
|
||||
for (final r in raw) {
|
||||
if (r is List && r.length == 4 && r.every((v) => v is int)) {
|
||||
runs.add(AniListRun(
|
||||
season: r[0] as int, anilistId: r[1] as int, firstEpisode: r[2] as int, offset: r[3] as int));
|
||||
}
|
||||
}
|
||||
}
|
||||
return runs.isEmpty ? null : FlowShow(runs, tvdbId: _int(j['tvdb']));
|
||||
}
|
||||
|
||||
/// One search for the season [e] stands for; keeps the best entry (or
|
||||
/// "none") under [key].
|
||||
Future<void> _match(String key, String title, LocalEpisode e) async {
|
||||
try {
|
||||
final plain = title.replaceAll(RegExp(r'[._]+'), ' ').replaceAll(RegExp(r'\s+'), ' ').trim();
|
||||
final movie = e.kind == LocalKind.movie;
|
||||
final season = movie ? null : e.season;
|
||||
final name = movie ? null : e.seasonName;
|
||||
final query = [
|
||||
plain,
|
||||
if (name != null) name else if (season != null && season > 1) 'season $season',
|
||||
].join(' ');
|
||||
var hits = await _ask(() => aniList.searchAnime(query));
|
||||
var best = pickMatch(hits, title: plain, season: season, seasonName: name, movie: movie);
|
||||
if (best == null && query != plain) {
|
||||
hits = await _ask(() => aniList.searchAnime(plain));
|
||||
best = pickMatch(hits, title: plain, season: season, seasonName: name, movie: movie);
|
||||
// Found the show but not the season: walk its sequels.
|
||||
if (best != null && season != null && season > 1 && name == null && seasonMarker(best) != season) {
|
||||
var cur = best;
|
||||
for (var hop = 1; hop < season; hop++) {
|
||||
final next = cur.sequel;
|
||||
if (next == null) break;
|
||||
final nx = entry(next) ?? await _ask(() => aniList.anime(next));
|
||||
if (nx == null) break;
|
||||
_keepEntry(nx);
|
||||
cur = nx;
|
||||
}
|
||||
best = cur;
|
||||
}
|
||||
}
|
||||
for (final h in hits) {
|
||||
if (h.id == best?.id) _keepEntry(h);
|
||||
}
|
||||
store.put('auto:$key', {'id': best?.id, 'at': _now().millisecondsSinceEpoch});
|
||||
if (best != null) _wantFlow(best.id);
|
||||
_bump();
|
||||
} finally {
|
||||
_setBusy(e.showKey, false);
|
||||
}
|
||||
}
|
||||
|
||||
/// "Wrong show?": AniList's answers for [query], best first.
|
||||
Future<List<AniListEntry>> search(String query) async {
|
||||
final hits = await _ask(() => aniList.searchAnime(query));
|
||||
return hits;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- corrections
|
||||
|
||||
/// Pins [showKey] to [e] everywhere (every folder, every rescan).
|
||||
void pinShow(String showKey, AniListEntry e) {
|
||||
_keepEntry(e);
|
||||
store.put('pin:show:$showKey', e.id);
|
||||
_wantFlow(e.id);
|
||||
_bump();
|
||||
}
|
||||
|
||||
/// Pins every video in [folder] (and below) to [e].
|
||||
void pinFolder(String folder, AniListEntry e) {
|
||||
_keepEntry(e);
|
||||
store.put('pin:folder:$folder', e.id);
|
||||
_wantFlow(e.id);
|
||||
_bump();
|
||||
}
|
||||
|
||||
void unpinShow(String showKey) {
|
||||
store.delete('pin:show:$showKey');
|
||||
_bump();
|
||||
}
|
||||
|
||||
void unpinFolder(String folder) {
|
||||
store.delete('pin:folder:$folder');
|
||||
_bump();
|
||||
}
|
||||
|
||||
/// "These files are episodes N..": [offset] is added to the file
|
||||
/// numbers of [showKey]'s season [season] (0 removes it).
|
||||
void setOffset(String showKey, int? season, int offset) {
|
||||
final key = 'offset:$showKey|${season ?? ''}';
|
||||
offset == 0 ? store.delete(key) : store.put(key, offset);
|
||||
_bump();
|
||||
}
|
||||
|
||||
/// Forgets the automatic match of [show] so it is searched again.
|
||||
void rematch(LocalShow show) {
|
||||
for (final k in store.keys('auto:${show.key}|').toList()) {
|
||||
store.delete(k);
|
||||
}
|
||||
want(show);
|
||||
_bump();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- plumbing
|
||||
|
||||
void _keepEntry(AniListEntry e) {
|
||||
_entries[e.id] = e;
|
||||
store.put('entry:${e.id}', {'at': _now().millisecondsSinceEpoch, 'e': e.toJson()});
|
||||
_bump();
|
||||
}
|
||||
|
||||
void _bump() => revision.value++;
|
||||
|
||||
void _setBusy(String showKey, bool on) {
|
||||
final now = {...busy.peek()};
|
||||
if (on ? now.add(showKey) : now.remove(showKey)) busy.value = now;
|
||||
}
|
||||
|
||||
bool _recentlyFailed(String key) {
|
||||
final at = _failed[key];
|
||||
return at != null && _now().difference(at) < const Duration(minutes: 5);
|
||||
}
|
||||
|
||||
void _enqueue(String key, Future<void> Function() job) {
|
||||
if (_jobs.containsKey(key)) return;
|
||||
_jobs[key] = () => job().catchError((Object e) {
|
||||
_failed[key] = _now();
|
||||
debugPrint('library: $key failed: $e');
|
||||
if (e is AniListException && e.isRateLimited) _pausedUntil = _now().add(const Duration(minutes: 1));
|
||||
});
|
||||
if (!_pumping) unawaited(_pump());
|
||||
}
|
||||
|
||||
Future<void> _pump() async {
|
||||
_pumping = true;
|
||||
try {
|
||||
while (_jobs.isNotEmpty) {
|
||||
final key = _jobs.keys.first;
|
||||
final job = _jobs[key]!;
|
||||
await job();
|
||||
_jobs.remove(key);
|
||||
}
|
||||
} finally {
|
||||
_pumping = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// One AniList request, spaced from the last one (and after a rate
|
||||
/// limit's pause).
|
||||
Future<T> _ask<T>(Future<T> Function() request) async {
|
||||
final paused = _pausedUntil;
|
||||
if (paused != null && paused.isAfter(_now())) await _delay(paused.difference(_now()));
|
||||
final last = _lastRequest;
|
||||
if (last != null) {
|
||||
final wait = spacing - _now().difference(last);
|
||||
if (wait > Duration.zero) await _delay(wait);
|
||||
}
|
||||
_lastRequest = _now();
|
||||
return request();
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
bool get idle => _jobs.isEmpty && !_pumping;
|
||||
|
||||
static int? _int(Object? v) => v is int ? v : null;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ scoring
|
||||
|
||||
/// The season number an entry's names carry (`Season 2`, `2nd Season`,
|
||||
/// `Part 2` is not one); null when none does.
|
||||
int? seasonMarker(AniListEntry e) {
|
||||
for (final n in e.names) {
|
||||
final s = _seasonOf(normalizeSeries(n));
|
||||
if (s != null) return s;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
final _seasonWords = RegExp(r'\b(?:season (\d{1,2})|(\d{1,2})(?:st|nd|rd|th) season|s(\d{1,2}))\b');
|
||||
final _roman = {'ii': 2, 'iii': 3, 'iv': 4, 'v': 5, 'vi': 6};
|
||||
|
||||
int? _seasonOf(String normalized) {
|
||||
final m = _seasonWords.firstMatch(normalized);
|
||||
if (m != null) return int.parse(m.group(1) ?? m.group(2) ?? m.group(3)!);
|
||||
final last = normalized.split(' ').last;
|
||||
return _roman[last];
|
||||
}
|
||||
|
||||
/// How alike two normalized titles are, 0..1.
|
||||
double titleSimilarity(String a, String b) {
|
||||
if (a.isEmpty || b.isEmpty) return 0;
|
||||
if (a == b) return 1;
|
||||
final short = a.length <= b.length ? a : b;
|
||||
final long = a.length <= b.length ? b : a;
|
||||
final ratio = short.length / long.length;
|
||||
if (long.startsWith('$short ')) return 0.8 + 0.2 * ratio;
|
||||
if (long.contains(' $short ') || long.endsWith(' $short')) return 0.6 + 0.2 * ratio;
|
||||
// Dice over letter pairs, spaces ignored.
|
||||
Set<String> pairs(String s) {
|
||||
final t = s.replaceAll(' ', '');
|
||||
return {for (var i = 0; i + 1 < t.length; i++) t.substring(i, i + 2)};
|
||||
}
|
||||
|
||||
final pa = pairs(a), pb = pairs(b);
|
||||
if (pa.isEmpty || pb.isEmpty) return 0;
|
||||
return 2 * pa.intersection(pb).length / (pa.length + pb.length);
|
||||
}
|
||||
|
||||
/// Scores [e] as the answer for a show called [title] (its season
|
||||
/// [season] or its season named [seasonName]; a [movie]); [rank]: its
|
||||
/// place in AniList's own order.
|
||||
double scoreMatch(AniListEntry e,
|
||||
{required String title, int? season, String? seasonName, bool movie = false, int rank = 0}) {
|
||||
final q = normalizeSeries(title);
|
||||
final full = seasonName == null ? q : normalizeSeries('$title $seasonName');
|
||||
var best = 0.0;
|
||||
for (final n in e.names) {
|
||||
final nn = normalizeSeries(n);
|
||||
final s = [titleSimilarity(nn, q), titleSimilarity(nn, full)].reduce((a, b) => a > b ? a : b);
|
||||
if (s > best) best = s;
|
||||
}
|
||||
var score = best;
|
||||
if (seasonName != null) {
|
||||
final sub = normalizeSeries(seasonName);
|
||||
if (e.names.any((n) => normalizeSeries(n).contains(sub))) score += 0.25;
|
||||
} else if (!movie) {
|
||||
final wanted = season ?? 1;
|
||||
final marker = seasonMarker(e);
|
||||
if (marker != null && marker == wanted) {
|
||||
score += 0.2;
|
||||
} else if (marker != null) {
|
||||
score -= 0.25;
|
||||
} else if (wanted > 1) {
|
||||
score -= 0.1;
|
||||
}
|
||||
}
|
||||
if (movie) {
|
||||
score += e.isMovie ? 0.15 : -0.25;
|
||||
} else {
|
||||
score += e.isSeries ? 0.05 : (e.isMovie ? -0.3 : -0.1);
|
||||
}
|
||||
return score - rank * 0.02;
|
||||
}
|
||||
|
||||
/// The best of [hits] for a show called [title], if any is good enough.
|
||||
AniListEntry? pickMatch(List<AniListEntry> hits,
|
||||
{required String title, int? season, String? seasonName, bool movie = false}) {
|
||||
AniListEntry? best;
|
||||
var bestScore = 0.55;
|
||||
for (var i = 0; i < hits.length; i++) {
|
||||
final s = scoreMatch(hits[i], title: title, season: season, seasonName: seasonName, movie: movie, rank: i);
|
||||
if (s > bestScore) {
|
||||
best = hits[i];
|
||||
bestScore = s;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
|
||||
import '../screens/home/ui.dart' show appName, displayVersion;
|
||||
import 'ports/app_dirs.dart';
|
||||
import '../app/app_graph.dart';
|
||||
|
||||
// The app's log on disk (<app support>/logs/app-YYYY-MM-DD.log; core's
|
||||
// logging.dart writes, rotates and redacts it), debugPrint routed into it,
|
||||
// and the "Copy diagnostics" text of Settings → About.
|
||||
|
||||
final _appLog = createLogger('app');
|
||||
|
||||
/// The logs folder, once [startAppLogging] ran.
|
||||
String? appLogDir;
|
||||
|
||||
/// Opens the log files in [dirs]' log folder and routes `debugPrint` and
|
||||
/// uncaught errors into the log.
|
||||
Future<void> startAppLogging(AppDirs dirs) async {
|
||||
try {
|
||||
if (startLogFiles(dirs.logs) != null) appLogDir = dirs.logs;
|
||||
} catch (e) {
|
||||
_appLog.warning('no log files: $e');
|
||||
}
|
||||
// Everything the app prints for diagnosis lands in the file too (the
|
||||
// log's console output replaces the print).
|
||||
debugPrint = (String? message, {int? wrapWidth}) {
|
||||
if (message == null || message.isEmpty) return;
|
||||
final bad = RegExp(r'fail|error|crash|exception', caseSensitive: false).hasMatch(message);
|
||||
bad ? _appLog.warning(message) : _appLog.info(message);
|
||||
};
|
||||
PlatformDispatcher.instance.onError = (error, stack) {
|
||||
_appLog.severe('uncaught: $error', error, stack);
|
||||
return false;
|
||||
};
|
||||
_appLog.info('$appName ${displayVersion()} on ${Platform.operatingSystem} '
|
||||
'${Platform.operatingSystemVersion}, pid $pid, Dart ${Platform.version.split(' ').first}'
|
||||
'${dirs.profile == null ? '' : ', profile ${dirs.profile}'}');
|
||||
for (final line in dirs.migrated) {
|
||||
_appLog.info(line);
|
||||
}
|
||||
final bad = AppDirs.invalidProfile(Platform.environment);
|
||||
if (bad != null) _appLog.warning('$profileEnv="$bad" ignored: use letters, digits, - and _ (at most 32)');
|
||||
}
|
||||
|
||||
/// The text "Copy diagnostics" gives: versions, then the last [since] of
|
||||
/// the app log (its hosted server's records included), already redacted;
|
||||
/// with [sessionLog], the server's session log of the same hours after it
|
||||
/// (every member's shared lines and the server's, merged).
|
||||
Future<String> diagnosticsText(AppGraph graph, {Duration since = const Duration(hours: 6), bool sessionLog = false}) async {
|
||||
final b = StringBuffer()
|
||||
..writeln('$appName ${displayVersion()}')
|
||||
..writeln('OS: ${Platform.operatingSystem} ${Platform.operatingSystemVersion}')
|
||||
..writeln('Dart: ${Platform.version.split(' ').first}');
|
||||
try {
|
||||
final mpv = await graph.coordinator.playback.mpvVersion().timeout(const Duration(seconds: 2));
|
||||
b.writeln('mpv: ${mpv ?? 'no player yet'}');
|
||||
} catch (_) {
|
||||
b.writeln('mpv: unknown');
|
||||
}
|
||||
try {
|
||||
final y = await graph.ports.links.ytDlp.timeout(const Duration(seconds: 2));
|
||||
final cookies = y.cookies.read(count: false);
|
||||
b.writeln('yt-dlp: ${y.version ?? 'not ready'}${y.lastError == null ? '' : ' (${y.lastError})'}, '
|
||||
'cookies: ${cookies == null ? 'none' : 'set'}');
|
||||
} catch (_) {
|
||||
b.writeln('yt-dlp: unknown');
|
||||
}
|
||||
final files = logFiles;
|
||||
b.writeln('Log: ${files?.currentPath ?? 'none (console only)'}');
|
||||
b.writeln('--- last ${since.inHours} h of the log ---');
|
||||
b.write(files?.recent(since) ?? '');
|
||||
if (sessionLog) {
|
||||
final server = await graph.coordinator.sessionLog(hours: since.inHours);
|
||||
b
|
||||
..writeln()
|
||||
..writeln("--- last ${since.inHours} h of the server's session log ---")
|
||||
..write(server ?? "(none: the server keeps no session log, or it couldn't be reached)");
|
||||
}
|
||||
return redactLog(b.toString());
|
||||
}
|
||||
|
||||
/// Saves [text] next to the logs as `diagnostics-<time>.txt`; its path,
|
||||
/// or null when it could not be written.
|
||||
Future<String?> saveDiagnostics(String text) async {
|
||||
final dir = appLogDir;
|
||||
if (dir == null) return null;
|
||||
final t = DateTime.now();
|
||||
String two(int n) => n.toString().padLeft(2, '0');
|
||||
final path = '$dir${Platform.pathSeparator}diagnostics-${t.year}${two(t.month)}${two(t.day)}-'
|
||||
'${two(t.hour)}${two(t.minute)}${two(t.second)}.txt';
|
||||
try {
|
||||
// Older ones go after the log files' week.
|
||||
for (final e in Directory(dir).listSync()) {
|
||||
if (e is File &&
|
||||
e.uri.pathSegments.last.startsWith('diagnostics-') &&
|
||||
t.difference(e.statSync().modified) > const Duration(days: 7)) {
|
||||
e.deleteSync();
|
||||
}
|
||||
}
|
||||
await File(path).writeAsString(text);
|
||||
return path;
|
||||
} catch (e) {
|
||||
_appLog.warning('cannot save diagnostics: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../files/thumbnails.dart';
|
||||
import '../app/app_graph.dart';
|
||||
|
||||
// The one way out of the app (close button, Alt+F4, Update and restart).
|
||||
// Order matters: every mpv player goes before the engine does (a live one
|
||||
// calls a dead Dart callback), then the network goodbye, then the window
|
||||
// closes through the platform's normal path so the engine is torn down
|
||||
// inside the message loop. The runners end the process right after
|
||||
// (`linux/runner/main.cc`, `windows/runner/main.cpp`).
|
||||
|
||||
bool _shuttingDown = false;
|
||||
|
||||
/// True once [shutDownApp] started.
|
||||
bool get shuttingDown => _shuttingDown;
|
||||
|
||||
/// The systems the close stops; `main` hands them over once.
|
||||
AppGraph? _graph;
|
||||
|
||||
/// Makes [graph] the one [shutDownApp] stops and returns the close
|
||||
/// request handler for the window.
|
||||
Future<void> Function() closeAppWith(AppGraph graph) {
|
||||
_graph = graph;
|
||||
return shutDownApp;
|
||||
}
|
||||
|
||||
/// Hides the window, releases every player and the sound device, leaves
|
||||
/// (the `bye` goes out first, so others see "left" at once; then your
|
||||
/// server stops), runs [beforeQuit], then closes the window. [restart]
|
||||
/// (Update and restart) leaves without the `bye`: the member stays
|
||||
/// `gone`, and the restarted app, same client id, takes it back within
|
||||
/// the grace. Every step is bounded; a second call does nothing.
|
||||
Future<void> shutDownApp({Future<void> Function()? beforeQuit, bool restart = false}) async {
|
||||
final graph = _graph;
|
||||
if (_shuttingDown || graph == null) return;
|
||||
_shuttingDown = true;
|
||||
final shell = graph.ports.shell;
|
||||
final total = Stopwatch()..start();
|
||||
await _step('window save', shell.saveWindow(), const Duration(milliseconds: 500));
|
||||
await _step('hide', shell.hide(), const Duration(milliseconds: 500));
|
||||
await Future.wait([
|
||||
_step('players', graph.coordinator.releasePlayers(), const Duration(seconds: 3)),
|
||||
_step('thumbnails', cancelAllThumbnails(), const Duration(milliseconds: 1500)),
|
||||
_step('sounds', graph.sfx.dispose(), const Duration(milliseconds: 500)),
|
||||
]);
|
||||
// The host owns the disposal order: anything still alive goes now.
|
||||
await _step('all players', graph.ports.players.disposeAll(), const Duration(milliseconds: 1500));
|
||||
// Windows: media_kit_video frees a player's video output on a thread of
|
||||
// its own after dispose returns; it must be done before the engine
|
||||
// stops, or the plugin's teardown waits on it for good.
|
||||
if (Platform.isWindows) await Future.delayed(const Duration(milliseconds: 300));
|
||||
// Jellyfin stop report, last diagnostics, the bye (flushed), then your
|
||||
// server: 3 s in all.
|
||||
await _step('leave', graph.navigation.leave(bye: !restart), const Duration(milliseconds: 2500));
|
||||
await _step('server stop', graph.yourServer.stop(), const Duration(milliseconds: 500));
|
||||
if (beforeQuit != null) await _step('before quit', beforeQuit(), const Duration(seconds: 3));
|
||||
debugPrint('close: ready to quit after ${total.elapsedMilliseconds} ms');
|
||||
await shell.quit();
|
||||
// The engine stops with the window, so this only fires when the close
|
||||
// did not go through. A kill, not exit(): no exit handlers (NVIDIA's
|
||||
// EGL one segfaults, Windows DLL detach can hang).
|
||||
Timer(const Duration(seconds: 3), () {
|
||||
debugPrint('close: the window did not close; ending the process');
|
||||
Process.killPid(pid, ProcessSignal.sigkill);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _step(String what, Future<void>? f, Duration limit) async {
|
||||
if (f == null) return;
|
||||
final sw = Stopwatch()..start();
|
||||
try {
|
||||
await f.timeout(limit);
|
||||
} on TimeoutException {
|
||||
debugPrint('close: $what did not finish in ${limit.inMilliseconds} ms');
|
||||
} catch (e) {
|
||||
debugPrint('close: $what failed: $e');
|
||||
}
|
||||
if (sw.elapsedMilliseconds > 200) debugPrint('close: $what took ${sw.elapsedMilliseconds} ms');
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_soloud/flutter_soloud.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
|
||||
final _log = createLogger('sound');
|
||||
|
||||
/// What [SoundEngine] needs from an audio library: [SoLoudBackend] in the
|
||||
/// app, a fake in tests. A sound handle is whatever [load] returns.
|
||||
abstract class AudioBackend {
|
||||
Future<void> init();
|
||||
/// Decodes [wav] (a whole WAV file) into memory under [name].
|
||||
Future<Object> load(String name, Uint8List wav);
|
||||
/// Starts one voice of [sound] at [volume] (linear gain, 0..1); voices
|
||||
/// overlap.
|
||||
void play(Object sound, double volume);
|
||||
Future<void> unload(Object sound);
|
||||
Future<void> deinit();
|
||||
}
|
||||
|
||||
/// flutter_soloud (SoLoud mixing into miniaudio's output device).
|
||||
class SoLoudBackend implements AudioBackend {
|
||||
SoLoud get _s => SoLoud.instance;
|
||||
|
||||
@override
|
||||
Future<void> init() => _s.init();
|
||||
|
||||
@override
|
||||
Future<Object> load(String name, Uint8List wav) => _s.loadMem(name, wav);
|
||||
|
||||
@override
|
||||
void play(Object sound, double volume) => _s.play(sound as AudioSource, volume: volume);
|
||||
|
||||
@override
|
||||
Future<void> unload(Object sound) => _s.disposeSource(sound as AudioSource);
|
||||
|
||||
@override
|
||||
Future<void> deinit() => _s.deinitAsync();
|
||||
}
|
||||
|
||||
/// The app's short in-memory sounds. The audio device opens on [warmUp] or
|
||||
/// the first [play]; if it can't (no device, a broken driver) the engine
|
||||
/// logs once and every play is silently dropped. Each sound is loaded once
|
||||
/// per slot from the WAV bytes its renderer gives; a new key in a slot
|
||||
/// replaces (and frees) what the slot held.
|
||||
class SoundEngine {
|
||||
final AudioBackend _backend;
|
||||
SoundEngine({AudioBackend? backend}) : _backend = backend ?? SoLoudBackend();
|
||||
|
||||
Future<bool>? _ready;
|
||||
var _disposed = false;
|
||||
|
||||
/// A file that exists only while the audio device is opening (set by the
|
||||
/// app). Found at the next start, the last open took the app down (a
|
||||
/// native crash in the driver or miniaudio, seen on Windows): sounds stay
|
||||
/// off rather than crash every start, until [retry] (Settings' preview).
|
||||
String? crashMark;
|
||||
var _skipped = false;
|
||||
/// Per slot: the loaded key and its sound (null: failed to load).
|
||||
final Map<String, (String, Future<Object?>)> _slots = {};
|
||||
/// Load names are unique: SoLoud hands back the old sound for a name it
|
||||
/// already holds, which would defeat a re-render.
|
||||
var _loads = 0;
|
||||
|
||||
/// Opens the audio device now, so the first sound has no wait.
|
||||
Future<bool> warmUp() => _ready ??= _init();
|
||||
|
||||
Future<bool> _init() async {
|
||||
if (_disposed) return false;
|
||||
final mark = crashMark == null ? null : File(crashMark!);
|
||||
try {
|
||||
if (mark != null && mark.existsSync()) {
|
||||
_skipped = true;
|
||||
_log.warning('no sounds: opening the audio device crashed the app last time '
|
||||
'(${mark.path}); a sound preview in Settings tries again');
|
||||
return false;
|
||||
}
|
||||
mark?.writeAsStringSync('opening the audio device');
|
||||
} catch (_) {}
|
||||
_log.info('opening the audio device');
|
||||
try {
|
||||
await _backend.init();
|
||||
_log.info('audio device open');
|
||||
return true;
|
||||
} catch (e) {
|
||||
_log.warning('no sounds: audio init failed: $e');
|
||||
return false;
|
||||
} finally {
|
||||
try {
|
||||
if (mark != null && mark.existsSync()) mark.deleteSync();
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens the device again after a crash mark kept sounds off (the user
|
||||
/// asked for a sound). A real crash leaves the mark again.
|
||||
void retry() {
|
||||
if (!_skipped) return;
|
||||
_skipped = false;
|
||||
try {
|
||||
final m = crashMark;
|
||||
if (m != null) File(m).deleteSync();
|
||||
} catch (_) {}
|
||||
_ready = null;
|
||||
}
|
||||
|
||||
/// Plays the sound of [slot] at [volume] (linear gain). [key] names what
|
||||
/// [wav] renders: the same key reuses the loaded sound; another key, or
|
||||
/// [fresh], renders and loads it again.
|
||||
Future<void> play(String slot, String key, Future<Uint8List> Function() wav,
|
||||
{required double volume, bool fresh = false}) async {
|
||||
if (_disposed || volume <= 0) return;
|
||||
if (!await warmUp() || _disposed) return;
|
||||
var held = _slots[slot];
|
||||
if (held == null || held.$1 != key || fresh) {
|
||||
if (held != null) unawaited(_unload(held.$2));
|
||||
held = (key, _load(key, wav));
|
||||
_slots[slot] = held;
|
||||
}
|
||||
final sound = await held.$2;
|
||||
if (sound == null) {
|
||||
// Retry on the next play.
|
||||
if (identical(_slots[slot], held)) _slots.remove(slot);
|
||||
return;
|
||||
}
|
||||
if (_disposed || !identical(_slots[slot], held)) return;
|
||||
try {
|
||||
_backend.play(sound, volume.clamp(0.0, 1.0));
|
||||
} catch (e) {
|
||||
_log.warning('sound $key: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<Object?> _load(String key, Future<Uint8List> Function() wav) async {
|
||||
try {
|
||||
return await _backend.load('$key#${_loads++}', await wav());
|
||||
} catch (e) {
|
||||
_log.warning('sound $key: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _unload(Future<Object?> sound) async {
|
||||
try {
|
||||
final s = await sound;
|
||||
if (s != null && !_disposed) await _backend.unload(s);
|
||||
} catch (e) {
|
||||
_log.fine('unload: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Frees every sound and closes the audio device. Plays after it are
|
||||
/// dropped.
|
||||
Future<void> dispose() async {
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
_slots.clear();
|
||||
final ready = _ready;
|
||||
if (ready == null || !await ready) return;
|
||||
try {
|
||||
// Deinit frees the loaded sounds with the engine.
|
||||
await _backend.deinit();
|
||||
} catch (e) {
|
||||
_log.warning('audio deinit: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
// First start of the renamed app (Starsync, app id dev.foxyz.starsync):
|
||||
// copy what the old names left, never move, so a 1.2.x install keeps
|
||||
// working next to it. Runs once (the marker file), then never again.
|
||||
|
||||
/// Written into the new support folder once the migration ran (whether or
|
||||
/// not anything was found): `migratedFrom` (the old support folder or
|
||||
/// null), `hiveFrom` (the folder the boxes came from or null), `boxes`,
|
||||
/// `files`.
|
||||
const migrationMarker = 'migration.json';
|
||||
|
||||
/// The Hive boxes the app opens, by name (settings, connect form, Files,
|
||||
/// Jellyfin sign-ins, track choices). Only these leave the documents
|
||||
/// folder, which other apps' boxes share.
|
||||
const appHiveBoxes = ['settings', 'connect_form', 'files', 'jellyfin', 'track_prefs'];
|
||||
|
||||
/// Support folders of the old names for the new [support] folder, the
|
||||
/// most likely first. Linux: `$XDG_DATA_HOME/moe.senkochan.watch_together`
|
||||
/// (the GTK application id up to 1.2.x), then `watch_together` (the
|
||||
/// executable name path_provider falls back to). Windows:
|
||||
/// `%APPDATA%\com.example\watch_together` (company and product name of
|
||||
/// the exe up to 1.2.x).
|
||||
List<String> legacySupportDirs(String support, {bool? windows}) {
|
||||
final win = windows ?? Platform.isWindows;
|
||||
final sep = win ? r'\' : '/';
|
||||
String parent(String p) {
|
||||
final norm = p.endsWith(sep) ? p.substring(0, p.length - 1) : p;
|
||||
final i = norm.lastIndexOf(sep);
|
||||
return i <= 0 ? norm : norm.substring(0, i);
|
||||
}
|
||||
|
||||
if (win) return ['${parent(parent(support))}${sep}com.example${sep}watch_together'];
|
||||
final share = parent(support);
|
||||
return ['$share${sep}moe.senkochan.watch_together', '$share${sep}watch_together'];
|
||||
}
|
||||
|
||||
/// Copies the first existing [oldSupport] folder into [support] and the
|
||||
/// [appHiveBoxes] (`.hive` and `.lock`) from [documents] into [hive],
|
||||
/// skipping anything already there; the originals stay. Once per support
|
||||
/// folder ([migrationMarker]). Returns one log line per thing copied
|
||||
/// (paths and counts, never contents); empty when it ran before.
|
||||
Future<List<String>> migrateLegacyData(
|
||||
{required String support, required String hive, required List<String> oldSupport, String? documents}) async {
|
||||
final sep = Platform.pathSeparator;
|
||||
final marker = File('$support$sep$migrationMarker');
|
||||
if (marker.existsSync()) return const [];
|
||||
final lines = <String>[];
|
||||
String? from;
|
||||
var files = 0;
|
||||
for (final old in oldSupport) {
|
||||
final dir = Directory(old);
|
||||
if (!dir.existsSync() || _same(old, support)) continue;
|
||||
files = await _copyTree(dir, Directory(support));
|
||||
from = old;
|
||||
lines.add('migration: copied $files files from $old to $support');
|
||||
break;
|
||||
}
|
||||
final boxes = <String>[];
|
||||
if (documents != null && Directory(documents).existsSync() && !_same(documents, hive)) {
|
||||
await Directory(hive).create(recursive: true);
|
||||
for (final box in appHiveBoxes) {
|
||||
for (final ext in const ['hive', 'lock']) {
|
||||
final src = File('$documents$sep$box.$ext');
|
||||
final dst = File('$hive$sep$box.$ext');
|
||||
if (!src.existsSync() || dst.existsSync()) continue;
|
||||
await src.copy(dst.path);
|
||||
if (ext == 'hive') boxes.add(box);
|
||||
}
|
||||
}
|
||||
if (boxes.isNotEmpty) {
|
||||
lines.add('migration: copied ${boxes.length} Hive boxes (${boxes.join(', ')}) from $documents to $hive');
|
||||
}
|
||||
}
|
||||
if (lines.isEmpty) lines.add('migration: no data from before the rename');
|
||||
await Directory(support).create(recursive: true);
|
||||
await marker.writeAsString(jsonEncode({
|
||||
'migratedFrom': from,
|
||||
'hiveFrom': boxes.isEmpty ? null : documents,
|
||||
'boxes': boxes,
|
||||
'files': files,
|
||||
}));
|
||||
return lines;
|
||||
}
|
||||
|
||||
bool _same(String a, String b) {
|
||||
try {
|
||||
return FileSystemEntity.identicalSync(a, b);
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Files (File.copy keeps the mode on POSIX: cookies.txt stays 0600, yt-dlp
|
||||
// executable) and symlinks as they are; existing targets win.
|
||||
Future<int> _copyTree(Directory from, Directory to) async {
|
||||
var n = 0;
|
||||
await to.create(recursive: true);
|
||||
await for (final e in from.list(recursive: true, followLinks: false)) {
|
||||
final target = '${to.path}${e.path.substring(from.path.length)}';
|
||||
if (e is Directory) {
|
||||
await Directory(target).create(recursive: true);
|
||||
} else if (await FileSystemEntity.type(target, followLinks: false) != FileSystemEntityType.notFound) {
|
||||
continue;
|
||||
} else if (e is Link) {
|
||||
await Directory(File(target).parent.path).create(recursive: true);
|
||||
await Link(target).create(await e.target());
|
||||
} else if (e is File) {
|
||||
await Directory(File(target).parent.path).create(recursive: true);
|
||||
await e.copy(target);
|
||||
n++;
|
||||
}
|
||||
}
|
||||
return n;
|
||||
}
|
||||
@@ -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,35 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
import '../data_migration.dart';
|
||||
import '../ports/app_dirs.dart';
|
||||
|
||||
/// The desktop's [AppDirsPort]: the app support folder from path_provider
|
||||
/// (Linux `$XDG_DATA_HOME/dev.foxyz.starsync`, Windows
|
||||
/// `%APPDATA%\foxyz\Starsync`); on the first start it copies the data of
|
||||
/// the old names in ([migrateLegacyData]). Needs Flutter's binding.
|
||||
class DesktopAppDirs implements AppDirsPort {
|
||||
const DesktopAppDirs();
|
||||
|
||||
@override
|
||||
Future<AppDirs> resolve() async {
|
||||
final support = (await getApplicationSupportDirectory()).path;
|
||||
final base = AppDirs.fromEnv(support: support, env: const {});
|
||||
List<String> migrated;
|
||||
try {
|
||||
String? documents;
|
||||
try {
|
||||
// Where Hive.initFlutter() kept the boxes up to 1.2.x.
|
||||
documents = (await getApplicationDocumentsDirectory()).path;
|
||||
} catch (_) {}
|
||||
migrated = await migrateLegacyData(
|
||||
support: support, hive: base.hive, oldSupport: legacySupportDirs(support), documents: documents);
|
||||
} catch (e) {
|
||||
migrated = ['migration failed: $e'];
|
||||
}
|
||||
final dirs = AppDirs.fromEnv(support: support, env: Platform.environment, migrated: migrated);
|
||||
await Directory(dirs.hive).create(recursive: true);
|
||||
return dirs;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
|
||||
import '../../files/scanner.dart';
|
||||
import '../ports/folder_access.dart';
|
||||
import '../ports/url_opener.dart';
|
||||
|
||||
/// [FolderAccess] on a desktop file system: file_picker's dialogs, the
|
||||
/// [scanFolder] walk, the file manager through [UrlOpener].
|
||||
class DesktopFolders implements FolderAccess {
|
||||
final UrlOpener urls;
|
||||
const DesktopFolders(this.urls);
|
||||
|
||||
@override
|
||||
Future<String?> pickFolder({required String dialogTitle}) => FilePicker.getDirectoryPath(dialogTitle: dialogTitle);
|
||||
|
||||
@override
|
||||
Future<List<String>> pickFiles({required String dialogTitle, required List<String> extensions}) async {
|
||||
final files = await FilePicker.pickFiles(
|
||||
dialogTitle: dialogTitle,
|
||||
type: FileType.custom,
|
||||
allowedExtensions: extensions,
|
||||
);
|
||||
return [for (final f in files) ?f.path];
|
||||
}
|
||||
|
||||
@override
|
||||
Future<FolderScan> scan(String dir) => scanFolder(dir);
|
||||
|
||||
@override
|
||||
bool folderExists(String dir) => Directory(dir).existsSync();
|
||||
|
||||
@override
|
||||
Future<FileStat> stat(String path) => File(path).stat();
|
||||
|
||||
// The file manager opens a folder path the way a browser opens a link.
|
||||
@override
|
||||
Future<bool> reveal(String dir) => urls.open(Uri.directory(dir).toString());
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||
|
||||
import '../desktop_entry.dart' show appId;
|
||||
import '../ports/system_notifications.dart';
|
||||
|
||||
/// [SystemNotifications] on the desktop, through flutter_local_notifications'
|
||||
/// platform plugins (used directly, not through its facade, so the platform
|
||||
/// is picked by the OS and not by Flutter's target platform):
|
||||
/// - Linux: `org.freedesktop.Notifications` over D-Bus (pure Dart), with the
|
||||
/// app's icon (the bundled PNG, else the launcher entry's icon by name);
|
||||
/// a click is the notification's default action.
|
||||
/// - Windows: toast notifications (WinRT), the app's name and icon, a click
|
||||
/// activates the app while it runs (an unpackaged app has no history).
|
||||
/// Silent: the app plays its own sound for the same notice. Anything that
|
||||
/// fails (no notification daemon, no session bus) is logged and dropped.
|
||||
class DesktopNotifications implements SystemNotifications {
|
||||
DesktopNotifications({this.appName = 'Starsync'});
|
||||
|
||||
final String appName;
|
||||
void Function(String payload)? _onClick;
|
||||
Future<bool>? _ready;
|
||||
LinuxFlutterLocalNotificationsPlugin? _linux;
|
||||
FlutterLocalNotificationsWindows? _windows;
|
||||
// Tag -> id, so a notification with the same tag replaces the last one.
|
||||
final _ids = <String, int>{};
|
||||
int _next = 1;
|
||||
|
||||
@override
|
||||
set onClick(void Function(String payload)? handler) => _onClick = handler;
|
||||
|
||||
void _clicked(NotificationResponse r) => _onClick?.call(r.payload ?? '');
|
||||
|
||||
/// The bundled 256 px icon next to the executable (a release or a
|
||||
/// `flutter run` build), else the icon the launcher entry installed.
|
||||
static LinuxNotificationIcon _linuxIcon() {
|
||||
final exe = File(Platform.resolvedExecutable).parent.path;
|
||||
final png = File('$exe/data/flutter_assets/assets/icon/icon_256.png');
|
||||
return png.existsSync() ? FilePathLinuxIcon(png.path) : ThemeLinuxIcon(appId);
|
||||
}
|
||||
|
||||
Future<bool> _init() => _ready ??= () async {
|
||||
try {
|
||||
if (Platform.isLinux) {
|
||||
final p = _linux = LinuxFlutterLocalNotificationsPlugin();
|
||||
return await p.initialize(
|
||||
settings: LinuxInitializationSettings(defaultActionName: 'Open', defaultIcon: _linuxIcon()),
|
||||
onDidReceiveNotificationResponse: _clicked,
|
||||
) ??
|
||||
false;
|
||||
}
|
||||
if (Platform.isWindows) {
|
||||
final exe = File(Platform.resolvedExecutable).parent.path;
|
||||
final icon = File('$exe/data/flutter_assets/assets/icon/icon_256.png');
|
||||
final p = _windows = FlutterLocalNotificationsWindows();
|
||||
return await p.initialize(
|
||||
settings: WindowsInitializationSettings(
|
||||
appName: appName,
|
||||
appUserModelId: 'Foxyz.Starsync',
|
||||
guid: '6c0f4b4e-3e8a-4b8e-9d0c-5a7f2b1e9c31',
|
||||
iconPath: icon.existsSync() ? icon.path : null,
|
||||
),
|
||||
onDidReceiveNotificationResponse: _clicked,
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('system notifications unavailable: $e');
|
||||
}
|
||||
return false;
|
||||
}();
|
||||
|
||||
@override
|
||||
Future<void> show({required String title, required String body, String tag = '', String payload = ''}) async {
|
||||
if (!await _init()) return;
|
||||
final id = tag.isEmpty ? _next++ : _ids[tag] ??= _next++;
|
||||
try {
|
||||
if (_linux case final p?) {
|
||||
await p.show(
|
||||
id: id,
|
||||
title: title,
|
||||
body: body,
|
||||
payload: payload,
|
||||
notificationDetails: const LinuxNotificationDetails(suppressSound: true),
|
||||
);
|
||||
} else if (_windows case final p?) {
|
||||
await p.show(
|
||||
id: id,
|
||||
title: title,
|
||||
body: body,
|
||||
payload: payload,
|
||||
notificationDetails: WindowsNotificationDetails(audio: WindowsNotificationAudio.silent()),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('system notification failed: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import '../../playback/yt_dlp_app.dart';
|
||||
import '../ports/ports.dart';
|
||||
import 'desktop_folders.dart';
|
||||
import 'desktop_notifications.dart';
|
||||
import 'desktop_shell.dart';
|
||||
import 'desktop_urls.dart';
|
||||
import 'media_kit_host.dart';
|
||||
|
||||
/// The desktop's [Ports].
|
||||
Ports desktopPorts() {
|
||||
const urls = DesktopUrls();
|
||||
return Ports(
|
||||
shell: DesktopShell(),
|
||||
urls: urls,
|
||||
folders: const DesktopFolders(urls),
|
||||
links: AppYtDlp.instance,
|
||||
notifications: DesktopNotifications(),
|
||||
players: MediaKitHost(),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:fox_utils/fox_utils.dart' show ClipboardX, DesktopDropX, DropTarget;
|
||||
import 'package:hive_ce_flutter/hive_flutter.dart';
|
||||
import 'package:window_manager/window_manager.dart';
|
||||
|
||||
import '../../state/settings.dart' show settingsBoxName;
|
||||
import 'attention.dart';
|
||||
import '../ports/shell_port.dart';
|
||||
import 'window_memory.dart';
|
||||
|
||||
/// [ShellPort] on Linux and Windows: window_manager for the window,
|
||||
/// [WindowMemory] for its size and place, the runner's channel for
|
||||
/// attention, fox_utils for drops and the clipboard.
|
||||
class DesktopShell implements ShellPort {
|
||||
WindowMemory? _memory;
|
||||
|
||||
@override
|
||||
Future<void> start({required String title, required Future<void> Function() onCloseRequest}) async {
|
||||
await windowManager.ensureInitialized();
|
||||
await windowManager.setPreventClose(true);
|
||||
final memory = _memory = WindowMemory(Hive.isBoxOpen(settingsBoxName) ? Hive.box(settingsBoxName) : null);
|
||||
windowManager.addListener(_CloseGuard(onCloseRequest));
|
||||
final windowOptions = WindowOptions(
|
||||
size: memory.size,
|
||||
minimumSize: const Size(160, 90),
|
||||
center: !memory.hasPosition,
|
||||
backgroundColor: const Color(0xFF000000),
|
||||
titleBarStyle: TitleBarStyle.hidden,
|
||||
title: title,
|
||||
);
|
||||
unawaited(windowManager.waitUntilReadyToShow(windowOptions, () async {
|
||||
await memory.restore();
|
||||
await windowManager.show();
|
||||
await windowManager.focus();
|
||||
}));
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> setFullScreen(bool on) => windowManager.setFullScreen(on);
|
||||
@override
|
||||
Future<void> minimize() => windowManager.minimize();
|
||||
@override
|
||||
Future<bool> isMaximized() => windowManager.isMaximized();
|
||||
@override
|
||||
Future<void> maximize() => windowManager.maximize();
|
||||
@override
|
||||
Future<void> unmaximize() => windowManager.unmaximize();
|
||||
|
||||
@override
|
||||
void Function() watchMaximized(void Function(bool maximized) onChange) {
|
||||
final l = _MaximizeWatch(onChange);
|
||||
windowManager.addListener(l);
|
||||
return () => windowManager.removeListener(l);
|
||||
}
|
||||
|
||||
final _activity = <void Function(WindowActivity)>{};
|
||||
|
||||
@override
|
||||
void Function() watchActivity(void Function(WindowActivity activity) onChange) {
|
||||
final l = _ActivityWatch(onChange);
|
||||
_activity.add(onChange);
|
||||
windowManager.addListener(l);
|
||||
return () {
|
||||
_activity.remove(onChange);
|
||||
windowManager.removeListener(l);
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> startDragging() => windowManager.startDragging();
|
||||
|
||||
@override
|
||||
Future<void> requestClose() => windowManager.close();
|
||||
|
||||
@override
|
||||
Future<void> saveWindow() async => _memory?.save();
|
||||
|
||||
@override
|
||||
Future<void> hide() async {
|
||||
// window_manager has no hide event: tell the watchers here.
|
||||
for (final f in [..._activity]) {
|
||||
f(WindowActivity.hidden);
|
||||
}
|
||||
await windowManager.hide();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> quit() async {
|
||||
try {
|
||||
await windowManager.setPreventClose(false);
|
||||
await windowManager.close();
|
||||
} catch (e) {
|
||||
debugPrint('window close failed: $e');
|
||||
await windowManager.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> focus() async {
|
||||
try {
|
||||
if (await windowManager.isMinimized()) await windowManager.restore();
|
||||
if (!await windowManager.isVisible()) await windowManager.show();
|
||||
await windowManager.focus();
|
||||
} catch (e) {
|
||||
debugPrint('window focus failed: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> requestAttention() => requestWindowAttention();
|
||||
|
||||
@override
|
||||
Future<Paste> readPaste() async {
|
||||
var files = const <String>[];
|
||||
String? text;
|
||||
try {
|
||||
files = await ClipboardX.readFiles();
|
||||
} catch (_) {}
|
||||
if (files.isEmpty) {
|
||||
try {
|
||||
text = await ClipboardX.readText();
|
||||
} catch (_) {}
|
||||
}
|
||||
return (files: files, text: text);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget dropTarget({
|
||||
Key? key,
|
||||
bool enable = true,
|
||||
VoidCallback? onEnter,
|
||||
VoidCallback? onExit,
|
||||
required void Function(List<String> paths, Offset at) onDrop,
|
||||
required Widget child,
|
||||
}) =>
|
||||
DropTarget(
|
||||
key: key,
|
||||
enable: enable,
|
||||
onDragEntered: onEnter == null ? null : (_) => onEnter(),
|
||||
onDragExited: onExit == null ? null : (_) => onExit(),
|
||||
onDragDone: (d) => onDrop(DesktopDropX.pathsOf(d.files), d.localPosition),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
||||
/// Close button / Alt+F4 run the orderly close instead.
|
||||
class _CloseGuard with WindowListener {
|
||||
final Future<void> Function() onClose;
|
||||
_CloseGuard(this.onClose);
|
||||
@override
|
||||
void onWindowClose() => unawaited(onClose());
|
||||
}
|
||||
|
||||
class _MaximizeWatch with WindowListener {
|
||||
final void Function(bool) onChange;
|
||||
_MaximizeWatch(this.onChange);
|
||||
@override
|
||||
void onWindowMaximize() => onChange(true);
|
||||
@override
|
||||
void onWindowUnmaximize() => onChange(false);
|
||||
}
|
||||
|
||||
/// Focus, blur, minimise and restore as [WindowActivity] (a restored
|
||||
/// window is shown; its focus event follows when it gets the keyboard).
|
||||
class _ActivityWatch with WindowListener {
|
||||
final void Function(WindowActivity) onChange;
|
||||
_ActivityWatch(this.onChange);
|
||||
@override
|
||||
void onWindowFocus() => onChange(WindowActivity.focused);
|
||||
@override
|
||||
void onWindowBlur() => onChange(WindowActivity.unfocused);
|
||||
@override
|
||||
void onWindowMinimize() => onChange(WindowActivity.minimised);
|
||||
@override
|
||||
void onWindowRestore() => onChange(WindowActivity.unfocused);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import 'dart:io';
|
||||
|
||||
import '../ports/url_opener.dart';
|
||||
|
||||
/// [UrlOpener] without url_launcher: `xdg-open`, `open` or Windows'
|
||||
/// `url.dll` handler.
|
||||
class DesktopUrls implements UrlOpener {
|
||||
const DesktopUrls();
|
||||
|
||||
@override
|
||||
Future<bool> open(String url) async {
|
||||
try {
|
||||
final ProcessResult r;
|
||||
if (Platform.isWindows) {
|
||||
r = await Process.run('rundll32', ['url.dll,FileProtocolHandler', url]);
|
||||
} else if (Platform.isMacOS) {
|
||||
r = await Process.run('open', [url]);
|
||||
} else {
|
||||
r = await Process.run('xdg-open', [url]);
|
||||
}
|
||||
return r.exitCode == 0;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,513 @@
|
||||
import 'dart:async';
|
||||
import 'dart:ffi' show Int8;
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:ffi/ffi.dart' show StringUtf8Pointer, malloc;
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:media_kit/media_kit.dart';
|
||||
import 'package:media_kit_video/media_kit_video.dart';
|
||||
import 'package:signals/signals.dart';
|
||||
import 'package:sync_relay_core/core.dart' show LogThrottle, createLogger;
|
||||
|
||||
import '../../state/settings.dart';
|
||||
import '../ports/player_host.dart';
|
||||
|
||||
// The desktop's players: media_kit over libmpv. Everything mpv-specific
|
||||
// that is not about one media lives here: the cache and HTTP pins, hwdec,
|
||||
// mpv's log level (media_kit has no API for it: `mpv_request_log_messages`
|
||||
// on its handle), the render-mode probe and its diagnostics, mpv's log into
|
||||
// the app log, and the order players are torn down in.
|
||||
|
||||
/// Read-ahead target: about two minutes rides out short outages and makes
|
||||
/// skipping an intro instant.
|
||||
const readAheadSecs = 150;
|
||||
|
||||
/// Forward demuxer cache of a session player. Holds [readAheadSecs] up to
|
||||
/// ~14 Mbit/s (a 1.4 GB / 24 min file is ~8); higher bitrates get less
|
||||
/// time. In RAM: `cache-on-disk=no` (media_kit sets it on without a
|
||||
/// cache-dir, and mpv warned "Failed to create file cache." per open).
|
||||
const sessionAheadBytes = 256 * 1024 * 1024;
|
||||
|
||||
/// Back buffer (media_kit sets it to bufferSize too): enough for a short
|
||||
/// seek back without holding another 256 MiB.
|
||||
const sessionBackBytes = 64 * 1024 * 1024;
|
||||
|
||||
/// The preload's forward cache: its first seconds and the container's
|
||||
/// index, at little memory. Raised to [sessionAheadBytes] when promoted.
|
||||
const preloadAheadBytes = 48 * 1024 * 1024;
|
||||
const preloadBackBytes = 8 * 1024 * 1024;
|
||||
|
||||
/// mpv's `volume-max` (its default is 130): the volume controls go this
|
||||
/// far, past 100 % amplified.
|
||||
const mpvMaxVolume = 150;
|
||||
|
||||
/// mpv's log level for session players. 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 during the render-mode probe, which needs mpv's
|
||||
/// verbose GL init lines.
|
||||
const sessionLogLevel = 'warn';
|
||||
|
||||
/// 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.
|
||||
const streamLavfOptions = 'reconnect_streamed=1,reconnect_on_http_error=5xx,'
|
||||
'reconnect_delay_max=4,reconnect_max_retries=3';
|
||||
|
||||
/// mpv echoes numbers in its own format (`150` reads back `150.000000`).
|
||||
bool pinMatches(String wanted, String got) {
|
||||
if (wanted == got) return true;
|
||||
final a = double.tryParse(wanted), b = double.tryParse(got);
|
||||
return a != null && b != null && a == b;
|
||||
}
|
||||
|
||||
/// The `VideoController` behind [video], for the `Video` widget (the only
|
||||
/// media_kit use outside this file).
|
||||
VideoController videoControllerOf(HostedVideo video) => (video as _MkVideo).controller;
|
||||
|
||||
final _mpvLog = createLogger('mpv');
|
||||
|
||||
class MediaKitHost implements PlayerHost {
|
||||
/// The hwdec setting ([AppSettings.hwdec] by default).
|
||||
final String? Function() _hwdecSetting;
|
||||
MediaKitHost({String? Function()? hwdecSetting})
|
||||
: _hwdecSetting = hwdecSetting ?? (() => AppSettings.instance.hwdec.peek());
|
||||
|
||||
final _live = <_MkPlayer>{};
|
||||
final _renderMode = signal(RenderMode.unknown);
|
||||
final _http = StreamController<String>.broadcast();
|
||||
final _throttle = LogThrottle(burst: 5);
|
||||
Future<void> _retiring = Future.value();
|
||||
var _closing = false;
|
||||
|
||||
@override
|
||||
final List<String> pinMismatches = [];
|
||||
|
||||
@override
|
||||
void init() => MediaKit.ensureInitialized();
|
||||
|
||||
@override
|
||||
ReadonlySignal<RenderMode> get renderMode => _renderMode;
|
||||
|
||||
@override
|
||||
Stream<String> get httpErrors => _http.stream;
|
||||
|
||||
@override
|
||||
int get live => _live.length;
|
||||
|
||||
@override
|
||||
Future<void> get retiring => _retiring;
|
||||
|
||||
@override
|
||||
String? get hwdec {
|
||||
final env = Platform.environment['SYNC_RELAY_HWDEC'];
|
||||
if (env != null && env.isNotEmpty) return env;
|
||||
return _hwdecSetting();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<HostedPlayer> create(PlayerRole role, {bool muted = false}) async {
|
||||
if (_closing) throw StateError('closing: no new players');
|
||||
final mk = _SyncPlayer(
|
||||
configuration: switch (role) {
|
||||
PlayerRole.session => PlayerConfiguration(
|
||||
bufferSize: sessionAheadBytes,
|
||||
libass: true,
|
||||
vo: Platform.environment['SYNC_RELAY_VO'],
|
||||
logLevel: MPVLogLevel.warn),
|
||||
PlayerRole.preload => PlayerConfiguration(
|
||||
bufferSize: preloadAheadBytes,
|
||||
libass: true,
|
||||
vo: Platform.environment['SYNC_RELAY_VO'],
|
||||
logLevel: MPVLogLevel.error),
|
||||
PlayerRole.thumbnail => const PlayerConfiguration(
|
||||
vo: 'null', muted: true, osc: false, libass: false, title: 'thumbnail', bufferSize: 4 << 20),
|
||||
});
|
||||
final p = _MkPlayer(this, mk, role);
|
||||
_live.add(p);
|
||||
if (role != PlayerRole.thumbnail) {
|
||||
p._logSub = p.logs.listen((l) => _route(p, l));
|
||||
final mismatches = <String>[];
|
||||
await _pinCache(p, role, mismatches);
|
||||
if (await p.pin('volume-max', '$mpvMaxVolume') case final m?) mismatches.add(m);
|
||||
if (muted || role == PlayerRole.preload) {
|
||||
if (await p.pin('mute', 'yes') case final m?) mismatches.add(m);
|
||||
}
|
||||
if (role == PlayerRole.session) {
|
||||
pinMismatches
|
||||
..clear()
|
||||
..addAll(mismatches);
|
||||
}
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> promote(HostedPlayer p) async {
|
||||
final mk = p as _MkPlayer;
|
||||
mk._role = PlayerRole.session;
|
||||
mk.setLogLevel(sessionLogLevel);
|
||||
final mismatches = <String>[];
|
||||
if (await p.pin('mute', 'no') case final m?) mismatches.add(m);
|
||||
await _pinCache(mk, PlayerRole.session, mismatches);
|
||||
pinMismatches
|
||||
..clear()
|
||||
..addAll(mismatches);
|
||||
}
|
||||
|
||||
Future<void> _pinCache(_MkPlayer p, PlayerRole role, List<String> out) async {
|
||||
final session = role == PlayerRole.session;
|
||||
for (final (k, v) in [
|
||||
('demuxer-readahead-secs', '$readAheadSecs'),
|
||||
('cache-secs', '$readAheadSecs'),
|
||||
('demuxer-max-bytes', '${session ? sessionAheadBytes : preloadAheadBytes}'),
|
||||
('demuxer-max-back-bytes', '${session ? sessionBackBytes : preloadBackBytes}'),
|
||||
('cache-on-disk', 'no'),
|
||||
if (hwdec case final h?) ('hwdec', h),
|
||||
('stream-lavf-o', streamLavfOptions),
|
||||
// Pages are resolved by the app's own yt-dlp (the media pipeline):
|
||||
// mpv's Lua hook never runs one.
|
||||
('ytdl', 'no'),
|
||||
]) {
|
||||
if (await p.pin(k, v) case final m?) out.add(m);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
HostedVideo video(HostedPlayer player, {bool probe = false}) {
|
||||
final p = player as _MkPlayer;
|
||||
if (probe) {
|
||||
_logRenderEnv();
|
||||
p.setLogLevel('v'); // back to the session level at the probe's end
|
||||
}
|
||||
// media_kit's video output sets hwdec itself on attach ('auto' unless
|
||||
// configured), which used to override SYNC_RELAY_HWDEC.
|
||||
final controller = VideoController(p.mk, configuration: VideoControllerConfiguration(hwdec: hwdec));
|
||||
final v = _MkVideo(p, controller);
|
||||
// mpv gets `vo=libmpv` before the output's render context exists; a
|
||||
// media opened in between fails its video output ("No render context
|
||||
// set") and plays black. Opens wait for the output (seen on X11, where
|
||||
// the context comes up later, when joining with media already up).
|
||||
p._videoReady = controller.platform.future
|
||||
.then((_) {})
|
||||
.timeout(const Duration(seconds: 5), onTimeout: () {})
|
||||
.catchError((Object _) {});
|
||||
if (probe) unawaited(_probeRender(p, v));
|
||||
return v;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ the log
|
||||
|
||||
// mpv's warnings and errors to the app log, a few per kind at a time (a
|
||||
// refused request makes ffmpeg retry and warn on every try); ffmpeg's
|
||||
// HTTP status to [httpErrors]; the GL init lines while probing.
|
||||
void _route(_MkPlayer p, PlayerLogLine log) {
|
||||
if (log.prefix == 'ffmpeg') {
|
||||
final http = RegExp(r'HTTP error \d{3}.*').firstMatch(log.text);
|
||||
if (http != null) _http.add(http[0]!.trim());
|
||||
}
|
||||
if (log.prefix == 'libmpv_render') {
|
||||
if (_glInfo.hasMatch(log.text)) _mpvLog.info('render: [libmpv_render] ${log.text.trim()}');
|
||||
if (log.text.contains('suspected software renderer')) {
|
||||
_probeMode = RenderMode.softwareGl;
|
||||
_renderMode.value = RenderMode.softwareGl;
|
||||
} else if (log.text.startsWith('GL_VERSION') && p._probing && _probeMode == RenderMode.unknown) {
|
||||
_probeMode = RenderMode.gpu;
|
||||
_renderMode.value = RenderMode.gpu;
|
||||
}
|
||||
}
|
||||
final level = log.level;
|
||||
if (level != 'warn' && !log.isError) return;
|
||||
final text = log.text.trim();
|
||||
if (text.isEmpty) return;
|
||||
final who = p.role == PlayerRole.preload ? 'preload ' : '';
|
||||
final pass = _throttle.allow('$who${log.prefix} $level ${text.length > 40 ? text.substring(0, 40) : text}');
|
||||
if (pass == null) return;
|
||||
final line = '$pass$who[${log.prefix}] $text';
|
||||
level == 'warn' ? _mpvLog.info(line) : _mpvLog.warning(line);
|
||||
}
|
||||
|
||||
// mpv's GL init lines (`GL_VENDOR='...'`, `Detected desktop OpenGL 4.6.`).
|
||||
static final _glInfo =
|
||||
RegExp(r'^\s*(GL_(VENDOR|RENDERER|VERSION|SHADING_LANGUAGE_VERSION)=|Detected (GLES|desktop OpenGL))');
|
||||
|
||||
// Once per app start: what decides how media_kit and mpv render (the
|
||||
// display server, Flutter's and Mesa's overrides). Not secrets.
|
||||
static bool _renderEnvLogged = false;
|
||||
static void _logRenderEnv() {
|
||||
if (_renderEnvLogged || !Platform.isLinux) return;
|
||||
_renderEnvLogged = true;
|
||||
const names = [
|
||||
'XDG_SESSION_TYPE',
|
||||
'WAYLAND_DISPLAY',
|
||||
'DISPLAY',
|
||||
'GDK_BACKEND',
|
||||
'FLUTTER_LINUX_RENDERER',
|
||||
'LIBGL_ALWAYS_SOFTWARE',
|
||||
'__EGL_VENDOR_LIBRARY_FILENAMES',
|
||||
];
|
||||
final env = Platform.environment;
|
||||
_mpvLog.info('render env: ${[for (final n in names) '$n=${env[n] ?? '-'}'].join(' ')}');
|
||||
}
|
||||
|
||||
RenderMode _probeMode = RenderMode.unknown;
|
||||
|
||||
// media_kit's Linux/Windows video output creates mpv's render context
|
||||
// while the controller is created: OpenGL when it can, else the software
|
||||
// renderer, which never logs GL init (and ignores shaders). No GL line
|
||||
// within 1.5 s of the output being up means software.
|
||||
Future<void> _probeRender(_MkPlayer p, _MkVideo v) async {
|
||||
p._probing = true;
|
||||
_probeMode = RenderMode.unknown;
|
||||
try {
|
||||
await v.controller.platform.future;
|
||||
await Future.delayed(const Duration(milliseconds: 1500));
|
||||
} catch (_) {
|
||||
return;
|
||||
} finally {
|
||||
p._probing = false;
|
||||
if (!p.disposed) p.setLogLevel(sessionLogLevel);
|
||||
}
|
||||
if (p.disposed) return;
|
||||
final mode = _probeMode;
|
||||
if (mode != RenderMode.unknown) {
|
||||
_renderMode.value = mode;
|
||||
_mpvLog.info('render: ${mode.name} (${mode == RenderMode.gpu ? 'OpenGL on the GPU' : 'OpenGL on the CPU'})');
|
||||
return;
|
||||
}
|
||||
_renderMode.value = RenderMode.software;
|
||||
_mpvLog.info("render: software (no GL init logged: media_kit's own fallback; "
|
||||
'its reason is on stderr, "media_kit: VideoOutput: ..." when run from a terminal)');
|
||||
}
|
||||
|
||||
// Once per player, after its first frame: the decoder mpv picked; the
|
||||
// first time per app start also mpv's and ffmpeg's versions.
|
||||
static bool _versionsLogged = false;
|
||||
Future<void> _logFirstFrame(_MkPlayer p) async {
|
||||
try {
|
||||
final current = await p.getProperty('hwdec-current');
|
||||
final wanted = await p.getProperty('hwdec');
|
||||
_mpvLog.info('render: first frame ${p.state.width}x${p.state.height}, '
|
||||
'hwdec-current=${current.isEmpty ? 'no' : current} (hwdec=$wanted), mode ${_renderMode.peek().name}');
|
||||
if (!_versionsLogged) {
|
||||
_versionsLogged = true;
|
||||
_mpvLog.info('render: ${await versions(p)}');
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('first frame info: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String?> versions(HostedPlayer player) async {
|
||||
try {
|
||||
return '${await player.getProperty('mpv-version')}, ffmpeg ${await player.getProperty('ffmpeg-version')}';
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------------- tear-down
|
||||
|
||||
@override
|
||||
Future<void> retire(HostedPlayer player) {
|
||||
final p = player as _MkPlayer;
|
||||
p._retired = true;
|
||||
unawaited(p.mk.pause().catchError((_) {}));
|
||||
return _retiring = () async {
|
||||
await _settleUi();
|
||||
await dispose(p);
|
||||
}();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> dispose(HostedPlayer player) async {
|
||||
final p = player as _MkPlayer;
|
||||
if (!_live.remove(p)) return;
|
||||
p._retired = true;
|
||||
await p._logSub?.cancel();
|
||||
try {
|
||||
await p.mk.dispose();
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> disposeAll({Duration each = const Duration(milliseconds: 1500)}) async {
|
||||
_closing = true;
|
||||
await Future.wait([
|
||||
for (final p in _live.toList()) dispose(p).timeout(each).catchError((Object _) {}),
|
||||
]);
|
||||
_closing = false;
|
||||
}
|
||||
|
||||
// Two frames: one may already be mid-build when the view drops the
|
||||
// output. Then the length of a route pop (the fullscreen route holds its
|
||||
// own `Video` and leaves animated). Bounded, since a hidden window
|
||||
// renders no frames; headless tests have no binding and skip it all.
|
||||
static Future<void> _settleUi() async {
|
||||
try {
|
||||
for (var i = 0; i < 2; i++) {
|
||||
await WidgetsBinding.instance.endOfFrame.timeout(const Duration(milliseconds: 300));
|
||||
}
|
||||
await Future.delayed(const Duration(milliseconds: 350));
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
/// A [Player] whose public [seek] belongs to the user. media_kit's controls
|
||||
/// call `player.seek`; with [onUserSeek] set those calls become explicit
|
||||
/// events for the sync layer instead of moving mpv behind its back.
|
||||
class _SyncPlayer extends Player {
|
||||
_SyncPlayer({super.configuration});
|
||||
|
||||
void Function(Duration position)? onUserSeek;
|
||||
|
||||
@override
|
||||
Future<void> seek(Duration duration) async {
|
||||
final hook = onUserSeek;
|
||||
if (hook == null) return super.seek(duration);
|
||||
hook(duration);
|
||||
}
|
||||
|
||||
Future<void> syncSeek(Duration duration) => super.seek(duration);
|
||||
}
|
||||
|
||||
class _MkPlayer implements HostedPlayer {
|
||||
final MediaKitHost host;
|
||||
final _SyncPlayer mk;
|
||||
PlayerRole _role;
|
||||
_MkPlayer(this.host, this.mk, this._role) {
|
||||
_firstFrame = mk.stream.width.listen((w) {
|
||||
if (w == null || w <= 0 || _role == PlayerRole.thumbnail) return;
|
||||
unawaited(_firstFrame?.cancel());
|
||||
unawaited(host._logFirstFrame(this));
|
||||
});
|
||||
}
|
||||
|
||||
StreamSubscription<PlayerLogLine>? _logSub;
|
||||
StreamSubscription<int?>? _firstFrame;
|
||||
bool _probing = false;
|
||||
bool _retired = false;
|
||||
Future<void>? _videoReady; // set once a video output is attached
|
||||
|
||||
dynamic get _mpv => mk.platform;
|
||||
|
||||
@override
|
||||
PlayerRole get role => _role;
|
||||
|
||||
@override
|
||||
bool get disposed => _retired;
|
||||
|
||||
@override
|
||||
HostedPlayerState get state {
|
||||
final s = mk.state;
|
||||
return HostedPlayerState(
|
||||
position: s.position,
|
||||
duration: s.duration,
|
||||
buffer: s.buffer,
|
||||
volume: s.volume,
|
||||
playing: s.playing,
|
||||
completed: s.completed,
|
||||
width: s.width,
|
||||
height: s.height,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<Duration> get positionStream => mk.stream.position;
|
||||
@override
|
||||
Stream<Duration> get durationStream => mk.stream.duration;
|
||||
@override
|
||||
Stream<Duration> get bufferStream => mk.stream.buffer;
|
||||
@override
|
||||
Stream<double> get volumeStream => mk.stream.volume;
|
||||
@override
|
||||
Stream<bool> get completedStream => mk.stream.completed;
|
||||
@override
|
||||
Stream<int?> get widthStream => mk.stream.width;
|
||||
@override
|
||||
Stream<PlayerLogLine> get logs => mk.stream.log.map((l) => PlayerLogLine(l.prefix, l.level, l.text));
|
||||
|
||||
@override
|
||||
void Function(Duration position)? get onUserSeek => mk.onUserSeek;
|
||||
@override
|
||||
set onUserSeek(void Function(Duration position)? f) => mk.onUserSeek = f;
|
||||
|
||||
@override
|
||||
Future<void> open(String url, {Map<String, String> headers = const {}, bool play = false}) async {
|
||||
if (_videoReady case final ready?) await ready;
|
||||
// An empty map, not null: media_kit caches headers per URI.
|
||||
await mk.open(Media(url, httpHeaders: headers), play: play);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> play() => mk.play();
|
||||
@override
|
||||
Future<void> pause() => mk.pause();
|
||||
@override
|
||||
Future<void> stop() => mk.stop();
|
||||
@override
|
||||
Future<void> seek(Duration position) => mk.seek(position);
|
||||
@override
|
||||
Future<void> syncSeek(Duration position) => mk.syncSeek(position);
|
||||
@override
|
||||
Future<void> setRate(double rate) => mk.setRate(rate);
|
||||
@override
|
||||
Future<void> setVolume(double volume) => mk.setVolume(volume);
|
||||
|
||||
// media_kit has no public property API: a guarded dynamic call on
|
||||
// `player.platform` (its NativePlayer).
|
||||
@override
|
||||
Future<void> setProperty(String name, String value) async => await _mpv?.setProperty(name, value);
|
||||
|
||||
@override
|
||||
Future<String> getProperty(String name) async => '${await _mpv?.getProperty(name)}';
|
||||
|
||||
@override
|
||||
Future<void> observeProperty(String name, Future<void> Function(String value) onChange) async =>
|
||||
await _mpv?.observeProperty(name, onChange);
|
||||
|
||||
@override
|
||||
Future<void> command(List<String> args) async => await _mpv?.command(args);
|
||||
|
||||
@override
|
||||
Future<String?> pin(String name, String value) async {
|
||||
try {
|
||||
await setProperty(name, value);
|
||||
final got = await getProperty(name);
|
||||
if (pinMatches(value, got)) return null;
|
||||
debugPrint('pin $name=$value not applied, mpv has "$got"');
|
||||
return '$name: $value -> $got';
|
||||
} catch (e) {
|
||||
debugPrint('pin $name=$value failed: $e');
|
||||
return '$name: $value -> error $e';
|
||||
}
|
||||
}
|
||||
|
||||
// mpv_request_log_messages on media_kit's handle.
|
||||
@override
|
||||
void setLogLevel(String level) {
|
||||
try {
|
||||
final dynamic platform = mk.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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _MkVideo implements HostedVideo {
|
||||
@override
|
||||
final _MkPlayer player;
|
||||
final VideoController controller;
|
||||
_MkVideo(this.player, this.controller);
|
||||
|
||||
@override
|
||||
Future<void> get ready => controller.platform.future;
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:hive_ce_flutter/hive_flutter.dart';
|
||||
import 'package:screen_retriever/screen_retriever.dart';
|
||||
import 'package:window_manager/window_manager.dart';
|
||||
|
||||
/// The window's size, position and maximized state across restarts (and
|
||||
/// hot restarts), in the settings box. The size and position kept are
|
||||
/// the normal (unmaximized) ones; fullscreen is never saved or restored.
|
||||
/// Wayland lets no app place its own window, so there only the size and
|
||||
/// maximized state come back (the compositor decides the position).
|
||||
class WindowMemory with WindowListener {
|
||||
static const _key = 'windowBounds';
|
||||
static const defaultSize = Size(1280, 720);
|
||||
|
||||
// Null when the settings box failed to open: defaults, nothing saved.
|
||||
final Box? _box;
|
||||
Timer? _save;
|
||||
WindowMemory(this._box);
|
||||
|
||||
static bool get _canPlace => !(Platform.isLinux && Platform.environment['WAYLAND_DISPLAY'] != null);
|
||||
|
||||
Map? get _saved => _box?.get(_key) is Map ? _box!.get(_key) as Map : null;
|
||||
|
||||
/// The size to open at: the last normal size, else [defaultSize].
|
||||
Size get size {
|
||||
final s = _saved;
|
||||
final w = s?['w'], h = s?['h'];
|
||||
return w is num && h is num && w >= 160 && h >= 90 ? Size(w.toDouble(), h.toDouble()) : defaultSize;
|
||||
}
|
||||
|
||||
/// Whether a saved position exists to place the window at (else center).
|
||||
bool get hasPosition => _canPlace && _saved?['x'] is num && _saved?['y'] is num;
|
||||
|
||||
/// After the window shows: back to the saved position when it still
|
||||
/// lands on a connected display, maximized when it was; then listens.
|
||||
Future<void> restore() async {
|
||||
final s = _saved;
|
||||
try {
|
||||
if (s != null && hasPosition) {
|
||||
final rect = Offset((s['x'] as num).toDouble(), (s['y'] as num).toDouble()) & size;
|
||||
if (await _onSomeDisplay(rect)) {
|
||||
await windowManager.setPosition(rect.topLeft);
|
||||
} else {
|
||||
await windowManager.center();
|
||||
}
|
||||
}
|
||||
if (s?['max'] == true) await windowManager.maximize();
|
||||
} catch (e) {
|
||||
debugPrint('window restore failed: $e');
|
||||
}
|
||||
windowManager.addListener(this);
|
||||
}
|
||||
|
||||
// At least a 100×50 corner of the title area on a display's visible area.
|
||||
static Future<bool> _onSomeDisplay(Rect r) async {
|
||||
final grip = Rect.fromLTWH(r.left, r.top, r.width.clamp(0, 100).toDouble(), 50);
|
||||
for (final d in await screenRetriever.getAllDisplays()) {
|
||||
final area = (d.visiblePosition ?? Offset.zero) & (d.visibleSize ?? d.size);
|
||||
if (area.overlaps(grip)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
void _later() {
|
||||
_save?.cancel();
|
||||
_save = Timer(const Duration(milliseconds: 400), () => unawaited(save()));
|
||||
}
|
||||
|
||||
/// Writes the current state now (also on close).
|
||||
Future<void> save() async {
|
||||
_save?.cancel();
|
||||
final box = _box;
|
||||
if (box == null) return;
|
||||
try {
|
||||
if (await windowManager.isFullScreen() || await windowManager.isMinimized()) return;
|
||||
final max = await windowManager.isMaximized();
|
||||
final s = Map<String, Object?>.of((_saved ?? const {}).cast<String, Object?>());
|
||||
s['max'] = max;
|
||||
if (!max) {
|
||||
final b = await windowManager.getBounds();
|
||||
s['w'] = b.width;
|
||||
s['h'] = b.height;
|
||||
if (_canPlace) {
|
||||
s['x'] = b.left;
|
||||
s['y'] = b.top;
|
||||
}
|
||||
}
|
||||
await box.put(_key, s);
|
||||
} catch (e) {
|
||||
debugPrint('window save failed: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void onWindowResized() => _later();
|
||||
@override
|
||||
void onWindowMoved() => _later();
|
||||
@override
|
||||
void onWindowMaximize() => _later();
|
||||
@override
|
||||
void onWindowUnmaximize() => _later();
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
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 = 'dev.foxyz.starsync';
|
||||
const desktopFileName = '$appId.desktop';
|
||||
|
||||
/// The app id up to 1.2.x: its entry and icons are removed (only those
|
||||
/// exact files, which that version wrote).
|
||||
const legacyAppId = 'moe.senkochan.watch_together';
|
||||
|
||||
const _iconSizes = [16, 32, 48, 64, 128, 256, 512];
|
||||
|
||||
/// 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). The entry and icons of [legacyAppId] go. Never in debug runs
|
||||
/// (unless [force], for tests); failures only log.
|
||||
Future<void> installDesktopEntry(
|
||||
{String? executable, String? dataHome, String? home, @visibleForTesting bool force = false}) async {
|
||||
if (!Platform.isLinux || (kDebugMode && !force)) 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';
|
||||
await _removeLegacy(share);
|
||||
for (final size in _iconSizes) {
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _removeLegacy(String share) async {
|
||||
for (final f in [
|
||||
File('$share/applications/$legacyAppId.desktop'),
|
||||
for (final size in _iconSizes) File('$share/icons/hicolor/${size}x$size/apps/$legacyAppId.png'),
|
||||
]) {
|
||||
if (!f.existsSync()) continue;
|
||||
try {
|
||||
await f.delete();
|
||||
debugPrint('desktop entry: removed ${f.path}');
|
||||
} catch (e) {
|
||||
debugPrint('desktop entry: cannot remove ${f.path}: $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
|
||||
''';
|
||||
@@ -0,0 +1,69 @@
|
||||
import 'dart:io';
|
||||
|
||||
/// The environment variable that picks a separate profile (DEVELOPMENT.md
|
||||
/// "Dev loop": two instances on one machine).
|
||||
const profileEnv = 'STARSYNC_PROFILE';
|
||||
|
||||
/// Where the app keeps its own files, resolved once in `main` through an
|
||||
/// [AppDirsPort] and handed on from there.
|
||||
///
|
||||
/// Everything lives in the app support folder: Hive's boxes in
|
||||
/// `<app support>/hive`, the logs in `<app support>/logs`. With
|
||||
/// `STARSYNC_PROFILE=<name>` (letters, digits, `-`, `_`; at most 32) the
|
||||
/// per-install data moves to `<app support>/profiles/<name>/`: the Hive
|
||||
/// boxes (settings and so the profile, connect form, window bounds,
|
||||
/// folders, Jellyfin sign-ins; the client id from restructure step 1 on)
|
||||
/// under `hive/`, the logs under `logs/`. Downloads and caches stay
|
||||
/// shared: yt-dlp and its cookies, the extracted shaders, thumbnails, the
|
||||
/// AniList cache.
|
||||
class AppDirs {
|
||||
/// The profile name; null for the normal app.
|
||||
final String? profile;
|
||||
/// The app support folder (yt-dlp, shaders; Hive and logs without a
|
||||
/// profile).
|
||||
final String support;
|
||||
/// Hive's folder.
|
||||
final String hive;
|
||||
/// The app's log folder.
|
||||
final String logs;
|
||||
/// What resolving copied over from before the rename, one line each
|
||||
/// (paths and counts), for the log once it is open.
|
||||
final List<String> migrated;
|
||||
|
||||
const AppDirs(
|
||||
{required this.profile,
|
||||
required this.support,
|
||||
required this.hive,
|
||||
required this.logs,
|
||||
this.migrated = const []});
|
||||
|
||||
/// The layout for [support] (the app support folder) and [env]; an
|
||||
/// invalid profile name is ignored ([invalidProfile] says which).
|
||||
static AppDirs fromEnv(
|
||||
{required String support, required Map<String, String> env, List<String> migrated = const []}) {
|
||||
final sep = Platform.pathSeparator;
|
||||
final name = validProfile(env[profileEnv]);
|
||||
final root = name == null ? support : '$support${sep}profiles$sep$name';
|
||||
return AppDirs(
|
||||
profile: name, support: support, hive: '$root${sep}hive', logs: '$root${sep}logs', migrated: migrated);
|
||||
}
|
||||
|
||||
/// [raw] when it is a usable profile name, else null.
|
||||
static String? validProfile(String? raw) {
|
||||
final v = raw?.trim() ?? '';
|
||||
return RegExp(r'^[A-Za-z0-9_-]{1,32}$').hasMatch(v) ? v : null;
|
||||
}
|
||||
|
||||
/// The env value when it is set but not a usable name (for a warning).
|
||||
static String? invalidProfile(Map<String, String> env) {
|
||||
final raw = env[profileEnv];
|
||||
return raw != null && raw.trim().isNotEmpty && validProfile(raw) == null ? raw : null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Finds the app's folders on this platform (and, the first time, brings
|
||||
/// over the data of the names before the rename).
|
||||
abstract interface class AppDirsPort {
|
||||
/// The layout for this process; its Hive folder exists afterwards.
|
||||
Future<AppDirs> resolve();
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import 'dart:io' show FileStat;
|
||||
|
||||
import '../../files/scanner.dart' show FolderScan;
|
||||
|
||||
/// The user's folders and files: picking them, listing videos, looking a
|
||||
/// path up. Paths for now; a platform without them (Android's tree URIs)
|
||||
/// brings its own implementation.
|
||||
abstract interface class FolderAccess {
|
||||
/// The system folder dialog; null when cancelled.
|
||||
Future<String?> pickFolder({required String dialogTitle});
|
||||
|
||||
/// The system file dialog limited to [extensions]; the picked paths
|
||||
/// (empty when cancelled).
|
||||
Future<List<String>> pickFiles({required String dialogTitle, required List<String> extensions});
|
||||
|
||||
/// The videos under [dir] (see [FolderScan]); never throws.
|
||||
Future<FolderScan> scan(String dir);
|
||||
|
||||
/// Whether the folder [dir] exists.
|
||||
bool folderExists(String dir);
|
||||
|
||||
/// Size and dates of [path]; throws when it is gone.
|
||||
Future<FileStat> stat(String path);
|
||||
|
||||
/// Shows the folder [dir] in the file manager; false when nothing
|
||||
/// opened it.
|
||||
Future<bool> reveal(String dir);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import 'package:sync_relay_core/core.dart' show LinkProbe, YtDlp;
|
||||
|
||||
/// Web page links: what they are and how they play, through the app's
|
||||
/// yt-dlp (a process on desktop).
|
||||
abstract interface class LinkResolver {
|
||||
/// The yt-dlp behind it (relaying a link, cookies, the hosted server's
|
||||
/// shared folder).
|
||||
Future<YtDlp> get ytDlp;
|
||||
|
||||
/// Gets it ready in the background (download on first run, updates).
|
||||
void warmUp();
|
||||
|
||||
/// This device opens web pages itself (the media plan's `resolve`
|
||||
/// capability). Desktop: always (yt-dlp is downloaded when first
|
||||
/// needed); a device without it gets pages passed on by someone else.
|
||||
bool get resolvesLinks;
|
||||
|
||||
/// What [url] is, remembered for a while ([keep]); [fresh] asks again.
|
||||
Future<LinkProbe> probe(String url, {Duration keep, bool fresh});
|
||||
|
||||
/// [url] no longer needs keeping longer than usual.
|
||||
void release(String url);
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:signals/signals.dart';
|
||||
|
||||
// The media players as the app sees them: mpv instances, their video
|
||||
// outputs, how the video is rendered, mpv's log and the order players are
|
||||
// torn down in. Desktop: media_kit over libmpv
|
||||
// (`platform/desktop/media_kit_host.dart`); nothing else in the app
|
||||
// touches media_kit except the `Video` widget glue.
|
||||
|
||||
/// How the video output renders (shaders need [gpu]).
|
||||
enum RenderMode {
|
||||
unknown,
|
||||
|
||||
/// OpenGL on a real GPU: shaders work.
|
||||
gpu,
|
||||
|
||||
/// OpenGL on a software rasterizer (llvmpipe): shaders crawl.
|
||||
softwareGl,
|
||||
|
||||
/// The video output's own software fallback: shaders are ignored.
|
||||
software,
|
||||
}
|
||||
|
||||
/// What a player is for: its cache, log level and log routing.
|
||||
enum PlayerRole {
|
||||
/// The session's player (and a fresh one per media): full cache, mpv log
|
||||
/// at `warn` into the app log.
|
||||
session,
|
||||
|
||||
/// The hidden player holding what plays next: small cache, muted, log
|
||||
/// at `error`.
|
||||
preload,
|
||||
|
||||
/// A poster frame for Files: no audio or video output, no cache, no log.
|
||||
thumbnail,
|
||||
}
|
||||
|
||||
/// One line of mpv's log.
|
||||
class PlayerLogLine {
|
||||
final String prefix;
|
||||
final String level;
|
||||
final String text;
|
||||
const PlayerLogLine(this.prefix, this.level, this.text);
|
||||
|
||||
bool get isError => level == 'error' || level == 'fatal';
|
||||
}
|
||||
|
||||
/// A snapshot of a player's state.
|
||||
class HostedPlayerState {
|
||||
final Duration position;
|
||||
final Duration duration;
|
||||
|
||||
/// End of the cached range (mpv `demuxer-cache-time`).
|
||||
final Duration buffer;
|
||||
final double volume;
|
||||
final bool playing;
|
||||
|
||||
/// At the end of the file.
|
||||
final bool completed;
|
||||
final int? width;
|
||||
final int? height;
|
||||
const HostedPlayerState({
|
||||
this.position = Duration.zero,
|
||||
this.duration = Duration.zero,
|
||||
this.buffer = Duration.zero,
|
||||
this.volume = 100,
|
||||
this.playing = false,
|
||||
this.completed = false,
|
||||
this.width,
|
||||
this.height,
|
||||
});
|
||||
}
|
||||
|
||||
/// One mpv player. Each media gets its own (see ARCHITECTURE "Player
|
||||
/// lifecycle"); [PlayerHost.retire] / [PlayerHost.disposeAll] end it.
|
||||
abstract interface class HostedPlayer {
|
||||
PlayerRole get role;
|
||||
HostedPlayerState get state;
|
||||
Stream<Duration> get positionStream;
|
||||
Stream<Duration> get durationStream;
|
||||
Stream<Duration> get bufferStream;
|
||||
Stream<double> get volumeStream;
|
||||
Stream<bool> get completedStream;
|
||||
Stream<int?> get widthStream;
|
||||
|
||||
/// mpv's log lines at the player's level.
|
||||
Stream<PlayerLogLine> get logs;
|
||||
|
||||
/// The player is gone (disposed or being disposed): commands do nothing.
|
||||
bool get disposed;
|
||||
|
||||
/// Seeks from the video widget's own controls land here instead of in
|
||||
/// mpv when set ([seek] goes through it too); [syncSeek] always moves
|
||||
/// mpv.
|
||||
void Function(Duration position)? onUserSeek;
|
||||
|
||||
/// Opens [url] (with [headers] on every request), paused unless [play].
|
||||
Future<void> open(String url, {Map<String, String> headers = const {}, bool play = false});
|
||||
Future<void> play();
|
||||
Future<void> pause();
|
||||
Future<void> stop();
|
||||
|
||||
/// A user seek: through [onUserSeek] when set, else [syncSeek].
|
||||
Future<void> seek(Duration position);
|
||||
|
||||
/// Moves mpv (sync corrections, scrub preview).
|
||||
Future<void> syncSeek(Duration position);
|
||||
Future<void> setRate(double rate);
|
||||
Future<void> setVolume(double volume);
|
||||
Future<void> setProperty(String name, String value);
|
||||
Future<String> getProperty(String name);
|
||||
Future<void> observeProperty(String name, Future<void> Function(String value) onChange);
|
||||
Future<void> command(List<String> args);
|
||||
|
||||
/// Sets [name] and reads it back: null when mpv took [value], else
|
||||
/// `name: value -> got` (mpv ignores invalid values silently).
|
||||
Future<String?> pin(String name, String value);
|
||||
|
||||
/// mpv's log level for this player (`warn`, `v`, ...).
|
||||
void setLogLevel(String level);
|
||||
}
|
||||
|
||||
/// A player's video output (a texture the `Video` widget shows).
|
||||
abstract interface class HostedVideo {
|
||||
HostedPlayer get player;
|
||||
|
||||
/// Completes once the output is up (its texture registered).
|
||||
Future<void> get ready;
|
||||
}
|
||||
|
||||
/// Creates, owns and disposes the app's players. One per app (the
|
||||
/// composition root's `Ports`); tests make their own.
|
||||
abstract interface class PlayerHost {
|
||||
/// Once at start, before any player.
|
||||
void init();
|
||||
|
||||
/// A new player for [role], set up before it is returned to the caller's
|
||||
/// first await: cache, pins ([pinMismatches]), hwdec, log level.
|
||||
Future<HostedPlayer> create(PlayerRole role, {bool muted = false});
|
||||
|
||||
/// [player] (a preload) becomes a session player: unmuted, the full
|
||||
/// cache pins, the session's log level.
|
||||
Future<void> promote(HostedPlayer player);
|
||||
|
||||
/// [player]'s video output, with the hwdec policy. [probe]: listen to
|
||||
/// mpv's render init to learn [renderMode] (the first output of an app
|
||||
/// start).
|
||||
HostedVideo video(HostedPlayer player, {bool probe = false});
|
||||
|
||||
/// How video renders, as last learned (kept after leaving).
|
||||
ReadonlySignal<RenderMode> get renderMode;
|
||||
|
||||
/// mpv `hwdec` for new players: env `SYNC_RELAY_HWDEC` wins over the
|
||||
/// setting (Windows' auto is `auto-copy`).
|
||||
String? get hwdec;
|
||||
|
||||
/// Pins that did not take on the last session player created.
|
||||
List<String> get pinMismatches;
|
||||
|
||||
/// ffmpeg's HTTP errors (`HTTP error 403 Forbidden`), from whichever
|
||||
/// player ffmpeg logs through (one per process).
|
||||
Stream<String> get httpErrors;
|
||||
|
||||
/// `mpv-version` and `ffmpeg-version` of [player], for diagnostics.
|
||||
Future<String?> versions(HostedPlayer player);
|
||||
|
||||
/// Ends [player] once the view let go of its texture (two frames and a
|
||||
/// route pop); at most one retiring player lingers, a new [create] of a
|
||||
/// session player waits for it ([retiring]).
|
||||
Future<void> retire(HostedPlayer player);
|
||||
|
||||
/// The last retire, until done.
|
||||
Future<void> get retiring;
|
||||
|
||||
/// Disposes [player] at once (no view showed it).
|
||||
Future<void> dispose(HostedPlayer player);
|
||||
|
||||
/// App close: every live player goes, each bounded by [each], before
|
||||
/// the engine does (a live mpv calls back into a dead isolate).
|
||||
Future<void> disposeAll({Duration each});
|
||||
|
||||
/// Live players, for tests and the close.
|
||||
int get live;
|
||||
}
|
||||
|
||||
/// [log] says the media could not be opened: before a [duration] is known,
|
||||
/// mpv's fatal "nothing to play" or a demuxer error.
|
||||
bool mpvLoadFailed(PlayerLogLine log, Duration duration) {
|
||||
return duration == Duration.zero &&
|
||||
((log.prefix == 'cplayer' && log.level == 'fatal') || (log.prefix == 'demux' && log.level == 'error'));
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import 'folder_access.dart';
|
||||
import 'link_resolver.dart';
|
||||
import 'player_host.dart';
|
||||
import 'shell_port.dart';
|
||||
import 'system_notifications.dart';
|
||||
import 'url_opener.dart';
|
||||
|
||||
export 'app_dirs.dart';
|
||||
export 'folder_access.dart';
|
||||
export 'link_resolver.dart';
|
||||
export 'player_host.dart';
|
||||
export 'shell_port.dart';
|
||||
export 'system_notifications.dart';
|
||||
export 'url_opener.dart';
|
||||
|
||||
/// The platform as the app sees it: one set, made in the composition
|
||||
/// root ([AppGraph]) and handed on from there.
|
||||
class Ports {
|
||||
final ShellPort shell;
|
||||
final UrlOpener urls;
|
||||
final FolderAccess folders;
|
||||
final LinkResolver links;
|
||||
final SystemNotifications notifications;
|
||||
/// The media players (mpv): created, rendered and disposed here.
|
||||
final PlayerHost players;
|
||||
const Ports({
|
||||
required this.shell,
|
||||
required this.urls,
|
||||
required this.folders,
|
||||
required this.links,
|
||||
required this.notifications,
|
||||
required this.players,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import 'window_activity.dart';
|
||||
|
||||
export 'window_activity.dart';
|
||||
|
||||
/// What the clipboard holds for a paste: its files, else its text.
|
||||
typedef Paste = ({List<String> files, String? text});
|
||||
|
||||
/// The app window and what the desktop hands it (drops, pastes). The
|
||||
/// desktop implementation wraps window_manager and fox_utils
|
||||
/// ([DesktopShell]); another platform brings its own.
|
||||
abstract interface class ShellPort {
|
||||
/// Before the first frame: the window shown at its remembered size and
|
||||
/// place; a close request (close button, Alt+F4) runs [onCloseRequest]
|
||||
/// instead of closing.
|
||||
Future<void> start({required String title, required Future<void> Function() onCloseRequest});
|
||||
|
||||
Future<void> setFullScreen(bool on);
|
||||
Future<void> minimize();
|
||||
Future<bool> isMaximized();
|
||||
Future<void> maximize();
|
||||
Future<void> unmaximize();
|
||||
|
||||
/// Calls [onChange] whenever the window is maximized or restored;
|
||||
/// returns the call that stops it.
|
||||
void Function() watchMaximized(void Function(bool maximized) onChange);
|
||||
|
||||
/// Calls [onChange] whenever the window gains or loses the focus, is
|
||||
/// minimised, restored or hidden; returns the call that stops it.
|
||||
void Function() watchActivity(void Function(WindowActivity activity) onChange);
|
||||
|
||||
/// Moves the window with the pointer (a press on the title area).
|
||||
Future<void> startDragging();
|
||||
|
||||
/// Asks to close the window: the same path as the close button.
|
||||
Future<void> requestClose();
|
||||
|
||||
/// The way out: remembers the window, hides it ([hide]), and later
|
||||
/// closes it for good ([quit]).
|
||||
Future<void> saveWindow();
|
||||
Future<void> hide();
|
||||
Future<void> quit();
|
||||
|
||||
/// Brings the window up and gives it the focus (restored when
|
||||
/// minimised, shown when hidden): a click on a system notification.
|
||||
Future<void> focus();
|
||||
|
||||
/// Points the desktop at the window when it is not focused (taskbar
|
||||
/// flash, urgency hint); nothing when it is.
|
||||
Future<void> requestAttention();
|
||||
|
||||
/// The clipboard's files, else its text (Ctrl+V outside a text field).
|
||||
Future<Paste> readPaste();
|
||||
|
||||
/// [child] taking dropped files ([onDrop]: their paths and where they
|
||||
/// landed); [enable] off lets drops through.
|
||||
Widget dropTarget({
|
||||
Key? key,
|
||||
bool enable = true,
|
||||
VoidCallback? onEnter,
|
||||
VoidCallback? onExit,
|
||||
required void Function(List<String> paths, Offset at) onDrop,
|
||||
required Widget child,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/// Notifications outside the window (the desktop's notification center,
|
||||
/// Android's shade). The Notifier decides what goes out here (see
|
||||
/// `noticeFor`); the port only shows them and reports clicks.
|
||||
abstract interface class SystemNotifications {
|
||||
/// Shows [title] / [body] with the app's name and icon; one with the
|
||||
/// same [tag] replaces it. [payload] comes back to [onClick].
|
||||
Future<void> show({required String title, required String body, String tag = '', String payload = ''});
|
||||
|
||||
/// Called with a notification's payload when the user clicks it (set
|
||||
/// once, by the Notifier).
|
||||
set onClick(void Function(String payload)? handler);
|
||||
}
|
||||
|
||||
/// Shows nothing (tests, platforms without one).
|
||||
class NoSystemNotifications implements SystemNotifications {
|
||||
const NoSystemNotifications();
|
||||
|
||||
@override
|
||||
Future<void> show({required String title, required String body, String tag = '', String payload = ''}) async {}
|
||||
|
||||
@override
|
||||
set onClick(void Function(String payload)? handler) {}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
/// Opens links outside the app.
|
||||
abstract interface class UrlOpener {
|
||||
/// Opens [url] with the system's handler (a browser for web links, the
|
||||
/// file manager for a `file:` folder); false when nothing opened it.
|
||||
Future<bool> open(String url);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/// Where the app window stands ([ShellPort.watchActivity]): auto sit-out
|
||||
/// and the Notifier (system notifications while it is not focused) read
|
||||
/// it. Pure Dart.
|
||||
enum WindowActivity {
|
||||
/// The window has the keyboard focus.
|
||||
focused,
|
||||
|
||||
/// Shown, but another window has the focus.
|
||||
unfocused,
|
||||
|
||||
/// Minimised to the taskbar.
|
||||
minimised,
|
||||
|
||||
/// Hidden (closing, or a platform that hides instead of minimising).
|
||||
hidden;
|
||||
|
||||
/// Not in front of the user: notices go out as system notifications.
|
||||
bool get background => this != focused;
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart' show debugPrint;
|
||||
|
||||
import '../app/app_graph.dart';
|
||||
import 'app_shutdown.dart';
|
||||
import 'ports/player_host.dart';
|
||||
|
||||
// The close smoke run (CI, `tool/smoke_windows.ps1` / `tool/smoke_linux.sh`):
|
||||
// env `STARSYNC_SMOKE=<media file>` makes the app, once up, open that file
|
||||
// in a session player with a video output through the media pipeline,
|
||||
// play a second and close the normal way ([shutDownApp]). Progress goes to
|
||||
// `STARSYNC_SMOKE_OUT` (one line per step: `start`, `opened <ms> render=<mode>`,
|
||||
// `closing`, or `skip <why>` / `fail <why>`), so the script can tell a
|
||||
// runner that cannot show a window (skip) from a close that hangs or
|
||||
// leaves a process behind (fail).
|
||||
|
||||
/// The smoke run's media, or null for a normal start.
|
||||
String? get smokeMedia {
|
||||
final m = Platform.environment['STARSYNC_SMOKE'];
|
||||
return m == null || m.isEmpty ? null : m;
|
||||
}
|
||||
|
||||
Future<void> runSmoke(AppGraph graph, String media) async {
|
||||
final outPath = Platform.environment['STARSYNC_SMOKE_OUT'];
|
||||
void step(String line) {
|
||||
debugPrint('smoke: $line'); // into the app log too
|
||||
if (outPath == null || outPath.isEmpty) return;
|
||||
try {
|
||||
File(outPath).writeAsStringSync('$line\n', mode: FileMode.append, flush: true);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
step('start');
|
||||
final sw = Stopwatch()..start();
|
||||
final pipe = graph.coordinator.media;
|
||||
try {
|
||||
if (!await File(media).exists()) {
|
||||
step('fail no media file at $media');
|
||||
} else {
|
||||
await pipe.start();
|
||||
await pipe.playback.setVolume(0);
|
||||
// The video output the session view would make: the render path
|
||||
// (EGL / ANGLE) is what breaks on some machines.
|
||||
final video = pipe.playback.video;
|
||||
await video.ready.timeout(const Duration(seconds: 15));
|
||||
final ok = await pipe.open(media);
|
||||
final end = DateTime.now().add(const Duration(seconds: 20));
|
||||
while (ok && pipe.playback.durationSecs <= 0 && DateTime.now().isBefore(end)) {
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
}
|
||||
if (pipe.playback.durationSecs <= 0) {
|
||||
step('fail the media did not open in 20 s');
|
||||
} else {
|
||||
await pipe.playback.player.play();
|
||||
await Future.delayed(const Duration(seconds: 1));
|
||||
final mode = graph.ports.players.renderMode.peek();
|
||||
step('opened ${sw.elapsedMilliseconds} render=${mode.name}');
|
||||
if (mode == RenderMode.unknown) await Future.delayed(const Duration(seconds: 2));
|
||||
}
|
||||
}
|
||||
} on TimeoutException {
|
||||
step('skip the video output did not come up (no display for this runner?)');
|
||||
} catch (e) {
|
||||
step('fail $e');
|
||||
}
|
||||
step('closing');
|
||||
await shutDownApp();
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
/// FNV-1a over a 15x15 grid of RGB(A) pixels in the central half of an
|
||||
/// image. Used on a small capture of what the compositor shows: the
|
||||
/// centre is video, the edges carry the (changing) control overlays.
|
||||
int hashCenterPixels(ByteData rgba, int width, int height) {
|
||||
var x = 0x811c9dc5;
|
||||
if (width <= 0 || height <= 0) return x;
|
||||
for (var gy = 1; gy < 16; gy++) {
|
||||
for (var gx = 1; gx < 16; gx++) {
|
||||
final px = (width * (0.25 + 0.5 * gx / 16)).floor();
|
||||
final py = (height * (0.25 + 0.5 * gy / 16)).floor();
|
||||
final o = (py * width + px) * 4;
|
||||
if (o + 3 > rgba.lengthInBytes) continue;
|
||||
for (var c = 0; c < 3; c++) {
|
||||
x = ((x ^ rgba.getUint8(o + c)) * 0x01000193) & 0xffffffff;
|
||||
}
|
||||
}
|
||||
}
|
||||
return x;
|
||||
}
|
||||
|
||||
/// FNV-1a over ~4k bytes spread evenly across a raw frame (mpv
|
||||
/// `screenshot-raw`); dimension-agnostic, enough to tell frames apart.
|
||||
int hashSpread(Uint8List bytes) {
|
||||
var x = 0x811c9dc5;
|
||||
if (bytes.isEmpty) return x;
|
||||
final step = bytes.length <= 4096 ? 1 : bytes.length ~/ 4096;
|
||||
for (var i = 0; i < bytes.length; i += step) {
|
||||
x = ((x ^ bytes[i]) * 0x01000193) & 0xffffffff;
|
||||
}
|
||||
return x;
|
||||
}
|
||||
|
||||
/// One sample of player state, taken about once a second.
|
||||
class FrameSample {
|
||||
final DateTime at;
|
||||
final double position;
|
||||
|
||||
/// Fingerprint of the picture currently shown on screen; null when it
|
||||
/// cannot be captured (no view, hidden window, headless).
|
||||
final int? frames;
|
||||
|
||||
/// Playing, not buffering, rate > 0, video track present, window
|
||||
/// visible. Anything else disarms the watchdog.
|
||||
final bool eligible;
|
||||
const FrameSample({
|
||||
required this.at,
|
||||
required this.position,
|
||||
required this.frames,
|
||||
required this.eligible,
|
||||
});
|
||||
}
|
||||
|
||||
/// Detects a frozen picture: position advances normally but the shown
|
||||
/// picture stands still for [stallAfter]. Seeks (position jumps), pauses,
|
||||
/// buffering and missing captures all reset the stall clock. A hit is a
|
||||
/// suspicion only — a still scene looks the same — so the caller confirms
|
||||
/// it against the decoder and calls [dismiss] when it was a still scene.
|
||||
/// Fires at most once until the picture is seen moving again for
|
||||
/// [rearmAfter], so a player that stays (or soon gets) frozen after a
|
||||
/// reload does not reload in a loop. [reset] keeps that latch.
|
||||
class FrameWatchdog {
|
||||
static const stallAfter = Duration(seconds: 3);
|
||||
static const rearmAfter = Duration(seconds: 10);
|
||||
|
||||
FrameSample? _last;
|
||||
DateTime? _stallSince;
|
||||
DateTime? _healthySince;
|
||||
bool _fired = false;
|
||||
|
||||
void reset() {
|
||||
_last = null;
|
||||
_stallSince = null;
|
||||
_healthySince = null;
|
||||
}
|
||||
|
||||
/// The last hit was a still scene: re-arm, but wait a full
|
||||
/// [stallAfter] before suspecting again.
|
||||
void dismiss() {
|
||||
_fired = false;
|
||||
_stallSince = null;
|
||||
}
|
||||
|
||||
/// Returns true exactly when a freeze is suspected.
|
||||
bool feed(FrameSample s) {
|
||||
final last = _last;
|
||||
_last = s;
|
||||
if (!s.eligible || s.frames == null || last == null ||
|
||||
!last.eligible || last.frames == null) {
|
||||
_stallSince = null;
|
||||
_healthySince = null;
|
||||
return false;
|
||||
}
|
||||
if (s.frames != last.frames) {
|
||||
_stallSince = null;
|
||||
_healthySince ??= last.at;
|
||||
if (s.at.difference(_healthySince!) >= rearmAfter) _fired = false;
|
||||
return false;
|
||||
}
|
||||
_healthySince = null;
|
||||
final wall = s.at.difference(last.at).inMilliseconds / 1000.0;
|
||||
final moved = s.position - last.position;
|
||||
// Normal playback only: a jump (seek) or a standstill (stall, the
|
||||
// position itself stuck) is not a frozen picture.
|
||||
final normal = wall > 0 && moved > wall * 0.5 && moved < wall * 2 + 0.5;
|
||||
if (!normal) {
|
||||
_stallSince = null;
|
||||
return false;
|
||||
}
|
||||
_stallSince ??= last.at;
|
||||
if (_fired) return false;
|
||||
if (s.at.difference(_stallSince!) >= stallAfter) {
|
||||
_fired = true;
|
||||
_stallSince = null;
|
||||
return true;
|
||||
}
|
||||
return 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,45 +1,50 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart' show listEquals;
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:media_kit/media_kit.dart';
|
||||
import 'package:media_kit_video/media_kit_video.dart';
|
||||
import 'package:flutter/foundation.dart' show debugPrint, listEquals, visibleForTesting;
|
||||
import 'package:signals/signals_flutter.dart';
|
||||
import 'package:sync_relay_core/core.dart' show JellyfinStreamType;
|
||||
import 'package:sync_relay_core/core.dart' show JellyfinStreamType, createLogger;
|
||||
|
||||
import '../jellyfin/track_prefs.dart' show MpvTrack;
|
||||
import '../platform/ports/player_host.dart';
|
||||
import '../shaders/shader_assets.dart';
|
||||
import '../shaders/shader_presets.dart';
|
||||
import '../state/settings.dart';
|
||||
import '../sync/state_diff.dart';
|
||||
import '../sync/user_seek.dart';
|
||||
import 'frame_watchdog.dart';
|
||||
import 'media_marks.dart';
|
||||
|
||||
/// Owns one mpv [Player] (and its [VideoController]) per session: created
|
||||
/// by [init] on join, released by [dispose] on leave, swapped by
|
||||
/// [recreate] for "Reload video". Never reuse a player across sessions —
|
||||
/// a GPU-side freeze sticks to the player, not to the widget.
|
||||
class PlaybackController {
|
||||
/// Read-ahead target: about two minutes rides out short outages and
|
||||
/// makes skipping an intro instant.
|
||||
static const readAheadSecs = 150;
|
||||
/// Forward demuxer cache. Holds [readAheadSecs] up to ~14 Mbit/s (a
|
||||
/// 1.4 GB / 24 min file is ~8); higher bitrates get less time. In RAM:
|
||||
/// media_kit sets cache-on-disk but no cache-dir, so mpv falls back.
|
||||
static const maxBytes = 256 * 1024 * 1024;
|
||||
/// 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;
|
||||
export '../platform/ports/player_host.dart' show RenderMode, HostedPlayer, HostedVideo;
|
||||
|
||||
SyncPlayer? _player;
|
||||
VideoController? _video;
|
||||
Timer? _watchTimer;
|
||||
bool _probing = false;
|
||||
final FrameWatchdog _watchdog = FrameWatchdog();
|
||||
StreamSubscription<PlayerLog>? _logSub;
|
||||
final List<StreamSubscription<Duration>> _timeSubs = [];
|
||||
final _log = createLogger('mpv');
|
||||
|
||||
/// The session player as everyone else sees it: whichever player the media
|
||||
/// pipeline made current ([attach]), its marks as signals (position,
|
||||
/// chapters, tracks, ...), the commands sent to it and the shaders on it.
|
||||
/// It never creates or disposes a player (the pipeline does, through the
|
||||
/// [PlayerHost]). Commands that come while a switch is under way (no
|
||||
/// player, or its media not loaded yet) wait and go to the new player once
|
||||
/// ([apply]): a seek into a player still opening is what made mpv log
|
||||
/// "error running command seek".
|
||||
class PlaybackController {
|
||||
final PlayerHost host;
|
||||
PlaybackController(this.host) {
|
||||
_httpSub = host.httpErrors.listen((e) => _httpError = e);
|
||||
// The probe found software rendering while shaders are on: say so.
|
||||
_renderSub = host.renderMode.subscribe((mode) {
|
||||
if (mode == RenderMode.software && _shaders.isNotEmpty) {
|
||||
_notice('Shaders have no effect: video uses software rendering');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// mpv's `volume-max`: the volume controls go this far.
|
||||
static const maxVolume = 150;
|
||||
|
||||
HostedPlayer? _player;
|
||||
HostedVideo? _video;
|
||||
StreamSubscription<PlayerLogLine>? _logSub;
|
||||
StreamSubscription<String>? _httpSub;
|
||||
void Function()? _renderSub;
|
||||
final List<StreamSubscription<Object?>> _timeSubs = [];
|
||||
String _chapterJson = '';
|
||||
// `glsl-shaders` as last set on the current player ('' = none).
|
||||
String _shaders = '';
|
||||
@@ -47,406 +52,203 @@ class PlaybackController {
|
||||
int _noticeId = 0;
|
||||
|
||||
/// Bumped for every new player; the video view keys itself on it so a
|
||||
/// recreated player gets a fresh `Video` widget.
|
||||
/// new player gets a fresh `Video` widget.
|
||||
final generation = signal(0);
|
||||
|
||||
/// True while a live player exists. The video view shows plain black
|
||||
/// otherwise, so no texture outlives its player.
|
||||
/// True while a live player is attached. The video view shows plain
|
||||
/// black otherwise, so no texture outlives its player.
|
||||
final alive = signal(false);
|
||||
|
||||
/// Called once when the watchdog sees a frozen picture.
|
||||
void Function()? onFrozen;
|
||||
/// The current media failed to load (the HTTP error when there was one,
|
||||
/// e.g. a CDN's 403), at most once per [beginOpen].
|
||||
void Function(String error)? onLoadError;
|
||||
|
||||
/// Fingerprint of the picture on screen, installed by the video view
|
||||
/// (`ShownFrameProbe.sample`). Without it the watchdog stays idle.
|
||||
Future<int?> Function()? shownFrame;
|
||||
|
||||
Player get player => _player!;
|
||||
|
||||
/// Seeks from the video controls (seek bar) go here instead of to mpv.
|
||||
/// Seeks from the controls (seek bar, the video widget's own) go here
|
||||
/// instead of to mpv.
|
||||
void Function(Duration position)? onUserSeek;
|
||||
|
||||
/// Latest shader message for the user (the session screen toasts it).
|
||||
// Per opened media: the last HTTP error ffmpeg logged, and whether a
|
||||
// load error was reported.
|
||||
String? _httpError;
|
||||
bool _loadErrorSent = false;
|
||||
|
||||
/// The current player. Only while [alive].
|
||||
HostedPlayer get player => _player!;
|
||||
|
||||
/// The current player, null between sessions.
|
||||
HostedPlayer? get current => _player;
|
||||
|
||||
/// 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
|
||||
/// [RenderMode.gpu]); [lastRenderMode] keeps it after leaving.
|
||||
final renderMode = signal(RenderMode.unknown);
|
||||
static final lastRenderMode = signal(RenderMode.unknown);
|
||||
/// How video renders (the host's last probe; shaders need gpu).
|
||||
ReadonlySignal<RenderMode> get renderMode => host.renderMode;
|
||||
|
||||
// ------------------------------------------------- media marks (signals)
|
||||
//
|
||||
// What the open file has, for any controls UI (the session's seek bar,
|
||||
// chapter / track menus and skip pills read these; nothing here knows
|
||||
// about widgets). Reset when the player goes away; [segments] is owned
|
||||
// by the coordinator (it survives Reload video, the item is the same).
|
||||
// What the open file has, for any controls UI. Reset when the player goes
|
||||
// away; [segments] is owned by the coordinator (it survives Reload video,
|
||||
// the item is the same).
|
||||
|
||||
/// mpv `chapter-list`: title and start; end = next start, or the
|
||||
/// duration for the last one. Empty without chapters.
|
||||
final chapters = signal<List<MediaChapter>>(const []);
|
||||
|
||||
/// Jellyfin MediaSegments of the current item (Intro / Outro / Recap /
|
||||
/// Preview / Commercial). Only viewers with their own account fetch
|
||||
/// them (relay viewers cannot call Jellyfin): empty otherwise.
|
||||
/// Jellyfin MediaSegments of the current item. Only viewers with their
|
||||
/// own account fetch them: empty otherwise.
|
||||
final segments = signal<List<MediaSegment>>(const []);
|
||||
|
||||
/// Audio and subtitle tracks from mpv's `track-list` (id, language,
|
||||
/// title, codec, channels, default / forced flags, [MpvTrack.selected]).
|
||||
/// Audio and subtitle tracks from mpv's `track-list`.
|
||||
final tracks = signal<List<MpvTrack>>(const []);
|
||||
|
||||
/// Playback position, in 250 ms steps (enough for pills and menus; a
|
||||
/// seek bar should listen to `player.stream.position`).
|
||||
/// Playback position, in 250 ms steps.
|
||||
final position = signal(Duration.zero);
|
||||
|
||||
/// Duration of the open file (zero until known).
|
||||
final duration = signal(Duration.zero);
|
||||
|
||||
/// End of the cached range ahead of the position (mpv
|
||||
/// `demuxer-cache-time`, media_kit's `buffer`), in 250 ms steps.
|
||||
/// End of the cached range ahead of the position, in 250 ms steps.
|
||||
final buffered = signal(Duration.zero);
|
||||
|
||||
/// The opening ([openingRange]: Intro segment, else an OP-titled
|
||||
/// chapter) and the ending ([endingRange]: Outro, else ED-titled).
|
||||
late final Computed<SkipRange?> opening =
|
||||
computed(() => openingRange(segments.value, chapters.value));
|
||||
late final Computed<SkipRange?> ending =
|
||||
computed(() => endingRange(segments.value, chapters.value));
|
||||
/// The player's volume, 0..[maxVolume]; kept across players.
|
||||
final volume = signal(100.0);
|
||||
|
||||
late final Computed<SkipRange?> opening = computed(() => openingRange(segments.value, chapters.value));
|
||||
late final Computed<SkipRange?> ending = computed(() => endingRange(segments.value, chapters.value));
|
||||
|
||||
/// Index into [chapters] of the chapter playing now.
|
||||
late final Computed<int?> currentChapter =
|
||||
computed(() => chapterIndexAt(chapters.value, position.value));
|
||||
late final Computed<int?> currentChapter = computed(() => chapterIndexAt(chapters.value, position.value));
|
||||
|
||||
/// Pinned mpv properties whose read-back differs from what was set
|
||||
/// (mpv ignores invalid values silently), as `name: wanted -> got`.
|
||||
final List<String> pinMismatches = [];
|
||||
bool get _ready => _player != null;
|
||||
/// The [HostedVideo] of the current player. Created by the video view
|
||||
/// when the pipeline did not make one ahead (it needs a running Flutter
|
||||
/// binding; headless tests never touch it). The first one of an app
|
||||
/// start probes the render mode.
|
||||
HostedVideo get video =>
|
||||
_video ??= host.video(player, probe: host.renderMode.peek() == RenderMode.unknown);
|
||||
|
||||
/// The [VideoController] of the current player. Created lazily by the
|
||||
/// video view (it needs a running Flutter binding; headless tests never
|
||||
/// touch it) and released with the player.
|
||||
VideoController get video {
|
||||
final existing = _video;
|
||||
if (existing != null) return existing;
|
||||
final hwdec = _hwdecOverride;
|
||||
// media_kit's video output sets hwdec itself on attach ('auto' unless
|
||||
// configured), which used to override SYNC_RELAY_HWDEC.
|
||||
final video = _video = VideoController(
|
||||
player,
|
||||
configuration: VideoControllerConfiguration(hwdec: hwdec),
|
||||
);
|
||||
unawaited(_detectRenderMode(player, video));
|
||||
return video;
|
||||
}
|
||||
/// The current player has a video output (the view is up).
|
||||
bool get hasVideo => _video != null;
|
||||
|
||||
// SYNC_RELAY_HWDEC wins over the Settings choice; read per player, so a
|
||||
// changed setting applies on the next join or Reload video.
|
||||
static String? get _hwdecOverride {
|
||||
final hwdec = Platform.environment['SYNC_RELAY_HWDEC'];
|
||||
if (hwdec != null && hwdec.isNotEmpty) return hwdec;
|
||||
return AppSettings.instance.hwdec.peek();
|
||||
}
|
||||
// ------------------------------------------------------------- attach
|
||||
|
||||
Future<void> init() async {
|
||||
if (_player != null) return;
|
||||
_player = SyncPlayer(
|
||||
configuration: PlayerConfiguration(
|
||||
bufferSize: maxBytes,
|
||||
libass: true,
|
||||
vo: Platform.environment['SYNC_RELAY_VO'],
|
||||
// Verbose: the render-mode probe needs mpv's GL init lines.
|
||||
logLevel: MPVLogLevel.v,
|
||||
))
|
||||
..onUserSeek = (d) => onUserSeek?.call(d);
|
||||
/// [p] becomes the session player (the first one, a fresh one per media,
|
||||
/// a promoted preload, Reload video's): log, marks and track observers
|
||||
/// move over, the view swaps to [video] ([generation]), volume and
|
||||
/// shaders carry over. Returns the previous player for the pipeline to
|
||||
/// retire. [loaded]: [p] already holds its media (a promoted preload).
|
||||
HostedPlayer? attach(HostedPlayer p, {HostedVideo? video, bool loaded = false}) {
|
||||
final old = _player;
|
||||
_unwatch();
|
||||
_player = p;
|
||||
_video = video;
|
||||
_chapterJson = '';
|
||||
_httpError = null;
|
||||
_loadErrorSent = false;
|
||||
_shaders = '';
|
||||
renderMode.value = RenderMode.unknown;
|
||||
shaderNotice.value = null;
|
||||
_logSub = _player!.stream.log.listen(_onLog);
|
||||
_watchMarks(_player!);
|
||||
await _pinBufferProps();
|
||||
await applyShaders();
|
||||
_watchdog.reset();
|
||||
_watchTimer = Timer.periodic(const Duration(seconds: 1), (_) => _watch());
|
||||
_loaded = loaded && p.state.duration > Duration.zero;
|
||||
_opened = loaded;
|
||||
p.onUserSeek = (d) => onUserSeek?.call(d);
|
||||
final st = p.state;
|
||||
position.value = _step(st.position);
|
||||
duration.value = st.duration;
|
||||
buffered.value = _step(st.buffer);
|
||||
chapters.value = const [];
|
||||
tracks.value = const [];
|
||||
_logSub = p.logs.listen(_onLog);
|
||||
_watchMarks(p);
|
||||
generation.value++;
|
||||
alive.value = true;
|
||||
unawaited(p.setVolume(volume.peek()).catchError((Object e) => debugPrint('new player volume: $e')));
|
||||
unawaited(applyShaders());
|
||||
unawaited(_refreshTracks());
|
||||
if (_loaded) unawaited(_flush());
|
||||
return old;
|
||||
}
|
||||
|
||||
/// Dispose the current player and start a fresh one (no media loaded).
|
||||
/// Keeps volume. The caller reopens media and resyncs.
|
||||
Future<void> recreate() async {
|
||||
final volume = _player?.state.volume;
|
||||
await dispose();
|
||||
await init();
|
||||
if (volume != null) {
|
||||
try {
|
||||
await player.setVolume(volume);
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _pin(String property, String value) async {
|
||||
try {
|
||||
final dynamic platform = player.platform;
|
||||
await platform?.setProperty(property, value);
|
||||
final got = '${await platform?.getProperty(property)}';
|
||||
if (!pinMatches(value, got)) {
|
||||
pinMismatches.add('$property: $value -> $got');
|
||||
debugPrint('pin $property=$value not applied, mpv has "$got"');
|
||||
}
|
||||
} catch (e, st) {
|
||||
pinMismatches.add('$property: $value -> error $e');
|
||||
debugPrint('pin $property=$value failed: $e\n$st');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _pinBufferProps() async {
|
||||
pinMismatches.clear();
|
||||
await _pin('demuxer-readahead-secs', '$readAheadSecs');
|
||||
await _pin('cache-secs', '$readAheadSecs');
|
||||
await _pin('demuxer-max-bytes', '$maxBytes');
|
||||
await _pin('demuxer-max-back-bytes', '$maxBackBytes');
|
||||
final hwdec = _hwdecOverride;
|
||||
if (hwdec != null) {
|
||||
await _pin('hwdec', hwdec);
|
||||
}
|
||||
await _pin('stream-lavf-o',
|
||||
'reconnect_streamed=1,reconnect_on_http_error=1,reconnect_delay_max=4');
|
||||
}
|
||||
|
||||
/// Puts the saved shader choice (Settings, Ctrl+0..6, shader button) on
|
||||
/// the current player, live. A missing file or a value mpv won't take
|
||||
/// turns shaders off and says why in [shaderNotice]; [announce] also
|
||||
/// reports success ("Shaders: Mode A (HQ)").
|
||||
Future<void> applyShaders({bool announce = false}) =>
|
||||
_shaderOp = _shaderOp.then((_) => _applyShaders(announce));
|
||||
|
||||
Future<void> _applyShaders(bool announce) async {
|
||||
/// The player leaves (leave, Reload video): the view drops its texture
|
||||
/// ([alive] false) and the marks clear. Returns it for the pipeline to
|
||||
/// dispose.
|
||||
HostedPlayer? detach() {
|
||||
final p = _player;
|
||||
if (p == null) return;
|
||||
final s = AppSettings.instance;
|
||||
final preset = s.shaderPreset.peek();
|
||||
final label = shaderLabel(preset, s.shaderQuality.peek());
|
||||
String? problem;
|
||||
var paths = const <String>[];
|
||||
try {
|
||||
paths = await shaderPathsFor(
|
||||
preset, s.shaderQuality.peek(), s.customShaders.peek());
|
||||
if (preset == ShaderPreset.custom && paths.isEmpty) {
|
||||
problem = 'no custom shader files picked';
|
||||
} else {
|
||||
problem = shaderPathsProblem(paths);
|
||||
}
|
||||
} catch (e) {
|
||||
problem = 'could not unpack the shaders ($e)';
|
||||
}
|
||||
if (!identical(p, _player)) return;
|
||||
if (problem != null) paths = const [];
|
||||
final value = joinShaderPaths(paths);
|
||||
if (value != _shaders || problem != null) {
|
||||
final got = await setShaderValue(value);
|
||||
if (!identical(p, _player)) return;
|
||||
if (problem == null && got != value) {
|
||||
problem = 'mpv did not take them';
|
||||
await setShaderValue('');
|
||||
}
|
||||
}
|
||||
if (problem != null) {
|
||||
_turnShadersOff('$label failed: $problem — turned off');
|
||||
} else if (announce) {
|
||||
_notice('Shaders: $label${_softwareHint(paths)}');
|
||||
}
|
||||
_unwatch();
|
||||
_player = null;
|
||||
_video = null;
|
||||
_loaded = false;
|
||||
_pending.clear();
|
||||
alive.value = false;
|
||||
_clearMarks();
|
||||
return p;
|
||||
}
|
||||
|
||||
/// Sets mpv `glsl-shaders` on the current player and returns the
|
||||
/// read-back (null on error).
|
||||
@visibleForTesting
|
||||
Future<String?> setShaderValue(String value) async {
|
||||
try {
|
||||
final dynamic platform = player.platform;
|
||||
await platform?.setProperty('glsl-shaders', value);
|
||||
final got = '${await platform?.getProperty('glsl-shaders')}';
|
||||
_shaders = got;
|
||||
if (got != value) debugPrint('glsl-shaders=$value not applied: "$got"');
|
||||
return got;
|
||||
} catch (e, st) {
|
||||
debugPrint('glsl-shaders=$value failed: $e\n$st');
|
||||
return null;
|
||||
void _unwatch() {
|
||||
unawaited(_logSub?.cancel());
|
||||
_logSub = null;
|
||||
for (final sub in _timeSubs) {
|
||||
unawaited(sub.cancel());
|
||||
}
|
||||
_timeSubs.clear();
|
||||
}
|
||||
|
||||
void _turnShadersOff(String message) {
|
||||
AppSettings.instance.setShaderPreset(ShaderPreset.off);
|
||||
_notice(message);
|
||||
/// A media is about to be opened in the current player: its marks and
|
||||
/// errors start over, and commands wait until it is loaded.
|
||||
void beginOpen() {
|
||||
_opened = true;
|
||||
_chapterJson = '';
|
||||
_httpError = null;
|
||||
_loadErrorSent = false;
|
||||
_loaded = false;
|
||||
chapters.value = const [];
|
||||
tracks.value = const [];
|
||||
buffered.value = Duration.zero;
|
||||
}
|
||||
|
||||
void _notice(String text) {
|
||||
shaderNotice.value = ShaderNotice(++_noticeId, text);
|
||||
static Duration _step(Duration d) {
|
||||
final ms = d.inMilliseconds;
|
||||
return Duration(milliseconds: ms - ms % 250);
|
||||
}
|
||||
|
||||
String _softwareHint(List<String> paths) => paths.isEmpty
|
||||
? ''
|
||||
: switch (renderMode.peek()) {
|
||||
RenderMode.software => ' (no effect: video uses software rendering)',
|
||||
RenderMode.softwareGl => ' (likely slow: OpenGL runs on the CPU)',
|
||||
_ => '',
|
||||
};
|
||||
|
||||
// mpv logs from the render context: GL init lines mean GPU rendering;
|
||||
// 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 != 'libmpv_render') return;
|
||||
if (log.text.contains('suspected software renderer')) {
|
||||
_setRenderMode(RenderMode.softwareGl);
|
||||
} else if (log.text.startsWith('GL_VERSION') &&
|
||||
renderMode.peek() == RenderMode.unknown) {
|
||||
_setRenderMode(RenderMode.gpu);
|
||||
} else if ((log.level == 'error' || log.level == 'fatal') &&
|
||||
_shaders.isNotEmpty) {
|
||||
debugPrint('shader error: ${log.text}');
|
||||
final p = _player;
|
||||
_shaders = '';
|
||||
_shaderOp = _shaderOp.then((_) async {
|
||||
if (!identical(p, _player)) return;
|
||||
await setShaderValue('');
|
||||
_turnShadersOff('Shaders failed in mpv (${log.text}) — turned off');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _setRenderMode(RenderMode mode) {
|
||||
renderMode.value = mode;
|
||||
lastRenderMode.value = mode;
|
||||
}
|
||||
|
||||
// media_kit's Linux/Windows video output creates mpv's render context
|
||||
// while the controller is created: OpenGL when it can, else the
|
||||
// software renderer, which never logs GL init (and ignores shaders).
|
||||
Future<void> _detectRenderMode(Player p, VideoController video) async {
|
||||
try {
|
||||
await video.platform.future;
|
||||
} catch (_) {
|
||||
return;
|
||||
}
|
||||
await Future.delayed(const Duration(milliseconds: 1500));
|
||||
if (!identical(p, _player) || renderMode.peek() != RenderMode.unknown) {
|
||||
return;
|
||||
}
|
||||
_setRenderMode(RenderMode.software);
|
||||
debugPrint('video output: software rendering (no GL init logged)');
|
||||
if (_shaders.isNotEmpty) {
|
||||
_notice('Shaders have no effect: video uses software rendering');
|
||||
}
|
||||
}
|
||||
|
||||
/// Fingerprint of the decoder's current frame (mpv `screenshot-raw`,
|
||||
/// off-thread); null without video.
|
||||
Future<int?> _sourceFrame(Player p) async {
|
||||
try {
|
||||
final raw = await p.screenshot(format: null);
|
||||
return raw == null || raw.isEmpty ? null : hashSpread(raw);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// A suspected freeze is real only if mpv's own frames still move: a
|
||||
/// still scene looks frozen on screen too.
|
||||
Future<bool> _sourceMoving(Player p) async {
|
||||
final a = await _sourceFrame(p);
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
if (!identical(p, _player) || !p.state.playing) return false;
|
||||
final b = await _sourceFrame(p);
|
||||
return a != null && b != null && a != b;
|
||||
}
|
||||
|
||||
static bool get _windowVisible {
|
||||
try {
|
||||
final s = WidgetsBinding.instance.lifecycleState;
|
||||
return s == null ||
|
||||
s == AppLifecycleState.resumed ||
|
||||
s == AppLifecycleState.inactive;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _watch() async {
|
||||
final p = _player;
|
||||
final probe = shownFrame;
|
||||
// Only meaningful once the video view is up.
|
||||
if (p == null || _video == null || probe == null || _probing) return;
|
||||
_probing = true;
|
||||
try {
|
||||
final frames = await probe();
|
||||
if (!identical(p, _player)) return;
|
||||
final s = p.state;
|
||||
final eligible = s.playing &&
|
||||
!s.buffering &&
|
||||
!s.completed &&
|
||||
s.rate > 0 &&
|
||||
(s.width ?? 0) > 0 &&
|
||||
_windowVisible;
|
||||
final fired = _watchdog.feed(FrameSample(
|
||||
at: DateTime.now(),
|
||||
position: positionSecs,
|
||||
frames: frames,
|
||||
eligible: eligible,
|
||||
));
|
||||
if (fired) {
|
||||
if (await _sourceMoving(p) && identical(p, _player)) {
|
||||
debugPrint('video watchdog: picture stuck while mpv frames move');
|
||||
onFrozen?.call();
|
||||
} else {
|
||||
_watchdog.dismiss();
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
_probing = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens [url] paused. [headers] go with every request for it (mpv
|
||||
/// `http-header-fields`): a Jellyfin token travels there, not in the URL.
|
||||
Future<void> openUrl(String url, {Map<String, String>? headers}) async {
|
||||
if (!_ready) return;
|
||||
try {
|
||||
_chapterJson = '';
|
||||
chapters.value = const [];
|
||||
tracks.value = const [];
|
||||
buffered.value = Duration.zero;
|
||||
// An empty map, not null: media_kit caches headers per URI.
|
||||
await player.open(Media(url, httpHeaders: headers ?? const {}), play: false);
|
||||
} catch (e, st) {
|
||||
debugPrint('open ${Uri.tryParse(url)?.replace(query: '') ?? 'media'} failed: $e\n$st');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
// Position / duration from media_kit's streams; chapter-list and
|
||||
// track-list observed in mpv (read back as JSON).
|
||||
void _watchMarks(SyncPlayer p) {
|
||||
_timeSubs.add(p.stream.position.listen((d) {
|
||||
final ms = d.inMilliseconds;
|
||||
position.value = Duration(milliseconds: ms - ms % 250);
|
||||
// Position, duration and volume from the player's streams; chapter-list
|
||||
// and track-list observed in mpv (read back as JSON).
|
||||
void _watchMarks(HostedPlayer p) {
|
||||
_timeSubs.add(p.volumeStream.listen((v) {
|
||||
if (identical(p, _player)) volume.value = v;
|
||||
}));
|
||||
_timeSubs.add(p.stream.buffer.listen((d) {
|
||||
final ms = d.inMilliseconds;
|
||||
buffered.value = Duration(milliseconds: ms - ms % 250);
|
||||
_timeSubs.add(p.positionStream.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;
|
||||
}
|
||||
position.value = _step(d);
|
||||
// Media without a known length (a live link) counts as loaded once
|
||||
// it plays.
|
||||
if (d > Duration.zero && !_loaded && _opened && identical(p, _player)) {
|
||||
_loaded = true;
|
||||
unawaited(_flush());
|
||||
}
|
||||
}));
|
||||
_timeSubs.add(p.stream.duration.listen((d) {
|
||||
_timeSubs.add(p.completedStream.listen((done) {
|
||||
if (done && duration.value > Duration.zero) position.value = duration.value;
|
||||
}));
|
||||
_timeSubs.add(p.bufferStream.listen((d) => buffered.value = _step(d)));
|
||||
_timeSubs.add(p.durationStream.listen((d) {
|
||||
duration.value = d;
|
||||
// The last chapter ends at the duration.
|
||||
if (_chapterJson.isNotEmpty) chapters.value = parseChapterList(_chapterJson, d);
|
||||
if (d > Duration.zero && !_loaded && identical(p, _player)) {
|
||||
_loaded = true;
|
||||
unawaited(_flush());
|
||||
}
|
||||
}));
|
||||
() async {
|
||||
try {
|
||||
final dynamic platform = p.platform;
|
||||
await platform?.observeProperty('chapter-list', (String v) async {
|
||||
await p.observeProperty('chapter-list', (String v) async {
|
||||
if (!identical(p, _player)) return;
|
||||
_chapterJson = v;
|
||||
chapters.value = parseChapterList(v, duration.peek());
|
||||
});
|
||||
await platform?.observeProperty('track-list', (String v) async {
|
||||
await p.observeProperty('track-list', (String v) async {
|
||||
if (!identical(p, _player)) return;
|
||||
_setTracks(parseTrackList(v));
|
||||
});
|
||||
@@ -460,14 +262,11 @@ class PlaybackController {
|
||||
if (!listEquals(tracks.peek(), list)) tracks.value = list;
|
||||
}
|
||||
|
||||
/// Reads `track-list` now (after a selection: [tracks] then shows it at
|
||||
/// once instead of on mpv's next change notice).
|
||||
Future<void> _refreshTracks() async {
|
||||
final p = _player;
|
||||
if (p == null) return;
|
||||
try {
|
||||
final dynamic platform = p.platform;
|
||||
final v = '${await platform?.getProperty('track-list')}';
|
||||
final v = await p.getProperty('track-list');
|
||||
if (identical(p, _player)) _setTracks(parseTrackList(v));
|
||||
} catch (_) {}
|
||||
}
|
||||
@@ -481,9 +280,122 @@ class PlaybackController {
|
||||
buffered.value = Duration.zero;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------- log
|
||||
|
||||
// The current player's log: load failures become player errors; a render
|
||||
// error while shaders are on is a shader that failed to load or compile
|
||||
// (mpv then renders without it).
|
||||
void _onLog(PlayerLogLine log) {
|
||||
if (log.prefix == 'ffmpeg') return;
|
||||
if (!_loadErrorSent && mpvLoadFailed(log, duration.peek())) {
|
||||
_loadErrorSent = true;
|
||||
final http = _httpError;
|
||||
_log.warning('cannot open the media: [${log.prefix}] ${log.text.trim()}'
|
||||
'${http == null ? ' (no HTTP error logged; a request that timed out after 5 s looks like this)' : ' ($http)'}');
|
||||
onLoadError?.call(http == null ? 'the video stream could not be opened' : 'the video server answered $http');
|
||||
return;
|
||||
}
|
||||
if (log.prefix == 'libmpv_render' && log.isError && _shaders.isNotEmpty) {
|
||||
debugPrint('shader error: ${log.text}');
|
||||
final p = _player;
|
||||
_shaders = '';
|
||||
_shaderOp = _shaderOp.then((_) async {
|
||||
if (!identical(p, _player)) return;
|
||||
await setShaderPaths(const []);
|
||||
_turnShadersOff('Shaders failed in mpv (${log.text}) — turned off');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------- shaders
|
||||
|
||||
/// Puts the saved shader choice on the current player, live. A missing
|
||||
/// file or a value mpv won't take turns shaders off and says why in
|
||||
/// [shaderNotice]; [announce] also reports success.
|
||||
Future<void> applyShaders({bool announce = false}) =>
|
||||
_shaderOp = _shaderOp.then((_) => _applyShaders(announce));
|
||||
|
||||
Future<void> _applyShaders(bool announce) async {
|
||||
final p = _player;
|
||||
if (p == null) return;
|
||||
final s = AppSettings.instance;
|
||||
final preset = s.shaderPreset.peek();
|
||||
final label = shaderLabel(preset, s.shaderQuality.peek());
|
||||
String? problem;
|
||||
var paths = const <String>[];
|
||||
try {
|
||||
paths = await shaderPathsFor(preset, s.shaderQuality.peek(), s.customShaders.peek());
|
||||
if (preset == ShaderPreset.custom && paths.isEmpty) {
|
||||
problem = 'no custom shader files picked';
|
||||
} else {
|
||||
problem = shaderPathsProblem(paths);
|
||||
}
|
||||
} catch (e) {
|
||||
problem = 'could not unpack the shaders ($e)';
|
||||
}
|
||||
if (!identical(p, _player)) return;
|
||||
if (problem != null) paths = const [];
|
||||
if (joinShaderPaths(paths) != _shaders || problem != null) {
|
||||
final took = await setShaderPaths(paths);
|
||||
if (!identical(p, _player)) return;
|
||||
if (problem == null && !took) {
|
||||
problem = 'mpv did not take them';
|
||||
await setShaderPaths(const []);
|
||||
}
|
||||
}
|
||||
if (problem != null) {
|
||||
_turnShadersOff('$label failed: $problem — turned off');
|
||||
} else if (announce) {
|
||||
_notice('Shaders: $label${_softwareHint(paths)}');
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets mpv `glsl-shaders` to [paths] on the current player; true when
|
||||
/// mpv's read-back is that list ([shaderListMatches]: old mpv prints it
|
||||
/// joined with ',').
|
||||
@visibleForTesting
|
||||
Future<bool> setShaderPaths(List<String> paths) async {
|
||||
final p = _player;
|
||||
if (p == null) return false;
|
||||
final value = joinShaderPaths(paths);
|
||||
try {
|
||||
await p.setProperty('glsl-shaders', value);
|
||||
final got = await p.getProperty('glsl-shaders');
|
||||
final took = shaderListMatches(paths, got);
|
||||
_shaders = took ? value : got;
|
||||
if (!took) debugPrint('glsl-shaders=$value not applied: "$got"');
|
||||
return took;
|
||||
} catch (e, st) {
|
||||
debugPrint('glsl-shaders=$value failed: $e\n$st');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
void _turnShadersOff(String message) {
|
||||
AppSettings.instance.setShaderPreset(ShaderPreset.off);
|
||||
_notice(message);
|
||||
}
|
||||
|
||||
void _notice(String text) => shaderNotice.value = ShaderNotice(++_noticeId, text);
|
||||
|
||||
String _softwareHint(List<String> paths) => paths.isEmpty
|
||||
? ''
|
||||
: switch (renderMode.peek()) {
|
||||
RenderMode.software => ' (no effect: video uses software rendering)',
|
||||
RenderMode.softwareGl => ' (likely slow: OpenGL runs on the CPU)',
|
||||
_ => '',
|
||||
};
|
||||
|
||||
/// `mpv-version` and `ffmpeg-version` of the session player.
|
||||
Future<String?> mpvVersion() async {
|
||||
final p = _player;
|
||||
return p == null ? null : host.versions(p);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ tracks
|
||||
|
||||
/// Waits (bounded) until the open file's tracks are listed and mpv has
|
||||
/// selected its audio track, so a report or a carried-over choice sees
|
||||
/// the real selection.
|
||||
/// selected its audio track.
|
||||
Future<List<MpvTrack>> waitForTracks({Duration timeout = const Duration(seconds: 5)}) async {
|
||||
final end = DateTime.now().add(timeout);
|
||||
while (_player != null && DateTime.now().isBefore(end)) {
|
||||
@@ -500,9 +412,8 @@ class PlaybackController {
|
||||
final p = _player;
|
||||
if (p == null) return (aid: null, sid: null);
|
||||
try {
|
||||
final dynamic platform = p.platform;
|
||||
final aid = int.tryParse('${await platform?.getProperty('current-tracks/audio/id')}');
|
||||
final sid = int.tryParse('${await platform?.getProperty('current-tracks/sub/id')}');
|
||||
final aid = int.tryParse(await p.getProperty('current-tracks/audio/id'));
|
||||
final sid = int.tryParse(await p.getProperty('current-tracks/sub/id'));
|
||||
return (aid: aid, sid: sid);
|
||||
} catch (_) {
|
||||
return (aid: null, sid: null);
|
||||
@@ -515,12 +426,11 @@ class PlaybackController {
|
||||
final p = _player;
|
||||
if (p == null) return;
|
||||
try {
|
||||
final dynamic platform = p.platform;
|
||||
if (aid != null) await platform?.setProperty('aid', '$aid');
|
||||
if (aid != null) await p.setProperty('aid', '$aid');
|
||||
if (subtitlesOff) {
|
||||
await platform?.setProperty('sid', 'no');
|
||||
await p.setProperty('sid', 'no');
|
||||
} else if (sid != null) {
|
||||
await platform?.setProperty('sid', '$sid');
|
||||
await p.setProperty('sid', '$sid');
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('select tracks failed: $e');
|
||||
@@ -528,6 +438,8 @@ class PlaybackController {
|
||||
await _refreshTracks();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------- position
|
||||
|
||||
double get positionSecs {
|
||||
final p = _player;
|
||||
if (p == null) return 0;
|
||||
@@ -537,7 +449,8 @@ class PlaybackController {
|
||||
double get bufferedSecsAhead {
|
||||
final p = _player;
|
||||
if (p == null) return 0;
|
||||
final ahead = (p.state.buffer - p.state.position).inMilliseconds / 1000.0;
|
||||
final st = p.state;
|
||||
final ahead = (st.buffer - st.position).inMilliseconds / 1000.0;
|
||||
if (ahead.isNaN || ahead < 0) return 0;
|
||||
return ahead;
|
||||
}
|
||||
@@ -548,23 +461,62 @@ class PlaybackController {
|
||||
return p.state.duration.inMilliseconds / 1000.0;
|
||||
}
|
||||
|
||||
/// The player is playing (not paused, not waiting for a switch).
|
||||
bool get playing => _player?.state.playing ?? false;
|
||||
|
||||
// ---------------------------------------------------------- commands
|
||||
|
||||
// The current player holds its media (a duration is known since
|
||||
// [beginOpen]); until then commands wait in [_pending].
|
||||
bool _loaded = false;
|
||||
// An open was started on the current player since it was attached.
|
||||
bool _opened = false;
|
||||
final _pending = <PlayerAction>[];
|
||||
|
||||
/// Commands applied to mpv, per kind (tests: a switch applies queued
|
||||
/// ones once).
|
||||
@visibleForTesting
|
||||
int seeksSent = 0;
|
||||
|
||||
/// A switch is under way: no player, or its media is not loaded yet.
|
||||
bool get switching => _player == null || !_loaded;
|
||||
|
||||
/// Applies [actions] (from the sync diff, a user seek, the play gate):
|
||||
/// at once to a loaded player, else queued for the new one, where the
|
||||
/// latest seek, transport and rate apply once it is loaded.
|
||||
Future<void> apply(List<PlayerAction> actions) async {
|
||||
if (!_ready) return;
|
||||
if (switching) {
|
||||
for (final a in actions) {
|
||||
_pending
|
||||
..removeWhere((p) => p.runtimeType == a.runtimeType)
|
||||
..add(a);
|
||||
}
|
||||
return;
|
||||
}
|
||||
await _run(actions);
|
||||
}
|
||||
|
||||
Future<void> _flush() async {
|
||||
if (_pending.isEmpty) return;
|
||||
final list = [..._pending];
|
||||
_pending.clear();
|
||||
await _run(list);
|
||||
}
|
||||
|
||||
Future<void> _run(List<PlayerAction> actions) async {
|
||||
final p = _player;
|
||||
if (p == null) return;
|
||||
for (final action in actions) {
|
||||
try {
|
||||
switch (action) {
|
||||
case SeekAction(:final position):
|
||||
if ((positionSecs - position).abs() < 0.5) break;
|
||||
await _player!
|
||||
.syncSeek(Duration(milliseconds: (position * 1000).round()));
|
||||
seeksSent++;
|
||||
await p.syncSeek(Duration(milliseconds: (position * 1000).round()));
|
||||
case TransportAction(:final playing):
|
||||
if (playing) {
|
||||
await player.play();
|
||||
} else {
|
||||
await player.pause();
|
||||
}
|
||||
playing ? await p.play() : await p.pause();
|
||||
case RateAction(:final rate):
|
||||
await player.setRate(rate);
|
||||
await p.setRate(rate);
|
||||
}
|
||||
} catch (e, st) {
|
||||
debugPrint('player action $action failed: $e\n$st');
|
||||
@@ -572,61 +524,36 @@ class PlaybackController {
|
||||
}
|
||||
}
|
||||
|
||||
/// Release the player. The view drops its texture first ([alive] goes
|
||||
/// false, one frame passes), then mpv is torn down.
|
||||
Future<void> dispose() async {
|
||||
/// The controls' volume: on the player now, and on every next one.
|
||||
Future<void> setVolume(double v) async {
|
||||
final clamped = v.clamp(0.0, maxVolume * 1.0);
|
||||
volume.value = clamped;
|
||||
final p = _player;
|
||||
if (p != null) await p.setVolume(clamped);
|
||||
}
|
||||
|
||||
/// A seek from the controls (seek bar, keys): the sync layer's user
|
||||
/// seek ([onUserSeek]).
|
||||
void userSeek(Duration to) => onUserSeek?.call(to);
|
||||
|
||||
/// Stops what plays (the player stays for the next media).
|
||||
Future<void> stop() async {
|
||||
final p = _player;
|
||||
if (p == null) return;
|
||||
_watchTimer?.cancel();
|
||||
_watchTimer = null;
|
||||
await _logSub?.cancel();
|
||||
_logSub = null;
|
||||
for (final sub in _timeSubs) {
|
||||
await sub.cancel();
|
||||
}
|
||||
_timeSubs.clear();
|
||||
_player = null;
|
||||
_video = null;
|
||||
alive.value = false;
|
||||
_clearMarks();
|
||||
await _settleUi();
|
||||
_pending.clear();
|
||||
_loaded = false;
|
||||
try {
|
||||
await p.dispose();
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
// Two frames: one may already be mid-build when [alive] flips. Then
|
||||
// the length of a route pop (the fullscreen route holds its own `Video`
|
||||
// and leaves animated). Bounded, since a hidden window renders no
|
||||
// frames; headless tests have no binding and skip it all.
|
||||
static Future<void> _settleUi() async {
|
||||
try {
|
||||
for (var i = 0; i < 2; i++) {
|
||||
await WidgetsBinding.instance.endOfFrame
|
||||
.timeout(const Duration(milliseconds: 300));
|
||||
}
|
||||
await Future.delayed(const Duration(milliseconds: 350));
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
Future<void> stop() async {
|
||||
if (!_ready) return;
|
||||
try {
|
||||
await player.stop();
|
||||
} catch (e, st) {
|
||||
debugPrint('player stop failed: $e\n$st');
|
||||
await p.stop();
|
||||
} catch (e) {
|
||||
debugPrint('player stop failed: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum RenderMode {
|
||||
unknown,
|
||||
/// OpenGL on a real GPU: shaders work.
|
||||
gpu,
|
||||
/// OpenGL on a software rasterizer (llvmpipe): shaders crawl.
|
||||
softwareGl,
|
||||
/// media_kit's software fallback: shaders are ignored.
|
||||
software,
|
||||
Future<void> dispose() async {
|
||||
_unwatch();
|
||||
_renderSub?.call();
|
||||
await _httpSub?.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
class ShaderNotice {
|
||||
@@ -634,10 +561,3 @@ class ShaderNotice {
|
||||
final String text;
|
||||
const ShaderNotice(this.id, this.text);
|
||||
}
|
||||
|
||||
/// mpv echoes numbers in its own format (`150` reads back `150.000000`).
|
||||
bool pinMatches(String wanted, String got) {
|
||||
if (wanted == got) return true;
|
||||
final a = double.tryParse(wanted), b = double.tryParse(got);
|
||||
return a != null && b != null && a == b;
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
|
||||
import '../platform/ports/link_resolver.dart';
|
||||
|
||||
/// 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 implements LinkResolver {
|
||||
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;
|
||||
@override
|
||||
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).
|
||||
@override
|
||||
bool get resolvesLinks => true;
|
||||
|
||||
@override
|
||||
void warmUp() => unawaited(ytDlp.then((y) => y.ensure()).catchError((_) => null));
|
||||
|
||||
final _probes = <String, ({DateTime at, DateTime until, Future<LinkProbe> f})>{};
|
||||
static const _probeTtl = Duration(minutes: 10);
|
||||
|
||||
/// What [url] is, from memory while it is kept (10 minutes from when it
|
||||
/// was probed; [keep] keeps it longer, for Up next's first item, which
|
||||
/// is probed again before that runs out). [fresh] probes again: the
|
||||
/// remembered result stays until the new one is in, and stays when the
|
||||
/// new one fails.
|
||||
@override
|
||||
Future<LinkProbe> probe(String url, {Duration keep = _probeTtl, bool fresh = false}) {
|
||||
final now = DateTime.now();
|
||||
_probes.removeWhere((_, v) => now.isAfter(v.until));
|
||||
final hit = _probes[url];
|
||||
if (hit != null && !fresh) {
|
||||
final longer = hit.at.add(keep);
|
||||
if (longer.isAfter(hit.until)) _probes[url] = (at: hit.at, until: longer, f: hit.f);
|
||||
return hit.f;
|
||||
}
|
||||
final f = ytDlp.then((y) => probeLink(Uri.parse(url), ytDlp: y));
|
||||
final until = now.add(keep);
|
||||
if (hit == null) {
|
||||
_probes[url] = (at: now, until: until, f: f);
|
||||
f.catchError((Object _) {
|
||||
if (_probes[url]?.f == f) _probes.remove(url);
|
||||
return LinkProbe.file(Uri(), -1);
|
||||
});
|
||||
} else {
|
||||
f.then<void>((_) {
|
||||
_probes[url] = (at: now, until: until, f: f);
|
||||
}, onError: (Object _) {});
|
||||
}
|
||||
return f;
|
||||
}
|
||||
|
||||
/// [url] no longer needs keeping longer: back to the usual 10 minutes.
|
||||
@override
|
||||
void release(String url) {
|
||||
final hit = _probes[url];
|
||||
if (hit == null) return;
|
||||
final usual = hit.at.add(_probeTtl);
|
||||
if (usual.isBefore(hit.until)) _probes[url] = (at: hit.at, until: usual, f: hit.f);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,887 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:signals/signals_flutter.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
|
||||
import '../files/thumbnails.dart';
|
||||
import '../jellyfin/accounts.dart';
|
||||
import '../jellyfin/format.dart' show pickFor;
|
||||
import '../jellyfin/session_media.dart';
|
||||
import '../library/media_refs.dart';
|
||||
import '../screens/home/picked_media.dart';
|
||||
import '../session/app_events.dart' show AddedToUpNext;
|
||||
import '../session/media_art.dart';
|
||||
import '../session/session_coordinator.dart';
|
||||
import '../sync/avatar_cache.dart';
|
||||
|
||||
// What's next on this app (see ARCHITECTURE.md "What's next" and "Up
|
||||
// next"): the server's queue as signals (Up next and its continuation),
|
||||
// adding to it (probed, with a picture), the continuation of what this
|
||||
// app put up (the next Jellyfin episode, the next file of a local show),
|
||||
// the server's countdown as the end card shows it, putting an item up
|
||||
// when the server asks this app (`queuePlay`), and keeping the item that
|
||||
// plays next ready (the media pipeline prepares it).
|
||||
|
||||
/// The end-of-media card for the first item (Up next's, else the
|
||||
/// continuation). [secondsLeft]: the server's countdown (`state.advance`);
|
||||
/// null when none runs (cancelled, or nothing counts down), then the card
|
||||
/// offers Play now only. [waitingFor]: the countdown ran out and the one
|
||||
/// who puts the item up is not here (its name).
|
||||
class UpNextCountdown {
|
||||
final int mediaId;
|
||||
final QueueItem item;
|
||||
final int? secondsLeft;
|
||||
final String? waitingFor;
|
||||
const UpNextCountdown({required this.mediaId, required this.item, this.secondsLeft, this.waitingFor});
|
||||
}
|
||||
|
||||
/// The first item's readiness on this app.
|
||||
enum WarmState { idle, warming, ready, failed }
|
||||
|
||||
class QueueController {
|
||||
final SessionCoordinator coord;
|
||||
QueueController(this.coord);
|
||||
|
||||
/// Queued items' pictures by hash, from `/i/`.
|
||||
late final PictureCache thumbs = PictureCache(coord.connection.client,
|
||||
route: 'i', keep: RelayConstants.maxQueueItems + 8, live: (s) => [for (final q in s.queue) q.thumb]);
|
||||
|
||||
/// Up next, in order, without the continuation (empty when nothing is
|
||||
/// queued or not in a server).
|
||||
late final Computed<List<QueueItem>> items = computed(
|
||||
() => [for (final q in coord.serverState.value?.queue ?? const <QueueItem>[]) if (!q.auto) q]);
|
||||
|
||||
/// The continuation of what plays (`auto`: the next episode), shared by
|
||||
/// everyone; null when none.
|
||||
late final Computed<QueueItem?> continues =
|
||||
computed(() => coord.serverState.value?.queue.where((q) => q.auto).firstOrNull);
|
||||
|
||||
/// What plays next: Up next's first item, else the continuation.
|
||||
late final Computed<QueueItem?> head = computed(() => coord.serverState.value?.queue.firstOrNull);
|
||||
|
||||
/// The end card while the media has ended with something next (see
|
||||
/// [UpNextCountdown]).
|
||||
final countdown = signal<UpNextCountdown?>(null);
|
||||
|
||||
/// The first item's id and how ready it is here (null: nothing to get
|
||||
/// ready on this app).
|
||||
final warm = signal<({String id, WarmState state})?>(null);
|
||||
|
||||
/// The next episodes of the Jellyfin series playing, on an app that
|
||||
/// plays it with its own account (the first one is what continues it;
|
||||
/// the rest show greyed under "Continues with", "+" adds one).
|
||||
final continuation = signal<List<EpisodeLink>>(const []);
|
||||
|
||||
/// The mediaId whose continuation was cleared (by anyone: `cancel`).
|
||||
final _hiddenFor = signal(-1);
|
||||
|
||||
/// The queue popover is open: the Up next button holds the controls up.
|
||||
final popoverOpen = signal(false);
|
||||
|
||||
// What adder-only items stand for, by their key (a link passed on from
|
||||
// here, a file here). Never leaves this app.
|
||||
final Map<String, ({PickedMedia pick, DateTime at})> _local = {};
|
||||
final Map<String, DateTime> _performing = {};
|
||||
Timer? _timer;
|
||||
bool _attached = false;
|
||||
// The mediaId whose continuation this app added (or is adding).
|
||||
int _continuedFor = -1;
|
||||
// A restarted server waits for what played (that mediaId) to come back.
|
||||
int _restoreFrom = -1;
|
||||
String _continuationFor = '';
|
||||
final _random = Random.secure();
|
||||
|
||||
String get _me => coord.connection.memberId;
|
||||
|
||||
void attach() {
|
||||
if (_attached) return;
|
||||
_attached = true;
|
||||
coord.connection.states.listen(_onState);
|
||||
// `cancel` from anyone: no continuation for that media any more.
|
||||
coord.connection.feeds.listen((f) {
|
||||
final id = coord.serverState.peek()?.mediaId;
|
||||
if (f.action == 'advance-cancel' && id != null) _hiddenFor.value = id;
|
||||
});
|
||||
effect(() {
|
||||
final next = coord.nextEpisode.value;
|
||||
final plan = coord.jellyfinPlan.value;
|
||||
unawaited(_loadContinuation(next, plan));
|
||||
});
|
||||
// What continues this media becomes known (the next episode found, a
|
||||
// file of ours up): add it.
|
||||
effect(() {
|
||||
coord.nextEpisode.value;
|
||||
coord.ownFile.value;
|
||||
untracked(() => _continue(coord.serverState.peek()));
|
||||
});
|
||||
// The state decides what to get ready; the media playing having
|
||||
// started (or sitting out) lets the preload go.
|
||||
effect(() {
|
||||
coord.settledMediaId.value;
|
||||
coord.selfAway.value;
|
||||
final r = coord.serverState.value;
|
||||
untracked(() => _warm(r));
|
||||
});
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- adding
|
||||
|
||||
/// Adds [pick] to Up next (at the end, or with [next] right after what
|
||||
/// plays). A link is probed first and refused like Put it up when it
|
||||
/// will not play. Returns an error text, or null when sent.
|
||||
Future<String?> add(PickedMedia pick, {bool next = false}) async {
|
||||
coord.markInput();
|
||||
if (coord.serverState.peek() == null) return 'not in a server';
|
||||
if (coord.stateStale.peek()) return 'reconnecting — add it again once back';
|
||||
if (items.peek().length >= RelayConstants.maxQueueItems) {
|
||||
return 'Up next is full (${RelayConstants.maxQueueItems} items)';
|
||||
}
|
||||
final ({QueueItem item, Uint8List? thumb}) built;
|
||||
try {
|
||||
final b = await _build(pick);
|
||||
if (b is String) return b;
|
||||
built = b as ({QueueItem item, Uint8List? thumb});
|
||||
} catch (e) {
|
||||
return "couldn't add it: $e";
|
||||
}
|
||||
final item = built.item;
|
||||
if (item.key.isNotEmpty) _local[item.key] = (pick: pick, at: DateTime.now());
|
||||
final thumb = built.thumb;
|
||||
coord.connection.sendQueue(QueueIntent.add(item, next: next, thumb: thumb == null ? '' : base64Encode(thumb)));
|
||||
coord.notify(AddedToUpNext(item.title));
|
||||
return null;
|
||||
}
|
||||
|
||||
/// A continuation episode becomes an Up next item.
|
||||
Future<String?> promote(EpisodeLink e) => add(e.pick);
|
||||
|
||||
/// The item, or an error text.
|
||||
Future<Object> _build(PickedMedia pick) async {
|
||||
switch (pick) {
|
||||
case JellyfinMedia():
|
||||
final accounts = coord.jellyfin;
|
||||
if (accounts.account(pick.serverId) == null) return 'that Jellyfin server is not linked any more';
|
||||
if (accounts.statusOf(pick.serverId) == JellyfinAccountStatus.expired) {
|
||||
return 'Jellyfin sign-in expired — reconnect it in Settings → Jellyfin & integrations';
|
||||
}
|
||||
final client = accounts.clientFor(pick.serverId);
|
||||
final thumb = client == null ? null : await _jellyfinThumb(client, pick.itemId);
|
||||
return (
|
||||
item: QueueItem(
|
||||
media: mediaForPick(pick),
|
||||
how: QueueHow.jellyfin,
|
||||
title: pick.title,
|
||||
sub: pick.subtitle,
|
||||
duration: (pick.runtime?.inMilliseconds ?? 0) / 1000,
|
||||
),
|
||||
thumb: thumb,
|
||||
);
|
||||
case FileMedia(:final path):
|
||||
final file = File(path);
|
||||
if (!await file.exists()) return 'file not found — check the path';
|
||||
final name = pick.label;
|
||||
final parsed = parseReleaseName(name);
|
||||
final shot = await thumbnailFor(path);
|
||||
Uint8List? thumb;
|
||||
if (shot != null) {
|
||||
try {
|
||||
final bytes = await File(shot.imagePath).readAsBytes();
|
||||
if (bytes.length <= RelayConstants.maxPosterBytes) thumb = bytes;
|
||||
} catch (_) {}
|
||||
}
|
||||
// The library's name for it when it knows the show (what friends
|
||||
// see in Up next), else the release name's.
|
||||
final what = coord.fileRef?.call(path);
|
||||
final ep = what != null && what.kind == 'episode'
|
||||
? (refEpisodeLine(what) ?? '').replaceAll(' ', '')
|
||||
: [
|
||||
if (parsed.season != null) 'S${parsed.season}',
|
||||
if (parsed.episode != null) 'E${parsed.episode}',
|
||||
if (parsed.episode == null) ?parsed.extra,
|
||||
].join();
|
||||
final title = what != null && what.title.isNotEmpty ? what.title : parsed.series.trim();
|
||||
return (
|
||||
item: QueueItem(
|
||||
media: HelloMedia(name: name, size: await file.length(), duration: 0, ref: what),
|
||||
how: QueueHow.file,
|
||||
title: title.isEmpty ? name : title,
|
||||
sub: ep,
|
||||
duration: (shot?.duration.inMilliseconds ?? 0) / 1000,
|
||||
key: _newKey(),
|
||||
),
|
||||
thumb: thumb,
|
||||
);
|
||||
case LinkMedia(:final url, :final anyway):
|
||||
// Alone, a link plays direct: there is nobody to pass it on to.
|
||||
final relay = coord.alone.peek() ? LinkRelay.direct : pick.relay;
|
||||
final linked = await mediaForLink(coord.jellyfin, url);
|
||||
final ref = linked.jellyfin;
|
||||
if (ref != null) {
|
||||
return (
|
||||
item: QueueItem(
|
||||
media: linked,
|
||||
how: QueueHow.jellyfin,
|
||||
title: ref.title.isEmpty ? linked.name : ref.title,
|
||||
sub: ref.subtitle ?? '',
|
||||
duration: ref.runtimeSecs ?? 0),
|
||||
thumb: null,
|
||||
);
|
||||
}
|
||||
LinkProbe? probe;
|
||||
String? probeError;
|
||||
try {
|
||||
probe = await coord.ytDlp.probe(url).timeout(const Duration(seconds: 45));
|
||||
} catch (e) {
|
||||
probeError = '$e';
|
||||
}
|
||||
final problem = probe == null ? playErrorText(probeError ?? '') : probe.problem;
|
||||
if (problem != null && !anyway) return problem;
|
||||
if (relay == LinkRelay.me) {
|
||||
if (probe == null) return problem ?? "couldn't reach the site";
|
||||
final relayProblem = probe.relayProblem;
|
||||
if (relayProblem != null) return relayProblem;
|
||||
}
|
||||
if (relay == LinkRelay.server && !coord.serverHasPassword) return 'server fetch needs a server password';
|
||||
if (relay == LinkRelay.server && probe != null && probe.isPage && probe.info != null) {
|
||||
final relayProblem = probe.relayProblem;
|
||||
if (relayProblem != null) return relayProblem;
|
||||
}
|
||||
final uri = Uri.tryParse(url) ?? Uri();
|
||||
final info = probe?.info;
|
||||
final title = probe?.title ?? linked.name;
|
||||
final site = info == null
|
||||
? uri.host
|
||||
: (info.uploader.isNotEmpty ? info.uploader : (info.extractor.isNotEmpty ? info.extractor : uri.host));
|
||||
final thumbUrl = info?.thumbnail == null ? null : Uri.tryParse(info!.thumbnail!);
|
||||
final thumb = thumbUrl == null ? null : (await thumbnailArt(thumbUrl))?.poster;
|
||||
final how = switch (relay) {
|
||||
LinkRelay.direct => QueueHow.direct,
|
||||
LinkRelay.server => QueueHow.server,
|
||||
LinkRelay.me => QueueHow.through,
|
||||
};
|
||||
return (
|
||||
item: QueueItem(
|
||||
media: HelloMedia(
|
||||
name: title,
|
||||
size: 0,
|
||||
duration: 0,
|
||||
url: how == QueueHow.through ? null : url,
|
||||
relay: how == QueueHow.server ? const MediaRelay(fetch: true) : null,
|
||||
ref: linkMediaRef(title)),
|
||||
how: how,
|
||||
title: title,
|
||||
sub: site,
|
||||
duration: info?.duration ?? 0,
|
||||
key: how == QueueHow.through ? _newKey() : '',
|
||||
),
|
||||
thumb: thumb,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _newKey() => [for (var i = 0; i < 12; i++) _random.nextInt(256).toRadixString(16).padLeft(2, '0')].join();
|
||||
|
||||
/// A wide picture of a Jellyfin item: an episode's own still, else the
|
||||
/// item's (or series') thumb or backdrop, else its poster.
|
||||
Future<Uint8List?> _jellyfinThumb(JellyfinClient client, String itemId) async {
|
||||
try {
|
||||
final item = await client.item(itemId).timeout(const Duration(seconds: 10));
|
||||
final ref = (item.kind == JellyfinItemKind.episode ? item.ownImage(JellyfinImageType.primary) : null) ??
|
||||
item.image(JellyfinImageType.thumb) ??
|
||||
item.image(JellyfinImageType.backdrop) ??
|
||||
item.image(JellyfinImageType.primary);
|
||||
if (ref == null) return null;
|
||||
return await fetchImage(
|
||||
client.imageUrl(ref, maxWidth: 480, quality: 80), client.authHeaders, RelayConstants.maxPosterBytes);
|
||||
} catch (e) {
|
||||
debugPrint('queue: no Jellyfin picture for $itemId: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// [item]'s link as this app may hand it out (Copy link, Open in
|
||||
/// browser): a link everyone opens (it is in the state), or a link this
|
||||
/// app passes on (its adder). Null otherwise: a server-fetched link stays
|
||||
/// with the server, another member's passed-on link with them.
|
||||
String? linkOf(QueueItem item) => switch (item.how) {
|
||||
QueueHow.direct => item.media.url,
|
||||
QueueHow.through when item.by == _me => switch (_local[item.key]?.pick) {
|
||||
LinkMedia(:final url) => url,
|
||||
_ => null,
|
||||
},
|
||||
_ => null,
|
||||
};
|
||||
|
||||
/// [item]'s page on its Jellyfin server, when that server is linked here.
|
||||
String? jellyfinPageOf(QueueItem item) {
|
||||
final ref = item.media.jellyfin;
|
||||
return ref == null ? null : coord.jellyfin.webUrlOf(ref.serverId, ref.itemId);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ editing
|
||||
|
||||
void move(String id, int to) {
|
||||
coord.markInput();
|
||||
coord.connection.sendQueue(QueueIntent.move(id, to));
|
||||
}
|
||||
|
||||
void remove(String id) {
|
||||
coord.markInput();
|
||||
coord.connection.sendQueue(QueueIntent.remove(id));
|
||||
}
|
||||
|
||||
/// Plays [id] now: this app puts it up when it is the one that can,
|
||||
/// else it asks the server, and the app that can puts it up.
|
||||
void playNow(String id) {
|
||||
coord.markInput();
|
||||
final r = coord.serverState.peek();
|
||||
final item = r?.queue.where((q) => q.id == id).firstOrNull;
|
||||
if (r == null || item == null) return;
|
||||
if (queuePerformer(item, r.members).id == _me) {
|
||||
_perform(item);
|
||||
} else {
|
||||
coord.connection.sendQueue(QueueIntent.play(id));
|
||||
}
|
||||
}
|
||||
|
||||
/// Plays what comes next now (Up next's first item, else the
|
||||
/// continuation).
|
||||
void skip() {
|
||||
final first = head.peek();
|
||||
if (first != null) playNow(first.id);
|
||||
}
|
||||
|
||||
/// Cancel on the end card: the countdown stops for everyone, and the
|
||||
/// continuation goes (`cancel`).
|
||||
void cancelCountdown() {
|
||||
coord.markInput();
|
||||
coord.connection.sendQueue(const QueueIntent.cancel());
|
||||
}
|
||||
|
||||
/// "Clear" under "Continues with": the continuation of what plays goes
|
||||
/// for everyone (`cancel`) and does not come back for this media.
|
||||
void clearContinuation() {
|
||||
final id = coord.serverState.peek()?.mediaId;
|
||||
if (id != null) _hiddenFor.value = id;
|
||||
cancelCountdown();
|
||||
}
|
||||
|
||||
/// The further episodes of [continuation] (after the one that continues
|
||||
/// what plays), unless it was cleared for the media playing.
|
||||
late final Computed<List<EpisodeLink>> shownContinuation = computed(() {
|
||||
final id = coord.serverState.value?.mediaId;
|
||||
if (id == null || _hiddenFor.value == id) return const <EpisodeLink>[];
|
||||
final auto = continues.value?.media.jellyfin?.itemId;
|
||||
return [for (final e in continuation.value) if (e.item.id != auto) e];
|
||||
});
|
||||
|
||||
// --------------------------------------------------------- per state
|
||||
|
||||
void _onState(StateBroadcast r) {
|
||||
_lastState = r;
|
||||
// What played came back after a restart under a new mediaId: a
|
||||
// continuation cleared for it stays cleared.
|
||||
if (_restoreFrom != -1 && r.media != null && _hiddenFor.peek() == _restoreFrom) _hiddenFor.value = r.mediaId;
|
||||
_prune(r);
|
||||
// The server asks for an item (a countdown ran out, Play now from
|
||||
// another app, nothing up yet): the app that puts it up does.
|
||||
final play = r.queuePlay;
|
||||
if (play != null) {
|
||||
final item = r.queue.where((q) => q.id == play).firstOrNull;
|
||||
if (item != null && queuePerformer(item, r.members).id == _me) _perform(item);
|
||||
}
|
||||
_syncCountdown(r);
|
||||
_continue(r);
|
||||
_warm(r);
|
||||
}
|
||||
|
||||
/// Forgets what no longer matters: performing marks after a minute (a
|
||||
/// swap that never landed may be tried again), local picks no longer
|
||||
/// queued.
|
||||
void _prune(StateBroadcast r) {
|
||||
final now = DateTime.now();
|
||||
_performing.removeWhere((_, at) => now.difference(at) > const Duration(minutes: 1));
|
||||
final keys = {for (final q in r.queue) q.key};
|
||||
// Kept while a restarted server waits for what played: Up next comes
|
||||
// back with it.
|
||||
if (_restoreFrom == -1) {
|
||||
_local.removeWhere((k, v) => !keys.contains(k) && now.difference(v.at) > const Duration(minutes: 1));
|
||||
}
|
||||
}
|
||||
|
||||
/// The end card from the server's state: the media ended with something
|
||||
/// next; the seconds of `state.advance` move on with a 1 s timer.
|
||||
void _syncCountdown(StateBroadcast r) {
|
||||
final head = r.queue.firstOrNull;
|
||||
if (r.transport != 'finished' || head == null || r.media == null) {
|
||||
_timer?.cancel();
|
||||
_timer = null;
|
||||
if (countdown.peek() != null) countdown.value = null;
|
||||
return;
|
||||
}
|
||||
final a = r.advance != null && r.advance!.id == head.id ? r.advance : null;
|
||||
int? left;
|
||||
String? waiting;
|
||||
if (a != null) {
|
||||
left = advanceSecondsLeft(a, coord.connection.serverNow());
|
||||
if (left == 0 && queuePerformer(head, r.members).id == null) {
|
||||
waiting = r.members.where((m) => m.id == head.by).firstOrNull?.name ?? head.byName;
|
||||
}
|
||||
}
|
||||
countdown.value = UpNextCountdown(mediaId: r.mediaId, item: head, secondsLeft: left, waitingFor: waiting);
|
||||
if (a == null) {
|
||||
_timer?.cancel();
|
||||
_timer = null;
|
||||
} else {
|
||||
_timer ??= Timer.periodic(const Duration(seconds: 1), (_) {
|
||||
final now = coord.serverState.peek();
|
||||
if (now != null) _syncCountdown(now);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ restart
|
||||
|
||||
/// The server restarted without media; what played ([mediaId]) is being
|
||||
/// offered back (-1: that is over).
|
||||
void restoreFrom(int mediaId) => _restoreFrom = mediaId;
|
||||
|
||||
/// What played is back after a server restart: Up next as [before] had
|
||||
/// it comes back too. The app whose offer won ([winner]) adds the items
|
||||
/// anyone can put up (links everyone opens, Jellyfin items); every app
|
||||
/// its own links passed on and files (only it has them; [self] is its
|
||||
/// member id then). A server-fetched link's address stayed with the old
|
||||
/// server, so it is gone; the continuation comes back as usual.
|
||||
void restoreAfterRestart(StateBroadcast before, String self, {required bool winner}) {
|
||||
for (final q in before.queue) {
|
||||
if (q.auto) continue;
|
||||
final ok = switch (q.how) {
|
||||
QueueHow.direct || QueueHow.jellyfin => winner,
|
||||
QueueHow.through || QueueHow.file => self.isNotEmpty && q.by == self && _local.containsKey(q.key),
|
||||
QueueHow.server => false,
|
||||
};
|
||||
if (!ok) continue;
|
||||
final local = _local[q.key];
|
||||
if (local != null) _local[q.key] = (pick: local.pick, at: DateTime.now());
|
||||
final thumb = thumbs.bytesFor(q.thumb);
|
||||
coord.connection.sendQueue(QueueIntent.add(
|
||||
QueueItem(media: q.media, how: q.how, title: q.title, sub: q.sub, duration: q.duration, key: q.key),
|
||||
thumb: thumb == null ? '' : base64Encode(thumb)));
|
||||
}
|
||||
_restoreFrom = -1;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------- continuation
|
||||
|
||||
/// Adds what continues the media playing (`auto`, PROTOCOL.md "Queue")
|
||||
/// when this app may ([mayAddContinuation]: it put it up, or that
|
||||
/// member is not in and this is the first who is), knows what comes
|
||||
/// next, and it is not there or cancelled yet; with the setting on. A
|
||||
/// Jellyfin episode played with this app's account continues with the
|
||||
/// series' next one, a file this app put up with the next episode of its
|
||||
/// show in the library (numbers skipped on the way said in its line).
|
||||
void _continue(StateBroadcast? r) {
|
||||
if (r == null || r.media == null || !coord.settings.autoAdvance.peek()) return;
|
||||
final id = r.mediaId;
|
||||
if (_continuedFor == id || _hiddenFor.peek() == id) return;
|
||||
if (r.queue.any((q) => q.auto && q.forMedia == id)) {
|
||||
_continuedFor = id;
|
||||
return;
|
||||
}
|
||||
final swapBy = r.media?['swapBy'];
|
||||
if (!mayAddContinuation(_me, swapBy is String ? swapBy : null, r.members)) return;
|
||||
final ref = refOfStateMedia(r.media);
|
||||
final next = coord.nextEpisode.peek();
|
||||
if (ref != null) {
|
||||
if (next == null || !sameJellyfinItem(ref, coord.currentRef.peek())) return;
|
||||
_continuedFor = id;
|
||||
unawaited(_addContinuation(id, next.pick));
|
||||
return;
|
||||
}
|
||||
final own = coord.ownFile.peek();
|
||||
final finder = coord.nextFile;
|
||||
if (own == null || own.$1 != id || finder == null) return;
|
||||
_continuedFor = id;
|
||||
unawaited(() async {
|
||||
try {
|
||||
final n = await finder(own.$2);
|
||||
if (n == null) return;
|
||||
final gap = n.skipped;
|
||||
await _addContinuation(id, FileMedia(n.episode.path),
|
||||
note: gap == null ? null : 'skips ${gap.label}');
|
||||
} catch (e) {
|
||||
debugPrint('queue: what continues ${own.$2}: $e');
|
||||
}
|
||||
}());
|
||||
}
|
||||
|
||||
Future<void> _addContinuation(int mediaId, PickedMedia pick, {String? note}) async {
|
||||
try {
|
||||
final b = await _build(pick);
|
||||
if (b is! ({QueueItem item, Uint8List? thumb})) return;
|
||||
final r = coord.serverState.peek();
|
||||
if (r == null || r.mediaId != mediaId || _hiddenFor.peek() == mediaId) return;
|
||||
final sub = [b.item.sub, ?note].where((s) => s.isNotEmpty).join(' · ');
|
||||
final item = b.item.copyWith(auto: true, forMedia: mediaId, sub: sub);
|
||||
if (item.key.isNotEmpty) _local[item.key] = (pick: pick, at: DateTime.now());
|
||||
final thumb = b.thumb;
|
||||
coord.connection.sendQueue(QueueIntent.add(item, thumb: thumb == null ? '' : base64Encode(thumb)));
|
||||
} catch (e) {
|
||||
debugPrint('queue: adding what continues media $mediaId: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Puts [item] up from here (once per item while it is in flight).
|
||||
/// When it cannot play after all, it is taken off with the reason.
|
||||
void _perform(QueueItem item) {
|
||||
if (_performing.containsKey(item.id)) return;
|
||||
_performing[item.id] = DateTime.now();
|
||||
unawaited(() async {
|
||||
String? error;
|
||||
try {
|
||||
error = await _swapTo(item);
|
||||
} catch (e) {
|
||||
error = '$e';
|
||||
}
|
||||
if (error != null) {
|
||||
debugPrint('queue: ${item.id} cannot go up: $error');
|
||||
coord.connection.sendQueue(QueueIntent.remove(item.id, why: "couldn't play it ($error)"));
|
||||
}
|
||||
}());
|
||||
}
|
||||
|
||||
Future<String?> _swapTo(QueueItem item) async {
|
||||
switch (item.how) {
|
||||
case QueueHow.jellyfin:
|
||||
final ref = item.media.jellyfin;
|
||||
final pick = ref == null ? null : pickForRef(coord.jellyfin, ref);
|
||||
if (pick == null) return 'no Jellyfin account here for it';
|
||||
coord.swapJellyfin(pick, queueId: item.id);
|
||||
return null;
|
||||
case QueueHow.direct:
|
||||
final url = item.media.url;
|
||||
if (url == null) return 'no link';
|
||||
// Each viewer opens it: one computer's probe does not decide.
|
||||
return coord.swapLink(url, relay: LinkRelay.direct, anyway: true, queueId: item.id);
|
||||
case QueueHow.server:
|
||||
coord.swapQueued(HelloMedia(name: item.title, size: 0, duration: 0, relay: const MediaRelay(fetch: true)),
|
||||
item.id,
|
||||
art: thumbs.bytesFor(item.thumb));
|
||||
return null;
|
||||
case QueueHow.through:
|
||||
case QueueHow.file:
|
||||
final local = _local[item.key];
|
||||
if (local == null) return "it isn't on this computer any more";
|
||||
// Only this app can pass it on, and a member sitting out has
|
||||
// nothing open: back first.
|
||||
if (coord.selfAway.peek()) coord.joinBack();
|
||||
return coord.swapPicked(local.pick, queueId: item.id);
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ warming
|
||||
|
||||
/// How long a warm result is kept, when it is resolved again, and when a
|
||||
/// failed one is tried again.
|
||||
static const _warmKeep = Duration(minutes: 35);
|
||||
static const _warmRefresh = Duration(minutes: 30);
|
||||
static const _warmRetry = Duration(minutes: 2);
|
||||
|
||||
String _warmKey = '';
|
||||
String? _warmUrl;
|
||||
Timer? _warmTimer;
|
||||
int _warmGen = 0;
|
||||
// What the preload player should hold once the media playing has
|
||||
// started (null: nothing); [_preloadFresh]: open it again (a link's
|
||||
// refresh).
|
||||
({String url, Map<String, String>? headers})? _preloadWant;
|
||||
bool _preloadFresh = false;
|
||||
String _preloadKey = '';
|
||||
bool _preloadBusy = false;
|
||||
bool _preloadOk = true;
|
||||
int _preloadGen = 0;
|
||||
StateBroadcast? _lastState;
|
||||
|
||||
bool _ownAccount(String serverId) =>
|
||||
coord.jellyfin.account(serverId) != null && coord.jellyfin.statusOf(serverId) != JellyfinAccountStatus.expired;
|
||||
|
||||
/// What this app gets ready: the first item of the queue (Up next's,
|
||||
/// else the continuation). Every app does, each its own copy (see
|
||||
/// [_WarmTarget]); while sitting out only the probe of a link this app
|
||||
/// puts up (its swap reads it). Null: nothing.
|
||||
_WarmTarget? _warmTarget(StateBroadcast? r) {
|
||||
final t = _warmTargetHere(r);
|
||||
if (t == null || !coord.selfAway.peek()) return t;
|
||||
return t.probe == null ? null : _WarmTarget(t.id, probe: t.probe);
|
||||
}
|
||||
|
||||
_WarmTarget? _warmTargetHere(StateBroadcast? r) {
|
||||
if (r == null) return null;
|
||||
final head = r.queue.firstOrNull;
|
||||
if (head != null) {
|
||||
final performer = queuePerformer(head, r.members).id == _me;
|
||||
final local = _local[head.key]?.pick;
|
||||
// The server's warm relay of the item, once bytes can flow: what
|
||||
// everyone without their own copy preloads.
|
||||
final relay = head.media.relay;
|
||||
final warmUrl = relay != null && relay.live ? coord.media.warmRelayUrl(head, relay) : null;
|
||||
switch (head.how) {
|
||||
case QueueHow.jellyfin:
|
||||
final ref = head.media.jellyfin;
|
||||
if (ref == null) return null;
|
||||
if (_ownAccount(ref.serverId)) return _WarmTarget(head.id, ref: ref);
|
||||
// No account here: the server's login passes it on (`/j`), else
|
||||
// the warm relay a member with an account feeds.
|
||||
final open = r.relayNext ? coord.jellyfinRelayUrl(ref) : warmUrl;
|
||||
return open == null ? null : _WarmTarget(head.id, open: open);
|
||||
case QueueHow.direct:
|
||||
final url = head.media.url;
|
||||
if (url == null) return null;
|
||||
return _WarmTarget(head.id, probe: performer ? url : null, open: url);
|
||||
case QueueHow.through:
|
||||
// Only its adder has the link (and feeds the warm relay).
|
||||
if (!performer) return warmUrl == null ? null : _WarmTarget(head.id, open: warmUrl);
|
||||
final url = local is LinkMedia ? local.url : null;
|
||||
return _WarmTarget(head.id, probe: url, open: url);
|
||||
case QueueHow.file:
|
||||
if (!performer) return warmUrl == null ? null : _WarmTarget(head.id, open: warmUrl);
|
||||
// The host's own file plays from its `/m` URL once it is up.
|
||||
return _WarmTarget(head.id, open: local is FileMedia && coord.hostFile == null ? local.path : null);
|
||||
case QueueHow.server:
|
||||
// The server fetches it into its warm relay; everyone preloads that.
|
||||
if (warmUrl != null) return _WarmTarget(head.id, open: warmUrl);
|
||||
return performer ? _WarmTarget(head.id) : null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Keeps the one item that plays next ready on this app, so a skip or
|
||||
/// the end of what plays starts it at once: a link probed when this app
|
||||
/// puts it up (`AppYtDlp.probe`, kept while it stays first), a Jellyfin
|
||||
/// item resolved with this app's account
|
||||
/// ([SessionCoordinator.warmJellyfin]), and then opened in the hidden
|
||||
/// preload player ([MediaPipeline.prepare]) once the media
|
||||
/// playing has started. Done as soon as it is first, again before that
|
||||
/// runs out, dropped when it is no longer first.
|
||||
void _warm(StateBroadcast? r) {
|
||||
_feedWarm(r);
|
||||
final t = _warmTarget(r);
|
||||
final key = t?.key ?? '';
|
||||
if (key != _warmKey) {
|
||||
_dropWarm(r);
|
||||
_warmKey = key;
|
||||
if (t == null) {
|
||||
if (warm.peek() != null) warm.value = null;
|
||||
} else if (t.empty) {
|
||||
warm.value = (id: t.id, state: WarmState.ready);
|
||||
} else {
|
||||
warm.value = (id: t.id, state: WarmState.warming);
|
||||
_resolveWarm(t, fresh: false);
|
||||
}
|
||||
}
|
||||
_syncPreload(r);
|
||||
}
|
||||
|
||||
// The warm relay this app feeds (`<queue id>|<token>`), started once.
|
||||
String _fedWarm = '';
|
||||
|
||||
/// Feeds the server's warm relay of Up next's first item from here when
|
||||
/// it names this member as its source (a link passed on, a file), or a
|
||||
/// Jellyfin item this app puts up with its own account when the server
|
||||
/// asks for a source; see [RelayFeeder.feedWarm]. The server
|
||||
/// drops the relay (and with it the source) once the item is no longer
|
||||
/// first.
|
||||
void _feedWarm(StateBroadcast? r) {
|
||||
final head = r?.queue.firstOrNull;
|
||||
final relay = head?.media.relay;
|
||||
if (r == null || head == null || relay == null || relay.fetch) return;
|
||||
final key = '${head.id}|${coord.connection.token}';
|
||||
if (key == _fedWarm) return;
|
||||
switch (head.how) {
|
||||
case QueueHow.through:
|
||||
case QueueHow.file:
|
||||
if (relay.source != _me) return;
|
||||
final pick = _local[head.key]?.pick;
|
||||
if (pick is FileMedia) {
|
||||
_fedWarm = key;
|
||||
coord.media.feeder.feedWarm(head.id, RelayMedia.single(FileRelayBytes(File(pick.path))));
|
||||
} else if (pick is LinkMedia) {
|
||||
_fedWarm = key;
|
||||
unawaited(() async {
|
||||
try {
|
||||
// The probe [_resolveWarm] keeps: no second yt-dlp run.
|
||||
final probe = await coord.ytDlp.probe(pick.url, keep: _warmKeep).timeout(const Duration(seconds: 60));
|
||||
final y = await coord.ytDlp.ytDlp;
|
||||
if (_fedWarm != key) return;
|
||||
coord.media.feeder.feedWarm(head.id, LinkRelayMedia(Uri.parse(pick.url), ytDlp: y, probe: probe));
|
||||
} catch (e) {
|
||||
debugPrint('queue: feeding ${head.id}: $e');
|
||||
}
|
||||
}());
|
||||
}
|
||||
case QueueHow.jellyfin:
|
||||
final ref = head.media.jellyfin;
|
||||
if (ref == null || (relay.source.isNotEmpty && relay.source != _me)) return;
|
||||
if (queuePerformer(head, r.members).id != _me) return;
|
||||
// Resolved with this app's account by [_resolveWarm].
|
||||
final w = coord.warmJellyfin;
|
||||
if (w == null || !sameJellyfinItem(w.ref, ref)) return;
|
||||
_fedWarm = key;
|
||||
coord.media.feeder.feedWarm(
|
||||
head.id, RelayMedia.single(HttpRelayBytes(Uri.parse(w.plan.url), headers: w.plan.headers)));
|
||||
case QueueHow.direct:
|
||||
case QueueHow.server:
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
void _dropWarm(StateBroadcast? r) {
|
||||
_warmTimer?.cancel();
|
||||
_warmTimer = null;
|
||||
_warmGen++;
|
||||
_preloadWant = null;
|
||||
final url = _warmUrl;
|
||||
_warmUrl = null;
|
||||
if (url != null) coord.ytDlp.release(url);
|
||||
// Kept while it is what just went up: that open takes it.
|
||||
final w = coord.warmJellyfin;
|
||||
if (w != null && !sameJellyfinItem(w.ref, refOfStateMedia(r?.media))) coord.warmJellyfin = null;
|
||||
}
|
||||
|
||||
void _resolveWarm(_WarmTarget t, {required bool fresh}) {
|
||||
final gen = ++_warmGen;
|
||||
unawaited(() async {
|
||||
var ok = true;
|
||||
({String url, Map<String, String>? headers})? open;
|
||||
try {
|
||||
final probe = t.probe, ref = t.ref, url = t.open;
|
||||
if (probe != null) {
|
||||
_warmUrl = probe;
|
||||
final p = await coord.ytDlp.probe(probe, keep: _warmKeep, fresh: fresh).timeout(const Duration(seconds: 60));
|
||||
ok = p.problem == null;
|
||||
}
|
||||
if (ref != null) {
|
||||
final plan = await resolveJellyfinRef(coord.jellyfin, ref, relay: false, relayUrl: null);
|
||||
if (gen != _warmGen) return;
|
||||
ok = plan is OwnPlay;
|
||||
if (plan is OwnPlay) {
|
||||
coord.warmJellyfin = (ref: ref, plan: plan, until: DateTime.now().add(_warmKeep));
|
||||
open = (url: plan.url, headers: plan.headers);
|
||||
_feedWarm(coord.serverState.peek());
|
||||
}
|
||||
} else if (url != null && ok) {
|
||||
open = (url: url, headers: null);
|
||||
}
|
||||
} catch (e) {
|
||||
ok = false;
|
||||
debugPrint('queue: getting ${t.id} ready: $e');
|
||||
}
|
||||
if (gen != _warmGen) return;
|
||||
_preloadWant = ok ? open : null;
|
||||
// A refresh opens a link again (a page's stream URLs expire); the
|
||||
// same Jellyfin stream stays as it is.
|
||||
_preloadFresh = fresh && ok && t.ref == null && open != null;
|
||||
final waits = ok && open != null && coord.media.preloadEnabled;
|
||||
warm.value = (id: t.id, state: !ok ? WarmState.failed : (waits ? WarmState.warming : WarmState.ready));
|
||||
_syncPreload(coord.serverState.peek());
|
||||
_warmTimer = Timer(ok ? _warmRefresh : _warmRetry, () => _resolveWarm(t, fresh: true));
|
||||
}());
|
||||
}
|
||||
|
||||
/// Puts [_preloadWant] in the preload player, or drops what it holds;
|
||||
/// only once the media playing has started (its own open goes first,
|
||||
/// and a preload a swap is about to take is never replaced before), at
|
||||
/// once while sitting out (nothing preloads then).
|
||||
void _syncPreload(StateBroadcast? r) {
|
||||
// The newest state seen here: the coordinator's lags behind while the
|
||||
// same `state` is still being handed to it.
|
||||
final seen = _lastState;
|
||||
if (r == null || (seen != null && seen.mediaId > r.mediaId)) r = seen;
|
||||
if (r == null) return;
|
||||
final out = coord.selfAway.peek();
|
||||
if (!out && r.media != null && coord.settledMediaId.peek() != r.mediaId) return;
|
||||
final want = out ? null : _preloadWant;
|
||||
final media = coord.media;
|
||||
if (want == null) {
|
||||
_preloadKey = '';
|
||||
_preloadGen++;
|
||||
if (media.preloadUrl != null) unawaited(media.dropPrepared());
|
||||
return;
|
||||
}
|
||||
if (!media.preloadEnabled) return;
|
||||
final key = '${want.url}|${want.headers}';
|
||||
final fresh = _preloadFresh;
|
||||
// Asked for already: in flight, there, or failed (tried again with the
|
||||
// next refresh). One that went away (Reload video) is asked again.
|
||||
if (!fresh && key == _preloadKey && (_preloadBusy || !_preloadOk || media.preloadUrl == want.url)) return;
|
||||
_preloadFresh = false;
|
||||
_preloadKey = key;
|
||||
_preloadBusy = true;
|
||||
final gen = ++_preloadGen;
|
||||
final id = warm.peek()?.id;
|
||||
unawaited(coord.media.prepare(want.url, headers: want.headers, fresh: fresh).then((ok) {
|
||||
if (gen != _preloadGen) return;
|
||||
_preloadBusy = false;
|
||||
_preloadOk = ok;
|
||||
// A preload that fails leaves the usual open: nothing to say.
|
||||
if (!ok) debugPrint('queue: preload of $id failed');
|
||||
final w = warm.peek();
|
||||
if (w != null && w.id == id && w.state == WarmState.warming) {
|
||||
warm.value = (id: w.id, state: WarmState.ready);
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
// -------------------------------------------------------- continuation
|
||||
|
||||
/// The next few episodes after the one playing (own account only).
|
||||
Future<void> _loadContinuation(EpisodeLink? next, JellyfinPlan? plan) async {
|
||||
if (next == null || plan is! OwnPlay) {
|
||||
_continuationFor = '';
|
||||
if (continuation.peek().isNotEmpty) continuation.value = const [];
|
||||
return;
|
||||
}
|
||||
final key = '${plan.account.serverId}/${next.item.id}';
|
||||
if (key == _continuationFor) return;
|
||||
_continuationFor = key;
|
||||
continuation.value = [next];
|
||||
final seriesId = next.item.seriesId;
|
||||
if (seriesId == null) return;
|
||||
try {
|
||||
final list = await coord.jellyfin.cached(
|
||||
plan.account.serverId, 'episodes:$seriesId', () => plan.client.episodes(seriesId));
|
||||
if (_continuationFor != key) return;
|
||||
final more = [next];
|
||||
var at = next.item;
|
||||
while (more.length < 4) {
|
||||
final after = episodeAfter(list, at.id);
|
||||
if (after == null) break;
|
||||
more.add(EpisodeLink(after, pickFor(plan.account, after)));
|
||||
at = after;
|
||||
}
|
||||
continuation.value = more;
|
||||
} catch (e) {
|
||||
debugPrint('queue: continuation of $seriesId: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What [QueueController] gets ready for one item on this app: [probe] a
|
||||
/// link (the app that puts it up), resolve [ref] with this app's own
|
||||
/// account, and preload [open] (or the resolved stream). None of it:
|
||||
/// nothing to do here, and it is ready ([empty]).
|
||||
class _WarmTarget {
|
||||
final String id;
|
||||
final String? probe;
|
||||
final JellyfinRef? ref;
|
||||
final String? open;
|
||||
const _WarmTarget(this.id, {this.probe, this.ref, this.open});
|
||||
|
||||
bool get empty => probe == null && ref == null && open == null;
|
||||
String get key => '$id|$probe|$open|${ref?.serverId}/${ref?.itemId}/${ref?.mediaSourceId}';
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
|
||||
// Up next's words for an item. Who puts an item up is core's
|
||||
// `queuePerformer` (whats_next.dart), read the same by the server and
|
||||
// every app.
|
||||
|
||||
/// How [item] plays, in a few words for its row: "through Foxyz" (you:
|
||||
/// "through you"), "server fetches", "everyone fetches", "Jellyfin",
|
||||
/// "Foxyz's file".
|
||||
String queueHowLabel(QueueItem item, {required String selfId}) {
|
||||
final mine = item.by == selfId && selfId.isNotEmpty;
|
||||
final who = mine ? 'you' : (item.byName.isEmpty ? 'who added it' : item.byName);
|
||||
return switch (item.how) {
|
||||
QueueHow.through => 'through $who',
|
||||
QueueHow.file => mine ? 'your file' : "$who's file",
|
||||
QueueHow.server => 'server fetches',
|
||||
QueueHow.direct => 'everyone fetches',
|
||||
QueueHow.jellyfin => 'Jellyfin',
|
||||
};
|
||||
}
|
||||
|
||||
/// "1:23:45" / "23:45" for [secs]; empty when unknown.
|
||||
String queueClock(double secs) {
|
||||
if (secs <= 0) return '';
|
||||
final s = secs.round();
|
||||
final h = s ~/ 3600, m = (s % 3600) ~/ 60, r = s % 60;
|
||||
String two(int v) => v.toString().padLeft(2, '0');
|
||||
return h > 0 ? '$h:${two(m)}:${two(r)}' : '$m:${two(r)}';
|
||||
}
|
||||
@@ -0,0 +1,607 @@
|
||||
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; a title search
|
||||
// (cover + banner of a show, for the media art of files and links); and
|
||||
// the library's lookups (search with synonyms and relations, one entry by
|
||||
// id; see ARCHITECTURE.md "Media identity").
|
||||
//
|
||||
// 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// An anime as the library matches it ([AniListClient.searchAnime],
|
||||
/// [AniListClient.anime]): titles and synonyms to match on, its format
|
||||
/// and length, art, and the next TV entry of its run ([sequel]) to number
|
||||
/// across split seasons and cours. JSON round-trips for the library's
|
||||
/// cache ([toJson]).
|
||||
class AniListEntry {
|
||||
final int id;
|
||||
final String romaji;
|
||||
final String english;
|
||||
final String native;
|
||||
final List<String> synonyms;
|
||||
/// `TV`, `TV_SHORT`, `MOVIE`, `OVA`, `ONA`, `SPECIAL`, `MUSIC`; '' unknown.
|
||||
final String format;
|
||||
/// Null while it airs with no announced count.
|
||||
final int? episodes;
|
||||
final int? year;
|
||||
final String? cover;
|
||||
final String? coverSmall;
|
||||
final String? banner;
|
||||
/// The next TV / TV_SHORT / ONA entry (`SEQUEL` relation).
|
||||
final int? sequel;
|
||||
final int? prequel;
|
||||
/// Episode titles AniList knows (its streaming episodes), by number.
|
||||
final Map<int, String> episodeTitles;
|
||||
|
||||
const AniListEntry({
|
||||
required this.id,
|
||||
this.romaji = '',
|
||||
this.english = '',
|
||||
this.native = '',
|
||||
this.synonyms = const [],
|
||||
this.format = '',
|
||||
this.episodes,
|
||||
this.year,
|
||||
this.cover,
|
||||
this.coverSmall,
|
||||
this.banner,
|
||||
this.sequel,
|
||||
this.prequel,
|
||||
this.episodeTitles = const {},
|
||||
});
|
||||
|
||||
/// English title, else romaji.
|
||||
String get title => english.isNotEmpty ? english : (romaji.isNotEmpty ? romaji : native);
|
||||
|
||||
/// Every name it goes by, for matching.
|
||||
List<String> get names => [
|
||||
for (final t in [english, romaji, native, ...synonyms])
|
||||
if (t.trim().isNotEmpty) t,
|
||||
];
|
||||
|
||||
bool get isMovie => format == 'MOVIE';
|
||||
|
||||
/// A run of episodes a season or cour is made of.
|
||||
bool get isSeries => format == 'TV' || format == 'TV_SHORT' || format == 'ONA';
|
||||
|
||||
static const graphQLFields = 'id title{romaji english native} synonyms format episodes seasonYear '
|
||||
'coverImage{extraLarge large} bannerImage '
|
||||
'relations{edges{relationType node{id format type}}} streamingEpisodes{title}';
|
||||
|
||||
static final _streamingTitle = RegExp(r'^\s*(?:Episode|Ep\.?)\s*(\d{1,4})\s*[-–:]\s*(.+)$', caseSensitive: false);
|
||||
|
||||
/// From an AniList `Media` object; null when it has no id.
|
||||
static AniListEntry? fromGraphQL(Object? m) {
|
||||
if (m is! Map || m['id'] is! int) return null;
|
||||
String str(Object? v) => v is String ? v : '';
|
||||
final t = m['title'] is Map ? m['title'] as Map : const {};
|
||||
final cov = m['coverImage'] is Map ? m['coverImage'] as Map : const {};
|
||||
final large = cov['large'] is String ? cov['large'] as String : null;
|
||||
int? related(String type) {
|
||||
final edges = m['relations'] is Map ? (m['relations'] as Map)['edges'] : null;
|
||||
if (edges is! List) return null;
|
||||
for (final e in edges) {
|
||||
if (e is! Map || e['relationType'] != type) continue;
|
||||
final node = e['node'];
|
||||
if (node is Map && node['id'] is int && node['type'] != 'MANGA' &&
|
||||
const {'TV', 'TV_SHORT', 'ONA'}.contains(node['format'])) {
|
||||
return node['id'] as int;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
final titles = <int, String>{};
|
||||
final streaming = m['streamingEpisodes'];
|
||||
if (streaming is List) {
|
||||
for (final s in streaming) {
|
||||
final match = s is Map && s['title'] is String ? _streamingTitle.firstMatch(s['title'] as String) : null;
|
||||
if (match != null) titles.putIfAbsent(int.parse(match.group(1)!), () => match.group(2)!.trim());
|
||||
}
|
||||
}
|
||||
return AniListEntry(
|
||||
id: m['id'] as int,
|
||||
romaji: str(t['romaji']),
|
||||
english: str(t['english']),
|
||||
native: str(t['native']),
|
||||
synonyms: [
|
||||
if (m['synonyms'] is List)
|
||||
for (final s in m['synonyms'] as List)
|
||||
if (s is String && s.trim().isNotEmpty) s.trim(),
|
||||
],
|
||||
format: str(m['format']),
|
||||
episodes: m['episodes'] is int ? m['episodes'] as int : null,
|
||||
year: m['seasonYear'] is int ? m['seasonYear'] as int : null,
|
||||
cover: cov['extraLarge'] is String ? cov['extraLarge'] as String : large,
|
||||
coverSmall: large,
|
||||
banner: m['bannerImage'] is String ? m['bannerImage'] as String : null,
|
||||
sequel: related('SEQUEL'),
|
||||
prequel: related('PREQUEL'),
|
||||
episodeTitles: titles,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, Object?> toJson() => {
|
||||
'id': id,
|
||||
'romaji': romaji,
|
||||
'english': english,
|
||||
'native': native,
|
||||
'synonyms': synonyms,
|
||||
'format': format,
|
||||
'episodes': episodes,
|
||||
'year': year,
|
||||
'cover': cover,
|
||||
'coverSmall': coverSmall,
|
||||
'banner': banner,
|
||||
'sequel': sequel,
|
||||
'prequel': prequel,
|
||||
'episodeTitles': {for (final e in episodeTitles.entries) '${e.key}': e.value},
|
||||
};
|
||||
|
||||
static AniListEntry? fromJson(Object? j) {
|
||||
if (j is! Map || j['id'] is! int) return null;
|
||||
String str(Object? v) => v is String ? v : '';
|
||||
String? opt(Object? v) => v is String ? v : null;
|
||||
int? n(Object? v) => v is int ? v : null;
|
||||
final titles = j['episodeTitles'];
|
||||
return AniListEntry(
|
||||
id: j['id'] as int,
|
||||
romaji: str(j['romaji']),
|
||||
english: str(j['english']),
|
||||
native: str(j['native']),
|
||||
synonyms: [if (j['synonyms'] is List) ...(j['synonyms'] as List).whereType<String>()],
|
||||
format: str(j['format']),
|
||||
episodes: n(j['episodes']),
|
||||
year: n(j['year']),
|
||||
cover: opt(j['cover']),
|
||||
coverSmall: opt(j['coverSmall']),
|
||||
banner: opt(j['banner']),
|
||||
sequel: n(j['sequel']),
|
||||
prequel: n(j['prequel']),
|
||||
episodeTitles: {
|
||||
if (titles is Map)
|
||||
for (final e in titles.entries)
|
||||
if (int.tryParse('${e.key}') case final k? when e.value is String) k: e.value as String,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() => 'AniListEntry($id "$title" $format ${episodes ?? '?'} eps)';
|
||||
}
|
||||
|
||||
/// What the library asks AniList ([AniListClient]; tests fake it).
|
||||
abstract interface class AniListLookup {
|
||||
/// Anime matching [query], AniList's best first.
|
||||
Future<List<AniListEntry>> searchAnime(String query);
|
||||
|
||||
/// One anime by id; null when AniList has none.
|
||||
Future<AniListEntry?> anime(int id);
|
||||
}
|
||||
|
||||
/// "#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 implements AniListLookup {
|
||||
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 ?? 'Starsync/$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 final _listQuery = 'query(\$search:String){Page(perPage:8){media(search:\$search,type:ANIME,'
|
||||
'isAdult:false,sort:SEARCH_MATCH){${AniListEntry.graphQLFields}}}}';
|
||||
static final _byIdQuery = 'query(\$id:Int){Media(id:\$id,type:ANIME){${AniListEntry.graphQLFields}}}';
|
||||
|
||||
/// Up to 8 anime matching [query], AniList's best first (the library's
|
||||
/// matching and its "Wrong show?" search). Not cached here: the library
|
||||
/// keeps what it needs.
|
||||
@override
|
||||
Future<List<AniListEntry>> searchAnime(String query) async {
|
||||
final q = query.trim();
|
||||
if (q.isEmpty) return const [];
|
||||
final (data, _, _) = await _graphql(_listQuery, {'search': q});
|
||||
final page = data?['Page'];
|
||||
final media = page is Map ? page['media'] : null;
|
||||
return [if (media is List) for (final m in media) ?AniListEntry.fromGraphQL(m)];
|
||||
}
|
||||
|
||||
@override
|
||||
Future<AniListEntry?> anime(int id) async {
|
||||
try {
|
||||
final (data, _, _) = await _graphql(_byIdQuery, {'id': id});
|
||||
return AniListEntry.fromGraphQL(data?['Media']);
|
||||
} on AniListException catch (e) {
|
||||
if (e.statusCode == 404) return null;
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
static const byIdArtQuery = r'''
|
||||
query($id:Int){Media(id:$id,type:ANIME){id title{romaji english} coverImage{extraLarge large} bannerImage}}''';
|
||||
|
||||
/// Cover and banner of AniList entry [id] (the media art of a file the
|
||||
/// library matched); null when there is none.
|
||||
Future<AniListMedia?> mediaById(int id) async {
|
||||
try {
|
||||
final (data, _, _) = await _graphql(byIdArtQuery, {'id': id});
|
||||
return AniListMedia.fromJson(data?['Media']);
|
||||
} on AniListException catch (e) {
|
||||
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,751 @@
|
||||
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;
|
||||
/// Date-only: midnight Japan time of the broadcast day. Compare it as a
|
||||
/// Japan day (`sameAirDay` in schedule_model.dart), never a local one.
|
||||
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;
|
||||
/// Why it failed; for a missing one with rejected releases, a
|
||||
/// ready-to-show sentence ("2 release(s) found, rejected: …").
|
||||
final String? reason;
|
||||
/// Last search for a monitored, not yet downloaded episode.
|
||||
final DateTime? lastSearchedAt;
|
||||
|
||||
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,
|
||||
this.lastSearchedAt,
|
||||
});
|
||||
|
||||
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']),
|
||||
lastSearchedAt: _time(j['last_searched_at']),
|
||||
);
|
||||
}
|
||||
|
||||
/// 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 ?? 'Starsync/$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,457 @@
|
||||
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,
|
||||
/// AnimeFlow's `missing`: aired, monitored, no release grabbed yet;
|
||||
/// AnimeFlow grabs it by itself once one appears (nothing to request).
|
||||
/// Also an aired episode of a tracked series AnimeFlow has no row for.
|
||||
missing,
|
||||
failed,
|
||||
/// AnimeFlow does not monitor it: the only status worth a Request.
|
||||
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, or (missing) why the releases found were rejected.
|
||||
final String? reason;
|
||||
/// AnimeFlow's last search for it (missing).
|
||||
final DateTime? lastSearchedAt;
|
||||
/// 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;
|
||||
/// The TVDB episode number in [season]: tracked, AnimeFlow's own;
|
||||
/// untracked on Jellyfin, AniList episode + the `anilist_map` run's
|
||||
/// offset.
|
||||
final int? jellyfinEpisode;
|
||||
/// Play target once available.
|
||||
final String? jellyfinSeriesId;
|
||||
/// This user's progress on the Jellyfin episode ([confirmOnJellyfin]);
|
||||
/// null when it is not on Jellyfin or was not looked up.
|
||||
final EpisodeWatch? watch;
|
||||
/// 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.lastSearchedAt,
|
||||
this.tracked = false,
|
||||
this.seriesId,
|
||||
this.season,
|
||||
this.jellyfinEpisode,
|
||||
this.jellyfinSeriesId,
|
||||
this.watch,
|
||||
});
|
||||
|
||||
ScheduleEntry copyWith({ScheduleStatus? status, EpisodeWatch? watch}) => ScheduleEntry(
|
||||
airing: airing,
|
||||
status: status ?? this.status,
|
||||
progress: progress,
|
||||
reason: reason,
|
||||
lastSearchedAt: lastSearchedAt,
|
||||
tracked: tracked,
|
||||
seriesId: seriesId,
|
||||
season: season,
|
||||
jellyfinEpisode: jellyfinEpisode,
|
||||
jellyfinSeriesId: jellyfinSeriesId,
|
||||
watch: watch ?? this.watch,
|
||||
);
|
||||
|
||||
@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,
|
||||
);
|
||||
|
||||
/// This user's state of a Jellyfin episode (its `UserData`).
|
||||
class EpisodeWatch {
|
||||
final bool played;
|
||||
/// 0..1: the resume point of a partly watched episode (0 = none).
|
||||
final double progress;
|
||||
const EpisodeWatch({this.played = false, this.progress = 0});
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => other is EpisodeWatch && other.played == played && other.progress == progress;
|
||||
@override
|
||||
int get hashCode => Object.hash(played, progress);
|
||||
@override
|
||||
String toString() => 'EpisodeWatch(${played ? 'played' : '$progress'})';
|
||||
}
|
||||
|
||||
/// One episode Jellyfin has of a series, as the schedule needs it.
|
||||
class JellyfinEpisodeRef {
|
||||
final int season;
|
||||
final int episode;
|
||||
/// Jellyfin's date-only premiere date.
|
||||
final DateTime? premiere;
|
||||
final EpisodeWatch watch;
|
||||
const JellyfinEpisodeRef(this.season, this.episode, {this.premiere, this.watch = const EpisodeWatch()});
|
||||
}
|
||||
|
||||
// Air dates (AnimeFlow's `air_date`, Jellyfin's premiere date) are
|
||||
// date-only: the Japanese broadcast day, sent as midnight Japan time (or
|
||||
// midnight UTC by older servers). Both land on the right day read as a
|
||||
// Japan (UTC+9, no DST) calendar day, never as a local one: midnight JST
|
||||
// is the evening before in Europe and America.
|
||||
const _jst = Duration(hours: 9);
|
||||
|
||||
/// The Japan calendar day of [t].
|
||||
DateTime _jstDay(DateTime t) {
|
||||
final j = t.toUtc().add(_jst);
|
||||
return DateTime.utc(j.year, j.month, j.day);
|
||||
}
|
||||
|
||||
String _dayKey(DateTime d) => '${d.year}-${d.month}-${d.day}';
|
||||
|
||||
/// The Japan days whose air date fits a broadcast at [airingAt]: its own
|
||||
/// and, for a late-night slot (before 05:00 JST, "25:30"), the evening
|
||||
/// before, which listings often give.
|
||||
List<DateTime> _airDays(DateTime airingAt) {
|
||||
final j = airingAt.toUtc().add(_jst);
|
||||
final d = DateTime.utc(j.year, j.month, j.day);
|
||||
return [d, if (j.hour < 5) DateTime.utc(j.year, j.month, j.day - 1)];
|
||||
}
|
||||
|
||||
/// The date-only [airDate] is the broadcast day of [airingAt] (see
|
||||
/// [_airDays]).
|
||||
bool sameAirDay(DateTime airDate, DateTime airingAt) => _airDays(airingAt).contains(_jstDay(airDate));
|
||||
|
||||
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@${_dayKey(_jstDay(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}'] ??
|
||||
_airDays(a.airingAt).map((d) => byDay['$id@${_dayKey(d)}']).nonNulls.firstOrNull;
|
||||
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.status == EpisodeStatus.missing ? e.reason : null,
|
||||
lastSearchedAt: e.lastSearchedAt,
|
||||
tracked: true,
|
||||
seriesId: it.seriesId,
|
||||
season: e.season,
|
||||
jellyfinEpisode: e.episode,
|
||||
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,
|
||||
);
|
||||
}(),
|
||||
];
|
||||
}
|
||||
|
||||
/// The episodes [confirmOnJellyfin] looks up: on Jellyfin, untracked
|
||||
/// ([ScheduleStatus.inLibrary]) or available through AnimeFlow.
|
||||
bool needsJellyfinLookup(ScheduleEntry e) =>
|
||||
e.jellyfinSeriesId != null && (e.status == ScheduleStatus.inLibrary || e.status == ScheduleStatus.available);
|
||||
|
||||
/// Checks [matchSchedule]'s entries against the episodes Jellyfin has:
|
||||
/// [episodes] maps a Jellyfin series id to its episodes. An
|
||||
/// [ScheduleStatus.inLibrary] one found stays inLibrary, absent becomes
|
||||
/// [ScheduleStatus.notOnJellyfin], a series missing from [episodes] (not
|
||||
/// looked up) [ScheduleStatus.aired]. Found episodes (inLibrary or
|
||||
/// available) get this user's [ScheduleEntry.watch]: (season, episode),
|
||||
/// else the one premiered that day.
|
||||
List<ScheduleEntry> confirmOnJellyfin(List<ScheduleEntry> entries, Map<String, List<JellyfinEpisodeRef>> episodes) => [
|
||||
for (final e in entries)
|
||||
if (!needsJellyfinLookup(e))
|
||||
e
|
||||
else
|
||||
switch ((episodes[e.jellyfinSeriesId], e.status)) {
|
||||
(null, ScheduleStatus.inLibrary) => e.copyWith(status: ScheduleStatus.aired),
|
||||
(null, _) => e,
|
||||
(final have?, _) => switch (_episodeOf(have, e)) {
|
||||
final x? => e.copyWith(watch: x.watch),
|
||||
null when e.status == ScheduleStatus.inLibrary => e.copyWith(status: ScheduleStatus.notOnJellyfin),
|
||||
null => e,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
JellyfinEpisodeRef? _episodeOf(List<JellyfinEpisodeRef> have, ScheduleEntry e) {
|
||||
final byNumber = have.where((x) => x.season == e.season && x.episode == e.jellyfinEpisode).firstOrNull;
|
||||
if (byNumber != null || !e.tracked) return byNumber;
|
||||
// AnimeFlow's numbering missed (specials, renumbered seasons): the
|
||||
// episode premiered that day, when only one did.
|
||||
return have.where((x) => x.premiere != null && sameAirDay(x.premiere!, e.airing.airingAt)).singleOrNull;
|
||||
}
|
||||
|
||||
/// The episodes Jellyfin has of a series, as the linked account sees
|
||||
/// them; null when it cannot tell (no linked account for that server).
|
||||
typedef JellyfinEpisodeLookup = Future<List<JellyfinEpisodeRef>?> 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" and this user's watched state per episode
|
||||
/// ([confirmOnJellyfin]); null = never confirmed (aired entries read
|
||||
/// [ScheduleStatus.aired], nothing shows as watched).
|
||||
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 entry on Jellyfin is on
|
||||
/// ([needsJellyfinLookup]), in parallel, one request per series; 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 (needsJellyfinLookup(e)) e.jellyfinSeriesId!,
|
||||
};
|
||||
final found = <String, List<JellyfinEpisodeRef>>{};
|
||||
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,169 @@
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
|
||||
import '../../jellyfin/accounts.dart';
|
||||
import '../../jellyfin/format.dart' show pickFor, progressOf;
|
||||
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',
|
||||
};
|
||||
|
||||
/// 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 ([sameAirDay]). 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 && sameAirDay(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 episodes Jellyfin has of [jellyfinSeriesId] (numbers, premiere
|
||||
/// date, this user's watched state), seen by the account AnimeFlow's ids
|
||||
/// belong to ([jellyfinAccountFor]): the user's own; null without a
|
||||
/// linked account. One request per series, memoised a few minutes
|
||||
/// ([JellyfinAccounts.cached]; a stop report drops it,
|
||||
/// [JellyfinAccounts.watched]).
|
||||
Future<List<JellyfinEpisodeRef>?> jellyfinScheduleEpisodes(
|
||||
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)
|
||||
JellyfinEpisodeRef(
|
||||
x.parentIndexNumber!,
|
||||
x.indexNumber!,
|
||||
premiere: x.premiereDate,
|
||||
watch: EpisodeWatch(played: x.userData.played, progress: x.userData.played ? 0 : progressOf(x)),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 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 && sameAirDay(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,457 @@
|
||||
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 or this user's watch progress
|
||||
// under it, a check once watched), 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 in the server: 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';
|
||||
}
|
||||
|
||||
/// Aired, tracked, no release yet: AnimeFlow downloads it by itself.
|
||||
bool _waiting(ScheduleEntry e, DateTime now) =>
|
||||
e.status == ScheduleStatus.missing ||
|
||||
(e.status == ScheduleStatus.upcoming && e.tracked && !e.airing.airingAt.isAfter(now));
|
||||
|
||||
const waitingHint = 'Aired in Japan; downloads automatically when a release appears';
|
||||
|
||||
String _ago(DateTime t, DateTime now) {
|
||||
final m = now.difference(t).inMinutes;
|
||||
return m < 1 ? 'just now' : m < 60 ? '$m min ago' : '${m ~/ 60} h ago';
|
||||
}
|
||||
|
||||
/// Status line: text, colour, icon.
|
||||
(String, Color, IconData) statusBadge(ScheduleEntry e, DateTime now) => _waiting(e, now)
|
||||
? e.reason != null
|
||||
// Releases exist but AnimeFlow rejected them (the hint says why).
|
||||
? ('release rejected', Colors.orangeAccent, Icons.block)
|
||||
: (
|
||||
e.lastSearchedAt == null ? 'waiting for release' : 'waiting · searched ${_ago(e.lastSearchedAt!, now)}',
|
||||
Colors.amber.shade200,
|
||||
Icons.hourglass_top,
|
||||
)
|
||||
: 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 => ('waiting for release', Colors.amber.shade200, Icons.hourglass_top),
|
||||
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),
|
||||
};
|
||||
|
||||
/// What the status line means, when its text alone does not say.
|
||||
String? statusHint(ScheduleEntry e, DateTime now) => !_waiting(e, now)
|
||||
? null
|
||||
: e.reason != null
|
||||
? '${e.reason}${e.lastSearchedAt == null ? '' : ' · searched ${_ago(e.lastSearchedAt!, now)}'}'
|
||||
: waitingHint;
|
||||
|
||||
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;
|
||||
|
||||
/// Right-click ([context]: the card): the page's menu; null: none.
|
||||
final void Function(BuildContext context)? onMenu;
|
||||
|
||||
const AiringCard({
|
||||
super.key,
|
||||
required this.entry,
|
||||
required this.now,
|
||||
required this.compact,
|
||||
required this.action,
|
||||
required this.onAction,
|
||||
this.disabledHint,
|
||||
this.busy = false,
|
||||
this.onMenu,
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
static Widget _hinted(String? hint, Widget child) => hint == null ? child : Tooltip(message: hint, child: child);
|
||||
|
||||
bool get _played => entry.watch?.played ?? false;
|
||||
|
||||
/// A watched episode's cover is a little dimmer.
|
||||
Widget _dimmed(Widget cover) => _played ? Opacity(opacity: 0.6, child: cover) : cover;
|
||||
|
||||
/// This user's state of the episode on Jellyfin: the library cards'
|
||||
/// green check once watched, else a thin bar to the resume point.
|
||||
List<Widget> _watchMarks({bool small = false}) {
|
||||
final w = entry.watch;
|
||||
if (w == null) return const [];
|
||||
if (w.played) {
|
||||
final size = small ? 16.0 : 22.0;
|
||||
return [
|
||||
Positioned(
|
||||
top: small ? 2 : 4,
|
||||
right: small ? 2 : 4,
|
||||
child: Tooltip(
|
||||
message: 'Watched',
|
||||
child: Container(
|
||||
key: ValueKey('watched-$_id'),
|
||||
constraints: BoxConstraints(minWidth: size, minHeight: size),
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(color: const Color(0xCC1B5E20), borderRadius: BorderRadius.circular(size / 2)),
|
||||
child: Icon(Icons.check, size: small ? 11 : 14, color: Colors.white),
|
||||
),
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
if (w.progress <= 0) return const [];
|
||||
return [
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: Tooltip(
|
||||
message: '${(w.progress * 100).round()}% watched',
|
||||
child: LinearProgressIndicator(
|
||||
key: ValueKey('watch-progress-$_id'),
|
||||
value: w.progress,
|
||||
minHeight: small ? 2 : 3,
|
||||
backgroundColor: Colors.black54,
|
||||
),
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
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 hint = statusHint(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;
|
||||
final onMenu = this.onMenu;
|
||||
final menu = onMenu == null || busy ? null : () => onMenu(context);
|
||||
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,
|
||||
onSecondaryTap: menu,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(6),
|
||||
child: Row(
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: SizedBox(
|
||||
width: 34,
|
||||
height: 48,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [_dimmed(_image(a.cover, tint)), ..._watchMarks(small: true)],
|
||||
),
|
||||
),
|
||||
),
|
||||
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: hint == null ? label : '$label: $hint',
|
||||
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,
|
||||
onSecondaryTap: menu,
|
||||
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: [
|
||||
_dimmed(_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),
|
||||
),
|
||||
),
|
||||
),
|
||||
..._watchMarks(),
|
||||
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(),
|
||||
_hinted(
|
||||
hint,
|
||||
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,606 @@
|
||||
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 '../../widgets/app_menu.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; right-click an
|
||||
// available one to queue it). Refreshes every minute while visible; ETags
|
||||
// keep that cheap. Episodes on Jellyfin show this user's watched state,
|
||||
// reloaded after a playback stops (JellyfinAccounts.watched).
|
||||
|
||||
enum ScheduleFilter { jellyfin, all }
|
||||
|
||||
class SchedulePage extends StatefulWidget {
|
||||
/// Nobody else is in the server (D9): available episodes say Play.
|
||||
final bool alone;
|
||||
|
||||
/// Puts a Jellyfin episode up in the server; returns an error to show.
|
||||
final Future<String?> Function(JellyfinMedia pick) onPutUp;
|
||||
|
||||
/// Adds an episode to Up next; null: no server, the cards have no menu.
|
||||
final AddToQueue? onAddToQueue;
|
||||
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.alone = false,
|
||||
required this.onPutUp,
|
||||
this.onAddToQueue,
|
||||
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;
|
||||
StreamSubscription<String>? _watchedSub;
|
||||
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();
|
||||
});
|
||||
// Back from watching: the cards' watched marks follow (the stop report
|
||||
// already dropped the cached episode lists).
|
||||
_watchedSub = _accounts.watched.listen((_) {
|
||||
if (mounted) _load();
|
||||
});
|
||||
}
|
||||
|
||||
ScheduleRepository _ownRepo() {
|
||||
final flow = AnimeFlowClient.fromAccounts(_accounts);
|
||||
return ScheduleRepository(
|
||||
aniList: AniListClient(),
|
||||
animeFlow: flow,
|
||||
jellyfinEpisodes: (id) => jellyfinScheduleEpisodes(_accounts, flow, id),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_timer?.cancel();
|
||||
_watchedSub?.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.alone ? 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) {
|
||||
// Only an unmonitored episode is worth asking for: AnimeFlow grabs
|
||||
// a missing one (no release yet) by itself once a release appears,
|
||||
// and retries a failed one.
|
||||
kind = e.status == ScheduleStatus.unmonitored ? 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));
|
||||
}
|
||||
}
|
||||
|
||||
/// Right-click on an available episode ([context]: its card): Put it
|
||||
/// up, Add to queue, Play next.
|
||||
Future<void> _menu(BuildContext context, ScheduleEntry e) async {
|
||||
final add = widget.onAddToQueue;
|
||||
if (add == null) return;
|
||||
final pick = await showAppMenu<int>(context, items: [
|
||||
AppMenuItem(value: 0, label: widget.alone ? 'Play' : 'Put it up'),
|
||||
const AppMenuItem(value: 1, label: 'Add to queue'),
|
||||
const AppMenuItem(value: 2, label: 'Play next'),
|
||||
]);
|
||||
if (pick == null || !mounted) return;
|
||||
if (pick == 0) return _putUp(e);
|
||||
final key = 'play:${_key(e)}';
|
||||
if (_busy.contains(key)) return;
|
||||
setState(() => _busy.add(key));
|
||||
try {
|
||||
final media = await resolveJellyfinEpisode(_accounts, _flow, e);
|
||||
final err = await add(media, next: pick == 2);
|
||||
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),
|
||||
onMenu: widget.onAddToQueue != null && (action == CardAction.putUp || action == CardAction.play) && hint == null
|
||||
? (context) => _menu(context, e)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
@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(),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -1,56 +1,38 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:signals/signals_flutter.dart';
|
||||
|
||||
import '../../platform/app_log.dart';
|
||||
import '../../shell/mark_problem.dart';
|
||||
import '../../app/app_graph.dart';
|
||||
import '../../state/settings.dart';
|
||||
import '../../update/update_controller.dart';
|
||||
import '../../update/updater.dart';
|
||||
import '../../widgets/app_menu.dart';
|
||||
import '../../widgets/copy_open.dart';
|
||||
import 'settings_page.dart';
|
||||
import 'ui.dart';
|
||||
import 'update_indicator.dart';
|
||||
|
||||
/// Opens [url] in the default browser (no url_launcher dependency).
|
||||
Future<bool> openInBrowser(String url) async {
|
||||
try {
|
||||
final ProcessResult r;
|
||||
if (Platform.isWindows) {
|
||||
r = await Process.run('rundll32', ['url.dll,FileProtocolHandler', url]);
|
||||
} else if (Platform.isMacOS) {
|
||||
r = await Process.run('open', [url]);
|
||||
} else {
|
||||
r = await Process.run('xdg-open', [url]);
|
||||
}
|
||||
return r.exitCode == 0;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
/// The Minimal player skin's credit (README says the same).
|
||||
const uoscCredit = 'Player timeline and controls inspired by uosc by tomasklaen, LGPL-2.1 '
|
||||
'(github.com/tomasklaen/uosc).';
|
||||
|
||||
class AboutPage extends StatelessWidget {
|
||||
/// Name, version, update state, the source link and the logs (Settings →
|
||||
/// About).
|
||||
class AboutPanel extends StatelessWidget {
|
||||
final UpdateController? updates;
|
||||
const AboutPage({super.key, this.updates});
|
||||
|
||||
Future<void> _open(BuildContext context) async {
|
||||
final messenger = ScaffoldMessenger.maybeOf(context);
|
||||
if (await openInBrowser(repoUrl)) return;
|
||||
await Clipboard.setData(const ClipboardData(text: repoUrl));
|
||||
messenger?.showSnackBar(
|
||||
const SnackBar(content: Text('Could not open a browser — link copied')));
|
||||
}
|
||||
const AboutPanel({super.key, this.updates});
|
||||
|
||||
@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: [
|
||||
Text(appName, style: t.titleLarge),
|
||||
const SizedBox(height: 4),
|
||||
SelectableText('Version $appVersion',
|
||||
SelectableText('Version ${displayVersion()}',
|
||||
style: t.bodyMedium?.copyWith(color: Colors.grey)),
|
||||
const SizedBox(height: 4),
|
||||
UpdateStatusLine(controller: updates),
|
||||
@@ -59,22 +41,90 @@ class AboutPage extends StatelessWidget {
|
||||
children: [
|
||||
Expanded(child: SelectableText(repoUrl, style: t.bodySmall)),
|
||||
IconButton(
|
||||
tooltip: 'Copy link',
|
||||
tooltip: ReuseText.copyLink,
|
||||
icon: const Icon(Icons.copy, size: 18),
|
||||
onPressed: () =>
|
||||
Clipboard.setData(const ClipboardData(text: repoUrl)),
|
||||
onPressed: () => copyToClipboard(context, repoUrl, done: 'Link copied'),
|
||||
),
|
||||
FilledButton.tonalIcon(
|
||||
onPressed: () => _open(context),
|
||||
onPressed: () => openLink(context, repoUrl),
|
||||
icon: const Icon(Icons.open_in_new, size: 18),
|
||||
label: const Text('Source'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(uoscCredit, key: const ValueKey('uosc-credit'), style: t.bodySmall?.copyWith(color: Colors.grey)),
|
||||
const SizedBox(height: kGap),
|
||||
Text('Something went wrong? Copy diagnostics and send them to whoever helps you: '
|
||||
'versions and the last hours of the log, passwords, tokens and cookies left out. '
|
||||
'In a session it can add the server\'s session log, everyone\'s lines merged. '
|
||||
'Mark a problem (Ctrl+Shift+B) when it happens so the spot is easy to find.',
|
||||
style: t.bodySmall?.copyWith(color: Colors.grey)),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
Builder(
|
||||
builder: (context) => FilledButton.tonalIcon(
|
||||
key: const ValueKey('copy-diagnostics'),
|
||||
onPressed: () => _copyDiagnostics(context),
|
||||
icon: const Icon(Icons.bug_report_outlined, size: 18),
|
||||
label: const Text('Copy diagnostics'),
|
||||
),
|
||||
),
|
||||
const MarkProblemButton(outlined: true),
|
||||
OutlinedButton.icon(
|
||||
onPressed: appLogDir == null ? null : () => _openLogs(context),
|
||||
icon: const Icon(Icons.folder_open, size: 18),
|
||||
label: const Text('Open logs folder'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
SignalBuilder(
|
||||
builder: (context) => SwitchListTile(
|
||||
key: const ValueKey('share-diagnostics'),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: const Text('Share diagnostics with the server'),
|
||||
subtitle: const Text('In a session, this app\'s log goes to the server\'s session log, '
|
||||
'merged with everyone\'s, so a problem can be read afterwards. Never chat; '
|
||||
'passwords and tokens left out. Off: only a problem you mark is sent.'),
|
||||
value: AppSettings.instance.shareDiagnostics.value,
|
||||
onChanged: AppSettings.instance.setShareDiagnostics,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
);
|
||||
}
|
||||
|
||||
/// In a session it asks whether to add the server's session log.
|
||||
static Future<void> _copyDiagnostics(BuildContext context) async {
|
||||
var withServer = false;
|
||||
final graph = AppScope.of(context);
|
||||
if (graph.navigation.online.peek()) {
|
||||
final pick = await showAppMenu<bool>(context, items: const [
|
||||
AppMenuItem(value: false, label: "This app's log"),
|
||||
AppMenuItem(value: true, label: "This app's and the server's session log"),
|
||||
]);
|
||||
if (pick == null) return;
|
||||
withServer = pick;
|
||||
}
|
||||
final text = await diagnosticsText(graph, sessionLog: withServer);
|
||||
final saved = await saveDiagnostics(text);
|
||||
if (!context.mounted) return;
|
||||
await copyToClipboard(context, text,
|
||||
done: saved == null ? 'Diagnostics copied' : 'Diagnostics copied (also saved in the logs folder)');
|
||||
}
|
||||
|
||||
static Future<void> _openLogs(BuildContext context) async {
|
||||
final dir = appLogDir;
|
||||
if (dir == null) return;
|
||||
final folders = AppScope.of(context).ports.folders;
|
||||
if (!await folders.reveal(dir)) {
|
||||
if (!context.mounted) return;
|
||||
await copyToClipboard(context, dir, done: "Couldn't open it — folder path copied");
|
||||
}
|
||||
}
|
||||
}
|
||||