Compare commits
53
Commits
ea607c1a52
..
v1.4.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f1ac4c3e64 | ||
|
|
96bb7e90f4 | ||
|
|
6e14061a8a | ||
|
|
cf61453e28 | ||
|
|
5116923858 | ||
|
|
ad53b6822a | ||
|
|
4c8ddeb034 | ||
|
|
f2721d125c | ||
|
|
69fae18fdc | ||
|
|
6048fbe7d1 | ||
|
|
d48915dc47 | ||
|
|
5bc252bf02 | ||
|
|
638a30e16c | ||
|
|
ec0849eb01 | ||
|
|
7715dc479a | ||
|
|
e91539d113 | ||
|
|
5e3834e6ba | ||
|
|
81b9d05de6 | ||
|
|
f9b79aa47c | ||
|
|
e16cf50f2c | ||
|
|
8aa1186d29 | ||
|
|
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 |
No files matched your search
+12
-3
@@ -35,13 +35,14 @@ jobs:
|
|||||||
- run: dart test
|
- run: dart test
|
||||||
working-directory: packages/server
|
working-directory: packages/server
|
||||||
# Compiling the dedicated server proves it stays Flutter-free.
|
# Compiling the dedicated server proves it stays Flutter-free.
|
||||||
- run: mkdir -p build && 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
|
working-directory: packages/server
|
||||||
- run: build/watch-together-server --help
|
- run: build/starsync-server --help
|
||||||
working-directory: packages/server
|
working-directory: packages/server
|
||||||
|
|
||||||
# The updater's Windows apply path (running exe parked as .old) only
|
# The updater's Windows apply path (running exe parked as .old) only
|
||||||
# means something on real NTFS. Runner notes: see release.yml.
|
# means something on real NTFS, and the close smoke run needs the real
|
||||||
|
# build. Runner notes: see release.yml.
|
||||||
updater-windows:
|
updater-windows:
|
||||||
# Not for a "Release v…" commit: release.yml builds it on the same
|
# Not for a "Release v…" commit: release.yml builds it on the same
|
||||||
# (only) Windows runner, which this job would hold up.
|
# (only) Windows runner, which this job would hold up.
|
||||||
@@ -71,3 +72,11 @@ jobs:
|
|||||||
- name: Windows build
|
- name: Windows build
|
||||||
shell: powershell
|
shell: powershell
|
||||||
run: powershell -NoProfile -ExecutionPolicy Bypass -File tool/package_windows.ps1
|
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
|
||||||
@@ -38,7 +38,7 @@ jobs:
|
|||||||
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
VER="${GITHUB_REF_NAME#v}"
|
VER="${GITHUB_REF_NAME#v}"
|
||||||
dart tool/release_publish.dart "$GITHUB_REF_NAME" "dist/watch-together-$VER-linux-x64.tar.gz"
|
dart tool/release_publish.dart "$GITHUB_REF_NAME" "dist/starsync-$VER-linux-x64.tar.gz"
|
||||||
|
|
||||||
windows:
|
windows:
|
||||||
# yuuka-windows: host-mode runner (no containers), runs as SYSTEM, has
|
# yuuka-windows: host-mode runner (no containers), runs as SYSTEM, has
|
||||||
@@ -73,5 +73,5 @@ jobs:
|
|||||||
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
$Ver = $env:GITHUB_REF_NAME.TrimStart('v')
|
$Ver = $env:GITHUB_REF_NAME.TrimStart('v')
|
||||||
dart tool/release_publish.dart $env:GITHUB_REF_NAME "dist/watch-together-$Ver-windows-x64.zip"
|
dart tool/release_publish.dart $env:GITHUB_REF_NAME "dist/starsync-$Ver-windows-x64.zip"
|
||||||
if ($LASTEXITCODE) { exit 1 }
|
if ($LASTEXITCODE) { exit 1 }
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
What changed in each Starsync version, newest first, in plain words. The
|
||||||
|
app shows a version's section when it offers the update and once after it
|
||||||
|
(`What's new`); the release page shows it too. How entries are added and
|
||||||
|
cut into a release: [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md#changelog).
|
||||||
|
|
||||||
|
## Unreleased
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
## 1.4.0
|
||||||
|
|
||||||
|
Links you put up are downloaded once for everyone, quick chat and danmaku in the player, plus What's new and fixes for Windows, the relay and chat.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- What's new: the update band shows what changed, and after an update the app shows the full list once (also in Settings → About)
|
||||||
|
- Update channel in Settings → Updates: Stable, or Dev for test builds; Dev stays on test builds after a release and still gets the newer release when there is one
|
||||||
|
- Sharper subtitles and picture: the video and its subtitles are drawn at your screen's resolution instead of the video's (Settings → Player → Sharp video and subtitles, on by default)
|
||||||
|
- Invite links: Invite copies just a starsync:// link, password included, so a friend just clicks it (or copies it and switches to Starsync) and the app asks "Join Bob's server?"
|
||||||
|
- Invite finds your internet address on its own and says when your router needs the port forwarded; Other addresses picks your home-network or VPN address instead
|
||||||
|
- Starsync runs once: a link clicked while it is open goes to the open window
|
||||||
|
- Quick chat: press Enter in the big player to type a message in a small box at the bottom, without opening the chat panel and without the mouse pointer or the controls popping up; it shares its draft with the chat panel, closes by itself when you stop typing, and subtitles move up out of its way
|
||||||
|
- Danmaku notifications: chat, joins, leaves and Up next can fly across the top of the picture instead of popping up (Settings → Notifications & sounds → Notifications: Danmaku, off by default), with speed, how many at once, where, opacity, size, boldness, outline and avatars all adjustable and a live preview
|
||||||
|
- Player panel: ⚙ → Player settings opens a panel at the right of the player that stays open while you watch, with tracks, subtitle size, picture, volume and notification settings
|
||||||
|
- Subtitles can move up above the controls while they show (Settings → Player → Subtitles above the controls, off by default), and their size can be changed
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- A link you put up is downloaded once: you and everyone else watch the same stream, so nobody drifts ahead or stalls alone, and sites like Bilibili are far less likely to block it
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Upscaling shaders (Anime4K, the HQ modes) no longer shift the colours or draw a grid over the picture, which showed most in a large window or fullscreen
|
||||||
|
- Sharp video: resizing the window or opening the chat panel no longer shrinks the picture for a moment before it jumps back
|
||||||
|
- Joining while something plays on Windows: the video opens again by itself when it was too early
|
||||||
|
- Relay: players no longer get stuck after a slow or stalled source
|
||||||
|
- Relay: X (Twitter) clips play through the relay
|
||||||
|
- Relay: gentler retries, so Bilibili stops blocking the server
|
||||||
|
- Relay: a source that refuses the old link (412) gets a fresh address and keeps playing
|
||||||
|
- Chat pop-ups stay away while the chat panel is open (the sound still plays)
|
||||||
|
- Bilibili and other sites no longer stop a video halfway when you watch alone or with "Everyone fetches it": a cut connection picks up where it left off
|
||||||
|
|
||||||
|
## 1.3.2
|
||||||
|
|
||||||
|
Fixes Windows closing by itself about 3 seconds after every start.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Windows: the app no longer closes by itself about 3 seconds after starting (1.3.0 and 1.3.1)
|
||||||
|
|
||||||
|
## 1.3.1
|
||||||
|
|
||||||
|
Joining another server no longer opens an empty player, a cleaner mini player and a softer Minimal progress line.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- A cleaner mini player: just the picture, play and a plain progress bar
|
||||||
|
- Minimal controls: the progress line shown while the controls hide is softer and easier on the eyes
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Joining another server no longer opens an empty player
|
||||||
|
|
||||||
|
## 1.3.0
|
||||||
|
|
||||||
|
The app is now called Starsync and was rebuilt inside for stability: a shared countdown to what plays next, honest watched marks, faster relay seeks and many fixes. Everyone must update to keep watching together.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- A shared countdown to what plays next, and anyone can cancel it
|
||||||
|
- The next episode of your local files continues by itself ("Continues with")
|
||||||
|
- System notifications while the window is in the background
|
||||||
|
- Your own server is always running: Invite your friends straight in
|
||||||
|
- See the server's version and everyone's app version
|
||||||
|
- The library matches your shows with AniList
|
||||||
|
- A new sound engine and new sounds
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- The app is now called Starsync (your settings and data come along)
|
||||||
|
- "Watched" counts only the time you actually watched (Jellyfin too)
|
||||||
|
- Joining no longer pauses everyone
|
||||||
|
- A dropped connection comes back as the same member, not a new one
|
||||||
|
- A server restart no longer loses what was playing
|
||||||
|
- Faster seeks over the relay
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- The app no longer freezes on exit
|
||||||
|
- Shaders on Windows
|
||||||
|
- X11: video renders properly again (also with software rendering)
|
||||||
|
- Black video when joining mid-play on X11
|
||||||
|
- Many stability fixes from the rebuild
|
||||||
@@ -11,9 +11,9 @@ everyone's play, pause and seek stay together.
|
|||||||
Why not Jellyfin SyncPlay: it runs in the browser and its sync is poor.
|
Why not Jellyfin SyncPlay: it runs in the browser and its sync is poor.
|
||||||
This is a desktop app that drives mpv.
|
This is a desktop app that drives mpv.
|
||||||
|
|
||||||
Name: `watch-together` is a placeholder and the final name is undecided
|
Up to 1.2.x the app was called `watch_together` (its files
|
||||||
(`sync-relay` is the working name). It appears in file names and the
|
`watch-together-*`); the repository and the update manifest keep their old
|
||||||
update manifest until that is settled.
|
addresses for now.
|
||||||
|
|
||||||
- Desktop only (Linux and Windows), always mpv. No web clients.
|
- Desktop only (Linux and Windows), always mpv. No web clients.
|
||||||
- No transcoding. Everyone plays the original file.
|
- No transcoding. Everyone plays the original file.
|
||||||
@@ -25,21 +25,35 @@ How it fits together: [docs/HOW-IT-WORKS.md](docs/HOW-IT-WORKS.md).
|
|||||||
|
|
||||||
From the Releases page of this repo:
|
From the Releases page of this repo:
|
||||||
|
|
||||||
- Linux: `watch-together-<version>-linux-x64.tar.gz`. Extract, run
|
- Linux: `starsync-<version>-linux-x64.tar.gz`. Extract, run
|
||||||
`watch_together`. On start it adds itself to your app launcher (and its
|
`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`).
|
icon), so the window shows its own icon on Wayland. Needs mpv's library installed (`mpv` or `libmpv2`).
|
||||||
- Windows: `watch-together-<version>-windows-x64.zip`. Extract, run
|
- Windows: `starsync-<version>-windows-x64.zip`. Extract, run
|
||||||
`watch_together.exe`. mpv is bundled. New and not yet tried on a real
|
`starsync.exe`. mpv is bundled. New and not yet tried on a real
|
||||||
Windows desktop; reports welcome.
|
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
|
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
|
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,
|
(never over a playing video), with its first changes. "What's new" lists
|
||||||
verifies, applies in place and restarts; "Later" hides it until the next
|
them all ([CHANGELOG.md](CHANGELOG.md)); the first start after an update
|
||||||
check; the install folder must be writable by you. The version you run is
|
shows that list once, and Settings → About has it again. "Update and
|
||||||
under the Settings tab. Settings has Check now and an auto-check toggle. From a terminal:
|
restart" downloads, verifies, applies in place and restarts; "Later" hides
|
||||||
`./watch-together-server --upgrade` (no display needed, so it works on a
|
it until the next check; the install folder must be writable by you. The version you run is
|
||||||
server box too), `watch_together --upgrade`, or the shipped `update.sh` /
|
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
|
`update.bat` (which run the first). Each updates the whole folder, app and
|
||||||
server; restart whichever is running to use the new version. A
|
server; restart whichever is running to use the new version. A
|
||||||
release with a newer protocol is a required update: everyone on a server
|
release with a newer protocol is a required update: everyone on a server
|
||||||
@@ -48,33 +62,38 @@ telling you to update.
|
|||||||
|
|
||||||
## Getting started
|
## Getting started
|
||||||
|
|
||||||
Three ways in, all at the top of the Library when you are not in a server.
|
The app always runs its own server, quietly: your server. When it starts
|
||||||
None of them needs Jellyfin or an account.
|
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.
|
||||||
|
|
||||||
- **Join a friend** (most people): your friend sends you an address and,
|
- **Join a friend** (most people): your friend sends you an invite (a
|
||||||
maybe, a password. See [Join](#join).
|
`starsync://` link) and, maybe, a password. Click the link, or just
|
||||||
- **Host**: you have the video and friends watch it with you. See
|
copy it: the app asks "Join <their server>?". See [Join](#join).
|
||||||
[Host from the app](#host-from-the-app).
|
- **Invite friends**: you have the video and friends watch it with you.
|
||||||
- **Watch solo**: just play something from the Library. It plays for you
|
See [Your server and Invite](#your-server-and-invite).
|
||||||
alone, with everything a server has (Jellyfin progress, next episode,
|
- **Watch on your own**: just play something from the Library, with
|
||||||
skip buttons); nothing is reachable from outside this computer. Invite
|
everything a server has (Jellyfin progress, next episode, skip buttons).
|
||||||
friends in the strip turns it into a hosted server (the Host card's port
|
Nobody else can reach your server until you invite them; inviting never
|
||||||
and password) and the video carries on from where it was; Stop ends it.
|
interrupts what plays.
|
||||||
|
|
||||||
## The window
|
## The window
|
||||||
|
|
||||||
One window. Along the top, the strip: your avatar (click it for your
|
One window. Along the top, the strip: your avatar (click it for your
|
||||||
profile: name, picture with a crop editor, colour, and links to the
|
profile: name, picture with a crop editor, colour, and links to the
|
||||||
settings), the Library / Schedule / Settings tabs, and the party. Offline
|
settings), the Library / Schedule / Settings tabs, and the party. Alone
|
||||||
the party side has Join a friend and Host; watching solo it says Solo,
|
on your server it has just Join a friend and Invite (and "Sharing" while
|
||||||
with Invite friends and Stop; in a server it shows everyone's avatar
|
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,
|
with a ring for their state (green ready, amber buffering, grey not ready,
|
||||||
dimmed reconnecting, dimmed with a moon sitting out; a crown on whoever
|
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
|
hosts, a tower on whoever passes the video on). Click the avatars for the
|
||||||
member list (force ready, Nudge, Sit out, "Copy invite address"; when you host it lists this computer's
|
member list (each member's app version, amber when it differs from yours,
|
||||||
network addresses with the port, and friends outside your network need
|
and the server's at the top; force ready, Nudge, Sit out; on your
|
||||||
that port forwarded or a server they can all reach, like a VPS). Beside them the server and "ready n/m", the chat button with unread
|
server where friends can reach it, Copy invite link and Stop sharing,
|
||||||
count, and Leave. Empty strip space drags the window.
|
on a friend's "Copy invite link"). 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
|
The player fills the window under the strip. Pick a tab and it shrinks to
|
||||||
a mini player in a corner: drag it anywhere and it snaps to the nearest
|
a mini player in a corner: drag it anywhere and it snaps to the nearest
|
||||||
@@ -93,10 +112,43 @@ loading…") or what went wrong, with Retry or Cancel when it helps. Chat and ac
|
|||||||
Tab), ready to type; it shows messages (select text to copy it, 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
|
right-click for Copy message and, with a link in it, Copy link / Open in
|
||||||
browser), and its "Activity" toggle adds
|
browser), and its "Activity" toggle adds
|
||||||
joins, ready, seeks and the like between them. Joins, leaves, chat and
|
joins, ready, seeks and the like between them. Joins, leaves, someone
|
||||||
other news also pop up bottom left with the person's avatar; click a message to open the chat
|
losing connection and coming back, video changes, additions to Up next
|
||||||
(Settings → Notifications → Chat messages: All by default, or Only when
|
(the title, its picture and who added it), chat and problems also pop up
|
||||||
paused, or Off; messages always reach the chat, with their sound). Unread chat shows as a count on the chat
|
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.
|
||||||
|
|
||||||
|
**Quick chat.** Enter in the big player opens a small chat box at the
|
||||||
|
bottom centre without opening the chat panel, also while the video
|
||||||
|
plays: type (the player's keys wait meanwhile), Enter sends, Esc or an
|
||||||
|
empty Enter closes it, and it closes by itself after a few seconds
|
||||||
|
without typing. It is the same draft as the chat panel's field, so a
|
||||||
|
message started in one is there in the other. What you sent shows for a
|
||||||
|
moment where the box was (or flies as your own line with danmaku, below);
|
||||||
|
bottom subtitles move up out of its way and back, top signs stay. The
|
||||||
|
mouse pointer and the controls stay hidden: typing is not moving the
|
||||||
|
mouse.
|
||||||
|
|
||||||
|
**Danmaku.** Settings → Notifications & sounds → Over the player →
|
||||||
|
Notifications: Toasts (default) or Danmaku. With Danmaku, over the big
|
||||||
|
player chat, joins, leaves, Up next and "starting now" fly right to left
|
||||||
|
across the top of the picture instead of popping up (problems and errors
|
||||||
|
still pop up, and everything does while the player is small). The look
|
||||||
|
is yours to tune there, with a live preview: crossing time, how many at
|
||||||
|
once, which band of the picture they use, opacity, size, boldness,
|
||||||
|
outline, avatars, and a frame round your own lines.
|
||||||
|
|
||||||
|
**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,
|
buttons and, in fullscreen with the controls hidden and the video paused,
|
||||||
as a small chip top right.
|
as a small chip top right.
|
||||||
|
|
||||||
@@ -108,26 +160,61 @@ 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
|
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
|
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)
|
then shows the cover, title and where the others are; Join back (or Space)
|
||||||
loads the video there and readies you. Settings → Party & network → "When idle,
|
loads the video there and readies you, in the background while the others
|
||||||
sit out instead of just unready" does it after 10 idle minutes.
|
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:
|
**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
|
only they hear it (a sound, a pop-up, and the taskbar entry asks for
|
||||||
attention when the window is in the background).
|
attention when the window is in the background).
|
||||||
|
|
||||||
## Host from the app
|
## Your server and Invite
|
||||||
|
|
||||||
Host in the strip: set a port and an optional password (blank = anyone can
|
Your server starts with the app and stops when it closes. Only this app
|
||||||
join), then Host. The app starts a server inside itself and joins it; you
|
can reach it until you press **Invite** (in the strip, or in the member
|
||||||
are the host (the crown). Nothing is up yet: pick what to
|
list): that opens it on your network at the port from Settings → Party &
|
||||||
watch in the Library tab (below) and put it up. Leaving or
|
network (default 8080, with the password set there; blank = anyone can
|
||||||
closing the app stops the server. Friends need to reach your machine: open
|
join) and copies an invite link (`starsync://203.0.113.7:8080/?name=…`,
|
||||||
the TCP port on your router (or use a LAN or VPN address). Friends open
|
just the link) to send to friends. Nothing restarts: what plays and Up next carry on, and you are
|
||||||
nothing. Ports you hosted on before are one click away in the Host card.
|
the host (the crown).
|
||||||
|
|
||||||
If you put up a local file, the server streams it to everyone from your
|
The link carries your internet address when the app can find it (it asks
|
||||||
|
a "what is my IP" service such as ipify over HTTPS when you share), else
|
||||||
|
this computer's home-network address (not a proxy one, nor a Docker or VM
|
||||||
|
bridge). In the member list, **Copy invite link** copies it again;
|
||||||
|
**Other addresses** there lets you pick another: **Internet** for friends
|
||||||
|
elsewhere, **Home network** for friends on the same Wi-Fi or router,
|
||||||
|
**VPN** (Tailscale and the like). Behind a router, friends elsewhere get
|
||||||
|
in only when the TCP port is forwarded on your router to this computer
|
||||||
|
(the card says so); the app does not open ports itself. The link
|
||||||
|
includes the password when one is set, so friends just click it: share
|
||||||
|
it only with them. 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.
|
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
|
## Library
|
||||||
|
|
||||||
What to watch, from three sources (the switch at the top). Drop a video,
|
What to watch, from three sources (the switch at the top). Drop a video,
|
||||||
@@ -161,15 +248,30 @@ Up next, add the folder to Files, or fill Link).
|
|||||||
open here (needs cookies, 403, 404, not a video) says why and goes up
|
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
|
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.
|
named ("Bob can't play this: Not allowed (403)") and nobody waits for them.
|
||||||
- **Files**: video folders you add on this computer, grouped by series
|
- **Files**: your own library from the video folders you add on this
|
||||||
(click a series to list its episodes), or any file through Browse. The others stream it from you.
|
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. Not in a server, the buttons say Play
|
Put it up and the player opens. With nobody else in the server, the
|
||||||
and it plays for you alone ([Watch solo](#getting-started)).
|
buttons say Play and a link plays straight from the site.
|
||||||
|
|
||||||
### Up next (the queue)
|
### Up next (the queue)
|
||||||
|
|
||||||
In a server (solo too), anything can wait its turn instead of going up
|
Anything can wait its turn instead of going up
|
||||||
now: **Add to queue** on the Link page (in the way you picked), or
|
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
|
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
|
or an episode on the Schedule for **Add to queue** / **Play next**; the
|
||||||
@@ -185,7 +287,7 @@ app open for that. Everyone shares one Up next and anyone can change it; what
|
|||||||
you added stays when you leave.
|
you added stays when you leave.
|
||||||
|
|
||||||
In the player, Up next sits in the top bar, right of the title: the
|
In the player, Up next sits in the top bar, right of the title: the
|
||||||
next item's picture and title (or the next episode's). Rest the mouse on
|
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
|
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
|
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
|
row to play it now (▶) or remove it (×), right-click it for more (copy its
|
||||||
@@ -193,17 +295,22 @@ 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,
|
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
|
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
|
**+ 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 (Cancel stops it on your screen)
|
what is up next with a 10 s countdown everyone sees, and it plays;
|
||||||
and it plays; `>` or Shift+N plays it at once. Under the queue,
|
Cancel stops it for everyone, and so does seeking back. `>` or Shift+N
|
||||||
**Continues with** shows the next episodes of the Jellyfin series
|
plays it at once. Under the queue, **Continues with** shows what
|
||||||
playing (greyed: they play only once Up next is empty); **+** adds one,
|
continues the episode playing: its next episode (a Jellyfin episode's,
|
||||||
**Clear** drops them for this episode.
|
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
|
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 …:
|
by your app: if you left when its turn comes, it is skipped ("Skipped …:
|
||||||
Foxyz left"). A Jellyfin item needs someone with an account on that
|
Foxyz left"). If you are only gone for a moment, the countdown waits for
|
||||||
server. Up next is kept by the server while it runs; restarting the
|
you. A Jellyfin item needs someone with an account on that server. Up
|
||||||
server empties it.
|
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
|
## Schedule
|
||||||
|
|
||||||
@@ -221,11 +328,11 @@ cover; one you started shows how far you got.
|
|||||||
## Dedicated server
|
## Dedicated server
|
||||||
|
|
||||||
For an always-on box (no display needed): the release tarball also contains
|
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.
|
then everyone (you included) uses Join.
|
||||||
|
|
||||||
```
|
```
|
||||||
./watch-together-server --port 8080 --secret <password>
|
./starsync-server --port 8080 --secret <password>
|
||||||
```
|
```
|
||||||
|
|
||||||
| Flag | Default | Meaning |
|
| Flag | Default | Meaning |
|
||||||
@@ -245,9 +352,11 @@ then everyone (you included) uses Join.
|
|||||||
|
|
||||||
It prints `READY port=... name=...` once listening and stops cleanly on
|
It prints `READY port=... name=...` once listening and stops cleanly on
|
||||||
Ctrl+C or SIGTERM. A dedicated server has no host.
|
Ctrl+C or SIGTERM. A dedicated server has no host.
|
||||||
To run it as a service, unpack the tarball to `/opt/watch-together` and use
|
To run it as a service, unpack the tarball to `/opt/starsync` and use
|
||||||
[deploy/watch-together.service](deploy/watch-together.service) (copy it to
|
[deploy/starsync.service](deploy/starsync.service) (copy it to
|
||||||
`/etc/systemd/system/`, edit the flags, `systemctl enable --now watch-together`).
|
`/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
|
Something not playing? Settings → About → **Copy diagnostics** puts the
|
||||||
app's recent log (passwords, tokens and cookies left out) on the
|
app's recent log (passwords, tokens and cookies left out) on the
|
||||||
@@ -255,8 +364,8 @@ 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
|
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
|
merges everyone's lines and its own into one session log
|
||||||
(`session-YYYY-MM-DD.log` next to the server's logs; the hosting app keeps
|
(`session-YYYY-MM-DD.log` next to the server's logs; your own server
|
||||||
it in its own logs folder), so a problem can be read afterwards from every
|
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.
|
side. Chat is never logged anywhere, nor passwords, tokens and cookies.
|
||||||
When something goes wrong, press **Mark a problem** (Ctrl+Shift+B, also in
|
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
|
the player's ⚙ and Settings → About) and add a few words if you like: a
|
||||||
@@ -268,34 +377,53 @@ notes that you don't share; a problem you mark still goes in).
|
|||||||
|
|
||||||
## Join
|
## Join
|
||||||
|
|
||||||
Join in the strip. Servers you got into are listed first (up to 6), with
|
Join a friend in the strip. Servers you got into are listed first (up
|
||||||
live status (online and how many are watching, or offline, plus "needs
|
to 6), with live status (online and how many are watching, or offline,
|
||||||
update" when the server runs another protocol): click one to rejoin, x to
|
plus "needs update" when its version and yours do not match): click one
|
||||||
forget it. Or enter the host's address, port, and the password if there
|
to rejoin, x to forget it. Or paste the invite (or the address and port)
|
||||||
is one, then Enter or Join. Your name and picture come from your profile
|
and the password if there is one, then Enter or Join.
|
||||||
(your avatar at the top left).
|
|
||||||
|
Faster: click a friend's `starsync://` link, or copy their invite and
|
||||||
|
switch to Starsync. The app asks "Join Bob's server (203.0.113.7:8080)?"
|
||||||
|
with the server's version and who is watching; Join joins (and adds it
|
||||||
|
to your recent servers), Not now leaves it (a copied invite is then not
|
||||||
|
offered again). The clipboard is only looked at for an invite when the
|
||||||
|
window comes to the front, and never saved or logged. The app runs once:
|
||||||
|
a link clicked while it runs goes to the open window. 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
|
## Settings
|
||||||
|
|
||||||
The Settings tab (Ctrl+,) has a page per topic, each setting with a line
|
The Settings tab (Ctrl+,) has a page per topic, each setting with a line
|
||||||
of help: Profile; Party & network (the Host card's default port and
|
of help: Profile; Party & network (your server's port and password for
|
||||||
password, recent servers with remove); Player (below); Jellyfin &
|
Invite, recent servers with remove); Player (below); Jellyfin &
|
||||||
integrations (linked Jellyfin servers, below, the AnimeFlow connection:
|
integrations (linked Jellyfin servers, below, the AnimeFlow connection:
|
||||||
"connected as <name>" with what you may do, or why not, and playing the
|
"connected as <name>" with what you may do, or why not, and playing the
|
||||||
next episode automatically); Notifications & sounds (what pops up; the sound
|
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
|
volume and sound set, and every sound with a preview, its own set and a switch); Updates; Keyboard shortcuts (read-only); About
|
||||||
(the version; resting the mouse on the Settings tab shows it too; and the
|
(the version, also at the bottom of every Settings page and in the
|
||||||
credits, e.g. that the Minimal player design is inspired by uosc).
|
Settings tab's tooltip; and the credits, e.g. that the Minimal player design is inspired by uosc).
|
||||||
|
|
||||||
Player settings live in the player too: ⚙ → Player settings, at the
|
Player settings live in the player too: ⚙ → Player settings opens a
|
||||||
right of its controls, and Settings → Player has the same ones (plus the
|
narrow panel at the right edge of the player that stays open while you
|
||||||
controls' layout and the custom shader files). Player design: Standard or
|
watch (the controls hide and show as usual) until its × or Esc. Its
|
||||||
Minimal (below); progress line when hidden (on by default: a thin line at
|
sections fold: tracks (subtitles, audio), subtitles (size, above the
|
||||||
the bottom of the video while the controls are hidden); upscaling shader
|
controls), picture (upscaling shader, sharp video, hardware decoding),
|
||||||
(see below); hardware decoding (auto, off or custom; auto is mpv's
|
sound (volume), notifications (toasts or danmaku and the danmaku look)
|
||||||
`auto-copy` on Windows, `auto` elsewhere; the env var
|
and controls (design, progress line). Settings → Player has the same
|
||||||
`SYNC_RELAY_HWDEC` overrides it); in the player also the subtitle and
|
ones (plus the controls' layout and the custom shader files). Player
|
||||||
audio track. Settings → Player → Controls: time display (elapsed / total
|
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); subtitle size; subtitles above the
|
||||||
|
controls (off by default: bottom lines move up while the control bar
|
||||||
|
shows; the quick chat box always moves them). Settings → Player → Controls: time display (elapsed / total
|
||||||
or remaining), controls size (normal or large), hide the controls after
|
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,
|
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
|
subtitles, audio, chat; hidden ones are still in ⚙). Every change
|
||||||
@@ -305,8 +433,9 @@ Anywhere in a server (unless you are typing): Space = ready / unready,
|
|||||||
Tab = chat & activity (Tab never moves between buttons). Anywhere:
|
Tab = chat & activity (Tab never moves between buttons). Anywhere:
|
||||||
Ctrl+Shift+B = mark a problem in the session log. Keys while the
|
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,
|
player fills the window: Left/Right = seek 10 s, Up/Down = volume, Shift+Right = skip 1:30,
|
||||||
PgUp/PgDn = previous / next chapter (as in mpv), F = fullscreen, Esc =
|
PgUp/PgDn = previous / next chapter (as in mpv), F = fullscreen, Enter =
|
||||||
close the chat, then fullscreen, then shrink the player to mini; from the
|
quick chat, Esc = close the player panel, then
|
||||||
|
the chat, then fullscreen, then shrink the player to mini; from the
|
||||||
mini it asks to leave, Ctrl+R = reload video,
|
mini it asks to leave, Ctrl+R = reload video,
|
||||||
`<` / `>` or Shift+P / Shift+N = previous / next Jellyfin episode,
|
`<` / `>` or Shift+P / Shift+N = previous / next Jellyfin episode,
|
||||||
Ctrl+1..6 = Anime4K mode A, B, C, A+A, B+B, C+A, Ctrl+0 = shaders off.
|
Ctrl+1..6 = Anime4K mode A, B, C, A+A, B+B, C+A, Ctrl+0 = shaders off.
|
||||||
@@ -364,7 +493,8 @@ during the first 6 minutes.
|
|||||||
| Sync WebSocket and `/health` | TCP 8080 | plain `ws://`, no TLS |
|
| Sync WebSocket and `/health` | TCP 8080 | plain `ws://`, no TLS |
|
||||||
| Local file streaming | same port | path `/m/...`, needs a session token |
|
| 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
|
## Jellyfin
|
||||||
|
|
||||||
@@ -397,15 +527,19 @@ Watching:
|
|||||||
directly with their own sign-in. Jellyfin tracks progress per account:
|
directly with their own sign-in. Jellyfin tracks progress per account:
|
||||||
your watched state, Continue watching and resume point follow what you
|
your watched state, Continue watching and resume point follow what you
|
||||||
saw (reported on start, every 10 s, on pause, resume, seek and track
|
saw (reported on start, every 10 s, on pause, resume, seek and track
|
||||||
change, and when it stops). Watched means you actually played most of
|
change, and when it stops). Watched means you were there while most
|
||||||
it (or reached the ending after half), not where you stopped: opening
|
of it played (or while half of it played and it reached the ending),
|
||||||
a watched episode to check a moment keeps it watched, and a quick peek
|
not where it stopped: time you sat out does not count, skipping around
|
||||||
leaves no resume point.
|
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
|
- Friends without an account watch through the server: whoever put the
|
||||||
item up passes it on automatically, with their own sign-in, which never
|
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
|
leaves their computer (only the current item, only to people in the
|
||||||
session). They show "via server" in the member list (click the avatars). When the app hosts,
|
session). They show "via server" in the member list (click the avatars). On your
|
||||||
or a dedicated server has its own login (`--jellyfin <file>`, see
|
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
|
[docs/ARCHITECTURE.md](docs/ARCHITECTURE.md), Jellyfin relay), the server
|
||||||
fetches it itself instead.
|
fetches it itself instead.
|
||||||
- If nobody can pass it on (the person who put it up left), the app says
|
- If nobody can pass it on (the person who put it up left), the app says
|
||||||
@@ -416,12 +550,11 @@ Episodes: the ⏮ / ⏭ buttons next to play (or `<` / `>`,
|
|||||||
Shift+P / Shift+N) put up the previous or next episode for everyone. From
|
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
|
the ending (when it is known, see Skipping above; else in the last two
|
||||||
minutes) a "Next episode" button floats in the corner while the controls
|
minutes) a "Next episode" button floats in the corner while the controls
|
||||||
are up. When an
|
are up. With "Play the next episode automatically" (Settings, on by
|
||||||
episode ends, a card shows what is next with Play next; with "Play the
|
default), the episode you put up continues with the next one: when it
|
||||||
next episode automatically" (Settings, on by default) it counts down 10 s
|
ends, everyone sees a 10 s countdown, and anyone can cancel it (see Up
|
||||||
first, and Cancel stops it on your screen (seeking back stops it for
|
next). Anyone with an account can put the next one up; friends watching
|
||||||
everyone). Anyone with an account can put the next one up; friends
|
via the host cannot.
|
||||||
watching via the host cannot.
|
|
||||||
|
|
||||||
Audio and subtitles: the Audio and Subtitles buttons in the controls pick tracks.
|
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
|
Your choice is remembered per series (and as a default) and the closest
|
||||||
@@ -456,7 +589,11 @@ 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
|
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
|
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
|
sites: keep the file private, and for YouTube consider a throwaway
|
||||||
account (Google may flag accounts used with yt-dlp).
|
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
|
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).
|
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
|
Through me and Server fetches it never show the link to anyone; they can't pass on live
|
||||||
@@ -469,9 +606,11 @@ 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
|
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
|
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
|
needs (the bar's mark; past it is keeping up). It turns amber when the
|
||||||
source is too slow or reconnecting, red when it left (the others play
|
source is too slow or its connection dropped ("Bob's connection dropped —
|
||||||
out what the server already has), and whoever passes it on is told once
|
playing what the server has (2 min)"), red when it left (the others play
|
||||||
when their upload is too slow. Each member's line says how they get it
|
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
|
("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
|
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.
|
member while the source keeps up is their own connection.
|
||||||
|
|||||||
+85
-40
@@ -3,23 +3,33 @@ import 'dart:io';
|
|||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:hive_ce_flutter/hive_flutter.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:sync_relay_core/core.dart';
|
||||||
import 'package:window_manager/window_manager.dart';
|
|
||||||
|
|
||||||
import 'app.dart';
|
import 'app.dart';
|
||||||
import 'src/playback/yt_dlp_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_log.dart';
|
||||||
import 'src/platform/app_shutdown.dart';
|
import 'src/platform/app_shutdown.dart' show closeAppWith;
|
||||||
|
import 'src/platform/desktop/attention.dart' show exitNow, windowLaunchToken;
|
||||||
|
import 'src/platform/desktop/desktop_app_dirs.dart';
|
||||||
|
import 'src/platform/desktop/windows_api.dart' show allowSetForegroundWindow;
|
||||||
import 'src/platform/desktop_entry.dart';
|
import 'src/platform/desktop_entry.dart';
|
||||||
import 'src/platform/window_memory.dart';
|
import 'src/platform/ports/window_activity.dart';
|
||||||
|
import 'src/platform/single_instance.dart';
|
||||||
|
import 'src/platform/url_scheme.dart';
|
||||||
|
import 'src/platform/smoke.dart';
|
||||||
import 'src/screens/home/ui.dart';
|
import 'src/screens/home/ui.dart';
|
||||||
import 'src/state/session_state.dart';
|
import 'src/session/app_events.dart' show UpdateAvailable;
|
||||||
|
import 'src/session/invite.dart' show InviteLink;
|
||||||
import 'src/state/settings.dart';
|
import 'src/state/settings.dart';
|
||||||
import 'src/update/update_controller.dart' show launchArgs;
|
import 'src/update/update_controller.dart' show UpdatePhase, launchArgs;
|
||||||
|
|
||||||
Future<void> main(List<String> args) async {
|
Future<void> main(List<String> args) async {
|
||||||
launchArgs = List.unmodifiable(args);
|
// An invite link is handled once: Update and restart does not pass it on.
|
||||||
|
launchArgs = List.unmodifiable([
|
||||||
|
for (final a in args)
|
||||||
|
if (InviteLink.parse(a) == null) a,
|
||||||
|
]);
|
||||||
if (args.contains('--upgrade')) {
|
if (args.contains('--upgrade')) {
|
||||||
exitCode = await runUpgrade();
|
exitCode = await runUpgrade();
|
||||||
exit(exitCode);
|
exit(exitCode);
|
||||||
@@ -43,46 +53,81 @@ Future<void> main(List<String> args) async {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
await startAppLogging();
|
final dirs = await const DesktopAppDirs().resolve();
|
||||||
await Hive.initFlutter();
|
// One app per profile: a second start (a clicked starsync:// link) hands
|
||||||
await AppSettings.instance.ensureLoaded();
|
// its arguments to the running one and ends before its window shows.
|
||||||
await windowManager.ensureInitialized();
|
final instance = await InstanceLock.claim(
|
||||||
await windowManager.setPreventClose(true);
|
Directory(dirs.hive).parent.path,
|
||||||
final memory = WindowMemory(Hive.isBoxOpen(settingsBoxName) ? Hive.box(settingsBoxName) : null);
|
args: args,
|
||||||
shutdownWindowMemory = memory;
|
activation: await windowLaunchToken(),
|
||||||
windowManager.addListener(_CloseGuard());
|
beforeSend: Platform.isWindows ? allowSetForegroundWindow : null,
|
||||||
final windowOptions = WindowOptions(
|
|
||||||
size: memory.size,
|
|
||||||
minimumSize: const Size(160, 90),
|
|
||||||
center: !memory.hasPosition,
|
|
||||||
backgroundColor: const Color(0xFF000000),
|
|
||||||
titleBarStyle: TitleBarStyle.hidden,
|
|
||||||
title: appName,
|
|
||||||
);
|
);
|
||||||
windowManager.waitUntilReadyToShow(windowOptions, () async {
|
if (instance == null) await exitNow();
|
||||||
await memory.restore();
|
await startAppLogging(dirs);
|
||||||
await windowManager.show();
|
Hive.init(dirs.hive);
|
||||||
await windowManager.focus();
|
await AppSettings.instance.ensureLoaded();
|
||||||
|
// The composition root: every system, made once.
|
||||||
|
final graph = AppGraph.build();
|
||||||
|
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, instance: instance);
|
||||||
|
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).
|
||||||
|
// Coming to the front also looks for an invite on the clipboard (the
|
||||||
|
// join prompt; nothing of it is logged).
|
||||||
|
ports.shell.watchActivity((a) {
|
||||||
|
graph.coordinator.windowActivity(a);
|
||||||
|
graph.notifier.window = a;
|
||||||
|
if (a == WindowActivity.focused) {
|
||||||
|
unawaited(ports.shell.readClipboardText().then(graph.joinOffers.fromClipboard));
|
||||||
|
}
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
await SessionState.instance.loadForm();
|
await graph.forms.load();
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
debugPrint('load connect form failed: $e\n$st');
|
debugPrint('load connect form failed: $e\n$st');
|
||||||
}
|
}
|
||||||
MediaKit.ensureInitialized();
|
// 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.
|
// yt-dlp for web page links: downloaded on first run, kept current.
|
||||||
AppYtDlp.instance.warmUp();
|
ports.links.warmUp();
|
||||||
// Linux: the launcher entry and icon Wayland needs (release installs).
|
// Linux: the launcher entry and icon Wayland needs (release installs).
|
||||||
unawaited(installDesktopEntry());
|
unawaited(installDesktopEntry());
|
||||||
runApp(const WatchTogetherApp());
|
// Windows: starsync:// links start this install (HKCU, release builds).
|
||||||
|
unawaited(registerWindowsScheme());
|
||||||
|
// A found update is an event too (the band shows it).
|
||||||
|
final updates = graph.updates;
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
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());
|
||||||
|
// A clicked invite link: the one this app started with, then each one a
|
||||||
|
// later start hands over (the window comes to the front for it).
|
||||||
|
graph.joinOffers.fromArgs(args);
|
||||||
|
instance.handoffs.listen((h) {
|
||||||
|
unawaited(ports.shell.focus(activation: h.activation));
|
||||||
|
graph.joinOffers.fromArgs(h.args);
|
||||||
|
});
|
||||||
|
unawaited(ports.shell.readClipboardText().then(graph.joinOffers.fromClipboard));
|
||||||
// The sound device opens once the window is up, not on the first sound.
|
// The sound device opens once the window is up, not on the first sound.
|
||||||
Future.delayed(const Duration(seconds: 2), SessionState.instance.sfx.warmUp);
|
Future.delayed(const Duration(seconds: 2), graph.sfx.warmUp);
|
||||||
}
|
|
||||||
|
|
||||||
/// Close button / Alt+F4: the orderly close ([shutDownApp]), so others
|
|
||||||
/// see "left", a hosted server shuts down and no player outlives the
|
|
||||||
/// engine.
|
|
||||||
class _CloseGuard with WindowListener {
|
|
||||||
@override
|
|
||||||
void onWindowClose() => unawaited(shutDownApp());
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
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/join_offers.dart';
|
||||||
|
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 '../update/update_controller.dart';
|
||||||
|
import '../update/whats_new.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;
|
||||||
|
|
||||||
|
/// The app's updater (checks, the update band, install and restart).
|
||||||
|
final UpdateController updates;
|
||||||
|
|
||||||
|
/// What's new once after an update; keeps the notes the updater saves.
|
||||||
|
final WhatsNew whatsNew;
|
||||||
|
|
||||||
|
/// The join prompt for a clicked invite link or one on the clipboard.
|
||||||
|
final JoinOffers joinOffers;
|
||||||
|
|
||||||
|
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,
|
||||||
|
required this.updates,
|
||||||
|
required this.whatsNew,
|
||||||
|
required this.joinOffers,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// The desktop app's graph. Tests pass their own [ports], [identity]
|
||||||
|
/// (default: the install's stored one), [healthGet], server starter and
|
||||||
|
/// internet address lookup ([publicAddress]).
|
||||||
|
factory AppGraph.build({
|
||||||
|
Ports? ports,
|
||||||
|
ClientIdentity? identity,
|
||||||
|
HealthGetter healthGet = defaultHealthGet,
|
||||||
|
ServerStarter? serverStarter,
|
||||||
|
JellyfinAccounts? jellyfin,
|
||||||
|
ShowMatcher? library,
|
||||||
|
WatchHistory? history,
|
||||||
|
Future<String?> Function()? publicAddress,
|
||||||
|
}) {
|
||||||
|
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,
|
||||||
|
publicAddress: publicAddress,
|
||||||
|
);
|
||||||
|
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);
|
||||||
|
};
|
||||||
|
final updates = UpdateController.instance;
|
||||||
|
final whatsNew = WhatsNew(store: SettingsWhatsNewStore(settings));
|
||||||
|
updates.beforeRestart ??= whatsNew.installing;
|
||||||
|
updates.channelOf ??= () => settings.updateChannel.peek();
|
||||||
|
return AppGraph._(
|
||||||
|
ports: p,
|
||||||
|
sfx: sfx,
|
||||||
|
notifier: notifier,
|
||||||
|
connection: connection,
|
||||||
|
coordinator: coordinator,
|
||||||
|
forms: forms,
|
||||||
|
yourServer: yourServer,
|
||||||
|
navigation: navigation,
|
||||||
|
library: matcher,
|
||||||
|
watch: watch,
|
||||||
|
updates: updates,
|
||||||
|
whatsNew: whatsNew,
|
||||||
|
joinOffers: JoinOffers(navigation: navigation, yourServer: yourServer, forms: forms),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -11,7 +11,6 @@ import '../widgets/copy_open.dart';
|
|||||||
import 'frame.dart';
|
import 'frame.dart';
|
||||||
import 'model/controls_layout.dart';
|
import 'model/controls_layout.dart';
|
||||||
import 'model/controls_model.dart';
|
import 'model/controls_model.dart';
|
||||||
import 'player_settings.dart';
|
|
||||||
|
|
||||||
// The controls' menus, shared by both skins: the track and chapter lists
|
// 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
|
// and the ⚙ menu, all on the app's menu (`showAppMenu`), anchored on the
|
||||||
@@ -121,7 +120,7 @@ Future<void> runControlAction(BuildContext context, PlayerControlsModel model, C
|
|||||||
model.changeVideo();
|
model.changeVideo();
|
||||||
case ControlAction.copyLink:
|
case ControlAction.copyLink:
|
||||||
final link = model.link();
|
final link = model.link();
|
||||||
if (link != null) await copyToClipboard(link, done: 'Link copied');
|
if (link != null) await copyToClipboard(context, link, done: 'Link copied');
|
||||||
case ControlAction.reload:
|
case ControlAction.reload:
|
||||||
model.reload();
|
model.reload();
|
||||||
case ControlAction.markAllReady:
|
case ControlAction.markAllReady:
|
||||||
@@ -129,7 +128,7 @@ Future<void> runControlAction(BuildContext context, PlayerControlsModel model, C
|
|||||||
case ControlAction.markProblem:
|
case ControlAction.markProblem:
|
||||||
await ControlsFrame.holdWhile(context, () => showMarkProblem(context));
|
await ControlsFrame.holdWhile(context, () => showMarkProblem(context));
|
||||||
case ControlAction.playerSettings:
|
case ControlAction.playerSettings:
|
||||||
await showPlayerSettings(context, renderMode: model.renderMode);
|
model.togglePanel();
|
||||||
case ControlAction.leave:
|
case ControlAction.leave:
|
||||||
model.leave();
|
model.leave();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart' show ValueListenable;
|
||||||
import 'package:flutter/gestures.dart' show kPrimaryMouseButton;
|
import 'package:flutter/gestures.dart' show kPrimaryMouseButton;
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
@@ -10,7 +11,10 @@ import 'controls_probe.dart' show ControlsHold;
|
|||||||
// without any (or the mouse leaving) hides them and the cursor. Holds keep
|
// 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
|
// 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
|
// takes the mouse), and the session's [ControlsHold] (the mouse on a
|
||||||
// floating pill or card, the Up next popover).
|
// floating pill or card, the Up next popover). Only real mouse movement
|
||||||
|
// wakes them: a widget appearing or going under a still pointer (the
|
||||||
|
// quick chat box) does not, nor does typing. [ControlsFrame.suppress]
|
||||||
|
// hides them while something takes their place (the quick chat box).
|
||||||
|
|
||||||
class ControlsFrame extends StatefulWidget {
|
class ControlsFrame extends StatefulWidget {
|
||||||
/// Draws the controls; [visible] false while they are hidden.
|
/// Draws the controls; [visible] false while they are hidden.
|
||||||
@@ -28,6 +32,11 @@ class ControlsFrame extends StatefulWidget {
|
|||||||
|
|
||||||
/// Keeps them up while held.
|
/// Keeps them up while held.
|
||||||
final ControlsHold? holdBy;
|
final ControlsHold? holdBy;
|
||||||
|
|
||||||
|
/// While true the controls are drawn hidden and [shown] says so; the
|
||||||
|
/// cursor still follows the mouse. They come back as they would have
|
||||||
|
/// been once it is false again.
|
||||||
|
final ValueListenable<bool>? suppress;
|
||||||
const ControlsFrame({
|
const ControlsFrame({
|
||||||
super.key,
|
super.key,
|
||||||
required this.builder,
|
required this.builder,
|
||||||
@@ -37,6 +46,7 @@ class ControlsFrame extends StatefulWidget {
|
|||||||
this.hideAfter = const Duration(seconds: 3),
|
this.hideAfter = const Duration(seconds: 3),
|
||||||
this.shown,
|
this.shown,
|
||||||
this.holdBy,
|
this.holdBy,
|
||||||
|
this.suppress,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// The frame around [context], for widgets that keep it up (a drag, a
|
/// The frame around [context], for widgets that keep it up (a drag, a
|
||||||
@@ -62,16 +72,23 @@ class ControlsFrameState extends State<ControlsFrame> {
|
|||||||
int _holds = 0;
|
int _holds = 0;
|
||||||
bool _heldBy = false;
|
bool _heldBy = false;
|
||||||
Timer? _timer;
|
Timer? _timer;
|
||||||
|
// Where the mouse was last seen, to tell real movement from a widget
|
||||||
|
// appearing or going under it.
|
||||||
|
Offset? _lastPos;
|
||||||
|
|
||||||
bool get visible => _visible;
|
bool get _suppressed => widget.suppress?.value ?? false;
|
||||||
|
|
||||||
|
/// The controls are drawn (up and not suppressed).
|
||||||
|
bool get visible => _visible && !_suppressed;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
widget.holdBy?.addListener(_holdByChanged);
|
widget.holdBy?.addListener(_holdByChanged);
|
||||||
|
widget.suppress?.addListener(_suppressChanged);
|
||||||
// Not during this build: an overlay listening to [shown] may be built.
|
// Not during this build: an overlay listening to [shown] may be built.
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
if (mounted) widget.shown?.value = true;
|
if (mounted) widget.shown?.value = visible;
|
||||||
});
|
});
|
||||||
_hideLater();
|
_hideLater();
|
||||||
}
|
}
|
||||||
@@ -84,12 +101,17 @@ class ControlsFrameState extends State<ControlsFrame> {
|
|||||||
widget.holdBy?.addListener(_holdByChanged);
|
widget.holdBy?.addListener(_holdByChanged);
|
||||||
_holdByChanged();
|
_holdByChanged();
|
||||||
}
|
}
|
||||||
|
if (oldWidget.suppress != widget.suppress) {
|
||||||
|
oldWidget.suppress?.removeListener(_suppressChanged);
|
||||||
|
widget.suppress?.addListener(_suppressChanged);
|
||||||
|
}
|
||||||
if (oldWidget.hideAfter != widget.hideAfter && _visible) _hideLater();
|
if (oldWidget.hideAfter != widget.hideAfter && _visible) _hideLater();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
widget.holdBy?.removeListener(_holdByChanged);
|
widget.holdBy?.removeListener(_holdByChanged);
|
||||||
|
widget.suppress?.removeListener(_suppressChanged);
|
||||||
_timer?.cancel();
|
_timer?.cancel();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
@@ -101,15 +123,29 @@ class ControlsFrameState extends State<ControlsFrame> {
|
|||||||
hold(on);
|
hold(on);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _suppressChanged() {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {});
|
||||||
|
widget.shown?.value = visible;
|
||||||
|
}
|
||||||
|
|
||||||
/// Show the controls and restart the hide countdown.
|
/// Show the controls and restart the hide countdown.
|
||||||
void wake() {
|
void wake() {
|
||||||
if (!_visible && mounted) {
|
if (!_visible && mounted) {
|
||||||
setState(() => _visible = true);
|
setState(() => _visible = true);
|
||||||
widget.shown?.value = true;
|
widget.shown?.value = visible;
|
||||||
}
|
}
|
||||||
_hideLater();
|
_hideLater();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A hover or enter at [pos]: wakes only when the mouse really moved.
|
||||||
|
void _moved(Offset pos) {
|
||||||
|
final last = _lastPos;
|
||||||
|
_lastPos = pos;
|
||||||
|
if (last != null && (pos - last).distance < 0.5) return;
|
||||||
|
wake();
|
||||||
|
}
|
||||||
|
|
||||||
/// Keep the controls up while [on] (paired calls).
|
/// Keep the controls up while [on] (paired calls).
|
||||||
void hold(bool on) {
|
void hold(bool on) {
|
||||||
_holds = on ? _holds + 1 : (_holds - 1).clamp(0, 1 << 20);
|
_holds = on ? _holds + 1 : (_holds - 1).clamp(0, 1 << 20);
|
||||||
@@ -141,9 +177,10 @@ class ControlsFrameState extends State<ControlsFrame> {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return MouseRegion(
|
return MouseRegion(
|
||||||
cursor: _visible ? SystemMouseCursors.basic : SystemMouseCursors.none,
|
cursor: _visible ? SystemMouseCursors.basic : SystemMouseCursors.none,
|
||||||
onEnter: (_) => wake(),
|
onEnter: (e) => _moved(e.position),
|
||||||
onHover: (_) => wake(),
|
onHover: (e) => _moved(e.position),
|
||||||
onExit: (_) {
|
onExit: (e) {
|
||||||
|
_lastPos = e.position;
|
||||||
_timer?.cancel();
|
_timer?.cancel();
|
||||||
_hide();
|
_hide();
|
||||||
},
|
},
|
||||||
@@ -154,12 +191,12 @@ class ControlsFrameState extends State<ControlsFrame> {
|
|||||||
Positioned.fill(
|
Positioned.fill(
|
||||||
child: DoubleClickListener(
|
child: DoubleClickListener(
|
||||||
onDoubleClick: widget.onDoubleClick!,
|
onDoubleClick: widget.onDoubleClick!,
|
||||||
skipTop: _visible ? widget.barTop : 0,
|
skipTop: visible ? widget.barTop : 0,
|
||||||
skipBottom: _visible ? widget.barBottom : 0,
|
skipBottom: visible ? widget.barBottom : 0,
|
||||||
child: const ColoredBox(color: Color(0x00000000), child: SizedBox.expand()),
|
child: const ColoredBox(color: Color(0x00000000), child: SizedBox.expand()),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
widget.builder(context, _visible),
|
widget.builder(context, visible),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -148,7 +148,7 @@ class MinimalControls extends StatelessWidget {
|
|||||||
const Expanded(
|
const Expanded(
|
||||||
child: Align(alignment: AlignmentDirectional.centerStart, child: NowPlayingTitle()),
|
child: Align(alignment: AlignmentDirectional.centerStart, child: NowPlayingTitle()),
|
||||||
),
|
),
|
||||||
UpNextButton(queue: m.queue, onPlayNextEpisode: m.playNextEpisode, look: UpNextLook.minimal, hold: hold),
|
UpNextButton(queue: m.queue, look: UpNextLook.minimal, hold: hold),
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
SignalBuilder(
|
SignalBuilder(
|
||||||
builder: (context) {
|
builder: (context) {
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ abstract final class MinimalTimelineSpec {
|
|||||||
/// What is left of it while they are hidden.
|
/// What is left of it while they are hidden.
|
||||||
static const lineSize = 3.0;
|
static const lineSize = 3.0;
|
||||||
|
|
||||||
|
/// The played part's accent tint.
|
||||||
|
static const played = 0.4;
|
||||||
|
|
||||||
/// The position line.
|
/// The position line.
|
||||||
static const lineWidth = 2.0;
|
static const lineWidth = 2.0;
|
||||||
static const fontSize = 15.0;
|
static const fontSize = 15.0;
|
||||||
@@ -467,13 +470,10 @@ class MinimalTimelinePainter extends CustomPainter {
|
|||||||
final w = size.width, h = size.height;
|
final w = size.width, h = size.height;
|
||||||
final x = lineX(w);
|
final x = lineX(w);
|
||||||
final band = Offset.zero & size;
|
final band = Offset.zero & size;
|
||||||
// The band, darker as it grows; hidden, a faint track.
|
// The same colours shown and hidden: hidden, it is the band shrunk to
|
||||||
canvas.drawRect(band, Paint()..color = Color.lerp(const Color(0x40FFFFFF), MinimalStyle.band, visibility)!);
|
// a line, so growing and shrinking never switches colour.
|
||||||
// Played: an accent tint, solid once the band is a line.
|
canvas.drawRect(band, Paint()..color = MinimalStyle.band);
|
||||||
canvas.drawRect(
|
canvas.drawRect(Rect.fromLTRB(0, 0, x, h), Paint()..color = accent.withValues(alpha: MinimalTimelineSpec.played));
|
||||||
Rect.fromLTRB(0, 0, x, h),
|
|
||||||
Paint()..color = accent.withValues(alpha: 0.28 + 0.72 * (1 - visibility)),
|
|
||||||
);
|
|
||||||
_stripes(canvas, w, h);
|
_stripes(canvas, w, h);
|
||||||
_chapters(canvas, w, h);
|
_chapters(canvas, w, h);
|
||||||
if (visibility > 0) {
|
if (visibility > 0) {
|
||||||
@@ -500,7 +500,8 @@ class MinimalTimelinePainter extends CustomPainter {
|
|||||||
canvas.save();
|
canvas.save();
|
||||||
canvas.clipRect(Rect.fromLTRB(ax, 0, w, h));
|
canvas.clipRect(Rect.fromLTRB(ax, 0, w, h));
|
||||||
final paint = Paint()
|
final paint = Paint()
|
||||||
..color = Colors.white.withValues(alpha: 0.10 + 0.08 * (1 - visibility))
|
// 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;
|
..strokeWidth = 1.5 * scale;
|
||||||
for (var c = (ax / period).floorToDouble() * period - h; c <= w; c += period) {
|
for (var c = (ax / period).floorToDouble() * period - h; c <= w; c += period) {
|
||||||
canvas.drawLine(Offset(c, h), Offset(c + h, 0), paint);
|
canvas.drawLine(Offset(c, h), Offset(c + h, 0), paint);
|
||||||
@@ -509,7 +510,7 @@ class MinimalTimelinePainter extends CustomPainter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _chapters(Canvas canvas, double w, double h) {
|
void _chapters(Canvas canvas, double w, double h) {
|
||||||
final cut = Paint()..color = Colors.black.withValues(alpha: 0.55);
|
final cut = Paint()..color = Colors.black.withValues(alpha: 0.3);
|
||||||
for (final f in chapters) {
|
for (final f in chapters) {
|
||||||
if (f <= 0) continue;
|
if (f <= 0) continue;
|
||||||
final cx = w * f;
|
final cx = w * f;
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ class ControlsSnapshot {
|
|||||||
final bool hasDuration;
|
final bool hasDuration;
|
||||||
|
|
||||||
/// Watching alone (no one to mark ready).
|
/// Watching alone (no one to mark ready).
|
||||||
final bool solo;
|
final bool alone;
|
||||||
|
|
||||||
/// A link to copy (`SessionCoordinator.currentLink`).
|
/// A link to copy (`SessionCoordinator.currentLink`).
|
||||||
final bool hasLink;
|
final bool hasLink;
|
||||||
@@ -126,7 +126,7 @@ class ControlsSnapshot {
|
|||||||
this.nextIsUpNext = false,
|
this.nextIsUpNext = false,
|
||||||
this.hasChapters = false,
|
this.hasChapters = false,
|
||||||
this.hasDuration = false,
|
this.hasDuration = false,
|
||||||
this.solo = false,
|
this.alone = false,
|
||||||
this.hasLink = false,
|
this.hasLink = false,
|
||||||
this.fullscreen = false,
|
this.fullscreen = false,
|
||||||
this.chatOpen = false,
|
this.chatOpen = false,
|
||||||
@@ -167,7 +167,7 @@ List<ControlAction> menuActions(ControlsSnapshot s, Set<OptionalControl> hidden)
|
|||||||
ControlAction.skipForward => s.hasDuration,
|
ControlAction.skipForward => s.hasDuration,
|
||||||
ControlAction.chat => hidden.contains(OptionalControl.chat),
|
ControlAction.chat => hidden.contains(OptionalControl.chat),
|
||||||
ControlAction.copyLink => s.hasLink,
|
ControlAction.copyLink => s.hasLink,
|
||||||
ControlAction.markAllReady => !s.solo,
|
ControlAction.markAllReady => !s.alone,
|
||||||
_ => true,
|
_ => true,
|
||||||
})
|
})
|
||||||
a,
|
a,
|
||||||
@@ -208,7 +208,7 @@ String controlTooltip(BarControl c, ControlsSnapshot s) => switch (c) {
|
|||||||
BarControl.playPause => switch (s.play) {
|
BarControl.playPause => switch (s.play) {
|
||||||
PlayState.playing => 'Pause for everyone (Space)',
|
PlayState.playing => 'Pause for everyone (Space)',
|
||||||
PlayState.waiting => 'Ready, waiting for the others (Space: not ready)',
|
PlayState.waiting => 'Ready, waiting for the others (Space: not ready)',
|
||||||
PlayState.paused => s.solo ? 'Play (Space)' : 'Ready: play when everyone is (Space)',
|
PlayState.paused => s.alone ? 'Play (Space)' : 'Ready: play when everyone is (Space)',
|
||||||
PlayState.finished => 'Ready (Space)',
|
PlayState.finished => 'Ready (Space)',
|
||||||
},
|
},
|
||||||
BarControl.previous => 'Previous: ${s.previousLabel ?? ''} (< or Shift+P)',
|
BarControl.previous => 'Previous: ${s.previousLabel ?? ''} (< or Shift+P)',
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import '../../playback/playback_controller.dart';
|
|||||||
import '../../queue/queue_controller.dart';
|
import '../../queue/queue_controller.dart';
|
||||||
import '../../session/session_coordinator.dart';
|
import '../../session/session_coordinator.dart';
|
||||||
import '../../shaders/shader_presets.dart';
|
import '../../shaders/shader_presets.dart';
|
||||||
import '../../state/session_state.dart';
|
|
||||||
import '../../state/settings.dart';
|
import '../../state/settings.dart';
|
||||||
import '../player_settings.dart' show applyShaderChoice;
|
import '../player_settings.dart' show applyShaderChoice;
|
||||||
import '../volume.dart' as vol;
|
import '../volume.dart' as vol;
|
||||||
@@ -82,16 +81,19 @@ class PlayerControlsModel {
|
|||||||
/// Everything the layout depends on (not the position: that changes
|
/// Everything the layout depends on (not the position: that changes
|
||||||
/// four times a second and only the timeline needs it).
|
/// four times a second and only the timeline needs it).
|
||||||
late final Computed<ControlsSnapshot> snapshot = computed(() {
|
late final Computed<ControlsSnapshot> snapshot = computed(() {
|
||||||
final items = queue.items.value;
|
final queued = queue.items.value.firstOrNull;
|
||||||
|
final continues = queue.continues.value;
|
||||||
final next = coord.nextEpisode.value;
|
final next = coord.nextEpisode.value;
|
||||||
return ControlsSnapshot(
|
return ControlsSnapshot(
|
||||||
play: play.value,
|
play: play.value,
|
||||||
previousLabel: coord.previousEpisode.value?.label,
|
previousLabel: coord.previousEpisode.value?.label,
|
||||||
nextLabel: items.isNotEmpty ? items.first.title : next?.label,
|
// Up next's first item, else the next episode, else what continues
|
||||||
nextIsUpNext: items.isNotEmpty,
|
// this (a file's next episode).
|
||||||
|
nextLabel: queued?.title ?? next?.label ?? continues?.title,
|
||||||
|
nextIsUpNext: queued != null,
|
||||||
hasChapters: chapters.value.isNotEmpty,
|
hasChapters: chapters.value.isNotEmpty,
|
||||||
hasDuration: duration.value > Duration.zero,
|
hasDuration: duration.value > Duration.zero,
|
||||||
solo: SessionState.instance.solo.value,
|
alone: coord.alone.value,
|
||||||
// Not a signal: read again whenever the state changes.
|
// Not a signal: read again whenever the state changes.
|
||||||
hasLink: coord.serverState.value != null && coord.currentLink() != null,
|
hasLink: coord.serverState.value != null && coord.currentLink() != null,
|
||||||
fullscreen: fullscreen.value,
|
fullscreen: fullscreen.value,
|
||||||
@@ -113,6 +115,12 @@ class PlayerControlsModel {
|
|||||||
/// A menu of the controls is open (the player's Esc closes it).
|
/// A menu of the controls is open (the player's Esc closes it).
|
||||||
int menusOpen = 0;
|
int menusOpen = 0;
|
||||||
|
|
||||||
|
/// The player panel docked at the right (⚙ → Player settings; Esc or
|
||||||
|
/// its × closes it). Stays open while the controls hide and show.
|
||||||
|
final panelOpen = signal(false);
|
||||||
|
|
||||||
|
void togglePanel() => panelOpen.value = !panelOpen.peek();
|
||||||
|
|
||||||
void dispose() => volumeFlash.dispose();
|
void dispose() => volumeFlash.dispose();
|
||||||
|
|
||||||
// -------------------------------------------------------------- actions
|
// -------------------------------------------------------------- actions
|
||||||
@@ -122,7 +130,7 @@ class PlayerControlsModel {
|
|||||||
|
|
||||||
/// A seek bar's press or drag; through `player.seek`, the user-seek
|
/// A seek bar's press or drag; through `player.seek`, the user-seek
|
||||||
/// path, so everyone follows.
|
/// path, so everyone follows.
|
||||||
void seek(Duration to) => playback.player.seek(to);
|
void seek(Duration to) => playback.userSeek(to);
|
||||||
|
|
||||||
/// Arrow keys: ±[secs], relative to a seek still in flight.
|
/// Arrow keys: ±[secs], relative to a seek still in flight.
|
||||||
void nudge(double secs) => coord.nudge(secs);
|
void nudge(double secs) => coord.nudge(secs);
|
||||||
@@ -138,9 +146,10 @@ class PlayerControlsModel {
|
|||||||
|
|
||||||
void pickChapter(MediaChapter c) => coord.seekTo(c.start.inMicroseconds / 1e6);
|
void pickChapter(MediaChapter c) => coord.seekTo(c.start.inMicroseconds / 1e6);
|
||||||
|
|
||||||
/// Up next's first item, else the next episode. False when neither.
|
/// What plays next (Up next's first item, else the continuation), else
|
||||||
|
/// the next episode. False when neither.
|
||||||
bool next() {
|
bool next() {
|
||||||
if (queue.items.peek().isNotEmpty) {
|
if (queue.head.peek() != null) {
|
||||||
queue.skip();
|
queue.skip();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -158,7 +167,7 @@ class PlayerControlsModel {
|
|||||||
|
|
||||||
void playNextEpisode() => coord.playNextEpisode();
|
void playNextEpisode() => coord.playNextEpisode();
|
||||||
|
|
||||||
void setVolume(double v) => playback.player.setVolume(v.clamp(0.0, vol.maxVolume));
|
void setVolume(double v) => playback.setVolume(v.clamp(0.0, vol.maxVolume));
|
||||||
|
|
||||||
/// One volume step up (+1) or down (-1) from the wheel or ↑ / ↓;
|
/// One volume step up (+1) or down (-1) from the wheel or ↑ / ↓;
|
||||||
/// flashes the volume readout.
|
/// flashes the volume readout.
|
||||||
@@ -184,7 +193,7 @@ class PlayerControlsModel {
|
|||||||
|
|
||||||
/// Saved as the choice and applied live; a null quality keeps the
|
/// Saved as the choice and applied live; a null quality keeps the
|
||||||
/// saved one.
|
/// saved one.
|
||||||
void pickShader(ShaderPreset preset, ShaderQuality? quality) => applyShaderChoice(preset, quality);
|
void pickShader(ShaderPreset preset, ShaderQuality? quality) => applyShaderChoice(coord, preset, quality);
|
||||||
|
|
||||||
void toggleTimeDisplay() => settings.setTimeDisplay(settings.timeDisplay.peek().toggled);
|
void toggleTimeDisplay() => settings.setTimeDisplay(settings.timeDisplay.peek().toggled);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,454 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:signals/signals_flutter.dart';
|
||||||
|
import 'package:sync_relay_core/core.dart' show JellyfinStreamType;
|
||||||
|
|
||||||
|
import '../app/app_graph.dart';
|
||||||
|
import '../danmaku/danmaku_settings.dart' show DanmakuKnobs, NotifyStyleSelect;
|
||||||
|
import '../playback/playback_controller.dart' show RenderMode;
|
||||||
|
import '../shaders/shader_presets.dart';
|
||||||
|
import '../shell/mark_problem.dart' show MarkProblemButton;
|
||||||
|
import '../shell/motion.dart';
|
||||||
|
import '../state/settings.dart';
|
||||||
|
import '../widgets/app_menu.dart';
|
||||||
|
import 'model/controls_prefs.dart';
|
||||||
|
import 'player_settings.dart' show applyHwdecLive, applyShaderChoice;
|
||||||
|
import 'volume.dart' as vol;
|
||||||
|
|
||||||
|
// The player panel (⚙ → Player settings, both skins): a narrow glass
|
||||||
|
// column docked at the right edge of the full player that stays open
|
||||||
|
// while the controls hide and show, until its × or Esc. What is useful
|
||||||
|
// while watching, in collapsible sections: tracks, subtitles, picture,
|
||||||
|
// sound, notifications (toasts or danmaku, the danmaku look) and the
|
||||||
|
// controls. Everything applies live and is saved; the rest of Settings →
|
||||||
|
// Player stays there.
|
||||||
|
|
||||||
|
/// Sections folded by the user, kept for the app's run.
|
||||||
|
final _folded = <String>{'Notifications', 'Controls'};
|
||||||
|
|
||||||
|
class PlayerPanel extends StatelessWidget {
|
||||||
|
static const width = 312.0;
|
||||||
|
final VoidCallback onClose;
|
||||||
|
|
||||||
|
/// The running player's render mode (shader and sharp video notes).
|
||||||
|
final ReadonlySignal<RenderMode>? renderMode;
|
||||||
|
|
||||||
|
/// The player's volume (0..[vol.maxVolume]) and how to set it; the
|
||||||
|
/// Sound section only with both.
|
||||||
|
final ReadonlySignal<double>? volume;
|
||||||
|
final ValueChanged<double>? onVolume;
|
||||||
|
const PlayerPanel({super.key, required this.onClose, this.renderMode, this.volume, this.onVolume});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final graph = AppScope.of(context);
|
||||||
|
final s = graph.coordinator.settings;
|
||||||
|
return AppGlass(
|
||||||
|
key: const ValueKey('player-panel'),
|
||||||
|
child: SizedBox(
|
||||||
|
width: width,
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 8, 6, 2),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.tune_rounded, size: 18, color: Colors.white70),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
const Expanded(
|
||||||
|
child: Text('Player', style: TextStyle(fontWeight: FontWeight.w600, fontSize: 14)),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
key: const ValueKey('player-panel-close'),
|
||||||
|
tooltip: 'Close (Esc)',
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
iconSize: 18,
|
||||||
|
onPressed: onClose,
|
||||||
|
icon: const Icon(Icons.close_rounded),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Flexible(
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 0, 16, 10),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
const _Section(title: 'Tracks', children: [
|
||||||
|
_TrackRow(type: JellyfinStreamType.subtitle),
|
||||||
|
_TrackRow(type: JellyfinStreamType.audio),
|
||||||
|
]),
|
||||||
|
_Section(title: 'Subtitles', children: [
|
||||||
|
SignalBuilder(
|
||||||
|
builder: (context) => _SliderRow(
|
||||||
|
key: const ValueKey('panel-sub-scale'),
|
||||||
|
label: 'Size',
|
||||||
|
value: s.subScale.value,
|
||||||
|
min: 0.5,
|
||||||
|
max: 2,
|
||||||
|
divisions: 30,
|
||||||
|
readout: '${(s.subScale.value * 100).round()} %',
|
||||||
|
onChanged: s.setSubScale,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SignalBuilder(
|
||||||
|
builder: (context) => _SwitchRow(
|
||||||
|
key: const ValueKey('panel-lift'),
|
||||||
|
label: 'Above the controls',
|
||||||
|
help: 'Bottom lines move up while the control bar shows (the chat box always lifts them)',
|
||||||
|
value: s.liftSubtitles.value,
|
||||||
|
onChanged: s.setLiftSubtitles,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
_Section(title: 'Picture', children: [
|
||||||
|
_ShaderRow(renderMode: renderMode),
|
||||||
|
SignalBuilder(builder: (context) {
|
||||||
|
final m = renderMode?.value;
|
||||||
|
final software = m == RenderMode.software || m == RenderMode.softwareGl;
|
||||||
|
return _SwitchRow(
|
||||||
|
key: const ValueKey('panel-sharp'),
|
||||||
|
label: 'Sharp video and subtitles',
|
||||||
|
help: software ? 'No effect: software rendering' : "At your screen's resolution",
|
||||||
|
value: s.screenResVideo.value,
|
||||||
|
onChanged: s.setScreenResVideo,
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
SignalBuilder(
|
||||||
|
builder: (context) => _Row(
|
||||||
|
label: 'Hardware decoding',
|
||||||
|
control: AppSelect<HwdecMode>(
|
||||||
|
key: const ValueKey('panel-hwdec'),
|
||||||
|
value: s.hwdecMode.value,
|
||||||
|
items: const [(HwdecMode.auto, 'Auto'), (HwdecMode.off, 'Off'), (HwdecMode.custom, 'Custom')],
|
||||||
|
onChanged: (m) {
|
||||||
|
s.setHwdecMode(m);
|
||||||
|
applyHwdecLive(graph.coordinator.playback);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
if (volume case final v?)
|
||||||
|
_Section(title: 'Sound', children: [
|
||||||
|
SignalBuilder(
|
||||||
|
builder: (context) => _SliderRow(
|
||||||
|
key: const ValueKey('panel-volume'),
|
||||||
|
label: 'Volume',
|
||||||
|
value: v.value,
|
||||||
|
min: 0,
|
||||||
|
max: vol.maxVolume,
|
||||||
|
readout: '${v.value.round()} %',
|
||||||
|
onChanged: onVolume,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
_Section(title: 'Notifications', children: [
|
||||||
|
_Row(label: 'Show as', control: NotifyStyleSelect(settings: s)),
|
||||||
|
SignalBuilder(
|
||||||
|
builder: (context) => AnimatedSize(
|
||||||
|
duration: easeTime,
|
||||||
|
curve: ease,
|
||||||
|
alignment: Alignment.topCenter,
|
||||||
|
child: s.notifyStyle.value == NotifyStyle.danmaku
|
||||||
|
? DanmakuKnobs(settings: s, dense: true)
|
||||||
|
: const SizedBox(width: double.infinity),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
_Section(title: 'Controls', children: [
|
||||||
|
SignalBuilder(
|
||||||
|
builder: (context) => _Row(
|
||||||
|
label: 'Design',
|
||||||
|
control: AppSelect<ControlsSkin>(
|
||||||
|
key: const ValueKey('panel-design'),
|
||||||
|
value: s.controlsSkin.value,
|
||||||
|
items: [for (final k in ControlsSkin.values) (k, k.label)],
|
||||||
|
onChanged: s.setControlsSkin,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SignalBuilder(
|
||||||
|
builder: (context) => _SwitchRow(
|
||||||
|
key: const ValueKey('panel-progress-line'),
|
||||||
|
label: 'Progress line when hidden',
|
||||||
|
value: s.progressLine.value,
|
||||||
|
onChanged: s.setProgressLine,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'More in Settings → Player',
|
||||||
|
style: TextStyle(color: Colors.white.withValues(alpha: 0.5), fontSize: 12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const MarkProblemButton(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A titled group that folds (its title is the toggle).
|
||||||
|
class _Section extends StatefulWidget {
|
||||||
|
final String title;
|
||||||
|
final List<Widget> children;
|
||||||
|
const _Section({required this.title, required this.children});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_Section> createState() => _SectionState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SectionState extends State<_Section> {
|
||||||
|
late bool _open = !_folded.contains(widget.title);
|
||||||
|
|
||||||
|
void _toggle() {
|
||||||
|
setState(() => _open = !_open);
|
||||||
|
_open ? _folded.remove(widget.title) : _folded.add(widget.title);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final muted = Colors.white.withValues(alpha: 0.55);
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
InkWell(
|
||||||
|
key: ValueKey('panel-section-${widget.title}'),
|
||||||
|
borderRadius: BorderRadius.circular(AppMenuStyle.rowRadius),
|
||||||
|
onTap: _toggle,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 10, bottom: 4),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(widget.title.toUpperCase(),
|
||||||
|
style: TextStyle(fontSize: 11, letterSpacing: 0.8, fontWeight: FontWeight.w600, color: muted)),
|
||||||
|
),
|
||||||
|
AnimatedRotation(
|
||||||
|
turns: _open ? 0 : -0.25,
|
||||||
|
duration: easeTime,
|
||||||
|
curve: ease,
|
||||||
|
child: Icon(Icons.expand_more_rounded, size: 18, color: muted),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
AnimatedSize(
|
||||||
|
duration: easeTime,
|
||||||
|
curve: ease,
|
||||||
|
alignment: Alignment.topCenter,
|
||||||
|
child: _open
|
||||||
|
? Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: widget.children)
|
||||||
|
: const SizedBox(width: double.infinity),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _Row extends StatelessWidget {
|
||||||
|
final String label;
|
||||||
|
final Widget control;
|
||||||
|
const _Row({required this.label, required this.control});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => SizedBox(
|
||||||
|
height: 40,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(child: Text(label, style: const TextStyle(fontSize: 13))),
|
||||||
|
Flexible(flex: 2, child: Align(alignment: Alignment.centerRight, child: control)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SwitchRow extends StatelessWidget {
|
||||||
|
final String label;
|
||||||
|
final String? help;
|
||||||
|
final bool value;
|
||||||
|
final ValueChanged<bool> onChanged;
|
||||||
|
const _SwitchRow({super.key, required this.label, this.help, required this.value, required this.onChanged});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(label, style: const TextStyle(fontSize: 13)),
|
||||||
|
if (help != null)
|
||||||
|
Text(help!, style: TextStyle(fontSize: 11.5, color: Colors.white.withValues(alpha: 0.5))),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Transform.scale(
|
||||||
|
scale: 0.8,
|
||||||
|
alignment: Alignment.centerRight,
|
||||||
|
child: Switch(value: value, onChanged: onChanged),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SliderRow extends StatelessWidget {
|
||||||
|
final String label;
|
||||||
|
final double value, min, max;
|
||||||
|
final int? divisions;
|
||||||
|
final String readout;
|
||||||
|
final ValueChanged<double>? onChanged;
|
||||||
|
const _SliderRow({
|
||||||
|
super.key,
|
||||||
|
required this.label,
|
||||||
|
required this.value,
|
||||||
|
required this.min,
|
||||||
|
required this.max,
|
||||||
|
this.divisions,
|
||||||
|
required this.readout,
|
||||||
|
required this.onChanged,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => SizedBox(
|
||||||
|
height: 38,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
SizedBox(width: 84, child: Text(label, style: const TextStyle(fontSize: 13))),
|
||||||
|
Expanded(
|
||||||
|
child: SliderTheme(
|
||||||
|
data: SliderTheme.of(context).copyWith(
|
||||||
|
trackHeight: 3,
|
||||||
|
overlayShape: SliderComponentShape.noOverlay,
|
||||||
|
thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 7),
|
||||||
|
tickMarkShape: SliderTickMarkShape.noTickMark,
|
||||||
|
),
|
||||||
|
child: Slider(value: value.clamp(min, max), min: min, max: max, divisions: divisions, onChanged: onChanged),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(
|
||||||
|
width: 52,
|
||||||
|
child: Text(readout,
|
||||||
|
textAlign: TextAlign.right,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: Colors.white.withValues(alpha: 0.7),
|
||||||
|
fontFeatures: const [FontFeature.tabularFigures()],
|
||||||
|
)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Subtitle or audio track of the running player; remembered for the
|
||||||
|
/// next episode.
|
||||||
|
class _TrackRow extends StatelessWidget {
|
||||||
|
final JellyfinStreamType type;
|
||||||
|
const _TrackRow({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];
|
||||||
|
final selected = list.where((t) => t.selected).firstOrNull?.id ?? (_audio ? null : -1);
|
||||||
|
final items = <(int, String)>[
|
||||||
|
if (!_audio) (-1, 'Off'),
|
||||||
|
for (final t in list) (t.id, t.label),
|
||||||
|
];
|
||||||
|
final key = ValueKey(_audio ? 'panel-audio' : 'panel-subtitles');
|
||||||
|
return _Row(
|
||||||
|
label: _audio ? 'Audio' : 'Subtitles',
|
||||||
|
control: items.isEmpty || selected == null
|
||||||
|
? Text(items.isEmpty ? 'None' : 'Pick one…', key: key, style: const TextStyle(fontSize: 13))
|
||||||
|
: AppSelect<int>(
|
||||||
|
key: key,
|
||||||
|
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 upscaling shader: Anime4K mode (or custom files) and HQ / Fast.
|
||||||
|
class _ShaderRow extends StatelessWidget {
|
||||||
|
final ReadonlySignal<RenderMode>? renderMode;
|
||||||
|
const _ShaderRow({this.renderMode});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final coord = AppScope.of(context).coordinator;
|
||||||
|
final s = coord.settings;
|
||||||
|
return SignalBuilder(builder: (context) {
|
||||||
|
final preset = s.shaderPreset.value;
|
||||||
|
final hasCustom = s.customShaders.value.isNotEmpty;
|
||||||
|
final software = switch (renderMode?.value) {
|
||||||
|
RenderMode.software || RenderMode.softwareGl => true,
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
_Row(
|
||||||
|
label: 'Upscaling',
|
||||||
|
control: AppSelect<ShaderPreset>(
|
||||||
|
key: const ValueKey('panel-shader'),
|
||||||
|
value: preset,
|
||||||
|
items: [
|
||||||
|
for (final p in ShaderPreset.values)
|
||||||
|
if (p != ShaderPreset.custom || hasCustom || preset == p) (p, p.label),
|
||||||
|
],
|
||||||
|
onChanged: (p) => applyShaderChoice(coord, p, null),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (preset.isAnime4k)
|
||||||
|
_Row(
|
||||||
|
label: 'Shader quality',
|
||||||
|
control: AppSelect<ShaderQuality>(
|
||||||
|
key: const ValueKey('panel-shader-quality'),
|
||||||
|
value: s.shaderQuality.value,
|
||||||
|
items: [for (final q in ShaderQuality.values) (q, q.label)],
|
||||||
|
onChanged: (q) => applyShaderChoice(coord, preset, q),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (software && preset != ShaderPreset.off)
|
||||||
|
const Text('Software rendering: shaders have little or no effect',
|
||||||
|
style: TextStyle(fontSize: 11.5, color: Colors.orange)),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,22 +2,19 @@ import 'dart:io';
|
|||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:signals/signals_flutter.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 '../playback/playback_controller.dart' show PlaybackController, RenderMode;
|
||||||
|
import '../session/session_coordinator.dart' show SessionCoordinator;
|
||||||
import '../shaders/shader_presets.dart';
|
import '../shaders/shader_presets.dart';
|
||||||
import '../state/session_state.dart';
|
import '../app/app_graph.dart';
|
||||||
import '../shell/mark_problem.dart';
|
|
||||||
import '../shell/motion.dart';
|
|
||||||
import '../state/settings.dart';
|
import '../state/settings.dart';
|
||||||
import '../widgets/app_menu.dart';
|
import '../widgets/app_menu.dart';
|
||||||
import 'frame.dart';
|
|
||||||
import 'model/controls_prefs.dart';
|
import 'model/controls_prefs.dart';
|
||||||
|
|
||||||
// Everything about the player, defined once: the rows of the player
|
// Everything about the player, defined once: the rows of Settings →
|
||||||
// settings card (⚙ → Player settings, both skins) and of Settings → Player. Each row reads and
|
// Player (the player panel, `player_panel.dart`, has compact rows for the
|
||||||
// writes [AppSettings] and applies to a running player right away.
|
// same settings). 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
|
/// 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).
|
/// right ([below]: an extra line under the row, e.g. a text field).
|
||||||
@@ -198,13 +195,41 @@ class ProgressLineSetting extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The video output at the player's size on screen ([AppSettings.screenResVideo]).
|
||||||
|
class ScreenResSetting extends StatelessWidget {
|
||||||
|
final ReadonlySignal<RenderMode>? renderMode;
|
||||||
|
const ScreenResSetting({super.key, this.renderMode});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final s = AppScope.of(context).coordinator.settings;
|
||||||
|
return SignalBuilder(builder: (context) {
|
||||||
|
final software = switch (renderMode?.value) {
|
||||||
|
RenderMode.software || RenderMode.softwareGl => true,
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
return SettingRow(
|
||||||
|
label: 'Sharp video and subtitles',
|
||||||
|
help: software
|
||||||
|
? 'No effect: video uses software rendering'
|
||||||
|
: "Scale the picture and draw subtitles at your screen's resolution, not the video's. "
|
||||||
|
'Off: lighter on a weak GPU',
|
||||||
|
control: Switch(
|
||||||
|
key: const ValueKey('screen-res-video'),
|
||||||
|
value: s.screenResVideo.value,
|
||||||
|
onChanged: s.setScreenResVideo,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Saves a shader choice (also used on the next join) and puts it on the
|
/// Saves a shader choice (also used on the next join) and puts it on the
|
||||||
/// running player, if any; the player announces the result.
|
/// running player, if any; the player announces the result.
|
||||||
void applyShaderChoice(ShaderPreset preset, ShaderQuality? quality) {
|
void applyShaderChoice(SessionCoordinator coord, ShaderPreset preset, ShaderQuality? quality) {
|
||||||
final s = AppSettings.instance;
|
final s = AppSettings.instance;
|
||||||
if (quality != null) s.setShaderQuality(quality);
|
if (quality != null) s.setShaderQuality(quality);
|
||||||
s.setShaderPreset(preset);
|
s.setShaderPreset(preset);
|
||||||
final coord = SessionState.instance.coordinator;
|
|
||||||
coord.markInput();
|
coord.markInput();
|
||||||
coord.playback.applyShaders(announce: true);
|
coord.playback.applyShaders(announce: true);
|
||||||
}
|
}
|
||||||
@@ -222,7 +247,9 @@ class ShaderPresetSetting extends StatelessWidget {
|
|||||||
final preset = s.shaderPreset.value;
|
final preset = s.shaderPreset.value;
|
||||||
final quality = s.shaderQuality.value;
|
final quality = s.shaderQuality.value;
|
||||||
final hasCustom = s.customShaders.value.isNotEmpty;
|
final hasCustom = s.customShaders.value.isNotEmpty;
|
||||||
final mode = (renderMode ?? PlaybackController.lastRenderMode).value;
|
final mode = (renderMode ??
|
||||||
|
context.getInheritedWidgetOfExactType<AppScope>()?.graph.ports.players.renderMode)
|
||||||
|
?.value;
|
||||||
final warning = switch (mode) {
|
final warning = switch (mode) {
|
||||||
RenderMode.software => 'Software rendering: shaders have no effect',
|
RenderMode.software => 'Software rendering: shaders have no effect',
|
||||||
RenderMode.softwareGl => 'OpenGL on the CPU: shaders will be slow',
|
RenderMode.softwareGl => 'OpenGL on the CPU: shaders will be slow',
|
||||||
@@ -242,7 +269,7 @@ class ShaderPresetSetting extends StatelessWidget {
|
|||||||
for (final p in ShaderPreset.values)
|
for (final p in ShaderPreset.values)
|
||||||
if (p != ShaderPreset.custom || hasCustom || preset == p) (p, p.label),
|
if (p != ShaderPreset.custom || hasCustom || preset == p) (p, p.label),
|
||||||
],
|
],
|
||||||
onChanged: (p) => applyShaderChoice(p, null),
|
onChanged: (p) => applyShaderChoice(AppScope.of(context).coordinator, p, null),
|
||||||
),
|
),
|
||||||
if (preset.isAnime4k) ...[
|
if (preset.isAnime4k) ...[
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
@@ -250,7 +277,7 @@ class ShaderPresetSetting extends StatelessWidget {
|
|||||||
key: const ValueKey('shader-quality'),
|
key: const ValueKey('shader-quality'),
|
||||||
value: quality,
|
value: quality,
|
||||||
items: [for (final q in ShaderQuality.values) (q, q.label)],
|
items: [for (final q in ShaderQuality.values) (q, q.label)],
|
||||||
onChanged: (q) => applyShaderChoice(preset, q),
|
onChanged: (q) => applyShaderChoice(AppScope.of(context).coordinator, preset, q),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
@@ -265,13 +292,12 @@ class ShaderPresetSetting extends StatelessWidget {
|
|||||||
|
|
||||||
/// Puts the hwdec setting on the running player (mpv switches decoders
|
/// Puts the hwdec setting on the running player (mpv switches decoders
|
||||||
/// on the fly). `SYNC_RELAY_HWDEC` wins, as at player start.
|
/// on the fly). `SYNC_RELAY_HWDEC` wins, as at player start.
|
||||||
Future<void> applyHwdecLive() async {
|
Future<void> applyHwdecLive(PlaybackController playback) async {
|
||||||
if ((Platform.environment['SYNC_RELAY_HWDEC'] ?? '').isNotEmpty) return;
|
if ((Platform.environment['SYNC_RELAY_HWDEC'] ?? '').isNotEmpty) return;
|
||||||
final playback = SessionState.instance.coordinator.playback;
|
final p = playback.current;
|
||||||
if (!playback.alive.peek()) return;
|
if (p == null) return;
|
||||||
try {
|
try {
|
||||||
final dynamic platform = playback.player.platform;
|
await p.setProperty('hwdec', AppSettings.instance.hwdec.peek());
|
||||||
await platform?.setProperty('hwdec', AppSettings.instance.hwdec.peek());
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('live hwdec failed: $e');
|
debugPrint('live hwdec failed: $e');
|
||||||
}
|
}
|
||||||
@@ -308,7 +334,7 @@ class _HwdecSettingState extends State<HwdecSetting> {
|
|||||||
items: const [(HwdecMode.auto, 'Auto'), (HwdecMode.off, 'Off'), (HwdecMode.custom, 'Custom')],
|
items: const [(HwdecMode.auto, 'Auto'), (HwdecMode.off, 'Off'), (HwdecMode.custom, 'Custom')],
|
||||||
onChanged: (m) {
|
onChanged: (m) {
|
||||||
_s.setHwdecMode(m);
|
_s.setHwdecMode(m);
|
||||||
applyHwdecLive();
|
applyHwdecLive(AppScope.of(context).coordinator.playback);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
below: mode != HwdecMode.custom
|
below: mode != HwdecMode.custom
|
||||||
@@ -324,59 +350,7 @@ class _HwdecSettingState extends State<HwdecSetting> {
|
|||||||
border: OutlineInputBorder(),
|
border: OutlineInputBorder(),
|
||||||
),
|
),
|
||||||
onChanged: _s.setHwdecCustom,
|
onChanged: _s.setHwdecCustom,
|
||||||
onSubmitted: (_) => applyHwdecLive(),
|
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 = SessionState.instance.coordinator;
|
|
||||||
return SignalBuilder(builder: (context) {
|
|
||||||
final list = [
|
|
||||||
for (final t in coord.playback.tracks.value)
|
|
||||||
if (t.type == type) t,
|
|
||||||
];
|
|
||||||
// -1: subtitles off.
|
|
||||||
final selected = list.where((t) => t.selected).firstOrNull?.id ?? (_audio ? null : -1);
|
|
||||||
final items = <(int, String)>[
|
|
||||||
if (!_audio) (-1, 'Off'),
|
|
||||||
for (final MpvTrack t in list) (t.id, t.label),
|
|
||||||
];
|
|
||||||
return SettingRow(
|
|
||||||
label: _audio ? 'Audio' : 'Subtitles',
|
|
||||||
help: 'Remembered for the next episode',
|
|
||||||
control: SizedBox(
|
|
||||||
width: 190,
|
|
||||||
child: items.isEmpty || selected == null
|
|
||||||
? Text(items.isEmpty ? 'None' : 'Pick one…',
|
|
||||||
key: ValueKey(_audio ? 'audio-track' : 'subtitle-track'), textAlign: TextAlign.end)
|
|
||||||
: _dropdown<int>(
|
|
||||||
key: ValueKey(_audio ? 'audio-track' : 'subtitle-track'),
|
|
||||||
value: selected,
|
|
||||||
expanded: true,
|
|
||||||
items: items,
|
|
||||||
onChanged: (id) {
|
|
||||||
coord.markInput();
|
|
||||||
if (id == -1) {
|
|
||||||
coord.selectTrack(subtitlesOff: true);
|
|
||||||
} else if (_audio) {
|
|
||||||
coord.selectTrack(aid: id);
|
|
||||||
} else {
|
|
||||||
coord.selectTrack(sid: id);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -396,57 +370,38 @@ List<Widget> controlsSettingRows() => [
|
|||||||
for (final c in OptionalControl.values) BarButtonSetting(control: c),
|
for (final c in OptionalControl.values) BarButtonSetting(control: c),
|
||||||
];
|
];
|
||||||
|
|
||||||
/// The player rows of Settings → Player and the player settings card, in
|
/// The player rows of Settings → Player, in order (the player panel has
|
||||||
/// order.
|
/// its own compact rows for the same settings).
|
||||||
List<Widget> playerSettingRows({ReadonlySignal<RenderMode>? renderMode}) => [
|
List<Widget> playerSettingRows({ReadonlySignal<RenderMode>? renderMode}) => [
|
||||||
const PlayerDesignSetting(),
|
const PlayerDesignSetting(),
|
||||||
const ProgressLineSetting(),
|
const ProgressLineSetting(),
|
||||||
ShaderPresetSetting(renderMode: renderMode),
|
ShaderPresetSetting(renderMode: renderMode),
|
||||||
|
ScreenResSetting(renderMode: renderMode),
|
||||||
const HwdecSetting(),
|
const HwdecSetting(),
|
||||||
|
const SubScaleSetting(),
|
||||||
|
const LiftSubtitlesSetting(),
|
||||||
];
|
];
|
||||||
|
|
||||||
/// The player settings card: every player setting, plus the running
|
/// Subtitle size (mpv `sub-scale`, [AppSettings.subScale]). Live.
|
||||||
/// player's tracks.
|
class SubScaleSetting extends StatelessWidget {
|
||||||
class PlayerSettingsCard extends StatelessWidget {
|
const SubScaleSetting({super.key});
|
||||||
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
// The app's glass, like the Up next popover and the menus.
|
final s = AppScope.of(context).coordinator.settings;
|
||||||
return AppGlass(
|
return SignalBuilder(
|
||||||
key: const ValueKey('player-settings-card'),
|
builder: (context) => SettingRow(
|
||||||
child: SizedBox(
|
label: 'Subtitle size',
|
||||||
width: 420,
|
help: '${(s.subScale.value * 100).round()} % of what the file sets',
|
||||||
child: Padding(
|
control: SizedBox(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 10),
|
width: 220,
|
||||||
child: Column(
|
child: Slider(
|
||||||
mainAxisSize: MainAxisSize.min,
|
key: const ValueKey('sub-scale'),
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
value: s.subScale.value,
|
||||||
children: [
|
min: 0.5,
|
||||||
const Text('Player', style: TextStyle(fontWeight: FontWeight.w600, fontSize: 14)),
|
max: 2,
|
||||||
const SizedBox(height: 4),
|
divisions: 30,
|
||||||
...playerSettingRows(renderMode: renderMode),
|
onChanged: s.setSubScale,
|
||||||
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(),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -454,14 +409,25 @@ class PlayerSettingsCard extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Opens the player settings card over the player, near the controls'
|
/// Bottom subtitles above the control bar while it is up
|
||||||
/// right end; the controls stay up while it is open.
|
/// ([AppSettings.liftSubtitles]). Live.
|
||||||
Future<void> showPlayerSettings(BuildContext context, {ReadonlySignal<RenderMode>? renderMode}) =>
|
class LiftSubtitlesSetting extends StatelessWidget {
|
||||||
ControlsFrame.holdWhile(
|
const LiftSubtitlesSetting({super.key});
|
||||||
context,
|
|
||||||
() => showPopover<void>(
|
@override
|
||||||
context,
|
Widget build(BuildContext context) {
|
||||||
at: const Alignment(0.96, 0.5),
|
final s = AppScope.of(context).coordinator.settings;
|
||||||
child: PlayerSettingsCard(renderMode: renderMode),
|
return SignalBuilder(
|
||||||
|
builder: (context) => SettingRow(
|
||||||
|
label: 'Subtitles above the controls',
|
||||||
|
help: 'Bottom lines move up while the control bar shows; signs at the top stay. '
|
||||||
|
'The chat box always moves them',
|
||||||
|
control: Switch(
|
||||||
|
key: const ValueKey('lift-subtitles'),
|
||||||
|
value: s.liftSubtitles.value,
|
||||||
|
onChanged: s.setLiftSubtitles,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'package:flutter/foundation.dart' show ValueListenable;
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:signals/signals_flutter.dart';
|
import 'package:signals/signals_flutter.dart';
|
||||||
|
|
||||||
@@ -34,6 +35,9 @@ class SessionControls extends StatelessWidget {
|
|||||||
final ControlsHold hold;
|
final ControlsHold hold;
|
||||||
final VoidCallback onDoubleClick;
|
final VoidCallback onDoubleClick;
|
||||||
final Widget? windowButtons;
|
final Widget? windowButtons;
|
||||||
|
|
||||||
|
/// Hides them while something takes their place ([ControlsFrame.suppress]).
|
||||||
|
final ValueListenable<bool>? suppress;
|
||||||
const SessionControls({
|
const SessionControls({
|
||||||
super.key,
|
super.key,
|
||||||
required this.model,
|
required this.model,
|
||||||
@@ -41,6 +45,7 @@ class SessionControls extends StatelessWidget {
|
|||||||
required this.hold,
|
required this.hold,
|
||||||
required this.onDoubleClick,
|
required this.onDoubleClick,
|
||||||
this.windowButtons,
|
this.windowButtons,
|
||||||
|
this.suppress,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -56,6 +61,7 @@ class SessionControls extends StatelessWidget {
|
|||||||
key: ValueKey(skin),
|
key: ValueKey(skin),
|
||||||
shown: shown,
|
shown: shown,
|
||||||
holdBy: hold,
|
holdBy: hold,
|
||||||
|
suppress: suppress,
|
||||||
hideAfter: Duration(seconds: s.controlsHideSecs.value),
|
hideAfter: Duration(seconds: s.controlsHideSecs.value),
|
||||||
onDoubleClick: onDoubleClick,
|
onDoubleClick: onDoubleClick,
|
||||||
barTop: controlsTopHeight(skin),
|
barTop: controlsTopHeight(skin),
|
||||||
|
|||||||
@@ -165,7 +165,7 @@ class StandardControls extends StatelessWidget {
|
|||||||
return Row(
|
return Row(
|
||||||
children: [
|
children: [
|
||||||
const Expanded(child: NowPlayingTitle()),
|
const Expanded(child: NowPlayingTitle()),
|
||||||
UpNextButton(queue: m.queue, onPlayNextEpisode: m.playNextEpisode, look: UpNextLook.standard, hold: hold),
|
UpNextButton(queue: m.queue, look: UpNextLook.standard, hold: hold),
|
||||||
SignalBuilder(
|
SignalBuilder(
|
||||||
builder: (context) {
|
builder: (context) {
|
||||||
final full = m.fullscreen.value;
|
final full = m.fullscreen.value;
|
||||||
|
|||||||
@@ -0,0 +1,582 @@
|
|||||||
|
import 'dart:collection';
|
||||||
|
import 'dart:math' as math;
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/scheduler.dart';
|
||||||
|
|
||||||
|
import '../shell/avatar.dart' show AvatarInfo, StatusAvatar;
|
||||||
|
|
||||||
|
// Danmaku (Bilibili / niconico style): short lines that fly right to left
|
||||||
|
// across the upper part of the picture. Each line takes a free lane (no
|
||||||
|
// two ever touch), at most [DanmakuStyle.maxOnScreen] fly at once and the
|
||||||
|
// rest wait their turn; every line crosses in the same time, so a long
|
||||||
|
// one moves faster. The clock is the layer's own: lines keep flying while
|
||||||
|
// the video is paused. The session's notifications use it when the user
|
||||||
|
// picks "Notifications as danmaku" (`danmaku_notices.dart`); the dev
|
||||||
|
// preview tries it out (DEVELOPMENT.md "Dev tools").
|
||||||
|
|
||||||
|
/// What a line is: a chat message or a short notice.
|
||||||
|
enum DanmakuKind { chat, join, leave, upNext, starting }
|
||||||
|
|
||||||
|
/// One line: [sender] (a coloured prefix, [senderColor]) and [text];
|
||||||
|
/// [own] lines (this member's) get a hairline frame. [avatar]: the
|
||||||
|
/// sender's picture (or initial) before it; the app's own notices have
|
||||||
|
/// none.
|
||||||
|
@immutable
|
||||||
|
class DanmakuItem {
|
||||||
|
final DanmakuKind kind;
|
||||||
|
final String? sender;
|
||||||
|
final Color? senderColor;
|
||||||
|
final String text;
|
||||||
|
final bool own;
|
||||||
|
final AvatarInfo? avatar;
|
||||||
|
const DanmakuItem(
|
||||||
|
{required this.kind, required this.text, this.sender, this.senderColor, this.own = false, this.avatar});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How lines look and move. Persisted as [toJson] (Settings →
|
||||||
|
/// Notifications & sounds); [DanmakuStyle.fromJson] clamps every value to
|
||||||
|
/// its range ([limits]), so a hand-edited or older box never breaks it.
|
||||||
|
@immutable
|
||||||
|
class DanmakuStyle {
|
||||||
|
/// Seconds a line takes from the right edge until it has left on the
|
||||||
|
/// left, whatever its length.
|
||||||
|
final double crossSecs;
|
||||||
|
|
||||||
|
/// Lines on screen at once; more wait.
|
||||||
|
final int maxOnScreen;
|
||||||
|
|
||||||
|
/// The band the lanes fill, as fractions of the height from the top:
|
||||||
|
/// below where top signs sit, never down into the dialogue.
|
||||||
|
final double bandTop;
|
||||||
|
final double bandBottom;
|
||||||
|
final double opacity;
|
||||||
|
|
||||||
|
/// Text size relative to the picture's height ([fontSizeFor]).
|
||||||
|
final double size;
|
||||||
|
|
||||||
|
/// Font weight of the text, 400 (regular) to 900 (black); the sender's
|
||||||
|
/// name is a step bolder.
|
||||||
|
final int weight;
|
||||||
|
|
||||||
|
/// Width of the dark outline round the white text, logical px.
|
||||||
|
final double outline;
|
||||||
|
final bool highlightOwn;
|
||||||
|
|
||||||
|
/// The sender's avatar before the line.
|
||||||
|
final bool avatars;
|
||||||
|
|
||||||
|
const DanmakuStyle({
|
||||||
|
this.crossSecs = 8,
|
||||||
|
this.maxOnScreen = 12,
|
||||||
|
this.bandTop = 0.10,
|
||||||
|
this.bandBottom = 0.45,
|
||||||
|
this.opacity = 0.92,
|
||||||
|
this.size = 1,
|
||||||
|
this.weight = 600,
|
||||||
|
this.outline = 1.4,
|
||||||
|
this.highlightOwn = true,
|
||||||
|
this.avatars = true,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// The range of each knob (the settings' sliders use them too).
|
||||||
|
static const limits = (
|
||||||
|
crossSecs: (4.0, 16.0),
|
||||||
|
maxOnScreen: (1, 40),
|
||||||
|
band: (0.0, 0.9),
|
||||||
|
minBand: 0.05,
|
||||||
|
opacity: (0.3, 1.0),
|
||||||
|
size: (0.6, 1.8),
|
||||||
|
weight: (400, 900),
|
||||||
|
outline: (0.0, 4.0),
|
||||||
|
);
|
||||||
|
|
||||||
|
DanmakuStyle copyWith({
|
||||||
|
double? crossSecs,
|
||||||
|
int? maxOnScreen,
|
||||||
|
double? bandTop,
|
||||||
|
double? bandBottom,
|
||||||
|
double? opacity,
|
||||||
|
double? size,
|
||||||
|
int? weight,
|
||||||
|
double? outline,
|
||||||
|
bool? highlightOwn,
|
||||||
|
bool? avatars,
|
||||||
|
}) =>
|
||||||
|
DanmakuStyle(
|
||||||
|
crossSecs: crossSecs ?? this.crossSecs,
|
||||||
|
maxOnScreen: maxOnScreen ?? this.maxOnScreen,
|
||||||
|
bandTop: bandTop ?? this.bandTop,
|
||||||
|
bandBottom: bandBottom ?? this.bandBottom,
|
||||||
|
opacity: opacity ?? this.opacity,
|
||||||
|
size: size ?? this.size,
|
||||||
|
weight: weight ?? this.weight,
|
||||||
|
outline: outline ?? this.outline,
|
||||||
|
highlightOwn: highlightOwn ?? this.highlightOwn,
|
||||||
|
avatars: avatars ?? this.avatars,
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, Object> toJson() => {
|
||||||
|
'crossSecs': crossSecs,
|
||||||
|
'maxOnScreen': maxOnScreen,
|
||||||
|
'bandTop': bandTop,
|
||||||
|
'bandBottom': bandBottom,
|
||||||
|
'opacity': opacity,
|
||||||
|
'size': size,
|
||||||
|
'weight': weight,
|
||||||
|
'outline': outline,
|
||||||
|
'highlightOwn': highlightOwn,
|
||||||
|
'avatars': avatars,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// [json] as stored ([toJson]); missing or wrong values take the
|
||||||
|
/// default, out-of-range ones the nearest end of their range.
|
||||||
|
factory DanmakuStyle.fromJson(Map<Object?, Object?> json) {
|
||||||
|
const d = DanmakuStyle();
|
||||||
|
const l = limits;
|
||||||
|
double num_(String k, double def, (double, double) r) {
|
||||||
|
final v = json[k];
|
||||||
|
return v is num && v.isFinite ? v.toDouble().clamp(r.$1, r.$2) : def;
|
||||||
|
}
|
||||||
|
|
||||||
|
int int_(String k, int def, (int, int) r) {
|
||||||
|
final v = json[k];
|
||||||
|
return v is num && v.isFinite ? v.round().clamp(r.$1, r.$2) : def;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool bool_(String k, bool def) {
|
||||||
|
final v = json[k];
|
||||||
|
return v is bool ? v : def;
|
||||||
|
}
|
||||||
|
|
||||||
|
var top = num_('bandTop', d.bandTop, l.band);
|
||||||
|
var bottom = num_('bandBottom', d.bandBottom, l.band);
|
||||||
|
if (bottom - top < l.minBand) (top, bottom) = (d.bandTop, d.bandBottom);
|
||||||
|
return DanmakuStyle(
|
||||||
|
crossSecs: num_('crossSecs', d.crossSecs, l.crossSecs),
|
||||||
|
maxOnScreen: int_('maxOnScreen', d.maxOnScreen, l.maxOnScreen),
|
||||||
|
bandTop: top,
|
||||||
|
bandBottom: bottom,
|
||||||
|
opacity: num_('opacity', d.opacity, l.opacity),
|
||||||
|
size: num_('size', d.size, l.size),
|
||||||
|
weight: (int_('weight', d.weight, l.weight) / 100).round() * 100,
|
||||||
|
outline: num_('outline', d.outline, l.outline),
|
||||||
|
highlightOwn: bool_('highlightOwn', d.highlightOwn),
|
||||||
|
avatars: bool_('avatars', d.avatars),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [weight] as a [FontWeight] (400..900 in steps of 100).
|
||||||
|
FontWeight get fontWeight => FontWeight.values[((weight.clamp(100, 900) / 100).round() - 1)];
|
||||||
|
|
||||||
|
/// The sender's name: a step bolder than the text.
|
||||||
|
FontWeight get nameWeight =>
|
||||||
|
FontWeight.values[math.min(FontWeight.values.length - 1, (weight.clamp(100, 900) / 100).round())];
|
||||||
|
|
||||||
|
/// The text size in a picture [height] px tall: 4.2 % of it (about 30 px
|
||||||
|
/// on a 720 px player), 13 to 44 px, times [size].
|
||||||
|
double fontSizeFor(double height) => (height * 0.042).clamp(13.0, 44.0) * size;
|
||||||
|
|
||||||
|
/// A lane: the line's height plus a little air.
|
||||||
|
double laneHeightFor(double height) => fontSizeFor(height) * 1.5;
|
||||||
|
|
||||||
|
/// Lanes in the band of a picture [height] px tall (at least one).
|
||||||
|
int lanesFor(double height) {
|
||||||
|
final band = (bandBottom - bandTop).clamp(0.0, 1.0) * height;
|
||||||
|
return math.max(1, (band / laneHeightFor(height)).floor());
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) =>
|
||||||
|
other is DanmakuStyle &&
|
||||||
|
other.crossSecs == crossSecs &&
|
||||||
|
other.maxOnScreen == maxOnScreen &&
|
||||||
|
other.bandTop == bandTop &&
|
||||||
|
other.bandBottom == bandBottom &&
|
||||||
|
other.opacity == opacity &&
|
||||||
|
other.size == size &&
|
||||||
|
other.weight == weight &&
|
||||||
|
other.outline == outline &&
|
||||||
|
other.highlightOwn == highlightOwn &&
|
||||||
|
other.avatars == avatars;
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode =>
|
||||||
|
Object.hash(crossSecs, maxOnScreen, bandTop, bandBottom, opacity, size, weight, outline, highlightOwn, avatars);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One line in flight in [lane], from [start] (seconds on the layer's
|
||||||
|
/// clock), [width] px wide, crossing in [crossSecs].
|
||||||
|
class DanmakuFlight<T> {
|
||||||
|
final T item;
|
||||||
|
final int lane;
|
||||||
|
final double start;
|
||||||
|
final double width;
|
||||||
|
final double crossSecs;
|
||||||
|
DanmakuFlight({required this.item, required this.lane, required this.start, required this.width, required this.crossSecs});
|
||||||
|
|
||||||
|
/// Its left edge at [t] in a picture [w] px wide: [w] at the start,
|
||||||
|
/// -[width] at the end.
|
||||||
|
double x(double t, double w) => w - (w + width) * ((t - start) / crossSecs);
|
||||||
|
double end() => start + crossSecs;
|
||||||
|
bool doneAt(double t) => t >= end();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lane assignment, pure (no widgets, a clock passed in): lines queue in
|
||||||
|
/// order of arrival; [tick] lets the waiting ones in while a lane is free
|
||||||
|
/// and fewer than the cap fly. A lane is free for a new line when the last
|
||||||
|
/// line in it has fully entered with [gap] behind it and the new one, at
|
||||||
|
/// its own speed, cannot catch up with it before it has left.
|
||||||
|
class DanmakuLanes<T> {
|
||||||
|
/// Space kept between two lines in a lane, px.
|
||||||
|
final double gap;
|
||||||
|
|
||||||
|
/// A line waiting longer than this is dropped (a burst on a crowded
|
||||||
|
/// picture would otherwise fly long after it was said).
|
||||||
|
final double maxWaitSecs;
|
||||||
|
|
||||||
|
/// Lines kept waiting at most; older ones go first when it is full.
|
||||||
|
final int maxWaiting;
|
||||||
|
|
||||||
|
DanmakuLanes({this.gap = 24, this.maxWaitSecs = 15, this.maxWaiting = 60});
|
||||||
|
|
||||||
|
final List<DanmakuFlight<T>> flights = [];
|
||||||
|
final Queue<(T, double)> _waiting = Queue();
|
||||||
|
int dropped = 0;
|
||||||
|
|
||||||
|
int get waiting => _waiting.length;
|
||||||
|
|
||||||
|
/// Everything flying and waiting goes; returns it.
|
||||||
|
List<T> clear() {
|
||||||
|
final all = [for (final f in flights) f.item, for (final w in _waiting) w.$1];
|
||||||
|
flights.clear();
|
||||||
|
_waiting.clear();
|
||||||
|
return all;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [item] arrived at [t]; it flies on the next [tick] that has room.
|
||||||
|
void add(T item, double t) {
|
||||||
|
_waiting.add((item, t));
|
||||||
|
while (_waiting.length > maxWaiting) {
|
||||||
|
_waiting.removeFirst();
|
||||||
|
dropped++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Advances to [t] in a picture [w] px wide with [lanes] lanes: drops
|
||||||
|
/// what has left and what waited too long, then places waiting lines in
|
||||||
|
/// order (the topmost free lane each) until the cap or no lane is free.
|
||||||
|
/// [measure] gives a line's width. Returns the lines placed.
|
||||||
|
List<DanmakuFlight<T>> tick(double t,
|
||||||
|
{required double w, required int lanes, required int cap, required double crossSecs, required double Function(T) measure}) {
|
||||||
|
flights.removeWhere((f) => f.doneAt(t));
|
||||||
|
while (_waiting.isNotEmpty && t - _waiting.first.$2 > maxWaitSecs) {
|
||||||
|
_waiting.removeFirst();
|
||||||
|
dropped++;
|
||||||
|
}
|
||||||
|
final placed = <DanmakuFlight<T>>[];
|
||||||
|
while (_waiting.isNotEmpty && flights.length < cap) {
|
||||||
|
final item = _waiting.first.$1;
|
||||||
|
final width = measure(item);
|
||||||
|
final lane = freeLane(t, w: w, lanes: lanes, width: width, crossSecs: crossSecs);
|
||||||
|
if (lane == null) break;
|
||||||
|
_waiting.removeFirst();
|
||||||
|
final f = DanmakuFlight(item: item, lane: lane, start: t, width: width, crossSecs: crossSecs);
|
||||||
|
flights.add(f);
|
||||||
|
placed.add(f);
|
||||||
|
}
|
||||||
|
return placed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The topmost lane a line [width] px wide can start in at [t], or null.
|
||||||
|
int? freeLane(double t, {required double w, required int lanes, required double width, required double crossSecs}) {
|
||||||
|
final last = <int, DanmakuFlight<T>>{};
|
||||||
|
for (final f in flights) {
|
||||||
|
final l = last[f.lane];
|
||||||
|
if (l == null || f.start > l.start) last[f.lane] = f;
|
||||||
|
}
|
||||||
|
for (var lane = 0; lane < lanes; lane++) {
|
||||||
|
final prev = last[lane];
|
||||||
|
if (prev == null || fits(prev, t, w: w, width: width, crossSecs: crossSecs, gap: gap)) return lane;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A new line ([width], [crossSecs]) may follow [prev] in its lane at [t].
|
||||||
|
/// Both move linearly, so checking now and the moment [prev] has left is
|
||||||
|
/// enough.
|
||||||
|
static bool fits(DanmakuFlight<Object?> prev, double t,
|
||||||
|
{required double w, required double width, required double crossSecs, required double gap}) {
|
||||||
|
// Fully in, with the gap behind it.
|
||||||
|
if (prev.x(t, w) + prev.width + gap > w) return false;
|
||||||
|
final leaves = prev.end();
|
||||||
|
if (leaves <= t) return true;
|
||||||
|
// Where the new line's head is when prev's tail reaches the left edge.
|
||||||
|
final head = w - (w + width) * ((leaves - t) / crossSecs);
|
||||||
|
return head >= gap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Feeds a [DanmakuLayer]; [onScreen] / [waiting] follow what the layer
|
||||||
|
/// does with the lines (for a status readout).
|
||||||
|
class DanmakuController extends ChangeNotifier {
|
||||||
|
final _inbox = <DanmakuItem>[];
|
||||||
|
final onScreen = ValueNotifier(0);
|
||||||
|
final waiting = ValueNotifier(0);
|
||||||
|
final dropped = ValueNotifier(0);
|
||||||
|
|
||||||
|
void add(DanmakuItem item) {
|
||||||
|
_inbox.add(item);
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Everything in flight and waiting goes.
|
||||||
|
void clear() {
|
||||||
|
_clear = true;
|
||||||
|
_inbox.clear();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _clear = false;
|
||||||
|
|
||||||
|
List<DanmakuItem> _take() {
|
||||||
|
final items = List.of(_inbox);
|
||||||
|
_inbox.clear();
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
onScreen.dispose();
|
||||||
|
waiting.dispose();
|
||||||
|
dropped.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The lines over a picture: fills its box, lets every click through.
|
||||||
|
/// Each line is a widget (avatar, outlined text, own frame) moved by a
|
||||||
|
/// [Flow] on every frame: only paint runs per frame, a build only when a
|
||||||
|
/// line comes or goes.
|
||||||
|
class DanmakuLayer extends StatefulWidget {
|
||||||
|
final DanmakuController controller;
|
||||||
|
final DanmakuStyle style;
|
||||||
|
|
||||||
|
/// The accent of [DanmakuItem.own] lines' frame and of "starting now".
|
||||||
|
final Color accent;
|
||||||
|
const DanmakuLayer({super.key, required this.controller, this.style = const DanmakuStyle(), required this.accent});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<DanmakuLayer> createState() => _DanmakuLayerState();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A line measured once (its width decides its speed and its lane) and
|
||||||
|
/// its widget, built once.
|
||||||
|
class _Line {
|
||||||
|
final DanmakuItem item;
|
||||||
|
final double width;
|
||||||
|
final double height;
|
||||||
|
final Widget widget;
|
||||||
|
_Line(this.item, this.width, this.height, this.widget);
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DanmakuLayerState extends State<DanmakuLayer> with SingleTickerProviderStateMixin {
|
||||||
|
late final Ticker _ticker = createTicker(_onTick);
|
||||||
|
final _lanes = DanmakuLanes<_Line>();
|
||||||
|
final _repaint = ValueNotifier(0);
|
||||||
|
// The layer's clock: [_base] from earlier runs of the ticker, which
|
||||||
|
// only runs while lines fly or wait (an idle layer asks for no frames).
|
||||||
|
Duration _base = Duration.zero;
|
||||||
|
Duration _elapsed = Duration.zero;
|
||||||
|
Size _size = Size.zero;
|
||||||
|
var _pending = <DanmakuItem>[];
|
||||||
|
var _shown = <DanmakuFlight<_Line>>[];
|
||||||
|
|
||||||
|
double get _t => (_base + _elapsed).inMicroseconds / 1e6;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
widget.controller.addListener(_onAdd);
|
||||||
|
_onAdd();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(DanmakuLayer old) {
|
||||||
|
super.didUpdateWidget(old);
|
||||||
|
if (old.controller != widget.controller) {
|
||||||
|
old.controller.removeListener(_onAdd);
|
||||||
|
widget.controller.addListener(_onAdd);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
widget.controller.removeListener(_onAdd);
|
||||||
|
_ticker.dispose();
|
||||||
|
_repaint.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onAdd() {
|
||||||
|
final c = widget.controller;
|
||||||
|
if (c._clear) {
|
||||||
|
c._clear = false;
|
||||||
|
_lanes.clear();
|
||||||
|
_pending = [];
|
||||||
|
}
|
||||||
|
_pending.addAll(c._take());
|
||||||
|
if (!_ticker.isActive) _ticker.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
_Line _build(DanmakuItem item) {
|
||||||
|
final s = widget.style;
|
||||||
|
final size = s.fontSizeFor(_size.height);
|
||||||
|
final base = TextStyle(
|
||||||
|
fontSize: size,
|
||||||
|
height: 1.2,
|
||||||
|
fontWeight: s.fontWeight,
|
||||||
|
color: Colors.white,
|
||||||
|
letterSpacing: 0.2,
|
||||||
|
shadows: const [],
|
||||||
|
);
|
||||||
|
InlineSpan span({Paint? stroke}) {
|
||||||
|
final name = item.sender;
|
||||||
|
final accent = item.kind == DanmakuKind.starting && stroke == null ? widget.accent : null;
|
||||||
|
return TextSpan(style: stroke == null ? base : base.copyWith(foreground: stroke, color: null), children: [
|
||||||
|
if (name != null)
|
||||||
|
TextSpan(
|
||||||
|
text: item.kind == DanmakuKind.chat ? '$name ' : '$name ',
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: s.nameWeight,
|
||||||
|
color: stroke == null ? (item.senderColor ?? Colors.white) : null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TextSpan(text: item.text, style: accent == null ? null : TextStyle(color: accent)),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
final measure = TextPainter(text: span(), textDirection: TextDirection.ltr, maxLines: 1)..layout();
|
||||||
|
final textW = measure.width, textH = measure.height;
|
||||||
|
measure.dispose();
|
||||||
|
final avatar = s.avatars ? item.avatar : null;
|
||||||
|
final avatarSize = size * 1.15;
|
||||||
|
final avatarGap = size * 0.3;
|
||||||
|
final frame = item.own && s.highlightOwn;
|
||||||
|
final padX = frame ? size * 0.4 : 0.0;
|
||||||
|
final width = textW + padX * 2 + (avatar == null ? 0 : avatarSize + avatarGap);
|
||||||
|
Widget text = Text.rich(span(), maxLines: 1, softWrap: false);
|
||||||
|
if (s.outline > 0) {
|
||||||
|
final stroke = Paint()
|
||||||
|
..style = PaintingStyle.stroke
|
||||||
|
..strokeWidth = s.outline * 2
|
||||||
|
..strokeJoin = StrokeJoin.round
|
||||||
|
..color = const Color(0xE6000000);
|
||||||
|
text = Stack(children: [Text.rich(span(stroke: stroke), maxLines: 1, softWrap: false), text]);
|
||||||
|
}
|
||||||
|
Widget line = Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
if (avatar != null) ...[
|
||||||
|
StatusAvatar(info: avatar, size: avatarSize, badges: false, ring: false),
|
||||||
|
SizedBox(width: avatarGap),
|
||||||
|
],
|
||||||
|
text,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
if (frame) {
|
||||||
|
line = DecoratedBox(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: widget.accent.withValues(alpha: 0.14),
|
||||||
|
borderRadius: BorderRadius.circular(999),
|
||||||
|
border: Border.all(color: widget.accent.withValues(alpha: 0.85), width: 1.2),
|
||||||
|
),
|
||||||
|
child: Padding(padding: EdgeInsets.symmetric(horizontal: padX), child: line),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final height = math.max(textH, avatar == null ? 0.0 : avatarSize);
|
||||||
|
return _Line(item, width, height, SizedBox(width: width, height: height, child: Center(child: line)));
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onTick(Duration elapsed) {
|
||||||
|
_elapsed = elapsed;
|
||||||
|
if (_size.isEmpty) return;
|
||||||
|
final t = _t;
|
||||||
|
for (final item in _pending) {
|
||||||
|
_lanes.add(_build(item), t);
|
||||||
|
}
|
||||||
|
_pending = [];
|
||||||
|
final s = widget.style;
|
||||||
|
_lanes.tick(t,
|
||||||
|
w: _size.width,
|
||||||
|
lanes: s.lanesFor(_size.height),
|
||||||
|
cap: s.maxOnScreen,
|
||||||
|
crossSecs: s.crossSecs,
|
||||||
|
measure: (l) => l.width);
|
||||||
|
final c = widget.controller;
|
||||||
|
c.onScreen.value = _lanes.flights.length;
|
||||||
|
c.waiting.value = _lanes.waiting;
|
||||||
|
c.dropped.value = _lanes.dropped;
|
||||||
|
final now = List.of(_lanes.flights);
|
||||||
|
if (now.length != _shown.length || !_sameFlights(now, _shown)) {
|
||||||
|
setState(() => _shown = now);
|
||||||
|
} else if (now.isNotEmpty) {
|
||||||
|
_repaint.value++;
|
||||||
|
}
|
||||||
|
if (now.isEmpty && _lanes.waiting == 0 && _pending.isEmpty) {
|
||||||
|
_base += _elapsed;
|
||||||
|
_elapsed = Duration.zero;
|
||||||
|
_ticker.stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool _sameFlights(List<DanmakuFlight<_Line>> a, List<DanmakuFlight<_Line>> b) {
|
||||||
|
for (var i = 0; i < a.length; i++) {
|
||||||
|
if (!identical(a[i], b[i])) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return IgnorePointer(
|
||||||
|
child: LayoutBuilder(builder: (context, box) {
|
||||||
|
_size = box.biggest;
|
||||||
|
return Opacity(
|
||||||
|
opacity: widget.style.opacity.clamp(0.0, 1.0),
|
||||||
|
child: Flow(
|
||||||
|
delegate: _DanmakuFlow(this, _repaint),
|
||||||
|
children: [for (final f in _shown) KeyedSubtree(key: ObjectKey(f), child: f.item.widget)],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DanmakuFlow extends FlowDelegate {
|
||||||
|
final _DanmakuLayerState state;
|
||||||
|
_DanmakuFlow(this.state, Listenable repaint) : super(repaint: repaint);
|
||||||
|
|
||||||
|
@override
|
||||||
|
BoxConstraints getConstraintsForChild(int i, BoxConstraints constraints) => const BoxConstraints();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void paintChildren(FlowPaintingContext context) {
|
||||||
|
final s = state.widget.style;
|
||||||
|
final size = context.size;
|
||||||
|
final t = state._t;
|
||||||
|
final top = s.bandTop * size.height;
|
||||||
|
final lane = s.laneHeightFor(size.height);
|
||||||
|
final flights = state._shown;
|
||||||
|
for (var i = 0; i < flights.length && i < context.childCount; i++) {
|
||||||
|
final f = flights[i];
|
||||||
|
final x = f.x(t, size.width);
|
||||||
|
final y = top + f.lane * lane + (lane - f.item.height) / 2;
|
||||||
|
context.paintChild(i, transform: Matrix4.translationValues(x, y, 0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool shouldRepaint(_DanmakuFlow old) => true;
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:signals/signals_flutter.dart';
|
||||||
|
|
||||||
|
import '../session/notifier.dart' show NoticeCategory, ToastItem;
|
||||||
|
import '../shell/avatar.dart' show AvatarInfo, colorForName;
|
||||||
|
import 'danmaku.dart';
|
||||||
|
|
||||||
|
// "Notifications: Danmaku" (Settings → Notifications & sounds): over the
|
||||||
|
// full player, chat and short notices fly across the picture instead of
|
||||||
|
// popping up as toasts. Problems, errors and the app's own notices stay
|
||||||
|
// toasts, and so does everything while the player is small or closed.
|
||||||
|
|
||||||
|
/// Whether [t] flies as a line (when danmaku is the style in use): chat,
|
||||||
|
/// joins / leaves / lost connection / back, "starting now", and Up next
|
||||||
|
/// news someone caused (added, changed the video). Nudges, problems, your
|
||||||
|
/// own state and the app's notices stay toasts.
|
||||||
|
bool fliesAsDanmaku(ToastItem t) => switch (t.category) {
|
||||||
|
NoticeCategory.chat || NoticeCategory.people || NoticeCategory.start => true,
|
||||||
|
NoticeCategory.upNext => t.actor != null && t.actor!.isNotEmpty,
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// [t] as a line: the sender (their avatar from [avatar], their colour)
|
||||||
|
/// before what they said or did; "starting now" without one. Null for
|
||||||
|
/// what does not fly ([fliesAsDanmaku]).
|
||||||
|
DanmakuItem? danmakuForToast(ToastItem t, {AvatarInfo Function(String name, String id)? avatar}) {
|
||||||
|
if (!fliesAsDanmaku(t)) return null;
|
||||||
|
final who = t.actor;
|
||||||
|
if (who == null || who.isEmpty) {
|
||||||
|
return DanmakuItem(kind: DanmakuKind.starting, text: '▶ ${t.text}');
|
||||||
|
}
|
||||||
|
final pic = avatar?.call(who, t.actorId) ?? AvatarInfo(name: who, color: colorForName(who));
|
||||||
|
final color = pic.color;
|
||||||
|
if (t.chat && t.text.startsWith('$who: ')) {
|
||||||
|
return DanmakuItem(
|
||||||
|
kind: DanmakuKind.chat,
|
||||||
|
sender: who,
|
||||||
|
senderColor: color,
|
||||||
|
text: t.text.substring(who.length + 2),
|
||||||
|
own: t.own,
|
||||||
|
avatar: pic);
|
||||||
|
}
|
||||||
|
final rest = t.text.startsWith('$who ') ? t.text.substring(who.length + 1) : t.text;
|
||||||
|
final kind = switch (t.category) {
|
||||||
|
NoticeCategory.upNext => DanmakuKind.upNext,
|
||||||
|
_ when rest.startsWith('left') || rest.startsWith('lost') => DanmakuKind.leave,
|
||||||
|
_ => DanmakuKind.join,
|
||||||
|
};
|
||||||
|
return DanmakuItem(kind: kind, sender: who, senderColor: color, text: rest, avatar: pic);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The session's lines over the picture: fresh toasts that fly
|
||||||
|
/// ([fliesAsDanmaku], and not [skip]) go to [controller] while this is
|
||||||
|
/// mounted (the player mounts it only while danmaku is the style and the
|
||||||
|
/// full player is up); what was there before it mounted is old news.
|
||||||
|
class DanmakuNotices extends StatefulWidget {
|
||||||
|
final ReadonlySignal<List<ToastItem>> toasts;
|
||||||
|
final ReadonlySignal<DanmakuStyle> style;
|
||||||
|
final DanmakuController controller;
|
||||||
|
final AvatarInfo Function(String name, String id)? avatar;
|
||||||
|
|
||||||
|
/// Toasts shown another way already (your own quick chat echo).
|
||||||
|
final bool Function(ToastItem t)? skip;
|
||||||
|
const DanmakuNotices({
|
||||||
|
super.key,
|
||||||
|
required this.toasts,
|
||||||
|
required this.style,
|
||||||
|
required this.controller,
|
||||||
|
this.avatar,
|
||||||
|
this.skip,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<DanmakuNotices> createState() => _DanmakuNoticesState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DanmakuNoticesState extends State<DanmakuNotices> {
|
||||||
|
late Set<int> _seen = {for (final t in widget.toasts.peek()) t.id};
|
||||||
|
late final void Function() _stop;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_stop = effect(() {
|
||||||
|
final items = widget.toasts.value;
|
||||||
|
untracked(() {
|
||||||
|
for (final t in items) {
|
||||||
|
if (_seen.contains(t.id) || (widget.skip?.call(t) ?? false)) continue;
|
||||||
|
final line = danmakuForToast(t, avatar: widget.avatar);
|
||||||
|
if (line != null) widget.controller.add(line);
|
||||||
|
}
|
||||||
|
_seen = {for (final t in items) t.id};
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_stop();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => SignalBuilder(
|
||||||
|
builder: (context) => DanmakuLayer(
|
||||||
|
controller: widget.controller,
|
||||||
|
style: widget.style.value,
|
||||||
|
accent: Theme.of(context).colorScheme.primary,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,851 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:math' as math;
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart' show ValueListenable;
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:signals/signals_flutter.dart';
|
||||||
|
|
||||||
|
import '../controls/minimal/style.dart';
|
||||||
|
import '../controls/session_controls.dart' show controlsBottomHeight;
|
||||||
|
import '../session/notifier.dart' show ToastItem;
|
||||||
|
import '../session/session_coordinator.dart' show SessionCoordinator;
|
||||||
|
import '../shell/avatar.dart' show AvatarInfo, avatarForName, colorForName, memberAvatarInfo, selfAvatarInfo;
|
||||||
|
import '../shell/motion.dart';
|
||||||
|
import '../shell/quick_chat.dart';
|
||||||
|
import '../shell/video_view.dart' show SubtitleLift;
|
||||||
|
import '../app/app_graph.dart' show AppScope;
|
||||||
|
import '../widgets/app_menu.dart' show AppGlass, AppMenuStyle;
|
||||||
|
import 'danmaku.dart';
|
||||||
|
import 'danmaku_notices.dart' show danmakuForToast;
|
||||||
|
|
||||||
|
// Dev builds only (Settings → About → Developer): try the danmaku look on
|
||||||
|
// the real player (when something is loaded) or on a stand-in picture,
|
||||||
|
// fed with samples, a random stream or this session's own toasts, with
|
||||||
|
// every knob of [DanmakuStyle] live and mock subtitles to judge overlap.
|
||||||
|
|
||||||
|
/// The preview's state, owned by the shell while the app runs.
|
||||||
|
class DanmakuPreview {
|
||||||
|
final controller = DanmakuController();
|
||||||
|
|
||||||
|
/// Shown over the full session player.
|
||||||
|
final active = signal(false);
|
||||||
|
final Signal<DanmakuStyle> style;
|
||||||
|
|
||||||
|
/// A random stream: lines per minute, lengths from one word to a long
|
||||||
|
/// sentence, CJK and emoji among them.
|
||||||
|
final auto = signal(false);
|
||||||
|
final autoRate = signal(40.0);
|
||||||
|
|
||||||
|
/// Mock subtitles: a top sign and bottom dialogue.
|
||||||
|
final mockTop = signal(false);
|
||||||
|
final mockBottom = signal(false);
|
||||||
|
|
||||||
|
/// The stand-in picture's control band (the real player has its own).
|
||||||
|
final controlsBand = signal(true);
|
||||||
|
|
||||||
|
/// This session's chat and notice toasts fly too (session only).
|
||||||
|
final live = signal(false);
|
||||||
|
|
||||||
|
/// In a session: the party's real avatars for the samples and the
|
||||||
|
/// session's toasts (set while the session panel is up).
|
||||||
|
SessionCoordinator? coord;
|
||||||
|
|
||||||
|
final _rng = math.Random();
|
||||||
|
Timer? _autoTimer;
|
||||||
|
late final void Function() _stopAuto;
|
||||||
|
|
||||||
|
/// Starts from [style] (the user's look, Settings → Notifications &
|
||||||
|
/// sounds); the preview's knobs change only the preview.
|
||||||
|
DanmakuPreview({DanmakuStyle style = const DanmakuStyle()}) : style = signal(style) {
|
||||||
|
_stopAuto = effect(() {
|
||||||
|
final on = auto.value;
|
||||||
|
autoRate.value;
|
||||||
|
untracked(() {
|
||||||
|
_autoTimer?.cancel();
|
||||||
|
if (on) _scheduleAuto();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void dispose() {
|
||||||
|
_stopAuto();
|
||||||
|
_autoTimer?.cancel();
|
||||||
|
controller.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _scheduleAuto() {
|
||||||
|
// Poisson arrivals: bursts and lulls like a real chat.
|
||||||
|
final perSec = autoRate.peek() / 60;
|
||||||
|
final wait = -math.log(1 - _rng.nextDouble()) / perSec;
|
||||||
|
_autoTimer = Timer(Duration(milliseconds: (wait * 1000).round().clamp(30, 60000)), () {
|
||||||
|
if (!auto.peek()) return;
|
||||||
|
controller.add(randomSample());
|
||||||
|
_scheduleAuto();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One sample line of [kind]; [own]: as if this member wrote it.
|
||||||
|
/// Senders come from the party in a session (their pictures), else
|
||||||
|
/// from [sampleNames] (initials).
|
||||||
|
DanmakuItem sample(DanmakuKind kind, {bool own = false}) {
|
||||||
|
final a = own ? selfAvatarInfo() : _sampleSender();
|
||||||
|
final who = a.name;
|
||||||
|
final color = own ? a.color : colorForName(who);
|
||||||
|
return switch (kind) {
|
||||||
|
DanmakuKind.chat =>
|
||||||
|
DanmakuItem(kind: kind, sender: who, senderColor: color, text: _pick(sampleChat), own: own, avatar: a),
|
||||||
|
DanmakuKind.join => DanmakuItem(kind: kind, sender: who, senderColor: color, text: 'joined', avatar: a),
|
||||||
|
DanmakuKind.leave => DanmakuItem(kind: kind, sender: who, senderColor: color, text: 'left', avatar: a),
|
||||||
|
DanmakuKind.upNext => DanmakuItem(
|
||||||
|
kind: kind, sender: who, senderColor: color, text: 'added ${_pick(sampleTitles)} to Up next', avatar: a),
|
||||||
|
DanmakuKind.starting => const DanmakuItem(kind: DanmakuKind.starting, text: '▶ Starting now'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [text] as this member's own chat line.
|
||||||
|
DanmakuItem own(String text) {
|
||||||
|
final a = selfAvatarInfo();
|
||||||
|
return DanmakuItem(kind: DanmakuKind.chat, sender: a.name, senderColor: a.color, text: text, own: true, avatar: a);
|
||||||
|
}
|
||||||
|
|
||||||
|
AvatarInfo _sampleSender() {
|
||||||
|
final c = coord;
|
||||||
|
final r = c?.serverState.peek();
|
||||||
|
final others = [
|
||||||
|
if (c != null && r != null)
|
||||||
|
for (final m in r.members)
|
||||||
|
if (m.id != c.connection.memberId) memberAvatarInfo(m, r, c),
|
||||||
|
];
|
||||||
|
// The party's real ones half the time, so initials show up too.
|
||||||
|
if (others.isNotEmpty && _rng.nextBool()) return _pick(others);
|
||||||
|
final name = _pick(sampleNames);
|
||||||
|
return AvatarInfo(name: name, color: colorForName(name));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the random stream sends: mostly chat, some of it yours, now and
|
||||||
|
/// then a notice.
|
||||||
|
DanmakuItem randomSample() {
|
||||||
|
final r = _rng.nextDouble();
|
||||||
|
if (r < 0.70) return sample(DanmakuKind.chat);
|
||||||
|
if (r < 0.80) return sample(DanmakuKind.chat, own: true);
|
||||||
|
if (r < 0.88) return sample(DanmakuKind.join);
|
||||||
|
if (r < 0.94) return sample(DanmakuKind.leave);
|
||||||
|
return sample(DanmakuKind.upNext);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A toast of this session as a line, as "Notifications: Danmaku" maps
|
||||||
|
/// it ([danmakuForToast]); the rest (problems) stay toasts.
|
||||||
|
DanmakuItem? fromToast(ToastItem t) {
|
||||||
|
final c = coord;
|
||||||
|
return danmakuForToast(t, avatar: c == null ? null : (name, id) => avatarForName(name, c, id));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Follows [toasts] while [live] is on; returns the stop.
|
||||||
|
void Function() watchToasts(ReadonlySignal<List<ToastItem>> toasts) {
|
||||||
|
var seen = {for (final t in toasts.peek()) t.id};
|
||||||
|
return effect(() {
|
||||||
|
final items = toasts.value;
|
||||||
|
final on = live.value;
|
||||||
|
untracked(() {
|
||||||
|
for (final t in items) {
|
||||||
|
if (!seen.add(t.id) || !on) continue;
|
||||||
|
final line = fromToast(t);
|
||||||
|
if (line != null) controller.add(line);
|
||||||
|
}
|
||||||
|
seen = {for (final t in items) t.id};
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
T _pick<T>(List<T> from) => from[_rng.nextInt(from.length)];
|
||||||
|
|
||||||
|
static const sampleNames = ['Alice', 'Bob', 'Chiyo', '小雪', 'Kenji', 'Mara'];
|
||||||
|
static const sampleTitles = ['Frieren · 12', 'Bocchi the Rock! · 5', 'Dungeon Meshi · 20', 'Mob Psycho 100 · 3'];
|
||||||
|
static const sampleChat = [
|
||||||
|
'lol',
|
||||||
|
'www',
|
||||||
|
'草',
|
||||||
|
'8888888',
|
||||||
|
'NICE',
|
||||||
|
'it\'s so over',
|
||||||
|
'she\'s back!!',
|
||||||
|
'wait, who is that?',
|
||||||
|
'op skip pls 🙏',
|
||||||
|
'the animation in this scene is insane',
|
||||||
|
'I can\'t believe they actually adapted this arc, the manga panel looked exactly like this, chills honestly',
|
||||||
|
'brb getting snacks, don\'t wait for me (actually do wait)',
|
||||||
|
'前方高能预警!!!',
|
||||||
|
'このシーン最高すぎる',
|
||||||
|
'神回確定',
|
||||||
|
'画质好清晰啊',
|
||||||
|
'🔥🔥🔥',
|
||||||
|
'😭😭 my heart',
|
||||||
|
'🎉 finally',
|
||||||
|
'Himmel would have done the same 🥲',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The danmaku with this preview's style and mock subtitles over a
|
||||||
|
/// picture; lets clicks through.
|
||||||
|
class DanmakuPreviewLayers extends StatelessWidget {
|
||||||
|
final DanmakuPreview preview;
|
||||||
|
|
||||||
|
/// The covered bottom the mock dialogue moves above, as the real
|
||||||
|
/// subtitles do ([SubtitleLift]).
|
||||||
|
final ValueListenable<double>? lift;
|
||||||
|
const DanmakuPreviewLayers({super.key, required this.preview, this.lift});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final accent = Theme.of(context).colorScheme.primary;
|
||||||
|
return SignalBuilder(
|
||||||
|
builder: (context) => Stack(
|
||||||
|
fit: StackFit.expand,
|
||||||
|
children: [
|
||||||
|
MockSubtitles(top: preview.mockTop.value, bottom: preview.mockBottom.value, lift: lift),
|
||||||
|
DanmakuLayer(controller: preview.controller, style: preview.style.value, accent: accent),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stand-ins for subtitles where real ones sit: an ASS sign at the top
|
||||||
|
/// (`\an8`, yellow serif) and two lines of dialogue at the bottom. The
|
||||||
|
/// dialogue moves up by [lift] (px covered at the bottom) in the same
|
||||||
|
/// steps as mpv's `sub-pos` moves the real ones; the sign stays.
|
||||||
|
class MockSubtitles extends StatelessWidget {
|
||||||
|
final bool top;
|
||||||
|
final bool bottom;
|
||||||
|
final ValueListenable<double>? lift;
|
||||||
|
const MockSubtitles({super.key, required this.top, required this.bottom, this.lift});
|
||||||
|
|
||||||
|
static Widget _outlined(String text, TextStyle style, double outline) => Stack(
|
||||||
|
children: [
|
||||||
|
Text(text,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: style.copyWith(
|
||||||
|
color: null,
|
||||||
|
foreground: Paint()
|
||||||
|
..style = PaintingStyle.stroke
|
||||||
|
..strokeWidth = outline
|
||||||
|
..strokeJoin = StrokeJoin.round
|
||||||
|
..color = const Color(0xFF1A1206),
|
||||||
|
)),
|
||||||
|
Text(text, textAlign: TextAlign.center, style: style),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (!top && !bottom) return const SizedBox.shrink();
|
||||||
|
return IgnorePointer(
|
||||||
|
child: LayoutBuilder(builder: (context, box) {
|
||||||
|
final h = box.maxHeight;
|
||||||
|
final dialogue = (h * 0.05).clamp(12.0, 52.0);
|
||||||
|
return Stack(
|
||||||
|
children: [
|
||||||
|
if (top)
|
||||||
|
Positioned(
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
top: h * 0.035,
|
||||||
|
child: Center(
|
||||||
|
child: _outlined(
|
||||||
|
'TOP SIGN · 第3新東京市',
|
||||||
|
TextStyle(
|
||||||
|
fontSize: dialogue * 0.85,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
color: const Color(0xFFFFE14D),
|
||||||
|
),
|
||||||
|
dialogue * 0.14,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (bottom)
|
||||||
|
ValueListenableBuilder<double>(
|
||||||
|
valueListenable: lift ?? const AlwaysStoppedAnimation(0.0),
|
||||||
|
builder: (context, up, child) => Positioned(
|
||||||
|
key: const ValueKey('mock-dialogue'),
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: h * 0.05 + up,
|
||||||
|
child: child!,
|
||||||
|
),
|
||||||
|
child: Center(
|
||||||
|
child: _outlined(
|
||||||
|
'I told you the controls would cover this line.\nThey never do now — or do they?',
|
||||||
|
TextStyle(fontSize: dialogue, height: 1.15, color: Colors.white, fontWeight: FontWeight.w500),
|
||||||
|
dialogue * 0.12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A moving picture for the preview when nothing is loaded: slow colour
|
||||||
|
/// fields drifting over a dusk gradient, so motion and contrast behind
|
||||||
|
/// the lines are realistic.
|
||||||
|
class DanmakuStandIn extends StatefulWidget {
|
||||||
|
/// False: a still picture (the settings' strip while it rests).
|
||||||
|
final bool animate;
|
||||||
|
const DanmakuStandIn({super.key, this.animate = true});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<DanmakuStandIn> createState() => _DanmakuStandInState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DanmakuStandInState extends State<DanmakuStandIn> with SingleTickerProviderStateMixin {
|
||||||
|
late final AnimationController _clock = AnimationController(vsync: this, duration: const Duration(seconds: 60));
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
if (widget.animate) _clock.repeat();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(DanmakuStandIn old) {
|
||||||
|
super.didUpdateWidget(old);
|
||||||
|
if (widget.animate && !_clock.isAnimating) _clock.repeat();
|
||||||
|
if (!widget.animate && _clock.isAnimating) _clock.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_clock.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => RepaintBoundary(
|
||||||
|
child: CustomPaint(painter: _StandInPainter(_clock), child: const SizedBox.expand()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class _StandInPainter extends CustomPainter {
|
||||||
|
final Animation<double> clock;
|
||||||
|
_StandInPainter(this.clock) : super(repaint: clock);
|
||||||
|
|
||||||
|
static const _blobs = [
|
||||||
|
(Color(0xFFFF7A59), 0.9, 0.0, 0.42),
|
||||||
|
(Color(0xFF7C5CFF), 1.3, 1.7, 0.5),
|
||||||
|
(Color(0xFF2EC5CE), 0.7, 3.1, 0.38),
|
||||||
|
(Color(0xFFFFC857), 1.1, 4.4, 0.3),
|
||||||
|
(Color(0xFFE85D9E), 0.5, 5.2, 0.45),
|
||||||
|
];
|
||||||
|
|
||||||
|
@override
|
||||||
|
void paint(Canvas canvas, Size size) {
|
||||||
|
final rect = Offset.zero & size;
|
||||||
|
canvas.drawRect(
|
||||||
|
rect,
|
||||||
|
Paint()
|
||||||
|
..shader = const LinearGradient(
|
||||||
|
begin: Alignment.topCenter,
|
||||||
|
end: Alignment.bottomCenter,
|
||||||
|
colors: [Color(0xFF0B1030), Color(0xFF2A1B4A), Color(0xFF5A2F4F), Color(0xFF14141C)],
|
||||||
|
stops: [0, 0.45, 0.72, 1],
|
||||||
|
).createShader(rect),
|
||||||
|
);
|
||||||
|
final t = clock.value * 2 * math.pi;
|
||||||
|
final r = size.shortestSide;
|
||||||
|
for (final (color, speed, phase, radius) in _blobs) {
|
||||||
|
final c = Offset(
|
||||||
|
size.width * (0.5 + 0.38 * math.sin(t * speed + phase)),
|
||||||
|
size.height * (0.5 + 0.32 * math.cos(t * speed * 0.8 + phase * 1.3)),
|
||||||
|
);
|
||||||
|
canvas.drawCircle(
|
||||||
|
c,
|
||||||
|
r * radius,
|
||||||
|
Paint()
|
||||||
|
..shader = RadialGradient(colors: [color.withValues(alpha: 0.55), color.withValues(alpha: 0)])
|
||||||
|
.createShader(Rect.fromCircle(center: c, radius: r * radius)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// A bright horizon line and some stars: hard edges behind the text.
|
||||||
|
final horizon = size.height * 0.68;
|
||||||
|
canvas.drawRect(
|
||||||
|
Rect.fromLTWH(0, horizon, size.width, size.height - horizon),
|
||||||
|
Paint()..color = const Color(0xCC0D0D14),
|
||||||
|
);
|
||||||
|
canvas.drawLine(Offset(0, horizon), Offset(size.width, horizon),
|
||||||
|
Paint()
|
||||||
|
..color = const Color(0x88FFD7A8)
|
||||||
|
..strokeWidth = 1.5);
|
||||||
|
final stars = math.Random(7);
|
||||||
|
for (var i = 0; i < 90; i++) {
|
||||||
|
final x = stars.nextDouble() * size.width;
|
||||||
|
final y = stars.nextDouble() * horizon;
|
||||||
|
final twinkle = 0.4 + 0.6 * (0.5 + 0.5 * math.sin(t * 8 + i));
|
||||||
|
canvas.drawCircle(Offset(x, y), 0.6 + stars.nextDouble() * 1.2,
|
||||||
|
Paint()..color = Colors.white.withValues(alpha: 0.5 * twinkle));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool shouldRepaint(_StandInPainter old) => false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The preview's knobs on the app's glass: fire samples, the random
|
||||||
|
/// stream, the look, mock subtitles. [onClose] ends the preview; it
|
||||||
|
/// collapses to its title bar.
|
||||||
|
class DanmakuPanel extends StatefulWidget {
|
||||||
|
final DanmakuPreview preview;
|
||||||
|
final VoidCallback onClose;
|
||||||
|
|
||||||
|
/// In a session: offer this session's own toasts as a source.
|
||||||
|
final bool session;
|
||||||
|
const DanmakuPanel({super.key, required this.preview, required this.onClose, this.session = false});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<DanmakuPanel> createState() => _DanmakuPanelState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DanmakuPanelState extends State<DanmakuPanel> {
|
||||||
|
bool _open = true;
|
||||||
|
|
||||||
|
DanmakuPreview get p => widget.preview;
|
||||||
|
|
||||||
|
void _style(DanmakuStyle Function(DanmakuStyle s) f) => p.style.value = f(p.style.peek());
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final muted = Colors.white.withValues(alpha: 0.6);
|
||||||
|
return ExcludeFocus(
|
||||||
|
child: AppGlass(
|
||||||
|
child: SizedBox(
|
||||||
|
width: 300,
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(14, 8, 6, 4),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Expanded(
|
||||||
|
child: Text('Danmaku preview', style: TextStyle(fontWeight: FontWeight.w600, fontSize: 14)),
|
||||||
|
),
|
||||||
|
_Counts(controller: p.controller),
|
||||||
|
IconButton(
|
||||||
|
tooltip: _open ? 'Collapse' : 'Expand',
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
iconSize: 18,
|
||||||
|
onPressed: () => setState(() => _open = !_open),
|
||||||
|
icon: AnimatedRotation(
|
||||||
|
turns: _open ? 0 : 0.5,
|
||||||
|
duration: easeTime,
|
||||||
|
curve: ease,
|
||||||
|
child: const Icon(Icons.expand_less),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
tooltip: 'Close the preview',
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
iconSize: 18,
|
||||||
|
onPressed: widget.onClose,
|
||||||
|
icon: const Icon(Icons.close),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
AnimatedSize(
|
||||||
|
duration: easeTime,
|
||||||
|
curve: ease,
|
||||||
|
alignment: Alignment.topCenter,
|
||||||
|
child: !_open
|
||||||
|
? const SizedBox(width: double.infinity)
|
||||||
|
: ConstrainedBox(
|
||||||
|
constraints: BoxConstraints(maxHeight: MediaQuery.sizeOf(context).height * 0.7),
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.fromLTRB(14, 0, 14, 12),
|
||||||
|
child: SignalBuilder(builder: (context) => _body(muted)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _body(Color muted) {
|
||||||
|
final s = p.style.value;
|
||||||
|
Widget fire(IconData icon, String label, DanmakuItem Function() item) => MinimalButton(
|
||||||
|
icon: icon,
|
||||||
|
label: label,
|
||||||
|
tooltip: '',
|
||||||
|
scale: 0.8,
|
||||||
|
onPressed: () => p.controller.add(item()),
|
||||||
|
);
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
_Head('Send', muted),
|
||||||
|
Wrap(
|
||||||
|
spacing: 4,
|
||||||
|
runSpacing: 4,
|
||||||
|
children: [
|
||||||
|
fire(Icons.chat_bubble_outline, 'Chat', () => p.sample(DanmakuKind.chat)),
|
||||||
|
fire(Icons.person_outline, 'Mine', () => p.sample(DanmakuKind.chat, own: true)),
|
||||||
|
fire(Icons.login, 'Join', () => p.sample(DanmakuKind.join)),
|
||||||
|
fire(Icons.logout, 'Leave', () => p.sample(DanmakuKind.leave)),
|
||||||
|
fire(Icons.playlist_add_rounded, 'Up next', () => p.sample(DanmakuKind.upNext)),
|
||||||
|
fire(Icons.play_arrow_rounded, 'Starting', () => p.sample(DanmakuKind.starting)),
|
||||||
|
fire(Icons.bolt, 'Burst ×12', () {
|
||||||
|
for (var i = 0; i < 11; i++) {
|
||||||
|
p.controller.add(p.randomSample());
|
||||||
|
}
|
||||||
|
return p.randomSample();
|
||||||
|
}),
|
||||||
|
MinimalButton(
|
||||||
|
icon: Icons.clear_all,
|
||||||
|
label: 'Clear',
|
||||||
|
tooltip: '',
|
||||||
|
scale: 0.8,
|
||||||
|
onPressed: p.controller.clear,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
_Toggle('Random stream', p.auto.value, (v) => p.auto.value = v),
|
||||||
|
_Slider('Rate', p.autoRate.value, 5, 240, '${p.autoRate.value.round()}/min', (v) => p.autoRate.value = v,
|
||||||
|
enabled: p.auto.value),
|
||||||
|
if (widget.session) _Toggle("This session's chat and notices", p.live.value, (v) => p.live.value = v),
|
||||||
|
_Head('Look', muted),
|
||||||
|
_Slider('Crossing', s.crossSecs, 4, 16, '${s.crossSecs.toStringAsFixed(1)} s',
|
||||||
|
(v) => _style((s) => s.copyWith(crossSecs: v))),
|
||||||
|
_Slider('At most', s.maxOnScreen.toDouble(), 1, 40, '${s.maxOnScreen} lines',
|
||||||
|
(v) => _style((s) => s.copyWith(maxOnScreen: v.round()))),
|
||||||
|
_Range('Lanes', s.bandTop, s.bandBottom, '${(s.bandTop * 100).round()}–${(s.bandBottom * 100).round()} %',
|
||||||
|
(a, b) => _style((s) => s.copyWith(bandTop: a, bandBottom: b))),
|
||||||
|
_Slider('Opacity', s.opacity, 0.3, 1, '${(s.opacity * 100).round()} %',
|
||||||
|
(v) => _style((s) => s.copyWith(opacity: v))),
|
||||||
|
_Slider('Size', s.size, 0.6, 1.8, '×${s.size.toStringAsFixed(2)}', (v) => _style((s) => s.copyWith(size: v))),
|
||||||
|
_Slider('Bold', s.weight.toDouble(), 400, 900, '${s.weight}',
|
||||||
|
(v) => _style((s) => s.copyWith(weight: (v / 100).round() * 100))),
|
||||||
|
_Slider('Outline', s.outline, 0, 4, '${s.outline.toStringAsFixed(1)} px',
|
||||||
|
(v) => _style((s) => s.copyWith(outline: v))),
|
||||||
|
_Toggle('Avatars', s.avatars, (v) => _style((s) => s.copyWith(avatars: v))),
|
||||||
|
_Toggle('Frame my own lines', s.highlightOwn, (v) => _style((s) => s.copyWith(highlightOwn: v))),
|
||||||
|
_Head('Subtitles (mock)', muted),
|
||||||
|
_Toggle('Top sign', p.mockTop.value, (v) => p.mockTop.value = v),
|
||||||
|
_Toggle('Bottom dialogue', p.mockBottom.value, (v) => p.mockBottom.value = v),
|
||||||
|
if (!widget.session) _Toggle('Control bar', p.controlsBand.value, (v) => p.controlsBand.value = v),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _Counts extends StatelessWidget {
|
||||||
|
final DanmakuController controller;
|
||||||
|
const _Counts({required this.controller});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => ListenableBuilder(
|
||||||
|
listenable: Listenable.merge([controller.onScreen, controller.waiting]),
|
||||||
|
builder: (context, _) => Tooltip(
|
||||||
|
message: 'Flying · waiting for a lane',
|
||||||
|
child: Text(
|
||||||
|
'${controller.onScreen.value} · ${controller.waiting.value}',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: Colors.white.withValues(alpha: 0.6),
|
||||||
|
fontFeatures: const [FontFeature.tabularFigures()],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class _Head extends StatelessWidget {
|
||||||
|
final String text;
|
||||||
|
final Color color;
|
||||||
|
const _Head(this.text, this.color);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 10, bottom: 4),
|
||||||
|
child: Text(text.toUpperCase(),
|
||||||
|
style: TextStyle(fontSize: 11, letterSpacing: 0.8, fontWeight: FontWeight.w600, color: color)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class _Toggle extends StatelessWidget {
|
||||||
|
final String label;
|
||||||
|
final bool value;
|
||||||
|
final ValueChanged<bool> onChanged;
|
||||||
|
const _Toggle(this.label, this.value, this.onChanged);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => SizedBox(
|
||||||
|
height: 34,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(child: Text(label, style: const TextStyle(fontSize: 13))),
|
||||||
|
Transform.scale(scale: 0.75, alignment: Alignment.centerRight, child: Switch(value: value, onChanged: onChanged)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class _Slider extends StatelessWidget {
|
||||||
|
final String label;
|
||||||
|
final double value, min, max;
|
||||||
|
final String readout;
|
||||||
|
final ValueChanged<double> onChanged;
|
||||||
|
final bool enabled;
|
||||||
|
const _Slider(this.label, this.value, this.min, this.max, this.readout, this.onChanged, {this.enabled = true});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => _SliderFrame(
|
||||||
|
label: label,
|
||||||
|
readout: readout,
|
||||||
|
child: Slider(value: value.clamp(min, max), min: min, max: max, onChanged: enabled ? onChanged : null),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class _Range extends StatelessWidget {
|
||||||
|
final String label;
|
||||||
|
final double a, b;
|
||||||
|
final String readout;
|
||||||
|
final void Function(double a, double b) onChanged;
|
||||||
|
const _Range(this.label, this.a, this.b, this.readout, this.onChanged);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => _SliderFrame(
|
||||||
|
label: label,
|
||||||
|
readout: readout,
|
||||||
|
child: RangeSlider(
|
||||||
|
values: RangeValues(a, b),
|
||||||
|
max: 0.9,
|
||||||
|
onChanged: (v) {
|
||||||
|
if (v.end - v.start >= 0.05) onChanged(v.start, v.end);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SliderFrame extends StatelessWidget {
|
||||||
|
final String label;
|
||||||
|
final String readout;
|
||||||
|
final Widget child;
|
||||||
|
const _SliderFrame({required this.label, required this.readout, required this.child});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => SizedBox(
|
||||||
|
height: 34,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
SizedBox(width: 64, child: Text(label, style: const TextStyle(fontSize: 13))),
|
||||||
|
Expanded(
|
||||||
|
child: SliderTheme(
|
||||||
|
data: SliderTheme.of(context).copyWith(
|
||||||
|
trackHeight: 3,
|
||||||
|
overlayShape: SliderComponentShape.noOverlay,
|
||||||
|
thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 6),
|
||||||
|
rangeThumbShape: const RoundRangeSliderThumbShape(enabledThumbRadius: 6),
|
||||||
|
),
|
||||||
|
child: child,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(
|
||||||
|
width: 76,
|
||||||
|
child: Text(
|
||||||
|
readout,
|
||||||
|
textAlign: TextAlign.right,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: Colors.white.withValues(alpha: 0.7),
|
||||||
|
fontFeatures: const [FontFeature.tabularFigures()],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The preview without a session player: the stand-in picture, a mock
|
||||||
|
/// control bar of the skin in use, the lines and the panel, over the
|
||||||
|
/// whole window (Esc or × closes it).
|
||||||
|
Future<void> showDanmakuPreviewPage(BuildContext context, DanmakuPreview preview) => showGeneralDialog<void>(
|
||||||
|
context: context,
|
||||||
|
barrierDismissible: false,
|
||||||
|
barrierLabel: 'Danmaku preview',
|
||||||
|
barrierColor: Colors.black,
|
||||||
|
transitionDuration: easeTime,
|
||||||
|
pageBuilder: (context, _, _) => DanmakuPreviewPage(preview: preview),
|
||||||
|
transitionBuilder: (context, anim, _, child) => FadeTransition(
|
||||||
|
opacity: CurvedAnimation(parent: anim, curve: ease, reverseCurve: Curves.easeIn),
|
||||||
|
child: child,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
class DanmakuPreviewPage extends StatefulWidget {
|
||||||
|
final DanmakuPreview preview;
|
||||||
|
const DanmakuPreviewPage({super.key, required this.preview});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<DanmakuPreviewPage> createState() => _DanmakuPreviewPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The page also has the quick chat box (Enter): what you send flies as
|
||||||
|
/// your own line, and the mock dialogue moves above the box in the lift's
|
||||||
|
/// steps, as the real subtitles do over the player.
|
||||||
|
class _DanmakuPreviewPageState extends State<DanmakuPreviewPage> {
|
||||||
|
final _chat = QuickChat();
|
||||||
|
final _lift = SubtitleLift();
|
||||||
|
final _pageFocus = FocusNode(debugLabel: 'danmaku preview');
|
||||||
|
double _boxH = QuickChatBox.height;
|
||||||
|
|
||||||
|
DanmakuPreview get preview => widget.preview;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_chat.open.addListener(_retarget);
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) => _pageFocus.requestFocus());
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_chat.open.removeListener(_retarget);
|
||||||
|
_chat.dispose();
|
||||||
|
_lift.dispose();
|
||||||
|
_pageFocus.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _retarget() {
|
||||||
|
final s = AppScope.of(context).coordinator.settings;
|
||||||
|
final bar = preview.controlsBand.peek() && s.liftSubtitles.peek()
|
||||||
|
? controlsBottomHeight(s.controlsSkin.peek(), s.controlsSize.peek()) + 8
|
||||||
|
: 0.0;
|
||||||
|
final box = _chat.open.value ? 16 + _boxH + 8 : 0.0;
|
||||||
|
_lift.animateTo(box > bar ? box : bar);
|
||||||
|
if (!_chat.open.value) _pageFocus.requestFocus();
|
||||||
|
}
|
||||||
|
|
||||||
|
KeyEventResult _key(FocusNode node, KeyEvent e) {
|
||||||
|
if (e is! KeyDownEvent) return KeyEventResult.ignored;
|
||||||
|
if (e.logicalKey == LogicalKeyboardKey.enter || e.logicalKey == LogicalKeyboardKey.numpadEnter) {
|
||||||
|
_chat.show();
|
||||||
|
return KeyEventResult.handled;
|
||||||
|
}
|
||||||
|
return KeyEventResult.ignored;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Focus(
|
||||||
|
focusNode: _pageFocus,
|
||||||
|
onKeyEvent: _key,
|
||||||
|
child: Material(
|
||||||
|
color: Colors.black,
|
||||||
|
child: Stack(
|
||||||
|
fit: StackFit.expand,
|
||||||
|
children: [
|
||||||
|
const DanmakuStandIn(),
|
||||||
|
DanmakuPreviewLayers(preview: preview, lift: _lift),
|
||||||
|
SignalBuilder(builder: (context) {
|
||||||
|
if (!preview.controlsBand.value) return const SizedBox.shrink();
|
||||||
|
final s = AppScope.of(context).coordinator.settings;
|
||||||
|
final h = controlsBottomHeight(s.controlsSkin.value, s.controlsSize.value);
|
||||||
|
return Align(
|
||||||
|
alignment: Alignment.bottomCenter,
|
||||||
|
child: IgnorePointer(
|
||||||
|
child: ValueListenableBuilder<bool>(
|
||||||
|
valueListenable: _chat.open,
|
||||||
|
builder: (context, open, child) =>
|
||||||
|
AnimatedOpacity(opacity: open ? 0 : 1, duration: easeTime, curve: ease, child: child),
|
||||||
|
child: Container(
|
||||||
|
height: h,
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.topCenter,
|
||||||
|
end: Alignment.bottomCenter,
|
||||||
|
colors: [Color(0x00000000), MinimalStyle.band],
|
||||||
|
),
|
||||||
|
border: Border(top: BorderSide(color: AppMenuStyle.border, width: 0.5)),
|
||||||
|
),
|
||||||
|
alignment: Alignment.center,
|
||||||
|
child: Text('control bar',
|
||||||
|
style: TextStyle(color: Colors.white.withValues(alpha: 0.35), fontSize: 12)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
Positioned(
|
||||||
|
left: 16,
|
||||||
|
right: 16,
|
||||||
|
bottom: 16,
|
||||||
|
child: QuickChatBox(
|
||||||
|
chat: _chat,
|
||||||
|
onSend: (text) => preview.controller.add(preview.own(text)),
|
||||||
|
onHeight: (h) {
|
||||||
|
_boxH = h;
|
||||||
|
if (_chat.open.value) _retarget();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Positioned(
|
||||||
|
left: 16,
|
||||||
|
top: 14,
|
||||||
|
child: Text('Stand-in picture: load something to preview on the real player · Enter: chat box',
|
||||||
|
style: TextStyle(color: Colors.white.withValues(alpha: 0.55), fontSize: 12)),
|
||||||
|
),
|
||||||
|
Positioned(
|
||||||
|
right: 16,
|
||||||
|
top: 16,
|
||||||
|
child: DanmakuPanel(preview: preview, onClose: () => Navigator.of(context).pop()),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The panel over the session player; while mounted, this session's
|
||||||
|
/// toasts reach the preview when its "chat and notices" switch is on.
|
||||||
|
class DanmakuSessionPanel extends StatefulWidget {
|
||||||
|
final DanmakuPreview preview;
|
||||||
|
final SessionCoordinator coord;
|
||||||
|
const DanmakuSessionPanel({super.key, required this.preview, required this.coord});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<DanmakuSessionPanel> createState() => _DanmakuSessionPanelState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DanmakuSessionPanelState extends State<DanmakuSessionPanel> {
|
||||||
|
late final void Function() _stop = widget.preview.watchToasts(widget.coord.toasts);
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
widget.preview.coord = widget.coord;
|
||||||
|
_stop;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_stop();
|
||||||
|
widget.preview.coord = null;
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => DanmakuPanel(
|
||||||
|
preview: widget.preview,
|
||||||
|
session: true,
|
||||||
|
onClose: () => widget.preview.active.value = false,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,342 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:math' as math;
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:signals/signals_flutter.dart';
|
||||||
|
|
||||||
|
import '../shell/avatar.dart' show AvatarInfo, colorForName, selfAvatarInfo;
|
||||||
|
import '../state/settings.dart';
|
||||||
|
import '../widgets/app_menu.dart' show AppSelect;
|
||||||
|
import 'danmaku.dart';
|
||||||
|
import 'danmaku_preview.dart' show DanmakuPreview, DanmakuStandIn, MockSubtitles;
|
||||||
|
|
||||||
|
// Settings → Notifications & sounds: how notifications show over the full
|
||||||
|
// player (Toasts or Danmaku) and every knob of the danmaku look, with a
|
||||||
|
// live preview strip. The player's side panel shows the style and the
|
||||||
|
// knobs too ([DanmakuKnobs]).
|
||||||
|
|
||||||
|
/// Names of the weights the boldness slider steps through.
|
||||||
|
const danmakuWeightNames = {
|
||||||
|
400: 'Regular',
|
||||||
|
500: 'Medium',
|
||||||
|
600: 'Semibold',
|
||||||
|
700: 'Bold',
|
||||||
|
800: 'Extra bold',
|
||||||
|
900: 'Black',
|
||||||
|
};
|
||||||
|
|
||||||
|
/// "Notifications: Toasts / Danmaku" ([AppSettings.notifyStyle]).
|
||||||
|
class NotifyStyleSelect extends StatelessWidget {
|
||||||
|
final AppSettings settings;
|
||||||
|
const NotifyStyleSelect({super.key, required this.settings});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => SignalBuilder(
|
||||||
|
builder: (context) => AppSelect<NotifyStyle>(
|
||||||
|
key: const ValueKey('notify-style'),
|
||||||
|
value: settings.notifyStyle.value,
|
||||||
|
onChanged: settings.setNotifyStyle,
|
||||||
|
items: [for (final m in NotifyStyle.values) (m, m.label)],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The card: the style picker, the preview strip and the knobs.
|
||||||
|
class DanmakuSettingsPanel extends StatelessWidget {
|
||||||
|
final AppSettings settings;
|
||||||
|
const DanmakuSettingsPanel({super.key, required this.settings});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final muted = TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant, fontSize: 12);
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Text('Notifications'),
|
||||||
|
Text(
|
||||||
|
'Danmaku: over the full player, chat, joins, leaves, Up next and "starting now" fly across '
|
||||||
|
'the top of the picture instead of popping up. Problems and errors stay pop-ups, and so does '
|
||||||
|
'everything with the player small',
|
||||||
|
style: muted),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
NotifyStyleSelect(settings: settings),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
DanmakuPreviewStrip(settings: settings),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
DanmakuKnobs(settings: settings),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A 16:9-ish strip with a moving picture, a top sign and bottom
|
||||||
|
/// dialogue, and sample lines in the current look, a few a second.
|
||||||
|
class DanmakuPreviewStrip extends StatefulWidget {
|
||||||
|
final AppSettings settings;
|
||||||
|
final double height;
|
||||||
|
const DanmakuPreviewStrip({super.key, required this.settings, this.height = 190});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<DanmakuPreviewStrip> createState() => _DanmakuPreviewStripState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DanmakuPreviewStripState extends State<DanmakuPreviewStrip> {
|
||||||
|
/// The strip plays this long after it opens or a knob moves, then
|
||||||
|
/// rests (a click plays it again): a settings page left open costs
|
||||||
|
/// nothing.
|
||||||
|
static const playFor = Duration(seconds: 16);
|
||||||
|
final _controller = DanmakuController();
|
||||||
|
final _rng = math.Random(3);
|
||||||
|
Timer? _timer;
|
||||||
|
Timer? _rest;
|
||||||
|
bool _playing = false;
|
||||||
|
int _n = 0;
|
||||||
|
late final void Function() _stop;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_stop = effect(() {
|
||||||
|
widget.settings.danmakuStyle.value;
|
||||||
|
untracked(_play);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_stop();
|
||||||
|
_timer?.cancel();
|
||||||
|
_rest?.cancel();
|
||||||
|
_controller.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _play() {
|
||||||
|
_rest?.cancel();
|
||||||
|
_rest = Timer(playFor, () {
|
||||||
|
_timer?.cancel();
|
||||||
|
_timer = null;
|
||||||
|
if (mounted) setState(() => _playing = false);
|
||||||
|
});
|
||||||
|
if (_timer != null) return;
|
||||||
|
_send();
|
||||||
|
_timer = Timer.periodic(const Duration(milliseconds: 1100), (_) => _send());
|
||||||
|
if (_playing) return;
|
||||||
|
_playing = true;
|
||||||
|
if (_built && mounted) setState(() {});
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _built = false;
|
||||||
|
|
||||||
|
void _send() {
|
||||||
|
final i = _n++;
|
||||||
|
if (i % 7 == 3) {
|
||||||
|
final me = selfAvatarInfo();
|
||||||
|
_controller.add(DanmakuItem(
|
||||||
|
kind: DanmakuKind.chat, sender: me.name, senderColor: me.color, text: 'this is my line', own: true, avatar: me));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final name = DanmakuPreview.sampleNames[_rng.nextInt(DanmakuPreview.sampleNames.length)];
|
||||||
|
final who = AvatarInfo(name: name, color: colorForName(name));
|
||||||
|
if (i % 9 == 5) {
|
||||||
|
_controller.add(DanmakuItem(kind: DanmakuKind.join, sender: name, senderColor: who.color, text: 'joined', avatar: who));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Short and medium lines: the long ones crowd a small strip.
|
||||||
|
final pool = [for (final t in DanmakuPreview.sampleChat) if (t.length < 48) t];
|
||||||
|
_controller.add(DanmakuItem(
|
||||||
|
kind: DanmakuKind.chat,
|
||||||
|
sender: name,
|
||||||
|
senderColor: who.color,
|
||||||
|
text: pool[_rng.nextInt(pool.length)],
|
||||||
|
avatar: who));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
_built = true;
|
||||||
|
final accent = Theme.of(context).colorScheme.primary;
|
||||||
|
return ClipRRect(
|
||||||
|
key: const ValueKey('danmaku-preview-strip'),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
child: SizedBox(
|
||||||
|
height: widget.height,
|
||||||
|
child: Stack(
|
||||||
|
fit: StackFit.expand,
|
||||||
|
children: [
|
||||||
|
DanmakuStandIn(animate: _playing),
|
||||||
|
const MockSubtitles(top: true, bottom: true),
|
||||||
|
SignalBuilder(
|
||||||
|
builder: (context) =>
|
||||||
|
DanmakuLayer(controller: _controller, style: widget.settings.danmakuStyle.value, accent: accent),
|
||||||
|
),
|
||||||
|
Center(
|
||||||
|
child: AnimatedOpacity(
|
||||||
|
opacity: _playing ? 0 : 1,
|
||||||
|
duration: const Duration(milliseconds: 200),
|
||||||
|
child: IgnorePointer(
|
||||||
|
ignoring: _playing,
|
||||||
|
child: FilledButton.tonalIcon(
|
||||||
|
key: const ValueKey('danmaku-preview-play'),
|
||||||
|
onPressed: _play,
|
||||||
|
icon: const Icon(Icons.play_arrow_rounded, size: 18),
|
||||||
|
label: const Text('Play preview'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every knob of the danmaku look, live and saved: crossing time, how
|
||||||
|
/// many at once, the lane band, opacity, size, boldness, outline,
|
||||||
|
/// avatars, your own lines framed; and a reset.
|
||||||
|
class DanmakuKnobs extends StatelessWidget {
|
||||||
|
final AppSettings settings;
|
||||||
|
|
||||||
|
/// Narrow (the player's side panel): shorter labels column.
|
||||||
|
final bool dense;
|
||||||
|
const DanmakuKnobs({super.key, required this.settings, this.dense = false});
|
||||||
|
|
||||||
|
void _set(DanmakuStyle Function(DanmakuStyle s) f) => settings.setDanmakuStyle(f(settings.danmakuStyle.peek()));
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
const l = DanmakuStyle.limits;
|
||||||
|
return SignalBuilder(builder: (context) {
|
||||||
|
final s = settings.danmakuStyle.value;
|
||||||
|
final labelW = dense ? 84.0 : 150.0;
|
||||||
|
Widget slider(String key, String label, double v, (num, num) r, String readout, ValueChanged<double> f,
|
||||||
|
{int? divisions}) =>
|
||||||
|
_Knob(
|
||||||
|
label: label,
|
||||||
|
labelWidth: labelW,
|
||||||
|
readout: readout,
|
||||||
|
child: Slider(
|
||||||
|
key: ValueKey('danmaku-$key'),
|
||||||
|
value: v.clamp(r.$1.toDouble(), r.$2.toDouble()),
|
||||||
|
min: r.$1.toDouble(),
|
||||||
|
max: r.$2.toDouble(),
|
||||||
|
divisions: divisions,
|
||||||
|
onChanged: f,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
Widget toggle(String key, String label, bool v, ValueChanged<bool> f) => _Knob(
|
||||||
|
label: label,
|
||||||
|
labelWidth: double.infinity,
|
||||||
|
child: Switch(key: ValueKey('danmaku-$key'), value: v, onChanged: f),
|
||||||
|
);
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
slider('cross', dense ? 'Crossing' : 'Crossing time', s.crossSecs, l.crossSecs,
|
||||||
|
'${s.crossSecs.toStringAsFixed(1)} s', (v) => _set((s) => s.copyWith(crossSecs: v))),
|
||||||
|
slider('cap', dense ? 'At most' : 'At most on screen', s.maxOnScreen.toDouble(), l.maxOnScreen,
|
||||||
|
'${s.maxOnScreen} lines', (v) => _set((s) => s.copyWith(maxOnScreen: v.round())),
|
||||||
|
divisions: l.maxOnScreen.$2 - l.maxOnScreen.$1),
|
||||||
|
_Knob(
|
||||||
|
label: dense ? 'Lanes' : 'Lanes (from the top)',
|
||||||
|
labelWidth: labelW,
|
||||||
|
readout: '${(s.bandTop * 100).round()}–${(s.bandBottom * 100).round()} %',
|
||||||
|
child: RangeSlider(
|
||||||
|
key: const ValueKey('danmaku-band'),
|
||||||
|
values: RangeValues(s.bandTop, s.bandBottom),
|
||||||
|
min: l.band.$1,
|
||||||
|
max: l.band.$2,
|
||||||
|
onChanged: (v) {
|
||||||
|
if (v.end - v.start >= l.minBand) _set((s) => s.copyWith(bandTop: v.start, bandBottom: v.end));
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
slider('opacity', 'Opacity', s.opacity, l.opacity, '${(s.opacity * 100).round()} %',
|
||||||
|
(v) => _set((s) => s.copyWith(opacity: v))),
|
||||||
|
slider('size', 'Size', s.size, l.size, '×${s.size.toStringAsFixed(2)}', (v) => _set((s) => s.copyWith(size: v))),
|
||||||
|
slider('weight', 'Boldness', s.weight.toDouble(), l.weight, danmakuWeightNames[s.weight] ?? '${s.weight}',
|
||||||
|
(v) => _set((s) => s.copyWith(weight: (v / 100).round() * 100)),
|
||||||
|
divisions: (l.weight.$2 - l.weight.$1) ~/ 100),
|
||||||
|
slider('outline', 'Outline', s.outline, l.outline,
|
||||||
|
s.outline < 0.05 ? 'none' : '${s.outline.toStringAsFixed(1)} px', (v) => _set((s) => s.copyWith(outline: v))),
|
||||||
|
toggle('avatars', 'Avatars before the lines', s.avatars, (v) => _set((s) => s.copyWith(avatars: v))),
|
||||||
|
toggle('own', 'Frame my own lines', s.highlightOwn, (v) => _set((s) => s.copyWith(highlightOwn: v))),
|
||||||
|
Align(
|
||||||
|
alignment: Alignment.centerRight,
|
||||||
|
child: TextButton.icon(
|
||||||
|
key: const ValueKey('danmaku-reset'),
|
||||||
|
onPressed: s == const DanmakuStyle() ? null : () => settings.setDanmakuStyle(const DanmakuStyle()),
|
||||||
|
icon: const Icon(Icons.restart_alt, size: 18),
|
||||||
|
label: const Text('Reset look'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _Knob extends StatelessWidget {
|
||||||
|
final String label;
|
||||||
|
final double labelWidth;
|
||||||
|
final String? readout;
|
||||||
|
final Widget child;
|
||||||
|
const _Knob({required this.label, required this.labelWidth, this.readout, required this.child});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final scheme = Theme.of(context).colorScheme;
|
||||||
|
final text = Text(label, style: const TextStyle(fontSize: 13));
|
||||||
|
if (labelWidth == double.infinity) {
|
||||||
|
return SizedBox(
|
||||||
|
height: 38,
|
||||||
|
child: Row(children: [Expanded(child: text), Transform.scale(scale: 0.8, alignment: Alignment.centerRight, child: child)]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return SizedBox(
|
||||||
|
height: 36,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
SizedBox(width: labelWidth, child: text),
|
||||||
|
Expanded(
|
||||||
|
child: SliderTheme(
|
||||||
|
data: SliderTheme.of(context).copyWith(
|
||||||
|
trackHeight: 3,
|
||||||
|
overlayShape: SliderComponentShape.noOverlay,
|
||||||
|
thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 7),
|
||||||
|
rangeThumbShape: const RoundRangeSliderThumbShape(enabledThumbRadius: 7),
|
||||||
|
showValueIndicator: ShowValueIndicator.never,
|
||||||
|
tickMarkShape: SliderTickMarkShape.noTickMark,
|
||||||
|
),
|
||||||
|
child: child,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(
|
||||||
|
width: 78,
|
||||||
|
child: Text(
|
||||||
|
readout ?? '',
|
||||||
|
textAlign: TextAlign.right,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: scheme.onSurfaceVariant,
|
||||||
|
fontFeatures: const [FontFeature.tabularFigures()],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,196 +0,0 @@
|
|||||||
// Release file names -> series / season / episode, anitomy-style but small:
|
|
||||||
// enough to group a downloads folder into shows. Unknown shapes fall back to
|
|
||||||
// the cleaned name with no episode, never to a wrong number.
|
|
||||||
|
|
||||||
/// What a release file name says.
|
|
||||||
class ParsedName {
|
|
||||||
/// Title without group, season marker, episode, year or release tags.
|
|
||||||
final String series;
|
|
||||||
final int? season;
|
|
||||||
final int? episode;
|
|
||||||
/// Release group: leading `[Group]` or scene-style trailing `-GRP`.
|
|
||||||
final String? group;
|
|
||||||
/// Release year, taken out of [series] (movies, `Title (2019)`).
|
|
||||||
final int? year;
|
|
||||||
/// Non-episode marker (`NCOP1`, `NCED`, `OVA 2`, `Special`): such files
|
|
||||||
/// carry no [episode] so they never sort among the real ones.
|
|
||||||
final String? extra;
|
|
||||||
|
|
||||||
const ParsedName(
|
|
||||||
this.series, {
|
|
||||||
this.season,
|
|
||||||
this.episode,
|
|
||||||
this.group,
|
|
||||||
this.year,
|
|
||||||
this.extra,
|
|
||||||
});
|
|
||||||
|
|
||||||
/// `S01E03`, `E25`, the [extra] marker, `S02` for a season pack, else ''.
|
|
||||||
String get label {
|
|
||||||
final s = season == null ? '' : 'S${season.toString().padLeft(2, '0')}';
|
|
||||||
if (episode != null) return '${s}E${episode.toString().padLeft(2, '0')}';
|
|
||||||
return extra ?? s;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Grouping key: case, punctuation and spacing do not split a series.
|
|
||||||
String get seriesKey => normalizeSeries(series);
|
|
||||||
|
|
||||||
@override
|
|
||||||
String toString() => 'ParsedName($series, s=$season, e=$episode, '
|
|
||||||
'group=$group, year=$year, extra=$extra)';
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Lowercase letters and digits of [s], single spaces between words.
|
|
||||||
String normalizeSeries(String s) => s
|
|
||||||
.toLowerCase()
|
|
||||||
.replaceAll(RegExp(r"['’`]"), '')
|
|
||||||
.replaceAll(RegExp(r'[^\p{L}\p{N}]+', unicode: true), ' ')
|
|
||||||
.trim();
|
|
||||||
|
|
||||||
// Release tags: the title ends where the first of these starts.
|
|
||||||
final _junk = RegExp(
|
|
||||||
r'\b(?:2160p|1440p|1080[pi]|720p|576p|480p|4K|UHD|[xh]\.?26[45]|HEVC|AVC|'
|
|
||||||
r'AV1|VP9|WEB(?:-?DL|-?Rip)?|BD(?:Rip|Remux)?|Blu-?Ray|BRRip|DVD(?:Rip)?|'
|
|
||||||
r'HDTV|HDR(?:10)?|DV|REMUX|AAC(?:\d\.\d)?|FLAC|Opus|DTS(?:-HD)?|E?-?AC-?3|'
|
|
||||||
r'DDP?\d?|TrueHD|Atmos|10-?bit|8-?bit|Hi10P?|PROPER|REPACK|Dual[ -]Audio|'
|
|
||||||
r'Multi(?:ple)?[ -]Subs?(?:titles?)?|AMZN|NF|CR|DSNP|HMAX|ATVP|'
|
|
||||||
r'Batch|Complete)\b',
|
|
||||||
caseSensitive: false,
|
|
||||||
);
|
|
||||||
final _ext = RegExp(r'\.(?=[a-z0-9]*[a-z])[a-z0-9]{2,4}$', caseSensitive: false);
|
|
||||||
final _leadingGroup = RegExp(r'^\s*\[([^\]]+)\]');
|
|
||||||
final _sceneGroup = RegExp(r'-([A-Za-z0-9]+)$');
|
|
||||||
final _bracketYear = RegExp(r'[(\[]((?:19|20)\d{2})[)\]]');
|
|
||||||
final _brackets = RegExp(r'\[[^\]]*\]|\([^)]*\)|\{[^}]*\}|【[^】]*】');
|
|
||||||
// S01E02, S01.E02, S1 E2; a batch `S01E01-E02` / `S01E01E02` keeps the first.
|
|
||||||
final _sxe = RegExp(r'\bS(\d{1,2})[ .]?E(\d{1,4})(?:v\d)?(?!\d)',
|
|
||||||
caseSensitive: false);
|
|
||||||
final _nxn = RegExp(r'\b(\d{1,2})x(\d{2,3})\b');
|
|
||||||
// Anime: `Title - 25`, `- 03v2`, `- 01-02` (batch keeps the first).
|
|
||||||
final _dashNumber = RegExp(r'\s[-–]\s(\d{1,4})(?:v\d)?(?=$|[\s\-~.])');
|
|
||||||
final _episodeWord = RegExp(
|
|
||||||
r'\b(?:Episode|Ep\.?|E)\s?(\d{1,4})(?:v\d)?\b',
|
|
||||||
caseSensitive: false,
|
|
||||||
);
|
|
||||||
// Extras: unambiguous words anywhere, short ones only right after the dash.
|
|
||||||
final _extraAnywhere = RegExp(
|
|
||||||
r'(?:^|\s)(NCOP|NCED|OVA|OAD|ONA)\s?(\d{1,2})?(?:v\d)?(?=$|\s)',
|
|
||||||
caseSensitive: false,
|
|
||||||
);
|
|
||||||
final _extraAfterDash = RegExp(
|
|
||||||
r'\s[-–]\s(OP|ED|SP|Specials?|Extras?|Recap|Preview|PV|Menu)\s?(\d{1,2})?'
|
|
||||||
r'(?:v\d)?(?=$|\s)',
|
|
||||||
caseSensitive: false,
|
|
||||||
);
|
|
||||||
final _seasonInTitle = RegExp(
|
|
||||||
r'\s(?:S(\d{1,2})|Season\s?(\d{1,2})|(\d{1,2})(?:st|nd|rd|th)\s+Season)\s*$',
|
|
||||||
caseSensitive: false,
|
|
||||||
);
|
|
||||||
final _trailingYear = RegExp(r'\s((?:19|20)\d{2})$');
|
|
||||||
|
|
||||||
/// Parses a file name (with or without the extension, no directories).
|
|
||||||
ParsedName parseReleaseName(String fileName) {
|
|
||||||
var s = fileName.replaceFirst(_ext, '').trim();
|
|
||||||
final original = s;
|
|
||||||
String? group = _leadingGroup.firstMatch(s)?.group(1)?.trim();
|
|
||||||
int? year;
|
|
||||||
final by = _bracketYear.firstMatch(s);
|
|
||||||
if (by != null) year = int.parse(by.group(1)!);
|
|
||||||
s = s.replaceAll(_brackets, ' ');
|
|
||||||
|
|
||||||
// Dots and underscores as separators (scene style), unless the name
|
|
||||||
// already has spaces and only the odd `Dr. Stone` dot.
|
|
||||||
final dotted = !s.contains(' ') || RegExp(r'\w\.\w').allMatches(s).length > 2;
|
|
||||||
if (dotted) {
|
|
||||||
// `...H264-GRP`, but not `...WEB-DL`.
|
|
||||||
final tags = _junk.allMatches(s);
|
|
||||||
if (group == null && tags.isNotEmpty && tags.last.end < s.length) {
|
|
||||||
final g = _sceneGroup.firstMatch(s);
|
|
||||||
if (g != null) {
|
|
||||||
group = g.group(1);
|
|
||||||
s = s.substring(0, g.start);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
s = s.replaceAll('.', ' ');
|
|
||||||
}
|
|
||||||
s = s.replaceAll('_', ' ').replaceAll(RegExp(r'\s+'), ' ').trim();
|
|
||||||
|
|
||||||
// Release tags end the interesting part.
|
|
||||||
final junk = _junk.firstMatch(s);
|
|
||||||
if (junk != null && junk.start > 0) s = s.substring(0, junk.start).trim();
|
|
||||||
|
|
||||||
int? season, episode;
|
|
||||||
String? extra;
|
|
||||||
var cut = s.length;
|
|
||||||
final extraMatch =
|
|
||||||
_extraAfterDash.firstMatch(s) ?? _extraAnywhere.firstMatch(s);
|
|
||||||
final sxe = _sxe.firstMatch(s);
|
|
||||||
if (extraMatch != null) {
|
|
||||||
final word = extraMatch.group(1)!;
|
|
||||||
final upper = word.length <= 4 ? word.toUpperCase() : _title(word);
|
|
||||||
// `OVA - 02`: the number may follow the dash instead.
|
|
||||||
final n = extraMatch.group(2) ??
|
|
||||||
_dashNumber.firstMatch(s.substring(extraMatch.end))?.group(1);
|
|
||||||
extra = n == null
|
|
||||||
? upper
|
|
||||||
: (upper.startsWith('NC') || upper.length <= 2)
|
|
||||||
? '$upper${int.parse(n)}'
|
|
||||||
: '$upper ${int.parse(n)}';
|
|
||||||
cut = extraMatch.start;
|
|
||||||
if (sxe != null && sxe.start < cut) {
|
|
||||||
season = int.parse(sxe.group(1)!);
|
|
||||||
cut = sxe.start;
|
|
||||||
}
|
|
||||||
} else if (sxe != null) {
|
|
||||||
season = int.parse(sxe.group(1)!);
|
|
||||||
episode = int.parse(sxe.group(2)!);
|
|
||||||
cut = sxe.start;
|
|
||||||
} else {
|
|
||||||
final nxn = _nxn.firstMatch(s);
|
|
||||||
if (nxn != null) {
|
|
||||||
season = int.parse(nxn.group(1)!);
|
|
||||||
episode = int.parse(nxn.group(2)!);
|
|
||||||
cut = nxn.start;
|
|
||||||
} else {
|
|
||||||
final m = _dashNumber.firstMatch(s) ?? _episodeWord.firstMatch(s);
|
|
||||||
if (m != null) {
|
|
||||||
episode = int.parse(m.group(1)!);
|
|
||||||
cut = m.start;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var series = s.substring(0, cut);
|
|
||||||
// Season pack or season marker kept in the title: `Title S2`,
|
|
||||||
// `Title Season 2`, `Title 2nd Season`, `Title.S01.1080p`.
|
|
||||||
final sIn = _seasonInTitle.firstMatch(series);
|
|
||||||
if (sIn != null) {
|
|
||||||
season ??= int.parse(sIn.group(1) ?? sIn.group(2) ?? sIn.group(3)!);
|
|
||||||
series = series.substring(0, sIn.start);
|
|
||||||
}
|
|
||||||
series = series.replaceAll(RegExp(r'[\s\-–:,~]+$'), '').trim();
|
|
||||||
// A trailing year is the release year, unless it is the whole title or
|
|
||||||
// too far ahead to be one (`Blade Runner 2049`).
|
|
||||||
final ty = _trailingYear.firstMatch(series);
|
|
||||||
if (ty != null) {
|
|
||||||
final y = int.parse(ty.group(1)!);
|
|
||||||
if (y <= DateTime.now().year + 1) {
|
|
||||||
year ??= y;
|
|
||||||
series = series.substring(0, ty.start).trim();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
series = series.replaceAll(RegExp(r'[\s\-–:,~]+$'), '').trim();
|
|
||||||
if (series.isEmpty) {
|
|
||||||
series = original.replaceAll(RegExp(r'[._]+'), ' ').trim();
|
|
||||||
}
|
|
||||||
return ParsedName(
|
|
||||||
series,
|
|
||||||
season: season,
|
|
||||||
episode: episode,
|
|
||||||
group: group,
|
|
||||||
year: year,
|
|
||||||
extra: extra,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
String _title(String w) => w[0].toUpperCase() + w.substring(1).toLowerCase();
|
|
||||||
@@ -1,16 +1,13 @@
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'dart:isolate';
|
import 'dart:isolate';
|
||||||
|
|
||||||
import 'name_parser.dart';
|
import 'package:sync_relay_core/core.dart';
|
||||||
|
|
||||||
// Folder scan for the Files source: videos with their parsed names,
|
export 'package:sync_relay_core/core.dart' show FileKind, LocalFile, kindOf, subtitleExtensions, videoExtensions;
|
||||||
// sidecar subtitles, everything else counted and hidden. The walk is
|
|
||||||
// synchronous I/O, so it runs in its own isolate.
|
|
||||||
|
|
||||||
const videoExtensions = {
|
// Folder scan for the Files source: the videos (grouped into shows by
|
||||||
'mkv', 'mp4', 'webm', 'avi', 'mov', 'm4v', 'ts', 'm2ts', 'wmv', 'flv', //
|
// core's `groupLocal`, L1), sidecar subtitles, everything else counted
|
||||||
};
|
// and hidden. The walk is synchronous I/O, so it runs in its own isolate.
|
||||||
const subtitleExtensions = {'ass', 'ssa', 'srt', 'vtt', 'sup'};
|
|
||||||
|
|
||||||
/// Files looked at per scan; past this the result is [FolderScan.truncated].
|
/// Files looked at per scan; past this the result is [FolderScan.truncated].
|
||||||
const maxScannedFiles = 20000;
|
const maxScannedFiles = 20000;
|
||||||
@@ -18,27 +15,8 @@ const maxScannedFiles = 20000;
|
|||||||
/// Samples and trailers under this size are hidden next to the real thing.
|
/// Samples and trailers under this size are hidden next to the real thing.
|
||||||
const _sampleMaxBytes = 300 << 20;
|
const _sampleMaxBytes = 300 << 20;
|
||||||
|
|
||||||
enum FileKind { video, subtitle, other }
|
|
||||||
|
|
||||||
FileKind kindOf(String path) {
|
|
||||||
final ext = _extension(_baseName(path));
|
|
||||||
if (videoExtensions.contains(ext)) return FileKind.video;
|
|
||||||
if (subtitleExtensions.contains(ext)) return FileKind.subtitle;
|
|
||||||
return FileKind.other;
|
|
||||||
}
|
|
||||||
|
|
||||||
class VideoFile {
|
|
||||||
final String path;
|
|
||||||
final int size;
|
|
||||||
final ParsedName parsed;
|
|
||||||
final DateTime modified;
|
|
||||||
const VideoFile(this.path, this.size, this.parsed, this.modified);
|
|
||||||
|
|
||||||
String get name => _baseName(path);
|
|
||||||
}
|
|
||||||
|
|
||||||
class FolderScan {
|
class FolderScan {
|
||||||
final List<VideoFile> videos;
|
final List<LocalFile> videos;
|
||||||
/// Non-video files and samples (subtitles and dot-entries not counted).
|
/// Non-video files and samples (subtitles and dot-entries not counted).
|
||||||
final int hiddenCount;
|
final int hiddenCount;
|
||||||
/// Up to 6 of the hidden file names, for "and N other files" hints.
|
/// Up to 6 of the hidden file names, for "and N other files" hints.
|
||||||
@@ -75,7 +53,7 @@ Future<FolderScan> scanFolder(String dir, {int maxFiles = maxScannedFiles}) =>
|
|||||||
|
|
||||||
/// The walk behind [scanFolder]; blocks, so call it from an isolate.
|
/// The walk behind [scanFolder]; blocks, so call it from an isolate.
|
||||||
FolderScan scanFolderSync(String dir, {int maxFiles = maxScannedFiles}) {
|
FolderScan scanFolderSync(String dir, {int maxFiles = maxScannedFiles}) {
|
||||||
final videos = <VideoFile>[];
|
final videos = <LocalFile>[];
|
||||||
final subs = <String>[];
|
final subs = <String>[];
|
||||||
final examples = <String>[];
|
final examples = <String>[];
|
||||||
var hidden = 0, seen = 0;
|
var hidden = 0, seen = 0;
|
||||||
@@ -123,8 +101,7 @@ FolderScan scanFolderSync(String dir, {int maxFiles = maxScannedFiles}) {
|
|||||||
if (examples.length < 6) examples.add(name);
|
if (examples.length < 6) examples.add(name);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
videos.add(VideoFile(e.path, size, parseReleaseName(name),
|
videos.add(LocalFile(e.path, size, stat?.modified ?? DateTime.fromMillisecondsSinceEpoch(0)));
|
||||||
stat?.modified ?? DateTime.fromMillisecondsSinceEpoch(0)));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return FolderScan(
|
return FolderScan(
|
||||||
@@ -138,7 +115,7 @@ FolderScan scanFolderSync(String dir, {int maxFiles = maxScannedFiles}) {
|
|||||||
|
|
||||||
/// Subtitles in the same folder whose name is the video's base name,
|
/// Subtitles in the same folder whose name is the video's base name,
|
||||||
/// optionally followed by `.lang` / `.forced` style suffixes.
|
/// optionally followed by `.lang` / `.forced` style suffixes.
|
||||||
Map<String, List<String>> _sidecars(List<VideoFile> videos, List<String> subs) {
|
Map<String, List<String>> _sidecars(List<LocalFile> videos, List<String> subs) {
|
||||||
final byStem = <String, List<String>>{};
|
final byStem = <String, List<String>>{};
|
||||||
for (final s in subs) {
|
for (final s in subs) {
|
||||||
final stem = _stripExtension(s);
|
final stem = _stripExtension(s);
|
||||||
@@ -163,93 +140,11 @@ Map<String, List<String>> _sidecars(List<VideoFile> videos, List<String> subs) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One show: its episodes in season/episode order plus its extras
|
|
||||||
/// (NCOP, OVA...) in name order.
|
|
||||||
class SeriesGroup {
|
|
||||||
/// Display title: the most common spelling among the files.
|
|
||||||
final String title;
|
|
||||||
final String key;
|
|
||||||
final List<VideoFile> episodes;
|
|
||||||
final List<VideoFile> extras;
|
|
||||||
const SeriesGroup(this.title, this.key, this.episodes, this.extras);
|
|
||||||
|
|
||||||
/// Distinct seasons, ascending; files without one count as season 1.
|
|
||||||
List<int> get seasons =>
|
|
||||||
({for (final e in episodes) e.parsed.season ?? 1}.toList()..sort());
|
|
||||||
}
|
|
||||||
|
|
||||||
/// [groupBySeries] result: shows, and videos that are not episodes.
|
|
||||||
class GroupedVideos {
|
|
||||||
final List<SeriesGroup> series;
|
|
||||||
final List<VideoFile> loose;
|
|
||||||
const GroupedVideos(this.series, this.loose);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Groups videos with an episode number (or an extra marker) by
|
|
||||||
/// [ParsedName.seriesKey]; the rest (movies, clips) stay loose, by name.
|
|
||||||
/// An extra whose series has no episodes in the list is loose too.
|
|
||||||
GroupedVideos groupBySeries(Iterable<VideoFile> videos) {
|
|
||||||
final episodes = <String, List<VideoFile>>{};
|
|
||||||
final extras = <String, List<VideoFile>>{};
|
|
||||||
final loose = <VideoFile>[];
|
|
||||||
for (final v in videos) {
|
|
||||||
final p = v.parsed;
|
|
||||||
final key = p.seriesKey;
|
|
||||||
if (p.episode != null && key.isNotEmpty) {
|
|
||||||
episodes.putIfAbsent(key, () => []).add(v);
|
|
||||||
} else if (p.extra != null && key.isNotEmpty) {
|
|
||||||
extras.putIfAbsent(key, () => []).add(v);
|
|
||||||
} else {
|
|
||||||
loose.add(v);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (final e in extras.entries) {
|
|
||||||
if (!episodes.containsKey(e.key)) loose.addAll(e.value);
|
|
||||||
}
|
|
||||||
int byName(VideoFile a, VideoFile b) =>
|
|
||||||
a.name.toLowerCase().compareTo(b.name.toLowerCase());
|
|
||||||
final groups = [
|
|
||||||
for (final e in episodes.entries)
|
|
||||||
SeriesGroup(
|
|
||||||
_commonTitle(e.value),
|
|
||||||
e.key,
|
|
||||||
e.value..sort(_byEpisode),
|
|
||||||
(extras[e.key] ?? [])..sort(byName),
|
|
||||||
),
|
|
||||||
]..sort((a, b) => a.title.toLowerCase().compareTo(b.title.toLowerCase()));
|
|
||||||
return GroupedVideos(groups, loose..sort(byName));
|
|
||||||
}
|
|
||||||
|
|
||||||
int _byEpisode(VideoFile a, VideoFile b) {
|
|
||||||
final pa = a.parsed, pb = b.parsed;
|
|
||||||
final s = (pa.season ?? 1).compareTo(pb.season ?? 1);
|
|
||||||
if (s != 0) return s;
|
|
||||||
final e = pa.episode!.compareTo(pb.episode!);
|
|
||||||
if (e != 0) return e;
|
|
||||||
// Same episode twice (v2, another group): newest release first.
|
|
||||||
return b.name.compareTo(a.name);
|
|
||||||
}
|
|
||||||
|
|
||||||
String _commonTitle(List<VideoFile> files) {
|
|
||||||
final counts = <String, int>{};
|
|
||||||
for (final f in files) {
|
|
||||||
counts.update(f.parsed.series, (n) => n + 1, ifAbsent: () => 1);
|
|
||||||
}
|
|
||||||
return counts.entries
|
|
||||||
.reduce((a, b) => b.value > a.value ? b : a)
|
|
||||||
.key;
|
|
||||||
}
|
|
||||||
|
|
||||||
String _baseName(String path) {
|
String _baseName(String path) {
|
||||||
final i = path.lastIndexOf(RegExp(r'[/\\]'));
|
final i = path.lastIndexOf(RegExp(r'[/\\]'));
|
||||||
return i < 0 ? path : path.substring(i + 1);
|
return i < 0 ? path : path.substring(i + 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
String _extension(String name) {
|
|
||||||
final dot = name.lastIndexOf('.');
|
|
||||||
return dot <= 0 ? '' : name.substring(dot + 1).toLowerCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
String _stripExtension(String path) {
|
String _stripExtension(String path) {
|
||||||
final dot = path.lastIndexOf('.');
|
final dot = path.lastIndexOf('.');
|
||||||
final sep = path.lastIndexOf(RegExp(r'[/\\]'));
|
final sep = path.lastIndexOf(RegExp(r'[/\\]'));
|
||||||
|
|||||||
@@ -4,9 +4,11 @@ import 'dart:io';
|
|||||||
|
|
||||||
import 'package:crypto/crypto.dart';
|
import 'package:crypto/crypto.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:media_kit/media_kit.dart';
|
|
||||||
import 'package:path_provider/path_provider.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
|
// Poster frames for the Files source, made by mpv itself (no ffmpeg on
|
||||||
// users' machines; Windows ships only libmpv). A hidden player per file
|
// users' machines; Windows ships only libmpv). A hidden player per file
|
||||||
// with `vo=null`: mpv still keeps the last decoded frame, so
|
// with `vo=null`: mpv still keeps the last decoded frame, so
|
||||||
@@ -23,6 +25,9 @@ class Thumbnail {
|
|||||||
|
|
||||||
const thumbnailWidth = 480;
|
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.
|
/// Where thumbnails are cached; swapped in tests.
|
||||||
Future<Directory> Function() thumbnailCacheRoot = getApplicationCacheDirectory;
|
Future<Directory> Function() thumbnailCacheRoot = getApplicationCacheDirectory;
|
||||||
|
|
||||||
@@ -33,7 +38,7 @@ const _timeout = Duration(seconds: 20);
|
|||||||
int _running = 0;
|
int _running = 0;
|
||||||
final _waiting = <Completer<void>>[];
|
final _waiting = <Completer<void>>[];
|
||||||
final _inFlight = <String, Future<Thumbnail?>>{};
|
final _inFlight = <String, Future<Thumbnail?>>{};
|
||||||
final _players = <Player>{};
|
final _players = <HostedPlayer>{};
|
||||||
bool _closed = false;
|
bool _closed = false;
|
||||||
|
|
||||||
/// App close: no new thumbnails, running ones stop at their next check
|
/// App close: no new thumbnails, running ones stop at their next check
|
||||||
@@ -49,7 +54,7 @@ Future<void> cancelAllThumbnails({
|
|||||||
for (final p in _players.toList()) {
|
for (final p in _players.toList()) {
|
||||||
_players.remove(p);
|
_players.remove(p);
|
||||||
try {
|
try {
|
||||||
await p.dispose().timeout(deadline);
|
await thumbnailPlayers.dispose(p).timeout(deadline);
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -147,19 +152,10 @@ Future<Thumbnail?> _generate(
|
|||||||
String out,
|
String out,
|
||||||
bool Function() cancelled,
|
bool Function() cancelled,
|
||||||
) async {
|
) async {
|
||||||
final player = Player(
|
final player = await thumbnailPlayers.create(PlayerRole.thumbnail);
|
||||||
configuration: const PlayerConfiguration(
|
|
||||||
vo: 'null',
|
|
||||||
muted: true,
|
|
||||||
osc: false,
|
|
||||||
libass: false,
|
|
||||||
title: 'thumbnail',
|
|
||||||
bufferSize: 4 << 20,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
_players.add(player);
|
_players.add(player);
|
||||||
try {
|
try {
|
||||||
final mpv = player.platform as NativePlayer;
|
final mpv = player;
|
||||||
for (final (k, v) in [
|
for (final (k, v) in [
|
||||||
('ao', 'null'),
|
('ao', 'null'),
|
||||||
('aid', 'no'),
|
('aid', 'no'),
|
||||||
@@ -176,7 +172,7 @@ Future<Thumbnail?> _generate(
|
|||||||
]) {
|
]) {
|
||||||
await mpv.setProperty(k, v);
|
await mpv.setProperty(k, v);
|
||||||
}
|
}
|
||||||
await player.open(Media(path), play: false);
|
await player.open(path);
|
||||||
final target = File(out);
|
final target = File(out);
|
||||||
final deadline = DateTime.now().add(_timeout);
|
final deadline = DateTime.now().add(_timeout);
|
||||||
while (DateTime.now().isBefore(deadline)) {
|
while (DateTime.now().isBefore(deadline)) {
|
||||||
@@ -199,6 +195,6 @@ Future<Thumbnail?> _generate(
|
|||||||
return null;
|
return null;
|
||||||
} finally {
|
} finally {
|
||||||
// [cancelAllThumbnails] may have taken it already.
|
// [cancelAllThumbnails] may have taken it already.
|
||||||
if (_players.remove(player)) await player.dispose();
|
if (_players.remove(player)) await thumbnailPlayers.dispose(player);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2,13 +2,13 @@ import 'dart:io';
|
|||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'package:sync_relay_core/core.dart';
|
||||||
|
|
||||||
import '../../shell/avatar.dart';
|
import '../../shell/avatar.dart';
|
||||||
import '../name_parser.dart';
|
|
||||||
import '../scanner.dart';
|
|
||||||
import 'thumb.dart';
|
import 'thumb.dart';
|
||||||
|
|
||||||
// The Files page's rows and cards: a series header, an episode row (also
|
// The Files page's rows and cards: an episode row (also used for Recent),
|
||||||
// used for Recent), a loose-video card, and the empty states.
|
// a loose-video card, and the empty states.
|
||||||
|
|
||||||
/// What a play control is doing right now.
|
/// What a play control is doing right now.
|
||||||
enum PlayState { idle, starting, playing }
|
enum PlayState { idle, starting, playing }
|
||||||
@@ -25,76 +25,8 @@ String parentName(String path) => baseName(File(path).parent.path);
|
|||||||
/// Cleaned title of a loose video: `Title (2019)`.
|
/// Cleaned title of a loose video: `Title (2019)`.
|
||||||
String cleanTitle(ParsedName p) => p.year == null ? p.series : '${p.series} (${p.year})';
|
String cleanTitle(ParsedName p) => p.year == null ? p.series : '${p.series} (${p.year})';
|
||||||
|
|
||||||
class SeriesHeader extends StatelessWidget {
|
/// One file as a row: an episode on a show's page, or a Recent entry
|
||||||
final SeriesGroup group;
|
/// ([indent] off).
|
||||||
final ThumbCache thumbs;
|
|
||||||
/// Its episodes are listed below; [onTap] opens or closes them.
|
|
||||||
final bool expanded;
|
|
||||||
final VoidCallback? onTap;
|
|
||||||
const SeriesHeader(
|
|
||||||
{super.key, required this.group, required this.thumbs, this.expanded = true, this.onTap});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final scheme = Theme.of(context).colorScheme;
|
|
||||||
final first = group.episodes.first;
|
|
||||||
final n = group.episodes.length;
|
|
||||||
final extras = group.extras.length;
|
|
||||||
final detail = [
|
|
||||||
'$n ${n == 1 ? 'episode' : 'episodes'}',
|
|
||||||
if (extras > 0) '$extras ${extras == 1 ? 'extra' : 'extras'}',
|
|
||||||
?first.parsed.group,
|
|
||||||
].join(' · ');
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.only(top: 14),
|
|
||||||
child: InkWell(
|
|
||||||
onTap: onTap,
|
|
||||||
borderRadius: BorderRadius.circular(10),
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
ClipRRect(
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
child: ThumbBuilder(
|
|
||||||
cache: thumbs,
|
|
||||||
path: first.path,
|
|
||||||
builder: (context, t, _) => ThumbImage(t, width: 96, height: 54),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(group.title,
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: Theme.of(context).textTheme.titleMedium),
|
|
||||||
Text(detail,
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (onTap != null)
|
|
||||||
AnimatedRotation(
|
|
||||||
turns: expanded ? 0.5 : 0,
|
|
||||||
duration: const Duration(milliseconds: 200),
|
|
||||||
child: Icon(Icons.expand_more, color: scheme.onSurfaceVariant),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// One file as a row: an episode under its series header, or a Recent
|
|
||||||
/// entry ([title] given, [indent] off).
|
|
||||||
class FileRow extends StatelessWidget {
|
class FileRow extends StatelessWidget {
|
||||||
final String path;
|
final String path;
|
||||||
final String title;
|
final String title;
|
||||||
@@ -207,7 +139,7 @@ const videoCardTextHeight = 66.0;
|
|||||||
/// A loose video (movie, clip): 16:9 frame with its duration, then the
|
/// A loose video (movie, clip): 16:9 frame with its duration, then the
|
||||||
/// cleaned title and size.
|
/// cleaned title and size.
|
||||||
class VideoCard extends StatelessWidget {
|
class VideoCard extends StatelessWidget {
|
||||||
final VideoFile video;
|
final LocalEpisode video;
|
||||||
final PlayState state;
|
final PlayState state;
|
||||||
final ThumbCache thumbs;
|
final ThumbCache thumbs;
|
||||||
final VoidCallback? onTap;
|
final VoidCallback? onTap;
|
||||||
@@ -281,7 +213,7 @@ class VideoCard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Text(formatSize(video.size),
|
Text(formatSize(video.file.size),
|
||||||
style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12)),
|
style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12)),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,56 +1,65 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:file_picker/file_picker.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:signals/signals_flutter.dart';
|
import 'package:signals/signals_flutter.dart';
|
||||||
|
import 'package:sync_relay_core/core.dart';
|
||||||
|
|
||||||
|
import '../../library/show_matcher.dart';
|
||||||
import '../../shell/motion.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 '../../widgets/app_menu.dart';
|
||||||
import '../folder_store.dart';
|
import '../folder_store.dart';
|
||||||
import '../name_parser.dart';
|
|
||||||
import '../scanner.dart';
|
import '../scanner.dart';
|
||||||
import 'file_tiles.dart';
|
import 'file_tiles.dart';
|
||||||
|
import 'library_view.dart';
|
||||||
import 'thumb.dart';
|
import 'thumb.dart';
|
||||||
|
|
||||||
export 'thumb.dart' show ThumbnailLoader;
|
export 'thumb.dart' show ThumbnailLoader;
|
||||||
|
|
||||||
// The Files source: watched folders on the left (chips on top when
|
// The Files source as a library: the watched folders on the left (chips
|
||||||
// narrow), the open one on the right grouped into series and loose
|
// on top when narrow): the Library (every folder together), Recent, then
|
||||||
// videos, or Recent. A folder is scanned when first opened and on Rescan;
|
// each folder. A library is shows and movies as poster cards, grouped by
|
||||||
// scans are kept for the app session. Series start collapsed; the ones
|
// the library's L1 and named by its L2 match, plus the videos that are
|
||||||
// opened stay open for the app session. Playing goes through [onPlay].
|
// 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.
|
/// Below this width the sidebar turns into chips above the list.
|
||||||
const filesNarrowWidth = 700.0;
|
const filesNarrowWidth = 700.0;
|
||||||
|
|
||||||
|
/// The place that shows every watched folder together.
|
||||||
|
const libraryPlace = '\u0000library';
|
||||||
|
|
||||||
/// Scans kept for the app session, by folder.
|
/// Scans kept for the app session, by folder.
|
||||||
final _scans = <String, FolderScan>{};
|
final _scans = <String, FolderScan>{};
|
||||||
final _running = <String, Future<FolderScan>>{};
|
final _running = <String, Future<FolderScan>>{};
|
||||||
String? _lastOpen;
|
String? _lastOpen = libraryPlace;
|
||||||
/// Series opened this session, by `folder\nseries key`.
|
/// The show open in each place (by show key) for the app session.
|
||||||
final _expanded = <String>{};
|
final _openShows = <String, String>{};
|
||||||
final _showRequests = ValueNotifier(0);
|
final _showRequests = ValueNotifier(0);
|
||||||
|
|
||||||
/// Opens [dir] (null: Recent) in the Files page: now if one is showing,
|
/// Opens [dir] (null: Recent; [libraryPlace]: the library) in the Files
|
||||||
/// else when it next opens.
|
/// page: now if one is showing, else when it next opens.
|
||||||
void showFilesFolder(String? dir) {
|
void showFilesFolder(String? dir) {
|
||||||
_lastOpen = dir;
|
_lastOpen = dir;
|
||||||
_showRequests.value++;
|
_showRequests.value++;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Forgets the session's scans, open series and the open folder.
|
/// Forgets the session's scans, open shows and the open place.
|
||||||
@visibleForTesting
|
@visibleForTesting
|
||||||
void resetFilesPageSession() {
|
void resetFilesPageSession() {
|
||||||
_scans.clear();
|
_scans.clear();
|
||||||
_running.clear();
|
_running.clear();
|
||||||
_expanded.clear();
|
_openShows.clear();
|
||||||
_lastOpen = null;
|
_lastOpen = libraryPlace;
|
||||||
}
|
}
|
||||||
|
|
||||||
class FilesPage extends StatefulWidget {
|
class FilesPage extends StatefulWidget {
|
||||||
/// Nobody else watches (offline or solo): no relay notice.
|
/// Nobody else is in the server: no relay notice.
|
||||||
final bool solo;
|
final bool alone;
|
||||||
/// The file will stream from this computer through the server.
|
/// The file will stream from this computer through the server.
|
||||||
final bool relayed;
|
final bool relayed;
|
||||||
/// Plays [path] in the session; returns an error text, or null.
|
/// Plays [path] in the session; returns an error text, or null.
|
||||||
@@ -60,17 +69,19 @@ class FilesPage extends StatefulWidget {
|
|||||||
final String? nowPlayingPath;
|
final String? nowPlayingPath;
|
||||||
/// Defaults to [thumbnailFor].
|
/// Defaults to [thumbnailFor].
|
||||||
final ThumbnailLoader? thumbnails;
|
final ThumbnailLoader? thumbnails;
|
||||||
/// Defaults to [scanFolder].
|
/// Defaults to [FolderAccess.scan].
|
||||||
final Future<FolderScan> Function(String dir)? scan;
|
final Future<FolderScan> Function(String dir)? scan;
|
||||||
/// Defaults to [FolderStore.instance].
|
/// Defaults to [FolderStore.instance].
|
||||||
final FolderStore? store;
|
final FolderStore? store;
|
||||||
|
/// Defaults to the app's (`AppGraph.library`).
|
||||||
|
final ShowMatcher? matcher;
|
||||||
/// Default: the system folder / file dialogs.
|
/// Default: the system folder / file dialogs.
|
||||||
final Future<String?> Function()? pickFolder;
|
final Future<String?> Function()? pickFolder;
|
||||||
final Future<String?> Function()? pickVideo;
|
final Future<String?> Function()? pickVideo;
|
||||||
|
|
||||||
const FilesPage({
|
const FilesPage({
|
||||||
super.key,
|
super.key,
|
||||||
this.solo = false,
|
this.alone = false,
|
||||||
required this.relayed,
|
required this.relayed,
|
||||||
required this.onPlay,
|
required this.onPlay,
|
||||||
this.onAddToQueue,
|
this.onAddToQueue,
|
||||||
@@ -78,6 +89,7 @@ class FilesPage extends StatefulWidget {
|
|||||||
this.thumbnails,
|
this.thumbnails,
|
||||||
this.scan,
|
this.scan,
|
||||||
this.store,
|
this.store,
|
||||||
|
this.matcher,
|
||||||
this.pickFolder,
|
this.pickFolder,
|
||||||
this.pickVideo,
|
this.pickVideo,
|
||||||
});
|
});
|
||||||
@@ -86,50 +98,57 @@ class FilesPage extends StatefulWidget {
|
|||||||
State<FilesPage> createState() => _FilesPageState();
|
State<FilesPage> createState() => _FilesPageState();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A scan grouped for display, made once per scan.
|
/// A place's scans grouped (L1), made once per set of scans.
|
||||||
class _Library {
|
class _Library {
|
||||||
final GroupedVideos grouped;
|
final List<FolderScan> scans;
|
||||||
final int subtitleFiles;
|
final LocalLibrary lib;
|
||||||
|
final Map<String, LocalEpisode> byPath;
|
||||||
|
|
||||||
_Library(FolderScan scan)
|
_Library(this.scans, List<String> roots)
|
||||||
: grouped = groupBySeries(scan.videos),
|
: lib = groupLocal([for (final s in scans) ...s.videos], roots: roots),
|
||||||
subtitleFiles = {for (final l in scan.subtitles.values) ...l}.length;
|
byPath = {} {
|
||||||
|
for (final e in lib.files) {
|
||||||
|
byPath[e.path] = e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Series rows in order: a header (video null), then, for the series
|
int get videos => scans.fold(0, (n, s) => n + s.videos.length);
|
||||||
/// [open] says, its episodes and extras.
|
int get hidden => scans.fold(0, (n, s) => n + s.hiddenCount);
|
||||||
List<(SeriesGroup, VideoFile?)> rows(bool Function(SeriesGroup g) open) => [
|
int get subtitleFiles => {
|
||||||
for (final g in grouped.series) ...[
|
for (final s in scans)
|
||||||
(g, null),
|
for (final l in s.subtitles.values) ...l
|
||||||
if (open(g))
|
}.length;
|
||||||
for (final v in [...g.episodes, ...g.extras]) (g, v),
|
bool get truncated => scans.any((s) => s.truncated);
|
||||||
],
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
final _libraries = Expando<_Library>();
|
|
||||||
|
|
||||||
class _FilesPageState extends State<FilesPage> {
|
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 =
|
late final ThumbCache _thumbs =
|
||||||
widget.thumbnails == null ? ThumbCache.shared : ThumbCache(widget.thumbnails!);
|
widget.thumbnails == null ? ThumbCache.shared : ThumbCache(widget.thumbnails!);
|
||||||
FolderStore get _store => widget.store ?? FolderStore.instance;
|
FolderStore get _store => widget.store ?? FolderStore.instance;
|
||||||
|
WatchHistory get _history => AppScope.of(context).watch.history;
|
||||||
|
|
||||||
/// Null: Recent.
|
/// Null: Recent; [libraryPlace]: every folder.
|
||||||
String? _open = _lastOpen;
|
String? _open = _lastOpen;
|
||||||
bool _showHidden = false;
|
bool _showHidden = false;
|
||||||
String? _starting;
|
String? _starting;
|
||||||
String? _error;
|
String? _error;
|
||||||
|
final _libs = <String, _Library>{};
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_showRequests.addListener(_shown);
|
_showRequests.addListener(_shown);
|
||||||
|
unawaited(_matcher.ensureLoaded());
|
||||||
_store.ensureLoaded().then((_) {
|
_store.ensureLoaded().then((_) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
final open = _open;
|
final open = _open;
|
||||||
if (open != null && !_store.folders.value.contains(open)) {
|
if (open != null && open != libraryPlace && !_store.folders.value.contains(open)) {
|
||||||
_select(null);
|
_select(libraryPlace);
|
||||||
} else if (open != null) {
|
} else {
|
||||||
_scan(open);
|
_scanFor(open);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -142,21 +161,34 @@ class _FilesPageState extends State<FilesPage> {
|
|||||||
|
|
||||||
void _shown() => _select(_lastOpen);
|
void _shown() => _select(_lastOpen);
|
||||||
|
|
||||||
Future<FolderScan> _runScan(String dir) => (widget.scan ?? scanFolder)(dir);
|
Future<FolderScan> _runScan(String dir) => (widget.scan ?? _folders.scan)(dir);
|
||||||
|
|
||||||
void _select(String? 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(() {
|
setState(() {
|
||||||
_open = _lastOpen = dir;
|
_open = _lastOpen = place;
|
||||||
_showHidden = false;
|
_showHidden = false;
|
||||||
});
|
});
|
||||||
if (dir != null && !_scans.containsKey(dir)) _scan(dir);
|
_scanFor(place);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _scan(String dir, {bool rescan = false}) async {
|
Future<void> _scan(String dir, {bool rescan = false}) async {
|
||||||
var f = _running[dir];
|
var f = _running[dir];
|
||||||
if (f == null || rescan) {
|
if (f == null || rescan) {
|
||||||
f = _running[dir] = _runScan(dir);
|
f = _running[dir] = _runScan(dir);
|
||||||
if (rescan) _thumbs.forget([for (final v in _scans[dir]?.videos ?? const <VideoFile>[]) v.path]);
|
if (rescan) _thumbs.forget([for (final v in _scans[dir]?.videos ?? const <LocalFile>[]) v.path]);
|
||||||
}
|
}
|
||||||
if (mounted) setState(() {});
|
if (mounted) setState(() {});
|
||||||
FolderScan result;
|
FolderScan result;
|
||||||
@@ -172,11 +204,31 @@ class _FilesPageState extends State<FilesPage> {
|
|||||||
if (mounted) setState(() {});
|
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 {
|
Future<void> _addFolder() async {
|
||||||
String? dir;
|
String? dir;
|
||||||
try {
|
try {
|
||||||
dir = await (widget.pickFolder ??
|
dir = await (widget.pickFolder ??
|
||||||
() => FilePicker.getDirectoryPath(dialogTitle: 'Add a folder with videos'))();
|
() => _folders.pickFolder(dialogTitle: 'Add a folder with videos'))();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_showError('Folder dialog unavailable (${e.runtimeType})');
|
_showError('Folder dialog unavailable (${e.runtimeType})');
|
||||||
}
|
}
|
||||||
@@ -195,19 +247,16 @@ class _FilesPageState extends State<FilesPage> {
|
|||||||
if (path != null && mounted) await _play(path);
|
if (path != null && mounted) await _play(path);
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<String?> _pickVideo() async {
|
Future<String?> _pickVideo() async {
|
||||||
final files = await FilePicker.pickFiles(
|
final files = await _folders.pickFiles(dialogTitle: 'Pick a video', extensions: videoExtensions.toList());
|
||||||
dialogTitle: 'Pick a video',
|
return files.firstOrNull;
|
||||||
type: FileType.custom,
|
|
||||||
allowedExtensions: videoExtensions.toList(),
|
|
||||||
);
|
|
||||||
return files.isEmpty ? null : files.first.path;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _removeFolder(String dir) {
|
void _removeFolder(String dir) {
|
||||||
_store.removeFolder(dir);
|
_store.removeFolder(dir);
|
||||||
_scans.remove(dir);
|
_scans.remove(dir);
|
||||||
if (_open == dir) _select(null);
|
if (_open == dir) _select(libraryPlace);
|
||||||
|
setState(() {});
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _play(String path) async {
|
Future<void> _play(String path) async {
|
||||||
@@ -248,7 +297,7 @@ class _FilesPageState extends State<FilesPage> {
|
|||||||
final add = widget.onAddToQueue;
|
final add = widget.onAddToQueue;
|
||||||
if (add == null) return;
|
if (add == null) return;
|
||||||
final pick = await showAppMenu<int>(context, items: [
|
final pick = await showAppMenu<int>(context, items: [
|
||||||
AppMenuItem(value: 0, label: widget.solo ? 'Play' : 'Put it up'),
|
AppMenuItem(value: 0, label: widget.alone ? 'Play' : 'Put it up'),
|
||||||
const AppMenuItem(value: 1, label: 'Add to queue'),
|
const AppMenuItem(value: 1, label: 'Add to queue'),
|
||||||
const AppMenuItem(value: 2, label: 'Play next'),
|
const AppMenuItem(value: 2, label: 'Play next'),
|
||||||
]);
|
]);
|
||||||
@@ -271,6 +320,29 @@ class _FilesPageState extends State<FilesPage> {
|
|||||||
child: Builder(
|
child: Builder(
|
||||||
builder: (context) => GestureDetector(onSecondaryTap: () => _menu(context, path), child: child)));
|
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
|
@override
|
||||||
Widget build(BuildContext context) => LayoutBuilder(
|
Widget build(BuildContext context) => LayoutBuilder(
|
||||||
builder: (context, box) {
|
builder: (context, box) {
|
||||||
@@ -278,11 +350,14 @@ class _FilesPageState extends State<FilesPage> {
|
|||||||
return SignalBuilder(builder: (context) {
|
return SignalBuilder(builder: (context) {
|
||||||
final folders = _store.folders.value;
|
final folders = _store.folders.value;
|
||||||
final recent = _store.recent.value;
|
final recent = _store.recent.value;
|
||||||
final open = _open != null && folders.contains(_open) ? _open : null;
|
_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(
|
final main = fadeThrough(
|
||||||
KeyedSubtree(
|
KeyedSubtree(
|
||||||
key: ValueKey(open ?? ''),
|
key: ValueKey('${open ?? ''}\n${showKey ?? ''}'),
|
||||||
child: _main(context, narrow, folders, recent, open),
|
child: _main(context, narrow, folders, recent, open, showKey),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
if (narrow) return main;
|
if (narrow) return main;
|
||||||
@@ -302,12 +377,23 @@ class _FilesPageState extends State<FilesPage> {
|
|||||||
Widget _sidebar(BuildContext context, List<String> folders, String? open) {
|
Widget _sidebar(BuildContext context, List<String> folders, String? open) {
|
||||||
final scheme = Theme.of(context).colorScheme;
|
final scheme = Theme.of(context).colorScheme;
|
||||||
final shape = RoundedRectangleBorder(borderRadius: BorderRadius.circular(10));
|
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(
|
return ListView(
|
||||||
padding: const EdgeInsets.fromLTRB(20, 20, 8, 20),
|
padding: const EdgeInsets.fromLTRB(20, 20, 8, 20),
|
||||||
children: [
|
children: [
|
||||||
const Text('Folders', style: TextStyle(fontWeight: FontWeight.w600)),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
ListTile(
|
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,
|
dense: true,
|
||||||
selected: open == null,
|
selected: open == null,
|
||||||
shape: shape,
|
shape: shape,
|
||||||
@@ -315,6 +401,9 @@ class _FilesPageState extends State<FilesPage> {
|
|||||||
title: const Text('Recent'),
|
title: const Text('Recent'),
|
||||||
onTap: () => _select(null),
|
onTap: () => _select(null),
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
const Text('Folders', style: TextStyle(fontWeight: FontWeight.w600)),
|
||||||
|
const SizedBox(height: 8),
|
||||||
for (final f in folders)
|
for (final f in folders)
|
||||||
ListTile(
|
ListTile(
|
||||||
key: ValueKey('folder:$f'),
|
key: ValueKey('folder:$f'),
|
||||||
@@ -323,10 +412,7 @@ class _FilesPageState extends State<FilesPage> {
|
|||||||
shape: shape,
|
shape: shape,
|
||||||
leading: const Icon(Icons.folder_outlined, size: 18),
|
leading: const Icon(Icons.folder_outlined, size: 18),
|
||||||
title: Text(baseName(f), maxLines: 1, overflow: TextOverflow.ellipsis),
|
title: Text(baseName(f), maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||||
subtitle: switch (_folderStatus(f)) {
|
subtitle: status(_folderStatus(f)),
|
||||||
final s? => Text(s, style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 11)),
|
|
||||||
null => null,
|
|
||||||
},
|
|
||||||
trailing: IconButton(
|
trailing: IconButton(
|
||||||
tooltip: 'Remove folder',
|
tooltip: 'Remove folder',
|
||||||
icon: const Icon(Icons.close, size: 16),
|
icon: const Icon(Icons.close, size: 16),
|
||||||
@@ -364,19 +450,15 @@ class _FilesPageState extends State<FilesPage> {
|
|||||||
return n == null ? null : '$n ${n == 1 ? 'video' : 'videos'}';
|
return n == null ? null : '$n ${n == 1 ? 'video' : 'videos'}';
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _main(
|
String? _libraryStatus(List<String> folders) {
|
||||||
BuildContext context,
|
if (folders.any(_running.containsKey)) return 'scanning…';
|
||||||
bool narrow,
|
if (folders.isEmpty || folders.any((f) => !_scans.containsKey(f))) return null;
|
||||||
List<String> folders,
|
final n = _libraryOf(libraryPlace)?.lib.shows.length ?? 0;
|
||||||
List<String> recent,
|
return '$n ${n == 1 ? 'show' : 'shows'}';
|
||||||
String? open,
|
}
|
||||||
) {
|
|
||||||
final scan = open == null ? null : _scans[open];
|
List<Widget> _notices() => [
|
||||||
final scanning = open != null && _running.containsKey(open);
|
if (!widget.alone && widget.relayed) const _RelayNotice(),
|
||||||
final top = <Widget>[
|
|
||||||
_header(context, open),
|
|
||||||
if (narrow) _chips(folders, open),
|
|
||||||
if (!widget.solo && widget.relayed) const _RelayNotice(),
|
|
||||||
AnimatedSize(
|
AnimatedSize(
|
||||||
duration: easeTime,
|
duration: easeTime,
|
||||||
curve: ease,
|
curve: ease,
|
||||||
@@ -386,11 +468,59 @@ class _FilesPageState extends State<FilesPage> {
|
|||||||
: _ErrorBanner(_error!, onClose: () => setState(() => _error = null)),
|
: _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>[
|
final slivers = <Widget>[
|
||||||
SliverList.list(children: top),
|
SliverList.list(children: top),
|
||||||
if (open == null)
|
if (open == null)
|
||||||
..._recent(context, recent)
|
..._recent(context, recent)
|
||||||
else if (scan == null)
|
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(
|
const SliverToBoxAdapter(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: EdgeInsets.only(top: 60),
|
padding: EdgeInsets.only(top: 60),
|
||||||
@@ -398,7 +528,7 @@ class _FilesPageState extends State<FilesPage> {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
..._folder(context, open, scan, scanning),
|
..._library(context, open, lib, scanning),
|
||||||
];
|
];
|
||||||
return CustomScrollView(
|
return CustomScrollView(
|
||||||
slivers: [
|
slivers: [
|
||||||
@@ -412,25 +542,30 @@ class _FilesPageState extends State<FilesPage> {
|
|||||||
|
|
||||||
Widget _header(BuildContext context, String? open) {
|
Widget _header(BuildContext context, String? open) {
|
||||||
final scheme = Theme.of(context).colorScheme;
|
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(
|
return Padding(
|
||||||
padding: const EdgeInsets.only(bottom: 14),
|
padding: const EdgeInsets.only(bottom: 14),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(open == null ? Icons.history : Icons.folder, color: scheme.primary),
|
Icon(icon, color: scheme.primary),
|
||||||
const SizedBox(width: 10),
|
const SizedBox(width: 10),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
open == null ? 'Recent' : baseName(open),
|
title,
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
style: Theme.of(context).textTheme.headlineSmall,
|
style: Theme.of(context).textTheme.headlineSmall,
|
||||||
),
|
),
|
||||||
if (open != null)
|
if (sub != null)
|
||||||
Text(
|
Text(
|
||||||
open,
|
sub,
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12),
|
style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12),
|
||||||
@@ -449,6 +584,11 @@ class _FilesPageState extends State<FilesPage> {
|
|||||||
spacing: 8,
|
spacing: 8,
|
||||||
runSpacing: 8,
|
runSpacing: 8,
|
||||||
children: [
|
children: [
|
||||||
|
ChoiceChip(
|
||||||
|
label: const Text('Library'),
|
||||||
|
selected: open == libraryPlace,
|
||||||
|
onSelected: (_) => _select(libraryPlace),
|
||||||
|
),
|
||||||
ChoiceChip(
|
ChoiceChip(
|
||||||
label: const Text('Recent'),
|
label: const Text('Recent'),
|
||||||
selected: open == null,
|
selected: open == null,
|
||||||
@@ -506,6 +646,7 @@ class _FilesPageState extends State<FilesPage> {
|
|||||||
_RecentRow(
|
_RecentRow(
|
||||||
key: ValueKey(path),
|
key: ValueKey(path),
|
||||||
path: path,
|
path: path,
|
||||||
|
matcher: _matcher,
|
||||||
state: _stateOf(path),
|
state: _stateOf(path),
|
||||||
thumbs: _thumbs,
|
thumbs: _thumbs,
|
||||||
onTap: _tapOf(path),
|
onTap: _tapOf(path),
|
||||||
@@ -516,21 +657,32 @@ class _FilesPageState extends State<FilesPage> {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
List<Widget> _folder(BuildContext context, String dir, FolderScan scan, bool scanning) {
|
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 scheme = Theme.of(context).colorScheme;
|
||||||
final lib = _libraries[scan] ??= _Library(scan);
|
final l = lib.lib;
|
||||||
final series = lib.grouped.series.length;
|
final folderScan = place == libraryPlace ? null : _scans[place];
|
||||||
String count(int n, String one, String many) => '$n ${n == 1 ? one : many}';
|
String count(int n, String one, String many) => '$n ${n == 1 ? one : many}';
|
||||||
final summary = [
|
final summary = [
|
||||||
count(scan.videos.length, 'video', 'videos') + (series > 0 ? ' in $series series' : ''),
|
count(lib.videos, 'video', 'videos') +
|
||||||
if (scan.hiddenCount > 0)
|
(l.shows.isEmpty ? '' : ' in ${count(l.shows.length, 'show', 'shows')}'),
|
||||||
'${count(scan.hiddenCount, 'other file', 'other files')} hidden',
|
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.subtitleFiles > 0) count(lib.subtitleFiles, 'subtitle file', 'subtitle files'),
|
||||||
if (scan.truncated) 'stopped after $maxScannedFiles files',
|
if (lib.truncated) 'stopped after $maxScannedFiles files',
|
||||||
].join(' · ');
|
].join(' · ');
|
||||||
final loose = lib.grouped.loose;
|
final folders = _foldersOf(place);
|
||||||
String seriesId(SeriesGroup g) => '$dir\n${g.key}';
|
final movies = l.movies;
|
||||||
final rows = lib.rows((g) => _expanded.contains(seriesId(g)));
|
final other = l.other;
|
||||||
return [
|
return [
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: Wrap(
|
child: Wrap(
|
||||||
@@ -539,7 +691,7 @@ class _FilesPageState extends State<FilesPage> {
|
|||||||
Icon(Icons.filter_alt_outlined, size: 16, color: scheme.onSurfaceVariant),
|
Icon(Icons.filter_alt_outlined, size: 16, color: scheme.onSurfaceVariant),
|
||||||
const SizedBox(width: 6),
|
const SizedBox(width: 6),
|
||||||
Text(summary, style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12)),
|
Text(summary, style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12)),
|
||||||
if (scan.hiddenCount > 0) ...[
|
if (folderScan != null && folderScan.hiddenCount > 0) ...[
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => setState(() => _showHidden = !_showHidden),
|
onPressed: () => setState(() => _showHidden = !_showHidden),
|
||||||
@@ -547,16 +699,22 @@ class _FilesPageState extends State<FilesPage> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
TextButton.icon(
|
TextButton.icon(
|
||||||
onPressed: scanning ? null : () => _scan(dir, rescan: true),
|
onPressed: scanning
|
||||||
|
? null
|
||||||
|
: () {
|
||||||
|
for (final f in folders) {
|
||||||
|
_scan(f, rescan: true);
|
||||||
|
}
|
||||||
|
},
|
||||||
icon: scanning
|
icon: scanning
|
||||||
? const SizedBox.square(
|
? const SizedBox.square(dimension: 14, child: CircularProgressIndicator(strokeWidth: 2))
|
||||||
dimension: 14, child: CircularProgressIndicator(strokeWidth: 2))
|
|
||||||
: const Icon(Icons.refresh, size: 16),
|
: const Icon(Icons.refresh, size: 16),
|
||||||
label: Text(scanning ? 'Scanning…' : 'Rescan'),
|
label: Text(scanning ? 'Scanning…' : 'Rescan'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
if (folderScan != null)
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: AnimatedSize(
|
child: AnimatedSize(
|
||||||
duration: easeTime,
|
duration: easeTime,
|
||||||
@@ -566,7 +724,7 @@ class _FilesPageState extends State<FilesPage> {
|
|||||||
? const SizedBox(width: double.infinity)
|
? const SizedBox(width: double.infinity)
|
||||||
: Column(
|
: Column(
|
||||||
children: [
|
children: [
|
||||||
for (final f in scan.hiddenExamples)
|
for (final f in folderScan.hiddenExamples)
|
||||||
ListTile(
|
ListTile(
|
||||||
dense: true,
|
dense: true,
|
||||||
enabled: false,
|
enabled: false,
|
||||||
@@ -577,19 +735,19 @@ class _FilesPageState extends State<FilesPage> {
|
|||||||
style: const TextStyle(fontSize: 12),
|
style: const TextStyle(fontSize: 12),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (scan.hiddenCount > scan.hiddenExamples.length)
|
if (folderScan.hiddenCount > folderScan.hiddenExamples.length)
|
||||||
ListTile(
|
ListTile(
|
||||||
dense: true,
|
dense: true,
|
||||||
enabled: false,
|
enabled: false,
|
||||||
title: Text('and ${scan.hiddenCount - scan.hiddenExamples.length} more'),
|
title: Text('and ${folderScan.hiddenCount - folderScan.hiddenExamples.length} more'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (scan.videos.isEmpty)
|
if (lib.videos == 0)
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: Directory(dir).existsSync()
|
child: place == libraryPlace || _folders.folderExists(place)
|
||||||
? const FilesEmpty(
|
? const FilesEmpty(
|
||||||
Icons.search_off,
|
Icons.search_off,
|
||||||
'No videos here',
|
'No videos here',
|
||||||
@@ -601,46 +759,25 @@ class _FilesPageState extends State<FilesPage> {
|
|||||||
'It may have been moved, renamed or be on a drive that is not connected.',
|
'It may have been moved, renamed or be on a drive that is not connected.',
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// Series: files whose names carry an episode number; a header
|
if (l.shows.isNotEmpty) ...[
|
||||||
// click opens or closes its episodes.
|
SliverToBoxAdapter(child: _section(context, 'Shows', count: '${l.shows.length}')),
|
||||||
SliverList.builder(
|
SliverGrid.builder(
|
||||||
itemCount: rows.length,
|
gridDelegate: posterGrid(),
|
||||||
itemBuilder: (context, i) {
|
itemCount: l.shows.length,
|
||||||
final (group, v) = rows[i];
|
itemBuilder: (context, i) => _showCard(place, lib, l.shows[i]),
|
||||||
if (v == null) {
|
|
||||||
final id = seriesId(group);
|
|
||||||
return SeriesHeader(
|
|
||||||
key: ValueKey('series:${group.key}'),
|
|
||||||
group: group,
|
|
||||||
thumbs: _thumbs,
|
|
||||||
expanded: _expanded.contains(id),
|
|
||||||
onTap: () => setState(() => _expanded.contains(id) ? _expanded.remove(id) : _expanded.add(id)),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return _menuable(
|
|
||||||
v.path,
|
|
||||||
FileRow(
|
|
||||||
key: ValueKey(v.path),
|
|
||||||
path: v.path,
|
|
||||||
title: v.parsed.label.isEmpty ? v.parsed.series : v.parsed.label,
|
|
||||||
subtitle: v.name,
|
|
||||||
size: formatSize(v.size),
|
|
||||||
state: _stateOf(v.path),
|
|
||||||
thumbs: _thumbs,
|
|
||||||
onTap: _tapOf(v.path),
|
|
||||||
));
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
// Everything else (movies, clips) as a thumbnail grid.
|
],
|
||||||
if (loose.isNotEmpty) ...[
|
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(
|
SliverToBoxAdapter(
|
||||||
child: Padding(
|
child: _section(context, l.shows.isEmpty && movies.isEmpty ? 'Videos' : 'Other videos'),
|
||||||
padding: const EdgeInsets.only(top: 22, bottom: 10),
|
|
||||||
child: Text(
|
|
||||||
series == 0 ? 'Videos' : 'Other videos',
|
|
||||||
style: Theme.of(context).textTheme.titleMedium,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
SliverLayoutBuilder(builder: (context, c) {
|
SliverLayoutBuilder(builder: (context, c) {
|
||||||
const gap = 12.0;
|
const gap = 12.0;
|
||||||
@@ -654,9 +791,9 @@ class _FilesPageState extends State<FilesPage> {
|
|||||||
crossAxisSpacing: gap,
|
crossAxisSpacing: gap,
|
||||||
mainAxisExtent: cardW * 9 / 16 + videoCardTextHeight,
|
mainAxisExtent: cardW * 9 / 16 + videoCardTextHeight,
|
||||||
),
|
),
|
||||||
itemCount: loose.length,
|
itemCount: other.length,
|
||||||
itemBuilder: (context, i) {
|
itemBuilder: (context, i) {
|
||||||
final v = loose[i];
|
final v = other[i];
|
||||||
return _menuable(
|
return _menuable(
|
||||||
v.path,
|
v.path,
|
||||||
VideoCard(
|
VideoCard(
|
||||||
@@ -672,12 +809,60 @@ class _FilesPageState extends State<FilesPage> {
|
|||||||
],
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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: `Series · S01E03` over the file name, or the cleaned
|
/// A Recent entry: `Show · E02` over the file name (the library's names
|
||||||
/// title over its folder; greyed when the file is gone.
|
/// when it knows the show), or the cleaned title over its folder; greyed
|
||||||
|
/// when the file is gone.
|
||||||
class _RecentRow extends StatefulWidget {
|
class _RecentRow extends StatefulWidget {
|
||||||
final String path;
|
final String path;
|
||||||
|
final ShowMatcher matcher;
|
||||||
final PlayState state;
|
final PlayState state;
|
||||||
final ThumbCache thumbs;
|
final ThumbCache thumbs;
|
||||||
final VoidCallback? onTap;
|
final VoidCallback? onTap;
|
||||||
@@ -685,6 +870,7 @@ class _RecentRow extends StatefulWidget {
|
|||||||
const _RecentRow({
|
const _RecentRow({
|
||||||
super.key,
|
super.key,
|
||||||
required this.path,
|
required this.path,
|
||||||
|
required this.matcher,
|
||||||
required this.state,
|
required this.state,
|
||||||
required this.thumbs,
|
required this.thumbs,
|
||||||
required this.onTap,
|
required this.onTap,
|
||||||
@@ -696,12 +882,14 @@ class _RecentRow extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _RecentRowState extends State<_RecentRow> {
|
class _RecentRowState extends State<_RecentRow> {
|
||||||
|
FolderAccess get _folders => AppScope.of(context).ports.folders;
|
||||||
|
|
||||||
FileStat? _stat;
|
FileStat? _stat;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
File(widget.path).stat().then((s) {
|
_folders.stat(widget.path).then((s) {
|
||||||
if (mounted) setState(() => _stat = s);
|
if (mounted) setState(() => _stat = s);
|
||||||
}, onError: (Object _) {});
|
}, onError: (Object _) {});
|
||||||
}
|
}
|
||||||
@@ -709,12 +897,14 @@ class _RecentRowState extends State<_RecentRow> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final path = widget.path;
|
final path = widget.path;
|
||||||
final p = parseReleaseName(baseName(path));
|
final e = widget.matcher.episodeAt(path) ??
|
||||||
|
readLocalFile(LocalFile(path, 0, DateTime.fromMillisecondsSinceEpoch(0)));
|
||||||
final missing = _stat?.type == FileSystemEntityType.notFound;
|
final missing = _stat?.type == FileSystemEntityType.notFound;
|
||||||
final episode = p.episode != null || p.extra != null;
|
final episode = e.kind == LocalKind.episode || e.kind == LocalKind.extra;
|
||||||
|
final name = widget.matcher.seasonMatch(e)?.entry.title ?? e.title;
|
||||||
return FileRow(
|
return FileRow(
|
||||||
path: path,
|
path: path,
|
||||||
title: episode ? '${p.series} · ${p.label}' : cleanTitle(p),
|
title: episode ? '$name · ${e.label}' : (e.kind == LocalKind.other ? cleanTitle(e.parsed) : name),
|
||||||
subtitle: missing ? 'File not found' : (episode ? baseName(path) : parentName(path)),
|
subtitle: missing ? 'File not found' : (episode ? baseName(path) : parentName(path)),
|
||||||
size: _stat == null || missing ? null : formatSize(_stat!.size),
|
size: _stat == null || missing ? null : formatSize(_stat!.size),
|
||||||
state: widget.state,
|
state: widget.state,
|
||||||
|
|||||||
@@ -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')),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -339,9 +339,9 @@ class BrowseScope extends InheritedWidget {
|
|||||||
case 2:
|
case 2:
|
||||||
queue!(item, next: true);
|
queue!(item, next: true);
|
||||||
case 3:
|
case 3:
|
||||||
await copyToClipboard(jellyfinCopyTitle(item), done: 'Title copied');
|
if (context.mounted) await copyToClipboard(context, jellyfinCopyTitle(item), done: 'Title copied');
|
||||||
case 4:
|
case 4:
|
||||||
await openLink(jellyfinWebUrl(client.baseUrl, item.id, serverId: account.serverId));
|
if (context.mounted) await openLink(context, jellyfinWebUrl(client.baseUrl, item.id, serverId: account.serverId));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -35,15 +35,15 @@ typedef _Data = ({
|
|||||||
|
|
||||||
/// The selected card's details, at the right of the library: banner,
|
/// The selected card's details, at the right of the library: banner,
|
||||||
/// title, meta, "Put it up · S1E3" ("Play · S1E3" when watching
|
/// title, meta, "Put it up · S1E3" ("Play · S1E3" when watching
|
||||||
/// [solo]), mark watched / unwatched, remove from Continue, overview and,
|
/// [alone]), mark watched / unwatched, remove from Continue, overview and,
|
||||||
/// for a series or an episode, the episode list (click picks which
|
/// for a series or an episode, the episode list (click picks which
|
||||||
/// episode goes up, double-click puts it up). Reloads when the
|
/// episode goes up, double-click puts it up). Reloads when the
|
||||||
/// [controller]'s data version moves.
|
/// [controller]'s data version moves.
|
||||||
class JellyfinDetailsPanel extends StatefulWidget {
|
class JellyfinDetailsPanel extends StatefulWidget {
|
||||||
final BrowseController controller;
|
final BrowseController controller;
|
||||||
final JellyfinSelection selection;
|
final JellyfinSelection selection;
|
||||||
/// Nobody else watches (offline or solo): the button says Play.
|
/// Nobody else is in the server: the button says Play.
|
||||||
final bool solo;
|
final bool alone;
|
||||||
/// Puts [item] (playable) up; returns an error text, or null when sent.
|
/// Puts [item] (playable) up; returns an error text, or null when sent.
|
||||||
final Future<String?> Function(JellyfinSelection item) onPutUp;
|
final Future<String?> Function(JellyfinSelection item) onPutUp;
|
||||||
/// Adds [item] to Up next (with next: right after what plays); null: no
|
/// Adds [item] to Up next (with next: right after what plays); null: no
|
||||||
@@ -57,7 +57,7 @@ class JellyfinDetailsPanel extends StatefulWidget {
|
|||||||
required this.onPutUp,
|
required this.onPutUp,
|
||||||
this.onAddToQueue,
|
this.onAddToQueue,
|
||||||
required this.onClose,
|
required this.onClose,
|
||||||
this.solo = false,
|
this.alone = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -156,7 +156,7 @@ class _JellyfinDetailsPanelState extends State<JellyfinDetailsPanel> {
|
|||||||
final client = _client;
|
final client = _client;
|
||||||
final pick = await showAppMenu<int>(context, items: [
|
final pick = await showAppMenu<int>(context, items: [
|
||||||
if (queue) ...[
|
if (queue) ...[
|
||||||
AppMenuItem(value: 0, label: widget.solo ? 'Play' : 'Put it up'),
|
AppMenuItem(value: 0, label: widget.alone ? 'Play' : 'Put it up'),
|
||||||
const AppMenuItem(value: 1, label: 'Add to queue'),
|
const AppMenuItem(value: 1, label: 'Add to queue'),
|
||||||
const AppMenuItem(value: 2, label: 'Play next'),
|
const AppMenuItem(value: 2, label: 'Play next'),
|
||||||
],
|
],
|
||||||
@@ -171,9 +171,9 @@ class _JellyfinDetailsPanelState extends State<JellyfinDetailsPanel> {
|
|||||||
case 2:
|
case 2:
|
||||||
await _queue(e, next: true);
|
await _queue(e, next: true);
|
||||||
case 3:
|
case 3:
|
||||||
await copyToClipboard(jellyfinCopyTitle(e), done: 'Title copied');
|
if (context.mounted) await copyToClipboard(context, jellyfinCopyTitle(e), done: 'Title copied');
|
||||||
case 4:
|
case 4:
|
||||||
await openLink(jellyfinWebUrl(client!.baseUrl, e.id, serverId: _serverId));
|
if (context.mounted) await openLink(context, jellyfinWebUrl(client!.baseUrl, e.id, serverId: _serverId));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -308,7 +308,7 @@ class _JellyfinDetailsPanelState extends State<JellyfinDetailsPanel> {
|
|||||||
if (shown.communityRating != null) '★ ${shown.communityRating!.toStringAsFixed(1)}',
|
if (shown.communityRating != null) '★ ${shown.communityRating!.toStringAsFixed(1)}',
|
||||||
if (!isSeries && shown.userData.played) 'Watched',
|
if (!isSeries && shown.userData.played) 'Watched',
|
||||||
].join(' · ');
|
].join(' · ');
|
||||||
final verb = widget.solo ? 'Play' : 'Put it up';
|
final verb = widget.alone ? 'Play' : 'Put it up';
|
||||||
final label = playable != null && isSeries ? '$verb · ${playable.episodeLabel ?? playable.name}' : verb;
|
final label = playable != null && isSeries ? '$verb · ${playable.episodeLabel ?? playable.name}' : verb;
|
||||||
final inContinue = playable != null &&
|
final inContinue = playable != null &&
|
||||||
(playable.userData.isResumable || _c.continueKeys.contains(continueKey(playable)));
|
(playable.userData.isResumable || _c.continueKeys.contains(continueKey(playable)));
|
||||||
@@ -397,7 +397,7 @@ class _JellyfinDetailsPanelState extends State<JellyfinDetailsPanel> {
|
|||||||
IconButton(
|
IconButton(
|
||||||
key: const ValueKey('details-open-jellyfin'),
|
key: const ValueKey('details-open-jellyfin'),
|
||||||
tooltip: ReuseText.openInJellyfin,
|
tooltip: ReuseText.openInJellyfin,
|
||||||
onPressed: () => openLink(jellyfinWebUrl(client.baseUrl, shown.id, serverId: _serverId)),
|
onPressed: () => openLink(context, jellyfinWebUrl(client.baseUrl, shown.id, serverId: _serverId)),
|
||||||
icon: const Icon(Icons.open_in_browser_rounded),
|
icon: const Icon(Icons.open_in_browser_rounded),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'package:sync_relay_core/core.dart';
|
import 'package:sync_relay_core/core.dart';
|
||||||
|
|
||||||
|
import '../library/media_refs.dart';
|
||||||
import '../screens/home/picked_media.dart';
|
import '../screens/home/picked_media.dart';
|
||||||
import 'accounts.dart';
|
import 'accounts.dart';
|
||||||
|
|
||||||
@@ -89,5 +90,6 @@ JellyfinMedia pickFor(JellyfinAccount account, JellyfinItem item) {
|
|||||||
title: item.name,
|
title: item.name,
|
||||||
subtitle: subtitle,
|
subtitle: subtitle,
|
||||||
runtime: item.runTime,
|
runtime: item.runTime,
|
||||||
|
ref: jellyfinMediaRef(item),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -26,13 +26,14 @@ class PlayerSnapshot {
|
|||||||
/// (the session shows one quiet toast) and never touches playback. A 401
|
/// (the session shows one quiet toast) and never touches playback. A 401
|
||||||
/// calls [onUnauthorized] and ends reporting.
|
/// calls [onUnauthorized] and ends reporting.
|
||||||
///
|
///
|
||||||
/// Watched or not is [WatchSession]'s rule, not Jellyfin's position
|
/// Watched or not is [WatchSession]'s rule (time In, D3), not Jellyfin's
|
||||||
/// percentages (see ARCHITECTURE.md "Jellyfin in a session"): an item
|
/// position percentages (see ARCHITECTURE.md "Watch tracking"): the
|
||||||
/// played before ([playedBefore]) stays played (re-marked after Playing,
|
/// session's watch tracker feeds [watch] and this reporter only reads its
|
||||||
/// Progress without a position, Stopped without one, then marked played
|
/// verdict. An item played before ([playedBefore]) stays played
|
||||||
/// again); otherwise Progress leaves the position out past
|
/// (re-marked after Playing, Progress without a position, Stopped without
|
||||||
/// [_noPositionFrom] of the runtime so Jellyfin cannot mark it played on
|
/// one, then marked played again); otherwise Progress leaves the position
|
||||||
/// its own, and Stopped carries what the rule says.
|
/// out past [_noPositionFrom] of the runtime so Jellyfin cannot mark it
|
||||||
|
/// played on its own, and Stopped carries what the rule says.
|
||||||
class JellyfinProgressReporter {
|
class JellyfinProgressReporter {
|
||||||
final JellyfinClient client;
|
final JellyfinClient client;
|
||||||
final String itemId;
|
final String itemId;
|
||||||
@@ -43,7 +44,8 @@ class JellyfinProgressReporter {
|
|||||||
final void Function(Object error)? onError;
|
final void Function(Object error)? onError;
|
||||||
final VoidCallback? onUnauthorized;
|
final VoidCallback? onUnauthorized;
|
||||||
|
|
||||||
/// What this viewer watched of the item while it was open here.
|
/// What this viewer watched of the item while it was open here (fed by
|
||||||
|
/// the watch tracker, not by this reporter).
|
||||||
final WatchSession watch;
|
final WatchSession watch;
|
||||||
|
|
||||||
JellyfinProgressReporter({
|
JellyfinProgressReporter({
|
||||||
@@ -57,12 +59,14 @@ class JellyfinProgressReporter {
|
|||||||
this.onError,
|
this.onError,
|
||||||
this.onUnauthorized,
|
this.onUnauthorized,
|
||||||
this.onStopped,
|
this.onStopped,
|
||||||
|
WatchSession? watch,
|
||||||
DateTime Function()? clock,
|
DateTime Function()? clock,
|
||||||
}) : _clock = clock ?? DateTime.now,
|
}) : _clock = clock ?? DateTime.now,
|
||||||
_keepPlayed = playedBefore,
|
_keepPlayed = playedBefore,
|
||||||
_runtime = (source.runTimeTicks ?? 0) / jellyfinTicksPerSecond,
|
_runtime = (source.runTimeTicks ?? 0) / jellyfinTicksPerSecond,
|
||||||
watch = WatchSession(
|
watch = (watch ?? WatchSession())
|
||||||
playedBefore: playedBefore, resumeBefore: resumeTicksBefore / jellyfinTicksPerSecond);
|
..playedBefore = playedBefore
|
||||||
|
..resumeBefore = resumeTicksBefore / jellyfinTicksPerSecond;
|
||||||
|
|
||||||
/// Past this share of the runtime Progress carries no position: Jellyfin
|
/// Past this share of the runtime Progress carries no position: Jellyfin
|
||||||
/// marks an item played past its "max resume" share (90 % by default).
|
/// marks an item played past its "max resume" share (90 % by default).
|
||||||
@@ -126,7 +130,6 @@ class JellyfinProgressReporter {
|
|||||||
if (_playing || _dead) return;
|
if (_playing || _dead) return;
|
||||||
_playing = true;
|
_playing = true;
|
||||||
_mark(s);
|
_mark(s);
|
||||||
watch.sample(s.position, playing: !s.paused, at: _clock());
|
|
||||||
_send('Playing', () => client.reportPlaybackStart(reportFor(s)));
|
_send('Playing', () => client.reportPlaybackStart(reportFor(s)));
|
||||||
if (_keepPlayed) _send('played', () => client.markPlayed(itemId));
|
if (_keepPlayed) _send('played', () => client.markPlayed(itemId));
|
||||||
}
|
}
|
||||||
@@ -136,7 +139,6 @@ class JellyfinProgressReporter {
|
|||||||
if (!_playing || _dead) return;
|
if (!_playing || _dead) return;
|
||||||
final last = _last, lastAt = _lastAt, sentAt = _lastSentAt;
|
final last = _last, lastAt = _lastAt, sentAt = _lastSentAt;
|
||||||
final now = _clock();
|
final now = _clock();
|
||||||
watch.sample(s.position, playing: !s.paused, at: now);
|
|
||||||
var due = force || last == null || lastAt == null || sentAt == null;
|
var due = force || last == null || lastAt == null || sentAt == null;
|
||||||
if (!due) {
|
if (!due) {
|
||||||
final elapsed = now.difference(lastAt).inMilliseconds / 1000;
|
final elapsed = now.difference(lastAt).inMilliseconds / 1000;
|
||||||
@@ -163,11 +165,10 @@ class JellyfinProgressReporter {
|
|||||||
Future<void> stop(PlayerSnapshot s) {
|
Future<void> stop(PlayerSnapshot s) {
|
||||||
if (!_playing || _dead) return _chain;
|
if (!_playing || _dead) return _chain;
|
||||||
_playing = false;
|
_playing = false;
|
||||||
watch.sample(s.position, playing: !s.paused, at: _clock());
|
|
||||||
final v = _keepPlayed
|
final v = _keepPlayed
|
||||||
? const WatchVerdict(WatchOutcome.keepPlayed)
|
? const WatchVerdict(WatchOutcome.keepPlayed)
|
||||||
: watch.verdict(runtime: _runtime, endingStart: _endingStart);
|
: watch.verdict(runtime: _runtime, endingStart: _endingStart);
|
||||||
debugPrint('jellyfin $itemId: $v (covered ${watch.covered.round()} s of ${_runtime.round()} s)');
|
debugPrint('jellyfin $itemId: $v (${watch.credited.round()} s In of ${_runtime.round()} s)');
|
||||||
final int? ticks;
|
final int? ticks;
|
||||||
switch (v.outcome) {
|
switch (v.outcome) {
|
||||||
case WatchOutcome.played || WatchOutcome.keepPlayed:
|
case WatchOutcome.played || WatchOutcome.keepPlayed:
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import 'package:sync_relay_core/core.dart';
|
|||||||
import '../screens/home/picked_media.dart';
|
import '../screens/home/picked_media.dart';
|
||||||
import 'accounts.dart';
|
import 'accounts.dart';
|
||||||
import 'format.dart';
|
import 'format.dart';
|
||||||
|
import '../library/media_refs.dart';
|
||||||
|
|
||||||
// Jellyfin media in a session: turning picks and pasted links into refs
|
// Jellyfin media in a session: turning picks and pasted links into refs
|
||||||
// (the wire carries only `{serverId, itemId, ...}`), the hosted server's
|
// (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,
|
name: m.title.isEmpty ? 'Jellyfin item' : m.title,
|
||||||
size: 0,
|
size: 0,
|
||||||
duration: 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 pick a ref stands for on a linked server (for highlighting it in
|
||||||
/// the browser); null when this install has no account there.
|
/// 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.
|
/// is one.
|
||||||
Future<HelloMedia> mediaForLink(JellyfinAccounts accounts, String url,
|
Future<HelloMedia> mediaForLink(JellyfinAccounts accounts, String url,
|
||||||
{Duration timeout = const Duration(seconds: 6)}) async {
|
{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);
|
final link = parseJellyfinLink(url);
|
||||||
if (link == null) return plain;
|
if (link == null) return plain;
|
||||||
await accounts.ensureLoaded();
|
await accounts.ensureLoaded();
|
||||||
@@ -102,9 +105,11 @@ Future<HelloMedia> mediaForLink(JellyfinAccounts accounts, String url,
|
|||||||
);
|
);
|
||||||
final account = accounts.account(serverId);
|
final account = accounts.account(serverId);
|
||||||
final client = accounts.clientFor(serverId);
|
final client = accounts.clientFor(serverId);
|
||||||
|
MediaRef? what;
|
||||||
if (account != null && client != null) {
|
if (account != null && client != null) {
|
||||||
try {
|
try {
|
||||||
final pick = pickFor(account, await client.item(link.itemId).timeout(timeout));
|
final pick = pickFor(account, await client.item(link.itemId).timeout(timeout));
|
||||||
|
what = pick.ref;
|
||||||
ref = refForPick(JellyfinMedia(
|
ref = refForPick(JellyfinMedia(
|
||||||
serverId: serverId,
|
serverId: serverId,
|
||||||
baseUrl: account.baseUrl,
|
baseUrl: account.baseUrl,
|
||||||
@@ -119,7 +124,11 @@ Future<HelloMedia> mediaForLink(JellyfinAccounts accounts, String url,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return HelloMedia(
|
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
|
/// 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';
|
return uri.host.isNotEmpty ? uri.host : 'link';
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The hosted server's relay login: the account for [serverId] (the
|
/// The hosted server's relay logins: one per linked Jellyfin account
|
||||||
/// hosted item's server), else the default account, when its token is not
|
/// whose token is not known to be expired (the default account first),
|
||||||
/// known to be expired. The device fields are exactly the ones this app
|
/// so `/j` relays an item with its own server's login. The device fields
|
||||||
/// sends with that token ([JellyfinAccounts.clientInfo]): Jellyfin rewrites
|
/// are exactly the ones this app sends with those tokens
|
||||||
/// a token's device from the request header, so the relay must not differ.
|
/// ([JellyfinAccounts.clientInfo]): Jellyfin rewrites a token's device
|
||||||
JellyfinCredential? relayCredentialFor(JellyfinAccounts accounts, {String? serverId}) {
|
/// from the request header, so the relay must not differ.
|
||||||
bool usable(JellyfinAccount? a) =>
|
List<JellyfinCredential> relayCredentialsFor(JellyfinAccounts accounts) {
|
||||||
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;
|
|
||||||
final info = accounts.clientInfo;
|
final info = accounts.clientInfo;
|
||||||
return JellyfinCredential(
|
final first = accounts.defaultAccount;
|
||||||
baseUrl: a!.baseUrl,
|
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,
|
serverId: a.serverId,
|
||||||
token: a.token,
|
token: a.token,
|
||||||
client: info.client,
|
client: info.client,
|
||||||
device: info.device,
|
device: info.device,
|
||||||
deviceId: info.deviceId,
|
deviceId: info.deviceId,
|
||||||
version: info.version,
|
version: info.version,
|
||||||
);
|
),
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `/j/...` URL of the current Jellyfin media on the host this client
|
/// `/j/...` URL of the current Jellyfin media on the host this client
|
||||||
@@ -330,26 +340,3 @@ Duration nextPromptStart(JellyfinItem? item, Duration runtime, {Duration? ending
|
|||||||
const lead = Duration(minutes: 2);
|
const lead = Duration(minutes: 2);
|
||||||
return runtime > lead ? runtime - lead : Duration.zero;
|
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, not out). Only what every
|
|
||||||
/// client sees in `state` decides (who put the item up is not in it: a
|
|
||||||
/// late joiner never saw that swap), so all clients agree.
|
|
||||||
String? autoAdvanceSwapper(List<MemberView> members, String serverId) {
|
|
||||||
final eligible = [
|
|
||||||
for (final m in members)
|
|
||||||
if (!m.stale && !m.out && 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;
|
||||||
|
}
|
||||||
@@ -2,12 +2,11 @@ import 'dart:async';
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:path_provider/path_provider.dart';
|
|
||||||
import 'package:sync_relay_core/core.dart';
|
import 'package:sync_relay_core/core.dart';
|
||||||
|
|
||||||
import '../playback/yt_dlp_app.dart';
|
|
||||||
import '../screens/home/ui.dart' show appName, displayVersion;
|
import '../screens/home/ui.dart' show appName, displayVersion;
|
||||||
import '../state/session_state.dart';
|
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
|
// 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,
|
// logging.dart writes, rotates and redacts it), debugPrint routed into it,
|
||||||
@@ -18,13 +17,11 @@ final _appLog = createLogger('app');
|
|||||||
/// The logs folder, once [startAppLogging] ran.
|
/// The logs folder, once [startAppLogging] ran.
|
||||||
String? appLogDir;
|
String? appLogDir;
|
||||||
|
|
||||||
/// Opens the log files and routes `debugPrint` and uncaught errors into
|
/// Opens the log files in [dirs]' log folder and routes `debugPrint` and
|
||||||
/// the log. Needs Flutter's binding (path_provider).
|
/// uncaught errors into the log.
|
||||||
Future<void> startAppLogging() async {
|
Future<void> startAppLogging(AppDirs dirs) async {
|
||||||
try {
|
try {
|
||||||
final base = await getApplicationSupportDirectory();
|
if (startLogFiles(dirs.logs) != null) appLogDir = dirs.logs;
|
||||||
final dir = '${base.path}${Platform.pathSeparator}logs';
|
|
||||||
if (startLogFiles(dir) != null) appLogDir = dir;
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_appLog.warning('no log files: $e');
|
_appLog.warning('no log files: $e');
|
||||||
}
|
}
|
||||||
@@ -40,26 +37,32 @@ Future<void> startAppLogging() async {
|
|||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
_appLog.info('$appName ${displayVersion()} on ${Platform.operatingSystem} '
|
_appLog.info('$appName ${displayVersion()} on ${Platform.operatingSystem} '
|
||||||
'${Platform.operatingSystemVersion}, pid $pid, Dart ${Platform.version.split(' ').first}');
|
'${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 text "Copy diagnostics" gives: versions, then the last [since] of
|
||||||
/// the app log (its hosted server's records included), already redacted;
|
/// the app log (its hosted server's records included), already redacted;
|
||||||
/// with [sessionLog], the server's session log of the same hours after it
|
/// with [sessionLog], the server's session log of the same hours after it
|
||||||
/// (every member's shared lines and the server's, merged).
|
/// (every member's shared lines and the server's, merged).
|
||||||
Future<String> diagnosticsText({Duration since = const Duration(hours: 6), bool sessionLog = false}) async {
|
Future<String> diagnosticsText(AppGraph graph, {Duration since = const Duration(hours: 6), bool sessionLog = false}) async {
|
||||||
final b = StringBuffer()
|
final b = StringBuffer()
|
||||||
..writeln('$appName ${displayVersion()}')
|
..writeln('$appName ${displayVersion()}')
|
||||||
..writeln('OS: ${Platform.operatingSystem} ${Platform.operatingSystemVersion}')
|
..writeln('OS: ${Platform.operatingSystem} ${Platform.operatingSystemVersion}')
|
||||||
..writeln('Dart: ${Platform.version.split(' ').first}');
|
..writeln('Dart: ${Platform.version.split(' ').first}');
|
||||||
try {
|
try {
|
||||||
final mpv = await SessionState.instance.coordinator.playback.mpvVersion().timeout(const Duration(seconds: 2));
|
final mpv = await graph.coordinator.playback.mpvVersion().timeout(const Duration(seconds: 2));
|
||||||
b.writeln('mpv: ${mpv ?? 'no player yet'}');
|
b.writeln('mpv: ${mpv ?? 'no player yet'}');
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
b.writeln('mpv: unknown');
|
b.writeln('mpv: unknown');
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
final y = await AppYtDlp.instance.ytDlp.timeout(const Duration(seconds: 2));
|
final y = await graph.ports.links.ytDlp.timeout(const Duration(seconds: 2));
|
||||||
final cookies = y.cookies.read(count: false);
|
final cookies = y.cookies.read(count: false);
|
||||||
b.writeln('yt-dlp: ${y.version ?? 'not ready'}${y.lastError == null ? '' : ' (${y.lastError})'}, '
|
b.writeln('yt-dlp: ${y.version ?? 'not ready'}${y.lastError == null ? '' : ' (${y.lastError})'}, '
|
||||||
'cookies: ${cookies == null ? 'none' : 'set'}');
|
'cookies: ${cookies == null ? 'none' : 'set'}');
|
||||||
@@ -71,7 +74,7 @@ Future<String> diagnosticsText({Duration since = const Duration(hours: 6), bool
|
|||||||
b.writeln('--- last ${since.inHours} h of the log ---');
|
b.writeln('--- last ${since.inHours} h of the log ---');
|
||||||
b.write(files?.recent(since) ?? '');
|
b.write(files?.recent(since) ?? '');
|
||||||
if (sessionLog) {
|
if (sessionLog) {
|
||||||
final server = await SessionState.instance.coordinator.sessionLog(hours: since.inHours);
|
final server = await graph.coordinator.sessionLog(hours: since.inHours);
|
||||||
b
|
b
|
||||||
..writeln()
|
..writeln()
|
||||||
..writeln("--- last ${since.inHours} h of the server's session log ---")
|
..writeln("--- last ${since.inHours} h of the server's session log ---")
|
||||||
|
|||||||
@@ -2,11 +2,10 @@ import 'dart:async';
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:window_manager/window_manager.dart';
|
|
||||||
|
|
||||||
import '../files/thumbnails.dart';
|
import '../files/thumbnails.dart';
|
||||||
import '../state/session_state.dart';
|
import '../app/app_graph.dart';
|
||||||
import 'window_memory.dart';
|
import 'single_instance.dart';
|
||||||
|
|
||||||
// The one way out of the app (close button, Alt+F4, Update and restart).
|
// 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
|
// Order matters: every mpv player goes before the engine does (a live one
|
||||||
@@ -15,46 +14,62 @@ import 'window_memory.dart';
|
|||||||
// inside the message loop. The runners end the process right after
|
// inside the message loop. The runners end the process right after
|
||||||
// (`linux/runner/main.cc`, `windows/runner/main.cpp`).
|
// (`linux/runner/main.cc`, `windows/runner/main.cpp`).
|
||||||
|
|
||||||
/// Saved on the way out; set by main once known.
|
|
||||||
WindowMemory? shutdownWindowMemory;
|
|
||||||
|
|
||||||
bool _shuttingDown = false;
|
bool _shuttingDown = false;
|
||||||
|
|
||||||
/// True once [shutDownApp] started.
|
/// True once [shutDownApp] started.
|
||||||
bool get shuttingDown => _shuttingDown;
|
bool get shuttingDown => _shuttingDown;
|
||||||
|
|
||||||
|
/// The systems the close stops; `main` hands them over once.
|
||||||
|
AppGraph? _graph;
|
||||||
|
|
||||||
|
/// This app's claim on its profile folder (one app at a time).
|
||||||
|
InstanceLock? _instance;
|
||||||
|
|
||||||
|
/// Makes [graph] the one [shutDownApp] stops (and [instance] the claim it
|
||||||
|
/// gives up) and returns the close request handler for the window.
|
||||||
|
Future<void> Function() closeAppWith(AppGraph graph, {InstanceLock? instance}) {
|
||||||
|
_graph = graph;
|
||||||
|
_instance = instance;
|
||||||
|
return shutDownApp;
|
||||||
|
}
|
||||||
|
|
||||||
/// Hides the window, releases every player and the sound device, leaves
|
/// Hides the window, releases every player and the sound device, leaves
|
||||||
/// (others see "left", a hosted server stops), runs [beforeQuit], then
|
/// (the `bye` goes out first, so others see "left" at once; then your
|
||||||
/// closes the window. Every step is bounded; a second call does nothing.
|
/// server stops), runs [beforeQuit], then closes the window. [restart]
|
||||||
Future<void> shutDownApp({Future<void> Function()? beforeQuit}) async {
|
/// (Update and restart) leaves without the `bye`: the member stays
|
||||||
if (_shuttingDown) return;
|
/// `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;
|
_shuttingDown = true;
|
||||||
final session = SessionState.instance;
|
// A start from now on waits for this app to end instead of handing its
|
||||||
|
// link to it.
|
||||||
|
_instance?.closing();
|
||||||
|
final shell = graph.ports.shell;
|
||||||
final total = Stopwatch()..start();
|
final total = Stopwatch()..start();
|
||||||
await _step('window save', shutdownWindowMemory?.save(), const Duration(milliseconds: 500));
|
await _step('window save', shell.saveWindow(), const Duration(milliseconds: 500));
|
||||||
await _step('hide', windowManager.hide(), const Duration(milliseconds: 500));
|
await _step('hide', shell.hide(), const Duration(milliseconds: 500));
|
||||||
await Future.wait([
|
await Future.wait([
|
||||||
_step('players', session.coordinator.releasePlayers(), const Duration(seconds: 3)),
|
_step('players', graph.coordinator.releasePlayers(), const Duration(seconds: 3)),
|
||||||
_step('thumbnails', cancelAllThumbnails(), const Duration(milliseconds: 1500)),
|
_step('thumbnails', cancelAllThumbnails(), const Duration(milliseconds: 1500)),
|
||||||
_step('sounds', session.sfx.dispose(), const Duration(milliseconds: 500)),
|
_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
|
// 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
|
// its own after dispose returns; it must be done before the engine
|
||||||
// stops, or the plugin's teardown waits on it for good.
|
// stops, or the plugin's teardown waits on it for good.
|
||||||
if (Platform.isWindows) await Future.delayed(const Duration(milliseconds: 300));
|
if (Platform.isWindows) await Future.delayed(const Duration(milliseconds: 300));
|
||||||
// Jellyfin stop report, last diagnostics, the goodbye, then the hosted
|
// Jellyfin stop report, last diagnostics, the bye (flushed), then your
|
||||||
// server: 3 s in all.
|
// server: 3 s in all.
|
||||||
await _step('leave', session.leave(), const Duration(milliseconds: 2500));
|
await _step('leave', graph.navigation.leave(bye: !restart), const Duration(milliseconds: 2500));
|
||||||
await _step('server stop', session.stopServer(), const Duration(milliseconds: 500));
|
await _step('server stop', graph.yourServer.stop(), const Duration(milliseconds: 500));
|
||||||
|
// Update and restart: the new binary claims the folder at once.
|
||||||
|
if (restart) await _step('instance', _instance?.release(), const Duration(milliseconds: 500));
|
||||||
if (beforeQuit != null) await _step('before quit', beforeQuit(), const Duration(seconds: 3));
|
if (beforeQuit != null) await _step('before quit', beforeQuit(), const Duration(seconds: 3));
|
||||||
debugPrint('close: ready to quit after ${total.elapsedMilliseconds} ms');
|
debugPrint('close: ready to quit after ${total.elapsedMilliseconds} ms');
|
||||||
try {
|
await shell.quit();
|
||||||
await windowManager.setPreventClose(false);
|
|
||||||
await windowManager.close();
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint('window close failed: $e');
|
|
||||||
await windowManager.destroy();
|
|
||||||
}
|
|
||||||
// The engine stops with the window, so this only fires when the close
|
// 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
|
// did not go through. A kill, not exit(): no exit handlers (NVIDIA's
|
||||||
// EGL one segfaults, Windows DLL detach can hang).
|
// EGL one segfaults, Windows DLL detach can hang).
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
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,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,59 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The desktop's activation token this process started with (Linux:
|
||||||
|
/// `XDG_ACTIVATION_TOKEN` or `DESKTOP_STARTUP_ID`, kept by the runner
|
||||||
|
/// before GTK used it); '' when none or elsewhere.
|
||||||
|
Future<String> windowLaunchToken() async {
|
||||||
|
if (!Platform.isLinux) return '';
|
||||||
|
try {
|
||||||
|
return await _window.invokeMethod<String>('launchToken') ?? '';
|
||||||
|
} catch (_) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Linux: brings the window up with another start's activation token
|
||||||
|
/// ([windowLaunchToken] of the start that handed over). False where the
|
||||||
|
/// runner lacks it.
|
||||||
|
Future<bool> presentWindow(String token) async {
|
||||||
|
if (!Platform.isLinux) return false;
|
||||||
|
try {
|
||||||
|
await _window.invokeMethod<void>('present', token);
|
||||||
|
return true;
|
||||||
|
} catch (_) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ends this process at once with no exit handlers (a second start that
|
||||||
|
/// handed over; its window never showed). The runner does it; where it
|
||||||
|
/// cannot, a kill.
|
||||||
|
Future<Never> exitNow() async {
|
||||||
|
try {
|
||||||
|
await _window.invokeMethod<void>('exit');
|
||||||
|
} catch (_) {}
|
||||||
|
Process.killPid(pid, ProcessSignal.sigkill);
|
||||||
|
// The kill lands before anything else runs.
|
||||||
|
await Future<void>.delayed(const Duration(days: 1));
|
||||||
|
throw StateError('unreachable');
|
||||||
|
}
|
||||||
@@ -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,190 @@
|
|||||||
|
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({String activation = ''}) async {
|
||||||
|
try {
|
||||||
|
if (await windowManager.isMinimized()) await windowManager.restore();
|
||||||
|
if (!await windowManager.isVisible()) await windowManager.show();
|
||||||
|
// Linux: present with the token when there is one (else a
|
||||||
|
// compositor only marks the window as wanting attention).
|
||||||
|
if (activation.isNotEmpty && await presentWindow(activation)) return;
|
||||||
|
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
|
||||||
|
Future<String?> readClipboardText() async {
|
||||||
|
try {
|
||||||
|
return await ClipboardX.readText();
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@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,605 @@
|
|||||||
|
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 _videos = <_MkVideo>{};
|
||||||
|
// The size outputs render at ([setOutputSize]); null: the video's own.
|
||||||
|
(int, int)? _outputSize;
|
||||||
|
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
|
||||||
|
void setOutputSize(int? width, int? height) {
|
||||||
|
final size = width != null && height != null && width > 0 && height > 0 ? (width, height) : null;
|
||||||
|
if (size == _outputSize) return;
|
||||||
|
_outputSize = size;
|
||||||
|
for (final v in _videos.toList()) {
|
||||||
|
unawaited(v.applySize());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@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);
|
||||||
|
_videos.add(v);
|
||||||
|
p._video = v;
|
||||||
|
if (_outputSize != null) unawaited(v.applySize());
|
||||||
|
// 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) {
|
||||||
|
// Windows: the controller can be "ready" before mpv's render context
|
||||||
|
// exists, and an open in that gap fails its video output ("No render
|
||||||
|
// context set"). Open the same media again once, a moment later.
|
||||||
|
if (log.prefix == 'cplayer' && log.text.contains('initializing the selected video_out')) {
|
||||||
|
final last = p._lastOpen;
|
||||||
|
if (last != null && !p._voRetried && !p._retired) {
|
||||||
|
p._voRetried = true;
|
||||||
|
_mpvLog.info('render: the video output was not up for the open; opening it again');
|
||||||
|
unawaited(Future.delayed(const Duration(milliseconds: 500), () async {
|
||||||
|
if (p._retired || !identical(p._lastOpen, last)) return;
|
||||||
|
await p.mk.open(Media(last.url, httpHeaders: last.headers), play: last.play);
|
||||||
|
}).catchError((Object e) => _mpvLog.warning('render: opening again failed: $e')));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
if (p._video case final v?) {
|
||||||
|
_videos.remove(v);
|
||||||
|
await v._paramsSub?.cancel();
|
||||||
|
}
|
||||||
|
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
|
||||||
|
// The last open, for one retry when mpv's video output was not up yet.
|
||||||
|
({String url, Map<String, String> headers, bool play})? _lastOpen;
|
||||||
|
bool _voRetried = false;
|
||||||
|
_MkVideo? _video;
|
||||||
|
|
||||||
|
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;
|
||||||
|
_lastOpen = (url: url, headers: headers, play: play);
|
||||||
|
_voRetried = false;
|
||||||
|
// 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;
|
||||||
|
|
||||||
|
// What this output was last set to: null with [_sized] the video's own
|
||||||
|
// size again; nothing sent yet while [_sized] is false.
|
||||||
|
(int, int)? _sent;
|
||||||
|
bool _sized = false;
|
||||||
|
StreamSubscription<VideoParams>? _paramsSub;
|
||||||
|
Future<void> _sizeOp = Future.value();
|
||||||
|
|
||||||
|
/// Renders at the host's output size (or the video's own when it has
|
||||||
|
/// none). media_kit's controller sets the output back to the video's
|
||||||
|
/// size whenever the video's parameters change (a new file, a track
|
||||||
|
/// with another size); the fixed size is sent again after it, under
|
||||||
|
/// the controller's own lock so it lands last.
|
||||||
|
Future<void> applySize({bool again = false}) => _sizeOp = _sizeOp.then((_) async {
|
||||||
|
if (player.disposed) return;
|
||||||
|
try {
|
||||||
|
// NativeVideoController (Linux, Windows): not exported, reached
|
||||||
|
// through its public fields.
|
||||||
|
final dynamic native = await controller.platform.future;
|
||||||
|
final view = player.host._outputSize;
|
||||||
|
final params = player.mk.state.videoParams;
|
||||||
|
final dw = params.dw ?? 0, dh = params.dh ?? 0;
|
||||||
|
final want = view == null ? null : fitOutputSize(view, dw > 0 && dh > 0 ? (dw, dh) : null);
|
||||||
|
if (_sized && want == _sent && !again) return;
|
||||||
|
if (want == null) {
|
||||||
|
if (!_sized) return; // never resized: already the video's size
|
||||||
|
await native.setSize();
|
||||||
|
} else {
|
||||||
|
// setSize skips a size it believes is set; after media_kit's own
|
||||||
|
// reset it is not.
|
||||||
|
native.width = null;
|
||||||
|
await native.setSize(width: want.$1, height: want.$2);
|
||||||
|
}
|
||||||
|
_sent = want;
|
||||||
|
_sized = true;
|
||||||
|
// The output takes its new size with the next frame mpv draws;
|
||||||
|
// paused, none comes: a pan too small to see makes mpv redraw.
|
||||||
|
if (!player.state.playing) {
|
||||||
|
await player.setProperty('video-pan-x', '0.000001');
|
||||||
|
await player.setProperty('video-pan-x', '0');
|
||||||
|
}
|
||||||
|
_paramsSub ??= player.mk.stream.videoParams.listen((params) async {
|
||||||
|
if ((params.dw ?? 0) == 0 || _sent == null) return;
|
||||||
|
try {
|
||||||
|
await (native.lock.synchronized(() async {}) as Future);
|
||||||
|
} catch (_) {}
|
||||||
|
unawaited(applySize(again: true));
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('video output size: $e');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
File renamed without changes.
@@ -0,0 +1,60 @@
|
|||||||
|
import 'dart:ffi';
|
||||||
|
|
||||||
|
import 'package:ffi/ffi.dart';
|
||||||
|
|
||||||
|
// The few Win32 calls the app makes itself (no package wraps them for
|
||||||
|
// us without a new dependency). Only ever called on Windows.
|
||||||
|
|
||||||
|
/// HKEY_CURRENT_USER as the API takes it (sign-extended 0x80000001).
|
||||||
|
const _hkeyCurrentUser = -0x7FFFFFFF;
|
||||||
|
const _regSz = 1;
|
||||||
|
const _rrfRtRegSz = 0x2;
|
||||||
|
|
||||||
|
typedef _AllowNative = Int32 Function(Uint32 pid);
|
||||||
|
typedef _Allow = int Function(int pid);
|
||||||
|
|
||||||
|
/// Lets process [pid] take the foreground (a second start does this for
|
||||||
|
/// the running app before handing over its link: Windows only lets a
|
||||||
|
/// process that got the user's click bring a window up).
|
||||||
|
void allowSetForegroundWindow(int pid) {
|
||||||
|
final user32 = DynamicLibrary.open('user32.dll');
|
||||||
|
user32.lookupFunction<_AllowNative, _Allow>('AllowSetForegroundWindow')(pid);
|
||||||
|
}
|
||||||
|
|
||||||
|
typedef _SetNative = Int32 Function(
|
||||||
|
IntPtr key, Pointer<Utf16> subKey, Pointer<Utf16> name, Uint32 type, Pointer<Void> data, Uint32 size);
|
||||||
|
typedef _Set = int Function(
|
||||||
|
int key, Pointer<Utf16> subKey, Pointer<Utf16> name, int type, Pointer<Void> data, int size);
|
||||||
|
typedef _GetNative = Int32 Function(IntPtr key, Pointer<Utf16> subKey, Pointer<Utf16> name, Uint32 flags,
|
||||||
|
Pointer<Uint32> type, Pointer<Void> data, Pointer<Uint32> size);
|
||||||
|
typedef _Get = int Function(int key, Pointer<Utf16> subKey, Pointer<Utf16> name, int flags,
|
||||||
|
Pointer<Uint32> type, Pointer<Void> data, Pointer<Uint32> size);
|
||||||
|
|
||||||
|
DynamicLibrary get _advapi => DynamicLibrary.open('advapi32.dll');
|
||||||
|
|
||||||
|
/// The string value [name] ('' = the key's default) of
|
||||||
|
/// HKEY_CURRENT_USER\[subKey]; null when missing.
|
||||||
|
String? readUserRegistryString(String subKey, String name) {
|
||||||
|
final get = _advapi.lookupFunction<_GetNative, _Get>('RegGetValueW');
|
||||||
|
return using((arena) {
|
||||||
|
final key = subKey.toNativeUtf16(allocator: arena);
|
||||||
|
final value = name.isEmpty ? nullptr : name.toNativeUtf16(allocator: arena);
|
||||||
|
final size = arena<Uint32>()..value = 0;
|
||||||
|
if (get(_hkeyCurrentUser, key, value, _rrfRtRegSz, nullptr, nullptr, size) != 0) return null;
|
||||||
|
final data = arena<Uint16>(size.value ~/ 2 + 1);
|
||||||
|
if (get(_hkeyCurrentUser, key, value, _rrfRtRegSz, nullptr, data.cast(), size) != 0) return null;
|
||||||
|
return data.cast<Utf16>().toDartString();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets the string value [name] ('' = the key's default) of
|
||||||
|
/// HKEY_CURRENT_USER\[subKey], creating the key; false when refused.
|
||||||
|
bool writeUserRegistryString(String subKey, String name, String data) {
|
||||||
|
final set = _advapi.lookupFunction<_SetNative, _Set>('RegSetKeyValueW');
|
||||||
|
return using((arena) {
|
||||||
|
final key = subKey.toNativeUtf16(allocator: arena);
|
||||||
|
final value = name.isEmpty ? nullptr : name.toNativeUtf16(allocator: arena);
|
||||||
|
final bytes = data.toNativeUtf16(allocator: arena);
|
||||||
|
return set(_hkeyCurrentUser, key, value.cast(), _regSz, bytes.cast(), (data.length + 1) * 2) == 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -3,18 +3,35 @@ import 'dart:io';
|
|||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:sync_relay_core/core.dart' show findInstallRoot;
|
import 'package:sync_relay_core/core.dart' show findInstallRoot;
|
||||||
|
|
||||||
|
import 'url_scheme.dart';
|
||||||
|
|
||||||
/// The app's id: GTK's application id, so the Wayland `app_id` the
|
/// The app's id: GTK's application id, so the Wayland `app_id` the
|
||||||
/// compositor matches to [desktopFileName] for the window's icon.
|
/// compositor matches to [desktopFileName] for the window's icon.
|
||||||
const appId = 'moe.senkochan.watch_together';
|
const appId = 'dev.foxyz.starsync';
|
||||||
const desktopFileName = '$appId.desktop';
|
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
|
/// Linux release installs (an unpacked tarball) carry no launcher entry, so
|
||||||
/// Wayland shows a generic icon. On start: write the `.desktop` file into
|
/// Wayland shows a generic icon. On start: write the `.desktop` file into
|
||||||
/// `~/.local/share/applications` (Exec = this binary) and the icon into
|
/// `~/.local/share/applications` (Exec = this binary) and the icon into
|
||||||
/// the hicolor theme, only when something changed (a moved install, a new
|
/// the hicolor theme, only when something changed (a moved install, a new
|
||||||
/// icon). Never in debug runs; failures only log.
|
/// icon). The entry and icons of [legacyAppId] go. The entry also takes
|
||||||
Future<void> installDesktopEntry({String? executable, String? dataHome, String? home}) async {
|
/// `starsync://` links ([schemeMimeType], %u) and the app becomes their
|
||||||
if (!Platform.isLinux || kDebugMode) return;
|
/// default handler ([registerScheme], default [registerLinuxScheme]).
|
||||||
|
/// Never in debug runs (unless [force], for tests); failures only log.
|
||||||
|
Future<void> installDesktopEntry({
|
||||||
|
String? executable,
|
||||||
|
String? dataHome,
|
||||||
|
String? home,
|
||||||
|
@visibleForTesting bool force = false,
|
||||||
|
Future<void> Function(String desktopFile, String applicationsDir)? registerScheme,
|
||||||
|
}) async {
|
||||||
|
if (!Platform.isLinux || (kDebugMode && !force)) return;
|
||||||
try {
|
try {
|
||||||
final exe = executable ?? Platform.resolvedExecutable;
|
final exe = executable ?? Platform.resolvedExecutable;
|
||||||
final root = findInstallRoot(exe, windows: false);
|
final root = findInstallRoot(exe, windows: false);
|
||||||
@@ -22,7 +39,8 @@ Future<void> installDesktopEntry({String? executable, String? dataHome, String?
|
|||||||
final share = dataHome ?? Platform.environment['XDG_DATA_HOME'] ??
|
final share = dataHome ?? Platform.environment['XDG_DATA_HOME'] ??
|
||||||
'${home ?? Platform.environment['HOME']}/.local/share';
|
'${home ?? Platform.environment['HOME']}/.local/share';
|
||||||
final icons = '${root.path}/data/flutter_assets/assets/icon';
|
final icons = '${root.path}/data/flutter_assets/assets/icon';
|
||||||
for (final size in const [16, 32, 48, 64, 128, 256, 512]) {
|
await _removeLegacy(share);
|
||||||
|
for (final size in _iconSizes) {
|
||||||
final src = File('$icons/icon_$size.png');
|
final src = File('$icons/icon_$size.png');
|
||||||
if (!src.existsSync()) continue;
|
if (!src.existsSync()) continue;
|
||||||
final dst = File('$share/icons/hicolor/${size}x$size/apps/$appId.png');
|
final dst = File('$share/icons/hicolor/${size}x$size/apps/$appId.png');
|
||||||
@@ -32,22 +50,41 @@ Future<void> installDesktopEntry({String? executable, String? dataHome, String?
|
|||||||
}
|
}
|
||||||
final entry = desktopEntryText(exe);
|
final entry = desktopEntryText(exe);
|
||||||
final file = File('$share/applications/$desktopFileName');
|
final file = File('$share/applications/$desktopFileName');
|
||||||
if (file.existsSync() && file.readAsStringSync() == entry) return;
|
if (!file.existsSync() || file.readAsStringSync() != entry) {
|
||||||
await file.parent.create(recursive: true);
|
await file.parent.create(recursive: true);
|
||||||
await file.writeAsString(entry);
|
await file.writeAsString(entry);
|
||||||
|
}
|
||||||
|
await (registerScheme ?? registerLinuxScheme)(desktopFileName, file.parent.path);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('desktop entry: $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
|
@visibleForTesting
|
||||||
String desktopEntryText(String exe) => '''[Desktop Entry]
|
String desktopEntryText(String exe) => '''[Desktop Entry]
|
||||||
Type=Application
|
Type=Application
|
||||||
Name=Starsync
|
Name=Starsync
|
||||||
Comment=Watch videos in sync with friends
|
Comment=Watch videos in sync with friends
|
||||||
Exec="$exe"
|
Exec="$exe" %u
|
||||||
Icon=$appId
|
Icon=$appId
|
||||||
StartupWMClass=$appId
|
StartupWMClass=$appId
|
||||||
|
StartupNotify=true
|
||||||
|
MimeType=$schemeMimeType;
|
||||||
Categories=AudioVideo;Video;Player;Network;
|
Categories=AudioVideo;Video;Player;Network;
|
||||||
Terminal=false
|
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,229 @@
|
|||||||
|
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});
|
||||||
|
|
||||||
|
/// The view's pixel size: every video output renders the picture fitted
|
||||||
|
/// in it ([fitOutputSize]), so mpv scales the picture and draws
|
||||||
|
/// subtitles at screen resolution (Settings → Player, "Sharp video").
|
||||||
|
/// Null: each output at its video's own size. Applies to live outputs
|
||||||
|
/// and to new ones once they are up.
|
||||||
|
void setOutputSize(int? width, int? height);
|
||||||
|
|
||||||
|
/// 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 ffmpeg's HTTP stream lost the file's size and so every
|
||||||
|
/// seek on it fails ("Seek failed (to 3417735, size -38)": -38 is ENOSYS,
|
||||||
|
/// what ffmpeg answers for the size once a reconnect's or a seek's request
|
||||||
|
/// failed). Only a fresh open gets it back.
|
||||||
|
bool mpvStreamLost(PlayerLogLine log) =>
|
||||||
|
log.prefix == 'ffmpeg' && log.text.contains('Seek failed') && log.text.contains('size -38)');
|
||||||
|
|
||||||
|
/// [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'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The size a video output renders at for a [view] of that many pixels:
|
||||||
|
/// the [video]'s picture (its display size, aspect included) fitted
|
||||||
|
/// inside, no letterbox. The texture then has the picture's aspect, so
|
||||||
|
/// while a resize settles Flutter scales the last one to the new view
|
||||||
|
/// with no jump (a texture of the whole view would shrink inside its own
|
||||||
|
/// black bars until the new one came). Drawn 1:1: the fitted side is the
|
||||||
|
/// view's, the other is rounded down to the view's parity, so the
|
||||||
|
/// picture sits on whole pixels when centred (a half-pixel offset would
|
||||||
|
/// blur every line). Unknown video: the view.
|
||||||
|
(int, int) fitOutputSize((int, int) view, (int, int)? video) {
|
||||||
|
final (vw, vh) = view;
|
||||||
|
if (video == null || video.$1 <= 0 || video.$2 <= 0) return view;
|
||||||
|
final aspect = video.$1 / video.$2;
|
||||||
|
int under(double x, int full) {
|
||||||
|
var n = x.floor().clamp(1, full);
|
||||||
|
if ((full - n).isOdd) n = n > 1 ? n - 1 : n;
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (vw / vh > aspect) return (under(vh * aspect, vw), vh); // pillarbox
|
||||||
|
return (vw, under(vw / aspect, vh)); // letterbox (or exact)
|
||||||
|
}
|
||||||
@@ -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,72 @@
|
|||||||
|
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, a
|
||||||
|
/// link handed over by a second start ([activation]: that start's
|
||||||
|
/// desktop activation token, which lets the window take the focus).
|
||||||
|
Future<void> focus({String activation = ''});
|
||||||
|
|
||||||
|
/// 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();
|
||||||
|
|
||||||
|
/// The clipboard's text only (the join prompt looks for an invite in
|
||||||
|
/// it); null when there is none or it can't be read.
|
||||||
|
Future<String?> readClipboardText();
|
||||||
|
|
||||||
|
/// [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,205 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
import 'dart:math';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart' show debugPrint, visibleForTesting;
|
||||||
|
|
||||||
|
/// What a second start hands to the running app: its arguments (a
|
||||||
|
/// clicked `starsync://` link) and the desktop's activation token for
|
||||||
|
/// bringing the window up (Linux: `XDG_ACTIVATION_TOKEN` /
|
||||||
|
/// `DESKTOP_STARTUP_ID`; '' when none).
|
||||||
|
typedef Handoff = ({List<String> args, String activation});
|
||||||
|
|
||||||
|
const _lockName = 'instance.lock';
|
||||||
|
const _infoName = 'instance.json';
|
||||||
|
|
||||||
|
/// Lock paths this process holds: a second claim from the same process
|
||||||
|
/// (tests) sees them held, as another process would.
|
||||||
|
final _heldHere = <String>{};
|
||||||
|
|
||||||
|
/// One app per profile folder at a time. The first start holds an
|
||||||
|
/// exclusive lock on `instance.lock` in [dir] (the OS drops it when the
|
||||||
|
/// process ends, a crash too) and listens on a loopback port written to
|
||||||
|
/// `instance.json` with a random token. A later start finds the lock
|
||||||
|
/// held, sends its [Handoff] there and ends; the running app comes to
|
||||||
|
/// the front and handles it ([handoffs]). Pure dart:io: the same on
|
||||||
|
/// Linux (Wayland and X11) and Windows.
|
||||||
|
class InstanceLock {
|
||||||
|
InstanceLock._(this.dir, this._lock, this._server, this._token);
|
||||||
|
|
||||||
|
/// The profile folder (each `STARSYNC_PROFILE` has its own, so each is
|
||||||
|
/// its own instance).
|
||||||
|
final String dir;
|
||||||
|
final RandomAccessFile? _lock;
|
||||||
|
final ServerSocket? _server;
|
||||||
|
final String _token;
|
||||||
|
final _handoffs = StreamController<Handoff>();
|
||||||
|
bool _closing = false;
|
||||||
|
bool _released = false;
|
||||||
|
|
||||||
|
/// What later starts handed over, in order (buffered until listened to).
|
||||||
|
Stream<Handoff> get handoffs => _handoffs.stream;
|
||||||
|
|
||||||
|
/// False when the lock could not be had nor the running app reached
|
||||||
|
/// (this app runs anyway, as before single instance).
|
||||||
|
bool get held => _lock != null;
|
||||||
|
|
||||||
|
/// Claims [dir] for this process, or hands [args] to the app that holds
|
||||||
|
/// it: null when handed over (this start should end now). A holder
|
||||||
|
/// that is closing (or not listening yet) is retried for [retryFor]
|
||||||
|
/// ([closingWait] once it said it is closing, so a start during a close
|
||||||
|
/// or an update's restart takes over after it). [beforeSend] gets the
|
||||||
|
/// holder's process id first (Windows: allow it to take the
|
||||||
|
/// foreground).
|
||||||
|
static Future<InstanceLock?> claim(
|
||||||
|
String dir, {
|
||||||
|
List<String> args = const [],
|
||||||
|
String activation = '',
|
||||||
|
void Function(int pid)? beforeSend,
|
||||||
|
Duration retryFor = const Duration(seconds: 5),
|
||||||
|
Duration closingWait = const Duration(seconds: 20),
|
||||||
|
Duration retryEvery = const Duration(milliseconds: 200),
|
||||||
|
}) async {
|
||||||
|
await Directory(dir).create(recursive: true);
|
||||||
|
final started = DateTime.now();
|
||||||
|
var closingSeen = false;
|
||||||
|
while (true) {
|
||||||
|
final lock = await _tryLock(dir);
|
||||||
|
if (lock != null) return _listen(dir, lock);
|
||||||
|
final answer = await _send(dir, (args: args, activation: activation), beforeSend);
|
||||||
|
if (answer == _Answer.ok) return null;
|
||||||
|
if (answer == _Answer.closing) closingSeen = true;
|
||||||
|
final waited = DateTime.now().difference(started);
|
||||||
|
if (waited > (closingSeen ? closingWait : retryFor)) {
|
||||||
|
debugPrint('single instance: no lock and no answer after ${waited.inMilliseconds} ms; starting anyway');
|
||||||
|
return InstanceLock._(dir, null, null, '');
|
||||||
|
}
|
||||||
|
await Future.delayed(retryEvery);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<RandomAccessFile?> _tryLock(String dir) async {
|
||||||
|
final path = '$dir${Platform.pathSeparator}$_lockName';
|
||||||
|
if (_heldHere.contains(path)) return null;
|
||||||
|
RandomAccessFile? raf;
|
||||||
|
try {
|
||||||
|
raf = await File(path).open(mode: FileMode.append);
|
||||||
|
await raf.lock(FileLock.exclusive);
|
||||||
|
_heldHere.add(path);
|
||||||
|
return raf;
|
||||||
|
} catch (_) {
|
||||||
|
if (raf != null) await _quiet(raf.close());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<InstanceLock> _listen(String dir, RandomAccessFile lock) async {
|
||||||
|
final token = base64Url.encode([for (var i = 0; i < 18; i++) Random.secure().nextInt(256)]);
|
||||||
|
ServerSocket? server;
|
||||||
|
try {
|
||||||
|
server = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0);
|
||||||
|
await File('$dir${Platform.pathSeparator}$_infoName')
|
||||||
|
.writeAsString(jsonEncode({'port': server.port, 'token': token, 'pid': pid}), flush: true);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('single instance: cannot listen for later starts: $e');
|
||||||
|
}
|
||||||
|
final self = InstanceLock._(dir, lock, server, token);
|
||||||
|
server?.listen(self._serve, onError: (Object e) => debugPrint('single instance: $e'));
|
||||||
|
return self;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _serve(Socket socket) async {
|
||||||
|
try {
|
||||||
|
final line = await utf8.decoder
|
||||||
|
.bind(socket)
|
||||||
|
.transform(const LineSplitter())
|
||||||
|
.first
|
||||||
|
.timeout(const Duration(seconds: 3));
|
||||||
|
if (line.length > 64 * 1024) throw const FormatException('too long');
|
||||||
|
final j = jsonDecode(line);
|
||||||
|
if (j is! Map || j['token'] != _token) {
|
||||||
|
socket.writeln(jsonEncode({'ok': false}));
|
||||||
|
} else if (_closing) {
|
||||||
|
socket.writeln(jsonEncode({'closing': true}));
|
||||||
|
} else {
|
||||||
|
final args = j['args'];
|
||||||
|
final activation = j['activation'];
|
||||||
|
_handoffs.add((
|
||||||
|
args: [if (args is List) for (final a in args) if (a is String) a],
|
||||||
|
activation: activation is String ? activation : '',
|
||||||
|
));
|
||||||
|
socket.writeln(jsonEncode({'ok': true}));
|
||||||
|
}
|
||||||
|
await socket.flush();
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('single instance: a later start sent nothing usable (${e.runtimeType})');
|
||||||
|
} finally {
|
||||||
|
await _quiet(socket.close());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<_Answer> _send(String dir, Handoff h, void Function(int pid)? beforeSend) async {
|
||||||
|
Socket? socket;
|
||||||
|
try {
|
||||||
|
final info = jsonDecode(await File('$dir${Platform.pathSeparator}$_infoName').readAsString());
|
||||||
|
final port = info is Map ? info['port'] : null;
|
||||||
|
final token = info is Map ? info['token'] : null;
|
||||||
|
final holder = info is Map ? info['pid'] : null;
|
||||||
|
if (port is! int || token is! String) return _Answer.none;
|
||||||
|
if (holder is int) beforeSend?.call(holder);
|
||||||
|
socket = await Socket.connect(InternetAddress.loopbackIPv4, port, timeout: const Duration(seconds: 2));
|
||||||
|
socket.writeln(jsonEncode({'token': token, 'args': h.args, 'activation': h.activation}));
|
||||||
|
await socket.flush();
|
||||||
|
final reply = await utf8.decoder
|
||||||
|
.bind(socket)
|
||||||
|
.transform(const LineSplitter())
|
||||||
|
.first
|
||||||
|
.timeout(const Duration(seconds: 3));
|
||||||
|
final r = jsonDecode(reply);
|
||||||
|
if (r is Map && r['ok'] == true) return _Answer.ok;
|
||||||
|
if (r is Map && r['closing'] == true) return _Answer.closing;
|
||||||
|
return _Answer.none;
|
||||||
|
} catch (_) {
|
||||||
|
// Not written yet, a stale one from a crash, or not listening yet.
|
||||||
|
return _Answer.none;
|
||||||
|
} finally {
|
||||||
|
socket?.destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The app is closing: later starts are told so and wait for the lock
|
||||||
|
/// instead of handing over to an app on its way out.
|
||||||
|
void closing() => _closing = true;
|
||||||
|
|
||||||
|
/// Gives the folder up before this process ends (Update and restart:
|
||||||
|
/// the new binary claims it at once).
|
||||||
|
Future<void> release() async {
|
||||||
|
if (_released) return;
|
||||||
|
_released = true;
|
||||||
|
_closing = true;
|
||||||
|
await _server?.close();
|
||||||
|
final lock = _lock;
|
||||||
|
if (lock == null) return;
|
||||||
|
try {
|
||||||
|
await File('$dir${Platform.pathSeparator}$_infoName').delete();
|
||||||
|
} catch (_) {}
|
||||||
|
try {
|
||||||
|
await lock.unlock();
|
||||||
|
} catch (_) {}
|
||||||
|
await _quiet(lock.close());
|
||||||
|
_heldHere.remove('$dir${Platform.pathSeparator}$_lockName');
|
||||||
|
unawaited(_handoffs.close());
|
||||||
|
}
|
||||||
|
|
||||||
|
@visibleForTesting
|
||||||
|
int? get port => _server?.port;
|
||||||
|
}
|
||||||
|
|
||||||
|
enum _Answer { ok, closing, none }
|
||||||
|
|
||||||
|
Future<void> _quiet(Future<Object?> f) async {
|
||||||
|
try {
|
||||||
|
await f;
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
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`, `quitting`, 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, or a process that ends on its own (fail). It
|
||||||
|
// stays up [smokeUptime] first: a start that dies by itself (the 1.3.0
|
||||||
|
// Windows exit timer) ends before `closing`.
|
||||||
|
|
||||||
|
/// How long the smoke run keeps the app up before closing it.
|
||||||
|
const smokeUptime = Duration(seconds: 10);
|
||||||
|
|
||||||
|
/// 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');
|
||||||
|
}
|
||||||
|
final wait = smokeUptime - sw.elapsed;
|
||||||
|
if (wait > Duration.zero) await Future.delayed(wait);
|
||||||
|
step('closing');
|
||||||
|
// Reached only through the app's own close (not a kill on the way).
|
||||||
|
await shutDownApp(beforeQuit: () async => step('quitting'));
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:sync_relay_core/core.dart' show findInstallRoot;
|
||||||
|
|
||||||
|
import '../session/invite.dart' show inviteScheme;
|
||||||
|
import 'desktop/windows_api.dart';
|
||||||
|
|
||||||
|
/// The MIME type desktops look up for a `starsync://` link (Linux).
|
||||||
|
const schemeMimeType = 'x-scheme-handler/$inviteScheme';
|
||||||
|
|
||||||
|
/// HKCU\Software\Classes\starsync: what Windows opens a link with.
|
||||||
|
const windowsSchemeKey = r'Software\Classes\' + inviteScheme;
|
||||||
|
|
||||||
|
/// The binary a link should start: `starsync.exe` next to an old-named
|
||||||
|
/// copy (`watch_together.exe`, kept for 1.2.x updaters) when it is there,
|
||||||
|
/// so links always start the current name.
|
||||||
|
String schemeExecutable(String exe, {bool Function(String path)? exists}) {
|
||||||
|
final has = exists ?? (p) => File(p).existsSync();
|
||||||
|
final sep = exe.contains(r'\') ? r'\' : '/';
|
||||||
|
final cut = exe.lastIndexOf(sep);
|
||||||
|
final name = exe.substring(cut + 1).toLowerCase();
|
||||||
|
if (name != 'watch_together.exe' && name != 'watch_together') return exe;
|
||||||
|
final current = '${exe.substring(0, cut + 1)}starsync${name.endsWith('.exe') ? '.exe' : ''}';
|
||||||
|
return has(current) ? current : exe;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The registry values of the Windows link handler for [exe]: (key under
|
||||||
|
/// HKEY_CURRENT_USER, value name ('' = default), data).
|
||||||
|
List<(String, String, String)> windowsSchemeValues(String exe) => [
|
||||||
|
(windowsSchemeKey, '', 'URL:Starsync invite'),
|
||||||
|
(windowsSchemeKey, 'URL Protocol', ''),
|
||||||
|
('$windowsSchemeKey\\DefaultIcon', '', '"$exe",0'),
|
||||||
|
('$windowsSchemeKey\\shell\\open\\command', '', '"$exe" "%1"'),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Windows: makes `starsync://` links start this install (per user, no
|
||||||
|
/// admin: HKCU\Software\Classes), only when the command there is not this
|
||||||
|
/// one already (first start, a moved install, an update from an old
|
||||||
|
/// name). Release installs only (never a debug run); failures only log.
|
||||||
|
Future<void> registerWindowsScheme({String? executable}) async {
|
||||||
|
if (!Platform.isWindows || kDebugMode) return;
|
||||||
|
try {
|
||||||
|
final exe = schemeExecutable(executable ?? Platform.resolvedExecutable);
|
||||||
|
if (findInstallRoot(exe, windows: true) == null) return;
|
||||||
|
final values = windowsSchemeValues(exe);
|
||||||
|
final command = values.last;
|
||||||
|
if (readUserRegistryString(command.$1, command.$2) == command.$3) return;
|
||||||
|
for (final (key, name, data) in values) {
|
||||||
|
if (!writeUserRegistryString(key, name, data)) {
|
||||||
|
debugPrint('url scheme: cannot write HKCU\\$key');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
debugPrint('url scheme: starsync:// links open $exe');
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('url scheme: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Linux: makes this app the default for [schemeMimeType] (xdg-mime,
|
||||||
|
/// `~/.config/mimeapps.list`) unless it already is. [desktopFile]: the
|
||||||
|
/// launcher entry's file name, which lists the type.
|
||||||
|
Future<void> registerLinuxScheme(String desktopFile, String applicationsDir) async {
|
||||||
|
try {
|
||||||
|
final q = await Process.run('xdg-mime', ['query', 'default', schemeMimeType]);
|
||||||
|
if (q.exitCode == 0 && '${q.stdout}'.trim() == desktopFile) return;
|
||||||
|
final r = await Process.run('xdg-mime', ['default', desktopFile, schemeMimeType]);
|
||||||
|
if (r.exitCode != 0) {
|
||||||
|
debugPrint('url scheme: xdg-mime default failed: ${'${r.stderr}'.trim()}');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// The MIME cache some desktops read (missing on some systems: fine).
|
||||||
|
try {
|
||||||
|
await Process.run('update-desktop-database', ['-q', applicationsDir]);
|
||||||
|
} catch (_) {}
|
||||||
|
debugPrint('url scheme: starsync:// links open $desktopFile');
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('url scheme: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -4,25 +4,19 @@ import 'dart:io';
|
|||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
import 'package:sync_relay_core/core.dart';
|
import 'package:sync_relay_core/core.dart';
|
||||||
|
|
||||||
/// mpv's `ytdl-format` for links every player opens itself.
|
import '../platform/ports/link_resolver.dart';
|
||||||
const ytdlFormat = 'bv*[height<=1080]+ba/b';
|
|
||||||
|
|
||||||
/// yt-dlp's `--sub-langs` for mpv's hook (its default is `all`), without
|
|
||||||
/// Bilibili's danmaku: an XML comment feed served raw-deflate, which
|
|
||||||
/// ffmpeg fails to inflate on every read (a warning each time) while the
|
|
||||||
/// video waits on it.
|
|
||||||
const ytdlSubLangs = 'all,-danmaku';
|
|
||||||
|
|
||||||
/// The app's yt-dlp (see [YtDlp]): in the app-support folder, shared with
|
/// 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,
|
/// the hosted server. [probe] remembers what pasted links are for a while,
|
||||||
/// so the Link preview and the swap ask once.
|
/// so the Link preview and the swap ask once.
|
||||||
class AppYtDlp {
|
class AppYtDlp implements LinkResolver {
|
||||||
static final instance = AppYtDlp();
|
static final instance = AppYtDlp();
|
||||||
|
|
||||||
/// [ytDlp]: a given one (tests); default in the app-support folder.
|
/// [ytDlp]: a given one (tests); default in the app-support folder.
|
||||||
AppYtDlp([YtDlp? ytDlp]) : _ytDlp = ytDlp == null ? null : Future.value(ytDlp);
|
AppYtDlp([YtDlp? ytDlp]) : _ytDlp = ytDlp == null ? null : Future.value(ytDlp);
|
||||||
|
|
||||||
Future<YtDlp>? _ytDlp;
|
Future<YtDlp>? _ytDlp;
|
||||||
|
@override
|
||||||
Future<YtDlp> get ytDlp => _ytDlp ??= () async {
|
Future<YtDlp> get ytDlp => _ytDlp ??= () async {
|
||||||
final base = await getApplicationSupportDirectory();
|
final base = await getApplicationSupportDirectory();
|
||||||
return YtDlp(dir(base.path));
|
return YtDlp(dir(base.path));
|
||||||
@@ -32,19 +26,11 @@ class AppYtDlp {
|
|||||||
|
|
||||||
/// Gets yt-dlp ready in the background (download on first run, a daily
|
/// Gets yt-dlp ready in the background (download on first run, a daily
|
||||||
/// update check afterwards).
|
/// update check afterwards).
|
||||||
void warmUp() => unawaited(ytDlp.then((y) => y.ensure()).catchError((_) => null));
|
@override
|
||||||
|
bool get resolvesLinks => true;
|
||||||
|
|
||||||
/// mpv's `ytdl-raw-options`: [ytdlSubLangs] and the cookies in the
|
@override
|
||||||
/// yt-dlp folder (see [YtDlpCookies]).
|
void warmUp() => unawaited(ytDlp.then((y) => y.ensure()).catchError((_) => null));
|
||||||
Future<String> mpvRawOptions() async {
|
|
||||||
const subs = 'sub-langs=%${ytdlSubLangs.length}%$ytdlSubLangs';
|
|
||||||
try {
|
|
||||||
final cookies = (await ytDlp).cookies.mpvRawOptions();
|
|
||||||
return cookies.isEmpty ? subs : '$subs,$cookies';
|
|
||||||
} catch (_) {
|
|
||||||
return subs;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
final _probes = <String, ({DateTime at, DateTime until, Future<LinkProbe> f})>{};
|
final _probes = <String, ({DateTime at, DateTime until, Future<LinkProbe> f})>{};
|
||||||
static const _probeTtl = Duration(minutes: 10);
|
static const _probeTtl = Duration(minutes: 10);
|
||||||
@@ -54,6 +40,7 @@ class AppYtDlp {
|
|||||||
/// is probed again before that runs out). [fresh] probes again: the
|
/// is probed again before that runs out). [fresh] probes again: the
|
||||||
/// remembered result stays until the new one is in, and stays when the
|
/// remembered result stays until the new one is in, and stays when the
|
||||||
/// new one fails.
|
/// new one fails.
|
||||||
|
@override
|
||||||
Future<LinkProbe> probe(String url, {Duration keep = _probeTtl, bool fresh = false}) {
|
Future<LinkProbe> probe(String url, {Duration keep = _probeTtl, bool fresh = false}) {
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
_probes.removeWhere((_, v) => now.isAfter(v.until));
|
_probes.removeWhere((_, v) => now.isAfter(v.until));
|
||||||
@@ -80,6 +67,7 @@ class AppYtDlp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// [url] no longer needs keeping longer: back to the usual 10 minutes.
|
/// [url] no longer needs keeping longer: back to the usual 10 minutes.
|
||||||
|
@override
|
||||||
void release(String url) {
|
void release(String url) {
|
||||||
final hit = _probes[url];
|
final hit = _probes[url];
|
||||||
if (hit == null) return;
|
if (hit == null) return;
|
||||||
|
|||||||
@@ -7,31 +7,36 @@ import 'package:flutter/foundation.dart';
|
|||||||
import 'package:signals/signals_flutter.dart';
|
import 'package:signals/signals_flutter.dart';
|
||||||
import 'package:sync_relay_core/core.dart';
|
import 'package:sync_relay_core/core.dart';
|
||||||
|
|
||||||
import '../files/name_parser.dart';
|
|
||||||
import '../files/thumbnails.dart';
|
import '../files/thumbnails.dart';
|
||||||
import '../jellyfin/accounts.dart';
|
import '../jellyfin/accounts.dart';
|
||||||
import '../jellyfin/format.dart' show pickFor;
|
import '../jellyfin/format.dart' show pickFor;
|
||||||
import '../jellyfin/session_media.dart';
|
import '../jellyfin/session_media.dart';
|
||||||
|
import '../library/media_refs.dart';
|
||||||
import '../screens/home/picked_media.dart';
|
import '../screens/home/picked_media.dart';
|
||||||
|
import '../session/app_events.dart' show AddedToUpNext;
|
||||||
import '../session/media_art.dart';
|
import '../session/media_art.dart';
|
||||||
import '../session/session_coordinator.dart';
|
import '../session/session_coordinator.dart';
|
||||||
import '../state/settings.dart';
|
|
||||||
import '../sync/avatar_cache.dart';
|
import '../sync/avatar_cache.dart';
|
||||||
import 'queue_logic.dart';
|
|
||||||
|
|
||||||
// Up next on this client (see ARCHITECTURE.md "Up next"): the server's
|
// What's next on this app (see ARCHITECTURE.md "What's next" and "Up
|
||||||
// queue as signals, adding to it (probed, with a picture), the end-of-media
|
// next"): the server's queue as signals (Up next and its continuation),
|
||||||
// countdown for its first item, putting an item up when this app is the
|
// adding to it (probed, with a picture), the continuation of what this
|
||||||
// one that can, and keeping that item ready.
|
// 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 Up next item. [secondsLeft] null:
|
/// The end-of-media card for the first item (Up next's, else the
|
||||||
/// no countdown here (auto-advance off, cancelled, or another app puts
|
/// continuation). [secondsLeft]: the server's countdown (`state.advance`);
|
||||||
/// it up), the card offers Play now only.
|
/// 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 {
|
class UpNextCountdown {
|
||||||
final int mediaId;
|
final int mediaId;
|
||||||
final QueueItem item;
|
final QueueItem item;
|
||||||
final int? secondsLeft;
|
final int? secondsLeft;
|
||||||
const UpNextCountdown({required this.mediaId, required this.item, this.secondsLeft});
|
final String? waitingFor;
|
||||||
|
const UpNextCountdown({required this.mediaId, required this.item, this.secondsLeft, this.waitingFor});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The first item's readiness on this app.
|
/// The first item's readiness on this app.
|
||||||
@@ -42,24 +47,36 @@ class QueueController {
|
|||||||
QueueController(this.coord);
|
QueueController(this.coord);
|
||||||
|
|
||||||
/// Queued items' pictures by hash, from `/i/`.
|
/// Queued items' pictures by hash, from `/i/`.
|
||||||
late final PictureCache thumbs = PictureCache(coord.sync,
|
late final PictureCache thumbs = PictureCache(coord.connection.client,
|
||||||
route: 'i', keep: RelayConstants.maxQueueItems + 8, live: (s) => [for (final q in s.queue) q.thumb]);
|
route: 'i', keep: RelayConstants.maxQueueItems + 8, live: (s) => [for (final q in s.queue) q.thumb]);
|
||||||
|
|
||||||
/// Up next, in order (empty when nothing is queued or not in a server).
|
/// Up next, in order, without the continuation (empty when nothing is
|
||||||
late final Computed<List<QueueItem>> items =
|
/// queued or not in a server).
|
||||||
computed(() => coord.serverState.value?.queue ?? const <QueueItem>[]);
|
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);
|
final countdown = signal<UpNextCountdown?>(null);
|
||||||
|
|
||||||
/// The first item's id and how ready it is here (null: nothing to get
|
/// The first item's id and how ready it is here (null: nothing to get
|
||||||
/// ready on this app).
|
/// ready on this app).
|
||||||
final warm = signal<({String id, WarmState state})?>(null);
|
final warm = signal<({String id, WarmState state})?>(null);
|
||||||
|
|
||||||
/// The natural continuation of what plays: the next episodes of the
|
/// The next episodes of the Jellyfin series playing, on an app that
|
||||||
/// Jellyfin series (not queued; shown greyed under Up next).
|
/// 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 []);
|
final continuation = signal<List<EpisodeLink>>(const []);
|
||||||
|
|
||||||
/// The mediaId whose continuation was cleared here.
|
/// The mediaId whose continuation was cleared (by anyone: `cancel`).
|
||||||
final _hiddenFor = signal(-1);
|
final _hiddenFor = signal(-1);
|
||||||
|
|
||||||
/// The queue popover is open: the Up next button holds the controls up.
|
/// The queue popover is open: the Up next button holds the controls up.
|
||||||
@@ -69,31 +86,43 @@ class QueueController {
|
|||||||
// here, a file here). Never leaves this app.
|
// here, a file here). Never leaves this app.
|
||||||
final Map<String, ({PickedMedia pick, DateTime at})> _local = {};
|
final Map<String, ({PickedMedia pick, DateTime at})> _local = {};
|
||||||
final Map<String, DateTime> _performing = {};
|
final Map<String, DateTime> _performing = {};
|
||||||
final Set<String> _skipped = {};
|
|
||||||
int _cancelledFor = -1;
|
|
||||||
Timer? _timer;
|
Timer? _timer;
|
||||||
bool _attached = false;
|
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 = '';
|
String _continuationFor = '';
|
||||||
final _random = Random.secure();
|
final _random = Random.secure();
|
||||||
|
|
||||||
String get _me => coord.sync.memberId;
|
String get _me => coord.connection.memberId;
|
||||||
|
|
||||||
void attach() {
|
void attach() {
|
||||||
if (_attached) return;
|
if (_attached) return;
|
||||||
_attached = true;
|
_attached = true;
|
||||||
coord.sync.states.listen(_onState);
|
coord.connection.states.listen(_onState);
|
||||||
coord.sync.feeds.listen(_onFeed);
|
// `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(() {
|
effect(() {
|
||||||
final next = coord.nextEpisode.value;
|
final next = coord.nextEpisode.value;
|
||||||
final plan = coord.jellyfinPlan.value;
|
final plan = coord.jellyfinPlan.value;
|
||||||
unawaited(_loadContinuation(next, plan));
|
unawaited(_loadContinuation(next, plan));
|
||||||
});
|
});
|
||||||
// The continuation and the state decide what to get ready; the media
|
// What continues this media becomes known (the next episode found, a
|
||||||
// playing having started (or sitting out) lets the preload go.
|
// 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(() {
|
effect(() {
|
||||||
shownContinuation.value;
|
|
||||||
coord.settledMediaId.value;
|
coord.settledMediaId.value;
|
||||||
coord.selfOut.value;
|
coord.selfAway.value;
|
||||||
final r = coord.serverState.value;
|
final r = coord.serverState.value;
|
||||||
untracked(() => _warm(r));
|
untracked(() => _warm(r));
|
||||||
});
|
});
|
||||||
@@ -107,6 +136,7 @@ class QueueController {
|
|||||||
Future<String?> add(PickedMedia pick, {bool next = false}) async {
|
Future<String?> add(PickedMedia pick, {bool next = false}) async {
|
||||||
coord.markInput();
|
coord.markInput();
|
||||||
if (coord.serverState.peek() == null) return 'not in a server';
|
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) {
|
if (items.peek().length >= RelayConstants.maxQueueItems) {
|
||||||
return 'Up next is full (${RelayConstants.maxQueueItems} items)';
|
return 'Up next is full (${RelayConstants.maxQueueItems} items)';
|
||||||
}
|
}
|
||||||
@@ -121,8 +151,8 @@ class QueueController {
|
|||||||
final item = built.item;
|
final item = built.item;
|
||||||
if (item.key.isNotEmpty) _local[item.key] = (pick: pick, at: DateTime.now());
|
if (item.key.isNotEmpty) _local[item.key] = (pick: pick, at: DateTime.now());
|
||||||
final thumb = built.thumb;
|
final thumb = built.thumb;
|
||||||
coord.sync.sendQueue(QueueIntent.add(item, next: next, thumb: thumb == null ? '' : base64Encode(thumb)));
|
coord.connection.sendQueue(QueueIntent.add(item, next: next, thumb: thumb == null ? '' : base64Encode(thumb)));
|
||||||
coord.showToast('Added ${item.title} to Up next');
|
coord.notify(AddedToUpNext(item.title));
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,15 +193,22 @@ class QueueController {
|
|||||||
if (bytes.length <= RelayConstants.maxPosterBytes) thumb = bytes;
|
if (bytes.length <= RelayConstants.maxPosterBytes) thumb = bytes;
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
final ep = [
|
// 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.season != null) 'S${parsed.season}',
|
||||||
if (parsed.episode != null) 'E${parsed.episode}',
|
if (parsed.episode != null) 'E${parsed.episode}',
|
||||||
|
if (parsed.episode == null) ?parsed.extra,
|
||||||
].join();
|
].join();
|
||||||
|
final title = what != null && what.title.isNotEmpty ? what.title : parsed.series.trim();
|
||||||
return (
|
return (
|
||||||
item: QueueItem(
|
item: QueueItem(
|
||||||
media: HelloMedia(name: name, size: await file.length(), duration: 0),
|
media: HelloMedia(name: name, size: await file.length(), duration: 0, ref: what),
|
||||||
how: QueueHow.file,
|
how: QueueHow.file,
|
||||||
title: parsed.series.trim().isEmpty ? name : parsed.series.trim(),
|
title: title.isEmpty ? name : title,
|
||||||
sub: ep,
|
sub: ep,
|
||||||
duration: (shot?.duration.inMilliseconds ?? 0) / 1000,
|
duration: (shot?.duration.inMilliseconds ?? 0) / 1000,
|
||||||
key: _newKey(),
|
key: _newKey(),
|
||||||
@@ -180,13 +217,13 @@ class QueueController {
|
|||||||
);
|
);
|
||||||
case LinkMedia(:final url, :final anyway):
|
case LinkMedia(:final url, :final anyway):
|
||||||
// Alone, a link plays direct: there is nobody to pass it on to.
|
// Alone, a link plays direct: there is nobody to pass it on to.
|
||||||
final relay = coord.solo ? LinkRelay.direct : pick.relay;
|
final relay = coord.alone.peek() ? LinkRelay.direct : pick.relay;
|
||||||
final linked = await mediaForLink(coord.jellyfin, url);
|
final linked = await mediaForLink(coord.jellyfin, url);
|
||||||
final ref = linked.jellyfin;
|
final ref = linked.jellyfin;
|
||||||
if (ref != null) {
|
if (ref != null) {
|
||||||
return (
|
return (
|
||||||
item: QueueItem(
|
item: QueueItem(
|
||||||
media: HelloMedia(name: linked.name, size: 0, duration: 0, jellyfin: ref),
|
media: linked,
|
||||||
how: QueueHow.jellyfin,
|
how: QueueHow.jellyfin,
|
||||||
title: ref.title.isEmpty ? linked.name : ref.title,
|
title: ref.title.isEmpty ? linked.name : ref.title,
|
||||||
sub: ref.subtitle ?? '',
|
sub: ref.subtitle ?? '',
|
||||||
@@ -233,7 +270,8 @@ class QueueController {
|
|||||||
size: 0,
|
size: 0,
|
||||||
duration: 0,
|
duration: 0,
|
||||||
url: how == QueueHow.through ? null : url,
|
url: how == QueueHow.through ? null : url,
|
||||||
relay: how == QueueHow.server ? const MediaRelay(fetch: true) : null),
|
relay: how == QueueHow.server ? const MediaRelay(fetch: true) : null,
|
||||||
|
ref: linkMediaRef(title)),
|
||||||
how: how,
|
how: how,
|
||||||
title: title,
|
title: title,
|
||||||
sub: site,
|
sub: site,
|
||||||
@@ -288,12 +326,12 @@ class QueueController {
|
|||||||
|
|
||||||
void move(String id, int to) {
|
void move(String id, int to) {
|
||||||
coord.markInput();
|
coord.markInput();
|
||||||
coord.sync.sendQueue(QueueIntent.move(id, to));
|
coord.connection.sendQueue(QueueIntent.move(id, to));
|
||||||
}
|
}
|
||||||
|
|
||||||
void remove(String id) {
|
void remove(String id) {
|
||||||
coord.markInput();
|
coord.markInput();
|
||||||
coord.sync.sendQueue(QueueIntent.remove(id));
|
coord.connection.sendQueue(QueueIntent.remove(id));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Plays [id] now: this app puts it up when it is the one that can,
|
/// Plays [id] now: this app puts it up when it is the one that can,
|
||||||
@@ -306,75 +344,61 @@ class QueueController {
|
|||||||
if (queuePerformer(item, r.members).id == _me) {
|
if (queuePerformer(item, r.members).id == _me) {
|
||||||
_perform(item);
|
_perform(item);
|
||||||
} else {
|
} else {
|
||||||
coord.sync.sendQueue(QueueIntent.play(id));
|
coord.connection.sendQueue(QueueIntent.play(id));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Plays the first Up next item now.
|
/// Plays what comes next now (Up next's first item, else the
|
||||||
|
/// continuation).
|
||||||
void skip() {
|
void skip() {
|
||||||
final first = items.peek().firstOrNull;
|
final first = head.peek();
|
||||||
if (first != null) playNow(first.id);
|
if (first != null) playNow(first.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Cancel on the end-of-media card: no countdown here for this media.
|
/// Cancel on the end card: the countdown stops for everyone, and the
|
||||||
|
/// continuation goes (`cancel`).
|
||||||
void cancelCountdown() {
|
void cancelCountdown() {
|
||||||
final c = countdown.peek();
|
coord.markInput();
|
||||||
_timer?.cancel();
|
coord.connection.sendQueue(const QueueIntent.cancel());
|
||||||
_timer = null;
|
|
||||||
if (c == null) return;
|
|
||||||
_cancelledFor = c.mediaId;
|
|
||||||
countdown.value = UpNextCountdown(mediaId: c.mediaId, item: c.item);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// "Clear": hides the continuation of what plays now (and its next
|
/// "Clear" under "Continues with": the continuation of what plays goes
|
||||||
/// episode does not come up by itself).
|
/// for everyone (`cancel`) and does not come back for this media.
|
||||||
void clearContinuation() {
|
void clearContinuation() {
|
||||||
final id = coord.serverState.peek()?.mediaId;
|
final id = coord.serverState.peek()?.mediaId;
|
||||||
if (id != null) _hiddenFor.value = id;
|
if (id != null) _hiddenFor.value = id;
|
||||||
if (coord.autoAdvance.peek() != null) coord.cancelAutoAdvance();
|
cancelCountdown();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The continuation applies to [mediaId]: nothing is queued and it was
|
/// The further episodes of [continuation] (after the one that continues
|
||||||
/// not cleared.
|
/// what plays), unless it was cleared for the media playing.
|
||||||
bool continuationShown(int mediaId) =>
|
|
||||||
(coord.serverState.peek()?.queue.isEmpty ?? true) && _hiddenFor.peek() != mediaId;
|
|
||||||
|
|
||||||
/// [continuation] unless cleared for the media playing.
|
|
||||||
late final Computed<List<EpisodeLink>> shownContinuation = computed(() {
|
late final Computed<List<EpisodeLink>> shownContinuation = computed(() {
|
||||||
final id = coord.serverState.value?.mediaId;
|
final id = coord.serverState.value?.mediaId;
|
||||||
return id != null && _hiddenFor.value == id ? const <EpisodeLink>[] : continuation.value;
|
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
|
// --------------------------------------------------------- per state
|
||||||
|
|
||||||
void _onState(StateBroadcast r) {
|
void _onState(StateBroadcast r) {
|
||||||
_lastState = 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);
|
_prune(r);
|
||||||
final queue = r.queue;
|
// 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;
|
final play = r.queuePlay;
|
||||||
if (play != null) {
|
if (play != null) {
|
||||||
final item = queue.where((q) => q.id == play).firstOrNull;
|
final item = r.queue.where((q) => q.id == play).firstOrNull;
|
||||||
if (item != null) _bringUp(item, r);
|
if (item != null && queuePerformer(item, r.members).id == _me) _perform(item);
|
||||||
}
|
|
||||||
final finished = r.transport == 'finished';
|
|
||||||
if (queue.isNotEmpty && r.media == null) {
|
|
||||||
// Nothing up yet: the first item goes up at once.
|
|
||||||
_bringUp(queue.first, r);
|
|
||||||
} else if (finished && queue.isNotEmpty) {
|
|
||||||
_atEnd(r);
|
|
||||||
} else {
|
|
||||||
if (countdown.peek() != null) _stopCountdown();
|
|
||||||
if (!finished) _cancelledFor = -1;
|
|
||||||
}
|
}
|
||||||
|
_syncCountdown(r);
|
||||||
|
_continue(r);
|
||||||
_warm(r);
|
_warm(r);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onFeed(FeedMessage f) {
|
|
||||||
if (f.action == 'queue-skip') {
|
|
||||||
coord.showToast('Skipped ${f.target ?? 'an item'}${f.reason.isEmpty ? '' : ': ${f.reason}'}');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Forgets what no longer matters: performing marks after a minute (a
|
/// Forgets what no longer matters: performing marks after a minute (a
|
||||||
/// swap that never landed may be tried again), local picks no longer
|
/// swap that never landed may be tried again), local picks no longer
|
||||||
/// queued.
|
/// queued.
|
||||||
@@ -382,75 +406,133 @@ class QueueController {
|
|||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
_performing.removeWhere((_, at) => now.difference(at) > const Duration(minutes: 1));
|
_performing.removeWhere((_, at) => now.difference(at) > const Duration(minutes: 1));
|
||||||
final keys = {for (final q in r.queue) q.key};
|
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));
|
_local.removeWhere((k, v) => !keys.contains(k) && now.difference(v.at) > const Duration(minutes: 1));
|
||||||
final ids = {for (final q in r.queue) q.id};
|
}
|
||||||
_skipped.removeWhere((id) => !ids.contains(id));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// [item] should go up now: by this app when it is the one that can;
|
/// The end card from the server's state: the media ended with something
|
||||||
/// nobody can: the lowest member takes it off with the reason.
|
/// next; the seconds of `state.advance` move on with a 1 s timer.
|
||||||
void _bringUp(QueueItem item, StateBroadcast r) {
|
void _syncCountdown(StateBroadcast r) {
|
||||||
final perf = queuePerformer(item, r.members);
|
|
||||||
if (perf.id == null) {
|
|
||||||
final why = perf.why;
|
|
||||||
if (why != null && queueJanitor(r.members) == _me && _skipped.add(item.id)) {
|
|
||||||
coord.sync.sendQueue(QueueIntent.remove(item.id, why: why));
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (perf.id == _me) _perform(item);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The media finished with Up next waiting: the card, and with
|
|
||||||
/// auto-advance on the countdown, for its first item.
|
|
||||||
void _atEnd(StateBroadcast r) {
|
|
||||||
// Up next goes before the next episode, even one already counting.
|
|
||||||
if (coord.autoAdvance.peek() != null) coord.cancelAutoAdvance();
|
|
||||||
final head = r.queue.first;
|
|
||||||
final perf = queuePerformer(head, r.members);
|
|
||||||
if (perf.id == null && perf.why != null) {
|
|
||||||
// Taken off by [_bringUp]'s rule; the next state shows the next one.
|
|
||||||
_bringUp(head, r);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
final c = countdown.peek();
|
|
||||||
if (c != null && c.mediaId == r.mediaId) {
|
|
||||||
if (c.item.id != head.id || c.item.thumb != head.thumb) {
|
|
||||||
countdown.value = UpNextCountdown(mediaId: c.mediaId, item: head, secondsLeft: c.secondsLeft);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
final auto = AppSettings.instance.autoAdvance.peek() && _cancelledFor != r.mediaId;
|
|
||||||
countdown.value = UpNextCountdown(mediaId: r.mediaId, item: head, secondsLeft: auto ? autoAdvanceSecs : null);
|
|
||||||
_timer?.cancel();
|
|
||||||
_timer = auto ? Timer.periodic(const Duration(seconds: 1), (_) => _tick()) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
void _tick() {
|
|
||||||
final c = countdown.peek();
|
|
||||||
final r = coord.serverState.peek();
|
|
||||||
final left = c?.secondsLeft;
|
|
||||||
if (c == null || left == null || r == null || r.mediaId != c.mediaId || r.transport != 'finished') {
|
|
||||||
_stopCountdown();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (left > 1) {
|
|
||||||
countdown.value = UpNextCountdown(mediaId: c.mediaId, item: c.item, secondsLeft: left - 1);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
_timer?.cancel();
|
|
||||||
_timer = null;
|
|
||||||
_cancelledFor = c.mediaId;
|
|
||||||
countdown.value = UpNextCountdown(mediaId: c.mediaId, item: c.item);
|
|
||||||
// Decided on the latest state: only the app that can puts it up.
|
|
||||||
final head = r.queue.firstOrNull;
|
final head = r.queue.firstOrNull;
|
||||||
if (head != null && queuePerformer(head, r.members).id == _me) _perform(head);
|
if (r.transport != 'finished' || head == null || r.media == null) {
|
||||||
}
|
|
||||||
|
|
||||||
void _stopCountdown() {
|
|
||||||
_timer?.cancel();
|
_timer?.cancel();
|
||||||
_timer = null;
|
_timer = null;
|
||||||
countdown.value = 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).
|
/// Puts [item] up from here (once per item while it is in flight).
|
||||||
@@ -467,7 +549,7 @@ class QueueController {
|
|||||||
}
|
}
|
||||||
if (error != null) {
|
if (error != null) {
|
||||||
debugPrint('queue: ${item.id} cannot go up: $error');
|
debugPrint('queue: ${item.id} cannot go up: $error');
|
||||||
coord.sync.sendQueue(QueueIntent.remove(item.id, why: "couldn't play it ($error)"));
|
coord.connection.sendQueue(QueueIntent.remove(item.id, why: "couldn't play it ($error)"));
|
||||||
}
|
}
|
||||||
}());
|
}());
|
||||||
}
|
}
|
||||||
@@ -496,7 +578,7 @@ class QueueController {
|
|||||||
if (local == null) return "it isn't on this computer any more";
|
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
|
// Only this app can pass it on, and a member sitting out has
|
||||||
// nothing open: back first.
|
// nothing open: back first.
|
||||||
if (coord.selfOut.peek()) coord.joinBack();
|
if (coord.selfAway.peek()) coord.joinBack();
|
||||||
return coord.swapPicked(local.pick, queueId: item.id);
|
return coord.swapPicked(local.pick, queueId: item.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -527,14 +609,13 @@ class QueueController {
|
|||||||
bool _ownAccount(String serverId) =>
|
bool _ownAccount(String serverId) =>
|
||||||
coord.jellyfin.account(serverId) != null && coord.jellyfin.statusOf(serverId) != JellyfinAccountStatus.expired;
|
coord.jellyfin.account(serverId) != null && coord.jellyfin.statusOf(serverId) != JellyfinAccountStatus.expired;
|
||||||
|
|
||||||
/// What this app gets ready: Up next's first item, else (Up next empty)
|
/// What this app gets ready: the first item of the queue (Up next's,
|
||||||
/// the first continuation episode (only here when this app plays the
|
/// else the continuation). Every app does, each its own copy (see
|
||||||
/// series with its own account). Every app does, each its own copy (see
|
|
||||||
/// [_WarmTarget]); while sitting out only the probe of a link this app
|
/// [_WarmTarget]); while sitting out only the probe of a link this app
|
||||||
/// puts up (its swap reads it). Null: nothing.
|
/// puts up (its swap reads it). Null: nothing.
|
||||||
_WarmTarget? _warmTarget(StateBroadcast? r) {
|
_WarmTarget? _warmTarget(StateBroadcast? r) {
|
||||||
final t = _warmTargetHere(r);
|
final t = _warmTargetHere(r);
|
||||||
if (t == null || !coord.selfOut.peek()) return t;
|
if (t == null || !coord.selfAway.peek()) return t;
|
||||||
return t.probe == null ? null : _WarmTarget(t.id, probe: t.probe);
|
return t.probe == null ? null : _WarmTarget(t.id, probe: t.probe);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -547,7 +628,7 @@ class QueueController {
|
|||||||
// The server's warm relay of the item, once bytes can flow: what
|
// The server's warm relay of the item, once bytes can flow: what
|
||||||
// everyone without their own copy preloads.
|
// everyone without their own copy preloads.
|
||||||
final relay = head.media.relay;
|
final relay = head.media.relay;
|
||||||
final warmUrl = relay != null && relay.live ? coord.warmRelayUrl(head, relay) : null;
|
final warmUrl = relay != null && relay.live ? coord.media.warmRelayUrl(head, relay) : null;
|
||||||
switch (head.how) {
|
switch (head.how) {
|
||||||
case QueueHow.jellyfin:
|
case QueueHow.jellyfin:
|
||||||
final ref = head.media.jellyfin;
|
final ref = head.media.jellyfin;
|
||||||
@@ -576,9 +657,7 @@ class QueueController {
|
|||||||
return performer ? _WarmTarget(head.id) : null;
|
return performer ? _WarmTarget(head.id) : null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
final next = shownContinuation.peek().firstOrNull;
|
return null;
|
||||||
if (next == null) return null;
|
|
||||||
return _WarmTarget('next:${next.item.id}', ref: mediaForPick(next.pick).jellyfin);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Keeps the one item that plays next ready on this app, so a skip or
|
/// Keeps the one item that plays next ready on this app, so a skip or
|
||||||
@@ -586,7 +665,7 @@ class QueueController {
|
|||||||
/// puts it up (`AppYtDlp.probe`, kept while it stays first), a Jellyfin
|
/// puts it up (`AppYtDlp.probe`, kept while it stays first), a Jellyfin
|
||||||
/// item resolved with this app's account
|
/// item resolved with this app's account
|
||||||
/// ([SessionCoordinator.warmJellyfin]), and then opened in the hidden
|
/// ([SessionCoordinator.warmJellyfin]), and then opened in the hidden
|
||||||
/// preload player ([SessionCoordinator.preloadMedia]) once the media
|
/// preload player ([MediaPipeline.prepare]) once the media
|
||||||
/// playing has started. Done as soon as it is first, again before that
|
/// playing has started. Done as soon as it is first, again before that
|
||||||
/// runs out, dropped when it is no longer first.
|
/// runs out, dropped when it is no longer first.
|
||||||
void _warm(StateBroadcast? r) {
|
void _warm(StateBroadcast? r) {
|
||||||
@@ -610,18 +689,21 @@ class QueueController {
|
|||||||
|
|
||||||
// The warm relay this app feeds (`<queue id>|<token>`), started once.
|
// The warm relay this app feeds (`<queue id>|<token>`), started once.
|
||||||
String _fedWarm = '';
|
String _fedWarm = '';
|
||||||
|
// The link whose warm reader is being set up: its preload waits for it
|
||||||
|
// (it then reads that reader, not the site).
|
||||||
|
String? _feedingLink;
|
||||||
|
|
||||||
/// Feeds the server's warm relay of Up next's first item from here when
|
/// 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
|
/// 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
|
/// Jellyfin item this app puts up with its own account when the server
|
||||||
/// asks for a source; see [SessionCoordinator.feedWarm]. 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
|
/// drops the relay (and with it the source) once the item is no longer
|
||||||
/// first.
|
/// first.
|
||||||
void _feedWarm(StateBroadcast? r) {
|
void _feedWarm(StateBroadcast? r) {
|
||||||
final head = r?.queue.firstOrNull;
|
final head = r?.queue.firstOrNull;
|
||||||
final relay = head?.media.relay;
|
final relay = head?.media.relay;
|
||||||
if (r == null || head == null || relay == null || relay.fetch) return;
|
if (r == null || head == null || relay == null || relay.fetch) return;
|
||||||
final key = '${head.id}|${coord.sync.token}';
|
final key = '${head.id}|${coord.connection.token}';
|
||||||
if (key == _fedWarm) return;
|
if (key == _fedWarm) return;
|
||||||
switch (head.how) {
|
switch (head.how) {
|
||||||
case QueueHow.through:
|
case QueueHow.through:
|
||||||
@@ -630,18 +712,25 @@ class QueueController {
|
|||||||
final pick = _local[head.key]?.pick;
|
final pick = _local[head.key]?.pick;
|
||||||
if (pick is FileMedia) {
|
if (pick is FileMedia) {
|
||||||
_fedWarm = key;
|
_fedWarm = key;
|
||||||
coord.feedWarm(head.id, RelayMedia.single(FileRelayBytes(File(pick.path))));
|
coord.media.feeder.feedWarm(head.id, RelayMedia.single(FileRelayBytes(File(pick.path))));
|
||||||
} else if (pick is LinkMedia) {
|
} else if (pick is LinkMedia) {
|
||||||
_fedWarm = key;
|
_fedWarm = key;
|
||||||
|
_feedingLink = pick.url;
|
||||||
unawaited(() async {
|
unawaited(() async {
|
||||||
try {
|
try {
|
||||||
// The probe [_resolveWarm] keeps: no second yt-dlp run.
|
// 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 probe = await coord.ytDlp.probe(pick.url, keep: _warmKeep).timeout(const Duration(seconds: 60));
|
||||||
final y = await coord.ytDlp.ytDlp;
|
final y = await coord.ytDlp.ytDlp;
|
||||||
if (_fedWarm != key) return;
|
if (_fedWarm != key) return;
|
||||||
coord.feedWarm(head.id, LinkRelayMedia(Uri.parse(pick.url), ytDlp: y, probe: probe));
|
// Read once for the preload and the warm relay.
|
||||||
|
await coord.media.feedWarmLink(head.id, pick.url, probe, y);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('queue: feeding ${head.id}: $e');
|
debugPrint('queue: feeding ${head.id}: $e');
|
||||||
|
} finally {
|
||||||
|
if (_feedingLink == pick.url) {
|
||||||
|
_feedingLink = null;
|
||||||
|
_syncPreload(coord.serverState.peek());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}());
|
}());
|
||||||
}
|
}
|
||||||
@@ -653,7 +742,7 @@ class QueueController {
|
|||||||
final w = coord.warmJellyfin;
|
final w = coord.warmJellyfin;
|
||||||
if (w == null || !sameJellyfinItem(w.ref, ref)) return;
|
if (w == null || !sameJellyfinItem(w.ref, ref)) return;
|
||||||
_fedWarm = key;
|
_fedWarm = key;
|
||||||
coord.feedWarm(
|
coord.media.feeder.feedWarm(
|
||||||
head.id, RelayMedia.single(HttpRelayBytes(Uri.parse(w.plan.url), headers: w.plan.headers)));
|
head.id, RelayMedia.single(HttpRelayBytes(Uri.parse(w.plan.url), headers: w.plan.headers)));
|
||||||
case QueueHow.direct:
|
case QueueHow.direct:
|
||||||
case QueueHow.server:
|
case QueueHow.server:
|
||||||
@@ -707,7 +796,7 @@ class QueueController {
|
|||||||
// A refresh opens a link again (a page's stream URLs expire); the
|
// A refresh opens a link again (a page's stream URLs expire); the
|
||||||
// same Jellyfin stream stays as it is.
|
// same Jellyfin stream stays as it is.
|
||||||
_preloadFresh = fresh && ok && t.ref == null && open != null;
|
_preloadFresh = fresh && ok && t.ref == null && open != null;
|
||||||
final waits = ok && open != null && coord.playback.preloadEnabled;
|
final waits = ok && open != null && coord.media.preloadEnabled;
|
||||||
warm.value = (id: t.id, state: !ok ? WarmState.failed : (waits ? WarmState.warming : WarmState.ready));
|
warm.value = (id: t.id, state: !ok ? WarmState.failed : (waits ? WarmState.warming : WarmState.ready));
|
||||||
_syncPreload(coord.serverState.peek());
|
_syncPreload(coord.serverState.peek());
|
||||||
_warmTimer = Timer(ok ? _warmRefresh : _warmRetry, () => _resolveWarm(t, fresh: true));
|
_warmTimer = Timer(ok ? _warmRefresh : _warmRetry, () => _resolveWarm(t, fresh: true));
|
||||||
@@ -724,28 +813,29 @@ class QueueController {
|
|||||||
final seen = _lastState;
|
final seen = _lastState;
|
||||||
if (r == null || (seen != null && seen.mediaId > r.mediaId)) r = seen;
|
if (r == null || (seen != null && seen.mediaId > r.mediaId)) r = seen;
|
||||||
if (r == null) return;
|
if (r == null) return;
|
||||||
final out = coord.selfOut.peek();
|
final out = coord.selfAway.peek();
|
||||||
if (!out && r.media != null && coord.settledMediaId.peek() != r.mediaId) return;
|
if (!out && r.media != null && coord.settledMediaId.peek() != r.mediaId) return;
|
||||||
final want = out ? null : _preloadWant;
|
final want = out ? null : _preloadWant;
|
||||||
final playback = coord.playback;
|
final media = coord.media;
|
||||||
if (want == null) {
|
if (want == null) {
|
||||||
_preloadKey = '';
|
_preloadKey = '';
|
||||||
_preloadGen++;
|
_preloadGen++;
|
||||||
if (playback.preloadUrl != null) unawaited(playback.dropPreload());
|
if (media.preloadUrl != null) unawaited(media.dropPrepared());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!playback.preloadEnabled) return;
|
if (!media.preloadEnabled) return;
|
||||||
|
if (want.url == _feedingLink) return;
|
||||||
final key = '${want.url}|${want.headers}';
|
final key = '${want.url}|${want.headers}';
|
||||||
final fresh = _preloadFresh;
|
final fresh = _preloadFresh;
|
||||||
// Asked for already: in flight, there, or failed (tried again with the
|
// Asked for already: in flight, there, or failed (tried again with the
|
||||||
// next refresh). One that went away (Reload video) is asked again.
|
// next refresh). One that went away (Reload video) is asked again.
|
||||||
if (!fresh && key == _preloadKey && (_preloadBusy || !_preloadOk || playback.preloadUrl == want.url)) return;
|
if (!fresh && key == _preloadKey && (_preloadBusy || !_preloadOk || media.preloadUrl == want.url)) return;
|
||||||
_preloadFresh = false;
|
_preloadFresh = false;
|
||||||
_preloadKey = key;
|
_preloadKey = key;
|
||||||
_preloadBusy = true;
|
_preloadBusy = true;
|
||||||
final gen = ++_preloadGen;
|
final gen = ++_preloadGen;
|
||||||
final id = warm.peek()?.id;
|
final id = warm.peek()?.id;
|
||||||
unawaited(coord.preloadMedia(want.url, headers: want.headers, fresh: fresh).then((ok) {
|
unawaited(coord.media.prepare(want.url, headers: want.headers, fresh: fresh).then((ok) {
|
||||||
if (gen != _preloadGen) return;
|
if (gen != _preloadGen) return;
|
||||||
_preloadBusy = false;
|
_preloadBusy = false;
|
||||||
_preloadOk = ok;
|
_preloadOk = ok;
|
||||||
|
|||||||
@@ -1,62 +1,15 @@
|
|||||||
import 'package:sync_relay_core/core.dart';
|
import 'package:sync_relay_core/core.dart';
|
||||||
|
|
||||||
import '../jellyfin/session_media.dart' show compareMemberIds;
|
// 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
|
||||||
// Who puts an Up next item up. Pure: only what every client sees in
|
// every app.
|
||||||
// `state` decides, so all clients agree without a wire message (like the
|
|
||||||
// Jellyfin auto-advance swapper).
|
|
||||||
|
|
||||||
/// Who puts [item] up: [id] a member id; null with [why] when nobody here
|
|
||||||
/// can (the item is skipped), null without [why] when nobody is around
|
|
||||||
/// right now (nothing happens).
|
|
||||||
typedef QueuePerformer = ({String? id, String? why});
|
|
||||||
|
|
||||||
/// - A link passed on by its adder, or the adder's file: only the adder
|
|
||||||
/// (their app has the file, the cookies). Gone: skipped.
|
|
||||||
/// - Jellyfin: the adder when it plays that server with its own account,
|
|
||||||
/// else the lowest member id that does (`jf[serverId] == true`, not
|
|
||||||
/// stale, not sitting out). Nobody: skipped.
|
|
||||||
/// - A link everyone opens, or one the server fetches: the adder when
|
|
||||||
/// present (not stale or sitting out), else the lowest such member id.
|
|
||||||
QueuePerformer queuePerformer(QueueItem item, List<MemberView> members) {
|
|
||||||
MemberView? adder;
|
|
||||||
for (final m in members) {
|
|
||||||
if (m.id == item.by) adder = m;
|
|
||||||
}
|
|
||||||
bool active(MemberView m) => !m.stale && !m.out;
|
|
||||||
if (item.how.adderOnly) {
|
|
||||||
if (adder == null) return (id: null, why: '${_name(item)} left');
|
|
||||||
return (id: adder.id, why: null);
|
|
||||||
}
|
|
||||||
final ref = item.media.jellyfin;
|
|
||||||
if (item.how == QueueHow.jellyfin && ref != null) {
|
|
||||||
bool can(MemberView m) => active(m) && m.jf[ref.serverId] == true;
|
|
||||||
if (adder != null && can(adder)) return (id: adder.id, why: null);
|
|
||||||
final id = autoAdvanceLowest(members.where(can));
|
|
||||||
return id == null ? (id: null, why: 'nobody here has that Jellyfin server') : (id: id, why: null);
|
|
||||||
}
|
|
||||||
if (adder != null && active(adder)) return (id: adder.id, why: null);
|
|
||||||
return (id: autoAdvanceLowest(members.where(active)), why: null);
|
|
||||||
}
|
|
||||||
|
|
||||||
String _name(QueueItem item) => item.byName.isEmpty ? 'who added it' : item.byName;
|
|
||||||
|
|
||||||
/// The lowest member id of [members] ("m2" before "m10"), or null.
|
|
||||||
String? autoAdvanceLowest(Iterable<MemberView> members) {
|
|
||||||
final ids = [for (final m in members) m.id]..sort(compareMemberIds);
|
|
||||||
return ids.isEmpty ? null : ids.first;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Who takes an item nobody can put up off the queue (`remove` with why):
|
|
||||||
/// the lowest member id that is not stale.
|
|
||||||
String? queueJanitor(List<MemberView> members) => autoAdvanceLowest(members.where((m) => !m.stale));
|
|
||||||
|
|
||||||
/// How [item] plays, in a few words for its row: "through Foxyz" (you:
|
/// How [item] plays, in a few words for its row: "through Foxyz" (you:
|
||||||
/// "through you"), "server fetches", "everyone fetches", "Jellyfin",
|
/// "through you"), "server fetches", "everyone fetches", "Jellyfin",
|
||||||
/// "Foxyz's file".
|
/// "Foxyz's file".
|
||||||
String queueHowLabel(QueueItem item, {required String selfId}) {
|
String queueHowLabel(QueueItem item, {required String selfId}) {
|
||||||
final mine = item.by == selfId && selfId.isNotEmpty;
|
final mine = item.by == selfId && selfId.isNotEmpty;
|
||||||
final who = mine ? 'you' : _name(item);
|
final who = mine ? 'you' : (item.byName.isEmpty ? 'who added it' : item.byName);
|
||||||
return switch (item.how) {
|
return switch (item.how) {
|
||||||
QueueHow.through => 'through $who',
|
QueueHow.through => 'through $who',
|
||||||
QueueHow.file => mine ? 'your file' : "$who's file",
|
QueueHow.file => mine ? 'your file' : "$who's file",
|
||||||
|
|||||||
@@ -3,23 +3,20 @@ import 'dart:math' as math;
|
|||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:fox_utils/fox_utils.dart' show ClipboardX, DesktopDropX, DropTarget;
|
|
||||||
import 'package:leaf_widgets/leaf_widgets.dart' show LeafPopover, LeafPopoverController;
|
import 'package:leaf_widgets/leaf_widgets.dart' show LeafPopover, LeafPopoverController;
|
||||||
import 'package:signals/signals_flutter.dart';
|
import 'package:signals/signals_flutter.dart';
|
||||||
import 'package:sync_relay_core/core.dart';
|
import 'package:sync_relay_core/core.dart';
|
||||||
|
|
||||||
import '../controls/controls_probe.dart' show ControlsHold;
|
import '../controls/controls_probe.dart' show ControlsHold;
|
||||||
import '../controls/minimal/style.dart';
|
import '../controls/minimal/style.dart';
|
||||||
import '../files/scanner.dart' show FileKind, kindOf;
|
|
||||||
import '../jellyfin/images.dart';
|
import '../jellyfin/images.dart';
|
||||||
import '../jellyfin/session_media.dart' show EpisodeLink;
|
import '../jellyfin/session_media.dart' show EpisodeLink;
|
||||||
import '../screens/home/picked_media.dart';
|
import '../screens/home/picked_media.dart';
|
||||||
import '../session/session_coordinator.dart' show autoAdvanceSecs;
|
|
||||||
import '../shell/avatar.dart';
|
import '../shell/avatar.dart';
|
||||||
import '../shell/handoff.dart' show handoffHolds;
|
import '../shell/handoff.dart' show handoffHolds;
|
||||||
import '../shell/library_link.dart' show LinkPreview, cachedLinkPreview;
|
import '../shell/library_link.dart' show LinkPreview, cachedLinkPreview;
|
||||||
import '../shell/motion.dart';
|
import '../shell/motion.dart';
|
||||||
import '../state/session_state.dart';
|
import '../app/app_graph.dart';
|
||||||
import '../widgets/app_menu.dart' show AppGlass, AppMenuItem, AppMenuStyle, showAppMenu;
|
import '../widgets/app_menu.dart' show AppGlass, AppMenuItem, AppMenuStyle, showAppMenu;
|
||||||
import '../widgets/copy_open.dart';
|
import '../widgets/copy_open.dart';
|
||||||
import '../widgets/text_focus.dart' show KeyboardClaim;
|
import '../widgets/text_focus.dart' show KeyboardClaim;
|
||||||
@@ -40,17 +37,14 @@ enum UpNextLook { standard, minimal }
|
|||||||
/// The next item's picture, "UP NEXT" over its title, a +n count and ⏭
|
/// The next item's picture, "UP NEXT" over its title, a +n count and ⏭
|
||||||
/// (play it now) in the player's top bar, one flat control among its
|
/// (play it now) in the player's top bar, one flat control among its
|
||||||
/// buttons; the title opens the queue, resting on it shows a preview card.
|
/// buttons; the title opens the queue, resting on it shows a preview card.
|
||||||
/// Without Up next, the continuation ("NEXT · S1 E8" over its name, ⏭
|
/// Without Up next, the continuation ("NEXT · S1E8" over its name, ⏭
|
||||||
/// plays it). Nothing when both are empty.
|
/// plays it). Nothing when both are empty.
|
||||||
class UpNextButton extends StatefulWidget {
|
class UpNextButton extends StatefulWidget {
|
||||||
final QueueController queue;
|
final QueueController queue;
|
||||||
/// Plays the continuation's first episode (the next episode).
|
|
||||||
final VoidCallback onPlayNextEpisode;
|
|
||||||
final UpNextLook look;
|
final UpNextLook look;
|
||||||
/// Held while the preview card is up, so the controls stay up with it.
|
/// Held while the preview card is up, so the controls stay up with it.
|
||||||
final ControlsHold? hold;
|
final ControlsHold? hold;
|
||||||
const UpNextButton(
|
const UpNextButton({super.key, required this.queue, required this.look, this.hold});
|
||||||
{super.key, required this.queue, required this.onPlayNextEpisode, required this.look, this.hold});
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<UpNextButton> createState() => _UpNextButtonState();
|
State<UpNextButton> createState() => _UpNextButtonState();
|
||||||
@@ -145,9 +139,9 @@ class _UpNextButtonState extends State<UpNextButton> {
|
|||||||
final thumbRadius = _minimal ? MinimalStyle.radius - 4 : 6.0;
|
final thumbRadius = _minimal ? MinimalStyle.radius - 4 : 6.0;
|
||||||
return SignalBuilder(builder: (context) {
|
return SignalBuilder(builder: (context) {
|
||||||
final items = _q.items.value;
|
final items = _q.items.value;
|
||||||
final cont = _q.shownContinuation.value;
|
final auto = _q.continues.value;
|
||||||
final open = _q.popoverOpen.value;
|
final open = _q.popoverOpen.value;
|
||||||
if (items.isEmpty && cont.isEmpty && !open) {
|
if (items.isEmpty && auto == null && !open) {
|
||||||
// Gone with the preview up: let go of the controls.
|
// Gone with the preview up: let go of the controls.
|
||||||
if (_preview.isShowing) {
|
if (_preview.isShowing) {
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
@@ -157,21 +151,15 @@ class _UpNextButtonState extends State<UpNextButton> {
|
|||||||
return const SizedBox.shrink();
|
return const SizedBox.shrink();
|
||||||
}
|
}
|
||||||
final queued = items.isNotEmpty;
|
final queued = items.isNotEmpty;
|
||||||
final item = items.firstOrNull;
|
final item = items.firstOrNull ?? auto;
|
||||||
final episode = cont.firstOrNull;
|
|
||||||
final more = items.length - 1;
|
final more = items.length - 1;
|
||||||
final String kicker;
|
final String kicker;
|
||||||
final String title;
|
final String title;
|
||||||
final Widget thumb;
|
final Widget thumb;
|
||||||
if (item != null) {
|
if (item != null) {
|
||||||
kicker = 'UP NEXT';
|
kicker = queued ? 'UP NEXT' : (item.sub.isEmpty ? 'NEXT' : 'NEXT · ${item.sub}');
|
||||||
title = item.title;
|
title = item.title;
|
||||||
thumb = QueueThumb(queue: _q, item: item, width: thumbWidth, radius: thumbRadius);
|
thumb = QueueThumb(queue: _q, item: item, width: thumbWidth, radius: thumbRadius);
|
||||||
} else if (episode != null) {
|
|
||||||
final n = episodeNumbers(episode.item);
|
|
||||||
kicker = n == null ? 'NEXT' : 'NEXT · $n';
|
|
||||||
title = episode.item.name;
|
|
||||||
thumb = _EpisodeThumb(queue: _q, episode: episode, width: thumbWidth, radius: thumbRadius);
|
|
||||||
} else {
|
} else {
|
||||||
kicker = 'UP NEXT';
|
kicker = 'UP NEXT';
|
||||||
title = 'Nothing yet';
|
title = 'Nothing yet';
|
||||||
@@ -248,10 +236,8 @@ class _UpNextButtonState extends State<UpNextButton> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
final playTip = queued
|
final playTip = 'Play ${item?.title ?? ''} now ($playNextKeys)';
|
||||||
? 'Play ${item!.title} now ($playNextKeys)'
|
final VoidCallback? play = item == null ? null : _q.skip;
|
||||||
: 'Play ${episode == null ? '' : episode.label} now ($playNextKeys)';
|
|
||||||
final VoidCallback? play = queued ? _q.skip : (episode != null ? widget.onPlayNextEpisode : null);
|
|
||||||
final playButton = _minimal
|
final playButton = _minimal
|
||||||
? MinimalButton(key: const ValueKey('up-next-play'), icon: Icons.skip_next_rounded, tooltip: playTip, onPressed: play)
|
? MinimalButton(key: const ValueKey('up-next-play'), icon: Icons.skip_next_rounded, tooltip: playTip, onPressed: play)
|
||||||
: Tooltip(
|
: Tooltip(
|
||||||
@@ -339,60 +325,35 @@ class UpNextPreview extends StatelessWidget {
|
|||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(_pad),
|
padding: const EdgeInsets.all(_pad),
|
||||||
child: SignalBuilder(builder: (context) {
|
child: SignalBuilder(builder: (context) {
|
||||||
final item = queue.items.value.firstOrNull;
|
final queued = queue.items.value.firstOrNull;
|
||||||
final episode = queue.shownContinuation.value.firstOrNull;
|
final item = queued ?? queue.continues.value;
|
||||||
final warm = queue.warm.value;
|
final warm = queue.warm.value;
|
||||||
const picture = _width - 2 * _pad;
|
const picture = _width - 2 * _pad;
|
||||||
if (item != null) {
|
if (item == null) return const SizedBox.shrink();
|
||||||
final r = queue.coord.serverState.value;
|
final r = queue.coord.serverState.value;
|
||||||
final selfId = queue.coord.sync.memberId;
|
final selfId = queue.coord.connection.memberId;
|
||||||
final more = queue.items.value.length - 1;
|
final more = queue.items.value.length - 1;
|
||||||
final ready = warm?.id == item.id ? warm!.state : null;
|
final ready = warm?.id == item.id ? warm!.state : null;
|
||||||
|
final kicker = queued == null
|
||||||
|
? 'CONTINUES WITH'
|
||||||
|
: more > 0
|
||||||
|
? 'UP NEXT · $more MORE AFTER IT'
|
||||||
|
: 'UP NEXT';
|
||||||
return Column(
|
return Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
QueueThumb(queue: queue, item: item, width: picture, radius: 8),
|
QueueThumb(queue: queue, item: item, width: picture, radius: 8),
|
||||||
const SizedBox(height: 10),
|
const SizedBox(height: 10),
|
||||||
Text(more > 0 ? 'UP NEXT · $more MORE AFTER IT' : 'UP NEXT', style: _kicker),
|
Text(kicker, style: _kicker),
|
||||||
const SizedBox(height: 3),
|
const SizedBox(height: 3),
|
||||||
Text(item.title, maxLines: 2, overflow: TextOverflow.ellipsis, style: _title),
|
Text(item.title, maxLines: 2, overflow: TextOverflow.ellipsis, style: _title),
|
||||||
if (item.sub.isNotEmpty)
|
if (item.sub.isNotEmpty) Text(item.sub, maxLines: 1, overflow: TextOverflow.ellipsis, style: _sub),
|
||||||
Text(item.sub, maxLines: 1, overflow: TextOverflow.ellipsis, style: _sub),
|
|
||||||
const SizedBox(height: 6),
|
const SizedBox(height: 6),
|
||||||
_MetaLine(item: item, members: r?.members ?? const [], selfId: selfId, wrap: true),
|
_MetaLine(item: item, members: r?.members ?? const [], selfId: selfId, wrap: true),
|
||||||
_footer(ready, ready == null ? performerNote(queue, item, r) : null),
|
_footer(ready, ready == null ? performerNote(queue, item, r) : null),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
|
||||||
if (episode == null) return const SizedBox.shrink();
|
|
||||||
final n = episodeNumbers(episode.item);
|
|
||||||
final runtime = episode.pick.runtime;
|
|
||||||
final sub = [episode.item.seriesName ?? '', if (runtime != null) queueClock(runtime.inSeconds.toDouble())]
|
|
||||||
.where((s) => s.isNotEmpty)
|
|
||||||
.join(' · ');
|
|
||||||
final overview = episode.item.overview?.trim() ?? '';
|
|
||||||
final ready = warm?.id == 'next:${episode.item.id}' ? warm!.state : null;
|
|
||||||
return Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
_EpisodeThumb(queue: queue, episode: episode, width: picture, radius: 8),
|
|
||||||
const SizedBox(height: 10),
|
|
||||||
Text(n == null ? 'NEXT EPISODE' : 'NEXT EPISODE · $n', style: _kicker),
|
|
||||||
const SizedBox(height: 3),
|
|
||||||
Text(episode.item.name, maxLines: 2, overflow: TextOverflow.ellipsis, style: _title),
|
|
||||||
if (sub.isNotEmpty) Text(sub, maxLines: 1, overflow: TextOverflow.ellipsis, style: _sub),
|
|
||||||
if (overview.isNotEmpty) ...[
|
|
||||||
const SizedBox(height: 6),
|
|
||||||
Text(overview,
|
|
||||||
maxLines: 3,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: const TextStyle(color: Colors.white54, fontSize: 12, height: 1.35)),
|
|
||||||
],
|
|
||||||
_footer(ready, null),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -450,7 +411,7 @@ class _KeyCap extends StatelessWidget {
|
|||||||
String? performerNote(QueueController queue, QueueItem item, StateBroadcast? r) {
|
String? performerNote(QueueController queue, QueueItem item, StateBroadcast? r) {
|
||||||
if (r == null) return null;
|
if (r == null) return null;
|
||||||
final id = queuePerformer(item, r.members).id;
|
final id = queuePerformer(item, r.members).id;
|
||||||
if (id == null || id == queue.coord.sync.memberId) return null;
|
if (id == null || id == queue.coord.connection.memberId) return null;
|
||||||
final name = r.members.where((m) => m.id == id).firstOrNull?.name;
|
final name = r.members.where((m) => m.id == id).firstOrNull?.name;
|
||||||
return name == null ? null : '$name puts it up';
|
return name == null ? null : '$name puts it up';
|
||||||
}
|
}
|
||||||
@@ -483,8 +444,7 @@ class _EpisodeThumb extends StatelessWidget {
|
|||||||
final QueueController queue;
|
final QueueController queue;
|
||||||
final EpisodeLink episode;
|
final EpisodeLink episode;
|
||||||
final double width;
|
final double width;
|
||||||
final double radius;
|
const _EpisodeThumb({required this.queue, required this.episode, required this.width});
|
||||||
const _EpisodeThumb({required this.queue, required this.episode, required this.width, this.radius = 6});
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -492,7 +452,7 @@ class _EpisodeThumb extends StatelessWidget {
|
|||||||
final client = queue.coord.jellyfin.clientFor(e.pick.serverId);
|
final client = queue.coord.jellyfin.clientFor(e.pick.serverId);
|
||||||
final image = e.item.ownImage(JellyfinImageType.primary) ?? e.item.image(JellyfinImageType.thumb);
|
final image = e.item.ownImage(JellyfinImageType.primary) ?? e.item.image(JellyfinImageType.thumb);
|
||||||
return ClipRRect(
|
return ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(radius),
|
borderRadius: BorderRadius.circular(6),
|
||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
width: width,
|
width: width,
|
||||||
height: width * 9 / 16,
|
height: width * 9 / 16,
|
||||||
@@ -640,23 +600,9 @@ class _QueuePanelState extends State<QueuePanel> {
|
|||||||
return KeyEventResult.ignored;
|
return KeyEventResult.ignored;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<({List<String> files, String? text})> _readClipboard() 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);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Ctrl+V: the clipboard's video files, else the links in its text.
|
/// Ctrl+V: the clipboard's video files, else the links in its text.
|
||||||
Future<void> _paste() async {
|
Future<void> _paste() async {
|
||||||
final clip = await (widget.readClipboard ?? _readClipboard)();
|
final clip = await (widget.readClipboard ?? AppScope.of(context).ports.shell.readPaste)();
|
||||||
final picks = _picksFrom([...clip.files, ...?clip.text?.split(RegExp(r'\s+'))]);
|
final picks = _picksFrom([...clip.files, ...?clip.text?.split(RegExp(r'\s+'))]);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
if (picks.isEmpty) {
|
if (picks.isEmpty) {
|
||||||
@@ -697,7 +643,8 @@ class _QueuePanelState extends State<QueuePanel> {
|
|||||||
final row = rows[i];
|
final row = rows[i];
|
||||||
if (p case LinkMedia(:final url)) {
|
if (p case LinkMedia(:final url)) {
|
||||||
// Its title and picture while it resolves (at once when seen before).
|
// Its title and picture while it resolves (at once when seen before).
|
||||||
unawaited((widget.preview ?? cachedLinkPreview)(url).then((v) {
|
final probe = AppScope.of(context).ports.links.probe;
|
||||||
|
unawaited((widget.preview ?? (u) => cachedLinkPreview(u, probe: probe))(url).then((v) {
|
||||||
if (mounted) setState(() => row.preview = v);
|
if (mounted) setState(() => row.preview = v);
|
||||||
}, onError: (Object _) {}));
|
}, onError: (Object _) {}));
|
||||||
}
|
}
|
||||||
@@ -783,13 +730,13 @@ class _QueuePanelState extends State<QueuePanel> {
|
|||||||
case 'remove':
|
case 'remove':
|
||||||
_q.remove(item.id);
|
_q.remove(item.id);
|
||||||
case 'title':
|
case 'title':
|
||||||
await copyToClipboard(item.title, done: 'Title copied');
|
if (context.mounted) await copyToClipboard(context, item.title, done: 'Title copied');
|
||||||
case 'link':
|
case 'link':
|
||||||
await copyToClipboard(link!, done: 'Link copied');
|
if (context.mounted) await copyToClipboard(context, link!, done: 'Link copied');
|
||||||
case 'open':
|
case 'open':
|
||||||
await openLink(link!);
|
if (context.mounted) await openLink(context, link!);
|
||||||
case 'jellyfin':
|
case 'jellyfin':
|
||||||
await openLink(page!);
|
if (context.mounted) await openLink(context, page!);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -829,12 +776,12 @@ class _QueuePanelState extends State<QueuePanel> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final height = MediaQuery.sizeOf(context).height;
|
final height = MediaQuery.sizeOf(context).height;
|
||||||
final drop = DropTarget(
|
final drop = AppScope.of(context).ports.shell.dropTarget(
|
||||||
onDragEntered: (_) => setState(() => _dragging = true),
|
onEnter: () => setState(() => _dragging = true),
|
||||||
onDragExited: (_) => setState(() => _dragging = false),
|
onExit: () => setState(() => _dragging = false),
|
||||||
onDragDone: (d) {
|
onDrop: (paths, _) {
|
||||||
setState(() => _dragging = false);
|
setState(() => _dragging = false);
|
||||||
_dropped(DesktopDropX.pathsOf(d.files));
|
_dropped(paths);
|
||||||
},
|
},
|
||||||
// Esc: the popover's own (it fades out).
|
// Esc: the popover's own (it fades out).
|
||||||
child: Builder(
|
child: Builder(
|
||||||
@@ -853,17 +800,18 @@ class _QueuePanelState extends State<QueuePanel> {
|
|||||||
),
|
),
|
||||||
child: SignalBuilder(builder: (context) {
|
child: SignalBuilder(builder: (context) {
|
||||||
final items = _ordered(_q.items.value);
|
final items = _ordered(_q.items.value);
|
||||||
|
final auto = _q.continues.value;
|
||||||
final cont = _q.shownContinuation.value;
|
final cont = _q.shownContinuation.value;
|
||||||
final warm = _q.warm.value;
|
final warm = _q.warm.value;
|
||||||
final r = _q.coord.serverState.value;
|
final r = _q.coord.serverState.value;
|
||||||
final selfId = _q.coord.sync.memberId;
|
final selfId = _q.coord.connection.memberId;
|
||||||
return Column(
|
return Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
_header(items.length),
|
_header(items.length),
|
||||||
Flexible(
|
Flexible(
|
||||||
child: items.isEmpty && cont.isEmpty && _adding.isEmpty
|
child: items.isEmpty && auto == null && cont.isEmpty && _adding.isEmpty
|
||||||
? _empty()
|
? _empty()
|
||||||
: ListView(
|
: ListView(
|
||||||
shrinkWrap: true,
|
shrinkWrap: true,
|
||||||
@@ -915,7 +863,9 @@ class _QueuePanelState extends State<QueuePanel> {
|
|||||||
adding: a,
|
adding: a,
|
||||||
onDismiss: () => setState(() => _adding.remove(a)),
|
onDismiss: () => setState(() => _adding.remove(a)),
|
||||||
),
|
),
|
||||||
if (cont.isNotEmpty) ..._continuation(cont, items.isEmpty && _adding.isEmpty),
|
if (auto != null || cont.isNotEmpty)
|
||||||
|
..._continuation(auto, cont, items.isEmpty && _adding.isEmpty,
|
||||||
|
warm: auto != null && warm?.id == auto.id ? warm!.state : null),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -971,7 +921,9 @@ class _QueuePanelState extends State<QueuePanel> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
List<Widget> _continuation(List<EpisodeLink> cont, bool alone) => [
|
/// "Continues with": the continuation everyone shares ([auto], Clear
|
||||||
|
/// drops it for everyone), then the series' further episodes known here.
|
||||||
|
List<Widget> _continuation(QueueItem? auto, List<EpisodeLink> cont, bool alone, {WarmState? warm}) => [
|
||||||
Padding(
|
Padding(
|
||||||
padding: EdgeInsets.fromLTRB(12, alone ? 0 : 8, 2, 0),
|
padding: EdgeInsets.fromLTRB(12, alone ? 0 : 8, 2, 0),
|
||||||
child: Row(
|
child: Row(
|
||||||
@@ -979,6 +931,7 @@ class _QueuePanelState extends State<QueuePanel> {
|
|||||||
const Text('CONTINUES WITH',
|
const Text('CONTINUES WITH',
|
||||||
style: TextStyle(color: Colors.white38, fontSize: 11, letterSpacing: 0.8, fontWeight: FontWeight.w600)),
|
style: TextStyle(color: Colors.white38, fontSize: 11, letterSpacing: 0.8, fontWeight: FontWeight.w600)),
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
|
if (auto != null)
|
||||||
TextButton(
|
TextButton(
|
||||||
key: const ValueKey('queue-clear-continuation'),
|
key: const ValueKey('queue-clear-continuation'),
|
||||||
style: TextButton.styleFrom(foregroundColor: Colors.white54, visualDensity: VisualDensity.compact),
|
style: TextButton.styleFrom(foregroundColor: Colors.white54, visualDensity: VisualDensity.compact),
|
||||||
@@ -988,6 +941,7 @@ class _QueuePanelState extends State<QueuePanel> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
if (auto != null) _ContinuesRow(item: auto, queue: _q, warm: warm),
|
||||||
for (final e in cont) _ContinuationRow(episode: e, queue: _q),
|
for (final e in cont) _ContinuationRow(episode: e, queue: _q),
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -1207,8 +1161,8 @@ IconData howIcon(QueueHow how) => switch (how) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/// Who added it: their picture (still here) or initial, and name.
|
/// Who added it: their picture (still here) or initial, and name.
|
||||||
AvatarInfo _adderAvatar(QueueItem item, List<MemberView> members) {
|
AvatarInfo _adderAvatar(BuildContext context, QueueItem item, List<MemberView> members) {
|
||||||
final coord = SessionState.instance.coordinator;
|
final coord = AppScope.of(context).coordinator;
|
||||||
for (final m in members) {
|
for (final m in members) {
|
||||||
if (m.id == item.by) return memberAvatarInfo(m, coord.serverState.peek(), coord);
|
if (m.id == item.by) return memberAvatarInfo(m, coord.serverState.peek(), coord);
|
||||||
}
|
}
|
||||||
@@ -1242,7 +1196,7 @@ class _MetaLine extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
if (dur.isNotEmpty) ...[Text(dur, style: style), dot],
|
if (dur.isNotEmpty) ...[Text(dur, style: style), dot],
|
||||||
Text('added by ', style: style),
|
Text('added by ', style: style),
|
||||||
StatusAvatar(info: _adderAvatar(item, members), size: fontSize + 3, badges: false, ring: false),
|
StatusAvatar(info: _adderAvatar(context, item, members), size: fontSize + 3, badges: false, ring: false),
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
Text(name, style: style),
|
Text(name, style: style),
|
||||||
dot,
|
dot,
|
||||||
@@ -1258,7 +1212,7 @@ class _MetaLine extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
if (dur.isNotEmpty) ...[Text(dur, style: style), dot],
|
if (dur.isNotEmpty) ...[Text(dur, style: style), dot],
|
||||||
Text('added by ', style: style),
|
Text('added by ', style: style),
|
||||||
StatusAvatar(info: _adderAvatar(item, members), size: fontSize + 3, badges: false, ring: false),
|
StatusAvatar(info: _adderAvatar(context, item, members), size: fontSize + 3, badges: false, ring: false),
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
// Capped rather than flexible: two flexibles split the room in
|
// Capped rather than flexible: two flexibles split the room in
|
||||||
// half and would cut the end off even when it fits.
|
// half and would cut the end off even when it fits.
|
||||||
@@ -1494,6 +1448,76 @@ class _WarmDotState extends State<_WarmDot> with SingleTickerProviderStateMixin
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The continuation's row (the `auto` item): greyed like what is not
|
||||||
|
/// queued yet, its picture with how ready it is here, ▶ plays it now.
|
||||||
|
class _ContinuesRow extends StatefulWidget {
|
||||||
|
final QueueItem item;
|
||||||
|
final QueueController queue;
|
||||||
|
final WarmState? warm;
|
||||||
|
const _ContinuesRow({required this.item, required this.queue, required this.warm});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_ContinuesRow> createState() => _ContinuesRowState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ContinuesRowState extends State<_ContinuesRow> {
|
||||||
|
bool _hover = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final item = widget.item;
|
||||||
|
return MouseRegion(
|
||||||
|
onEnter: (_) => setState(() => _hover = true),
|
||||||
|
onExit: (_) => setState(() => _hover = false),
|
||||||
|
child: AnimatedOpacity(
|
||||||
|
duration: const Duration(milliseconds: 120),
|
||||||
|
opacity: _hover ? 0.95 : 0.7,
|
||||||
|
child: Container(
|
||||||
|
key: ValueKey('queue-continues-${item.id}'),
|
||||||
|
height: queueRowHeight,
|
||||||
|
padding: const EdgeInsets.only(left: _lead, right: 8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: _hover ? AppMenuStyle.hover : Colors.transparent,
|
||||||
|
borderRadius: BorderRadius.circular(AppMenuStyle.rowRadius),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Stack(
|
||||||
|
children: [
|
||||||
|
QueueThumb(queue: widget.queue, item: item, width: _thumb),
|
||||||
|
if (widget.warm != null) Positioned(top: 3, right: 3, child: _WarmDot(state: widget.warm!)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(item.title, maxLines: 1, overflow: TextOverflow.ellipsis, style: _titleStyle),
|
||||||
|
Text(
|
||||||
|
[item.sub, queueClock(item.duration)].where((s) => s.isNotEmpty).join(' · '),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: _subStyle,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_RoundIcon(
|
||||||
|
key: ValueKey('queue-play-${item.id}'),
|
||||||
|
icon: Icons.play_arrow_rounded,
|
||||||
|
tooltip: 'Play now',
|
||||||
|
onPressed: () => widget.queue.playNow(item.id),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class _ContinuationRow extends StatefulWidget {
|
class _ContinuationRow extends StatefulWidget {
|
||||||
final EpisodeLink episode;
|
final EpisodeLink episode;
|
||||||
final QueueController queue;
|
final QueueController queue;
|
||||||
@@ -1560,8 +1584,9 @@ class _ContinuationRowState extends State<_ContinuationRow> {
|
|||||||
|
|
||||||
// -------------------------------------------------------------- end card
|
// -------------------------------------------------------------- end card
|
||||||
|
|
||||||
/// When the media ended with Up next waiting: its picture, title and who
|
/// When the media ended with something next (Up next's first item, else
|
||||||
/// added it, the countdown with Cancel, and Play now.
|
/// the continuation): its picture, title and who added it, the server's
|
||||||
|
/// countdown with Cancel (for everyone), and Play now.
|
||||||
class UpNextCard extends StatelessWidget {
|
class UpNextCard extends StatelessWidget {
|
||||||
final QueueController queue;
|
final QueueController queue;
|
||||||
final UpNextCountdown countdown;
|
final UpNextCountdown countdown;
|
||||||
@@ -1573,8 +1598,14 @@ class UpNextCard extends StatelessWidget {
|
|||||||
final item = c.item;
|
final item = c.item;
|
||||||
final left = c.secondsLeft;
|
final left = c.secondsLeft;
|
||||||
final r = queue.coord.serverState.value;
|
final r = queue.coord.serverState.value;
|
||||||
final selfId = queue.coord.sync.memberId;
|
final selfId = queue.coord.connection.memberId;
|
||||||
final waiting = left == null ? performerNote(queue, item, r) : null;
|
final waitingFor = c.waitingFor;
|
||||||
|
final waiting = waitingFor != null
|
||||||
|
? 'Waiting for $waitingFor'
|
||||||
|
: left == null || left == 0
|
||||||
|
? performerNote(queue, item, r)
|
||||||
|
: null;
|
||||||
|
final what = item.auto ? 'Next' : 'Up next';
|
||||||
return AppGlass(
|
return AppGlass(
|
||||||
key: const ValueKey('up-next-card'),
|
key: const ValueKey('up-next-card'),
|
||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
@@ -1594,7 +1625,7 @@ class UpNextCard extends StatelessWidget {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(left == null ? 'Up next' : 'Up next in $left s',
|
Text(left == null || left == 0 ? what : '$what in $left s',
|
||||||
style: const TextStyle(color: Colors.white60, fontSize: 12)),
|
style: const TextStyle(color: Colors.white60, fontSize: 12)),
|
||||||
const SizedBox(height: 2),
|
const SizedBox(height: 2),
|
||||||
Text(item.title,
|
Text(item.title,
|
||||||
@@ -1620,7 +1651,7 @@ class UpNextCard extends StatelessWidget {
|
|||||||
ClipRRect(
|
ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(2),
|
borderRadius: BorderRadius.circular(2),
|
||||||
child: TweenAnimationBuilder<double>(
|
child: TweenAnimationBuilder<double>(
|
||||||
tween: Tween(end: left / autoAdvanceSecs),
|
tween: Tween(end: left / RelayConstants.advanceSecs),
|
||||||
duration: const Duration(seconds: 1),
|
duration: const Duration(seconds: 1),
|
||||||
builder: (context, v, _) => LinearProgressIndicator(
|
builder: (context, v, _) => LinearProgressIndicator(
|
||||||
value: v,
|
value: v,
|
||||||
|
|||||||
@@ -8,8 +8,10 @@ import 'package:sync_relay_core/core.dart';
|
|||||||
|
|
||||||
|
|
||||||
// AniList airing schedule (public GraphQL, no login): every episode airing
|
// AniList airing schedule (public GraphQL, no login): every episode airing
|
||||||
// in a time range, with covers, banners and popularity; and a title search
|
// in a time range, with covers, banners and popularity; a title search
|
||||||
// (cover + banner of a show, for the media art of files and links).
|
// (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
|
// 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
|
// pages are cached: in memory for 10 min (tab switches) and on disk for an
|
||||||
@@ -105,6 +107,177 @@ class AniListMedia {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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.
|
/// "#e4a15d" -> opaque [Color]; anything else -> null.
|
||||||
Color? parseHexColor(Object? s) {
|
Color? parseHexColor(Object? s) {
|
||||||
if (s is! String || s.length != 7 || !s.startsWith('#')) return null;
|
if (s is! String || s.length != 7 || !s.startsWith('#')) return null;
|
||||||
@@ -142,7 +315,7 @@ class _Cached {
|
|||||||
const _Cached(this.fetched, this.from, this.to, this.raw);
|
const _Cached(this.fetched, this.from, this.to, this.raw);
|
||||||
}
|
}
|
||||||
|
|
||||||
class AniListClient {
|
class AniListClient implements AniListLookup {
|
||||||
AniListClient({
|
AniListClient({
|
||||||
String url = aniListDefaultUrl,
|
String url = aniListDefaultUrl,
|
||||||
HttpClient? http,
|
HttpClient? http,
|
||||||
@@ -158,7 +331,7 @@ class AniListClient {
|
|||||||
_cacheDir = cacheDir ?? _defaultCacheDir,
|
_cacheDir = cacheDir ?? _defaultCacheDir,
|
||||||
_delay = delay ?? Future<void>.delayed,
|
_delay = delay ?? Future<void>.delayed,
|
||||||
_now = now ?? DateTime.now,
|
_now = now ?? DateTime.now,
|
||||||
userAgent = userAgent ?? 'watch-together/$appVersion';
|
userAgent = userAgent ?? 'Starsync/$appVersion';
|
||||||
|
|
||||||
final Uri url;
|
final Uri url;
|
||||||
final Duration memoryTtl;
|
final Duration memoryTtl;
|
||||||
@@ -306,6 +479,49 @@ coverImage{extraLarge large} bannerImage}}''';
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
static String _errorText(Object? j) {
|
||||||
final errs = j is Map ? j['errors'] : null;
|
final errs = j is Map ? j['errors'] : null;
|
||||||
if (errs is! List || errs.isEmpty) return '';
|
if (errs is! List || errs.isEmpty) return '';
|
||||||
|
|||||||
@@ -474,7 +474,7 @@ class AnimeFlowClient {
|
|||||||
_ownsHttp = http == null,
|
_ownsHttp = http == null,
|
||||||
_delay = delay ?? Future<void>.delayed,
|
_delay = delay ?? Future<void>.delayed,
|
||||||
_now = now ?? DateTime.now,
|
_now = now ?? DateTime.now,
|
||||||
userAgent = userAgent ?? 'watch-together/$appVersion';
|
userAgent = userAgent ?? 'Starsync/$appVersion';
|
||||||
|
|
||||||
/// Authenticated with the linked Jellyfin account on the same domain
|
/// Authenticated with the linked Jellyfin account on the same domain
|
||||||
/// (see [tokenFor]), looked up on every call so a re-link applies.
|
/// (see [tokenFor]), looked up on every call so a re-link applies.
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ enum CardAction {
|
|||||||
none,
|
none,
|
||||||
putUp,
|
putUp,
|
||||||
|
|
||||||
/// Available, nobody else watching (offline or solo): plays it here.
|
/// Available, nobody else in the server: plays it here.
|
||||||
play,
|
play,
|
||||||
request,
|
request,
|
||||||
requestSeries,
|
requestSeries,
|
||||||
|
|||||||
@@ -24,9 +24,8 @@ import 'schedule_card.dart';
|
|||||||
enum ScheduleFilter { jellyfin, all }
|
enum ScheduleFilter { jellyfin, all }
|
||||||
|
|
||||||
class SchedulePage extends StatefulWidget {
|
class SchedulePage extends StatefulWidget {
|
||||||
/// Nobody else watches (offline or solo): available episodes say Play
|
/// Nobody else is in the server (D9): available episodes say Play.
|
||||||
/// (offline that starts a solo session).
|
final bool alone;
|
||||||
final bool solo;
|
|
||||||
|
|
||||||
/// Puts a Jellyfin episode up in the server; returns an error to show.
|
/// Puts a Jellyfin episode up in the server; returns an error to show.
|
||||||
final Future<String?> Function(JellyfinMedia pick) onPutUp;
|
final Future<String?> Function(JellyfinMedia pick) onPutUp;
|
||||||
@@ -43,7 +42,7 @@ class SchedulePage extends StatefulWidget {
|
|||||||
|
|
||||||
const SchedulePage({
|
const SchedulePage({
|
||||||
super.key,
|
super.key,
|
||||||
this.solo = false,
|
this.alone = false,
|
||||||
required this.onPutUp,
|
required this.onPutUp,
|
||||||
this.onAddToQueue,
|
this.onAddToQueue,
|
||||||
required this.onToast,
|
required this.onToast,
|
||||||
@@ -228,7 +227,7 @@ class _SchedulePageState extends State<SchedulePage> {
|
|||||||
|
|
||||||
(CardAction, String?) _actionFor(ScheduleEntry e) {
|
(CardAction, String?) _actionFor(ScheduleEntry e) {
|
||||||
if (e.status == ScheduleStatus.available || e.status == ScheduleStatus.inLibrary) {
|
if (e.status == ScheduleStatus.available || e.status == ScheduleStatus.inLibrary) {
|
||||||
final put = widget.solo ? CardAction.play : CardAction.putUp;
|
final put = widget.alone ? CardAction.play : CardAction.putUp;
|
||||||
if (e.jellyfinSeriesId == null) return (put, 'Not linked to a Jellyfin series yet');
|
if (e.jellyfinSeriesId == null) return (put, 'Not linked to a Jellyfin series yet');
|
||||||
return (put, null);
|
return (put, null);
|
||||||
}
|
}
|
||||||
@@ -287,7 +286,7 @@ class _SchedulePageState extends State<SchedulePage> {
|
|||||||
final add = widget.onAddToQueue;
|
final add = widget.onAddToQueue;
|
||||||
if (add == null) return;
|
if (add == null) return;
|
||||||
final pick = await showAppMenu<int>(context, items: [
|
final pick = await showAppMenu<int>(context, items: [
|
||||||
AppMenuItem(value: 0, label: widget.solo ? 'Play' : 'Put it up'),
|
AppMenuItem(value: 0, label: widget.alone ? 'Play' : 'Put it up'),
|
||||||
const AppMenuItem(value: 1, label: 'Add to queue'),
|
const AppMenuItem(value: 1, label: 'Add to queue'),
|
||||||
const AppMenuItem(value: 2, label: 'Play next'),
|
const AppMenuItem(value: 2, label: 'Play next'),
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -3,9 +3,11 @@ import 'package:signals/signals_flutter.dart';
|
|||||||
|
|
||||||
import '../../platform/app_log.dart';
|
import '../../platform/app_log.dart';
|
||||||
import '../../shell/mark_problem.dart';
|
import '../../shell/mark_problem.dart';
|
||||||
import '../../state/session_state.dart';
|
import '../../app/app_graph.dart';
|
||||||
import '../../state/settings.dart';
|
import '../../state/settings.dart';
|
||||||
import '../../update/update_controller.dart';
|
import '../../update/update_controller.dart';
|
||||||
|
import '../../update/whats_new.dart';
|
||||||
|
import '../../widgets/whats_new_card.dart';
|
||||||
import '../../widgets/app_menu.dart';
|
import '../../widgets/app_menu.dart';
|
||||||
import '../../widgets/copy_open.dart';
|
import '../../widgets/copy_open.dart';
|
||||||
import 'settings_page.dart';
|
import 'settings_page.dart';
|
||||||
@@ -16,11 +18,40 @@ import 'update_indicator.dart';
|
|||||||
const uoscCredit = 'Player timeline and controls inspired by uosc by tomasklaen, LGPL-2.1 '
|
const uoscCredit = 'Player timeline and controls inspired by uosc by tomasklaen, LGPL-2.1 '
|
||||||
'(github.com/tomasklaen/uosc).';
|
'(github.com/tomasklaen/uosc).';
|
||||||
|
|
||||||
/// Name, version, update state, the source link and the logs (Settings →
|
/// Dev builds only ([devTools]): previews and experiments not decided
|
||||||
/// About).
|
/// yet (DEVELOPMENT.md "Dev tools").
|
||||||
|
class DeveloperPanel extends StatelessWidget {
|
||||||
|
final VoidCallback? onDanmakuPreview;
|
||||||
|
const DeveloperPanel({super.key, this.onDanmakuPreview});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final t = Theme.of(context).textTheme;
|
||||||
|
return SettingsPanel(
|
||||||
|
title: 'Developer',
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text('Test builds only: try what is not decided yet.', style: t.bodySmall?.copyWith(color: Colors.grey)),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
FilledButton.tonalIcon(
|
||||||
|
key: const ValueKey('danmaku-preview'),
|
||||||
|
onPressed: onDanmakuPreview,
|
||||||
|
icon: const Icon(Icons.subtitles_outlined, size: 18),
|
||||||
|
label: const Text('Danmaku preview'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Name, version, update state, What's new in this version, the source
|
||||||
|
/// link and the logs (Settings → About).
|
||||||
class AboutPanel extends StatelessWidget {
|
class AboutPanel extends StatelessWidget {
|
||||||
final UpdateController? updates;
|
final UpdateController? updates;
|
||||||
const AboutPanel({super.key, this.updates});
|
final WhatsNew? whatsNew;
|
||||||
|
const AboutPanel({super.key, this.updates, this.whatsNew});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -36,6 +67,23 @@ class AboutPanel extends StatelessWidget {
|
|||||||
style: t.bodyMedium?.copyWith(color: Colors.grey)),
|
style: t.bodyMedium?.copyWith(color: Colors.grey)),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
UpdateStatusLine(controller: updates),
|
UpdateStatusLine(controller: updates),
|
||||||
|
// This version's notes, once kept (the install, a check).
|
||||||
|
ValueListenableBuilder(
|
||||||
|
valueListenable: (updates ?? AppScope.of(context).updates).latest,
|
||||||
|
builder: (context, _, _) {
|
||||||
|
final notes = (whatsNew ?? AppScope.of(context).whatsNew).current;
|
||||||
|
if (notes == null) return const SizedBox.shrink();
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 8),
|
||||||
|
child: FilledButton.tonalIcon(
|
||||||
|
key: const ValueKey('about-whats-new'),
|
||||||
|
onPressed: () => showWhatsNew(context, notes),
|
||||||
|
icon: const Icon(Icons.auto_awesome, size: 18),
|
||||||
|
label: Text("What's new in ${notes.version}"),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
const SizedBox(height: kGap),
|
const SizedBox(height: kGap),
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
@@ -43,10 +91,10 @@ class AboutPanel extends StatelessWidget {
|
|||||||
IconButton(
|
IconButton(
|
||||||
tooltip: ReuseText.copyLink,
|
tooltip: ReuseText.copyLink,
|
||||||
icon: const Icon(Icons.copy, size: 18),
|
icon: const Icon(Icons.copy, size: 18),
|
||||||
onPressed: () => copyToClipboard(repoUrl, done: 'Link copied'),
|
onPressed: () => copyToClipboard(context, repoUrl, done: 'Link copied'),
|
||||||
),
|
),
|
||||||
FilledButton.tonalIcon(
|
FilledButton.tonalIcon(
|
||||||
onPressed: () => openLink(repoUrl),
|
onPressed: () => openLink(context, repoUrl),
|
||||||
icon: const Icon(Icons.open_in_new, size: 18),
|
icon: const Icon(Icons.open_in_new, size: 18),
|
||||||
label: const Text('Source'),
|
label: const Text('Source'),
|
||||||
),
|
),
|
||||||
@@ -75,7 +123,7 @@ class AboutPanel extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
const MarkProblemButton(outlined: true),
|
const MarkProblemButton(outlined: true),
|
||||||
OutlinedButton.icon(
|
OutlinedButton.icon(
|
||||||
onPressed: appLogDir == null ? null : () => _openLogs(),
|
onPressed: appLogDir == null ? null : () => _openLogs(context),
|
||||||
icon: const Icon(Icons.folder_open, size: 18),
|
icon: const Icon(Icons.folder_open, size: 18),
|
||||||
label: const Text('Open logs folder'),
|
label: const Text('Open logs folder'),
|
||||||
),
|
),
|
||||||
@@ -102,7 +150,8 @@ class AboutPanel extends StatelessWidget {
|
|||||||
/// In a session it asks whether to add the server's session log.
|
/// In a session it asks whether to add the server's session log.
|
||||||
static Future<void> _copyDiagnostics(BuildContext context) async {
|
static Future<void> _copyDiagnostics(BuildContext context) async {
|
||||||
var withServer = false;
|
var withServer = false;
|
||||||
if (SessionState.instance.route.peek() == AppRoute.session) {
|
final graph = AppScope.of(context);
|
||||||
|
if (graph.navigation.online.peek()) {
|
||||||
final pick = await showAppMenu<bool>(context, items: const [
|
final pick = await showAppMenu<bool>(context, items: const [
|
||||||
AppMenuItem(value: false, label: "This app's log"),
|
AppMenuItem(value: false, label: "This app's log"),
|
||||||
AppMenuItem(value: true, label: "This app's and the server's session log"),
|
AppMenuItem(value: true, label: "This app's and the server's session log"),
|
||||||
@@ -110,18 +159,20 @@ class AboutPanel extends StatelessWidget {
|
|||||||
if (pick == null) return;
|
if (pick == null) return;
|
||||||
withServer = pick;
|
withServer = pick;
|
||||||
}
|
}
|
||||||
final text = await diagnosticsText(sessionLog: withServer);
|
final text = await diagnosticsText(graph, sessionLog: withServer);
|
||||||
final saved = await saveDiagnostics(text);
|
final saved = await saveDiagnostics(text);
|
||||||
await copyToClipboard(text,
|
if (!context.mounted) return;
|
||||||
|
await copyToClipboard(context, text,
|
||||||
done: saved == null ? 'Diagnostics copied' : 'Diagnostics copied (also saved in the logs folder)');
|
done: saved == null ? 'Diagnostics copied' : 'Diagnostics copied (also saved in the logs folder)');
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<void> _openLogs() async {
|
static Future<void> _openLogs(BuildContext context) async {
|
||||||
final dir = appLogDir;
|
final dir = appLogDir;
|
||||||
if (dir == null) return;
|
if (dir == null) return;
|
||||||
// The file manager opens a folder path the way a browser opens a link.
|
final folders = AppScope.of(context).ports.folders;
|
||||||
if (!await openInBrowser(Uri.directory(dir).toString())) {
|
if (!await folders.reveal(dir)) {
|
||||||
await copyToClipboard(dir, done: "Couldn't open it — folder path copied");
|
if (!context.mounted) return;
|
||||||
|
await copyToClipboard(context, dir, done: "Couldn't open it — folder path copied");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,25 +1,36 @@
|
|||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
import '../../session/connection.dart' show connectErrorText;
|
||||||
|
import '../../session/identity.dart';
|
||||||
|
import '../../session/navigation.dart';
|
||||||
import '../../session/session_coordinator.dart';
|
import '../../session/session_coordinator.dart';
|
||||||
import '../../state/session_state.dart';
|
import '../../state/connect_prefs.dart';
|
||||||
import 'profile.dart';
|
import '../../sync/sync_client.dart' show HelloRefused;
|
||||||
|
import 'health_probe.dart' show VersionGateRefused;
|
||||||
|
|
||||||
/// What a card shows under its fields after a failed attempt.
|
/// What the Join card shows under its fields after a failed attempt.
|
||||||
|
/// [offerUpdate]: this app is the older one (the version gate), so the
|
||||||
|
/// card offers the updater.
|
||||||
class CardError {
|
class CardError {
|
||||||
final String title;
|
final String title;
|
||||||
final String message;
|
final String message;
|
||||||
const CardError(this.title, this.message);
|
final bool offerUpdate;
|
||||||
|
const CardError(this.title, this.message, {this.offerUpdate = false});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Host/Join submit state for the strip's two connect cards. One attempt at a time;
|
/// The Join card's submit state, owned by the shell so an attempt and its
|
||||||
/// [cancel] abandons it via [SessionState.cancelPending].
|
/// error outlive the card. One attempt at a time; [cancel] abandons it
|
||||||
|
/// ([Navigation.cancelPending]).
|
||||||
class ConnectFlow extends ChangeNotifier {
|
class ConnectFlow extends ChangeNotifier {
|
||||||
ConnectFlow({SessionState? session}) : _session = session ?? SessionState.instance;
|
ConnectFlow({required this.navigation, required this.forms, this.profile});
|
||||||
final SessionState _session;
|
final Navigation navigation;
|
||||||
|
final ConnectStore forms;
|
||||||
|
/// Name and picture for the hello (default: the app's).
|
||||||
|
final Profile? profile;
|
||||||
|
|
||||||
ConnectMode? busy;
|
bool busy = false;
|
||||||
final errors = <ConnectMode, CardError?>{};
|
CardError? error;
|
||||||
final notices = <ConnectMode, String?>{};
|
String? notice;
|
||||||
int _attempt = 0;
|
int _attempt = 0;
|
||||||
bool _disposed = false;
|
bool _disposed = false;
|
||||||
|
|
||||||
@@ -33,114 +44,101 @@ class ConnectFlow extends ChangeNotifier {
|
|||||||
if (!_disposed) notifyListeners();
|
if (!_disposed) notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
void showError(ConnectMode mode, CardError error) {
|
void showError(CardError e) {
|
||||||
errors[mode] = error;
|
error = e;
|
||||||
notices[mode] = null;
|
notice = null;
|
||||||
_notify();
|
_notify();
|
||||||
}
|
}
|
||||||
|
|
||||||
void clearError(ConnectMode mode) {
|
void clearError() {
|
||||||
if (errors[mode] == null && notices[mode] == null) return;
|
if (error == null && notice == null) return;
|
||||||
errors[mode] = null;
|
error = null;
|
||||||
notices[mode] = null;
|
notice = null;
|
||||||
_notify();
|
_notify();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Validates and builds the params; throws [FormatException] with a
|
/// Validates and builds the params; throws [FormatException] with a
|
||||||
/// user-facing message. [avatar] is the base64 picture (the profile's).
|
/// user-facing message. [avatar] is the base64 picture (the profile's).
|
||||||
/// Hosting puts nothing up: what to watch is picked in the library.
|
static ConnectParams buildParams(ConnectForm f, {String avatar = ''}) {
|
||||||
static ConnectParams buildParams(ConnectMode mode, ConnectForm f, {String avatar = ''}) {
|
final port = int.tryParse(f.port.trim());
|
||||||
final rawPort = mode == ConnectMode.host ? f.hostPort : f.port;
|
|
||||||
final port = int.tryParse(rawPort.trim());
|
|
||||||
if (port == null || port <= 0 || port > 65535) {
|
if (port == null || port <= 0 || port > 65535) {
|
||||||
throw const FormatException('port must be 1–65535');
|
throw const FormatException('port must be 1–65535');
|
||||||
}
|
}
|
||||||
if (f.name.trim().isEmpty) {
|
if (f.name.trim().isEmpty) {
|
||||||
throw const FormatException('set your display name first (click your avatar at the top left)');
|
throw const FormatException('set your display name first (click your avatar at the top left)');
|
||||||
}
|
}
|
||||||
if (mode == ConnectMode.join && f.host.trim().isEmpty) {
|
if (f.host.trim().isEmpty) {
|
||||||
throw const FormatException('enter the server address');
|
throw const FormatException('enter the server address');
|
||||||
}
|
}
|
||||||
return ConnectParams(
|
return ConnectParams(host: f.host.trim(), port: port, secret: f.secret, name: f.name.trim(), avatar: avatar);
|
||||||
host: f.host.trim(),
|
|
||||||
port: port,
|
|
||||||
secret: mode == ConnectMode.host ? f.hostSecret : f.secret,
|
|
||||||
name: f.name.trim(),
|
|
||||||
avatar: avatar,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// [edit] fills the card's fields into a copy of the last form, so the
|
/// [edit] fills the card's fields into a copy of the form; the form is
|
||||||
/// other card's saved values survive.
|
/// saved whatever happens (a failed join is when the user wants it back).
|
||||||
Future<void> submit(ConnectMode mode, void Function(ConnectForm f) edit) async {
|
Future<void> submit(void Function(ConnectForm f) edit) async {
|
||||||
if (busy != null) return;
|
if (busy) return;
|
||||||
final myAttempt = ++_attempt;
|
final myAttempt = ++_attempt;
|
||||||
final form = _session.lastForm.copy();
|
final form = forms.form.copy();
|
||||||
edit(form);
|
edit(form);
|
||||||
form.name = form.name.trim();
|
form.name = form.name.trim();
|
||||||
form.host = form.host.trim();
|
form.host = form.host.trim();
|
||||||
busy = mode;
|
busy = true;
|
||||||
errors[mode] = null;
|
error = null;
|
||||||
notices[mode] = null;
|
notice = null;
|
||||||
_notify();
|
_notify();
|
||||||
|
|
||||||
ConnectParams params;
|
ConnectParams params;
|
||||||
try {
|
try {
|
||||||
final profile = Profile.instance;
|
final profile = this.profile ?? Profile.instance;
|
||||||
await profile.ensureLoaded();
|
await profile.ensureLoaded();
|
||||||
params = buildParams(mode, form, avatar: profile.avatarBase64());
|
params = buildParams(form, avatar: profile.avatarBase64());
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (myAttempt != _attempt) return;
|
if (myAttempt != _attempt) return;
|
||||||
busy = null;
|
busy = false;
|
||||||
errors[mode] = CardError('Check the form',
|
error = CardError('Check the form', e is FormatException ? e.message : 'check the form and try again');
|
||||||
e is FormatException ? e.message : 'check the form and try again');
|
|
||||||
_notify();
|
_notify();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final fromSolo = mode == ConnectMode.host && _session.solo.value;
|
forms.form
|
||||||
|
..host = form.host
|
||||||
|
..port = form.port
|
||||||
|
..secret = form.secret;
|
||||||
try {
|
try {
|
||||||
if (mode == ConnectMode.host) {
|
await navigation.join(params);
|
||||||
await _session.host(params, form);
|
|
||||||
} else {
|
|
||||||
await _session.join(params, form);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (myAttempt != _attempt) return;
|
if (myAttempt != _attempt) return;
|
||||||
busy = null;
|
busy = false;
|
||||||
errors[mode] = CardError('Could not connect', '$e');
|
// In plain words: the version gate (who has to update), a refusal
|
||||||
|
// (an older host, a wrong password, another window), offline.
|
||||||
|
error = switch (e) {
|
||||||
|
VersionGateRefused(:final message, :final youUpdate) =>
|
||||||
|
CardError("Versions don't match", message, offerUpdate: youUpdate),
|
||||||
|
HelloRefused() => CardError('Could not connect', connectErrorText(e)),
|
||||||
|
_ => CardError('Could not connect', '$e'),
|
||||||
|
};
|
||||||
_notify();
|
_notify();
|
||||||
|
await forms.saveQuietly();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (myAttempt != _attempt) {
|
if (myAttempt != _attempt) {
|
||||||
// Cancelled after it got through: back out (an Invite: back to solo).
|
// Cancelled after it got through: back home.
|
||||||
try {
|
try {
|
||||||
if (fromSolo) {
|
await navigation.goHome();
|
||||||
await _session.cancelInvite();
|
|
||||||
} else {
|
|
||||||
await _session.leave();
|
|
||||||
await _session.stopServer();
|
|
||||||
}
|
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
busy = null;
|
busy = false;
|
||||||
_notify();
|
_notify();
|
||||||
await _session.rememberServer(RecentServer(
|
|
||||||
hosted: mode == ConnectMode.host,
|
|
||||||
host: mode == ConnectMode.host ? '' : form.host,
|
|
||||||
port: mode == ConnectMode.host ? form.hostPort.trim() : form.port.trim(),
|
|
||||||
secret: mode == ConnectMode.host ? form.hostSecret : form.secret,
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> cancel() async {
|
Future<void> cancel() async {
|
||||||
final mode = busy;
|
final was = busy;
|
||||||
_attempt++;
|
_attempt++;
|
||||||
try {
|
try {
|
||||||
await _session.cancelPending();
|
await navigation.cancelPending();
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
busy = null;
|
busy = false;
|
||||||
if (mode != null) notices[mode] = 'Cancelled';
|
if (was) notice = 'Cancelled';
|
||||||
_notify();
|
_notify();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,21 +1,20 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import '../../state/session_state.dart';
|
import '../../update/update_controller.dart';
|
||||||
import 'connect_flow.dart';
|
import 'connect_flow.dart';
|
||||||
import 'ui.dart';
|
import 'ui.dart';
|
||||||
|
import 'update_indicator.dart' show runUpdateCheck;
|
||||||
|
|
||||||
/// The submit button for one [ConnectMode], swapped for a progress bar and
|
/// The Join button, swapped for a progress bar and Cancel while the
|
||||||
/// Cancel while that attempt runs. Disabled while the other mode is busy.
|
/// attempt runs.
|
||||||
class ConnectButton extends StatelessWidget {
|
class ConnectButton extends StatelessWidget {
|
||||||
final ConnectFlow flow;
|
final ConnectFlow flow;
|
||||||
final ConnectMode mode;
|
|
||||||
final String label;
|
final String label;
|
||||||
final VoidCallback onSubmit;
|
final VoidCallback onSubmit;
|
||||||
final bool expand;
|
final bool expand;
|
||||||
const ConnectButton({
|
const ConnectButton({
|
||||||
super.key,
|
super.key,
|
||||||
required this.flow,
|
required this.flow,
|
||||||
required this.mode,
|
|
||||||
required this.label,
|
required this.label,
|
||||||
required this.onSubmit,
|
required this.onSubmit,
|
||||||
this.expand = false,
|
this.expand = false,
|
||||||
@@ -23,8 +22,7 @@ class ConnectButton extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final busyHere = flow.busy == mode;
|
if (flow.busy) {
|
||||||
if (busyHere) {
|
|
||||||
return SizedBox(
|
return SizedBox(
|
||||||
width: expand ? null : 220,
|
width: expand ? null : 220,
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -35,10 +33,7 @@ class ConnectButton extends StatelessWidget {
|
|||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(child: Text('Connecting…', style: Theme.of(context).textTheme.bodySmall)),
|
||||||
child: Text(mode == ConnectMode.host ? 'Starting server…' : 'Connecting…',
|
|
||||||
style: Theme.of(context).textTheme.bodySmall),
|
|
||||||
),
|
|
||||||
TextButton(onPressed: flow.cancel, child: const Text('Cancel')),
|
TextButton(onPressed: flow.cancel, child: const Text('Cancel')),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -46,35 +41,48 @@ class ConnectButton extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
final button = FilledButton(
|
final button = FilledButton(onPressed: onSubmit, child: Text(label));
|
||||||
onPressed: flow.busy != null ? null : onSubmit,
|
|
||||||
child: Text(label),
|
|
||||||
);
|
|
||||||
return expand ? SizedBox(width: double.infinity, child: button) : button;
|
return expand ? SizedBox(width: double.infinity, child: button) : button;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Last error or notice for [mode], or nothing.
|
/// The last error or notice, or nothing. A version gate that says this
|
||||||
|
/// app is the older one offers the update ([updates], default the app's
|
||||||
|
/// [UpdateController]).
|
||||||
class ConnectFeedback extends StatelessWidget {
|
class ConnectFeedback extends StatelessWidget {
|
||||||
final ConnectFlow flow;
|
final ConnectFlow flow;
|
||||||
final ConnectMode mode;
|
final UpdateController? updates;
|
||||||
const ConnectFeedback({super.key, required this.flow, required this.mode});
|
const ConnectFeedback({super.key, required this.flow, this.updates});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final error = flow.errors[mode];
|
final error = flow.error;
|
||||||
final notice = flow.notices[mode];
|
final notice = flow.notice;
|
||||||
if (error != null) {
|
if (error != null) {
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.only(top: 12),
|
padding: const EdgeInsets.only(top: 12),
|
||||||
child: ErrorBox(title: error.title, message: error.message),
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
ErrorBox(title: error.title, message: error.message),
|
||||||
|
if (error.offerUpdate)
|
||||||
|
Align(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: TextButton.icon(
|
||||||
|
key: const ValueKey('join-update'),
|
||||||
|
onPressed: () => runUpdateCheck(updates, true),
|
||||||
|
icon: const Icon(Icons.system_update_alt, size: 16),
|
||||||
|
label: const Text('Check for the update'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (notice != null) {
|
if (notice != null) {
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.only(top: 12),
|
padding: const EdgeInsets.only(top: 12),
|
||||||
child: Text(notice,
|
child: Text(notice, style: Theme.of(context).textTheme.bodySmall?.copyWith(color: Colors.grey)),
|
||||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: Colors.grey)),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return const SizedBox.shrink();
|
return const SizedBox.shrink();
|
||||||
|
|||||||
@@ -10,34 +10,76 @@ typedef HealthGetter = Future<String> Function(Uri uri);
|
|||||||
const healthPollEvery = Duration(seconds: 10);
|
const healthPollEvery = Duration(seconds: 10);
|
||||||
const _timeout = Duration(seconds: 4);
|
const _timeout = Duration(seconds: 4);
|
||||||
|
|
||||||
/// What a recent-server tile shows: unknown until the first probe answers.
|
/// Whether this app and a server have a wire version in common.
|
||||||
|
enum VersionFit {
|
||||||
|
/// Their ranges meet: the join can go ahead.
|
||||||
|
ok,
|
||||||
|
/// The server's newest version is older than anything this app speaks.
|
||||||
|
theyUpdate,
|
||||||
|
/// The server's oldest version is newer than anything this app speaks.
|
||||||
|
youUpdate,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [VersionFit] of a server that speaks [min] .. [max] (`/health`
|
||||||
|
/// `protoMin` / `protoMax`), against this build's
|
||||||
|
/// [RelayConstants.protoMin] .. [RelayConstants.protoMax].
|
||||||
|
VersionFit versionFit(int min, int max,
|
||||||
|
{int ownMin = RelayConstants.protoMin, int ownMax = RelayConstants.protoMax}) {
|
||||||
|
if (max < ownMin) return VersionFit.theyUpdate;
|
||||||
|
if (min > ownMax) return VersionFit.youUpdate;
|
||||||
|
return VersionFit.ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a recent-server row and the version gate read from `/health`:
|
||||||
|
/// unknown until the first probe answers.
|
||||||
class ServerHealth {
|
class ServerHealth {
|
||||||
final bool online;
|
final bool online;
|
||||||
final String name;
|
final String name;
|
||||||
final int? proto;
|
/// The server's wire range (`protoMin` .. `protoMax`; a server too old
|
||||||
|
/// to say has only `proto`, used for both). Null when it says nothing.
|
||||||
|
final int? protoMin;
|
||||||
|
final int? protoMax;
|
||||||
final int viewers;
|
final int viewers;
|
||||||
|
/// Its Starsync version (`version`), '' from a server too old to say.
|
||||||
|
final String version;
|
||||||
const ServerHealth.offline()
|
const ServerHealth.offline()
|
||||||
: online = false,
|
: online = false,
|
||||||
name = '',
|
name = '',
|
||||||
proto = null,
|
protoMin = null,
|
||||||
viewers = 0;
|
protoMax = null,
|
||||||
const ServerHealth.online({required this.name, this.proto, this.viewers = 0})
|
viewers = 0,
|
||||||
|
version = '';
|
||||||
|
const ServerHealth.online({required this.name, this.protoMin, this.protoMax, this.viewers = 0, this.version = ''})
|
||||||
: online = true;
|
: online = true;
|
||||||
|
|
||||||
/// True when the server speaks another protocol than this build.
|
/// Null while unknown (offline, or no version given).
|
||||||
bool get needsUpdate =>
|
VersionFit? get fit {
|
||||||
online && proto != null && proto != RelayConstants.protoVersion;
|
final min = protoMin, max = protoMax;
|
||||||
|
if (!online || min == null || max == null) return null;
|
||||||
|
return versionFit(min, max);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True when the server and this app have no version in common.
|
||||||
|
bool get needsUpdate => fit != null && fit != VersionFit.ok;
|
||||||
|
|
||||||
|
/// The server's own name for people ('' for the dedicated server's
|
||||||
|
/// default `server`).
|
||||||
|
String get label => name.isNotEmpty && name != 'server' ? name : '';
|
||||||
|
|
||||||
/// Lenient parse of `GET /health`; anything unexpected reads as offline.
|
/// Lenient parse of `GET /health`; anything unexpected reads as offline.
|
||||||
static ServerHealth parse(String body) {
|
static ServerHealth parse(String body) {
|
||||||
try {
|
try {
|
||||||
final j = jsonDecode(body);
|
final j = jsonDecode(body);
|
||||||
if (j is! Map || j['ok'] != true) return const ServerHealth.offline();
|
if (j is! Map || j['ok'] != true) return const ServerHealth.offline();
|
||||||
final name = j['name'], proto = j['proto'], viewers = j['viewers'];
|
final name = j['name'], proto = j['proto'], viewers = j['viewers'], version = j['version'];
|
||||||
|
final min = j['protoMin'], max = j['protoMax'];
|
||||||
|
final p = proto is num ? proto.toInt() : null;
|
||||||
return ServerHealth.online(
|
return ServerHealth.online(
|
||||||
name: name is String ? name.trim() : '',
|
name: name is String ? name.trim() : '',
|
||||||
proto: proto is num ? proto.toInt() : null,
|
protoMin: min is num ? min.toInt() : p,
|
||||||
|
protoMax: max is num ? max.toInt() : p,
|
||||||
viewers: viewers is num ? viewers.toInt() : 0,
|
viewers: viewers is num ? viewers.toInt() : 0,
|
||||||
|
version: version is String ? version.trim() : '',
|
||||||
);
|
);
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
return const ServerHealth.offline();
|
return const ServerHealth.offline();
|
||||||
@@ -45,6 +87,26 @@ class ServerHealth {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The version gate's message for a server that does not fit: who has
|
||||||
|
/// to update, in plain words ("Bob's server runs Starsync 1.2.1 — they
|
||||||
|
/// need to update").
|
||||||
|
String versionGateText(ServerHealth h) {
|
||||||
|
final who = h.label.isNotEmpty ? h.label : 'The server';
|
||||||
|
final runs = h.version.isNotEmpty ? '$who runs Starsync ${h.version}' : '$who runs ${h.fit == VersionFit.youUpdate ? 'a newer' : 'an older'} Starsync';
|
||||||
|
return h.fit == VersionFit.youUpdate ? '$runs — you need to update' : '$runs — they need to update';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A join stopped before connecting: the server and this app have no
|
||||||
|
/// version in common ([versionGateText]). [youUpdate]: this app is the
|
||||||
|
/// older one, so the updater can help.
|
||||||
|
class VersionGateRefused implements Exception {
|
||||||
|
final String message;
|
||||||
|
final bool youUpdate;
|
||||||
|
const VersionGateRefused(this.message, {required this.youUpdate});
|
||||||
|
@override
|
||||||
|
String toString() => message;
|
||||||
|
}
|
||||||
|
|
||||||
Uri healthUri(String host, String port) {
|
Uri healthUri(String host, String port) {
|
||||||
final h = host.trim();
|
final h = host.trim();
|
||||||
// Bare IPv6 literals need brackets in a URL.
|
// Bare IPv6 literals need brackets in a URL.
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import 'package:sync_relay_core/core.dart' show MediaRef;
|
||||||
|
|
||||||
/// What the media picker selected. Null selection = nothing (hosting
|
/// What the media picker selected. Null selection = nothing (hosting
|
||||||
/// without media is allowed).
|
/// without media is allowed).
|
||||||
sealed class PickedMedia {
|
sealed class PickedMedia {
|
||||||
@@ -72,6 +74,9 @@ final class JellyfinMedia extends PickedMedia {
|
|||||||
/// "Series · S1E6" for episodes, the year for movies; may be empty.
|
/// "Series · S1E6" for episodes, the year for movies; may be empty.
|
||||||
final String subtitle;
|
final String subtitle;
|
||||||
final Duration? runtime;
|
final Duration? runtime;
|
||||||
|
/// What it is (`media.ref`), when it was picked from the item itself
|
||||||
|
/// (with its provider ids); not stored, not part of equality.
|
||||||
|
final MediaRef? ref;
|
||||||
const JellyfinMedia({
|
const JellyfinMedia({
|
||||||
required this.serverId,
|
required this.serverId,
|
||||||
required this.baseUrl,
|
required this.baseUrl,
|
||||||
@@ -80,6 +85,7 @@ final class JellyfinMedia extends PickedMedia {
|
|||||||
required this.title,
|
required this.title,
|
||||||
this.subtitle = '',
|
this.subtitle = '',
|
||||||
this.runtime,
|
this.runtime,
|
||||||
|
this.ref,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import 'package:file_picker/file_picker.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:signals/signals_flutter.dart';
|
import 'package:signals/signals_flutter.dart';
|
||||||
|
import 'package:sync_relay_core/core.dart' show UpdateChannel, isDevBuild;
|
||||||
|
|
||||||
|
import '../../app/app_graph.dart';
|
||||||
import '../../state/settings.dart';
|
import '../../state/settings.dart';
|
||||||
import '../../update/update_controller.dart';
|
import '../../update/update_controller.dart';
|
||||||
import 'ui.dart';
|
import 'ui.dart';
|
||||||
@@ -16,6 +17,7 @@ const sessionShortcuts = [
|
|||||||
('Shift+→', 'Skip 1:30 (for everyone)'),
|
('Shift+→', 'Skip 1:30 (for everyone)'),
|
||||||
('PgUp / PgDn', 'Previous / next chapter'),
|
('PgUp / PgDn', 'Previous / next chapter'),
|
||||||
('F', 'Toggle fullscreen'),
|
('F', 'Toggle fullscreen'),
|
||||||
|
('Enter', 'Quick chat box (Enter sends, Esc closes)'),
|
||||||
('Tab', 'Open / close chat & activity'),
|
('Tab', 'Open / close chat & activity'),
|
||||||
('Ctrl+R', 'Reload the video'),
|
('Ctrl+R', 'Reload the video'),
|
||||||
('> or Shift+N', 'Put up the next Jellyfin episode'),
|
('> or Shift+N', 'Put up the next Jellyfin episode'),
|
||||||
@@ -88,13 +90,13 @@ class SettingsHintCard extends StatelessWidget {
|
|||||||
class CustomShaderFiles extends StatelessWidget {
|
class CustomShaderFiles extends StatelessWidget {
|
||||||
const CustomShaderFiles({super.key});
|
const CustomShaderFiles({super.key});
|
||||||
|
|
||||||
Future<void> _addFiles() async {
|
Future<void> _addFiles(BuildContext context) async {
|
||||||
final List<PlatformFile> files;
|
final folders = AppScope.of(context).ports.folders;
|
||||||
|
final List<String> files;
|
||||||
try {
|
try {
|
||||||
files = await FilePicker.pickFiles(
|
files = await folders.pickFiles(
|
||||||
dialogTitle: 'Pick mpv GLSL shaders',
|
dialogTitle: 'Pick mpv GLSL shaders',
|
||||||
type: FileType.custom,
|
extensions: const ['glsl', 'hook'],
|
||||||
allowedExtensions: const ['glsl', 'hook'],
|
|
||||||
);
|
);
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
return;
|
return;
|
||||||
@@ -102,8 +104,8 @@ class CustomShaderFiles extends StatelessWidget {
|
|||||||
final s = AppSettings.instance;
|
final s = AppSettings.instance;
|
||||||
final current = s.customShaders.peek();
|
final current = s.customShaders.peek();
|
||||||
final added = [
|
final added = [
|
||||||
for (final f in files)
|
for (final p in files)
|
||||||
if (f.path case final p? when !current.contains(p)) p,
|
if (!current.contains(p)) p,
|
||||||
];
|
];
|
||||||
if (added.isNotEmpty) s.setCustomShaders([...current, ...added]);
|
if (added.isNotEmpty) s.setCustomShaders([...current, ...added]);
|
||||||
}
|
}
|
||||||
@@ -124,7 +126,7 @@ class CustomShaderFiles extends StatelessWidget {
|
|||||||
style: muted),
|
style: muted),
|
||||||
),
|
),
|
||||||
TextButton.icon(
|
TextButton.icon(
|
||||||
onPressed: _addFiles,
|
onPressed: () => _addFiles(context),
|
||||||
icon: const Icon(Icons.add, size: 18),
|
icon: const Icon(Icons.add, size: 18),
|
||||||
label: const Text('Add .glsl files'),
|
label: const Text('Add .glsl files'),
|
||||||
),
|
),
|
||||||
@@ -194,6 +196,7 @@ class UpdateSettings extends StatelessWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
|
UpdateChannelSetting(controller: c, settings: s),
|
||||||
SignalBuilder(
|
SignalBuilder(
|
||||||
builder: (context) => SwitchListTile(
|
builder: (context) => SwitchListTile(
|
||||||
key: const ValueKey('auto-check-updates'),
|
key: const ValueKey('auto-check-updates'),
|
||||||
@@ -209,6 +212,75 @@ class UpdateSettings extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Stable or Dev (test builds). Leaving Dev in a test build asks whether
|
||||||
|
/// to go back to the latest release now when that one is older.
|
||||||
|
class UpdateChannelSetting extends StatelessWidget {
|
||||||
|
final UpdateController controller;
|
||||||
|
final AppSettings settings;
|
||||||
|
const UpdateChannelSetting({super.key, required this.controller, required this.settings});
|
||||||
|
|
||||||
|
Future<void> _choose(BuildContext context, UpdateChannel ch) async {
|
||||||
|
final s = settings;
|
||||||
|
if (ch == s.updateChannel.peek()) return;
|
||||||
|
s.setUpdateChannel(ch);
|
||||||
|
if (ch == UpdateChannel.stable && isDevBuild(controller.currentVersion)) {
|
||||||
|
final m = await controller.stableToGoBackTo();
|
||||||
|
if (m != null && context.mounted) {
|
||||||
|
final back = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: Text('Go back to stable ${m.version}?'),
|
||||||
|
content: Text('This is test build ${controller.currentVersion}, newer than the latest release. '
|
||||||
|
'Going back installs ${m.version} and restarts the app. Or stay on this build: '
|
||||||
|
'the next release newer than it is offered as usual.'),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
key: const ValueKey('stay-on-test-build'),
|
||||||
|
onPressed: () => Navigator.pop(context, false),
|
||||||
|
child: const Text('Stay on this build'),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
key: const ValueKey('go-back-to-stable'),
|
||||||
|
onPressed: () => Navigator.pop(context, true),
|
||||||
|
child: Text('Go back to ${m.version}'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (back == true) {
|
||||||
|
await controller.goBackTo(m);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await runUpdateCheck(controller, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final s = settings;
|
||||||
|
return SignalBuilder(builder: (context) {
|
||||||
|
final ch = s.updateChannel.value;
|
||||||
|
return ListTile(
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
title: const Text('Update channel'),
|
||||||
|
subtitle: Text('On ${ch == UpdateChannel.dev ? 'Dev' : 'Stable'}, running ${controller.currentVersion}. '
|
||||||
|
'${ch == UpdateChannel.dev ? 'Test builds and releases, whichever is newer; stays on Dev after a release' : 'Releases only'}'),
|
||||||
|
trailing: SegmentedButton<UpdateChannel>(
|
||||||
|
key: const ValueKey('update-channel'),
|
||||||
|
showSelectedIcon: false,
|
||||||
|
segments: const [
|
||||||
|
ButtonSegment(value: UpdateChannel.stable, label: Text('Stable')),
|
||||||
|
ButtonSegment(value: UpdateChannel.dev, label: Text('Dev')),
|
||||||
|
],
|
||||||
|
selected: {ch},
|
||||||
|
onSelectionChanged: (sel) => _choose(context, sel.first),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class AutoAdvanceSetting extends StatelessWidget {
|
class AutoAdvanceSetting extends StatelessWidget {
|
||||||
const AutoAdvanceSetting({super.key});
|
const AutoAdvanceSetting({super.key});
|
||||||
|
|
||||||
@@ -220,8 +292,8 @@ class AutoAdvanceSetting extends StatelessWidget {
|
|||||||
key: const ValueKey('auto-advance'),
|
key: const ValueKey('auto-advance'),
|
||||||
contentPadding: EdgeInsets.zero,
|
contentPadding: EdgeInsets.zero,
|
||||||
title: const Text('Play the next episode automatically'),
|
title: const Text('Play the next episode automatically'),
|
||||||
subtitle: const Text('When a Jellyfin episode ends, a 10 s countdown puts up the next one; '
|
subtitle: const Text('When you put up an episode (Jellyfin, or a file of a show in Files), the next one '
|
||||||
'anyone can cancel it'),
|
'continues it after a 10 s countdown everyone sees; anyone can cancel it'),
|
||||||
value: s.autoAdvance.value,
|
value: s.autoAdvance.value,
|
||||||
onChanged: s.setAutoAdvance,
|
onChanged: s.setAutoAdvance,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'package:flutter/foundation.dart' show kDebugMode;
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:sync_relay_core/core.dart' show appVersion, isDevBuild;
|
import 'package:sync_relay_core/core.dart' show appVersion, isDevBuild;
|
||||||
|
|
||||||
@@ -26,6 +27,14 @@ String displayVersion({String version = appVersion, String pubspec = _pubspecVer
|
|||||||
/// Settings tab): "1.2.1", "1.2.1-dev.2", "dev run".
|
/// Settings tab): "1.2.1", "1.2.1-dev.2", "dev run".
|
||||||
String shortVersion({String version = appVersion}) => version == '0.0.0-dev' ? 'dev run' : version;
|
String shortVersion({String version = appVersion}) => version == '0.0.0-dev' ? 'dev run' : version;
|
||||||
|
|
||||||
|
/// Dev tools (Settings → About → Developer, the experiments they switch
|
||||||
|
/// on) only in test builds and runs from source (a `-dev` version) and
|
||||||
|
/// debug runs, never in a release.
|
||||||
|
bool devToolsFor({String version = appVersion, bool debug = kDebugMode}) => debug || version.contains('-dev');
|
||||||
|
|
||||||
|
/// [devToolsFor] this build.
|
||||||
|
final bool devTools = devToolsFor();
|
||||||
|
|
||||||
const kRadius = 12.0;
|
const kRadius = 12.0;
|
||||||
const kGap = 16.0;
|
const kGap = 16.0;
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:sync_relay_core/core.dart' show isDevBuild;
|
import 'package:sync_relay_core/core.dart' show UpdateManifest, isDevBuild;
|
||||||
|
|
||||||
import '../../update/update_controller.dart';
|
import '../../update/update_controller.dart';
|
||||||
|
import '../../update/whats_new.dart';
|
||||||
import '../../widgets/update_banner.dart';
|
import '../../widgets/update_banner.dart';
|
||||||
|
import '../../widgets/whats_new_card.dart';
|
||||||
import 'ui.dart';
|
import 'ui.dart';
|
||||||
|
|
||||||
/// The band across the top of the window while an update is up for
|
/// The band across the top of the window while an update is up for
|
||||||
/// grabs: "Starsync 1.2.1 is ready", its notes, Update and restart and
|
/// grabs: "Starsync 1.2.1 is ready", its first changes (or its one-line
|
||||||
/// Later (gone until the next check or start). The shell keeps it off
|
/// notes), What's new (the whole list, [showWhatsNew]), Update and restart
|
||||||
|
/// and Later (gone until the next check or start). The shell keeps it off
|
||||||
/// the full player while a video is on ([AppShell]).
|
/// the full player while a video is on ([AppShell]).
|
||||||
class UpdateBar extends StatelessWidget {
|
class UpdateBar extends StatelessWidget {
|
||||||
final UpdateController? controller;
|
final UpdateController? controller;
|
||||||
@@ -31,6 +34,8 @@ class UpdateBar extends StatelessWidget {
|
|||||||
final behind = isDevBuild(current) ? null : 'You are running $current';
|
final behind = isDevBuild(current) ? null : 'You are running $current';
|
||||||
final String title;
|
final String title;
|
||||||
String detail;
|
String detail;
|
||||||
|
// The offered version's first changes instead of its notes.
|
||||||
|
var preview = false;
|
||||||
switch (s.phase) {
|
switch (s.phase) {
|
||||||
case UpdatePhase.downloading:
|
case UpdatePhase.downloading:
|
||||||
title = 'Downloading $appName ${m.version}… ${((s.progress ?? 0) * 100).round()}%';
|
title = 'Downloading $appName ${m.version}… ${((s.progress ?? 0) * 100).round()}%';
|
||||||
@@ -51,9 +56,11 @@ class UpdateBar extends StatelessWidget {
|
|||||||
} else if (m.isRequired) {
|
} else if (m.isRequired) {
|
||||||
title = '$appName ${m.version} is required to join servers on it';
|
title = '$appName ${m.version} is required to join servers on it';
|
||||||
detail = m.notes;
|
detail = m.notes;
|
||||||
|
preview = m.changes != null;
|
||||||
} else {
|
} else {
|
||||||
title = '$appName ${m.version} is ready';
|
title = '$appName ${m.version} is ready';
|
||||||
detail = m.notes;
|
detail = m.notes;
|
||||||
|
preview = m.changes != null;
|
||||||
}
|
}
|
||||||
detail = [?behind, detail].where((t) => t.isNotEmpty).join(' · ');
|
detail = [?behind, detail].where((t) => t.isNotEmpty).join(' · ');
|
||||||
}
|
}
|
||||||
@@ -88,7 +95,9 @@ class UpdateBar extends StatelessWidget {
|
|||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
style: TextStyle(color: fg, fontWeight: FontWeight.w600)),
|
style: TextStyle(color: fg, fontWeight: FontWeight.w600)),
|
||||||
if (detail.isNotEmpty)
|
if (preview)
|
||||||
|
WhatsNewPreview(changes: m.changes!, color: fg, lead: behind)
|
||||||
|
else if (detail.isNotEmpty)
|
||||||
Tooltip(
|
Tooltip(
|
||||||
message: detail,
|
message: detail,
|
||||||
waitDuration: const Duration(milliseconds: 600),
|
waitDuration: const Duration(milliseconds: 600),
|
||||||
@@ -102,6 +111,14 @@ class UpdateBar extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
if (!busy) ...[
|
if (!busy) ...[
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
|
if (m.changes != null || m.notes.trim().isNotEmpty)
|
||||||
|
TextButton.icon(
|
||||||
|
key: const ValueKey('update-whats-new'),
|
||||||
|
style: TextButton.styleFrom(foregroundColor: fg),
|
||||||
|
onPressed: () => _openWhatsNew(context, c, m, s.canInstall),
|
||||||
|
icon: const Icon(Icons.auto_awesome, size: 16),
|
||||||
|
label: const Text("What's new"),
|
||||||
|
),
|
||||||
if (!m.isRequired)
|
if (!m.isRequired)
|
||||||
TextButton(
|
TextButton(
|
||||||
key: const ValueKey('update-later'),
|
key: const ValueKey('update-later'),
|
||||||
@@ -127,6 +144,27 @@ class UpdateBar extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The offered version's card, with Update and restart when it installs here.
|
||||||
|
void _openWhatsNew(BuildContext context, UpdateController c, UpdateManifest m, bool canInstall) {
|
||||||
|
showWhatsNew(
|
||||||
|
context,
|
||||||
|
ReleaseNotes.of(m),
|
||||||
|
overline: canInstall ? '$appName · ready to install' : '$appName · new release',
|
||||||
|
action: canInstall
|
||||||
|
? Builder(
|
||||||
|
builder: (context) => FilledButton(
|
||||||
|
key: const ValueKey('whats-new-install'),
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.of(context).maybePop();
|
||||||
|
c.install();
|
||||||
|
},
|
||||||
|
child: const Text('Update and restart'),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// The existing update card in a dialog; closes itself when the card
|
/// The existing update card in a dialog; closes itself when the card
|
||||||
/// would render nothing (e.g. "Later").
|
/// would render nothing (e.g. "Later").
|
||||||
Future<void> showUpdateDialog(BuildContext context, {UpdateController? controller}) {
|
Future<void> showUpdateDialog(BuildContext context, {UpdateController? controller}) {
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import '../../jellyfin/session_media.dart';
|
import '../../jellyfin/session_media.dart';
|
||||||
import '../../session/session_coordinator.dart' show AutoAdvance, autoAdvanceSecs;
|
|
||||||
import '../../widgets/app_menu.dart' show AppGlass;
|
|
||||||
import 'player_controls.dart' show FloatingPill;
|
import 'player_controls.dart' show FloatingPill;
|
||||||
|
|
||||||
// Jellyfin pieces of the session player. Plain widgets fed by the
|
// Jellyfin pieces of the session player. Plain widgets fed by the
|
||||||
@@ -24,80 +22,6 @@ class NextEpisodePill extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// At the end of an episode: "Next: S1E7 title" with Play now, and while
|
|
||||||
/// the auto-advance countdown runs, the seconds left and Cancel.
|
|
||||||
class EndOfEpisodeCard extends StatelessWidget {
|
|
||||||
final EpisodeLink next;
|
|
||||||
final AutoAdvance? countdown;
|
|
||||||
final VoidCallback onPlayNow;
|
|
||||||
final VoidCallback onCancel;
|
|
||||||
const EndOfEpisodeCard({
|
|
||||||
super.key,
|
|
||||||
required this.next,
|
|
||||||
required this.countdown,
|
|
||||||
required this.onPlayNow,
|
|
||||||
required this.onCancel,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final c = countdown;
|
|
||||||
// The same card as Up next's (`UpNextCard`): the app's glass, 400 wide.
|
|
||||||
return AppGlass(
|
|
||||||
key: const ValueKey('end-of-episode'),
|
|
||||||
child: SizedBox(
|
|
||||||
width: 400,
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.all(12),
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
||||||
children: [
|
|
||||||
Text(c == null ? 'Up next' : 'Next episode in ${c.secondsLeft} s',
|
|
||||||
style: const TextStyle(color: Colors.white60, fontSize: 12)),
|
|
||||||
const SizedBox(height: 2),
|
|
||||||
Text('Next: ${next.label}',
|
|
||||||
maxLines: 2,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: const TextStyle(color: Colors.white, fontSize: 15, fontWeight: FontWeight.w600, height: 1.25)),
|
|
||||||
if (c != null) ...[
|
|
||||||
const SizedBox(height: 10),
|
|
||||||
ClipRRect(
|
|
||||||
borderRadius: BorderRadius.circular(2),
|
|
||||||
child: TweenAnimationBuilder<double>(
|
|
||||||
tween: Tween(end: c.secondsLeft / autoAdvanceSecs),
|
|
||||||
duration: const Duration(seconds: 1),
|
|
||||||
builder: (context, v, _) =>
|
|
||||||
LinearProgressIndicator(value: v, minHeight: 3, backgroundColor: const Color(0x1FFFFFFF)),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
const SizedBox(height: 10),
|
|
||||||
Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.end,
|
|
||||||
children: [
|
|
||||||
if (c != null)
|
|
||||||
TextButton(
|
|
||||||
key: const ValueKey('auto-advance-cancel'),
|
|
||||||
onPressed: onCancel,
|
|
||||||
child: const Text('Cancel')),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
FilledButton.icon(
|
|
||||||
key: const ValueKey('play-next'),
|
|
||||||
onPressed: onPlayNow,
|
|
||||||
icon: const Icon(Icons.skip_next_rounded, size: 18),
|
|
||||||
label: Text(c == null ? 'Play next' : 'Play now'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// This viewer cannot play the Jellyfin media: say why and offer to link
|
/// This viewer cannot play the Jellyfin media: say why and offer to link
|
||||||
/// an account (never a silent stall).
|
/// an account (never a silent stall).
|
||||||
class JellyfinBlockedCard extends StatelessWidget {
|
class JellyfinBlockedCard extends StatelessWidget {
|
||||||
|
|||||||
@@ -0,0 +1,305 @@
|
|||||||
|
import 'package:sync_relay_core/core.dart' show FeedMessage;
|
||||||
|
|
||||||
|
// Everything that may become a toast, a sound, an attention request or an
|
||||||
|
// activity-log line, as one sealed type. Code that has news emits an
|
||||||
|
// event ([Notifier.notify]); only the notifier decides what the user sees
|
||||||
|
// and hears. Pure Dart (no Flutter): it can move to core with the policy.
|
||||||
|
|
||||||
|
sealed class AppEvent {
|
||||||
|
const AppEvent();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- feed
|
||||||
|
|
||||||
|
/// A server `feed` line (see PROTOCOL.md, `feed.action`); every one lands
|
||||||
|
/// in the activity log.
|
||||||
|
sealed class FeedEvent extends AppEvent {
|
||||||
|
final FeedMessage feed;
|
||||||
|
/// This member is the actor (its own chat, ready, queue add...).
|
||||||
|
final bool own;
|
||||||
|
const FeedEvent(this.feed, {this.own = false});
|
||||||
|
|
||||||
|
String get actor => feed.actor;
|
||||||
|
|
||||||
|
/// [feed] as its typed event. [selfId]: this member's id (people are
|
||||||
|
/// matched by `actorId` / `targetId`, never by name); [readyCount]:
|
||||||
|
/// members ready in the latest state (the pitch of a `ready` sound);
|
||||||
|
/// [queueThumb]: the picture hash of the Up next item a `queue-add`
|
||||||
|
/// added (from the state that came with it), '' when none.
|
||||||
|
factory FeedEvent.of(FeedMessage f, {String selfId = '', int readyCount = 0, String queueThumb = ''}) {
|
||||||
|
bool isSelf(String id) => selfId.isNotEmpty && id == selfId;
|
||||||
|
final own = isSelf(f.actorId);
|
||||||
|
return switch (f.action) {
|
||||||
|
'join' => MemberJoined(f, own: own),
|
||||||
|
'leave' => MemberLeft(f, own: own),
|
||||||
|
'gone' => MemberGone(f, own: own),
|
||||||
|
'return' => MemberReturned(f, own: own),
|
||||||
|
'away' => MemberSatOut(f,
|
||||||
|
own: own,
|
||||||
|
atYou: isSelf(f.targetId) && !isSelf(f.actorId),
|
||||||
|
autoSelf: isSelf(f.actorId) && f.targetId.isEmpty && f.reason.isNotEmpty),
|
||||||
|
'in' => MemberBack(f, own: own),
|
||||||
|
'ready' => ReadyChanged(f, own: own, ready: true, readyIndex: readyCount),
|
||||||
|
'unready' => ReadyChanged(f, own: own, ready: false),
|
||||||
|
'force-ready' => ForcedReady(f, own: own, ready: true),
|
||||||
|
'force-unready' => ForcedReady(f, own: own, ready: false),
|
||||||
|
'force-play' => ForcedTransport(f, own: own, play: true),
|
||||||
|
'force-hold' => ForcedTransport(f, own: own, play: false),
|
||||||
|
'swap' => MediaSwapped(f, own: own),
|
||||||
|
'seek' => Seeked(f, own: own),
|
||||||
|
'pause' => Paused(f, own: own),
|
||||||
|
'resume' => Resumed(f, own: own),
|
||||||
|
'finished' => Finished(f, own: own),
|
||||||
|
'chat' => ChatPosted(f, own: own),
|
||||||
|
'rename' => MemberRenamed(f, own: own),
|
||||||
|
'avatar' => AvatarChanged(f, own: own),
|
||||||
|
'queue-add' => QueueAdded(f, own: own, thumb: queueThumb),
|
||||||
|
'queue-remove' => QueueRemoved(f, own: own),
|
||||||
|
'queue-skip' => QueueSkipped(f, own: own),
|
||||||
|
'fetch-fail' => FetchFailed(f, own: own),
|
||||||
|
_ => OtherFeed(f, own: own),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class MemberJoined extends FeedEvent {
|
||||||
|
const MemberJoined(super.feed, {super.own});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Left for good (`reason` `bye`: the app closed or Leave; `grace`: gone
|
||||||
|
/// too long).
|
||||||
|
class MemberLeft extends FeedEvent {
|
||||||
|
const MemberLeft(super.feed, {super.own});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lost signal: gone for a while, kept in its grace period.
|
||||||
|
class MemberGone extends FeedEvent {
|
||||||
|
const MemberGone(super.feed, {super.own});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Back within its grace after a [MemberGone].
|
||||||
|
class MemberReturned extends FeedEvent {
|
||||||
|
const MemberReturned(super.feed, {super.own});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Someone sat out (`away`: the actor itself, or `target` sat out by the
|
||||||
|
/// actor; `reason` `idle` / `minimised` for an automatic one); [atYou]:
|
||||||
|
/// someone else sat this member out; [autoSelf]: this app's auto sit-out
|
||||||
|
/// set this member away.
|
||||||
|
class MemberSatOut extends FeedEvent {
|
||||||
|
final bool atYou;
|
||||||
|
final bool autoSelf;
|
||||||
|
const MemberSatOut(super.feed, {super.own, this.atYou = false, this.autoSelf = false});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Back from sitting out (`in`).
|
||||||
|
class MemberBack extends FeedEvent {
|
||||||
|
const MemberBack(super.feed, {super.own});
|
||||||
|
}
|
||||||
|
|
||||||
|
class ReadyChanged extends FeedEvent {
|
||||||
|
final bool ready;
|
||||||
|
/// Members ready when it came (pitch of the ready sound).
|
||||||
|
final int readyIndex;
|
||||||
|
const ReadyChanged(super.feed, {super.own, required this.ready, this.readyIndex = 0});
|
||||||
|
}
|
||||||
|
|
||||||
|
class ForcedReady extends FeedEvent {
|
||||||
|
final bool ready;
|
||||||
|
const ForcedReady(super.feed, {super.own, required this.ready});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Force play / hold.
|
||||||
|
class ForcedTransport extends FeedEvent {
|
||||||
|
final bool play;
|
||||||
|
const ForcedTransport(super.feed, {super.own, required this.play});
|
||||||
|
}
|
||||||
|
|
||||||
|
class MediaSwapped extends FeedEvent {
|
||||||
|
const MediaSwapped(super.feed, {super.own});
|
||||||
|
}
|
||||||
|
|
||||||
|
class Seeked extends FeedEvent {
|
||||||
|
const Seeked(super.feed, {super.own});
|
||||||
|
}
|
||||||
|
|
||||||
|
class Paused extends FeedEvent {
|
||||||
|
const Paused(super.feed, {super.own});
|
||||||
|
}
|
||||||
|
|
||||||
|
class Resumed extends FeedEvent {
|
||||||
|
const Resumed(super.feed, {super.own});
|
||||||
|
}
|
||||||
|
|
||||||
|
class Finished extends FeedEvent {
|
||||||
|
const Finished(super.feed, {super.own});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A chat message (`reason` carries the text); [own]: this member sent it.
|
||||||
|
class ChatPosted extends FeedEvent {
|
||||||
|
const ChatPosted(super.feed, {super.own});
|
||||||
|
}
|
||||||
|
|
||||||
|
class MemberRenamed extends FeedEvent {
|
||||||
|
const MemberRenamed(super.feed, {super.own});
|
||||||
|
}
|
||||||
|
|
||||||
|
class AvatarChanged extends FeedEvent {
|
||||||
|
const AvatarChanged(super.feed, {super.own});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Someone added an item to Up next (`target` its title); [thumb]: the
|
||||||
|
/// item's picture hash ('' when none).
|
||||||
|
class QueueAdded extends FeedEvent {
|
||||||
|
final String thumb;
|
||||||
|
const QueueAdded(super.feed, {super.own, this.thumb = ''});
|
||||||
|
}
|
||||||
|
|
||||||
|
class QueueRemoved extends FeedEvent {
|
||||||
|
const QueueRemoved(super.feed, {super.own});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Up next skipped an item (`target` its title, `reason` why).
|
||||||
|
class QueueSkipped extends FeedEvent {
|
||||||
|
const QueueSkipped(super.feed, {super.own});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The server could not fetch the current media.
|
||||||
|
class FetchFailed extends FeedEvent {
|
||||||
|
const FetchFailed(super.feed, {super.own});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An action this app does not know (a newer server).
|
||||||
|
class OtherFeed extends FeedEvent {
|
||||||
|
const OtherFeed(super.feed, {super.own});
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------- session
|
||||||
|
|
||||||
|
/// Someone poked this member.
|
||||||
|
class Nudged extends AppEvent {
|
||||||
|
/// Their name; '' when unknown.
|
||||||
|
final String name;
|
||||||
|
const Nudged(this.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Another member's player cannot open the current media.
|
||||||
|
class MemberCantPlay extends AppEvent {
|
||||||
|
final String name;
|
||||||
|
final String error;
|
||||||
|
const MemberCantPlay(this.name, this.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// This member passes the video on slower than it plays.
|
||||||
|
class SlowSource extends AppEvent {
|
||||||
|
final String text;
|
||||||
|
const SlowSource(this.text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// This member started passing the video on through the server.
|
||||||
|
class RelayStarted extends AppEvent {
|
||||||
|
const RelayStarted();
|
||||||
|
}
|
||||||
|
|
||||||
|
class RelayFailed extends AppEvent {
|
||||||
|
final String error;
|
||||||
|
const RelayFailed(this.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The connection to the server dropped and stayed down a few seconds
|
||||||
|
/// (once per drop).
|
||||||
|
class ConnectionLost extends AppEvent {
|
||||||
|
const ConnectionLost();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// It came back after [ConnectionLost].
|
||||||
|
class ConnectionBack extends AppEvent {
|
||||||
|
const ConnectionBack();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The server refused something this app sent (`err`: Up next is full, a
|
||||||
|
/// forbidden action...).
|
||||||
|
class ServerRefused extends AppEvent {
|
||||||
|
final String text;
|
||||||
|
const ServerRefused(this.text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The member passing the current video on has been gone a few seconds
|
||||||
|
/// ([left]: it left the server); viewers play what the server has.
|
||||||
|
class RelaySourceDropped extends AppEvent {
|
||||||
|
final String name;
|
||||||
|
final bool left;
|
||||||
|
const RelaySourceDropped(this.name, {this.left = false});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The current media started playing for the first time ("starting
|
||||||
|
/// now"; [title]: what plays, '' when unknown).
|
||||||
|
class PlaybackStarted extends AppEvent {
|
||||||
|
final String title;
|
||||||
|
const PlaybackStarted(this.title);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A reconnect found the server restarted (another `welcome.instance`).
|
||||||
|
class ServerRestarted extends AppEvent {
|
||||||
|
const ServerRestarted();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Another window of this install took the member over (`replaced`).
|
||||||
|
class Replaced extends AppEvent {
|
||||||
|
const Replaced();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Jellyfin progress reports fail (once per media).
|
||||||
|
class JellyfinProgressFailed extends AppEvent {
|
||||||
|
final bool signInExpired;
|
||||||
|
const JellyfinProgressFailed({required this.signInExpired});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ app
|
||||||
|
|
||||||
|
/// The player's shader message (a preset change, shaders turned off).
|
||||||
|
class ShaderMessage extends AppEvent {
|
||||||
|
final String text;
|
||||||
|
const ShaderMessage(this.text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// This app added an item to Up next.
|
||||||
|
class AddedToUpNext extends AppEvent {
|
||||||
|
final String title;
|
||||||
|
const AddedToUpNext(this.title);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Putting something up or adding it to Up next failed ([text]: why).
|
||||||
|
class ActionFailed extends AppEvent {
|
||||||
|
final String text;
|
||||||
|
const ActionFailed(this.text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The Schedule tab's request results and errors.
|
||||||
|
class ScheduleResult extends AppEvent {
|
||||||
|
final String text;
|
||||||
|
const ScheduleResult(this.text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// "Mark a problem" done; [inSession]: in the server's session log.
|
||||||
|
class ProblemMarked extends AppEvent {
|
||||||
|
final bool inSession;
|
||||||
|
const ProblemMarked({required this.inSession});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Something went on the clipboard ([text]: the confirmation, e.g.
|
||||||
|
/// "Link copied").
|
||||||
|
class Copied extends AppEvent {
|
||||||
|
final String text;
|
||||||
|
const Copied(this.text);
|
||||||
|
}
|
||||||
|
|
||||||
|
class CopyFailed extends AppEvent {
|
||||||
|
const CopyFailed();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A newer release is out (the update band shows it).
|
||||||
|
class UpdateAvailable extends AppEvent {
|
||||||
|
final String version;
|
||||||
|
const UpdateAvailable(this.version);
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import 'package:sync_relay_core/core.dart' show Presence, PresenceReason;
|
||||||
|
|
||||||
|
import '../platform/ports/shell_port.dart' show WindowActivity;
|
||||||
|
|
||||||
|
/// The auto sit-out timings, in one place.
|
||||||
|
abstract final class AutoSitOutTimes {
|
||||||
|
/// Not playing and no input for this long: away (`idle`). Watching
|
||||||
|
/// counts as activity, so a pause after a long watch starts fresh.
|
||||||
|
static const idle = Duration(minutes: 5);
|
||||||
|
|
||||||
|
/// Minimised (or hidden) for this long, playing or not: away
|
||||||
|
/// (`minimised`).
|
||||||
|
static const minimised = Duration(minutes: 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What [AutoSitOut.tick] asks the session to do.
|
||||||
|
sealed class AutoSitOutStep {
|
||||||
|
const AutoSitOutStep();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Nothing to do.
|
||||||
|
final class AutoStay extends AutoSitOutStep {
|
||||||
|
const AutoStay();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sit this member out (`presence` `away` with [reason]).
|
||||||
|
final class AutoAway extends AutoSitOutStep {
|
||||||
|
final PresenceReason reason;
|
||||||
|
const AutoAway(this.reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bring this member back `in`: it was set away automatically and the
|
||||||
|
/// user is back.
|
||||||
|
final class AutoBack extends AutoSitOutStep {
|
||||||
|
const AutoBack();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Auto sit-out: idle for
|
||||||
|
/// [AutoSitOutTimes.idle] while nothing plays, or minimised for
|
||||||
|
/// [AutoSitOutTimes.minimised] (playing or not), sets this member away;
|
||||||
|
/// input or the window's focus brings it back, but only from an away
|
||||||
|
/// this set ([autoAway]); a manual away (own or someone else's) stays.
|
||||||
|
/// Never when alone (nobody to hold up or to tell). Pure: the session
|
||||||
|
/// feeds it input, window changes and a 1 s [tick].
|
||||||
|
class AutoSitOut {
|
||||||
|
final Duration idleAfter;
|
||||||
|
final Duration minimisedAfter;
|
||||||
|
AutoSitOut(DateTime now,
|
||||||
|
{this.idleAfter = AutoSitOutTimes.idle, this.minimisedAfter = AutoSitOutTimes.minimised})
|
||||||
|
: _lastActive = now;
|
||||||
|
|
||||||
|
DateTime _lastActive;
|
||||||
|
DateTime? _minimisedSince;
|
||||||
|
bool _activitySinceAway = false;
|
||||||
|
bool _autoAway = false;
|
||||||
|
// An away sent, not yet seen in a state.
|
||||||
|
DateTime? _sentAt;
|
||||||
|
static const _echoWait = Duration(seconds: 10);
|
||||||
|
|
||||||
|
/// The member is away because this set it (not by hand, not someone
|
||||||
|
/// else).
|
||||||
|
bool get autoAway => _autoAway;
|
||||||
|
|
||||||
|
/// Keyboard, mouse or any action in the app.
|
||||||
|
void input(DateTime now) {
|
||||||
|
_lastActive = now;
|
||||||
|
_activitySinceAway = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The window's focus or visibility changed.
|
||||||
|
void window(WindowActivity a, DateTime now) {
|
||||||
|
switch (a) {
|
||||||
|
case WindowActivity.minimised:
|
||||||
|
case WindowActivity.hidden:
|
||||||
|
_minimisedSince ??= now;
|
||||||
|
case WindowActivity.focused:
|
||||||
|
_minimisedSince = null;
|
||||||
|
input(now);
|
||||||
|
case WindowActivity.unfocused:
|
||||||
|
_minimisedSince = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The user sat out (or came back) by hand: an away is no longer ours.
|
||||||
|
void manual() {
|
||||||
|
_autoAway = false;
|
||||||
|
_sentAt = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Once a second. [self]: this member's presence in the latest state
|
||||||
|
/// (null: not in one); [alone]: nobody else connected ([isAlone]);
|
||||||
|
/// [enabled]: the setting.
|
||||||
|
AutoSitOutStep tick(DateTime now,
|
||||||
|
{required Presence? self, required bool playing, required bool alone, required bool enabled}) {
|
||||||
|
if (playing) _lastActive = now;
|
||||||
|
if (self == null || self == Presence.gone) return const AutoStay();
|
||||||
|
if (self != Presence.away) {
|
||||||
|
final sent = _sentAt;
|
||||||
|
if (sent != null && now.difference(sent) < _echoWait) return const AutoStay();
|
||||||
|
_sentAt = null;
|
||||||
|
_autoAway = false;
|
||||||
|
} else {
|
||||||
|
_sentAt = null;
|
||||||
|
if (_autoAway && _activitySinceAway && _minimisedSince == null) {
|
||||||
|
_autoAway = false;
|
||||||
|
return const AutoBack();
|
||||||
|
}
|
||||||
|
return const AutoStay();
|
||||||
|
}
|
||||||
|
if (!enabled || alone) return const AutoStay();
|
||||||
|
final since = _minimisedSince;
|
||||||
|
final PresenceReason? reason;
|
||||||
|
if (since != null && now.difference(since) >= minimisedAfter) {
|
||||||
|
reason = PresenceReason.minimised;
|
||||||
|
} else if (!playing && now.difference(_lastActive) >= idleAfter) {
|
||||||
|
reason = PresenceReason.idle;
|
||||||
|
} else {
|
||||||
|
reason = null;
|
||||||
|
}
|
||||||
|
if (reason == null) return const AutoStay();
|
||||||
|
_autoAway = true;
|
||||||
|
_activitySinceAway = false;
|
||||||
|
_sentAt = now;
|
||||||
|
return AutoAway(reason);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,716 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
|
import 'dart:math';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart' show debugPrint;
|
||||||
|
import 'package:signals/signals_core.dart';
|
||||||
|
import 'package:sync_relay_core/core.dart';
|
||||||
|
|
||||||
|
import '../screens/home/ui.dart' show appName, displayVersion;
|
||||||
|
import '../state/settings.dart';
|
||||||
|
import '../sync/sync_client.dart';
|
||||||
|
import 'app_events.dart';
|
||||||
|
import 'identity.dart' show ClientIdentity;
|
||||||
|
|
||||||
|
/// Thrown when a connect is superseded by cancel/leave before it finished.
|
||||||
|
class ConnectCancelled implements Exception {
|
||||||
|
const ConnectCancelled();
|
||||||
|
@override
|
||||||
|
String toString() => 'connection cancelled';
|
||||||
|
}
|
||||||
|
|
||||||
|
class ConnectParams {
|
||||||
|
final String host;
|
||||||
|
final int port;
|
||||||
|
final String secret;
|
||||||
|
final String name;
|
||||||
|
final String avatar;
|
||||||
|
final String mediaPath;
|
||||||
|
final String linkUrl;
|
||||||
|
/// Sent in hello by the hosting app's own client (see AppConfig.hostKey).
|
||||||
|
final String hostKey;
|
||||||
|
/// Put up with a `swap` right after joining (a Jellyfin ref or a link);
|
||||||
|
/// wins over [linkUrl].
|
||||||
|
final HelloMedia? media;
|
||||||
|
const ConnectParams({
|
||||||
|
required this.host,
|
||||||
|
required this.port,
|
||||||
|
required this.secret,
|
||||||
|
required this.name,
|
||||||
|
this.avatar = '',
|
||||||
|
this.mediaPath = '',
|
||||||
|
this.linkUrl = '',
|
||||||
|
this.hostKey = '',
|
||||||
|
this.media,
|
||||||
|
});
|
||||||
|
|
||||||
|
ConnectParams withMedia(HelloMedia? media) => ConnectParams(
|
||||||
|
host: host,
|
||||||
|
port: port,
|
||||||
|
secret: secret,
|
||||||
|
name: name,
|
||||||
|
avatar: avatar,
|
||||||
|
mediaPath: mediaPath,
|
||||||
|
linkUrl: media == null ? linkUrl : '',
|
||||||
|
hostKey: hostKey,
|
||||||
|
media: media,
|
||||||
|
);
|
||||||
|
|
||||||
|
ConnectParams withProfile({String? name, String? avatar}) => ConnectParams(
|
||||||
|
host: host,
|
||||||
|
port: port,
|
||||||
|
secret: secret,
|
||||||
|
name: name ?? this.name,
|
||||||
|
avatar: avatar ?? this.avatar,
|
||||||
|
mediaPath: mediaPath,
|
||||||
|
linkUrl: linkUrl,
|
||||||
|
hostKey: hostKey,
|
||||||
|
media: media,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where the [Connection] is. Each change is a new value (a repeat is
|
||||||
|
/// notified too).
|
||||||
|
sealed class ConnState {
|
||||||
|
const ConnState();
|
||||||
|
|
||||||
|
/// The reconnect loop runs ([ConnLost] or [ConnReconnecting]).
|
||||||
|
bool get retrying => false;
|
||||||
|
|
||||||
|
/// A welcomed socket is up ([ConnOnline] or [ConnUnstable]): what the
|
||||||
|
/// server says is current.
|
||||||
|
bool get online => false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Not connected and not trying: before the first connect, after leave,
|
||||||
|
/// cancel or "stop reconnecting".
|
||||||
|
final class ConnClosed extends ConnState {
|
||||||
|
const ConnClosed();
|
||||||
|
@override
|
||||||
|
String toString() => 'closed';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The first dial of a session (hello sent, waiting for welcome).
|
||||||
|
final class ConnConnecting extends ConnState {
|
||||||
|
const ConnConnecting();
|
||||||
|
@override
|
||||||
|
String toString() => 'connecting';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Welcome received; frames arrive.
|
||||||
|
final class ConnOnline extends ConnState {
|
||||||
|
const ConnOnline();
|
||||||
|
@override
|
||||||
|
bool get online => true;
|
||||||
|
@override
|
||||||
|
String toString() => 'online';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Still the same socket, but nothing came for [Connection.unstableAfter]
|
||||||
|
/// (the server sends a state every second): the link may be dying. Back
|
||||||
|
/// to [ConnOnline] with the next frame, [ConnLost] at
|
||||||
|
/// [Connection.silenceLimit].
|
||||||
|
final class ConnUnstable extends ConnState {
|
||||||
|
const ConnUnstable();
|
||||||
|
@override
|
||||||
|
bool get online => true;
|
||||||
|
@override
|
||||||
|
String toString() => 'unstable';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dropped (the socket closed, or nothing came for
|
||||||
|
/// [Connection.silenceLimit]); the next attempt is scheduled. The session
|
||||||
|
/// keeps what it had (the last state, the player, a relay it serves).
|
||||||
|
final class ConnLost extends ConnState {
|
||||||
|
const ConnLost();
|
||||||
|
@override
|
||||||
|
bool get retrying => true;
|
||||||
|
@override
|
||||||
|
String toString() => 'lost';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reconnect attempt [attempt] (1 is the first after the drop) is
|
||||||
|
/// dialling; after it failed it stays until the next one, unless the
|
||||||
|
/// socket's close said [ConnLost] meanwhile.
|
||||||
|
final class ConnReconnecting extends ConnState {
|
||||||
|
final int attempt;
|
||||||
|
const ConnReconnecting(this.attempt);
|
||||||
|
@override
|
||||||
|
bool get retrying => true;
|
||||||
|
@override
|
||||||
|
String toString() => 'reconnecting($attempt)';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The server refused this app; nothing retries. [code]: `version` or
|
||||||
|
/// `secret` when the server said so, `closed` when the host stopped
|
||||||
|
/// sharing (close 4016), else empty (offline, timeout,
|
||||||
|
/// anything else on the first dial); [message]: in plain words
|
||||||
|
/// ([connectErrorText]).
|
||||||
|
final class ConnRefused extends ConnState {
|
||||||
|
final String code;
|
||||||
|
final String message;
|
||||||
|
const ConnRefused(this.code, this.message);
|
||||||
|
@override
|
||||||
|
String toString() => 'refused($code: $message)';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Another connection of this install holds the member (`err` `replaced`,
|
||||||
|
/// close 4013, or this hello was `stale`): Starsync was opened in another
|
||||||
|
/// window. Nothing retries ("Retry" takes the member back).
|
||||||
|
final class ConnReplaced extends ConnState {
|
||||||
|
const ConnReplaced();
|
||||||
|
String get message => replacedText;
|
||||||
|
@override
|
||||||
|
String toString() => 'replaced';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [ConnReplaced] in plain words.
|
||||||
|
const replacedText = 'Starsync was opened in another window';
|
||||||
|
|
||||||
|
/// `ConnRefused('closed')`: the host closed their server to friends
|
||||||
|
/// (close 4016).
|
||||||
|
const stoppedSharingText = 'the host stopped sharing their server';
|
||||||
|
|
||||||
|
/// A version refusal in plain words: [err] is the server's `err`
|
||||||
|
/// `version` (its range in `min`/`max`), or a proto 15 server's codeless
|
||||||
|
/// `proto mismatch`, which is always an older host.
|
||||||
|
String versionText(ErrorFrame? err) {
|
||||||
|
final max = err?.max, min = err?.min;
|
||||||
|
if (err?.code == null || (max != null && max < RelayConstants.protoMin)) {
|
||||||
|
return 'the host runs an older Starsync — they need to update';
|
||||||
|
}
|
||||||
|
if (min != null && min > RelayConstants.protoMax) return 'the host runs a newer Starsync — update the app';
|
||||||
|
return 'the host runs a different Starsync version — update both apps';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A failed dial in plain words.
|
||||||
|
String connectErrorText(Object e) {
|
||||||
|
if (e is HelloRefused) {
|
||||||
|
final err = e.error;
|
||||||
|
if (err != null && err.code == null && err.message.contains('proto mismatch')) return versionText(err);
|
||||||
|
return switch (e.code) {
|
||||||
|
ErrCode.version => versionText(err),
|
||||||
|
ErrCode.secret => 'wrong server password (401)',
|
||||||
|
ErrCode.replaced || ErrCode.stale => replacedText,
|
||||||
|
_ => 'refused: ${err?.message ?? 'closed (${e.closeCode})'}',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
final msg = '$e';
|
||||||
|
if (msg.contains('SocketException') || msg.contains('Connection refused')) {
|
||||||
|
return 'host offline — check address and port';
|
||||||
|
}
|
||||||
|
return 'connect failed: $e';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The terminal state a failed dial leaves, or null when another attempt
|
||||||
|
/// may work.
|
||||||
|
ConnState? _terminalFor(Object e, {required bool first}) {
|
||||||
|
if (e is HelloRefused) {
|
||||||
|
final err = e.error;
|
||||||
|
if (err != null && err.code == null && err.message.contains('proto mismatch')) {
|
||||||
|
return ConnRefused('version', versionText(err));
|
||||||
|
}
|
||||||
|
switch (e.code) {
|
||||||
|
case ErrCode.replaced:
|
||||||
|
case ErrCode.stale:
|
||||||
|
return const ConnReplaced();
|
||||||
|
case ErrCode.version:
|
||||||
|
return ConnRefused('version', connectErrorText(e));
|
||||||
|
case ErrCode.secret:
|
||||||
|
return ConnRefused('secret', connectErrorText(e));
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return first ? ConnRefused('', connectErrorText(e)) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a hello says about this install besides the connect form.
|
||||||
|
typedef HelloIdentity = ({String client, int seq, ClientCaps caps, int? feedAfter});
|
||||||
|
|
||||||
|
/// Builds the `hello` for [params]. Injectable so later protocol versions
|
||||||
|
/// add fields without changing [Connection].
|
||||||
|
typedef HelloBuilder = HelloMessage Function(ConnectParams params, HelloIdentity id);
|
||||||
|
|
||||||
|
/// Protocol 16's hello: this build's version range, the install's client
|
||||||
|
/// id and connection count, the app version, platform and what it can do;
|
||||||
|
/// `feedAfter` on a reconnect to the same server.
|
||||||
|
HelloMessage defaultHello(ConnectParams params, HelloIdentity id) => HelloMessage(
|
||||||
|
proto: RelayConstants.protoMax,
|
||||||
|
protoMin: RelayConstants.protoMin,
|
||||||
|
secret: params.secret,
|
||||||
|
user: HelloUser(name: params.name, avatar: params.avatar),
|
||||||
|
hostKey: params.hostKey,
|
||||||
|
client: id.client,
|
||||||
|
seq: id.seq,
|
||||||
|
app: appVersion,
|
||||||
|
platform: Platform.operatingSystem,
|
||||||
|
caps: id.caps,
|
||||||
|
feedAfter: id.feedAfter,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// How a welcome relates to the member this app held before.
|
||||||
|
typedef Rejoin = ({
|
||||||
|
/// A reconnect of the running session (not its first dial).
|
||||||
|
bool reconnect,
|
||||||
|
/// `welcome.resumed`: the same member, everything kept.
|
||||||
|
bool resumed,
|
||||||
|
/// The same server boot as the last welcome (`welcome.instance`).
|
||||||
|
bool sameServer,
|
||||||
|
/// The token the session held before this welcome ('' on the first).
|
||||||
|
String oldToken,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// The session's part of a welcome, called before its state goes out
|
||||||
|
/// (see [Connection.onWelcome]).
|
||||||
|
typedef WelcomeHandler = void Function(WelcomeMessage welcome, Rejoin rejoin);
|
||||||
|
|
||||||
|
/// The link to the server: the [SyncClient] socket, the first connect,
|
||||||
|
/// reconnects (backoff with jitter between attempts, epochs that make a
|
||||||
|
/// cancelled attempt stale), the silence policy ("unstable" after
|
||||||
|
/// [unstableAfter] without a frame, lost at [silenceLimit]), the 1 s
|
||||||
|
/// heartbeat (clock ping, which also keeps this member under the
|
||||||
|
/// server's silence limit, and diagnostics), the connection lost/back
|
||||||
|
/// events and [state]. The install's identity rides in every hello
|
||||||
|
/// ([identity]); what a welcome means to the session is its [onWelcome].
|
||||||
|
class Connection {
|
||||||
|
final SyncClient client;
|
||||||
|
/// Where [ConnectionLost] / [ConnectionBack] go (the Notifier).
|
||||||
|
final void Function(AppEvent event) _notify;
|
||||||
|
/// Builds each dial's hello.
|
||||||
|
final HelloBuilder hello;
|
||||||
|
/// `hello.client` / `hello.seq` (the stored one in the app; a fresh
|
||||||
|
/// in-memory one by default, so every test connection is its own
|
||||||
|
/// install).
|
||||||
|
final ClientIdentity identity;
|
||||||
|
/// `hello.caps` at each dial (the session sets it).
|
||||||
|
ClientCaps Function() caps;
|
||||||
|
/// Wait before the first reconnect attempt; it doubles per failed
|
||||||
|
/// attempt up to [maxReconnectDelay], each with ±20 % jitter.
|
||||||
|
final Duration reconnectDelay;
|
||||||
|
final Duration maxReconnectDelay;
|
||||||
|
/// No frame for this long: [ConnUnstable].
|
||||||
|
final Duration unstableAfter;
|
||||||
|
/// No frame for this long means the connection is lost.
|
||||||
|
final Duration silenceLimit;
|
||||||
|
/// The lost sound plays only once a drop lasted this long (a quick
|
||||||
|
/// reconnect says nothing, and no lost/back pair per flap).
|
||||||
|
final Duration lostSoundAfter;
|
||||||
|
/// The clock the silence and the lost sound are measured on (UTC; a
|
||||||
|
/// test passes a fake one). The heartbeat that checks it stays a real
|
||||||
|
/// 1 s timer.
|
||||||
|
final DateTime Function() now;
|
||||||
|
final Random _random;
|
||||||
|
|
||||||
|
Connection(
|
||||||
|
{void Function(AppEvent event)? notify,
|
||||||
|
SyncClient? client,
|
||||||
|
this.hello = defaultHello,
|
||||||
|
ClientIdentity? identity,
|
||||||
|
ClientCaps Function()? caps,
|
||||||
|
this.reconnectDelay = const Duration(seconds: 1),
|
||||||
|
this.maxReconnectDelay = const Duration(seconds: 15),
|
||||||
|
this.unstableAfter = const Duration(seconds: 3),
|
||||||
|
this.silenceLimit = const Duration(seconds: 9),
|
||||||
|
this.lostSoundAfter = const Duration(seconds: 4),
|
||||||
|
DateTime Function()? now,
|
||||||
|
Random? random})
|
||||||
|
: _notify = notify ?? ((_) {}),
|
||||||
|
now = now ?? _utcNow,
|
||||||
|
identity = identity ?? ClientIdentity.ephemeral(),
|
||||||
|
caps = caps ?? (() => const ClientCaps(source: true)),
|
||||||
|
_random = random ?? Random(),
|
||||||
|
client = client ?? SyncClient() {
|
||||||
|
this.client.onFrame = () => _lastFrameAt = this.now();
|
||||||
|
_subs = [
|
||||||
|
this.client.status.listen(_onStatus),
|
||||||
|
this.client.errors.listen(_onError),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
late final List<StreamSubscription> _subs;
|
||||||
|
|
||||||
|
static DateTime _utcNow() => DateTime.now().toUtc();
|
||||||
|
|
||||||
|
final _state = signal<ConnState>(const ConnClosed());
|
||||||
|
/// Every change is notified, a repeat too.
|
||||||
|
ReadonlySignal<ConnState> get state => _state;
|
||||||
|
|
||||||
|
/// Set by the session (see [WelcomeHandler]).
|
||||||
|
WelcomeHandler? onWelcome;
|
||||||
|
|
||||||
|
/// This app's log lines to the server's session log (Settings: "Share
|
||||||
|
/// diagnostics with the server"), and "Mark a problem". Runs from
|
||||||
|
/// [begin] to [close].
|
||||||
|
late final DiagUploader diag = DiagUploader(
|
||||||
|
target: _diagTarget, version: '$appName ${displayVersion()} on ${Platform.operatingSystem}');
|
||||||
|
|
||||||
|
DiagTarget? _diagTarget() {
|
||||||
|
final uri = client.wsUri;
|
||||||
|
if (client.token.isEmpty || uri == null || !client.clockSync.hasSample) return null;
|
||||||
|
return (base: DiagUploader.baseOf(uri), token: client.token, offsetMs: client.clockSync.offsetMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
ConnectParams? _params;
|
||||||
|
/// The last connect's parameters (with [updateProfile]'s changes); a
|
||||||
|
/// reconnect dials with them.
|
||||||
|
ConnectParams? get params => _params;
|
||||||
|
|
||||||
|
// Bumped by every connect/leave/cancel/retry: stale attempts no-op.
|
||||||
|
int _epoch = 0;
|
||||||
|
Timer? _reconnectTimer;
|
||||||
|
Timer? _heartbeat;
|
||||||
|
// Set by close/cancel/a terminal refusal: no reconnect, no lost detection.
|
||||||
|
bool _closed = false;
|
||||||
|
bool _everOnline = false;
|
||||||
|
/// The connection-lost sound played; the reconnected one answers it once.
|
||||||
|
bool _lostSounded = false;
|
||||||
|
DateTime? _lastFrameAt;
|
||||||
|
/// When the current drop began (the last frame for a silent link).
|
||||||
|
DateTime? _droppedAt;
|
||||||
|
int _attempt = 0;
|
||||||
|
/// Non-terminal `err` frames, in plain words.
|
||||||
|
final _errorTexts = StreamController<String>.broadcast();
|
||||||
|
|
||||||
|
// The socket and what the server said, read through.
|
||||||
|
Stream<StateBroadcast> get states => client.states;
|
||||||
|
/// Feed entries, chat included; replayed ones after a reconnect.
|
||||||
|
Stream<FeedMessage> get feeds => client.feeds;
|
||||||
|
Stream<NudgeNotice> get nudges => client.nudges;
|
||||||
|
/// `err` frames after the welcome (a `replaced` one ends the connection
|
||||||
|
/// instead).
|
||||||
|
Stream<String> get errors => _errorTexts.stream;
|
||||||
|
String get token => client.token;
|
||||||
|
String get memberId => client.memberId;
|
||||||
|
/// This member's `/j/...` URL from `welcome`.
|
||||||
|
String? get relayUrl => client.relayUrl;
|
||||||
|
StateBroadcast? get lastState => client.lastState;
|
||||||
|
/// Now on the server's clock (the ping/pong clock offset).
|
||||||
|
DateTime serverNow() => client.serverNow();
|
||||||
|
|
||||||
|
void sendReady(bool ready, {String source = 'manual'}) => client.sendReady(ready, source: source);
|
||||||
|
void sendForce(String target, bool ready) => client.sendForce(target, ready);
|
||||||
|
void sendSeek(double position, {int seq = 0}) => client.sendSeek(position, seq: seq);
|
||||||
|
void sendSwap(HelloMedia media, {String? queueId, SwapRestore? restore}) =>
|
||||||
|
client.sendSwap(media, queueId: queueId, restore: restore);
|
||||||
|
void sendQueue(QueueIntent intent) => client.sendQueue(intent);
|
||||||
|
void sendReport({
|
||||||
|
required double position,
|
||||||
|
required double buffered,
|
||||||
|
required int eventId,
|
||||||
|
double duration = 0,
|
||||||
|
int mediaId = 0,
|
||||||
|
Map<String, bool>? jf,
|
||||||
|
String error = '',
|
||||||
|
}) =>
|
||||||
|
client.sendReport(
|
||||||
|
position: position,
|
||||||
|
buffered: buffered,
|
||||||
|
eventId: eventId,
|
||||||
|
duration: duration,
|
||||||
|
mediaId: mediaId,
|
||||||
|
jf: jf,
|
||||||
|
error: error);
|
||||||
|
void sendChat(String text) => client.sendChat(text);
|
||||||
|
void sendArt(int mediaId, {String? poster, String? banner}) =>
|
||||||
|
client.sendArt(mediaId, poster: poster, banner: banner);
|
||||||
|
void sendPresence(PresenceIntent intent) => client.sendPresence(intent);
|
||||||
|
void sendNudge(String target) => client.sendNudge(target);
|
||||||
|
|
||||||
|
/// Name and/or picture change for everyone now; reconnects send them too.
|
||||||
|
void updateProfile({String? name, String? avatar}) {
|
||||||
|
_params = _params?.withProfile(name: name, avatar: avatar);
|
||||||
|
client.sendProfile(name: name, avatar: avatar);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A new epoch: attempts in flight become stale, a scheduled reconnect
|
||||||
|
/// is dropped. Returns it.
|
||||||
|
int invalidate() {
|
||||||
|
_epoch++;
|
||||||
|
_reconnectTimer?.cancel();
|
||||||
|
_reconnectTimer = null;
|
||||||
|
return _epoch;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// False once a cancel, retry, leave or new connect followed [epoch].
|
||||||
|
bool isCurrent(int epoch) => epoch == _epoch;
|
||||||
|
|
||||||
|
/// A new session with [params]: forgets the last one's server and
|
||||||
|
/// starts the diagnostics. [dial] connects.
|
||||||
|
void begin(ConnectParams params) {
|
||||||
|
_params = params;
|
||||||
|
_closed = false;
|
||||||
|
_everOnline = false;
|
||||||
|
_lostSounded = false;
|
||||||
|
_droppedAt = null;
|
||||||
|
_attempt = 0;
|
||||||
|
diag
|
||||||
|
..share = AppSettings.instance.shareDiagnostics.peek()
|
||||||
|
..start();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The first dial of the session [begin] started. Throws
|
||||||
|
/// [ConnectCancelled] when [epoch] went stale meanwhile (whoever bumped
|
||||||
|
/// it also closed the socket); any other failure leaves [state] refused
|
||||||
|
/// (or replaced).
|
||||||
|
Future<WelcomeMessage> dial(int epoch) async {
|
||||||
|
final params = _params!;
|
||||||
|
_set(const ConnConnecting());
|
||||||
|
try {
|
||||||
|
final welcome = await _connect(params, reconnect: false);
|
||||||
|
if (epoch != _epoch) throw const ConnectCancelled();
|
||||||
|
_online();
|
||||||
|
return welcome;
|
||||||
|
} catch (e) {
|
||||||
|
if (epoch != _epoch) throw const ConnectCancelled();
|
||||||
|
debugPrint('connect failed: $e');
|
||||||
|
_set(_terminalFor(e, first: true)!);
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<WelcomeMessage> _connect(ConnectParams params, {required bool reconnect}) {
|
||||||
|
final oldToken = client.token;
|
||||||
|
final before = client.instance;
|
||||||
|
final id = (
|
||||||
|
client: identity.clientId,
|
||||||
|
seq: identity.nextSeq(),
|
||||||
|
caps: caps(),
|
||||||
|
feedAfter: reconnect && before.isNotEmpty && client.lastFeedSeq > 0 ? client.lastFeedSeq : null,
|
||||||
|
);
|
||||||
|
return client.connectWith(
|
||||||
|
wsUri: _wsUri(params),
|
||||||
|
hello: hello(params, id),
|
||||||
|
onWelcome: (w) {
|
||||||
|
_lastFrameAt = now();
|
||||||
|
onWelcome?.call(w, (
|
||||||
|
reconnect: reconnect,
|
||||||
|
resumed: w.resumed,
|
||||||
|
sameServer: before.isNotEmpty && before == w.instance,
|
||||||
|
oldToken: oldToken,
|
||||||
|
));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Uri _wsUri(ConnectParams p) => Uri.parse('ws://${p.host}:${p.port}/sync');
|
||||||
|
|
||||||
|
void _online() {
|
||||||
|
_attempt = 0;
|
||||||
|
_everOnline = true;
|
||||||
|
_droppedAt = null;
|
||||||
|
if (_lostSounded) {
|
||||||
|
_lostSounded = false;
|
||||||
|
_notify(const ConnectionBack());
|
||||||
|
}
|
||||||
|
_lastFrameAt = now();
|
||||||
|
_set(const ConnOnline());
|
||||||
|
_heartbeat ??= Timer.periodic(const Duration(seconds: 1), (_) => _beat());
|
||||||
|
}
|
||||||
|
|
||||||
|
void _set(ConnState s) => _state.set(s, force: true);
|
||||||
|
|
||||||
|
void _onStatus(SyncStatus s) {
|
||||||
|
// Before the first welcome a close is just the dial failing; during a
|
||||||
|
// reconnect attempt the attempt reports it. A socket error (a reset
|
||||||
|
// link) may come without a close.
|
||||||
|
if (s != SyncStatus.closed && s != SyncStatus.error) return;
|
||||||
|
if (_closed || !_everOnline || !_state.peek().online) return;
|
||||||
|
switch (s == SyncStatus.closed ? client.closeCode : null) {
|
||||||
|
case CloseCodes.replaced:
|
||||||
|
case CloseCodes.stale:
|
||||||
|
_end(const ConnReplaced());
|
||||||
|
case CloseCodes.version:
|
||||||
|
_end(ConnRefused('version', versionText(null)));
|
||||||
|
case CloseCodes.secret:
|
||||||
|
_end(const ConnRefused('secret', 'wrong server password (401)'));
|
||||||
|
case CloseCodes.closed:
|
||||||
|
_end(const ConnRefused('closed', stoppedSharingText));
|
||||||
|
default:
|
||||||
|
_dropped(now());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onError(ErrorFrame e) {
|
||||||
|
if (e.code == ErrCode.replaced) {
|
||||||
|
_end(const ConnReplaced());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!_errorTexts.isClosed) _errorTexts.add(e.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A terminal state: no more attempts, the socket goes.
|
||||||
|
void _end(ConnState s) {
|
||||||
|
debugPrint('connection ended: $s');
|
||||||
|
_halt(s);
|
||||||
|
unawaited(client.drop());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lost since [since]: the socket goes, a reconnect is scheduled (unless
|
||||||
|
/// one already is or dials).
|
||||||
|
void _dropped(DateTime since) {
|
||||||
|
if (_closed || _state.peek().retrying) return;
|
||||||
|
_droppedAt = since;
|
||||||
|
unawaited(client.drop());
|
||||||
|
scheduleReconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every second once online: a ping (a clock sample; it also keeps this
|
||||||
|
/// member under the server's silence limit while nothing else is sent),
|
||||||
|
/// the diagnostics, the silence check and the delayed lost sound.
|
||||||
|
void _beat() {
|
||||||
|
final t = now();
|
||||||
|
final s = _state.peek();
|
||||||
|
if (s.online) client.sendPing();
|
||||||
|
diag
|
||||||
|
..share = AppSettings.instance.shareDiagnostics.peek()
|
||||||
|
..tick();
|
||||||
|
if (_closed) return;
|
||||||
|
final last = _lastFrameAt;
|
||||||
|
if (s.online && last != null) {
|
||||||
|
final quiet = t.difference(last);
|
||||||
|
if (quiet >= silenceLimit) {
|
||||||
|
debugPrint('connection: nothing for ${quiet.inSeconds} s; reconnecting');
|
||||||
|
_dropped(last);
|
||||||
|
} else if (quiet >= unstableAfter) {
|
||||||
|
if (s is ConnOnline) _set(const ConnUnstable());
|
||||||
|
} else if (s is ConnUnstable) {
|
||||||
|
_set(const ConnOnline());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
final since = _droppedAt;
|
||||||
|
if (_state.peek().retrying && since != null && !_lostSounded && t.difference(since) >= lostSoundAfter) {
|
||||||
|
_lostSounded = true;
|
||||||
|
_notify(const ConnectionLost());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reconnect after the backoff ([state] lost), unless closed, never
|
||||||
|
/// online (unless [manual]) or already retrying.
|
||||||
|
void scheduleReconnect({bool manual = false}) {
|
||||||
|
if (_closed || _params == null) return;
|
||||||
|
if (!manual && !_everOnline) return;
|
||||||
|
if (_state.peek().retrying) return;
|
||||||
|
_droppedAt ??= now();
|
||||||
|
_set(const ConnLost());
|
||||||
|
_armReconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The wait before attempt [attempt] (1 = the first after a drop).
|
||||||
|
Duration delayFor(int attempt) {
|
||||||
|
final base = reconnectDelay.inMilliseconds * pow(2, max(0, attempt - 1));
|
||||||
|
final capped = min(base.toDouble(), maxReconnectDelay.inMilliseconds.toDouble());
|
||||||
|
return Duration(milliseconds: (capped * (0.8 + 0.4 * _random.nextDouble())).round());
|
||||||
|
}
|
||||||
|
|
||||||
|
void _armReconnect() {
|
||||||
|
final epoch = _epoch;
|
||||||
|
_reconnectTimer?.cancel();
|
||||||
|
_reconnectTimer = Timer(delayFor(_attempt + 1), () => _reconnect(epoch));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One reconnect attempt. An attempt from an older epoch must not touch
|
||||||
|
/// state (it would kill the newer connection).
|
||||||
|
Future<void> _reconnect(int epoch) async {
|
||||||
|
_reconnectTimer = null;
|
||||||
|
bool stale() => epoch != _epoch;
|
||||||
|
final params = _params;
|
||||||
|
if (stale() || params == null || _closed) return;
|
||||||
|
try {
|
||||||
|
_set(ConnReconnecting(++_attempt));
|
||||||
|
await _connect(params, reconnect: true);
|
||||||
|
// Whoever bumped the epoch also closed (or replaced) the socket.
|
||||||
|
if (stale()) return;
|
||||||
|
_online();
|
||||||
|
} catch (e) {
|
||||||
|
if (stale()) return;
|
||||||
|
debugPrint('reconnect failed: $e');
|
||||||
|
final end = _terminalFor(e, first: false);
|
||||||
|
if (end != null) {
|
||||||
|
_end(end);
|
||||||
|
} else {
|
||||||
|
_retryAfterFailure(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _retryAfterFailure(Object e) {
|
||||||
|
if (_closed) {
|
||||||
|
_set(const ConnClosed());
|
||||||
|
} else if (!_everOnline) {
|
||||||
|
// Only a manual retry before any welcome gets here.
|
||||||
|
_set(_terminalFor(e, first: true)!);
|
||||||
|
} else {
|
||||||
|
if (!_state.peek().retrying) _set(const ConnLost());
|
||||||
|
_armReconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// "Stop reconnecting": no more attempts; one dialling is aborted.
|
||||||
|
void cancelReconnect() {
|
||||||
|
_halt(const ConnClosed());
|
||||||
|
unawaited(client.drop());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cancel during setup: aborts the dial.
|
||||||
|
Future<void> cancelConnect() async {
|
||||||
|
_halt(const ConnClosed());
|
||||||
|
try {
|
||||||
|
await client.drop();
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _halt(ConnState s) {
|
||||||
|
_closed = true;
|
||||||
|
invalidate();
|
||||||
|
_heartbeat?.cancel();
|
||||||
|
_heartbeat = null;
|
||||||
|
_set(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// "Retry": a reconnect after the first backoff whatever the state
|
||||||
|
/// (after [ConnReplaced] it takes the member back from the other
|
||||||
|
/// window). False without a session to retry.
|
||||||
|
bool retry() {
|
||||||
|
if (_params == null) return false;
|
||||||
|
invalidate();
|
||||||
|
_closed = false;
|
||||||
|
_attempt = 0;
|
||||||
|
_set(const ConnClosed());
|
||||||
|
scheduleReconnect(manual: true);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// No more reconnects or silence checks, [state] closed; the socket
|
||||||
|
/// stays until [close] (the session's last words still go out).
|
||||||
|
void stop() {
|
||||||
|
_closed = true;
|
||||||
|
_reconnectTimer?.cancel();
|
||||||
|
_reconnectTimer = null;
|
||||||
|
_heartbeat?.cancel();
|
||||||
|
_heartbeat = null;
|
||||||
|
_set(const ConnClosed());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [stop], the last diagnostics (before the token goes), with [bye] the
|
||||||
|
/// member's `bye` (flushed: the server's close is awaited briefly), then
|
||||||
|
/// the socket.
|
||||||
|
Future<void> close({bool bye = false}) async {
|
||||||
|
stop();
|
||||||
|
await diag.stop();
|
||||||
|
// A welcomed socket still open (a dead one costs the short wait).
|
||||||
|
if (bye && client.isOpen && client.token.isNotEmpty) await client.sendBye();
|
||||||
|
try {
|
||||||
|
await client.disconnect();
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> dispose() async {
|
||||||
|
stop();
|
||||||
|
for (final s in _subs) {
|
||||||
|
await s.cancel();
|
||||||
|
}
|
||||||
|
await _errorTexts.close();
|
||||||
|
await client.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,17 @@
|
|||||||
import 'package:sync_relay_core/core.dart';
|
import 'package:sync_relay_core/core.dart';
|
||||||
|
|
||||||
import '../jellyfin/session_media.dart';
|
import '../jellyfin/session_media.dart';
|
||||||
|
import 'party.dart';
|
||||||
|
|
||||||
// Who fetches the current media and how it spreads, from the state alone:
|
// Who fetches the current media and how it spreads, from the state alone:
|
||||||
// the members card's source line and each member's route and buffer.
|
// the members card's source line and each member's route and buffer.
|
||||||
|
|
||||||
|
/// `media.relay` of a state, or null when the media is not relayed.
|
||||||
|
MediaRelay? stateMediaRelay(StateBroadcast state) {
|
||||||
|
final r = state.media?['relay'];
|
||||||
|
return r is Map ? MediaRelay.fromJson(Map<String, Object?>.from(r)) : null;
|
||||||
|
}
|
||||||
|
|
||||||
/// "4.1 MB/s", "850 kB/s".
|
/// "4.1 MB/s", "850 kB/s".
|
||||||
String formatRate(int bytesPerSec) {
|
String formatRate(int bytesPerSec) {
|
||||||
if (bytesPerSec >= 1000000) {
|
if (bytesPerSec >= 1000000) {
|
||||||
@@ -49,6 +56,9 @@ enum SourceState {
|
|||||||
slow,
|
slow,
|
||||||
/// Its channel is down (or the server has not read the link yet).
|
/// Its channel is down (or the server has not read the link yet).
|
||||||
waiting,
|
waiting,
|
||||||
|
/// The source member's connection dropped (`gone`, within its grace):
|
||||||
|
/// viewers play what the server has.
|
||||||
|
gone,
|
||||||
/// It left the server.
|
/// It left the server.
|
||||||
left,
|
left,
|
||||||
}
|
}
|
||||||
@@ -113,6 +123,9 @@ class Delivery {
|
|||||||
final String sourceName;
|
final String sourceName;
|
||||||
final bool sourceIsSelf;
|
final bool sourceIsSelf;
|
||||||
final SourceState state;
|
final SourceState state;
|
||||||
|
/// [SourceState.gone]: since when; and the time the line is read at.
|
||||||
|
final DateTime? goneSince;
|
||||||
|
final DateTime? at;
|
||||||
/// `media.relay.rate` / `need` (0 unknown).
|
/// `media.relay.rate` / `need` (0 unknown).
|
||||||
final int rate, need;
|
final int rate, need;
|
||||||
/// Members (not sitting out, not the source) reading from the server,
|
/// Members (not sitting out, not the source) reading from the server,
|
||||||
@@ -124,6 +137,8 @@ class Delivery {
|
|||||||
this.sourceName = '',
|
this.sourceName = '',
|
||||||
this.sourceIsSelf = false,
|
this.sourceIsSelf = false,
|
||||||
this.state = SourceState.ok,
|
this.state = SourceState.ok,
|
||||||
|
this.goneSince,
|
||||||
|
this.at,
|
||||||
this.rate = 0,
|
this.rate = 0,
|
||||||
this.need = 0,
|
this.need = 0,
|
||||||
this.viaServer = 0,
|
this.viaServer = 0,
|
||||||
@@ -154,7 +169,10 @@ class Delivery {
|
|||||||
};
|
};
|
||||||
case DeliveryKind.memberRelay:
|
case DeliveryKind.memberRelay:
|
||||||
return switch (state) {
|
return switch (state) {
|
||||||
SourceState.left => '$sourceName left: playing what the server has',
|
SourceState.left => '$sourceName left — playing what the server has',
|
||||||
|
SourceState.gone => sourceIsSelf
|
||||||
|
? 'Reconnecting you to the server…'
|
||||||
|
: "$sourceName's connection dropped — playing what the server has${_for()}",
|
||||||
SourceState.waiting => sourceIsSelf ? 'Reconnecting you to the server…' : 'Waiting for $who to reconnect',
|
SourceState.waiting => sourceIsSelf ? 'Reconnecting you to the server…' : 'Waiting for $who to reconnect',
|
||||||
SourceState.slow => sourceIsSelf ? 'Your upload is slower than the video' : "$who's upload is slower than the video",
|
SourceState.slow => sourceIsSelf ? 'Your upload is slower than the video' : "$who's upload is slower than the video",
|
||||||
SourceState.ok => sourceIsSelf ? 'You pass it on through the server' : '$who passes it on through the server',
|
SourceState.ok => sourceIsSelf ? 'You pass it on through the server' : '$who passes it on through the server',
|
||||||
@@ -162,6 +180,12 @@ class Delivery {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String _for() {
|
||||||
|
final since = goneSince, now = at;
|
||||||
|
if (since == null || now == null || now.difference(since).inMinutes < 1) return '';
|
||||||
|
return ' (${sinceText(since, now)})';
|
||||||
|
}
|
||||||
|
|
||||||
/// "3 through the server · 4.1 MB/s, needs 2.3 MB/s"; empty when
|
/// "3 through the server · 4.1 MB/s, needs 2.3 MB/s"; empty when
|
||||||
/// there is nothing to add.
|
/// there is nothing to add.
|
||||||
String get detail {
|
String get detail {
|
||||||
@@ -169,7 +193,7 @@ class Delivery {
|
|||||||
if (direct > 0 && (kind == DeliveryKind.jellyfin || kind == DeliveryKind.memberRelay))
|
if (direct > 0 && (kind == DeliveryKind.jellyfin || kind == DeliveryKind.memberRelay))
|
||||||
'$direct with their own account',
|
'$direct with their own account',
|
||||||
if (viaServer > 0 && kind != DeliveryKind.link) '$viaServer through the server',
|
if (viaServer > 0 && kind != DeliveryKind.link) '$viaServer through the server',
|
||||||
if (rate > 0 && state != SourceState.left) need > 0 ? '${formatRate(rate)}, needs ${formatRate(need)}' : formatRate(rate),
|
if (rate > 0 && state != SourceState.left && state != SourceState.gone) need > 0 ? '${formatRate(rate)}, needs ${formatRate(need)}' : formatRate(rate),
|
||||||
];
|
];
|
||||||
return parts.join(' · ');
|
return parts.join(' · ');
|
||||||
}
|
}
|
||||||
@@ -192,8 +216,9 @@ class Delivery {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The current media's [Delivery]; null with nothing up or nobody else in
|
/// The current media's [Delivery]; null with nothing up or nobody else in
|
||||||
/// the server. [names] remembers a source that left.
|
/// the server. [names] remembers a source that left; [now] reads how long
|
||||||
Delivery? deliveryOf(StateBroadcast r, {required String selfId, MemberNames? names}) {
|
/// a gone source has been gone.
|
||||||
|
Delivery? deliveryOf(StateBroadcast r, {required String selfId, MemberNames? names, DateTime? now}) {
|
||||||
final media = r.media;
|
final media = r.media;
|
||||||
if (media == null || r.members.length < 2) return null;
|
if (media == null || r.members.length < 2) return null;
|
||||||
final rel = media['relay'];
|
final rel = media['relay'];
|
||||||
@@ -217,7 +242,7 @@ Delivery? deliveryOf(StateBroadcast r, {required String selfId, MemberNames? nam
|
|||||||
final source = r.members.where((m) => m.id == sourceId).firstOrNull;
|
final source = r.members.where((m) => m.id == sourceId).firstOrNull;
|
||||||
var via = 0, direct = 0;
|
var via = 0, direct = 0;
|
||||||
for (final m in r.members) {
|
for (final m in r.members) {
|
||||||
if (m.out || m.id == sourceId) continue;
|
if (!isPresent(m) || m.id == sourceId) continue;
|
||||||
switch (routeOf(m, kind, sourceId, ref, r.relay)) {
|
switch (routeOf(m, kind, sourceId, ref, r.relay)) {
|
||||||
case DeliveryRoute.server:
|
case DeliveryRoute.server:
|
||||||
via++;
|
via++;
|
||||||
@@ -236,6 +261,8 @@ Delivery? deliveryOf(StateBroadcast r, {required String selfId, MemberNames? nam
|
|||||||
? SourceState.ok
|
? SourceState.ok
|
||||||
: kind == DeliveryKind.memberRelay && source == null
|
: kind == DeliveryKind.memberRelay && source == null
|
||||||
? SourceState.left
|
? SourceState.left
|
||||||
|
: kind == DeliveryKind.memberRelay && (relay.sourceGone || source?.presence == Presence.gone)
|
||||||
|
? SourceState.gone
|
||||||
: !relay.live
|
: !relay.live
|
||||||
? SourceState.waiting
|
? SourceState.waiting
|
||||||
: relay.slow
|
: relay.slow
|
||||||
@@ -247,6 +274,8 @@ Delivery? deliveryOf(StateBroadcast r, {required String selfId, MemberNames? nam
|
|||||||
sourceName: source?.name ?? names?[sourceId] ?? 'The member passing it on',
|
sourceName: source?.name ?? names?[sourceId] ?? 'The member passing it on',
|
||||||
sourceIsSelf: sourceId.isNotEmpty && sourceId == selfId,
|
sourceIsSelf: sourceId.isNotEmpty && sourceId == selfId,
|
||||||
state: state,
|
state: state,
|
||||||
|
goneSince: state == SourceState.gone ? relay?.sourceGoneSince ?? source?.goneSince : null,
|
||||||
|
at: now ?? DateTime.now().toUtc(),
|
||||||
rate: relay?.rate ?? 0,
|
rate: relay?.rate ?? 0,
|
||||||
need: relay?.need ?? 0,
|
need: relay?.need ?? 0,
|
||||||
viaServer: via,
|
viaServer: via,
|
||||||
@@ -277,7 +306,7 @@ DeliveryRoute? routeOf(MemberView m, DeliveryKind kind, String sourceId, Jellyfi
|
|||||||
|
|
||||||
/// [m]'s route and buffer for [d] (from [deliveryOf] on [r]).
|
/// [m]'s route and buffer for [d] (from [deliveryOf] on [r]).
|
||||||
MemberDelivery memberDeliveryOf(MemberView m, StateBroadcast r, Delivery d) {
|
MemberDelivery memberDeliveryOf(MemberView m, StateBroadcast r, Delivery d) {
|
||||||
final counts = !m.out && !m.stale && m.error.isEmpty;
|
final counts = countsForReady(m);
|
||||||
final need = r.bufferNeed;
|
final need = r.bufferNeed;
|
||||||
final ref = refOfStateMedia(r.media);
|
final ref = refOfStateMedia(r.media);
|
||||||
final health = !counts
|
final health = !counts
|
||||||
@@ -288,7 +317,7 @@ MemberDelivery memberDeliveryOf(MemberView m, StateBroadcast r, Delivery d) {
|
|||||||
? BufferHealth.starving
|
? BufferHealth.starving
|
||||||
: BufferHealth.low;
|
: BufferHealth.low;
|
||||||
return MemberDelivery(
|
return MemberDelivery(
|
||||||
route: m.out ? null : routeOf(m, d.kind, d.sourceId, ref, r.relay),
|
route: m.presence == Presence.away ? null : routeOf(m, d.kind, d.sourceId, ref, r.relay),
|
||||||
kind: d.kind,
|
kind: d.kind,
|
||||||
jellyfin: ref != null,
|
jellyfin: ref != null,
|
||||||
rate: m.rate,
|
rate: m.rate,
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
enum HealthSeverity { ok, info, warn, error }
|
|
||||||
|
|
||||||
class HealthStatus {
|
|
||||||
final HealthSeverity severity;
|
|
||||||
final String message;
|
|
||||||
const HealthStatus(this.severity, this.message);
|
|
||||||
}
|
|
||||||
+81
-17
@@ -1,13 +1,82 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
import 'dart:math';
|
||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:hive_ce/hive.dart';
|
||||||
import 'package:signals/signals_flutter.dart';
|
import 'package:signals/signals_flutter.dart';
|
||||||
|
import 'package:sync_relay_core/core.dart' show validClientId;
|
||||||
|
|
||||||
import '../../state/connect_prefs.dart';
|
import '../state/connect_prefs.dart';
|
||||||
import '../../state/session_state.dart';
|
import '../state/settings.dart' show settingsBoxName;
|
||||||
import '../../widgets/avatar_pipeline.dart';
|
import '../widgets/avatar_pipeline.dart';
|
||||||
|
|
||||||
|
// Who this app is: to the server (the per-install client id and its
|
||||||
|
// rising connection count, [ClientIdentity]) and to people (name, picture
|
||||||
|
// and colour, [Profile]).
|
||||||
|
|
||||||
|
/// `hello.client` and `hello.seq` (PROTOCOL.md "Presence"): a random id
|
||||||
|
/// per install, kept in the settings box (so per data folder, and per
|
||||||
|
/// `STARSYNC_PROFILE`), and a number that rises with every connection this
|
||||||
|
/// install makes. A server maps the id to one lasting member; the higher
|
||||||
|
/// `seq` wins when two windows of the same install connect.
|
||||||
|
class ClientIdentity {
|
||||||
|
static const _keyClient = 'clientId';
|
||||||
|
static const _keySeq = 'clientSeq';
|
||||||
|
|
||||||
|
final Box? _box;
|
||||||
|
ClientIdentity._(this._box, this.clientId, this._seq);
|
||||||
|
|
||||||
|
/// The id: 22 characters of base64url (16 random bytes). Private: never
|
||||||
|
/// shown, never logged.
|
||||||
|
final String clientId;
|
||||||
|
int _seq;
|
||||||
|
|
||||||
|
/// The last `seq` handed out.
|
||||||
|
int get seq => _seq;
|
||||||
|
|
||||||
|
/// The stored identity (the settings box, once open); one is made and
|
||||||
|
/// stored on first use. Without the box (tests, no store): [ephemeral].
|
||||||
|
factory ClientIdentity.stored() {
|
||||||
|
final box = Hive.isBoxOpen(settingsBoxName) ? Hive.box(settingsBoxName) : null;
|
||||||
|
if (box == null) return ClientIdentity.ephemeral();
|
||||||
|
var id = box.get(_keyClient);
|
||||||
|
if (id is! String || !validClientId(id)) {
|
||||||
|
id = newClientId();
|
||||||
|
unawaited(_put(box, _keyClient, id));
|
||||||
|
}
|
||||||
|
final seq = box.get(_keySeq);
|
||||||
|
return ClientIdentity._(box, id, seq is int && seq > 0 ? seq : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A fresh id kept in memory only (each test coordinator is its own
|
||||||
|
/// install).
|
||||||
|
factory ClientIdentity.ephemeral() => ClientIdentity._(null, newClientId(), 0);
|
||||||
|
|
||||||
|
/// The `seq` for the next hello: one higher than any before, stored at
|
||||||
|
/// once.
|
||||||
|
int nextSeq() {
|
||||||
|
_seq++;
|
||||||
|
final box = _box;
|
||||||
|
if (box != null) unawaited(_put(box, _keySeq, _seq));
|
||||||
|
return _seq;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> _put(Box box, String key, Object value) async {
|
||||||
|
try {
|
||||||
|
await box.put(key, value);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('save $key failed: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 16 random bytes as 22 characters of base64url.
|
||||||
|
static String newClientId() {
|
||||||
|
final r = Random.secure();
|
||||||
|
return base64Url.encode([for (var i = 0; i < 16; i++) r.nextInt(256)]).replaceAll('=', '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Colours offered in the profile card; the first is the default.
|
/// Colours offered in the profile card; the first is the default.
|
||||||
const profileColors = <Color>[
|
const profileColors = <Color>[
|
||||||
@@ -26,17 +95,11 @@ const _keyColor = 'profileColor';
|
|||||||
/// avatar = removed). Swappable so tests can watch it.
|
/// avatar = removed). Swappable so tests can watch it.
|
||||||
typedef ProfilePush = void Function({String? name, String? avatar});
|
typedef ProfilePush = void Function({String? name, String? avatar});
|
||||||
|
|
||||||
/// The one place a profile change reaches the server.
|
/// Display name, picture and colour of this app's user. Backed by the
|
||||||
void pushProfileToSession({String? name, String? avatar}) {
|
/// connect form ([forms]: the name) and the connect-form box (the cropped
|
||||||
final session = SessionState.instance;
|
|
||||||
if (session.route.peek() != AppRoute.session) return;
|
|
||||||
session.coordinator.updateProfile(name: name, avatar: avatar);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Display name, picture and colour of this app's user. Backed by
|
|
||||||
/// [SessionState.lastForm] (name) and the connect-form box (the cropped
|
|
||||||
/// 256 px PNG and the colour); every change is persisted, and while in a
|
/// 256 px PNG and the colour); every change is persisted, and while in a
|
||||||
/// server it is pushed through [push] so everyone sees it at once.
|
/// server it is pushed through [push] so everyone sees it at once. The
|
||||||
|
/// composition root sets both.
|
||||||
class Profile {
|
class Profile {
|
||||||
static final Profile instance = Profile._();
|
static final Profile instance = Profile._();
|
||||||
Profile._();
|
Profile._();
|
||||||
@@ -46,7 +109,8 @@ class Profile {
|
|||||||
final picture = signal<Uint8List?>(null);
|
final picture = signal<Uint8List?>(null);
|
||||||
final color = signal<Color>(profileColors.first);
|
final color = signal<Color>(profileColors.first);
|
||||||
final error = signal<String?>(null);
|
final error = signal<String?>(null);
|
||||||
ProfilePush push = pushProfileToSession;
|
ProfilePush push = ({String? name, String? avatar}) {};
|
||||||
|
ConnectStore forms = ConnectStore();
|
||||||
bool _loaded = false;
|
bool _loaded = false;
|
||||||
Timer? _nameTimer;
|
Timer? _nameTimer;
|
||||||
|
|
||||||
@@ -61,7 +125,7 @@ class Profile {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _load() async {
|
Future<void> _load() async {
|
||||||
final form = SessionState.instance.lastForm;
|
final form = forms.form;
|
||||||
name.value = form.name;
|
name.value = form.name;
|
||||||
try {
|
try {
|
||||||
final box = connectFormBoxIfOpen() ?? await openConnectFormBox();
|
final box = connectFormBoxIfOpen() ?? await openConnectFormBox();
|
||||||
@@ -92,7 +156,7 @@ class Profile {
|
|||||||
final v = value.trim();
|
final v = value.trim();
|
||||||
if (v.isEmpty || v == name.peek()) return;
|
if (v.isEmpty || v == name.peek()) return;
|
||||||
name.value = v;
|
name.value = v;
|
||||||
SessionState.instance.lastForm.name = v;
|
forms.form.name = v;
|
||||||
unawaited(_save());
|
unawaited(_save());
|
||||||
// Typing renames once it settles, not on every key.
|
// Typing renames once it settles, not on every key.
|
||||||
_nameTimer?.cancel();
|
_nameTimer?.cancel();
|
||||||
@@ -117,7 +181,7 @@ class Profile {
|
|||||||
|
|
||||||
Future<void> _save() async {
|
Future<void> _save() async {
|
||||||
try {
|
try {
|
||||||
await SessionState.instance.saveForm();
|
await forms.save();
|
||||||
final box = connectFormBoxIfOpen() ?? await openConnectFormBox();
|
final box = connectFormBoxIfOpen() ?? await openConnectFormBox();
|
||||||
final png = picture.peek();
|
final png = picture.peek();
|
||||||
await box.putAll({
|
await box.putAll({
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
enum IdleEvent { none, warn, unready }
|
|
||||||
|
|
||||||
/// Idle auto-unready clock. Idle time counts from the later of the user's
|
|
||||||
/// last input and the last moment playback was running: watching is
|
|
||||||
/// activity, so a long watch followed by a pause starts a fresh 10 min.
|
|
||||||
/// The unready also always trails its warning by the full grace period.
|
|
||||||
class IdleTracker {
|
|
||||||
static const warnAfter = Duration(minutes: 8);
|
|
||||||
static const unreadyAfter = Duration(minutes: 10);
|
|
||||||
|
|
||||||
DateTime _lastActive;
|
|
||||||
DateTime? _warnedAt;
|
|
||||||
|
|
||||||
IdleTracker(DateTime now) : _lastActive = now;
|
|
||||||
|
|
||||||
void activity(DateTime now) {
|
|
||||||
_lastActive = now;
|
|
||||||
_warnedAt = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Called once per tick. Warn and unready only matter while self is
|
|
||||||
/// ready; while playing the ready flag gates nothing.
|
|
||||||
IdleEvent tick(DateTime now, {required bool playing, required bool selfReady}) {
|
|
||||||
if (playing) {
|
|
||||||
activity(now);
|
|
||||||
return IdleEvent.none;
|
|
||||||
}
|
|
||||||
if (!selfReady) return IdleEvent.none;
|
|
||||||
final warnedAt = _warnedAt;
|
|
||||||
if (warnedAt != null) {
|
|
||||||
if (now.difference(warnedAt) >= unreadyAfter - warnAfter) {
|
|
||||||
activity(now);
|
|
||||||
return IdleEvent.unready;
|
|
||||||
}
|
|
||||||
return IdleEvent.none;
|
|
||||||
}
|
|
||||||
if (now.difference(_lastActive) >= warnAfter) {
|
|
||||||
_warnedAt = now;
|
|
||||||
return IdleEvent.warn;
|
|
||||||
}
|
|
||||||
return IdleEvent.none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,290 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart' show debugPrint;
|
||||||
|
|
||||||
|
/// The URL scheme of an invite link ([InviteLink]).
|
||||||
|
const inviteScheme = 'starsync';
|
||||||
|
|
||||||
|
/// The port a link without one means (the app's default sharing port).
|
||||||
|
const defaultInvitePort = 8080;
|
||||||
|
|
||||||
|
/// What a friend needs to join your server: where ([host], [port]), what
|
||||||
|
/// it is called ([name]), whether it asks a password ([password]; the
|
||||||
|
/// password itself is never in it) and which Starsync runs it
|
||||||
|
/// ([version]). [fp]: reserved for a pinned certificate fingerprint
|
||||||
|
/// (`wss://` with a self-signed certificate); always null for now.
|
||||||
|
class Invite {
|
||||||
|
final String host;
|
||||||
|
final int port;
|
||||||
|
final String name;
|
||||||
|
final bool password;
|
||||||
|
final String version;
|
||||||
|
final String? fp;
|
||||||
|
const Invite({
|
||||||
|
required this.host,
|
||||||
|
required this.port,
|
||||||
|
this.name = '',
|
||||||
|
this.password = false,
|
||||||
|
this.version = '',
|
||||||
|
this.fp,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// What goes in the Join card's address and port.
|
||||||
|
String get address => '${host.contains(':') ? '[$host]' : host}:$port';
|
||||||
|
|
||||||
|
/// The same invite at another of this machine's addresses.
|
||||||
|
Invite at(String host) =>
|
||||||
|
Invite(host: host, port: port, name: name, password: password, version: version, fp: fp);
|
||||||
|
|
||||||
|
/// Its `starsync://` link with the server's password ([secret], the
|
||||||
|
/// host's; empty when none is set): what Invite copies, nothing around
|
||||||
|
/// it, so a friend just clicks it.
|
||||||
|
InviteLink link({String secret = ''}) => InviteLink(host: host, port: port, name: name, secret: secret);
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) =>
|
||||||
|
other is Invite &&
|
||||||
|
other.host == host &&
|
||||||
|
other.port == port &&
|
||||||
|
other.name == name &&
|
||||||
|
other.password == password &&
|
||||||
|
other.version == version &&
|
||||||
|
other.fp == fp;
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode => Object.hash(host, port, name, password, version, fp);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => 'Invite($address${password ? ', password' : ''})';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A `starsync://host:port/?name=…&pw=…` invite link (PROTOCOL.md "Invite
|
||||||
|
/// link"): where to join, the server's name for the prompt, and the
|
||||||
|
/// password when the server has one. [toString] never shows the
|
||||||
|
/// password; [uri] does.
|
||||||
|
class InviteLink {
|
||||||
|
final String host;
|
||||||
|
final int port;
|
||||||
|
final String name;
|
||||||
|
final String secret;
|
||||||
|
const InviteLink({required this.host, required this.port, this.name = '', this.secret = ''});
|
||||||
|
|
||||||
|
String get address => '${host.contains(':') ? '[$host]' : host}:$port';
|
||||||
|
|
||||||
|
/// One per server (for "already asked about this one").
|
||||||
|
String get key => '${host.toLowerCase()}:$port';
|
||||||
|
|
||||||
|
/// The link itself (with the password, when it has one).
|
||||||
|
String get uri {
|
||||||
|
final query = [
|
||||||
|
if (name.isNotEmpty) 'name=${Uri.encodeQueryComponent(name)}',
|
||||||
|
if (secret.isNotEmpty) 'pw=${Uri.encodeQueryComponent(secret)}',
|
||||||
|
];
|
||||||
|
return '$inviteScheme://$address${query.isEmpty ? '' : '/?${query.join('&')}'}';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Null unless [text] is a `starsync://` link with a host and a usable
|
||||||
|
/// port (none means [defaultInvitePort]).
|
||||||
|
static InviteLink? parse(String text) {
|
||||||
|
final t = text.trim();
|
||||||
|
if (!t.toLowerCase().startsWith('$inviteScheme://')) return null;
|
||||||
|
final Uri u;
|
||||||
|
try {
|
||||||
|
u = Uri.parse(t);
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
final host = u.host.trim();
|
||||||
|
if (host.isEmpty || !_hostChars.hasMatch(host)) return null;
|
||||||
|
final port = u.hasPort ? u.port : defaultInvitePort;
|
||||||
|
if (port <= 0 || port > 65535) return null;
|
||||||
|
final Map<String, String> q;
|
||||||
|
try {
|
||||||
|
q = u.queryParameters;
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return InviteLink(host: host, port: port, name: _cleanName(q['name'] ?? ''), secret: q['pw'] ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) =>
|
||||||
|
other is InviteLink && other.host == host && other.port == port && other.name == name && other.secret == secret;
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode => Object.hash(host, port, name, secret);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => 'InviteLink($address${secret.isEmpty ? '' : ', password'})';
|
||||||
|
}
|
||||||
|
|
||||||
|
final _hostChars = RegExp(r'^[A-Za-z0-9.:-]+$');
|
||||||
|
final _control = RegExp(r'[\x00-\x1f\x7f]');
|
||||||
|
|
||||||
|
/// A link's name as shown in the prompt: no control characters, at most
|
||||||
|
/// 64 characters.
|
||||||
|
String _cleanName(String raw) {
|
||||||
|
final n = raw.replaceAll(_control, '').trim();
|
||||||
|
return n.length > 64 ? n.substring(0, 64) : n;
|
||||||
|
}
|
||||||
|
|
||||||
|
final _linkIn = RegExp('$inviteScheme://[^\\s<>"\'`]+', caseSensitive: false);
|
||||||
|
final _plainInvite = RegExp(r'Join me on Starsync:\s*(\S+)');
|
||||||
|
|
||||||
|
/// Only the start of a long text is searched (a clipboard can hold a book).
|
||||||
|
const _searchLimit = 4096;
|
||||||
|
|
||||||
|
/// The invite in [text]: a `starsync://` link anywhere in it, else the
|
||||||
|
/// address of a plain invite ("Join me on Starsync: 192.168.1.20:8080",
|
||||||
|
/// what 1.3.x copied). Null for anything else, a bare address included.
|
||||||
|
InviteLink? inviteIn(String text) {
|
||||||
|
final t = text.length > _searchLimit ? text.substring(0, _searchLimit) : text;
|
||||||
|
final m = _linkIn.firstMatch(t);
|
||||||
|
if (m != null) return InviteLink.parse(m[0]!);
|
||||||
|
final plain = _plainInvite.firstMatch(t);
|
||||||
|
if (plain == null) return null;
|
||||||
|
final a = addressIn(plain[1]!);
|
||||||
|
final port = a == null ? null : int.tryParse(a.port);
|
||||||
|
if (a == null || port == null || port <= 0 || port > 65535) return null;
|
||||||
|
return InviteLink(host: a.host, port: port);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `host:port` in a pasted invite (or a bare address); null when
|
||||||
|
/// there is none.
|
||||||
|
({String host, String port})? addressIn(String text) {
|
||||||
|
final m = RegExp(r'(\[[0-9a-fA-F:]+\]|[A-Za-z0-9.-]+):(\d{1,5})\b').firstMatch(text);
|
||||||
|
if (m == null) return null;
|
||||||
|
final host = m.group(1)!;
|
||||||
|
return (host: host.startsWith('[') ? host.substring(1, host.length - 1) : host, port: m.group(2)!);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Interface names of container and VM bridges, never a way in for a
|
||||||
|
/// friend (Docker, Podman, libvirt, LXC, VirtualBox, VMware, WSL/Hyper-V).
|
||||||
|
final _virtualName = RegExp(
|
||||||
|
r'^(docker|br-|veth|virbr|vmnet|vboxnet|cni|flannel|podman|lxcbr|lxdbr|vEthernet|VirtualBox|VMware)',
|
||||||
|
caseSensitive: false);
|
||||||
|
|
||||||
|
/// The proxy fake-IP range (198.18.0.0/15, benchmarking space that TUN
|
||||||
|
/// proxies like Clash hand out): this machine's address on it is useless
|
||||||
|
/// to anyone else.
|
||||||
|
bool isFakeIp(List<int> b) => b[0] == 198 && (b[1] == 18 || b[1] == 19);
|
||||||
|
|
||||||
|
bool _isPrivate(List<int> b) =>
|
||||||
|
b[0] == 10 || (b[0] == 192 && b[1] == 168) || (b[0] == 172 && b[1] >= 16 && b[1] < 32);
|
||||||
|
|
||||||
|
/// Shared address space (100.64.0.0/10): mesh VPNs like Tailscale (and
|
||||||
|
/// carrier-grade NAT).
|
||||||
|
bool _isShared(List<int> b) => b[0] == 100 && b[1] >= 64 && b[1] < 128;
|
||||||
|
|
||||||
|
/// Where an invite address works.
|
||||||
|
enum AddressKind {
|
||||||
|
/// The internet: anyone, when the port is open to them.
|
||||||
|
public,
|
||||||
|
|
||||||
|
/// The same home network.
|
||||||
|
lan,
|
||||||
|
|
||||||
|
/// A mesh VPN (Tailscale and the like): everyone on it.
|
||||||
|
vpn,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [AddressKind] of an IPv4 address.
|
||||||
|
AddressKind addressKind(String ip) {
|
||||||
|
final a = InternetAddress.tryParse(ip);
|
||||||
|
if (a == null || a.type != InternetAddressType.IPv4) return AddressKind.public;
|
||||||
|
final b = a.rawAddress;
|
||||||
|
if (_isPrivate(b)) return AddressKind.lan;
|
||||||
|
if (_isShared(b)) return AddressKind.vpn;
|
||||||
|
return AddressKind.public;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [ip] is an IPv4 address the internet routes to this network: not
|
||||||
|
/// private, shared, loopback, link-local, fake-IP, multicast or
|
||||||
|
/// reserved (the documentation ranges pass: tests use them).
|
||||||
|
bool isPublicIpv4(String ip) {
|
||||||
|
final a = InternetAddress.tryParse(ip.trim());
|
||||||
|
if (a == null || a.type != InternetAddressType.IPv4) return false;
|
||||||
|
final b = a.rawAddress;
|
||||||
|
return !(b[0] == 0 ||
|
||||||
|
b[0] == 127 ||
|
||||||
|
b[0] >= 224 ||
|
||||||
|
(b[0] == 169 && b[1] == 254) ||
|
||||||
|
_isPrivate(b) ||
|
||||||
|
_isShared(b) ||
|
||||||
|
isFakeIp(b));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The addresses of [interfaces] a friend could dial, best first: IPv4,
|
||||||
|
/// no loopback, link-local or fake-IP address, no container or VM bridge;
|
||||||
|
/// home networks (192.168, then 10, then 172.16/12) first, then a mesh
|
||||||
|
/// VPN's, then anything else (a public address). No repeats.
|
||||||
|
List<String> inviteAddresses(Iterable<({String name, InternetAddress address})> interfaces) {
|
||||||
|
int rank(List<int> b) => b[0] == 192 && b[1] == 168
|
||||||
|
? 0
|
||||||
|
: _isPrivate(b)
|
||||||
|
? 1
|
||||||
|
: _isShared(b)
|
||||||
|
? 2
|
||||||
|
: 3;
|
||||||
|
final usable = [
|
||||||
|
for (final i in interfaces)
|
||||||
|
if (i.address.type == InternetAddressType.IPv4 &&
|
||||||
|
!i.address.isLoopback &&
|
||||||
|
!i.address.isLinkLocal &&
|
||||||
|
!(i.address.rawAddress[0] == 169 && i.address.rawAddress[1] == 254) &&
|
||||||
|
!isFakeIp(i.address.rawAddress) &&
|
||||||
|
!_virtualName.hasMatch(i.name))
|
||||||
|
i.address,
|
||||||
|
];
|
||||||
|
final indexed = [for (var n = 0; n < usable.length; n++) (n, usable[n])];
|
||||||
|
indexed.sort((a, b) {
|
||||||
|
final r = rank(a.$2.rawAddress).compareTo(rank(b.$2.rawAddress));
|
||||||
|
return r != 0 ? r : a.$1.compareTo(b.$1);
|
||||||
|
});
|
||||||
|
return {for (final (_, a) in indexed) a.address}.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One address an invite can carry and where it works.
|
||||||
|
typedef InviteAddress = ({String host, AddressKind kind});
|
||||||
|
|
||||||
|
/// What the members card offers for a shared server: the internet
|
||||||
|
/// address first when known ([public], from [lookUpPublicAddress]), then
|
||||||
|
/// this machine's own ([local], [inviteAddresses] order). [forwarding]:
|
||||||
|
/// the internet address is the router's, not this machine's, so friends
|
||||||
|
/// elsewhere get in only when the port is forwarded to it.
|
||||||
|
class InviteReach {
|
||||||
|
final String? public;
|
||||||
|
final List<String> local;
|
||||||
|
/// The internet lookup is still under way.
|
||||||
|
final bool looking;
|
||||||
|
const InviteReach({this.public, this.local = const [], this.looking = false});
|
||||||
|
|
||||||
|
bool get forwarding => public != null && !local.contains(public);
|
||||||
|
|
||||||
|
/// Every address, best first, each once: the internet one, then this
|
||||||
|
/// machine's.
|
||||||
|
List<InviteAddress> get choices => [
|
||||||
|
if (public != null) (host: public!, kind: AddressKind.public),
|
||||||
|
for (final l in local)
|
||||||
|
if (l != public) (host: l, kind: addressKind(l)),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// The address an invite carries unless the user picks another: the
|
||||||
|
/// internet one when known, else the best of this machine's.
|
||||||
|
String? get best => choices.firstOrNull?.host;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// This machine's interfaces as [inviteAddresses] takes them (empty when
|
||||||
|
/// they can't be listed).
|
||||||
|
Future<List<({String name, InternetAddress address})>> machineInterfaces() async {
|
||||||
|
try {
|
||||||
|
final list = await NetworkInterface.list(type: InternetAddressType.IPv4);
|
||||||
|
return [
|
||||||
|
for (final i in list)
|
||||||
|
for (final a in i.addresses) (name: i.name, address: a),
|
||||||
|
];
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('list network interfaces failed: $e');
|
||||||
|
return const [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:crypto/crypto.dart';
|
||||||
|
import 'package:signals/signals.dart';
|
||||||
|
|
||||||
|
import '../screens/home/health_probe.dart';
|
||||||
|
import '../state/connect_prefs.dart';
|
||||||
|
import 'invite.dart';
|
||||||
|
import 'navigation.dart';
|
||||||
|
import 'your_server.dart';
|
||||||
|
|
||||||
|
/// Where a join prompt came from.
|
||||||
|
enum OfferSource {
|
||||||
|
/// A clicked `starsync://` link (this app started with it, or a second
|
||||||
|
/// start handed it over).
|
||||||
|
link,
|
||||||
|
|
||||||
|
/// An invite found on the clipboard when the window came up.
|
||||||
|
clipboard,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A server the app offers to join: "Join Bob's server (1.2.3.4:8080)?".
|
||||||
|
class JoinOffer {
|
||||||
|
final InviteLink link;
|
||||||
|
final OfferSource source;
|
||||||
|
|
||||||
|
/// Its `/health` once read (name, version gate); null while asking.
|
||||||
|
final ServerHealth? health;
|
||||||
|
const JoinOffer(this.link, this.source, {this.health});
|
||||||
|
|
||||||
|
JoinOffer withHealth(ServerHealth h) => JoinOffer(link, source, health: h);
|
||||||
|
|
||||||
|
/// The server's own name (`/health`), else the link's, else ''.
|
||||||
|
String get name {
|
||||||
|
final own = health?.label ?? '';
|
||||||
|
return own.isNotEmpty ? own : link.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// "Join Bob's server (1.2.3.4:8080)?"
|
||||||
|
String get question => name.isEmpty ? 'Join ${link.address}?' : 'Join $name (${link.address})?';
|
||||||
|
|
||||||
|
/// What `/health` said, in plain words: '' while asking.
|
||||||
|
String get detail {
|
||||||
|
final h = health;
|
||||||
|
if (h == null) return '';
|
||||||
|
if (!h.online) return "Can't reach it right now";
|
||||||
|
if (h.needsUpdate) return versionGateText(h);
|
||||||
|
return [
|
||||||
|
if (h.version.isNotEmpty) 'Starsync ${h.version}',
|
||||||
|
if (h.viewers > 0) '${h.viewers} watching',
|
||||||
|
].join(' · ');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A fingerprint of [link] (password included) for "already asked": the
|
||||||
|
/// link itself is never stored.
|
||||||
|
String inviteFingerprint(InviteLink link) =>
|
||||||
|
sha256.convert(utf8.encode('starsync-invite:${link.uri}')).toString().substring(0, 24);
|
||||||
|
|
||||||
|
/// The join prompt: a clicked invite link, or one found on the
|
||||||
|
/// clipboard, becomes an [offer] (`Join <name> (host:port)?`) with the
|
||||||
|
/// server's `/health`; [accept] hands back what to join, [dismiss] drops
|
||||||
|
/// it. A clipboard invite is offered once: joined or dismissed, the same
|
||||||
|
/// link is not offered again (a fingerprint is kept, never the link).
|
||||||
|
/// Links to your own server, or to the one you are in, are not offered.
|
||||||
|
/// Nothing here logs what the clipboard holds. Made by the composition
|
||||||
|
/// root.
|
||||||
|
class JoinOffers {
|
||||||
|
JoinOffers({required this.navigation, required this.yourServer, required this.forms});
|
||||||
|
|
||||||
|
final Navigation navigation;
|
||||||
|
final YourServer yourServer;
|
||||||
|
final ConnectStore forms;
|
||||||
|
|
||||||
|
final offer = signal<JoinOffer?>(null);
|
||||||
|
|
||||||
|
/// The clipboard text last looked at (its hash): the same text is not
|
||||||
|
/// parsed twice in a row.
|
||||||
|
int? _lastClip;
|
||||||
|
|
||||||
|
/// The app's start arguments, or a second start's: the first
|
||||||
|
/// `starsync://` link among them becomes an offer.
|
||||||
|
void fromArgs(List<String> args) {
|
||||||
|
for (final a in args) {
|
||||||
|
final link = InviteLink.parse(a);
|
||||||
|
if (link != null) {
|
||||||
|
_offer(link, OfferSource.link);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The clipboard's text when the window came up ([text] null: none).
|
||||||
|
void fromClipboard(String? text) {
|
||||||
|
if (text == null || text.isEmpty) return;
|
||||||
|
final h = text.hashCode;
|
||||||
|
if (h == _lastClip) return;
|
||||||
|
_lastClip = h;
|
||||||
|
final link = inviteIn(text);
|
||||||
|
if (link == null || forms.seenInvite(inviteFingerprint(link))) return;
|
||||||
|
_offer(link, OfferSource.clipboard);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// This app put [text] on the clipboard (Copy invite): never offered
|
||||||
|
/// back to it.
|
||||||
|
void copied(String text) => _lastClip = text.hashCode;
|
||||||
|
|
||||||
|
void _offer(InviteLink link, OfferSource source) {
|
||||||
|
if (yourServer.isOwn(link)) return;
|
||||||
|
if (navigation.place.peek() == Place.friend && navigation.address.peek() == '${link.host}:${link.port}') return;
|
||||||
|
final current = offer.peek();
|
||||||
|
if (current != null && current.link == link) return;
|
||||||
|
final o = JoinOffer(link, source);
|
||||||
|
offer.value = o;
|
||||||
|
unawaited(probeHealth(link.host, '${link.port}', navigation.healthGet).then((h) {
|
||||||
|
if (identical(offer.peek(), o)) offer.value = o.withHealth(h);
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Not now: a clipboard invite is not offered again.
|
||||||
|
Future<void> dismiss() async {
|
||||||
|
final o = offer.peek();
|
||||||
|
if (o == null) return;
|
||||||
|
offer.value = null;
|
||||||
|
if (o.source == OfferSource.clipboard) await forms.rememberInvite(inviteFingerprint(o.link));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Join: the link to join (its password, else the one saved for that
|
||||||
|
/// recent server); the invite is not offered again. Null when there
|
||||||
|
/// was none.
|
||||||
|
InviteLink? accept() {
|
||||||
|
final o = offer.peek();
|
||||||
|
if (o == null) return null;
|
||||||
|
offer.value = null;
|
||||||
|
unawaited(forms.rememberInvite(inviteFingerprint(o.link)));
|
||||||
|
final l = o.link;
|
||||||
|
if (l.secret.isNotEmpty) return l;
|
||||||
|
return InviteLink(host: l.host, port: l.port, name: l.name, secret: forms.secretFor(l.host, l.port));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,7 +7,6 @@ import 'package:flutter/foundation.dart';
|
|||||||
import 'package:image/image.dart' as img;
|
import 'package:image/image.dart' as img;
|
||||||
import 'package:sync_relay_core/core.dart';
|
import 'package:sync_relay_core/core.dart';
|
||||||
|
|
||||||
import '../files/name_parser.dart';
|
|
||||||
import '../schedule/anilist.dart';
|
import '../schedule/anilist.dart';
|
||||||
|
|
||||||
// Art for media this client swapped in (`art`, see PROTOCOL.md "Media
|
// Art for media this client swapped in (`art`, see PROTOCOL.md "Media
|
||||||
@@ -58,6 +57,11 @@ Future<ArtBytes?> aniListArt(AniListClient aniList, String name,
|
|||||||
if (!RegExp(r'\p{L}', unicode: true).hasMatch(series)) return null;
|
if (!RegExp(r'\p{L}', unicode: true).hasMatch(series)) return null;
|
||||||
final match = await aniList.searchMedia(series);
|
final match = await aniList.searchMedia(series);
|
||||||
if (match == null) return null;
|
if (match == null) return null;
|
||||||
|
return _artOf(match, fetch);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<ArtBytes> _artOf(AniListMedia match,
|
||||||
|
Future<Uint8List?> Function(Uri url, Map<String, String> headers, int maxBytes) fetch) async {
|
||||||
Future<Uint8List?> get(String? url, int maxBytes) async {
|
Future<Uint8List?> get(String? url, int maxBytes) async {
|
||||||
final uri = url == null ? null : Uri.tryParse(url);
|
final uri = url == null ? null : Uri.tryParse(url);
|
||||||
return uri == null ? null : fetch(uri, const {}, maxBytes);
|
return uri == null ? null : fetch(uri, const {}, maxBytes);
|
||||||
@@ -71,6 +75,14 @@ Future<ArtBytes?> aniListArt(AniListClient aniList, String name,
|
|||||||
return (poster: poster, banner: banner);
|
return (poster: poster, banner: banner);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Cover and banner of AniList entry [id] (a file the library matched);
|
||||||
|
/// null when AniList has none.
|
||||||
|
Future<ArtBytes?> aniListArtById(AniListClient aniList, int id,
|
||||||
|
{Future<Uint8List?> Function(Uri url, Map<String, String> headers, int maxBytes) fetch = fetchImage}) async {
|
||||||
|
final match = await aniList.mediaById(id);
|
||||||
|
return match == null ? null : _artOf(match, fetch);
|
||||||
|
}
|
||||||
|
|
||||||
/// A web video's thumbnail at [url] as both cover and banner (the same
|
/// A web video's thumbnail at [url] as both cover and banner (the same
|
||||||
/// bytes, so the same hash: what tells clients it is a thumbnail, see
|
/// bytes, so the same hash: what tells clients it is a thumbnail, see
|
||||||
/// `NowPlaying.thumbnail`); shrunk to a JPEG under the cover cap when
|
/// `NowPlaying.thumbnail`); shrunk to a JPEG under the cover cap when
|
||||||
|
|||||||
@@ -0,0 +1,973 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart' show debugPrint, mapEquals, visibleForTesting;
|
||||||
|
import 'package:signals/signals.dart';
|
||||||
|
import 'package:sync_relay_core/core.dart';
|
||||||
|
|
||||||
|
import '../jellyfin/accounts.dart';
|
||||||
|
import '../jellyfin/session_media.dart';
|
||||||
|
import '../platform/ports/link_resolver.dart';
|
||||||
|
import '../platform/ports/player_host.dart';
|
||||||
|
import '../playback/playback_controller.dart';
|
||||||
|
import '../sync/state_diff.dart';
|
||||||
|
import 'app_events.dart';
|
||||||
|
import 'delivery.dart' show stateMediaRelay;
|
||||||
|
import 'media_plan.dart';
|
||||||
|
import 'relay_feeder.dart';
|
||||||
|
import 'shared_links.dart';
|
||||||
|
import 'switch_trace.dart';
|
||||||
|
|
||||||
|
export 'media_plan.dart';
|
||||||
|
|
||||||
|
final _log = createLogger('session');
|
||||||
|
|
||||||
|
// The one way to open media: plan → resolve → open → ready.
|
||||||
|
//
|
||||||
|
// - plan ([planMedia]): from the state, what this member serves and its
|
||||||
|
// capabilities, where this app reads the media from.
|
||||||
|
// - resolve: a Jellyfin item through this app's account, the server's
|
||||||
|
// login or the member passing it on ([openJellyfin]); a web page through
|
||||||
|
// this app's yt-dlp ([LinkResolver]: its stream URLs, an EDL of video and
|
||||||
|
// audio, the CDN's headers, its subtitles; mpv's own yt-dlp hook is
|
||||||
|
// off); a relay waits until it is fed.
|
||||||
|
// - open: a player through the [PlayerHost]: the prepared one (Up next's
|
||||||
|
// preload) promoted, else a fresh one per media; the same media again
|
||||||
|
// keeps its player.
|
||||||
|
// - ready: the buffer the gate needs is reported at once (the
|
||||||
|
// coordinator's report).
|
||||||
|
//
|
||||||
|
// Being a relay source ([RelayFeeder]) and the viewer's side of a source
|
||||||
|
// that drops ([recover]) live here too. A link this member passes on is
|
||||||
|
// read from the site once ([SharedLinks]): its player opens the shared
|
||||||
|
// reader's loopback URL and the relay serves the same bytes.
|
||||||
|
|
||||||
|
/// What the coordinator does next after [MediaPipeline.switchTo]: a
|
||||||
|
/// Jellyfin item resolves and opens through [MediaPipeline.openJellyfin]
|
||||||
|
/// (the coordinator keeps its per-item work: progress, episodes, tracks).
|
||||||
|
sealed class SwitchResult {
|
||||||
|
const SwitchResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
final class SwitchDone extends SwitchResult {
|
||||||
|
const SwitchDone();
|
||||||
|
}
|
||||||
|
|
||||||
|
final class SwitchJellyfin extends SwitchResult {
|
||||||
|
final JellyfinSource source;
|
||||||
|
const SwitchJellyfin(this.source);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The session context the pipeline reads (the coordinator's connection
|
||||||
|
/// and accounts).
|
||||||
|
class MediaSession {
|
||||||
|
final ServerAddress Function() server;
|
||||||
|
final String Function() selfId;
|
||||||
|
final ClientCaps Function() caps;
|
||||||
|
final JellyfinAccounts jellyfin;
|
||||||
|
const MediaSession({required this.server, required this.selfId, required this.caps, required this.jellyfin});
|
||||||
|
|
||||||
|
static final none = MediaSession(
|
||||||
|
server: () => const ServerAddress('', 0, ''),
|
||||||
|
selfId: () => '',
|
||||||
|
caps: () => const ClientCaps(),
|
||||||
|
jellyfin: JellyfinAccounts(store: MemoryJellyfinStore()));
|
||||||
|
}
|
||||||
|
|
||||||
|
class MediaPipeline {
|
||||||
|
final PlayerHost host;
|
||||||
|
final LinkResolver? links;
|
||||||
|
final PlaybackController playback;
|
||||||
|
final SwitchTrace trace;
|
||||||
|
late final RelayFeeder feeder;
|
||||||
|
|
||||||
|
/// The links this member passes on, read once for its player and the
|
||||||
|
/// relay.
|
||||||
|
final shared = SharedLinks();
|
||||||
|
MediaSession session;
|
||||||
|
|
||||||
|
/// The player could not open the current media (raw words: the
|
||||||
|
/// coordinator turns them into `report.error`; [cookies]: which ones a
|
||||||
|
/// page's resolve used, D11).
|
||||||
|
void Function(String raw, {String? cookies}) onError = (_, {cookies}) {};
|
||||||
|
void Function(AppEvent event) notify = (_) {};
|
||||||
|
|
||||||
|
/// A media opened in the session player ([open]): the coordinator
|
||||||
|
/// reports at once (a promoted player already has its buffer).
|
||||||
|
void Function(int mediaId, String url) onOpened = (_, _) {};
|
||||||
|
|
||||||
|
MediaPipeline({
|
||||||
|
required this.host,
|
||||||
|
this.links,
|
||||||
|
PlaybackController? playback,
|
||||||
|
SwitchTrace? trace,
|
||||||
|
MediaSession? session,
|
||||||
|
}) : playback = playback ?? PlaybackController(host),
|
||||||
|
trace = trace ?? SwitchTrace(),
|
||||||
|
session = session ?? MediaSession.none {
|
||||||
|
feeder = RelayFeeder(
|
||||||
|
server: () => this.session.server(),
|
||||||
|
notify: (e) => notify(e),
|
||||||
|
onWarmEnded: (queueId) => shared.dropWarm(queueId));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------ players
|
||||||
|
|
||||||
|
// The current player has had media opened in it (one fresh from
|
||||||
|
// [start] has not, so the first open after join or Reload video uses
|
||||||
|
// it).
|
||||||
|
bool _held = false;
|
||||||
|
|
||||||
|
/// A live session player exists.
|
||||||
|
bool get ready => playback.alive.peek();
|
||||||
|
|
||||||
|
/// The session's first player (on join); each new media gets its own.
|
||||||
|
Future<void> start() async {
|
||||||
|
if (playback.current != null) return;
|
||||||
|
final p = await host.create(PlayerRole.session);
|
||||||
|
_held = false;
|
||||||
|
playback.attach(p);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// "Reload video": a fresh player and video output, the current media
|
||||||
|
/// opened again (the caller resyncs).
|
||||||
|
Future<void> reload() async {
|
||||||
|
final old = playback.detach();
|
||||||
|
if (old != null) await host.retire(old);
|
||||||
|
await start();
|
||||||
|
final url = _openUrl, headers = _openHeaders;
|
||||||
|
_openUrl = null;
|
||||||
|
// A page is resolved again: its stream URLs may have expired.
|
||||||
|
if (url != null) await open(url, headers: headers, fresh: true, resolve: _openResolve);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Leave: the prepared player and the session player go.
|
||||||
|
Future<void> release() async {
|
||||||
|
await dropPrepared();
|
||||||
|
final old = playback.detach();
|
||||||
|
_held = false;
|
||||||
|
if (old != null) await host.retire(old);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// App close: every player goes now (no mpv may outlive the engine).
|
||||||
|
Future<void> disposeAll() async {
|
||||||
|
_pre = null;
|
||||||
|
playback.detach();
|
||||||
|
await host.disposeAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replaces the current player with a fresh one (pins, hwdec, volume,
|
||||||
|
/// yt-dlp hook, shaders), its video output ready before the view swaps
|
||||||
|
/// to it when the view is up. True once it is the session player.
|
||||||
|
Future<bool> _freshPlayer() async {
|
||||||
|
await host.retiring;
|
||||||
|
final old = playback.current;
|
||||||
|
if (old == null) return false;
|
||||||
|
final HostedPlayer p;
|
||||||
|
try {
|
||||||
|
p = await host.create(PlayerRole.session);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('fresh player: $e');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
HostedVideo? video;
|
||||||
|
try {
|
||||||
|
// The view is up: the new output is created (and its texture
|
||||||
|
// registered) before the view swaps, so the old picture stays until
|
||||||
|
// then. Made by the view instead while the render mode is unknown,
|
||||||
|
// so the probe hears its GL init.
|
||||||
|
if (playback.hasVideo && host.renderMode.peek() != RenderMode.unknown) {
|
||||||
|
video = host.video(p);
|
||||||
|
await video.ready;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('fresh player setup: $e');
|
||||||
|
}
|
||||||
|
// Released or reloaded meanwhile: that player stays.
|
||||||
|
if (!identical(old, playback.current)) {
|
||||||
|
await host.dispose(p);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
final prev = playback.attach(p, video: video);
|
||||||
|
_held = true;
|
||||||
|
if (prev != null) unawaited(host.retire(prev));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------ resolve
|
||||||
|
|
||||||
|
/// [url] is a web page this app resolves: http(s), not this server, not
|
||||||
|
/// named like a media file.
|
||||||
|
bool _isPage(String url) {
|
||||||
|
final uri = Uri.tryParse(url);
|
||||||
|
if (uri == null || !(uri.isScheme('http') || uri.isScheme('https'))) return false;
|
||||||
|
final s = session.server();
|
||||||
|
if (uri.host == s.host && uri.port == s.port) return false;
|
||||||
|
return !hasMediaExtension(uri);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The resolve step for a link: a page through this app's yt-dlp (the
|
||||||
|
/// probe, remembered a while; [fresh] asks again: stream URLs expire)
|
||||||
|
/// gives what a player opens; a media file is opened as is, and so is a
|
||||||
|
/// stream opened with headers (a Jellyfin item's, with its token). Throws
|
||||||
|
/// a [ResolveFailure] in plain words, with the cookies used (D11).
|
||||||
|
Future<_Target> _resolve(String url, Map<String, String>? headers, {bool fresh = false}) async {
|
||||||
|
if (headers == null || headers.isEmpty) {
|
||||||
|
// A link this member passes on: the shared reader's loopback URL
|
||||||
|
// (it resolves an expired URL again itself, so [fresh] is moot).
|
||||||
|
final link = await shared.playerFor(url);
|
||||||
|
if (link != null) return _Target(link.source.playerUrl, const {}, subtitles: link.subtitles, shared: true);
|
||||||
|
}
|
||||||
|
if (!_isPage(url) || (headers != null && headers.isNotEmpty)) return _Target(url, headers ?? const {});
|
||||||
|
final l = links;
|
||||||
|
if (l == null) throw const ResolveFailure('no link resolver here');
|
||||||
|
final LinkProbe probe;
|
||||||
|
try {
|
||||||
|
probe = await l.probe(url, fresh: fresh).timeout(const Duration(seconds: 60));
|
||||||
|
} catch (e) {
|
||||||
|
throw ResolveFailure('$e');
|
||||||
|
}
|
||||||
|
if (!probe.isPage) return _Target(url, headers ?? const {});
|
||||||
|
final play = probe.info?.play;
|
||||||
|
if (play == null) {
|
||||||
|
throw ResolveFailure(probe.ytDlpError ?? (probe.info == null ? 'Not a video page yt-dlp knows' : 'no format a player opens'),
|
||||||
|
cookies: probe.cookies);
|
||||||
|
}
|
||||||
|
if (headers == null || headers.isEmpty) {
|
||||||
|
// Played here only: through a reader of its own too, which resumes
|
||||||
|
// after a cut and gets a fresh address (a CDN cuts mpv's reconnects
|
||||||
|
// off). Pieces tracks, or a reader that cannot open: the site as is.
|
||||||
|
final y = await l.ytDlp;
|
||||||
|
final local =
|
||||||
|
await shared.local(url, probe, () => LinkRelayMedia(Uri.parse(url), ytDlp: y, probe: probe), fresh: fresh);
|
||||||
|
if (local != null) {
|
||||||
|
return _Target(local.source.playerUrl, const {}, subtitles: local.subtitles, shared: true, local: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return _Target(play.mpvUrl, {...play.headers, ...?headers}, subtitles: play.subtitles);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The [SharedLinks.local] readers the player and the preload use stay;
|
||||||
|
/// the rest close.
|
||||||
|
void _trimLocal() => shared.trimLocal({_openUrl, _pre?.url});
|
||||||
|
|
||||||
|
/// The page's subtitles on [p] once its media is in (not selected: the
|
||||||
|
/// usual choice applies).
|
||||||
|
Future<void> _addSubtitles(HostedPlayer p, List<YtDlpSubtitle> subs) async {
|
||||||
|
if (subs.isEmpty) return;
|
||||||
|
final end = DateTime.now().add(const Duration(seconds: 30));
|
||||||
|
while (p.state.duration <= Duration.zero) {
|
||||||
|
if (p.disposed || DateTime.now().isAfter(end)) return;
|
||||||
|
await Future.delayed(const Duration(milliseconds: 200));
|
||||||
|
}
|
||||||
|
for (final sub in subs) {
|
||||||
|
try {
|
||||||
|
await p.command(['sub-add', sub.url, 'auto', sub.name.isEmpty ? sub.lang : sub.name, sub.lang]);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('subtitle ${sub.lang}: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------- open
|
||||||
|
|
||||||
|
String? _openUrl;
|
||||||
|
Map<String, String>? _openHeaders;
|
||||||
|
// [_openUrl] went through the page resolve (not a Jellyfin stream).
|
||||||
|
bool _openResolve = true;
|
||||||
|
bool _opening = false;
|
||||||
|
int _opens = 0;
|
||||||
|
|
||||||
|
/// The URL open (or opening) in the player; null for none.
|
||||||
|
String? get openUrl => _openUrl;
|
||||||
|
|
||||||
|
/// An open is in flight: the player's duration may still be the
|
||||||
|
/// previous media's.
|
||||||
|
bool get opening => _opening;
|
||||||
|
|
||||||
|
/// How many times this session opened a media (a drop and return must
|
||||||
|
/// not add one).
|
||||||
|
int get opens => _opens;
|
||||||
|
|
||||||
|
/// The mediaId whose media the player holds (or is opening); -1 none.
|
||||||
|
int playerMediaId = -1;
|
||||||
|
// [playerMediaId] at the last open: the same media again (a reconnect,
|
||||||
|
// joining back) keeps the player, any other gets a fresh one.
|
||||||
|
int _openedFor = -1;
|
||||||
|
int _mediaId = -1;
|
||||||
|
|
||||||
|
/// The media of the latest state is open here (or has nothing to open,
|
||||||
|
/// could not, or waits for the server): its mediaId. Up next prepares
|
||||||
|
/// what comes next only then, so the two never compete.
|
||||||
|
final settledMediaId = signal(-1);
|
||||||
|
|
||||||
|
/// Opens [url] (with [headers] on every request) in the session player:
|
||||||
|
/// the prepared player when it holds the same, else a fresh one when the
|
||||||
|
/// player held other media. False on failure (the error went to
|
||||||
|
/// [onError]). [resolve]: a web page is resolved first (not for a
|
||||||
|
/// Jellyfin stream).
|
||||||
|
Future<bool> open(String url, {Map<String, String>? headers, bool fresh = false, bool resolve = true}) async {
|
||||||
|
if (url == _openUrl) return true;
|
||||||
|
_opens++;
|
||||||
|
final mediaId = _mediaId;
|
||||||
|
_log.info('media $mediaId: open ${_urlKind(url, headers)}: ${logUrl(url)}'
|
||||||
|
'${headers == null || headers.isEmpty ? '' : ' (headers: ${headers.keys.join(', ')})'}');
|
||||||
|
final keepPlayer = playerMediaId == _openedFor;
|
||||||
|
_openedFor = playerMediaId;
|
||||||
|
_openUrl = url;
|
||||||
|
_openHeaders = headers;
|
||||||
|
_openResolve = resolve;
|
||||||
|
_opening = true;
|
||||||
|
_trimLocal();
|
||||||
|
try {
|
||||||
|
trace.mark(mediaId, SwitchTrace.open);
|
||||||
|
final promoted = await _openInPlayer(url, headers,
|
||||||
|
keepPlayer: keepPlayer,
|
||||||
|
fresh: fresh,
|
||||||
|
resolve: resolve,
|
||||||
|
onFresh: () => trace.mark(mediaId, SwitchTrace.player));
|
||||||
|
trace.mark(mediaId, promoted ? SwitchTrace.preloaded : SwitchTrace.opened);
|
||||||
|
_opening = false;
|
||||||
|
onOpened(mediaId, url);
|
||||||
|
return true;
|
||||||
|
} on ResolveFailure catch (e) {
|
||||||
|
_log.warning('media $mediaId: cannot resolve ${logUrl(url)}: ${e.message}${e.cookies == null ? '' : ' (${e.cookies})'}');
|
||||||
|
onError(e.message, cookies: e.cookies);
|
||||||
|
return false;
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('open media failed: $e');
|
||||||
|
onError('$e');
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
_opening = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> _openInPlayer(String url, Map<String, String>? headers,
|
||||||
|
{required bool keepPlayer, bool fresh = false, bool resolve = true, void Function()? onFresh}) async {
|
||||||
|
if (playback.current == null) return false;
|
||||||
|
final pre = _pre;
|
||||||
|
if (pre != null && pre.matches(url, headers) && await _promote(pre)) return true;
|
||||||
|
final target = resolve ? await _resolve(url, headers, fresh: fresh) : _Target(url, headers ?? const {});
|
||||||
|
if (!identical(target.url, url)) trace.mark(_mediaId, SwitchTrace.resolved);
|
||||||
|
if (_held && !keepPlayer && await _freshPlayer()) onFresh?.call();
|
||||||
|
final p = playback.current;
|
||||||
|
if (p == null) return false;
|
||||||
|
_held = true;
|
||||||
|
playback.beginOpen();
|
||||||
|
await p.open(target.url, headers: target.headers);
|
||||||
|
unawaited(_addSubtitles(p, target.subtitles));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What [url] is, for the log.
|
||||||
|
String _urlKind(String url, Map<String, String>? headers) {
|
||||||
|
if (url.startsWith('edl://')) return 'relay (tracks)';
|
||||||
|
final u = Uri.tryParse(url);
|
||||||
|
if (u == null || !(u.isScheme('http') || u.isScheme('https'))) return 'local file';
|
||||||
|
final s = session.server();
|
||||||
|
if (u.host == s.host && u.port == s.port && u.pathSegments.isNotEmpty) {
|
||||||
|
return switch (u.pathSegments.first) {
|
||||||
|
'r' => 'relay',
|
||||||
|
'm' => "the host's file",
|
||||||
|
'j' => "Jellyfin through the server's login",
|
||||||
|
_ => 'server URL',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (headers != null && headers.isNotEmpty) return 'Jellyfin, own sign-in';
|
||||||
|
if (shared.has(url)) return 'link passed on, read once here';
|
||||||
|
if (shared.hasLocal(url)) return 'link, read here with resume';
|
||||||
|
return 'link, fetched here';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------- plan and switch
|
||||||
|
|
||||||
|
// What this member serves: set at its swap, claimed by the state that
|
||||||
|
// names it as the relay's source.
|
||||||
|
MediaOrigin? _pendingOrigin;
|
||||||
|
(int, MediaOrigin)? _activeOrigin;
|
||||||
|
// The relayed media this viewer opens once it is live.
|
||||||
|
int _awaitLiveFor = -1;
|
||||||
|
|
||||||
|
/// This member's swap is about to be relayed from [origin].
|
||||||
|
void expectOrigin(MediaOrigin origin) => _pendingOrigin = origin;
|
||||||
|
|
||||||
|
/// Where this app reads [r]'s media from (claims a pending origin when
|
||||||
|
/// the state names this member as the source).
|
||||||
|
SourcePlan plan(StateBroadcast r) {
|
||||||
|
final relay = stateMediaRelay(r);
|
||||||
|
final self = session.selfId();
|
||||||
|
if (relay != null && relay.source.isNotEmpty && relay.source == self) {
|
||||||
|
final pending = _pendingOrigin;
|
||||||
|
if (pending != null) {
|
||||||
|
_pendingOrigin = null;
|
||||||
|
_activeOrigin = (r.mediaId, pending);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
final active = _activeOrigin;
|
||||||
|
return planMedia(PlanInput(
|
||||||
|
state: r,
|
||||||
|
selfId: self,
|
||||||
|
caps: session.caps(),
|
||||||
|
server: session.server(),
|
||||||
|
origin: active != null && active.$1 == r.mediaId ? active.$2 : null,
|
||||||
|
ownJellyfin: (id) =>
|
||||||
|
session.jellyfin.account(id) != null &&
|
||||||
|
session.jellyfin.statusOf(id) != JellyfinAccountStatus.expired,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A new media [r.mediaId]: the previous one leaves the player at once
|
||||||
|
/// (nobody sees or hears it while the new one resolves, is fetched or
|
||||||
|
/// opens), then plan → resolve → open. A Jellyfin item comes back to
|
||||||
|
/// the coordinator ([SwitchJellyfin]).
|
||||||
|
Future<SwitchResult> switchTo(StateBroadcast r) async {
|
||||||
|
_mediaId = r.mediaId;
|
||||||
|
_awaitLiveFor = -1;
|
||||||
|
_recovery = null;
|
||||||
|
final source = plan(r);
|
||||||
|
if (feeder.mediaId != r.mediaId) feeder.stop();
|
||||||
|
_jellyfinFeed = null;
|
||||||
|
await leavePrevious(r.mediaId);
|
||||||
|
final name = (r.media?['name'] as String?) ?? '';
|
||||||
|
final queueId = stateMediaRelay(r)?.queueId ?? '';
|
||||||
|
final duration = (r.media?['duration'] as num?)?.toDouble() ?? 0;
|
||||||
|
if (source is! OwnLink) await shared.closeCurrent();
|
||||||
|
switch (source) {
|
||||||
|
case JellyfinSource():
|
||||||
|
return SwitchJellyfin(source);
|
||||||
|
case OwnFile(:final path):
|
||||||
|
// The source first: viewers wait on it, not on this player.
|
||||||
|
feeder.serve(r.mediaId, RelayMedia.single(FileRelayBytes(File(path))),
|
||||||
|
announce: true, queueId: queueId, name: name, duration: duration);
|
||||||
|
await open(path);
|
||||||
|
case OwnLink(:final url, :final origin):
|
||||||
|
// Read from the site once: the relay serves what the player reads
|
||||||
|
// (pieces tracks keep the two fetches).
|
||||||
|
RelayMedia link() => LinkRelayMedia(Uri.parse(url), ytDlp: origin.ytDlp, probe: origin.probe);
|
||||||
|
final one = await shared.current(url, origin.probe, link, queueId: queueId);
|
||||||
|
feeder.serve(r.mediaId, one?.upload ?? link(), announce: true, queueId: queueId, name: name, duration: duration);
|
||||||
|
await open(url);
|
||||||
|
case RelayedSource(:final url, :final waiting, :final fetch, :final sourceId):
|
||||||
|
if (waiting) {
|
||||||
|
// The server is still getting it (yt-dlp takes seconds), or the
|
||||||
|
// member passing it on has not dialled in yet: mpv gives up on a
|
||||||
|
// silent request after 5 s, so open once it is live.
|
||||||
|
_openUrl = null;
|
||||||
|
_awaitLiveFor = r.mediaId;
|
||||||
|
final who = r.members.where((m) => m.id == sourceId).firstOrNull?.name;
|
||||||
|
_log.info('media ${r.mediaId}: waiting for ${fetch ? 'the server' : who} before opening the relay');
|
||||||
|
} else {
|
||||||
|
await open(url);
|
||||||
|
}
|
||||||
|
case Unresolvable(:final reason):
|
||||||
|
_openUrl = null;
|
||||||
|
onError(reason);
|
||||||
|
case ServerFile(:final url) || DirectLink(:final url):
|
||||||
|
await open(url);
|
||||||
|
case NoSource():
|
||||||
|
_openUrl = null;
|
||||||
|
}
|
||||||
|
return const SwitchDone();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The previous media leaves the player at once; a reconnect to the
|
||||||
|
/// media the player holds keeps it.
|
||||||
|
Future<void> leavePrevious(int mediaId) async {
|
||||||
|
final held = playerMediaId;
|
||||||
|
playerMediaId = mediaId;
|
||||||
|
// Opened from the welcome, before any state: that was this media.
|
||||||
|
if (held == -1 && _openedFor == -1 && _openUrl != null) _openedFor = mediaId;
|
||||||
|
if (held == -1 || held == mediaId) return;
|
||||||
|
_openUrl = null;
|
||||||
|
_openHeaders = null;
|
||||||
|
await playback.stop();
|
||||||
|
_trimLocal();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The same media came back as [r.mediaId] (a restarted server put back
|
||||||
|
/// what played, `swap.restore`) and the player keeps it open: what this
|
||||||
|
/// member passes on is served again (its file, its link, the Jellyfin
|
||||||
|
/// item played with its own account: [ownJellyfin]).
|
||||||
|
void resumeAs(StateBroadcast r, {({String url, Map<String, String> headers})? ownJellyfin}) {
|
||||||
|
renumber(r.mediaId);
|
||||||
|
settledMediaId.value = r.mediaId;
|
||||||
|
final source = plan(r);
|
||||||
|
final name = (r.media?['name'] as String?) ?? '';
|
||||||
|
final queueId = stateMediaRelay(r)?.queueId ?? '';
|
||||||
|
final duration = (r.media?['duration'] as num?)?.toDouble() ?? 0;
|
||||||
|
switch (source) {
|
||||||
|
case OwnFile(:final path):
|
||||||
|
feeder.serve(r.mediaId, RelayMedia.single(FileRelayBytes(File(path))),
|
||||||
|
queueId: queueId, name: name, duration: duration);
|
||||||
|
case OwnLink(:final url, :final origin):
|
||||||
|
feeder.serve(r.mediaId,
|
||||||
|
shared.currentFor(url)?.upload ?? LinkRelayMedia(Uri.parse(url), ytDlp: origin.ytDlp, probe: origin.probe),
|
||||||
|
queueId: queueId, name: name, duration: duration);
|
||||||
|
case JellyfinSource(:final ref, :final origin):
|
||||||
|
if (origin == null || ownJellyfin == null) return;
|
||||||
|
final media = RelayMedia.single(HttpRelayBytes(Uri.parse(ownJellyfin.url), headers: ownJellyfin.headers));
|
||||||
|
if (jellyfinRelayNeeded(r, ref, session.selfId())) {
|
||||||
|
feeder.serve(r.mediaId, media, queueId: queueId, name: name, duration: duration);
|
||||||
|
} else {
|
||||||
|
_jellyfinFeed = (mediaId: r.mediaId, ref: ref, media: media, queueId: queueId, name: name, duration: duration);
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The media was settled here (opened, failed, nothing to open).
|
||||||
|
void settled(int mediaId) {
|
||||||
|
if (_mediaId == mediaId) settledMediaId.value = mediaId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Same media, a new state: a relay that became live opens, a relay
|
||||||
|
/// opened while its source was away and never loaded opens again now
|
||||||
|
/// that it is back (D6), a Jellyfin stream this member holds back starts
|
||||||
|
/// once someone needs it.
|
||||||
|
Future<void> onState(StateBroadcast r) async {
|
||||||
|
final rec = _recovery;
|
||||||
|
final relay = stateMediaRelay(r);
|
||||||
|
final url = _openUrl;
|
||||||
|
if (rec != null && rec.mediaId == r.mediaId && relay != null && relay.live && !relay.sourceGone &&
|
||||||
|
url != null && !_opening && playback.durationSecs <= 0) {
|
||||||
|
_recovery = null;
|
||||||
|
_log.info('media ${r.mediaId}: the relay has its source again; opening it again (it never loaded)');
|
||||||
|
_openUrl = null;
|
||||||
|
_openedFor = -2; // a fresh player: the old one may be stuck in a broken open
|
||||||
|
await open(url, headers: _openHeaders, resolve: _openResolve);
|
||||||
|
}
|
||||||
|
if (_awaitLiveFor == r.mediaId) {
|
||||||
|
final source = plan(r);
|
||||||
|
if (source is RelayedSource && !source.waiting) {
|
||||||
|
_awaitLiveFor = -1;
|
||||||
|
await open(source.url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
final feed = _jellyfinFeed;
|
||||||
|
if (feed != null && feed.mediaId == r.mediaId && jellyfinRelayNeeded(r, feed.ref, session.selfId())) {
|
||||||
|
_jellyfinFeed = null;
|
||||||
|
_log.info('media ${r.mediaId}: someone needs the Jellyfin item passed on, starting the relay');
|
||||||
|
feeder.serve(r.mediaId, feed.media, queueId: feed.queueId, name: feed.name, duration: feed.duration);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sitting out: nothing open, nothing pulled. A relay this member serves
|
||||||
|
/// goes on (it does not use the player).
|
||||||
|
Future<void> unload() async {
|
||||||
|
_awaitLiveFor = -1;
|
||||||
|
if (_mediaId == -1 && _openUrl == null) return;
|
||||||
|
_mediaId = -1;
|
||||||
|
playerMediaId = -1;
|
||||||
|
_openUrl = null;
|
||||||
|
_openHeaders = null;
|
||||||
|
_recovery = null;
|
||||||
|
if (ready) await playback.stop();
|
||||||
|
_trimLocal();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The next state's media is new to this player: it opens again from
|
||||||
|
/// scratch (a relay this member served for the old one stops).
|
||||||
|
void forget() {
|
||||||
|
_mediaId = -1;
|
||||||
|
playerMediaId = -1;
|
||||||
|
_openUrl = null;
|
||||||
|
_openHeaders = null;
|
||||||
|
_awaitLiveFor = -1;
|
||||||
|
_recovery = null;
|
||||||
|
_activeOrigin = null;
|
||||||
|
_jellyfinFeed = null;
|
||||||
|
feeder.stopAll();
|
||||||
|
unawaited(shared.closeAll());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The same media under a restarted server's numbering: the player keeps
|
||||||
|
/// it.
|
||||||
|
void renumber(int mediaId) => _mediaId = playerMediaId = _openedFor = mediaId;
|
||||||
|
|
||||||
|
/// A new session (connect): nothing held, nothing served.
|
||||||
|
void reset() {
|
||||||
|
_mediaId = -1;
|
||||||
|
playerMediaId = -1;
|
||||||
|
_openedFor = -1;
|
||||||
|
_openUrl = null;
|
||||||
|
_openHeaders = null;
|
||||||
|
_awaitLiveFor = -1;
|
||||||
|
_recovery = null;
|
||||||
|
_pendingOrigin = null;
|
||||||
|
_activeOrigin = null;
|
||||||
|
_jellyfinFeed = null;
|
||||||
|
settledMediaId.value = -1;
|
||||||
|
feeder.stopAll();
|
||||||
|
unawaited(shared.closeAll());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------- Jellyfin
|
||||||
|
|
||||||
|
// A Jellyfin stream this member holds back: nobody present needs it
|
||||||
|
// passed on yet.
|
||||||
|
({int mediaId, JellyfinRef ref, RelayMedia media, String queueId, String name, double duration})? _jellyfinFeed;
|
||||||
|
|
||||||
|
/// The resolve step of a Jellyfin item: this app's account (or the
|
||||||
|
/// [warm] plan resolved ahead), else the server's login, else the member
|
||||||
|
/// passing it on, else blocked. This member's own item is passed on to
|
||||||
|
/// viewers without an account once someone needs it. Then it opens.
|
||||||
|
/// Null when [current] went false meanwhile.
|
||||||
|
Future<JellyfinPlan?> openJellyfin(JellyfinSource source, StateBroadcast r,
|
||||||
|
{OwnPlay? warm, required bool Function() current}) async {
|
||||||
|
// Until it resolves nothing is open: no syncing the previous media.
|
||||||
|
final prev = _openUrl;
|
||||||
|
_openUrl = null;
|
||||||
|
_opening = true;
|
||||||
|
if (ready) {
|
||||||
|
try {
|
||||||
|
await playback.player.pause();
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
JellyfinPlan plan;
|
||||||
|
try {
|
||||||
|
plan = warm ??
|
||||||
|
await resolveJellyfinRef(session.jellyfin, source.ref,
|
||||||
|
relay: source.serverLogin != null, relayUrl: source.serverLogin, memberRelayUrl: source.memberRelay);
|
||||||
|
} finally {
|
||||||
|
if (current()) _opening = false;
|
||||||
|
}
|
||||||
|
if (!current()) return null;
|
||||||
|
trace.mark(r.mediaId, SwitchTrace.resolved);
|
||||||
|
final origin = source.origin;
|
||||||
|
if (origin != null) {
|
||||||
|
final name = (r.media?['name'] as String?) ?? '';
|
||||||
|
final duration = (r.media?['duration'] as num?)?.toDouble() ?? 0;
|
||||||
|
final queueId = stateMediaRelay(r)?.queueId ?? '';
|
||||||
|
RelayMedia? media;
|
||||||
|
if (plan is OwnPlay) {
|
||||||
|
media = RelayMedia.single(HttpRelayBytes(Uri.parse(plan.url), headers: plan.headers));
|
||||||
|
} else if (origin.link != null) {
|
||||||
|
plan = RelayPlay(origin.link!);
|
||||||
|
media = RelayMedia.single(HttpRelayBytes(Uri.parse(origin.link!)));
|
||||||
|
}
|
||||||
|
if (media != null) {
|
||||||
|
if (jellyfinRelayNeeded(r, source.ref, session.selfId())) {
|
||||||
|
feeder.serve(r.mediaId, media, queueId: queueId, name: name, duration: duration);
|
||||||
|
} else {
|
||||||
|
_jellyfinFeed =
|
||||||
|
(mediaId: r.mediaId, ref: source.ref, media: media, queueId: queueId, name: name, duration: duration);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch (plan) {
|
||||||
|
case BlockedPlay():
|
||||||
|
await playback.stop();
|
||||||
|
case RelayPlay(:final url):
|
||||||
|
// The same relay URL (a resumed member): the player still has it.
|
||||||
|
if (url == prev) {
|
||||||
|
_openUrl = url;
|
||||||
|
} else if (!await open(url, resolve: false)) {
|
||||||
|
return current() ? plan : null;
|
||||||
|
}
|
||||||
|
case OwnPlay(:final url, :final headers):
|
||||||
|
if (!await open(url, headers: headers, resolve: false)) return current() ? plan : null;
|
||||||
|
}
|
||||||
|
return current() ? plan : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------ recovery (D6)
|
||||||
|
|
||||||
|
// The relay this viewer reads lost its source and the buffer ran dry:
|
||||||
|
// what it waits for.
|
||||||
|
_Recovery? _recovery;
|
||||||
|
|
||||||
|
/// This viewer reads the current media through a relay whose source is
|
||||||
|
/// gone, or left: it plays what the server has.
|
||||||
|
bool get sourceDown => _recovery != null;
|
||||||
|
|
||||||
|
/// The user sought to [to] (applied at once): while the source is down,
|
||||||
|
/// that is the one try.
|
||||||
|
void noteUserSeek(double to) {
|
||||||
|
final rec = _recovery;
|
||||||
|
if (rec == null) return;
|
||||||
|
rec.tried = true;
|
||||||
|
rec.tryTo = to;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// While the source is down, this player cannot be where the server is
|
||||||
|
/// ([serverPosition]): a seek waits, or the player is stuck short of it.
|
||||||
|
/// The report then says nothing is buffered, so the server waits for the
|
||||||
|
/// source instead of playing on without this viewer.
|
||||||
|
bool starved(double serverPosition) {
|
||||||
|
final rec = _recovery;
|
||||||
|
if (rec == null) return false;
|
||||||
|
return rec.seekTo != null || (playback.positionSecs - serverPosition).abs() > 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Seeks while the source is down, not applied (tests: never a loop).
|
||||||
|
@visibleForTesting
|
||||||
|
int heldBackSeeks = 0;
|
||||||
|
|
||||||
|
/// The viewer's side of a source that drops (D6): while the relay this
|
||||||
|
/// app reads has no source, it plays what is buffered. A seek past it
|
||||||
|
/// (the server's buffer may still hold it) is tried once; any further
|
||||||
|
/// one waits (mpv would wait on bytes nobody sends, time out after 5 s
|
||||||
|
/// and be asked again: the seek loop), and the report says nothing is
|
||||||
|
/// buffered ([starved]), so the server holds everyone with the reason
|
||||||
|
/// `source` ("waiting for Bob"). When the source is back, one seek to
|
||||||
|
/// where the server is resumes the player. Returns the [actions] to
|
||||||
|
/// apply.
|
||||||
|
List<PlayerAction> recover(StateBroadcast r, List<PlayerAction> actions, {required double serverPosition}) {
|
||||||
|
final relay = stateMediaRelay(r);
|
||||||
|
final url = _openUrl;
|
||||||
|
final viaRelay = url != null && (url.startsWith('edl://') || Uri.tryParse(url)?.pathSegments.firstOrNull == 'r');
|
||||||
|
final down = relay != null &&
|
||||||
|
viaRelay &&
|
||||||
|
!relay.fetch &&
|
||||||
|
relay.source != session.selfId() &&
|
||||||
|
(relay.sourceGone || !relay.live);
|
||||||
|
final rec = _recovery;
|
||||||
|
if (down) {
|
||||||
|
final at = playback.positionSecs;
|
||||||
|
final reach = at + playback.bufferedSecsAhead;
|
||||||
|
final now = _recovery ??= _Recovery(r.mediaId);
|
||||||
|
if (rec == null) {
|
||||||
|
_log.info('media ${r.mediaId}: the relay has no source; playing what is buffered '
|
||||||
|
'(to ${reach.toStringAsFixed(1)} s)');
|
||||||
|
}
|
||||||
|
final out = <PlayerAction>[];
|
||||||
|
for (final a in actions) {
|
||||||
|
if (a is SeekAction && (a.position > reach + 0.5 || a.position < at - 30)) {
|
||||||
|
final last = now.tryTo;
|
||||||
|
if (!now.tried && (last == null || (a.position - last).abs() > 1.5)) {
|
||||||
|
now.tried = true;
|
||||||
|
now.tryTo = a.position;
|
||||||
|
out.add(a);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
heldBackSeeks++;
|
||||||
|
now.seekTo = a.position;
|
||||||
|
} else {
|
||||||
|
out.add(a);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
_recovery = null;
|
||||||
|
if (rec == null || rec.mediaId != r.mediaId) return actions;
|
||||||
|
final stuck = rec.seekTo != null ||
|
||||||
|
(playback.positionSecs - serverPosition).abs() > 1.0 ||
|
||||||
|
playback.bufferedSecsAhead < 1;
|
||||||
|
_log.info('media ${r.mediaId}: the relay has its source again'
|
||||||
|
'${stuck ? '; resuming at ${serverPosition.toStringAsFixed(1)} s' : ''}');
|
||||||
|
if (!stuck) return actions;
|
||||||
|
// One seek to where the server is: the demuxer reads from there again.
|
||||||
|
return [
|
||||||
|
for (final a in actions) if (a is! SeekAction) a,
|
||||||
|
SeekAction(serverPosition),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------ prepare
|
||||||
|
|
||||||
|
/// Preparing on (env `SYNC_RELAY_PRELOAD=0` turns it off; tests set it).
|
||||||
|
bool preloadEnabled = Platform.environment['SYNC_RELAY_PRELOAD'] != '0';
|
||||||
|
|
||||||
|
_Prepared? _pre;
|
||||||
|
|
||||||
|
/// The URL prepared now (null: none).
|
||||||
|
String? get preloadUrl => _pre?.url;
|
||||||
|
|
||||||
|
/// This viewer's URL of the warm relay of Up next's first [item]: the
|
||||||
|
/// same URL the media has once the item goes up, so the prepared player
|
||||||
|
/// is promoted.
|
||||||
|
String warmRelayUrl(QueueItem item, MediaRelay relay) =>
|
||||||
|
session.server().relayUrl(item.media.name.isEmpty ? item.title : item.media.name, item.id, relay.tracks);
|
||||||
|
|
||||||
|
/// Feeds the warm relay of Up next item [queueId], the link [url] this
|
||||||
|
/// member passes on, from one shared reader (pieces tracks: from their
|
||||||
|
/// own fetch, as [LinkRelayMedia]). A preload of [url] that reads the
|
||||||
|
/// site itself (it was prepared before the reader existed) is prepared
|
||||||
|
/// again on the reader, so the item is fetched once.
|
||||||
|
Future<void> feedWarmLink(String queueId, String url, LinkProbe probe, YtDlp ytDlp) async {
|
||||||
|
if (feeder.feedsWarm(queueId)) return;
|
||||||
|
RelayMedia link() => LinkRelayMedia(Uri.parse(url), ytDlp: ytDlp, probe: probe);
|
||||||
|
final one = await shared.warm(queueId, url, probe, link);
|
||||||
|
if (feeder.feedsWarm(queueId)) return;
|
||||||
|
feeder.feedWarm(queueId, one?.upload ?? link());
|
||||||
|
final pre = _pre;
|
||||||
|
if (one != null && pre != null && pre.url == url && pre.headers.isEmpty && !pre.shared && !pre.failed) {
|
||||||
|
_log.info('Up next ${logUrl(url)}: preparing it again on the shared reader');
|
||||||
|
unawaited(prepare(url, fresh: true));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Opens [url] early in a hidden player (paused at the start, muted, a
|
||||||
|
/// small cache, its own video output when the view is up), replacing any
|
||||||
|
/// other; completes once [need] seconds are buffered or the whole media
|
||||||
|
/// is: true, false when it failed (or was dropped or replaced
|
||||||
|
/// meanwhile). The same [url] and [headers] again is the same one unless
|
||||||
|
/// [fresh]. [open] for the same URL promotes it.
|
||||||
|
Future<bool> prepare(String url, {Map<String, String>? headers, double need = 4, bool fresh = false}) async {
|
||||||
|
if (!preloadEnabled || !ready) return false;
|
||||||
|
final had = _pre;
|
||||||
|
if (had != null && had.matches(url, headers) && !had.failed && !fresh) return had.ready.future;
|
||||||
|
// Set before any await: a second call for the same URL finds it.
|
||||||
|
_pre = null;
|
||||||
|
if (had != null) unawaited(_disposePrepared(had));
|
||||||
|
final pre = _pre = _Prepared(url, headers ?? const {});
|
||||||
|
_trimLocal();
|
||||||
|
try {
|
||||||
|
final target = await _resolve(url, headers, fresh: fresh);
|
||||||
|
pre.shared = target.shared && !target.local;
|
||||||
|
if (!identical(_pre, pre)) {
|
||||||
|
pre.fail();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
final p = pre.player = await host.create(PlayerRole.preload);
|
||||||
|
pre.logSub = p.logs.listen((log) {
|
||||||
|
if (mpvLoadFailed(log, p.state.duration)) pre.fail();
|
||||||
|
});
|
||||||
|
pre.subtitles = target.subtitles;
|
||||||
|
if (playback.hasVideo) {
|
||||||
|
pre.video = host.video(p);
|
||||||
|
await pre.video!.ready;
|
||||||
|
}
|
||||||
|
if (!identical(_pre, pre)) {
|
||||||
|
pre.fail();
|
||||||
|
await _disposePrepared(pre);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
await p.open(target.url, headers: target.headers);
|
||||||
|
if (!pre.opened.isCompleted) pre.opened.complete();
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('preload failed: $e');
|
||||||
|
pre.fail();
|
||||||
|
if (identical(_pre, pre)) _pre = null;
|
||||||
|
await _disposePrepared(pre);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
unawaited(_watchPrepared(pre, need));
|
||||||
|
return pre.ready.future;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _watchPrepared(_Prepared pre, double need) async {
|
||||||
|
final end = DateTime.now().add(const Duration(seconds: 60));
|
||||||
|
while (identical(_pre, pre) && !pre.failed && DateTime.now().isBefore(end)) {
|
||||||
|
final st = pre.player!.state;
|
||||||
|
final secs = st.duration.inMilliseconds / 1000;
|
||||||
|
final buffered = (st.buffer - st.position).inMilliseconds / 1000;
|
||||||
|
if (secs > 0 && (buffered >= need || st.buffer.inMilliseconds / 1000 >= secs - 0.5)) {
|
||||||
|
if (!pre.ready.isCompleted) pre.ready.complete(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await Future.delayed(const Duration(milliseconds: 200));
|
||||||
|
}
|
||||||
|
if (!pre.ready.isCompleted) pre.ready.complete(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drops the prepared player (nothing to get ready, or something else
|
||||||
|
/// is).
|
||||||
|
Future<void> dropPrepared() async {
|
||||||
|
final pre = _pre;
|
||||||
|
if (pre == null) return;
|
||||||
|
_pre = null;
|
||||||
|
await _disposePrepared(pre);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _disposePrepared(_Prepared pre) async {
|
||||||
|
pre.fail();
|
||||||
|
await pre.logSub?.cancel();
|
||||||
|
final p = pre.player;
|
||||||
|
if (p != null) await host.dispose(p);
|
||||||
|
if (!identical(_pre, pre)) _trimLocal();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [pre] becomes the session player; its buffer comes along.
|
||||||
|
Future<bool> _promote(_Prepared pre) async {
|
||||||
|
await pre.opened.future;
|
||||||
|
final old = playback.current;
|
||||||
|
if (!identical(_pre, pre) || old == null) return false;
|
||||||
|
_pre = null;
|
||||||
|
final p = pre.player;
|
||||||
|
if (pre.failed || p == null) {
|
||||||
|
unawaited(_disposePrepared(pre));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
await pre.logSub?.cancel();
|
||||||
|
if (!pre.ready.isCompleted) pre.ready.complete(true);
|
||||||
|
await host.promote(p);
|
||||||
|
final prev = playback.attach(p, video: pre.video, loaded: true);
|
||||||
|
_held = true;
|
||||||
|
unawaited(_addSubtitles(p, pre.subtitles));
|
||||||
|
if (prev != null) unawaited(host.retire(prev));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> dispose() async {
|
||||||
|
await release();
|
||||||
|
feeder.stopAll();
|
||||||
|
await shared.closeAll();
|
||||||
|
await playback.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _Recovery {
|
||||||
|
final int mediaId;
|
||||||
|
|
||||||
|
/// The latest seek held back (where the server went meanwhile).
|
||||||
|
double? seekTo;
|
||||||
|
|
||||||
|
/// The one seek past the buffer was tried ([tryTo]: where to).
|
||||||
|
bool tried = false;
|
||||||
|
double? tryTo;
|
||||||
|
_Recovery(this.mediaId);
|
||||||
|
}
|
||||||
|
|
||||||
|
class _Prepared {
|
||||||
|
final String url;
|
||||||
|
final Map<String, String> headers;
|
||||||
|
HostedPlayer? player;
|
||||||
|
HostedVideo? video;
|
||||||
|
StreamSubscription<PlayerLogLine>? logSub;
|
||||||
|
List<YtDlpSubtitle> subtitles = const [];
|
||||||
|
|
||||||
|
/// It reads a shared reader's loopback URL, not the site.
|
||||||
|
bool shared = false;
|
||||||
|
|
||||||
|
/// Completes once the media is handed to mpv (or it failed).
|
||||||
|
final opened = Completer<void>();
|
||||||
|
bool failed = false;
|
||||||
|
final ready = Completer<bool>();
|
||||||
|
_Prepared(this.url, this.headers);
|
||||||
|
|
||||||
|
bool matches(String url, Map<String, String>? headers) =>
|
||||||
|
url == this.url && mapEquals(headers ?? const {}, this.headers);
|
||||||
|
|
||||||
|
void fail() {
|
||||||
|
failed = true;
|
||||||
|
if (!ready.isCompleted) ready.complete(false);
|
||||||
|
if (!opened.isCompleted) opened.complete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a player opens for a link (the resolve step).
|
||||||
|
class _Target {
|
||||||
|
final String url;
|
||||||
|
final Map<String, String> headers;
|
||||||
|
final List<YtDlpSubtitle> subtitles;
|
||||||
|
|
||||||
|
/// A link this member passes on, read through its shared reader.
|
||||||
|
final bool shared;
|
||||||
|
|
||||||
|
/// Read through a [SharedLinks.local] reader (played here only).
|
||||||
|
final bool local;
|
||||||
|
const _Target(this.url, this.headers, {this.subtitles = const [], this.shared = false, this.local = false});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A page could not be resolved: why ([message], yt-dlp's words) and which
|
||||||
|
/// cookies that used ([cookies], D11).
|
||||||
|
class ResolveFailure implements Exception {
|
||||||
|
final String message;
|
||||||
|
final String? cookies;
|
||||||
|
const ResolveFailure(this.message, {this.cookies});
|
||||||
|
@override
|
||||||
|
String toString() => message;
|
||||||
|
}
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
import 'package:sync_relay_core/core.dart';
|
||||||
|
|
||||||
|
import '../jellyfin/session_media.dart' show refOfStateMedia, relayUrlFor;
|
||||||
|
import 'delivery.dart' show stateMediaRelay;
|
||||||
|
import 'party.dart' show isPresent;
|
||||||
|
|
||||||
|
// The plan step of the media pipeline: from the server's state, what this
|
||||||
|
// member serves, its own capabilities and who is present, where this app
|
||||||
|
// reads the current media from. One switch, pure: `media_plan_test` holds
|
||||||
|
// the table.
|
||||||
|
|
||||||
|
/// What this member serves for the media it put up (set at its swap,
|
||||||
|
/// claimed by the state that names it as the relay's source).
|
||||||
|
sealed class MediaOrigin {
|
||||||
|
const MediaOrigin();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A file on this computer, passed on through the relay.
|
||||||
|
final class FileOrigin extends MediaOrigin {
|
||||||
|
final String path;
|
||||||
|
const FileOrigin(this.path);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A link this computer reads and passes on ("through me").
|
||||||
|
final class LinkOrigin extends MediaOrigin {
|
||||||
|
final String url;
|
||||||
|
|
||||||
|
/// What the link is (a page goes through [ytDlp]).
|
||||||
|
final LinkProbe probe;
|
||||||
|
final YtDlp ytDlp;
|
||||||
|
const LinkOrigin(this.url, this.probe, this.ytDlp);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A Jellyfin item: served with this install's account, else with the
|
||||||
|
/// pasted [link] (its api_key stays on this machine).
|
||||||
|
final class JellyfinOrigin extends MediaOrigin {
|
||||||
|
final String? link;
|
||||||
|
const JellyfinOrigin(this.link);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where this app's server is, as this member dialled it.
|
||||||
|
class ServerAddress {
|
||||||
|
final String host;
|
||||||
|
final int port;
|
||||||
|
final String token;
|
||||||
|
const ServerAddress(this.host, this.port, this.token);
|
||||||
|
|
||||||
|
/// This viewer's `/m` URL of your server's file [name].
|
||||||
|
String fileUrl(String name) => 'http://$host:$port/m/default/$token/${Uri.encodeComponent(name)}';
|
||||||
|
|
||||||
|
/// This viewer's `/r` URL of a relay ([key]: the media id, or the queue
|
||||||
|
/// id of one started in Up next); with tracks, a pieces playlist or an
|
||||||
|
/// mpv EDL of every track.
|
||||||
|
String relayUrl(String name, String key, [List<RelayTrackSpec> tracks = const []]) =>
|
||||||
|
relayViewerUrl('http://$host:$port/r/default/$token/$key', name, tracks: tracks);
|
||||||
|
|
||||||
|
/// This member's `/j` URL of [ref] (the server's Jellyfin login).
|
||||||
|
String jellyfinUrl(JellyfinRef ref) => relayUrlFor(host, port, token, ref.itemId);
|
||||||
|
|
||||||
|
/// The relay channel this member feeds ([queueId]: the warm relay of
|
||||||
|
/// that Up next item).
|
||||||
|
Uri sourceChannel([String queueId = '']) => Uri.parse('ws://$host:$port/r/default/$token/source'
|
||||||
|
'${queueId.isEmpty ? '' : '?q=${Uri.encodeQueryComponent(queueId)}'}');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where this app reads the current media from.
|
||||||
|
sealed class SourcePlan {
|
||||||
|
const SourcePlan();
|
||||||
|
|
||||||
|
/// The URL a player opens; null when there is nothing to open yet.
|
||||||
|
String? get url => null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// No media, or nothing this app can open (yet).
|
||||||
|
final class NoSource extends SourcePlan {
|
||||||
|
const NoSource();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// This member passes its file on: the player plays the file itself, the
|
||||||
|
/// relay source feeds the server.
|
||||||
|
final class OwnFile extends SourcePlan {
|
||||||
|
final String path;
|
||||||
|
const OwnFile(this.path);
|
||||||
|
@override
|
||||||
|
String get url => path;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// This member passes a link on: it plays the link itself (a page through
|
||||||
|
/// its yt-dlp), the relay source reads it for the server.
|
||||||
|
final class OwnLink extends SourcePlan {
|
||||||
|
@override
|
||||||
|
final String url;
|
||||||
|
final LinkOrigin origin;
|
||||||
|
const OwnLink(this.url, this.origin);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Your server's own file (`/m`).
|
||||||
|
final class ServerFile extends SourcePlan {
|
||||||
|
@override
|
||||||
|
final String url;
|
||||||
|
const ServerFile(this.url);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A link everyone opens itself; [page]: a web page, resolved by this
|
||||||
|
/// app's yt-dlp (the pipeline's resolve step).
|
||||||
|
final class DirectLink extends SourcePlan {
|
||||||
|
@override
|
||||||
|
final String url;
|
||||||
|
final bool page;
|
||||||
|
const DirectLink(this.url, {required this.page});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The server's relay (`/r`): a member passing it on ([sourceId]) or the
|
||||||
|
/// server's own fetch ([fetch]). [waiting]: nobody feeds it yet, so it opens
|
||||||
|
/// once `relay.live` (mpv gives up on a silent request after 5 s).
|
||||||
|
final class RelayedSource extends SourcePlan {
|
||||||
|
@override
|
||||||
|
final String url;
|
||||||
|
final bool fetch;
|
||||||
|
final String sourceId;
|
||||||
|
final bool waiting;
|
||||||
|
const RelayedSource(this.url, {required this.fetch, required this.sourceId, required this.waiting});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A Jellyfin item: this app's own account first ([ownAccount]), then the
|
||||||
|
/// server's login ([serverLogin], `/j`), then the member who put it up
|
||||||
|
/// ([memberRelay], `/r`); the resolve step decides. [origin]: this member
|
||||||
|
/// put it up (it passes it on to viewers without an account).
|
||||||
|
final class JellyfinSource extends SourcePlan {
|
||||||
|
final JellyfinRef ref;
|
||||||
|
final bool ownAccount;
|
||||||
|
final String? serverLogin;
|
||||||
|
final String? memberRelay;
|
||||||
|
final JellyfinOrigin? origin;
|
||||||
|
const JellyfinSource(this.ref, {required this.ownAccount, this.serverLogin, this.memberRelay, this.origin});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A page link this app cannot resolve (no yt-dlp here: `caps.resolve`
|
||||||
|
/// false): [via] names who can pass it on.
|
||||||
|
final class Unresolvable extends SourcePlan {
|
||||||
|
final String link;
|
||||||
|
final String via;
|
||||||
|
const Unresolvable(this.link, this.via);
|
||||||
|
|
||||||
|
String get reason => "This device can't open web pages; ${via.isEmpty ? 'ask someone' : via} to pass it on (Through me)";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What [planMedia] reads.
|
||||||
|
class PlanInput {
|
||||||
|
final StateBroadcast state;
|
||||||
|
final String selfId;
|
||||||
|
final ClientCaps caps;
|
||||||
|
final ServerAddress server;
|
||||||
|
|
||||||
|
/// What this member serves for [StateBroadcast.mediaId] (null: nothing).
|
||||||
|
final MediaOrigin? origin;
|
||||||
|
|
||||||
|
/// This app plays Jellyfin [serverId] with its own account.
|
||||||
|
final bool Function(String serverId) ownJellyfin;
|
||||||
|
const PlanInput({
|
||||||
|
required this.state,
|
||||||
|
required this.selfId,
|
||||||
|
required this.caps,
|
||||||
|
required this.server,
|
||||||
|
this.origin,
|
||||||
|
required this.ownJellyfin,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A relay nobody feeds yet: the server's fetch before it is read, or a
|
||||||
|
/// member's before its channel is up while that member is still here
|
||||||
|
/// (once it is gone or left, the server's buffer is all there is: open
|
||||||
|
/// that).
|
||||||
|
bool relayWaitsForSource(StateBroadcast r, MediaRelay relay) =>
|
||||||
|
!relay.live && !relay.sourceGone && (relay.fetch || r.members.any((m) => m.id == relay.source));
|
||||||
|
|
||||||
|
/// The members that can resolve a page link (`caps.resolve`), present
|
||||||
|
/// ones, lowest id first.
|
||||||
|
List<MemberView> linkResolvers(StateBroadcast r, {String except = ''}) =>
|
||||||
|
[for (final m in r.members) if (m.caps.resolve && isPresent(m) && m.id != except) m];
|
||||||
|
|
||||||
|
/// Where this app reads [input]'s media from.
|
||||||
|
SourcePlan planMedia(PlanInput input) {
|
||||||
|
final r = input.state;
|
||||||
|
final media = r.media;
|
||||||
|
if (media == null) return const NoSource();
|
||||||
|
final name = (media['name'] as String?) ?? '';
|
||||||
|
final relay = stateMediaRelay(r);
|
||||||
|
final mine = relay != null && relay.source.isNotEmpty && relay.source == input.selfId;
|
||||||
|
final origin = mine ? input.origin : null;
|
||||||
|
final key = relay == null || relay.queueId.isEmpty ? '${r.mediaId}' : relay.queueId;
|
||||||
|
|
||||||
|
final ref = refOfStateMedia(media);
|
||||||
|
if (ref != null) {
|
||||||
|
return JellyfinSource(
|
||||||
|
ref,
|
||||||
|
ownAccount: input.ownJellyfin(ref.serverId),
|
||||||
|
serverLogin: r.relay && input.server.token.isNotEmpty ? input.server.jellyfinUrl(ref) : null,
|
||||||
|
memberRelay: relay != null && !mine ? input.server.relayUrl(name, key) : null,
|
||||||
|
origin: origin is JellyfinOrigin ? origin : (mine ? const JellyfinOrigin(null) : null),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
switch (origin) {
|
||||||
|
case FileOrigin(:final path):
|
||||||
|
return OwnFile(path);
|
||||||
|
case LinkOrigin(:final url):
|
||||||
|
return OwnLink(url, origin);
|
||||||
|
case JellyfinOrigin() || null:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (relay != null) {
|
||||||
|
// Another member's relay or the server's fetch; this member's own
|
||||||
|
// relay without its origin (a restarted app) reads the server's buffer.
|
||||||
|
return RelayedSource(input.server.relayUrl(name, key, relay.tracks),
|
||||||
|
fetch: relay.fetch, sourceId: relay.source, waiting: !mine && relayWaitsForSource(r, relay));
|
||||||
|
}
|
||||||
|
final url = media['url'];
|
||||||
|
if (url is String && url.isNotEmpty) {
|
||||||
|
final uri = Uri.tryParse(url);
|
||||||
|
final web = uri != null && (uri.isScheme('http') || uri.isScheme('https'));
|
||||||
|
final page = web && !hasMediaExtension(uri);
|
||||||
|
if (page && !input.caps.resolve) {
|
||||||
|
final who = linkResolvers(r, except: input.selfId).firstOrNull;
|
||||||
|
return Unresolvable(url, who == null ? '' : 'ask ${who.name}');
|
||||||
|
}
|
||||||
|
return DirectLink(url, page: page);
|
||||||
|
}
|
||||||
|
if (name.isNotEmpty && input.server.token.isNotEmpty) return ServerFile(input.server.fileUrl(name));
|
||||||
|
return const NoSource();
|
||||||
|
}
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart' show debugPrint;
|
||||||
|
import 'package:signals/signals.dart';
|
||||||
|
import 'package:sync_relay_core/core.dart';
|
||||||
|
|
||||||
|
import '../screens/home/health_probe.dart';
|
||||||
|
import '../state/connect_prefs.dart';
|
||||||
|
import 'app_events.dart' show ActionFailed, ServerRefused;
|
||||||
|
import 'connection.dart';
|
||||||
|
import 'identity.dart' show Profile;
|
||||||
|
import 'session_coordinator.dart';
|
||||||
|
import 'your_server.dart';
|
||||||
|
|
||||||
|
/// Which server this app is in.
|
||||||
|
enum Place {
|
||||||
|
/// None yet (starting, or between two servers).
|
||||||
|
none,
|
||||||
|
|
||||||
|
/// Your own server ([YourServer]): home.
|
||||||
|
yours,
|
||||||
|
|
||||||
|
/// A friend's (joined from the Join card).
|
||||||
|
friend,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A join that did not get as far as connecting, in plain words.
|
||||||
|
class JoinFailed implements Exception {
|
||||||
|
final String message;
|
||||||
|
const JoinFailed(this.message);
|
||||||
|
@override
|
||||||
|
String toString() => message;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where this app is: home on your own server, or in a friend's. It
|
||||||
|
/// starts the app at home, switches to a friend's server on a join (after
|
||||||
|
/// the version gate) and back home when you leave theirs, a join fails or
|
||||||
|
/// they stop sharing. Your server stays up all the while; you leave it
|
||||||
|
/// (with `bye`) while you are away. Made by the composition root.
|
||||||
|
class Navigation {
|
||||||
|
Navigation({
|
||||||
|
required this.coordinator,
|
||||||
|
required this.yourServer,
|
||||||
|
required this.forms,
|
||||||
|
this.profile,
|
||||||
|
this.healthGet = defaultHealthGet,
|
||||||
|
}) {
|
||||||
|
yourServer.onCrash = () {
|
||||||
|
if (place.peek() != Place.friend) unawaited(goHome());
|
||||||
|
};
|
||||||
|
// A friend closed their server to friends: back home, and say so.
|
||||||
|
_stops.add(effect(() {
|
||||||
|
final s = coordinator.connection.state.value;
|
||||||
|
if (s is ConnRefused && s.code == 'closed' && untracked(() => place.value) == Place.friend) {
|
||||||
|
// Said once home (a connect clears the toasts).
|
||||||
|
scheduleMicrotask(() => unawaited(goHome().then(
|
||||||
|
(_) => coordinator.notify(const ServerRefused('The host stopped sharing their server')))));
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
final SessionCoordinator coordinator;
|
||||||
|
final YourServer yourServer;
|
||||||
|
final ConnectStore forms;
|
||||||
|
/// Name and picture for your own connection (default: the app's).
|
||||||
|
final Profile? profile;
|
||||||
|
final HealthGetter healthGet;
|
||||||
|
final _stops = <void Function()>[];
|
||||||
|
|
||||||
|
Profile get _me => profile ?? Profile.instance;
|
||||||
|
|
||||||
|
final place = signal(Place.none);
|
||||||
|
|
||||||
|
/// In a server (yours or a friend's).
|
||||||
|
late final Computed<bool> online = computed(() => place.value != Place.none);
|
||||||
|
|
||||||
|
/// On your own server.
|
||||||
|
late final Computed<bool> home = computed(() => place.value == Place.yours);
|
||||||
|
|
||||||
|
/// A friend's server's own name (`/health`), '' for yours, until known
|
||||||
|
/// or when it only has the default name.
|
||||||
|
final serverLabel = signal('');
|
||||||
|
|
||||||
|
/// The server's Starsync version: yours is this app's; a friend's
|
||||||
|
/// comes from its `/health`; '' until known.
|
||||||
|
final serverVersion = signal('');
|
||||||
|
|
||||||
|
/// The address dialled for a friend's server ("host:port"), '' at home.
|
||||||
|
final address = signal('');
|
||||||
|
|
||||||
|
// Bumped by every move; a superseded one stops at its next step.
|
||||||
|
int _op = 0;
|
||||||
|
|
||||||
|
/// The app starts: home, on your own server.
|
||||||
|
Future<void> start() => goHome();
|
||||||
|
|
||||||
|
/// To your own server (started if it is not running). False when it
|
||||||
|
/// could not start or the move was superseded; the reason is a notice.
|
||||||
|
Future<bool> goHome() async {
|
||||||
|
final op = ++_op;
|
||||||
|
_arrive(Place.none);
|
||||||
|
try {
|
||||||
|
await _me.ensureLoaded();
|
||||||
|
final name = _me.name.peek().trim();
|
||||||
|
final port = await yourServer.start(name: name);
|
||||||
|
if (op != _op) return false;
|
||||||
|
coordinator.hostFile = yourServer.setFile;
|
||||||
|
await coordinator.connect(ConnectParams(
|
||||||
|
host: '127.0.0.1',
|
||||||
|
port: port,
|
||||||
|
secret: '',
|
||||||
|
name: name.isEmpty ? 'Me' : name,
|
||||||
|
avatar: _me.avatarBase64(),
|
||||||
|
hostKey: yourServer.hostKey,
|
||||||
|
));
|
||||||
|
if (op != _op) return false;
|
||||||
|
serverVersion.value = appVersion;
|
||||||
|
_arrive(Place.yours);
|
||||||
|
return true;
|
||||||
|
} on ConnectCancelled {
|
||||||
|
return false;
|
||||||
|
} catch (e, st) {
|
||||||
|
debugPrint('your server: $e\n$st');
|
||||||
|
if (op == _op) coordinator.notify(ActionFailed("couldn't start your server: $e"));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _arrive(Place p) {
|
||||||
|
if (p != Place.yours) coordinator.hostFile = null;
|
||||||
|
if (p == Place.none) {
|
||||||
|
serverLabel.value = '';
|
||||||
|
serverVersion.value = '';
|
||||||
|
address.value = '';
|
||||||
|
}
|
||||||
|
place.value = p;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Joins a friend's server. The version gate first (`/health`): an
|
||||||
|
/// unreachable server or one without a version in common stops here,
|
||||||
|
/// still at home ([JoinFailed], [VersionGateRefused]). Then you leave
|
||||||
|
/// your server and connect; when that fails you are back home and the
|
||||||
|
/// error is rethrown (a refusal: [HelloRefused]).
|
||||||
|
Future<void> join(ConnectParams params) async {
|
||||||
|
final op = ++_op;
|
||||||
|
final health = await probeHealth(params.host, '${params.port}', healthGet);
|
||||||
|
if (op != _op) throw const ConnectCancelled();
|
||||||
|
if (!health.online) throw const JoinFailed('host offline — check address and port');
|
||||||
|
final fit = health.fit;
|
||||||
|
if (fit != null && fit != VersionFit.ok) {
|
||||||
|
throw VersionGateRefused(versionGateText(health), youUpdate: fit == VersionFit.youUpdate);
|
||||||
|
}
|
||||||
|
_arrive(Place.none);
|
||||||
|
try {
|
||||||
|
await coordinator.connect(params);
|
||||||
|
if (op != _op) throw const ConnectCancelled();
|
||||||
|
} catch (_) {
|
||||||
|
if (op == _op) unawaited(goHome());
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
serverLabel.value = health.label;
|
||||||
|
serverVersion.value = health.version;
|
||||||
|
address.value = '${params.host}:${params.port}';
|
||||||
|
_arrive(Place.friend);
|
||||||
|
await forms.remember(RecentServer(host: params.host, port: '${params.port}', secret: params.secret));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The Join card's Cancel: stops a join under way; when it had already
|
||||||
|
/// left home, back home.
|
||||||
|
Future<void> cancelPending() async {
|
||||||
|
_op++;
|
||||||
|
try {
|
||||||
|
await coordinator.cancelConnect();
|
||||||
|
} catch (_) {}
|
||||||
|
if (place.peek() != Place.yours) await goHome();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Out of a friend's server (Leave): back home.
|
||||||
|
Future<bool> leaveFriend() => goHome();
|
||||||
|
|
||||||
|
/// The app closes: out of whichever server ([bye] false: Update and
|
||||||
|
/// restart, the member stays `gone` for the restarted app).
|
||||||
|
Future<void> leave({bool bye = true}) async {
|
||||||
|
_op++;
|
||||||
|
await coordinator.leave(bye: bye);
|
||||||
|
_arrive(Place.none);
|
||||||
|
}
|
||||||
|
|
||||||
|
void dispose() {
|
||||||
|
for (final stop in _stops) {
|
||||||
|
stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
// The user's notification choices (Settings → Notifications & sounds), as
|
||||||
|
// the Notifier's policy reads them. Pure Dart; [AppSettings] stores them.
|
||||||
|
|
||||||
|
/// When chat messages pop up as toasts (they always reach the chat).
|
||||||
|
enum ChatPopups {
|
||||||
|
all('All'),
|
||||||
|
paused('Only when paused'),
|
||||||
|
off('Off');
|
||||||
|
|
||||||
|
final String label;
|
||||||
|
const ChatPopups(this.label);
|
||||||
|
|
||||||
|
/// Whether a chat message pops up now ([playing]: the video plays).
|
||||||
|
bool shows({required bool playing}) => switch (this) {
|
||||||
|
ChatPopups.all => true,
|
||||||
|
ChatPopups.paused => !playing,
|
||||||
|
ChatPopups.off => false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the user picked: chat pop-ups, and whether joins and leaves also
|
||||||
|
/// reach them while they sit out or the window is in the background
|
||||||
|
/// ([people]; off by default: then only chat, nudges, "starting now" and
|
||||||
|
/// Up next do).
|
||||||
|
class NotifyPrefs {
|
||||||
|
final ChatPopups chat;
|
||||||
|
final bool people;
|
||||||
|
const NotifyPrefs({this.chat = ChatPopups.all, this.people = false});
|
||||||
|
}
|
||||||
@@ -0,0 +1,460 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:signals/signals_core.dart';
|
||||||
|
import 'package:sync_relay_core/core.dart' show FeedMessage, Presence;
|
||||||
|
|
||||||
|
import '../platform/ports/system_notifications.dart';
|
||||||
|
import '../platform/ports/window_activity.dart';
|
||||||
|
import '../sfx/sfx.dart';
|
||||||
|
import 'app_events.dart';
|
||||||
|
import 'notice_prefs.dart';
|
||||||
|
|
||||||
|
export 'app_events.dart';
|
||||||
|
export 'notice_prefs.dart';
|
||||||
|
|
||||||
|
class ToastItem {
|
||||||
|
final int id;
|
||||||
|
final String text;
|
||||||
|
final DateTime at;
|
||||||
|
/// The member a feed or chat toast is about (their name, as the feed
|
||||||
|
/// carries it; [text] starts with it); null for the app's own toasts.
|
||||||
|
final String? actor;
|
||||||
|
/// [actor]'s member id (feed and chat toasts); '' when unknown.
|
||||||
|
final String actorId;
|
||||||
|
/// A chat message: a click opens the chat.
|
||||||
|
final bool chat;
|
||||||
|
/// A picture hash to show (an Up next item's), '' when none.
|
||||||
|
final String thumb;
|
||||||
|
/// What it is about (the shell may show some of it another way:
|
||||||
|
/// "Notifications as danmaku").
|
||||||
|
final NoticeCategory category;
|
||||||
|
/// This member's own (their chat message coming back from the server).
|
||||||
|
final bool own;
|
||||||
|
const ToastItem(
|
||||||
|
{required this.id,
|
||||||
|
required this.text,
|
||||||
|
required this.at,
|
||||||
|
this.actor,
|
||||||
|
this.actorId = '',
|
||||||
|
this.chat = false,
|
||||||
|
this.thumb = '',
|
||||||
|
this.category = NoticeCategory.app,
|
||||||
|
this.own = false});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a notice is about; the policy filters by it ([noticeFor]).
|
||||||
|
enum NoticeCategory {
|
||||||
|
/// Chat messages.
|
||||||
|
chat,
|
||||||
|
|
||||||
|
/// Someone poked this member.
|
||||||
|
nudge,
|
||||||
|
|
||||||
|
/// The media starts playing for the first time ("starting now").
|
||||||
|
start,
|
||||||
|
|
||||||
|
/// Up next and the video changing (adds, skips, swaps).
|
||||||
|
upNext,
|
||||||
|
|
||||||
|
/// Joins, leaves, lost signal and returns.
|
||||||
|
people,
|
||||||
|
|
||||||
|
/// The rest of what the party does (ready, seeks, pauses, someone sat
|
||||||
|
/// out, renames): the activity log and a few sounds.
|
||||||
|
party,
|
||||||
|
|
||||||
|
/// Something keeps the watch from going smoothly (someone cannot play
|
||||||
|
/// it, the source dropped, the server could not fetch it).
|
||||||
|
problem,
|
||||||
|
|
||||||
|
/// About this member itself (sat out, auto sit-out, the relay it
|
||||||
|
/// serves, its connection, another window took over).
|
||||||
|
self,
|
||||||
|
|
||||||
|
/// The app's own notices (copied, shaders, update, errors): never
|
||||||
|
/// filtered.
|
||||||
|
app;
|
||||||
|
|
||||||
|
/// What reaches you while you sit out or the window is in the
|
||||||
|
/// background (D1): chat, nudges, "starting now" and Up next, plus joins
|
||||||
|
/// and leaves when [NotifyPrefs.people] is on.
|
||||||
|
bool reachesAway(NotifyPrefs prefs) => switch (this) {
|
||||||
|
chat || nudge || start || upNext || self || app => true,
|
||||||
|
people => prefs.people,
|
||||||
|
party || problem => false,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Social: nothing of it plays or pops up while you watch alone (D9).
|
||||||
|
bool get social => this == chat || this == start || this == people || this == party;
|
||||||
|
|
||||||
|
/// May go out as a system notification while the window is in the
|
||||||
|
/// background.
|
||||||
|
bool get system => this == chat || this == nudge || this == start || this == upNext || this == people;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The session as the policy sees it.
|
||||||
|
typedef NoticeSession = ({Presence? self, bool alone, bool playing});
|
||||||
|
|
||||||
|
/// Where the user stands when a notice comes: their presence ([self],
|
||||||
|
/// null outside a session), whether anyone else is there ([alone]), the
|
||||||
|
/// window ([window]), whether the video plays ([playing]) and their
|
||||||
|
/// choices ([prefs]). A replayed feed line says so itself.
|
||||||
|
class NoticeContext {
|
||||||
|
final Presence? self;
|
||||||
|
final bool alone;
|
||||||
|
final WindowActivity window;
|
||||||
|
final bool playing;
|
||||||
|
final NotifyPrefs prefs;
|
||||||
|
/// The chat panel is open: chat is read there, not in pop-ups.
|
||||||
|
final bool chatOpen;
|
||||||
|
const NoticeContext({
|
||||||
|
this.self,
|
||||||
|
this.alone = false,
|
||||||
|
this.window = WindowActivity.focused,
|
||||||
|
this.playing = false,
|
||||||
|
this.prefs = const NotifyPrefs(),
|
||||||
|
this.chatOpen = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
bool get away => self == Presence.away;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A notification outside the window ([SystemNotifications]).
|
||||||
|
class SystemNote {
|
||||||
|
final String title;
|
||||||
|
final String body;
|
||||||
|
final String tag;
|
||||||
|
/// [payloadChat]: a click opens the chat.
|
||||||
|
final String payload;
|
||||||
|
const SystemNote({required this.title, required this.body, this.tag = '', this.payload = ''});
|
||||||
|
|
||||||
|
static const payloadChat = 'chat';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What one [AppEvent] does: a toast ([toast], about [actor]), a sound,
|
||||||
|
/// the window asking for attention, a system notification, an
|
||||||
|
/// activity-log line.
|
||||||
|
class Notice {
|
||||||
|
final NoticeCategory category;
|
||||||
|
final String? toast;
|
||||||
|
final String? actor;
|
||||||
|
/// The toast is a chat message (a click opens the chat).
|
||||||
|
final bool chat;
|
||||||
|
/// A picture hash for the toast (an Up next item's).
|
||||||
|
final String thumb;
|
||||||
|
/// This member is the actor (a feed line of its own).
|
||||||
|
final bool own;
|
||||||
|
final SfxCue? sound;
|
||||||
|
final bool attention;
|
||||||
|
final SystemNote? system;
|
||||||
|
/// The feed line it goes into the activity log as.
|
||||||
|
final FeedMessage? log;
|
||||||
|
const Notice({
|
||||||
|
this.category = NoticeCategory.app,
|
||||||
|
this.toast,
|
||||||
|
this.actor,
|
||||||
|
this.chat = false,
|
||||||
|
this.thumb = '',
|
||||||
|
this.own = false,
|
||||||
|
this.sound,
|
||||||
|
this.attention = false,
|
||||||
|
this.system,
|
||||||
|
this.log,
|
||||||
|
});
|
||||||
|
|
||||||
|
static const none = Notice();
|
||||||
|
|
||||||
|
Notice _with({bool toast = true, bool sound = true, SystemNote? system, bool own = false}) => Notice(
|
||||||
|
category: category,
|
||||||
|
toast: toast ? this.toast : null,
|
||||||
|
actor: toast ? actor : null,
|
||||||
|
chat: toast && chat,
|
||||||
|
thumb: toast ? thumb : '',
|
||||||
|
own: own,
|
||||||
|
sound: sound ? this.sound : null,
|
||||||
|
attention: attention,
|
||||||
|
system: system,
|
||||||
|
log: log,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _title(String t) => t.isEmpty ? 'the video' : t;
|
||||||
|
|
||||||
|
/// The event alone: its category, toast, sound and log line.
|
||||||
|
Notice _plain(AppEvent e) => switch (e) {
|
||||||
|
// The feed: everything is logged.
|
||||||
|
MemberJoined(:final feed) => Notice(
|
||||||
|
category: NoticeCategory.people,
|
||||||
|
log: feed,
|
||||||
|
toast: '${feed.actor} joined',
|
||||||
|
actor: feed.actor,
|
||||||
|
sound: const SfxCue(SfxKind.join)),
|
||||||
|
MemberLeft(:final feed) => Notice(
|
||||||
|
category: NoticeCategory.people,
|
||||||
|
log: feed,
|
||||||
|
toast: '${feed.actor} left',
|
||||||
|
actor: feed.actor,
|
||||||
|
sound: const SfxCue(SfxKind.leave)),
|
||||||
|
// After the server's delayed `gone` line (a quick reconnect says
|
||||||
|
// nothing), and back after it.
|
||||||
|
MemberGone(:final feed) => Notice(
|
||||||
|
category: NoticeCategory.people, log: feed, toast: '${feed.actor} lost connection', actor: feed.actor),
|
||||||
|
MemberReturned(:final feed) =>
|
||||||
|
Notice(category: NoticeCategory.people, log: feed, toast: '${feed.actor} is back', actor: feed.actor),
|
||||||
|
// Put back after a server restart: news, but not a change.
|
||||||
|
MediaSwapped(:final feed) when feed.reason == 'restore' => Notice(
|
||||||
|
category: NoticeCategory.upNext,
|
||||||
|
log: feed,
|
||||||
|
toast: '${feed.actor} put back what was playing',
|
||||||
|
actor: feed.actor),
|
||||||
|
MediaSwapped(:final feed) => Notice(
|
||||||
|
category: NoticeCategory.upNext,
|
||||||
|
log: feed,
|
||||||
|
toast: '${feed.actor} changed media',
|
||||||
|
actor: feed.actor,
|
||||||
|
sound: const SfxCue(SfxKind.swap)),
|
||||||
|
QueueAdded(:final feed, :final thumb) => Notice(
|
||||||
|
category: NoticeCategory.upNext,
|
||||||
|
log: feed,
|
||||||
|
toast: '${feed.actor} added ${_title(feed.target ?? '')} to Up next',
|
||||||
|
actor: feed.actor,
|
||||||
|
thumb: thumb),
|
||||||
|
QueueSkipped(:final feed) => Notice(
|
||||||
|
category: NoticeCategory.upNext,
|
||||||
|
log: feed,
|
||||||
|
toast: 'Skipped ${feed.target ?? 'an item'}${feed.reason.isEmpty ? '' : ': ${feed.reason}'}'),
|
||||||
|
QueueRemoved(:final feed) => Notice(category: NoticeCategory.upNext, log: feed),
|
||||||
|
ChatPosted(:final feed) => Notice(
|
||||||
|
category: NoticeCategory.chat,
|
||||||
|
log: feed,
|
||||||
|
toast: '${feed.actor}: ${feed.reason}',
|
||||||
|
actor: feed.actor,
|
||||||
|
chat: true,
|
||||||
|
sound: const SfxCue(SfxKind.chat)),
|
||||||
|
MemberSatOut(:final feed, atYou: true) => Notice(
|
||||||
|
category: NoticeCategory.self,
|
||||||
|
log: feed,
|
||||||
|
toast: '${feed.actor} sat you out',
|
||||||
|
actor: feed.actor,
|
||||||
|
sound: const SfxCue(SfxKind.kick)),
|
||||||
|
MemberSatOut(:final feed, autoSelf: true) => Notice(
|
||||||
|
category: NoticeCategory.self,
|
||||||
|
log: feed,
|
||||||
|
toast: 'You sit out (${feed.reason == 'minimised' ? 'minimised' : 'idle'}); you are back as soon as you return',
|
||||||
|
sound: const SfxCue(SfxKind.warn)),
|
||||||
|
ReadyChanged(:final feed, ready: true, :final readyIndex) =>
|
||||||
|
Notice(category: NoticeCategory.party, log: feed, sound: SfxCue(SfxKind.ready, readyIndex: readyIndex)),
|
||||||
|
ReadyChanged(:final feed, ready: false) =>
|
||||||
|
Notice(category: NoticeCategory.party, log: feed, sound: const SfxCue(SfxKind.unready)),
|
||||||
|
ForcedReady(:final feed, ready: true) =>
|
||||||
|
Notice(category: NoticeCategory.party, log: feed, sound: const SfxCue(SfxKind.forceReady)),
|
||||||
|
FetchFailed(:final feed) => Notice(category: NoticeCategory.problem, log: feed),
|
||||||
|
FeedEvent(:final feed) => Notice(category: NoticeCategory.party, log: feed),
|
||||||
|
// The session.
|
||||||
|
Nudged(:final name) => Notice(
|
||||||
|
category: NoticeCategory.nudge,
|
||||||
|
toast: '${name.isEmpty ? 'Someone' : name} nudged you',
|
||||||
|
actor: name.isEmpty ? null : name,
|
||||||
|
sound: const SfxCue(SfxKind.nudge),
|
||||||
|
attention: true),
|
||||||
|
PlaybackStarted(:final title) =>
|
||||||
|
Notice(category: NoticeCategory.start, toast: title.isEmpty ? 'Starting now' : 'Starting now: $title'),
|
||||||
|
MemberCantPlay(:final name, :final error) =>
|
||||||
|
Notice(category: NoticeCategory.problem, toast: "$name can't play this: $error", actor: name),
|
||||||
|
RelaySourceDropped(:final name, :final left) => Notice(
|
||||||
|
category: NoticeCategory.problem,
|
||||||
|
toast: left
|
||||||
|
? '$name left: playing what the server has'
|
||||||
|
: "$name's connection dropped: playing what the server has",
|
||||||
|
actor: name),
|
||||||
|
SlowSource(:final text) => Notice(category: NoticeCategory.self, toast: text),
|
||||||
|
RelayStarted() => const Notice(
|
||||||
|
category: NoticeCategory.self,
|
||||||
|
toast: 'Passing the video on through the server. Keep the app open while it plays.'),
|
||||||
|
RelayFailed(:final error) => Notice(category: NoticeCategory.self, toast: 'Cannot pass the video on: $error'),
|
||||||
|
ConnectionLost() => const Notice(category: NoticeCategory.self, sound: SfxCue(SfxKind.lost)),
|
||||||
|
ConnectionBack() => const Notice(category: NoticeCategory.self, sound: SfxCue(SfxKind.back)),
|
||||||
|
ServerRestarted() =>
|
||||||
|
const Notice(category: NoticeCategory.self, toast: 'The server restarted; you are back in'),
|
||||||
|
Replaced() => const Notice(category: NoticeCategory.self, toast: 'Starsync was opened in another window'),
|
||||||
|
ServerRefused(:final text) => Notice(category: NoticeCategory.self, toast: text),
|
||||||
|
JellyfinProgressFailed(:final signInExpired) =>
|
||||||
|
Notice(toast: 'Jellyfin progress not saved: ${signInExpired ? 'sign-in expired' : 'server unreachable'}'),
|
||||||
|
// The app.
|
||||||
|
ShaderMessage(:final text) => Notice(toast: text),
|
||||||
|
AddedToUpNext(:final title) => Notice(toast: 'Added $title to Up next'),
|
||||||
|
ActionFailed(:final text) => Notice(toast: text),
|
||||||
|
ScheduleResult(:final text) => Notice(toast: text),
|
||||||
|
ProblemMarked(:final inSession) =>
|
||||||
|
Notice(toast: inSession ? 'Marked in the session log' : "Marked in this app's log"),
|
||||||
|
Copied(:final text) => Notice(toast: text),
|
||||||
|
CopyFailed() => const Notice(toast: "Couldn't copy"),
|
||||||
|
// The update band shows it (D8: a clear prompt, never over playback).
|
||||||
|
UpdateAvailable() => Notice.none,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// The policy: what [e] does for a user in [c].
|
||||||
|
/// - Lines a reconnect replayed (`welcome.feed`): the activity log only.
|
||||||
|
/// - Your own joins, leaves, swaps and Up next adds: logged, no pop-up or
|
||||||
|
/// sound (the app already said so); your own chat pops up, silently.
|
||||||
|
/// - Alone (D9): no social sounds or pop-ups (nobody is there); the app's
|
||||||
|
/// notices, problems and your own still show.
|
||||||
|
/// - Sitting out (D1): only chat, nudges, "starting now", Up next, your
|
||||||
|
/// own and the app's, plus joins and leaves when chosen
|
||||||
|
/// ([NotifyPrefs.people]); the rest is logged only.
|
||||||
|
/// - "Starting now" pops up only while you sit out or the window is in
|
||||||
|
/// the background (otherwise you see it start).
|
||||||
|
/// - Chat pop-ups follow [NotifyPrefs.chat]; the chat sound has its own
|
||||||
|
/// switch (Settings → Sounds).
|
||||||
|
/// - The window in the background: what reaches you there also goes out
|
||||||
|
/// as a system notification (chat, nudges, "starting now", Up next;
|
||||||
|
/// joins and leaves when chosen).
|
||||||
|
Notice noticeFor(AppEvent e, [NoticeContext c = const NoticeContext()]) {
|
||||||
|
if (e is FeedEvent && e.feed.replayed) return Notice(category: _plain(e).category, log: e.feed);
|
||||||
|
final n = _plain(e);
|
||||||
|
final cat = n.category;
|
||||||
|
final own = e is FeedEvent && e.own;
|
||||||
|
var toast = n.toast != null;
|
||||||
|
var sound = n.sound != null;
|
||||||
|
if (own && (cat == NoticeCategory.people || cat == NoticeCategory.upNext)) {
|
||||||
|
toast = false;
|
||||||
|
sound = false;
|
||||||
|
}
|
||||||
|
if (own && cat == NoticeCategory.chat) sound = false;
|
||||||
|
if (c.alone && cat.social) {
|
||||||
|
toast = false;
|
||||||
|
sound = false;
|
||||||
|
}
|
||||||
|
if (c.away && !cat.reachesAway(c.prefs)) {
|
||||||
|
toast = false;
|
||||||
|
sound = false;
|
||||||
|
}
|
||||||
|
if (cat == NoticeCategory.start && !c.away && !c.window.background) toast = false;
|
||||||
|
if (cat == NoticeCategory.chat && !c.prefs.chat.shows(playing: c.playing)) toast = false;
|
||||||
|
// The open chat shows it already; the sound still plays.
|
||||||
|
if (cat == NoticeCategory.chat && c.chatOpen && !c.window.background) toast = false;
|
||||||
|
SystemNote? system;
|
||||||
|
if (toast && !own && c.window.background && cat.system && cat.reachesAway(c.prefs)) {
|
||||||
|
final actor = n.actor;
|
||||||
|
final text = n.toast!;
|
||||||
|
final body = actor != null && text.startsWith(actor) ? text.substring(actor.length).replaceFirst(RegExp(r'^:? '), '') : text;
|
||||||
|
system = SystemNote(
|
||||||
|
title: actor ?? 'Starsync',
|
||||||
|
body: actor == null ? text : body,
|
||||||
|
tag: cat == NoticeCategory.chat ? '' : cat.name,
|
||||||
|
payload: cat == NoticeCategory.chat ? SystemNote.payloadChat : '',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return n._with(toast: toast, sound: sound, system: system, own: own);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The one way news reaches the user: [notify] turns an [AppEvent] into
|
||||||
|
/// toasts ([toasts], each dropped after [toastLife] by the Notifier's
|
||||||
|
/// own timer), sounds, an attention request, a system notification and
|
||||||
|
/// the activity log ([feedLog]) per [noticeFor], in the context the
|
||||||
|
/// session ([session]), the window ([window]) and the user's choices
|
||||||
|
/// ([prefs]) give.
|
||||||
|
class Notifier {
|
||||||
|
final SfxService sfx;
|
||||||
|
final Future<void> Function() attention;
|
||||||
|
final SystemNotifications system;
|
||||||
|
/// Brings the window up (a click on a system notification).
|
||||||
|
final Future<void> Function() focusWindow;
|
||||||
|
final Duration toastLife;
|
||||||
|
/// The session's part of the context; set by the coordinator.
|
||||||
|
NoticeSession Function() session = () => (self: null, alone: false, playing: false);
|
||||||
|
NotifyPrefs Function() prefs;
|
||||||
|
/// The window's activity ([ShellPort.watchActivity], wired in `main`).
|
||||||
|
WindowActivity window = WindowActivity.focused;
|
||||||
|
|
||||||
|
/// The chat panel is open (set by the shell).
|
||||||
|
bool chatOpen = false;
|
||||||
|
|
||||||
|
Notifier({
|
||||||
|
this.sfx = const NoopSfxService(),
|
||||||
|
Future<void> Function()? attention,
|
||||||
|
this.system = const NoSystemNotifications(),
|
||||||
|
Future<void> Function()? focusWindow,
|
||||||
|
this.toastLife = const Duration(seconds: 5),
|
||||||
|
NotifyPrefs Function()? prefs,
|
||||||
|
}) : attention = attention ?? _nothing,
|
||||||
|
focusWindow = focusWindow ?? _nothing,
|
||||||
|
prefs = prefs ?? _defaultPrefs {
|
||||||
|
system.onClick = _clicked;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> _nothing() async {}
|
||||||
|
static NotifyPrefs _defaultPrefs() => const NotifyPrefs();
|
||||||
|
|
||||||
|
/// Toasts on screen (the shell shows them).
|
||||||
|
final toasts = signal<List<ToastItem>>([]);
|
||||||
|
/// The activity log: the last 100 feed lines.
|
||||||
|
final feedLog = signal<List<FeedMessage>>([]);
|
||||||
|
/// Bumped when a click on a chat notification asks for the chat (the
|
||||||
|
/// shell opens it).
|
||||||
|
final chatRequests = signal(0);
|
||||||
|
int _toastId = 0;
|
||||||
|
final _timers = <int, Timer>{};
|
||||||
|
|
||||||
|
NoticeContext get context {
|
||||||
|
final s = session();
|
||||||
|
return NoticeContext(
|
||||||
|
self: s.self, alone: s.alone, window: window, playing: s.playing, prefs: prefs(), chatOpen: chatOpen);
|
||||||
|
}
|
||||||
|
|
||||||
|
void notify(AppEvent e) {
|
||||||
|
final n = noticeFor(e, context);
|
||||||
|
final log = n.log;
|
||||||
|
if (log != null) {
|
||||||
|
final all = [...feedLog.peek(), log];
|
||||||
|
feedLog.value = all.length > 100 ? all.sublist(all.length - 100) : all;
|
||||||
|
}
|
||||||
|
final text = n.toast;
|
||||||
|
if (text != null) {
|
||||||
|
final id = ++_toastId;
|
||||||
|
toasts.value = [
|
||||||
|
...toasts.peek(),
|
||||||
|
ToastItem(
|
||||||
|
id: id,
|
||||||
|
text: text,
|
||||||
|
at: DateTime.now().toUtc(),
|
||||||
|
actor: n.actor,
|
||||||
|
actorId: n.actor == null ? '' : n.log?.actorId ?? '',
|
||||||
|
chat: n.chat,
|
||||||
|
thumb: n.thumb,
|
||||||
|
category: n.category,
|
||||||
|
own: n.own),
|
||||||
|
];
|
||||||
|
_timers[id] = Timer(toastLife, () => dismiss(id));
|
||||||
|
}
|
||||||
|
final sound = n.sound;
|
||||||
|
if (sound != null) unawaited(sfx.play(sound));
|
||||||
|
if (n.attention) unawaited(attention());
|
||||||
|
final note = n.system;
|
||||||
|
if (note != null) {
|
||||||
|
unawaited(system.show(title: note.title, body: note.body, tag: note.tag, payload: note.payload));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _clicked(String payload) {
|
||||||
|
unawaited(focusWindow());
|
||||||
|
if (payload == SystemNote.payloadChat) chatRequests.value++;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Takes toast [id] off the screen (its time is up, or it was clicked).
|
||||||
|
void dismiss(int id) {
|
||||||
|
_timers.remove(id)?.cancel();
|
||||||
|
final now = toasts.peek();
|
||||||
|
if (now.any((t) => t.id == id)) toasts.value = [for (final t in now) if (t.id != id) t];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A new session: no toasts, an empty log.
|
||||||
|
void clear() {
|
||||||
|
for (final t in _timers.values) {
|
||||||
|
t.cancel();
|
||||||
|
}
|
||||||
|
_timers.clear();
|
||||||
|
feedLog.value = [];
|
||||||
|
toasts.value = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import 'package:sync_relay_core/core.dart' show hasMediaExtension;
|
import 'package:sync_relay_core/core.dart' show MediaRef, hasMediaExtension, parseReleaseName;
|
||||||
|
|
||||||
import '../files/name_parser.dart';
|
import '../library/media_refs.dart' show refEpisodeLine;
|
||||||
import '../jellyfin/session_media.dart';
|
import '../jellyfin/session_media.dart';
|
||||||
import 'now_playing.dart';
|
import 'now_playing.dart';
|
||||||
|
|
||||||
@@ -26,12 +26,29 @@ NowPlaying? nowPlayingOf(Map<String, Object?>? media) {
|
|||||||
if (web) {
|
if (web) {
|
||||||
return NowPlaying(title: name, posterHash: poster, bannerHash: banner, thumbnail: true);
|
return NowPlaying(title: name, posterHash: poster, bannerHash: banner, thumbnail: true);
|
||||||
}
|
}
|
||||||
|
final what = _mediaRef(media['ref']);
|
||||||
final (title, episode) = ref != null
|
final (title, episode) = ref != null
|
||||||
? jellyfinTitle(ref.title.isNotEmpty ? ref.title : name, ref.subtitle)
|
? jellyfinTitle(ref.title.isNotEmpty ? ref.title : name, ref.subtitle)
|
||||||
|
: what != null && what.title.isNotEmpty
|
||||||
|
? refTitle(what, name)
|
||||||
: fileTitle(name);
|
: fileTitle(name);
|
||||||
return NowPlaying(title: title, episode: episode, posterHash: poster, bannerHash: banner);
|
return NowPlaying(title: title, episode: episode, posterHash: poster, bannerHash: banner);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
MediaRef? _mediaRef(Object? j) {
|
||||||
|
try {
|
||||||
|
return MediaRef.fromJson(j);
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Files and links that say what they are (`media.ref`, from the
|
||||||
|
/// sender's library): its proper title and `S3 E13` / `E108`; an extra
|
||||||
|
/// keeps the name's own marker (OP1, NCED2).
|
||||||
|
(String, String?) refTitle(MediaRef ref, String name) =>
|
||||||
|
(ref.title, refEpisodeLine(ref) ?? (ref.kind == 'extra' ? fileTitle(name).$2 : null));
|
||||||
|
|
||||||
/// [media] (`state.media`, not Jellyfin) is a web page's video, whose art
|
/// [media] (`state.media`, not Jellyfin) is a web page's video, whose art
|
||||||
/// will be its thumbnail: a shared http(s) link that is no media file (the
|
/// will be its thumbnail: a shared http(s) link that is no media file (the
|
||||||
/// everyone-opens-it mode), or a relayed one (passed on by a member or
|
/// everyone-opens-it mode), or a relayed one (passed on by a member or
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import 'package:sync_relay_core/core.dart';
|
||||||
|
|
||||||
|
// The app's reading of presence (PROTOCOL.md "Presence"): every count,
|
||||||
|
// list and wait in the UI goes through these, on top of core's
|
||||||
|
// [PresencePolicy] and [isAlone].
|
||||||
|
|
||||||
|
/// What each presence counts for (the one table, shared with the server).
|
||||||
|
const presencePolicy = PresencePolicy();
|
||||||
|
|
||||||
|
/// Counted in "2/3 ready" and waited for by name: its presence counts for
|
||||||
|
/// the ready counter and its player can open the media.
|
||||||
|
bool countsForReady(MemberView m) => m.error.isEmpty && presencePolicy.countsFor(m.presence, Purpose.readyCounter);
|
||||||
|
|
||||||
|
/// Connected and watching (`in` or `joining`): can do things for the
|
||||||
|
/// party now (put up an Up next item; core's [isPresentMember]).
|
||||||
|
bool isPresent(MemberView m) => isPresentMember(m);
|
||||||
|
|
||||||
|
/// Nobody but [selfId] is connected in [members] (D9, core's [isAlone]).
|
||||||
|
bool aloneIn(List<MemberView> members, String selfId) =>
|
||||||
|
isAlone([for (final m in members) if (m.id != selfId) m.presence]);
|
||||||
|
|
||||||
|
/// "3 min" / "40 s": how long since [since] (a `goneSince`), at [now].
|
||||||
|
String sinceText(DateTime since, DateTime now) {
|
||||||
|
final d = now.difference(since);
|
||||||
|
if (d.inMinutes >= 1) return '${d.inMinutes} min';
|
||||||
|
return '${d.inSeconds.clamp(0, 59)} s';
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart' show debugPrint;
|
||||||
|
|
||||||
|
import 'invite.dart' show isPublicIpv4;
|
||||||
|
|
||||||
|
/// Fetches a URL's body as text; injectable so tests fake the services.
|
||||||
|
typedef TextGet = Future<String> Function(Uri uri);
|
||||||
|
|
||||||
|
/// "What is my IP" services, asked in order until one answers with a
|
||||||
|
/// public IPv4 address: each answers the plain address over HTTPS.
|
||||||
|
final publicAddressServices = [
|
||||||
|
Uri.parse('https://api.ipify.org'),
|
||||||
|
Uri.parse('https://ipv4.icanhazip.com'),
|
||||||
|
Uri.parse('https://checkip.amazonaws.com'),
|
||||||
|
Uri.parse('https://ifconfig.me/ip'),
|
||||||
|
];
|
||||||
|
|
||||||
|
const _eachTimeout = Duration(seconds: 3);
|
||||||
|
|
||||||
|
/// This network's internet (IPv4) address as the [services] see it: the
|
||||||
|
/// first that answers with a public address wins; a failing, slow or odd
|
||||||
|
/// one is skipped. Null when none does (offline, all blocked).
|
||||||
|
Future<String?> lookUpPublicAddress({
|
||||||
|
TextGet get = defaultTextGet,
|
||||||
|
List<Uri>? services,
|
||||||
|
Duration each = _eachTimeout,
|
||||||
|
}) async {
|
||||||
|
for (final uri in services ?? publicAddressServices) {
|
||||||
|
try {
|
||||||
|
final body = (await get(uri).timeout(each)).trim();
|
||||||
|
// Some answer "ip\n", a few with a label: take the first word.
|
||||||
|
final ip = body.split(RegExp(r'\s+')).first;
|
||||||
|
if (isPublicIpv4(ip)) return ip;
|
||||||
|
debugPrint('public address: ${uri.host} gave no public IPv4 address');
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('public address: ${uri.host} failed: ${e.runtimeType}');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Plain dart:io GET of a small text body; non-200 throws.
|
||||||
|
Future<String> defaultTextGet(Uri uri) async {
|
||||||
|
final client = HttpClient()
|
||||||
|
..connectionTimeout = _eachTimeout
|
||||||
|
..userAgent = 'Starsync';
|
||||||
|
try {
|
||||||
|
final req = await client.getUrl(uri);
|
||||||
|
final res = await req.close().timeout(_eachTimeout);
|
||||||
|
if (res.statusCode != 200) {
|
||||||
|
await res.drain<void>();
|
||||||
|
throw HttpException('status ${res.statusCode}', uri: uri);
|
||||||
|
}
|
||||||
|
final body = StringBuffer();
|
||||||
|
await for (final chunk in res.transform(utf8.decoder).timeout(_eachTimeout)) {
|
||||||
|
body.write(chunk);
|
||||||
|
if (body.length > 256) break; // an address, not a page
|
||||||
|
}
|
||||||
|
return body.toString();
|
||||||
|
} finally {
|
||||||
|
client.close(force: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart' show debugPrint;
|
||||||
|
import 'package:signals/signals.dart';
|
||||||
|
import 'package:sync_relay_core/core.dart';
|
||||||
|
|
||||||
|
import 'app_events.dart';
|
||||||
|
import 'media_plan.dart' show ServerAddress;
|
||||||
|
import 'party.dart' show isPresent;
|
||||||
|
|
||||||
|
/// This member as a relay source: the current media's bytes (its file, a
|
||||||
|
/// link it passes on, its Jellyfin stream) on `/r/.../source`, and Up next's
|
||||||
|
/// first item on the warm relay (`?q=<queueId>`), which the swap that puts
|
||||||
|
/// the item up carries on as the media's. A source re-dials by itself
|
||||||
|
/// through a drop of this app (the server keeps the relay through the
|
||||||
|
/// grace; the same `info` continues it).
|
||||||
|
class RelayFeeder {
|
||||||
|
final ServerAddress Function() server;
|
||||||
|
final void Function(AppEvent event) notify;
|
||||||
|
|
||||||
|
/// The warm relay of Up next item `queueId` stopped without being
|
||||||
|
/// carried on as the media's (the server dropped it, or [stopWarm]).
|
||||||
|
final void Function(String queueId) onWarmEnded;
|
||||||
|
RelayFeeder({required this.server, required this.notify, void Function(String queueId)? onWarmEnded})
|
||||||
|
: onWarmEnded = onWarmEnded ?? ((_) {});
|
||||||
|
|
||||||
|
RelaySource? _source;
|
||||||
|
int _mediaId = -1;
|
||||||
|
({String queueId, RelaySource src})? _warm;
|
||||||
|
|
||||||
|
/// This member serves the current media's bytes through the server.
|
||||||
|
final relaying = signal(false);
|
||||||
|
|
||||||
|
/// The media id this member serves (-1: none).
|
||||||
|
int get mediaId => _mediaId;
|
||||||
|
|
||||||
|
/// The tracks of Up next item [queueId] when this member feeds its warm
|
||||||
|
/// relay and has opened them (empty otherwise).
|
||||||
|
List<RelayTrack> warmTracks(String queueId) => _warm?.queueId == queueId ? _warm!.src.tracks : const [];
|
||||||
|
|
||||||
|
/// This member feeds the warm relay of Up next item [queueId].
|
||||||
|
bool feedsWarm(String queueId) => _warm?.queueId == queueId;
|
||||||
|
|
||||||
|
/// Serves [media] for media [mediaId] on this member's relay channel.
|
||||||
|
/// The server only pulls while someone watches through it. When the
|
||||||
|
/// media came from Up next item [queueId] whose warm relay this member
|
||||||
|
/// feeds, that source carries on instead. [name] and [duration] go in
|
||||||
|
/// the `info`, the same on every dial, so the server knows a re-dial
|
||||||
|
/// after a drop as the same media.
|
||||||
|
void serve(int mediaId, RelayMedia media,
|
||||||
|
{bool announce = false, String queueId = '', String name = '', double duration = 0}) {
|
||||||
|
// Already serving this media (it re-dials by itself through a drop).
|
||||||
|
if (_source != null && _mediaId == mediaId) {
|
||||||
|
unawaited(media.close());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
stop();
|
||||||
|
final warm = _warm;
|
||||||
|
if (queueId.isNotEmpty && warm != null && warm.queueId == queueId) {
|
||||||
|
_warm = null;
|
||||||
|
unawaited(media.close());
|
||||||
|
_adopt(mediaId, warm.src, announce: announce, started: true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final src = RelaySource(server().sourceChannel(), media, name: name, duration: duration);
|
||||||
|
_adopt(mediaId, src, announce: announce, started: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _adopt(int mediaId, RelaySource src, {required bool announce, required bool started}) {
|
||||||
|
_source = src;
|
||||||
|
_mediaId = mediaId;
|
||||||
|
relaying.value = true;
|
||||||
|
if (announce) notify(const RelayStarted());
|
||||||
|
unawaited(() async {
|
||||||
|
if (!started) {
|
||||||
|
try {
|
||||||
|
await src.start();
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('relay source failed: $e');
|
||||||
|
if (identical(_source, src)) {
|
||||||
|
_clear();
|
||||||
|
notify(RelayFailed('$e'));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await src.done;
|
||||||
|
if (identical(_source, src)) _clear();
|
||||||
|
}());
|
||||||
|
}
|
||||||
|
|
||||||
|
void _clear() {
|
||||||
|
_source = null;
|
||||||
|
_mediaId = -1;
|
||||||
|
relaying.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stops serving the current media.
|
||||||
|
void stop() {
|
||||||
|
final src = _source;
|
||||||
|
_clear();
|
||||||
|
if (src != null) unawaited(src.close());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Feeds the warm relay of Up next item [queueId] from here with [media];
|
||||||
|
/// one already feeding it is kept. The server pulls only while someone
|
||||||
|
/// preloads it, and closes the channel once the item is no longer first
|
||||||
|
/// (the source then stops).
|
||||||
|
void feedWarm(String queueId, RelayMedia media) {
|
||||||
|
if (_warm?.queueId == queueId) {
|
||||||
|
unawaited(media.close());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
stopWarm();
|
||||||
|
final src = RelaySource(server().sourceChannel(queueId), media);
|
||||||
|
_warm = (queueId: queueId, src: src);
|
||||||
|
unawaited(() async {
|
||||||
|
try {
|
||||||
|
await src.start();
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('warm relay source of $queueId failed: $e');
|
||||||
|
if (identical(_warm?.src, src)) {
|
||||||
|
_warm = null;
|
||||||
|
onWarmEnded(queueId);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await src.done;
|
||||||
|
if (identical(_warm?.src, src)) {
|
||||||
|
_warm = null;
|
||||||
|
onWarmEnded(queueId);
|
||||||
|
}
|
||||||
|
}());
|
||||||
|
}
|
||||||
|
|
||||||
|
void stopWarm() {
|
||||||
|
final w = _warm;
|
||||||
|
_warm = null;
|
||||||
|
if (w == null) return;
|
||||||
|
unawaited(w.src.close());
|
||||||
|
onWarmEnded(w.queueId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Leave, or a media this member no longer serves.
|
||||||
|
void stopAll() {
|
||||||
|
stop();
|
||||||
|
stopWarm();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Someone present needs this member's relay of Jellyfin [ref]: the
|
||||||
|
/// server's own login does not pass it on (`state.relay`) and a present
|
||||||
|
/// member other than [selfId] does not play that server with its own
|
||||||
|
/// account. A member whose account list is not reported yet (`caps` say it
|
||||||
|
/// has Jellyfin, `jf` still empty) is asked again with the next state.
|
||||||
|
/// Without anyone needing it, no relay channel is opened.
|
||||||
|
bool jellyfinRelayNeeded(StateBroadcast r, JellyfinRef ref, String selfId) {
|
||||||
|
if (r.relay) return false;
|
||||||
|
for (final m in r.members) {
|
||||||
|
if (m.id == selfId || !isPresent(m)) continue;
|
||||||
|
if (m.jf[ref.serverId] == true) continue;
|
||||||
|
if (m.caps.jellyfin && m.jf.isEmpty) continue;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
Loaded 100 of 444 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user