Compare commits
39
Commits
bf9fc193b3
...
main
| 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 |
No files matched your search
+10
-1
@@ -41,7 +41,8 @@ jobs:
|
|||||||
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
|
||||||
@@ -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
|
||||||
@@ -46,9 +46,11 @@ 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
|
||||||
|
restart" downloads, verifies, applies in place and restarts; "Later" hides
|
||||||
|
it until the next check; the install folder must be writable by you. The version you run is
|
||||||
at the bottom of every Settings page (and in the Settings tab's tooltip). Settings has Check now and an auto-check toggle. From a terminal:
|
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
|
`./starsync-server --upgrade` (no display needed, so it works on a
|
||||||
server box too), `./starsync --upgrade`, or the shipped `update.sh` /
|
server box too), `./starsync --upgrade`, or the shipped `update.sh` /
|
||||||
@@ -64,8 +66,9 @@ The app always runs its own server, quietly: your server. When it starts
|
|||||||
you are on it, alone, and it is a plain player. Three ways in, at the top
|
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.
|
of the Library the first time; none needs Jellyfin or an account.
|
||||||
|
|
||||||
- **Join a friend** (most people): your friend sends you an invite (an
|
- **Join a friend** (most people): your friend sends you an invite (a
|
||||||
address) and, maybe, a password. See [Join](#join).
|
`starsync://` link) and, maybe, a password. Click the link, or just
|
||||||
|
copy it: the app asks "Join <their server>?". See [Join](#join).
|
||||||
- **Invite friends**: you have the video and friends watch it with you.
|
- **Invite friends**: you have the video and friends watch it with you.
|
||||||
See [Your server and Invite](#your-server-and-invite).
|
See [Your server and Invite](#your-server-and-invite).
|
||||||
- **Watch on your own**: just play something from the Library, with
|
- **Watch on your own**: just play something from the Library, with
|
||||||
@@ -86,8 +89,8 @@ 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 (each member's app version, amber when it differs from yours,
|
member list (each member's app version, amber when it differs from yours,
|
||||||
and the server's at the top; force ready, Nudge, Sit out; on your
|
and the server's at the top; force ready, Nudge, Sit out; on your
|
||||||
server the invite with Copy invite and Stop sharing, on a friend's "Copy
|
server where friends can reach it, Copy invite link and Stop sharing,
|
||||||
invite address"). Beside them the server and "ready n/m" (who is sitting out or
|
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
|
reconnecting does not count), the chat button with unread
|
||||||
count, and Invite on your server or Leave on a friend's. Empty strip
|
count, and Invite on your server or Leave on a friend's. Empty strip
|
||||||
space drags the window.
|
space drags the window.
|
||||||
@@ -119,6 +122,26 @@ 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
|
with their sound). Watching alone, nothing pops up or sounds about the
|
||||||
party (there is nobody); the app's own notices still show.
|
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,
|
**While sitting out or in the background.** Sitting out, only chat,
|
||||||
nudges, "starting now" (the video starts for the others) and Up next
|
nudges, "starting now" (the video starts for the others) and Up next
|
||||||
reach you; joins and leaves too if you turn on Settings → Notifications &
|
reach you; joins and leaves too if you turn on Settings → Notifications &
|
||||||
@@ -167,13 +190,22 @@ Your server starts with the app and stops when it closes. Only this app
|
|||||||
can reach it until you press **Invite** (in the strip, or in the member
|
can reach it until you press **Invite** (in the strip, or in the member
|
||||||
list): that opens it on your network at the port from Settings → Party &
|
list): that opens it on your network at the port from Settings → Party &
|
||||||
network (default 8080, with the password set there; blank = anyone can
|
network (default 8080, with the password set there; blank = anyone can
|
||||||
join) and copies an invite ("Join me on Starsync: 192.168.1.20:8080 ·
|
join) and copies an invite link (`starsync://203.0.113.7:8080/?name=…`,
|
||||||
Starsync 1.3.0") to send to friends. Nothing restarts: what plays and Up
|
just the link) to send to friends. Nothing restarts: what plays and Up next carry on, and you are
|
||||||
next carry on, and you are the host (the crown). The invite picks this
|
the host (the crown).
|
||||||
computer's home-network address (not a VPN or proxy one, nor a Docker or
|
|
||||||
VM bridge). Friends elsewhere need the TCP port forwarded on your router
|
The link carries your internet address when the app can find it (it asks
|
||||||
(or a VPN address, or a server they can all reach, like a VPS). Friends
|
a "what is my IP" service such as ipify over HTTPS when you share), else
|
||||||
open nothing. **Stop sharing** (in the member list) closes it again:
|
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
|
everyone else leaves (their app goes back to their own server and says
|
||||||
why), you and Up next stay.
|
why), you and Up next stay.
|
||||||
|
|
||||||
@@ -224,8 +256,10 @@ Up next, add the folder to Files, or fill Link).
|
|||||||
"Show (Season 3 - Alicization)" gives the season and its name. Shows
|
"Show (Season 3 - Alicization)" gives the season and its name. Shows
|
||||||
are looked up on AniList for their proper name, cover and banner. A
|
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
|
show's page has its seasons and episodes and a button for the next one
|
||||||
after what you last played here; when episodes are missing on the way
|
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?"). Matched
|
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
|
the wrong show? **Wrong show?** searches AniList and pins the right one
|
||||||
for the show everywhere or just one folder; **Episode numbers…** says
|
for the show everywhere or just one folder; **Episode numbers…** says
|
||||||
which episode the first file is when the files count differently. Both
|
which episode the first file is when the files count differently. Both
|
||||||
@@ -253,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
|
||||||
@@ -261,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
|
||||||
|
|
||||||
@@ -342,7 +381,15 @@ Join a friend in the strip. Servers you got into are listed first (up
|
|||||||
to 6), with live status (online and how many are watching, or offline,
|
to 6), with live status (online and how many are watching, or offline,
|
||||||
plus "needs update" when its version and yours do not match): click one
|
plus "needs update" when its version and yours do not match): click one
|
||||||
to rejoin, x to forget it. Or paste the invite (or the address and port)
|
to rejoin, x to forget it. Or paste the invite (or the address and port)
|
||||||
and the password if there is one, then Enter or Join. Before connecting
|
and the password if there is one, then Enter or Join.
|
||||||
|
|
||||||
|
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
|
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
|
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
|
update"), and offers the update when it is you. Your name and picture
|
||||||
@@ -362,15 +409,21 @@ volume and sound set, and every sound with a preview, its own set and a switch);
|
|||||||
(the version, also at the bottom of every Settings page and in the
|
(the version, also at the bottom of every Settings page and in the
|
||||||
Settings tab's tooltip; and the credits, e.g. that the Minimal player design is inspired by uosc).
|
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
|
||||||
@@ -380,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.
|
||||||
@@ -473,10 +527,13 @@ 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
|
||||||
@@ -493,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
|
||||||
@@ -533,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
|
||||||
|
|||||||
@@ -3,22 +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 'app.dart';
|
import 'app.dart';
|
||||||
import 'src/app/app_graph.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' show closeAppWith;
|
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/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/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/session/app_events.dart' show UpdateAvailable;
|
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 UpdateController, UpdatePhase, 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,6 +54,15 @@ Future<void> main(List<String> args) async {
|
|||||||
);
|
);
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
final dirs = await const DesktopAppDirs().resolve();
|
final dirs = await const DesktopAppDirs().resolve();
|
||||||
|
// One app per profile: a second start (a clicked starsync:// link) hands
|
||||||
|
// its arguments to the running one and ends before its window shows.
|
||||||
|
final instance = await InstanceLock.claim(
|
||||||
|
Directory(dirs.hive).parent.path,
|
||||||
|
args: args,
|
||||||
|
activation: await windowLaunchToken(),
|
||||||
|
beforeSend: Platform.isWindows ? allowSetForegroundWindow : null,
|
||||||
|
);
|
||||||
|
if (instance == null) await exitNow();
|
||||||
await startAppLogging(dirs);
|
await startAppLogging(dirs);
|
||||||
Hive.init(dirs.hive);
|
Hive.init(dirs.hive);
|
||||||
await AppSettings.instance.ensureLoaded();
|
await AppSettings.instance.ensureLoaded();
|
||||||
@@ -51,29 +71,39 @@ Future<void> main(List<String> args) async {
|
|||||||
final ports = graph.ports;
|
final ports = graph.ports;
|
||||||
// Close button / Alt+F4: the orderly close ([shutDownApp]), so others
|
// Close button / Alt+F4: the orderly close ([shutDownApp]), so others
|
||||||
// see "left", your server shuts down and no player outlives the engine.
|
// see "left", your server shuts down and no player outlives the engine.
|
||||||
final close = closeAppWith(graph);
|
final close = closeAppWith(graph, instance: instance);
|
||||||
await ports.shell.start(
|
await ports.shell.start(
|
||||||
title: dirs.profile == null ? appName : '$appName [${dirs.profile}]', onCloseRequest: () => close());
|
title: dirs.profile == null ? appName : '$appName [${dirs.profile}]', onCloseRequest: () => close());
|
||||||
// Focus, minimise and restore feed auto sit-out and the Notifier (in
|
// Focus, minimise and restore feed auto sit-out and the Notifier (in
|
||||||
// the background, notices go out as system notifications).
|
// 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) {
|
ports.shell.watchActivity((a) {
|
||||||
graph.coordinator.windowActivity(a);
|
graph.coordinator.windowActivity(a);
|
||||||
graph.notifier.window = a;
|
graph.notifier.window = a;
|
||||||
|
if (a == WindowActivity.focused) {
|
||||||
|
unawaited(ports.shell.readClipboardText().then(graph.joinOffers.fromClipboard));
|
||||||
|
}
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
await graph.forms.load();
|
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');
|
||||||
}
|
}
|
||||||
// The library's matches, pins and offsets (device-owned).
|
// The library's matches, pins and offsets, and the watch history
|
||||||
|
// (device-owned).
|
||||||
unawaited(graph.library.ensureLoaded());
|
unawaited(graph.library.ensureLoaded());
|
||||||
MediaKit.ensureInitialized();
|
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.
|
||||||
ports.links.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());
|
||||||
|
// Windows: starsync:// links start this install (HKCU, release builds).
|
||||||
|
unawaited(registerWindowsScheme());
|
||||||
// A found update is an event too (the band shows it).
|
// A found update is an event too (the band shows it).
|
||||||
final updates = UpdateController.instance;
|
final updates = graph.updates;
|
||||||
updates.status.addListener(() {
|
updates.status.addListener(() {
|
||||||
final s = updates.status.value;
|
final s = updates.status.value;
|
||||||
final version = s.manifest?.version;
|
final version = s.manifest?.version;
|
||||||
@@ -82,9 +112,22 @@ Future<void> main(List<String> args) async {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
runApp(AppScope(graph: graph, child: const WatchTogetherApp()));
|
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
|
// Home: your own server, quietly (only this app can reach it until
|
||||||
// Invite).
|
// Invite).
|
||||||
unawaited(graph.navigation.start());
|
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), graph.sfx.warmUp);
|
Future.delayed(const Duration(seconds: 2), graph.sfx.warmUp);
|
||||||
}
|
}
|
||||||
@@ -2,6 +2,7 @@ import 'package:flutter/widgets.dart';
|
|||||||
|
|
||||||
import '../jellyfin/accounts.dart';
|
import '../jellyfin/accounts.dart';
|
||||||
import '../library/library_store.dart';
|
import '../library/library_store.dart';
|
||||||
|
import '../library/local_next.dart';
|
||||||
import '../library/show_matcher.dart';
|
import '../library/show_matcher.dart';
|
||||||
import '../platform/app_log.dart' show appLogDir;
|
import '../platform/app_log.dart' show appLogDir;
|
||||||
import '../platform/desktop/desktop_ports.dart';
|
import '../platform/desktop/desktop_ports.dart';
|
||||||
@@ -11,6 +12,7 @@ import '../schedule/animeflow.dart';
|
|||||||
import '../screens/home/health_probe.dart';
|
import '../screens/home/health_probe.dart';
|
||||||
import '../session/connection.dart' show Connection;
|
import '../session/connection.dart' show Connection;
|
||||||
import '../session/identity.dart' show ClientIdentity, Profile;
|
import '../session/identity.dart' show ClientIdentity, Profile;
|
||||||
|
import '../session/join_offers.dart';
|
||||||
import '../session/navigation.dart';
|
import '../session/navigation.dart';
|
||||||
import '../session/notifier.dart';
|
import '../session/notifier.dart';
|
||||||
import '../session/session_coordinator.dart';
|
import '../session/session_coordinator.dart';
|
||||||
@@ -20,6 +22,10 @@ import '../sfx/sound_bank.dart';
|
|||||||
import '../sfx/synth_sfx.dart';
|
import '../sfx/synth_sfx.dart';
|
||||||
import '../state/connect_prefs.dart';
|
import '../state/connect_prefs.dart';
|
||||||
import '../state/settings.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
|
/// The composition root: builds the app's systems once (in `main`), wires
|
||||||
/// them through their constructors and disposes them. No logic and no
|
/// them through their constructors and disposes them. No logic and no
|
||||||
@@ -51,6 +57,18 @@ class AppGraph {
|
|||||||
/// The library's matches (L2): local shows to AniList, pins, offsets.
|
/// The library's matches (L2): local shows to AniList, pins, offsets.
|
||||||
final ShowMatcher library;
|
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._({
|
AppGraph._({
|
||||||
required this.ports,
|
required this.ports,
|
||||||
required this.sfx,
|
required this.sfx,
|
||||||
@@ -61,10 +79,15 @@ class AppGraph {
|
|||||||
required this.yourServer,
|
required this.yourServer,
|
||||||
required this.navigation,
|
required this.navigation,
|
||||||
required this.library,
|
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]
|
/// The desktop app's graph. Tests pass their own [ports], [identity]
|
||||||
/// (default: the install's stored one), [healthGet] and server starter.
|
/// (default: the install's stored one), [healthGet], server starter and
|
||||||
|
/// internet address lookup ([publicAddress]).
|
||||||
factory AppGraph.build({
|
factory AppGraph.build({
|
||||||
Ports? ports,
|
Ports? ports,
|
||||||
ClientIdentity? identity,
|
ClientIdentity? identity,
|
||||||
@@ -72,12 +95,15 @@ class AppGraph {
|
|||||||
ServerStarter? serverStarter,
|
ServerStarter? serverStarter,
|
||||||
JellyfinAccounts? jellyfin,
|
JellyfinAccounts? jellyfin,
|
||||||
ShowMatcher? library,
|
ShowMatcher? library,
|
||||||
|
WatchHistory? history,
|
||||||
|
Future<String?> Function()? publicAddress,
|
||||||
}) {
|
}) {
|
||||||
final p = ports ?? desktopPorts();
|
final p = ports ?? desktopPorts();
|
||||||
|
final settings = AppSettings.instance;
|
||||||
final sfx = SynthSfx(
|
final sfx = SynthSfx(
|
||||||
volume: () => AppSettings.instance.soundVolume.peek(),
|
volume: () => settings.soundVolume.peek(),
|
||||||
// A nudge is an explicit poke: it ignores its switch (not the volume).
|
// A nudge is an explicit poke: it ignores its switch (not the volume).
|
||||||
enabled: (k) => k == SfxKind.nudge || !AppSettings.instance.soundsOff.peek().contains(k.name),
|
enabled: (k) => k == SfxKind.nudge || !settings.soundsOff.peek().contains(k.name),
|
||||||
presetFor: soundPresetFor,
|
presetFor: soundPresetFor,
|
||||||
);
|
);
|
||||||
final notifier = Notifier(
|
final notifier = Notifier(
|
||||||
@@ -85,11 +111,19 @@ class AppGraph {
|
|||||||
attention: p.shell.requestAttention,
|
attention: p.shell.requestAttention,
|
||||||
system: p.notifications,
|
system: p.notifications,
|
||||||
focusWindow: p.shell.focus,
|
focusWindow: p.shell.focus,
|
||||||
prefs: () => AppSettings.instance.notifyPrefs,
|
prefs: () => settings.notifyPrefs,
|
||||||
);
|
);
|
||||||
final connection = Connection(notify: notifier.notify, identity: identity ?? ClientIdentity.stored());
|
final connection = Connection(notify: notifier.notify, identity: identity ?? ClientIdentity.stored());
|
||||||
final coordinator =
|
// Device-owned: the watch history box.
|
||||||
SessionCoordinator(notifier: notifier, connection: connection, ytDlp: p.links, jellyfin: jellyfin);
|
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.
|
// Device-owned: the library box; AnimeFlow's runs when signed in.
|
||||||
final matcher = library ??
|
final matcher = library ??
|
||||||
ShowMatcher(
|
ShowMatcher(
|
||||||
@@ -97,7 +131,9 @@ class AppGraph {
|
|||||||
aniList: AniListClient(),
|
aniList: AniListClient(),
|
||||||
flow: AnimeFlowLookup(AnimeFlowClient.fromAccounts(coordinator.jellyfin)),
|
flow: AnimeFlowLookup(AnimeFlowClient.fromAccounts(coordinator.jellyfin)),
|
||||||
);
|
);
|
||||||
coordinator.fileRef = matcher.refForPath;
|
coordinator
|
||||||
|
..fileRef = matcher.refForPath
|
||||||
|
..nextFile = LocalNextFinder(matcher: matcher, scan: p.folders.scan).nextAfter;
|
||||||
final forms = ConnectStore();
|
final forms = ConnectStore();
|
||||||
final yourServer = YourServer(
|
final yourServer = YourServer(
|
||||||
forms: forms,
|
forms: forms,
|
||||||
@@ -113,6 +149,7 @@ class AppGraph {
|
|||||||
},
|
},
|
||||||
logDir: () => appLogDir,
|
logDir: () => appLogDir,
|
||||||
starter: serverStarter,
|
starter: serverStarter,
|
||||||
|
publicAddress: publicAddress,
|
||||||
);
|
);
|
||||||
final navigation = Navigation(
|
final navigation = Navigation(
|
||||||
coordinator: coordinator,
|
coordinator: coordinator,
|
||||||
@@ -127,6 +164,10 @@ class AppGraph {
|
|||||||
..push = ({String? name, String? avatar}) {
|
..push = ({String? name, String? avatar}) {
|
||||||
if (navigation.online.peek()) coordinator.updateProfile(name: name, avatar: 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._(
|
return AppGraph._(
|
||||||
ports: p,
|
ports: p,
|
||||||
sfx: sfx,
|
sfx: sfx,
|
||||||
@@ -137,12 +178,17 @@ class AppGraph {
|
|||||||
yourServer: yourServer,
|
yourServer: yourServer,
|
||||||
navigation: navigation,
|
navigation: navigation,
|
||||||
library: matcher,
|
library: matcher,
|
||||||
|
watch: watch,
|
||||||
|
updates: updates,
|
||||||
|
whatsNew: whatsNew,
|
||||||
|
joinOffers: JoinOffers(navigation: navigation, yourServer: yourServer, forms: forms),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> dispose() async {
|
Future<void> dispose() async {
|
||||||
navigation.dispose();
|
navigation.dispose();
|
||||||
await coordinator.dispose();
|
await coordinator.dispose();
|
||||||
|
await watch.dispose();
|
||||||
await yourServer.stop();
|
await yourServer.stop();
|
||||||
await sfx.dispose();
|
await sfx.dispose();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -81,13 +81,16 @@ 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,
|
||||||
alone: coord.alone.value,
|
alone: coord.alone.value,
|
||||||
@@ -112,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
|
||||||
@@ -121,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);
|
||||||
@@ -137,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;
|
||||||
}
|
}
|
||||||
@@ -157,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.
|
||||||
|
|||||||
@@ -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,23 +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 '../session/session_coordinator.dart' show SessionCoordinator;
|
||||||
import '../shaders/shader_presets.dart';
|
import '../shaders/shader_presets.dart';
|
||||||
import '../app/app_graph.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).
|
||||||
@@ -199,6 +195,35 @@ 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(SessionCoordinator coord, ShaderPreset preset, ShaderQuality? quality) {
|
void applyShaderChoice(SessionCoordinator coord, ShaderPreset preset, ShaderQuality? quality) {
|
||||||
@@ -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',
|
||||||
@@ -267,10 +294,10 @@ class ShaderPresetSetting extends StatelessWidget {
|
|||||||
/// 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(PlaybackController playback) async {
|
Future<void> applyHwdecLive(PlaybackController playback) async {
|
||||||
if ((Platform.environment['SYNC_RELAY_HWDEC'] ?? '').isNotEmpty) return;
|
if ((Platform.environment['SYNC_RELAY_HWDEC'] ?? '').isNotEmpty) return;
|
||||||
if (!playback.alive.peek()) return;
|
final p = playback.current;
|
||||||
|
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');
|
||||||
}
|
}
|
||||||
@@ -331,58 +358,6 @@ class _HwdecSettingState extends State<HwdecSetting> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Audio or subtitle track of the running player (in the player only).
|
|
||||||
/// The pick is remembered for the next Jellyfin item.
|
|
||||||
class TrackSetting extends StatelessWidget {
|
|
||||||
final JellyfinStreamType type;
|
|
||||||
const TrackSetting({super.key, required this.type});
|
|
||||||
|
|
||||||
bool get _audio => type == JellyfinStreamType.audio;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final coord = AppScope.of(context).coordinator;
|
|
||||||
return SignalBuilder(builder: (context) {
|
|
||||||
final list = [
|
|
||||||
for (final t in coord.playback.tracks.value)
|
|
||||||
if (t.type == type) t,
|
|
||||||
];
|
|
||||||
// -1: subtitles off.
|
|
||||||
final selected = list.where((t) => t.selected).firstOrNull?.id ?? (_audio ? null : -1);
|
|
||||||
final items = <(int, String)>[
|
|
||||||
if (!_audio) (-1, 'Off'),
|
|
||||||
for (final MpvTrack t in list) (t.id, t.label),
|
|
||||||
];
|
|
||||||
return SettingRow(
|
|
||||||
label: _audio ? 'Audio' : 'Subtitles',
|
|
||||||
help: 'Remembered for the next episode',
|
|
||||||
control: SizedBox(
|
|
||||||
width: 190,
|
|
||||||
child: items.isEmpty || selected == null
|
|
||||||
? Text(items.isEmpty ? 'None' : 'Pick one…',
|
|
||||||
key: ValueKey(_audio ? 'audio-track' : 'subtitle-track'), textAlign: TextAlign.end)
|
|
||||||
: _dropdown<int>(
|
|
||||||
key: ValueKey(_audio ? 'audio-track' : 'subtitle-track'),
|
|
||||||
value: selected,
|
|
||||||
expanded: true,
|
|
||||||
items: items,
|
|
||||||
onChanged: (id) {
|
|
||||||
coord.markInput();
|
|
||||||
if (id == -1) {
|
|
||||||
coord.selectTrack(subtitlesOff: true);
|
|
||||||
} else if (_audio) {
|
|
||||||
coord.selectTrack(aid: id);
|
|
||||||
} else {
|
|
||||||
coord.selectTrack(sid: id);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The controls' own choices (Settings → Player → Controls).
|
/// The controls' own choices (Settings → Player → Controls).
|
||||||
List<Widget> controlsSettingRows() => [
|
List<Widget> controlsSettingRows() => [
|
||||||
const TimeDisplaySetting(),
|
const TimeDisplaySetting(),
|
||||||
@@ -395,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(),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -453,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()],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -9,6 +9,7 @@ import '../../library/show_matcher.dart';
|
|||||||
import '../../shell/motion.dart';
|
import '../../shell/motion.dart';
|
||||||
import '../../platform/ports/folder_access.dart';
|
import '../../platform/ports/folder_access.dart';
|
||||||
import '../../app/app_graph.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 '../scanner.dart';
|
import '../scanner.dart';
|
||||||
@@ -127,6 +128,7 @@ class _FilesPageState extends State<FilesPage> {
|
|||||||
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; [libraryPlace]: every folder.
|
/// Null: Recent; [libraryPlace]: every folder.
|
||||||
String? _open = _lastOpen;
|
String? _open = _lastOpen;
|
||||||
@@ -325,8 +327,15 @@ class _FilesPageState extends State<FilesPage> {
|
|||||||
thumbs: _thumbs,
|
thumbs: _thumbs,
|
||||||
);
|
);
|
||||||
|
|
||||||
/// The last episode of [show] played here (Recent), by key.
|
/// 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) {
|
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) {
|
for (final p in _store.recent.value) {
|
||||||
final e = lib.byPath[p];
|
final e = lib.byPath[p];
|
||||||
if (e != null && e.showKey == show.key && e.kind == LocalKind.episode) return e.key;
|
if (e != null && e.showKey == show.key && e.kind == LocalKind.episode) return e.key;
|
||||||
@@ -342,6 +351,7 @@ class _FilesPageState extends State<FilesPage> {
|
|||||||
final folders = _store.folders.value;
|
final folders = _store.folders.value;
|
||||||
final recent = _store.recent.value;
|
final recent = _store.recent.value;
|
||||||
_matcher.revision.value;
|
_matcher.revision.value;
|
||||||
|
_history.revision.value;
|
||||||
final open = _open == null || _open == libraryPlace || folders.contains(_open) ? _open : libraryPlace;
|
final open = _open == null || _open == libraryPlace || folders.contains(_open) ? _open : libraryPlace;
|
||||||
final showKey = open == null ? null : _openShows[open];
|
final showKey = open == null ? null : _openShows[open];
|
||||||
final main = fadeThrough(
|
final main = fadeThrough(
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import 'package:flutter/foundation.dart';
|
|||||||
import 'package:sync_relay_core/core.dart';
|
import 'package:sync_relay_core/core.dart';
|
||||||
|
|
||||||
import '../screens/home/picked_media.dart';
|
import '../screens/home/picked_media.dart';
|
||||||
import '../session/party.dart' show isPresent;
|
|
||||||
import 'accounts.dart';
|
import 'accounts.dart';
|
||||||
import 'format.dart';
|
import 'format.dart';
|
||||||
import '../library/media_refs.dart';
|
import '../library/media_refs.dart';
|
||||||
@@ -341,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`, present: `in` or joining). 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 (isPresent(m) && 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,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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -129,6 +129,7 @@ class ShowMatcher {
|
|||||||
|
|
||||||
// Files seen in scans this run, for refs of files put up.
|
// Files seen in scans this run, for refs of files put up.
|
||||||
final _byPath = <String, LocalEpisode>{};
|
final _byPath = <String, LocalEpisode>{};
|
||||||
|
final _showByPath = <String, LocalShow>{};
|
||||||
final _entries = <int, AniListEntry>{};
|
final _entries = <int, AniListEntry>{};
|
||||||
final _flows = <int, FlowShow?>{};
|
final _flows = <int, FlowShow?>{};
|
||||||
final _jobs = <String, Future<void> Function()>{};
|
final _jobs = <String, Future<void> Function()>{};
|
||||||
@@ -152,13 +153,22 @@ class ShowMatcher {
|
|||||||
|
|
||||||
// ------------------------------------------------------------- reads
|
// ------------------------------------------------------------- reads
|
||||||
|
|
||||||
/// Remembers [library]'s files, so [refForPath] knows their show.
|
/// Remembers [library]'s files, so [refForPath] and [showAt] know
|
||||||
|
/// their show.
|
||||||
void remember(LocalLibrary library) {
|
void remember(LocalLibrary library) {
|
||||||
for (final e in library.files) {
|
for (final e in library.files) {
|
||||||
_byPath[e.path] = e;
|
_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) {
|
AniListEntry? entry(int id) {
|
||||||
final known = _entries[id];
|
final known = _entries[id];
|
||||||
if (known != null) return known;
|
if (known != null) return known;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import 'package:flutter/foundation.dart';
|
|||||||
|
|
||||||
import '../files/thumbnails.dart';
|
import '../files/thumbnails.dart';
|
||||||
import '../app/app_graph.dart';
|
import '../app/app_graph.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
|
||||||
@@ -21,10 +22,14 @@ bool get shuttingDown => _shuttingDown;
|
|||||||
/// The systems the close stops; `main` hands them over once.
|
/// The systems the close stops; `main` hands them over once.
|
||||||
AppGraph? _graph;
|
AppGraph? _graph;
|
||||||
|
|
||||||
/// Makes [graph] the one [shutDownApp] stops and returns the close
|
/// This app's claim on its profile folder (one app at a time).
|
||||||
/// request handler for the window.
|
InstanceLock? _instance;
|
||||||
Future<void> Function() closeAppWith(AppGraph graph) {
|
|
||||||
|
/// 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;
|
_graph = graph;
|
||||||
|
_instance = instance;
|
||||||
return shutDownApp;
|
return shutDownApp;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,6 +43,9 @@ Future<void> shutDownApp({Future<void> Function()? beforeQuit, bool restart = fa
|
|||||||
final graph = _graph;
|
final graph = _graph;
|
||||||
if (_shuttingDown || graph == null) return;
|
if (_shuttingDown || graph == null) return;
|
||||||
_shuttingDown = true;
|
_shuttingDown = true;
|
||||||
|
// 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 shell = graph.ports.shell;
|
||||||
final total = Stopwatch()..start();
|
final total = Stopwatch()..start();
|
||||||
await _step('window save', shell.saveWindow(), const Duration(milliseconds: 500));
|
await _step('window save', shell.saveWindow(), const Duration(milliseconds: 500));
|
||||||
@@ -47,6 +55,8 @@ Future<void> shutDownApp({Future<void> Function()? beforeQuit, bool restart = fa
|
|||||||
_step('thumbnails', cancelAllThumbnails(), const Duration(milliseconds: 1500)),
|
_step('thumbnails', cancelAllThumbnails(), const Duration(milliseconds: 1500)),
|
||||||
_step('sounds', graph.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.
|
||||||
@@ -55,6 +65,8 @@ Future<void> shutDownApp({Future<void> Function()? beforeQuit, bool restart = fa
|
|||||||
// server: 3 s in all.
|
// server: 3 s in all.
|
||||||
await _step('leave', graph.navigation.leave(bye: !restart), const Duration(milliseconds: 2500));
|
await _step('leave', graph.navigation.leave(bye: !restart), const Duration(milliseconds: 2500));
|
||||||
await _step('server stop', graph.yourServer.stop(), 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');
|
||||||
await shell.quit();
|
await shell.quit();
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
@@ -17,3 +19,41 @@ Future<void> requestWindowAttention() async {
|
|||||||
debugPrint('window attention: $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');
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import 'desktop_folders.dart';
|
|||||||
import 'desktop_notifications.dart';
|
import 'desktop_notifications.dart';
|
||||||
import 'desktop_shell.dart';
|
import 'desktop_shell.dart';
|
||||||
import 'desktop_urls.dart';
|
import 'desktop_urls.dart';
|
||||||
|
import 'media_kit_host.dart';
|
||||||
|
|
||||||
/// The desktop's [Ports].
|
/// The desktop's [Ports].
|
||||||
Ports desktopPorts() {
|
Ports desktopPorts() {
|
||||||
@@ -14,5 +15,6 @@ Ports desktopPorts() {
|
|||||||
folders: const DesktopFolders(urls),
|
folders: const DesktopFolders(urls),
|
||||||
links: AppYtDlp.instance,
|
links: AppYtDlp.instance,
|
||||||
notifications: DesktopNotifications(),
|
notifications: DesktopNotifications(),
|
||||||
|
players: MediaKitHost(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -98,10 +98,13 @@ class DesktopShell implements ShellPort {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> focus() async {
|
Future<void> focus({String activation = ''}) async {
|
||||||
try {
|
try {
|
||||||
if (await windowManager.isMinimized()) await windowManager.restore();
|
if (await windowManager.isMinimized()) await windowManager.restore();
|
||||||
if (!await windowManager.isVisible()) await windowManager.show();
|
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();
|
await windowManager.focus();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('window focus failed: $e');
|
debugPrint('window focus failed: $e');
|
||||||
@@ -126,6 +129,15 @@ class DesktopShell implements ShellPort {
|
|||||||
return (files: files, text: text);
|
return (files: files, text: text);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<String?> readClipboardText() async {
|
||||||
|
try {
|
||||||
|
return await ClipboardX.readText();
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget dropTarget({
|
Widget dropTarget({
|
||||||
Key? key,
|
Key? key,
|
||||||
|
|||||||
@@ -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');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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,6 +3,8 @@ 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 = 'dev.foxyz.starsync';
|
const appId = 'dev.foxyz.starsync';
|
||||||
@@ -18,10 +20,17 @@ const _iconSizes = [16, 32, 48, 64, 128, 256, 512];
|
|||||||
/// 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). The entry and icons of [legacyAppId] go. Never in debug runs
|
/// icon). The entry and icons of [legacyAppId] go. The entry also takes
|
||||||
/// (unless [force], for tests); failures only log.
|
/// `starsync://` links ([schemeMimeType], %u) and the app becomes their
|
||||||
Future<void> installDesktopEntry(
|
/// default handler ([registerScheme], default [registerLinuxScheme]).
|
||||||
{String? executable, String? dataHome, String? home, @visibleForTesting bool force = false}) async {
|
/// 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;
|
if (!Platform.isLinux || (kDebugMode && !force)) return;
|
||||||
try {
|
try {
|
||||||
final exe = executable ?? Platform.resolvedExecutable;
|
final exe = executable ?? Platform.resolvedExecutable;
|
||||||
@@ -41,9 +50,11 @@ Future<void> installDesktopEntry(
|
|||||||
}
|
}
|
||||||
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');
|
||||||
}
|
}
|
||||||
@@ -69,9 +80,11 @@ 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
|
||||||
''';
|
''';
|
||||||
@@ -10,8 +10,10 @@ abstract interface class LinkResolver {
|
|||||||
/// Gets it ready in the background (download on first run, updates).
|
/// Gets it ready in the background (download on first run, updates).
|
||||||
void warmUp();
|
void warmUp();
|
||||||
|
|
||||||
/// mpv's `ytdl-raw-options` (sub languages, cookies).
|
/// This device opens web pages itself (the media plan's `resolve`
|
||||||
Future<String> mpvRawOptions();
|
/// 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.
|
/// What [url] is, remembered for a while ([keep]); [fresh] asks again.
|
||||||
Future<LinkProbe> probe(String url, {Duration keep, bool fresh});
|
Future<LinkProbe> probe(String url, {Duration keep, bool fresh});
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'folder_access.dart';
|
import 'folder_access.dart';
|
||||||
import 'link_resolver.dart';
|
import 'link_resolver.dart';
|
||||||
|
import 'player_host.dart';
|
||||||
import 'shell_port.dart';
|
import 'shell_port.dart';
|
||||||
import 'system_notifications.dart';
|
import 'system_notifications.dart';
|
||||||
import 'url_opener.dart';
|
import 'url_opener.dart';
|
||||||
@@ -7,6 +8,7 @@ import 'url_opener.dart';
|
|||||||
export 'app_dirs.dart';
|
export 'app_dirs.dart';
|
||||||
export 'folder_access.dart';
|
export 'folder_access.dart';
|
||||||
export 'link_resolver.dart';
|
export 'link_resolver.dart';
|
||||||
|
export 'player_host.dart';
|
||||||
export 'shell_port.dart';
|
export 'shell_port.dart';
|
||||||
export 'system_notifications.dart';
|
export 'system_notifications.dart';
|
||||||
export 'url_opener.dart';
|
export 'url_opener.dart';
|
||||||
@@ -19,11 +21,14 @@ class Ports {
|
|||||||
final FolderAccess folders;
|
final FolderAccess folders;
|
||||||
final LinkResolver links;
|
final LinkResolver links;
|
||||||
final SystemNotifications notifications;
|
final SystemNotifications notifications;
|
||||||
|
/// The media players (mpv): created, rendered and disposed here.
|
||||||
|
final PlayerHost players;
|
||||||
const Ports({
|
const Ports({
|
||||||
required this.shell,
|
required this.shell,
|
||||||
required this.urls,
|
required this.urls,
|
||||||
required this.folders,
|
required this.folders,
|
||||||
required this.links,
|
required this.links,
|
||||||
required this.notifications,
|
required this.notifications,
|
||||||
|
required this.players,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -43,8 +43,10 @@ abstract interface class ShellPort {
|
|||||||
Future<void> quit();
|
Future<void> quit();
|
||||||
|
|
||||||
/// Brings the window up and gives it the focus (restored when
|
/// Brings the window up and gives it the focus (restored when
|
||||||
/// minimised, shown when hidden): a click on a system notification.
|
/// minimised, shown when hidden): a click on a system notification, a
|
||||||
Future<void> focus();
|
/// 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
|
/// Points the desktop at the window when it is not focused (taskbar
|
||||||
/// flash, urgency hint); nothing when it is.
|
/// flash, urgency hint); nothing when it is.
|
||||||
@@ -53,6 +55,10 @@ abstract interface class ShellPort {
|
|||||||
/// The clipboard's files, else its text (Ctrl+V outside a text field).
|
/// The clipboard's files, else its text (Ctrl+V outside a text field).
|
||||||
Future<Paste> readPaste();
|
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
|
/// [child] taking dropped files ([onDrop]: their paths and where they
|
||||||
/// landed); [enable] off lets drops through.
|
/// landed); [enable] off lets drops through.
|
||||||
Widget dropTarget({
|
Widget dropTarget({
|
||||||
|
|||||||
@@ -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
@@ -6,15 +6,6 @@ import 'package:sync_relay_core/core.dart';
|
|||||||
|
|
||||||
import '../platform/ports/link_resolver.dart';
|
import '../platform/ports/link_resolver.dart';
|
||||||
|
|
||||||
/// mpv's `ytdl-format` for links every player opens itself.
|
|
||||||
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.
|
||||||
@@ -36,20 +27,10 @@ class AppYtDlp implements LinkResolver {
|
|||||||
/// 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).
|
||||||
@override
|
@override
|
||||||
void warmUp() => unawaited(ytDlp.then((y) => y.ensure()).catchError((_) => null));
|
bool get resolvesLinks => true;
|
||||||
|
|
||||||
/// mpv's `ytdl-raw-options`: [ytdlSubLangs] and the cookies in the
|
|
||||||
/// yt-dlp folder (see [YtDlpCookies]).
|
|
||||||
@override
|
@override
|
||||||
Future<String> mpvRawOptions() async {
|
void warmUp() => unawaited(ytDlp.then((y) => y.ensure()).catchError((_) => null));
|
||||||
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);
|
||||||
|
|||||||
@@ -16,23 +16,27 @@ import '../screens/home/picked_media.dart';
|
|||||||
import '../session/app_events.dart' show AddedToUpNext;
|
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.
|
||||||
@@ -46,21 +50,33 @@ class QueueController {
|
|||||||
late final PictureCache thumbs = PictureCache(coord.connection.client,
|
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.
|
||||||
@@ -70,10 +86,12 @@ 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();
|
||||||
|
|
||||||
@@ -83,15 +101,26 @@ class QueueController {
|
|||||||
if (_attached) return;
|
if (_attached) return;
|
||||||
_attached = true;
|
_attached = true;
|
||||||
coord.connection.states.listen(_onState);
|
coord.connection.states.listen(_onState);
|
||||||
|
// `cancel` from anyone: no continuation for that media any more.
|
||||||
|
coord.connection.feeds.listen((f) {
|
||||||
|
final id = coord.serverState.peek()?.mediaId;
|
||||||
|
if (f.action == 'advance-cancel' && id != null) _hiddenFor.value = id;
|
||||||
|
});
|
||||||
effect(() {
|
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.selfAway.value;
|
coord.selfAway.value;
|
||||||
final r = coord.serverState.value;
|
final r = coord.serverState.value;
|
||||||
@@ -319,62 +348,54 @@ class QueueController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -385,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};
|
||||||
_local.removeWhere((k, v) => !keys.contains(k) && now.difference(v.at) > const Duration(minutes: 1));
|
// Kept while a restarted server waits for what played: Up next comes
|
||||||
final ids = {for (final q in r.queue) q.id};
|
// back with it.
|
||||||
_skipped.removeWhere((id) => !ids.contains(id));
|
if (_restoreFrom == -1) {
|
||||||
|
_local.removeWhere((k, v) => !keys.contains(k) && now.difference(v.at) > const Duration(minutes: 1));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// [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.connection.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) {
|
||||||
|
_timer?.cancel();
|
||||||
|
_timer = null;
|
||||||
|
if (countdown.peek() != null) countdown.value = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final a = r.advance != null && r.advance!.id == head.id ? r.advance : null;
|
||||||
|
int? left;
|
||||||
|
String? waiting;
|
||||||
|
if (a != null) {
|
||||||
|
left = advanceSecondsLeft(a, coord.connection.serverNow());
|
||||||
|
if (left == 0 && queuePerformer(head, r.members).id == null) {
|
||||||
|
waiting = r.members.where((m) => m.id == head.by).firstOrNull?.name ?? head.byName;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
countdown.value = UpNextCountdown(mediaId: r.mediaId, item: head, secondsLeft: left, waitingFor: waiting);
|
||||||
|
if (a == null) {
|
||||||
|
_timer?.cancel();
|
||||||
|
_timer = null;
|
||||||
|
} else {
|
||||||
|
_timer ??= Timer.periodic(const Duration(seconds: 1), (_) {
|
||||||
|
final now = coord.serverState.peek();
|
||||||
|
if (now != null) _syncCountdown(now);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _stopCountdown() {
|
// ------------------------------------------------------------ restart
|
||||||
_timer?.cancel();
|
|
||||||
_timer = null;
|
/// The server restarted without media; what played ([mediaId]) is being
|
||||||
countdown.value = null;
|
/// 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).
|
||||||
@@ -530,9 +609,8 @@ 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) {
|
||||||
@@ -550,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;
|
||||||
@@ -579,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
|
||||||
@@ -589,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) {
|
||||||
@@ -613,11 +689,14 @@ 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) {
|
||||||
@@ -633,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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}());
|
}());
|
||||||
}
|
}
|
||||||
@@ -656,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:
|
||||||
@@ -710,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));
|
||||||
@@ -730,25 +816,26 @@ class QueueController {
|
|||||||
final out = coord.selfAway.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,66 +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
|
||||||
import '../session/party.dart' show isPresent;
|
// `queuePerformer` (whats_next.dart), read the same by the server and
|
||||||
|
// every app.
|
||||||
// Who puts an Up next item up. Pure: only what every client sees in
|
|
||||||
// `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 (disconnected, in its
|
|
||||||
/// grace): it waits for them; left for good: skipped.
|
|
||||||
/// - Jellyfin: the adder when it plays that server with its own account,
|
|
||||||
/// else the lowest member id that does (`jf[serverId] == true`,
|
|
||||||
/// present: `in` or joining). Nobody: skipped.
|
|
||||||
/// - A link everyone opens, or one the server fetches: the adder when
|
|
||||||
/// present, else the lowest present 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) => isPresent(m);
|
|
||||||
if (item.how.adderOnly) {
|
|
||||||
if (adder == null) return (id: null, why: '${_name(item)} left');
|
|
||||||
if (adder.presence == Presence.gone) return (id: null, why: null);
|
|
||||||
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 connected (not gone).
|
|
||||||
String? queueJanitor(List<MemberView> members) =>
|
|
||||||
autoAdvanceLowest(members.where((m) => m.presence != Presence.gone));
|
|
||||||
|
|
||||||
/// 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",
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import '../controls/minimal/style.dart';
|
|||||||
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;
|
||||||
@@ -38,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();
|
||||||
@@ -143,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((_) {
|
||||||
@@ -155,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';
|
||||||
@@ -246,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(
|
||||||
@@ -337,58 +325,33 @@ 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.connection.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;
|
||||||
return Column(
|
final kicker = queued == null
|
||||||
mainAxisSize: MainAxisSize.min,
|
? 'CONTINUES WITH'
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
: more > 0
|
||||||
children: [
|
? 'UP NEXT · $more MORE AFTER IT'
|
||||||
QueueThumb(queue: queue, item: item, width: picture, radius: 8),
|
: 'UP NEXT';
|
||||||
const SizedBox(height: 10),
|
|
||||||
Text(more > 0 ? 'UP NEXT · $more MORE AFTER IT' : 'UP NEXT', style: _kicker),
|
|
||||||
const SizedBox(height: 3),
|
|
||||||
Text(item.title, maxLines: 2, overflow: TextOverflow.ellipsis, style: _title),
|
|
||||||
if (item.sub.isNotEmpty)
|
|
||||||
Text(item.sub, maxLines: 1, overflow: TextOverflow.ellipsis, style: _sub),
|
|
||||||
const SizedBox(height: 6),
|
|
||||||
_MetaLine(item: item, members: r?.members ?? const [], selfId: selfId, wrap: true),
|
|
||||||
_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(
|
return Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
_EpisodeThumb(queue: queue, episode: episode, width: picture, radius: 8),
|
QueueThumb(queue: queue, item: item, width: picture, radius: 8),
|
||||||
const SizedBox(height: 10),
|
const SizedBox(height: 10),
|
||||||
Text(n == null ? 'NEXT EPISODE' : 'NEXT EPISODE · $n', style: _kicker),
|
Text(kicker, style: _kicker),
|
||||||
const SizedBox(height: 3),
|
const SizedBox(height: 3),
|
||||||
Text(episode.item.name, maxLines: 2, overflow: TextOverflow.ellipsis, style: _title),
|
Text(item.title, maxLines: 2, overflow: TextOverflow.ellipsis, style: _title),
|
||||||
if (sub.isNotEmpty) Text(sub, maxLines: 1, overflow: TextOverflow.ellipsis, style: _sub),
|
if (item.sub.isNotEmpty) Text(item.sub, maxLines: 1, overflow: TextOverflow.ellipsis, style: _sub),
|
||||||
if (overview.isNotEmpty) ...[
|
const SizedBox(height: 6),
|
||||||
const SizedBox(height: 6),
|
_MetaLine(item: item, members: r?.members ?? const [], selfId: selfId, wrap: true),
|
||||||
Text(overview,
|
_footer(ready, ready == null ? performerNote(queue, item, r) : null),
|
||||||
maxLines: 3,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: const TextStyle(color: Colors.white54, fontSize: 12, height: 1.35)),
|
|
||||||
],
|
|
||||||
_footer(ready, null),
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
@@ -481,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) {
|
||||||
@@ -490,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,
|
||||||
@@ -838,6 +800,7 @@ 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;
|
||||||
@@ -848,7 +811,7 @@ class _QueuePanelState extends State<QueuePanel> {
|
|||||||
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,
|
||||||
@@ -900,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),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -956,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(
|
||||||
@@ -964,15 +931,17 @@ 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(),
|
||||||
TextButton(
|
if (auto != null)
|
||||||
key: const ValueKey('queue-clear-continuation'),
|
TextButton(
|
||||||
style: TextButton.styleFrom(foregroundColor: Colors.white54, visualDensity: VisualDensity.compact),
|
key: const ValueKey('queue-clear-continuation'),
|
||||||
onPressed: _q.clearContinuation,
|
style: TextButton.styleFrom(foregroundColor: Colors.white54, visualDensity: VisualDensity.compact),
|
||||||
child: const Text('Clear', style: TextStyle(fontSize: 12)),
|
onPressed: _q.clearContinuation,
|
||||||
),
|
child: const Text('Clear', style: TextStyle(fontSize: 12)),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
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),
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -1479,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;
|
||||||
@@ -1545,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;
|
||||||
@@ -1559,7 +1599,13 @@ class UpNextCard extends StatelessWidget {
|
|||||||
final left = c.secondsLeft;
|
final left = c.secondsLeft;
|
||||||
final r = queue.coord.serverState.value;
|
final r = queue.coord.serverState.value;
|
||||||
final selfId = queue.coord.connection.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(
|
||||||
@@ -1579,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,
|
||||||
@@ -1605,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,
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import '../../shell/mark_problem.dart';
|
|||||||
import '../../app/app_graph.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: [
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
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 '../../app/app_graph.dart';
|
||||||
import '../../state/settings.dart';
|
import '../../state/settings.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'),
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -391,7 +391,8 @@ class Connection {
|
|||||||
void sendReady(bool ready, {String source = 'manual'}) => client.sendReady(ready, source: source);
|
void sendReady(bool ready, {String source = 'manual'}) => client.sendReady(ready, source: source);
|
||||||
void sendForce(String target, bool ready) => client.sendForce(target, ready);
|
void sendForce(String target, bool ready) => client.sendForce(target, ready);
|
||||||
void sendSeek(double position, {int seq = 0}) => client.sendSeek(position, seq: seq);
|
void sendSeek(double position, {int seq = 0}) => client.sendSeek(position, seq: seq);
|
||||||
void sendSwap(HelloMedia media, {String? queueId}) => client.sendSwap(media, queueId: queueId);
|
void sendSwap(HelloMedia media, {String? queueId, SwapRestore? restore}) =>
|
||||||
|
client.sendSwap(media, queueId: queueId, restore: restore);
|
||||||
void sendQueue(QueueIntent intent) => client.sendQueue(intent);
|
void sendQueue(QueueIntent intent) => client.sendQueue(intent);
|
||||||
void sendReport({
|
void sendReport({
|
||||||
required double position,
|
required double position,
|
||||||
|
|||||||
@@ -2,6 +2,12 @@ import 'dart:io';
|
|||||||
|
|
||||||
import 'package:flutter/foundation.dart' show debugPrint;
|
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
|
/// What a friend needs to join your server: where ([host], [port]), what
|
||||||
/// it is called ([name]), whether it asks a password ([password]; the
|
/// it is called ([name]), whether it asks a password ([password]; the
|
||||||
/// password itself is never in it) and which Starsync runs it
|
/// password itself is never in it) and which Starsync runs it
|
||||||
@@ -26,13 +32,14 @@ class Invite {
|
|||||||
/// What goes in the Join card's address and port.
|
/// What goes in the Join card's address and port.
|
||||||
String get address => '${host.contains(':') ? '[$host]' : host}:$port';
|
String get address => '${host.contains(':') ? '[$host]' : host}:$port';
|
||||||
|
|
||||||
/// The text Invite copies: the address, whether to ask for the password,
|
/// The same invite at another of this machine's addresses.
|
||||||
/// the version to match.
|
Invite at(String host) =>
|
||||||
String get text => [
|
Invite(host: host, port: port, name: name, password: password, version: version, fp: fp);
|
||||||
'Join me on Starsync: $address',
|
|
||||||
if (password) '(ask me for the password)',
|
/// Its `starsync://` link with the server's password ([secret], the
|
||||||
if (version.isNotEmpty) '· Starsync $version',
|
/// host's; empty when none is set): what Invite copies, nothing around
|
||||||
].join(' ');
|
/// it, so a friend just clicks it.
|
||||||
|
InviteLink link({String secret = ''}) => InviteLink(host: host, port: port, name: name, secret: secret);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool operator ==(Object other) =>
|
bool operator ==(Object other) =>
|
||||||
@@ -51,6 +58,97 @@ class Invite {
|
|||||||
String toString() => 'Invite($address${password ? ', password' : ''})';
|
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
|
/// The `host:port` in a pasted invite (or a bare address); null when
|
||||||
/// there is none.
|
/// there is none.
|
||||||
({String host, String port})? addressIn(String text) {
|
({String host, String port})? addressIn(String text) {
|
||||||
@@ -74,9 +172,48 @@ bool isFakeIp(List<int> b) => b[0] == 198 && (b[1] == 18 || b[1] == 19);
|
|||||||
bool _isPrivate(List<int> b) =>
|
bool _isPrivate(List<int> b) =>
|
||||||
b[0] == 10 || (b[0] == 192 && b[1] == 168) || (b[0] == 172 && b[1] >= 16 && b[1] < 32);
|
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.
|
/// 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;
|
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,
|
/// The addresses of [interfaces] a friend could dial, best first: IPv4,
|
||||||
/// no loopback, link-local or fake-IP address, no container or VM bridge;
|
/// 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
|
/// home networks (192.168, then 10, then 172.16/12) first, then a mesh
|
||||||
@@ -107,6 +244,36 @@ List<String> inviteAddresses(Iterable<({String name, InternetAddress address})>
|
|||||||
return {for (final (_, a) in indexed) a.address}.toList();
|
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
|
/// This machine's interfaces as [inviteAddresses] takes them (empty when
|
||||||
/// they can't be listed).
|
/// they can't be listed).
|
||||||
Future<List<({String name, InternetAddress address})>> machineInterfaces() async {
|
Future<List<({String name, InternetAddress address})>> machineInterfaces() async {
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -25,6 +25,11 @@ class ToastItem {
|
|||||||
final bool chat;
|
final bool chat;
|
||||||
/// A picture hash to show (an Up next item's), '' when none.
|
/// A picture hash to show (an Up next item's), '' when none.
|
||||||
final String thumb;
|
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(
|
const ToastItem(
|
||||||
{required this.id,
|
{required this.id,
|
||||||
required this.text,
|
required this.text,
|
||||||
@@ -32,7 +37,9 @@ class ToastItem {
|
|||||||
this.actor,
|
this.actor,
|
||||||
this.actorId = '',
|
this.actorId = '',
|
||||||
this.chat = false,
|
this.chat = false,
|
||||||
this.thumb = ''});
|
this.thumb = '',
|
||||||
|
this.category = NoticeCategory.app,
|
||||||
|
this.own = false});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What a notice is about; the policy filters by it ([noticeFor]).
|
/// What a notice is about; the policy filters by it ([noticeFor]).
|
||||||
@@ -98,12 +105,15 @@ class NoticeContext {
|
|||||||
final WindowActivity window;
|
final WindowActivity window;
|
||||||
final bool playing;
|
final bool playing;
|
||||||
final NotifyPrefs prefs;
|
final NotifyPrefs prefs;
|
||||||
|
/// The chat panel is open: chat is read there, not in pop-ups.
|
||||||
|
final bool chatOpen;
|
||||||
const NoticeContext({
|
const NoticeContext({
|
||||||
this.self,
|
this.self,
|
||||||
this.alone = false,
|
this.alone = false,
|
||||||
this.window = WindowActivity.focused,
|
this.window = WindowActivity.focused,
|
||||||
this.playing = false,
|
this.playing = false,
|
||||||
this.prefs = const NotifyPrefs(),
|
this.prefs = const NotifyPrefs(),
|
||||||
|
this.chatOpen = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
bool get away => self == Presence.away;
|
bool get away => self == Presence.away;
|
||||||
@@ -132,6 +142,8 @@ class Notice {
|
|||||||
final bool chat;
|
final bool chat;
|
||||||
/// A picture hash for the toast (an Up next item's).
|
/// A picture hash for the toast (an Up next item's).
|
||||||
final String thumb;
|
final String thumb;
|
||||||
|
/// This member is the actor (a feed line of its own).
|
||||||
|
final bool own;
|
||||||
final SfxCue? sound;
|
final SfxCue? sound;
|
||||||
final bool attention;
|
final bool attention;
|
||||||
final SystemNote? system;
|
final SystemNote? system;
|
||||||
@@ -143,6 +155,7 @@ class Notice {
|
|||||||
this.actor,
|
this.actor,
|
||||||
this.chat = false,
|
this.chat = false,
|
||||||
this.thumb = '',
|
this.thumb = '',
|
||||||
|
this.own = false,
|
||||||
this.sound,
|
this.sound,
|
||||||
this.attention = false,
|
this.attention = false,
|
||||||
this.system,
|
this.system,
|
||||||
@@ -151,12 +164,13 @@ class Notice {
|
|||||||
|
|
||||||
static const none = Notice();
|
static const none = Notice();
|
||||||
|
|
||||||
Notice _with({bool toast = true, bool sound = true, SystemNote? system}) => Notice(
|
Notice _with({bool toast = true, bool sound = true, SystemNote? system, bool own = false}) => Notice(
|
||||||
category: category,
|
category: category,
|
||||||
toast: toast ? this.toast : null,
|
toast: toast ? this.toast : null,
|
||||||
actor: toast ? actor : null,
|
actor: toast ? actor : null,
|
||||||
chat: toast && chat,
|
chat: toast && chat,
|
||||||
thumb: toast ? thumb : '',
|
thumb: toast ? thumb : '',
|
||||||
|
own: own,
|
||||||
sound: sound ? this.sound : null,
|
sound: sound ? this.sound : null,
|
||||||
attention: attention,
|
attention: attention,
|
||||||
system: system,
|
system: system,
|
||||||
@@ -187,6 +201,12 @@ Notice _plain(AppEvent e) => switch (e) {
|
|||||||
category: NoticeCategory.people, log: feed, toast: '${feed.actor} lost connection', actor: feed.actor),
|
category: NoticeCategory.people, log: feed, toast: '${feed.actor} lost connection', actor: feed.actor),
|
||||||
MemberReturned(:final feed) =>
|
MemberReturned(:final feed) =>
|
||||||
Notice(category: NoticeCategory.people, log: feed, toast: '${feed.actor} is back', actor: feed.actor),
|
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(
|
MediaSwapped(:final feed) => Notice(
|
||||||
category: NoticeCategory.upNext,
|
category: NoticeCategory.upNext,
|
||||||
log: feed,
|
log: feed,
|
||||||
@@ -311,6 +331,8 @@ Notice noticeFor(AppEvent e, [NoticeContext c = const NoticeContext()]) {
|
|||||||
}
|
}
|
||||||
if (cat == NoticeCategory.start && !c.away && !c.window.background) toast = 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;
|
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;
|
SystemNote? system;
|
||||||
if (toast && !own && c.window.background && cat.system && cat.reachesAway(c.prefs)) {
|
if (toast && !own && c.window.background && cat.system && cat.reachesAway(c.prefs)) {
|
||||||
final actor = n.actor;
|
final actor = n.actor;
|
||||||
@@ -323,7 +345,7 @@ Notice noticeFor(AppEvent e, [NoticeContext c = const NoticeContext()]) {
|
|||||||
payload: cat == NoticeCategory.chat ? SystemNote.payloadChat : '',
|
payload: cat == NoticeCategory.chat ? SystemNote.payloadChat : '',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return n._with(toast: toast, sound: sound, system: system);
|
return n._with(toast: toast, sound: sound, system: system, own: own);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The one way news reaches the user: [notify] turns an [AppEvent] into
|
/// The one way news reaches the user: [notify] turns an [AppEvent] into
|
||||||
@@ -345,6 +367,9 @@ class Notifier {
|
|||||||
/// The window's activity ([ShellPort.watchActivity], wired in `main`).
|
/// The window's activity ([ShellPort.watchActivity], wired in `main`).
|
||||||
WindowActivity window = WindowActivity.focused;
|
WindowActivity window = WindowActivity.focused;
|
||||||
|
|
||||||
|
/// The chat panel is open (set by the shell).
|
||||||
|
bool chatOpen = false;
|
||||||
|
|
||||||
Notifier({
|
Notifier({
|
||||||
this.sfx = const NoopSfxService(),
|
this.sfx = const NoopSfxService(),
|
||||||
Future<void> Function()? attention,
|
Future<void> Function()? attention,
|
||||||
@@ -373,7 +398,8 @@ class Notifier {
|
|||||||
|
|
||||||
NoticeContext get context {
|
NoticeContext get context {
|
||||||
final s = session();
|
final s = session();
|
||||||
return NoticeContext(self: s.self, alone: s.alone, window: window, playing: s.playing, prefs: prefs());
|
return NoticeContext(
|
||||||
|
self: s.self, alone: s.alone, window: window, playing: s.playing, prefs: prefs(), chatOpen: chatOpen);
|
||||||
}
|
}
|
||||||
|
|
||||||
void notify(AppEvent e) {
|
void notify(AppEvent e) {
|
||||||
@@ -395,7 +421,9 @@ class Notifier {
|
|||||||
actor: n.actor,
|
actor: n.actor,
|
||||||
actorId: n.actor == null ? '' : n.log?.actorId ?? '',
|
actorId: n.actor == null ? '' : n.log?.actorId ?? '',
|
||||||
chat: n.chat,
|
chat: n.chat,
|
||||||
thumb: n.thumb),
|
thumb: n.thumb,
|
||||||
|
category: n.category,
|
||||||
|
own: n.own),
|
||||||
];
|
];
|
||||||
_timers[id] = Timer(toastLife, () => dismiss(id));
|
_timers[id] = Timer(toastLife, () => dismiss(id));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ const presencePolicy = PresencePolicy();
|
|||||||
bool countsForReady(MemberView m) => m.error.isEmpty && presencePolicy.countsFor(m.presence, Purpose.readyCounter);
|
bool countsForReady(MemberView m) => m.error.isEmpty && presencePolicy.countsFor(m.presence, Purpose.readyCounter);
|
||||||
|
|
||||||
/// Connected and watching (`in` or `joining`): can do things for the
|
/// Connected and watching (`in` or `joining`): can do things for the
|
||||||
/// party now (put up an Up next item, the auto-advance swap).
|
/// party now (put up an Up next item; core's [isPresentMember]).
|
||||||
bool isPresent(MemberView m) => m.presence == Presence.watching || m.presence == Presence.joining;
|
bool isPresent(MemberView m) => isPresentMember(m);
|
||||||
|
|
||||||
/// Nobody but [selfId] is connected in [members] (D9, core's [isAlone]).
|
/// Nobody but [selfId] is connected in [members] (D9, core's [isAlone]).
|
||||||
bool aloneIn(List<MemberView> members, String selfId) =>
|
bool aloneIn(List<MemberView> members, String selfId) =>
|
||||||
|
|||||||
@@ -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
@@ -0,0 +1,215 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:sync_relay_core/core.dart';
|
||||||
|
import 'package:sync_relay_server/server.dart' show SharedSource;
|
||||||
|
|
||||||
|
final _log = createLogger('session');
|
||||||
|
|
||||||
|
/// A link this member passes on, read from the site once ([SharedSource]):
|
||||||
|
/// its page URL, the reader, and the page's subtitles for the player.
|
||||||
|
class SharedLink {
|
||||||
|
final String url;
|
||||||
|
final SharedSource source;
|
||||||
|
final List<YtDlpSubtitle> subtitles;
|
||||||
|
SharedLink(this.url, this.source, {this.subtitles = const []});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The links this member passes on and reads once, by page URL: the one a
|
||||||
|
/// swap check read (until the swap's state takes it), Up next's warm one
|
||||||
|
/// (until the swap that puts it up takes it), and the current media's.
|
||||||
|
/// Each one's player opens [SharedSource.playerUrl] and its relay serves
|
||||||
|
/// [SharedSource.upload]. A page with a pieces track (HLS/DASH) is not
|
||||||
|
/// shared ([fits]): its player and relay keep their own fetches.
|
||||||
|
/// Page links played here only ([local]) get a reader of their own too,
|
||||||
|
/// for its resume after cuts.
|
||||||
|
class SharedLinks {
|
||||||
|
SharedLink? _current;
|
||||||
|
({String queueId, SharedLink link})? _warm;
|
||||||
|
SharedLink? _pending;
|
||||||
|
Timer? _pendingTimer;
|
||||||
|
|
||||||
|
/// A link that can be shared: a media file, or a page whose relay tracks
|
||||||
|
/// are all plain files (one, or a video and an audio).
|
||||||
|
static bool fits(LinkProbe probe) =>
|
||||||
|
probe.relayProblem == null && (!probe.isPage || (probe.info?.tracks.every((t) => !t.format.isPieces) ?? false));
|
||||||
|
|
||||||
|
static List<YtDlpSubtitle> _subs(LinkProbe? probe) => probe?.info?.play?.subtitles ?? const [];
|
||||||
|
|
||||||
|
/// The swap check of a link this member passes on: a shared source
|
||||||
|
/// for [url] over [media], started (the tracks' sizes read). Kept for
|
||||||
|
/// the swap's state ([current]) for a minute, then dropped.
|
||||||
|
Future<SharedSource> forSwap(String url, RelayMedia media, LinkProbe probe,
|
||||||
|
{Duration timeout = const Duration(seconds: 15)}) async {
|
||||||
|
final s = SharedSource(media);
|
||||||
|
try {
|
||||||
|
await s.start().timeout(timeout);
|
||||||
|
} catch (_) {
|
||||||
|
unawaited(s.close());
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
_dropPending();
|
||||||
|
_pending = SharedLink(url, s, subtitles: _subs(probe));
|
||||||
|
_pendingTimer = Timer(const Duration(seconds: 60), _dropPending);
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _dropPending() {
|
||||||
|
_pendingTimer?.cancel();
|
||||||
|
_pendingTimer = null;
|
||||||
|
final p = _pending;
|
||||||
|
_pending = null;
|
||||||
|
if (p != null && !identical(p, _current)) unawaited(p.source.close());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The current media's shared source for [url]: the one it has, else
|
||||||
|
/// Up next's warm one for [queueId], else the swap check's, else a new
|
||||||
|
/// one over [media] (opened, its sizes read meanwhile). Null when the
|
||||||
|
/// link cannot be shared ([fits], or it does not open): the caller
|
||||||
|
/// keeps today's two fetches. Any other current one is closed.
|
||||||
|
Future<SharedSource?> current(String url, LinkProbe probe, RelayMedia Function() media,
|
||||||
|
{String queueId = ''}) async {
|
||||||
|
final had = _current;
|
||||||
|
if (had != null && had.url == url) return had.source;
|
||||||
|
await closeCurrent();
|
||||||
|
SharedLink? link;
|
||||||
|
final pending = _pending, warm = _warm;
|
||||||
|
if (warm != null && warm.link.url == url && queueId.isNotEmpty && warm.queueId == queueId) {
|
||||||
|
// The swap carries the warm relay's source on: the player reads the
|
||||||
|
// reader that source serves.
|
||||||
|
_warm = null;
|
||||||
|
link = warm.link;
|
||||||
|
if (pending != null && pending.url == url) _dropPending();
|
||||||
|
} else if (pending != null && pending.url == url) {
|
||||||
|
_pendingTimer?.cancel();
|
||||||
|
_pending = null;
|
||||||
|
link = pending;
|
||||||
|
} else if (fits(probe)) {
|
||||||
|
final s = SharedSource(media());
|
||||||
|
try {
|
||||||
|
await s.open();
|
||||||
|
} catch (e) {
|
||||||
|
_log.info('link ${logUrl(url)} not read once here: $e');
|
||||||
|
unawaited(s.close());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
link = SharedLink(url, s, subtitles: _subs(probe));
|
||||||
|
}
|
||||||
|
_current = link;
|
||||||
|
return link?.source;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The current media's shared source when it reads [url].
|
||||||
|
SharedSource? currentFor(String url) => _current?.url == url ? _current!.source : null;
|
||||||
|
|
||||||
|
/// Up next item [queueId]'s shared source for [url] (fed to its warm
|
||||||
|
/// relay), made over [media] and opened; the one it has is kept. Null
|
||||||
|
/// when the link cannot be shared.
|
||||||
|
Future<SharedSource?> warm(String queueId, String url, LinkProbe probe, RelayMedia Function() media) async {
|
||||||
|
final had = _warm;
|
||||||
|
if (had != null && had.queueId == queueId && had.link.url == url) return had.link.source;
|
||||||
|
dropWarm();
|
||||||
|
if (!fits(probe)) return null;
|
||||||
|
final s = SharedSource(media());
|
||||||
|
final link = SharedLink(url, s, subtitles: _subs(probe));
|
||||||
|
_warm = (queueId: queueId, link: link);
|
||||||
|
try {
|
||||||
|
await s.open();
|
||||||
|
} catch (e) {
|
||||||
|
_log.info('Up next ${logUrl(url)} not read once here: $e');
|
||||||
|
if (identical(_warm?.link, link)) _warm = null;
|
||||||
|
unawaited(s.close());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return identical(_warm?.link, link) ? s : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The warm one goes (its relay ended, the item is no longer first);
|
||||||
|
/// with [queueId], only that item's.
|
||||||
|
void dropWarm([String? queueId]) {
|
||||||
|
final w = _warm;
|
||||||
|
if (w == null || (queueId != null && w.queueId != queueId)) return;
|
||||||
|
_warm = null;
|
||||||
|
unawaited(w.link.source.close());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a player opens for [url] when it is shared here (the current
|
||||||
|
/// media's or the warm one), opened: null otherwise.
|
||||||
|
Future<SharedLink?> playerFor(String url) async {
|
||||||
|
final link = _current?.url == url ? _current : (_warm?.link.url == url ? _warm!.link : null);
|
||||||
|
if (link == null) return null;
|
||||||
|
try {
|
||||||
|
await link.source.open();
|
||||||
|
return link;
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [url] is shared here (the current media's or the warm one).
|
||||||
|
bool has(String url) => _current?.url == url || _warm?.link.url == url;
|
||||||
|
|
||||||
|
// Page links this app plays without passing them on (alone, or
|
||||||
|
// everyone fetches it), by page URL: the session player's and the
|
||||||
|
// preload's.
|
||||||
|
final _local = <String, Future<SharedLink?>>{};
|
||||||
|
|
||||||
|
/// A page link played here only: read through its own [SharedSource]
|
||||||
|
/// over [media] too, so the player gets the reader's resume after a cut,
|
||||||
|
/// back-off and fresh address (403/410/412) instead of ffmpeg's
|
||||||
|
/// reconnect, which a CDN like Bilibili cuts off for good. The one [url]
|
||||||
|
/// has is kept unless [fresh]. Null when the page does not fit ([fits]:
|
||||||
|
/// a pieces track) or the reader cannot open it: the player reads the
|
||||||
|
/// site itself. [trimLocal] closes the ones no longer needed.
|
||||||
|
Future<SharedLink?> local(String url, LinkProbe probe, RelayMedia Function() media,
|
||||||
|
{bool fresh = false, Duration timeout = const Duration(seconds: 15)}) {
|
||||||
|
final had = _local[url];
|
||||||
|
if (had != null && !fresh) return had;
|
||||||
|
if (had != null) _local.remove(url)!.then((l) => l?.source.close());
|
||||||
|
if (!fits(probe)) return Future.value();
|
||||||
|
Future<SharedLink?> make() async {
|
||||||
|
final s = SharedSource(media());
|
||||||
|
try {
|
||||||
|
// The sizes too: a format URL the site refuses (403) fails here,
|
||||||
|
// and the player then gets the site's own answer.
|
||||||
|
await s.start().timeout(timeout);
|
||||||
|
return SharedLink(url, s, subtitles: _subs(probe));
|
||||||
|
} catch (e) {
|
||||||
|
_log.info('link ${logUrl(url)} read by the player itself: $e');
|
||||||
|
unawaited(s.close());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final f = _local[url] = make();
|
||||||
|
unawaited(f.then((l) {
|
||||||
|
if (l == null && identical(_local[url], f)) _local.remove(url);
|
||||||
|
}));
|
||||||
|
return f;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [url] is read here through a [local] reader.
|
||||||
|
bool hasLocal(String url) => _local.containsKey(url);
|
||||||
|
|
||||||
|
/// Closes the [local] readers of every URL but [keep] (the player's and
|
||||||
|
/// the preload's).
|
||||||
|
void trimLocal(Set<String?> keep) {
|
||||||
|
for (final url in [..._local.keys]) {
|
||||||
|
if (keep.contains(url)) continue;
|
||||||
|
unawaited(_local.remove(url)!.then((l) => l?.source.close()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> closeCurrent() async {
|
||||||
|
final c = _current;
|
||||||
|
_current = null;
|
||||||
|
if (c != null) await c.source.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Leave, a new session: everything goes.
|
||||||
|
Future<void> closeAll() async {
|
||||||
|
_dropPending();
|
||||||
|
dropWarm();
|
||||||
|
trimLocal(const {});
|
||||||
|
await closeCurrent();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -210,6 +210,20 @@ StatusLine? _line(StatusFacts f, StateBroadcast? r, MemberView? self, SourceTrou
|
|||||||
return StatusLine(StatusKind.problem, "Can't play this: ${f.playerError}",
|
return StatusLine(StatusKind.problem, "Can't play this: ${f.playerError}",
|
||||||
level: StatusLevel.error, action: StatusAction.reload, short: "Can't play this");
|
level: StatusLevel.error, action: StatusAction.reload, short: "Can't play this");
|
||||||
}
|
}
|
||||||
|
// The server holds everyone for the relay's source (D6): a viewer's
|
||||||
|
// buffer ran dry while it is gone.
|
||||||
|
if (r.transport == 'held' && r.hold?.reason == HoldReason.source) {
|
||||||
|
final relayed = stateMediaRelay(r);
|
||||||
|
final id = relayed?.source ?? '';
|
||||||
|
final name = source?.name ??
|
||||||
|
r.members.where((m) => m.id == id).firstOrNull?.name ??
|
||||||
|
f.names?[id] ??
|
||||||
|
'the member passing it on';
|
||||||
|
return StatusLine(StatusKind.waiting, 'Waiting for $name',
|
||||||
|
detail: "$name's connection dropped; what the server had has played",
|
||||||
|
level: StatusLevel.warn,
|
||||||
|
short: 'Waiting for $name');
|
||||||
|
}
|
||||||
// The relay: what everyone reads comes through the server.
|
// The relay: what everyone reads comes through the server.
|
||||||
final relay = f.ownPlan ? null : stateMediaRelay(r);
|
final relay = f.ownPlan ? null : stateMediaRelay(r);
|
||||||
if (relay != null && relay.source != f.selfId) {
|
if (relay != null && relay.source != f.selfId) {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import '../jellyfin/session_media.dart' show relayCredentialsFor;
|
|||||||
import '../state/connect_prefs.dart';
|
import '../state/connect_prefs.dart';
|
||||||
import '../state/server_isolate.dart';
|
import '../state/server_isolate.dart';
|
||||||
import 'invite.dart';
|
import 'invite.dart';
|
||||||
|
import 'public_address.dart';
|
||||||
|
|
||||||
/// A fresh key per app run: this app's own client sends it in hello to be
|
/// A fresh key per app run: this app's own client sends it in hello to be
|
||||||
/// recognised as the host (`members[].host`; it also stands in for the
|
/// recognised as the host (`members[].host`; it also stands in for the
|
||||||
@@ -44,8 +45,9 @@ Future<ServerIsolate> _startIsolate(
|
|||||||
/// port, so only this app reaches it; [invite] attaches a second
|
/// port, so only this app reaches it; [invite] attaches a second
|
||||||
/// listener on every interface at the configured port (Settings → Party
|
/// listener on every interface at the configured port (Settings → Party
|
||||||
/// & network: port and password) and [stopSharing] detaches it. Members,
|
/// & network: port and password) and [stopSharing] detaches it. Members,
|
||||||
/// what plays and Up next never restart or reconnect for either. Made
|
/// what plays and Up next never restart or reconnect for either. Sharing
|
||||||
/// by the composition root.
|
/// also looks up the internet address ([reach]). Made by the composition
|
||||||
|
/// root.
|
||||||
class YourServer {
|
class YourServer {
|
||||||
YourServer({
|
YourServer({
|
||||||
required this.forms,
|
required this.forms,
|
||||||
@@ -54,10 +56,13 @@ class YourServer {
|
|||||||
String? Function()? logDir,
|
String? Function()? logDir,
|
||||||
ServerStarter? starter,
|
ServerStarter? starter,
|
||||||
Future<List<({String name, InternetAddress address})>> Function()? interfaces,
|
Future<List<({String name, InternetAddress address})>> Function()? interfaces,
|
||||||
|
Future<String?> Function()? publicAddress,
|
||||||
|
this.publicWait = const Duration(seconds: 4),
|
||||||
}) : _ytDlpDir = ytDlpDir ?? (() async => ''),
|
}) : _ytDlpDir = ytDlpDir ?? (() async => ''),
|
||||||
_logDir = logDir ?? (() => null),
|
_logDir = logDir ?? (() => null),
|
||||||
_starter = starter ?? _startIsolate,
|
_starter = starter ?? _startIsolate,
|
||||||
_interfaces = interfaces ?? machineInterfaces;
|
_interfaces = interfaces ?? machineInterfaces,
|
||||||
|
_publicAddress = publicAddress ?? lookUpPublicAddress;
|
||||||
|
|
||||||
final ConnectStore forms;
|
final ConnectStore forms;
|
||||||
/// Linked Jellyfin servers (default: the app's): the relay logins.
|
/// Linked Jellyfin servers (default: the app's): the relay logins.
|
||||||
@@ -66,6 +71,18 @@ class YourServer {
|
|||||||
final String? Function() _logDir;
|
final String? Function() _logDir;
|
||||||
final ServerStarter _starter;
|
final ServerStarter _starter;
|
||||||
final Future<List<({String name, InternetAddress address})>> Function() _interfaces;
|
final Future<List<({String name, InternetAddress address})>> Function() _interfaces;
|
||||||
|
final Future<String?> Function() _publicAddress;
|
||||||
|
|
||||||
|
/// How long [invite] waits for the internet address before it hands
|
||||||
|
/// out a local one (the lookup goes on; [reach] and [shared] follow).
|
||||||
|
final Duration publicWait;
|
||||||
|
|
||||||
|
/// Where friends can reach it while shared: the internet address (once
|
||||||
|
/// looked up) and this machine's; null when not shared.
|
||||||
|
final reach = signal<InviteReach?>(null);
|
||||||
|
|
||||||
|
/// What `/health` calls it ("Bob's server"), the invite's name.
|
||||||
|
String _name = '';
|
||||||
|
|
||||||
/// Sent by this app's own client (see [newHostKey]).
|
/// Sent by this app's own client (see [newHostKey]).
|
||||||
final String hostKey = newHostKey();
|
final String hostKey = newHostKey();
|
||||||
@@ -81,6 +98,13 @@ class YourServer {
|
|||||||
/// An [invite] or [stopSharing] is under way.
|
/// An [invite] or [stopSharing] is under way.
|
||||||
final busy = signal(false);
|
final busy = signal(false);
|
||||||
|
|
||||||
|
/// The password the server was shared with (empty: none).
|
||||||
|
String _secret = '';
|
||||||
|
|
||||||
|
/// What Invite copies: [invite]'s bare `starsync://` link with the
|
||||||
|
/// password the server was shared with, so a friend just clicks it.
|
||||||
|
String inviteLink(Invite invite) => invite.link(secret: _secret).uri;
|
||||||
|
|
||||||
/// Set when the server died without being stopped; the shell says so
|
/// Set when the server died without being stopped; the shell says so
|
||||||
/// and clears it. The next [start] makes a new one.
|
/// and clears it. The next [start] makes a new one.
|
||||||
final crash = signal<String?>(null);
|
final crash = signal<String?>(null);
|
||||||
@@ -106,12 +130,13 @@ class YourServer {
|
|||||||
|
|
||||||
Future<int> _start(String name) async {
|
Future<int> _start(String name) async {
|
||||||
final who = name.trim();
|
final who = name.trim();
|
||||||
|
_name = who.isEmpty ? '' : "$who's server";
|
||||||
late final ServerIsolate server;
|
late final ServerIsolate server;
|
||||||
server = await _starter(
|
server = await _starter(
|
||||||
AppConfig(
|
AppConfig(
|
||||||
port: 0,
|
port: 0,
|
||||||
secret: '',
|
secret: '',
|
||||||
name: who.isEmpty ? 'server' : "$who's server",
|
name: who.isEmpty ? 'server' : _name,
|
||||||
hostKey: hostKey,
|
hostKey: hostKey,
|
||||||
ytDlpDir: await _ytDlpDir(),
|
ytDlpDir: await _ytDlpDir(),
|
||||||
),
|
),
|
||||||
@@ -133,6 +158,7 @@ class YourServer {
|
|||||||
_server = null;
|
_server = null;
|
||||||
port.value = null;
|
port.value = null;
|
||||||
shared.value = null;
|
shared.value = null;
|
||||||
|
reach.value = null;
|
||||||
final message = 'server crashed: $cause';
|
final message = 'server crashed: $cause';
|
||||||
debugPrint('[server] $message');
|
debugPrint('[server] $message');
|
||||||
crash.value = message;
|
crash.value = message;
|
||||||
@@ -152,33 +178,77 @@ class YourServer {
|
|||||||
final form = forms.form;
|
final form = forms.form;
|
||||||
final want = int.tryParse(form.hostPort.trim()) ?? 8080;
|
final want = int.tryParse(form.hostPort.trim()) ?? 8080;
|
||||||
await _accounts.ensureLoaded(validate: false);
|
await _accounts.ensureLoaded(validate: false);
|
||||||
|
final secret = form.hostSecret;
|
||||||
final bound = await server.share(
|
final bound = await server.share(
|
||||||
port: want,
|
port: want,
|
||||||
secret: form.hostSecret,
|
secret: secret,
|
||||||
// Guests without an account watch Jellyfin items through the
|
// Guests without an account watch Jellyfin items through the
|
||||||
// server, with this app's own sign-in on the item's server.
|
// server, with this app's own sign-in on the item's server.
|
||||||
jellyfin: relayCredentialsFor(_accounts),
|
jellyfin: relayCredentialsFor(_accounts),
|
||||||
);
|
);
|
||||||
final invite = await inviteFor(bound, password: form.hostSecret.isNotEmpty);
|
_secret = secret;
|
||||||
|
final local = inviteAddresses(await _interfaces());
|
||||||
|
final gen = ++_shareGen;
|
||||||
|
reach.value = InviteReach(local: local, looking: true);
|
||||||
|
final lookup = _lookUp(local, gen);
|
||||||
|
// The internet address when it comes quickly; else a local one now
|
||||||
|
// and the internet one when it lands.
|
||||||
|
final public = await lookup.timeout(publicWait, onTimeout: () => null);
|
||||||
|
final invite = inviteFor(bound, password: secret.isNotEmpty, host: reach.peek()?.best);
|
||||||
shared.value = invite;
|
shared.value = invite;
|
||||||
|
if (public == null) {
|
||||||
|
unawaited(lookup.then((ip) {
|
||||||
|
final now = shared.peek();
|
||||||
|
if (ip != null && gen == _shareGen && identical(now, invite)) shared.value = now!.at(ip);
|
||||||
|
}));
|
||||||
|
}
|
||||||
return invite;
|
return invite;
|
||||||
} finally {
|
} finally {
|
||||||
busy.value = false;
|
busy.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The invite for [port] from this machine's best address (see
|
/// Bumped by every share and unshare: a lookup of an earlier one is
|
||||||
/// [inviteAddresses]); 127.0.0.1 when there is none.
|
/// dropped.
|
||||||
Future<Invite> inviteFor(int port, {required bool password}) async {
|
int _shareGen = 0;
|
||||||
final addresses = inviteAddresses(await _interfaces());
|
|
||||||
|
/// The internet address into [reach] (its own failure is no address).
|
||||||
|
Future<String?> _lookUp(List<String> local, int gen) async {
|
||||||
|
String? ip;
|
||||||
|
try {
|
||||||
|
ip = await _publicAddress();
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('public address lookup failed: $e');
|
||||||
|
}
|
||||||
|
if (gen != _shareGen || reach.peek() == null) return null;
|
||||||
|
reach.value = InviteReach(public: ip, local: local);
|
||||||
|
return ip;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The invite for [port] at [host] (else this machine's best address,
|
||||||
|
/// see [inviteAddresses]; 127.0.0.1 when there is none).
|
||||||
|
Invite inviteFor(int port, {required bool password, String? host}) {
|
||||||
|
final local = reach.peek()?.local ?? const [];
|
||||||
return Invite(
|
return Invite(
|
||||||
host: addresses.isEmpty ? '127.0.0.1' : addresses.first,
|
host: host ?? (local.isEmpty ? '127.0.0.1' : local.first),
|
||||||
port: port,
|
port: port,
|
||||||
|
name: _name,
|
||||||
password: password,
|
password: password,
|
||||||
version: appVersion,
|
version: appVersion,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// [link] points at this server (one of its invite addresses, or this
|
||||||
|
/// machine, at its shared or loopback port).
|
||||||
|
bool isOwn(InviteLink link) {
|
||||||
|
final host = link.host.toLowerCase();
|
||||||
|
final local = host == 'localhost' || host == '127.0.0.1' || host == '::1';
|
||||||
|
if (local && link.port == port.peek()) return true;
|
||||||
|
final s = shared.peek();
|
||||||
|
if (s == null || link.port != s.port) return false;
|
||||||
|
return local || host == s.host || (reach.peek()?.choices.any((c) => c.host == host) ?? false);
|
||||||
|
}
|
||||||
|
|
||||||
/// Closes it to friends again: everyone but you leaves (told, so their
|
/// Closes it to friends again: everyone but you leaves (told, so their
|
||||||
/// apps do not retry); you, what plays and Up next stay.
|
/// apps do not retry); you, what plays and Up next stay.
|
||||||
Future<void> stopSharing() async {
|
Future<void> stopSharing() async {
|
||||||
@@ -187,7 +257,9 @@ class YourServer {
|
|||||||
busy.value = true;
|
busy.value = true;
|
||||||
try {
|
try {
|
||||||
await server.unshare();
|
await server.unshare();
|
||||||
|
_shareGen++;
|
||||||
shared.value = null;
|
shared.value = null;
|
||||||
|
reach.value = null;
|
||||||
} finally {
|
} finally {
|
||||||
busy.value = false;
|
busy.value = false;
|
||||||
}
|
}
|
||||||
@@ -202,6 +274,7 @@ class YourServer {
|
|||||||
_server = null;
|
_server = null;
|
||||||
port.value = null;
|
port.value = null;
|
||||||
shared.value = null;
|
shared.value = null;
|
||||||
|
reach.value = null;
|
||||||
await server?.stop();
|
await server?.stop();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'dart:convert';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
@@ -7,8 +8,13 @@ import 'shader_presets.dart';
|
|||||||
|
|
||||||
const anime4kAssetDir = 'assets/shaders/anime4k';
|
const anime4kAssetDir = 'assets/shaders/anime4k';
|
||||||
|
|
||||||
|
/// The extracted copy's layout: bumped when [highpAnime4k] changes what
|
||||||
|
/// is written, so an older copy is written again.
|
||||||
|
const anime4kCopyRevision = 'hp1';
|
||||||
|
|
||||||
/// mpv needs real files: copies the shipped Anime4K shaders out of the
|
/// mpv needs real files: copies the shipped Anime4K shaders out of the
|
||||||
/// asset bundle into `<supportDir>/shaders/anime4k-<version>`, once per
|
/// asset bundle into `<supportDir>/shaders/anime4k-<version>-<revision>`,
|
||||||
|
/// each made full precision on OpenGL ES ([highpAnime4k]), once per
|
||||||
/// version (a `.complete` marker is written last, so a cut-short copy is
|
/// version (a `.complete` marker is written last, so a cut-short copy is
|
||||||
/// redone). Returns that directory.
|
/// redone). Returns that directory.
|
||||||
Future<Directory> extractAnime4k(
|
Future<Directory> extractAnime4k(
|
||||||
@@ -17,7 +23,7 @@ Future<Directory> extractAnime4k(
|
|||||||
}) async {
|
}) async {
|
||||||
final dir = Directory(
|
final dir = Directory(
|
||||||
'${supportDir.path}${Platform.pathSeparator}shaders'
|
'${supportDir.path}${Platform.pathSeparator}shaders'
|
||||||
'${Platform.pathSeparator}anime4k-$anime4kVersion');
|
'${Platform.pathSeparator}anime4k-$anime4kVersion-$anime4kCopyRevision');
|
||||||
final marker = File('${dir.path}${Platform.pathSeparator}.complete');
|
final marker = File('${dir.path}${Platform.pathSeparator}.complete');
|
||||||
final files = anime4kAllFiles;
|
final files = anime4kAllFiles;
|
||||||
if (await marker.exists() &&
|
if (await marker.exists() &&
|
||||||
@@ -28,14 +34,68 @@ Future<Directory> extractAnime4k(
|
|||||||
final b = bundle ?? rootBundle;
|
final b = bundle ?? rootBundle;
|
||||||
for (final name in [...files, 'LICENSE']) {
|
for (final name in [...files, 'LICENSE']) {
|
||||||
final data = await b.load('$anime4kAssetDir/$name');
|
final data = await b.load('$anime4kAssetDir/$name');
|
||||||
|
final bytes = data.buffer.asUint8List(data.offsetInBytes, data.lengthInBytes);
|
||||||
await File(_join(dir, name)).writeAsBytes(
|
await File(_join(dir, name)).writeAsBytes(
|
||||||
data.buffer.asUint8List(data.offsetInBytes, data.lengthInBytes),
|
name.endsWith('.glsl') ? utf8.encode(highpAnime4k(utf8.decode(bytes))) : bytes,
|
||||||
flush: true);
|
flush: true);
|
||||||
}
|
}
|
||||||
await marker.writeAsString(anime4kVersion, flush: true);
|
await marker.writeAsString(anime4kVersion, flush: true);
|
||||||
|
// Older copies (another version or revision) are not read again.
|
||||||
|
try {
|
||||||
|
await for (final e in dir.parent.list()) {
|
||||||
|
final name = e.uri.pathSegments.where((s) => s.isNotEmpty).last;
|
||||||
|
if (e is Directory && name.startsWith('anime4k-') && e.path != dir.path) {
|
||||||
|
await e.delete(recursive: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
return dir;
|
return dir;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The name of the helper [highpAnime4k] adds to each pass.
|
||||||
|
const _hp = 'starsync_hp';
|
||||||
|
|
||||||
|
/// [glsl] (an Anime4K file) with its convolutions computed at full
|
||||||
|
/// precision on an OpenGL ES context, as they are on desktop OpenGL.
|
||||||
|
///
|
||||||
|
/// media_kit renders through OpenGL ES (EGL on Linux, ANGLE on Windows),
|
||||||
|
/// where mpv declares `precision mediump sampler2D`: a texture read is
|
||||||
|
/// mediump, and Anime4K's weights are bare literals, so every
|
||||||
|
/// `mat4(...) * go_0(...)` of a network ran at half precision (fp16 on
|
||||||
|
/// NVIDIA). The large (VL) networks overflow and round there: shifted
|
||||||
|
/// colours, posterised areas and a regular grid, worse once the
|
||||||
|
/// upscalers run at screen size. Each pass gets an identity function
|
||||||
|
/// with a highp parameter and result, and every `go_N` / `g_N` read goes
|
||||||
|
/// through it, so the products and sums that follow are highp. Desktop
|
||||||
|
/// GLSL ignores precision qualifiers, so nothing changes there. Anything
|
||||||
|
/// that is not such a read is left as it is.
|
||||||
|
String highpAnime4k(String glsl) {
|
||||||
|
final read = RegExp(r'\b\w+_tex(?:Off)?\((?:[^()]|\([^()]*\))*\)');
|
||||||
|
final define = RegExp(r'^#define g(?:o)?_\d+\b');
|
||||||
|
final out = <String>[];
|
||||||
|
var inHeader = false, helper = false;
|
||||||
|
for (final line in const LineSplitter().convert(glsl)) {
|
||||||
|
if (line.startsWith('//!')) {
|
||||||
|
// A new pass starts at the first `//!` line after code.
|
||||||
|
if (!inHeader) helper = false;
|
||||||
|
inHeader = true;
|
||||||
|
out.add(line);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
inHeader = false;
|
||||||
|
if (define.hasMatch(line)) {
|
||||||
|
if (!helper) {
|
||||||
|
out.add('highp vec4 $_hp(highp vec4 v) { return v; }');
|
||||||
|
helper = true;
|
||||||
|
}
|
||||||
|
out.add(line.replaceAllMapped(read, (m) => '$_hp(${m[0]})'));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
out.add(line);
|
||||||
|
}
|
||||||
|
return '${out.join('\n')}\n';
|
||||||
|
}
|
||||||
|
|
||||||
/// Absolute paths of [preset]'s Anime4K files inside [dir].
|
/// Absolute paths of [preset]'s Anime4K files inside [dir].
|
||||||
List<String> anime4kPaths(
|
List<String> anime4kPaths(
|
||||||
Directory dir, ShaderPreset preset, ShaderQuality quality) =>
|
Directory dir, ShaderPreset preset, ShaderQuality quality) =>
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import 'package:signals/signals_flutter.dart';
|
|||||||
|
|
||||||
import '../controls/frame.dart' show DoubleClickListener;
|
import '../controls/frame.dart' show DoubleClickListener;
|
||||||
import '../controls/session_controls.dart' show controlsBottomHeight;
|
import '../controls/session_controls.dart' show controlsBottomHeight;
|
||||||
|
import '../danmaku/danmaku_notices.dart' show fliesAsDanmaku;
|
||||||
|
import '../danmaku/danmaku_preview.dart';
|
||||||
import '../files/folder_store.dart';
|
import '../files/folder_store.dart';
|
||||||
import '../files/ui/files_page.dart' show showFilesFolder;
|
import '../files/ui/files_page.dart' show showFilesFolder;
|
||||||
import '../jellyfin/accounts.dart';
|
import '../jellyfin/accounts.dart';
|
||||||
@@ -17,14 +19,18 @@ import '../session/app_events.dart' show ActionFailed, ScheduleResult;
|
|||||||
import '../screens/home/health_probe.dart';
|
import '../screens/home/health_probe.dart';
|
||||||
import '../screens/home/media_picker.dart';
|
import '../screens/home/media_picker.dart';
|
||||||
import '../session/identity.dart';
|
import '../session/identity.dart';
|
||||||
|
import '../screens/home/ui.dart' show appName;
|
||||||
import '../screens/home/update_indicator.dart';
|
import '../screens/home/update_indicator.dart';
|
||||||
import '../app/app_graph.dart';
|
import '../app/app_graph.dart';
|
||||||
import '../session/navigation.dart' show Place;
|
import '../session/navigation.dart' show Place;
|
||||||
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 'chat_panel.dart';
|
import 'chat_panel.dart';
|
||||||
import 'connect_cards.dart';
|
import 'connect_cards.dart';
|
||||||
import 'handoff.dart';
|
import 'handoff.dart';
|
||||||
|
import 'join_offer_card.dart';
|
||||||
import 'library_link.dart' show LinkProber;
|
import 'library_link.dart' show LinkProber;
|
||||||
import 'library_tab.dart';
|
import 'library_tab.dart';
|
||||||
import 'motion.dart';
|
import 'motion.dart';
|
||||||
@@ -33,6 +39,7 @@ import 'profile_card.dart';
|
|||||||
import '../schedule/ui/schedule_page.dart';
|
import '../schedule/ui/schedule_page.dart';
|
||||||
import 'session_player.dart';
|
import 'session_player.dart';
|
||||||
import 'settings_tab.dart';
|
import 'settings_tab.dart';
|
||||||
|
import 'quick_chat.dart';
|
||||||
import 'toasts.dart';
|
import 'toasts.dart';
|
||||||
import 'welcome.dart';
|
import 'welcome.dart';
|
||||||
|
|
||||||
@@ -52,6 +59,9 @@ import 'welcome.dart';
|
|||||||
class AppShell extends StatefulWidget {
|
class AppShell extends StatefulWidget {
|
||||||
final UpdateController? updates;
|
final UpdateController? updates;
|
||||||
final bool autoUpdateCheck;
|
final bool autoUpdateCheck;
|
||||||
|
/// The card once after an update; defaults to the graph's [WhatsNew] when
|
||||||
|
/// [autoUpdateCheck] is on (off in tests unless given).
|
||||||
|
final WhatsNew? whatsNew;
|
||||||
/// Off in tests: the drag area, window buttons and fullscreen need
|
/// Off in tests: the drag area, window buttons and fullscreen need
|
||||||
/// window_manager.
|
/// window_manager.
|
||||||
final bool windowControls;
|
final bool windowControls;
|
||||||
@@ -67,6 +77,7 @@ class AppShell extends StatefulWidget {
|
|||||||
super.key,
|
super.key,
|
||||||
this.updates,
|
this.updates,
|
||||||
this.autoUpdateCheck = true,
|
this.autoUpdateCheck = true,
|
||||||
|
this.whatsNew,
|
||||||
this.windowControls = true,
|
this.windowControls = true,
|
||||||
this.healthGet = defaultHealthGet,
|
this.healthGet = defaultHealthGet,
|
||||||
this.jellyfin,
|
this.jellyfin,
|
||||||
@@ -103,6 +114,11 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
|||||||
/// Whether the full player's controls are up (the player's frame
|
/// Whether the full player's controls are up (the player's frame
|
||||||
/// reports it): the toasts sit above them.
|
/// reports it): the toasts sit above them.
|
||||||
final _controlsShown = ValueNotifier(true);
|
final _controlsShown = ValueNotifier(true);
|
||||||
|
/// The player's quick chat box (Enter); its draft is the chat panel's.
|
||||||
|
late final _quickChat = QuickChat(draft: _chat);
|
||||||
|
/// The player flies notifications as danmaku: those stay out of the
|
||||||
|
/// toasts.
|
||||||
|
final _danmakuActive = ValueNotifier(false);
|
||||||
|
|
||||||
Alignment _corner = Alignment.bottomRight;
|
Alignment _corner = Alignment.bottomRight;
|
||||||
Offset? _drag; // mini top-left while dragging
|
Offset? _drag; // mini top-left while dragging
|
||||||
@@ -158,6 +174,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
|||||||
// Reads linked servers and checks their tokens in the background.
|
// Reads linked servers and checks their tokens in the background.
|
||||||
_jellyfin.ensureLoaded();
|
_jellyfin.ensureLoaded();
|
||||||
if (widget.autoUpdateCheck) _autoCheck();
|
if (widget.autoUpdateCheck) _autoCheck();
|
||||||
|
if (widget.whatsNew != null || widget.autoUpdateCheck) unawaited(_startWhatsNew());
|
||||||
HardwareKeyboard.instance.addHandler(_onKey);
|
HardwareKeyboard.instance.addHandler(_onKey);
|
||||||
final nav = _graph.navigation;
|
final nav = _graph.navigation;
|
||||||
final coord = _graph.coordinator;
|
final coord = _graph.coordinator;
|
||||||
@@ -240,7 +257,9 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
|||||||
for (final stop in _stops) {
|
for (final stop in _stops) {
|
||||||
stop();
|
stop();
|
||||||
}
|
}
|
||||||
|
_danmaku?.dispose();
|
||||||
HardwareKeyboard.instance.removeHandler(_onKey);
|
HardwareKeyboard.instance.removeHandler(_onKey);
|
||||||
|
_whatsNewSource?.due.removeListener(_maybeWhatsNew);
|
||||||
_updateTimer?.cancel();
|
_updateTimer?.cancel();
|
||||||
_fade.dispose();
|
_fade.dispose();
|
||||||
_online.dispose();
|
_online.dispose();
|
||||||
@@ -250,6 +269,8 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
|||||||
_flow.dispose();
|
_flow.dispose();
|
||||||
_join.dispose();
|
_join.dispose();
|
||||||
_picker.dispose();
|
_picker.dispose();
|
||||||
|
_quickChat.dispose();
|
||||||
|
_danmakuActive.dispose();
|
||||||
_chat.dispose();
|
_chat.dispose();
|
||||||
_chatFocus.dispose();
|
_chatFocus.dispose();
|
||||||
_controlsShown.dispose();
|
_controlsShown.dispose();
|
||||||
@@ -276,6 +297,36 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
|||||||
if (s.autoCheckUpdates.peek()) await runUpdateCheck(widget.updates);
|
if (s.autoCheckUpdates.peek()) await runUpdateCheck(widget.updates);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
WhatsNew? _whatsNewSource;
|
||||||
|
bool _whatsNewOpen = false;
|
||||||
|
|
||||||
|
/// What's new once after an update: due now (notes kept by the install)
|
||||||
|
/// or once a check reads this version's manifest.
|
||||||
|
Future<void> _startWhatsNew() async {
|
||||||
|
await _graph.coordinator.settings.ensureLoaded();
|
||||||
|
if (!mounted) return;
|
||||||
|
final w = _whatsNewSource = widget.whatsNew ?? _graph.whatsNew;
|
||||||
|
w.due.addListener(_maybeWhatsNew);
|
||||||
|
// Not over the full player while a video is on: once it ends or minimises.
|
||||||
|
_stops.add(effect(() {
|
||||||
|
_watching.value;
|
||||||
|
untracked(() => _later(_maybeWhatsNew));
|
||||||
|
}));
|
||||||
|
w.start(widget.updates ?? _graph.updates);
|
||||||
|
_maybeWhatsNew();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _maybeWhatsNew() {
|
||||||
|
final w = _whatsNewSource;
|
||||||
|
final notes = w?.due.value;
|
||||||
|
if (w == null || notes == null || !mounted || _whatsNewOpen) return;
|
||||||
|
if (_shown == PlayerMode.full && _watching.peek()) return;
|
||||||
|
_whatsNewOpen = true;
|
||||||
|
w.shown();
|
||||||
|
unawaited(showWhatsNew(context, notes, overline: '$appName · updated')
|
||||||
|
.whenComplete(() => _whatsNewOpen = false));
|
||||||
|
}
|
||||||
|
|
||||||
void _wentOnline() {
|
void _wentOnline() {
|
||||||
_unread = 0;
|
_unread = 0;
|
||||||
_feedSeen = _graph.coordinator.feedLog.peek().length;
|
_feedSeen = _graph.coordinator.feedLog.peek().length;
|
||||||
@@ -287,6 +338,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
|||||||
if (_fullscreen) _toggleFullscreen();
|
if (_fullscreen) _toggleFullscreen();
|
||||||
setState(() {
|
setState(() {
|
||||||
_chatOpen = false;
|
_chatOpen = false;
|
||||||
|
_graph.notifier.chatOpen = false;
|
||||||
_unread = 0;
|
_unread = 0;
|
||||||
});
|
});
|
||||||
_show(PlayerMode.none);
|
_show(PlayerMode.none);
|
||||||
@@ -312,11 +364,28 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
|||||||
}
|
}
|
||||||
if (!mounted || seq != _modeSeq) return;
|
if (!mounted || seq != _modeSeq) return;
|
||||||
setState(() => _shown = m);
|
setState(() => _shown = m);
|
||||||
|
if (m != PlayerMode.full) _later(_maybeWhatsNew);
|
||||||
await _fade.animateTo(1, duration: fadeThroughTime * (1 - fadeOutPart), curve: ease);
|
await _fade.animateTo(1, duration: fadeThroughTime * (1 - fadeOutPart), curve: ease);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _expand() => _show(PlayerMode.full);
|
void _expand() => _show(PlayerMode.full);
|
||||||
|
|
||||||
|
/// Dev builds: the danmaku preview, made on first use.
|
||||||
|
DanmakuPreview? _danmaku;
|
||||||
|
|
||||||
|
/// Over the full player when a player is up, else on its own page with
|
||||||
|
/// a stand-in picture.
|
||||||
|
void _openDanmakuPreview() {
|
||||||
|
final d = _danmaku ??= DanmakuPreview(style: _graph.coordinator.settings.danmakuStyle.peek());
|
||||||
|
if (_online.peek() && _graph.coordinator.playback.alive.peek()) {
|
||||||
|
setState(() => d.active.value = true);
|
||||||
|
_show(PlayerMode.full);
|
||||||
|
} else {
|
||||||
|
d.active.value = false;
|
||||||
|
showDanmakuPreviewPage(context, d);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void _collapse() {
|
void _collapse() {
|
||||||
if (_fullscreen) _toggleFullscreen();
|
if (_fullscreen) _toggleFullscreen();
|
||||||
_show(PlayerMode.mini);
|
_show(PlayerMode.mini);
|
||||||
@@ -362,6 +431,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
|||||||
|
|
||||||
void _toggleChat() => setState(() {
|
void _toggleChat() => setState(() {
|
||||||
_chatOpen = !_chatOpen;
|
_chatOpen = !_chatOpen;
|
||||||
|
_graph.notifier.chatOpen = _chatOpen;
|
||||||
_unread = 0;
|
_unread = 0;
|
||||||
if (!_chatOpen) _chatFocus.unfocus();
|
if (!_chatOpen) _chatFocus.unfocus();
|
||||||
});
|
});
|
||||||
@@ -602,6 +672,22 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
|||||||
|
|
||||||
void _invite() => unawaited(inviteFriends(context));
|
void _invite() => unawaited(inviteFriends(context));
|
||||||
|
|
||||||
|
/// The join prompt's Join: the invite into the Join card's fields, then
|
||||||
|
/// the join (version gate and all); a failure opens the card with it.
|
||||||
|
Future<void> _acceptOffer() async {
|
||||||
|
final link = _graph.joinOffers.accept();
|
||||||
|
if (link == null) return;
|
||||||
|
final port = '${link.port}';
|
||||||
|
_join.host.text = link.host;
|
||||||
|
_join.port.text = port;
|
||||||
|
_join.secret.text = link.secret;
|
||||||
|
await _flow.submit((f) => f
|
||||||
|
..host = link.host
|
||||||
|
..port = port
|
||||||
|
..secret = link.secret);
|
||||||
|
if (mounted && _flow.error != null) _joinFriend();
|
||||||
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------------------- build
|
// ----------------------------------------------------------------- build
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -723,6 +809,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
|||||||
onEditProfile: () => showProfileCard(context, onOpenSettings: _openSettings, updates: widget.updates),
|
onEditProfile: () => showProfileCard(context, onOpenSettings: _openSettings, updates: widget.updates),
|
||||||
updates: widget.updates,
|
updates: widget.updates,
|
||||||
jellyfin: _jellyfin,
|
jellyfin: _jellyfin,
|
||||||
|
onDanmakuPreview: _openDanmakuPreview,
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
final full = _shown == PlayerMode.full;
|
final full = _shown == PlayerMode.full;
|
||||||
@@ -757,6 +844,12 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
|||||||
),
|
),
|
||||||
if (online && _shown != PlayerMode.none) _playerLayer(area),
|
if (online && _shown != PlayerMode.none) _playerLayer(area),
|
||||||
_toasts(full && online),
|
_toasts(full && online),
|
||||||
|
Positioned(
|
||||||
|
top: 12,
|
||||||
|
left: 16,
|
||||||
|
right: 16,
|
||||||
|
child: Center(child: JoinOfferCard(offers: _graph.joinOffers, onJoin: _acceptOffer)),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -771,6 +864,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
|||||||
onOpenChat: () {
|
onOpenChat: () {
|
||||||
if (!_chatOpen) _toggleChat();
|
if (!_chatOpen) _toggleChat();
|
||||||
},
|
},
|
||||||
|
skip: (t) => (_danmakuActive.value && fliesAsDanmaku(t)) || _quickChat.echoed(t),
|
||||||
);
|
);
|
||||||
return ValueListenableBuilder<bool>(
|
return ValueListenableBuilder<bool>(
|
||||||
valueListenable: _controlsShown,
|
valueListenable: _controlsShown,
|
||||||
@@ -866,6 +960,9 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
|||||||
videoBuilder: widget.videoBuilder,
|
videoBuilder: widget.videoBuilder,
|
||||||
windowControls: widget.windowControls,
|
windowControls: widget.windowControls,
|
||||||
controlsShown: _controlsShown,
|
controlsShown: _controlsShown,
|
||||||
|
quickChat: _quickChat,
|
||||||
|
danmakuActive: _danmakuActive,
|
||||||
|
danmaku: _danmaku,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -278,7 +278,7 @@ Future<void> _messageMenu(BuildContext context, String text, String? link) async
|
|||||||
case 'seek':
|
case 'seek':
|
||||||
return (Icons.fast_forward, scheme.primary, 'seeked');
|
return (Icons.fast_forward, scheme.primary, 'seeked');
|
||||||
case 'swap':
|
case 'swap':
|
||||||
return (Icons.swap_horiz, scheme.primary, 'changed the media');
|
return (Icons.swap_horiz, scheme.primary, f.reason == 'restore' ? 'put back what was playing' : 'changed the media');
|
||||||
case 'gone':
|
case 'gone':
|
||||||
return (Icons.cloud_off, Colors.orange, 'lost signal');
|
return (Icons.cloud_off, Colors.orange, 'lost signal');
|
||||||
case 'return':
|
case 'return':
|
||||||
@@ -296,6 +296,9 @@ Future<void> _messageMenu(BuildContext context, String text, String? link) async
|
|||||||
case 'queue-skip':
|
case 'queue-skip':
|
||||||
final why = f.reason.isEmpty ? '' : ' · ${f.reason}';
|
final why = f.reason.isEmpty ? '' : ' · ${f.reason}';
|
||||||
return (Icons.redo_rounded, Colors.orange, 'skipped ${f.target ?? ''}$why');
|
return (Icons.redo_rounded, Colors.orange, 'skipped ${f.target ?? ''}$why');
|
||||||
|
case 'advance-cancel':
|
||||||
|
final what = f.target == null || f.target!.isEmpty ? '' : ' (${f.target} is off Up next)';
|
||||||
|
return (Icons.cancel_outlined, Colors.grey, 'cancelled what plays next$what');
|
||||||
case 'fetch-fail':
|
case 'fetch-fail':
|
||||||
return (Icons.cloud_off, Colors.red, "couldn't fetch ${f.target ?? 'the video'}");
|
return (Icons.cloud_off, Colors.red, "couldn't fetch ${f.target ?? 'the video'}");
|
||||||
default:
|
default:
|
||||||
@@ -304,9 +307,16 @@ Future<void> _messageMenu(BuildContext context, String text, String? link) async
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The server's own lines (pause / resume).
|
/// The server's own lines (pause / resume, the end, an Up next item
|
||||||
|
/// nobody here can put up).
|
||||||
(IconData, Color, String) serverFeedStyle(FeedMessage f) => switch (f.action) {
|
(IconData, Color, String) serverFeedStyle(FeedMessage f) => switch (f.action) {
|
||||||
'resume' => (Icons.play_arrow, Colors.green, 'Playback resumed'),
|
'resume' => (Icons.play_arrow, Colors.green, 'Playback resumed'),
|
||||||
|
'finished' => (Icons.stop_circle_outlined, Colors.grey, 'Finished'),
|
||||||
|
'queue-skip' => (
|
||||||
|
Icons.redo_rounded,
|
||||||
|
Colors.orange,
|
||||||
|
'Skipped ${f.target ?? ''}${f.reason.isEmpty ? '' : ' · ${f.reason}'}'
|
||||||
|
),
|
||||||
_ => (Icons.pause, Colors.orange, 'Playback paused · waiting for readiness'),
|
_ => (Icons.pause, Colors.orange, 'Playback paused · waiting for readiness'),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import '../app/app_graph.dart';
|
|||||||
import '../screens/home/connect_flow.dart';
|
import '../screens/home/connect_flow.dart';
|
||||||
import '../screens/home/connect_status.dart';
|
import '../screens/home/connect_status.dart';
|
||||||
import '../screens/home/health_probe.dart';
|
import '../screens/home/health_probe.dart';
|
||||||
import '../session/invite.dart' show addressIn;
|
import '../session/invite.dart' show addressIn, inviteIn;
|
||||||
import '../session/navigation.dart' show Place;
|
import '../session/navigation.dart' show Place;
|
||||||
import '../state/connect_prefs.dart';
|
import '../state/connect_prefs.dart';
|
||||||
import '../update/update_controller.dart';
|
import '../update/update_controller.dart';
|
||||||
@@ -138,9 +138,17 @@ class JoinCard extends StatelessWidget {
|
|||||||
_join();
|
_join();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// An invite or "host:port" pasted into the address: split it.
|
/// An invite (link or text) or "host:port" pasted into the address:
|
||||||
|
/// split it (a link's password goes into the password field).
|
||||||
void _onAddress(String v) {
|
void _onAddress(String v) {
|
||||||
flow.clearError();
|
flow.clearError();
|
||||||
|
final link = inviteIn(v);
|
||||||
|
if (link != null) {
|
||||||
|
fields.host.text = link.host;
|
||||||
|
fields.port.text = '${link.port}';
|
||||||
|
if (link.secret.isNotEmpty) fields.secret.text = link.secret;
|
||||||
|
return;
|
||||||
|
}
|
||||||
final a = addressIn(v);
|
final a = addressIn(v);
|
||||||
if (a == null || v.trim() == a.host) return;
|
if (a == null || v.trim() == a.host) return;
|
||||||
fields.host.text = a.host;
|
fields.host.text = a.host;
|
||||||
@@ -223,7 +231,7 @@ class JoinCard extends StatelessWidget {
|
|||||||
const Padding(
|
const Padding(
|
||||||
padding: EdgeInsets.only(top: 8),
|
padding: EdgeInsets.only(top: 8),
|
||||||
child: Text(
|
child: Text(
|
||||||
'Your friend gives you their invite (the address) and the password, if any. '
|
'Paste your friend\'s invite link (it carries the password), or their address and password. '
|
||||||
'No account or Jellyfin needed. Your own server stays as it is.',
|
'No account or Jellyfin needed. Your own server stays as it is.',
|
||||||
style: TextStyle(color: Colors.grey, fontSize: 12),
|
style: TextStyle(color: Colors.grey, fontSize: 12),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:signals/signals_flutter.dart';
|
||||||
|
|
||||||
|
import '../session/join_offers.dart';
|
||||||
|
import 'motion.dart';
|
||||||
|
|
||||||
|
/// The join prompt, top centre over the body: "Join Bob's server
|
||||||
|
/// (1.2.3.4:8080)?" with what its `/health` says (version, who is in,
|
||||||
|
/// the version gate, unreachable), Not now and Join. Shown while
|
||||||
|
/// [offers] has an offer (a clicked invite link, or one on the
|
||||||
|
/// clipboard).
|
||||||
|
class JoinOfferCard extends StatelessWidget {
|
||||||
|
final JoinOffers offers;
|
||||||
|
final VoidCallback onJoin;
|
||||||
|
const JoinOfferCard({super.key, required this.offers, required this.onJoin});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => SignalBuilder(builder: (context) {
|
||||||
|
final o = offers.offer.value;
|
||||||
|
return AnimatedSwitcher(
|
||||||
|
duration: easeTime,
|
||||||
|
switchInCurve: ease,
|
||||||
|
switchOutCurve: ease,
|
||||||
|
transitionBuilder: (child, a) => FadeTransition(
|
||||||
|
opacity: a,
|
||||||
|
child: SlideTransition(
|
||||||
|
position: Tween(begin: const Offset(0, -0.2), end: Offset.zero).animate(a),
|
||||||
|
child: child,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: o == null
|
||||||
|
? const SizedBox.shrink()
|
||||||
|
: _OfferBody(key: ValueKey(o.link), offer: o, onJoin: onJoin, onDismiss: offers.dismiss),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class _OfferBody extends StatelessWidget {
|
||||||
|
final JoinOffer offer;
|
||||||
|
final VoidCallback onJoin;
|
||||||
|
final VoidCallback onDismiss;
|
||||||
|
const _OfferBody({super.key, required this.offer, required this.onJoin, required this.onDismiss});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final scheme = Theme.of(context).colorScheme;
|
||||||
|
final h = offer.health;
|
||||||
|
final problem = h != null && (!h.online || h.needsUpdate);
|
||||||
|
final detail = h == null ? 'Checking the server…' : offer.detail;
|
||||||
|
return Material(
|
||||||
|
key: const ValueKey('join-offer'),
|
||||||
|
elevation: 12,
|
||||||
|
color: scheme.surfaceContainerHigh,
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 560),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 12, 10, 12),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(offer.source == OfferSource.clipboard ? Icons.content_paste_go : Icons.link,
|
||||||
|
color: scheme.primary),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Flexible(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(offer.question,
|
||||||
|
key: const ValueKey('join-offer-question'),
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.w600)),
|
||||||
|
if (detail.isNotEmpty)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 2),
|
||||||
|
child: Text(
|
||||||
|
[
|
||||||
|
if (offer.source == OfferSource.clipboard) 'Invite on your clipboard',
|
||||||
|
detail,
|
||||||
|
].join(' · '),
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12, color: problem ? Colors.amber.shade300 : scheme.onSurfaceVariant),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
TextButton(key: const ValueKey('join-offer-dismiss'), onPressed: onDismiss, child: const Text('Not now')),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
FilledButton(key: const ValueKey('join-offer-join'), onPressed: onJoin, child: const Text('Join')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,7 +6,6 @@ import '../screens/home/ui.dart' show appName, displayVersion, shortVersion;
|
|||||||
import '../session/delivery.dart';
|
import '../session/delivery.dart';
|
||||||
import '../session/party.dart';
|
import '../session/party.dart';
|
||||||
import '../app/app_graph.dart';
|
import '../app/app_graph.dart';
|
||||||
import '../session/app_events.dart' show ActionFailed;
|
|
||||||
import '../session/invite.dart';
|
import '../session/invite.dart';
|
||||||
import '../session/navigation.dart' show Place;
|
import '../session/navigation.dart' show Place;
|
||||||
import '../widgets/app_keys.dart';
|
import '../widgets/app_keys.dart';
|
||||||
@@ -16,6 +15,9 @@ import '../widgets/window_buttons.dart';
|
|||||||
import 'avatar.dart';
|
import 'avatar.dart';
|
||||||
import 'delivery_line.dart';
|
import 'delivery_line.dart';
|
||||||
import 'motion.dart';
|
import 'motion.dart';
|
||||||
|
import 'your_invite.dart';
|
||||||
|
|
||||||
|
export 'your_invite.dart' show YourInvite, inviteFriends;
|
||||||
|
|
||||||
/// The shell's tabs, in strip order.
|
/// The shell's tabs, in strip order.
|
||||||
enum ShellTab {
|
enum ShellTab {
|
||||||
@@ -506,9 +508,13 @@ class MembersCard extends StatelessWidget {
|
|||||||
alignment: Alignment.centerLeft,
|
alignment: Alignment.centerLeft,
|
||||||
child: TextButton.icon(
|
child: TextButton.icon(
|
||||||
key: const ValueKey('copy-invite'),
|
key: const ValueKey('copy-invite'),
|
||||||
onPressed: () => copyToClipboard(context, address, done: 'Invite address copied'),
|
onPressed: () => copyToClipboard(
|
||||||
|
context,
|
||||||
|
friendInviteLink(address, nav.serverLabel.peek(),
|
||||||
|
secret: AppScope.of(context).connection.params?.secret ?? ''),
|
||||||
|
done: 'Invite link copied'),
|
||||||
icon: const Icon(Icons.copy, size: 16),
|
icon: const Icon(Icons.copy, size: 16),
|
||||||
label: const Text('Copy invite address'),
|
label: const Text('Copy invite link'),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -623,90 +629,12 @@ class VersionText extends StatelessWidget {
|
|||||||
bool versionDiffers(String version, {String own = appVersion}) =>
|
bool versionDiffers(String version, {String own = appVersion}) =>
|
||||||
version.isNotEmpty && own != '0.0.0-dev' && version != '0.0.0-dev' && version != own;
|
version.isNotEmpty && own != '0.0.0-dev' && version != '0.0.0-dev' && version != own;
|
||||||
|
|
||||||
/// Your server in the members card: shared, the invite's address
|
/// The invite link to a friend's server you are in ([address] as dialled,
|
||||||
/// (Copy invite), a hint for friends outside the network and Stop
|
/// "host:port"), with the password you joined with: the link is the key,
|
||||||
/// sharing; not shared, Invite (opens it and copies the invite).
|
/// as for your own server.
|
||||||
class YourInvite extends StatelessWidget {
|
String friendInviteLink(String address, String name, {String secret = ''}) {
|
||||||
const YourInvite({super.key});
|
final a = addressIn(address);
|
||||||
|
final port = a == null ? null : int.tryParse(a.port);
|
||||||
@override
|
if (a == null || port == null) return address;
|
||||||
Widget build(BuildContext context) {
|
return InviteLink(host: a.host, port: port, name: name, secret: secret).uri;
|
||||||
final muted = Theme.of(context).colorScheme.onSurfaceVariant;
|
|
||||||
final graph = AppScope.of(context);
|
|
||||||
final yours = graph.yourServer;
|
|
||||||
return SignalBuilder(builder: (context) {
|
|
||||||
final invite = yours.shared.value;
|
|
||||||
final busy = yours.busy.value || yours.port.value == null;
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.fromLTRB(12, 0, 12, 12),
|
|
||||||
child: Column(
|
|
||||||
key: const ValueKey('your-invite'),
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
if (invite == null) ...[
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.fromLTRB(8, 0, 8, 6),
|
|
||||||
child: Text('Only you can reach it. Invite opens it to friends; nothing you watch stops.',
|
|
||||||
style: TextStyle(color: muted, fontSize: 12)),
|
|
||||||
),
|
|
||||||
TextButton.icon(
|
|
||||||
key: const ValueKey('copy-invite'),
|
|
||||||
onPressed: busy ? null : () => inviteFriends(context),
|
|
||||||
icon: const Icon(Icons.group_add_outlined, size: 16),
|
|
||||||
label: const Text('Invite'),
|
|
||||||
),
|
|
||||||
] else ...[
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
|
||||||
child: Text(invite.address,
|
|
||||||
key: const ValueKey('invite-address'), style: const TextStyle(fontFamily: 'monospace')),
|
|
||||||
),
|
|
||||||
Wrap(
|
|
||||||
spacing: 4,
|
|
||||||
children: [
|
|
||||||
TextButton.icon(
|
|
||||||
key: const ValueKey('copy-invite'),
|
|
||||||
onPressed: () => copyToClipboard(context, invite.text, done: 'Invite copied'),
|
|
||||||
icon: const Icon(Icons.copy, size: 16),
|
|
||||||
label: const Text('Copy invite'),
|
|
||||||
),
|
|
||||||
TextButton(
|
|
||||||
key: const ValueKey('stop-sharing'),
|
|
||||||
onPressed: busy ? null : () => yours.stopSharing(),
|
|
||||||
child: const Text('Stop sharing'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.fromLTRB(8, 2, 8, 0),
|
|
||||||
child: Text(
|
|
||||||
'Works for friends on your network. Friends elsewhere need port ${invite.port} '
|
|
||||||
'forwarded on your router, or a server they can all reach (like a VPS). '
|
|
||||||
'Stop sharing sends everyone else out; you and Up next stay.',
|
|
||||||
style: TextStyle(color: muted, fontSize: 12),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Invite: opens your server to friends if it is not yet and copies the
|
|
||||||
/// invite ("Invite copied"); a port that can't be opened is a notice.
|
|
||||||
Future<void> inviteFriends(BuildContext context) async {
|
|
||||||
final graph = AppScope.of(context);
|
|
||||||
final Invite invite;
|
|
||||||
try {
|
|
||||||
invite = await graph.yourServer.invite();
|
|
||||||
} catch (e) {
|
|
||||||
final port = graph.forms.form.hostPort;
|
|
||||||
graph.notifier.notify(ActionFailed(
|
|
||||||
"couldn't open your server on port $port: $e — pick another port in Settings → Party & network"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!context.mounted) return;
|
|
||||||
await copyToClipboard(context, invite.text, done: 'Invite copied · ${invite.address}');
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,397 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:math' as math;
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/rendering.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
|
import '../session/notifier.dart' show ToastItem;
|
||||||
|
import '../widgets/app_menu.dart' show AppMenuStyle;
|
||||||
|
import 'avatar.dart';
|
||||||
|
import 'motion.dart';
|
||||||
|
|
||||||
|
// The quick chat box: Enter in the full player opens a small box at the
|
||||||
|
// bottom centre, in the control bar's place, without opening the chat
|
||||||
|
// panel. It types into the same draft as the chat panel's field.
|
||||||
|
|
||||||
|
/// The box's state, owned by the shell (it outlives the player's modes):
|
||||||
|
/// open or not, the draft ([draft], the chat panel's controller), its
|
||||||
|
/// focus, the echo of what was just sent, and the sends whose own chat
|
||||||
|
/// toast should not show again ([echoed]).
|
||||||
|
class QuickChat {
|
||||||
|
/// Closes by itself after this long without typing; the draft stays.
|
||||||
|
static const idleClose = Duration(seconds: 8);
|
||||||
|
|
||||||
|
/// How long the echo of a sent line stays where the box was.
|
||||||
|
static const echoTime = Duration(milliseconds: 2600);
|
||||||
|
|
||||||
|
/// The draft, shared with the chat panel's field.
|
||||||
|
final TextEditingController draft;
|
||||||
|
final bool _ownsDraft;
|
||||||
|
final focus = FocusNode(debugLabel: 'quick chat');
|
||||||
|
final open = ValueNotifier(false);
|
||||||
|
|
||||||
|
/// The line just sent, shown briefly in the box's place (null: none).
|
||||||
|
final echo = ValueNotifier<String?>(null);
|
||||||
|
|
||||||
|
/// Bumped on each send ([lastSent]): the player flies your own line
|
||||||
|
/// when danmaku is the notification style (and drops the echo).
|
||||||
|
final sends = ValueNotifier(0);
|
||||||
|
String lastSent = '';
|
||||||
|
|
||||||
|
/// Without typing this long, the box closes ([idleClose]).
|
||||||
|
final Duration idle;
|
||||||
|
Timer? _idleTimer;
|
||||||
|
Timer? _echoTimer;
|
||||||
|
final _recent = <(String, DateTime)>[];
|
||||||
|
|
||||||
|
QuickChat({TextEditingController? draft, this.idle = idleClose})
|
||||||
|
: draft = draft ?? TextEditingController(),
|
||||||
|
_ownsDraft = draft == null {
|
||||||
|
this.draft.addListener(_typed);
|
||||||
|
}
|
||||||
|
|
||||||
|
void dispose() {
|
||||||
|
draft.removeListener(_typed);
|
||||||
|
if (_ownsDraft) draft.dispose();
|
||||||
|
_idleTimer?.cancel();
|
||||||
|
_echoTimer?.cancel();
|
||||||
|
focus.dispose();
|
||||||
|
open.dispose();
|
||||||
|
echo.dispose();
|
||||||
|
sends.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Opens the box with the draft as it was, caret at its end, and gives
|
||||||
|
/// it the keyboard at once (keys typed right after go to it).
|
||||||
|
void show() {
|
||||||
|
_echoTimer?.cancel();
|
||||||
|
echo.value = null;
|
||||||
|
open.value = true;
|
||||||
|
draft.selection = TextSelection.collapsed(offset: draft.text.length);
|
||||||
|
focus.requestFocus();
|
||||||
|
_armIdle();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Closes the box; the draft stays for the next Enter (or the panel).
|
||||||
|
void close() {
|
||||||
|
_idleTimer?.cancel();
|
||||||
|
if (!open.value) return;
|
||||||
|
open.value = false;
|
||||||
|
focus.unfocus();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _typed() {
|
||||||
|
if (open.value) _armIdle();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _armIdle() {
|
||||||
|
_idleTimer?.cancel();
|
||||||
|
_idleTimer = Timer(idle, close);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enter: the trimmed draft to send (cleared, the box closes and the
|
||||||
|
/// echo shows it), or null when blank (the box just closes).
|
||||||
|
String? submit() {
|
||||||
|
final text = draft.text.trim();
|
||||||
|
close();
|
||||||
|
if (text.isEmpty) return null;
|
||||||
|
draft.clear();
|
||||||
|
final now = DateTime.now();
|
||||||
|
_recent.removeWhere((r) => now.difference(r.$2) > const Duration(seconds: 20));
|
||||||
|
_recent.add((text, now));
|
||||||
|
lastSent = text;
|
||||||
|
echo.value = text;
|
||||||
|
_echoTimer?.cancel();
|
||||||
|
_echoTimer = Timer(echoTime, () => echo.value = null);
|
||||||
|
sends.value++;
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [t] is your own chat toast for a line this box already showed (as
|
||||||
|
/// its echo or your own danmaku line): it should not pop up again.
|
||||||
|
bool echoed(ToastItem t) {
|
||||||
|
if (!t.chat || !t.own) return false;
|
||||||
|
final now = DateTime.now();
|
||||||
|
return _recent.any((r) => now.difference(r.$2) < const Duration(seconds: 20) && t.text.endsWith(': ${r.$1}'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The box: a glass pill at the bottom centre that starts compact
|
||||||
|
/// ([minWidth]) and widens with the text up to [maxWidth], then wraps to
|
||||||
|
/// up to four lines, growing upward. Enter sends ([onSend], empty Enter
|
||||||
|
/// closes), Esc closes. Your avatar leads it. While closed it stays
|
||||||
|
/// mounted, invisible and out of the way, so [QuickChat.show] focuses it
|
||||||
|
/// at once. The pointer over it stays hidden until the mouse moves.
|
||||||
|
class QuickChatBox extends StatefulWidget {
|
||||||
|
static const minWidth = 300.0;
|
||||||
|
static const height = 44.0;
|
||||||
|
final QuickChat chat;
|
||||||
|
final double maxWidth;
|
||||||
|
final void Function(String text) onSend;
|
||||||
|
|
||||||
|
/// Its height whenever it changes (the subtitle lift follows it).
|
||||||
|
final ValueChanged<double>? onHeight;
|
||||||
|
const QuickChatBox({super.key, required this.chat, required this.onSend, this.maxWidth = 640, this.onHeight});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<QuickChatBox> createState() => _QuickChatBoxState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _QuickChatBoxState extends State<QuickChatBox> {
|
||||||
|
static const _text = TextStyle(color: Colors.white, fontSize: 15, height: 1.3);
|
||||||
|
bool _pointerMoved = false;
|
||||||
|
Offset? _pointerAt;
|
||||||
|
|
||||||
|
QuickChat get c => widget.chat;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
c.draft.addListener(_changed);
|
||||||
|
c.open.addListener(_openChanged);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(QuickChatBox old) {
|
||||||
|
super.didUpdateWidget(old);
|
||||||
|
if (old.chat != widget.chat) {
|
||||||
|
old.chat.draft.removeListener(_changed);
|
||||||
|
old.chat.open.removeListener(_openChanged);
|
||||||
|
c.draft.addListener(_changed);
|
||||||
|
c.open.addListener(_openChanged);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
c.draft.removeListener(_changed);
|
||||||
|
c.open.removeListener(_openChanged);
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _changed() => setState(() {});
|
||||||
|
|
||||||
|
void _openChanged() {
|
||||||
|
if (c.open.value) _pointerMoved = false;
|
||||||
|
setState(() {});
|
||||||
|
}
|
||||||
|
|
||||||
|
KeyEventResult _key(FocusNode node, KeyEvent e) {
|
||||||
|
if (e is! KeyDownEvent && e is! KeyRepeatEvent) return KeyEventResult.ignored;
|
||||||
|
final k = e.logicalKey;
|
||||||
|
if (k == LogicalKeyboardKey.escape) {
|
||||||
|
c.close();
|
||||||
|
return KeyEventResult.handled;
|
||||||
|
}
|
||||||
|
if (k == LogicalKeyboardKey.enter || k == LogicalKeyboardKey.numpadEnter) {
|
||||||
|
// An input method composing a word takes Enter itself.
|
||||||
|
if (c.draft.value.composing.isValid) return KeyEventResult.ignored;
|
||||||
|
if (e is KeyRepeatEvent) return KeyEventResult.handled;
|
||||||
|
final text = c.submit();
|
||||||
|
if (text != null) widget.onSend(text);
|
||||||
|
return KeyEventResult.handled;
|
||||||
|
}
|
||||||
|
return KeyEventResult.ignored;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wide enough for the draft on one line, between [QuickChatBox.minWidth]
|
||||||
|
/// and the room there is.
|
||||||
|
double _width(double room) {
|
||||||
|
final max = math.max(QuickChatBox.minWidth, math.min(widget.maxWidth, room));
|
||||||
|
final tp = TextPainter(
|
||||||
|
text: TextSpan(text: c.draft.text.isEmpty ? ' ' : c.draft.text, style: _text),
|
||||||
|
textDirection: TextDirection.ltr,
|
||||||
|
maxLines: 1,
|
||||||
|
)..layout();
|
||||||
|
// Avatar, gaps and padding around the text, plus room for the caret.
|
||||||
|
final w = tp.width + 22 + 12 + 18 + 18 + 40;
|
||||||
|
tp.dispose();
|
||||||
|
return w.clamp(QuickChatBox.minWidth, max);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final open = c.open.value;
|
||||||
|
final me = selfAvatarInfo();
|
||||||
|
return LayoutBuilder(builder: (context, box) {
|
||||||
|
final width = _width(box.maxWidth);
|
||||||
|
final field = Focus(
|
||||||
|
canRequestFocus: false,
|
||||||
|
skipTraversal: true,
|
||||||
|
onKeyEvent: _key,
|
||||||
|
child: TextField(
|
||||||
|
key: const ValueKey('quick-chat-field'),
|
||||||
|
controller: c.draft,
|
||||||
|
focusNode: c.focus,
|
||||||
|
minLines: 1,
|
||||||
|
maxLines: 4,
|
||||||
|
keyboardType: TextInputType.text,
|
||||||
|
textInputAction: TextInputAction.send,
|
||||||
|
maxLength: 500,
|
||||||
|
maxLengthEnforcement: MaxLengthEnforcement.enforced,
|
||||||
|
// Enter is ours (above); a platform send action lands here.
|
||||||
|
onSubmitted: (_) {
|
||||||
|
final text = c.submit();
|
||||||
|
if (text != null) widget.onSend(text);
|
||||||
|
},
|
||||||
|
mouseCursor: _pointerMoved ? SystemMouseCursors.text : SystemMouseCursors.none,
|
||||||
|
style: _text,
|
||||||
|
cursorColor: Colors.white,
|
||||||
|
cursorWidth: 1.6,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
isDense: true,
|
||||||
|
counterText: '',
|
||||||
|
border: InputBorder.none,
|
||||||
|
contentPadding: const EdgeInsets.symmetric(vertical: 4),
|
||||||
|
hintText: 'Say something',
|
||||||
|
hintStyle: _text.copyWith(color: Colors.white.withValues(alpha: 0.42)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return Align(
|
||||||
|
alignment: Alignment.bottomCenter,
|
||||||
|
child: IgnorePointer(
|
||||||
|
ignoring: !open,
|
||||||
|
child: AnimatedSlide(
|
||||||
|
offset: open ? Offset.zero : const Offset(0, 0.35),
|
||||||
|
duration: easeTime,
|
||||||
|
curve: ease,
|
||||||
|
child: AnimatedOpacity(
|
||||||
|
opacity: open ? 1 : 0,
|
||||||
|
duration: open ? easeTime : const Duration(milliseconds: 160),
|
||||||
|
curve: ease,
|
||||||
|
child: MouseRegion(
|
||||||
|
onHover: (e) {
|
||||||
|
final at = _pointerAt;
|
||||||
|
_pointerAt = e.position;
|
||||||
|
if (!_pointerMoved && at != null && (e.position - at).distance > 0.5) {
|
||||||
|
setState(() => _pointerMoved = true);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: _ReportHeight(
|
||||||
|
onHeight: widget.onHeight,
|
||||||
|
child: AnimatedContainer(
|
||||||
|
key: const ValueKey('quick-chat'),
|
||||||
|
duration: const Duration(milliseconds: 160),
|
||||||
|
curve: Curves.easeOutCubic,
|
||||||
|
width: width,
|
||||||
|
constraints: const BoxConstraints(minHeight: QuickChatBox.height),
|
||||||
|
padding: const EdgeInsets.fromLTRB(11, 6, 16, 6),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppMenuStyle.glass,
|
||||||
|
borderRadius: BorderRadius.circular(QuickChatBox.height / 2),
|
||||||
|
border: Border.all(color: Colors.white.withValues(alpha: open ? 0.22 : 0.1), width: 0.8),
|
||||||
|
boxShadow: const [BoxShadow(color: Color(0x55000000), blurRadius: 18, offset: Offset(0, 6))],
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
StatusAvatar(info: me, size: 22, badges: false, ring: false),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(child: field),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Icon(Icons.keyboard_return_rounded, size: 16, color: Colors.white.withValues(alpha: 0.38)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What you just sent, in the box's place for [QuickChat.echoTime]: your
|
||||||
|
/// avatar and the line on the same glass, then it fades. Shown when the
|
||||||
|
/// notifications are toasts (with danmaku your own line flies instead).
|
||||||
|
class QuickChatEcho extends StatelessWidget {
|
||||||
|
final QuickChat chat;
|
||||||
|
const QuickChatEcho({super.key, required this.chat});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final accent = Theme.of(context).colorScheme.primary;
|
||||||
|
return IgnorePointer(
|
||||||
|
child: ValueListenableBuilder<String?>(
|
||||||
|
valueListenable: chat.echo,
|
||||||
|
builder: (context, text, _) => AnimatedSwitcher(
|
||||||
|
duration: easeTime,
|
||||||
|
switchInCurve: ease,
|
||||||
|
switchOutCurve: Curves.easeIn,
|
||||||
|
transitionBuilder: (child, a) => FadeTransition(
|
||||||
|
opacity: a,
|
||||||
|
child: SlideTransition(
|
||||||
|
position: Tween(begin: const Offset(0, 0.25), end: Offset.zero).animate(a),
|
||||||
|
child: child,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: text == null
|
||||||
|
? const SizedBox.shrink(key: ValueKey('no-echo'))
|
||||||
|
: Align(
|
||||||
|
key: ValueKey('echo-$text'),
|
||||||
|
alignment: Alignment.bottomCenter,
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 640),
|
||||||
|
child: Container(
|
||||||
|
key: const ValueKey('quick-chat-echo'),
|
||||||
|
padding: const EdgeInsets.fromLTRB(11, 10, 16, 10),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppMenuStyle.glass,
|
||||||
|
borderRadius: BorderRadius.circular(QuickChatBox.height / 2),
|
||||||
|
border: Border.all(color: accent.withValues(alpha: 0.7), width: 1),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
StatusAvatar(info: selfAvatarInfo(), size: 22, badges: false, ring: false),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Flexible(
|
||||||
|
child: Text(text,
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: const TextStyle(color: Colors.white, fontSize: 15, height: 1.3)),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Icon(Icons.check_rounded, size: 16, color: accent),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reports its child's height after layout when it changes.
|
||||||
|
class _ReportHeight extends SingleChildRenderObjectWidget {
|
||||||
|
final ValueChanged<double>? onHeight;
|
||||||
|
const _ReportHeight({required this.onHeight, required super.child});
|
||||||
|
|
||||||
|
@override
|
||||||
|
RenderObject createRenderObject(BuildContext context) => _RenderReportHeight(onHeight);
|
||||||
|
|
||||||
|
@override
|
||||||
|
void updateRenderObject(BuildContext context, _RenderReportHeight r) => r.onHeight = onHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
class _RenderReportHeight extends RenderProxyBox {
|
||||||
|
ValueChanged<double>? onHeight;
|
||||||
|
double? _last;
|
||||||
|
_RenderReportHeight(this.onHeight);
|
||||||
|
|
||||||
|
@override
|
||||||
|
void performLayout() {
|
||||||
|
super.performLayout();
|
||||||
|
final h = size.height;
|
||||||
|
if (h == _last) return;
|
||||||
|
_last = h;
|
||||||
|
final f = onHeight;
|
||||||
|
if (f != null) WidgetsBinding.instance.addPostFrameCallback((_) => f(h));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,9 +8,16 @@ import 'package:media_kit_video/media_kit_video.dart';
|
|||||||
import 'package:signals/signals_flutter.dart';
|
import 'package:signals/signals_flutter.dart';
|
||||||
|
|
||||||
import '../controls/controls_probe.dart';
|
import '../controls/controls_probe.dart';
|
||||||
|
import '../controls/minimal/style.dart';
|
||||||
|
import '../controls/model/controls_layout.dart';
|
||||||
import '../controls/model/controls_model.dart';
|
import '../controls/model/controls_model.dart';
|
||||||
|
import '../controls/player_panel.dart';
|
||||||
import '../controls/session_controls.dart';
|
import '../controls/session_controls.dart';
|
||||||
import '../controls/volume.dart';
|
import '../controls/volume.dart';
|
||||||
|
import '../danmaku/danmaku.dart' show DanmakuController, DanmakuItem, DanmakuKind;
|
||||||
|
import '../danmaku/danmaku_notices.dart';
|
||||||
|
import '../danmaku/danmaku_preview.dart';
|
||||||
|
import '../platform/desktop/media_kit_host.dart' show videoControllerOf;
|
||||||
import '../queue/queue_ui.dart';
|
import '../queue/queue_ui.dart';
|
||||||
import '../playback/media_marks.dart' show SkipKind, SkipOffer, formatClock;
|
import '../playback/media_marks.dart' show SkipKind, SkipOffer, formatClock;
|
||||||
import '../screens/session/jellyfin_widgets.dart';
|
import '../screens/session/jellyfin_widgets.dart';
|
||||||
@@ -27,8 +34,10 @@ import 'avatar.dart';
|
|||||||
import 'motion.dart';
|
import 'motion.dart';
|
||||||
import 'now_playing_title.dart';
|
import 'now_playing_title.dart';
|
||||||
import 'player_status.dart';
|
import 'player_status.dart';
|
||||||
|
import 'quick_chat.dart';
|
||||||
import 'sit_out_card.dart';
|
import 'sit_out_card.dart';
|
||||||
import 'start_banner.dart';
|
import 'start_banner.dart';
|
||||||
|
import 'video_view.dart';
|
||||||
|
|
||||||
/// Where the session's video is shown: nowhere, the floating mini player,
|
/// Where the session's video is shown: nowhere, the floating mini player,
|
||||||
/// or filling the body (and, with fullscreen, the window).
|
/// or filling the body (and, with fullscreen, the window).
|
||||||
@@ -41,9 +50,10 @@ typedef VideoViewBuilder = Widget Function(BuildContext context, VideoControlsBu
|
|||||||
|
|
||||||
/// The session's player: the one `Video` of the current player and
|
/// The session's player: the one `Video` of the current player and
|
||||||
/// everything over it. Full: the controls (one model, the skin picked in
|
/// everything over it. Full: the controls (one model, the skin picked in
|
||||||
/// Settings), the start card, the status pill, Jellyfin overlays and
|
/// Settings), the start card, the status pill, Jellyfin overlays, the
|
||||||
/// the player keys. Mini: hover controls and a progress line. The shell keeps this
|
/// quick chat box, the danmaku, the player panel and the player keys.
|
||||||
/// widget mounted at one place in the tree while the mode changes, so the
|
/// Mini: hover controls and a progress line. The shell keeps this widget
|
||||||
|
/// mounted at one place in the tree while the mode changes, so the
|
||||||
/// `Video` is never mounted twice nor rebuilt for a move.
|
/// `Video` is never mounted twice nor rebuilt for a move.
|
||||||
class SessionPlayer extends StatefulWidget {
|
class SessionPlayer extends StatefulWidget {
|
||||||
final PlayerMode mode;
|
final PlayerMode mode;
|
||||||
@@ -66,6 +76,15 @@ class SessionPlayer extends StatefulWidget {
|
|||||||
/// Follows whether the full player's controls are up (the shell lifts
|
/// Follows whether the full player's controls are up (the shell lifts
|
||||||
/// its toasts above them); the player keeps its own when null.
|
/// its toasts above them); the player keeps its own when null.
|
||||||
final ValueNotifier<bool>? controlsShown;
|
final ValueNotifier<bool>? controlsShown;
|
||||||
|
/// The quick chat box's state (the shell's: its draft is the chat
|
||||||
|
/// panel's); the player keeps its own when null.
|
||||||
|
final QuickChat? quickChat;
|
||||||
|
/// Set while the session's notifications fly as danmaku here (the
|
||||||
|
/// shell then leaves those out of its toasts).
|
||||||
|
final ValueNotifier<bool>? danmakuActive;
|
||||||
|
/// Dev builds: the danmaku preview, shown over the full player while
|
||||||
|
/// it is active.
|
||||||
|
final DanmakuPreview? danmaku;
|
||||||
const SessionPlayer({
|
const SessionPlayer({
|
||||||
super.key,
|
super.key,
|
||||||
required this.mode,
|
required this.mode,
|
||||||
@@ -83,6 +102,9 @@ class SessionPlayer extends StatefulWidget {
|
|||||||
this.videoBuilder,
|
this.videoBuilder,
|
||||||
this.windowControls = true,
|
this.windowControls = true,
|
||||||
this.controlsShown,
|
this.controlsShown,
|
||||||
|
this.quickChat,
|
||||||
|
this.danmakuActive,
|
||||||
|
this.danmaku,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -97,11 +119,29 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
|||||||
final _noFocus = FocusNode(skipTraversal: true, canRequestFocus: false);
|
final _noFocus = FocusNode(skipTraversal: true, canRequestFocus: false);
|
||||||
DateTime? _lastKeyAt;
|
DateTime? _lastKeyAt;
|
||||||
late final void Function() _stopShaderWatch;
|
late final void Function() _stopShaderWatch;
|
||||||
|
late final void Function() _stopSubScale;
|
||||||
|
late final void Function() _stopDanmakuWatch;
|
||||||
|
late final void Function() _stopLiftWatch;
|
||||||
int _shaderNoticeSeen = 0;
|
int _shaderNoticeSeen = 0;
|
||||||
bool _miniHover = false;
|
bool _miniHover = false;
|
||||||
// Whether the controls are up (their frame reports it).
|
// Whether the controls are up (their frame reports it).
|
||||||
final _ownShown = ValueNotifier(true);
|
final _ownShown = ValueNotifier(true);
|
||||||
ValueNotifier<bool> get _controlsShown => widget.controlsShown ?? _ownShown;
|
ValueNotifier<bool> get _controlsShown => widget.controlsShown ?? _ownShown;
|
||||||
|
// The quick chat box (Enter): the shell's, else one of our own.
|
||||||
|
QuickChat? _ownChat;
|
||||||
|
QuickChat get _quick => widget.quickChat ?? (_ownChat ??= QuickChat());
|
||||||
|
// The box or its echo sits in the control bar's place: the controls
|
||||||
|
// stay hidden meanwhile.
|
||||||
|
final _boxUp = ValueNotifier(false);
|
||||||
|
double _boxHeight = QuickChatBox.height;
|
||||||
|
// What covers the bottom, as bottom subtitles move above it.
|
||||||
|
final _lift = SubtitleLift();
|
||||||
|
// The Enter that opened the box: kept from the focused widget.
|
||||||
|
KeyEvent? _swallow;
|
||||||
|
// The session's danmaku lines (and your own sent ones).
|
||||||
|
final _lines = DanmakuController();
|
||||||
|
final _danmakuOn = signal(false);
|
||||||
|
late final _full = signal(widget.mode == PlayerMode.full);
|
||||||
// The mouse on a floating element, or the Up next popover open, keeps
|
// The mouse on a floating element, or the Up next popover open, keeps
|
||||||
// the controls up.
|
// the controls up.
|
||||||
final _hold = ControlsHold();
|
final _hold = ControlsHold();
|
||||||
@@ -123,7 +163,8 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
late final SessionCoordinator _coord = AppScope.of(context).coordinator;
|
late final SessionCoordinator _coord = AppScope.of(context).coordinator;
|
||||||
bool get _full => widget.mode == PlayerMode.full;
|
AppSettings get _settings => _coord.settings;
|
||||||
|
bool get _isFull => widget.mode == PlayerMode.full;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -132,6 +173,7 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
|||||||
_model;
|
_model;
|
||||||
_coord.jellyfin.ensureLoaded();
|
_coord.jellyfin.ensureLoaded();
|
||||||
HardwareKeyboard.instance.addHandler(_globalKey);
|
HardwareKeyboard.instance.addHandler(_globalKey);
|
||||||
|
FocusManager.instance.addEarlyKeyEventHandler(_earlyKey);
|
||||||
// Tab (AppKeys) reaches the chat through here in fullscreen.
|
// Tab (AppKeys) reaches the chat through here in fullscreen.
|
||||||
AppKeys.addChatToggle(_toggleChat);
|
AppKeys.addChatToggle(_toggleChat);
|
||||||
// Shader messages (preset changes, load failures) become toasts.
|
// Shader messages (preset changes, load failures) become toasts.
|
||||||
@@ -145,6 +187,32 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
|||||||
_stopPopoverWatch = effect(() {
|
_stopPopoverWatch = effect(() {
|
||||||
_hold.set(#upNextPopover, _coord.queue.popoverOpen.value);
|
_hold.set(#upNextPopover, _coord.queue.popoverOpen.value);
|
||||||
});
|
});
|
||||||
|
// Subtitle size on every player, live.
|
||||||
|
_stopSubScale = effect(() {
|
||||||
|
final scale = _settings.subScale.value;
|
||||||
|
_coord.playback.generation.value;
|
||||||
|
final p = untracked(() => _coord.playback.current);
|
||||||
|
if (p == null || p.disposed) return;
|
||||||
|
unawaited(p.setProperty('sub-scale', scale.toStringAsFixed(2)).catchError((Object _) {}));
|
||||||
|
});
|
||||||
|
// Danmaku is the style and the full player is up (not sitting out).
|
||||||
|
_stopDanmakuWatch = effect(() {
|
||||||
|
final on = _full.value && _settings.notifyStyle.value == NotifyStyle.danmaku && !_coord.selfAway.value;
|
||||||
|
untracked(() {
|
||||||
|
_danmakuOn.value = on;
|
||||||
|
widget.danmakuActive?.value = on;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
_stopLiftWatch = effect(() {
|
||||||
|
_settings.liftSubtitles.value;
|
||||||
|
_settings.controlsSkin.value;
|
||||||
|
_settings.controlsSize.value;
|
||||||
|
untracked(_retargetLift);
|
||||||
|
});
|
||||||
|
_quick.open.addListener(_boxChanged);
|
||||||
|
_quick.echo.addListener(_boxChanged);
|
||||||
|
_quick.sends.addListener(_sent);
|
||||||
|
_controlsShown.addListener(_retargetLift);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -153,16 +221,48 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
|||||||
_fullscreen.value = widget.fullscreen;
|
_fullscreen.value = widget.fullscreen;
|
||||||
_chatOpen.value = widget.chatOpen;
|
_chatOpen.value = widget.chatOpen;
|
||||||
_unread.value = widget.unread;
|
_unread.value = widget.unread;
|
||||||
|
_full.value = _isFull;
|
||||||
|
if (!_isFull) {
|
||||||
|
_quick.close();
|
||||||
|
_model.panelOpen.value = false;
|
||||||
|
}
|
||||||
|
if (oldWidget.quickChat != widget.quickChat) {
|
||||||
|
final old = oldWidget.quickChat ?? _ownChat;
|
||||||
|
old?.open.removeListener(_boxChanged);
|
||||||
|
old?.echo.removeListener(_boxChanged);
|
||||||
|
old?.sends.removeListener(_sent);
|
||||||
|
_quick.open.addListener(_boxChanged);
|
||||||
|
_quick.echo.addListener(_boxChanged);
|
||||||
|
_quick.sends.addListener(_sent);
|
||||||
|
}
|
||||||
|
if (oldWidget.controlsShown != widget.controlsShown) {
|
||||||
|
(oldWidget.controlsShown ?? _ownShown).removeListener(_retargetLift);
|
||||||
|
_controlsShown.addListener(_retargetLift);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
HardwareKeyboard.instance.removeHandler(_globalKey);
|
HardwareKeyboard.instance.removeHandler(_globalKey);
|
||||||
|
FocusManager.instance.removeEarlyKeyEventHandler(_earlyKey);
|
||||||
AppKeys.removeChatToggle(_toggleChat);
|
AppKeys.removeChatToggle(_toggleChat);
|
||||||
_stopShaderWatch();
|
_stopShaderWatch();
|
||||||
_stopPopoverWatch();
|
_stopPopoverWatch();
|
||||||
|
_stopSubScale();
|
||||||
|
_stopDanmakuWatch();
|
||||||
|
_stopLiftWatch();
|
||||||
|
widget.danmakuActive?.value = false;
|
||||||
|
_quick.open.removeListener(_boxChanged);
|
||||||
|
_quick.echo.removeListener(_boxChanged);
|
||||||
|
_quick.sends.removeListener(_sent);
|
||||||
|
_controlsShown.removeListener(_retargetLift);
|
||||||
|
_quick.close();
|
||||||
|
_ownChat?.dispose();
|
||||||
_noFocus.dispose();
|
_noFocus.dispose();
|
||||||
_ownShown.dispose();
|
_ownShown.dispose();
|
||||||
|
_boxUp.dispose();
|
||||||
|
_lift.dispose();
|
||||||
|
_lines.dispose();
|
||||||
_hold.dispose();
|
_hold.dispose();
|
||||||
_model.dispose();
|
_model.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
@@ -170,6 +270,55 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
|||||||
|
|
||||||
void _toggleChat() => _model.toggleChat();
|
void _toggleChat() => _model.toggleChat();
|
||||||
|
|
||||||
|
// ------------------------------------------------------- quick chat box
|
||||||
|
|
||||||
|
void _boxChanged() {
|
||||||
|
_boxUp.value = _quick.open.value || _quick.echo.value != null;
|
||||||
|
_retargetLift();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What covers the bottom of the full player, for the subtitle lift:
|
||||||
|
/// the box (or its echo) from 16 px up, else the control bar while it is
|
||||||
|
/// up when "Subtitles above the controls" is on; 8 px above it.
|
||||||
|
void _retargetLift() {
|
||||||
|
if (!mounted) return;
|
||||||
|
final box = _boxUp.value ? _edge + _boxHeight + 8 : 0.0;
|
||||||
|
final bar = _settings.liftSubtitles.peek() && _controlsShown.value ? _bottomControlsHeight() + 8 : 0.0;
|
||||||
|
_lift.animateTo(math.max(box, bar));
|
||||||
|
}
|
||||||
|
|
||||||
|
void _boxHeightChanged(double h) {
|
||||||
|
if (h == _boxHeight) return;
|
||||||
|
_boxHeight = h;
|
||||||
|
_retargetLift();
|
||||||
|
setState(() {});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enter in the box with text: to the chat; your own line flies right
|
||||||
|
/// away when danmaku is the style (the echo is for toasts).
|
||||||
|
void _send(String text) => _coord.sendChat(text);
|
||||||
|
|
||||||
|
void _sent() {
|
||||||
|
if (!_danmakuOn.peek()) return;
|
||||||
|
_quick.echo.value = null;
|
||||||
|
final me = selfAvatarInfo();
|
||||||
|
_lines.add(DanmakuItem(
|
||||||
|
kind: DanmakuKind.chat, sender: me.name, senderColor: me.color, text: _quick.lastSent, own: true, avatar: me));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Swallows the Enter that opened the box before the focused widget
|
||||||
|
/// (a button keeps focus after a click) acts on it too.
|
||||||
|
KeyEventResult _earlyKey(KeyEvent event) {
|
||||||
|
final s = _swallow;
|
||||||
|
if (s == null || event.logicalKey != s.logicalKey) return KeyEventResult.ignored;
|
||||||
|
if (event is KeyDownEvent && !identical(event, s)) {
|
||||||
|
_swallow = null;
|
||||||
|
return KeyEventResult.ignored;
|
||||||
|
}
|
||||||
|
if (event is KeyUpEvent) _swallow = null;
|
||||||
|
return KeyEventResult.handled;
|
||||||
|
}
|
||||||
|
|
||||||
/// The player keys, while the player fills the body and nothing (a
|
/// The player keys, while the player fills the body and nothing (a
|
||||||
/// dialog, a card, a text field) is in front of it. Space and Tab are
|
/// dialog, a card, a text field) is in front of it. Space and Tab are
|
||||||
/// app-wide ([AppKeys]).
|
/// app-wide ([AppKeys]).
|
||||||
@@ -178,7 +327,7 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
|||||||
if (!AppScope.of(context).navigation.online.peek()) return false;
|
if (!AppScope.of(context).navigation.online.peek()) return false;
|
||||||
// Mini: Esc is the last step out (a friend's server: asking to leave
|
// Mini: Esc is the last step out (a friend's server: asking to leave
|
||||||
// it; yours: the player closes).
|
// it; yours: the player closes).
|
||||||
if (!_full) {
|
if (!_isFull) {
|
||||||
if (event.logicalKey != LogicalKeyboardKey.escape) return false;
|
if (event.logicalKey != LogicalKeyboardKey.escape) return false;
|
||||||
if (ModalRoute.of(context)?.isCurrent == false || textInputFocused()) return false;
|
if (ModalRoute.of(context)?.isCurrent == false || textInputFocused()) return false;
|
||||||
widget.onLeave();
|
widget.onLeave();
|
||||||
@@ -194,12 +343,19 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (textInputFocused()) return false;
|
if (textInputFocused()) return false;
|
||||||
|
final key = event.logicalKey;
|
||||||
|
// Enter: the quick chat box (the chat panel's draft), whatever plays.
|
||||||
|
if (key == LogicalKeyboardKey.enter || key == LogicalKeyboardKey.numpadEnter) {
|
||||||
|
if (_coord.selfAway.peek()) return false;
|
||||||
|
_swallow = event;
|
||||||
|
_quick.show();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
final now = DateTime.now().toUtc();
|
final now = DateTime.now().toUtc();
|
||||||
if (_lastKeyAt != null && now.difference(_lastKeyAt!).inMilliseconds < 350) {
|
if (_lastKeyAt != null && now.difference(_lastKeyAt!).inMilliseconds < 350) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
final model = _model;
|
final model = _model;
|
||||||
final key = event.logicalKey;
|
|
||||||
// Ctrl+0..6: Anime4K's own mpv keys (off, modes A..C+A).
|
// Ctrl+0..6: Anime4K's own mpv keys (off, modes A..C+A).
|
||||||
final digit = _shaderKeys.indexOf(key);
|
final digit = _shaderKeys.indexOf(key);
|
||||||
if (digit >= 0 && HardwareKeyboard.instance.isControlPressed) {
|
if (digit >= 0 && HardwareKeyboard.instance.isControlPressed) {
|
||||||
@@ -251,7 +407,9 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
|||||||
}
|
}
|
||||||
if (key == LogicalKeyboardKey.escape) {
|
if (key == LogicalKeyboardKey.escape) {
|
||||||
_lastKeyAt = now;
|
_lastKeyAt = now;
|
||||||
if (widget.chatOpen) {
|
if (_model.panelOpen.peek()) {
|
||||||
|
_model.panelOpen.value = false;
|
||||||
|
} else if (widget.chatOpen) {
|
||||||
widget.onToggleChat();
|
widget.onToggleChat();
|
||||||
} else if (widget.fullscreen) {
|
} else if (widget.fullscreen) {
|
||||||
widget.onToggleFullscreen();
|
widget.onToggleFullscreen();
|
||||||
@@ -283,29 +441,35 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
|||||||
if (!playback.alive.value) {
|
if (!playback.alive.value) {
|
||||||
return Center(
|
return Center(
|
||||||
child: Text('player starting…',
|
child: Text('player starting…',
|
||||||
style: TextStyle(color: Colors.white70, fontSize: _full ? 14 : 12)),
|
style: TextStyle(color: Colors.white70, fontSize: _isFull ? 14 : 12)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (gen != _videoGen) {
|
if (gen != _videoGen) {
|
||||||
_videoGen = gen;
|
_videoGen = gen;
|
||||||
_videoKey = GlobalKey<VideoState>();
|
_videoKey = GlobalKey<VideoState>();
|
||||||
}
|
}
|
||||||
final VideoControlsBuilder? controls = _full ? _sessionControls : null;
|
// Never null for the mini: media_kit keeps the last controls
|
||||||
|
// when given null, so the full player's would stay under it.
|
||||||
|
final VideoControlsBuilder controls = _isFull ? _sessionControls : _noControls;
|
||||||
final video = widget.videoBuilder?.call(context, controls) ?? _video(controls);
|
final video = widget.videoBuilder?.call(context, controls) ?? _video(controls);
|
||||||
// The wheel anywhere on the full player steps the volume
|
// The wheel anywhere on the full player steps the volume
|
||||||
// (lists, menus and popovers under the mouse scroll instead).
|
// (lists, menus and popovers under the mouse scroll instead).
|
||||||
return VolumeWheel(
|
return VolumeWheel(
|
||||||
enabled: _full,
|
enabled: _isFull,
|
||||||
onStep: _model.stepVolume,
|
onStep: _model.stepVolume,
|
||||||
child: Stack(
|
child: Stack(
|
||||||
fit: StackFit.expand,
|
fit: StackFit.expand,
|
||||||
children: [
|
children: [
|
||||||
video,
|
video,
|
||||||
if (_full) ...[
|
SubtitleLifter(playback: _coord.playback, lift: _lift, active: _isFull),
|
||||||
|
if (_isFull) ...[
|
||||||
_blockedCard(),
|
_blockedCard(),
|
||||||
_actionArea(),
|
_actionArea(),
|
||||||
if (widget.fullscreen) ...[if (!_coord.alone.value) _pausedParty(), _unreadChip()],
|
if (widget.fullscreen) ...[if (!_coord.alone.value) _pausedParty(), _unreadChip()],
|
||||||
_volumeReadout(),
|
_volumeReadout(),
|
||||||
|
_quickChatBox(),
|
||||||
|
_playerPanel(),
|
||||||
|
if (widget.danmaku case final d?) _danmakuPanel(d),
|
||||||
] else
|
] else
|
||||||
_miniOverlay(),
|
_miniOverlay(),
|
||||||
// Sitting out: nothing is loaded; the card covers the
|
// Sitting out: nothing is loaded; the card covers the
|
||||||
@@ -319,11 +483,67 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _video(VideoControlsBuilder? controls) => Video(
|
Widget _video(VideoControlsBuilder? controls) => VideoOutputSizer(
|
||||||
key: _videoKey,
|
playback: _coord.playback,
|
||||||
controller: _coord.playback.video,
|
sharp: _settings.screenResVideo,
|
||||||
subtitleViewConfiguration: const SubtitleViewConfiguration(visible: false),
|
child: Video(
|
||||||
controls: controls,
|
key: _videoKey,
|
||||||
|
controller: videoControllerOf(_coord.playback.video),
|
||||||
|
subtitleViewConfiguration: const SubtitleViewConfiguration(visible: false),
|
||||||
|
controls: controls,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
/// The quick chat box in the control bar's place, centred (Enter opens
|
||||||
|
/// it), and the echo of what you sent.
|
||||||
|
Widget _quickChatBox() => Positioned(
|
||||||
|
left: _edge,
|
||||||
|
right: _edge,
|
||||||
|
bottom: _edge,
|
||||||
|
child: Stack(
|
||||||
|
alignment: Alignment.bottomCenter,
|
||||||
|
children: [
|
||||||
|
QuickChatEcho(chat: _quick),
|
||||||
|
QuickChatBox(chat: _quick, onSend: _send, onHeight: _boxHeightChanged),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
/// The player panel docked at the right (⚙ → Player settings), between
|
||||||
|
/// the top bar and the control bar; it stays while they hide.
|
||||||
|
Widget _playerPanel() => Positioned(
|
||||||
|
right: _edge,
|
||||||
|
top: _topBarHeight + 8,
|
||||||
|
bottom: _bottomControlsHeightNow() + _edge,
|
||||||
|
child: SignalBuilder(builder: (context) {
|
||||||
|
final open = _model.panelOpen.value;
|
||||||
|
return AnimatedSwitcher(
|
||||||
|
duration: easeTime,
|
||||||
|
switchInCurve: ease,
|
||||||
|
switchOutCurve: Curves.easeIn,
|
||||||
|
layoutBuilder: (current, previous) =>
|
||||||
|
Stack(alignment: Alignment.topRight, children: [...previous, ?current]),
|
||||||
|
transitionBuilder: (child, a) => FadeTransition(
|
||||||
|
opacity: a,
|
||||||
|
child: SlideTransition(
|
||||||
|
position: Tween(begin: const Offset(0.12, 0), end: Offset.zero).animate(a),
|
||||||
|
child: child,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: !open
|
||||||
|
? const SizedBox.shrink(key: ValueKey('no-player-panel'))
|
||||||
|
: ControlsHoldRegion(
|
||||||
|
key: const ValueKey('player-panel-hold'),
|
||||||
|
hold: _hold,
|
||||||
|
child: PlayerPanel(
|
||||||
|
onClose: () => _model.panelOpen.value = false,
|
||||||
|
renderMode: _model.renderMode,
|
||||||
|
volume: _model.volume,
|
||||||
|
onVolume: _model.setVolume,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
/// Fullscreen hides the strip and its window buttons: the top bar
|
/// Fullscreen hides the strip and its window buttons: the top bar
|
||||||
@@ -350,12 +570,30 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
|||||||
fit: StackFit.expand,
|
fit: StackFit.expand,
|
||||||
children: [
|
children: [
|
||||||
const StartBanner(),
|
const StartBanner(),
|
||||||
|
// Under the controls, over the picture.
|
||||||
|
SignalBuilder(
|
||||||
|
builder: (context) => _danmakuOn.value
|
||||||
|
? DanmakuNotices(
|
||||||
|
toasts: _coord.toasts,
|
||||||
|
style: _settings.danmakuStyle,
|
||||||
|
controller: _lines,
|
||||||
|
avatar: (name, id) => avatarForName(name, _coord, id),
|
||||||
|
skip: _quick.echoed,
|
||||||
|
)
|
||||||
|
: const SizedBox.shrink(),
|
||||||
|
),
|
||||||
|
if (widget.danmaku case final d?)
|
||||||
|
SignalBuilder(
|
||||||
|
builder: (context) =>
|
||||||
|
d.active.value ? DanmakuPreviewLayers(preview: d, lift: _lift) : const SizedBox.shrink(),
|
||||||
|
),
|
||||||
SessionControls(
|
SessionControls(
|
||||||
model: _model,
|
model: _model,
|
||||||
shown: _controlsShown,
|
shown: _controlsShown,
|
||||||
hold: _hold,
|
hold: _hold,
|
||||||
onDoubleClick: widget.onToggleFullscreen,
|
onDoubleClick: widget.onToggleFullscreen,
|
||||||
windowButtons: _fullscreenWindowButtons(),
|
windowButtons: _fullscreenWindowButtons(),
|
||||||
|
suppress: _boxUp,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -366,12 +604,33 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
|||||||
static const _edge = 16.0;
|
static const _edge = 16.0;
|
||||||
|
|
||||||
/// The bottom controls' height in the skin and size in use.
|
/// The bottom controls' height in the skin and size in use.
|
||||||
static double _bottomControlsHeight() {
|
double _bottomControlsHeight() =>
|
||||||
final s = AppSettings.instance;
|
controlsBottomHeight(_settings.controlsSkin.value, _settings.controlsSize.value);
|
||||||
return controlsBottomHeight(s.controlsSkin.value, s.controlsSize.value);
|
|
||||||
}
|
|
||||||
|
|
||||||
static double get _topBarHeight => controlsTopHeight(AppSettings.instance.controlsSkin.peek());
|
double _bottomControlsHeightNow() =>
|
||||||
|
controlsBottomHeight(_settings.controlsSkin.peek(), _settings.controlsSize.peek());
|
||||||
|
|
||||||
|
double get _topBarHeight => controlsTopHeight(_settings.controlsSkin.peek());
|
||||||
|
|
||||||
|
/// The danmaku preview's panel, top right under the top bar; the mouse
|
||||||
|
/// on it keeps the controls up.
|
||||||
|
Widget _danmakuPanel(DanmakuPreview d) => Positioned(
|
||||||
|
right: _edge,
|
||||||
|
top: _topBarHeight + 8,
|
||||||
|
child: SignalBuilder(
|
||||||
|
builder: (context) => fadeThrough(
|
||||||
|
expand: false,
|
||||||
|
alignment: Alignment.topRight,
|
||||||
|
d.active.value
|
||||||
|
? ControlsHoldRegion(
|
||||||
|
key: const ValueKey('danmaku-panel'),
|
||||||
|
hold: _hold,
|
||||||
|
child: DanmakuSessionPanel(preview: d, coord: _coord),
|
||||||
|
)
|
||||||
|
: const SizedBox.shrink(key: ValueKey('danmaku-none')),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
/// The volume flash after a wheel or key step: a pill under the top bar.
|
/// The volume flash after a wheel or key step: a pill under the top bar.
|
||||||
Widget _volumeReadout() => Positioned(
|
Widget _volumeReadout() => Positioned(
|
||||||
@@ -396,15 +655,20 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
|||||||
Widget _actionArea() {
|
Widget _actionArea() {
|
||||||
return SignalBuilder(builder: (context) {
|
return SignalBuilder(builder: (context) {
|
||||||
final controls = _bottomControlsHeight();
|
final controls = _bottomControlsHeight();
|
||||||
return ValueListenableBuilder<bool>(
|
return ListenableBuilder(
|
||||||
valueListenable: _controlsShown,
|
listenable: Listenable.merge([_controlsShown, _boxUp]),
|
||||||
builder: (context, shown, child) => AnimatedPositioned(
|
builder: (context, child) => AnimatedPositioned(
|
||||||
duration: easeTime,
|
duration: easeTime,
|
||||||
curve: ease,
|
curve: ease,
|
||||||
left: _edge,
|
left: _edge,
|
||||||
right: _edge,
|
right: _edge,
|
||||||
top: 0,
|
top: 0,
|
||||||
bottom: shown ? controls + _edge : _edge,
|
// Above the quick chat box while it (or its echo) is up.
|
||||||
|
bottom: _boxUp.value
|
||||||
|
? _boxHeight + _edge * 2
|
||||||
|
: _controlsShown.value
|
||||||
|
? controls + _edge
|
||||||
|
: _edge,
|
||||||
child: child!,
|
child: child!,
|
||||||
),
|
),
|
||||||
child: CustomMultiChildLayout(
|
child: CustomMultiChildLayout(
|
||||||
@@ -418,16 +682,14 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The action area's right end: the end-of-media card (Up next, else the
|
/// The action area's right end: the end-of-media card (Up next, else
|
||||||
/// next episode; it stays up), else the pills, shown only with the
|
/// the continuation; it stays up), else the pills, shown only with the
|
||||||
/// controls ([PillGate]).
|
/// controls ([PillGate]).
|
||||||
Widget _actionSide() {
|
Widget _actionSide() {
|
||||||
final coord = _coord;
|
final coord = _coord;
|
||||||
return SignalBuilder(
|
return SignalBuilder(
|
||||||
builder: (context) {
|
builder: (context) {
|
||||||
final next = coord.nextEpisode.value;
|
final next = coord.nextEpisode.value;
|
||||||
final countdown = coord.autoAdvance.value;
|
|
||||||
// Up next goes before the next episode.
|
|
||||||
final upNext = coord.queue.countdown.value;
|
final upNext = coord.queue.countdown.value;
|
||||||
final finished = coord.serverState.value?.transport == 'finished';
|
final finished = coord.serverState.value?.transport == 'finished';
|
||||||
final pill = coord.nextPromptVisible.value;
|
final pill = coord.nextPromptVisible.value;
|
||||||
@@ -436,18 +698,6 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
|||||||
if (upNext != null && finished) {
|
if (upNext != null && finished) {
|
||||||
return ControlsHoldRegion(hold: _hold, child: UpNextCard(queue: coord.queue, countdown: upNext));
|
return ControlsHoldRegion(hold: _hold, child: UpNextCard(queue: coord.queue, countdown: upNext));
|
||||||
}
|
}
|
||||||
// "Clear" in Up next hides the next episode for this media.
|
|
||||||
if (next != null && (finished || countdown != null) && coord.queue.shownContinuation.value.isNotEmpty) {
|
|
||||||
return ControlsHoldRegion(
|
|
||||||
hold: _hold,
|
|
||||||
child: EndOfEpisodeCard(
|
|
||||||
next: next,
|
|
||||||
countdown: countdown,
|
|
||||||
onPlayNow: coord.playNextEpisode,
|
|
||||||
onCancel: coord.cancelAutoAdvance,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (!showNext && skip == null) return const SizedBox.shrink();
|
if (!showNext && skip == null) return const SizedBox.shrink();
|
||||||
return Column(
|
return Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
@@ -555,7 +805,7 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
|||||||
child: Stack(
|
child: Stack(
|
||||||
fit: StackFit.expand,
|
fit: StackFit.expand,
|
||||||
children: [
|
children: [
|
||||||
SitOutCard(key: const ValueKey('sit-out-card'), compact: !_full),
|
SitOutCard(key: const ValueKey('sit-out-card'), compact: !_isFull),
|
||||||
Positioned(
|
Positioned(
|
||||||
top: 4,
|
top: 4,
|
||||||
right: 4,
|
right: 4,
|
||||||
@@ -564,9 +814,9 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
|||||||
children: [
|
children: [
|
||||||
IconButton(
|
IconButton(
|
||||||
focusNode: _noFocus,
|
focusNode: _noFocus,
|
||||||
onPressed: _full ? widget.onCollapse : widget.onExpand,
|
onPressed: _isFull ? widget.onCollapse : widget.onExpand,
|
||||||
tooltip: _full ? 'Small player' : 'Expand',
|
tooltip: _isFull ? 'Small player' : 'Expand',
|
||||||
icon: Icon(_full ? Icons.close_fullscreen : Icons.open_in_full, color: Colors.white, size: 18),
|
icon: Icon(_isFull ? Icons.close_fullscreen : Icons.open_in_full, color: Colors.white, size: 18),
|
||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
focusNode: _noFocus,
|
focusNode: _noFocus,
|
||||||
@@ -581,11 +831,13 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
/// Mini: title, expand and close on hover; ready, time and a seekable
|
/// Mini: title, expand and close on hover; the status, play (the full
|
||||||
/// bar at the bottom. A thin progress line stays while not hovered.
|
/// player's play button: ready in a server) and the time, and a bar that
|
||||||
|
/// seeks. A thin progress line stays while not hovered.
|
||||||
Widget _miniOverlay() {
|
Widget _miniOverlay() {
|
||||||
final coord = _coord;
|
final coord = _coord;
|
||||||
final playback = coord.playback;
|
final playback = coord.playback;
|
||||||
|
const scale = 0.8;
|
||||||
return MouseRegion(
|
return MouseRegion(
|
||||||
onEnter: (_) => setState(() => _miniHover = true),
|
onEnter: (_) => setState(() => _miniHover = true),
|
||||||
onExit: (_) => setState(() => _miniHover = false),
|
onExit: (_) => setState(() => _miniHover = false),
|
||||||
@@ -602,58 +854,76 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
|||||||
gradient: LinearGradient(
|
gradient: LinearGradient(
|
||||||
begin: Alignment.topCenter,
|
begin: Alignment.topCenter,
|
||||||
end: Alignment.bottomCenter,
|
end: Alignment.bottomCenter,
|
||||||
colors: [Colors.black54, Colors.transparent, Colors.black87],
|
stops: [0, 0.35, 0.55, 1],
|
||||||
|
colors: [Color(0x99000000), Color(0x00000000), Color(0x00000000), Color(0xCC000000)],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
Row(
|
Padding(
|
||||||
children: [
|
padding: const EdgeInsets.fromLTRB(12, 6, 6, 0),
|
||||||
const SizedBox(width: 10),
|
child: Row(
|
||||||
const Expanded(child: NowPlayingTitle(cover: false, compact: true)),
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
IconButton(
|
children: [
|
||||||
focusNode: _noFocus,
|
const Expanded(
|
||||||
onPressed: widget.onExpand,
|
child: Padding(
|
||||||
tooltip: 'Expand',
|
padding: EdgeInsets.only(top: 4),
|
||||||
icon: const Icon(Icons.open_in_full, color: Colors.white, size: 18),
|
child: NowPlayingTitle(cover: false, compact: true),
|
||||||
),
|
),
|
||||||
IconButton(
|
),
|
||||||
focusNode: _noFocus,
|
MinimalButton(
|
||||||
onPressed: widget.onClose,
|
icon: Icons.open_in_full_rounded,
|
||||||
tooltip: 'Sit out (stay in the server)',
|
tooltip: 'Expand',
|
||||||
icon: const Icon(Icons.close, color: Colors.white, size: 18),
|
scale: scale,
|
||||||
),
|
onPressed: widget.onExpand),
|
||||||
],
|
MinimalButton(
|
||||||
|
icon: Icons.close_rounded,
|
||||||
|
tooltip: 'Sit out (stay in the server)',
|
||||||
|
scale: scale,
|
||||||
|
onPressed: widget.onClose),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
SignalBuilder(builder: (context) {
|
SignalBuilder(builder: (context) {
|
||||||
final ready = coord.selfMember.value?.ready ?? false;
|
final snap = _model.snapshot.value;
|
||||||
final pos = playback.position.value, dur = playback.duration.value;
|
final pos = playback.position.value, dur = playback.duration.value;
|
||||||
return Row(
|
final status = playerStatusNow(coord)?.short;
|
||||||
children: [
|
return Padding(
|
||||||
IconButton(
|
padding: const EdgeInsets.fromLTRB(8, 0, 12, 2),
|
||||||
focusNode: _noFocus,
|
child: Column(
|
||||||
tooltip: ready ? 'Ready (click: not ready)' : 'Not ready (click: ready)',
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
onPressed: coord.toggleReady,
|
children: [
|
||||||
icon: Icon(
|
if (status != null && status.isNotEmpty)
|
||||||
ready ? Icons.check_circle : Icons.radio_button_unchecked,
|
Padding(
|
||||||
color: ready ? Colors.greenAccent : Colors.white,
|
padding: const EdgeInsets.fromLTRB(4, 0, 0, 2),
|
||||||
|
child: Text(status,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: const TextStyle(color: Color(0xFFFFC857), fontSize: 12)),
|
||||||
|
),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
MinimalButton(
|
||||||
|
icon: controlIcon(BarControl.playPause, snap),
|
||||||
|
tooltip: controlTooltip(BarControl.playPause, snap),
|
||||||
|
accent: true,
|
||||||
|
scale: scale,
|
||||||
|
onPressed: _model.togglePlay,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text('${formatClock(pos)} / ${formatClock(dur)}',
|
||||||
|
style: const TextStyle(
|
||||||
|
color: MinimalStyle.dim,
|
||||||
|
fontSize: 12,
|
||||||
|
fontFeatures: [FontFeature.tabularFigures()])),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
Text('${formatClock(pos)} / ${formatClock(dur)}',
|
),
|
||||||
style: const TextStyle(color: Colors.white70, fontSize: 12)),
|
|
||||||
const Spacer(),
|
|
||||||
Flexible(
|
|
||||||
child: Text(playerStatusNow(_coord)?.short ?? '',
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: const TextStyle(color: Colors.amber, fontSize: 12)),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 10),
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
_MiniSeekBar(onSeek: (d) => playback.player.seek(d)),
|
_MiniSeekBar(onSeek: playback.userSeek),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -667,6 +937,9 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The mini player's empty controls slot (see [SessionPlayer]'s video).
|
||||||
|
Widget _noControls(VideoState state) => const SizedBox.shrink();
|
||||||
|
|
||||||
/// A floating pill's visibility: only while the controls are up (the
|
/// A floating pill's visibility: only while the controls are up (the
|
||||||
/// mouse moved) or the mouse is on it. With [announce] it is also up for
|
/// mouse moved) or the mouse is on it. With [announce] it is also up for
|
||||||
/// [firstShown] whenever [kind] changes (a new offer: Skip OP, Skip 1:30).
|
/// [firstShown] whenever [kind] changes (a new offer: Skip OP, Skip 1:30).
|
||||||
@@ -781,7 +1054,9 @@ const _shaderKeys = [
|
|||||||
LogicalKeyboardKey.digit6,
|
LogicalKeyboardKey.digit6,
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Mini player progress: a 2 px line, or (hovered) a 4 px bar that seeks.
|
/// Mini player progress across the bottom edge: a 3 px line, or (hovered)
|
||||||
|
/// a 4 px bar with its track and the cached part that seeks (taller to
|
||||||
|
/// hit). Plain rectangles: the player's rounded clip shapes the ends.
|
||||||
class _MiniSeekBar extends StatelessWidget {
|
class _MiniSeekBar extends StatelessWidget {
|
||||||
final bool thin;
|
final bool thin;
|
||||||
final ValueChanged<Duration>? onSeek;
|
final ValueChanged<Duration>? onSeek;
|
||||||
@@ -790,26 +1065,59 @@ class _MiniSeekBar extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final playback = AppScope.of(context).coordinator.playback;
|
final playback = AppScope.of(context).coordinator.playback;
|
||||||
|
final accent = Theme.of(context).colorScheme.primary;
|
||||||
return SignalBuilder(builder: (context) {
|
return SignalBuilder(builder: (context) {
|
||||||
final d = playback.duration.value;
|
final d = playback.duration.value;
|
||||||
final p = playback.position.value;
|
double f(Duration x) => d.inMilliseconds == 0 ? 0.0 : (x.inMilliseconds / d.inMilliseconds).clamp(0.0, 1.0);
|
||||||
final v = d.inMilliseconds == 0 ? 0.0 : (p.inMilliseconds / d.inMilliseconds).clamp(0.0, 1.0);
|
final bar = SizedBox(
|
||||||
final bar = LinearProgressIndicator(
|
height: thin ? 3 : 4,
|
||||||
value: v,
|
width: double.infinity,
|
||||||
minHeight: thin ? 2 : 4,
|
child: CustomPaint(
|
||||||
backgroundColor: thin ? Colors.transparent : Colors.white24,
|
painter: _MiniBarPainter(
|
||||||
|
played: f(playback.position.value),
|
||||||
|
cached: thin ? null : f(playback.buffered.value),
|
||||||
|
accent: accent,
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
if (thin || onSeek == null || d == Duration.zero) return bar;
|
if (thin || onSeek == null || d == Duration.zero) return bar;
|
||||||
void seekAt(double x, double width) => onSeek!(d * (x / width).clamp(0.0, 1.0));
|
void seekAt(double x, double width) => onSeek!(d * (x / width).clamp(0.0, 1.0));
|
||||||
return LayoutBuilder(
|
return LayoutBuilder(
|
||||||
builder: (context, box) => GestureDetector(
|
builder: (context, box) => MouseRegion(
|
||||||
behavior: HitTestBehavior.opaque,
|
cursor: SystemMouseCursors.click,
|
||||||
onTapDown: (e) => seekAt(e.localPosition.dx, box.maxWidth),
|
child: GestureDetector(
|
||||||
onHorizontalDragEnd: null,
|
behavior: HitTestBehavior.opaque,
|
||||||
onHorizontalDragUpdate: (e) => seekAt(e.localPosition.dx, box.maxWidth),
|
onTapDown: (e) => seekAt(e.localPosition.dx, box.maxWidth),
|
||||||
child: Padding(padding: const EdgeInsets.symmetric(vertical: 6), child: bar),
|
onHorizontalDragUpdate: (e) => seekAt(e.localPosition.dx, box.maxWidth),
|
||||||
|
child: Padding(padding: const EdgeInsets.only(top: 8), child: bar),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _MiniBarPainter extends CustomPainter {
|
||||||
|
final double played;
|
||||||
|
|
||||||
|
/// End of the cache ahead (a track is drawn only with it).
|
||||||
|
final double? cached;
|
||||||
|
final Color accent;
|
||||||
|
const _MiniBarPainter({required this.played, required this.cached, required this.accent});
|
||||||
|
|
||||||
|
@override
|
||||||
|
void paint(Canvas canvas, Size size) {
|
||||||
|
final w = size.width, h = size.height;
|
||||||
|
final c = cached;
|
||||||
|
if (c != null) {
|
||||||
|
canvas.drawRect(Offset.zero & size, Paint()..color = Colors.white.withValues(alpha: 0.18));
|
||||||
|
if (c > played) {
|
||||||
|
canvas.drawRect(Rect.fromLTRB(w * played, 0, w * c, h), Paint()..color = Colors.white.withValues(alpha: 0.3));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
canvas.drawRect(Rect.fromLTRB(0, 0, w * played, h), Paint()..color = accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool shouldRepaint(_MiniBarPainter old) => old.played != played || old.cached != cached || old.accent != accent;
|
||||||
|
}
|
||||||
@@ -8,12 +8,13 @@ import 'package:sync_relay_core/core.dart'
|
|||||||
show CookieBrowser, CookieFile, NetscapeCookie, YtDlp, YtDlpCookies, cookieBrowsers, parseCookies;
|
show CookieBrowser, CookieFile, NetscapeCookie, YtDlp, YtDlpCookies, cookieBrowsers, parseCookies;
|
||||||
|
|
||||||
import '../controls/player_settings.dart';
|
import '../controls/player_settings.dart';
|
||||||
|
import '../danmaku/danmaku_settings.dart' show DanmakuSettingsPanel;
|
||||||
import '../jellyfin/accounts.dart';
|
import '../jellyfin/accounts.dart';
|
||||||
import '../jellyfin/accounts_panel.dart';
|
import '../jellyfin/accounts_panel.dart';
|
||||||
import '../schedule/animeflow.dart';
|
import '../schedule/animeflow.dart';
|
||||||
import '../screens/home/about_page.dart';
|
import '../screens/home/about_page.dart';
|
||||||
import '../screens/home/settings_page.dart';
|
import '../screens/home/settings_page.dart';
|
||||||
import '../screens/home/ui.dart' show appName, displayVersion;
|
import '../screens/home/ui.dart' show appName, devTools, displayVersion;
|
||||||
import '../session/auto_sit_out.dart' show AutoSitOutTimes;
|
import '../session/auto_sit_out.dart' show AutoSitOutTimes;
|
||||||
import '../sfx/sfx.dart';
|
import '../sfx/sfx.dart';
|
||||||
import '../sfx/sound_bank.dart';
|
import '../sfx/sound_bank.dart';
|
||||||
@@ -52,6 +53,8 @@ class SettingsTab extends StatelessWidget {
|
|||||||
final JellyfinAccounts? jellyfin;
|
final JellyfinAccounts? jellyfin;
|
||||||
/// AnimeFlow's `/me` for the integrations page (tests pass a fake).
|
/// AnimeFlow's `/me` for the integrations page (tests pass a fake).
|
||||||
final Future<AnimeFlowMe> Function()? animeFlowMe;
|
final Future<AnimeFlowMe> Function()? animeFlowMe;
|
||||||
|
/// Dev builds: About → Developer → Danmaku preview.
|
||||||
|
final VoidCallback? onDanmakuPreview;
|
||||||
const SettingsTab({
|
const SettingsTab({
|
||||||
super.key,
|
super.key,
|
||||||
required this.section,
|
required this.section,
|
||||||
@@ -60,6 +63,7 @@ class SettingsTab extends StatelessWidget {
|
|||||||
this.updates,
|
this.updates,
|
||||||
this.jellyfin,
|
this.jellyfin,
|
||||||
this.animeFlowMe,
|
this.animeFlowMe,
|
||||||
|
this.onDanmakuPreview,
|
||||||
});
|
});
|
||||||
|
|
||||||
List<Widget> _page(BuildContext context) {
|
List<Widget> _page(BuildContext context) {
|
||||||
@@ -121,7 +125,8 @@ class SettingsTab extends StatelessWidget {
|
|||||||
return [
|
return [
|
||||||
const SettingsHintCard(
|
const SettingsHintCard(
|
||||||
icon: Icons.tune,
|
icon: Icons.tune,
|
||||||
text: "Player settings are also in the player: ⚙ → Player settings, at the right of its controls.",
|
text: "Player settings are also in the player: ⚙ → Player settings opens a panel at its right edge "
|
||||||
|
"that stays while you watch.",
|
||||||
),
|
),
|
||||||
SettingsPanel(
|
SettingsPanel(
|
||||||
title: 'Player',
|
title: 'Player',
|
||||||
@@ -152,7 +157,7 @@ class SettingsTab extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
const SettingsPanel(title: 'Jellyfin playback', child: AutoAdvanceSetting()),
|
const SettingsPanel(title: 'Episodes', child: AutoAdvanceSetting()),
|
||||||
const SettingsPanel(title: 'Jellyfin library', child: HideReReleasesSetting()),
|
const SettingsPanel(title: 'Jellyfin library', child: HideReReleasesSetting()),
|
||||||
];
|
];
|
||||||
case SettingsSection.notifications:
|
case SettingsSection.notifications:
|
||||||
@@ -162,7 +167,10 @@ class SettingsTab extends StatelessWidget {
|
|||||||
case SettingsSection.shortcuts:
|
case SettingsSection.shortcuts:
|
||||||
return const [SettingsPanel(title: 'Keyboard shortcuts', child: ShortcutList())];
|
return const [SettingsPanel(title: 'Keyboard shortcuts', child: ShortcutList())];
|
||||||
case SettingsSection.about:
|
case SettingsSection.about:
|
||||||
return [AboutPanel(updates: updates)];
|
return [
|
||||||
|
AboutPanel(updates: updates),
|
||||||
|
if (devTools) DeveloperPanel(onDanmakuPreview: onDanmakuPreview),
|
||||||
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -772,7 +780,8 @@ class NotificationsInfo extends StatelessWidget {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
line('Joins, leaves, lost connections and video changes',
|
line('Joins, leaves, lost connections and video changes',
|
||||||
"Pop up bottom left for 5 s with the person's picture, above the player's controls"),
|
"Pop up bottom left for 5 s with the person's picture, above the player's controls "
|
||||||
|
'(or fly across the picture: Danmaku, below)'),
|
||||||
line('Added to Up next', 'With the title, its picture and who added it'),
|
line('Added to Up next', 'With the title, its picture and who added it'),
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||||
@@ -805,6 +814,11 @@ class NotificationsInfo extends StatelessWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
SettingsPanel(
|
||||||
|
key: const ValueKey('danmaku-settings'),
|
||||||
|
title: 'Over the player',
|
||||||
|
child: DanmakuSettingsPanel(settings: AppScope.of(context).coordinator.settings),
|
||||||
|
),
|
||||||
SettingsPanel(
|
SettingsPanel(
|
||||||
title: 'Sitting out and in the background',
|
title: 'Sitting out and in the background',
|
||||||
child: Column(
|
child: Column(
|
||||||
|
|||||||
@@ -58,7 +58,11 @@ ShellToast shellToastFor(ToastItem t, AvatarInfo Function(String name) avatar) {
|
|||||||
/// all (chat pop-ups, sitting out, alone) is the Notifier's policy.
|
/// all (chat pop-ups, sitting out, alone) is the Notifier's policy.
|
||||||
class PartyToasts extends StatefulWidget {
|
class PartyToasts extends StatefulWidget {
|
||||||
final VoidCallback? onOpenChat;
|
final VoidCallback? onOpenChat;
|
||||||
const PartyToasts({super.key, this.onOpenChat});
|
|
||||||
|
/// Toasts shown another way (flying as danmaku, your quick chat echo):
|
||||||
|
/// left out when they come.
|
||||||
|
final bool Function(ToastItem t)? skip;
|
||||||
|
const PartyToasts({super.key, this.onOpenChat, this.skip});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<PartyToasts> createState() => _PartyToastsState();
|
State<PartyToasts> createState() => _PartyToastsState();
|
||||||
@@ -86,6 +90,7 @@ class _PartyToastsState extends State<PartyToasts> {
|
|||||||
// Only new toasts re-run this, not the party they mention.
|
// Only new toasts re-run this, not the party they mention.
|
||||||
untracked(() {
|
untracked(() {
|
||||||
for (final t in fresh) {
|
for (final t in fresh) {
|
||||||
|
if (widget.skip?.call(t) ?? false) continue;
|
||||||
_add(shellToastFor(t, (n) => avatarForName(n, coord, t.actorId)));
|
_add(shellToastFor(t, (n) => avatarForName(n, coord, t.actorId)));
|
||||||
}
|
}
|
||||||
if (gone.isNotEmpty) _leave(gone);
|
if (gone.isNotEmpty) _leave(gone);
|
||||||
|
|||||||
@@ -0,0 +1,273 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:math' as math;
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart' show ValueListenable;
|
||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
import 'package:signals/signals_flutter.dart';
|
||||||
|
|
||||||
|
import '../playback/playback_controller.dart';
|
||||||
|
|
||||||
|
// What the session's video view tells mpv about where it is shown: the
|
||||||
|
// size its output renders at ("Sharp video and subtitles") and how far
|
||||||
|
// bottom subtitles move up while something covers the bottom of the
|
||||||
|
// picture (the quick chat box; the control bar when chosen).
|
||||||
|
|
||||||
|
/// The view size the host fits each video output into ([fitOutputSize])
|
||||||
|
/// for a view of [logical] size at [dpr]: its
|
||||||
|
/// physical pixels when [on] and video renders on the GPU, else null (the
|
||||||
|
/// video's own size). Software rendering keeps the video's size: every
|
||||||
|
/// pixel there is CPU work.
|
||||||
|
(int, int)? videoOutputSize(Size logical, double dpr, {required bool on, required RenderMode mode}) {
|
||||||
|
if (!on || mode != RenderMode.gpu) return null;
|
||||||
|
final w = (logical.width * dpr).round(), h = (logical.height * dpr).round();
|
||||||
|
if (w < 16 || h < 16) return null;
|
||||||
|
return (w, h);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How long a view size must hold before the output follows it (a
|
||||||
|
/// window drag resizes many times a second; each resize reallocates the
|
||||||
|
/// output's texture).
|
||||||
|
const outputResizeDelay = Duration(milliseconds: 120);
|
||||||
|
|
||||||
|
/// Sends the size of the view around [child] to the player host (see
|
||||||
|
/// [videoOutputSize]): at once the first time, then once a resize has
|
||||||
|
/// settled for [outputResizeDelay]. Follows the setting and the render
|
||||||
|
/// mode live.
|
||||||
|
class VideoOutputSizer extends StatefulWidget {
|
||||||
|
final PlaybackController playback;
|
||||||
|
|
||||||
|
/// The setting ([AppSettings.screenResVideo]).
|
||||||
|
final ReadonlySignal<bool> sharp;
|
||||||
|
final Widget child;
|
||||||
|
const VideoOutputSizer({super.key, required this.playback, required this.sharp, required this.child});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<VideoOutputSizer> createState() => _VideoOutputSizerState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _VideoOutputSizerState extends State<VideoOutputSizer> {
|
||||||
|
Size? _view;
|
||||||
|
double _dpr = 1;
|
||||||
|
Timer? _debounce;
|
||||||
|
late final void Function() _stop;
|
||||||
|
var _first = true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_stop = effect(() {
|
||||||
|
widget.sharp.value;
|
||||||
|
widget.playback.renderMode.value;
|
||||||
|
untracked(_send);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_debounce?.cancel();
|
||||||
|
_stop();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _send() {
|
||||||
|
final view = _view;
|
||||||
|
if (view == null) return;
|
||||||
|
final size = videoOutputSize(view, _dpr,
|
||||||
|
on: widget.sharp.peek(), mode: widget.playback.renderMode.peek());
|
||||||
|
widget.playback.host.setOutputSize(size?.$1, size?.$2);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _resized(Size view, double dpr) {
|
||||||
|
if (view == _view && dpr == _dpr) return;
|
||||||
|
_view = view;
|
||||||
|
_dpr = dpr;
|
||||||
|
_debounce?.cancel();
|
||||||
|
if (_first) {
|
||||||
|
_first = false;
|
||||||
|
_send();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_debounce = Timer(outputResizeDelay, _send);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final dpr = MediaQuery.devicePixelRatioOf(context);
|
||||||
|
return LayoutBuilder(builder: (context, box) {
|
||||||
|
_resized(box.biggest, dpr);
|
||||||
|
return widget.child;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// mpv `sub-pos` (100 = where the file puts them) that lifts bottom
|
||||||
|
/// subtitles clear of what covers the bottom of the view ([controls]
|
||||||
|
/// logical px tall: the control bar or the chat line, plus [gap]) in a [view] showing a [videoW] x [videoH] video fitted
|
||||||
|
/// inside it: only as far as the letterbox under the picture does not
|
||||||
|
/// already make room. `sub-pos` moves bottom-aligned lines by a share of
|
||||||
|
/// the picture's height; top-aligned ASS signs stay put.
|
||||||
|
double subtitleLiftPos({required Size view, int? videoW, int? videoH, required double controls, double gap = 8}) {
|
||||||
|
if (view.height <= 0 || view.width <= 0) return 100;
|
||||||
|
var picH = view.height;
|
||||||
|
if (videoW != null && videoH != null && videoW > 0 && videoH > 0) {
|
||||||
|
picH = math.min(view.height, view.width * videoH / videoW);
|
||||||
|
}
|
||||||
|
final bar = (view.height - picH) / 2;
|
||||||
|
final shift = math.max(0.0, controls + gap - bar);
|
||||||
|
return (100 - shift / picH * 100).clamp(50.0, 100.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The subtitle lift moves in [liftStepCount] steps [liftStepGap] apart
|
||||||
|
/// (about 130 ms): each `sub-pos` change makes mpv re-render the
|
||||||
|
/// subtitles, and a playing video shows a change only with its next
|
||||||
|
/// frame, so a change per screen frame (60 a second against a video's 24)
|
||||||
|
/// landed unevenly and looked choppy. One step per frame of a 24 fps
|
||||||
|
/// video, eased out (most of the way in the first step), reads as one
|
||||||
|
/// quick, smooth move.
|
||||||
|
const liftStepCount = 4;
|
||||||
|
const liftStepGap = Duration(milliseconds: 42);
|
||||||
|
|
||||||
|
/// The covered heights a lift from [from] to [to] passes through, one per
|
||||||
|
/// step, eased out; the last is exactly [to]. Empty when there is nothing
|
||||||
|
/// to move.
|
||||||
|
List<double> liftSteps(double from, double to, {int count = liftStepCount}) {
|
||||||
|
if ((to - from).abs() < 0.5) return const [];
|
||||||
|
return [
|
||||||
|
for (var i = 1; i <= count; i++) i == count ? to : from + (to - from) * Curves.easeOutQuad.transform(i / count),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How much of the bottom of the view is covered, as it moves: [value]
|
||||||
|
/// the covered height in logical px (0: nothing), stepping toward the
|
||||||
|
/// latest [animateTo] target ([liftSteps], [liftStepGap] apart; a new
|
||||||
|
/// target starts from where it is). mpv's `sub-pos` follows it
|
||||||
|
/// ([SubtitleLifter]), and so do the danmaku preview's mock subtitles.
|
||||||
|
class SubtitleLift extends ChangeNotifier implements ValueListenable<double> {
|
||||||
|
double _value = 0;
|
||||||
|
double _target = 0;
|
||||||
|
List<double> _steps = const [];
|
||||||
|
int _next = 0;
|
||||||
|
Timer? _timer;
|
||||||
|
|
||||||
|
@override
|
||||||
|
double get value => _value;
|
||||||
|
double get target => _target;
|
||||||
|
|
||||||
|
/// Moves toward [target] (px); the first step at once.
|
||||||
|
void animateTo(double target) {
|
||||||
|
target = math.max(0, target);
|
||||||
|
if ((target - _target).abs() < 0.5) return;
|
||||||
|
_target = target;
|
||||||
|
_timer?.cancel();
|
||||||
|
_timer = null;
|
||||||
|
_steps = liftSteps(_value, target);
|
||||||
|
_next = 0;
|
||||||
|
if (_steps.isEmpty) return;
|
||||||
|
_step();
|
||||||
|
if (_next < _steps.length) _timer = Timer.periodic(liftStepGap, (_) => _step());
|
||||||
|
}
|
||||||
|
|
||||||
|
void _step() {
|
||||||
|
if (_next >= _steps.length) return;
|
||||||
|
_value = _steps[_next++];
|
||||||
|
if (_next >= _steps.length) {
|
||||||
|
_timer?.cancel();
|
||||||
|
_timer = null;
|
||||||
|
}
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_timer?.cancel();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bottom subtitles of the current player sit above what covers the
|
||||||
|
/// bottom of the full player ([lift]: the quick chat box, and the control
|
||||||
|
/// bar while it is up when [AppSettings.liftSubtitles] is on): mpv
|
||||||
|
/// `sub-pos` follows each step of [lift], only as far as the letterbox
|
||||||
|
/// under the picture leaves no room ([subtitleLiftPos]). Top-aligned
|
||||||
|
/// signs stay put. Draws nothing.
|
||||||
|
class SubtitleLifter extends StatefulWidget {
|
||||||
|
final PlaybackController playback;
|
||||||
|
final SubtitleLift lift;
|
||||||
|
|
||||||
|
/// Only the full player lifts.
|
||||||
|
final bool active;
|
||||||
|
const SubtitleLifter({super.key, required this.playback, required this.lift, required this.active});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<SubtitleLifter> createState() => _SubtitleLifterState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SubtitleLifterState extends State<SubtitleLifter> {
|
||||||
|
static const _home = '100.0';
|
||||||
|
String _sent = _home;
|
||||||
|
HostedPlayer? _player;
|
||||||
|
Size _view = Size.zero;
|
||||||
|
late final void Function() _stop;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
widget.lift.addListener(_apply);
|
||||||
|
// A new player starts where the file wants its subtitles.
|
||||||
|
_stop = effect(() {
|
||||||
|
widget.playback.generation.value;
|
||||||
|
untracked(_apply);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(SubtitleLifter old) {
|
||||||
|
super.didUpdateWidget(old);
|
||||||
|
if (old.lift != widget.lift) {
|
||||||
|
old.lift.removeListener(_apply);
|
||||||
|
widget.lift.addListener(_apply);
|
||||||
|
}
|
||||||
|
if (old.active != widget.active) _apply();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
widget.lift.removeListener(_apply);
|
||||||
|
_stop();
|
||||||
|
// Leave the player as the file wants it.
|
||||||
|
if (_sent != _home) _send(_home);
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _apply() {
|
||||||
|
if (!mounted) return;
|
||||||
|
final p = widget.playback.current;
|
||||||
|
if (!identical(p, _player)) {
|
||||||
|
_player = p;
|
||||||
|
_sent = _home;
|
||||||
|
}
|
||||||
|
final cover = widget.active ? widget.lift.value : 0.0;
|
||||||
|
final st = p?.state;
|
||||||
|
final pos = cover <= 0
|
||||||
|
? 100.0
|
||||||
|
: subtitleLiftPos(view: _view, videoW: st?.width, videoH: st?.height, controls: cover, gap: 0);
|
||||||
|
final v = pos.toStringAsFixed(1);
|
||||||
|
if (v != _sent) _send(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _send(String pos) {
|
||||||
|
_sent = pos;
|
||||||
|
final p = widget.playback.current;
|
||||||
|
if (p == null || p.disposed) return;
|
||||||
|
unawaited(p.setProperty('sub-pos', pos).catchError((Object _) {}));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => LayoutBuilder(builder: (context, box) {
|
||||||
|
if (box.biggest != _view) {
|
||||||
|
_view = box.biggest;
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) => _apply());
|
||||||
|
}
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:signals/signals_flutter.dart';
|
||||||
|
|
||||||
|
import '../app/app_graph.dart';
|
||||||
|
import '../session/app_events.dart' show ActionFailed;
|
||||||
|
import '../session/invite.dart';
|
||||||
|
import '../widgets/copy_open.dart';
|
||||||
|
|
||||||
|
/// Your server in the members card. Not shared: "Only you can reach it"
|
||||||
|
/// and Invite (opens it and copies the invite link). Shared: Copy invite
|
||||||
|
/// link (the bare `starsync://` link with the password, at the address
|
||||||
|
/// picked: the internet one when known, else this machine's), a short
|
||||||
|
/// line only when something needs the user ([YourServer.reach]: the port
|
||||||
|
/// needs forwarding, no internet address), Stop sharing, and the other
|
||||||
|
/// addresses (Internet / Home network / VPN) folded under a toggle.
|
||||||
|
class YourInvite extends StatefulWidget {
|
||||||
|
const YourInvite({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<YourInvite> createState() => _YourInviteState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _YourInviteState extends State<YourInvite> {
|
||||||
|
/// The address picked (null: the invite's own, the best one).
|
||||||
|
String? _picked;
|
||||||
|
bool _others = false;
|
||||||
|
|
||||||
|
static String _kindLabel(AddressKind k) => switch (k) {
|
||||||
|
AddressKind.public => 'Internet',
|
||||||
|
AddressKind.lan => 'Home network',
|
||||||
|
AddressKind.vpn => 'VPN',
|
||||||
|
};
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final muted = Theme.of(context).colorScheme.onSurfaceVariant;
|
||||||
|
final graph = AppScope.of(context);
|
||||||
|
final yours = graph.yourServer;
|
||||||
|
return SignalBuilder(builder: (context) {
|
||||||
|
final invite = yours.shared.value;
|
||||||
|
final reach = yours.reach.value;
|
||||||
|
final busy = yours.busy.value || yours.port.value == null;
|
||||||
|
final small = TextStyle(color: muted, fontSize: 12);
|
||||||
|
if (invite == null) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(12, 0, 12, 12),
|
||||||
|
child: Column(
|
||||||
|
key: const ValueKey('your-invite'),
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(8, 0, 8, 6),
|
||||||
|
child: Text('Only you can reach it. Invite opens it to friends; nothing you watch stops.',
|
||||||
|
style: small),
|
||||||
|
),
|
||||||
|
TextButton.icon(
|
||||||
|
key: const ValueKey('copy-invite'),
|
||||||
|
onPressed: busy ? null : () => inviteFriends(context),
|
||||||
|
icon: const Icon(Icons.group_add_outlined, size: 16),
|
||||||
|
label: const Text('Invite'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final choices = reach?.choices ?? [(host: invite.host, kind: addressKind(invite.host))];
|
||||||
|
final picked = choices.any((c) => c.host == _picked) ? _picked! : invite.host;
|
||||||
|
final kind = choices.where((c) => c.host == picked).firstOrNull?.kind ?? addressKind(picked);
|
||||||
|
final looking = reach?.looking ?? false;
|
||||||
|
final note = switch (kind) {
|
||||||
|
AddressKind.public when reach?.forwarding ?? false =>
|
||||||
|
'Friends outside your home network need port ${invite.port} forwarded to this computer.',
|
||||||
|
AddressKind.public => null,
|
||||||
|
_ when reach != null && !looking && reach.public == null =>
|
||||||
|
"Couldn't find your internet address: this link works on your home network only.",
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(12, 0, 12, 12),
|
||||||
|
child: Column(
|
||||||
|
key: const ValueKey('your-invite'),
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 4,
|
||||||
|
crossAxisAlignment: WrapCrossAlignment.center,
|
||||||
|
children: [
|
||||||
|
Tooltip(
|
||||||
|
message: invite.at(picked).address,
|
||||||
|
child: FilledButton.icon(
|
||||||
|
key: const ValueKey('copy-invite'),
|
||||||
|
onPressed: () {
|
||||||
|
final text = yours.inviteLink(invite.at(picked));
|
||||||
|
graph.joinOffers.copied(text);
|
||||||
|
copyToClipboard(context, text, done: 'Invite link copied');
|
||||||
|
},
|
||||||
|
icon: const Icon(Icons.link, size: 18),
|
||||||
|
label: const Text('Copy invite link'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Tooltip(
|
||||||
|
message: 'Sends everyone else out; you and Up next stay',
|
||||||
|
child: TextButton(
|
||||||
|
key: const ValueKey('stop-sharing'),
|
||||||
|
onPressed: busy ? null : () => yours.stopSharing(),
|
||||||
|
child: const Text('Stop sharing'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (looking)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(4, 8, 4, 0),
|
||||||
|
child: Row(children: [
|
||||||
|
const SizedBox(width: 12, height: 12, child: CircularProgressIndicator(strokeWidth: 2)),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Flexible(child: Text('Looking up your internet address…', style: small)),
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
else if (note != null)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(4, 8, 4, 0),
|
||||||
|
child: Text(note, key: const ValueKey('invite-note'), style: small),
|
||||||
|
),
|
||||||
|
if (choices.length > 1) ...[
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
TextButton.icon(
|
||||||
|
key: const ValueKey('invite-other-addresses'),
|
||||||
|
style: TextButton.styleFrom(
|
||||||
|
foregroundColor: muted,
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
textStyle: Theme.of(context).textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
onPressed: () => setState(() => _others = !_others),
|
||||||
|
icon: Icon(_others ? Icons.expand_less : Icons.expand_more, size: 16),
|
||||||
|
label: const Text('Other addresses'),
|
||||||
|
),
|
||||||
|
if (_others)
|
||||||
|
for (final c in choices)
|
||||||
|
_AddressRow(
|
||||||
|
key: ValueKey('invite-address-${c.host}'),
|
||||||
|
label: _kindLabel(c.kind),
|
||||||
|
address: invite.at(c.host).address,
|
||||||
|
selected: c.host == picked,
|
||||||
|
onTap: () => setState(() => _picked = c.host),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AddressRow extends StatelessWidget {
|
||||||
|
final String label;
|
||||||
|
final String address;
|
||||||
|
final bool selected;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
const _AddressRow(
|
||||||
|
{super.key, required this.label, required this.address, required this.selected, required this.onTap});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final scheme = Theme.of(context).colorScheme;
|
||||||
|
return InkWell(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
onTap: onTap,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
|
||||||
|
child: Row(children: [
|
||||||
|
Icon(selected ? Icons.radio_button_checked : Icons.radio_button_off,
|
||||||
|
size: 18, color: selected ? scheme.primary : scheme.onSurfaceVariant),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
SizedBox(width: 96, child: Text(label, style: TextStyle(fontSize: 12, color: scheme.onSurfaceVariant))),
|
||||||
|
Expanded(child: Text(address, style: const TextStyle(fontFamily: 'monospace'))),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Invite: opens your server to friends if it is not yet and copies the
|
||||||
|
/// bare invite link (with the password, when one is set) at the best
|
||||||
|
/// address (the internet one when found, else this machine's); a port
|
||||||
|
/// that can't be opened is a notice.
|
||||||
|
Future<void> inviteFriends(BuildContext context) async {
|
||||||
|
final graph = AppScope.of(context);
|
||||||
|
final Invite invite;
|
||||||
|
try {
|
||||||
|
invite = await graph.yourServer.invite();
|
||||||
|
} catch (e) {
|
||||||
|
final port = graph.forms.form.hostPort;
|
||||||
|
graph.notifier.notify(ActionFailed(
|
||||||
|
"couldn't open your server on port $port: $e — pick another port in Settings → Party & network"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!context.mounted) return;
|
||||||
|
final text = graph.yourServer.inviteLink(invite);
|
||||||
|
graph.joinOffers.copied(text);
|
||||||
|
await copyToClipboard(context, text, done: 'Invite link copied');
|
||||||
|
}
|
||||||
@@ -184,4 +184,46 @@ class ConnectStore {
|
|||||||
];
|
];
|
||||||
await saveQuietly();
|
await saveQuietly();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The saved password of a recent server at [host]:[port], '' when none.
|
||||||
|
String secretFor(String host, int port) =>
|
||||||
|
recents.peek().where((r) => r.key == '${host.toLowerCase()}:$port').firstOrNull?.secret ?? '';
|
||||||
|
|
||||||
|
/// Invites the join prompt already asked about (joined or dismissed),
|
||||||
|
/// as fingerprints ([inviteFingerprint]), newest last; never the links.
|
||||||
|
final _seenInvites = <String>[];
|
||||||
|
|
||||||
|
bool seenInvite(String fingerprint) {
|
||||||
|
_loadSeen();
|
||||||
|
return _seenInvites.contains(fingerprint);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> rememberInvite(String fingerprint) async {
|
||||||
|
_loadSeen();
|
||||||
|
_seenInvites
|
||||||
|
..remove(fingerprint)
|
||||||
|
..add(fingerprint);
|
||||||
|
while (_seenInvites.length > maxSeenInvites) {
|
||||||
|
_seenInvites.removeAt(0);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
final box = connectFormBoxIfOpen() ?? await openConnectFormBox();
|
||||||
|
await box.put(connectKeySeenInvites, [..._seenInvites]);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('save seen invites failed: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _seenLoaded = false;
|
||||||
|
void _loadSeen() {
|
||||||
|
if (_seenLoaded) return;
|
||||||
|
final box = connectFormBoxIfOpen();
|
||||||
|
if (box == null) return;
|
||||||
|
_seenLoaded = true;
|
||||||
|
final raw = box.get(connectKeySeenInvites);
|
||||||
|
if (raw is List) _seenInvites.insertAll(0, [for (final v in raw) if (v is String && !_seenInvites.contains(v)) v]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const connectKeySeenInvites = 'seenInvites';
|
||||||
|
const maxSeenInvites = 30;
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
import 'dart:io' show Platform;
|
import 'dart:io' show File, Platform;
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:hive_ce/hive.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 UpdateChannel, appVersion, defaultUpdateChannel;
|
||||||
|
|
||||||
import '../controls/model/controls_prefs.dart';
|
import '../controls/model/controls_prefs.dart';
|
||||||
|
import '../danmaku/danmaku.dart' show DanmakuStyle;
|
||||||
import '../shaders/shader_presets.dart';
|
import '../shaders/shader_presets.dart';
|
||||||
import '../session/notice_prefs.dart';
|
import '../session/notice_prefs.dart';
|
||||||
import 'settings_schema.dart';
|
import 'settings_schema.dart';
|
||||||
@@ -44,9 +46,31 @@ const keyAutoSitOut = 'autoSitOut';
|
|||||||
const _keyChatPopups = 'chatPopups';
|
const _keyChatPopups = 'chatPopups';
|
||||||
const _keyNotifyPeople = 'notifyPeople';
|
const _keyNotifyPeople = 'notifyPeople';
|
||||||
const _keyShareDiagnostics = 'shareDiagnostics';
|
const _keyShareDiagnostics = 'shareDiagnostics';
|
||||||
|
const _keyWhatsNewSeen = 'whatsNewSeen';
|
||||||
|
const _keyWhatsNewNotes = 'whatsNewNotes';
|
||||||
|
const _keyScreenResVideo = 'screenResVideo';
|
||||||
|
const _keyLiftSubtitles = 'liftSubtitles';
|
||||||
|
const _keyNotifyStyle = 'notifyStyle';
|
||||||
|
const _keySubScale = 'subScale';
|
||||||
|
const _keyDanmakuStyle = 'danmakuStyle';
|
||||||
|
const _keyUpdateChannel = 'updateChannel';
|
||||||
|
|
||||||
|
/// A file next to the binary naming a channel (`dev` / `stable`): the
|
||||||
|
/// app takes it as the update channel on its next start and deletes it.
|
||||||
|
/// The one-time command `tool/dev_release.sh` prints writes it.
|
||||||
|
const updateChannelFileName = 'update-channel';
|
||||||
|
|
||||||
enum HwdecMode { auto, off, custom }
|
enum HwdecMode { auto, off, custom }
|
||||||
|
|
||||||
|
/// How notifications show over the full player ([AppSettings.notifyStyle]).
|
||||||
|
enum NotifyStyle {
|
||||||
|
toasts('Toasts'),
|
||||||
|
danmaku('Danmaku');
|
||||||
|
|
||||||
|
final String label;
|
||||||
|
const NotifyStyle(this.label);
|
||||||
|
}
|
||||||
|
|
||||||
/// The mpv `hwdec` of [HwdecMode.auto]. Windows: 'auto-copy' (decoded on
|
/// The mpv `hwdec` of [HwdecMode.auto]. Windows: 'auto-copy' (decoded on
|
||||||
/// the GPU, frames copied back): media_kit renders through ANGLE, where
|
/// the GPU, frames copied back): media_kit renders through ANGLE, where
|
||||||
/// plain 'auto' picks the dxva2 interop, which cannot create its EGL
|
/// plain 'auto' picks the dxva2 interop, which cannot create its EGL
|
||||||
@@ -56,10 +80,33 @@ String get autoHwdec => Platform.isWindows ? 'auto-copy' : 'auto';
|
|||||||
class AppSettings {
|
class AppSettings {
|
||||||
static final AppSettings instance = AppSettings();
|
static final AppSettings instance = AppSettings();
|
||||||
|
|
||||||
|
/// The running build's version: picks the default [updateChannel].
|
||||||
|
final String runningVersion;
|
||||||
|
/// The [updateChannelFileName] file to adopt at load (null: none).
|
||||||
|
final File? channelFile;
|
||||||
|
|
||||||
|
AppSettings({this.runningVersion = appVersion, File? channelFile})
|
||||||
|
: channelFile = channelFile ?? _channelFileNextToBinary();
|
||||||
|
|
||||||
|
static File? _channelFileNextToBinary() {
|
||||||
|
try {
|
||||||
|
return File('${File(Platform.resolvedExecutable).parent.path}/$updateChannelFileName');
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
final hwdecMode = signal(HwdecMode.auto);
|
final hwdecMode = signal(HwdecMode.auto);
|
||||||
final hwdecCustom = signal('');
|
final hwdecCustom = signal('');
|
||||||
final autoCheckUpdates = signal(true);
|
final autoCheckUpdates = signal(true);
|
||||||
/// Jellyfin episodes: at the end, count down 10 s and put up the next.
|
/// Where updates come from (Settings → Updates). Never chosen: dev for
|
||||||
|
/// a test build, else stable; a test build stores dev on its first
|
||||||
|
/// start, so the install stays on test builds after a release (also
|
||||||
|
/// when that release updated it).
|
||||||
|
late final updateChannel = signal(defaultUpdateChannel(runningVersion));
|
||||||
|
/// An episode this app puts up (Jellyfin, a file of a local show) is
|
||||||
|
/// continued by the next one (Up next's `auto` item, the server's
|
||||||
|
/// countdown at its end).
|
||||||
final autoAdvance = signal(true);
|
final autoAdvance = signal(true);
|
||||||
/// Session: send ready=false when the video changes (a swap keeps the
|
/// Session: send ready=false when the video changes (a swap keeps the
|
||||||
/// server's ready flags).
|
/// server's ready flags).
|
||||||
@@ -85,6 +132,24 @@ class AppSettings {
|
|||||||
/// Send this app's log lines to the server's session log while in a
|
/// Send this app's log lines to the server's session log while in a
|
||||||
/// session (never chat). Off: nothing is sent but a note that it is off.
|
/// session (never chat). Off: nothing is sent but a note that it is off.
|
||||||
final shareDiagnostics = signal(true);
|
final shareDiagnostics = signal(true);
|
||||||
|
/// The video output renders at the player's size on screen, so mpv
|
||||||
|
/// scales the picture (and runs shaders) and draws subtitles at screen
|
||||||
|
/// resolution; off: at the video's own size, scaled by Flutter. Applies
|
||||||
|
/// live (GPU rendering only).
|
||||||
|
final screenResVideo = signal(true);
|
||||||
|
/// Bottom subtitles also move up above the control bar while it is up
|
||||||
|
/// (mpv `sub-pos`; the quick chat box always lifts them). Default off.
|
||||||
|
/// Applies live.
|
||||||
|
final liftSubtitles = signal(false);
|
||||||
|
/// Subtitle size, mpv `sub-scale` (1: as the file has them), 0.5..2.
|
||||||
|
/// Applies live and to each new player.
|
||||||
|
final subScale = signal(1.0);
|
||||||
|
/// How notifications show over the full player: toasts (the default),
|
||||||
|
/// or chat and short notices flying across the picture as danmaku
|
||||||
|
/// (`danmaku/danmaku_notices.dart`; problems stay toasts). Applies live.
|
||||||
|
final notifyStyle = signal(NotifyStyle.toasts);
|
||||||
|
/// How those lines look and move (stored as one map, [DanmakuStyle.toJson]).
|
||||||
|
final danmakuStyle = signal(const DanmakuStyle());
|
||||||
/// Chat message toasts (default: all). Applies live.
|
/// Chat message toasts (default: all). Applies live.
|
||||||
final chatPopups = signal(ChatPopups.all);
|
final chatPopups = signal(ChatPopups.all);
|
||||||
/// Joins and leaves also notify while sitting out or with the window in
|
/// Joins and leaves also notify while sitting out or with the window in
|
||||||
@@ -105,6 +170,13 @@ class AppSettings {
|
|||||||
/// `SfxKind` name -> `SoundPreset` name, per-sound overrides.
|
/// `SfxKind` name -> `SoundPreset` name, per-sound overrides.
|
||||||
final soundPresetFor = signal<Map<String, String>>(const {});
|
final soundPresetFor = signal<Map<String, String>>(const {});
|
||||||
|
|
||||||
|
/// What's new (`update/whats_new.dart`): the version whose card was
|
||||||
|
/// shown, and the notes last kept (a version's `ReleaseNotes` as JSON).
|
||||||
|
String? whatsNewSeen;
|
||||||
|
Map<String, Object?>? whatsNewNotes;
|
||||||
|
/// The box was empty when this run opened it: a new install.
|
||||||
|
bool freshInstall = false;
|
||||||
|
|
||||||
/// Video shaders: one choice, used on join and changed both here and
|
/// Video shaders: one choice, used on join and changed both here and
|
||||||
/// in the session (Ctrl+0..6, the shader button).
|
/// in the session (Ctrl+0..6, the shader button).
|
||||||
final shaderPreset = signal(ShaderPreset.off);
|
final shaderPreset = signal(ShaderPreset.off);
|
||||||
@@ -129,6 +201,7 @@ class AppSettings {
|
|||||||
Future<void> _loadNow() async {
|
Future<void> _loadNow() async {
|
||||||
try {
|
try {
|
||||||
final box = _box = await Hive.openBox(settingsBoxName);
|
final box = _box = await Hive.openBox(settingsBoxName);
|
||||||
|
freshInstall = box.isEmpty;
|
||||||
for (final line in await migrateSettings(box)) {
|
for (final line in await migrateSettings(box)) {
|
||||||
debugPrint(line);
|
debugPrint(line);
|
||||||
}
|
}
|
||||||
@@ -141,6 +214,7 @@ class AppSettings {
|
|||||||
if (custom is String) hwdecCustom.value = custom;
|
if (custom is String) hwdecCustom.value = custom;
|
||||||
final auto = box.get(_keyAutoCheckUpdates);
|
final auto = box.get(_keyAutoCheckUpdates);
|
||||||
if (auto is bool) autoCheckUpdates.value = auto;
|
if (auto is bool) autoCheckUpdates.value = auto;
|
||||||
|
await _loadUpdateChannel(box);
|
||||||
final advance = box.get(_keyAutoAdvance);
|
final advance = box.get(_keyAutoAdvance);
|
||||||
if (advance is bool) autoAdvance.value = advance;
|
if (advance is bool) autoAdvance.value = advance;
|
||||||
final unready = box.get(_keyUnreadyOnSwap);
|
final unready = box.get(_keyUnreadyOnSwap);
|
||||||
@@ -163,6 +237,16 @@ class AppSettings {
|
|||||||
if (hideRe is bool) hideReReleases.value = hideRe;
|
if (hideRe is bool) hideReReleases.value = hideRe;
|
||||||
final line = box.get(_keyProgressLine);
|
final line = box.get(_keyProgressLine);
|
||||||
if (line is bool) progressLine.value = line;
|
if (line is bool) progressLine.value = line;
|
||||||
|
final screenRes = box.get(_keyScreenResVideo);
|
||||||
|
if (screenRes is bool) screenResVideo.value = screenRes;
|
||||||
|
final lift = box.get(_keyLiftSubtitles);
|
||||||
|
if (lift is bool) liftSubtitles.value = lift;
|
||||||
|
final scale = box.get(_keySubScale);
|
||||||
|
if (scale is num && scale.isFinite) subScale.value = scale.toDouble().clamp(0.5, 2.0);
|
||||||
|
final notifyAs = box.get(_keyNotifyStyle);
|
||||||
|
notifyStyle.value = NotifyStyle.values.where((m) => m.name == notifyAs).firstOrNull ?? NotifyStyle.toasts;
|
||||||
|
final style = box.get(_keyDanmakuStyle);
|
||||||
|
if (style is Map) danmakuStyle.value = DanmakuStyle.fromJson(style);
|
||||||
final share = box.get(_keyShareDiagnostics);
|
final share = box.get(_keyShareDiagnostics);
|
||||||
if (share is bool) shareDiagnostics.value = share;
|
if (share is bool) shareDiagnostics.value = share;
|
||||||
final popups = box.get(_keyChatPopups);
|
final popups = box.get(_keyChatPopups);
|
||||||
@@ -192,6 +276,10 @@ class AppSettings {
|
|||||||
shaderQuality.value =
|
shaderQuality.value =
|
||||||
ShaderQuality.values.where((q) => q.name == quality).firstOrNull ??
|
ShaderQuality.values.where((q) => q.name == quality).firstOrNull ??
|
||||||
ShaderQuality.hq;
|
ShaderQuality.hq;
|
||||||
|
final seen = box.get(_keyWhatsNewSeen);
|
||||||
|
if (seen is String) whatsNewSeen = seen;
|
||||||
|
final notes = box.get(_keyWhatsNewNotes);
|
||||||
|
if (notes is Map) whatsNewNotes = {for (final e in notes.entries) '${e.key}': e.value};
|
||||||
final files = box.get(_keyCustomShaders);
|
final files = box.get(_keyCustomShaders);
|
||||||
if (files is List) {
|
if (files is List) {
|
||||||
customShaders.value = List.unmodifiable(files.whereType<String>());
|
customShaders.value = List.unmodifiable(files.whereType<String>());
|
||||||
@@ -216,6 +304,30 @@ class AppSettings {
|
|||||||
_put(_keyAutoCheckUpdates, on);
|
_put(_keyAutoCheckUpdates, on);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _loadUpdateChannel(Box box) async {
|
||||||
|
UpdateChannel? requested;
|
||||||
|
final file = channelFile;
|
||||||
|
try {
|
||||||
|
if (file != null && file.existsSync()) {
|
||||||
|
requested = UpdateChannel.fromName(file.readAsStringSync().trim());
|
||||||
|
file.deleteSync();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('update channel file: $e');
|
||||||
|
}
|
||||||
|
final stored = requested ?? UpdateChannel.fromName(box.get(_keyUpdateChannel));
|
||||||
|
final channel = stored ?? defaultUpdateChannel(runningVersion);
|
||||||
|
updateChannel.value = channel;
|
||||||
|
if (channel.name != box.get(_keyUpdateChannel) && (requested != null || channel == UpdateChannel.dev)) {
|
||||||
|
await box.put(_keyUpdateChannel, channel.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void setUpdateChannel(UpdateChannel channel) {
|
||||||
|
updateChannel.value = channel;
|
||||||
|
_put(_keyUpdateChannel, channel.name);
|
||||||
|
}
|
||||||
|
|
||||||
void setAutoAdvance(bool on) {
|
void setAutoAdvance(bool on) {
|
||||||
autoAdvance.value = on;
|
autoAdvance.value = on;
|
||||||
_put(_keyAutoAdvance, on);
|
_put(_keyAutoAdvance, on);
|
||||||
@@ -263,6 +375,32 @@ class AppSettings {
|
|||||||
_put(_keyProgressLine, on);
|
_put(_keyProgressLine, on);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void setScreenResVideo(bool on) {
|
||||||
|
screenResVideo.value = on;
|
||||||
|
_put(_keyScreenResVideo, on);
|
||||||
|
}
|
||||||
|
|
||||||
|
void setLiftSubtitles(bool on) {
|
||||||
|
liftSubtitles.value = on;
|
||||||
|
_put(_keyLiftSubtitles, on);
|
||||||
|
}
|
||||||
|
|
||||||
|
void setSubScale(double v) {
|
||||||
|
subScale.value = v.clamp(0.5, 2.0);
|
||||||
|
_put(_keySubScale, subScale.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
void setNotifyStyle(NotifyStyle style) {
|
||||||
|
notifyStyle.value = style;
|
||||||
|
_put(_keyNotifyStyle, style.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
void setDanmakuStyle(DanmakuStyle style) {
|
||||||
|
if (style == danmakuStyle.peek()) return;
|
||||||
|
danmakuStyle.value = style;
|
||||||
|
_put(_keyDanmakuStyle, style.toJson());
|
||||||
|
}
|
||||||
|
|
||||||
void setShareDiagnostics(bool on) {
|
void setShareDiagnostics(bool on) {
|
||||||
shareDiagnostics.value = on;
|
shareDiagnostics.value = on;
|
||||||
_put(_keyShareDiagnostics, on);
|
_put(_keyShareDiagnostics, on);
|
||||||
@@ -326,6 +464,16 @@ class AppSettings {
|
|||||||
_put(_keyCustomShaders, [...paths]);
|
_put(_keyCustomShaders, [...paths]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> setWhatsNewSeen(String version) {
|
||||||
|
whatsNewSeen = version;
|
||||||
|
return _put(_keyWhatsNewSeen, version);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> setWhatsNewNotes(Map<String, Object?> notes) {
|
||||||
|
whatsNewNotes = notes;
|
||||||
|
return _put(_keyWhatsNewNotes, notes);
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _put(String key, Object value) async {
|
Future<void> _put(String key, Object value) async {
|
||||||
try {
|
try {
|
||||||
final box = _box ??
|
final box = _box ??
|
||||||
|
|||||||
@@ -288,8 +288,8 @@ class SyncClient {
|
|||||||
_send(PingMessage(clientMs: _clock().millisecondsSinceEpoch).toJson());
|
_send(PingMessage(clientMs: _clock().millisecondsSinceEpoch).toJson());
|
||||||
}
|
}
|
||||||
|
|
||||||
void sendSwap(HelloMedia media, {String? queueId}) {
|
void sendSwap(HelloMedia media, {String? queueId, SwapRestore? restore}) {
|
||||||
_send(SwapIntent(media: media, queueId: queueId).toJson());
|
_send(SwapIntent(media: media, queueId: queueId, restore: restore).toJson());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One change to Up next (see [QueueIntent]).
|
/// One change to Up next (see [QueueIntent]).
|
||||||
|
|||||||
@@ -1,26 +1,5 @@
|
|||||||
import 'package:media_kit/media_kit.dart';
|
|
||||||
import 'package:sync_relay_core/core.dart';
|
import 'package:sync_relay_core/core.dart';
|
||||||
|
|
||||||
/// A [Player] whose public [seek] belongs to the user. media_kit's seek bar
|
|
||||||
/// (and anything else in the video controls) calls `player.seek`; with
|
|
||||||
/// [onUserSeek] set those calls become explicit events for the sync layer
|
|
||||||
/// instead of moving mpv behind its back. Sync-driven seeks use [syncSeek].
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Seek mpv directly (preview while scrubbing, server corrections).
|
|
||||||
Future<void> syncSeek(Duration duration) => super.seek(duration);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Turns a burst of user seeks (a seek bar drag fires one per pointer move,
|
/// Turns a burst of user seeks (a seek bar drag fires one per pointer move,
|
||||||
/// arrow keys one per press) into one `seek` intent, and tells the
|
/// arrow keys one per press) into one `seek` intent, and tells the
|
||||||
/// coordinator when server seeks must not touch the player:
|
/// coordinator when server seeks must not touch the player:
|
||||||
|
|||||||
@@ -54,23 +54,41 @@ class UpdateController {
|
|||||||
|
|
||||||
final String currentVersion;
|
final String currentVersion;
|
||||||
final String manifestUrl;
|
final String manifestUrl;
|
||||||
|
final String devUrl;
|
||||||
final String? executablePath;
|
final String? executablePath;
|
||||||
final Directory? installRoot;
|
final Directory? installRoot;
|
||||||
final RestartFn restart;
|
final RestartFn restart;
|
||||||
final bool skipDevBuilds;
|
final bool skipDevBuilds;
|
||||||
|
/// Awaited after the apply, before [restart]: keeps the new version's
|
||||||
|
/// notes for its first start ([WhatsNew.installing], wired by `AppGraph`).
|
||||||
|
Future<void> Function(UpdateManifest manifest)? beforeRestart;
|
||||||
|
/// The update channel each check reads (the setting, wired by
|
||||||
|
/// `AppGraph`); null: [defaultUpdateChannel] of [currentVersion].
|
||||||
|
UpdateChannel Function()? channelOf;
|
||||||
|
|
||||||
UpdateController(
|
UpdateController(
|
||||||
{this.currentVersion = appVersion,
|
{this.currentVersion = appVersion,
|
||||||
this.manifestUrl = updateManifestUrl,
|
this.manifestUrl = updateManifestUrl,
|
||||||
|
this.devUrl = devManifestUrl,
|
||||||
this.executablePath,
|
this.executablePath,
|
||||||
this.installRoot,
|
this.installRoot,
|
||||||
this.restart = _closeAndRestart,
|
this.restart = _closeAndRestart,
|
||||||
this.skipDevBuilds = true});
|
this.skipDevBuilds = true,
|
||||||
|
this.beforeRestart,
|
||||||
|
this.channelOf});
|
||||||
|
|
||||||
|
UpdateChannel get channel => channelOf?.call() ?? defaultUpdateChannel(currentVersion);
|
||||||
|
|
||||||
final status = ValueNotifier(const UpdateStatus(UpdatePhase.idle));
|
final status = ValueNotifier(const UpdateStatus(UpdatePhase.idle));
|
||||||
|
/// The manifest of the last check that read one, newer or not (What's
|
||||||
|
/// new picks up this version's notes from it).
|
||||||
|
final latest = ValueNotifier<UpdateManifest?>(null);
|
||||||
/// Outcome of the last finished check, for Settings/About wording
|
/// Outcome of the last finished check, for Settings/About wording
|
||||||
/// ("Up to date", "Check failed: ..."); null until one finishes.
|
/// ("Up to date", "Check failed: ..."); null until one finishes.
|
||||||
final lastCheck = ValueNotifier<String?>(null);
|
final lastCheck = ValueNotifier<String?>(null);
|
||||||
|
/// On the stable channel in a test build: the latest release when it
|
||||||
|
/// is older than this build (offered only by [goBackTo], after asking).
|
||||||
|
final stableBehind = ValueNotifier<UpdateManifest?>(null);
|
||||||
Future<void>? _check;
|
Future<void>? _check;
|
||||||
late final String _exe = executablePath ?? currentExecutablePath();
|
late final String _exe = executablePath ?? currentExecutablePath();
|
||||||
late final Directory? _root = installRoot ?? findInstallRoot(_exe);
|
late final Directory? _root = installRoot ?? findInstallRoot(_exe);
|
||||||
@@ -106,8 +124,19 @@ class UpdateController {
|
|||||||
final root = _root;
|
final root = _root;
|
||||||
if (root != null) await cleanupUpdateLeftovers(root);
|
if (root != null) await cleanupUpdateLeftovers(root);
|
||||||
try {
|
try {
|
||||||
|
final ch = channel;
|
||||||
final m = await fetchLatestManifest(
|
final m = await fetchLatestManifest(
|
||||||
currentVersion: currentVersion, url: manifestUrl);
|
currentVersion: currentVersion, channel: ch, url: manifestUrl, devUrl: devUrl);
|
||||||
|
latest.value = m;
|
||||||
|
stableBehind.value = ch == UpdateChannel.stable && isStableBehind(currentVersion, m) ? m : null;
|
||||||
|
if (stableBehind.value != null) {
|
||||||
|
_log.info('stable channel: latest release ${m.version} is older than $currentVersion');
|
||||||
|
lastCheck.value = 'Stable is at ${m.version}, older than this test build';
|
||||||
|
if (!_installing && status.value.manifest?.version != m.version) {
|
||||||
|
status.value = const UpdateStatus(UpdatePhase.idle);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!isNewerVersion(currentVersion, m.version)) {
|
if (!isNewerVersion(currentVersion, m.version)) {
|
||||||
_log.info('up to date ($currentVersion, latest ${m.version})');
|
_log.info('up to date ($currentVersion, latest ${m.version})');
|
||||||
lastCheck.value = 'Up to date';
|
lastCheck.value = 'Up to date';
|
||||||
@@ -125,6 +154,27 @@ class UpdateController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The latest release when it is older than this test build (the
|
||||||
|
/// question before going back to stable); null when it is not, or the
|
||||||
|
/// check failed.
|
||||||
|
Future<UpdateManifest?> stableToGoBackTo() async {
|
||||||
|
try {
|
||||||
|
final m = await fetchManifest(url: manifestUrl);
|
||||||
|
return isStableBehind(currentVersion, m) ? m : null;
|
||||||
|
} catch (e) {
|
||||||
|
_log.warning('stable manifest: $e');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Installs [m] although it is older (going back to stable, asked
|
||||||
|
/// first): offered as an update, then [install].
|
||||||
|
Future<void> goBackTo(UpdateManifest m) async {
|
||||||
|
if (_installing) return;
|
||||||
|
status.value = UpdateStatus(UpdatePhase.available, manifest: m, canInstall: _root != null);
|
||||||
|
if (_root != null) await install();
|
||||||
|
}
|
||||||
|
|
||||||
bool get _installing => switch (status.value.phase) {
|
bool get _installing => switch (status.value.phase) {
|
||||||
UpdatePhase.downloading || UpdatePhase.applying || UpdatePhase.restarting => true,
|
UpdatePhase.downloading || UpdatePhase.applying || UpdatePhase.restarting => true,
|
||||||
_ => false,
|
_ => false,
|
||||||
@@ -173,6 +223,11 @@ class UpdateController {
|
|||||||
_log.info('updated to ${m.version}, restarting');
|
_log.info('updated to ${m.version}, restarting');
|
||||||
status.value =
|
status.value =
|
||||||
UpdateStatus(UpdatePhase.restarting, manifest: m, canInstall: true);
|
UpdateStatus(UpdatePhase.restarting, manifest: m, canInstall: true);
|
||||||
|
try {
|
||||||
|
await beforeRestart?.call(m);
|
||||||
|
} catch (e) {
|
||||||
|
_log.warning('keeping the notes of ${m.version}: $e');
|
||||||
|
}
|
||||||
// Started by an old name (1.2.x's restart, an old launcher): the new
|
// Started by an old name (1.2.x's restart, an old launcher): the new
|
||||||
// binary next to it.
|
// binary next to it.
|
||||||
await restart(relaunchExecutable(_exe), launchArgs);
|
await restart(relaunchExecutable(_exe), launchArgs);
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:sync_relay_core/core.dart';
|
||||||
|
|
||||||
|
import '../state/settings.dart';
|
||||||
|
import 'update_controller.dart';
|
||||||
|
|
||||||
|
// "What's new in X.Y.Z" once after an update. The notes come from the
|
||||||
|
// manifest: the one installed (kept before the restart) or, for an update
|
||||||
|
// made another way (1.3.x's updater, `--upgrade`, by hand), the manifest of
|
||||||
|
// the first check that finds this very version. A fresh install shows
|
||||||
|
// nothing; Settings → About opens the card again.
|
||||||
|
|
||||||
|
/// What the card shows for one version.
|
||||||
|
class ReleaseNotes {
|
||||||
|
final String version;
|
||||||
|
|
||||||
|
/// The one-line notes (all a manifest from before the changelog has).
|
||||||
|
final String notes;
|
||||||
|
final ReleaseChanges? changes;
|
||||||
|
const ReleaseNotes({required this.version, this.notes = '', this.changes});
|
||||||
|
|
||||||
|
factory ReleaseNotes.of(UpdateManifest m) => ReleaseNotes(version: m.version, notes: m.notes, changes: m.changes);
|
||||||
|
|
||||||
|
/// Null for anything unreadable.
|
||||||
|
static ReleaseNotes? fromJson(Object? json) {
|
||||||
|
if (json is! Map) return null;
|
||||||
|
final version = json['version'];
|
||||||
|
if (version is! String || version.isEmpty) return null;
|
||||||
|
final notes = json['notes'];
|
||||||
|
return ReleaseNotes(
|
||||||
|
version: version,
|
||||||
|
notes: notes is String ? notes : '',
|
||||||
|
changes: ReleaseChanges.fromJson(json['changes']),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, Object?> toJson() => {
|
||||||
|
'version': version,
|
||||||
|
'notes': notes,
|
||||||
|
if (changes != null) 'changes': changes!.toJson(),
|
||||||
|
};
|
||||||
|
|
||||||
|
bool get isEmpty => notes.trim().isEmpty && (changes?.isEmpty ?? true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where [WhatsNew] keeps its two values (settings box; tests: memory).
|
||||||
|
abstract class WhatsNewStore {
|
||||||
|
/// The version whose card was shown (or that was installed fresh).
|
||||||
|
String? get seen;
|
||||||
|
Future<void> setSeen(String version);
|
||||||
|
|
||||||
|
/// The notes last known for an installed or current version.
|
||||||
|
ReleaseNotes? get notes;
|
||||||
|
Future<void> setNotes(ReleaseNotes notes);
|
||||||
|
|
||||||
|
/// The settings box was empty at start: a fresh install.
|
||||||
|
bool get fresh;
|
||||||
|
}
|
||||||
|
|
||||||
|
class SettingsWhatsNewStore implements WhatsNewStore {
|
||||||
|
final AppSettings settings;
|
||||||
|
SettingsWhatsNewStore(this.settings);
|
||||||
|
@override
|
||||||
|
String? get seen => settings.whatsNewSeen;
|
||||||
|
@override
|
||||||
|
Future<void> setSeen(String version) => settings.setWhatsNewSeen(version);
|
||||||
|
@override
|
||||||
|
ReleaseNotes? get notes => ReleaseNotes.fromJson(settings.whatsNewNotes);
|
||||||
|
@override
|
||||||
|
Future<void> setNotes(ReleaseNotes notes) => settings.setWhatsNewNotes(notes.toJson());
|
||||||
|
@override
|
||||||
|
bool get fresh => settings.freshInstall;
|
||||||
|
}
|
||||||
|
|
||||||
|
class MemoryWhatsNewStore implements WhatsNewStore {
|
||||||
|
@override
|
||||||
|
String? seen;
|
||||||
|
@override
|
||||||
|
ReleaseNotes? notes;
|
||||||
|
@override
|
||||||
|
bool fresh;
|
||||||
|
MemoryWhatsNewStore({this.seen, this.notes, this.fresh = false});
|
||||||
|
@override
|
||||||
|
Future<void> setSeen(String version) async => seen = version;
|
||||||
|
@override
|
||||||
|
Future<void> setNotes(ReleaseNotes notes) async => this.notes = notes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Made once by `AppGraph` (the shell and About read it from there).
|
||||||
|
class WhatsNew {
|
||||||
|
|
||||||
|
final String currentVersion;
|
||||||
|
|
||||||
|
final WhatsNewStore store;
|
||||||
|
|
||||||
|
WhatsNew({this.currentVersion = appVersion, required this.store});
|
||||||
|
|
||||||
|
/// The card to show now (once); null when there is none.
|
||||||
|
final due = ValueNotifier<ReleaseNotes?>(null);
|
||||||
|
UpdateController? _updates;
|
||||||
|
|
||||||
|
/// At start, settings loaded: a pending card for this version, and
|
||||||
|
/// [updates]' checks from now on.
|
||||||
|
void start(UpdateController updates) {
|
||||||
|
if (!identical(_updates, updates)) {
|
||||||
|
_updates?.latest.removeListener(_onManifest);
|
||||||
|
_updates = updates..latest.addListener(_onManifest);
|
||||||
|
}
|
||||||
|
final s = store;
|
||||||
|
if (s.seen == currentVersion) return;
|
||||||
|
if (s.seen == null && s.fresh) {
|
||||||
|
// A new install: nothing changed for this person.
|
||||||
|
unawaited(s.setSeen(currentVersion));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final known = s.notes;
|
||||||
|
if (known != null && known.version == currentVersion && !known.isEmpty) due.value = known;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onManifest() {
|
||||||
|
final m = _updates?.latest.value;
|
||||||
|
if (m != null) offer(ReleaseNotes.of(m));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Notes for some version (an update check's manifest). Kept when they
|
||||||
|
/// are this version's; due when its card was not shown yet.
|
||||||
|
void offer(ReleaseNotes n) {
|
||||||
|
if (n.version != currentVersion || n.isEmpty) return;
|
||||||
|
final s = store;
|
||||||
|
unawaited(s.setNotes(n));
|
||||||
|
if (s.seen != currentVersion && !s.fresh) due.value = n;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Before the restart into [m]: its notes for the first start.
|
||||||
|
Future<void> installing(UpdateManifest m) => store.setNotes(ReleaseNotes.of(m));
|
||||||
|
|
||||||
|
/// The card was shown: never again for this version.
|
||||||
|
void shown() {
|
||||||
|
due.value = null;
|
||||||
|
unawaited(store.setSeen(currentVersion));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// This version's notes, when known (Settings → About).
|
||||||
|
ReleaseNotes? get current {
|
||||||
|
final n = store.notes;
|
||||||
|
return n != null && n.version == currentVersion && !n.isEmpty ? n : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:hive_ce/hive.dart';
|
||||||
|
import 'package:signals/signals_flutter.dart';
|
||||||
|
import 'package:sync_relay_core/core.dart';
|
||||||
|
|
||||||
|
// The device's watch history (device-owned, one Hive box): what this app
|
||||||
|
// watched of files and links, by [WatchKey], with the verdict of the
|
||||||
|
// watched rule (`WatchSession`, D3). Jellyfin keeps its own (reported by
|
||||||
|
// `JellyfinProgressReporter`); AniList later reads the tracker's records.
|
||||||
|
// See ARCHITECTURE.md "Watch tracking".
|
||||||
|
|
||||||
|
const watchHistoryBoxName = 'watch_history';
|
||||||
|
|
||||||
|
/// What a watch is about, independent of the opening: a file by its path
|
||||||
|
/// on this computer, a link by its URL, a Jellyfin item by its ids, else
|
||||||
|
/// the media's name and size (someone else's file, a link passed on).
|
||||||
|
class WatchKey {
|
||||||
|
final String value;
|
||||||
|
const WatchKey._(this.value);
|
||||||
|
|
||||||
|
factory WatchKey.file(String path) => WatchKey._('file:$path');
|
||||||
|
factory WatchKey.link(String url) => WatchKey._('link:$url');
|
||||||
|
factory WatchKey.jellyfin(JellyfinRef ref) => WatchKey._('jf:${ref.serverId}/${ref.itemId}');
|
||||||
|
factory WatchKey.media(String name, int size) => WatchKey._('media:$name|$size');
|
||||||
|
|
||||||
|
bool get isJellyfin => value.startsWith('jf:');
|
||||||
|
|
||||||
|
/// The key of `state.media` [media]: [ownFile] / [ownLink] when this app
|
||||||
|
/// put it up from there, else its Jellyfin ref, its shared link, its
|
||||||
|
/// name and size.
|
||||||
|
static WatchKey? of(Map<String, Object?>? media, {String? ownFile, String? ownLink}) {
|
||||||
|
if (media == null) return null;
|
||||||
|
if (ownFile != null) return WatchKey.file(ownFile);
|
||||||
|
final jf = media['jellyfin'];
|
||||||
|
if (jf is Map) {
|
||||||
|
final ref = JellyfinRef.fromJson(Map<String, Object?>.from(jf));
|
||||||
|
if (ref.serverId.isNotEmpty && ref.itemId.isNotEmpty) return WatchKey.jellyfin(ref);
|
||||||
|
}
|
||||||
|
if (ownLink != null) return WatchKey.link(ownLink);
|
||||||
|
final url = media['url'];
|
||||||
|
if (url is String && (url.startsWith('http://') || url.startsWith('https://'))) return WatchKey.link(url);
|
||||||
|
final name = media['name'];
|
||||||
|
final size = media['size'];
|
||||||
|
return WatchKey.media(name is String ? name : '', size is num ? size.toInt() : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) => other is WatchKey && other.value == value;
|
||||||
|
@override
|
||||||
|
int get hashCode => value.hashCode;
|
||||||
|
@override
|
||||||
|
String toString() => value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One closed watch: what the tracker hands its sinks (the device history,
|
||||||
|
/// AniList later).
|
||||||
|
class WatchRecord {
|
||||||
|
final WatchKey key;
|
||||||
|
final String title;
|
||||||
|
final MediaRef? ref;
|
||||||
|
final WatchVerdict verdict;
|
||||||
|
/// Seconds the media played while this viewer was In, and its runtime
|
||||||
|
/// (0 when unknown).
|
||||||
|
final double timeIn;
|
||||||
|
final double runtime;
|
||||||
|
final DateTime at;
|
||||||
|
const WatchRecord({
|
||||||
|
required this.key,
|
||||||
|
required this.title,
|
||||||
|
required this.verdict,
|
||||||
|
required this.timeIn,
|
||||||
|
required this.runtime,
|
||||||
|
required this.at,
|
||||||
|
this.ref,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => 'WatchRecord($key, $verdict, ${timeIn.round()} s In of ${runtime.round()} s)';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the history holds for a key: watched (played), or where to
|
||||||
|
/// resume, and when.
|
||||||
|
class WatchEntry {
|
||||||
|
final bool played;
|
||||||
|
/// Seconds; 0 when played or none.
|
||||||
|
final double resumeAt;
|
||||||
|
final String title;
|
||||||
|
final MediaRef? ref;
|
||||||
|
final DateTime at;
|
||||||
|
const WatchEntry({required this.played, this.resumeAt = 0, this.title = '', this.ref, required this.at});
|
||||||
|
|
||||||
|
Map<String, Object?> toJson() => {
|
||||||
|
'played': played,
|
||||||
|
if (resumeAt > 0) 'resumeAt': resumeAt,
|
||||||
|
if (title.isNotEmpty) 'title': title,
|
||||||
|
if (ref != null) 'ref': ref!.toJson(),
|
||||||
|
'at': at.toUtc().toIso8601String(),
|
||||||
|
};
|
||||||
|
|
||||||
|
static WatchEntry? fromJson(Object? j) {
|
||||||
|
if (j is! Map) return null;
|
||||||
|
final at = DateTime.tryParse('${j['at']}');
|
||||||
|
if (at == null) return null;
|
||||||
|
final resume = j['resumeAt'];
|
||||||
|
MediaRef? ref;
|
||||||
|
try {
|
||||||
|
ref = MediaRef.fromJson(j['ref']);
|
||||||
|
} catch (_) {}
|
||||||
|
return WatchEntry(
|
||||||
|
played: j['played'] == true,
|
||||||
|
resumeAt: resume is num && resume.isFinite && resume > 0 ? resume.toDouble() : 0,
|
||||||
|
title: j['title'] is String ? j['title'] as String : '',
|
||||||
|
ref: ref,
|
||||||
|
at: at,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The device's watch history: [entry] per key, [record] from the
|
||||||
|
/// tracker. [revision] bumps on every change (pages that show it listen).
|
||||||
|
class WatchHistory {
|
||||||
|
final Map<String, Object?> _memory = {};
|
||||||
|
/// False: memory only (tests).
|
||||||
|
final bool persist;
|
||||||
|
Box? _box;
|
||||||
|
Future<void>? _load;
|
||||||
|
final revision = signal(0);
|
||||||
|
|
||||||
|
WatchHistory({this.persist = true});
|
||||||
|
|
||||||
|
Future<void> ensureLoaded() => _load ??= () async {
|
||||||
|
if (!persist) return;
|
||||||
|
try {
|
||||||
|
final box = _box = await Hive.openBox(watchHistoryBoxName);
|
||||||
|
for (final e in _memory.entries) {
|
||||||
|
await box.put(e.key, e.value);
|
||||||
|
}
|
||||||
|
_memory.clear();
|
||||||
|
revision.value++;
|
||||||
|
} catch (e, st) {
|
||||||
|
debugPrint('open watch history failed: $e\n$st');
|
||||||
|
}
|
||||||
|
}();
|
||||||
|
|
||||||
|
WatchEntry? entry(WatchKey key) => WatchEntry.fromJson(_box?.get(key.value) ?? _memory[key.value]);
|
||||||
|
|
||||||
|
/// A closed watch: played -> played (the resume point goes); part way
|
||||||
|
/// -> its resume point, unless it was played before (it stays played);
|
||||||
|
/// a peek changes nothing. Jellyfin items are not kept here.
|
||||||
|
void record(WatchRecord r) {
|
||||||
|
if (r.key.isJellyfin) return;
|
||||||
|
final before = entry(r.key);
|
||||||
|
final WatchEntry next;
|
||||||
|
switch (r.verdict.outcome) {
|
||||||
|
case WatchOutcome.played || WatchOutcome.keepPlayed:
|
||||||
|
next = WatchEntry(played: true, title: r.title, ref: r.ref, at: r.at);
|
||||||
|
case WatchOutcome.partial:
|
||||||
|
next = WatchEntry(
|
||||||
|
played: before?.played ?? false,
|
||||||
|
resumeAt: (before?.played ?? false) ? 0 : r.verdict.resumeAt ?? 0,
|
||||||
|
title: r.title,
|
||||||
|
ref: r.ref,
|
||||||
|
at: r.at);
|
||||||
|
case WatchOutcome.peek:
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final json = next.toJson();
|
||||||
|
final box = _box;
|
||||||
|
if (box == null) {
|
||||||
|
_memory[r.key.value] = json;
|
||||||
|
} else {
|
||||||
|
box.put(r.key.value, json).catchError((Object e) => debugPrint('watch history put ${r.key}: $e'));
|
||||||
|
}
|
||||||
|
revision.value++;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every entry, newest first.
|
||||||
|
List<(WatchKey, WatchEntry)> get entries {
|
||||||
|
final raw = <String, Object?>{..._memory, if (_box != null) for (final k in _box!.keys) '$k': _box!.get(k)};
|
||||||
|
final out = [
|
||||||
|
for (final e in raw.entries)
|
||||||
|
if (WatchEntry.fromJson(e.value) case final w?) (WatchKey._(e.key), w),
|
||||||
|
];
|
||||||
|
out.sort((a, b) => b.$2.at.compareTo(a.$2.at));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:sync_relay_core/core.dart';
|
||||||
|
|
||||||
|
import 'watch_history.dart';
|
||||||
|
|
||||||
|
// Watch tracking (ARCHITECTURE.md "Watch tracking", D3): one watch per
|
||||||
|
// media of the server (its `mediaId`), fed every second by the session
|
||||||
|
// with what this app sees, judged by core's `WatchSession` when it ends.
|
||||||
|
// The verdict goes to the device history (files and links) and out on
|
||||||
|
// [WatchTracker.records] (the seam for AniList later); Jellyfin's
|
||||||
|
// played/resume reports read the same session.
|
||||||
|
|
||||||
|
final _log = createLogger('watch');
|
||||||
|
|
||||||
|
/// What the session hands the tracker each second.
|
||||||
|
class WatchSample {
|
||||||
|
/// The server's media and its id.
|
||||||
|
final int mediaId;
|
||||||
|
final Map<String, Object?>? media;
|
||||||
|
/// This app's own source of it: the file it put up, the link it put up.
|
||||||
|
final String? ownFile;
|
||||||
|
final String? ownLink;
|
||||||
|
final double position;
|
||||||
|
/// The media plays here: the server plays it and this player has it.
|
||||||
|
final bool playing;
|
||||||
|
/// This viewer is In (`in`): not away, joining or gone.
|
||||||
|
final bool present;
|
||||||
|
/// The player's duration (0 while unknown) and where the ending starts.
|
||||||
|
final double duration;
|
||||||
|
final double? endingStart;
|
||||||
|
final DateTime at;
|
||||||
|
const WatchSample({
|
||||||
|
required this.mediaId,
|
||||||
|
required this.media,
|
||||||
|
required this.position,
|
||||||
|
required this.playing,
|
||||||
|
required this.present,
|
||||||
|
required this.at,
|
||||||
|
this.ownFile,
|
||||||
|
this.ownLink,
|
||||||
|
this.duration = 0,
|
||||||
|
this.endingStart,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class WatchTracker {
|
||||||
|
final WatchHistory history;
|
||||||
|
WatchTracker({WatchHistory? history}) : history = history ?? WatchHistory(persist: false);
|
||||||
|
|
||||||
|
final _records = StreamController<WatchRecord>.broadcast();
|
||||||
|
|
||||||
|
/// Every closed watch (peeks too): the device history takes files and
|
||||||
|
/// links; AniList progress later listens here.
|
||||||
|
Stream<WatchRecord> get records => _records.stream;
|
||||||
|
|
||||||
|
int _mediaId = -1;
|
||||||
|
WatchKey? _key;
|
||||||
|
String _title = '';
|
||||||
|
MediaRef? _ref;
|
||||||
|
double _runtime = 0;
|
||||||
|
double? _endingStart;
|
||||||
|
WatchSession? _session;
|
||||||
|
|
||||||
|
/// The watch of the media playing (null before the first sample).
|
||||||
|
WatchSession? get session => _session;
|
||||||
|
WatchKey? get key => _key;
|
||||||
|
|
||||||
|
/// One second of the session. A new media closes the watch of the one
|
||||||
|
/// before.
|
||||||
|
void sample(WatchSample s) {
|
||||||
|
if (s.mediaId != _mediaId) {
|
||||||
|
close(at: s.at);
|
||||||
|
if (s.media == null) return;
|
||||||
|
_open(s);
|
||||||
|
}
|
||||||
|
final session = _session;
|
||||||
|
if (session == null) return;
|
||||||
|
// A file or link this app put up is known once its swap landed.
|
||||||
|
final key = WatchKey.of(s.media, ownFile: s.ownFile, ownLink: s.ownLink);
|
||||||
|
if (key != null && key != _key && !(_key?.isJellyfin ?? false)) _key = key;
|
||||||
|
if (s.duration > 0) _runtime = s.duration;
|
||||||
|
_endingStart = s.endingStart;
|
||||||
|
session.sample(s.position, playing: s.playing, present: s.present, at: s.at);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _open(WatchSample s) {
|
||||||
|
final key = WatchKey.of(s.media, ownFile: s.ownFile, ownLink: s.ownLink);
|
||||||
|
final before = key == null ? null : history.entry(key);
|
||||||
|
_mediaId = s.mediaId;
|
||||||
|
_key = key;
|
||||||
|
_title = '${s.media?['name'] ?? ''}';
|
||||||
|
try {
|
||||||
|
_ref = MediaRef.fromJson(s.media?['ref']);
|
||||||
|
} catch (_) {
|
||||||
|
_ref = null;
|
||||||
|
}
|
||||||
|
final d = s.media?['duration'];
|
||||||
|
_runtime = d is num && d > 0 ? d.toDouble() : 0;
|
||||||
|
_endingStart = null;
|
||||||
|
_session = WatchSession(playedBefore: before?.played ?? false, resumeBefore: before?.resumeAt ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The same media goes on under another [mediaId] (a restarted server
|
||||||
|
/// numbers it anew): the watch carries on.
|
||||||
|
void renumber(int from, int to) {
|
||||||
|
if (_mediaId == from) _mediaId = to;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What Jellyfin says about the item before this opening (its played
|
||||||
|
/// state and resume point, read once it is open).
|
||||||
|
void before({required bool played, double resumeAt = 0}) {
|
||||||
|
final session = _session;
|
||||||
|
if (session == null) return;
|
||||||
|
session
|
||||||
|
..playedBefore = played
|
||||||
|
..resumeBefore = resumeAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The verdict so far (the runtime as last seen); null without a watch.
|
||||||
|
WatchVerdict? get verdict => _session?.verdict(runtime: _runtime, endingStart: _endingStart);
|
||||||
|
|
||||||
|
/// Records the watch as it stands (the end of the media; it may go on
|
||||||
|
/// after a seek back and be recorded again).
|
||||||
|
WatchRecord? checkpoint({DateTime? at}) {
|
||||||
|
final session = _session, key = _key;
|
||||||
|
if (session == null || key == null) return null;
|
||||||
|
final v = session.verdict(runtime: _runtime, endingStart: _endingStart);
|
||||||
|
final r = WatchRecord(
|
||||||
|
key: key,
|
||||||
|
title: _title,
|
||||||
|
ref: _ref,
|
||||||
|
verdict: v,
|
||||||
|
timeIn: session.timeIn,
|
||||||
|
runtime: _runtime,
|
||||||
|
at: at ?? DateTime.now().toUtc());
|
||||||
|
_log.info('$key: ${v.outcome.name} (${session.timeIn.round()} s In of ${_runtime.round()} s)');
|
||||||
|
history.record(r);
|
||||||
|
_records.add(r);
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The watch ends (another media, leaving): recorded and forgotten.
|
||||||
|
WatchRecord? close({DateTime? at}) {
|
||||||
|
final r = _session == null ? null : checkpoint(at: at);
|
||||||
|
_mediaId = -1;
|
||||||
|
_key = null;
|
||||||
|
_session = null;
|
||||||
|
_ref = null;
|
||||||
|
_runtime = 0;
|
||||||
|
_endingStart = null;
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The media the current watch is of (-1: none).
|
||||||
|
int get mediaId => _mediaId;
|
||||||
|
|
||||||
|
Future<void> dispose() => _records.close();
|
||||||
|
}
|
||||||
@@ -0,0 +1,326 @@
|
|||||||
|
import 'dart:math' as math;
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:sync_relay_core/core.dart';
|
||||||
|
|
||||||
|
import '../screens/home/ui.dart' show appName;
|
||||||
|
import '../shell/motion.dart';
|
||||||
|
import '../update/whats_new.dart';
|
||||||
|
import 'app_menu.dart';
|
||||||
|
|
||||||
|
// The "What's new" card: a version's changes (CHANGELOG.md, through the
|
||||||
|
// manifest's `changes`) as Added / Changed / Fixed sections, each with its
|
||||||
|
// icon and colour, on the app's glass. The update band opens it for the
|
||||||
|
// offered version, the first start after an update for the new one
|
||||||
|
// ([WhatsNew]), Settings → About for the running one. Notes without
|
||||||
|
// `changes` (older manifests) show as one paragraph.
|
||||||
|
|
||||||
|
enum ChangeKind {
|
||||||
|
added('Added', Icons.auto_awesome_rounded, Icons.add_rounded, Color(0xFF7EE0A6)),
|
||||||
|
changed('Changed', Icons.tune_rounded, Icons.arrow_forward_rounded, Color(0xFF8DB8FF)),
|
||||||
|
fixed('Fixed', Icons.handyman_rounded, Icons.check_rounded, Color(0xFFFFC772));
|
||||||
|
|
||||||
|
final String label;
|
||||||
|
final IconData icon;
|
||||||
|
/// Before each item.
|
||||||
|
final IconData bullet;
|
||||||
|
|
||||||
|
/// Reads on the dark glass.
|
||||||
|
final Color color;
|
||||||
|
const ChangeKind(this.label, this.icon, this.bullet, this.color);
|
||||||
|
|
||||||
|
List<String> of(ReleaseChanges c) => switch (this) {
|
||||||
|
ChangeKind.added => c.added,
|
||||||
|
ChangeKind.changed => c.changed,
|
||||||
|
ChangeKind.fixed => c.fixed,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The first [n] items, Added before Changed before Fixed.
|
||||||
|
List<(ChangeKind, String)> topChanges(ReleaseChanges c, int n) => [
|
||||||
|
for (final k in ChangeKind.values)
|
||||||
|
for (final item in k.of(c)) (k, item),
|
||||||
|
].take(n).toList();
|
||||||
|
|
||||||
|
/// Opens the card over the window. [action]: a button beside Close (the
|
||||||
|
/// band's "Update and restart").
|
||||||
|
Future<void> showWhatsNew(BuildContext context, ReleaseNotes notes, {String? overline, Widget? action}) =>
|
||||||
|
showPopover<void>(
|
||||||
|
context,
|
||||||
|
at: Alignment.center,
|
||||||
|
dim: true,
|
||||||
|
child: WhatsNewCard(notes: notes, overline: overline, action: action),
|
||||||
|
);
|
||||||
|
|
||||||
|
class WhatsNewCard extends StatefulWidget {
|
||||||
|
final ReleaseNotes notes;
|
||||||
|
|
||||||
|
/// The small line over the title (default: the app's name).
|
||||||
|
final String? overline;
|
||||||
|
final Widget? action;
|
||||||
|
|
||||||
|
/// Default: closes the route.
|
||||||
|
final VoidCallback? onClose;
|
||||||
|
const WhatsNewCard({super.key, required this.notes, this.overline, this.action, this.onClose});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<WhatsNewCard> createState() => _WhatsNewCardState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _WhatsNewCardState extends State<WhatsNewCard> with SingleTickerProviderStateMixin {
|
||||||
|
late final _intro = AnimationController(
|
||||||
|
vsync: this,
|
||||||
|
duration: easeTime + Duration(milliseconds: 45 * math.min(_rows, 12)),
|
||||||
|
)..forward();
|
||||||
|
|
||||||
|
int get _rows => widget.notes.changes?.length ?? 1;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_intro.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Row [i] of [_rows] slides up and fades in a little after the one above.
|
||||||
|
Widget _staggered(int i, Widget child) {
|
||||||
|
final start = math.min(i, 12) * 0.06;
|
||||||
|
final a = CurvedAnimation(
|
||||||
|
parent: _intro,
|
||||||
|
curve: Interval(math.min(start, 0.6), 1, curve: ease),
|
||||||
|
);
|
||||||
|
return FadeTransition(
|
||||||
|
opacity: a,
|
||||||
|
child: SlideTransition(
|
||||||
|
position: Tween(begin: const Offset(0, 0.25), end: Offset.zero).animate(a),
|
||||||
|
child: child,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _close() => widget.onClose != null ? widget.onClose!() : Navigator.of(context).maybePop();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final scheme = theme.colorScheme;
|
||||||
|
const muted = Color(0xB3FFFFFF);
|
||||||
|
final n = widget.notes;
|
||||||
|
final changes = n.changes;
|
||||||
|
final screen = MediaQuery.sizeOf(context);
|
||||||
|
final maxHeight = math.min(620.0, screen.height - 64);
|
||||||
|
var row = 0;
|
||||||
|
final body = <Widget>[
|
||||||
|
if (changes == null)
|
||||||
|
_staggered(row++, Text(n.notes, style: const TextStyle(fontSize: 13.5, height: 1.45)))
|
||||||
|
else
|
||||||
|
for (final k in ChangeKind.values)
|
||||||
|
if (k.of(changes).isNotEmpty) ...[
|
||||||
|
_staggered(row++, _SectionHead(kind: k, count: k.of(changes).length)),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
for (final item in k.of(changes)) _staggered(row++, _Item(kind: k, text: item)),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
return AppGlass(
|
||||||
|
key: const ValueKey('whats-new-card'),
|
||||||
|
radius: 16,
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: BoxConstraints.tightFor(width: math.min(460.0, screen.width - 32)).copyWith(maxHeight: maxHeight),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(18, 16, 8, 0),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
DecoratedBox(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
colors: [scheme.primary, scheme.tertiary.withValues(alpha: 0.85)],
|
||||||
|
),
|
||||||
|
boxShadow: [BoxShadow(color: scheme.primary.withValues(alpha: 0.35), blurRadius: 14)],
|
||||||
|
),
|
||||||
|
child: const SizedBox.square(
|
||||||
|
dimension: 40,
|
||||||
|
child: Icon(Icons.auto_awesome, color: Colors.white, size: 22),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
(widget.overline ?? appName).toUpperCase(),
|
||||||
|
style: TextStyle(
|
||||||
|
color: scheme.primary,
|
||||||
|
fontSize: 10.5,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
letterSpacing: 1.4,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
"What's new in ${n.version}",
|
||||||
|
key: const ValueKey('whats-new-title'),
|
||||||
|
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w700, height: 1.2),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
tooltip: 'Close',
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
icon: const Icon(Icons.close, size: 18, color: muted),
|
||||||
|
onPressed: _close,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (changes != null && n.notes.trim().isNotEmpty)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(18, 10, 18, 0),
|
||||||
|
child: Text(n.notes, style: const TextStyle(color: muted, fontSize: 13, height: 1.4)),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
const Divider(height: 1, thickness: 0.5, color: AppMenuStyle.border),
|
||||||
|
Flexible(
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
key: const ValueKey('whats-new-list'),
|
||||||
|
padding: const EdgeInsets.fromLTRB(18, 14, 18, 4),
|
||||||
|
child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: body),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Divider(height: 1, thickness: 0.5, color: AppMenuStyle.border),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(12, 10, 12, 10),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
if (widget.action != null) ...[
|
||||||
|
TextButton(key: const ValueKey('whats-new-close'), onPressed: _close, child: const Text('Close')),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
widget.action!,
|
||||||
|
] else
|
||||||
|
FilledButton.tonal(
|
||||||
|
key: const ValueKey('whats-new-close'),
|
||||||
|
onPressed: _close,
|
||||||
|
child: const Text('Nice'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SectionHead extends StatelessWidget {
|
||||||
|
final ChangeKind kind;
|
||||||
|
final int count;
|
||||||
|
const _SectionHead({required this.kind, required this.count});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => Align(
|
||||||
|
alignment: AlignmentDirectional.centerStart,
|
||||||
|
child: DecoratedBox(
|
||||||
|
key: ValueKey('whats-new-${kind.name}'),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: kind.color.withValues(alpha: 0.14),
|
||||||
|
borderRadius: BorderRadius.circular(999),
|
||||||
|
border: Border.all(color: kind.color.withValues(alpha: 0.28), width: 0.5),
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(8, 3, 10, 3),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(kind.icon, size: 14, color: kind.color),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Text(
|
||||||
|
kind.label,
|
||||||
|
style: TextStyle(color: kind.color, fontSize: 12, fontWeight: FontWeight.w700, letterSpacing: 0.3),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Text('$count', style: TextStyle(color: kind.color.withValues(alpha: 0.7), fontSize: 11.5)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class _Item extends StatelessWidget {
|
||||||
|
final ChangeKind kind;
|
||||||
|
final String text;
|
||||||
|
const _Item({required this.kind, required this.text});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 2, left: 2),
|
||||||
|
child: DecoratedBox(
|
||||||
|
decoration: BoxDecoration(color: kind.color.withValues(alpha: 0.16), shape: BoxShape.circle),
|
||||||
|
child: SizedBox.square(
|
||||||
|
dimension: 16,
|
||||||
|
child: Icon(
|
||||||
|
kind.bullet,
|
||||||
|
size: 12,
|
||||||
|
color: kind.color,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Expanded(child: Text(text, style: const TextStyle(fontSize: 13.5, height: 1.4))),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The band's line: the first items with their kind's icon, in [color].
|
||||||
|
class WhatsNewPreview extends StatelessWidget {
|
||||||
|
final ReleaseChanges changes;
|
||||||
|
final Color color;
|
||||||
|
final String? lead;
|
||||||
|
const WhatsNewPreview({super.key, required this.changes, required this.color, this.lead});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final top = topChanges(changes, 3);
|
||||||
|
final style = TextStyle(color: color.withValues(alpha: 0.85), fontSize: 12);
|
||||||
|
return Text.rich(
|
||||||
|
key: const ValueKey('whats-new-preview'),
|
||||||
|
TextSpan(
|
||||||
|
children: [
|
||||||
|
if (lead != null) TextSpan(text: '$lead · '),
|
||||||
|
for (final (i, (k, item)) in top.indexed) ...[
|
||||||
|
if (i > 0) const TextSpan(text: ' '),
|
||||||
|
WidgetSpan(
|
||||||
|
alignment: PlaceholderAlignment.middle,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.only(right: 4),
|
||||||
|
child: Icon(k.icon, size: 13, color: color.withValues(alpha: 0.85)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TextSpan(text: item),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: style,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,9 @@
|
|||||||
#ifdef GDK_WINDOWING_X11
|
#ifdef GDK_WINDOWING_X11
|
||||||
#include <gdk/gdkx.h>
|
#include <gdk/gdkx.h>
|
||||||
#endif
|
#endif
|
||||||
|
#ifdef GDK_WINDOWING_WAYLAND
|
||||||
|
#include <gdk/gdkwayland.h>
|
||||||
|
#endif
|
||||||
|
|
||||||
#include "flutter/generated_plugin_registrant.h"
|
#include "flutter/generated_plugin_registrant.h"
|
||||||
|
|
||||||
@@ -24,17 +27,63 @@ static void first_frame_cb(MyApplication* self, FlView* view) {
|
|||||||
gtk_widget_show(gtk_widget_get_toplevel(GTK_WIDGET(view)));
|
gtk_widget_show(gtk_widget_get_toplevel(GTK_WIDGET(view)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The desktop's activation token this process was started with
|
||||||
|
// (XDG_ACTIVATION_TOKEN on Wayland, DESKTOP_STARTUP_ID on X11), read
|
||||||
|
// before GTK starts and consumes it. A second start hands it to the
|
||||||
|
// running app (lib/src/platform/single_instance.dart), which brings its
|
||||||
|
// window up with it: compositors only let a window take the focus with
|
||||||
|
// a fresh token.
|
||||||
|
static gchar* launch_token = nullptr;
|
||||||
|
|
||||||
|
// Brings the window up with an activation token from another process.
|
||||||
|
static void present_with_token(GtkWindow* window, const gchar* token) {
|
||||||
|
if (token != nullptr && token[0] != '\0') {
|
||||||
|
GdkDisplay* display = gtk_widget_get_display(GTK_WIDGET(window));
|
||||||
|
#ifdef GDK_WINDOWING_WAYLAND
|
||||||
|
if (GDK_IS_WAYLAND_DISPLAY(display)) {
|
||||||
|
gdk_wayland_display_set_startup_notification_id(display, token);
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
#ifdef GDK_WINDOWING_X11
|
||||||
|
if (GDK_IS_X11_DISPLAY(display)) {
|
||||||
|
gtk_window_set_startup_id(window, token);
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
gtk_window_present(window);
|
||||||
|
}
|
||||||
|
|
||||||
// "starsync/window": `attention` sets the urgency hint while the window is
|
// "starsync/window": `attention` sets the urgency hint while the window is
|
||||||
// not focused; focusing it clears the hint.
|
// not focused (focusing it clears the hint); `launchToken` is this
|
||||||
|
// process's activation token ('' when none); `present` brings the window
|
||||||
|
// up with a token (argument, may be empty); `exit` ends a second start
|
||||||
|
// that handed over (no exit handlers, see main.cc).
|
||||||
static void window_method_cb(FlMethodChannel* channel, FlMethodCall* call,
|
static void window_method_cb(FlMethodChannel* channel, FlMethodCall* call,
|
||||||
gpointer user_data) {
|
gpointer user_data) {
|
||||||
GtkWindow* window = GTK_WINDOW(user_data);
|
GtkWindow* window = GTK_WINDOW(user_data);
|
||||||
g_autoptr(FlMethodResponse) response = nullptr;
|
g_autoptr(FlMethodResponse) response = nullptr;
|
||||||
if (g_strcmp0(fl_method_call_get_name(call), "attention") == 0) {
|
const gchar* method = fl_method_call_get_name(call);
|
||||||
|
if (g_strcmp0(method, "attention") == 0) {
|
||||||
if (!gtk_window_is_active(window)) {
|
if (!gtk_window_is_active(window)) {
|
||||||
gtk_window_set_urgency_hint(window, TRUE);
|
gtk_window_set_urgency_hint(window, TRUE);
|
||||||
}
|
}
|
||||||
response = FL_METHOD_RESPONSE(fl_method_success_response_new(nullptr));
|
response = FL_METHOD_RESPONSE(fl_method_success_response_new(nullptr));
|
||||||
|
} else if (g_strcmp0(method, "launchToken") == 0) {
|
||||||
|
g_autoptr(FlValue) value =
|
||||||
|
fl_value_new_string(launch_token != nullptr ? launch_token : "");
|
||||||
|
response = FL_METHOD_RESPONSE(fl_method_success_response_new(value));
|
||||||
|
} else if (g_strcmp0(method, "present") == 0) {
|
||||||
|
FlValue* args = fl_method_call_get_args(call);
|
||||||
|
const gchar* token = nullptr;
|
||||||
|
if (args != nullptr && fl_value_get_type(args) == FL_VALUE_TYPE_STRING) {
|
||||||
|
token = fl_value_get_string(args);
|
||||||
|
}
|
||||||
|
present_with_token(window, token);
|
||||||
|
response = FL_METHOD_RESPONSE(fl_method_success_response_new(nullptr));
|
||||||
|
} else if (g_strcmp0(method, "exit") == 0) {
|
||||||
|
fflush(stdout);
|
||||||
|
fflush(stderr);
|
||||||
|
_exit(0);
|
||||||
} else {
|
} else {
|
||||||
response = FL_METHOD_RESPONSE(fl_method_not_implemented_response_new());
|
response = FL_METHOD_RESPONSE(fl_method_not_implemented_response_new());
|
||||||
}
|
}
|
||||||
@@ -219,6 +268,11 @@ MyApplication* my_application_new() {
|
|||||||
// the application to be recognized beyond its binary name.
|
// the application to be recognized beyond its binary name.
|
||||||
g_set_prgname(APPLICATION_ID);
|
g_set_prgname(APPLICATION_ID);
|
||||||
|
|
||||||
|
// Before GTK starts (it takes these out of the environment).
|
||||||
|
const gchar* token = g_getenv("XDG_ACTIVATION_TOKEN");
|
||||||
|
if (token == nullptr || token[0] == '\0') token = g_getenv("DESKTOP_STARTUP_ID");
|
||||||
|
launch_token = g_strdup(token);
|
||||||
|
|
||||||
return MY_APPLICATION(g_object_new(my_application_get_type(),
|
return MY_APPLICATION(g_object_new(my_application_get_type(),
|
||||||
"application-id", APPLICATION_ID, "flags",
|
"application-id", APPLICATION_ID, "flags",
|
||||||
G_APPLICATION_NON_UNIQUE, nullptr));
|
G_APPLICATION_NON_UNIQUE, nullptr));
|
||||||
|
|||||||
@@ -538,10 +538,9 @@ packages:
|
|||||||
media_kit_video:
|
media_kit_video:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: media_kit_video
|
path: "../../third_party/media_kit_video"
|
||||||
sha256: afaa509e7b7e0bf247557a3a740cde903a52c34ace9810f94500e127bd7b043d
|
relative: true
|
||||||
url: "https://pub.dev"
|
source: path
|
||||||
source: hosted
|
|
||||||
version: "2.0.1"
|
version: "2.0.1"
|
||||||
meta:
|
meta:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
@@ -866,14 +865,14 @@ packages:
|
|||||||
path: "../../packages/core"
|
path: "../../packages/core"
|
||||||
relative: true
|
relative: true
|
||||||
source: path
|
source: path
|
||||||
version: "1.2.0"
|
version: "1.3.0"
|
||||||
sync_relay_server:
|
sync_relay_server:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
path: "../../packages/server"
|
path: "../../packages/server"
|
||||||
relative: true
|
relative: true
|
||||||
source: path
|
source: path
|
||||||
version: "1.2.0"
|
version: "1.3.0"
|
||||||
synchronized:
|
synchronized:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
name: starsync
|
name: starsync
|
||||||
description: Starsync desktop app (repo sync_relay_project). One binary.
|
description: Starsync desktop app (repo sync_relay_project). One binary.
|
||||||
publish_to: none
|
publish_to: none
|
||||||
version: 1.2.0
|
version: 1.4.0
|
||||||
environment:
|
environment:
|
||||||
sdk: '>=3.13.0-0 <4.0.0'
|
sdk: '>=3.13.0-0 <4.0.0'
|
||||||
|
|
||||||
@@ -10,7 +10,9 @@ dependencies:
|
|||||||
sdk: flutter
|
sdk: flutter
|
||||||
signals: ^7.0.0
|
signals: ^7.0.0
|
||||||
media_kit: ^1.1.0
|
media_kit: ^1.1.0
|
||||||
media_kit_video: ^2.0.1
|
# Vendored with Starsync's patches (third_party/media_kit_video/starsync).
|
||||||
|
media_kit_video:
|
||||||
|
path: ../../third_party/media_kit_video
|
||||||
media_kit_libs_video: ^1.0.0
|
media_kit_libs_video: ^1.0.0
|
||||||
path_provider: ^2.1.0
|
path_provider: ^2.1.0
|
||||||
web_socket_channel: ^3.0.0
|
web_socket_channel: ^3.0.0
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import 'dart:io';
|
|||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:hive_ce/hive.dart';
|
import 'package:hive_ce/hive.dart';
|
||||||
import 'package:media_kit/media_kit.dart';
|
import 'package:media_kit/media_kit.dart';
|
||||||
|
import 'package:signals/signals.dart' show effect;
|
||||||
import 'package:sync_relay_core/core.dart';
|
import 'package:sync_relay_core/core.dart';
|
||||||
import 'package:sync_relay_core/testing.dart';
|
import 'package:sync_relay_core/testing.dart';
|
||||||
import 'package:sync_relay_server/server.dart';
|
import 'package:sync_relay_server/server.dart';
|
||||||
@@ -362,6 +363,120 @@ void main() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('a restarted server comes back empty: what played is offered back where it was, nothing reopens, '
|
||||||
|
'the watch goes on, Up next comes back', timeout: const Timeout(Duration(minutes: 2)), () async {
|
||||||
|
final first = SyncServer(const AppConfig(port: 0, secret: 'pw', name: 'restart'));
|
||||||
|
await first.start();
|
||||||
|
final port = first.boundPort;
|
||||||
|
final origin = await _origin(await _media());
|
||||||
|
SyncServer? second;
|
||||||
|
try {
|
||||||
|
final alice = await member(port, 'alice');
|
||||||
|
final bob = await member(port, 'bob');
|
||||||
|
await playTogether(alice, [alice, bob], 'http://127.0.0.1:${origin.port}/ep.mkv');
|
||||||
|
expect(await bob.queue.add(LinkMedia('http://127.0.0.1:${origin.port}/next.mkv', anyway: true)), isNull);
|
||||||
|
await _poll(() => alice.queue.items.peek().length == 1, 'Up next has an item');
|
||||||
|
await Future.delayed(const Duration(seconds: 4));
|
||||||
|
final opens = [alice.opens, bob.opens];
|
||||||
|
final watch = alice.watch.session;
|
||||||
|
final watched = watch!.timeIn;
|
||||||
|
expect(watched, greaterThan(2));
|
||||||
|
|
||||||
|
await first.stop();
|
||||||
|
await _poll(() => alice.reconnecting.peek() && bob.reconnecting.peek(), 'both reconnecting');
|
||||||
|
second = SyncServer(AppConfig(port: port, secret: 'pw', name: 'restart'));
|
||||||
|
await second.start();
|
||||||
|
final engine = second.hub.engine;
|
||||||
|
await _poll(() => engine.media?.name == 'ep.mkv', 'the media offered back', timeout: const Duration(seconds: 30));
|
||||||
|
expect(engine.mediaId, 2, reason: 'one offer taken: the server started at 1');
|
||||||
|
expect(engine.positionAt(), greaterThan(4), reason: 'where it was, not the start');
|
||||||
|
await _poll(() => [alice, bob].every((x) => x.playback.player.state.playing && x.serverState.peek()?.transport == 'playing'),
|
||||||
|
'playing again', timeout: const Duration(seconds: 20));
|
||||||
|
expect([alice.opens, bob.opens], opens, reason: 'the players kept it: nothing reopened');
|
||||||
|
await _poll(() => engine.queue.items.length == 1, 'Up next back');
|
||||||
|
expect(engine.queue.items.single.media.url, endsWith('/next.mkv'));
|
||||||
|
// The watch went on through the restart (one watch, still counting).
|
||||||
|
await Future.delayed(const Duration(seconds: 2));
|
||||||
|
expect(identical(alice.watch.session, watch), isTrue);
|
||||||
|
expect(alice.watch.mediaId, engine.mediaId);
|
||||||
|
expect(watch.timeIn, greaterThan(watched + 1));
|
||||||
|
} finally {
|
||||||
|
await second?.stop();
|
||||||
|
await origin.close(force: true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a restarted server comes back empty: the member passing its file on puts it back and feeds it again',
|
||||||
|
timeout: const Timeout(Duration(minutes: 2)), () async {
|
||||||
|
final first = SyncServer(const AppConfig(port: 0, secret: 'pw', name: 'restart'));
|
||||||
|
await first.start();
|
||||||
|
final port = first.boundPort;
|
||||||
|
final file = await _media();
|
||||||
|
SyncServer? second;
|
||||||
|
try {
|
||||||
|
final alice = await member(port, 'alice');
|
||||||
|
final bob = await member(port, 'bob');
|
||||||
|
expect(await alice.swapPicked(FileMedia(file)), isNull);
|
||||||
|
await _poll(() => [alice, bob].every((x) => x.playback.durationSecs > 100), 'both opened it',
|
||||||
|
timeout: const Duration(seconds: 30));
|
||||||
|
for (final x in [alice, bob]) {
|
||||||
|
x.toggleReady();
|
||||||
|
}
|
||||||
|
await _poll(() => [alice, bob].every((x) => x.playback.player.state.playing), 'both playing');
|
||||||
|
await Future.delayed(const Duration(seconds: 3));
|
||||||
|
final aliceOpens = alice.opens;
|
||||||
|
|
||||||
|
await first.stop();
|
||||||
|
await _poll(() => alice.reconnecting.peek() && bob.reconnecting.peek(), 'both reconnecting');
|
||||||
|
second = SyncServer(AppConfig(port: port, secret: 'pw', name: 'restart'));
|
||||||
|
await second.start();
|
||||||
|
final hub = second.hub;
|
||||||
|
await _poll(() => hub.engine.media?.name == 'coord_presence_120s.mkv', 'the file offered back',
|
||||||
|
timeout: const Duration(seconds: 30));
|
||||||
|
expect(hub.engine.positionAt(), greaterThan(3));
|
||||||
|
await _poll(() => hub.relay?.sourceToken == alice.connection.token && alice.relaying.peek(), 'alice feeds it again');
|
||||||
|
await _poll(() => [alice, bob].every((x) => x.playback.player.state.playing && x.serverState.peek()?.transport == 'playing'),
|
||||||
|
'playing again', timeout: const Duration(seconds: 30));
|
||||||
|
expect(alice.opens, aliceOpens, reason: 'her own file stayed open');
|
||||||
|
expect(hub.relay!.buffers.single.bytesToViewers, greaterThan(0), reason: 'bob reads the new relay');
|
||||||
|
} finally {
|
||||||
|
await second?.stop();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('moving to another server never shows the last one\'s media', timeout: const Timeout(Duration(minutes: 1)),
|
||||||
|
() async {
|
||||||
|
final home = SyncServer(const AppConfig(port: 0, secret: 'pw', name: 'home'));
|
||||||
|
final empty = SyncServer(const AppConfig(port: 0, secret: 'pw', name: 'empty'));
|
||||||
|
await home.start();
|
||||||
|
await empty.start();
|
||||||
|
final origin = await _origin(await _media());
|
||||||
|
try {
|
||||||
|
final alice = await member(home.boundPort, 'alice');
|
||||||
|
expect(await alice.swapPicked(LinkMedia('http://127.0.0.1:${origin.port}/ep.mkv', anyway: true)), isNull);
|
||||||
|
await _poll(() => alice.serverState.peek()?.media != null, 'media up at home');
|
||||||
|
// The shell opens the player on any state with media as soon as the
|
||||||
|
// app is in a server again: nothing from the last one may be left
|
||||||
|
// while the new one is dialled.
|
||||||
|
final seen = <String>[];
|
||||||
|
final stop = effect(() {
|
||||||
|
if (alice.connection.state.value is ConnConnecting) {
|
||||||
|
seen.add(alice.serverState.peek()?.media?['name'] as String? ?? '');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await alice.connect(ConnectParams(host: '127.0.0.1', port: empty.boundPort, secret: 'pw', name: 'alice'));
|
||||||
|
await _poll(() => alice.serverState.peek() != null, 'the new server\'s state');
|
||||||
|
stop();
|
||||||
|
expect(seen, isNotEmpty);
|
||||||
|
expect(seen.where((n) => n.isNotEmpty), isEmpty, reason: 'no media from the last server');
|
||||||
|
expect(alice.serverState.peek()?.media, isNull);
|
||||||
|
} finally {
|
||||||
|
await home.stop();
|
||||||
|
await empty.stop();
|
||||||
|
await origin.close(force: true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('cancel during connect setup aborts without joining', timeout: const Timeout(Duration(minutes: 1)), () async {
|
test('cancel during connect setup aborts without joining', timeout: const Timeout(Duration(minutes: 1)), () async {
|
||||||
final server = SyncServer(const AppConfig(port: 0, secret: '', name: 'cancel'));
|
final server = SyncServer(const AppConfig(port: 0, secret: '', name: 'cancel'));
|
||||||
await server.start();
|
await server.start();
|
||||||
|
|||||||
@@ -0,0 +1,261 @@
|
|||||||
|
import 'dart:math' as math;
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:starsync/src/danmaku/danmaku.dart';
|
||||||
|
import 'package:starsync/src/danmaku/danmaku_notices.dart';
|
||||||
|
import 'package:starsync/src/session/notifier.dart' show NoticeCategory, ToastItem;
|
||||||
|
import 'package:starsync/src/shell/avatar.dart' show AvatarInfo;
|
||||||
|
|
||||||
|
// Danmaku lanes: free lanes only, no two lines in a lane ever touch, the
|
||||||
|
// density cap with a queue in arrival order, and the layer over a real
|
||||||
|
// widget clock.
|
||||||
|
|
||||||
|
const w = 1000.0;
|
||||||
|
|
||||||
|
/// Lines in one lane never overlap (gap kept) at any sampled time.
|
||||||
|
void expectNoOverlap(DanmakuLanes<int> lanes, double t, {double gap = 0}) {
|
||||||
|
final byLane = <int, List<DanmakuFlight<int>>>{};
|
||||||
|
for (final f in lanes.flights) {
|
||||||
|
byLane.putIfAbsent(f.lane, () => []).add(f);
|
||||||
|
}
|
||||||
|
for (final fs in byLane.values) {
|
||||||
|
fs.sort((a, b) => a.start.compareTo(b.start));
|
||||||
|
for (var i = 1; i < fs.length; i++) {
|
||||||
|
final ahead = fs[i - 1], behind = fs[i];
|
||||||
|
final tail = ahead.x(t, w) + ahead.width;
|
||||||
|
// Only while both are on screen.
|
||||||
|
if (tail <= 0 || behind.x(t, w) >= w) continue;
|
||||||
|
expect(behind.x(t, w), greaterThanOrEqualTo(tail + gap - 1e-6),
|
||||||
|
reason: 'lane ${behind.lane} at t=$t: #${behind.item} reaches #${ahead.item}');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
List<DanmakuFlight<int>> tick(DanmakuLanes<int> lanes, double t, Map<int, double> widths,
|
||||||
|
{int lanesN = 5, int cap = 100, double cross = 8}) =>
|
||||||
|
lanes.tick(t, w: w, lanes: lanesN, cap: cap, crossSecs: cross, measure: (i) => widths[i]!);
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('DanmakuLanes', () {
|
||||||
|
test('lines at the same moment take the lanes top down', () {
|
||||||
|
final lanes = DanmakuLanes<int>();
|
||||||
|
final widths = {for (var i = 0; i < 3; i++) i: 200.0};
|
||||||
|
for (var i = 0; i < 3; i++) {
|
||||||
|
lanes.add(i, 0);
|
||||||
|
}
|
||||||
|
final placed = tick(lanes, 0, widths);
|
||||||
|
expect([for (final f in placed) f.lane], [0, 1, 2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a lane is reused once the line in it has fully entered', () {
|
||||||
|
final lanes = DanmakuLanes<int>(gap: 20);
|
||||||
|
final widths = {0: 200.0, 1: 200.0};
|
||||||
|
lanes.add(0, 0);
|
||||||
|
tick(lanes, 0, widths, lanesN: 1);
|
||||||
|
lanes.add(1, 0.1);
|
||||||
|
// 200 px + gap at 150 px/s: not yet in.
|
||||||
|
expect(tick(lanes, 0.1, widths, lanesN: 1), isEmpty);
|
||||||
|
expect(lanes.waiting, 1);
|
||||||
|
// Same length, same speed: it may follow once 220 px have entered.
|
||||||
|
final placed = tick(lanes, 1.6, widths, lanesN: 1);
|
||||||
|
expect(placed.single.lane, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a long (faster) line never catches a short one ahead in its lane', () {
|
||||||
|
final lanes = DanmakuLanes<int>(gap: 20);
|
||||||
|
final widths = {0: 60.0, 1: 900.0};
|
||||||
|
lanes.add(0, 0);
|
||||||
|
tick(lanes, 0, widths, lanesN: 1);
|
||||||
|
lanes.add(1, 0.5);
|
||||||
|
// The short one is in, but the long one would run into it: wait.
|
||||||
|
for (var t = 0.5; t < 8; t += 0.1) {
|
||||||
|
final placed = tick(lanes, t, widths, lanesN: 1);
|
||||||
|
if (placed.isNotEmpty) {
|
||||||
|
// Placed: from then on they never touch.
|
||||||
|
for (var s = t; s < t + 8; s += 0.05) {
|
||||||
|
expectNoOverlap(lanes, s, gap: 20);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fail('the long line never got the lane');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a random stream of mixed lengths never overlaps', () {
|
||||||
|
final rng = math.Random(3);
|
||||||
|
final lanes = DanmakuLanes<int>(gap: 24, maxWaitSecs: 1000, maxWaiting: 10000);
|
||||||
|
final widths = <int, double>{};
|
||||||
|
var n = 0;
|
||||||
|
for (var t = 0.0; t < 120; t += 0.05) {
|
||||||
|
if (rng.nextDouble() < 0.15) {
|
||||||
|
widths[n] = 20 + rng.nextDouble() * 900;
|
||||||
|
lanes.add(n++, t);
|
||||||
|
}
|
||||||
|
tick(lanes, t, widths, lanesN: 6, cap: 14, cross: 6 + rng.nextDouble() * 0.0);
|
||||||
|
expectNoOverlap(lanes, t, gap: 24);
|
||||||
|
expect(lanes.flights.length, lessThanOrEqualTo(14));
|
||||||
|
}
|
||||||
|
expect(n, greaterThan(300));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the density cap holds the rest in arrival order', () {
|
||||||
|
final lanes = DanmakuLanes<int>();
|
||||||
|
final widths = {for (var i = 0; i < 10; i++) i: 100.0};
|
||||||
|
for (var i = 0; i < 10; i++) {
|
||||||
|
lanes.add(i, 0);
|
||||||
|
}
|
||||||
|
final first = tick(lanes, 0, widths, lanesN: 20, cap: 4);
|
||||||
|
expect([for (final f in first) f.item], [0, 1, 2, 3]);
|
||||||
|
expect(lanes.waiting, 6);
|
||||||
|
// Nothing leaves before the crossing time: still capped.
|
||||||
|
expect(tick(lanes, 7.9, widths, lanesN: 20, cap: 4), isEmpty);
|
||||||
|
final next = tick(lanes, 8, widths, lanesN: 20, cap: 4);
|
||||||
|
expect([for (final f in next) f.item], [4, 5, 6, 7]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a line that waited too long is dropped; the queue is bounded', () {
|
||||||
|
final lanes = DanmakuLanes<int>(maxWaitSecs: 5, maxWaiting: 3);
|
||||||
|
final widths = {for (var i = 0; i < 6; i++) i: 100.0};
|
||||||
|
for (var i = 0; i < 5; i++) {
|
||||||
|
lanes.add(i, 0);
|
||||||
|
}
|
||||||
|
expect(lanes.waiting, 3);
|
||||||
|
expect(lanes.dropped, 2);
|
||||||
|
tick(lanes, 6, widths, cap: 0);
|
||||||
|
expect(lanes.waiting, 0);
|
||||||
|
expect(lanes.dropped, 5);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('DanmakuStyle', () {
|
||||||
|
test('lanes fill the band at the text size', () {
|
||||||
|
const s = DanmakuStyle(bandTop: 0.1, bandBottom: 0.45);
|
||||||
|
// 720 px: 30.24 px text, 45.36 px lanes in a 252 px band.
|
||||||
|
expect(s.lanesFor(720), 5);
|
||||||
|
expect(const DanmakuStyle(bandTop: 0.4, bandBottom: 0.41).lanesFor(720), 1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('the layer flies lines under the cap and lets the rest wait', (tester) async {
|
||||||
|
final c = DanmakuController();
|
||||||
|
addTearDown(c.dispose);
|
||||||
|
await tester.pumpWidget(Directionality(
|
||||||
|
textDirection: TextDirection.ltr,
|
||||||
|
child: SizedBox(
|
||||||
|
width: 800,
|
||||||
|
height: 450,
|
||||||
|
child: DanmakuLayer(
|
||||||
|
controller: c,
|
||||||
|
accent: Colors.purple,
|
||||||
|
style: const DanmakuStyle(maxOnScreen: 3, crossSecs: 4),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
));
|
||||||
|
for (var i = 0; i < 7; i++) {
|
||||||
|
c.add(DanmakuItem(
|
||||||
|
kind: DanmakuKind.chat,
|
||||||
|
sender: 'Bob',
|
||||||
|
text: 'line $i',
|
||||||
|
own: i == 0,
|
||||||
|
avatar: i.isEven ? const AvatarInfo(name: 'Bob', color: Colors.teal) : null));
|
||||||
|
}
|
||||||
|
await tester.pump(const Duration(milliseconds: 16));
|
||||||
|
await tester.pump(const Duration(milliseconds: 16));
|
||||||
|
expect(c.onScreen.value, 3);
|
||||||
|
expect(c.waiting.value, 4);
|
||||||
|
// After a crossing the next ones fly.
|
||||||
|
await tester.pump(const Duration(seconds: 4));
|
||||||
|
await tester.pump(const Duration(milliseconds: 16));
|
||||||
|
expect(c.onScreen.value, 3);
|
||||||
|
expect(c.waiting.value, 1);
|
||||||
|
c.clear();
|
||||||
|
await tester.pump(const Duration(milliseconds: 16));
|
||||||
|
expect(c.onScreen.value, 0);
|
||||||
|
expect(c.waiting.value, 0);
|
||||||
|
await tester.pumpWidget(const SizedBox());
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
group('DanmakuStyle as a setting', () {
|
||||||
|
test('round-trips through its map', () {
|
||||||
|
const s = DanmakuStyle(
|
||||||
|
crossSecs: 6, maxOnScreen: 5, bandTop: 0.2, bandBottom: 0.5, opacity: 0.7, size: 1.3, weight: 800,
|
||||||
|
outline: 2, highlightOwn: false, avatars: false);
|
||||||
|
expect(DanmakuStyle.fromJson(s.toJson()), s);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('junk and out-of-range values: defaults and the nearest ends', () {
|
||||||
|
expect(DanmakuStyle.fromJson(const {}), const DanmakuStyle());
|
||||||
|
final s = DanmakuStyle.fromJson(const {
|
||||||
|
'crossSecs': 99,
|
||||||
|
'maxOnScreen': 0,
|
||||||
|
'opacity': 'x',
|
||||||
|
'weight': 1234,
|
||||||
|
'bandTop': 0.6,
|
||||||
|
'bandBottom': 0.61,
|
||||||
|
'avatars': 1,
|
||||||
|
});
|
||||||
|
expect(s.crossSecs, 16);
|
||||||
|
expect(s.maxOnScreen, 1);
|
||||||
|
expect(s.opacity, const DanmakuStyle().opacity);
|
||||||
|
expect(s.weight, 900);
|
||||||
|
expect((s.bandTop, s.bandBottom), (0.10, 0.45), reason: 'a band too thin takes the default');
|
||||||
|
expect(s.avatars, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('boldness: the text at its weight, the name a step bolder', () {
|
||||||
|
expect(const DanmakuStyle(weight: 400).fontWeight, FontWeight.w400);
|
||||||
|
expect(const DanmakuStyle(weight: 400).nameWeight, FontWeight.w500);
|
||||||
|
expect(const DanmakuStyle(weight: 900).nameWeight, FontWeight.w900);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('notifications as danmaku', () {
|
||||||
|
ToastItem toast(String text,
|
||||||
|
{String? actor, NoticeCategory category = NoticeCategory.app, bool chat = false, bool own = false}) =>
|
||||||
|
ToastItem(id: 1, text: text, at: DateTime(2026), actor: actor, chat: chat, own: own, category: category);
|
||||||
|
|
||||||
|
test('chat and short notices fly; problems, nudges and the app stay toasts', () {
|
||||||
|
expect(fliesAsDanmaku(toast('Bob: hi', actor: 'Bob', chat: true, category: NoticeCategory.chat)), isTrue);
|
||||||
|
expect(fliesAsDanmaku(toast('Bob joined', actor: 'Bob', category: NoticeCategory.people)), isTrue);
|
||||||
|
expect(fliesAsDanmaku(toast('Starting now', category: NoticeCategory.start)), isTrue);
|
||||||
|
expect(fliesAsDanmaku(toast('Bob added X to Up next', actor: 'Bob', category: NoticeCategory.upNext)), isTrue);
|
||||||
|
expect(fliesAsDanmaku(toast('Skipped X: gone', category: NoticeCategory.upNext)), isFalse);
|
||||||
|
expect(fliesAsDanmaku(toast("Bob can't play this", actor: 'Bob', category: NoticeCategory.problem)), isFalse);
|
||||||
|
expect(fliesAsDanmaku(toast('Bob nudged you', actor: 'Bob', category: NoticeCategory.nudge)), isFalse);
|
||||||
|
expect(fliesAsDanmaku(toast('Link copied')), isFalse);
|
||||||
|
expect(fliesAsDanmaku(toast('The server restarted', category: NoticeCategory.self)), isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('lines: the sender before the message, own chat framed', () {
|
||||||
|
final chat = danmakuForToast(toast('Me: hello', actor: 'Me', chat: true, own: true, category: NoticeCategory.chat))!;
|
||||||
|
expect((chat.kind, chat.sender, chat.text, chat.own), (DanmakuKind.chat, 'Me', 'hello', true));
|
||||||
|
final left = danmakuForToast(toast('Bob lost connection', actor: 'Bob', category: NoticeCategory.people))!;
|
||||||
|
expect((left.kind, left.text), (DanmakuKind.leave, 'lost connection'));
|
||||||
|
final start = danmakuForToast(toast('Starting now: Frieren', category: NoticeCategory.start))!;
|
||||||
|
expect((start.kind, start.sender), (DanmakuKind.starting, null));
|
||||||
|
expect(danmakuForToast(toast('Copied')), isNull);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('an idle layer asks for no frames', (tester) async {
|
||||||
|
final c = DanmakuController();
|
||||||
|
addTearDown(c.dispose);
|
||||||
|
await tester.pumpWidget(Directionality(
|
||||||
|
textDirection: TextDirection.ltr,
|
||||||
|
child: SizedBox(width: 800, height: 450, child: DanmakuLayer(controller: c, accent: Colors.purple)),
|
||||||
|
));
|
||||||
|
await tester.pump(const Duration(milliseconds: 16));
|
||||||
|
expect(tester.binding.hasScheduledFrame, isFalse);
|
||||||
|
c.add(const DanmakuItem(kind: DanmakuKind.chat, sender: 'Bob', text: 'hi'));
|
||||||
|
await tester.pump(const Duration(milliseconds: 16));
|
||||||
|
expect(c.onScreen.value, 1);
|
||||||
|
expect(tester.binding.hasScheduledFrame, isTrue);
|
||||||
|
await tester.pump(const Duration(seconds: 9));
|
||||||
|
await tester.pump(const Duration(milliseconds: 16));
|
||||||
|
expect(c.onScreen.value, 0);
|
||||||
|
expect(tester.binding.hasScheduledFrame, isFalse);
|
||||||
|
await tester.pumpWidget(const SizedBox());
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -19,16 +19,25 @@ void main() {
|
|||||||
..createSync(recursive: true);
|
..createSync(recursive: true);
|
||||||
final otherIcon = File('$share/icons/hicolor/48x48/apps/other.png')..createSync();
|
final otherIcon = File('$share/icons/hicolor/48x48/apps/other.png')..createSync();
|
||||||
|
|
||||||
await installDesktopEntry(executable: '$root/starsync', dataHome: share, force: true);
|
final registered = <String>[];
|
||||||
|
await installDesktopEntry(
|
||||||
|
executable: '$root/starsync',
|
||||||
|
dataHome: share,
|
||||||
|
force: true,
|
||||||
|
registerScheme: (file, dir) async => registered.add('$file in $dir'));
|
||||||
|
|
||||||
expect(oldEntry.existsSync(), isFalse);
|
expect(oldEntry.existsSync(), isFalse);
|
||||||
expect(oldIcon.existsSync(), isFalse);
|
expect(oldIcon.existsSync(), isFalse);
|
||||||
expect(other.existsSync(), isTrue);
|
expect(other.existsSync(), isTrue);
|
||||||
expect(otherIcon.existsSync(), isTrue);
|
expect(otherIcon.existsSync(), isTrue);
|
||||||
final entry = File('${apps.path}/dev.foxyz.starsync.desktop').readAsStringSync();
|
final entry = File('${apps.path}/dev.foxyz.starsync.desktop').readAsStringSync();
|
||||||
expect(entry, contains('Exec="$root/starsync"'));
|
expect(entry, contains('StartupWMClass=dev.foxyz.starsync'));
|
||||||
expect(entry, contains('StartupWMClass=dev.foxyz.starsync'));
|
|
||||||
expect(entry, contains('Icon=dev.foxyz.starsync'));
|
expect(entry, contains('Icon=dev.foxyz.starsync'));
|
||||||
|
// starsync:// links: the entry takes them and becomes their default.
|
||||||
|
expect(entry, contains('Exec="$root/starsync" %u'));
|
||||||
|
expect(entry, contains('MimeType=x-scheme-handler/starsync;'));
|
||||||
|
expect(entry, contains('StartupNotify=true'));
|
||||||
|
expect(registered, ['dev.foxyz.starsync.desktop in ${apps.path}']);
|
||||||
expect(File('$share/icons/hicolor/48x48/apps/dev.foxyz.starsync.png').existsSync(), isTrue);
|
expect(File('$share/icons/hicolor/48x48/apps/dev.foxyz.starsync.png').existsSync(), isTrue);
|
||||||
} finally {
|
} finally {
|
||||||
await tmp.delete(recursive: true);
|
await tmp.delete(recursive: true);
|
||||||
|
|||||||
@@ -0,0 +1,301 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:sync_relay_core/core.dart';
|
||||||
|
import 'package:starsync/src/platform/single_instance.dart';
|
||||||
|
import 'package:starsync/src/platform/url_scheme.dart';
|
||||||
|
import 'package:starsync/src/session/invite.dart';
|
||||||
|
import 'package:starsync/src/session/join_offers.dart';
|
||||||
|
import 'package:starsync/src/session/navigation.dart' show Place;
|
||||||
|
import 'package:starsync/src/session/public_address.dart';
|
||||||
|
import 'package:starsync/src/shell/party_strip.dart' show friendInviteLink;
|
||||||
|
import 'package:starsync/src/shell/join_offer_card.dart';
|
||||||
|
import 'package:starsync/src/state/connect_prefs.dart' show RecentServer;
|
||||||
|
|
||||||
|
import 'test_graph.dart';
|
||||||
|
|
||||||
|
// starsync:// invite links, the internet address lookup, the join
|
||||||
|
// prompt (links and the clipboard), one app per profile and the link
|
||||||
|
// handler's registration values.
|
||||||
|
|
||||||
|
String health({String name = "Bob's server", String version = '1.3.2', int viewers = 2}) => jsonEncode({
|
||||||
|
'ok': true,
|
||||||
|
'name': name,
|
||||||
|
'proto': RelayConstants.protoMax,
|
||||||
|
'protoMin': RelayConstants.protoMin,
|
||||||
|
'protoMax': RelayConstants.protoMax,
|
||||||
|
'version': version,
|
||||||
|
'viewers': viewers,
|
||||||
|
});
|
||||||
|
|
||||||
|
final _graph = testGraph(healthGet: (uri) async => health());
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
test('a friend\'s server: the copied link carries the password you joined with', () {
|
||||||
|
final link = friendInviteLink('example.org:6969', "Bob's server", secret: 'hunter2');
|
||||||
|
final back = InviteLink.parse(link)!;
|
||||||
|
expect([back.host, back.port, back.secret], ['example.org', 6969, 'hunter2']);
|
||||||
|
expect(friendInviteLink('example.org:6969', 'Bob'), isNot(contains('pw=')));
|
||||||
|
});
|
||||||
|
|
||||||
|
group('invite link', () {
|
||||||
|
test('round trip: address, name and password, which never shows in toString', () {
|
||||||
|
const link = InviteLink(host: '203.0.113.7', port: 9000, name: "Bob's server & co", secret: 'p@ss w&rd=1');
|
||||||
|
expect(link.uri, startsWith('starsync://203.0.113.7:9000/?name='));
|
||||||
|
expect(InviteLink.parse(link.uri), link);
|
||||||
|
expect(link.toString(), isNot(contains('p@ss')));
|
||||||
|
expect(link.toString(), contains('password'));
|
||||||
|
expect(redactLog('opening ${link.uri}'), isNot(contains('w%26rd')));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('bare links, IPv6, the default port; anything else is no link', () {
|
||||||
|
expect(InviteLink.parse('starsync://192.168.1.20:8080'), const InviteLink(host: '192.168.1.20', port: 8080));
|
||||||
|
expect(const InviteLink(host: '192.168.1.20', port: 8080).uri, 'starsync://192.168.1.20:8080');
|
||||||
|
expect(InviteLink.parse('STARSYNC://vps.example.com'), const InviteLink(host: 'vps.example.com', port: 8080));
|
||||||
|
const v6 = InviteLink(host: 'fe80::1', port: 8080);
|
||||||
|
expect(v6.uri, 'starsync://[fe80::1]:8080');
|
||||||
|
expect(InviteLink.parse(v6.uri)?.port, 8080);
|
||||||
|
expect(InviteLink.parse('starsync://host:99999'), isNull);
|
||||||
|
expect(InviteLink.parse('starsync://'), isNull);
|
||||||
|
expect(InviteLink.parse('https://192.168.1.20:8080'), isNull);
|
||||||
|
expect(InviteLink.parse('starsync://a b:1'), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the invite is just its link, with the password when one is set', () {
|
||||||
|
const invite = Invite(host: '192.168.1.20', port: 8080, name: "Bob's server", password: true, version: '1.3.2');
|
||||||
|
final link = invite.link(secret: 'hunter2');
|
||||||
|
expect(link.uri, 'starsync://192.168.1.20:8080/?name=Bob%27s+server&pw=hunter2');
|
||||||
|
expect(inviteIn(link.uri), link);
|
||||||
|
expect(link.toString(), isNot(contains('hunter2')));
|
||||||
|
expect(const Invite(host: '192.168.1.20', port: 8080).link().uri, 'starsync://192.168.1.20:8080');
|
||||||
|
// A 1.3.x app pasting it into the Join card's address.
|
||||||
|
expect(addressIn(link.uri), (host: '192.168.1.20', port: '8080'));
|
||||||
|
expect(invite.at('203.0.113.7').address, '203.0.113.7:8080');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('found in a text: a link anywhere, an old plain invite, never a bare address', () {
|
||||||
|
expect(inviteIn('hey, come watch: starsync://10.0.0.5:8081/?name=Ann see you')?.address, '10.0.0.5:8081');
|
||||||
|
expect(inviteIn('Join me on Starsync: 192.168.1.20:8080 (ask me for the password) · Starsync 1.3.0'),
|
||||||
|
const InviteLink(host: '192.168.1.20', port: 8080));
|
||||||
|
expect(inviteIn('192.168.1.20:8080'), isNull);
|
||||||
|
expect(inviteIn('see https://example.com:443/x'), isNull);
|
||||||
|
expect(inviteIn('${'x' * 5000} starsync://1.2.3.4:1'), isNull, reason: 'only the start of a long text');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reach: the internet address first; forwarding when it is the router\'s', () {
|
||||||
|
const behindRouter = InviteReach(public: '203.0.113.7', local: ['192.168.1.20', '100.64.1.2']);
|
||||||
|
expect(behindRouter.best, '203.0.113.7');
|
||||||
|
expect(behindRouter.forwarding, isTrue);
|
||||||
|
expect([for (final c in behindRouter.choices) c.kind], [AddressKind.public, AddressKind.lan, AddressKind.vpn]);
|
||||||
|
const direct = InviteReach(public: '203.0.113.7', local: ['203.0.113.7']);
|
||||||
|
expect(direct.forwarding, isFalse);
|
||||||
|
expect(direct.choices.length, 1);
|
||||||
|
expect(const InviteReach(local: ['192.168.1.20']).best, '192.168.1.20');
|
||||||
|
expect(isPublicIpv4('8.8.8.8'), isTrue);
|
||||||
|
for (final ip in ['10.1.2.3', '192.168.0.1', '172.20.0.1', '100.100.1.1', '127.0.0.1', '198.18.0.1', '::1', 'x']) {
|
||||||
|
expect(isPublicIpv4(ip), isFalse, reason: ip);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('internet address', () {
|
||||||
|
final a = Uri.parse('https://a.test'), b = Uri.parse('https://b.test'), c = Uri.parse('https://c.test');
|
||||||
|
|
||||||
|
test('the first service with a public address wins; failing, private or odd answers are skipped', () async {
|
||||||
|
final asked = <String>[];
|
||||||
|
final ip = await lookUpPublicAddress(
|
||||||
|
services: [a, b, c],
|
||||||
|
get: (uri) async {
|
||||||
|
asked.add(uri.host);
|
||||||
|
return switch (uri.host) {
|
||||||
|
'a.test' => throw const SocketException('blocked'),
|
||||||
|
'b.test' => '192.168.1.1\n',
|
||||||
|
_ => '203.0.113.7\n',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(ip, '203.0.113.7');
|
||||||
|
expect(asked, ['a.test', 'b.test', 'c.test']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a slow service is given up on; none answering is no address', () async {
|
||||||
|
final ip = await lookUpPublicAddress(
|
||||||
|
services: [a, b],
|
||||||
|
each: const Duration(milliseconds: 50),
|
||||||
|
get: (uri) => uri.host == 'a.test' ? Completer<String>().future : Future.value('203.0.113.9'),
|
||||||
|
);
|
||||||
|
expect(ip, '203.0.113.9');
|
||||||
|
expect(await lookUpPublicAddress(services: [a], get: (_) async => '<html>nope</html>'), isNull);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('join prompt', () {
|
||||||
|
final offers = _graph.joinOffers;
|
||||||
|
final yours = _graph.yourServer;
|
||||||
|
tearDown(() {
|
||||||
|
offers.offer.value = null;
|
||||||
|
yours.shared.value = null;
|
||||||
|
yours.reach.value = null;
|
||||||
|
_graph.forms.recents.value = const [];
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a clicked link becomes an offer with what /health says', () async {
|
||||||
|
offers.fromArgs(['--whatever', 'starsync://203.0.113.7:8080/?name=Bob']);
|
||||||
|
expect(offers.offer.peek()?.source, OfferSource.link);
|
||||||
|
expect(offers.offer.peek()?.question, 'Join Bob (203.0.113.7:8080)?');
|
||||||
|
await pumpEventQueue();
|
||||||
|
final o = offers.offer.peek()!;
|
||||||
|
expect(o.question, "Join Bob's server (203.0.113.7:8080)?");
|
||||||
|
expect(o.detail, 'Starsync 1.3.2 · 2 watching');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the clipboard: offered once; Not now and Join both stop it coming back', () async {
|
||||||
|
offers.fromClipboard('come: starsync://203.0.113.20:8080');
|
||||||
|
expect(offers.offer.peek()?.source, OfferSource.clipboard);
|
||||||
|
await offers.dismiss();
|
||||||
|
expect(offers.offer.peek(), isNull);
|
||||||
|
offers.fromClipboard('something else');
|
||||||
|
offers.fromClipboard('come: starsync://203.0.113.20:8080');
|
||||||
|
expect(offers.offer.peek(), isNull, reason: 'dismissed');
|
||||||
|
|
||||||
|
offers.fromClipboard('starsync://203.0.113.21:8080');
|
||||||
|
_graph.forms.recents.value = const [RecentServer(host: '203.0.113.21', port: '8080', secret: 'saved')];
|
||||||
|
final link = offers.accept();
|
||||||
|
expect(link?.secret, 'saved', reason: 'a link without its password takes the saved one');
|
||||||
|
offers.fromClipboard('x');
|
||||||
|
offers.fromClipboard('starsync://203.0.113.21:8080');
|
||||||
|
expect(offers.offer.peek(), isNull, reason: 'joined');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('never your own invite, nor the server you are in, nor plain text', () {
|
||||||
|
yours.port.value = 40000;
|
||||||
|
addTearDown(() => yours.port.value = null);
|
||||||
|
yours.shared.value = const Invite(host: '203.0.113.7', port: 8080);
|
||||||
|
yours.reach.value = const InviteReach(public: '203.0.113.7', local: ['192.168.1.20']);
|
||||||
|
offers.fromClipboard('starsync://192.168.1.20:8080');
|
||||||
|
offers.fromClipboard('starsync://127.0.0.1:40000');
|
||||||
|
expect(offers.offer.peek(), isNull);
|
||||||
|
// What this app just copied is not offered back to it.
|
||||||
|
const copied = 'starsync://198.51.100.4:8080';
|
||||||
|
offers.copied(copied);
|
||||||
|
offers.fromClipboard(copied);
|
||||||
|
expect(offers.offer.peek(), isNull);
|
||||||
|
offers.fromClipboard('just some text 10.0.0.1:80');
|
||||||
|
expect(offers.offer.peek(), isNull);
|
||||||
|
|
||||||
|
final nav = _graph.navigation;
|
||||||
|
nav.place.value = Place.friend;
|
||||||
|
nav.address.value = '198.51.100.9:8080';
|
||||||
|
addTearDown(() {
|
||||||
|
nav.place.value = Place.none;
|
||||||
|
nav.address.value = '';
|
||||||
|
});
|
||||||
|
offers.fromArgs(['starsync://198.51.100.9:8080']);
|
||||||
|
expect(offers.offer.peek(), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('the card: the question, Join and Not now', (tester) async {
|
||||||
|
var joined = 0;
|
||||||
|
await tester.pumpWidget(scoped(
|
||||||
|
_graph,
|
||||||
|
MaterialApp(
|
||||||
|
theme: ThemeData.dark(),
|
||||||
|
home: Scaffold(body: JoinOfferCard(offers: offers, onJoin: () => joined++)))));
|
||||||
|
expect(find.byKey(const ValueKey('join-offer')), findsNothing);
|
||||||
|
offers.offer.value = const JoinOffer(InviteLink(host: '203.0.113.7', port: 8080, name: 'Ann'), OfferSource.clipboard);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('Join Ann (203.0.113.7:8080)?'), findsOneWidget);
|
||||||
|
expect(find.text('Invite on your clipboard · Checking the server…'), findsOneWidget);
|
||||||
|
await tester.tap(find.byKey(const ValueKey('join-offer-join')));
|
||||||
|
expect(joined, 1);
|
||||||
|
await tester.tap(find.byKey(const ValueKey('join-offer-dismiss')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.byKey(const ValueKey('join-offer')), findsNothing);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('one app per profile', () {
|
||||||
|
late Directory tmp;
|
||||||
|
setUp(() async => tmp = await Directory.systemTemp.createTemp('starsync-instance'));
|
||||||
|
tearDown(() async => tmp.delete(recursive: true));
|
||||||
|
|
||||||
|
test('a second start hands its arguments and token over and ends', () async {
|
||||||
|
final first = await InstanceLock.claim(tmp.path);
|
||||||
|
expect(first, isNotNull);
|
||||||
|
expect(first!.held, isTrue);
|
||||||
|
final got = first.handoffs.first;
|
||||||
|
int? allowed;
|
||||||
|
final second = await InstanceLock.claim(tmp.path,
|
||||||
|
args: ['starsync://1.2.3.4:8080/?pw=x'], activation: 'tok', beforeSend: (p) => allowed = p);
|
||||||
|
expect(second, isNull, reason: 'handed over');
|
||||||
|
expect(allowed, pid);
|
||||||
|
final h = await got;
|
||||||
|
expect(h.args, ['starsync://1.2.3.4:8080/?pw=x']);
|
||||||
|
expect(h.activation, 'tok');
|
||||||
|
await first.release();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('each profile folder is its own instance', () async {
|
||||||
|
final a = await InstanceLock.claim('${tmp.path}/a');
|
||||||
|
final b = await InstanceLock.claim('${tmp.path}/b');
|
||||||
|
expect(a?.held, isTrue);
|
||||||
|
expect(b?.held, isTrue);
|
||||||
|
await a!.release();
|
||||||
|
await b!.release();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a start while the app closes waits and takes over once it is gone', () async {
|
||||||
|
final first = (await InstanceLock.claim(tmp.path))!;
|
||||||
|
first.closing();
|
||||||
|
final second = InstanceLock.claim(tmp.path, retryEvery: const Duration(milliseconds: 20));
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 150));
|
||||||
|
await first.release();
|
||||||
|
final taken = await second;
|
||||||
|
expect(taken?.held, isTrue);
|
||||||
|
await taken!.release();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a wrong token is refused (nothing handed over)', () async {
|
||||||
|
final first = (await InstanceLock.claim(tmp.path))!;
|
||||||
|
var handed = false;
|
||||||
|
first.handoffs.listen((_) => handed = true);
|
||||||
|
final s = await Socket.connect(InternetAddress.loopbackIPv4, first.port!);
|
||||||
|
s.writeln(jsonEncode({'token': 'guess', 'args': ['x']}));
|
||||||
|
final reply = await utf8.decoder.bind(s).join();
|
||||||
|
expect(reply, contains('"ok":false'));
|
||||||
|
expect(handed, isFalse);
|
||||||
|
s.destroy();
|
||||||
|
await first.release();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('no lock and nobody answering: starts anyway after a while', () async {
|
||||||
|
final first = (await InstanceLock.claim(tmp.path))!;
|
||||||
|
first.closing();
|
||||||
|
final started = await InstanceLock.claim(tmp.path,
|
||||||
|
retryFor: const Duration(milliseconds: 100),
|
||||||
|
closingWait: const Duration(milliseconds: 100),
|
||||||
|
retryEvery: const Duration(milliseconds: 20));
|
||||||
|
expect(started?.held, isFalse);
|
||||||
|
await first.release();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('link handler', () {
|
||||||
|
test('Windows: HKCU\\Software\\Classes\\starsync opens starsync.exe with the link', () {
|
||||||
|
final v = windowsSchemeValues(r'C:\Apps\Starsync\starsync.exe');
|
||||||
|
expect(v.first, (r'Software\Classes\starsync', '', 'URL:Starsync invite'));
|
||||||
|
expect(v, contains((r'Software\Classes\starsync', 'URL Protocol', '')));
|
||||||
|
expect(v.last, (r'Software\Classes\starsync\shell\open\command', '', r'"C:\Apps\Starsync\starsync.exe" "%1"'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an old-named copy points links at starsync next to it', () {
|
||||||
|
expect(schemeExecutable(r'C:\S\watch_together.exe', exists: (_) => true), r'C:\S\starsync.exe');
|
||||||
|
expect(schemeExecutable(r'C:\S\watch_together.exe', exists: (_) => false), r'C:\S\watch_together.exe');
|
||||||
|
expect(schemeExecutable('/opt/s/watch_together', exists: (_) => true), '/opt/s/starsync');
|
||||||
|
expect(schemeExecutable('/opt/s/starsync', exists: (_) => true), '/opt/s/starsync');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,5 +1,3 @@
|
|||||||
// restructure: rewrite in step 6 (Watch tracking): the progress-reporting
|
|
||||||
// checks; the guest-through-the-relay flow stays.
|
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
@@ -14,6 +12,7 @@ import 'package:starsync/src/jellyfin/track_prefs.dart';
|
|||||||
import 'package:starsync/src/playback/media_marks.dart';
|
import 'package:starsync/src/playback/media_marks.dart';
|
||||||
import 'package:starsync/src/screens/home/picked_media.dart';
|
import 'package:starsync/src/screens/home/picked_media.dart';
|
||||||
import 'package:starsync/src/session/session_coordinator.dart';
|
import 'package:starsync/src/session/session_coordinator.dart';
|
||||||
|
import 'package:starsync/src/state/settings.dart';
|
||||||
|
|
||||||
// Two account holders and a guest watch Jellyfin episodes together over a
|
// Two account holders and a guest watch Jellyfin episodes together over a
|
||||||
// real sync server, real mpv and a local fake Jellyfin (never a real one):
|
// real sync server, real mpv and a local fake Jellyfin (never a real one):
|
||||||
@@ -220,7 +219,8 @@ String _kind(_Call c) => c.path.split('/').last;
|
|||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
test(
|
test(
|
||||||
'account holders direct-play and report, the guest plays through the relay, all in sync',
|
'account holders direct-play and report what they watched, the guest plays through the relay, '
|
||||||
|
'the next episode continues it for everyone',
|
||||||
timeout: const Timeout(Duration(minutes: 4)),
|
timeout: const Timeout(Duration(minutes: 4)),
|
||||||
() async {
|
() async {
|
||||||
MediaKit.ensureInitialized();
|
MediaKit.ensureInitialized();
|
||||||
@@ -296,6 +296,11 @@ void main() {
|
|||||||
expect((p - pos.first).abs(), lessThan(1.0), reason: 'in sync: $pos');
|
expect((p - pos.first).abs(), lessThan(1.0), reason: 'in sync: $pos');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Carol sits out for the rest of the episode: she is not tracked as
|
||||||
|
// having watched it (P7).
|
||||||
|
c.sitOut();
|
||||||
|
await _poll(() => c.selfAway.peek(), 'carol away');
|
||||||
|
|
||||||
// Pause (A unreadies): an immediate Progress with IsPaused.
|
// Pause (A unreadies): an immediate Progress with IsPaused.
|
||||||
a.toggleReady();
|
a.toggleReady();
|
||||||
await _poll(() => !a.playback.player.state.playing, 'held');
|
await _poll(() => !a.playback.player.state.playing, 'held');
|
||||||
@@ -304,7 +309,7 @@ void main() {
|
|||||||
_kind(s) == 'Progress' && s.body?['IsPaused'] == true),
|
_kind(s) == 'Progress' && s.body?['IsPaused'] == true),
|
||||||
'paused progress');
|
'paused progress');
|
||||||
a.toggleReady();
|
a.toggleReady();
|
||||||
await _poll(() => all.every((x) => x.playback.player.state.playing), 'playing again');
|
await _poll(() => [a, b].every((x) => x.playback.player.state.playing), 'playing again');
|
||||||
|
|
||||||
// Account holders see the "Next episode" prompt (this item is
|
// Account holders see the "Next episode" prompt (this item is
|
||||||
// shorter than its 2 min lead); the guest cannot put it up.
|
// shorter than its 2 min lead); the guest cannot put it up.
|
||||||
@@ -319,23 +324,28 @@ void main() {
|
|||||||
_kind(s) == 'Progress' && s.body?['AudioStreamIndex'] == 2),
|
_kind(s) == 'Progress' && s.body?['AudioStreamIndex'] == 2),
|
||||||
'track change reported');
|
'track change reported');
|
||||||
|
|
||||||
// Near the end: finished -> Stopped, then one auto-advance swap.
|
// Alice put the episode up and has the next one: it continues it,
|
||||||
a.seekTo(16);
|
// in Up next for everyone.
|
||||||
|
await _poll(() => b.queue.continues.peek()?.media.jellyfin?.itemId == _ep2, 'the continuation');
|
||||||
|
final auto = b.queue.continues.peek()!;
|
||||||
|
expect(auto.by, a.connection.memberId);
|
||||||
|
|
||||||
|
// Played to the end: finished -> Stopped, the server's countdown
|
||||||
|
// on every screen, then one swap.
|
||||||
await _poll(() => a.serverState.peek()?.transport == 'finished', 'finished',
|
await _poll(() => a.serverState.peek()?.transport == 'finished', 'finished',
|
||||||
timeout: const Duration(seconds: 20));
|
timeout: const Duration(seconds: 40));
|
||||||
final finishedId = a.serverState.peek()!.mediaId;
|
final finishedId = a.serverState.peek()!.mediaId;
|
||||||
await _poll(() => a.autoAdvance.peek() != null && c.autoAdvance.peek() != null,
|
await _poll(() => [a, b].every((x) => (x.queue.countdown.peek()?.secondsLeft ?? 0) > 0),
|
||||||
'both account holders count down');
|
'everyone sees the countdown');
|
||||||
expect(b.autoAdvance.peek(), isNull);
|
expect(a.serverState.peek()!.advance!.id, auto.id);
|
||||||
final swappers = [a.autoAdvance.peek()!.swapper, c.autoAdvance.peek()!.swapper];
|
expect(a.queue.countdown.peek()!.item.id, auto.id);
|
||||||
expect(swappers.where((s) => s), hasLength(1),
|
|
||||||
reason: 'exactly one designated swapper: the lowest member id (alice, m1)');
|
|
||||||
expect(a.autoAdvance.peek()!.swapper, isTrue);
|
|
||||||
await _poll(() => (a.serverState.peek()?.mediaId ?? 0) > finishedId, 'advanced',
|
await _poll(() => (a.serverState.peek()?.mediaId ?? 0) > finishedId, 'advanced',
|
||||||
timeout: const Duration(seconds: 20));
|
timeout: const Duration(seconds: 25));
|
||||||
await Future.delayed(const Duration(seconds: 3));
|
await Future.delayed(const Duration(seconds: 3));
|
||||||
expect(a.serverState.peek()!.mediaId, finishedId + 1, reason: 'no double swap');
|
expect(a.serverState.peek()!.mediaId, finishedId + 1, reason: 'no double swap');
|
||||||
expect(refOfStateMedia(a.serverState.peek()!.media)!.itemId, _ep2);
|
expect(refOfStateMedia(a.serverState.peek()!.media)!.itemId, _ep2);
|
||||||
|
expect(a.serverState.peek()!.queue, isEmpty, reason: 'the swap took the continuation');
|
||||||
|
c.joinBack();
|
||||||
await _poll(() => all.every((x) => x.playback.durationSecs > 19 && x.jellyfinPlan.peek() != null),
|
await _poll(() => all.every((x) => x.playback.durationSecs > 19 && x.jellyfinPlan.peek() != null),
|
||||||
'all opened the next episode');
|
'all opened the next episode');
|
||||||
expect(b.jellyfinPlan.peek(), isA<RelayPlay>());
|
expect(b.jellyfinPlan.peek(), isA<RelayPlay>());
|
||||||
@@ -383,13 +393,17 @@ void main() {
|
|||||||
expect(ep1.last, 'Stopped');
|
expect(ep1.last, 'Stopped');
|
||||||
expect(ep1.where((k) => k == 'Stopped'), hasLength(1));
|
expect(ep1.where((k) => k == 'Stopped'), hasLength(1));
|
||||||
final stopped1 = aCalls.lastWhere((s) => s.body?['ItemId'] == _ep1);
|
final stopped1 = aCalls.lastWhere((s) => s.body?['ItemId'] == _ep1);
|
||||||
// Part of it watched, then a seek to the end: not watched (see
|
// Alice watched it through (time In, see WatchSession): Stopped
|
||||||
// WatchSession). A resume point capped at 85 % of the 20 s, so
|
// without a position, then marked played.
|
||||||
// Jellyfin cannot mark it played on its own.
|
expect(stopped1.body!.containsKey('PositionTicks'), isFalse, reason: 'watched: Jellyfin marks it played');
|
||||||
expect(stopped1.body!['PositionTicks'], lessThan(18 * 10000000),
|
bool markedPlayed(String token) => jelly.calls.any((c) =>
|
||||||
reason: 'part way: resume point under 90 %, so not marked played');
|
c.method == 'POST' &&
|
||||||
expect(jelly.calls.any((c) => c.method == 'POST' && c.path == '/UserPlayedItems/$_ep1'), isFalse,
|
c.path == '/UserPlayedItems/$_ep1' &&
|
||||||
reason: 'seeking to the end is not watching');
|
_FakeJellyfin.authFields(c.auth)['Token'] == token);
|
||||||
|
expect(markedPlayed('ATOKEN'), isTrue);
|
||||||
|
expect(markedPlayed('CTOKEN'), isFalse, reason: 'carol sat out most of it (P7)');
|
||||||
|
final carol1 = jelly.sessions(token: 'CTOKEN').lastWhere((s) => s.body?['ItemId'] == _ep1);
|
||||||
|
expect(_kind(carol1), 'Stopped', reason: 'sitting out stops her reports');
|
||||||
expect(stopped1.body!['PlaySessionId'], 'ps-ua-$_ep1');
|
expect(stopped1.body!['PlaySessionId'], 'ps-ua-$_ep1');
|
||||||
expect(ep2.first, 'Playing');
|
expect(ep2.first, 'Playing');
|
||||||
expect(ep2.last, 'Stopped', reason: 'leaving reports Stopped');
|
expect(ep2.last, 'Stopped', reason: 'leaving reports Stopped');
|
||||||
@@ -442,7 +456,12 @@ void main() {
|
|||||||
final accA = await _account(jelly.base, 'ATOKEN', 'ua', 'A-PC');
|
final accA = await _account(jelly.base, 'ATOKEN', 'ua', 'A-PC');
|
||||||
final accB = JellyfinAccounts(store: MemoryJellyfinStore());
|
final accB = JellyfinAccounts(store: MemoryJellyfinStore());
|
||||||
await accB.ensureLoaded(validate: false);
|
await accB.ensureLoaded(validate: false);
|
||||||
final a = SessionCoordinator(jellyfin: accA, trackPrefs: TrackPrefs(store: MemoryJellyfinStore()));
|
// No continuation: its warm relay would be read while the test stops
|
||||||
|
// (a cut relay response fails a test, see DEVELOPMENT "Traps").
|
||||||
|
final a = SessionCoordinator(
|
||||||
|
jellyfin: accA,
|
||||||
|
trackPrefs: TrackPrefs(store: MemoryJellyfinStore()),
|
||||||
|
settings: AppSettings()..autoAdvance.value = false);
|
||||||
final b = SessionCoordinator(jellyfin: accB, trackPrefs: TrackPrefs(store: MemoryJellyfinStore()));
|
final b = SessionCoordinator(jellyfin: accB, trackPrefs: TrackPrefs(store: MemoryJellyfinStore()));
|
||||||
try {
|
try {
|
||||||
await a.connect(ConnectParams(
|
await a.connect(ConnectParams(
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
// restructure: rewrite in step 6 (What's next + Watch tracking): the
|
|
||||||
// "progress" and "episodes" groups (reporting, auto-advance swapper).
|
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
|
||||||
import 'package:flutter/gestures.dart';
|
import 'package:flutter/gestures.dart';
|
||||||
@@ -14,8 +12,9 @@ import 'package:starsync/src/jellyfin/session_media.dart';
|
|||||||
import 'package:starsync/src/jellyfin/track_prefs.dart';
|
import 'package:starsync/src/jellyfin/track_prefs.dart';
|
||||||
import 'package:starsync/src/screens/home/media_picker.dart';
|
import 'package:starsync/src/screens/home/media_picker.dart';
|
||||||
import 'package:starsync/src/screens/session/jellyfin_widgets.dart';
|
import 'package:starsync/src/screens/session/jellyfin_widgets.dart';
|
||||||
|
import 'package:starsync/src/queue/queue_controller.dart' show UpNextCountdown;
|
||||||
|
import 'package:starsync/src/queue/queue_ui.dart' show UpNextCard;
|
||||||
import 'package:starsync/src/session/delivery.dart';
|
import 'package:starsync/src/session/delivery.dart';
|
||||||
import 'package:starsync/src/session/session_coordinator.dart' show AutoAdvance;
|
|
||||||
import 'package:starsync/src/shell/library_tab.dart' show putUpJellyfinMedia;
|
import 'package:starsync/src/shell/library_tab.dart' show putUpJellyfinMedia;
|
||||||
|
|
||||||
import 'jellyfin_accounts_test.dart';
|
import 'jellyfin_accounts_test.dart';
|
||||||
@@ -212,9 +211,12 @@ void main() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The reporter reads the watch the session's tracker feeds (time In,
|
||||||
|
// D3); here the test feeds it.
|
||||||
group('progress', () {
|
group('progress', () {
|
||||||
late JellyfinAccounts accounts;
|
late JellyfinAccounts accounts;
|
||||||
late DateTime now;
|
late DateTime now;
|
||||||
|
late WatchSession watch;
|
||||||
late JellyfinProgressReporter reporter;
|
late JellyfinProgressReporter reporter;
|
||||||
final errors = <Object>[];
|
final errors = <Object>[];
|
||||||
var unauthorized = 0;
|
var unauthorized = 0;
|
||||||
@@ -224,27 +226,35 @@ void main() {
|
|||||||
now = DateTime(2026, 1, 1, 20);
|
now = DateTime(2026, 1, 1, 20);
|
||||||
errors.clear();
|
errors.clear();
|
||||||
unauthorized = 0;
|
unauthorized = 0;
|
||||||
|
watch = WatchSession();
|
||||||
reporter = JellyfinProgressReporter(
|
reporter = JellyfinProgressReporter(
|
||||||
client: accounts.clientFor(fixtureServerId)!,
|
client: accounts.clientFor(fixtureServerId)!,
|
||||||
itemId: episodeId,
|
itemId: episodeId,
|
||||||
source: fixtureSource(),
|
source: fixtureSource(),
|
||||||
playSessionId: playSessionId,
|
playSessionId: playSessionId,
|
||||||
|
watch: watch,
|
||||||
clock: () => now,
|
clock: () => now,
|
||||||
onError: errors.add,
|
onError: errors.add,
|
||||||
onUnauthorized: () => unauthorized++,
|
onUnauthorized: () => unauthorized++,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/// One second of the session at wall second [secs]: the watch gets its
|
||||||
|
/// sample (In unless [away]), the reporter its snapshot.
|
||||||
|
void tick(int secs, double pos, {bool paused = false, bool away = false, int? aid, int? sid}) {
|
||||||
|
now = DateTime(2026, 1, 1, 20).add(Duration(seconds: secs));
|
||||||
|
watch.sample(pos, playing: !paused, present: !away, at: now);
|
||||||
|
reporter.update(PlayerSnapshot(position: pos, paused: paused, aid: aid, sid: sid));
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> idle() => reporter.stop(const PlayerSnapshot(position: 0, paused: true))
|
Future<void> idle() => reporter.stop(const PlayerSnapshot(position: 0, paused: true))
|
||||||
.then((_) {});
|
.then((_) {});
|
||||||
|
|
||||||
test('Playing, Progress every 10 s and on pause / seek / track change, Stopped', () async {
|
test('Playing, Progress every 10 s and on pause / seek / track change, Stopped', () async {
|
||||||
// English audio (mpv aid 2 = Jellyfin index 2), English full subs (sid 1 = 3).
|
// English audio (mpv aid 2 = Jellyfin index 2), English full subs (sid 1 = 3).
|
||||||
reporter.start(const PlayerSnapshot(position: 0, paused: true, aid: 2, sid: 1));
|
reporter.start(const PlayerSnapshot(position: 0, paused: true, aid: 2, sid: 1));
|
||||||
void at(int secs, double pos, {bool paused = false, int? aid = 2, int? sid = 1}) {
|
void at(int secs, double pos, {bool paused = false, int? aid = 2, int? sid = 1}) =>
|
||||||
now = DateTime(2026, 1, 1, 20).add(Duration(seconds: secs));
|
tick(secs, pos, paused: paused, aid: aid, sid: sid);
|
||||||
reporter.update(PlayerSnapshot(position: pos, paused: paused, aid: aid, sid: sid));
|
|
||||||
}
|
|
||||||
|
|
||||||
at(1, 0); // resume -> Progress
|
at(1, 0); // resume -> Progress
|
||||||
at(2, 1);
|
at(2, 1);
|
||||||
@@ -282,15 +292,14 @@ void main() {
|
|||||||
'MediaSourceId': episodeId,
|
'MediaSourceId': episodeId,
|
||||||
'PlaySessionId': playSessionId,
|
'PlaySessionId': playSessionId,
|
||||||
'PositionTicks': 0,
|
'PositionTicks': 0,
|
||||||
}, reason: '11 s watched is a peek: the resume point from before (none) stays');
|
}, reason: '11 s In is a peek: the resume point from before (none) stays');
|
||||||
expect(errors, isEmpty);
|
expect(errors, isEmpty);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('watched to the end: Stopped without a position, then marked played', () async {
|
test('watched to the end: Stopped without a position, then marked played', () async {
|
||||||
reporter.start(const PlayerSnapshot(position: 0, paused: false));
|
reporter.start(const PlayerSnapshot(position: 0, paused: false));
|
||||||
for (var i = 1; i <= 1420; i++) {
|
for (var i = 0; i <= 1420; i++) {
|
||||||
now = DateTime(2026, 1, 1, 20).add(Duration(seconds: i));
|
tick(i, i.toDouble());
|
||||||
reporter.update(PlayerSnapshot(position: i.toDouble(), paused: false));
|
|
||||||
}
|
}
|
||||||
await reporter.stop(const PlayerSnapshot(position: 1420, paused: true));
|
await reporter.stop(const PlayerSnapshot(position: 1420, paused: true));
|
||||||
expect(reports.map((r) => r.path.split('/').last).toList().sublist(reports.length - 3),
|
expect(reports.map((r) => r.path.split('/').last).toList().sublist(reports.length - 3),
|
||||||
@@ -302,6 +311,19 @@ void main() {
|
|||||||
expect(nearEnd, isEmpty, reason: 'near the end Progress carries no position: Jellyfin must not decide');
|
expect(nearEnd, isEmpty, reason: 'near the end Progress carries no position: Jellyfin must not decide');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('sat out for most of it: a resume point, never played (P7)', () async {
|
||||||
|
reporter.start(const PlayerSnapshot(position: 0, paused: false));
|
||||||
|
for (var i = 0; i <= 1420; i++) {
|
||||||
|
// In for five minutes, then away while the others watch on.
|
||||||
|
tick(i, i.toDouble(), away: i > 300);
|
||||||
|
}
|
||||||
|
await reporter.stop(const PlayerSnapshot(position: 1420, paused: true));
|
||||||
|
expect(reports.map((r) => r.path.split('/').last), isNot(contains('UserPlayedItems')));
|
||||||
|
final stopped = reports.lastWhere((r) => r.path.endsWith('Stopped')).body;
|
||||||
|
expect(stopped['PositionTicks'], 300 * jellyfinTicksPerSecond,
|
||||||
|
reason: 'it resumes where this viewer stopped watching');
|
||||||
|
});
|
||||||
|
|
||||||
test('played before: a peek never unmarks it', () async {
|
test('played before: a peek never unmarks it', () async {
|
||||||
final r = JellyfinProgressReporter(
|
final r = JellyfinProgressReporter(
|
||||||
client: accounts.clientFor(fixtureServerId)!,
|
client: accounts.clientFor(fixtureServerId)!,
|
||||||
@@ -376,21 +398,6 @@ void main() {
|
|||||||
expect(media.jellyfin!.subtitle, 'Black Summoner · S1E7');
|
expect(media.jellyfin!.subtitle, 'Black Summoner · S1E7');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('one auto-advance swapper: the lowest id among members with their own account', () {
|
|
||||||
final members = [
|
|
||||||
member('m10', 'Ann', jf: {fixtureServerId: true}),
|
|
||||||
member('m2', 'Bob', jf: {fixtureServerId: true}),
|
|
||||||
member('m1', 'Guest', jf: {fixtureServerId: false}),
|
|
||||||
member('m0', 'Old', jf: {fixtureServerId: true}, gone: true),
|
|
||||||
member('m3', 'Fresh'),
|
|
||||||
];
|
|
||||||
expect(autoAdvanceSwapper(members, fixtureServerId), 'm2');
|
|
||||||
expect(autoAdvanceSwapper(members.reversed.toList(), fixtureServerId), 'm2',
|
|
||||||
reason: 'order does not matter: every client agrees');
|
|
||||||
expect(autoAdvanceSwapper(members, 'other-server'), isNull);
|
|
||||||
expect(compareMemberIds('m9', 'm10'), lessThan(0));
|
|
||||||
});
|
|
||||||
|
|
||||||
test('member route: via server only for relay viewers of the current item', () {
|
test('member route: via server only for relay viewers of the current item', () {
|
||||||
DeliveryRoute? route(MemberView m, {bool relay = true}) =>
|
DeliveryRoute? route(MemberView m, {bool relay = true}) =>
|
||||||
routeOf(m, DeliveryKind.jellyfin, '', episodeRef, relay);
|
routeOf(m, DeliveryKind.jellyfin, '', episodeRef, relay);
|
||||||
@@ -508,23 +515,36 @@ void main() {
|
|||||||
const JellyfinMedia(
|
const JellyfinMedia(
|
||||||
serverId: fixtureServerId, baseUrl: 'b', itemId: nextEpisodeId, title: 'Battle with the Heroes'));
|
serverId: fixtureServerId, baseUrl: 'b', itemId: nextEpisodeId, title: 'Battle with the Heroes'));
|
||||||
|
|
||||||
testWidgets('end card: Next prompt, countdown with Cancel', (tester) async {
|
testWidgets("end card: the server's countdown with Cancel, Play now; waiting for who puts it up",
|
||||||
var play = 0, cancel = 0;
|
(tester) async {
|
||||||
await tester.pumpWidget(scoped(_graph, app(EndOfEpisodeCard(
|
final queue = _graph.coordinator.queue;
|
||||||
next: next, countdown: null, onPlayNow: () => play++, onCancel: () => cancel++))));
|
const item = QueueItem(
|
||||||
expect(find.text('Next: S1E7 Battle with the Heroes'), findsOneWidget);
|
id: 'q4',
|
||||||
expect(find.byKey(const ValueKey('auto-advance-cancel')), findsNothing);
|
media: HelloMedia(name: 'ep7', size: 0, duration: 0, jellyfin: JellyfinRef(serverId: fixtureServerId, itemId: nextEpisodeId)),
|
||||||
await tester.tap(find.byKey(const ValueKey('play-next')));
|
how: QueueHow.jellyfin,
|
||||||
expect(play, 1);
|
title: 'Battle with the Heroes',
|
||||||
|
sub: 'S1E7',
|
||||||
|
by: 'm1',
|
||||||
|
byName: 'Ann',
|
||||||
|
auto: true,
|
||||||
|
forMedia: 3);
|
||||||
|
await tester.pumpWidget(scoped(_graph, app(UpNextCard(
|
||||||
|
queue: queue, countdown: const UpNextCountdown(mediaId: 3, item: item, secondsLeft: 7)))));
|
||||||
|
expect(find.text('Next in 7 s'), findsOneWidget);
|
||||||
|
expect(find.text('Battle with the Heroes'), findsOneWidget);
|
||||||
|
expect(find.byKey(const ValueKey('up-next-cancel')), findsOneWidget);
|
||||||
|
expect(find.byKey(const ValueKey('up-next-play-now')), findsOneWidget);
|
||||||
|
|
||||||
await tester.pumpWidget(scoped(_graph, app(EndOfEpisodeCard(
|
// Cancelled (for everyone): Play now only.
|
||||||
next: next,
|
await tester.pumpWidget(scoped(_graph, app(UpNextCard(
|
||||||
countdown: AutoAdvance(mediaId: 3, secondsLeft: 7, next: next, swapper: false),
|
queue: queue, countdown: const UpNextCountdown(mediaId: 3, item: item)))));
|
||||||
onPlayNow: () => play++,
|
expect(find.text('Next'), findsOneWidget);
|
||||||
onCancel: () => cancel++))));
|
expect(find.byKey(const ValueKey('up-next-cancel')), findsNothing);
|
||||||
expect(find.text('Next episode in 7 s'), findsOneWidget);
|
|
||||||
await tester.tap(find.byKey(const ValueKey('auto-advance-cancel')));
|
// Ran out while the one who puts it up is gone: the card says so.
|
||||||
expect(cancel, 1);
|
await tester.pumpWidget(scoped(_graph, app(UpNextCard(
|
||||||
|
queue: queue, countdown: const UpNextCountdown(mediaId: 3, item: item, secondsLeft: 0, waitingFor: 'Ann')))));
|
||||||
|
expect(find.text('Waiting for Ann'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('next-episode pill: faint at rest, lit on hover, click puts it up', (tester) async {
|
testWidgets('next-episode pill: faint at rest, lit on hover, click puts it up', (tester) async {
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:sync_relay_core/core.dart';
|
||||||
|
import 'package:starsync/src/session/media_plan.dart';
|
||||||
|
import 'package:starsync/src/session/relay_feeder.dart';
|
||||||
|
import 'package:starsync/src/session/status.dart';
|
||||||
|
|
||||||
|
// The plan step of the media pipeline as a table: the state, what this
|
||||||
|
// member serves and its capabilities -> where this app reads the media
|
||||||
|
// from.
|
||||||
|
|
||||||
|
const _server = ServerAddress('h', 9, 'TOK');
|
||||||
|
const _jf = JellyfinRef(serverId: 'S1', itemId: 'I1', title: 'Ep');
|
||||||
|
|
||||||
|
MemberView _m(String id, {Presence presence = Presence.watching, Map<String, bool> jf = const {}, ClientCaps caps = const ClientCaps()}) =>
|
||||||
|
MemberView(id: id, name: id.toUpperCase(), ready: false, inSet: true, buffered: 0, presence: presence, jf: jf, caps: caps);
|
||||||
|
|
||||||
|
StateBroadcast _state(Map<String, Object?>? media,
|
||||||
|
{List<MemberView>? members, bool relay = false, HoldInfo? hold, String transport = 'held'}) =>
|
||||||
|
StateBroadcast(
|
||||||
|
eventId: 1,
|
||||||
|
transport: transport,
|
||||||
|
position: 0,
|
||||||
|
serverTime: DateTime.utc(2026),
|
||||||
|
mediaId: 7,
|
||||||
|
media: media,
|
||||||
|
members: members ?? [_m('m1'), _m('m2')],
|
||||||
|
relay: relay,
|
||||||
|
hold: hold,
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, Object?> _relay(MediaRelay r) => r.toJson();
|
||||||
|
|
||||||
|
SourcePlan _plan(StateBroadcast r,
|
||||||
|
{String self = 'm1', MediaOrigin? origin, bool resolve = true, Set<String> accounts = const {}}) =>
|
||||||
|
planMedia(PlanInput(
|
||||||
|
state: r,
|
||||||
|
selfId: self,
|
||||||
|
caps: ClientCaps(source: true, resolve: resolve),
|
||||||
|
server: _server,
|
||||||
|
origin: origin,
|
||||||
|
ownJellyfin: accounts.contains,
|
||||||
|
));
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('plan: state + origin + caps -> source', () {
|
||||||
|
final probe = LinkProbe.file(Uri.parse('https://x/v.mkv'), 10);
|
||||||
|
final ytDlp = YtDlp('${Directory.systemTemp.path}/plan_no_ytdlp', enabled: false);
|
||||||
|
final rows = <(String, StateBroadcast, Map<String, Object?>, String)>[
|
||||||
|
('nothing up', _state(null), {}, 'NoSource'),
|
||||||
|
('your server\'s file', _state({'name': 'Ep 1.mkv', 'size': 5}), {}, 'ServerFile http://h:9/m/default/TOK/Ep%201.mkv'),
|
||||||
|
('a media file link', _state({'name': 'v', 'url': 'https://cdn/v.mkv'}), {}, 'DirectLink https://cdn/v.mkv page=false'),
|
||||||
|
('a page link', _state({'name': 'v', 'url': 'https://site/watch?v=1'}), {}, 'DirectLink https://site/watch?v=1 page=true'),
|
||||||
|
('a page link, no resolver here, m2 can',
|
||||||
|
_state({'name': 'v', 'url': 'https://site/watch?v=1'},
|
||||||
|
members: [_m('m1'), _m('m2', caps: const ClientCaps(resolve: true))]),
|
||||||
|
{'resolve': false},
|
||||||
|
"Unresolvable This device can't open web pages; ask M2 to pass it on (Through me)"),
|
||||||
|
('another member\'s relay, live',
|
||||||
|
_state({'name': 'f.mkv', 'relay': _relay(const MediaRelay(source: 'm2', live: true))}), {},
|
||||||
|
'RelayedSource http://h:9/r/default/TOK/7/f.mkv waiting=false'),
|
||||||
|
('another member\'s relay, not live yet: wait',
|
||||||
|
_state({'name': 'f.mkv', 'relay': _relay(const MediaRelay(source: 'm2'))}), {},
|
||||||
|
'RelayedSource http://h:9/r/default/TOK/7/f.mkv waiting=true'),
|
||||||
|
('its source left: open what the server has',
|
||||||
|
_state({'name': 'f.mkv', 'relay': _relay(const MediaRelay(source: 'm9'))}), {},
|
||||||
|
'RelayedSource http://h:9/r/default/TOK/7/f.mkv waiting=false'),
|
||||||
|
('its source gone: open what the server has',
|
||||||
|
_state({'name': 'f.mkv', 'relay': _relay(const MediaRelay(source: 'm2', sourceGone: true))}), {},
|
||||||
|
'RelayedSource http://h:9/r/default/TOK/7/f.mkv waiting=false'),
|
||||||
|
('the server fetches, not read yet',
|
||||||
|
_state({'name': 'p', 'relay': _relay(const MediaRelay(fetch: true))}), {},
|
||||||
|
'RelayedSource http://h:9/r/default/TOK/7/p waiting=true'),
|
||||||
|
('a warm relay promoted: its queue id in the URL',
|
||||||
|
_state({'name': 'p', 'relay': _relay(const MediaRelay(source: 'm2', live: true, queueId: 'q3'))}), {},
|
||||||
|
'RelayedSource http://h:9/r/default/TOK/q3/p waiting=false'),
|
||||||
|
('your own file, passed on',
|
||||||
|
_state({'name': 'f.mkv', 'relay': _relay(const MediaRelay(source: 'm1'))}), {'origin': const FileOrigin('/v/f.mkv')},
|
||||||
|
'OwnFile /v/f.mkv'),
|
||||||
|
('your own link, passed on',
|
||||||
|
_state({'name': 'p', 'relay': _relay(const MediaRelay(source: 'm1', live: true))}),
|
||||||
|
{'origin': LinkOrigin('https://site/p', probe, ytDlp)},
|
||||||
|
'OwnLink https://site/p'),
|
||||||
|
('your relay, origin forgotten (a restart): read the server\'s buffer',
|
||||||
|
_state({'name': 'f.mkv', 'relay': _relay(const MediaRelay(source: 'm1', live: true))}), {},
|
||||||
|
'RelayedSource http://h:9/r/default/TOK/7/f.mkv waiting=false'),
|
||||||
|
('Jellyfin, own account',
|
||||||
|
_state({'name': 'Ep', 'jellyfin': _jf.toJson()}), {'accounts': {'S1'}},
|
||||||
|
'JellyfinSource own=true login=null member=null origin=false'),
|
||||||
|
('Jellyfin, the server\'s login',
|
||||||
|
_state({'name': 'Ep', 'jellyfin': _jf.toJson()}, relay: true), {},
|
||||||
|
'JellyfinSource own=false login=http://h:9/j/default/TOK/I1 member=null origin=false'),
|
||||||
|
('Jellyfin, a member passes it on',
|
||||||
|
_state({'name': 'Ep', 'jellyfin': _jf.toJson(), 'relay': _relay(const MediaRelay(source: 'm2'))}), {},
|
||||||
|
'JellyfinSource own=false login=null member=http://h:9/r/default/TOK/7/Ep origin=false'),
|
||||||
|
('Jellyfin, you put it up',
|
||||||
|
_state({'name': 'Ep', 'jellyfin': _jf.toJson(), 'relay': _relay(const MediaRelay(source: 'm1'))}),
|
||||||
|
{'accounts': {'S1'}, 'origin': const JellyfinOrigin(null)},
|
||||||
|
'JellyfinSource own=true login=null member=null origin=true'),
|
||||||
|
];
|
||||||
|
for (final (what, r, opts, want) in rows) {
|
||||||
|
test(what, () {
|
||||||
|
final got = _plan(r,
|
||||||
|
origin: opts['origin'] as MediaOrigin?,
|
||||||
|
resolve: opts['resolve'] as bool? ?? true,
|
||||||
|
accounts: opts['accounts'] as Set<String>? ?? const {});
|
||||||
|
expect(_describe(got), want);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a Jellyfin relay channel only when someone present needs it', () {
|
||||||
|
StateBroadcast with_(List<MemberView> ms, {bool relay = false}) =>
|
||||||
|
_state({'name': 'Ep', 'jellyfin': _jf.toJson()}, members: [_m('m1'), ...ms], relay: relay);
|
||||||
|
expect(jellyfinRelayNeeded(with_([_m('m2', jf: {'S1': true})]), _jf, 'm1'), isFalse, reason: 'everyone has an account');
|
||||||
|
expect(jellyfinRelayNeeded(with_([_m('m2', jf: {'S1': false})]), _jf, 'm1'), isTrue);
|
||||||
|
expect(jellyfinRelayNeeded(with_([_m('m2')]), _jf, 'm1'), isTrue, reason: 'no Jellyfin at all');
|
||||||
|
expect(jellyfinRelayNeeded(with_([_m('m2', caps: const ClientCaps(jellyfin: true))]), _jf, 'm1'), isFalse,
|
||||||
|
reason: 'its accounts are not reported yet: asked again with the next state');
|
||||||
|
expect(jellyfinRelayNeeded(with_([_m('m2')], relay: true), _jf, 'm1'), isFalse, reason: "the server's login passes it on");
|
||||||
|
expect(jellyfinRelayNeeded(with_([_m('m2', presence: Presence.away)]), _jf, 'm1'), isFalse, reason: 'sitting out');
|
||||||
|
expect(jellyfinRelayNeeded(with_([]), _jf, 'm1'), isFalse, reason: 'alone');
|
||||||
|
});
|
||||||
|
|
||||||
|
test("D6: held for the source: everyone's status waits for it by name", () {
|
||||||
|
final r = _state({'name': 'f.mkv', 'relay': _relay(const MediaRelay(source: 'm2', live: true, sourceGone: true))},
|
||||||
|
members: [
|
||||||
|
_m('m1'),
|
||||||
|
MemberView(id: 'm2', name: 'Bob', ready: true, inSet: true, buffered: 0, presence: Presence.gone, goneSince: DateTime.utc(2026)),
|
||||||
|
],
|
||||||
|
hold: const HoldInfo(reason: HoldReason.source, members: ['m1']));
|
||||||
|
for (final own in [false, true]) {
|
||||||
|
final line = statusOf(StatusFacts(state: r, selfId: 'm1', ownPlan: own, now: DateTime.utc(2026))).line!;
|
||||||
|
expect(line.text, 'Waiting for Bob');
|
||||||
|
expect(line.kind, StatusKind.waiting);
|
||||||
|
expect(line.level, StatusLevel.warn);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
String _describe(SourcePlan p) => switch (p) {
|
||||||
|
NoSource() => 'NoSource',
|
||||||
|
ServerFile(:final url) => 'ServerFile $url',
|
||||||
|
DirectLink(:final url, :final page) => 'DirectLink $url page=$page',
|
||||||
|
Unresolvable() => 'Unresolvable ${p.reason}',
|
||||||
|
RelayedSource(:final url, :final waiting) => 'RelayedSource $url waiting=$waiting',
|
||||||
|
OwnFile(:final path) => 'OwnFile $path',
|
||||||
|
OwnLink(:final url) => 'OwnLink $url',
|
||||||
|
JellyfinSource(:final ownAccount, :final serverLogin, :final memberRelay, :final origin) =>
|
||||||
|
'JellyfinSource own=$ownAccount login=$serverLogin member=$memberRelay origin=${origin != null}',
|
||||||
|
};
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:sync_relay_core/core.dart';
|
||||||
|
import 'package:sync_relay_server/server.dart';
|
||||||
|
import 'package:starsync/src/jellyfin/accounts.dart';
|
||||||
|
import 'package:starsync/src/jellyfin/track_prefs.dart';
|
||||||
|
import 'package:starsync/src/playback/yt_dlp_app.dart';
|
||||||
|
import 'package:starsync/src/screens/home/picked_media.dart';
|
||||||
|
import 'package:starsync/src/session/session_coordinator.dart';
|
||||||
|
|
||||||
|
// Commands that come while a media is switching (no player yet, or its
|
||||||
|
// media not loaded) wait and go to the new player once, so mpv never gets a
|
||||||
|
// seek it cannot run ("error running command seek", "event:5").
|
||||||
|
|
||||||
|
final _tmp = Platform.environment['WT_TEST_TMP'] ?? Directory.systemTemp.path;
|
||||||
|
|
||||||
|
Future<String> _media() async {
|
||||||
|
final path = '$_tmp/media_switch_60s.mp4';
|
||||||
|
if (await File(path).exists()) return path;
|
||||||
|
final r = await Process.run('ffmpeg', [
|
||||||
|
'-y', '-v', 'error',
|
||||||
|
'-f', 'lavfi', '-i', 'testsrc2=size=320x180:rate=24',
|
||||||
|
'-f', 'lavfi', '-i', 'sine=frequency=440:sample_rate=48000',
|
||||||
|
'-t', '60', '-c:v', 'libx264', '-preset', 'ultrafast', '-g', '24', '-pix_fmt', 'yuv420p',
|
||||||
|
'-c:a', 'aac', '-movflags', '+faststart', path,
|
||||||
|
]);
|
||||||
|
if (r.exitCode != 0) fail('ffmpeg failed: ${r.stderr}');
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _poll(FutureOr<bool> Function() cond, String what,
|
||||||
|
{Duration timeout = const Duration(seconds: 30), Duration every = const Duration(milliseconds: 100)}) async {
|
||||||
|
final deadline = DateTime.now().add(timeout);
|
||||||
|
while (DateTime.now().isBefore(deadline)) {
|
||||||
|
if (await cond()) return;
|
||||||
|
await Future.delayed(every);
|
||||||
|
}
|
||||||
|
fail('timed out waiting for $what');
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
test('commands during a switch wait and apply once to the new player, without mpv errors',
|
||||||
|
timeout: const Timeout(Duration(minutes: 2)), () async {
|
||||||
|
final file = await _media();
|
||||||
|
// A host that answers a media request only after 2 s (a slow CDN):
|
||||||
|
// the new player has nothing loaded for that long.
|
||||||
|
final slow = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
|
||||||
|
slow.listen((req) async {
|
||||||
|
final size = await File(file).length();
|
||||||
|
final r = resolveMediaRequest(rangeHeader: req.headers.value('range'), size: size, filename: 'v.mp4');
|
||||||
|
if (r.status == 206 || r.status == 200) await Future.delayed(const Duration(seconds: 2));
|
||||||
|
req.response.statusCode = r.status;
|
||||||
|
r.headers.forEach(req.response.headers.set);
|
||||||
|
try {
|
||||||
|
if (r.status == 200 || r.status == 206) {
|
||||||
|
await req.response.addStream(File(file).openRead(r.start, (r.end ?? size - 1) + 1));
|
||||||
|
}
|
||||||
|
await req.response.close();
|
||||||
|
} catch (_) {}
|
||||||
|
});
|
||||||
|
final server = SyncServer(const AppConfig(port: 0, secret: 'p15', name: 'p15'));
|
||||||
|
await server.start();
|
||||||
|
final acc = JellyfinAccounts(store: MemoryJellyfinStore());
|
||||||
|
await acc.ensureLoaded(validate: false);
|
||||||
|
final c = SessionCoordinator(
|
||||||
|
jellyfin: acc,
|
||||||
|
trackPrefs: TrackPrefs(store: MemoryJellyfinStore()),
|
||||||
|
ytDlp: AppYtDlp(YtDlp('$_tmp/media_switch_no_ytdlp', enabled: false)));
|
||||||
|
final errors = <String>[];
|
||||||
|
try {
|
||||||
|
await c.connect(ConnectParams(host: '127.0.0.1', port: server.boundPort, secret: 'p15', name: 'solo'));
|
||||||
|
await c.playback.setVolume(0);
|
||||||
|
final before = c.serverState.peek()?.mediaId ?? 0;
|
||||||
|
expect(await c.swapPicked(LinkMedia('http://127.0.0.1:${slow.port}/clip.mp4')), isNull);
|
||||||
|
// The switch: the state names the new media, the new player waits on
|
||||||
|
// the slow host.
|
||||||
|
await _poll(() => (c.serverState.peek()?.mediaId ?? 0) > before && c.media.openUrl != null, 'switching');
|
||||||
|
final p = c.playback.player;
|
||||||
|
final sub = p.logs.listen((l) {
|
||||||
|
if (l.text.contains('error running command') || l.text.contains('event:5')) errors.add(l.text);
|
||||||
|
});
|
||||||
|
expect(c.playback.switching, isTrue, reason: 'its media is not loaded yet');
|
||||||
|
final seeks = c.playback.seeksSent;
|
||||||
|
// The user skips ahead twice, changes the volume and readies, all
|
||||||
|
// while the media is still on its way.
|
||||||
|
c.seekTo(12);
|
||||||
|
c.seekTo(20);
|
||||||
|
await c.playback.setVolume(30);
|
||||||
|
c.toggleReady();
|
||||||
|
expect(c.playback.seeksSent, seeks, reason: 'nothing reached mpv yet');
|
||||||
|
await _poll(() => !c.playback.switching, 'loaded', timeout: const Duration(seconds: 20));
|
||||||
|
await _poll(() => c.playback.playing && c.playback.positionSecs > 20, 'playing from 20 s');
|
||||||
|
expect(c.playback.positionSecs, lessThan(23), reason: 'the latest seek, applied once it loaded');
|
||||||
|
expect(c.playback.seeksSent - seeks, 1, reason: 'the queued seeks went out as one');
|
||||||
|
expect(c.playback.player.state.volume, 30, reason: 'the volume went to the new player');
|
||||||
|
await sub.cancel();
|
||||||
|
expect(errors, isEmpty, reason: 'no command reached a player without its media');
|
||||||
|
} finally {
|
||||||
|
await c.dispose();
|
||||||
|
await server.stop();
|
||||||
|
await slow.close(force: true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -66,6 +66,8 @@ const _alone = NoticeContext(self: Presence.watching, alone: true);
|
|||||||
const _background = NoticeContext(self: Presence.watching, window: WindowActivity.unfocused);
|
const _background = NoticeContext(self: Presence.watching, window: WindowActivity.unfocused);
|
||||||
const _minimisedAway = NoticeContext(self: Presence.away, window: WindowActivity.minimised);
|
const _minimisedAway = NoticeContext(self: Presence.away, window: WindowActivity.minimised);
|
||||||
const _playing = NoticeContext(self: Presence.watching, playing: true);
|
const _playing = NoticeContext(self: Presence.watching, playing: true);
|
||||||
|
const _chatOpen = NoticeContext(self: Presence.watching, chatOpen: true);
|
||||||
|
const _chatOpenBackground = NoticeContext(self: Presence.watching, chatOpen: true, window: WindowActivity.unfocused);
|
||||||
const _chatPaused = NoticeContext(self: Presence.watching, playing: true, prefs: NotifyPrefs(chat: ChatPopups.paused));
|
const _chatPaused = NoticeContext(self: Presence.watching, playing: true, prefs: NotifyPrefs(chat: ChatPopups.paused));
|
||||||
const _chatOff = NoticeContext(self: Presence.watching, window: WindowActivity.unfocused, prefs: NotifyPrefs(chat: ChatPopups.off));
|
const _chatOff = NoticeContext(self: Presence.watching, window: WindowActivity.unfocused, prefs: NotifyPrefs(chat: ChatPopups.off));
|
||||||
|
|
||||||
@@ -115,6 +117,10 @@ final _rows = <_Row>[
|
|||||||
_Row('finished', _of(_feed('finished', actor: 'server')), Finished, logged: true),
|
_Row('finished', _of(_feed('finished', actor: 'server')), Finished, logged: true),
|
||||||
_Row('chat from someone', _of(_feed('chat', reason: 'hi')), ChatPosted,
|
_Row('chat from someone', _of(_feed('chat', reason: 'hi')), ChatPosted,
|
||||||
toast: 'Ann: hi', actor: 'Ann', sound: SfxKind.chat, logged: true),
|
toast: 'Ann: hi', actor: 'Ann', sound: SfxKind.chat, logged: true),
|
||||||
|
_Row('chat with the chat open: only its sound', _of(_feed('chat', reason: 'hi')), ChatPosted,
|
||||||
|
context: _chatOpen, sound: SfxKind.chat, logged: true),
|
||||||
|
_Row('chat open but the window in the background: pops up and goes out', _of(_feed('chat', reason: 'hi')), ChatPosted,
|
||||||
|
context: _chatOpenBackground, toast: 'Ann: hi', actor: 'Ann', sound: SfxKind.chat, logged: true, system: ('Ann', 'hi')),
|
||||||
_Row('own chat', _of(_feed('chat', actor: 'Me', reason: 'hi')), ChatPosted,
|
_Row('own chat', _of(_feed('chat', actor: 'Me', reason: 'hi')), ChatPosted,
|
||||||
toast: 'Me: hi', actor: 'Me', logged: true),
|
toast: 'Me: hi', actor: 'Me', logged: true),
|
||||||
_Row('rename', _of(_feed('rename', target: 'Annie')), MemberRenamed, logged: true),
|
_Row('rename', _of(_feed('rename', target: 'Annie')), MemberRenamed, logged: true),
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart' show SystemChannels;
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:signals/signals_flutter.dart' show signal;
|
||||||
import 'package:sync_relay_core/core.dart';
|
import 'package:sync_relay_core/core.dart';
|
||||||
import 'package:starsync/src/controls/frame.dart';
|
|
||||||
import 'package:starsync/src/controls/model/controls_prefs.dart';
|
import 'package:starsync/src/controls/model/controls_prefs.dart';
|
||||||
import 'package:starsync/src/controls/player_settings.dart';
|
import 'package:starsync/src/controls/player_panel.dart';
|
||||||
|
import 'package:starsync/src/danmaku/danmaku.dart' show DanmakuStyle;
|
||||||
import 'package:starsync/src/jellyfin/track_prefs.dart';
|
import 'package:starsync/src/jellyfin/track_prefs.dart';
|
||||||
import 'package:starsync/src/schedule/animeflow.dart';
|
import 'package:starsync/src/schedule/animeflow.dart';
|
||||||
import 'package:starsync/src/shaders/shader_presets.dart';
|
import 'package:starsync/src/shaders/shader_presets.dart';
|
||||||
@@ -37,6 +38,8 @@ void bigWindow(WidgetTester tester, [Size size = const Size(1280, 1100)]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> pick(WidgetTester tester, String key, String item) async {
|
Future<void> pick(WidgetTester tester, String key, String item) async {
|
||||||
|
await tester.ensureVisible(find.byKey(ValueKey(key)));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
await tester.tap(find.byKey(ValueKey(key)));
|
await tester.tap(find.byKey(ValueKey(key)));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
await tester.tap(find.text(item).last);
|
await tester.tap(find.text(item).last);
|
||||||
@@ -57,6 +60,8 @@ void main() {
|
|||||||
s.shaderPreset.value = ShaderPreset.off;
|
s.shaderPreset.value = ShaderPreset.off;
|
||||||
s.shaderQuality.value = ShaderQuality.hq;
|
s.shaderQuality.value = ShaderQuality.hq;
|
||||||
s.hwdecMode.value = HwdecMode.auto;
|
s.hwdecMode.value = HwdecMode.auto;
|
||||||
|
s.liftSubtitles.value = false;
|
||||||
|
s.notifyStyle.value = NotifyStyle.toasts;
|
||||||
coord.playback.tracks.value = const [];
|
coord.playback.tracks.value = const [];
|
||||||
coord.serverState.value = null;
|
coord.serverState.value = null;
|
||||||
coord.hostFile = null;
|
coord.hostFile = null;
|
||||||
@@ -64,76 +69,66 @@ void main() {
|
|||||||
_graph.forms.recents.value = const [];
|
_graph.forms.recents.value = const [];
|
||||||
});
|
});
|
||||||
|
|
||||||
Widget opener() => Builder(
|
group('player panel', () {
|
||||||
builder: (context) => TextButton(
|
testWidgets('tracks, subtitles, picture, sound, notifications and controls; all live', (tester) async {
|
||||||
key: const ValueKey('open-settings'),
|
|
||||||
onPressed: () => showPlayerSettings(context),
|
|
||||||
child: const Text('settings'),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
group('player settings card', () {
|
|
||||||
testWidgets('lists every player setting, changes apply live', (tester) async {
|
|
||||||
bigWindow(tester);
|
bigWindow(tester);
|
||||||
coord.playback.tracks.value = const [
|
coord.playback.tracks.value = const [
|
||||||
MpvTrack(id: 1, type: JellyfinStreamType.audio, language: 'jpn', codec: 'eac3', channels: 2, selected: true),
|
MpvTrack(id: 1, type: JellyfinStreamType.audio, language: 'jpn', codec: 'eac3', channels: 2, selected: true),
|
||||||
MpvTrack(id: 1, type: JellyfinStreamType.subtitle, language: 'eng', codec: 'ass', title: 'Full'),
|
MpvTrack(id: 1, type: JellyfinStreamType.subtitle, language: 'eng', codec: 'ass', title: 'Full'),
|
||||||
];
|
];
|
||||||
await tester.pumpWidget(scoped(_graph, app(Align(alignment: Alignment.bottomRight, child: opener()))));
|
var volume = 80.0;
|
||||||
await tester.tap(find.byKey(const ValueKey('open-settings')));
|
var closed = 0;
|
||||||
|
final vol = signal(volume);
|
||||||
|
await tester.pumpWidget(scoped(
|
||||||
|
_graph,
|
||||||
|
app(Align(
|
||||||
|
alignment: Alignment.topRight,
|
||||||
|
child: SizedBox(
|
||||||
|
height: 1000,
|
||||||
|
child: PlayerPanel(
|
||||||
|
onClose: () => closed++,
|
||||||
|
volume: vol,
|
||||||
|
onVolume: (v) => vol.value = volume = v,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
))));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
final card = find.byKey(const ValueKey('player-settings-card'));
|
final panel = find.byKey(const ValueKey('player-panel'));
|
||||||
expect(card, findsOneWidget);
|
expect(panel, findsOneWidget);
|
||||||
for (final label in [
|
for (final label in ['Subtitles', 'Audio', 'Size', 'Upscaling', 'Hardware decoding', 'Volume']) {
|
||||||
'Player design',
|
expect(find.descendant(of: panel, matching: find.text(label)), findsWidgets, reason: label);
|
||||||
'Progress line when hidden',
|
|
||||||
'Upscaling shader',
|
|
||||||
'Hardware decoding',
|
|
||||||
'Subtitles',
|
|
||||||
'Audio',
|
|
||||||
]) {
|
|
||||||
expect(find.descendant(of: card, matching: find.text(label)), findsOneWidget, reason: label);
|
|
||||||
}
|
}
|
||||||
// Subtitles off (none selected), audio on the selected track.
|
// Subtitles off (none selected), audio on the selected track.
|
||||||
expect(find.descendant(of: find.byKey(const ValueKey('subtitle-track')), matching: find.text('Off')),
|
expect(find.descendant(of: find.byKey(const ValueKey('panel-subtitles')), matching: find.text('Off')),
|
||||||
findsOneWidget);
|
findsOneWidget);
|
||||||
expect(find.descendant(of: find.byKey(const ValueKey('audio-track')), matching: find.textContaining('Japanese')),
|
expect(find.descendant(of: find.byKey(const ValueKey('panel-audio')), matching: find.textContaining('Japanese')),
|
||||||
findsOneWidget);
|
findsOneWidget);
|
||||||
|
await pick(tester, 'panel-shader', 'Mode B');
|
||||||
await pick(tester, 'player-design', 'Minimal');
|
|
||||||
expect(s.controlsSkin.value, ControlsSkin.minimal);
|
|
||||||
await tester.tap(find.byKey(const ValueKey('progress-line')));
|
|
||||||
await tester.pump();
|
|
||||||
expect(s.progressLine.value, isFalse);
|
|
||||||
await pick(tester, 'shader-preset', 'Mode B');
|
|
||||||
expect(s.shaderPreset.value, ShaderPreset.b);
|
expect(s.shaderPreset.value, ShaderPreset.b);
|
||||||
await pick(tester, 'shader-quality', 'Fast');
|
await pick(tester, 'panel-hwdec', 'Off');
|
||||||
expect(s.shaderQuality.value, ShaderQuality.fast);
|
|
||||||
await pick(tester, 'hwdec', 'Off');
|
|
||||||
expect(s.hwdec.value, 'no');
|
expect(s.hwdec.value, 'no');
|
||||||
// Esc closes it.
|
await tester.tap(find.descendant(of: find.byKey(const ValueKey('panel-lift')), matching: find.byType(Switch)));
|
||||||
await tester.sendKeyEvent(LogicalKeyboardKey.escape);
|
await tester.pump();
|
||||||
|
expect(s.liftSubtitles.value, isTrue);
|
||||||
|
await tester.drag(find.descendant(of: find.byKey(const ValueKey('panel-volume')), matching: find.byType(Slider)),
|
||||||
|
const Offset(-60, 0));
|
||||||
|
expect(volume, lessThan(80));
|
||||||
|
// Folded sections open with a click: notifications, then danmaku's knobs.
|
||||||
|
expect(find.byKey(const ValueKey('notify-style')), findsNothing);
|
||||||
|
await tester.tap(find.byKey(const ValueKey('panel-section-Notifications')));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(card, findsNothing);
|
await pick(tester, 'notify-style', 'Danmaku');
|
||||||
});
|
expect(s.notifyStyle.value, NotifyStyle.danmaku);
|
||||||
|
expect(find.byKey(const ValueKey('danmaku-weight')), findsOneWidget);
|
||||||
testWidgets('keeps the controls up while open', (tester) async {
|
await tester.ensureVisible(find.byKey(const ValueKey('panel-section-Controls')));
|
||||||
bigWindow(tester);
|
await tester.tap(find.byKey(const ValueKey('panel-section-Controls')));
|
||||||
await tester.pumpWidget(scoped(_graph, app(ControlsFrame(
|
|
||||||
hideAfter: const Duration(seconds: 1),
|
|
||||||
builder: (context, visible) => Align(alignment: Alignment.bottomRight, child: opener()),
|
|
||||||
))));
|
|
||||||
final controls = tester.state<ControlsFrameState>(find.byType(ControlsFrame));
|
|
||||||
await tester.tap(find.byKey(const ValueKey('open-settings')));
|
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(find.byKey(const ValueKey('player-settings-card')), findsOneWidget);
|
await pick(tester, 'panel-design', 'Minimal');
|
||||||
expect(find.text('Standard'), findsOneWidget, reason: 'the current skin');
|
expect(s.controlsSkin.value, ControlsSkin.minimal);
|
||||||
await tester.pump(const Duration(seconds: 3));
|
await tester.ensureVisible(find.byKey(const ValueKey('player-panel-close')));
|
||||||
expect(controls.visible, isTrue, reason: 'held while the card is open');
|
|
||||||
await tester.sendKeyEvent(LogicalKeyboardKey.escape);
|
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
await tester.pump(const Duration(seconds: 2));
|
await tester.tap(find.byKey(const ValueKey('player-panel-close')));
|
||||||
expect(controls.visible, isFalse);
|
expect(closed, 1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -169,6 +164,37 @@ void main() {
|
|||||||
expect(find.byIcon(Icons.hub_outlined), findsOneWidget);
|
expect(find.byIcon(Icons.hub_outlined), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('Notifications: Toasts / Danmaku, a preview strip and every knob of the look', (tester) async {
|
||||||
|
addTearDown(() => s.danmakuStyle.value = const DanmakuStyle());
|
||||||
|
await pumpSettings(tester, SettingsSection.notifications, size: const Size(1280, 2600));
|
||||||
|
final card = find.byKey(const ValueKey('danmaku-settings'));
|
||||||
|
expect(card, findsOneWidget);
|
||||||
|
expect(find.byKey(const ValueKey('danmaku-preview-strip')), findsOneWidget);
|
||||||
|
expect(s.notifyStyle.value, NotifyStyle.toasts, reason: 'toasts by default');
|
||||||
|
await pick(tester, 'notify-style', 'Danmaku');
|
||||||
|
expect(s.notifyStyle.value, NotifyStyle.danmaku);
|
||||||
|
for (final k in ['cross', 'cap', 'band', 'opacity', 'size', 'weight', 'outline', 'avatars', 'own']) {
|
||||||
|
expect(find.byKey(ValueKey('danmaku-$k')), findsOneWidget, reason: k);
|
||||||
|
}
|
||||||
|
// Boldness: one step right of Semibold is Bold.
|
||||||
|
final weight = find.byKey(const ValueKey('danmaku-weight'));
|
||||||
|
final box = tester.getRect(weight);
|
||||||
|
await tester.tapAt(Offset(box.left + box.width * 0.6, box.center.dy));
|
||||||
|
await tester.pump();
|
||||||
|
expect(s.danmakuStyle.value.weight, 700);
|
||||||
|
expect(find.text('Bold'), findsOneWidget);
|
||||||
|
await tester.tap(find.byKey(const ValueKey('danmaku-avatars')));
|
||||||
|
await tester.pump();
|
||||||
|
expect(s.danmakuStyle.value.avatars, isFalse);
|
||||||
|
await tester.tap(find.byKey(const ValueKey('danmaku-reset')));
|
||||||
|
await tester.pump();
|
||||||
|
expect(s.danmakuStyle.value, const DanmakuStyle());
|
||||||
|
// The strip rests after a while and plays again on a click.
|
||||||
|
await tester.pump(const Duration(seconds: 20));
|
||||||
|
expect(find.byKey(const ValueKey('danmaku-preview-play')), findsOneWidget);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
});
|
||||||
|
|
||||||
testWidgets('Player: the card\'s rows, a pointer to it, and the controls\' choices', (tester) async {
|
testWidgets('Player: the card\'s rows, a pointer to it, and the controls\' choices', (tester) async {
|
||||||
await pumpSettings(tester, SettingsSection.player, size: const Size(1280, 2400));
|
await pumpSettings(tester, SettingsSection.player, size: const Size(1280, 2400));
|
||||||
expect(find.textContaining('also in the player: ⚙ → Player settings'), findsOneWidget);
|
expect(find.textContaining('also in the player: ⚙ → Player settings'), findsOneWidget);
|
||||||
@@ -177,6 +203,8 @@ void main() {
|
|||||||
'progress-line',
|
'progress-line',
|
||||||
'shader-preset',
|
'shader-preset',
|
||||||
'hwdec',
|
'hwdec',
|
||||||
|
'sub-scale',
|
||||||
|
'lift-subtitles',
|
||||||
'time-display',
|
'time-display',
|
||||||
'controls-size',
|
'controls-size',
|
||||||
'controls-hide',
|
'controls-hide',
|
||||||
@@ -274,12 +302,12 @@ void main() {
|
|||||||
expect(isFakeIp(InternetAddress('198.20.1.1').rawAddress), isFalse);
|
expect(isFakeIp(InternetAddress('198.20.1.1').rawAddress), isFalse);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('an invite: the address, whether to ask for a password, the version; pasted back', () {
|
test('an invite: the address, its link; pasted back', () {
|
||||||
const invite = Invite(host: '192.168.1.20', port: 8080, password: true, version: '1.3.0');
|
const invite = Invite(host: '192.168.1.20', port: 8080, password: true, version: '1.3.0');
|
||||||
expect(invite.address, '192.168.1.20:8080');
|
expect(invite.address, '192.168.1.20:8080');
|
||||||
expect(invite.text, 'Join me on Starsync: 192.168.1.20:8080 (ask me for the password) · Starsync 1.3.0');
|
expect(invite.link().uri, 'starsync://192.168.1.20:8080');
|
||||||
expect(invite.fp, isNull, reason: 'reserved for pinned certificates');
|
expect(invite.fp, isNull, reason: 'reserved for pinned certificates');
|
||||||
expect(addressIn(invite.text), (host: '192.168.1.20', port: '8080'));
|
expect(addressIn(invite.link().uri), (host: '192.168.1.20', port: '8080'));
|
||||||
expect(addressIn('vps.example.com:9000'), (host: 'vps.example.com', port: '9000'));
|
expect(addressIn('vps.example.com:9000'), (host: 'vps.example.com', port: '9000'));
|
||||||
expect(addressIn('[fe80::1]:8080'), (host: 'fe80::1', port: '8080'));
|
expect(addressIn('[fe80::1]:8080'), (host: 'fe80::1', port: '8080'));
|
||||||
expect(addressIn('just a host'), isNull);
|
expect(addressIn('just a host'), isNull);
|
||||||
@@ -336,12 +364,54 @@ void main() {
|
|||||||
expect(find.byKey(const ValueKey('stop-sharing')), findsNothing);
|
expect(find.byKey(const ValueKey('stop-sharing')), findsNothing);
|
||||||
|
|
||||||
yours.shared.value = const Invite(host: '192.168.1.20', port: 8080, version: '1.3.0');
|
yours.shared.value = const Invite(host: '192.168.1.20', port: 8080, version: '1.3.0');
|
||||||
await tester.pumpAndSettle();
|
yours.reach.value = const InviteReach(local: ['192.168.1.20'], looking: true);
|
||||||
expect(find.text('192.168.1.20:8080'), findsOneWidget);
|
await tester.pump();
|
||||||
expect(find.text('Copy invite'), findsOneWidget);
|
await tester.pump(const Duration(milliseconds: 400));
|
||||||
|
expect(find.text('Looking up your internet address…'), findsOneWidget);
|
||||||
|
expect(find.text('Copy invite link'), findsOneWidget);
|
||||||
expect(find.byKey(const ValueKey('stop-sharing')), findsOneWidget);
|
expect(find.byKey(const ValueKey('stop-sharing')), findsOneWidget);
|
||||||
expect(find.textContaining('port 8080 forwarded'), findsOneWidget);
|
// One address so far: nothing else to pick, nothing to say.
|
||||||
expect(find.textContaining('VPS'), findsOneWidget);
|
expect(find.byKey(const ValueKey('invite-other-addresses')), findsNothing);
|
||||||
|
expect(find.byKey(const ValueKey('invite-note')), findsNothing);
|
||||||
|
|
||||||
|
// Behind a router: the internet address, one line on forwarding;
|
||||||
|
// the home address waits behind Other addresses.
|
||||||
|
yours.shared.value = const Invite(host: '203.0.113.7', port: 8080, version: '1.3.0');
|
||||||
|
yours.reach.value = const InviteReach(public: '203.0.113.7', local: ['192.168.1.20']);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('Looking up your internet address…'), findsNothing);
|
||||||
|
expect(find.text('Friends outside your home network need port 8080 forwarded to this computer.'),
|
||||||
|
findsOneWidget);
|
||||||
|
expect(find.text('192.168.1.20:8080'), findsNothing);
|
||||||
|
Future<String?> copy() async {
|
||||||
|
String? copied;
|
||||||
|
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, (call) async {
|
||||||
|
if (call.method == 'Clipboard.setData') copied = (call.arguments as Map)['text'] as String;
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
addTearDown(() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, null));
|
||||||
|
await tester.tap(find.byKey(const ValueKey('copy-invite')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
return copied;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Just the link, nothing around it.
|
||||||
|
expect(await copy(), matches(RegExp(r'^starsync://203\.0\.113\.7:8080(/\?name=\S+)?$')));
|
||||||
|
|
||||||
|
await tester.tap(find.byKey(const ValueKey('invite-other-addresses')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('203.0.113.7:8080'), findsOneWidget);
|
||||||
|
await tester.tap(find.text('192.168.1.20:8080'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.byKey(const ValueKey('invite-note')), findsNothing, reason: 'home network picked');
|
||||||
|
expect(await copy(), startsWith('starsync://192.168.1.20:8080'));
|
||||||
|
|
||||||
|
// No internet address found: one line saying so.
|
||||||
|
yours.reach.value = const InviteReach(local: ['192.168.1.20']);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.textContaining("Couldn't find your internet address"), findsOneWidget);
|
||||||
|
expect(_graph.notifier.toasts.peek().map((t) => t.text), contains('Invite link copied'));
|
||||||
|
await tester.pump(const Duration(seconds: 6)); // the toasts go
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1,25 +1,20 @@
|
|||||||
// restructure: rewrite in step 6 (What's next): who performs an item and
|
|
||||||
// the janitor become server state.
|
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:sync_relay_core/core.dart';
|
import 'package:sync_relay_core/core.dart';
|
||||||
|
import 'package:starsync/src/files/scanner.dart' show FolderScan;
|
||||||
|
import 'package:starsync/src/library/library_store.dart';
|
||||||
|
import 'package:starsync/src/library/local_next.dart';
|
||||||
|
import 'package:starsync/src/library/show_matcher.dart';
|
||||||
import 'package:starsync/src/queue/queue_logic.dart';
|
import 'package:starsync/src/queue/queue_logic.dart';
|
||||||
|
|
||||||
MemberView member(String id,
|
import 'show_matcher_test.dart' show FakeAniList;
|
||||||
{bool gone = false, bool away = false, Map<String, bool> jf = const {}, String name = ''}) =>
|
|
||||||
MemberView(
|
// What's next on the app: an item's words, and what continues a file of
|
||||||
id: id,
|
// a local show (who puts an item up and the countdown are core's and the
|
||||||
name: name.isEmpty ? id : name,
|
// server's: core `whats_next_test`, `queue_test`).
|
||||||
ready: false,
|
|
||||||
inSet: true,
|
|
||||||
buffered: 0,
|
|
||||||
presence: gone ? Presence.gone : away ? Presence.away : Presence.watching,
|
|
||||||
jf: jf);
|
|
||||||
|
|
||||||
QueueItem item(QueueHow how, {String by = 'm2', String byName = 'Bob'}) => QueueItem(
|
QueueItem item(QueueHow how, {String by = 'm2', String byName = 'Bob'}) => QueueItem(
|
||||||
id: 'q1',
|
id: 'q1',
|
||||||
media: how == QueueHow.jellyfin
|
media: const HelloMedia(name: 'x', size: 0, duration: 0, url: 'https://x/y'),
|
||||||
? const HelloMedia(name: 'ep', size: 0, duration: 0, jellyfin: JellyfinRef(serverId: 'S', itemId: 'i'))
|
|
||||||
: const HelloMedia(name: 'x', size: 0, duration: 0, url: 'https://x/y'),
|
|
||||||
how: how,
|
how: how,
|
||||||
title: 'x',
|
title: 'x',
|
||||||
by: by,
|
by: by,
|
||||||
@@ -27,49 +22,58 @@ QueueItem item(QueueHow how, {String by = 'm2', String byName = 'Bob'}) => Queue
|
|||||||
);
|
);
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
group('queuePerformer', () {
|
|
||||||
test('a link passed on or a file: only its adder; gone waits for it, left means skipped', () {
|
|
||||||
for (final how in [QueueHow.through, QueueHow.file]) {
|
|
||||||
expect(queuePerformer(item(how), [member('m1'), member('m2', away: true)]).id, 'm2');
|
|
||||||
final waits = queuePerformer(item(how), [member('m1'), member('m2', gone: true)]);
|
|
||||||
expect((waits.id, waits.why), (null, null), reason: 'a gone adder may come back within its grace');
|
|
||||||
final left = queuePerformer(item(how), [member('m1'), member('m3')]);
|
|
||||||
expect(left.id, isNull);
|
|
||||||
expect(left.why, 'Bob left');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Jellyfin: the adder with access, else the lowest member with access, else skipped', () {
|
|
||||||
final access = {'S': true};
|
|
||||||
expect(queuePerformer(item(QueueHow.jellyfin), [member('m1', jf: access), member('m2', jf: access)]).id, 'm2');
|
|
||||||
expect(
|
|
||||||
queuePerformer(item(QueueHow.jellyfin), [member('m10', jf: access), member('m3', jf: access), member('m2')])
|
|
||||||
.id,
|
|
||||||
'm3');
|
|
||||||
expect(queuePerformer(item(QueueHow.jellyfin), [member('m3', jf: access, away: true), member('m2')]).why,
|
|
||||||
isNotNull);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('a link everyone opens or the server fetches: the adder, else the lowest active member', () {
|
|
||||||
for (final how in [QueueHow.direct, QueueHow.server]) {
|
|
||||||
expect(queuePerformer(item(how), [member('m1'), member('m2')]).id, 'm2');
|
|
||||||
expect(queuePerformer(item(how), [member('m4'), member('m2', gone: true), member('m3')]).id, 'm3');
|
|
||||||
final nobody = queuePerformer(item(how), [member('m2', away: true)]);
|
|
||||||
expect((nobody.id, nobody.why), (null, null), reason: 'nobody around is not a skip');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('the janitor is the lowest member that is not gone', () {
|
|
||||||
expect(queueJanitor([member('m9'), member('m1', gone: true), member('m2')]), 'm2');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('labels', () {
|
test('labels', () {
|
||||||
expect(queueHowLabel(item(QueueHow.through), selfId: 'm2'), 'through you');
|
expect(queueHowLabel(item(QueueHow.through), selfId: 'm2'), 'through you');
|
||||||
expect(queueHowLabel(item(QueueHow.through), selfId: 'm1'), 'through Bob');
|
expect(queueHowLabel(item(QueueHow.through), selfId: 'm1'), 'through Bob');
|
||||||
expect(queueHowLabel(item(QueueHow.file), selfId: 'm1'), "Bob's file");
|
expect(queueHowLabel(item(QueueHow.file), selfId: 'm1'), "Bob's file");
|
||||||
|
expect(queueHowLabel(item(QueueHow.file, byName: ''), selfId: 'm1'), "who added it's file");
|
||||||
expect(queueClock(3725), '1:02:05');
|
expect(queueClock(3725), '1:02:05');
|
||||||
expect(queueClock(65), '1:05');
|
expect(queueClock(65), '1:05');
|
||||||
expect(queueClock(0), '');
|
expect(queueClock(0), '');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group('what continues a file of a local show', () {
|
||||||
|
const dir = '/lib/Bleach';
|
||||||
|
String ep(int n) => '$dir/[Judas] Bleach - ${n.toString().padLeft(3, '0')}.mkv';
|
||||||
|
// 064 and 065 are there, 066..107 are not.
|
||||||
|
final files = [for (final n in [63, 64, 65, 108, 109]) LocalFile(ep(n), 1 << 30, DateTime(2026))];
|
||||||
|
late List<String> scanned;
|
||||||
|
late LocalNextFinder finder;
|
||||||
|
late ShowMatcher matcher;
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
scanned = [];
|
||||||
|
matcher = ShowMatcher(store: MemoryLibraryStore(), aniList: FakeAniList(), delay: (_) async {});
|
||||||
|
finder = LocalNextFinder(
|
||||||
|
matcher: matcher,
|
||||||
|
scan: (d) async {
|
||||||
|
scanned.add(d);
|
||||||
|
return FolderScan(videos: files, hiddenCount: 0, hiddenExamples: const [], subtitles: const {}, truncated: false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('from its own folder when the library has not seen it this run', () async {
|
||||||
|
final next = await finder.nextAfter(ep(64));
|
||||||
|
expect(next!.episode.path, ep(65));
|
||||||
|
expect(next.skipped, isNull);
|
||||||
|
expect(scanned, [dir]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('numbers skipped on the way are said', () async {
|
||||||
|
final next = await finder.nextAfter(ep(65));
|
||||||
|
expect(next!.episode.path, ep(108));
|
||||||
|
expect(next.skipped!.label, '66–107');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the last one, or not an episode: nothing continues it', () async {
|
||||||
|
expect(await finder.nextAfter(ep(109)), isNull);
|
||||||
|
expect(await finder.nextAfter('$dir/notes.mkv'), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the library the Files page saw is used without a scan', () async {
|
||||||
|
matcher.remember(groupLocal(files, roots: const ['/lib']));
|
||||||
|
expect((await finder.nextAfter(ep(63)))!.episode.path, ep(64));
|
||||||
|
expect(scanned, isEmpty);
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
@@ -1,30 +1,31 @@
|
|||||||
// restructure: rewrite in step 5 (Media pipeline): preload and the warm
|
|
||||||
// relay become one pipeline; keep the timing per source.
|
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:media_kit/media_kit.dart';
|
|
||||||
import 'package:sync_relay_core/core.dart';
|
import 'package:sync_relay_core/core.dart';
|
||||||
import 'package:sync_relay_server/server.dart';
|
import 'package:sync_relay_server/server.dart';
|
||||||
import 'package:starsync/src/jellyfin/accounts.dart';
|
import 'package:starsync/src/jellyfin/accounts.dart';
|
||||||
import 'package:starsync/src/jellyfin/session_media.dart';
|
import 'package:starsync/src/jellyfin/session_media.dart';
|
||||||
import 'package:starsync/src/jellyfin/track_prefs.dart';
|
import 'package:starsync/src/jellyfin/track_prefs.dart';
|
||||||
|
import 'package:starsync/src/platform/desktop/media_kit_host.dart';
|
||||||
import 'package:starsync/src/playback/yt_dlp_app.dart';
|
import 'package:starsync/src/playback/yt_dlp_app.dart';
|
||||||
import 'package:starsync/src/queue/queue_controller.dart' show WarmState;
|
import 'package:starsync/src/queue/queue_controller.dart' show WarmState;
|
||||||
import 'package:starsync/src/screens/home/picked_media.dart';
|
import 'package:starsync/src/screens/home/picked_media.dart';
|
||||||
import 'package:starsync/src/session/session_coordinator.dart';
|
import 'package:starsync/src/session/session_coordinator.dart';
|
||||||
import 'package:starsync/src/session/switch_trace.dart';
|
import 'package:starsync/src/session/switch_trace.dart';
|
||||||
|
import 'package:starsync/src/state/settings.dart';
|
||||||
|
|
||||||
// How long an Up next switch takes, end to end, over real mpv and a real
|
// How long an Up next switch takes through the media pipeline, end to
|
||||||
// sync server: two Jellyfin account holders and a guest (through the
|
// end, over real mpv and a real sync server: two Jellyfin account holders
|
||||||
// server's /j login) watch an episode, then ⏭ puts up the next episode,
|
// and a guest (through the server's /j login) watch an episode, then ⏭ puts
|
||||||
// then a direct link, then a link passed on "through" alice, then one the
|
// up the next episode, then a direct link, then a link passed on "through"
|
||||||
// server fetches (both relayed: the guests preload them from the server's
|
// alice, then one the server fetches (both relayed: the guests prepare
|
||||||
// warm relay of the item). The fake Jellyfin and the link's host answer with a
|
// them from the server's warm relay of the item). Every client prepares
|
||||||
// delay and a capped rate, like a server elsewhere. Prints each client's
|
// the next item in a hidden player (`MediaPipeline.prepare`) and the swap
|
||||||
// phases (SwitchTrace) and the wall time from ⏭ to the server playing.
|
// promotes it. The fake Jellyfin and the link's host answer with a delay
|
||||||
|
// and a capped rate, like a server elsewhere. Prints each client's phases
|
||||||
|
// (SwitchTrace) and the wall time from ⏭ to the server playing.
|
||||||
//
|
//
|
||||||
// WT_SWITCH_PRELOAD=0 turns the preload off (the cold path, for comparison);
|
// WT_SWITCH_PRELOAD=0 turns the preload off (the cold path, for comparison);
|
||||||
// WT_SWITCH_DELAY_MS and WT_SWITCH_RATE_KBS set the remote's delay and rate.
|
// WT_SWITCH_DELAY_MS and WT_SWITCH_RATE_KBS set the remote's delay and rate.
|
||||||
@@ -202,7 +203,12 @@ void main() {
|
|||||||
'Up next: ⏭ to a Jellyfin episode, then to a direct link, timed per client',
|
'Up next: ⏭ to a Jellyfin episode, then to a direct link, timed per client',
|
||||||
timeout: const Timeout(Duration(minutes: 4)),
|
timeout: const Timeout(Duration(minutes: 4)),
|
||||||
() async {
|
() async {
|
||||||
MediaKit.ensureInitialized();
|
// mpv's warnings, as the host writes them to the app log.
|
||||||
|
final mpvLines = <String>[];
|
||||||
|
// (The root's records, the app's logging not being hierarchical.)
|
||||||
|
final logSub = createLogger('mpv').onRecord.listen((r) {
|
||||||
|
if (r.loggerName == 'mpv') mpvLines.add(r.message);
|
||||||
|
});
|
||||||
final preload = Platform.environment['WT_SWITCH_PRELOAD'] != '0';
|
final preload = Platform.environment['WT_SWITCH_PRELOAD'] != '0';
|
||||||
final file = await _media();
|
final file = await _media();
|
||||||
final jelly = _FakeJellyfin(file);
|
final jelly = _FakeJellyfin(file);
|
||||||
@@ -236,9 +242,21 @@ void main() {
|
|||||||
final accC = await _account(jelly.base, 'CTOKEN', 'uc', 'C-PC');
|
final accC = await _account(jelly.base, 'CTOKEN', 'uc', 'C-PC');
|
||||||
final accB = JellyfinAccounts(store: MemoryJellyfinStore());
|
final accB = JellyfinAccounts(store: MemoryJellyfinStore());
|
||||||
await accB.ensureLoaded(validate: false);
|
await accB.ensureLoaded(validate: false);
|
||||||
SessionCoordinator coord(JellyfinAccounts acc) => SessionCoordinator(
|
final hosts = <SessionCoordinator, MediaKitHost>{};
|
||||||
jellyfin: acc, trackPrefs: TrackPrefs(store: MemoryJellyfinStore()), ytDlp: ytDlp)
|
SessionCoordinator coord(JellyfinAccounts acc) {
|
||||||
..playback.preloadEnabled = preload;
|
final host = MediaKitHost()..init();
|
||||||
|
// Up next alone: no continuation of the episodes (it would be
|
||||||
|
// queued after the items this test adds and times).
|
||||||
|
final c = SessionCoordinator(
|
||||||
|
jellyfin: acc,
|
||||||
|
trackPrefs: TrackPrefs(store: MemoryJellyfinStore()),
|
||||||
|
ytDlp: ytDlp,
|
||||||
|
players: host,
|
||||||
|
settings: AppSettings()..autoAdvance.value = false)
|
||||||
|
..media.preloadEnabled = preload;
|
||||||
|
hosts[c] = host;
|
||||||
|
return c;
|
||||||
|
}
|
||||||
final a = coord(accA), b = coord(accB), c = coord(accC);
|
final a = coord(accA), b = coord(accB), c = coord(accC);
|
||||||
final all = {'alice': a, 'guest': b, 'carol': c};
|
final all = {'alice': a, 'guest': b, 'carol': c};
|
||||||
ConnectParams params(String name, [HelloMedia? media]) =>
|
ConnectParams params(String name, [HelloMedia? media]) =>
|
||||||
@@ -252,7 +270,7 @@ void main() {
|
|||||||
if (preload) {
|
if (preload) {
|
||||||
for (final x in all.values) {
|
for (final x in all.values) {
|
||||||
expect(x.queue.warm.peek()?.state, WarmState.ready, reason: 'ready on every client before ⏭');
|
expect(x.queue.warm.peek()?.state, WarmState.ready, reason: 'ready on every client before ⏭');
|
||||||
expect(x.playback.preloadUrl, isNotNull, reason: 'preloaded on every client');
|
expect(x.media.preloadUrl, isNotNull, reason: 'preloaded on every client');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
final clock = Stopwatch()..start();
|
final clock = Stopwatch()..start();
|
||||||
@@ -269,11 +287,14 @@ void main() {
|
|||||||
}
|
}
|
||||||
await _poll(() => all.values.every((x) => x.playback.player.state.playing), 'all play $what');
|
await _poll(() => all.values.every((x) => x.playback.player.state.playing), 'all play $what');
|
||||||
// The promoted player is the session player: marks, tracks and
|
// The promoted player is the session player: marks, tracks and
|
||||||
// the volume carried over, the clock moves.
|
// the volume carried over, the clock moves; the old one retired.
|
||||||
for (final x in all.values) {
|
for (final x in all.values) {
|
||||||
expect(x.playback.durationSecs, greaterThan(59));
|
expect(x.playback.durationSecs, greaterThan(59));
|
||||||
expect(x.playback.player.state.volume, 0, reason: 'volume kept');
|
expect(x.playback.player.state.volume, 0, reason: 'volume kept');
|
||||||
await _poll(() => x.playback.tracks.peek().isNotEmpty, 'tracks listed');
|
await _poll(() => x.playback.tracks.peek().isNotEmpty, 'tracks listed');
|
||||||
|
final host = hosts[x]!;
|
||||||
|
await host.retiring;
|
||||||
|
expect(host.live, lessThanOrEqualTo(2), reason: 'the session player and at most the next prepared one');
|
||||||
}
|
}
|
||||||
final at = a.playback.positionSecs;
|
final at = a.playback.positionSecs;
|
||||||
await Future.delayed(const Duration(seconds: 1));
|
await Future.delayed(const Duration(seconds: 1));
|
||||||
@@ -286,7 +307,7 @@ void main() {
|
|||||||
await b.connect(params('guest'));
|
await b.connect(params('guest'));
|
||||||
await c.connect(params('carol'));
|
await c.connect(params('carol'));
|
||||||
for (final x in all.values) {
|
for (final x in all.values) {
|
||||||
await x.playback.player.setVolume(0);
|
await x.playback.setVolume(0);
|
||||||
}
|
}
|
||||||
await _poll(() => all.values.every((x) => x.playback.durationSecs > 59), 'all opened episode 1');
|
await _poll(() => all.values.every((x) => x.playback.durationSecs > 59), 'all opened episode 1');
|
||||||
expect(await a.queue.add(pickForRef(accA, const JellyfinRef(serverId: _sid, itemId: _ep2, title: 'Second'))!),
|
expect(await a.queue.add(pickForRef(accA, const JellyfinRef(serverId: _sid, itemId: _ep2, title: 'Second'))!),
|
||||||
@@ -322,6 +343,13 @@ void main() {
|
|||||||
final relay = stateMediaRelay(a.serverState.peek()!);
|
final relay = stateMediaRelay(a.serverState.peek()!);
|
||||||
expect(relay?.queueId, id, reason: 'the warm relay became the media\'s');
|
expect(relay?.queueId, id, reason: 'the warm relay became the media\'s');
|
||||||
expect(relay?.fetch, mode == LinkRelay.server);
|
expect(relay?.fetch, mode == LinkRelay.server);
|
||||||
|
if (mode == LinkRelay.me) {
|
||||||
|
// Read once: alice's preload and the warm relay read one shared
|
||||||
|
// reader, and the swap carried it on as the media's.
|
||||||
|
final one = a.media.shared.currentFor(linkUrl);
|
||||||
|
expect(one, isNotNull, reason: 'alice reads the link once');
|
||||||
|
expect(one!.bytesIn, greaterThan(0));
|
||||||
|
}
|
||||||
if (preload) {
|
if (preload) {
|
||||||
for (final x in all.values) {
|
for (final x in all.values) {
|
||||||
expect(x.switchTrace.of(x.serverState.peek()!.mediaId), contains(SwitchTrace.preloaded),
|
expect(x.switchTrace.of(x.serverState.peek()!.mediaId), contains(SwitchTrace.preloaded),
|
||||||
@@ -332,14 +360,17 @@ void main() {
|
|||||||
// ignore: avoid_print
|
// ignore: avoid_print
|
||||||
print(report.join('\n'));
|
print(report.join('\n'));
|
||||||
expect(serverErrors, isEmpty);
|
expect(serverErrors, isEmpty);
|
||||||
|
// No command reached a player still opening, and the
|
||||||
|
// cache is in RAM on every player (no "Failed to create file cache").
|
||||||
|
expect(mpvLines.where((l) => l.contains('error running command') || l.contains('file cache')), isEmpty);
|
||||||
|
|
||||||
// Nothing queued: no preload. Queued and taken off again: dropped.
|
// Nothing queued: no preload. Queued and taken off again: dropped.
|
||||||
if (preload) {
|
if (preload) {
|
||||||
await _poll(() => all.values.every((x) => x.playback.preloadUrl == null), 'nothing preloaded');
|
await _poll(() => all.values.every((x) => x.media.preloadUrl == null), 'nothing preloaded');
|
||||||
expect(await a.queue.add(LinkMedia(linkUrl, relay: LinkRelay.direct)), isNull);
|
expect(await a.queue.add(LinkMedia(linkUrl, relay: LinkRelay.direct)), isNull);
|
||||||
await _poll(() => all.values.every((x) => x.playback.preloadUrl == linkUrl), 'preloaded again');
|
await _poll(() => all.values.every((x) => x.media.preloadUrl == linkUrl), 'preloaded again');
|
||||||
a.queue.remove(a.serverState.peek()!.queue.first.id);
|
a.queue.remove(a.serverState.peek()!.queue.first.id);
|
||||||
await _poll(() => all.values.every((x) => x.playback.preloadUrl == null), 'dropped when taken off');
|
await _poll(() => all.values.every((x) => x.media.preloadUrl == null), 'dropped when taken off');
|
||||||
}
|
}
|
||||||
// Preloaded everywhere: every client starts from its preload.
|
// Preloaded everywhere: every client starts from its preload.
|
||||||
if (preload) {
|
if (preload) {
|
||||||
@@ -348,6 +379,7 @@ void main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
await logSub.cancel();
|
||||||
for (final x in all.values) {
|
for (final x in all.values) {
|
||||||
await x.dispose();
|
await x.dispose();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,226 @@
|
|||||||
|
import 'package:flutter/gestures.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:media_kit/media_kit.dart';
|
||||||
|
import 'package:media_kit_video/media_kit_video.dart' show VideoState;
|
||||||
|
import 'package:starsync/src/controls/frame.dart';
|
||||||
|
import 'package:starsync/src/danmaku/danmaku.dart';
|
||||||
|
import 'package:starsync/src/session/navigation.dart' show Place;
|
||||||
|
import 'package:starsync/src/session/notifier.dart' show NoticeCategory, ToastItem;
|
||||||
|
import 'package:starsync/src/shell/quick_chat.dart';
|
||||||
|
import 'package:starsync/src/shell/session_player.dart';
|
||||||
|
import 'package:starsync/src/state/settings.dart';
|
||||||
|
import 'package:starsync/src/widgets/app_keys.dart';
|
||||||
|
|
||||||
|
import 'test_graph.dart';
|
||||||
|
|
||||||
|
/// This file's composition root (see test_graph.dart).
|
||||||
|
final _graph = testGraph();
|
||||||
|
|
||||||
|
// The quick chat box over the full player: Enter opens it whatever plays,
|
||||||
|
// keys go to it (not to the player), the cursor and the controls stay as
|
||||||
|
// the mouse left them, a send shows your own line (echo, or danmaku), Esc
|
||||||
|
// and the idle time close it with the draft kept, the draft is the chat
|
||||||
|
// panel's. The picture is a black box with the session's controls on it
|
||||||
|
// (never a media_kit `Video`).
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
final coord = _graph.coordinator;
|
||||||
|
final s = coord.settings;
|
||||||
|
|
||||||
|
setUpAll(() async {
|
||||||
|
MediaKit.ensureInitialized();
|
||||||
|
await coord.media.start();
|
||||||
|
_graph.navigation.place.value = Place.yours;
|
||||||
|
});
|
||||||
|
|
||||||
|
late TextEditingController draft;
|
||||||
|
late QuickChat quick;
|
||||||
|
var fullscreenToggles = 0;
|
||||||
|
var readyToggles = 0;
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
draft = TextEditingController();
|
||||||
|
quick = QuickChat(draft: draft);
|
||||||
|
fullscreenToggles = 0;
|
||||||
|
readyToggles = 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDown(() {
|
||||||
|
s.notifyStyle.value = NotifyStyle.toasts;
|
||||||
|
});
|
||||||
|
|
||||||
|
Future<void> mount(WidgetTester tester) async {
|
||||||
|
tester.view.physicalSize = const Size(1280, 720);
|
||||||
|
tester.view.devicePixelRatio = 1;
|
||||||
|
addTearDown(tester.view.reset);
|
||||||
|
await tester.pumpWidget(scoped(
|
||||||
|
_graph,
|
||||||
|
MaterialApp(
|
||||||
|
theme: ThemeData.dark(),
|
||||||
|
home: AppKeys(
|
||||||
|
onToggleReady: () => readyToggles++,
|
||||||
|
child: Scaffold(
|
||||||
|
body: SessionPlayer(
|
||||||
|
mode: PlayerMode.full,
|
||||||
|
fullscreen: false,
|
||||||
|
onExpand: () {},
|
||||||
|
onCollapse: () {},
|
||||||
|
onClose: () {},
|
||||||
|
onToggleFullscreen: () => fullscreenToggles++,
|
||||||
|
onToggleChat: () {},
|
||||||
|
onChangeVideo: () {},
|
||||||
|
onOpenIntegrations: () {},
|
||||||
|
onLeave: () {},
|
||||||
|
windowControls: false,
|
||||||
|
quickChat: quick,
|
||||||
|
videoBuilder: (context, controls) => Stack(fit: StackFit.expand, children: [
|
||||||
|
const ColoredBox(color: Colors.black),
|
||||||
|
if (controls != null) controls(VideoState()),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
));
|
||||||
|
await tester.pump();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> unmount(WidgetTester tester) async {
|
||||||
|
await tester.pumpWidget(scoped(_graph, const SizedBox()));
|
||||||
|
await tester.pump(const Duration(seconds: 5));
|
||||||
|
quick.dispose();
|
||||||
|
draft.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
ControlsFrameState frame(WidgetTester tester) => tester.state<ControlsFrameState>(find.byType(ControlsFrame));
|
||||||
|
MouseCursor frameCursor(WidgetTester tester) => tester
|
||||||
|
.widget<MouseRegion>(find.descendant(of: find.byType(ControlsFrame), matching: find.byType(MouseRegion)).first)
|
||||||
|
.cursor;
|
||||||
|
final field = find.byKey(const ValueKey('quick-chat-field'));
|
||||||
|
bool fieldFocused() => quick.focus.hasFocus;
|
||||||
|
|
||||||
|
Future<void> hideControls(WidgetTester tester) async {
|
||||||
|
await tester.pump(Duration(seconds: s.controlsHideSecs.value + 1));
|
||||||
|
await tester.pump(const Duration(milliseconds: 500));
|
||||||
|
}
|
||||||
|
|
||||||
|
testWidgets('Enter opens the box; keys go to it; the cursor and controls stay hidden', (tester) async {
|
||||||
|
await mount(tester);
|
||||||
|
// A still mouse over the bottom centre, where the box comes up.
|
||||||
|
final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse);
|
||||||
|
await mouse.addPointer(location: const Offset(640, 690));
|
||||||
|
addTearDown(mouse.removePointer);
|
||||||
|
await tester.pump();
|
||||||
|
await hideControls(tester);
|
||||||
|
expect(frame(tester).visible, isFalse);
|
||||||
|
expect(frameCursor(tester), SystemMouseCursors.none);
|
||||||
|
|
||||||
|
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
|
||||||
|
await tester.pump();
|
||||||
|
expect(quick.open.value, isTrue);
|
||||||
|
expect(fieldFocused(), isTrue, reason: 'the box has the keyboard at once');
|
||||||
|
await tester.pump(const Duration(milliseconds: 400));
|
||||||
|
expect(frame(tester).visible, isFalse, reason: 'typing is not mouse activity');
|
||||||
|
expect(frameCursor(tester), SystemMouseCursors.none);
|
||||||
|
final fieldWidget = tester.widget<TextField>(field);
|
||||||
|
expect(fieldWidget.mouseCursor, SystemMouseCursors.none, reason: 'no I-beam over a still mouse');
|
||||||
|
|
||||||
|
// Space and F reach the box, not ready or fullscreen.
|
||||||
|
await tester.sendKeyEvent(LogicalKeyboardKey.space);
|
||||||
|
await tester.sendKeyEvent(LogicalKeyboardKey.keyF);
|
||||||
|
await tester.pump();
|
||||||
|
expect(readyToggles, 0);
|
||||||
|
expect(fullscreenToggles, 0);
|
||||||
|
|
||||||
|
// Esc closes; the draft stays; the controls do not come back.
|
||||||
|
await tester.enterText(field, 'half a thought');
|
||||||
|
await tester.sendKeyEvent(LogicalKeyboardKey.escape);
|
||||||
|
await tester.pump(const Duration(milliseconds: 400));
|
||||||
|
expect(quick.open.value, isFalse);
|
||||||
|
expect(draft.text, 'half a thought');
|
||||||
|
expect(frame(tester).visible, isFalse, reason: 'the box going is not mouse movement');
|
||||||
|
expect(frameCursor(tester), SystemMouseCursors.none);
|
||||||
|
|
||||||
|
// The player's keys are back.
|
||||||
|
await tester.sendKeyEvent(LogicalKeyboardKey.space);
|
||||||
|
expect(readyToggles, 1);
|
||||||
|
await tester.pump(const Duration(milliseconds: 400));
|
||||||
|
await tester.sendKeyEvent(LogicalKeyboardKey.keyF);
|
||||||
|
expect(fullscreenToggles, 1);
|
||||||
|
|
||||||
|
// Enter brings the draft back.
|
||||||
|
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
|
||||||
|
await tester.pump();
|
||||||
|
expect(quick.open.value, isTrue);
|
||||||
|
expect(tester.widget<TextField>(field).controller!.text, 'half a thought');
|
||||||
|
await unmount(tester);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('a send shows your own line: the echo with toasts, and the controls stay hidden', (tester) async {
|
||||||
|
await mount(tester);
|
||||||
|
await hideControls(tester);
|
||||||
|
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
|
||||||
|
await tester.pump();
|
||||||
|
await tester.enterText(field, 'hello there');
|
||||||
|
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
|
||||||
|
await tester.pump();
|
||||||
|
expect(quick.open.value, isFalse);
|
||||||
|
expect(draft.text, isEmpty, reason: 'sent: the shared draft is cleared');
|
||||||
|
await tester.pump(const Duration(milliseconds: 400));
|
||||||
|
final echo = find.byKey(const ValueKey('quick-chat-echo'));
|
||||||
|
expect(echo, findsOneWidget);
|
||||||
|
expect(find.descendant(of: echo, matching: find.text('hello there')), findsOneWidget);
|
||||||
|
expect(frame(tester).visible, isFalse, reason: 'held down while the echo is in their place');
|
||||||
|
// Your own chat toast coming back is not shown again.
|
||||||
|
final own = ToastItem(
|
||||||
|
id: 99, text: 'Me: hello there', at: DateTime.now(), actor: 'Me', chat: true, own: true,
|
||||||
|
category: NoticeCategory.chat);
|
||||||
|
expect(quick.echoed(own), isTrue);
|
||||||
|
await tester.pump(QuickChat.echoTime);
|
||||||
|
await tester.pump(const Duration(milliseconds: 400));
|
||||||
|
expect(echo, findsNothing);
|
||||||
|
expect(frame(tester).visible, isFalse);
|
||||||
|
// Empty Enter just closes.
|
||||||
|
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
|
||||||
|
await tester.pump();
|
||||||
|
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
|
||||||
|
await tester.pump(const Duration(milliseconds: 400));
|
||||||
|
expect(quick.open.value, isFalse);
|
||||||
|
expect(echo, findsNothing);
|
||||||
|
await unmount(tester);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('with danmaku your own line flies instead of the echo', (tester) async {
|
||||||
|
s.notifyStyle.value = NotifyStyle.danmaku;
|
||||||
|
await mount(tester);
|
||||||
|
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
|
||||||
|
await tester.pump();
|
||||||
|
await tester.enterText(field, 'flying');
|
||||||
|
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
|
||||||
|
await tester.pump(const Duration(milliseconds: 16));
|
||||||
|
await tester.pump(const Duration(milliseconds: 16));
|
||||||
|
expect(find.byKey(const ValueKey('quick-chat-echo')), findsNothing);
|
||||||
|
final line = find.descendant(of: find.byType(DanmakuLayer), matching: find.textContaining('flying', findRichText: true));
|
||||||
|
expect(line, findsWidgets);
|
||||||
|
await tester.pump(const Duration(seconds: 10));
|
||||||
|
await unmount(tester);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('the box hides by itself after a while without typing; the draft stays', (tester) async {
|
||||||
|
await mount(tester);
|
||||||
|
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
|
||||||
|
await tester.pump();
|
||||||
|
await tester.enterText(field, 'later');
|
||||||
|
await tester.pump(QuickChat.idleClose - const Duration(seconds: 1));
|
||||||
|
expect(quick.open.value, isTrue);
|
||||||
|
await tester.enterText(field, 'later, maybe');
|
||||||
|
await tester.pump(QuickChat.idleClose - const Duration(seconds: 1));
|
||||||
|
expect(quick.open.value, isTrue, reason: 'typing restarts the wait');
|
||||||
|
await tester.pump(const Duration(seconds: 2));
|
||||||
|
expect(quick.open.value, isFalse);
|
||||||
|
expect(draft.text, 'later, maybe');
|
||||||
|
await unmount(tester);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -4,7 +4,8 @@ import 'package:flutter_test/flutter_test.dart';
|
|||||||
import 'package:media_kit/media_kit.dart';
|
import 'package:media_kit/media_kit.dart';
|
||||||
import 'package:sync_relay_core/core.dart';
|
import 'package:sync_relay_core/core.dart';
|
||||||
import 'package:sync_relay_server/server.dart';
|
import 'package:sync_relay_server/server.dart';
|
||||||
import 'package:starsync/src/playback/playback_controller.dart';
|
import 'package:starsync/src/platform/desktop/media_kit_host.dart';
|
||||||
|
import 'package:starsync/src/session/media_pipeline.dart';
|
||||||
|
|
||||||
final _tmp = Platform.environment['WT_TEST_TMP'] ?? Directory.systemTemp.path;
|
final _tmp = Platform.environment['WT_TEST_TMP'] ?? Directory.systemTemp.path;
|
||||||
|
|
||||||
@@ -42,13 +43,15 @@ void main() {
|
|||||||
final server = SyncServer(AppConfig(
|
final server = SyncServer(AppConfig(
|
||||||
port: 0, secret: '', name: 'readahead', mediaPath: media));
|
port: 0, secret: '', name: 'readahead', mediaPath: media));
|
||||||
await server.start();
|
await server.start();
|
||||||
final p = PlaybackController();
|
final host = MediaKitHost();
|
||||||
|
final m = MediaPipeline(host: host);
|
||||||
|
final p = m.playback;
|
||||||
try {
|
try {
|
||||||
await p.init();
|
await m.start();
|
||||||
expect(p.pinMismatches, isEmpty,
|
expect(host.pinMismatches, isEmpty,
|
||||||
reason: 'every pinned mpv property took effect');
|
reason: 'every pinned mpv property took effect');
|
||||||
final token = server.session.issueToken('probe', '');
|
final token = server.session.issueToken('probe', '');
|
||||||
await p.openUrl(
|
await m.open(
|
||||||
'http://127.0.0.1:${server.boundPort}/m/default/$token/readahead.mp4');
|
'http://127.0.0.1:${server.boundPort}/m/default/$token/readahead.mp4');
|
||||||
var best = 0.0;
|
var best = 0.0;
|
||||||
final end = DateTime.now().add(const Duration(seconds: 30));
|
final end = DateTime.now().add(const Duration(seconds: 30));
|
||||||
@@ -62,7 +65,7 @@ void main() {
|
|||||||
expect(best, greaterThan(120),
|
expect(best, greaterThan(120),
|
||||||
reason: 'paused player reads ahead about two minutes');
|
reason: 'paused player reads ahead about two minutes');
|
||||||
} finally {
|
} finally {
|
||||||
await p.dispose();
|
await m.dispose();
|
||||||
await server.stop();
|
await server.stop();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -35,21 +35,136 @@ Future<String> _media() async {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _poll(FutureOr<bool> Function() cond, String what,
|
Future<void> _poll(FutureOr<bool> Function() cond, String what,
|
||||||
{Duration timeout = const Duration(seconds: 30)}) async {
|
{Duration timeout = const Duration(seconds: 30), Duration every = const Duration(milliseconds: 200)}) async {
|
||||||
final deadline = DateTime.now().add(timeout);
|
final deadline = DateTime.now().add(timeout);
|
||||||
while (DateTime.now().isBefore(deadline)) {
|
while (DateTime.now().isBefore(deadline)) {
|
||||||
if (await cond()) return;
|
if (await cond()) return;
|
||||||
await Future.delayed(const Duration(milliseconds: 200));
|
await Future.delayed(every);
|
||||||
}
|
}
|
||||||
fail('timed out waiting for $what');
|
fail('timed out waiting for $what');
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Serves one file with ranges; [requests] counts hits.
|
/// 60 s at about 3 Mbit/s: long enough that a slow source's relay holds
|
||||||
|
/// only part of it. An mp4 with its index first: headless players have no
|
||||||
|
/// video track selected (vid=no), and mkv's cues list only the video, so
|
||||||
|
/// an mkv would be seeked by scanning from its start (an app's player,
|
||||||
|
/// with its video output, uses the cues).
|
||||||
|
Future<String> _longMedia() async {
|
||||||
|
final path = '$_tmp/relay_e2e_60s.mp4';
|
||||||
|
if (await File(path).exists()) return path;
|
||||||
|
final r = await Process.run('ffmpeg', [
|
||||||
|
'-y', '-v', 'error',
|
||||||
|
'-f', 'lavfi', '-i', 'testsrc2=size=640x360:rate=24',
|
||||||
|
'-f', 'lavfi', '-i', 'sine=frequency=440:sample_rate=48000',
|
||||||
|
'-t', '60', '-c:v', 'libx264', '-preset', 'ultrafast', '-b:v', '3M', '-minrate', '3M', '-maxrate', '3M',
|
||||||
|
'-bufsize', '2M', '-g', '24', '-pix_fmt', 'yuv420p', '-c:a', 'aac', '-movflags', '+faststart', path,
|
||||||
|
]);
|
||||||
|
if (r.exitCode != 0) fail('ffmpeg failed: ${r.stderr}');
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A file read at most [bytesPerSec] (a source's uplink).
|
||||||
|
class _SlowFile implements RelayBytes {
|
||||||
|
final File file;
|
||||||
|
final int bytesPerSec;
|
||||||
|
_SlowFile(this.file, this.bytesPerSec);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<int> size() => file.length();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<List<int>> read(int start, int end) async* {
|
||||||
|
const chunk = 32 * 1024;
|
||||||
|
final sw = Stopwatch()..start();
|
||||||
|
var sent = 0;
|
||||||
|
await for (final c in file.openRead(start, end + 1)) {
|
||||||
|
for (var o = 0; o < c.length; o += chunk) {
|
||||||
|
final part = c.sublist(o, o + chunk > c.length ? c.length : o + chunk);
|
||||||
|
sent += part.length;
|
||||||
|
final due = sent * 1000 ~/ bytesPerSec - sw.elapsedMilliseconds;
|
||||||
|
if (due > 0) await Future<void>.delayed(Duration(milliseconds: due));
|
||||||
|
yield part;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> close() async {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A member that is only a source: it sits out (out of the gate), puts
|
||||||
|
/// [file] up relayed and feeds it at [bytesPerSec]. [drop] goes away
|
||||||
|
/// without a `bye` (the member `gone`, the relay kept); [back] returns as
|
||||||
|
/// the same member and feeds again.
|
||||||
|
class _BareSource {
|
||||||
|
final int port;
|
||||||
|
final File file;
|
||||||
|
final String name;
|
||||||
|
static const _client = 'bareSourceClient01';
|
||||||
|
var _seq = 0;
|
||||||
|
WebSocket? ws;
|
||||||
|
String token = '';
|
||||||
|
RelaySource? src;
|
||||||
|
Timer? _alive;
|
||||||
|
_BareSource(this.port, this.file) : name = 'slow.mp4';
|
||||||
|
|
||||||
|
Future<void> join() async {
|
||||||
|
final w = ws = await WebSocket.connect('ws://127.0.0.1:$port/sync');
|
||||||
|
final welcome = Completer<Map>();
|
||||||
|
w.listen((f) {
|
||||||
|
final j = jsonDecode('$f') as Map;
|
||||||
|
if (j['t'] == 'welcome' && !welcome.isCompleted) welcome.complete(j);
|
||||||
|
}, onError: (_) {});
|
||||||
|
w.add(jsonEncode(HelloMessage(
|
||||||
|
secret: 'pw', user: const HelloUser(name: 'bob'), client: _client, seq: ++_seq)
|
||||||
|
.toJson()));
|
||||||
|
token = (await welcome.future.timeout(const Duration(seconds: 5)))['token'] as String;
|
||||||
|
w.add(jsonEncode(const PresenceIntent(state: Presence.away).toJson()));
|
||||||
|
// Not silent, like an app (a silent member goes gone).
|
||||||
|
_alive?.cancel();
|
||||||
|
_alive = Timer.periodic(const Duration(seconds: 1),
|
||||||
|
(_) => w.add(jsonEncode({'t': 'report', 'position': 0, 'buffered': 0, 'eventId': 0})));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> swap() async {
|
||||||
|
ws!.add(jsonEncode({
|
||||||
|
't': 'swap',
|
||||||
|
'media': {'name': name, 'size': await file.length(), 'duration': 0, 'relay': <String, Object?>{}},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> feed(int bytesPerSec) async {
|
||||||
|
final s = src = RelaySource(Uri.parse('ws://127.0.0.1:$port/r/default/$token/source'),
|
||||||
|
RelayMedia.single(_SlowFile(file, bytesPerSec)),
|
||||||
|
name: name);
|
||||||
|
await s.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gone without a word: the channel and the socket close, no `bye`.
|
||||||
|
Future<void> drop() async {
|
||||||
|
_alive?.cancel();
|
||||||
|
await src?.close();
|
||||||
|
await ws?.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> back(int bytesPerSec) async {
|
||||||
|
await join();
|
||||||
|
await feed(bytesPerSec);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> close() async {
|
||||||
|
_alive?.cancel();
|
||||||
|
await src?.close();
|
||||||
|
await ws?.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Serves one file with ranges; [requests] lists hits (user agent, URL).
|
||||||
Future<(HttpServer, List<String>)> _origin(String file) async {
|
Future<(HttpServer, List<String>)> _origin(String file) async {
|
||||||
final s = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
|
final s = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
|
||||||
final requests = <String>[];
|
final requests = <String>[];
|
||||||
s.listen((req) async {
|
s.listen((req) async {
|
||||||
requests.add(req.uri.toString());
|
requests.add('${req.headers.value('user-agent')} ${req.uri}');
|
||||||
final size = await File(file).length();
|
final size = await File(file).length();
|
||||||
final r = resolveMediaRequest(rangeHeader: req.headers.value('range'), size: size, filename: 'v.mkv');
|
final r = resolveMediaRequest(rangeHeader: req.headers.value('range'), size: size, filename: 'v.mkv');
|
||||||
req.response.statusCode = r.status;
|
req.response.statusCode = r.status;
|
||||||
@@ -159,6 +274,12 @@ void main() {
|
|||||||
expect(alice.serverState.peek()!.toJson().toString(), isNot(contains('SECRET')));
|
expect(alice.serverState.peek()!.toJson().toString(), isNot(contains('SECRET')));
|
||||||
await playTogether([alice, bob]);
|
await playTogether([alice, bob]);
|
||||||
expect(server.hub.relay!.buffers.single.bytesToViewers, greaterThan(0));
|
expect(server.hub.relay!.buffers.single.bytesToViewers, greaterThan(0));
|
||||||
|
// Read once: bob's player plays his shared reader's loopback, so
|
||||||
|
// every hit at the origin is that reader's (Dart's), none mpv's.
|
||||||
|
final one = bob.media.shared.currentFor(link);
|
||||||
|
expect(one, isNotNull);
|
||||||
|
expect(one!.bytesIn, greaterThan(0));
|
||||||
|
expect(hits.where((h) => !h.startsWith('Dart/')), isEmpty, reason: '$hits');
|
||||||
|
|
||||||
final before = hits.length;
|
final before = hits.length;
|
||||||
expect(await alice.swapPicked(LinkMedia(link, relay: LinkRelay.server)), isNull);
|
expect(await alice.swapPicked(LinkMedia(link, relay: LinkRelay.server)), isNull);
|
||||||
@@ -267,4 +388,113 @@ void main() {
|
|||||||
await proxy.close();
|
await proxy.close();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// A seek into a part the relay has not buffered yet. The source's
|
||||||
|
// uplink is about 3x the media's bitrate; the time from the seek to the
|
||||||
|
// player playing there again is printed and loosely bounded.
|
||||||
|
test('a seek past what a slow source has sent plays within a few seconds',
|
||||||
|
timeout: const Timeout(Duration(minutes: 2)), () async {
|
||||||
|
final file = File(await _longMedia());
|
||||||
|
final alice = await member('alice');
|
||||||
|
final bob = _BareSource(server.boundPort, file);
|
||||||
|
try {
|
||||||
|
await bob.join();
|
||||||
|
await bob.swap();
|
||||||
|
await _poll(() => stateMediaRelay(alice.serverState.peek()!)?.source.isNotEmpty ?? false, 'relay state');
|
||||||
|
await bob.feed(1200 * 1024);
|
||||||
|
await _poll(() => alice.playback.durationSecs > 59, 'alice opened it');
|
||||||
|
alice.toggleReady();
|
||||||
|
await _poll(() => alice.playback.playing && alice.playback.positionSecs > 2, 'playing');
|
||||||
|
final took = <int>[];
|
||||||
|
for (final to in [40.0, 52.0]) {
|
||||||
|
final sw = Stopwatch()..start();
|
||||||
|
alice.seekTo(to);
|
||||||
|
await _poll(() => alice.playback.playing && alice.playback.positionSecs > to + 0.5, 'played at $to',
|
||||||
|
every: const Duration(milliseconds: 20));
|
||||||
|
took.add(sw.elapsedMilliseconds);
|
||||||
|
}
|
||||||
|
// ignore: avoid_print
|
||||||
|
print('relay seek: seek to an unbuffered part until playing there: ${took.join(', ')} ms');
|
||||||
|
for (final t in took) {
|
||||||
|
expect(t, lessThan(5000), reason: "a seek into what the source has not sent yet (4 s of buffer at about 3x the bitrate)");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await bob.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// D6, the viewer's side: the source goes, a seek lands past what
|
||||||
|
// the server got from it, and the buffer runs dry.
|
||||||
|
test('the source drops: what is buffered plays, a seek past it waits for the source, no seek loop, it resumes (D6)',
|
||||||
|
timeout: const Timeout(Duration(minutes: 3)), () async {
|
||||||
|
final file = File(await _longMedia());
|
||||||
|
final alice = await member('alice');
|
||||||
|
final carol = await member('carol');
|
||||||
|
final bob = _BareSource(server.boundPort, file);
|
||||||
|
final errors = <String>[];
|
||||||
|
final subs = <StreamSubscription<Object?>>[];
|
||||||
|
try {
|
||||||
|
await bob.join();
|
||||||
|
await bob.swap();
|
||||||
|
await _poll(() => stateMediaRelay(alice.serverState.peek()!)?.source.isNotEmpty ?? false, 'relay state');
|
||||||
|
// Just above the bitrate: the relay holds only a little ahead.
|
||||||
|
await bob.feed(560 * 1024);
|
||||||
|
await _poll(() => alice.playback.durationSecs > 59 && carol.playback.durationSecs > 59, 'both opened it');
|
||||||
|
for (final x in [alice, carol]) {
|
||||||
|
subs.add(x.playback.player.logs.listen((l) {
|
||||||
|
if (l.text.contains('error running command')) errors.add(l.text);
|
||||||
|
}));
|
||||||
|
x.toggleReady();
|
||||||
|
}
|
||||||
|
await _poll(() => alice.playback.playing && alice.playback.positionSecs > 4, 'playing');
|
||||||
|
|
||||||
|
await bob.drop();
|
||||||
|
await _poll(() => stateMediaRelay(alice.serverState.peek()!)?.sourceGone ?? false, 'source gone');
|
||||||
|
expect(alice.status.peek().line?.text, startsWith("bob's connection dropped"));
|
||||||
|
final seeks = [alice.playback.seeksSent, carol.playback.seeksSent];
|
||||||
|
// Carol skips to where the source never got to.
|
||||||
|
carol.seekTo(50);
|
||||||
|
await _poll(() => alice.serverState.peek()!.hold?.reason == HoldReason.source, 'held for the source',
|
||||||
|
timeout: const Duration(seconds: 60));
|
||||||
|
expect(alice.status.peek().line?.text, 'Waiting for bob');
|
||||||
|
expect(carol.status.peek().line?.text, 'Waiting for bob');
|
||||||
|
// Held for a while: no seek loop into bytes nobody sends.
|
||||||
|
await Future.delayed(const Duration(seconds: 12));
|
||||||
|
final sent = [alice.playback.seeksSent - seeks[0], carol.playback.seeksSent - seeks[1]];
|
||||||
|
expect(sent.every((n) => n <= 1), isTrue, reason: 'one try each, no loop: $sent');
|
||||||
|
expect(alice.serverState.peek()!.transport, 'held');
|
||||||
|
expect([alice.playerError.peek(), carol.playerError.peek()], everyElement(isEmpty), reason: 'not a broken player');
|
||||||
|
// Someone joins meanwhile: it opens what the server has and waits too.
|
||||||
|
final dave = await member('dave');
|
||||||
|
subs.add(dave.playback.player.logs.listen((l) {
|
||||||
|
if (l.text.contains('error running command')) errors.add(l.text);
|
||||||
|
}));
|
||||||
|
await _poll(() => dave.status.peek().line?.text == 'Waiting for bob', 'dave waits for bob');
|
||||||
|
dave.toggleReady();
|
||||||
|
|
||||||
|
await bob.back(4 * 1024 * 1024);
|
||||||
|
await _poll(() {
|
||||||
|
final relay = stateMediaRelay(alice.serverState.peek()!);
|
||||||
|
return relay != null && relay.live && !relay.sourceGone;
|
||||||
|
}, 'source back');
|
||||||
|
await _poll(
|
||||||
|
() =>
|
||||||
|
alice.serverState.peek()!.transport == 'playing' &&
|
||||||
|
alice.playback.playing &&
|
||||||
|
alice.playback.positionSecs > 50.5 &&
|
||||||
|
carol.playback.positionSecs > 50.5 &&
|
||||||
|
dave.playback.positionSecs > 50.5,
|
||||||
|
'all playing at 50 again',
|
||||||
|
timeout: const Duration(seconds: 40));
|
||||||
|
// ignore: avoid_print
|
||||||
|
print('D6: seeks while the source was gone $sent, held back '
|
||||||
|
'${alice.media.heldBackSeeks} + ${carol.media.heldBackSeeks}');
|
||||||
|
expect(errors, isEmpty, reason: 'no command reached a player without its media');
|
||||||
|
} finally {
|
||||||
|
for (final s in subs) {
|
||||||
|
await s.cancel();
|
||||||
|
}
|
||||||
|
await bob.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
Loaded 100 of 299 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user