Compare commits
22
Commits
9f2586b7ff
...
v1.3.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
49fee1ca19 | ||
|
|
c91d57633f | ||
|
|
22ce40a488 | ||
|
|
07276ce020 | ||
|
|
4c8e40769a | ||
|
|
211ba65e5e | ||
|
|
fe4749ec7f | ||
|
|
51af830160 | ||
|
|
d9d7881747 | ||
|
|
1c915067aa | ||
|
|
0924309abf | ||
|
|
cf41a13528 | ||
|
|
9bbc594048 | ||
|
|
741b258ac1 | ||
|
|
a87ee98faa | ||
|
|
25b19f2d6a | ||
|
|
8970f2694b | ||
|
|
72a3b9adac | ||
|
|
bf9fc193b3 | ||
|
|
4d00b7455e | ||
|
|
629be0d9ed | ||
|
|
130c934b9b |
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
|
||||||
@@ -49,7 +49,7 @@ 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). "Update and restart" downloads,
|
||||||
verifies, applies in place and restarts; "Later" hides it until the next
|
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
|
check; the install folder must be writable by you. The version you run is
|
||||||
under the Settings tab. 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` /
|
||||||
`update.bat` (which run the first). Each updates the whole folder, app and
|
`update.bat` (which run the first). Each updates the whole folder, app and
|
||||||
@@ -60,35 +60,37 @@ telling you to update.
|
|||||||
|
|
||||||
## Getting started
|
## Getting started
|
||||||
|
|
||||||
Three ways in, all at the top of the Library when you are not in a server.
|
The app always runs its own server, quietly: your server. When it starts
|
||||||
None of them needs Jellyfin or an account.
|
you are on it, alone, and it is a plain player. Three ways in, at the top
|
||||||
|
of the Library the first time; none needs Jellyfin or an account.
|
||||||
|
|
||||||
- **Join a friend** (most people): your friend sends you an address and,
|
- **Join a friend** (most people): your friend sends you an invite (an
|
||||||
maybe, a password. See [Join](#join).
|
address) and, maybe, a password. See [Join](#join).
|
||||||
- **Host**: you have the video and friends watch it with you. See
|
- **Invite friends**: you have the video and friends watch it with you.
|
||||||
[Host from the app](#host-from-the-app).
|
See [Your server and Invite](#your-server-and-invite).
|
||||||
- **Watch solo**: just play something from the Library. It plays for you
|
- **Watch on your own**: just play something from the Library, with
|
||||||
alone, with everything a server has (Jellyfin progress, next episode,
|
everything a server has (Jellyfin progress, next episode, skip buttons).
|
||||||
skip buttons); nothing is reachable from outside this computer. Invite
|
Nobody else can reach your server until you invite them; inviting never
|
||||||
friends in the strip turns it into a hosted server (the Host card's port
|
interrupts what plays.
|
||||||
and password) and the video carries on from where it was; Stop ends it.
|
|
||||||
|
|
||||||
## The window
|
## The window
|
||||||
|
|
||||||
One window. Along the top, the strip: your avatar (click it for your
|
One window. Along the top, the strip: your avatar (click it for your
|
||||||
profile: name, picture with a crop editor, colour, and links to the
|
profile: name, picture with a crop editor, colour, and links to the
|
||||||
settings), the Library / Schedule / Settings tabs, and the party. Offline
|
settings), the Library / Schedule / Settings tabs, and the party. Alone
|
||||||
the party side has Join a friend and Host; watching solo it says Solo,
|
on your server it has just Join a friend and Invite (and "Sharing" while
|
||||||
with Invite friends and Stop; in a server it shows everyone's avatar
|
your server is open to friends); with others there it shows everyone's avatar
|
||||||
with a ring for their state (green ready, amber buffering, grey not ready,
|
with a ring for their state (green ready, amber buffering, grey not ready,
|
||||||
a little faded joining, dimmed reconnecting, dimmed with a moon sitting
|
a little faded joining, dimmed reconnecting, dimmed with a moon sitting
|
||||||
out; a crown on whoever
|
out; a crown on whoever
|
||||||
hosts, a tower on whoever passes the video on). Click the avatars for the
|
hosts, a tower on whoever passes the video on). Click the avatars for the
|
||||||
member list (force ready, Nudge, Sit out, "Copy invite address"; when you host it lists this computer's
|
member list (each member's app version, amber when it differs from yours,
|
||||||
network addresses with the port, and friends outside your network need
|
and the server's at the top; force ready, Nudge, Sit out; on your
|
||||||
that port forwarded or a server they can all reach, like a VPS). Beside them the server and "ready n/m" (who is sitting out or
|
server the invite with Copy invite and Stop sharing, on a friend's "Copy
|
||||||
|
invite address"). Beside them the server and "ready n/m" (who is sitting out or
|
||||||
reconnecting does not count), the chat button with unread
|
reconnecting does not count), the chat button with unread
|
||||||
count, and Leave. Empty strip space drags the window.
|
count, and Invite on your server or Leave on a friend's. Empty strip
|
||||||
|
space drags the window.
|
||||||
|
|
||||||
The player fills the window under the strip. Pick a tab and it shrinks to
|
The player fills the window under the strip. Pick a tab and it shrinks to
|
||||||
a mini player in a corner: drag it anywhere and it snaps to the nearest
|
a mini player in a corner: drag it anywhere and it snaps to the nearest
|
||||||
@@ -107,10 +109,23 @@ loading…") or what went wrong, with Retry or Cancel when it helps. Chat and ac
|
|||||||
Tab), ready to type; it shows messages (select text to copy it, or
|
Tab), ready to type; it shows messages (select text to copy it, or
|
||||||
right-click for Copy message and, with a link in it, Copy link / Open in
|
right-click for Copy message and, with a link in it, Copy link / Open in
|
||||||
browser), and its "Activity" toggle adds
|
browser), and its "Activity" toggle adds
|
||||||
joins, ready, seeks and the like between them. Joins, leaves, chat and
|
joins, ready, seeks and the like between them. Joins, leaves, someone
|
||||||
other news also pop up bottom left with the person's avatar; click a message to open the chat
|
losing connection and coming back, video changes, additions to Up next
|
||||||
(Settings → Notifications → Chat messages: All by default, or Only when
|
(the title, its picture and who added it), chat and problems also pop up
|
||||||
paused, or Off; messages always reach the chat, with their sound). Unread chat shows as a count on the chat
|
bottom left with the person's avatar, above the player's controls (never
|
||||||
|
on them); ready, seeks and pauses stay in the activity. Click a message
|
||||||
|
to open the chat (Settings → Notifications & sounds → Chat messages: All
|
||||||
|
by default, or Only when paused, or Off; messages always reach the chat,
|
||||||
|
with their sound). Watching alone, nothing pops up or sounds about the
|
||||||
|
party (there is nobody); the app's own notices still show.
|
||||||
|
|
||||||
|
**While sitting out or in the background.** Sitting out, only chat,
|
||||||
|
nudges, "starting now" (the video starts for the others) and Up next
|
||||||
|
reach you; joins and leaves too if you turn on Settings → Notifications &
|
||||||
|
sounds → "Joins and leaves too". With the window in the background or
|
||||||
|
minimised, the same ones also come as desktop notifications (Linux's
|
||||||
|
notification center, Windows' toasts); clicking one brings the window
|
||||||
|
up, and a chat one opens the chat. Unread chat shows as a count on the chat
|
||||||
buttons and, in fullscreen with the controls hidden and the video paused,
|
buttons and, in fullscreen with the controls hidden and the video paused,
|
||||||
as a small chip top right.
|
as a small chip top right.
|
||||||
|
|
||||||
@@ -146,19 +161,28 @@ same profile) takes your place over, and the first one says so and stops.
|
|||||||
only they hear it (a sound, a pop-up, and the taskbar entry asks for
|
only they hear it (a sound, a pop-up, and the taskbar entry asks for
|
||||||
attention when the window is in the background).
|
attention when the window is in the background).
|
||||||
|
|
||||||
## Host from the app
|
## Your server and Invite
|
||||||
|
|
||||||
Host in the strip: set a port and an optional password (blank = anyone can
|
Your server starts with the app and stops when it closes. Only this app
|
||||||
join), then Host. The app starts a server inside itself and joins it; you
|
can reach it until you press **Invite** (in the strip, or in the member
|
||||||
are the host (the crown). Nothing is up yet: pick what to
|
list): that opens it on your network at the port from Settings → Party &
|
||||||
watch in the Library tab (below) and put it up. Leaving or
|
network (default 8080, with the password set there; blank = anyone can
|
||||||
closing the app stops the server. Friends need to reach your machine: open
|
join) and copies an invite ("Join me on Starsync: 192.168.1.20:8080 ·
|
||||||
the TCP port on your router (or use a LAN or VPN address). Friends open
|
Starsync 1.3.0") to send to friends. Nothing restarts: what plays and Up
|
||||||
nothing. Ports you hosted on before are one click away in the Host card.
|
next carry on, and you are the host (the crown). The invite picks this
|
||||||
|
computer's home-network address (not a VPN or proxy one, nor a Docker or
|
||||||
|
VM bridge). Friends elsewhere need the TCP port forwarded on your router
|
||||||
|
(or a VPN address, or a server they can all reach, like a VPS). Friends
|
||||||
|
open nothing. **Stop sharing** (in the member list) closes it again:
|
||||||
|
everyone else leaves (their app goes back to their own server and says
|
||||||
|
why), you and Up next stay.
|
||||||
|
|
||||||
If you put up a local file, the server streams it to everyone from your
|
If you put up a local file, your server streams it to everyone from your
|
||||||
disk. Nothing is uploaded or stored anywhere.
|
disk. Nothing is uploaded or stored anywhere.
|
||||||
|
|
||||||
|
Joining a friend leaves your server for theirs; it stays up meanwhile,
|
||||||
|
and leaving theirs brings you back to it as you left it.
|
||||||
|
|
||||||
## Library
|
## Library
|
||||||
|
|
||||||
What to watch, from three sources (the switch at the top). Drop a video,
|
What to watch, from three sources (the switch at the top). Drop a video,
|
||||||
@@ -192,15 +216,30 @@ Up next, add the folder to Files, or fill Link).
|
|||||||
open here (needs cookies, 403, 404, not a video) says why and goes up
|
open here (needs cookies, 403, 404, not a video) says why and goes up
|
||||||
only with "Put it up anyway"; in the player, whoever cannot play it is
|
only with "Put it up anyway"; in the player, whoever cannot play it is
|
||||||
named ("Bob can't play this: Not allowed (403)") and nobody waits for them.
|
named ("Bob can't play this: Not allowed (403)") and nobody waits for them.
|
||||||
- **Files**: video folders you add on this computer, grouped by series
|
- **Files**: your own library from the video folders you add on this
|
||||||
(click a series to list its episodes), or any file through Browse. The others stream it from you.
|
computer (the **Library** shows them all together, each folder on its
|
||||||
|
own too), or any file through Browse. Files are sorted into shows and
|
||||||
|
movies by their names and folders: one show split over several folders
|
||||||
|
is one show, OP/ED and specials go under its **Extras**, a folder like
|
||||||
|
"Show (Season 3 - Alicization)" gives the season and its name. Shows
|
||||||
|
are looked up on AniList for their proper name, cover and banner. A
|
||||||
|
show's page has its seasons and episodes and a button for the next one
|
||||||
|
after what you last watched here; when episodes are missing on the way
|
||||||
|
it says so ("66–107 aren't in your folders — skip to 108?"). An
|
||||||
|
episode you put up continues with the next one at its end (see Up
|
||||||
|
next). Matched
|
||||||
|
the wrong show? **Wrong show?** searches AniList and pins the right one
|
||||||
|
for the show everywhere or just one folder; **Episode numbers…** says
|
||||||
|
which episode the first file is when the files count differently. Both
|
||||||
|
stay through rescans. The others stream what you put up from you, and
|
||||||
|
see its proper name too.
|
||||||
|
|
||||||
Put it up and the player opens. Not in a server, the buttons say Play
|
Put it up and the player opens. With nobody else in the server, the
|
||||||
and it plays for you alone ([Watch solo](#getting-started)).
|
buttons say Play and a link plays straight from the site.
|
||||||
|
|
||||||
### Up next (the queue)
|
### Up next (the queue)
|
||||||
|
|
||||||
In a server (solo too), anything can wait its turn instead of going up
|
Anything can wait its turn instead of going up
|
||||||
now: **Add to queue** on the Link page (in the way you picked), or
|
now: **Add to queue** on the Link page (in the way you picked), or
|
||||||
right-click a Jellyfin card or episode, a video in Files, a recent link
|
right-click a Jellyfin card or episode, a video in Files, a recent link
|
||||||
or an episode on the Schedule for **Add to queue** / **Play next**; the
|
or an episode on the Schedule for **Add to queue** / **Play next**; the
|
||||||
@@ -216,7 +255,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
|
||||||
@@ -224,17 +263,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
|
||||||
|
|
||||||
@@ -288,8 +332,8 @@ clipboard: paste it to whoever helps you, from both sides if you can.
|
|||||||
|
|
||||||
While you are in a server, the app also sends its log to that server, which
|
While you are in a server, the app also sends its log to that server, which
|
||||||
merges everyone's lines and its own into one session log
|
merges everyone's lines and its own into one session log
|
||||||
(`session-YYYY-MM-DD.log` next to the server's logs; the hosting app keeps
|
(`session-YYYY-MM-DD.log` next to the server's logs; your own server
|
||||||
it in its own logs folder), so a problem can be read afterwards from every
|
keeps it in the app's logs folder), so a problem can be read afterwards from every
|
||||||
side. Chat is never logged anywhere, nor passwords, tokens and cookies.
|
side. Chat is never logged anywhere, nor passwords, tokens and cookies.
|
||||||
When something goes wrong, press **Mark a problem** (Ctrl+Shift+B, also in
|
When something goes wrong, press **Mark a problem** (Ctrl+Shift+B, also in
|
||||||
the player's ⚙ and Settings → About) and add a few words if you like: a
|
the player's ⚙ and Settings → About) and add a few words if you like: a
|
||||||
@@ -301,24 +345,29 @@ notes that you don't share; a problem you mark still goes in).
|
|||||||
|
|
||||||
## Join
|
## Join
|
||||||
|
|
||||||
Join in the strip. Servers you got into are listed first (up to 6), with
|
Join a friend in the strip. Servers you got into are listed first (up
|
||||||
live status (online and how many are watching, or offline, plus "needs
|
to 6), with live status (online and how many are watching, or offline,
|
||||||
update" when the server runs another protocol): click one to rejoin, x to
|
plus "needs update" when its version and yours do not match): click one
|
||||||
forget it. Or enter the host's address, port, and the password if there
|
to rejoin, x to forget it. Or paste the invite (or the address and port)
|
||||||
is one, then Enter or Join. Your name and picture come from your profile
|
and the password if there is one, then Enter or Join. Before connecting
|
||||||
(your avatar at the top left).
|
the app checks the server's version: when they do not match it says who
|
||||||
|
has to update ("Bob's server runs Starsync 1.2.1 — they need to
|
||||||
|
update"), and offers the update when it is you. Your name and picture
|
||||||
|
come from your profile (your avatar at the top left). Leave goes back to
|
||||||
|
your own server.
|
||||||
|
|
||||||
## Settings
|
## Settings
|
||||||
|
|
||||||
The Settings tab (Ctrl+,) has a page per topic, each setting with a line
|
The Settings tab (Ctrl+,) has a page per topic, each setting with a line
|
||||||
of help: Profile; Party & network (the Host card's default port and
|
of help: Profile; Party & network (your server's port and password for
|
||||||
password, recent servers with remove); Player (below); Jellyfin &
|
Invite, recent servers with remove); Player (below); Jellyfin &
|
||||||
integrations (linked Jellyfin servers, below, the AnimeFlow connection:
|
integrations (linked Jellyfin servers, below, the AnimeFlow connection:
|
||||||
"connected as <name>" with what you may do, or why not, and playing the
|
"connected as <name>" with what you may do, or why not, and playing the
|
||||||
next episode automatically); Notifications & sounds (what pops up; the sound
|
next episode automatically); Notifications & sounds (what pops up, chat
|
||||||
|
pop-ups, what reaches you while sitting out or in the background; the sound
|
||||||
volume and sound set, and every sound with a preview, its own set and a switch); Updates; Keyboard shortcuts (read-only); About
|
volume and sound set, and every sound with a preview, its own set and a switch); Updates; Keyboard shortcuts (read-only); About
|
||||||
(the version; resting the mouse on the Settings tab shows it too; and the
|
(the version, also at the bottom of every Settings page and in the
|
||||||
credits, e.g. that the Minimal player design is inspired by uosc).
|
Settings tab's tooltip; and the credits, e.g. that the Minimal player design is inspired by uosc).
|
||||||
|
|
||||||
Player settings live in the player too: ⚙ → Player settings, at the
|
Player settings live in the player too: ⚙ → Player settings, at the
|
||||||
right of its controls, and Settings → Player has the same ones (plus the
|
right of its controls, and Settings → Player has the same ones (plus the
|
||||||
@@ -397,7 +446,8 @@ during the first 6 minutes.
|
|||||||
| Sync WebSocket and `/health` | TCP 8080 | plain `ws://`, no TLS |
|
| Sync WebSocket and `/health` | TCP 8080 | plain `ws://`, no TLS |
|
||||||
| Local file streaming | same port | path `/m/...`, needs a session token |
|
| Local file streaming | same port | path `/m/...`, needs a session token |
|
||||||
|
|
||||||
Only the host needs the port reachable.
|
Only the one who invites needs the port reachable; until Invite your
|
||||||
|
server listens on 127.0.0.1 and a free port only.
|
||||||
|
|
||||||
## Jellyfin
|
## Jellyfin
|
||||||
|
|
||||||
@@ -430,15 +480,19 @@ Watching:
|
|||||||
directly with their own sign-in. Jellyfin tracks progress per account:
|
directly with their own sign-in. Jellyfin tracks progress per account:
|
||||||
your watched state, Continue watching and resume point follow what you
|
your watched state, Continue watching and resume point follow what you
|
||||||
saw (reported on start, every 10 s, on pause, resume, seek and track
|
saw (reported on start, every 10 s, on pause, resume, seek and track
|
||||||
change, and when it stops). Watched means you actually played most of
|
change, and when it stops). Watched means you were there while most
|
||||||
it (or reached the ending after half), not where you stopped: opening
|
of it played (or while half of it played and it reached the ending),
|
||||||
a watched episode to check a moment keeps it watched, and a quick peek
|
not where it stopped: time you sat out does not count, skipping around
|
||||||
leaves no resume point.
|
does not count, opening a watched episode to check a moment keeps it
|
||||||
|
watched, and a quick peek leaves no resume point. Files and links you
|
||||||
|
watch are remembered the same way on your computer (a show's next
|
||||||
|
episode in Files follows them).
|
||||||
- Friends without an account watch through the server: whoever put the
|
- Friends without an account watch through the server: whoever put the
|
||||||
item up passes it on automatically, with their own sign-in, which never
|
item up passes it on automatically, with their own sign-in, which never
|
||||||
leaves their computer (only the current item, only to people in the
|
leaves their computer (only the current item, only to people in the
|
||||||
session). They show "via server" in the member list (click the avatars). When the app hosts,
|
session). They show "via server" in the member list (click the avatars). On your
|
||||||
or a dedicated server has its own login (`--jellyfin <file>`, see
|
own server (with your sign-in on that item's Jellyfin, whichever of
|
||||||
|
your linked servers it is), or a dedicated server with its own login (`--jellyfin <file>`, see
|
||||||
[docs/ARCHITECTURE.md](docs/ARCHITECTURE.md), Jellyfin relay), the server
|
[docs/ARCHITECTURE.md](docs/ARCHITECTURE.md), Jellyfin relay), the server
|
||||||
fetches it itself instead.
|
fetches it itself instead.
|
||||||
- If nobody can pass it on (the person who put it up left), the app says
|
- If nobody can pass it on (the person who put it up left), the app says
|
||||||
@@ -449,12 +503,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
|
||||||
@@ -489,7 +542,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
|
||||||
@@ -502,9 +559,11 @@ top: who passes it on, or that the server fetches it, everyone plays the
|
|||||||
link, or everyone streams it from Jellyfin, with how many watch through
|
link, or everyone streams it from Jellyfin, with how many watch through
|
||||||
the server and, for a relay, how fast it arrives against what the video
|
the server and, for a relay, how fast it arrives against what the video
|
||||||
needs (the bar's mark; past it is keeping up). It turns amber when the
|
needs (the bar's mark; past it is keeping up). It turns amber when the
|
||||||
source is too slow or reconnecting, red when it left (the others play
|
source is too slow or its connection dropped ("Bob's connection dropped —
|
||||||
out what the server already has), and whoever passes it on is told once
|
playing what the server has (2 min)"), red when it left (the others play
|
||||||
when their upload is too slow. Each member's line says how they get it
|
out what the server already has; the player's pill says the same and a
|
||||||
|
pop-up tells you once), and whoever passes it on is told once when their
|
||||||
|
upload is too slow. Each member's line says how they get it
|
||||||
("via server" with their speed, or "direct") after a dot for their
|
("via server" with their speed, or "direct") after a dot for their
|
||||||
buffer: green enough to play on, amber low, red empty. A red dot on one
|
buffer: green enough to play on, amber low, red empty. A red dot on one
|
||||||
member while the source keeps up is their own connection.
|
member while the source keeps up is their own connection.
|
||||||
|
|||||||
+36
-14
@@ -3,17 +3,18 @@ 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/files/thumbnails.dart' show thumbnailPlayers;
|
||||||
import 'src/platform/app_log.dart';
|
import 'src/platform/app_log.dart';
|
||||||
import 'src/platform/app_shutdown.dart';
|
import 'src/platform/app_shutdown.dart' show closeAppWith;
|
||||||
import 'src/platform/desktop/desktop_app_dirs.dart';
|
import 'src/platform/desktop/desktop_app_dirs.dart';
|
||||||
import 'src/platform/desktop_entry.dart';
|
import 'src/platform/desktop_entry.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/state/session_state.dart';
|
|
||||||
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 UpdateController, UpdatePhase, launchArgs;
|
||||||
|
|
||||||
@@ -46,20 +47,33 @@ Future<void> main(List<String> args) async {
|
|||||||
await startAppLogging(dirs);
|
await startAppLogging(dirs);
|
||||||
Hive.init(dirs.hive);
|
Hive.init(dirs.hive);
|
||||||
await AppSettings.instance.ensureLoaded();
|
await AppSettings.instance.ensureLoaded();
|
||||||
|
// The composition root: every system, made once.
|
||||||
|
final graph = AppGraph.build();
|
||||||
|
// Sounds stay off after an audio-device crash (see SoundEngine.crashMark).
|
||||||
|
graph.sfx.engine.crashMark = '${dirs.support}${Platform.pathSeparator}audio-init.pending';
|
||||||
|
final ports = graph.ports;
|
||||||
// Close button / Alt+F4: the orderly close ([shutDownApp]), so others
|
// Close button / Alt+F4: the orderly close ([shutDownApp]), so others
|
||||||
// see "left", a hosted server shuts down and no player outlives the
|
// see "left", your server shuts down and no player outlives the engine.
|
||||||
// engine.
|
final close = closeAppWith(graph);
|
||||||
final ports = SessionState.instance.ports;
|
|
||||||
await ports.shell.start(
|
await ports.shell.start(
|
||||||
title: dirs.profile == null ? appName : '$appName [${dirs.profile}]', onCloseRequest: shutDownApp);
|
title: dirs.profile == null ? appName : '$appName [${dirs.profile}]', onCloseRequest: () => close());
|
||||||
// Focus, minimise and restore feed auto sit-out.
|
// Focus, minimise and restore feed auto sit-out and the Notifier (in
|
||||||
ports.shell.watchActivity(SessionState.instance.coordinator.windowActivity);
|
// the background, notices go out as system notifications).
|
||||||
|
ports.shell.watchActivity((a) {
|
||||||
|
graph.coordinator.windowActivity(a);
|
||||||
|
graph.notifier.window = a;
|
||||||
|
});
|
||||||
try {
|
try {
|
||||||
await SessionState.instance.loadForm();
|
await graph.forms.load();
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
debugPrint('load connect form failed: $e\n$st');
|
debugPrint('load connect form failed: $e\n$st');
|
||||||
}
|
}
|
||||||
MediaKit.ensureInitialized();
|
// The library's matches, pins and offsets, and the watch history
|
||||||
|
// (device-owned).
|
||||||
|
unawaited(graph.library.ensureLoaded());
|
||||||
|
unawaited(graph.watch.history.ensureLoaded());
|
||||||
|
ports.players.init();
|
||||||
|
thumbnailPlayers = ports.players;
|
||||||
// yt-dlp for web page links: downloaded on first run, kept current.
|
// yt-dlp for web page links: downloaded on first run, kept current.
|
||||||
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).
|
||||||
@@ -70,10 +84,18 @@ Future<void> main(List<String> args) async {
|
|||||||
final s = updates.status.value;
|
final s = updates.status.value;
|
||||||
final version = s.manifest?.version;
|
final version = s.manifest?.version;
|
||||||
if (s.phase == UpdatePhase.available && version != null) {
|
if (s.phase == UpdatePhase.available && version != null) {
|
||||||
SessionState.instance.notifier.notify(UpdateAvailable(version));
|
graph.notifier.notify(UpdateAvailable(version));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
runApp(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
|
||||||
|
// Invite).
|
||||||
|
unawaited(graph.navigation.start());
|
||||||
// The sound device opens once the window is up, not on the first sound.
|
// The sound device opens once the window is up, not on the first sound.
|
||||||
Future.delayed(const Duration(seconds: 2), SessionState.instance.sfx.warmUp);
|
Future.delayed(const Duration(seconds: 2), graph.sfx.warmUp);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
|
||||||
|
import '../jellyfin/accounts.dart';
|
||||||
|
import '../library/library_store.dart';
|
||||||
|
import '../library/local_next.dart';
|
||||||
|
import '../library/show_matcher.dart';
|
||||||
|
import '../platform/app_log.dart' show appLogDir;
|
||||||
|
import '../platform/desktop/desktop_ports.dart';
|
||||||
|
import '../platform/ports/ports.dart';
|
||||||
|
import '../schedule/anilist.dart';
|
||||||
|
import '../schedule/animeflow.dart';
|
||||||
|
import '../screens/home/health_probe.dart';
|
||||||
|
import '../session/connection.dart' show Connection;
|
||||||
|
import '../session/identity.dart' show ClientIdentity, Profile;
|
||||||
|
import '../session/navigation.dart';
|
||||||
|
import '../session/notifier.dart';
|
||||||
|
import '../session/session_coordinator.dart';
|
||||||
|
import '../session/your_server.dart';
|
||||||
|
import '../sfx/sfx.dart';
|
||||||
|
import '../sfx/sound_bank.dart';
|
||||||
|
import '../sfx/synth_sfx.dart';
|
||||||
|
import '../state/connect_prefs.dart';
|
||||||
|
import '../state/settings.dart';
|
||||||
|
import '../watch/watch_history.dart';
|
||||||
|
import '../watch/watch_tracker.dart';
|
||||||
|
|
||||||
|
/// The composition root: builds the app's systems once (in `main`), wires
|
||||||
|
/// them through their constructors and disposes them. No logic and no
|
||||||
|
/// signals of its own. Widgets reach it through [AppScope].
|
||||||
|
class AppGraph {
|
||||||
|
/// The platform (window, links, folders, yt-dlp, notifications).
|
||||||
|
final Ports ports;
|
||||||
|
|
||||||
|
/// Owns the app's audio device ([SynthSfx.dispose] on close).
|
||||||
|
final SynthSfx sfx;
|
||||||
|
|
||||||
|
/// Every toast, sound, system notification and activity-log line.
|
||||||
|
final Notifier notifier;
|
||||||
|
|
||||||
|
/// The link to whichever server this app is in.
|
||||||
|
final Connection connection;
|
||||||
|
|
||||||
|
final SessionCoordinator coordinator;
|
||||||
|
|
||||||
|
/// The connect form and recent servers.
|
||||||
|
final ConnectStore forms;
|
||||||
|
|
||||||
|
/// This app's own server, alive for the whole run.
|
||||||
|
final YourServer yourServer;
|
||||||
|
|
||||||
|
/// Home (your server) or a friend's.
|
||||||
|
final Navigation navigation;
|
||||||
|
|
||||||
|
/// The library's matches (L2): local shows to AniList, pins, offsets.
|
||||||
|
final ShowMatcher library;
|
||||||
|
|
||||||
|
/// Watch tracking and the device's watch history (D3).
|
||||||
|
final WatchTracker watch;
|
||||||
|
|
||||||
|
AppGraph._({
|
||||||
|
required this.ports,
|
||||||
|
required this.sfx,
|
||||||
|
required this.notifier,
|
||||||
|
required this.connection,
|
||||||
|
required this.coordinator,
|
||||||
|
required this.forms,
|
||||||
|
required this.yourServer,
|
||||||
|
required this.navigation,
|
||||||
|
required this.library,
|
||||||
|
required this.watch,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// The desktop app's graph. Tests pass their own [ports], [identity]
|
||||||
|
/// (default: the install's stored one), [healthGet] and server starter.
|
||||||
|
factory AppGraph.build({
|
||||||
|
Ports? ports,
|
||||||
|
ClientIdentity? identity,
|
||||||
|
HealthGetter healthGet = defaultHealthGet,
|
||||||
|
ServerStarter? serverStarter,
|
||||||
|
JellyfinAccounts? jellyfin,
|
||||||
|
ShowMatcher? library,
|
||||||
|
WatchHistory? history,
|
||||||
|
}) {
|
||||||
|
final p = ports ?? desktopPorts();
|
||||||
|
final settings = AppSettings.instance;
|
||||||
|
final sfx = SynthSfx(
|
||||||
|
volume: () => settings.soundVolume.peek(),
|
||||||
|
// A nudge is an explicit poke: it ignores its switch (not the volume).
|
||||||
|
enabled: (k) => k == SfxKind.nudge || !settings.soundsOff.peek().contains(k.name),
|
||||||
|
presetFor: soundPresetFor,
|
||||||
|
);
|
||||||
|
final notifier = Notifier(
|
||||||
|
sfx: sfx,
|
||||||
|
attention: p.shell.requestAttention,
|
||||||
|
system: p.notifications,
|
||||||
|
focusWindow: p.shell.focus,
|
||||||
|
prefs: () => settings.notifyPrefs,
|
||||||
|
);
|
||||||
|
final connection = Connection(notify: notifier.notify, identity: identity ?? ClientIdentity.stored());
|
||||||
|
// Device-owned: the watch history box.
|
||||||
|
final watch = WatchTracker(history: history ?? WatchHistory());
|
||||||
|
final coordinator = SessionCoordinator(
|
||||||
|
notifier: notifier,
|
||||||
|
connection: connection,
|
||||||
|
ytDlp: p.links,
|
||||||
|
jellyfin: jellyfin,
|
||||||
|
players: p.players,
|
||||||
|
settings: settings,
|
||||||
|
watch: watch);
|
||||||
|
// Device-owned: the library box; AnimeFlow's runs when signed in.
|
||||||
|
final matcher = library ??
|
||||||
|
ShowMatcher(
|
||||||
|
store: HiveLibraryStore(),
|
||||||
|
aniList: AniListClient(),
|
||||||
|
flow: AnimeFlowLookup(AnimeFlowClient.fromAccounts(coordinator.jellyfin)),
|
||||||
|
);
|
||||||
|
coordinator
|
||||||
|
..fileRef = matcher.refForPath
|
||||||
|
..nextFile = LocalNextFinder(matcher: matcher, scan: p.folders.scan).nextAfter;
|
||||||
|
final forms = ConnectStore();
|
||||||
|
final yourServer = YourServer(
|
||||||
|
forms: forms,
|
||||||
|
jellyfin: jellyfin,
|
||||||
|
// The app's yt-dlp folder; '' (the server's own default) when the
|
||||||
|
// app-support folder is not available.
|
||||||
|
ytDlpDir: () async {
|
||||||
|
try {
|
||||||
|
return (await p.links.ytDlp).dir;
|
||||||
|
} catch (_) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
logDir: () => appLogDir,
|
||||||
|
starter: serverStarter,
|
||||||
|
);
|
||||||
|
final navigation = Navigation(
|
||||||
|
coordinator: coordinator,
|
||||||
|
yourServer: yourServer,
|
||||||
|
forms: forms,
|
||||||
|
healthGet: healthGet,
|
||||||
|
);
|
||||||
|
// The profile keeps its name in the connect form and pushes changes
|
||||||
|
// to whichever server this app is in.
|
||||||
|
Profile.instance
|
||||||
|
..forms = forms
|
||||||
|
..push = ({String? name, String? avatar}) {
|
||||||
|
if (navigation.online.peek()) coordinator.updateProfile(name: name, avatar: avatar);
|
||||||
|
};
|
||||||
|
return AppGraph._(
|
||||||
|
ports: p,
|
||||||
|
sfx: sfx,
|
||||||
|
notifier: notifier,
|
||||||
|
connection: connection,
|
||||||
|
coordinator: coordinator,
|
||||||
|
forms: forms,
|
||||||
|
yourServer: yourServer,
|
||||||
|
navigation: navigation,
|
||||||
|
library: matcher,
|
||||||
|
watch: watch,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> dispose() async {
|
||||||
|
navigation.dispose();
|
||||||
|
await coordinator.dispose();
|
||||||
|
await watch.dispose();
|
||||||
|
await yourServer.stop();
|
||||||
|
await sfx.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The sound set [kind] plays from: its own pick in Settings, else the
|
||||||
|
/// overall one.
|
||||||
|
SoundPreset soundPresetFor(SfxKind kind) {
|
||||||
|
final s = AppSettings.instance;
|
||||||
|
final name = s.soundPresetFor.peek()[kind.name] ?? s.soundPreset.peek();
|
||||||
|
return SoundPreset.values.where((p) => p.name == name).firstOrNull ?? defaultPreset;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hands the [AppGraph] to every widget below: `AppScope.of(context)`.
|
||||||
|
/// The graph never changes, so reading it registers no dependency (safe
|
||||||
|
/// in `initState`).
|
||||||
|
class AppScope extends InheritedWidget {
|
||||||
|
final AppGraph graph;
|
||||||
|
const AppScope({super.key, required this.graph, required super.child});
|
||||||
|
|
||||||
|
static AppGraph of(BuildContext context) {
|
||||||
|
final scope = context.getInheritedWidgetOfExactType<AppScope>();
|
||||||
|
assert(scope != null, 'no AppScope above this widget');
|
||||||
|
return scope!.graph;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool updateShouldNotify(AppScope oldWidget) => !identical(graph, oldWidget.graph);
|
||||||
|
}
|
||||||
@@ -121,7 +121,7 @@ Future<void> runControlAction(BuildContext context, PlayerControlsModel model, C
|
|||||||
model.changeVideo();
|
model.changeVideo();
|
||||||
case ControlAction.copyLink:
|
case ControlAction.copyLink:
|
||||||
final link = model.link();
|
final link = model.link();
|
||||||
if (link != null) await copyToClipboard(link, done: 'Link copied');
|
if (link != null) await copyToClipboard(context, link, done: 'Link copied');
|
||||||
case ControlAction.reload:
|
case ControlAction.reload:
|
||||||
model.reload();
|
model.reload();
|
||||||
case ControlAction.markAllReady:
|
case ControlAction.markAllReady:
|
||||||
|
|||||||
@@ -148,7 +148,7 @@ class MinimalControls extends StatelessWidget {
|
|||||||
const Expanded(
|
const Expanded(
|
||||||
child: Align(alignment: AlignmentDirectional.centerStart, child: NowPlayingTitle()),
|
child: Align(alignment: AlignmentDirectional.centerStart, child: NowPlayingTitle()),
|
||||||
),
|
),
|
||||||
UpNextButton(queue: m.queue, onPlayNextEpisode: m.playNextEpisode, look: UpNextLook.minimal, hold: hold),
|
UpNextButton(queue: m.queue, look: UpNextLook.minimal, hold: hold),
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
SignalBuilder(
|
SignalBuilder(
|
||||||
builder: (context) {
|
builder: (context) {
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ abstract final class MinimalTimelineSpec {
|
|||||||
/// What is left of it while they are hidden.
|
/// What is left of it while they are hidden.
|
||||||
static const lineSize = 3.0;
|
static const lineSize = 3.0;
|
||||||
|
|
||||||
|
/// The played part's accent tint.
|
||||||
|
static const played = 0.4;
|
||||||
|
|
||||||
/// The position line.
|
/// The position line.
|
||||||
static const lineWidth = 2.0;
|
static const lineWidth = 2.0;
|
||||||
static const fontSize = 15.0;
|
static const fontSize = 15.0;
|
||||||
@@ -467,13 +470,10 @@ class MinimalTimelinePainter extends CustomPainter {
|
|||||||
final w = size.width, h = size.height;
|
final w = size.width, h = size.height;
|
||||||
final x = lineX(w);
|
final x = lineX(w);
|
||||||
final band = Offset.zero & size;
|
final band = Offset.zero & size;
|
||||||
// The band, darker as it grows; hidden, a faint track.
|
// The same colours shown and hidden: hidden, it is the band shrunk to
|
||||||
canvas.drawRect(band, Paint()..color = Color.lerp(const Color(0x40FFFFFF), MinimalStyle.band, visibility)!);
|
// a line, so growing and shrinking never switches colour.
|
||||||
// Played: an accent tint, solid once the band is a line.
|
canvas.drawRect(band, Paint()..color = MinimalStyle.band);
|
||||||
canvas.drawRect(
|
canvas.drawRect(Rect.fromLTRB(0, 0, x, h), Paint()..color = accent.withValues(alpha: MinimalTimelineSpec.played));
|
||||||
Rect.fromLTRB(0, 0, x, h),
|
|
||||||
Paint()..color = accent.withValues(alpha: 0.28 + 0.72 * (1 - visibility)),
|
|
||||||
);
|
|
||||||
_stripes(canvas, w, h);
|
_stripes(canvas, w, h);
|
||||||
_chapters(canvas, w, h);
|
_chapters(canvas, w, h);
|
||||||
if (visibility > 0) {
|
if (visibility > 0) {
|
||||||
@@ -500,7 +500,8 @@ class MinimalTimelinePainter extends CustomPainter {
|
|||||||
canvas.save();
|
canvas.save();
|
||||||
canvas.clipRect(Rect.fromLTRB(ax, 0, w, h));
|
canvas.clipRect(Rect.fromLTRB(ax, 0, w, h));
|
||||||
final paint = Paint()
|
final paint = Paint()
|
||||||
..color = Colors.white.withValues(alpha: 0.10 + 0.08 * (1 - visibility))
|
// Stronger on the line, where only a few pixels of each show.
|
||||||
|
..color = Colors.white.withValues(alpha: 0.12 + 0.22 * (1 - visibility))
|
||||||
..strokeWidth = 1.5 * scale;
|
..strokeWidth = 1.5 * scale;
|
||||||
for (var c = (ax / period).floorToDouble() * period - h; c <= w; c += period) {
|
for (var c = (ax / period).floorToDouble() * period - h; c <= w; c += period) {
|
||||||
canvas.drawLine(Offset(c, h), Offset(c + h, 0), paint);
|
canvas.drawLine(Offset(c, h), Offset(c + h, 0), paint);
|
||||||
@@ -509,7 +510,7 @@ class MinimalTimelinePainter extends CustomPainter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _chapters(Canvas canvas, double w, double h) {
|
void _chapters(Canvas canvas, double w, double h) {
|
||||||
final cut = Paint()..color = Colors.black.withValues(alpha: 0.55);
|
final cut = Paint()..color = Colors.black.withValues(alpha: 0.3);
|
||||||
for (final f in chapters) {
|
for (final f in chapters) {
|
||||||
if (f <= 0) continue;
|
if (f <= 0) continue;
|
||||||
final cx = w * f;
|
final cx = w * f;
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ class ControlsSnapshot {
|
|||||||
final bool hasDuration;
|
final bool hasDuration;
|
||||||
|
|
||||||
/// Watching alone (no one to mark ready).
|
/// Watching alone (no one to mark ready).
|
||||||
final bool solo;
|
final bool alone;
|
||||||
|
|
||||||
/// A link to copy (`SessionCoordinator.currentLink`).
|
/// A link to copy (`SessionCoordinator.currentLink`).
|
||||||
final bool hasLink;
|
final bool hasLink;
|
||||||
@@ -126,7 +126,7 @@ class ControlsSnapshot {
|
|||||||
this.nextIsUpNext = false,
|
this.nextIsUpNext = false,
|
||||||
this.hasChapters = false,
|
this.hasChapters = false,
|
||||||
this.hasDuration = false,
|
this.hasDuration = false,
|
||||||
this.solo = false,
|
this.alone = false,
|
||||||
this.hasLink = false,
|
this.hasLink = false,
|
||||||
this.fullscreen = false,
|
this.fullscreen = false,
|
||||||
this.chatOpen = false,
|
this.chatOpen = false,
|
||||||
@@ -167,7 +167,7 @@ List<ControlAction> menuActions(ControlsSnapshot s, Set<OptionalControl> hidden)
|
|||||||
ControlAction.skipForward => s.hasDuration,
|
ControlAction.skipForward => s.hasDuration,
|
||||||
ControlAction.chat => hidden.contains(OptionalControl.chat),
|
ControlAction.chat => hidden.contains(OptionalControl.chat),
|
||||||
ControlAction.copyLink => s.hasLink,
|
ControlAction.copyLink => s.hasLink,
|
||||||
ControlAction.markAllReady => !s.solo,
|
ControlAction.markAllReady => !s.alone,
|
||||||
_ => true,
|
_ => true,
|
||||||
})
|
})
|
||||||
a,
|
a,
|
||||||
@@ -208,7 +208,7 @@ String controlTooltip(BarControl c, ControlsSnapshot s) => switch (c) {
|
|||||||
BarControl.playPause => switch (s.play) {
|
BarControl.playPause => switch (s.play) {
|
||||||
PlayState.playing => 'Pause for everyone (Space)',
|
PlayState.playing => 'Pause for everyone (Space)',
|
||||||
PlayState.waiting => 'Ready, waiting for the others (Space: not ready)',
|
PlayState.waiting => 'Ready, waiting for the others (Space: not ready)',
|
||||||
PlayState.paused => s.solo ? 'Play (Space)' : 'Ready: play when everyone is (Space)',
|
PlayState.paused => s.alone ? 'Play (Space)' : 'Ready: play when everyone is (Space)',
|
||||||
PlayState.finished => 'Ready (Space)',
|
PlayState.finished => 'Ready (Space)',
|
||||||
},
|
},
|
||||||
BarControl.previous => 'Previous: ${s.previousLabel ?? ''} (< or Shift+P)',
|
BarControl.previous => 'Previous: ${s.previousLabel ?? ''} (< or Shift+P)',
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import '../../playback/playback_controller.dart';
|
|||||||
import '../../queue/queue_controller.dart';
|
import '../../queue/queue_controller.dart';
|
||||||
import '../../session/session_coordinator.dart';
|
import '../../session/session_coordinator.dart';
|
||||||
import '../../shaders/shader_presets.dart';
|
import '../../shaders/shader_presets.dart';
|
||||||
import '../../state/session_state.dart';
|
|
||||||
import '../../state/settings.dart';
|
import '../../state/settings.dart';
|
||||||
import '../player_settings.dart' show applyShaderChoice;
|
import '../player_settings.dart' show applyShaderChoice;
|
||||||
import '../volume.dart' as vol;
|
import '../volume.dart' as vol;
|
||||||
@@ -82,16 +81,19 @@ class PlayerControlsModel {
|
|||||||
/// Everything the layout depends on (not the position: that changes
|
/// Everything the layout depends on (not the position: that changes
|
||||||
/// four times a second and only the timeline needs it).
|
/// four times a second and only the timeline needs it).
|
||||||
late final Computed<ControlsSnapshot> snapshot = computed(() {
|
late final Computed<ControlsSnapshot> snapshot = computed(() {
|
||||||
final items = queue.items.value;
|
final queued = queue.items.value.firstOrNull;
|
||||||
|
final continues = queue.continues.value;
|
||||||
final next = coord.nextEpisode.value;
|
final next = coord.nextEpisode.value;
|
||||||
return ControlsSnapshot(
|
return ControlsSnapshot(
|
||||||
play: play.value,
|
play: play.value,
|
||||||
previousLabel: coord.previousEpisode.value?.label,
|
previousLabel: coord.previousEpisode.value?.label,
|
||||||
nextLabel: items.isNotEmpty ? items.first.title : next?.label,
|
// Up next's first item, else the next episode, else what continues
|
||||||
nextIsUpNext: items.isNotEmpty,
|
// this (a file's next episode).
|
||||||
|
nextLabel: queued?.title ?? next?.label ?? continues?.title,
|
||||||
|
nextIsUpNext: queued != null,
|
||||||
hasChapters: chapters.value.isNotEmpty,
|
hasChapters: chapters.value.isNotEmpty,
|
||||||
hasDuration: duration.value > Duration.zero,
|
hasDuration: duration.value > Duration.zero,
|
||||||
solo: SessionState.instance.solo.value,
|
alone: coord.alone.value,
|
||||||
// Not a signal: read again whenever the state changes.
|
// Not a signal: read again whenever the state changes.
|
||||||
hasLink: coord.serverState.value != null && coord.currentLink() != null,
|
hasLink: coord.serverState.value != null && coord.currentLink() != null,
|
||||||
fullscreen: fullscreen.value,
|
fullscreen: fullscreen.value,
|
||||||
@@ -122,7 +124,7 @@ class PlayerControlsModel {
|
|||||||
|
|
||||||
/// A seek bar's press or drag; through `player.seek`, the user-seek
|
/// A seek bar's press or drag; through `player.seek`, the user-seek
|
||||||
/// path, so everyone follows.
|
/// path, so everyone follows.
|
||||||
void seek(Duration to) => playback.player.seek(to);
|
void seek(Duration to) => playback.userSeek(to);
|
||||||
|
|
||||||
/// Arrow keys: ±[secs], relative to a seek still in flight.
|
/// Arrow keys: ±[secs], relative to a seek still in flight.
|
||||||
void nudge(double secs) => coord.nudge(secs);
|
void nudge(double secs) => coord.nudge(secs);
|
||||||
@@ -138,9 +140,10 @@ class PlayerControlsModel {
|
|||||||
|
|
||||||
void pickChapter(MediaChapter c) => coord.seekTo(c.start.inMicroseconds / 1e6);
|
void pickChapter(MediaChapter c) => coord.seekTo(c.start.inMicroseconds / 1e6);
|
||||||
|
|
||||||
/// Up next's first item, else the next episode. False when neither.
|
/// What plays next (Up next's first item, else the continuation), else
|
||||||
|
/// the next episode. False when neither.
|
||||||
bool next() {
|
bool next() {
|
||||||
if (queue.items.peek().isNotEmpty) {
|
if (queue.head.peek() != null) {
|
||||||
queue.skip();
|
queue.skip();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -158,7 +161,7 @@ class PlayerControlsModel {
|
|||||||
|
|
||||||
void playNextEpisode() => coord.playNextEpisode();
|
void playNextEpisode() => coord.playNextEpisode();
|
||||||
|
|
||||||
void setVolume(double v) => playback.player.setVolume(v.clamp(0.0, vol.maxVolume));
|
void setVolume(double v) => playback.setVolume(v.clamp(0.0, vol.maxVolume));
|
||||||
|
|
||||||
/// One volume step up (+1) or down (-1) from the wheel or ↑ / ↓;
|
/// One volume step up (+1) or down (-1) from the wheel or ↑ / ↓;
|
||||||
/// flashes the volume readout.
|
/// flashes the volume readout.
|
||||||
@@ -184,7 +187,7 @@ class PlayerControlsModel {
|
|||||||
|
|
||||||
/// Saved as the choice and applied live; a null quality keeps the
|
/// Saved as the choice and applied live; a null quality keeps the
|
||||||
/// saved one.
|
/// saved one.
|
||||||
void pickShader(ShaderPreset preset, ShaderQuality? quality) => applyShaderChoice(preset, quality);
|
void pickShader(ShaderPreset preset, ShaderQuality? quality) => applyShaderChoice(coord, preset, quality);
|
||||||
|
|
||||||
void toggleTimeDisplay() => settings.setTimeDisplay(settings.timeDisplay.peek().toggled);
|
void toggleTimeDisplay() => settings.setTimeDisplay(settings.timeDisplay.peek().toggled);
|
||||||
|
|
||||||
|
|||||||
@@ -6,8 +6,9 @@ import 'package:sync_relay_core/core.dart' show JellyfinStreamType;
|
|||||||
|
|
||||||
import '../jellyfin/track_prefs.dart' show MpvTrack;
|
import '../jellyfin/track_prefs.dart' show MpvTrack;
|
||||||
import '../playback/playback_controller.dart' show PlaybackController, RenderMode;
|
import '../playback/playback_controller.dart' show PlaybackController, RenderMode;
|
||||||
|
import '../session/session_coordinator.dart' show SessionCoordinator;
|
||||||
import '../shaders/shader_presets.dart';
|
import '../shaders/shader_presets.dart';
|
||||||
import '../state/session_state.dart';
|
import '../app/app_graph.dart';
|
||||||
import '../shell/mark_problem.dart';
|
import '../shell/mark_problem.dart';
|
||||||
import '../shell/motion.dart';
|
import '../shell/motion.dart';
|
||||||
import '../state/settings.dart';
|
import '../state/settings.dart';
|
||||||
@@ -200,11 +201,10 @@ class ProgressLineSetting extends StatelessWidget {
|
|||||||
|
|
||||||
/// Saves a shader choice (also used on the next join) and puts it on the
|
/// Saves a shader choice (also used on the next join) and puts it on the
|
||||||
/// running player, if any; the player announces the result.
|
/// running player, if any; the player announces the result.
|
||||||
void applyShaderChoice(ShaderPreset preset, ShaderQuality? quality) {
|
void applyShaderChoice(SessionCoordinator coord, ShaderPreset preset, ShaderQuality? quality) {
|
||||||
final s = AppSettings.instance;
|
final s = AppSettings.instance;
|
||||||
if (quality != null) s.setShaderQuality(quality);
|
if (quality != null) s.setShaderQuality(quality);
|
||||||
s.setShaderPreset(preset);
|
s.setShaderPreset(preset);
|
||||||
final coord = SessionState.instance.coordinator;
|
|
||||||
coord.markInput();
|
coord.markInput();
|
||||||
coord.playback.applyShaders(announce: true);
|
coord.playback.applyShaders(announce: true);
|
||||||
}
|
}
|
||||||
@@ -222,7 +222,9 @@ class ShaderPresetSetting extends StatelessWidget {
|
|||||||
final preset = s.shaderPreset.value;
|
final preset = s.shaderPreset.value;
|
||||||
final quality = s.shaderQuality.value;
|
final quality = s.shaderQuality.value;
|
||||||
final hasCustom = s.customShaders.value.isNotEmpty;
|
final hasCustom = s.customShaders.value.isNotEmpty;
|
||||||
final mode = (renderMode ?? PlaybackController.lastRenderMode).value;
|
final mode = (renderMode ??
|
||||||
|
context.getInheritedWidgetOfExactType<AppScope>()?.graph.ports.players.renderMode)
|
||||||
|
?.value;
|
||||||
final warning = switch (mode) {
|
final warning = switch (mode) {
|
||||||
RenderMode.software => 'Software rendering: shaders have no effect',
|
RenderMode.software => 'Software rendering: shaders have no effect',
|
||||||
RenderMode.softwareGl => 'OpenGL on the CPU: shaders will be slow',
|
RenderMode.softwareGl => 'OpenGL on the CPU: shaders will be slow',
|
||||||
@@ -242,7 +244,7 @@ class ShaderPresetSetting extends StatelessWidget {
|
|||||||
for (final p in ShaderPreset.values)
|
for (final p in ShaderPreset.values)
|
||||||
if (p != ShaderPreset.custom || hasCustom || preset == p) (p, p.label),
|
if (p != ShaderPreset.custom || hasCustom || preset == p) (p, p.label),
|
||||||
],
|
],
|
||||||
onChanged: (p) => applyShaderChoice(p, null),
|
onChanged: (p) => applyShaderChoice(AppScope.of(context).coordinator, p, null),
|
||||||
),
|
),
|
||||||
if (preset.isAnime4k) ...[
|
if (preset.isAnime4k) ...[
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
@@ -250,7 +252,7 @@ class ShaderPresetSetting extends StatelessWidget {
|
|||||||
key: const ValueKey('shader-quality'),
|
key: const ValueKey('shader-quality'),
|
||||||
value: quality,
|
value: quality,
|
||||||
items: [for (final q in ShaderQuality.values) (q, q.label)],
|
items: [for (final q in ShaderQuality.values) (q, q.label)],
|
||||||
onChanged: (q) => applyShaderChoice(preset, q),
|
onChanged: (q) => applyShaderChoice(AppScope.of(context).coordinator, preset, q),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
@@ -265,13 +267,12 @@ class ShaderPresetSetting extends StatelessWidget {
|
|||||||
|
|
||||||
/// Puts the hwdec setting on the running player (mpv switches decoders
|
/// Puts the hwdec setting on the running player (mpv switches decoders
|
||||||
/// on the fly). `SYNC_RELAY_HWDEC` wins, as at player start.
|
/// on the fly). `SYNC_RELAY_HWDEC` wins, as at player start.
|
||||||
Future<void> applyHwdecLive() async {
|
Future<void> applyHwdecLive(PlaybackController playback) async {
|
||||||
if ((Platform.environment['SYNC_RELAY_HWDEC'] ?? '').isNotEmpty) return;
|
if ((Platform.environment['SYNC_RELAY_HWDEC'] ?? '').isNotEmpty) return;
|
||||||
final playback = SessionState.instance.coordinator.playback;
|
final p = playback.current;
|
||||||
if (!playback.alive.peek()) return;
|
if (p == null) return;
|
||||||
try {
|
try {
|
||||||
final dynamic platform = playback.player.platform;
|
await p.setProperty('hwdec', AppSettings.instance.hwdec.peek());
|
||||||
await platform?.setProperty('hwdec', AppSettings.instance.hwdec.peek());
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('live hwdec failed: $e');
|
debugPrint('live hwdec failed: $e');
|
||||||
}
|
}
|
||||||
@@ -308,7 +309,7 @@ class _HwdecSettingState extends State<HwdecSetting> {
|
|||||||
items: const [(HwdecMode.auto, 'Auto'), (HwdecMode.off, 'Off'), (HwdecMode.custom, 'Custom')],
|
items: const [(HwdecMode.auto, 'Auto'), (HwdecMode.off, 'Off'), (HwdecMode.custom, 'Custom')],
|
||||||
onChanged: (m) {
|
onChanged: (m) {
|
||||||
_s.setHwdecMode(m);
|
_s.setHwdecMode(m);
|
||||||
applyHwdecLive();
|
applyHwdecLive(AppScope.of(context).coordinator.playback);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
below: mode != HwdecMode.custom
|
below: mode != HwdecMode.custom
|
||||||
@@ -324,7 +325,7 @@ class _HwdecSettingState extends State<HwdecSetting> {
|
|||||||
border: OutlineInputBorder(),
|
border: OutlineInputBorder(),
|
||||||
),
|
),
|
||||||
onChanged: _s.setHwdecCustom,
|
onChanged: _s.setHwdecCustom,
|
||||||
onSubmitted: (_) => applyHwdecLive(),
|
onSubmitted: (_) => applyHwdecLive(AppScope.of(context).coordinator.playback),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -342,7 +343,7 @@ class TrackSetting extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final coord = SessionState.instance.coordinator;
|
final coord = AppScope.of(context).coordinator;
|
||||||
return SignalBuilder(builder: (context) {
|
return SignalBuilder(builder: (context) {
|
||||||
final list = [
|
final list = [
|
||||||
for (final t in coord.playback.tracks.value)
|
for (final t in coord.playback.tracks.value)
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -1,196 +0,0 @@
|
|||||||
// Release file names -> series / season / episode, anitomy-style but small:
|
|
||||||
// enough to group a downloads folder into shows. Unknown shapes fall back to
|
|
||||||
// the cleaned name with no episode, never to a wrong number.
|
|
||||||
|
|
||||||
/// What a release file name says.
|
|
||||||
class ParsedName {
|
|
||||||
/// Title without group, season marker, episode, year or release tags.
|
|
||||||
final String series;
|
|
||||||
final int? season;
|
|
||||||
final int? episode;
|
|
||||||
/// Release group: leading `[Group]` or scene-style trailing `-GRP`.
|
|
||||||
final String? group;
|
|
||||||
/// Release year, taken out of [series] (movies, `Title (2019)`).
|
|
||||||
final int? year;
|
|
||||||
/// Non-episode marker (`NCOP1`, `NCED`, `OVA 2`, `Special`): such files
|
|
||||||
/// carry no [episode] so they never sort among the real ones.
|
|
||||||
final String? extra;
|
|
||||||
|
|
||||||
const ParsedName(
|
|
||||||
this.series, {
|
|
||||||
this.season,
|
|
||||||
this.episode,
|
|
||||||
this.group,
|
|
||||||
this.year,
|
|
||||||
this.extra,
|
|
||||||
});
|
|
||||||
|
|
||||||
/// `S01E03`, `E25`, the [extra] marker, `S02` for a season pack, else ''.
|
|
||||||
String get label {
|
|
||||||
final s = season == null ? '' : 'S${season.toString().padLeft(2, '0')}';
|
|
||||||
if (episode != null) return '${s}E${episode.toString().padLeft(2, '0')}';
|
|
||||||
return extra ?? s;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Grouping key: case, punctuation and spacing do not split a series.
|
|
||||||
String get seriesKey => normalizeSeries(series);
|
|
||||||
|
|
||||||
@override
|
|
||||||
String toString() => 'ParsedName($series, s=$season, e=$episode, '
|
|
||||||
'group=$group, year=$year, extra=$extra)';
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Lowercase letters and digits of [s], single spaces between words.
|
|
||||||
String normalizeSeries(String s) => s
|
|
||||||
.toLowerCase()
|
|
||||||
.replaceAll(RegExp(r"['’`]"), '')
|
|
||||||
.replaceAll(RegExp(r'[^\p{L}\p{N}]+', unicode: true), ' ')
|
|
||||||
.trim();
|
|
||||||
|
|
||||||
// Release tags: the title ends where the first of these starts.
|
|
||||||
final _junk = RegExp(
|
|
||||||
r'\b(?:2160p|1440p|1080[pi]|720p|576p|480p|4K|UHD|[xh]\.?26[45]|HEVC|AVC|'
|
|
||||||
r'AV1|VP9|WEB(?:-?DL|-?Rip)?|BD(?:Rip|Remux)?|Blu-?Ray|BRRip|DVD(?:Rip)?|'
|
|
||||||
r'HDTV|HDR(?:10)?|DV|REMUX|AAC(?:\d\.\d)?|FLAC|Opus|DTS(?:-HD)?|E?-?AC-?3|'
|
|
||||||
r'DDP?\d?|TrueHD|Atmos|10-?bit|8-?bit|Hi10P?|PROPER|REPACK|Dual[ -]Audio|'
|
|
||||||
r'Multi(?:ple)?[ -]Subs?(?:titles?)?|AMZN|NF|CR|DSNP|HMAX|ATVP|'
|
|
||||||
r'Batch|Complete)\b',
|
|
||||||
caseSensitive: false,
|
|
||||||
);
|
|
||||||
final _ext = RegExp(r'\.(?=[a-z0-9]*[a-z])[a-z0-9]{2,4}$', caseSensitive: false);
|
|
||||||
final _leadingGroup = RegExp(r'^\s*\[([^\]]+)\]');
|
|
||||||
final _sceneGroup = RegExp(r'-([A-Za-z0-9]+)$');
|
|
||||||
final _bracketYear = RegExp(r'[(\[]((?:19|20)\d{2})[)\]]');
|
|
||||||
final _brackets = RegExp(r'\[[^\]]*\]|\([^)]*\)|\{[^}]*\}|【[^】]*】');
|
|
||||||
// S01E02, S01.E02, S1 E2; a batch `S01E01-E02` / `S01E01E02` keeps the first.
|
|
||||||
final _sxe = RegExp(r'\bS(\d{1,2})[ .]?E(\d{1,4})(?:v\d)?(?!\d)',
|
|
||||||
caseSensitive: false);
|
|
||||||
final _nxn = RegExp(r'\b(\d{1,2})x(\d{2,3})\b');
|
|
||||||
// Anime: `Title - 25`, `- 03v2`, `- 01-02` (batch keeps the first).
|
|
||||||
final _dashNumber = RegExp(r'\s[-–]\s(\d{1,4})(?:v\d)?(?=$|[\s\-~.])');
|
|
||||||
final _episodeWord = RegExp(
|
|
||||||
r'\b(?:Episode|Ep\.?|E)\s?(\d{1,4})(?:v\d)?\b',
|
|
||||||
caseSensitive: false,
|
|
||||||
);
|
|
||||||
// Extras: unambiguous words anywhere, short ones only right after the dash.
|
|
||||||
final _extraAnywhere = RegExp(
|
|
||||||
r'(?:^|\s)(NCOP|NCED|OVA|OAD|ONA)\s?(\d{1,2})?(?:v\d)?(?=$|\s)',
|
|
||||||
caseSensitive: false,
|
|
||||||
);
|
|
||||||
final _extraAfterDash = RegExp(
|
|
||||||
r'\s[-–]\s(OP|ED|SP|Specials?|Extras?|Recap|Preview|PV|Menu)\s?(\d{1,2})?'
|
|
||||||
r'(?:v\d)?(?=$|\s)',
|
|
||||||
caseSensitive: false,
|
|
||||||
);
|
|
||||||
final _seasonInTitle = RegExp(
|
|
||||||
r'\s(?:S(\d{1,2})|Season\s?(\d{1,2})|(\d{1,2})(?:st|nd|rd|th)\s+Season)\s*$',
|
|
||||||
caseSensitive: false,
|
|
||||||
);
|
|
||||||
final _trailingYear = RegExp(r'\s((?:19|20)\d{2})$');
|
|
||||||
|
|
||||||
/// Parses a file name (with or without the extension, no directories).
|
|
||||||
ParsedName parseReleaseName(String fileName) {
|
|
||||||
var s = fileName.replaceFirst(_ext, '').trim();
|
|
||||||
final original = s;
|
|
||||||
String? group = _leadingGroup.firstMatch(s)?.group(1)?.trim();
|
|
||||||
int? year;
|
|
||||||
final by = _bracketYear.firstMatch(s);
|
|
||||||
if (by != null) year = int.parse(by.group(1)!);
|
|
||||||
s = s.replaceAll(_brackets, ' ');
|
|
||||||
|
|
||||||
// Dots and underscores as separators (scene style), unless the name
|
|
||||||
// already has spaces and only the odd `Dr. Stone` dot.
|
|
||||||
final dotted = !s.contains(' ') || RegExp(r'\w\.\w').allMatches(s).length > 2;
|
|
||||||
if (dotted) {
|
|
||||||
// `...H264-GRP`, but not `...WEB-DL`.
|
|
||||||
final tags = _junk.allMatches(s);
|
|
||||||
if (group == null && tags.isNotEmpty && tags.last.end < s.length) {
|
|
||||||
final g = _sceneGroup.firstMatch(s);
|
|
||||||
if (g != null) {
|
|
||||||
group = g.group(1);
|
|
||||||
s = s.substring(0, g.start);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
s = s.replaceAll('.', ' ');
|
|
||||||
}
|
|
||||||
s = s.replaceAll('_', ' ').replaceAll(RegExp(r'\s+'), ' ').trim();
|
|
||||||
|
|
||||||
// Release tags end the interesting part.
|
|
||||||
final junk = _junk.firstMatch(s);
|
|
||||||
if (junk != null && junk.start > 0) s = s.substring(0, junk.start).trim();
|
|
||||||
|
|
||||||
int? season, episode;
|
|
||||||
String? extra;
|
|
||||||
var cut = s.length;
|
|
||||||
final extraMatch =
|
|
||||||
_extraAfterDash.firstMatch(s) ?? _extraAnywhere.firstMatch(s);
|
|
||||||
final sxe = _sxe.firstMatch(s);
|
|
||||||
if (extraMatch != null) {
|
|
||||||
final word = extraMatch.group(1)!;
|
|
||||||
final upper = word.length <= 4 ? word.toUpperCase() : _title(word);
|
|
||||||
// `OVA - 02`: the number may follow the dash instead.
|
|
||||||
final n = extraMatch.group(2) ??
|
|
||||||
_dashNumber.firstMatch(s.substring(extraMatch.end))?.group(1);
|
|
||||||
extra = n == null
|
|
||||||
? upper
|
|
||||||
: (upper.startsWith('NC') || upper.length <= 2)
|
|
||||||
? '$upper${int.parse(n)}'
|
|
||||||
: '$upper ${int.parse(n)}';
|
|
||||||
cut = extraMatch.start;
|
|
||||||
if (sxe != null && sxe.start < cut) {
|
|
||||||
season = int.parse(sxe.group(1)!);
|
|
||||||
cut = sxe.start;
|
|
||||||
}
|
|
||||||
} else if (sxe != null) {
|
|
||||||
season = int.parse(sxe.group(1)!);
|
|
||||||
episode = int.parse(sxe.group(2)!);
|
|
||||||
cut = sxe.start;
|
|
||||||
} else {
|
|
||||||
final nxn = _nxn.firstMatch(s);
|
|
||||||
if (nxn != null) {
|
|
||||||
season = int.parse(nxn.group(1)!);
|
|
||||||
episode = int.parse(nxn.group(2)!);
|
|
||||||
cut = nxn.start;
|
|
||||||
} else {
|
|
||||||
final m = _dashNumber.firstMatch(s) ?? _episodeWord.firstMatch(s);
|
|
||||||
if (m != null) {
|
|
||||||
episode = int.parse(m.group(1)!);
|
|
||||||
cut = m.start;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var series = s.substring(0, cut);
|
|
||||||
// Season pack or season marker kept in the title: `Title S2`,
|
|
||||||
// `Title Season 2`, `Title 2nd Season`, `Title.S01.1080p`.
|
|
||||||
final sIn = _seasonInTitle.firstMatch(series);
|
|
||||||
if (sIn != null) {
|
|
||||||
season ??= int.parse(sIn.group(1) ?? sIn.group(2) ?? sIn.group(3)!);
|
|
||||||
series = series.substring(0, sIn.start);
|
|
||||||
}
|
|
||||||
series = series.replaceAll(RegExp(r'[\s\-–:,~]+$'), '').trim();
|
|
||||||
// A trailing year is the release year, unless it is the whole title or
|
|
||||||
// too far ahead to be one (`Blade Runner 2049`).
|
|
||||||
final ty = _trailingYear.firstMatch(series);
|
|
||||||
if (ty != null) {
|
|
||||||
final y = int.parse(ty.group(1)!);
|
|
||||||
if (y <= DateTime.now().year + 1) {
|
|
||||||
year ??= y;
|
|
||||||
series = series.substring(0, ty.start).trim();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
series = series.replaceAll(RegExp(r'[\s\-–:,~]+$'), '').trim();
|
|
||||||
if (series.isEmpty) {
|
|
||||||
series = original.replaceAll(RegExp(r'[._]+'), ' ').trim();
|
|
||||||
}
|
|
||||||
return ParsedName(
|
|
||||||
series,
|
|
||||||
season: season,
|
|
||||||
episode: episode,
|
|
||||||
group: group,
|
|
||||||
year: year,
|
|
||||||
extra: extra,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
String _title(String w) => w[0].toUpperCase() + w.substring(1).toLowerCase();
|
|
||||||
@@ -1,16 +1,13 @@
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'dart:isolate';
|
import 'dart:isolate';
|
||||||
|
|
||||||
import 'name_parser.dart';
|
import 'package:sync_relay_core/core.dart';
|
||||||
|
|
||||||
// Folder scan for the Files source: videos with their parsed names,
|
export 'package:sync_relay_core/core.dart' show FileKind, LocalFile, kindOf, subtitleExtensions, videoExtensions;
|
||||||
// sidecar subtitles, everything else counted and hidden. The walk is
|
|
||||||
// synchronous I/O, so it runs in its own isolate.
|
|
||||||
|
|
||||||
const videoExtensions = {
|
// Folder scan for the Files source: the videos (grouped into shows by
|
||||||
'mkv', 'mp4', 'webm', 'avi', 'mov', 'm4v', 'ts', 'm2ts', 'wmv', 'flv', //
|
// core's `groupLocal`, L1), sidecar subtitles, everything else counted
|
||||||
};
|
// and hidden. The walk is synchronous I/O, so it runs in its own isolate.
|
||||||
const subtitleExtensions = {'ass', 'ssa', 'srt', 'vtt', 'sup'};
|
|
||||||
|
|
||||||
/// Files looked at per scan; past this the result is [FolderScan.truncated].
|
/// Files looked at per scan; past this the result is [FolderScan.truncated].
|
||||||
const maxScannedFiles = 20000;
|
const maxScannedFiles = 20000;
|
||||||
@@ -18,27 +15,8 @@ const maxScannedFiles = 20000;
|
|||||||
/// Samples and trailers under this size are hidden next to the real thing.
|
/// Samples and trailers under this size are hidden next to the real thing.
|
||||||
const _sampleMaxBytes = 300 << 20;
|
const _sampleMaxBytes = 300 << 20;
|
||||||
|
|
||||||
enum FileKind { video, subtitle, other }
|
|
||||||
|
|
||||||
FileKind kindOf(String path) {
|
|
||||||
final ext = _extension(_baseName(path));
|
|
||||||
if (videoExtensions.contains(ext)) return FileKind.video;
|
|
||||||
if (subtitleExtensions.contains(ext)) return FileKind.subtitle;
|
|
||||||
return FileKind.other;
|
|
||||||
}
|
|
||||||
|
|
||||||
class VideoFile {
|
|
||||||
final String path;
|
|
||||||
final int size;
|
|
||||||
final ParsedName parsed;
|
|
||||||
final DateTime modified;
|
|
||||||
const VideoFile(this.path, this.size, this.parsed, this.modified);
|
|
||||||
|
|
||||||
String get name => _baseName(path);
|
|
||||||
}
|
|
||||||
|
|
||||||
class FolderScan {
|
class FolderScan {
|
||||||
final List<VideoFile> videos;
|
final List<LocalFile> videos;
|
||||||
/// Non-video files and samples (subtitles and dot-entries not counted).
|
/// Non-video files and samples (subtitles and dot-entries not counted).
|
||||||
final int hiddenCount;
|
final int hiddenCount;
|
||||||
/// Up to 6 of the hidden file names, for "and N other files" hints.
|
/// Up to 6 of the hidden file names, for "and N other files" hints.
|
||||||
@@ -75,7 +53,7 @@ Future<FolderScan> scanFolder(String dir, {int maxFiles = maxScannedFiles}) =>
|
|||||||
|
|
||||||
/// The walk behind [scanFolder]; blocks, so call it from an isolate.
|
/// The walk behind [scanFolder]; blocks, so call it from an isolate.
|
||||||
FolderScan scanFolderSync(String dir, {int maxFiles = maxScannedFiles}) {
|
FolderScan scanFolderSync(String dir, {int maxFiles = maxScannedFiles}) {
|
||||||
final videos = <VideoFile>[];
|
final videos = <LocalFile>[];
|
||||||
final subs = <String>[];
|
final subs = <String>[];
|
||||||
final examples = <String>[];
|
final examples = <String>[];
|
||||||
var hidden = 0, seen = 0;
|
var hidden = 0, seen = 0;
|
||||||
@@ -123,8 +101,7 @@ FolderScan scanFolderSync(String dir, {int maxFiles = maxScannedFiles}) {
|
|||||||
if (examples.length < 6) examples.add(name);
|
if (examples.length < 6) examples.add(name);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
videos.add(VideoFile(e.path, size, parseReleaseName(name),
|
videos.add(LocalFile(e.path, size, stat?.modified ?? DateTime.fromMillisecondsSinceEpoch(0)));
|
||||||
stat?.modified ?? DateTime.fromMillisecondsSinceEpoch(0)));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return FolderScan(
|
return FolderScan(
|
||||||
@@ -138,7 +115,7 @@ FolderScan scanFolderSync(String dir, {int maxFiles = maxScannedFiles}) {
|
|||||||
|
|
||||||
/// Subtitles in the same folder whose name is the video's base name,
|
/// Subtitles in the same folder whose name is the video's base name,
|
||||||
/// optionally followed by `.lang` / `.forced` style suffixes.
|
/// optionally followed by `.lang` / `.forced` style suffixes.
|
||||||
Map<String, List<String>> _sidecars(List<VideoFile> videos, List<String> subs) {
|
Map<String, List<String>> _sidecars(List<LocalFile> videos, List<String> subs) {
|
||||||
final byStem = <String, List<String>>{};
|
final byStem = <String, List<String>>{};
|
||||||
for (final s in subs) {
|
for (final s in subs) {
|
||||||
final stem = _stripExtension(s);
|
final stem = _stripExtension(s);
|
||||||
@@ -163,93 +140,11 @@ Map<String, List<String>> _sidecars(List<VideoFile> videos, List<String> subs) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One show: its episodes in season/episode order plus its extras
|
|
||||||
/// (NCOP, OVA...) in name order.
|
|
||||||
class SeriesGroup {
|
|
||||||
/// Display title: the most common spelling among the files.
|
|
||||||
final String title;
|
|
||||||
final String key;
|
|
||||||
final List<VideoFile> episodes;
|
|
||||||
final List<VideoFile> extras;
|
|
||||||
const SeriesGroup(this.title, this.key, this.episodes, this.extras);
|
|
||||||
|
|
||||||
/// Distinct seasons, ascending; files without one count as season 1.
|
|
||||||
List<int> get seasons =>
|
|
||||||
({for (final e in episodes) e.parsed.season ?? 1}.toList()..sort());
|
|
||||||
}
|
|
||||||
|
|
||||||
/// [groupBySeries] result: shows, and videos that are not episodes.
|
|
||||||
class GroupedVideos {
|
|
||||||
final List<SeriesGroup> series;
|
|
||||||
final List<VideoFile> loose;
|
|
||||||
const GroupedVideos(this.series, this.loose);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Groups videos with an episode number (or an extra marker) by
|
|
||||||
/// [ParsedName.seriesKey]; the rest (movies, clips) stay loose, by name.
|
|
||||||
/// An extra whose series has no episodes in the list is loose too.
|
|
||||||
GroupedVideos groupBySeries(Iterable<VideoFile> videos) {
|
|
||||||
final episodes = <String, List<VideoFile>>{};
|
|
||||||
final extras = <String, List<VideoFile>>{};
|
|
||||||
final loose = <VideoFile>[];
|
|
||||||
for (final v in videos) {
|
|
||||||
final p = v.parsed;
|
|
||||||
final key = p.seriesKey;
|
|
||||||
if (p.episode != null && key.isNotEmpty) {
|
|
||||||
episodes.putIfAbsent(key, () => []).add(v);
|
|
||||||
} else if (p.extra != null && key.isNotEmpty) {
|
|
||||||
extras.putIfAbsent(key, () => []).add(v);
|
|
||||||
} else {
|
|
||||||
loose.add(v);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (final e in extras.entries) {
|
|
||||||
if (!episodes.containsKey(e.key)) loose.addAll(e.value);
|
|
||||||
}
|
|
||||||
int byName(VideoFile a, VideoFile b) =>
|
|
||||||
a.name.toLowerCase().compareTo(b.name.toLowerCase());
|
|
||||||
final groups = [
|
|
||||||
for (final e in episodes.entries)
|
|
||||||
SeriesGroup(
|
|
||||||
_commonTitle(e.value),
|
|
||||||
e.key,
|
|
||||||
e.value..sort(_byEpisode),
|
|
||||||
(extras[e.key] ?? [])..sort(byName),
|
|
||||||
),
|
|
||||||
]..sort((a, b) => a.title.toLowerCase().compareTo(b.title.toLowerCase()));
|
|
||||||
return GroupedVideos(groups, loose..sort(byName));
|
|
||||||
}
|
|
||||||
|
|
||||||
int _byEpisode(VideoFile a, VideoFile b) {
|
|
||||||
final pa = a.parsed, pb = b.parsed;
|
|
||||||
final s = (pa.season ?? 1).compareTo(pb.season ?? 1);
|
|
||||||
if (s != 0) return s;
|
|
||||||
final e = pa.episode!.compareTo(pb.episode!);
|
|
||||||
if (e != 0) return e;
|
|
||||||
// Same episode twice (v2, another group): newest release first.
|
|
||||||
return b.name.compareTo(a.name);
|
|
||||||
}
|
|
||||||
|
|
||||||
String _commonTitle(List<VideoFile> files) {
|
|
||||||
final counts = <String, int>{};
|
|
||||||
for (final f in files) {
|
|
||||||
counts.update(f.parsed.series, (n) => n + 1, ifAbsent: () => 1);
|
|
||||||
}
|
|
||||||
return counts.entries
|
|
||||||
.reduce((a, b) => b.value > a.value ? b : a)
|
|
||||||
.key;
|
|
||||||
}
|
|
||||||
|
|
||||||
String _baseName(String path) {
|
String _baseName(String path) {
|
||||||
final i = path.lastIndexOf(RegExp(r'[/\\]'));
|
final i = path.lastIndexOf(RegExp(r'[/\\]'));
|
||||||
return i < 0 ? path : path.substring(i + 1);
|
return i < 0 ? path : path.substring(i + 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
String _extension(String name) {
|
|
||||||
final dot = name.lastIndexOf('.');
|
|
||||||
return dot <= 0 ? '' : name.substring(dot + 1).toLowerCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
String _stripExtension(String path) {
|
String _stripExtension(String path) {
|
||||||
final dot = path.lastIndexOf('.');
|
final dot = path.lastIndexOf('.');
|
||||||
final sep = path.lastIndexOf(RegExp(r'[/\\]'));
|
final sep = path.lastIndexOf(RegExp(r'[/\\]'));
|
||||||
|
|||||||
@@ -4,9 +4,11 @@ import 'dart:io';
|
|||||||
|
|
||||||
import 'package:crypto/crypto.dart';
|
import 'package:crypto/crypto.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:media_kit/media_kit.dart';
|
|
||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
|
||||||
|
import '../platform/desktop/media_kit_host.dart' show MediaKitHost;
|
||||||
|
import '../platform/ports/player_host.dart';
|
||||||
|
|
||||||
// Poster frames for the Files source, made by mpv itself (no ffmpeg on
|
// Poster frames for the Files source, made by mpv itself (no ffmpeg on
|
||||||
// users' machines; Windows ships only libmpv). A hidden player per file
|
// users' machines; Windows ships only libmpv). A hidden player per file
|
||||||
// with `vo=null`: mpv still keeps the last decoded frame, so
|
// with `vo=null`: mpv still keeps the last decoded frame, so
|
||||||
@@ -23,6 +25,9 @@ class Thumbnail {
|
|||||||
|
|
||||||
const thumbnailWidth = 480;
|
const thumbnailWidth = 480;
|
||||||
|
|
||||||
|
/// The players thumbnails are made with (the app's host, set in `main`).
|
||||||
|
PlayerHost thumbnailPlayers = MediaKitHost()..init();
|
||||||
|
|
||||||
/// Where thumbnails are cached; swapped in tests.
|
/// Where thumbnails are cached; swapped in tests.
|
||||||
Future<Directory> Function() thumbnailCacheRoot = getApplicationCacheDirectory;
|
Future<Directory> Function() thumbnailCacheRoot = getApplicationCacheDirectory;
|
||||||
|
|
||||||
@@ -33,7 +38,7 @@ const _timeout = Duration(seconds: 20);
|
|||||||
int _running = 0;
|
int _running = 0;
|
||||||
final _waiting = <Completer<void>>[];
|
final _waiting = <Completer<void>>[];
|
||||||
final _inFlight = <String, Future<Thumbnail?>>{};
|
final _inFlight = <String, Future<Thumbnail?>>{};
|
||||||
final _players = <Player>{};
|
final _players = <HostedPlayer>{};
|
||||||
bool _closed = false;
|
bool _closed = false;
|
||||||
|
|
||||||
/// App close: no new thumbnails, running ones stop at their next check
|
/// App close: no new thumbnails, running ones stop at their next check
|
||||||
@@ -49,7 +54,7 @@ Future<void> cancelAllThumbnails({
|
|||||||
for (final p in _players.toList()) {
|
for (final p in _players.toList()) {
|
||||||
_players.remove(p);
|
_players.remove(p);
|
||||||
try {
|
try {
|
||||||
await p.dispose().timeout(deadline);
|
await thumbnailPlayers.dispose(p).timeout(deadline);
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -147,19 +152,10 @@ Future<Thumbnail?> _generate(
|
|||||||
String out,
|
String out,
|
||||||
bool Function() cancelled,
|
bool Function() cancelled,
|
||||||
) async {
|
) async {
|
||||||
final player = Player(
|
final player = await thumbnailPlayers.create(PlayerRole.thumbnail);
|
||||||
configuration: const PlayerConfiguration(
|
|
||||||
vo: 'null',
|
|
||||||
muted: true,
|
|
||||||
osc: false,
|
|
||||||
libass: false,
|
|
||||||
title: 'thumbnail',
|
|
||||||
bufferSize: 4 << 20,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
_players.add(player);
|
_players.add(player);
|
||||||
try {
|
try {
|
||||||
final mpv = player.platform as NativePlayer;
|
final mpv = player;
|
||||||
for (final (k, v) in [
|
for (final (k, v) in [
|
||||||
('ao', 'null'),
|
('ao', 'null'),
|
||||||
('aid', 'no'),
|
('aid', 'no'),
|
||||||
@@ -176,7 +172,7 @@ Future<Thumbnail?> _generate(
|
|||||||
]) {
|
]) {
|
||||||
await mpv.setProperty(k, v);
|
await mpv.setProperty(k, v);
|
||||||
}
|
}
|
||||||
await player.open(Media(path), play: false);
|
await player.open(path);
|
||||||
final target = File(out);
|
final target = File(out);
|
||||||
final deadline = DateTime.now().add(_timeout);
|
final deadline = DateTime.now().add(_timeout);
|
||||||
while (DateTime.now().isBefore(deadline)) {
|
while (DateTime.now().isBefore(deadline)) {
|
||||||
@@ -199,6 +195,6 @@ Future<Thumbnail?> _generate(
|
|||||||
return null;
|
return null;
|
||||||
} finally {
|
} finally {
|
||||||
// [cancelAllThumbnails] may have taken it already.
|
// [cancelAllThumbnails] may have taken it already.
|
||||||
if (_players.remove(player)) await player.dispose();
|
if (_players.remove(player)) await thumbnailPlayers.dispose(player);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2,13 +2,13 @@ import 'dart:io';
|
|||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'package:sync_relay_core/core.dart';
|
||||||
|
|
||||||
import '../../shell/avatar.dart';
|
import '../../shell/avatar.dart';
|
||||||
import '../name_parser.dart';
|
|
||||||
import '../scanner.dart';
|
|
||||||
import 'thumb.dart';
|
import 'thumb.dart';
|
||||||
|
|
||||||
// The Files page's rows and cards: a series header, an episode row (also
|
// The Files page's rows and cards: an episode row (also used for Recent),
|
||||||
// used for Recent), a loose-video card, and the empty states.
|
// a loose-video card, and the empty states.
|
||||||
|
|
||||||
/// What a play control is doing right now.
|
/// What a play control is doing right now.
|
||||||
enum PlayState { idle, starting, playing }
|
enum PlayState { idle, starting, playing }
|
||||||
@@ -25,76 +25,8 @@ String parentName(String path) => baseName(File(path).parent.path);
|
|||||||
/// Cleaned title of a loose video: `Title (2019)`.
|
/// Cleaned title of a loose video: `Title (2019)`.
|
||||||
String cleanTitle(ParsedName p) => p.year == null ? p.series : '${p.series} (${p.year})';
|
String cleanTitle(ParsedName p) => p.year == null ? p.series : '${p.series} (${p.year})';
|
||||||
|
|
||||||
class SeriesHeader extends StatelessWidget {
|
/// One file as a row: an episode on a show's page, or a Recent entry
|
||||||
final SeriesGroup group;
|
/// ([indent] off).
|
||||||
final ThumbCache thumbs;
|
|
||||||
/// Its episodes are listed below; [onTap] opens or closes them.
|
|
||||||
final bool expanded;
|
|
||||||
final VoidCallback? onTap;
|
|
||||||
const SeriesHeader(
|
|
||||||
{super.key, required this.group, required this.thumbs, this.expanded = true, this.onTap});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final scheme = Theme.of(context).colorScheme;
|
|
||||||
final first = group.episodes.first;
|
|
||||||
final n = group.episodes.length;
|
|
||||||
final extras = group.extras.length;
|
|
||||||
final detail = [
|
|
||||||
'$n ${n == 1 ? 'episode' : 'episodes'}',
|
|
||||||
if (extras > 0) '$extras ${extras == 1 ? 'extra' : 'extras'}',
|
|
||||||
?first.parsed.group,
|
|
||||||
].join(' · ');
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.only(top: 14),
|
|
||||||
child: InkWell(
|
|
||||||
onTap: onTap,
|
|
||||||
borderRadius: BorderRadius.circular(10),
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
ClipRRect(
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
child: ThumbBuilder(
|
|
||||||
cache: thumbs,
|
|
||||||
path: first.path,
|
|
||||||
builder: (context, t, _) => ThumbImage(t, width: 96, height: 54),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(group.title,
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: Theme.of(context).textTheme.titleMedium),
|
|
||||||
Text(detail,
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (onTap != null)
|
|
||||||
AnimatedRotation(
|
|
||||||
turns: expanded ? 0.5 : 0,
|
|
||||||
duration: const Duration(milliseconds: 200),
|
|
||||||
child: Icon(Icons.expand_more, color: scheme.onSurfaceVariant),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// One file as a row: an episode under its series header, or a Recent
|
|
||||||
/// entry ([title] given, [indent] off).
|
|
||||||
class FileRow extends StatelessWidget {
|
class FileRow extends StatelessWidget {
|
||||||
final String path;
|
final String path;
|
||||||
final String title;
|
final String title;
|
||||||
@@ -207,7 +139,7 @@ const videoCardTextHeight = 66.0;
|
|||||||
/// A loose video (movie, clip): 16:9 frame with its duration, then the
|
/// A loose video (movie, clip): 16:9 frame with its duration, then the
|
||||||
/// cleaned title and size.
|
/// cleaned title and size.
|
||||||
class VideoCard extends StatelessWidget {
|
class VideoCard extends StatelessWidget {
|
||||||
final VideoFile video;
|
final LocalEpisode video;
|
||||||
final PlayState state;
|
final PlayState state;
|
||||||
final ThumbCache thumbs;
|
final ThumbCache thumbs;
|
||||||
final VoidCallback? onTap;
|
final VoidCallback? onTap;
|
||||||
@@ -281,7 +213,7 @@ class VideoCard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Text(formatSize(video.size),
|
Text(formatSize(video.file.size),
|
||||||
style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12)),
|
style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12)),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -3,57 +3,63 @@ 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';
|
||||||
|
|
||||||
|
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 '../../state/session_state.dart';
|
import '../../app/app_graph.dart';
|
||||||
|
import '../../watch/watch_history.dart';
|
||||||
import '../../widgets/app_menu.dart';
|
import '../../widgets/app_menu.dart';
|
||||||
import '../folder_store.dart';
|
import '../folder_store.dart';
|
||||||
import '../name_parser.dart';
|
|
||||||
import '../scanner.dart';
|
import '../scanner.dart';
|
||||||
import 'file_tiles.dart';
|
import 'file_tiles.dart';
|
||||||
|
import 'library_view.dart';
|
||||||
import 'thumb.dart';
|
import 'thumb.dart';
|
||||||
|
|
||||||
export 'thumb.dart' show ThumbnailLoader;
|
export 'thumb.dart' show ThumbnailLoader;
|
||||||
|
|
||||||
// The Files source: watched folders on the left (chips on top when
|
// The Files source as a library: the watched folders on the left (chips
|
||||||
// narrow), the open one on the right grouped into series and loose
|
// on top when narrow): the Library (every folder together), Recent, then
|
||||||
// videos, or Recent. A folder is scanned when first opened and on Rescan;
|
// each folder. A library is shows and movies as poster cards, grouped by
|
||||||
// scans are kept for the app session. Series start collapsed; the ones
|
// the library's L1 and named by its L2 match, plus the videos that are
|
||||||
// opened stay open for the app session. Playing goes through [onPlay].
|
// neither; a show opens its own page. Folders are scanned when first
|
||||||
|
// needed and on Rescan; scans, the open place and the open show are kept
|
||||||
|
// for the app session. Playing goes through [onPlay].
|
||||||
|
|
||||||
/// Below this width the sidebar turns into chips above the list.
|
/// Below this width the sidebar turns into chips above the list.
|
||||||
const filesNarrowWidth = 700.0;
|
const filesNarrowWidth = 700.0;
|
||||||
|
|
||||||
|
/// The place that shows every watched folder together.
|
||||||
|
const libraryPlace = '\u0000library';
|
||||||
|
|
||||||
/// Scans kept for the app session, by folder.
|
/// Scans kept for the app session, by folder.
|
||||||
final _scans = <String, FolderScan>{};
|
final _scans = <String, FolderScan>{};
|
||||||
final _running = <String, Future<FolderScan>>{};
|
final _running = <String, Future<FolderScan>>{};
|
||||||
String? _lastOpen;
|
String? _lastOpen = libraryPlace;
|
||||||
/// Series opened this session, by `folder\nseries key`.
|
/// The show open in each place (by show key) for the app session.
|
||||||
final _expanded = <String>{};
|
final _openShows = <String, String>{};
|
||||||
final _showRequests = ValueNotifier(0);
|
final _showRequests = ValueNotifier(0);
|
||||||
|
|
||||||
FolderAccess get _folders => SessionState.instance.ports.folders;
|
/// Opens [dir] (null: Recent; [libraryPlace]: the library) in the Files
|
||||||
|
/// page: now if one is showing, else when it next opens.
|
||||||
/// Opens [dir] (null: Recent) in the Files page: now if one is showing,
|
|
||||||
/// else when it next opens.
|
|
||||||
void showFilesFolder(String? dir) {
|
void showFilesFolder(String? dir) {
|
||||||
_lastOpen = dir;
|
_lastOpen = dir;
|
||||||
_showRequests.value++;
|
_showRequests.value++;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Forgets the session's scans, open series and the open folder.
|
/// Forgets the session's scans, open shows and the open place.
|
||||||
@visibleForTesting
|
@visibleForTesting
|
||||||
void resetFilesPageSession() {
|
void resetFilesPageSession() {
|
||||||
_scans.clear();
|
_scans.clear();
|
||||||
_running.clear();
|
_running.clear();
|
||||||
_expanded.clear();
|
_openShows.clear();
|
||||||
_lastOpen = null;
|
_lastOpen = libraryPlace;
|
||||||
}
|
}
|
||||||
|
|
||||||
class FilesPage extends StatefulWidget {
|
class FilesPage extends StatefulWidget {
|
||||||
/// Nobody else watches (offline or solo): no relay notice.
|
/// Nobody else is in the server: no relay notice.
|
||||||
final bool solo;
|
final bool alone;
|
||||||
/// The file will stream from this computer through the server.
|
/// The file will stream from this computer through the server.
|
||||||
final bool relayed;
|
final bool relayed;
|
||||||
/// Plays [path] in the session; returns an error text, or null.
|
/// Plays [path] in the session; returns an error text, or null.
|
||||||
@@ -67,13 +73,15 @@ class FilesPage extends StatefulWidget {
|
|||||||
final Future<FolderScan> Function(String dir)? scan;
|
final Future<FolderScan> Function(String dir)? scan;
|
||||||
/// Defaults to [FolderStore.instance].
|
/// Defaults to [FolderStore.instance].
|
||||||
final FolderStore? store;
|
final FolderStore? store;
|
||||||
|
/// Defaults to the app's (`AppGraph.library`).
|
||||||
|
final ShowMatcher? matcher;
|
||||||
/// Default: the system folder / file dialogs.
|
/// Default: the system folder / file dialogs.
|
||||||
final Future<String?> Function()? pickFolder;
|
final Future<String?> Function()? pickFolder;
|
||||||
final Future<String?> Function()? pickVideo;
|
final Future<String?> Function()? pickVideo;
|
||||||
|
|
||||||
const FilesPage({
|
const FilesPage({
|
||||||
super.key,
|
super.key,
|
||||||
this.solo = false,
|
this.alone = false,
|
||||||
required this.relayed,
|
required this.relayed,
|
||||||
required this.onPlay,
|
required this.onPlay,
|
||||||
this.onAddToQueue,
|
this.onAddToQueue,
|
||||||
@@ -81,6 +89,7 @@ class FilesPage extends StatefulWidget {
|
|||||||
this.thumbnails,
|
this.thumbnails,
|
||||||
this.scan,
|
this.scan,
|
||||||
this.store,
|
this.store,
|
||||||
|
this.matcher,
|
||||||
this.pickFolder,
|
this.pickFolder,
|
||||||
this.pickVideo,
|
this.pickVideo,
|
||||||
});
|
});
|
||||||
@@ -89,50 +98,57 @@ class FilesPage extends StatefulWidget {
|
|||||||
State<FilesPage> createState() => _FilesPageState();
|
State<FilesPage> createState() => _FilesPageState();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A scan grouped for display, made once per scan.
|
/// A place's scans grouped (L1), made once per set of scans.
|
||||||
class _Library {
|
class _Library {
|
||||||
final GroupedVideos grouped;
|
final List<FolderScan> scans;
|
||||||
final int subtitleFiles;
|
final LocalLibrary lib;
|
||||||
|
final Map<String, LocalEpisode> byPath;
|
||||||
|
|
||||||
_Library(FolderScan scan)
|
_Library(this.scans, List<String> roots)
|
||||||
: grouped = groupBySeries(scan.videos),
|
: lib = groupLocal([for (final s in scans) ...s.videos], roots: roots),
|
||||||
subtitleFiles = {for (final l in scan.subtitles.values) ...l}.length;
|
byPath = {} {
|
||||||
|
for (final e in lib.files) {
|
||||||
|
byPath[e.path] = e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Series rows in order: a header (video null), then, for the series
|
int get videos => scans.fold(0, (n, s) => n + s.videos.length);
|
||||||
/// [open] says, its episodes and extras.
|
int get hidden => scans.fold(0, (n, s) => n + s.hiddenCount);
|
||||||
List<(SeriesGroup, VideoFile?)> rows(bool Function(SeriesGroup g) open) => [
|
int get subtitleFiles => {
|
||||||
for (final g in grouped.series) ...[
|
for (final s in scans)
|
||||||
(g, null),
|
for (final l in s.subtitles.values) ...l
|
||||||
if (open(g))
|
}.length;
|
||||||
for (final v in [...g.episodes, ...g.extras]) (g, v),
|
bool get truncated => scans.any((s) => s.truncated);
|
||||||
],
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
final _libraries = Expando<_Library>();
|
|
||||||
|
|
||||||
class _FilesPageState extends State<FilesPage> {
|
class _FilesPageState extends State<FilesPage> {
|
||||||
|
FolderAccess get _folders => AppScope.of(context).ports.folders;
|
||||||
|
ShowMatcher get _matcher => widget.matcher ?? AppScope.of(context).library;
|
||||||
|
|
||||||
late final ThumbCache _thumbs =
|
late final ThumbCache _thumbs =
|
||||||
widget.thumbnails == null ? ThumbCache.shared : ThumbCache(widget.thumbnails!);
|
widget.thumbnails == null ? ThumbCache.shared : ThumbCache(widget.thumbnails!);
|
||||||
FolderStore get _store => widget.store ?? FolderStore.instance;
|
FolderStore get _store => widget.store ?? FolderStore.instance;
|
||||||
|
WatchHistory get _history => AppScope.of(context).watch.history;
|
||||||
|
|
||||||
/// Null: Recent.
|
/// Null: Recent; [libraryPlace]: every folder.
|
||||||
String? _open = _lastOpen;
|
String? _open = _lastOpen;
|
||||||
bool _showHidden = false;
|
bool _showHidden = false;
|
||||||
String? _starting;
|
String? _starting;
|
||||||
String? _error;
|
String? _error;
|
||||||
|
final _libs = <String, _Library>{};
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_showRequests.addListener(_shown);
|
_showRequests.addListener(_shown);
|
||||||
|
unawaited(_matcher.ensureLoaded());
|
||||||
_store.ensureLoaded().then((_) {
|
_store.ensureLoaded().then((_) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
final open = _open;
|
final open = _open;
|
||||||
if (open != null && !_store.folders.value.contains(open)) {
|
if (open != null && open != libraryPlace && !_store.folders.value.contains(open)) {
|
||||||
_select(null);
|
_select(libraryPlace);
|
||||||
} else if (open != null) {
|
} else {
|
||||||
_scan(open);
|
_scanFor(open);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -147,19 +163,32 @@ class _FilesPageState extends State<FilesPage> {
|
|||||||
|
|
||||||
Future<FolderScan> _runScan(String dir) => (widget.scan ?? _folders.scan)(dir);
|
Future<FolderScan> _runScan(String dir) => (widget.scan ?? _folders.scan)(dir);
|
||||||
|
|
||||||
void _select(String? dir) {
|
/// The folders a place needs scanned.
|
||||||
|
List<String> _foldersOf(String? place) => place == null
|
||||||
|
? const []
|
||||||
|
: place == libraryPlace
|
||||||
|
? _store.folders.peek()
|
||||||
|
: [place];
|
||||||
|
|
||||||
|
void _scanFor(String? place) {
|
||||||
|
for (final f in _foldersOf(place)) {
|
||||||
|
if (!_scans.containsKey(f) && !_running.containsKey(f)) _scan(f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _select(String? place) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_open = _lastOpen = dir;
|
_open = _lastOpen = place;
|
||||||
_showHidden = false;
|
_showHidden = false;
|
||||||
});
|
});
|
||||||
if (dir != null && !_scans.containsKey(dir)) _scan(dir);
|
_scanFor(place);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _scan(String dir, {bool rescan = false}) async {
|
Future<void> _scan(String dir, {bool rescan = false}) async {
|
||||||
var f = _running[dir];
|
var f = _running[dir];
|
||||||
if (f == null || rescan) {
|
if (f == null || rescan) {
|
||||||
f = _running[dir] = _runScan(dir);
|
f = _running[dir] = _runScan(dir);
|
||||||
if (rescan) _thumbs.forget([for (final v in _scans[dir]?.videos ?? const <VideoFile>[]) v.path]);
|
if (rescan) _thumbs.forget([for (final v in _scans[dir]?.videos ?? const <LocalFile>[]) v.path]);
|
||||||
}
|
}
|
||||||
if (mounted) setState(() {});
|
if (mounted) setState(() {});
|
||||||
FolderScan result;
|
FolderScan result;
|
||||||
@@ -175,6 +204,26 @@ class _FilesPageState extends State<FilesPage> {
|
|||||||
if (mounted) setState(() {});
|
if (mounted) setState(() {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The grouped library of [place], once its folders are scanned (the
|
||||||
|
/// library: those scanned so far).
|
||||||
|
_Library? _libraryOf(String place) {
|
||||||
|
final folders = _foldersOf(place);
|
||||||
|
final scans = [for (final f in folders) ?_scans[f]];
|
||||||
|
if (place != libraryPlace && scans.isEmpty) return null;
|
||||||
|
final known = _libs[place];
|
||||||
|
if (known != null && known.scans.length == scans.length && _same(known.scans, scans)) return known;
|
||||||
|
final lib = _libs[place] = _Library(scans, folders);
|
||||||
|
_matcher.remember(lib.lib);
|
||||||
|
return lib;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool _same(List<FolderScan> a, List<FolderScan> b) {
|
||||||
|
for (var i = 0; i < a.length; i++) {
|
||||||
|
if (!identical(a[i], b[i])) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _addFolder() async {
|
Future<void> _addFolder() async {
|
||||||
String? dir;
|
String? dir;
|
||||||
try {
|
try {
|
||||||
@@ -198,7 +247,7 @@ class _FilesPageState extends State<FilesPage> {
|
|||||||
if (path != null && mounted) await _play(path);
|
if (path != null && mounted) await _play(path);
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<String?> _pickVideo() async {
|
Future<String?> _pickVideo() async {
|
||||||
final files = await _folders.pickFiles(dialogTitle: 'Pick a video', extensions: videoExtensions.toList());
|
final files = await _folders.pickFiles(dialogTitle: 'Pick a video', extensions: videoExtensions.toList());
|
||||||
return files.firstOrNull;
|
return files.firstOrNull;
|
||||||
}
|
}
|
||||||
@@ -206,7 +255,8 @@ class _FilesPageState extends State<FilesPage> {
|
|||||||
void _removeFolder(String dir) {
|
void _removeFolder(String dir) {
|
||||||
_store.removeFolder(dir);
|
_store.removeFolder(dir);
|
||||||
_scans.remove(dir);
|
_scans.remove(dir);
|
||||||
if (_open == dir) _select(null);
|
if (_open == dir) _select(libraryPlace);
|
||||||
|
setState(() {});
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _play(String path) async {
|
Future<void> _play(String path) async {
|
||||||
@@ -247,7 +297,7 @@ class _FilesPageState extends State<FilesPage> {
|
|||||||
final add = widget.onAddToQueue;
|
final add = widget.onAddToQueue;
|
||||||
if (add == null) return;
|
if (add == null) return;
|
||||||
final pick = await showAppMenu<int>(context, items: [
|
final pick = await showAppMenu<int>(context, items: [
|
||||||
AppMenuItem(value: 0, label: widget.solo ? 'Play' : 'Put it up'),
|
AppMenuItem(value: 0, label: widget.alone ? 'Play' : 'Put it up'),
|
||||||
const AppMenuItem(value: 1, label: 'Add to queue'),
|
const AppMenuItem(value: 1, label: 'Add to queue'),
|
||||||
const AppMenuItem(value: 2, label: 'Play next'),
|
const AppMenuItem(value: 2, label: 'Play next'),
|
||||||
]);
|
]);
|
||||||
@@ -270,6 +320,29 @@ class _FilesPageState extends State<FilesPage> {
|
|||||||
child: Builder(
|
child: Builder(
|
||||||
builder: (context) => GestureDetector(onSecondaryTap: () => _menu(context, path), child: child)));
|
builder: (context) => GestureDetector(onSecondaryTap: () => _menu(context, path), child: child)));
|
||||||
|
|
||||||
|
late final _actions = FilesActions(
|
||||||
|
play: (p) => unawaited(_play(p)),
|
||||||
|
stateOf: _stateOf,
|
||||||
|
menuable: _menuable,
|
||||||
|
thumbs: _thumbs,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// The last episode of [show] watched here (the watch history: the
|
||||||
|
/// newest one watched to the end), else the last one opened (Recent), by
|
||||||
|
/// key.
|
||||||
|
String? _lastPlayed(_Library lib, LocalShow show) {
|
||||||
|
for (final (key, entry) in _history.entries) {
|
||||||
|
if (!entry.played || !key.value.startsWith('file:')) continue;
|
||||||
|
final e = lib.byPath[key.value.substring(5)];
|
||||||
|
if (e != null && e.showKey == show.key && e.kind == LocalKind.episode) return e.key;
|
||||||
|
}
|
||||||
|
for (final p in _store.recent.value) {
|
||||||
|
final e = lib.byPath[p];
|
||||||
|
if (e != null && e.showKey == show.key && e.kind == LocalKind.episode) return e.key;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) => LayoutBuilder(
|
Widget build(BuildContext context) => LayoutBuilder(
|
||||||
builder: (context, box) {
|
builder: (context, box) {
|
||||||
@@ -277,11 +350,14 @@ class _FilesPageState extends State<FilesPage> {
|
|||||||
return SignalBuilder(builder: (context) {
|
return SignalBuilder(builder: (context) {
|
||||||
final folders = _store.folders.value;
|
final folders = _store.folders.value;
|
||||||
final recent = _store.recent.value;
|
final recent = _store.recent.value;
|
||||||
final open = _open != null && folders.contains(_open) ? _open : null;
|
_matcher.revision.value;
|
||||||
|
_history.revision.value;
|
||||||
|
final open = _open == null || _open == libraryPlace || folders.contains(_open) ? _open : libraryPlace;
|
||||||
|
final showKey = open == null ? null : _openShows[open];
|
||||||
final main = fadeThrough(
|
final main = fadeThrough(
|
||||||
KeyedSubtree(
|
KeyedSubtree(
|
||||||
key: ValueKey(open ?? ''),
|
key: ValueKey('${open ?? ''}\n${showKey ?? ''}'),
|
||||||
child: _main(context, narrow, folders, recent, open),
|
child: _main(context, narrow, folders, recent, open, showKey),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
if (narrow) return main;
|
if (narrow) return main;
|
||||||
@@ -301,12 +377,23 @@ class _FilesPageState extends State<FilesPage> {
|
|||||||
Widget _sidebar(BuildContext context, List<String> folders, String? open) {
|
Widget _sidebar(BuildContext context, List<String> folders, String? open) {
|
||||||
final scheme = Theme.of(context).colorScheme;
|
final scheme = Theme.of(context).colorScheme;
|
||||||
final shape = RoundedRectangleBorder(borderRadius: BorderRadius.circular(10));
|
final shape = RoundedRectangleBorder(borderRadius: BorderRadius.circular(10));
|
||||||
|
Widget? status(String? s) =>
|
||||||
|
s == null ? null : Text(s, style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 11));
|
||||||
return ListView(
|
return ListView(
|
||||||
padding: const EdgeInsets.fromLTRB(20, 20, 8, 20),
|
padding: const EdgeInsets.fromLTRB(20, 20, 8, 20),
|
||||||
children: [
|
children: [
|
||||||
const Text('Folders', style: TextStyle(fontWeight: FontWeight.w600)),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
ListTile(
|
ListTile(
|
||||||
|
key: const ValueKey('place:library'),
|
||||||
|
dense: true,
|
||||||
|
selected: open == libraryPlace,
|
||||||
|
shape: shape,
|
||||||
|
leading: const Icon(Icons.video_library_outlined, size: 18),
|
||||||
|
title: const Text('Library'),
|
||||||
|
subtitle: status(_libraryStatus(folders)),
|
||||||
|
onTap: () => _select(libraryPlace),
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
key: const ValueKey('place:recent'),
|
||||||
dense: true,
|
dense: true,
|
||||||
selected: open == null,
|
selected: open == null,
|
||||||
shape: shape,
|
shape: shape,
|
||||||
@@ -314,6 +401,9 @@ class _FilesPageState extends State<FilesPage> {
|
|||||||
title: const Text('Recent'),
|
title: const Text('Recent'),
|
||||||
onTap: () => _select(null),
|
onTap: () => _select(null),
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
const Text('Folders', style: TextStyle(fontWeight: FontWeight.w600)),
|
||||||
|
const SizedBox(height: 8),
|
||||||
for (final f in folders)
|
for (final f in folders)
|
||||||
ListTile(
|
ListTile(
|
||||||
key: ValueKey('folder:$f'),
|
key: ValueKey('folder:$f'),
|
||||||
@@ -322,10 +412,7 @@ class _FilesPageState extends State<FilesPage> {
|
|||||||
shape: shape,
|
shape: shape,
|
||||||
leading: const Icon(Icons.folder_outlined, size: 18),
|
leading: const Icon(Icons.folder_outlined, size: 18),
|
||||||
title: Text(baseName(f), maxLines: 1, overflow: TextOverflow.ellipsis),
|
title: Text(baseName(f), maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||||
subtitle: switch (_folderStatus(f)) {
|
subtitle: status(_folderStatus(f)),
|
||||||
final s? => Text(s, style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 11)),
|
|
||||||
null => null,
|
|
||||||
},
|
|
||||||
trailing: IconButton(
|
trailing: IconButton(
|
||||||
tooltip: 'Remove folder',
|
tooltip: 'Remove folder',
|
||||||
icon: const Icon(Icons.close, size: 16),
|
icon: const Icon(Icons.close, size: 16),
|
||||||
@@ -363,19 +450,15 @@ class _FilesPageState extends State<FilesPage> {
|
|||||||
return n == null ? null : '$n ${n == 1 ? 'video' : 'videos'}';
|
return n == null ? null : '$n ${n == 1 ? 'video' : 'videos'}';
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _main(
|
String? _libraryStatus(List<String> folders) {
|
||||||
BuildContext context,
|
if (folders.any(_running.containsKey)) return 'scanning…';
|
||||||
bool narrow,
|
if (folders.isEmpty || folders.any((f) => !_scans.containsKey(f))) return null;
|
||||||
List<String> folders,
|
final n = _libraryOf(libraryPlace)?.lib.shows.length ?? 0;
|
||||||
List<String> recent,
|
return '$n ${n == 1 ? 'show' : 'shows'}';
|
||||||
String? open,
|
}
|
||||||
) {
|
|
||||||
final scan = open == null ? null : _scans[open];
|
List<Widget> _notices() => [
|
||||||
final scanning = open != null && _running.containsKey(open);
|
if (!widget.alone && widget.relayed) const _RelayNotice(),
|
||||||
final top = <Widget>[
|
|
||||||
_header(context, open),
|
|
||||||
if (narrow) _chips(folders, open),
|
|
||||||
if (!widget.solo && widget.relayed) const _RelayNotice(),
|
|
||||||
AnimatedSize(
|
AnimatedSize(
|
||||||
duration: easeTime,
|
duration: easeTime,
|
||||||
curve: ease,
|
curve: ease,
|
||||||
@@ -385,11 +468,59 @@ class _FilesPageState extends State<FilesPage> {
|
|||||||
: _ErrorBanner(_error!, onClose: () => setState(() => _error = null)),
|
: _ErrorBanner(_error!, onClose: () => setState(() => _error = null)),
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
Widget _main(
|
||||||
|
BuildContext context,
|
||||||
|
bool narrow,
|
||||||
|
List<String> folders,
|
||||||
|
List<String> recent,
|
||||||
|
String? open,
|
||||||
|
String? showKey,
|
||||||
|
) {
|
||||||
|
final lib = open == null ? null : _libraryOf(open);
|
||||||
|
final show = showKey == null ? null : lib?.lib.show(showKey);
|
||||||
|
if (open != null && lib != null && show != null) {
|
||||||
|
return ShowPage(
|
||||||
|
key: ValueKey('show:$open:${show.key}'),
|
||||||
|
show: show,
|
||||||
|
matcher: _matcher,
|
||||||
|
actions: _actions,
|
||||||
|
lastPlayed: _lastPlayed(lib, show),
|
||||||
|
backLabel: open == libraryPlace ? 'Library' : baseName(open),
|
||||||
|
onBack: () => setState(() => _openShows.remove(open)),
|
||||||
|
top: [if (narrow) _chips(folders, open), ..._notices()],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final scanning = _foldersOf(open).any(_running.containsKey);
|
||||||
|
// A folder added elsewhere (a drop, the handoff) joins the library.
|
||||||
|
if (open == libraryPlace && folders.any((f) => !_scans.containsKey(f) && !_running.containsKey(f))) {
|
||||||
|
scheduleMicrotask(() {
|
||||||
|
if (mounted) _scanFor(libraryPlace);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
final top = <Widget>[
|
||||||
|
_header(context, open),
|
||||||
|
if (narrow) _chips(folders, open),
|
||||||
|
..._notices(),
|
||||||
|
];
|
||||||
final slivers = <Widget>[
|
final slivers = <Widget>[
|
||||||
SliverList.list(children: top),
|
SliverList.list(children: top),
|
||||||
if (open == null)
|
if (open == null)
|
||||||
..._recent(context, recent)
|
..._recent(context, recent)
|
||||||
else if (scan == null)
|
else if (open == libraryPlace && folders.isEmpty)
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: FilesEmpty(
|
||||||
|
Icons.video_library_outlined,
|
||||||
|
'Your library is empty',
|
||||||
|
'Add a folder with your videos: shows, seasons and movies are found by their names.',
|
||||||
|
action: FilledButton.icon(
|
||||||
|
onPressed: _addFolder,
|
||||||
|
icon: const Icon(Icons.create_new_folder_outlined),
|
||||||
|
label: const Text('Add folder'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else if (lib == null)
|
||||||
const SliverToBoxAdapter(
|
const SliverToBoxAdapter(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: EdgeInsets.only(top: 60),
|
padding: EdgeInsets.only(top: 60),
|
||||||
@@ -397,7 +528,7 @@ class _FilesPageState extends State<FilesPage> {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
..._folder(context, open, scan, scanning),
|
..._library(context, open, lib, scanning),
|
||||||
];
|
];
|
||||||
return CustomScrollView(
|
return CustomScrollView(
|
||||||
slivers: [
|
slivers: [
|
||||||
@@ -411,25 +542,30 @@ class _FilesPageState extends State<FilesPage> {
|
|||||||
|
|
||||||
Widget _header(BuildContext context, String? open) {
|
Widget _header(BuildContext context, String? open) {
|
||||||
final scheme = Theme.of(context).colorScheme;
|
final scheme = Theme.of(context).colorScheme;
|
||||||
|
final (icon, title, sub) = switch (open) {
|
||||||
|
null => (Icons.history, 'Recent', null),
|
||||||
|
libraryPlace => (Icons.video_library, 'Library', null),
|
||||||
|
final dir => (Icons.folder, baseName(dir), dir),
|
||||||
|
};
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.only(bottom: 14),
|
padding: const EdgeInsets.only(bottom: 14),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(open == null ? Icons.history : Icons.folder, color: scheme.primary),
|
Icon(icon, color: scheme.primary),
|
||||||
const SizedBox(width: 10),
|
const SizedBox(width: 10),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
open == null ? 'Recent' : baseName(open),
|
title,
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
style: Theme.of(context).textTheme.headlineSmall,
|
style: Theme.of(context).textTheme.headlineSmall,
|
||||||
),
|
),
|
||||||
if (open != null)
|
if (sub != null)
|
||||||
Text(
|
Text(
|
||||||
open,
|
sub,
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12),
|
style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12),
|
||||||
@@ -448,6 +584,11 @@ class _FilesPageState extends State<FilesPage> {
|
|||||||
spacing: 8,
|
spacing: 8,
|
||||||
runSpacing: 8,
|
runSpacing: 8,
|
||||||
children: [
|
children: [
|
||||||
|
ChoiceChip(
|
||||||
|
label: const Text('Library'),
|
||||||
|
selected: open == libraryPlace,
|
||||||
|
onSelected: (_) => _select(libraryPlace),
|
||||||
|
),
|
||||||
ChoiceChip(
|
ChoiceChip(
|
||||||
label: const Text('Recent'),
|
label: const Text('Recent'),
|
||||||
selected: open == null,
|
selected: open == null,
|
||||||
@@ -505,6 +646,7 @@ class _FilesPageState extends State<FilesPage> {
|
|||||||
_RecentRow(
|
_RecentRow(
|
||||||
key: ValueKey(path),
|
key: ValueKey(path),
|
||||||
path: path,
|
path: path,
|
||||||
|
matcher: _matcher,
|
||||||
state: _stateOf(path),
|
state: _stateOf(path),
|
||||||
thumbs: _thumbs,
|
thumbs: _thumbs,
|
||||||
onTap: _tapOf(path),
|
onTap: _tapOf(path),
|
||||||
@@ -515,21 +657,32 @@ class _FilesPageState extends State<FilesPage> {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
List<Widget> _folder(BuildContext context, String dir, FolderScan scan, bool scanning) {
|
Widget _section(BuildContext context, String title, {String? count}) => Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 22, bottom: 10),
|
||||||
|
child: Row(children: [
|
||||||
|
Text(title, style: Theme.of(context).textTheme.titleMedium),
|
||||||
|
if (count != null) ...[
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(count, style: Theme.of(context).textTheme.bodySmall?.copyWith(color: Colors.grey)),
|
||||||
|
],
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
List<Widget> _library(BuildContext context, String place, _Library lib, bool scanning) {
|
||||||
final scheme = Theme.of(context).colorScheme;
|
final scheme = Theme.of(context).colorScheme;
|
||||||
final lib = _libraries[scan] ??= _Library(scan);
|
final l = lib.lib;
|
||||||
final series = lib.grouped.series.length;
|
final folderScan = place == libraryPlace ? null : _scans[place];
|
||||||
String count(int n, String one, String many) => '$n ${n == 1 ? one : many}';
|
String count(int n, String one, String many) => '$n ${n == 1 ? one : many}';
|
||||||
final summary = [
|
final summary = [
|
||||||
count(scan.videos.length, 'video', 'videos') + (series > 0 ? ' in $series series' : ''),
|
count(lib.videos, 'video', 'videos') +
|
||||||
if (scan.hiddenCount > 0)
|
(l.shows.isEmpty ? '' : ' in ${count(l.shows.length, 'show', 'shows')}'),
|
||||||
'${count(scan.hiddenCount, 'other file', 'other files')} hidden',
|
if (lib.hidden > 0) '${count(lib.hidden, 'other file', 'other files')} hidden',
|
||||||
if (lib.subtitleFiles > 0) count(lib.subtitleFiles, 'subtitle file', 'subtitle files'),
|
if (lib.subtitleFiles > 0) count(lib.subtitleFiles, 'subtitle file', 'subtitle files'),
|
||||||
if (scan.truncated) 'stopped after $maxScannedFiles files',
|
if (lib.truncated) 'stopped after $maxScannedFiles files',
|
||||||
].join(' · ');
|
].join(' · ');
|
||||||
final loose = lib.grouped.loose;
|
final folders = _foldersOf(place);
|
||||||
String seriesId(SeriesGroup g) => '$dir\n${g.key}';
|
final movies = l.movies;
|
||||||
final rows = lib.rows((g) => _expanded.contains(seriesId(g)));
|
final other = l.other;
|
||||||
return [
|
return [
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: Wrap(
|
child: Wrap(
|
||||||
@@ -538,7 +691,7 @@ class _FilesPageState extends State<FilesPage> {
|
|||||||
Icon(Icons.filter_alt_outlined, size: 16, color: scheme.onSurfaceVariant),
|
Icon(Icons.filter_alt_outlined, size: 16, color: scheme.onSurfaceVariant),
|
||||||
const SizedBox(width: 6),
|
const SizedBox(width: 6),
|
||||||
Text(summary, style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12)),
|
Text(summary, style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12)),
|
||||||
if (scan.hiddenCount > 0) ...[
|
if (folderScan != null && folderScan.hiddenCount > 0) ...[
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => setState(() => _showHidden = !_showHidden),
|
onPressed: () => setState(() => _showHidden = !_showHidden),
|
||||||
@@ -546,16 +699,22 @@ class _FilesPageState extends State<FilesPage> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
TextButton.icon(
|
TextButton.icon(
|
||||||
onPressed: scanning ? null : () => _scan(dir, rescan: true),
|
onPressed: scanning
|
||||||
|
? null
|
||||||
|
: () {
|
||||||
|
for (final f in folders) {
|
||||||
|
_scan(f, rescan: true);
|
||||||
|
}
|
||||||
|
},
|
||||||
icon: scanning
|
icon: scanning
|
||||||
? const SizedBox.square(
|
? const SizedBox.square(dimension: 14, child: CircularProgressIndicator(strokeWidth: 2))
|
||||||
dimension: 14, child: CircularProgressIndicator(strokeWidth: 2))
|
|
||||||
: const Icon(Icons.refresh, size: 16),
|
: const Icon(Icons.refresh, size: 16),
|
||||||
label: Text(scanning ? 'Scanning…' : 'Rescan'),
|
label: Text(scanning ? 'Scanning…' : 'Rescan'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
if (folderScan != null)
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: AnimatedSize(
|
child: AnimatedSize(
|
||||||
duration: easeTime,
|
duration: easeTime,
|
||||||
@@ -565,7 +724,7 @@ class _FilesPageState extends State<FilesPage> {
|
|||||||
? const SizedBox(width: double.infinity)
|
? const SizedBox(width: double.infinity)
|
||||||
: Column(
|
: Column(
|
||||||
children: [
|
children: [
|
||||||
for (final f in scan.hiddenExamples)
|
for (final f in folderScan.hiddenExamples)
|
||||||
ListTile(
|
ListTile(
|
||||||
dense: true,
|
dense: true,
|
||||||
enabled: false,
|
enabled: false,
|
||||||
@@ -576,19 +735,19 @@ class _FilesPageState extends State<FilesPage> {
|
|||||||
style: const TextStyle(fontSize: 12),
|
style: const TextStyle(fontSize: 12),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (scan.hiddenCount > scan.hiddenExamples.length)
|
if (folderScan.hiddenCount > folderScan.hiddenExamples.length)
|
||||||
ListTile(
|
ListTile(
|
||||||
dense: true,
|
dense: true,
|
||||||
enabled: false,
|
enabled: false,
|
||||||
title: Text('and ${scan.hiddenCount - scan.hiddenExamples.length} more'),
|
title: Text('and ${folderScan.hiddenCount - folderScan.hiddenExamples.length} more'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (scan.videos.isEmpty)
|
if (lib.videos == 0)
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: _folders.folderExists(dir)
|
child: place == libraryPlace || _folders.folderExists(place)
|
||||||
? const FilesEmpty(
|
? const FilesEmpty(
|
||||||
Icons.search_off,
|
Icons.search_off,
|
||||||
'No videos here',
|
'No videos here',
|
||||||
@@ -600,46 +759,25 @@ class _FilesPageState extends State<FilesPage> {
|
|||||||
'It may have been moved, renamed or be on a drive that is not connected.',
|
'It may have been moved, renamed or be on a drive that is not connected.',
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// Series: files whose names carry an episode number; a header
|
if (l.shows.isNotEmpty) ...[
|
||||||
// click opens or closes its episodes.
|
SliverToBoxAdapter(child: _section(context, 'Shows', count: '${l.shows.length}')),
|
||||||
SliverList.builder(
|
SliverGrid.builder(
|
||||||
itemCount: rows.length,
|
gridDelegate: posterGrid(),
|
||||||
itemBuilder: (context, i) {
|
itemCount: l.shows.length,
|
||||||
final (group, v) = rows[i];
|
itemBuilder: (context, i) => _showCard(place, lib, l.shows[i]),
|
||||||
if (v == null) {
|
|
||||||
final id = seriesId(group);
|
|
||||||
return SeriesHeader(
|
|
||||||
key: ValueKey('series:${group.key}'),
|
|
||||||
group: group,
|
|
||||||
thumbs: _thumbs,
|
|
||||||
expanded: _expanded.contains(id),
|
|
||||||
onTap: () => setState(() => _expanded.contains(id) ? _expanded.remove(id) : _expanded.add(id)),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return _menuable(
|
|
||||||
v.path,
|
|
||||||
FileRow(
|
|
||||||
key: ValueKey(v.path),
|
|
||||||
path: v.path,
|
|
||||||
title: v.parsed.label.isEmpty ? v.parsed.series : v.parsed.label,
|
|
||||||
subtitle: v.name,
|
|
||||||
size: formatSize(v.size),
|
|
||||||
state: _stateOf(v.path),
|
|
||||||
thumbs: _thumbs,
|
|
||||||
onTap: _tapOf(v.path),
|
|
||||||
));
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
// Everything else (movies, clips) as a thumbnail grid.
|
],
|
||||||
if (loose.isNotEmpty) ...[
|
if (movies.isNotEmpty) ...[
|
||||||
|
SliverToBoxAdapter(child: _section(context, 'Movies', count: '${movies.length}')),
|
||||||
|
SliverGrid.builder(
|
||||||
|
gridDelegate: posterGrid(),
|
||||||
|
itemCount: movies.length,
|
||||||
|
itemBuilder: (context, i) => _movieCard(movies[i]),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
if (other.isNotEmpty) ...[
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: Padding(
|
child: _section(context, l.shows.isEmpty && movies.isEmpty ? 'Videos' : 'Other videos'),
|
||||||
padding: const EdgeInsets.only(top: 22, bottom: 10),
|
|
||||||
child: Text(
|
|
||||||
series == 0 ? 'Videos' : 'Other videos',
|
|
||||||
style: Theme.of(context).textTheme.titleMedium,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
SliverLayoutBuilder(builder: (context, c) {
|
SliverLayoutBuilder(builder: (context, c) {
|
||||||
const gap = 12.0;
|
const gap = 12.0;
|
||||||
@@ -653,9 +791,9 @@ class _FilesPageState extends State<FilesPage> {
|
|||||||
crossAxisSpacing: gap,
|
crossAxisSpacing: gap,
|
||||||
mainAxisExtent: cardW * 9 / 16 + videoCardTextHeight,
|
mainAxisExtent: cardW * 9 / 16 + videoCardTextHeight,
|
||||||
),
|
),
|
||||||
itemCount: loose.length,
|
itemCount: other.length,
|
||||||
itemBuilder: (context, i) {
|
itemBuilder: (context, i) {
|
||||||
final v = loose[i];
|
final v = other[i];
|
||||||
return _menuable(
|
return _menuable(
|
||||||
v.path,
|
v.path,
|
||||||
VideoCard(
|
VideoCard(
|
||||||
@@ -671,12 +809,60 @@ class _FilesPageState extends State<FilesPage> {
|
|||||||
],
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _showCard(String place, _Library lib, LocalShow show) {
|
||||||
|
final m = _matcher.showMatch(show);
|
||||||
|
final last = _lastPlayed(lib, show);
|
||||||
|
final String subtitle;
|
||||||
|
if (last == null) {
|
||||||
|
final n = show.episodeCount;
|
||||||
|
subtitle = '$n ${n == 1 ? 'episode' : 'episodes'}';
|
||||||
|
} else {
|
||||||
|
final next = show.nextAfter(last);
|
||||||
|
subtitle = next == null ? 'Watched to the end' : 'Next: ${episodeNumber(next.episode)}';
|
||||||
|
}
|
||||||
|
final playing = widget.nowPlayingPath != null && show.files.any((e) => e.path == widget.nowPlayingPath);
|
||||||
|
return PosterCard(
|
||||||
|
key: ValueKey('showcard:${show.key}'),
|
||||||
|
title: m?.entry.title ?? show.title,
|
||||||
|
subtitle: subtitle,
|
||||||
|
coverUrl: m?.entry.cover,
|
||||||
|
framePath: show.episodes.firstOrNull?.path,
|
||||||
|
thumbs: _thumbs,
|
||||||
|
playing: playing,
|
||||||
|
onShown: () => _matcher.want(show),
|
||||||
|
onTap: () => setState(() => _openShows[place] = show.key),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _movieCard(LocalEpisode movie) {
|
||||||
|
final m = _matcher.seasonMatch(movie);
|
||||||
|
return _menuable(
|
||||||
|
movie.path,
|
||||||
|
PosterCard(
|
||||||
|
key: ValueKey('moviecard:${movie.path}'),
|
||||||
|
title: m?.entry.title ?? (movie.parsed.year == null ? movie.title : '${movie.title} (${movie.parsed.year})'),
|
||||||
|
subtitle: [
|
||||||
|
if (movie.movieNumber != null) movie.label,
|
||||||
|
if (m?.entry.year != null) '${m!.entry.year}' else if (movie.parsed.year != null) '${movie.parsed.year}',
|
||||||
|
formatSize(movie.file.size),
|
||||||
|
].join(' · '),
|
||||||
|
coverUrl: m?.entry.cover,
|
||||||
|
framePath: movie.path,
|
||||||
|
thumbs: _thumbs,
|
||||||
|
playing: _stateOf(movie.path) == PlayState.playing,
|
||||||
|
onShown: () => _matcher.wantMovie(movie),
|
||||||
|
onTap: _tapOf(movie.path),
|
||||||
|
));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A Recent entry: `Series · S01E03` over the file name, or the cleaned
|
/// A Recent entry: `Show · E02` over the file name (the library's names
|
||||||
/// title over its folder; greyed when the file is gone.
|
/// when it knows the show), or the cleaned title over its folder; greyed
|
||||||
|
/// when the file is gone.
|
||||||
class _RecentRow extends StatefulWidget {
|
class _RecentRow extends StatefulWidget {
|
||||||
final String path;
|
final String path;
|
||||||
|
final ShowMatcher matcher;
|
||||||
final PlayState state;
|
final PlayState state;
|
||||||
final ThumbCache thumbs;
|
final ThumbCache thumbs;
|
||||||
final VoidCallback? onTap;
|
final VoidCallback? onTap;
|
||||||
@@ -684,6 +870,7 @@ class _RecentRow extends StatefulWidget {
|
|||||||
const _RecentRow({
|
const _RecentRow({
|
||||||
super.key,
|
super.key,
|
||||||
required this.path,
|
required this.path,
|
||||||
|
required this.matcher,
|
||||||
required this.state,
|
required this.state,
|
||||||
required this.thumbs,
|
required this.thumbs,
|
||||||
required this.onTap,
|
required this.onTap,
|
||||||
@@ -695,6 +882,8 @@ class _RecentRow extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _RecentRowState extends State<_RecentRow> {
|
class _RecentRowState extends State<_RecentRow> {
|
||||||
|
FolderAccess get _folders => AppScope.of(context).ports.folders;
|
||||||
|
|
||||||
FileStat? _stat;
|
FileStat? _stat;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -708,12 +897,14 @@ class _RecentRowState extends State<_RecentRow> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final path = widget.path;
|
final path = widget.path;
|
||||||
final p = parseReleaseName(baseName(path));
|
final e = widget.matcher.episodeAt(path) ??
|
||||||
|
readLocalFile(LocalFile(path, 0, DateTime.fromMillisecondsSinceEpoch(0)));
|
||||||
final missing = _stat?.type == FileSystemEntityType.notFound;
|
final missing = _stat?.type == FileSystemEntityType.notFound;
|
||||||
final episode = p.episode != null || p.extra != null;
|
final episode = e.kind == LocalKind.episode || e.kind == LocalKind.extra;
|
||||||
|
final name = widget.matcher.seasonMatch(e)?.entry.title ?? e.title;
|
||||||
return FileRow(
|
return FileRow(
|
||||||
path: path,
|
path: path,
|
||||||
title: episode ? '${p.series} · ${p.label}' : cleanTitle(p),
|
title: episode ? '$name · ${e.label}' : (e.kind == LocalKind.other ? cleanTitle(e.parsed) : name),
|
||||||
subtitle: missing ? 'File not found' : (episode ? baseName(path) : parentName(path)),
|
subtitle: missing ? 'File not found' : (episode ? baseName(path) : parentName(path)),
|
||||||
size: _stat == null || missing ? null : formatSize(_stat!.size),
|
size: _stat == null || missing ? null : formatSize(_stat!.size),
|
||||||
state: widget.state,
|
state: widget.state,
|
||||||
|
|||||||
@@ -0,0 +1,712 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/rendering.dart';
|
||||||
|
import 'package:signals/signals_flutter.dart';
|
||||||
|
import 'package:sync_relay_core/core.dart';
|
||||||
|
|
||||||
|
import '../../library/show_matcher.dart';
|
||||||
|
import '../../schedule/anilist.dart' show AniListEntry;
|
||||||
|
import '../../shell/avatar.dart' show Pill;
|
||||||
|
import 'file_tiles.dart';
|
||||||
|
import 'thumb.dart';
|
||||||
|
|
||||||
|
// The Files library: shows and movies as poster cards (like the Jellyfin
|
||||||
|
// browser's), a show's own page (banner, the next episode with what is
|
||||||
|
// missing on the way, seasons, extras, movies) and the corrections
|
||||||
|
// ("Wrong show?", "Episode numbers…"). Names and art come from the
|
||||||
|
// library's AniList match when there is one, else from the files.
|
||||||
|
|
||||||
|
/// What the page hands its views: playing, the right-click menu, the
|
||||||
|
/// thumbnails, what plays now.
|
||||||
|
class FilesActions {
|
||||||
|
final void Function(String path) play;
|
||||||
|
final PlayState Function(String path) stateOf;
|
||||||
|
final Widget Function(String path, Widget child) menuable;
|
||||||
|
final ThumbCache thumbs;
|
||||||
|
const FilesActions({required this.play, required this.stateOf, required this.menuable, required this.thumbs});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// "66–107 aren't in your folders", "66 isn't in your folders".
|
||||||
|
String missingText(EpisodeGap gap) =>
|
||||||
|
gap.first == gap.last ? '${gap.label} isn\'t in your folders' : '${gap.label} aren\'t in your folders';
|
||||||
|
|
||||||
|
/// The next-episode prompt: "66–107 aren't in your folders — skip to
|
||||||
|
/// 108?", or at the start "1–55 … — start at 56?"; null when nothing is
|
||||||
|
/// skipped.
|
||||||
|
String? skipPrompt(LocalNext next, {required bool started}) {
|
||||||
|
final gap = next.skipped;
|
||||||
|
if (gap == null) return null;
|
||||||
|
final n = next.episode.episode;
|
||||||
|
return started ? '${missingText(gap)} — skip to $n?' : '${missingText(gap)} — start at $n?';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A show's label for an episode: `56`, `S3 E1`.
|
||||||
|
String episodeNumber(LocalEpisode e) =>
|
||||||
|
e.season == null ? '${e.episode}' : 'S${e.season} E${e.episode}';
|
||||||
|
|
||||||
|
/// The 2:3 picture of a show or movie: its AniList cover, else a frame of
|
||||||
|
/// [framePath].
|
||||||
|
class PosterImage extends StatelessWidget {
|
||||||
|
final String? url;
|
||||||
|
final String? framePath;
|
||||||
|
final ThumbCache thumbs;
|
||||||
|
const PosterImage({super.key, this.url, this.framePath, required this.thumbs});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final frame = framePath == null
|
||||||
|
? const ThumbImage(null)
|
||||||
|
: ThumbBuilder(cache: thumbs, path: framePath!, builder: (context, t, _) => ThumbImage(t));
|
||||||
|
final u = url;
|
||||||
|
if (u == null) return frame;
|
||||||
|
return Image.network(
|
||||||
|
u,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
gaplessPlayback: true,
|
||||||
|
errorBuilder: (_, _, _) => frame,
|
||||||
|
loadingBuilder: (context, child, progress) => progress == null ? child : frame,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A show (or movie) as a poster card: picture, title, one line under it.
|
||||||
|
class PosterCard extends StatefulWidget {
|
||||||
|
final String title;
|
||||||
|
final String subtitle;
|
||||||
|
final String? coverUrl;
|
||||||
|
final String? framePath;
|
||||||
|
final ThumbCache thumbs;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
final bool playing;
|
||||||
|
/// Called once when the card is first built (the library matches what
|
||||||
|
/// is on screen).
|
||||||
|
final VoidCallback? onShown;
|
||||||
|
const PosterCard({
|
||||||
|
super.key,
|
||||||
|
required this.title,
|
||||||
|
required this.subtitle,
|
||||||
|
required this.thumbs,
|
||||||
|
required this.onTap,
|
||||||
|
this.coverUrl,
|
||||||
|
this.framePath,
|
||||||
|
this.playing = false,
|
||||||
|
this.onShown,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<PosterCard> createState() => _PosterCardState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PosterCardState extends State<PosterCard> {
|
||||||
|
bool _hover = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
widget.onShown?.call();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final scheme = Theme.of(context).colorScheme;
|
||||||
|
final t = Theme.of(context).textTheme;
|
||||||
|
return InkWell(
|
||||||
|
onTap: widget.onTap,
|
||||||
|
onHover: (h) => setState(() => _hover = h),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: AnimatedContainer(
|
||||||
|
duration: const Duration(milliseconds: 150),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(
|
||||||
|
color: widget.playing ? scheme.primary : (_hover ? scheme.onSurfaceVariant : Colors.transparent),
|
||||||
|
width: 2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
child: PosterImage(url: widget.coverUrl, framePath: widget.framePath, thumbs: widget.thumbs),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Text(widget.title,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: t.titleSmall?.copyWith(color: widget.playing ? scheme.primary : null)),
|
||||||
|
Text(widget.subtitle,
|
||||||
|
maxLines: 1, overflow: TextOverflow.ellipsis, style: t.bodySmall?.copyWith(color: Colors.grey)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Title and meta lines under a [PosterCard]'s picture.
|
||||||
|
const posterTextHeight = 46.0;
|
||||||
|
|
||||||
|
/// Poster cards in a grid: about 170 px wide each.
|
||||||
|
SliverGridDelegate posterGrid() => const _PosterGrid();
|
||||||
|
|
||||||
|
class _PosterGrid extends SliverGridDelegate {
|
||||||
|
const _PosterGrid();
|
||||||
|
|
||||||
|
@override
|
||||||
|
SliverGridLayout getLayout(SliverConstraints constraints) {
|
||||||
|
const gap = 12.0;
|
||||||
|
final w = constraints.crossAxisExtent;
|
||||||
|
final cols = (w / 170).floor().clamp(2, 12);
|
||||||
|
final cardW = (w - gap * (cols - 1)) / cols;
|
||||||
|
return SliverGridRegularTileLayout(
|
||||||
|
crossAxisCount: cols,
|
||||||
|
mainAxisStride: cardW * 1.5 + posterTextHeight + gap,
|
||||||
|
crossAxisStride: cardW + gap,
|
||||||
|
childMainAxisExtent: cardW * 1.5 + posterTextHeight,
|
||||||
|
childCrossAxisExtent: cardW,
|
||||||
|
reverseCrossAxis: false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool shouldRelayout(covariant SliverGridDelegate oldDelegate) => false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One show's page: header (banner, proper name, what the files are,
|
||||||
|
/// the match), the next episode with what is missing on the way, the
|
||||||
|
/// season's episodes with their holes, extras and the show's movies.
|
||||||
|
class ShowPage extends StatefulWidget {
|
||||||
|
final LocalShow show;
|
||||||
|
final ShowMatcher matcher;
|
||||||
|
final FilesActions actions;
|
||||||
|
/// The last episode of this show played here ([LocalEpisode.key]).
|
||||||
|
final String? lastPlayed;
|
||||||
|
final String backLabel;
|
||||||
|
final VoidCallback onBack;
|
||||||
|
/// Notices above the page (the relay notice, errors).
|
||||||
|
final List<Widget> top;
|
||||||
|
const ShowPage({
|
||||||
|
super.key,
|
||||||
|
required this.show,
|
||||||
|
required this.matcher,
|
||||||
|
required this.actions,
|
||||||
|
required this.lastPlayed,
|
||||||
|
required this.backLabel,
|
||||||
|
required this.onBack,
|
||||||
|
this.top = const [],
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ShowPage> createState() => _ShowPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ShowPageState extends State<ShowPage> {
|
||||||
|
int? _season;
|
||||||
|
bool _seasonPicked = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
widget.matcher.want(widget.show);
|
||||||
|
}
|
||||||
|
|
||||||
|
LocalShow get show => widget.show;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => SignalBuilder(builder: (context) {
|
||||||
|
widget.matcher.revision.value;
|
||||||
|
final busy = widget.matcher.busy.value.contains(show.key);
|
||||||
|
final m = widget.matcher.showMatch(show);
|
||||||
|
final next = show.nextAfter(widget.lastPlayed);
|
||||||
|
final seasons = show.seasons;
|
||||||
|
if (!_seasonPicked) _season = next?.episode.season ?? seasons.firstOrNull?.number;
|
||||||
|
final season = show.season(_season) ?? seasons.firstOrNull;
|
||||||
|
return CustomScrollView(
|
||||||
|
key: PageStorageKey('show:${show.key}'),
|
||||||
|
slivers: [
|
||||||
|
SliverPadding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(12, 12, 20, 24),
|
||||||
|
sliver: SliverMainAxisGroup(slivers: [
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: Align(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: TextButton.icon(
|
||||||
|
onPressed: widget.onBack,
|
||||||
|
icon: const Icon(Icons.arrow_back, size: 18),
|
||||||
|
label: Text(widget.backLabel),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SliverList.list(children: widget.top),
|
||||||
|
SliverToBoxAdapter(child: _header(context, m, next, busy)),
|
||||||
|
if (seasons.length > 1)
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 14),
|
||||||
|
child: Wrap(spacing: 8, runSpacing: 8, children: [
|
||||||
|
for (final s in seasons)
|
||||||
|
ChoiceChip(
|
||||||
|
key: ValueKey('season:${s.number}'),
|
||||||
|
label: Text(s.title),
|
||||||
|
selected: identical(s, season),
|
||||||
|
onSelected: (_) => setState(() {
|
||||||
|
_season = s.number;
|
||||||
|
_seasonPicked = true;
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (season != null) ..._episodes(context, season),
|
||||||
|
if (show.extras.isNotEmpty) ...[
|
||||||
|
_section(context, 'Extras', show.extras.length),
|
||||||
|
SliverList.list(children: [for (final e in show.extras) _row(e, e.label, e.name)]),
|
||||||
|
],
|
||||||
|
if (show.movies.isNotEmpty) ...[
|
||||||
|
_section(context, show.movies.length == 1 ? 'Movie' : 'Movies', show.movies.length),
|
||||||
|
SliverList.list(children: [for (final e in show.movies) _row(e, e.label, e.name)]),
|
||||||
|
],
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Widget _section(BuildContext context, String title, int n) => SliverToBoxAdapter(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 22, bottom: 6),
|
||||||
|
child: Row(children: [
|
||||||
|
Text(title, style: Theme.of(context).textTheme.titleMedium),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text('$n', style: Theme.of(context).textTheme.bodySmall?.copyWith(color: Colors.grey)),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
Widget _row(LocalEpisode e, String title, String subtitle) => widget.actions.menuable(
|
||||||
|
e.path,
|
||||||
|
FileRow(
|
||||||
|
key: ValueKey(e.path),
|
||||||
|
path: e.path,
|
||||||
|
title: title,
|
||||||
|
subtitle: subtitle,
|
||||||
|
size: formatSize(e.file.size),
|
||||||
|
state: widget.actions.stateOf(e.path),
|
||||||
|
thumbs: widget.actions.thumbs,
|
||||||
|
onTap: () => widget.actions.play(e.path),
|
||||||
|
));
|
||||||
|
|
||||||
|
List<Widget> _episodes(BuildContext context, LocalSeason season) {
|
||||||
|
final rows = <Widget>[];
|
||||||
|
var expect = 1;
|
||||||
|
for (final e in season.episodes) {
|
||||||
|
final n = e.episode!;
|
||||||
|
final gap = season.gaps.where((g) => g.first >= expect && g.last < n).firstOrNull;
|
||||||
|
if (gap != null) rows.add(_GapRow(missingText(gap)));
|
||||||
|
if (n >= expect) expect = n + 1;
|
||||||
|
final offset = widget.matcher.offset(show.key, season.number);
|
||||||
|
final shown = n + offset;
|
||||||
|
final name = widget.matcher.episodeTitle(e);
|
||||||
|
// Like the Jellyfin browser's rows: "12. Name", else "Episode 12".
|
||||||
|
final title = name == null ? 'Episode $shown' : '$shown. $name';
|
||||||
|
rows.add(_row(e, title, e.name));
|
||||||
|
}
|
||||||
|
final tail = season.gaps.where((g) => g.first >= expect).firstOrNull;
|
||||||
|
if (tail != null) rows.add(_GapRow(missingText(tail)));
|
||||||
|
return [
|
||||||
|
_section(context, season.title, season.distinct.length),
|
||||||
|
SliverList.list(children: rows),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _header(BuildContext context, SeasonMatch? m, LocalNext? next, bool busy) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final t = theme.textTheme;
|
||||||
|
final scheme = theme.colorScheme;
|
||||||
|
final entry = m?.entry;
|
||||||
|
final title = entry?.title ?? show.title;
|
||||||
|
final count = show.episodeCount;
|
||||||
|
final meta = [
|
||||||
|
if (show.seasons.length > 1) '${show.seasons.length} seasons',
|
||||||
|
'$count ${count == 1 ? 'episode' : 'episodes'}',
|
||||||
|
if (show.extras.isNotEmpty) '${show.extras.length} ${show.extras.length == 1 ? 'extra' : 'extras'}',
|
||||||
|
if (show.movies.isNotEmpty) '${show.movies.length} ${show.movies.length == 1 ? 'movie' : 'movies'}',
|
||||||
|
?show.group,
|
||||||
|
if (show.folders.length > 1) 'in ${show.folders.length} folders',
|
||||||
|
].join(' · ');
|
||||||
|
final String matchLine;
|
||||||
|
if (entry != null) {
|
||||||
|
final what = [
|
||||||
|
if (entry.format.isNotEmpty) entry.format.replaceAll('_', ' '),
|
||||||
|
if (entry.year != null) '${entry.year}',
|
||||||
|
if (entry.episodes != null) '${entry.episodes} eps',
|
||||||
|
].join(', ');
|
||||||
|
matchLine = [
|
||||||
|
'AniList: ${entry.romaji.isNotEmpty && entry.romaji != title ? entry.romaji : entry.title}'
|
||||||
|
'${what.isEmpty ? '' : ' ($what)'}',
|
||||||
|
if (title != show.title) 'files say "${show.title}"',
|
||||||
|
].join(' · ');
|
||||||
|
} else {
|
||||||
|
matchLine = busy ? 'Looking it up on AniList…' : 'Not matched on AniList';
|
||||||
|
}
|
||||||
|
final started = widget.lastPlayed != null;
|
||||||
|
final prompt = next == null ? null : skipPrompt(next, started: started);
|
||||||
|
final nextLabel = next == null
|
||||||
|
? 'Watched to the end'
|
||||||
|
: next.skipped != null
|
||||||
|
? (started ? 'Skip to ${next.episode.episode}' : 'Start at ${next.episode.episode}')
|
||||||
|
: '${started ? 'Next' : 'Play'}: ${episodeNumber(next.episode)}';
|
||||||
|
return LayoutBuilder(builder: (context, box) {
|
||||||
|
final h = (box.maxWidth * 0.28).clamp(220.0, 340.0);
|
||||||
|
final cover = entry?.cover;
|
||||||
|
return ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
child: SizedBox(
|
||||||
|
height: h,
|
||||||
|
child: Stack(fit: StackFit.expand, children: [
|
||||||
|
PosterImage(
|
||||||
|
url: entry?.banner ?? entry?.cover,
|
||||||
|
framePath: show.episodes.firstOrNull?.path,
|
||||||
|
thumbs: widget.actions.thumbs),
|
||||||
|
DecoratedBox(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.centerLeft,
|
||||||
|
end: Alignment.centerRight,
|
||||||
|
colors: [
|
||||||
|
scheme.surface.withValues(alpha: 0.96),
|
||||||
|
scheme.surface.withValues(alpha: 0.8),
|
||||||
|
scheme.surface.withValues(alpha: 0.15),
|
||||||
|
],
|
||||||
|
stops: const [0, 0.5, 1],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
|
child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||||
|
if (cover != null && box.maxWidth > 560) ...[
|
||||||
|
ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
child: SizedBox(
|
||||||
|
width: (h - 40) / 1.5,
|
||||||
|
child: PosterImage(url: cover, thumbs: widget.actions.thumbs),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 18),
|
||||||
|
],
|
||||||
|
Expanded(
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||||
|
Row(children: [
|
||||||
|
Flexible(
|
||||||
|
child: Text(title,
|
||||||
|
maxLines: 2, overflow: TextOverflow.ellipsis, style: t.headlineMedium),
|
||||||
|
),
|
||||||
|
if (m?.pinned != null) ...[
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Pill(m!.pinned == PinScope.folder ? 'pinned for this folder' : 'pinned', scheme.primary),
|
||||||
|
],
|
||||||
|
]),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Text(meta, style: t.bodyMedium?.copyWith(color: Colors.grey)),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(matchLine,
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: t.bodySmall?.copyWith(color: scheme.onSurfaceVariant)),
|
||||||
|
if (prompt != null) ...[
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
Row(children: [
|
||||||
|
Icon(Icons.info_outline, size: 16, color: scheme.tertiary),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Flexible(child: Text(prompt, style: t.bodyMedium?.copyWith(color: scheme.tertiary))),
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Wrap(spacing: 8, runSpacing: 8, children: [
|
||||||
|
FilledButton.icon(
|
||||||
|
key: const ValueKey('show-next'),
|
||||||
|
onPressed: next == null ? null : () => widget.actions.play(next.episode.path),
|
||||||
|
icon: const Icon(Icons.play_arrow),
|
||||||
|
label: Text(nextLabel),
|
||||||
|
),
|
||||||
|
OutlinedButton.icon(
|
||||||
|
onPressed: () => showWrongShowDialog(context, show, widget.matcher),
|
||||||
|
icon: const Icon(Icons.manage_search, size: 18),
|
||||||
|
label: const Text('Wrong show?'),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => showEpisodeNumbersDialog(context, show, widget.matcher),
|
||||||
|
child: const Text('Episode numbers…'),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _GapRow extends StatelessWidget {
|
||||||
|
final String text;
|
||||||
|
const _GapRow(this.text);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final scheme = Theme.of(context).colorScheme;
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(20, 6, 8, 6),
|
||||||
|
child: Row(children: [
|
||||||
|
Icon(Icons.more_vert, size: 18, color: scheme.onSurfaceVariant),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Expanded(
|
||||||
|
child: Text(text,
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(color: scheme.onSurfaceVariant, fontStyle: FontStyle.italic, fontSize: 13)),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// "Wrong show?": search AniList, pick the right entry, pin it for the
|
||||||
|
/// show everywhere or for one of its folders.
|
||||||
|
Future<void> showWrongShowDialog(BuildContext context, LocalShow show, ShowMatcher matcher) =>
|
||||||
|
showDialog<void>(context: context, builder: (context) => WrongShowDialog(show: show, matcher: matcher));
|
||||||
|
|
||||||
|
class WrongShowDialog extends StatefulWidget {
|
||||||
|
final LocalShow show;
|
||||||
|
final ShowMatcher matcher;
|
||||||
|
const WrongShowDialog({super.key, required this.show, required this.matcher});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<WrongShowDialog> createState() => _WrongShowDialogState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _WrongShowDialogState extends State<WrongShowDialog> {
|
||||||
|
late final _query = TextEditingController(text: widget.show.title.replaceAll(RegExp(r'[._]+'), ' '));
|
||||||
|
List<AniListEntry>? _hits;
|
||||||
|
String? _error;
|
||||||
|
bool _searching = false;
|
||||||
|
/// Null: the whole show; else one of its folders.
|
||||||
|
String? _folder;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_search();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_query.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _search() async {
|
||||||
|
setState(() {
|
||||||
|
_searching = true;
|
||||||
|
_error = null;
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
final hits = await widget.matcher.search(_query.text);
|
||||||
|
if (mounted) setState(() => _hits = hits);
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) setState(() => _error = 'AniList did not answer ($e)');
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _searching = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _pin(AniListEntry e) {
|
||||||
|
final folder = _folder;
|
||||||
|
folder == null ? widget.matcher.pinShow(widget.show.key, e) : widget.matcher.pinFolder(folder, e);
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final show = widget.show;
|
||||||
|
final m = widget.matcher;
|
||||||
|
final showPin = m.showPin(show.key);
|
||||||
|
final folderPins = [for (final f in show.folders) if (m.folderPin(f)?.folder == f) f];
|
||||||
|
final scheme = Theme.of(context).colorScheme;
|
||||||
|
return AlertDialog(
|
||||||
|
title: const Text('Wrong show?'),
|
||||||
|
content: SizedBox(
|
||||||
|
width: 520,
|
||||||
|
child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||||
|
Text('Search AniList and pick what "${show.title}" really is. '
|
||||||
|
'A pin wins over the automatic match and survives rescans.',
|
||||||
|
style: TextStyle(color: scheme.onSurfaceVariant)),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
TextField(
|
||||||
|
controller: _query,
|
||||||
|
autofocus: true,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
isDense: true,
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
hintText: 'Title on AniList',
|
||||||
|
suffixIcon: IconButton(tooltip: 'Search', icon: const Icon(Icons.search), onPressed: _search),
|
||||||
|
),
|
||||||
|
onSubmitted: (_) => _search(),
|
||||||
|
),
|
||||||
|
if (show.folders.length > 1 || folderPins.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
DropdownButton<String?>(
|
||||||
|
value: _folder,
|
||||||
|
isExpanded: true,
|
||||||
|
items: [
|
||||||
|
const DropdownMenuItem(value: null, child: Text('For this show everywhere')),
|
||||||
|
for (final f in show.folders)
|
||||||
|
DropdownMenuItem(value: f, child: Text('Only the folder "${baseName(f)}"', overflow: TextOverflow.ellipsis)),
|
||||||
|
],
|
||||||
|
onChanged: (v) => setState(() => _folder = v),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxHeight: 340),
|
||||||
|
child: _searching && _hits == null
|
||||||
|
? const Padding(padding: EdgeInsets.all(24), child: Center(child: CircularProgressIndicator()))
|
||||||
|
: _error != null
|
||||||
|
? Text(_error!, style: TextStyle(color: scheme.error))
|
||||||
|
: (_hits ?? const []).isEmpty
|
||||||
|
? const Padding(padding: EdgeInsets.all(12), child: Text('Nothing found'))
|
||||||
|
: ListView(shrinkWrap: true, children: [
|
||||||
|
for (final e in _hits!)
|
||||||
|
ListTile(
|
||||||
|
key: ValueKey('hit:${e.id}'),
|
||||||
|
dense: true,
|
||||||
|
leading: SizedBox(
|
||||||
|
width: 32,
|
||||||
|
height: 48,
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
child: e.coverSmall == null
|
||||||
|
? const ColoredBox(color: Colors.black26)
|
||||||
|
: Image.network(e.coverSmall!,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
errorBuilder: (_, _, _) => const ColoredBox(color: Colors.black26)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
title: Text(e.title, maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||||
|
subtitle: Text([
|
||||||
|
if (e.romaji.isNotEmpty && e.romaji != e.title) e.romaji,
|
||||||
|
if (e.format.isNotEmpty) e.format.replaceAll('_', ' '),
|
||||||
|
if (e.year != null) '${e.year}',
|
||||||
|
if (e.episodes != null) '${e.episodes} eps',
|
||||||
|
].join(' · '), maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||||
|
onTap: () => _pin(e),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
if (showPin != null)
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
m.unpinShow(show.key);
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
},
|
||||||
|
child: const Text('Remove the pin'),
|
||||||
|
),
|
||||||
|
for (final f in folderPins)
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
m.unpinFolder(f);
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
},
|
||||||
|
child: Text('Unpin "${baseName(f)}"'),
|
||||||
|
),
|
||||||
|
TextButton(onPressed: () => Navigator.of(context).pop(), child: const Text('Cancel')),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// "Episode numbers…": which episode the first file of each season is
|
||||||
|
/// ("these files are episodes N..M").
|
||||||
|
Future<void> showEpisodeNumbersDialog(BuildContext context, LocalShow show, ShowMatcher matcher) =>
|
||||||
|
showDialog<void>(context: context, builder: (context) => _EpisodeNumbersDialog(show: show, matcher: matcher));
|
||||||
|
|
||||||
|
class _EpisodeNumbersDialog extends StatefulWidget {
|
||||||
|
final LocalShow show;
|
||||||
|
final ShowMatcher matcher;
|
||||||
|
const _EpisodeNumbersDialog({required this.show, required this.matcher});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_EpisodeNumbersDialog> createState() => _EpisodeNumbersDialogState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _EpisodeNumbersDialogState extends State<_EpisodeNumbersDialog> {
|
||||||
|
late final _fields = {
|
||||||
|
for (final s in widget.show.seasons)
|
||||||
|
s.number: TextEditingController(
|
||||||
|
text: '${s.distinct.first.episode! + widget.matcher.offset(widget.show.key, s.number)}'),
|
||||||
|
};
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
for (final c in _fields.values) {
|
||||||
|
c.dispose();
|
||||||
|
}
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _save() {
|
||||||
|
for (final s in widget.show.seasons) {
|
||||||
|
final first = s.distinct.first.episode!;
|
||||||
|
final want = int.tryParse(_fields[s.number]!.text.trim());
|
||||||
|
if (want != null && want > 0) widget.matcher.setOffset(widget.show.key, s.number, want - first);
|
||||||
|
}
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => AlertDialog(
|
||||||
|
title: const Text('Episode numbers'),
|
||||||
|
content: SizedBox(
|
||||||
|
width: 420,
|
||||||
|
child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||||
|
const Text('When the files count differently from AniList, say which episode the first file is; '
|
||||||
|
'the others follow.'),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
for (final s in widget.show.seasons)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 8),
|
||||||
|
child: Row(children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text('${s.title}: the file numbered ${s.distinct.first.episode} is episode',
|
||||||
|
maxLines: 2),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
SizedBox(
|
||||||
|
width: 72,
|
||||||
|
child: TextField(
|
||||||
|
key: ValueKey('offset:${s.number}'),
|
||||||
|
controller: _fields[s.number],
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
decoration: const InputDecoration(isDense: true, border: OutlineInputBorder()),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(onPressed: () => Navigator.of(context).pop(), child: const Text('Cancel')),
|
||||||
|
FilledButton(onPressed: _save, child: const Text('Save')),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -339,9 +339,9 @@ class BrowseScope extends InheritedWidget {
|
|||||||
case 2:
|
case 2:
|
||||||
queue!(item, next: true);
|
queue!(item, next: true);
|
||||||
case 3:
|
case 3:
|
||||||
await copyToClipboard(jellyfinCopyTitle(item), done: 'Title copied');
|
if (context.mounted) await copyToClipboard(context, jellyfinCopyTitle(item), done: 'Title copied');
|
||||||
case 4:
|
case 4:
|
||||||
await openLink(jellyfinWebUrl(client.baseUrl, item.id, serverId: account.serverId));
|
if (context.mounted) await openLink(context, jellyfinWebUrl(client.baseUrl, item.id, serverId: account.serverId));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -35,15 +35,15 @@ typedef _Data = ({
|
|||||||
|
|
||||||
/// The selected card's details, at the right of the library: banner,
|
/// The selected card's details, at the right of the library: banner,
|
||||||
/// title, meta, "Put it up · S1E3" ("Play · S1E3" when watching
|
/// title, meta, "Put it up · S1E3" ("Play · S1E3" when watching
|
||||||
/// [solo]), mark watched / unwatched, remove from Continue, overview and,
|
/// [alone]), mark watched / unwatched, remove from Continue, overview and,
|
||||||
/// for a series or an episode, the episode list (click picks which
|
/// for a series or an episode, the episode list (click picks which
|
||||||
/// episode goes up, double-click puts it up). Reloads when the
|
/// episode goes up, double-click puts it up). Reloads when the
|
||||||
/// [controller]'s data version moves.
|
/// [controller]'s data version moves.
|
||||||
class JellyfinDetailsPanel extends StatefulWidget {
|
class JellyfinDetailsPanel extends StatefulWidget {
|
||||||
final BrowseController controller;
|
final BrowseController controller;
|
||||||
final JellyfinSelection selection;
|
final JellyfinSelection selection;
|
||||||
/// Nobody else watches (offline or solo): the button says Play.
|
/// Nobody else is in the server: the button says Play.
|
||||||
final bool solo;
|
final bool alone;
|
||||||
/// Puts [item] (playable) up; returns an error text, or null when sent.
|
/// Puts [item] (playable) up; returns an error text, or null when sent.
|
||||||
final Future<String?> Function(JellyfinSelection item) onPutUp;
|
final Future<String?> Function(JellyfinSelection item) onPutUp;
|
||||||
/// Adds [item] to Up next (with next: right after what plays); null: no
|
/// Adds [item] to Up next (with next: right after what plays); null: no
|
||||||
@@ -57,7 +57,7 @@ class JellyfinDetailsPanel extends StatefulWidget {
|
|||||||
required this.onPutUp,
|
required this.onPutUp,
|
||||||
this.onAddToQueue,
|
this.onAddToQueue,
|
||||||
required this.onClose,
|
required this.onClose,
|
||||||
this.solo = false,
|
this.alone = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -156,7 +156,7 @@ class _JellyfinDetailsPanelState extends State<JellyfinDetailsPanel> {
|
|||||||
final client = _client;
|
final client = _client;
|
||||||
final pick = await showAppMenu<int>(context, items: [
|
final pick = await showAppMenu<int>(context, items: [
|
||||||
if (queue) ...[
|
if (queue) ...[
|
||||||
AppMenuItem(value: 0, label: widget.solo ? 'Play' : 'Put it up'),
|
AppMenuItem(value: 0, label: widget.alone ? 'Play' : 'Put it up'),
|
||||||
const AppMenuItem(value: 1, label: 'Add to queue'),
|
const AppMenuItem(value: 1, label: 'Add to queue'),
|
||||||
const AppMenuItem(value: 2, label: 'Play next'),
|
const AppMenuItem(value: 2, label: 'Play next'),
|
||||||
],
|
],
|
||||||
@@ -171,9 +171,9 @@ class _JellyfinDetailsPanelState extends State<JellyfinDetailsPanel> {
|
|||||||
case 2:
|
case 2:
|
||||||
await _queue(e, next: true);
|
await _queue(e, next: true);
|
||||||
case 3:
|
case 3:
|
||||||
await copyToClipboard(jellyfinCopyTitle(e), done: 'Title copied');
|
if (context.mounted) await copyToClipboard(context, jellyfinCopyTitle(e), done: 'Title copied');
|
||||||
case 4:
|
case 4:
|
||||||
await openLink(jellyfinWebUrl(client!.baseUrl, e.id, serverId: _serverId));
|
if (context.mounted) await openLink(context, jellyfinWebUrl(client!.baseUrl, e.id, serverId: _serverId));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -308,7 +308,7 @@ class _JellyfinDetailsPanelState extends State<JellyfinDetailsPanel> {
|
|||||||
if (shown.communityRating != null) '★ ${shown.communityRating!.toStringAsFixed(1)}',
|
if (shown.communityRating != null) '★ ${shown.communityRating!.toStringAsFixed(1)}',
|
||||||
if (!isSeries && shown.userData.played) 'Watched',
|
if (!isSeries && shown.userData.played) 'Watched',
|
||||||
].join(' · ');
|
].join(' · ');
|
||||||
final verb = widget.solo ? 'Play' : 'Put it up';
|
final verb = widget.alone ? 'Play' : 'Put it up';
|
||||||
final label = playable != null && isSeries ? '$verb · ${playable.episodeLabel ?? playable.name}' : verb;
|
final label = playable != null && isSeries ? '$verb · ${playable.episodeLabel ?? playable.name}' : verb;
|
||||||
final inContinue = playable != null &&
|
final inContinue = playable != null &&
|
||||||
(playable.userData.isResumable || _c.continueKeys.contains(continueKey(playable)));
|
(playable.userData.isResumable || _c.continueKeys.contains(continueKey(playable)));
|
||||||
@@ -397,7 +397,7 @@ class _JellyfinDetailsPanelState extends State<JellyfinDetailsPanel> {
|
|||||||
IconButton(
|
IconButton(
|
||||||
key: const ValueKey('details-open-jellyfin'),
|
key: const ValueKey('details-open-jellyfin'),
|
||||||
tooltip: ReuseText.openInJellyfin,
|
tooltip: ReuseText.openInJellyfin,
|
||||||
onPressed: () => openLink(jellyfinWebUrl(client.baseUrl, shown.id, serverId: _serverId)),
|
onPressed: () => openLink(context, jellyfinWebUrl(client.baseUrl, shown.id, serverId: _serverId)),
|
||||||
icon: const Icon(Icons.open_in_browser_rounded),
|
icon: const Icon(Icons.open_in_browser_rounded),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'package:sync_relay_core/core.dart';
|
import 'package:sync_relay_core/core.dart';
|
||||||
|
|
||||||
|
import '../library/media_refs.dart';
|
||||||
import '../screens/home/picked_media.dart';
|
import '../screens/home/picked_media.dart';
|
||||||
import 'accounts.dart';
|
import 'accounts.dart';
|
||||||
|
|
||||||
@@ -89,5 +90,6 @@ JellyfinMedia pickFor(JellyfinAccount account, JellyfinItem item) {
|
|||||||
title: item.name,
|
title: item.name,
|
||||||
subtitle: subtitle,
|
subtitle: subtitle,
|
||||||
runtime: item.runTime,
|
runtime: item.runTime,
|
||||||
|
ref: jellyfinMediaRef(item),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -26,13 +26,14 @@ class PlayerSnapshot {
|
|||||||
/// (the session shows one quiet toast) and never touches playback. A 401
|
/// (the session shows one quiet toast) and never touches playback. A 401
|
||||||
/// calls [onUnauthorized] and ends reporting.
|
/// calls [onUnauthorized] and ends reporting.
|
||||||
///
|
///
|
||||||
/// Watched or not is [WatchSession]'s rule, not Jellyfin's position
|
/// Watched or not is [WatchSession]'s rule (time In, D3), not Jellyfin's
|
||||||
/// percentages (see ARCHITECTURE.md "Jellyfin in a session"): an item
|
/// position percentages (see ARCHITECTURE.md "Watch tracking"): the
|
||||||
/// played before ([playedBefore]) stays played (re-marked after Playing,
|
/// session's watch tracker feeds [watch] and this reporter only reads its
|
||||||
/// Progress without a position, Stopped without one, then marked played
|
/// verdict. An item played before ([playedBefore]) stays played
|
||||||
/// again); otherwise Progress leaves the position out past
|
/// (re-marked after Playing, Progress without a position, Stopped without
|
||||||
/// [_noPositionFrom] of the runtime so Jellyfin cannot mark it played on
|
/// one, then marked played again); otherwise Progress leaves the position
|
||||||
/// its own, and Stopped carries what the rule says.
|
/// out past [_noPositionFrom] of the runtime so Jellyfin cannot mark it
|
||||||
|
/// played on its own, and Stopped carries what the rule says.
|
||||||
class JellyfinProgressReporter {
|
class JellyfinProgressReporter {
|
||||||
final JellyfinClient client;
|
final JellyfinClient client;
|
||||||
final String itemId;
|
final String itemId;
|
||||||
@@ -43,7 +44,8 @@ class JellyfinProgressReporter {
|
|||||||
final void Function(Object error)? onError;
|
final void Function(Object error)? onError;
|
||||||
final VoidCallback? onUnauthorized;
|
final VoidCallback? onUnauthorized;
|
||||||
|
|
||||||
/// What this viewer watched of the item while it was open here.
|
/// What this viewer watched of the item while it was open here (fed by
|
||||||
|
/// the watch tracker, not by this reporter).
|
||||||
final WatchSession watch;
|
final WatchSession watch;
|
||||||
|
|
||||||
JellyfinProgressReporter({
|
JellyfinProgressReporter({
|
||||||
@@ -57,12 +59,14 @@ class JellyfinProgressReporter {
|
|||||||
this.onError,
|
this.onError,
|
||||||
this.onUnauthorized,
|
this.onUnauthorized,
|
||||||
this.onStopped,
|
this.onStopped,
|
||||||
|
WatchSession? watch,
|
||||||
DateTime Function()? clock,
|
DateTime Function()? clock,
|
||||||
}) : _clock = clock ?? DateTime.now,
|
}) : _clock = clock ?? DateTime.now,
|
||||||
_keepPlayed = playedBefore,
|
_keepPlayed = playedBefore,
|
||||||
_runtime = (source.runTimeTicks ?? 0) / jellyfinTicksPerSecond,
|
_runtime = (source.runTimeTicks ?? 0) / jellyfinTicksPerSecond,
|
||||||
watch = WatchSession(
|
watch = (watch ?? WatchSession())
|
||||||
playedBefore: playedBefore, resumeBefore: resumeTicksBefore / jellyfinTicksPerSecond);
|
..playedBefore = playedBefore
|
||||||
|
..resumeBefore = resumeTicksBefore / jellyfinTicksPerSecond;
|
||||||
|
|
||||||
/// Past this share of the runtime Progress carries no position: Jellyfin
|
/// Past this share of the runtime Progress carries no position: Jellyfin
|
||||||
/// marks an item played past its "max resume" share (90 % by default).
|
/// marks an item played past its "max resume" share (90 % by default).
|
||||||
@@ -126,7 +130,6 @@ class JellyfinProgressReporter {
|
|||||||
if (_playing || _dead) return;
|
if (_playing || _dead) return;
|
||||||
_playing = true;
|
_playing = true;
|
||||||
_mark(s);
|
_mark(s);
|
||||||
watch.sample(s.position, playing: !s.paused, at: _clock());
|
|
||||||
_send('Playing', () => client.reportPlaybackStart(reportFor(s)));
|
_send('Playing', () => client.reportPlaybackStart(reportFor(s)));
|
||||||
if (_keepPlayed) _send('played', () => client.markPlayed(itemId));
|
if (_keepPlayed) _send('played', () => client.markPlayed(itemId));
|
||||||
}
|
}
|
||||||
@@ -136,7 +139,6 @@ class JellyfinProgressReporter {
|
|||||||
if (!_playing || _dead) return;
|
if (!_playing || _dead) return;
|
||||||
final last = _last, lastAt = _lastAt, sentAt = _lastSentAt;
|
final last = _last, lastAt = _lastAt, sentAt = _lastSentAt;
|
||||||
final now = _clock();
|
final now = _clock();
|
||||||
watch.sample(s.position, playing: !s.paused, at: now);
|
|
||||||
var due = force || last == null || lastAt == null || sentAt == null;
|
var due = force || last == null || lastAt == null || sentAt == null;
|
||||||
if (!due) {
|
if (!due) {
|
||||||
final elapsed = now.difference(lastAt).inMilliseconds / 1000;
|
final elapsed = now.difference(lastAt).inMilliseconds / 1000;
|
||||||
@@ -163,11 +165,10 @@ class JellyfinProgressReporter {
|
|||||||
Future<void> stop(PlayerSnapshot s) {
|
Future<void> stop(PlayerSnapshot s) {
|
||||||
if (!_playing || _dead) return _chain;
|
if (!_playing || _dead) return _chain;
|
||||||
_playing = false;
|
_playing = false;
|
||||||
watch.sample(s.position, playing: !s.paused, at: _clock());
|
|
||||||
final v = _keepPlayed
|
final v = _keepPlayed
|
||||||
? const WatchVerdict(WatchOutcome.keepPlayed)
|
? const WatchVerdict(WatchOutcome.keepPlayed)
|
||||||
: watch.verdict(runtime: _runtime, endingStart: _endingStart);
|
: watch.verdict(runtime: _runtime, endingStart: _endingStart);
|
||||||
debugPrint('jellyfin $itemId: $v (covered ${watch.covered.round()} s of ${_runtime.round()} s)');
|
debugPrint('jellyfin $itemId: $v (${watch.credited.round()} s In of ${_runtime.round()} s)');
|
||||||
final int? ticks;
|
final int? ticks;
|
||||||
switch (v.outcome) {
|
switch (v.outcome) {
|
||||||
case WatchOutcome.played || WatchOutcome.keepPlayed:
|
case WatchOutcome.played || WatchOutcome.keepPlayed:
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ 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';
|
||||||
|
|
||||||
// Jellyfin media in a session: turning picks and pasted links into refs
|
// Jellyfin media in a session: turning picks and pasted links into refs
|
||||||
// (the wire carries only `{serverId, itemId, ...}`), the hosted server's
|
// (the wire carries only `{serverId, itemId, ...}`), the hosted server's
|
||||||
@@ -28,7 +28,8 @@ HelloMedia mediaForPick(JellyfinMedia m) => HelloMedia(
|
|||||||
name: m.title.isEmpty ? 'Jellyfin item' : m.title,
|
name: m.title.isEmpty ? 'Jellyfin item' : m.title,
|
||||||
size: 0,
|
size: 0,
|
||||||
duration: 0,
|
duration: 0,
|
||||||
jellyfin: refForPick(m));
|
jellyfin: refForPick(m),
|
||||||
|
ref: m.ref ?? jellyfinPickRef(m.title, m.subtitle));
|
||||||
|
|
||||||
/// The pick a ref stands for on a linked server (for highlighting it in
|
/// The pick a ref stands for on a linked server (for highlighting it in
|
||||||
/// the browser); null when this install has no account there.
|
/// the browser); null when this install has no account there.
|
||||||
@@ -78,7 +79,8 @@ final _sourceId = RegExp(r'^[A-Za-z0-9-]{1,64}$');
|
|||||||
/// is one.
|
/// is one.
|
||||||
Future<HelloMedia> mediaForLink(JellyfinAccounts accounts, String url,
|
Future<HelloMedia> mediaForLink(JellyfinAccounts accounts, String url,
|
||||||
{Duration timeout = const Duration(seconds: 6)}) async {
|
{Duration timeout = const Duration(seconds: 6)}) async {
|
||||||
final plain = HelloMedia(name: linkName(url), size: 0, duration: 0, url: url);
|
final name = linkName(url);
|
||||||
|
final plain = HelloMedia(name: name, size: 0, duration: 0, url: url, ref: linkMediaRef(name));
|
||||||
final link = parseJellyfinLink(url);
|
final link = parseJellyfinLink(url);
|
||||||
if (link == null) return plain;
|
if (link == null) return plain;
|
||||||
await accounts.ensureLoaded();
|
await accounts.ensureLoaded();
|
||||||
@@ -103,9 +105,11 @@ Future<HelloMedia> mediaForLink(JellyfinAccounts accounts, String url,
|
|||||||
);
|
);
|
||||||
final account = accounts.account(serverId);
|
final account = accounts.account(serverId);
|
||||||
final client = accounts.clientFor(serverId);
|
final client = accounts.clientFor(serverId);
|
||||||
|
MediaRef? what;
|
||||||
if (account != null && client != null) {
|
if (account != null && client != null) {
|
||||||
try {
|
try {
|
||||||
final pick = pickFor(account, await client.item(link.itemId).timeout(timeout));
|
final pick = pickFor(account, await client.item(link.itemId).timeout(timeout));
|
||||||
|
what = pick.ref;
|
||||||
ref = refForPick(JellyfinMedia(
|
ref = refForPick(JellyfinMedia(
|
||||||
serverId: serverId,
|
serverId: serverId,
|
||||||
baseUrl: account.baseUrl,
|
baseUrl: account.baseUrl,
|
||||||
@@ -120,7 +124,11 @@ Future<HelloMedia> mediaForLink(JellyfinAccounts accounts, String url,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return HelloMedia(
|
return HelloMedia(
|
||||||
name: ref.title.isEmpty ? 'Jellyfin item' : ref.title, size: 0, duration: 0, jellyfin: ref);
|
name: ref.title.isEmpty ? 'Jellyfin item' : ref.title,
|
||||||
|
size: 0,
|
||||||
|
duration: 0,
|
||||||
|
jellyfin: ref,
|
||||||
|
ref: what ?? jellyfinPickRef(ref.title, ref.subtitle ?? ''));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Display name for a link: last path segment, decoded, never the query
|
/// Display name for a link: last path segment, decoded, never the query
|
||||||
@@ -139,27 +147,28 @@ String linkName(String url) {
|
|||||||
return uri.host.isNotEmpty ? uri.host : 'link';
|
return uri.host.isNotEmpty ? uri.host : 'link';
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The hosted server's relay login: the account for [serverId] (the
|
/// The hosted server's relay logins: one per linked Jellyfin account
|
||||||
/// hosted item's server), else the default account, when its token is not
|
/// whose token is not known to be expired (the default account first),
|
||||||
/// known to be expired. The device fields are exactly the ones this app
|
/// so `/j` relays an item with its own server's login. The device fields
|
||||||
/// sends with that token ([JellyfinAccounts.clientInfo]): Jellyfin rewrites
|
/// are exactly the ones this app sends with those tokens
|
||||||
/// a token's device from the request header, so the relay must not differ.
|
/// ([JellyfinAccounts.clientInfo]): Jellyfin rewrites a token's device
|
||||||
JellyfinCredential? relayCredentialFor(JellyfinAccounts accounts, {String? serverId}) {
|
/// from the request header, so the relay must not differ.
|
||||||
bool usable(JellyfinAccount? a) =>
|
List<JellyfinCredential> relayCredentialsFor(JellyfinAccounts accounts) {
|
||||||
a != null && accounts.statusOf(a.serverId) != JellyfinAccountStatus.expired;
|
|
||||||
var a = serverId == null ? null : accounts.account(serverId);
|
|
||||||
if (!usable(a)) a = accounts.defaultAccount;
|
|
||||||
if (!usable(a)) return null;
|
|
||||||
final info = accounts.clientInfo;
|
final info = accounts.clientInfo;
|
||||||
return JellyfinCredential(
|
final first = accounts.defaultAccount;
|
||||||
baseUrl: a!.baseUrl,
|
return [
|
||||||
|
for (final a in [?first, ...accounts.accounts.where((a) => a.serverId != first?.serverId)])
|
||||||
|
if (accounts.statusOf(a.serverId) != JellyfinAccountStatus.expired)
|
||||||
|
JellyfinCredential(
|
||||||
|
baseUrl: a.baseUrl,
|
||||||
serverId: a.serverId,
|
serverId: a.serverId,
|
||||||
token: a.token,
|
token: a.token,
|
||||||
client: info.client,
|
client: info.client,
|
||||||
device: info.device,
|
device: info.device,
|
||||||
deviceId: info.deviceId,
|
deviceId: info.deviceId,
|
||||||
version: info.version,
|
version: info.version,
|
||||||
);
|
),
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `/j/...` URL of the current Jellyfin media on the host this client
|
/// `/j/...` URL of the current Jellyfin media on the host this client
|
||||||
@@ -331,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,81 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:hive_ce/hive.dart';
|
||||||
|
|
||||||
|
// Where the library's matches live (device-owned, one Hive box): AniList
|
||||||
|
// entries seen, automatic matches, the user's pins and episode offsets.
|
||||||
|
// Plain JSON-ish values under string keys; reads are synchronous once
|
||||||
|
// loaded, writes go through.
|
||||||
|
|
||||||
|
const libraryBoxName = 'library';
|
||||||
|
|
||||||
|
abstract interface class LibraryStore {
|
||||||
|
/// Loads the stored values (once).
|
||||||
|
Future<void> ensureLoaded();
|
||||||
|
Object? get(String key);
|
||||||
|
void put(String key, Object value);
|
||||||
|
void delete(String key);
|
||||||
|
|
||||||
|
/// Every key starting with [prefix].
|
||||||
|
Iterable<String> keys(String prefix);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// For tests and as the fallback when Hive is not available.
|
||||||
|
class MemoryLibraryStore implements LibraryStore {
|
||||||
|
final Map<String, Object> values;
|
||||||
|
MemoryLibraryStore([Map<String, Object>? values]) : values = values ?? {};
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> ensureLoaded() async {}
|
||||||
|
@override
|
||||||
|
Object? get(String key) => values[key];
|
||||||
|
@override
|
||||||
|
void put(String key, Object value) => values[key] = value;
|
||||||
|
@override
|
||||||
|
void delete(String key) => values.remove(key);
|
||||||
|
@override
|
||||||
|
Iterable<String> keys(String prefix) => values.keys.where((k) => k.startsWith(prefix)).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `library` box; until it opens (or when it cannot) it acts as an
|
||||||
|
/// empty memory store, so matching still works for the run.
|
||||||
|
class HiveLibraryStore implements LibraryStore {
|
||||||
|
Box? _box;
|
||||||
|
final _memory = MemoryLibraryStore();
|
||||||
|
Future<void>? _load;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> ensureLoaded() => _load ??= () async {
|
||||||
|
try {
|
||||||
|
final box = _box = await Hive.openBox(libraryBoxName);
|
||||||
|
// Writes made before the box opened.
|
||||||
|
for (final e in _memory.values.entries) {
|
||||||
|
await box.put(e.key, e.value);
|
||||||
|
}
|
||||||
|
_memory.values.clear();
|
||||||
|
} catch (e, st) {
|
||||||
|
debugPrint('open library store failed: $e\n$st');
|
||||||
|
}
|
||||||
|
}();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Object? get(String key) => _box?.get(key) ?? _memory.get(key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
void put(String key, Object value) {
|
||||||
|
final box = _box;
|
||||||
|
if (box == null) return _memory.put(key, value);
|
||||||
|
box.put(key, value).catchError((Object e) => debugPrint('library store put $key: $e'));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void delete(String key) {
|
||||||
|
_memory.delete(key);
|
||||||
|
_box?.delete(key).catchError((Object e) => debugPrint('library store delete $key: $e'));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Iterable<String> keys(String prefix) => [
|
||||||
|
...?_box?.keys.whereType<String>().where((k) => k.startsWith(prefix)),
|
||||||
|
..._memory.keys(prefix),
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:sync_relay_core/core.dart';
|
||||||
|
|
||||||
|
import '../files/scanner.dart' show FolderScan;
|
||||||
|
import 'show_matcher.dart';
|
||||||
|
|
||||||
|
// The next episode of a local show (the library's `nextAfter`, numbers
|
||||||
|
// skipped on the way included): what continues a file this app put up
|
||||||
|
// (see ARCHITECTURE.md "What's next").
|
||||||
|
|
||||||
|
/// [path]'s next episode: from the library the Files page saw this run
|
||||||
|
/// ([ShowMatcher.showAt]), else from the file's own folder read now (one
|
||||||
|
/// [scan], grouped by `groupLocal` with that folder's name as a hint). Null when there is none (the last
|
||||||
|
/// episode, not an episode, an unreadable folder).
|
||||||
|
class LocalNextFinder {
|
||||||
|
final ShowMatcher matcher;
|
||||||
|
final Future<FolderScan> Function(String dir) scan;
|
||||||
|
LocalNextFinder({required this.matcher, required this.scan});
|
||||||
|
|
||||||
|
Future<LocalNext?> nextAfter(String path) async {
|
||||||
|
var show = matcher.showAt(path);
|
||||||
|
if (show == null) {
|
||||||
|
final dir = File(path).parent.path;
|
||||||
|
final found = await scan(dir);
|
||||||
|
// The folder itself is a hint (a bare `01.mkv` in `Bleach`).
|
||||||
|
final lib = groupLocal(found.videos, roots: [Directory(dir).parent.path]);
|
||||||
|
show = lib.shows.where((s) => s.files.any((e) => e.path == path)).firstOrNull;
|
||||||
|
}
|
||||||
|
final at = show?.files.where((e) => e.path == path).firstOrNull;
|
||||||
|
if (show == null || at == null || at.kind != LocalKind.episode) return null;
|
||||||
|
return show.nextAfter(at.key);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import 'package:sync_relay_core/core.dart';
|
||||||
|
|
||||||
|
// `media.ref` (PROTOCOL.md "Media identity") for each source: what a media
|
||||||
|
// is, independent of where its bytes come from. Local files get theirs
|
||||||
|
// from the library (`ShowMatcher.refFor`, L1 + L2); these are the other
|
||||||
|
// two: Jellyfin items by their provider ids, links best effort from their
|
||||||
|
// name.
|
||||||
|
|
||||||
|
/// A Jellyfin episode, movie or video: kind, series (or movie) title,
|
||||||
|
/// season and episode as Jellyfin numbers them, and the AniList / TVDB ids
|
||||||
|
/// its metadata plugins set ([series]' when given: an episode's own ids
|
||||||
|
/// are the episode's, not the show's). Null for anything that is not
|
||||||
|
/// played (series, seasons, folders).
|
||||||
|
MediaRef? jellyfinMediaRef(JellyfinItem item, {JellyfinItem? series}) {
|
||||||
|
final kind = switch (item.kind) {
|
||||||
|
JellyfinItemKind.episode => 'episode',
|
||||||
|
JellyfinItemKind.movie || JellyfinItemKind.video => 'movie',
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
if (kind == null) return null;
|
||||||
|
final episode = kind == 'episode';
|
||||||
|
// An episode's Tvdb id names the episode; the show's comes from the
|
||||||
|
// series item. AniList ids are only ever set on shows and movies.
|
||||||
|
final owner = episode ? series : item;
|
||||||
|
final special = episode && item.parentIndexNumber == 0;
|
||||||
|
return MediaRef(
|
||||||
|
kind: special ? 'extra' : kind,
|
||||||
|
title: episode ? (item.seriesName ?? series?.name ?? item.name) : item.name,
|
||||||
|
anilistId: owner?.providerId('AniList'),
|
||||||
|
tvdbId: owner?.providerId('Tvdb'),
|
||||||
|
season: episode && !special ? item.parentIndexNumber : null,
|
||||||
|
ep: episode && !special ? item.indexNumber : null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final _sxe = RegExp(r'S(\d{1,3})\s?E(\d{1,4})$', caseSensitive: false);
|
||||||
|
|
||||||
|
/// A picked Jellyfin item from its card's lines alone ([title], and
|
||||||
|
/// [subtitle] "Series · S1E6" for episodes, the year for movies): kind,
|
||||||
|
/// title and numbers, no ids.
|
||||||
|
MediaRef jellyfinPickRef(String title, String subtitle) {
|
||||||
|
final sub = subtitle.trim();
|
||||||
|
final cut = sub.lastIndexOf(' · ');
|
||||||
|
final label = cut < 0 ? sub : sub.substring(cut + 3).trim();
|
||||||
|
final m = _sxe.firstMatch(label);
|
||||||
|
if (m == null) return MediaRef(kind: 'movie', title: title);
|
||||||
|
final season = int.parse(m.group(1)!);
|
||||||
|
return MediaRef(
|
||||||
|
kind: season == 0 ? 'extra' : 'episode',
|
||||||
|
title: cut < 0 ? title : sub.substring(0, cut).trim(),
|
||||||
|
season: season == 0 ? null : season,
|
||||||
|
ep: season == 0 ? null : int.parse(m.group(2)!),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A link, best effort: its name read like a release name (`Show - 05`
|
||||||
|
/// is an episode, `OP1` an extra); anything else a movie of that title.
|
||||||
|
MediaRef linkMediaRef(String name) {
|
||||||
|
final p = parseReleaseName(name);
|
||||||
|
final title = p.series.trim().isEmpty ? name.trim() : p.series.trim();
|
||||||
|
return switch (p.kind) {
|
||||||
|
ReleaseKind.episode => MediaRef(
|
||||||
|
kind: 'episode',
|
||||||
|
title: title,
|
||||||
|
season: p.season,
|
||||||
|
ep: p.season == null ? null : p.episode,
|
||||||
|
absolute: p.season == null ? p.episode : null),
|
||||||
|
ReleaseKind.extra => MediaRef(kind: 'extra', title: title, season: p.season),
|
||||||
|
ReleaseKind.movie || ReleaseKind.other => MediaRef(kind: 'movie', title: title),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The episode line a ref gives (`S3 E13`, `E108`), or null (movies;
|
||||||
|
/// extras keep the name's own marker).
|
||||||
|
String? refEpisodeLine(MediaRef ref) {
|
||||||
|
if (ref.kind != 'episode') return null;
|
||||||
|
if (ref.season != null && ref.ep != null) return 'S${ref.season} E${ref.ep}';
|
||||||
|
final n = ref.absolute ?? ref.ep ?? ref.anilistEp;
|
||||||
|
return n == null ? null : 'E$n';
|
||||||
|
}
|
||||||
@@ -0,0 +1,666 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart' show debugPrint, visibleForTesting;
|
||||||
|
import 'package:signals/signals.dart';
|
||||||
|
import 'package:sync_relay_core/core.dart';
|
||||||
|
|
||||||
|
import '../schedule/anilist.dart';
|
||||||
|
import '../schedule/animeflow.dart';
|
||||||
|
import 'library_store.dart';
|
||||||
|
|
||||||
|
// L2 of the library (ARCHITECTURE.md "Media identity"): local shows (L1)
|
||||||
|
// matched to AniList, device-owned and cached. A season of a show is
|
||||||
|
// searched once (title and synonyms, the season's number or name as
|
||||||
|
// hints) and the answer kept by show key, season and season name; its
|
||||||
|
// episodes map onto AniList entries along the run (a season AniList
|
||||||
|
// splits into cours continues on the SEQUEL entry), or onto AnimeFlow's
|
||||||
|
// `anilist_map` when signed in. The user's pins (a show everywhere, or one
|
||||||
|
// folder) and episode offsets override everything and survive rescans.
|
||||||
|
// Requests are lazy (only for shows on screen), one at a time and spaced
|
||||||
|
// out for AniList's rate limit; everything answers offline from the cache.
|
||||||
|
|
||||||
|
/// Where AnimeFlow places an AniList entry: the TVDB season runs of its
|
||||||
|
/// series ([AniListRun]) and the series' TVDB id.
|
||||||
|
class FlowShow {
|
||||||
|
final List<AniListRun> runs;
|
||||||
|
final int? tvdbId;
|
||||||
|
const FlowShow(this.runs, {this.tvdbId});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// AnimeFlow's knowledge of a show, when signed in.
|
||||||
|
abstract interface class FlowLookup {
|
||||||
|
/// The series [anilistId] belongs to; null when AnimeFlow has none (or
|
||||||
|
/// cannot be asked).
|
||||||
|
Future<FlowShow?> forAniList(int anilistId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [FlowLookup] over the AnimeFlow app API (`/library`, `/series`), read
|
||||||
|
/// once per app run.
|
||||||
|
class AnimeFlowLookup implements FlowLookup {
|
||||||
|
final AnimeFlowClient client;
|
||||||
|
AnimeFlowLookup(this.client);
|
||||||
|
|
||||||
|
Future<(List<AnimeFlowLibraryItem>, List<AnimeFlowSeries>)>? _load;
|
||||||
|
|
||||||
|
Future<(List<AnimeFlowLibraryItem>, List<AnimeFlowSeries>)> _fetch() async =>
|
||||||
|
(await client.library(), await client.series());
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<FlowShow?> forAniList(int anilistId) async {
|
||||||
|
final load = _load ??= _fetch();
|
||||||
|
final (List<AnimeFlowLibraryItem>, List<AnimeFlowSeries>) data;
|
||||||
|
try {
|
||||||
|
data = await load;
|
||||||
|
} catch (e) {
|
||||||
|
_load = null;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
final (library, series) = data;
|
||||||
|
for (final item in library) {
|
||||||
|
final has = item.anilistMap.any((r) => r.anilistId == anilistId) || item.anilistIds.values.contains(anilistId);
|
||||||
|
if (!has) continue;
|
||||||
|
final s = series
|
||||||
|
.where((s) =>
|
||||||
|
(item.seriesId != null && s.seriesId == item.seriesId) ||
|
||||||
|
(s.jellyfinSeriesId != null && s.jellyfinSeriesId == item.jellyfinSeriesId))
|
||||||
|
.firstOrNull;
|
||||||
|
return FlowShow(item.anilistMap, tvdbId: s?.tvdbId);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a pin covers.
|
||||||
|
enum PinScope { show, folder }
|
||||||
|
|
||||||
|
/// The AniList entry a season (or a movie, or an extra's show) is.
|
||||||
|
class SeasonMatch {
|
||||||
|
final AniListEntry entry;
|
||||||
|
|
||||||
|
/// Set by the user ("Wrong show?"), not by the search.
|
||||||
|
final PinScope? pinned;
|
||||||
|
const SeasonMatch(this.entry, {this.pinned});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An episode on AniList.
|
||||||
|
class EpisodeTarget {
|
||||||
|
final int anilistId;
|
||||||
|
final int? anilistEp;
|
||||||
|
const EpisodeTarget(this.anilistId, this.anilistEp);
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) => other is EpisodeTarget && other.anilistId == anilistId && other.anilistEp == anilistEp;
|
||||||
|
@override
|
||||||
|
int get hashCode => Object.hash(anilistId, anilistEp);
|
||||||
|
@override
|
||||||
|
String toString() => 'EpisodeTarget($anilistId, $anilistEp)';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The library's L2: see the file comment. Made by the composition root.
|
||||||
|
class ShowMatcher {
|
||||||
|
ShowMatcher({
|
||||||
|
required this.store,
|
||||||
|
required this.aniList,
|
||||||
|
this.flow,
|
||||||
|
this.spacing = const Duration(milliseconds: 800),
|
||||||
|
this.retryNoneAfter = const Duration(days: 3),
|
||||||
|
DateTime Function()? now,
|
||||||
|
Future<void> Function(Duration)? delay,
|
||||||
|
}) : _now = now ?? DateTime.now,
|
||||||
|
_delay = delay ?? Future<void>.delayed;
|
||||||
|
|
||||||
|
final LibraryStore store;
|
||||||
|
final AniListLookup aniList;
|
||||||
|
final FlowLookup? flow;
|
||||||
|
|
||||||
|
/// Between two AniList requests.
|
||||||
|
final Duration spacing;
|
||||||
|
|
||||||
|
/// A search that found nothing is asked again after this.
|
||||||
|
final Duration retryNoneAfter;
|
||||||
|
final DateTime Function() _now;
|
||||||
|
final Future<void> Function(Duration) _delay;
|
||||||
|
|
||||||
|
/// Bumps whenever something new is known (a match, an entry, a pin).
|
||||||
|
final revision = signal(0);
|
||||||
|
|
||||||
|
/// Shows being matched right now, by show key.
|
||||||
|
final busy = signal<Set<String>>(const {});
|
||||||
|
|
||||||
|
// Files seen in scans this run, for refs of files put up.
|
||||||
|
final _byPath = <String, LocalEpisode>{};
|
||||||
|
final _showByPath = <String, LocalShow>{};
|
||||||
|
final _entries = <int, AniListEntry>{};
|
||||||
|
final _flows = <int, FlowShow?>{};
|
||||||
|
final _jobs = <String, Future<void> Function()>{};
|
||||||
|
final _failed = <String, DateTime>{};
|
||||||
|
bool _pumping = false;
|
||||||
|
DateTime? _lastRequest;
|
||||||
|
DateTime? _pausedUntil;
|
||||||
|
|
||||||
|
/// Loads the cache (once); reads before it finishes see nothing.
|
||||||
|
Future<void> ensureLoaded() => store.ensureLoaded().then((_) => _bump());
|
||||||
|
|
||||||
|
// ------------------------------------------------------------- keys
|
||||||
|
|
||||||
|
static String matchKey(String showKey, int? season, String? seasonName) =>
|
||||||
|
'$showKey|${season ?? ''}|${normalizeSeries(seasonName ?? '')}';
|
||||||
|
|
||||||
|
static String _movieKey(LocalEpisode e) => '${e.showKey}|movie|${e.movieNumber ?? ''}';
|
||||||
|
|
||||||
|
String _keyOf(LocalEpisode e) =>
|
||||||
|
e.kind == LocalKind.movie ? _movieKey(e) : matchKey(e.showKey, e.season, e.seasonName);
|
||||||
|
|
||||||
|
// ------------------------------------------------------------- reads
|
||||||
|
|
||||||
|
/// Remembers [library]'s files, so [refForPath] and [showAt] know
|
||||||
|
/// their show.
|
||||||
|
void remember(LocalLibrary library) {
|
||||||
|
for (final e in library.files) {
|
||||||
|
_byPath[e.path] = e;
|
||||||
|
}
|
||||||
|
for (final s in library.shows) {
|
||||||
|
for (final e in s.files) {
|
||||||
|
_showByPath[e.path] = s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The show the file at [path] belongs to, as seen in a scan this run.
|
||||||
|
LocalShow? showAt(String path) => _showByPath[path];
|
||||||
|
|
||||||
|
AniListEntry? entry(int id) {
|
||||||
|
final known = _entries[id];
|
||||||
|
if (known != null) return known;
|
||||||
|
final raw = store.get('entry:$id');
|
||||||
|
final e = raw is Map ? AniListEntry.fromJson(raw['e']) : null;
|
||||||
|
if (e != null) _entries[id] = e;
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The user's pin for [showKey] (everywhere), if any.
|
||||||
|
int? showPin(String showKey) => _int(store.get('pin:show:$showKey'));
|
||||||
|
|
||||||
|
/// The pin of [folder] or the nearest folder above it, if any.
|
||||||
|
({String folder, int id})? folderPin(String folder) {
|
||||||
|
var f = folder;
|
||||||
|
while (f.isNotEmpty) {
|
||||||
|
final id = _int(store.get('pin:folder:$f'));
|
||||||
|
if (id != null) return (folder: f, id: id);
|
||||||
|
final cut = f.lastIndexOf(RegExp(r'[/\\]'));
|
||||||
|
if (cut <= 0) break;
|
||||||
|
f = f.substring(0, cut);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// "These files are episodes N..": added to every file's number of
|
||||||
|
/// [showKey]'s season [season].
|
||||||
|
int offset(String showKey, int? season) => _int(store.get('offset:$showKey|${season ?? ''}')) ?? 0;
|
||||||
|
|
||||||
|
/// The entry [e]'s season (or movie, or the show of an extra) is: a
|
||||||
|
/// folder pin, else the show's pin, else the search's answer.
|
||||||
|
SeasonMatch? seasonMatch(LocalEpisode e) {
|
||||||
|
if (e.kind == LocalKind.other) return null;
|
||||||
|
final fp = folderPin(e.folder);
|
||||||
|
if (fp != null) {
|
||||||
|
final en = entry(fp.id);
|
||||||
|
return en == null ? null : SeasonMatch(en, pinned: PinScope.folder);
|
||||||
|
}
|
||||||
|
final sp = showPin(e.showKey);
|
||||||
|
if (sp != null) {
|
||||||
|
final en = entry(sp);
|
||||||
|
return en == null ? null : SeasonMatch(en, pinned: PinScope.show);
|
||||||
|
}
|
||||||
|
final auto = store.get('auto:${_keyOf(e)}');
|
||||||
|
final id = auto is Map ? _int(auto['id']) : null;
|
||||||
|
if (id == null) {
|
||||||
|
// An extra of no matched season: the show's first one.
|
||||||
|
if (e.kind == LocalKind.extra && e.season != null) {
|
||||||
|
return seasonMatch(LocalEpisode(
|
||||||
|
file: e.file, parsed: e.parsed, kind: LocalKind.extra, showKey: e.showKey, title: e.title, extra: e.extra));
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
final en = entry(id);
|
||||||
|
return en == null ? null : SeasonMatch(en);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the show is as a whole: its first season's match (else a
|
||||||
|
/// movie's or an extra's).
|
||||||
|
SeasonMatch? showMatch(LocalShow show) {
|
||||||
|
for (final e in [
|
||||||
|
for (final s in show.seasons) s.episodes.first,
|
||||||
|
...show.movies,
|
||||||
|
...show.extras,
|
||||||
|
]) {
|
||||||
|
final m = seasonMatch(e);
|
||||||
|
if (m != null) return m;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The proper name of [show]: its match's title, else as found.
|
||||||
|
String titleOf(LocalShow show) => showMatch(show)?.entry.title ?? show.title;
|
||||||
|
|
||||||
|
/// The AniList entry and episode [e] is; for extras and movies only the
|
||||||
|
/// entry. Null before a match.
|
||||||
|
EpisodeTarget? target(LocalEpisode e) {
|
||||||
|
final m = seasonMatch(e);
|
||||||
|
if (m == null) return null;
|
||||||
|
if (e.kind != LocalKind.episode) return EpisodeTarget(m.entry.id, null);
|
||||||
|
final n = e.episode! + offset(e.showKey, e.season);
|
||||||
|
if (n < 1) return EpisodeTarget(m.entry.id, null);
|
||||||
|
final fs = _flowOf(m.entry.id);
|
||||||
|
if (fs != null && e.season != null) {
|
||||||
|
final runs = fs.runs.where((r) => r.season == e.season && r.firstEpisode <= n).toList()
|
||||||
|
..sort((a, b) => b.firstEpisode.compareTo(a.firstEpisode));
|
||||||
|
if (runs.isNotEmpty) return EpisodeTarget(runs.first.anilistId, n - runs.first.offset);
|
||||||
|
}
|
||||||
|
// Along the run: past an entry's episodes, its sequel's.
|
||||||
|
var cur = m.entry;
|
||||||
|
var k = n;
|
||||||
|
for (var hop = 0; hop < 30; hop++) {
|
||||||
|
final count = cur.episodes;
|
||||||
|
final next = cur.sequel;
|
||||||
|
if (count == null || k <= count || next == null) break;
|
||||||
|
k -= count;
|
||||||
|
final nx = entry(next);
|
||||||
|
if (nx == null) {
|
||||||
|
_wantEntry(next);
|
||||||
|
return EpisodeTarget(next, k);
|
||||||
|
}
|
||||||
|
cur = nx;
|
||||||
|
}
|
||||||
|
return EpisodeTarget(cur.id, k);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// AniList's title of [e]'s episode, when it knows one.
|
||||||
|
String? episodeTitle(LocalEpisode e) {
|
||||||
|
final t = target(e);
|
||||||
|
final ep = t?.anilistEp;
|
||||||
|
if (t == null || ep == null) return null;
|
||||||
|
return entry(t.anilistId)?.episodeTitles[ep];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What [e] is on the wire (`media.ref`): the match when there is one,
|
||||||
|
/// else what L1 read (kind, title, numbers).
|
||||||
|
MediaRef refFor(LocalEpisode e) {
|
||||||
|
final m = seasonMatch(e);
|
||||||
|
final t = target(e);
|
||||||
|
final n = e.episode == null ? null : e.episode! + offset(e.showKey, e.season);
|
||||||
|
final tvdb = m == null ? null : _flowOf(m.entry.id)?.tvdbId;
|
||||||
|
return MediaRef(
|
||||||
|
kind: switch (e.kind) {
|
||||||
|
LocalKind.episode => 'episode',
|
||||||
|
LocalKind.extra => 'extra',
|
||||||
|
LocalKind.movie || LocalKind.other => 'movie',
|
||||||
|
},
|
||||||
|
title: m?.entry.title ?? e.title,
|
||||||
|
anilistId: t?.anilistId ?? m?.entry.id,
|
||||||
|
anilistEp: t?.anilistEp,
|
||||||
|
tvdbId: tvdb,
|
||||||
|
season: e.kind == LocalKind.episode ? e.season : null,
|
||||||
|
ep: e.kind == LocalKind.episode && e.season != null ? n : null,
|
||||||
|
absolute: e.kind == LocalKind.episode && e.season == null ? n : null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [refFor] the file at [path]: as seen in a scan this run, else read
|
||||||
|
/// from its name and folder alone.
|
||||||
|
MediaRef refForPath(String path) =>
|
||||||
|
refFor(_byPath[path] ?? readLocalFile(LocalFile(path, 0, DateTime.fromMillisecondsSinceEpoch(0))));
|
||||||
|
|
||||||
|
/// The library's reading of [path] (null: not seen in a scan this run).
|
||||||
|
LocalEpisode? episodeAt(String path) => _byPath[path];
|
||||||
|
|
||||||
|
// ------------------------------------------------------------- matching
|
||||||
|
|
||||||
|
/// Matches [show]'s seasons and movies that have no answer yet, in the
|
||||||
|
/// background (see the file comment).
|
||||||
|
void want(LocalShow show) {
|
||||||
|
final seen = <String>{};
|
||||||
|
for (final e in [
|
||||||
|
for (final s in show.seasons) s.episodes.first,
|
||||||
|
...show.movies,
|
||||||
|
if (show.seasons.isEmpty) ...show.extras.take(1),
|
||||||
|
]) {
|
||||||
|
final key = _keyOf(e);
|
||||||
|
if (!seen.add(key)) continue;
|
||||||
|
final pinned = folderPin(e.folder)?.id ?? showPin(e.showKey);
|
||||||
|
if (pinned != null) {
|
||||||
|
if (entry(pinned) == null) _wantEntry(pinned);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
_wantMatch(key, show, e);
|
||||||
|
}
|
||||||
|
final m = showMatch(show);
|
||||||
|
if (m != null) _wantFlow(m.entry.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [want] for each standalone movie of [library] and each of [shows].
|
||||||
|
void wantMovie(LocalEpisode movie) {
|
||||||
|
if (movie.kind != LocalKind.movie) return;
|
||||||
|
_wantMatch(_movieKey(movie), null, movie);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _wantMatch(String key, LocalShow? show, LocalEpisode e) {
|
||||||
|
final auto = store.get('auto:$key');
|
||||||
|
if (auto is Map) {
|
||||||
|
final id = _int(auto['id']);
|
||||||
|
if (id != null) {
|
||||||
|
if (entry(id) == null) _wantEntry(id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final at = _int(auto['at']) ?? 0;
|
||||||
|
if (_now().difference(DateTime.fromMillisecondsSinceEpoch(at)) < retryNoneAfter) return;
|
||||||
|
}
|
||||||
|
if (_recentlyFailed(key)) return;
|
||||||
|
_enqueue('match:$key', () => _match(key, show?.title ?? e.title, e));
|
||||||
|
_setBusy(e.showKey, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _wantEntry(int id) {
|
||||||
|
if (_recentlyFailed('entry:$id')) return;
|
||||||
|
_enqueue('entry:$id', () async {
|
||||||
|
final en = await _ask(() => aniList.anime(id));
|
||||||
|
if (en != null) _keepEntry(en);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _wantFlow(int id) {
|
||||||
|
final f = flow;
|
||||||
|
if (f == null || _flows.containsKey(id)) return;
|
||||||
|
final stored = store.get('flow:$id');
|
||||||
|
if (stored is Map) {
|
||||||
|
_flows[id] = _flowFromJson(stored);
|
||||||
|
final at = _int(stored['at']) ?? 0;
|
||||||
|
if (_now().difference(DateTime.fromMillisecondsSinceEpoch(at)) < const Duration(days: 1)) return;
|
||||||
|
} else {
|
||||||
|
_flows[id] = null;
|
||||||
|
}
|
||||||
|
unawaited(f.forAniList(id).then((fs) {
|
||||||
|
if (fs == null) return;
|
||||||
|
_flows[id] = fs;
|
||||||
|
store.put('flow:$id', {
|
||||||
|
'at': _now().millisecondsSinceEpoch,
|
||||||
|
'tvdb': fs.tvdbId,
|
||||||
|
'runs': [
|
||||||
|
for (final r in fs.runs) [r.season, r.anilistId, r.firstEpisode, r.offset]
|
||||||
|
],
|
||||||
|
});
|
||||||
|
_bump();
|
||||||
|
}).catchError((Object e) => debugPrint('library: animeflow lookup $id: $e')));
|
||||||
|
}
|
||||||
|
|
||||||
|
FlowShow? _flowOf(int id) {
|
||||||
|
if (!_flows.containsKey(id)) {
|
||||||
|
final stored = store.get('flow:$id');
|
||||||
|
if (stored is Map) _flows[id] = _flowFromJson(stored);
|
||||||
|
}
|
||||||
|
return _flows[id];
|
||||||
|
}
|
||||||
|
|
||||||
|
static FlowShow? _flowFromJson(Map j) {
|
||||||
|
final runs = <AniListRun>[];
|
||||||
|
final raw = j['runs'];
|
||||||
|
if (raw is List) {
|
||||||
|
for (final r in raw) {
|
||||||
|
if (r is List && r.length == 4 && r.every((v) => v is int)) {
|
||||||
|
runs.add(AniListRun(
|
||||||
|
season: r[0] as int, anilistId: r[1] as int, firstEpisode: r[2] as int, offset: r[3] as int));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return runs.isEmpty ? null : FlowShow(runs, tvdbId: _int(j['tvdb']));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One search for the season [e] stands for; keeps the best entry (or
|
||||||
|
/// "none") under [key].
|
||||||
|
Future<void> _match(String key, String title, LocalEpisode e) async {
|
||||||
|
try {
|
||||||
|
final plain = title.replaceAll(RegExp(r'[._]+'), ' ').replaceAll(RegExp(r'\s+'), ' ').trim();
|
||||||
|
final movie = e.kind == LocalKind.movie;
|
||||||
|
final season = movie ? null : e.season;
|
||||||
|
final name = movie ? null : e.seasonName;
|
||||||
|
final query = [
|
||||||
|
plain,
|
||||||
|
if (name != null) name else if (season != null && season > 1) 'season $season',
|
||||||
|
].join(' ');
|
||||||
|
var hits = await _ask(() => aniList.searchAnime(query));
|
||||||
|
var best = pickMatch(hits, title: plain, season: season, seasonName: name, movie: movie);
|
||||||
|
if (best == null && query != plain) {
|
||||||
|
hits = await _ask(() => aniList.searchAnime(plain));
|
||||||
|
best = pickMatch(hits, title: plain, season: season, seasonName: name, movie: movie);
|
||||||
|
// Found the show but not the season: walk its sequels.
|
||||||
|
if (best != null && season != null && season > 1 && name == null && seasonMarker(best) != season) {
|
||||||
|
var cur = best;
|
||||||
|
for (var hop = 1; hop < season; hop++) {
|
||||||
|
final next = cur.sequel;
|
||||||
|
if (next == null) break;
|
||||||
|
final nx = entry(next) ?? await _ask(() => aniList.anime(next));
|
||||||
|
if (nx == null) break;
|
||||||
|
_keepEntry(nx);
|
||||||
|
cur = nx;
|
||||||
|
}
|
||||||
|
best = cur;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (final h in hits) {
|
||||||
|
if (h.id == best?.id) _keepEntry(h);
|
||||||
|
}
|
||||||
|
store.put('auto:$key', {'id': best?.id, 'at': _now().millisecondsSinceEpoch});
|
||||||
|
if (best != null) _wantFlow(best.id);
|
||||||
|
_bump();
|
||||||
|
} finally {
|
||||||
|
_setBusy(e.showKey, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// "Wrong show?": AniList's answers for [query], best first.
|
||||||
|
Future<List<AniListEntry>> search(String query) async {
|
||||||
|
final hits = await _ask(() => aniList.searchAnime(query));
|
||||||
|
return hits;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------- corrections
|
||||||
|
|
||||||
|
/// Pins [showKey] to [e] everywhere (every folder, every rescan).
|
||||||
|
void pinShow(String showKey, AniListEntry e) {
|
||||||
|
_keepEntry(e);
|
||||||
|
store.put('pin:show:$showKey', e.id);
|
||||||
|
_wantFlow(e.id);
|
||||||
|
_bump();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pins every video in [folder] (and below) to [e].
|
||||||
|
void pinFolder(String folder, AniListEntry e) {
|
||||||
|
_keepEntry(e);
|
||||||
|
store.put('pin:folder:$folder', e.id);
|
||||||
|
_wantFlow(e.id);
|
||||||
|
_bump();
|
||||||
|
}
|
||||||
|
|
||||||
|
void unpinShow(String showKey) {
|
||||||
|
store.delete('pin:show:$showKey');
|
||||||
|
_bump();
|
||||||
|
}
|
||||||
|
|
||||||
|
void unpinFolder(String folder) {
|
||||||
|
store.delete('pin:folder:$folder');
|
||||||
|
_bump();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// "These files are episodes N..": [offset] is added to the file
|
||||||
|
/// numbers of [showKey]'s season [season] (0 removes it).
|
||||||
|
void setOffset(String showKey, int? season, int offset) {
|
||||||
|
final key = 'offset:$showKey|${season ?? ''}';
|
||||||
|
offset == 0 ? store.delete(key) : store.put(key, offset);
|
||||||
|
_bump();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Forgets the automatic match of [show] so it is searched again.
|
||||||
|
void rematch(LocalShow show) {
|
||||||
|
for (final k in store.keys('auto:${show.key}|').toList()) {
|
||||||
|
store.delete(k);
|
||||||
|
}
|
||||||
|
want(show);
|
||||||
|
_bump();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------- plumbing
|
||||||
|
|
||||||
|
void _keepEntry(AniListEntry e) {
|
||||||
|
_entries[e.id] = e;
|
||||||
|
store.put('entry:${e.id}', {'at': _now().millisecondsSinceEpoch, 'e': e.toJson()});
|
||||||
|
_bump();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _bump() => revision.value++;
|
||||||
|
|
||||||
|
void _setBusy(String showKey, bool on) {
|
||||||
|
final now = {...busy.peek()};
|
||||||
|
if (on ? now.add(showKey) : now.remove(showKey)) busy.value = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _recentlyFailed(String key) {
|
||||||
|
final at = _failed[key];
|
||||||
|
return at != null && _now().difference(at) < const Duration(minutes: 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _enqueue(String key, Future<void> Function() job) {
|
||||||
|
if (_jobs.containsKey(key)) return;
|
||||||
|
_jobs[key] = () => job().catchError((Object e) {
|
||||||
|
_failed[key] = _now();
|
||||||
|
debugPrint('library: $key failed: $e');
|
||||||
|
if (e is AniListException && e.isRateLimited) _pausedUntil = _now().add(const Duration(minutes: 1));
|
||||||
|
});
|
||||||
|
if (!_pumping) unawaited(_pump());
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _pump() async {
|
||||||
|
_pumping = true;
|
||||||
|
try {
|
||||||
|
while (_jobs.isNotEmpty) {
|
||||||
|
final key = _jobs.keys.first;
|
||||||
|
final job = _jobs[key]!;
|
||||||
|
await job();
|
||||||
|
_jobs.remove(key);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
_pumping = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One AniList request, spaced from the last one (and after a rate
|
||||||
|
/// limit's pause).
|
||||||
|
Future<T> _ask<T>(Future<T> Function() request) async {
|
||||||
|
final paused = _pausedUntil;
|
||||||
|
if (paused != null && paused.isAfter(_now())) await _delay(paused.difference(_now()));
|
||||||
|
final last = _lastRequest;
|
||||||
|
if (last != null) {
|
||||||
|
final wait = spacing - _now().difference(last);
|
||||||
|
if (wait > Duration.zero) await _delay(wait);
|
||||||
|
}
|
||||||
|
_lastRequest = _now();
|
||||||
|
return request();
|
||||||
|
}
|
||||||
|
|
||||||
|
@visibleForTesting
|
||||||
|
bool get idle => _jobs.isEmpty && !_pumping;
|
||||||
|
|
||||||
|
static int? _int(Object? v) => v is int ? v : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ scoring
|
||||||
|
|
||||||
|
/// The season number an entry's names carry (`Season 2`, `2nd Season`,
|
||||||
|
/// `Part 2` is not one); null when none does.
|
||||||
|
int? seasonMarker(AniListEntry e) {
|
||||||
|
for (final n in e.names) {
|
||||||
|
final s = _seasonOf(normalizeSeries(n));
|
||||||
|
if (s != null) return s;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final _seasonWords = RegExp(r'\b(?:season (\d{1,2})|(\d{1,2})(?:st|nd|rd|th) season|s(\d{1,2}))\b');
|
||||||
|
final _roman = {'ii': 2, 'iii': 3, 'iv': 4, 'v': 5, 'vi': 6};
|
||||||
|
|
||||||
|
int? _seasonOf(String normalized) {
|
||||||
|
final m = _seasonWords.firstMatch(normalized);
|
||||||
|
if (m != null) return int.parse(m.group(1) ?? m.group(2) ?? m.group(3)!);
|
||||||
|
final last = normalized.split(' ').last;
|
||||||
|
return _roman[last];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How alike two normalized titles are, 0..1.
|
||||||
|
double titleSimilarity(String a, String b) {
|
||||||
|
if (a.isEmpty || b.isEmpty) return 0;
|
||||||
|
if (a == b) return 1;
|
||||||
|
final short = a.length <= b.length ? a : b;
|
||||||
|
final long = a.length <= b.length ? b : a;
|
||||||
|
final ratio = short.length / long.length;
|
||||||
|
if (long.startsWith('$short ')) return 0.8 + 0.2 * ratio;
|
||||||
|
if (long.contains(' $short ') || long.endsWith(' $short')) return 0.6 + 0.2 * ratio;
|
||||||
|
// Dice over letter pairs, spaces ignored.
|
||||||
|
Set<String> pairs(String s) {
|
||||||
|
final t = s.replaceAll(' ', '');
|
||||||
|
return {for (var i = 0; i + 1 < t.length; i++) t.substring(i, i + 2)};
|
||||||
|
}
|
||||||
|
|
||||||
|
final pa = pairs(a), pb = pairs(b);
|
||||||
|
if (pa.isEmpty || pb.isEmpty) return 0;
|
||||||
|
return 2 * pa.intersection(pb).length / (pa.length + pb.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Scores [e] as the answer for a show called [title] (its season
|
||||||
|
/// [season] or its season named [seasonName]; a [movie]); [rank]: its
|
||||||
|
/// place in AniList's own order.
|
||||||
|
double scoreMatch(AniListEntry e,
|
||||||
|
{required String title, int? season, String? seasonName, bool movie = false, int rank = 0}) {
|
||||||
|
final q = normalizeSeries(title);
|
||||||
|
final full = seasonName == null ? q : normalizeSeries('$title $seasonName');
|
||||||
|
var best = 0.0;
|
||||||
|
for (final n in e.names) {
|
||||||
|
final nn = normalizeSeries(n);
|
||||||
|
final s = [titleSimilarity(nn, q), titleSimilarity(nn, full)].reduce((a, b) => a > b ? a : b);
|
||||||
|
if (s > best) best = s;
|
||||||
|
}
|
||||||
|
var score = best;
|
||||||
|
if (seasonName != null) {
|
||||||
|
final sub = normalizeSeries(seasonName);
|
||||||
|
if (e.names.any((n) => normalizeSeries(n).contains(sub))) score += 0.25;
|
||||||
|
} else if (!movie) {
|
||||||
|
final wanted = season ?? 1;
|
||||||
|
final marker = seasonMarker(e);
|
||||||
|
if (marker != null && marker == wanted) {
|
||||||
|
score += 0.2;
|
||||||
|
} else if (marker != null) {
|
||||||
|
score -= 0.25;
|
||||||
|
} else if (wanted > 1) {
|
||||||
|
score -= 0.1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (movie) {
|
||||||
|
score += e.isMovie ? 0.15 : -0.25;
|
||||||
|
} else {
|
||||||
|
score += e.isSeries ? 0.05 : (e.isMovie ? -0.3 : -0.1);
|
||||||
|
}
|
||||||
|
return score - rank * 0.02;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The best of [hits] for a show called [title], if any is good enough.
|
||||||
|
AniListEntry? pickMatch(List<AniListEntry> hits,
|
||||||
|
{required String title, int? season, String? seasonName, bool movie = false}) {
|
||||||
|
AniListEntry? best;
|
||||||
|
var bestScore = 0.55;
|
||||||
|
for (var i = 0; i < hits.length; i++) {
|
||||||
|
final s = scoreMatch(hits[i], title: title, season: season, seasonName: seasonName, movie: movie, rank: i);
|
||||||
|
if (s > bestScore) {
|
||||||
|
best = hits[i];
|
||||||
|
bestScore = s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@ import 'package:sync_relay_core/core.dart';
|
|||||||
|
|
||||||
import '../screens/home/ui.dart' show appName, displayVersion;
|
import '../screens/home/ui.dart' show appName, displayVersion;
|
||||||
import 'ports/app_dirs.dart';
|
import 'ports/app_dirs.dart';
|
||||||
import '../state/session_state.dart';
|
import '../app/app_graph.dart';
|
||||||
|
|
||||||
// The app's log on disk (<app support>/logs/app-YYYY-MM-DD.log; core's
|
// The app's log on disk (<app support>/logs/app-YYYY-MM-DD.log; core's
|
||||||
// logging.dart writes, rotates and redacts it), debugPrint routed into it,
|
// logging.dart writes, rotates and redacts it), debugPrint routed into it,
|
||||||
@@ -50,19 +50,19 @@ Future<void> startAppLogging(AppDirs dirs) async {
|
|||||||
/// the app log (its hosted server's records included), already redacted;
|
/// the app log (its hosted server's records included), already redacted;
|
||||||
/// with [sessionLog], the server's session log of the same hours after it
|
/// with [sessionLog], the server's session log of the same hours after it
|
||||||
/// (every member's shared lines and the server's, merged).
|
/// (every member's shared lines and the server's, merged).
|
||||||
Future<String> diagnosticsText({Duration since = const Duration(hours: 6), bool sessionLog = false}) async {
|
Future<String> diagnosticsText(AppGraph graph, {Duration since = const Duration(hours: 6), bool sessionLog = false}) async {
|
||||||
final b = StringBuffer()
|
final b = StringBuffer()
|
||||||
..writeln('$appName ${displayVersion()}')
|
..writeln('$appName ${displayVersion()}')
|
||||||
..writeln('OS: ${Platform.operatingSystem} ${Platform.operatingSystemVersion}')
|
..writeln('OS: ${Platform.operatingSystem} ${Platform.operatingSystemVersion}')
|
||||||
..writeln('Dart: ${Platform.version.split(' ').first}');
|
..writeln('Dart: ${Platform.version.split(' ').first}');
|
||||||
try {
|
try {
|
||||||
final mpv = await SessionState.instance.coordinator.playback.mpvVersion().timeout(const Duration(seconds: 2));
|
final mpv = await graph.coordinator.playback.mpvVersion().timeout(const Duration(seconds: 2));
|
||||||
b.writeln('mpv: ${mpv ?? 'no player yet'}');
|
b.writeln('mpv: ${mpv ?? 'no player yet'}');
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
b.writeln('mpv: unknown');
|
b.writeln('mpv: unknown');
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
final y = await SessionState.instance.ports.links.ytDlp.timeout(const Duration(seconds: 2));
|
final y = await graph.ports.links.ytDlp.timeout(const Duration(seconds: 2));
|
||||||
final cookies = y.cookies.read(count: false);
|
final cookies = y.cookies.read(count: false);
|
||||||
b.writeln('yt-dlp: ${y.version ?? 'not ready'}${y.lastError == null ? '' : ' (${y.lastError})'}, '
|
b.writeln('yt-dlp: ${y.version ?? 'not ready'}${y.lastError == null ? '' : ' (${y.lastError})'}, '
|
||||||
'cookies: ${cookies == null ? 'none' : 'set'}');
|
'cookies: ${cookies == null ? 'none' : 'set'}');
|
||||||
@@ -74,7 +74,7 @@ Future<String> diagnosticsText({Duration since = const Duration(hours: 6), bool
|
|||||||
b.writeln('--- last ${since.inHours} h of the log ---');
|
b.writeln('--- last ${since.inHours} h of the log ---');
|
||||||
b.write(files?.recent(since) ?? '');
|
b.write(files?.recent(since) ?? '');
|
||||||
if (sessionLog) {
|
if (sessionLog) {
|
||||||
final server = await SessionState.instance.coordinator.sessionLog(hours: since.inHours);
|
final server = await graph.coordinator.sessionLog(hours: since.inHours);
|
||||||
b
|
b
|
||||||
..writeln()
|
..writeln()
|
||||||
..writeln("--- last ${since.inHours} h of the server's session log ---")
|
..writeln("--- last ${since.inHours} h of the server's session log ---")
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import 'dart:io';
|
|||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
import '../files/thumbnails.dart';
|
import '../files/thumbnails.dart';
|
||||||
import '../state/session_state.dart';
|
import '../app/app_graph.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
|
||||||
@@ -18,33 +18,45 @@ bool _shuttingDown = false;
|
|||||||
/// True once [shutDownApp] started.
|
/// True once [shutDownApp] started.
|
||||||
bool get shuttingDown => _shuttingDown;
|
bool get shuttingDown => _shuttingDown;
|
||||||
|
|
||||||
|
/// The systems the close stops; `main` hands them over once.
|
||||||
|
AppGraph? _graph;
|
||||||
|
|
||||||
|
/// Makes [graph] the one [shutDownApp] stops and returns the close
|
||||||
|
/// request handler for the window.
|
||||||
|
Future<void> Function() closeAppWith(AppGraph graph) {
|
||||||
|
_graph = graph;
|
||||||
|
return shutDownApp;
|
||||||
|
}
|
||||||
|
|
||||||
/// Hides the window, releases every player and the sound device, leaves
|
/// Hides the window, releases every player and the sound device, leaves
|
||||||
/// (the `bye` goes out first, so others see "left" at once; a hosted
|
/// (the `bye` goes out first, so others see "left" at once; then your
|
||||||
/// server stops), runs [beforeQuit], then closes the window. [restart]
|
/// server stops), runs [beforeQuit], then closes the window. [restart]
|
||||||
/// (Update and restart) leaves without the `bye`: the member stays
|
/// (Update and restart) leaves without the `bye`: the member stays
|
||||||
/// `gone`, and the restarted app, same client id, takes it back within
|
/// `gone`, and the restarted app, same client id, takes it back within
|
||||||
/// the grace. Every step is bounded; a second call does nothing.
|
/// the grace. Every step is bounded; a second call does nothing.
|
||||||
Future<void> shutDownApp({Future<void> Function()? beforeQuit, bool restart = false}) async {
|
Future<void> shutDownApp({Future<void> Function()? beforeQuit, bool restart = false}) async {
|
||||||
if (_shuttingDown) return;
|
final graph = _graph;
|
||||||
|
if (_shuttingDown || graph == null) return;
|
||||||
_shuttingDown = true;
|
_shuttingDown = true;
|
||||||
final session = SessionState.instance;
|
final shell = graph.ports.shell;
|
||||||
final shell = session.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));
|
||||||
await _step('hide', shell.hide(), const Duration(milliseconds: 500));
|
await _step('hide', shell.hide(), const Duration(milliseconds: 500));
|
||||||
await Future.wait([
|
await Future.wait([
|
||||||
_step('players', session.coordinator.releasePlayers(), const Duration(seconds: 3)),
|
_step('players', graph.coordinator.releasePlayers(), const Duration(seconds: 3)),
|
||||||
_step('thumbnails', cancelAllThumbnails(), const Duration(milliseconds: 1500)),
|
_step('thumbnails', cancelAllThumbnails(), const Duration(milliseconds: 1500)),
|
||||||
_step('sounds', session.sfx.dispose(), const Duration(milliseconds: 500)),
|
_step('sounds', graph.sfx.dispose(), const Duration(milliseconds: 500)),
|
||||||
]);
|
]);
|
||||||
|
// The host owns the disposal order: anything still alive goes now.
|
||||||
|
await _step('all players', graph.ports.players.disposeAll(), const Duration(milliseconds: 1500));
|
||||||
// Windows: media_kit_video frees a player's video output on a thread of
|
// Windows: media_kit_video frees a player's video output on a thread of
|
||||||
// its own after dispose returns; it must be done before the engine
|
// its own after dispose returns; it must be done before the engine
|
||||||
// stops, or the plugin's teardown waits on it for good.
|
// stops, or the plugin's teardown waits on it for good.
|
||||||
if (Platform.isWindows) await Future.delayed(const Duration(milliseconds: 300));
|
if (Platform.isWindows) await Future.delayed(const Duration(milliseconds: 300));
|
||||||
// Jellyfin stop report, last diagnostics, the bye (flushed), then the
|
// Jellyfin stop report, last diagnostics, the bye (flushed), then your
|
||||||
// hosted server: 3 s in all.
|
// server: 3 s in all.
|
||||||
await _step('leave', session.leave(bye: !restart), const Duration(milliseconds: 2500));
|
await _step('leave', graph.navigation.leave(bye: !restart), const Duration(milliseconds: 2500));
|
||||||
await _step('server stop', session.stopServer(), const Duration(milliseconds: 500));
|
await _step('server stop', graph.yourServer.stop(), const Duration(milliseconds: 500));
|
||||||
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,4 +1,5 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
|
|
||||||
import 'package:flutter_soloud/flutter_soloud.dart';
|
import 'package:flutter_soloud/flutter_soloud.dart';
|
||||||
@@ -50,6 +51,13 @@ class SoundEngine {
|
|||||||
|
|
||||||
Future<bool>? _ready;
|
Future<bool>? _ready;
|
||||||
var _disposed = false;
|
var _disposed = false;
|
||||||
|
|
||||||
|
/// A file that exists only while the audio device is opening (set by the
|
||||||
|
/// app). Found at the next start, the last open took the app down (a
|
||||||
|
/// native crash in the driver or miniaudio, seen on Windows): sounds stay
|
||||||
|
/// off rather than crash every start, until [retry] (Settings' preview).
|
||||||
|
String? crashMark;
|
||||||
|
var _skipped = false;
|
||||||
/// Per slot: the loaded key and its sound (null: failed to load).
|
/// Per slot: the loaded key and its sound (null: failed to load).
|
||||||
final Map<String, (String, Future<Object?>)> _slots = {};
|
final Map<String, (String, Future<Object?>)> _slots = {};
|
||||||
/// Load names are unique: SoLoud hands back the old sound for a name it
|
/// Load names are unique: SoLoud hands back the old sound for a name it
|
||||||
@@ -61,15 +69,43 @@ class SoundEngine {
|
|||||||
|
|
||||||
Future<bool> _init() async {
|
Future<bool> _init() async {
|
||||||
if (_disposed) return false;
|
if (_disposed) return false;
|
||||||
|
final mark = crashMark == null ? null : File(crashMark!);
|
||||||
|
try {
|
||||||
|
if (mark != null && mark.existsSync()) {
|
||||||
|
_skipped = true;
|
||||||
|
_log.warning('no sounds: opening the audio device crashed the app last time '
|
||||||
|
'(${mark.path}); a sound preview in Settings tries again');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
mark?.writeAsStringSync('opening the audio device');
|
||||||
|
} catch (_) {}
|
||||||
|
_log.info('opening the audio device');
|
||||||
try {
|
try {
|
||||||
await _backend.init();
|
await _backend.init();
|
||||||
|
_log.info('audio device open');
|
||||||
return true;
|
return true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_log.warning('no sounds: audio init failed: $e');
|
_log.warning('no sounds: audio init failed: $e');
|
||||||
return false;
|
return false;
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
if (mark != null && mark.existsSync()) mark.deleteSync();
|
||||||
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Opens the device again after a crash mark kept sounds off (the user
|
||||||
|
/// asked for a sound). A real crash leaves the mark again.
|
||||||
|
void retry() {
|
||||||
|
if (!_skipped) return;
|
||||||
|
_skipped = false;
|
||||||
|
try {
|
||||||
|
final m = crashMark;
|
||||||
|
if (m != null) File(m).deleteSync();
|
||||||
|
} catch (_) {}
|
||||||
|
_ready = null;
|
||||||
|
}
|
||||||
|
|
||||||
/// Plays the sound of [slot] at [volume] (linear gain). [key] names what
|
/// Plays the sound of [slot] at [volume] (linear gain). [key] names what
|
||||||
/// [wav] renders: the same key reuses the loaded sound; another key, or
|
/// [wav] renders: the same key reuses the loaded sound; another key, or
|
||||||
/// [fresh], renders and loads it again.
|
/// [fresh], renders and loads it again.
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||||
|
|
||||||
|
import '../desktop_entry.dart' show appId;
|
||||||
|
import '../ports/system_notifications.dart';
|
||||||
|
|
||||||
|
/// [SystemNotifications] on the desktop, through flutter_local_notifications'
|
||||||
|
/// platform plugins (used directly, not through its facade, so the platform
|
||||||
|
/// is picked by the OS and not by Flutter's target platform):
|
||||||
|
/// - Linux: `org.freedesktop.Notifications` over D-Bus (pure Dart), with the
|
||||||
|
/// app's icon (the bundled PNG, else the launcher entry's icon by name);
|
||||||
|
/// a click is the notification's default action.
|
||||||
|
/// - Windows: toast notifications (WinRT), the app's name and icon, a click
|
||||||
|
/// activates the app while it runs (an unpackaged app has no history).
|
||||||
|
/// Silent: the app plays its own sound for the same notice. Anything that
|
||||||
|
/// fails (no notification daemon, no session bus) is logged and dropped.
|
||||||
|
class DesktopNotifications implements SystemNotifications {
|
||||||
|
DesktopNotifications({this.appName = 'Starsync'});
|
||||||
|
|
||||||
|
final String appName;
|
||||||
|
void Function(String payload)? _onClick;
|
||||||
|
Future<bool>? _ready;
|
||||||
|
LinuxFlutterLocalNotificationsPlugin? _linux;
|
||||||
|
FlutterLocalNotificationsWindows? _windows;
|
||||||
|
// Tag -> id, so a notification with the same tag replaces the last one.
|
||||||
|
final _ids = <String, int>{};
|
||||||
|
int _next = 1;
|
||||||
|
|
||||||
|
@override
|
||||||
|
set onClick(void Function(String payload)? handler) => _onClick = handler;
|
||||||
|
|
||||||
|
void _clicked(NotificationResponse r) => _onClick?.call(r.payload ?? '');
|
||||||
|
|
||||||
|
/// The bundled 256 px icon next to the executable (a release or a
|
||||||
|
/// `flutter run` build), else the icon the launcher entry installed.
|
||||||
|
static LinuxNotificationIcon _linuxIcon() {
|
||||||
|
final exe = File(Platform.resolvedExecutable).parent.path;
|
||||||
|
final png = File('$exe/data/flutter_assets/assets/icon/icon_256.png');
|
||||||
|
return png.existsSync() ? FilePathLinuxIcon(png.path) : ThemeLinuxIcon(appId);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> _init() => _ready ??= () async {
|
||||||
|
try {
|
||||||
|
if (Platform.isLinux) {
|
||||||
|
final p = _linux = LinuxFlutterLocalNotificationsPlugin();
|
||||||
|
return await p.initialize(
|
||||||
|
settings: LinuxInitializationSettings(defaultActionName: 'Open', defaultIcon: _linuxIcon()),
|
||||||
|
onDidReceiveNotificationResponse: _clicked,
|
||||||
|
) ??
|
||||||
|
false;
|
||||||
|
}
|
||||||
|
if (Platform.isWindows) {
|
||||||
|
final exe = File(Platform.resolvedExecutable).parent.path;
|
||||||
|
final icon = File('$exe/data/flutter_assets/assets/icon/icon_256.png');
|
||||||
|
final p = _windows = FlutterLocalNotificationsWindows();
|
||||||
|
return await p.initialize(
|
||||||
|
settings: WindowsInitializationSettings(
|
||||||
|
appName: appName,
|
||||||
|
appUserModelId: 'Foxyz.Starsync',
|
||||||
|
guid: '6c0f4b4e-3e8a-4b8e-9d0c-5a7f2b1e9c31',
|
||||||
|
iconPath: icon.existsSync() ? icon.path : null,
|
||||||
|
),
|
||||||
|
onDidReceiveNotificationResponse: _clicked,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('system notifications unavailable: $e');
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> show({required String title, required String body, String tag = '', String payload = ''}) async {
|
||||||
|
if (!await _init()) return;
|
||||||
|
final id = tag.isEmpty ? _next++ : _ids[tag] ??= _next++;
|
||||||
|
try {
|
||||||
|
if (_linux case final p?) {
|
||||||
|
await p.show(
|
||||||
|
id: id,
|
||||||
|
title: title,
|
||||||
|
body: body,
|
||||||
|
payload: payload,
|
||||||
|
notificationDetails: const LinuxNotificationDetails(suppressSound: true),
|
||||||
|
);
|
||||||
|
} else if (_windows case final p?) {
|
||||||
|
await p.show(
|
||||||
|
id: id,
|
||||||
|
title: title,
|
||||||
|
body: body,
|
||||||
|
payload: payload,
|
||||||
|
notificationDetails: WindowsNotificationDetails(audio: WindowsNotificationAudio.silent()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('system notification failed: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
import '../../playback/yt_dlp_app.dart';
|
import '../../playback/yt_dlp_app.dart';
|
||||||
import '../ports/ports.dart';
|
import '../ports/ports.dart';
|
||||||
import 'desktop_folders.dart';
|
import 'desktop_folders.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() {
|
||||||
@@ -12,6 +14,7 @@ Ports desktopPorts() {
|
|||||||
urls: urls,
|
urls: urls,
|
||||||
folders: const DesktopFolders(urls),
|
folders: const DesktopFolders(urls),
|
||||||
links: AppYtDlp.instance,
|
links: AppYtDlp.instance,
|
||||||
notifications: const NoSystemNotifications(),
|
notifications: DesktopNotifications(),
|
||||||
|
players: MediaKitHost(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -97,6 +97,17 @@ class DesktopShell implements ShellPort {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> focus() async {
|
||||||
|
try {
|
||||||
|
if (await windowManager.isMinimized()) await windowManager.restore();
|
||||||
|
if (!await windowManager.isVisible()) await windowManager.show();
|
||||||
|
await windowManager.focus();
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('window focus failed: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> requestAttention() => requestWindowAttention();
|
Future<void> requestAttention() => requestWindowAttention();
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,513 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:ffi' show Int8;
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:ffi/ffi.dart' show StringUtf8Pointer, malloc;
|
||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
import 'package:media_kit/media_kit.dart';
|
||||||
|
import 'package:media_kit_video/media_kit_video.dart';
|
||||||
|
import 'package:signals/signals.dart';
|
||||||
|
import 'package:sync_relay_core/core.dart' show LogThrottle, createLogger;
|
||||||
|
|
||||||
|
import '../../state/settings.dart';
|
||||||
|
import '../ports/player_host.dart';
|
||||||
|
|
||||||
|
// The desktop's players: media_kit over libmpv. Everything mpv-specific
|
||||||
|
// that is not about one media lives here: the cache and HTTP pins, hwdec,
|
||||||
|
// mpv's log level (media_kit has no API for it: `mpv_request_log_messages`
|
||||||
|
// on its handle), the render-mode probe and its diagnostics, mpv's log into
|
||||||
|
// the app log, and the order players are torn down in.
|
||||||
|
|
||||||
|
/// Read-ahead target: about two minutes rides out short outages and makes
|
||||||
|
/// skipping an intro instant.
|
||||||
|
const readAheadSecs = 150;
|
||||||
|
|
||||||
|
/// Forward demuxer cache of a session player. Holds [readAheadSecs] up to
|
||||||
|
/// ~14 Mbit/s (a 1.4 GB / 24 min file is ~8); higher bitrates get less
|
||||||
|
/// time. In RAM: `cache-on-disk=no` (media_kit sets it on without a
|
||||||
|
/// cache-dir, and mpv warned "Failed to create file cache." per open).
|
||||||
|
const sessionAheadBytes = 256 * 1024 * 1024;
|
||||||
|
|
||||||
|
/// Back buffer (media_kit sets it to bufferSize too): enough for a short
|
||||||
|
/// seek back without holding another 256 MiB.
|
||||||
|
const sessionBackBytes = 64 * 1024 * 1024;
|
||||||
|
|
||||||
|
/// The preload's forward cache: its first seconds and the container's
|
||||||
|
/// index, at little memory. Raised to [sessionAheadBytes] when promoted.
|
||||||
|
const preloadAheadBytes = 48 * 1024 * 1024;
|
||||||
|
const preloadBackBytes = 8 * 1024 * 1024;
|
||||||
|
|
||||||
|
/// mpv's `volume-max` (its default is 130): the volume controls go this
|
||||||
|
/// far, past 100 % amplified.
|
||||||
|
const mpvMaxVolume = 150;
|
||||||
|
|
||||||
|
/// mpv's log level for session players. media_kit drains mpv's log on the
|
||||||
|
/// UI thread one line at a time, so a chatty level (ffmpeg retrying a
|
||||||
|
/// refused request logs every attempt) can starve the UI: warnings and
|
||||||
|
/// errors only, except during the render-mode probe, which needs mpv's
|
||||||
|
/// verbose GL init lines.
|
||||||
|
const sessionLogLevel = 'warn';
|
||||||
|
|
||||||
|
/// ffmpeg's HTTP options (mpv `stream-lavf-o`, comma form): reconnect after
|
||||||
|
/// a dropped connection, also on a streamed (growing) relay response, and a
|
||||||
|
/// server error (5xx) while connecting; a refused request (4xx) fails at
|
||||||
|
/// once. At most 3 tries per read, 4 s apart at most.
|
||||||
|
const streamLavfOptions = 'reconnect_streamed=1,reconnect_on_http_error=5xx,'
|
||||||
|
'reconnect_delay_max=4,reconnect_max_retries=3';
|
||||||
|
|
||||||
|
/// mpv echoes numbers in its own format (`150` reads back `150.000000`).
|
||||||
|
bool pinMatches(String wanted, String got) {
|
||||||
|
if (wanted == got) return true;
|
||||||
|
final a = double.tryParse(wanted), b = double.tryParse(got);
|
||||||
|
return a != null && b != null && a == b;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `VideoController` behind [video], for the `Video` widget (the only
|
||||||
|
/// media_kit use outside this file).
|
||||||
|
VideoController videoControllerOf(HostedVideo video) => (video as _MkVideo).controller;
|
||||||
|
|
||||||
|
final _mpvLog = createLogger('mpv');
|
||||||
|
|
||||||
|
class MediaKitHost implements PlayerHost {
|
||||||
|
/// The hwdec setting ([AppSettings.hwdec] by default).
|
||||||
|
final String? Function() _hwdecSetting;
|
||||||
|
MediaKitHost({String? Function()? hwdecSetting})
|
||||||
|
: _hwdecSetting = hwdecSetting ?? (() => AppSettings.instance.hwdec.peek());
|
||||||
|
|
||||||
|
final _live = <_MkPlayer>{};
|
||||||
|
final _renderMode = signal(RenderMode.unknown);
|
||||||
|
final _http = StreamController<String>.broadcast();
|
||||||
|
final _throttle = LogThrottle(burst: 5);
|
||||||
|
Future<void> _retiring = Future.value();
|
||||||
|
var _closing = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
final List<String> pinMismatches = [];
|
||||||
|
|
||||||
|
@override
|
||||||
|
void init() => MediaKit.ensureInitialized();
|
||||||
|
|
||||||
|
@override
|
||||||
|
ReadonlySignal<RenderMode> get renderMode => _renderMode;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<String> get httpErrors => _http.stream;
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get live => _live.length;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> get retiring => _retiring;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String? get hwdec {
|
||||||
|
final env = Platform.environment['SYNC_RELAY_HWDEC'];
|
||||||
|
if (env != null && env.isNotEmpty) return env;
|
||||||
|
return _hwdecSetting();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<HostedPlayer> create(PlayerRole role, {bool muted = false}) async {
|
||||||
|
if (_closing) throw StateError('closing: no new players');
|
||||||
|
final mk = _SyncPlayer(
|
||||||
|
configuration: switch (role) {
|
||||||
|
PlayerRole.session => PlayerConfiguration(
|
||||||
|
bufferSize: sessionAheadBytes,
|
||||||
|
libass: true,
|
||||||
|
vo: Platform.environment['SYNC_RELAY_VO'],
|
||||||
|
logLevel: MPVLogLevel.warn),
|
||||||
|
PlayerRole.preload => PlayerConfiguration(
|
||||||
|
bufferSize: preloadAheadBytes,
|
||||||
|
libass: true,
|
||||||
|
vo: Platform.environment['SYNC_RELAY_VO'],
|
||||||
|
logLevel: MPVLogLevel.error),
|
||||||
|
PlayerRole.thumbnail => const PlayerConfiguration(
|
||||||
|
vo: 'null', muted: true, osc: false, libass: false, title: 'thumbnail', bufferSize: 4 << 20),
|
||||||
|
});
|
||||||
|
final p = _MkPlayer(this, mk, role);
|
||||||
|
_live.add(p);
|
||||||
|
if (role != PlayerRole.thumbnail) {
|
||||||
|
p._logSub = p.logs.listen((l) => _route(p, l));
|
||||||
|
final mismatches = <String>[];
|
||||||
|
await _pinCache(p, role, mismatches);
|
||||||
|
if (await p.pin('volume-max', '$mpvMaxVolume') case final m?) mismatches.add(m);
|
||||||
|
if (muted || role == PlayerRole.preload) {
|
||||||
|
if (await p.pin('mute', 'yes') case final m?) mismatches.add(m);
|
||||||
|
}
|
||||||
|
if (role == PlayerRole.session) {
|
||||||
|
pinMismatches
|
||||||
|
..clear()
|
||||||
|
..addAll(mismatches);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> promote(HostedPlayer p) async {
|
||||||
|
final mk = p as _MkPlayer;
|
||||||
|
mk._role = PlayerRole.session;
|
||||||
|
mk.setLogLevel(sessionLogLevel);
|
||||||
|
final mismatches = <String>[];
|
||||||
|
if (await p.pin('mute', 'no') case final m?) mismatches.add(m);
|
||||||
|
await _pinCache(mk, PlayerRole.session, mismatches);
|
||||||
|
pinMismatches
|
||||||
|
..clear()
|
||||||
|
..addAll(mismatches);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _pinCache(_MkPlayer p, PlayerRole role, List<String> out) async {
|
||||||
|
final session = role == PlayerRole.session;
|
||||||
|
for (final (k, v) in [
|
||||||
|
('demuxer-readahead-secs', '$readAheadSecs'),
|
||||||
|
('cache-secs', '$readAheadSecs'),
|
||||||
|
('demuxer-max-bytes', '${session ? sessionAheadBytes : preloadAheadBytes}'),
|
||||||
|
('demuxer-max-back-bytes', '${session ? sessionBackBytes : preloadBackBytes}'),
|
||||||
|
('cache-on-disk', 'no'),
|
||||||
|
if (hwdec case final h?) ('hwdec', h),
|
||||||
|
('stream-lavf-o', streamLavfOptions),
|
||||||
|
// Pages are resolved by the app's own yt-dlp (the media pipeline):
|
||||||
|
// mpv's Lua hook never runs one.
|
||||||
|
('ytdl', 'no'),
|
||||||
|
]) {
|
||||||
|
if (await p.pin(k, v) case final m?) out.add(m);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
HostedVideo video(HostedPlayer player, {bool probe = false}) {
|
||||||
|
final p = player as _MkPlayer;
|
||||||
|
if (probe) {
|
||||||
|
_logRenderEnv();
|
||||||
|
p.setLogLevel('v'); // back to the session level at the probe's end
|
||||||
|
}
|
||||||
|
// media_kit's video output sets hwdec itself on attach ('auto' unless
|
||||||
|
// configured), which used to override SYNC_RELAY_HWDEC.
|
||||||
|
final controller = VideoController(p.mk, configuration: VideoControllerConfiguration(hwdec: hwdec));
|
||||||
|
final v = _MkVideo(p, controller);
|
||||||
|
// mpv gets `vo=libmpv` before the output's render context exists; a
|
||||||
|
// media opened in between fails its video output ("No render context
|
||||||
|
// set") and plays black. Opens wait for the output (seen on X11, where
|
||||||
|
// the context comes up later, when joining with media already up).
|
||||||
|
p._videoReady = controller.platform.future
|
||||||
|
.then((_) {})
|
||||||
|
.timeout(const Duration(seconds: 5), onTimeout: () {})
|
||||||
|
.catchError((Object _) {});
|
||||||
|
if (probe) unawaited(_probeRender(p, v));
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------ the log
|
||||||
|
|
||||||
|
// mpv's warnings and errors to the app log, a few per kind at a time (a
|
||||||
|
// refused request makes ffmpeg retry and warn on every try); ffmpeg's
|
||||||
|
// HTTP status to [httpErrors]; the GL init lines while probing.
|
||||||
|
void _route(_MkPlayer p, PlayerLogLine log) {
|
||||||
|
if (log.prefix == 'ffmpeg') {
|
||||||
|
final http = RegExp(r'HTTP error \d{3}.*').firstMatch(log.text);
|
||||||
|
if (http != null) _http.add(http[0]!.trim());
|
||||||
|
}
|
||||||
|
if (log.prefix == 'libmpv_render') {
|
||||||
|
if (_glInfo.hasMatch(log.text)) _mpvLog.info('render: [libmpv_render] ${log.text.trim()}');
|
||||||
|
if (log.text.contains('suspected software renderer')) {
|
||||||
|
_probeMode = RenderMode.softwareGl;
|
||||||
|
_renderMode.value = RenderMode.softwareGl;
|
||||||
|
} else if (log.text.startsWith('GL_VERSION') && p._probing && _probeMode == RenderMode.unknown) {
|
||||||
|
_probeMode = RenderMode.gpu;
|
||||||
|
_renderMode.value = RenderMode.gpu;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
final level = log.level;
|
||||||
|
if (level != 'warn' && !log.isError) return;
|
||||||
|
final text = log.text.trim();
|
||||||
|
if (text.isEmpty) return;
|
||||||
|
final who = p.role == PlayerRole.preload ? 'preload ' : '';
|
||||||
|
final pass = _throttle.allow('$who${log.prefix} $level ${text.length > 40 ? text.substring(0, 40) : text}');
|
||||||
|
if (pass == null) return;
|
||||||
|
final line = '$pass$who[${log.prefix}] $text';
|
||||||
|
level == 'warn' ? _mpvLog.info(line) : _mpvLog.warning(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
// mpv's GL init lines (`GL_VENDOR='...'`, `Detected desktop OpenGL 4.6.`).
|
||||||
|
static final _glInfo =
|
||||||
|
RegExp(r'^\s*(GL_(VENDOR|RENDERER|VERSION|SHADING_LANGUAGE_VERSION)=|Detected (GLES|desktop OpenGL))');
|
||||||
|
|
||||||
|
// Once per app start: what decides how media_kit and mpv render (the
|
||||||
|
// display server, Flutter's and Mesa's overrides). Not secrets.
|
||||||
|
static bool _renderEnvLogged = false;
|
||||||
|
static void _logRenderEnv() {
|
||||||
|
if (_renderEnvLogged || !Platform.isLinux) return;
|
||||||
|
_renderEnvLogged = true;
|
||||||
|
const names = [
|
||||||
|
'XDG_SESSION_TYPE',
|
||||||
|
'WAYLAND_DISPLAY',
|
||||||
|
'DISPLAY',
|
||||||
|
'GDK_BACKEND',
|
||||||
|
'FLUTTER_LINUX_RENDERER',
|
||||||
|
'LIBGL_ALWAYS_SOFTWARE',
|
||||||
|
'__EGL_VENDOR_LIBRARY_FILENAMES',
|
||||||
|
];
|
||||||
|
final env = Platform.environment;
|
||||||
|
_mpvLog.info('render env: ${[for (final n in names) '$n=${env[n] ?? '-'}'].join(' ')}');
|
||||||
|
}
|
||||||
|
|
||||||
|
RenderMode _probeMode = RenderMode.unknown;
|
||||||
|
|
||||||
|
// media_kit's Linux/Windows video output creates mpv's render context
|
||||||
|
// while the controller is created: OpenGL when it can, else the software
|
||||||
|
// renderer, which never logs GL init (and ignores shaders). No GL line
|
||||||
|
// within 1.5 s of the output being up means software.
|
||||||
|
Future<void> _probeRender(_MkPlayer p, _MkVideo v) async {
|
||||||
|
p._probing = true;
|
||||||
|
_probeMode = RenderMode.unknown;
|
||||||
|
try {
|
||||||
|
await v.controller.platform.future;
|
||||||
|
await Future.delayed(const Duration(milliseconds: 1500));
|
||||||
|
} catch (_) {
|
||||||
|
return;
|
||||||
|
} finally {
|
||||||
|
p._probing = false;
|
||||||
|
if (!p.disposed) p.setLogLevel(sessionLogLevel);
|
||||||
|
}
|
||||||
|
if (p.disposed) return;
|
||||||
|
final mode = _probeMode;
|
||||||
|
if (mode != RenderMode.unknown) {
|
||||||
|
_renderMode.value = mode;
|
||||||
|
_mpvLog.info('render: ${mode.name} (${mode == RenderMode.gpu ? 'OpenGL on the GPU' : 'OpenGL on the CPU'})');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_renderMode.value = RenderMode.software;
|
||||||
|
_mpvLog.info("render: software (no GL init logged: media_kit's own fallback; "
|
||||||
|
'its reason is on stderr, "media_kit: VideoOutput: ..." when run from a terminal)');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Once per player, after its first frame: the decoder mpv picked; the
|
||||||
|
// first time per app start also mpv's and ffmpeg's versions.
|
||||||
|
static bool _versionsLogged = false;
|
||||||
|
Future<void> _logFirstFrame(_MkPlayer p) async {
|
||||||
|
try {
|
||||||
|
final current = await p.getProperty('hwdec-current');
|
||||||
|
final wanted = await p.getProperty('hwdec');
|
||||||
|
_mpvLog.info('render: first frame ${p.state.width}x${p.state.height}, '
|
||||||
|
'hwdec-current=${current.isEmpty ? 'no' : current} (hwdec=$wanted), mode ${_renderMode.peek().name}');
|
||||||
|
if (!_versionsLogged) {
|
||||||
|
_versionsLogged = true;
|
||||||
|
_mpvLog.info('render: ${await versions(p)}');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('first frame info: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<String?> versions(HostedPlayer player) async {
|
||||||
|
try {
|
||||||
|
return '${await player.getProperty('mpv-version')}, ffmpeg ${await player.getProperty('ffmpeg-version')}';
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------- tear-down
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> retire(HostedPlayer player) {
|
||||||
|
final p = player as _MkPlayer;
|
||||||
|
p._retired = true;
|
||||||
|
unawaited(p.mk.pause().catchError((_) {}));
|
||||||
|
return _retiring = () async {
|
||||||
|
await _settleUi();
|
||||||
|
await dispose(p);
|
||||||
|
}();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> dispose(HostedPlayer player) async {
|
||||||
|
final p = player as _MkPlayer;
|
||||||
|
if (!_live.remove(p)) return;
|
||||||
|
p._retired = true;
|
||||||
|
await p._logSub?.cancel();
|
||||||
|
try {
|
||||||
|
await p.mk.dispose();
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> disposeAll({Duration each = const Duration(milliseconds: 1500)}) async {
|
||||||
|
_closing = true;
|
||||||
|
await Future.wait([
|
||||||
|
for (final p in _live.toList()) dispose(p).timeout(each).catchError((Object _) {}),
|
||||||
|
]);
|
||||||
|
_closing = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Two frames: one may already be mid-build when the view drops the
|
||||||
|
// output. Then the length of a route pop (the fullscreen route holds its
|
||||||
|
// own `Video` and leaves animated). Bounded, since a hidden window
|
||||||
|
// renders no frames; headless tests have no binding and skip it all.
|
||||||
|
static Future<void> _settleUi() async {
|
||||||
|
try {
|
||||||
|
for (var i = 0; i < 2; i++) {
|
||||||
|
await WidgetsBinding.instance.endOfFrame.timeout(const Duration(milliseconds: 300));
|
||||||
|
}
|
||||||
|
await Future.delayed(const Duration(milliseconds: 350));
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A [Player] whose public [seek] belongs to the user. media_kit's controls
|
||||||
|
/// call `player.seek`; with [onUserSeek] set those calls become explicit
|
||||||
|
/// events for the sync layer instead of moving mpv behind its back.
|
||||||
|
class _SyncPlayer extends Player {
|
||||||
|
_SyncPlayer({super.configuration});
|
||||||
|
|
||||||
|
void Function(Duration position)? onUserSeek;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> seek(Duration duration) async {
|
||||||
|
final hook = onUserSeek;
|
||||||
|
if (hook == null) return super.seek(duration);
|
||||||
|
hook(duration);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> syncSeek(Duration duration) => super.seek(duration);
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MkPlayer implements HostedPlayer {
|
||||||
|
final MediaKitHost host;
|
||||||
|
final _SyncPlayer mk;
|
||||||
|
PlayerRole _role;
|
||||||
|
_MkPlayer(this.host, this.mk, this._role) {
|
||||||
|
_firstFrame = mk.stream.width.listen((w) {
|
||||||
|
if (w == null || w <= 0 || _role == PlayerRole.thumbnail) return;
|
||||||
|
unawaited(_firstFrame?.cancel());
|
||||||
|
unawaited(host._logFirstFrame(this));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
StreamSubscription<PlayerLogLine>? _logSub;
|
||||||
|
StreamSubscription<int?>? _firstFrame;
|
||||||
|
bool _probing = false;
|
||||||
|
bool _retired = false;
|
||||||
|
Future<void>? _videoReady; // set once a video output is attached
|
||||||
|
|
||||||
|
dynamic get _mpv => mk.platform;
|
||||||
|
|
||||||
|
@override
|
||||||
|
PlayerRole get role => _role;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get disposed => _retired;
|
||||||
|
|
||||||
|
@override
|
||||||
|
HostedPlayerState get state {
|
||||||
|
final s = mk.state;
|
||||||
|
return HostedPlayerState(
|
||||||
|
position: s.position,
|
||||||
|
duration: s.duration,
|
||||||
|
buffer: s.buffer,
|
||||||
|
volume: s.volume,
|
||||||
|
playing: s.playing,
|
||||||
|
completed: s.completed,
|
||||||
|
width: s.width,
|
||||||
|
height: s.height,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<Duration> get positionStream => mk.stream.position;
|
||||||
|
@override
|
||||||
|
Stream<Duration> get durationStream => mk.stream.duration;
|
||||||
|
@override
|
||||||
|
Stream<Duration> get bufferStream => mk.stream.buffer;
|
||||||
|
@override
|
||||||
|
Stream<double> get volumeStream => mk.stream.volume;
|
||||||
|
@override
|
||||||
|
Stream<bool> get completedStream => mk.stream.completed;
|
||||||
|
@override
|
||||||
|
Stream<int?> get widthStream => mk.stream.width;
|
||||||
|
@override
|
||||||
|
Stream<PlayerLogLine> get logs => mk.stream.log.map((l) => PlayerLogLine(l.prefix, l.level, l.text));
|
||||||
|
|
||||||
|
@override
|
||||||
|
void Function(Duration position)? get onUserSeek => mk.onUserSeek;
|
||||||
|
@override
|
||||||
|
set onUserSeek(void Function(Duration position)? f) => mk.onUserSeek = f;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> open(String url, {Map<String, String> headers = const {}, bool play = false}) async {
|
||||||
|
if (_videoReady case final ready?) await ready;
|
||||||
|
// An empty map, not null: media_kit caches headers per URI.
|
||||||
|
await mk.open(Media(url, httpHeaders: headers), play: play);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> play() => mk.play();
|
||||||
|
@override
|
||||||
|
Future<void> pause() => mk.pause();
|
||||||
|
@override
|
||||||
|
Future<void> stop() => mk.stop();
|
||||||
|
@override
|
||||||
|
Future<void> seek(Duration position) => mk.seek(position);
|
||||||
|
@override
|
||||||
|
Future<void> syncSeek(Duration position) => mk.syncSeek(position);
|
||||||
|
@override
|
||||||
|
Future<void> setRate(double rate) => mk.setRate(rate);
|
||||||
|
@override
|
||||||
|
Future<void> setVolume(double volume) => mk.setVolume(volume);
|
||||||
|
|
||||||
|
// media_kit has no public property API: a guarded dynamic call on
|
||||||
|
// `player.platform` (its NativePlayer).
|
||||||
|
@override
|
||||||
|
Future<void> setProperty(String name, String value) async => await _mpv?.setProperty(name, value);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<String> getProperty(String name) async => '${await _mpv?.getProperty(name)}';
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> observeProperty(String name, Future<void> Function(String value) onChange) async =>
|
||||||
|
await _mpv?.observeProperty(name, onChange);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> command(List<String> args) async => await _mpv?.command(args);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<String?> pin(String name, String value) async {
|
||||||
|
try {
|
||||||
|
await setProperty(name, value);
|
||||||
|
final got = await getProperty(name);
|
||||||
|
if (pinMatches(value, got)) return null;
|
||||||
|
debugPrint('pin $name=$value not applied, mpv has "$got"');
|
||||||
|
return '$name: $value -> $got';
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('pin $name=$value failed: $e');
|
||||||
|
return '$name: $value -> error $e';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// mpv_request_log_messages on media_kit's handle.
|
||||||
|
@override
|
||||||
|
void setLogLevel(String level) {
|
||||||
|
try {
|
||||||
|
final dynamic platform = mk.platform;
|
||||||
|
final name = level.toNativeUtf8();
|
||||||
|
try {
|
||||||
|
platform.mpv.mpv_request_log_messages(platform.ctx, name.cast<Int8>());
|
||||||
|
} finally {
|
||||||
|
malloc.free(name);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('mpv log level $level not set: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MkVideo implements HostedVideo {
|
||||||
|
@override
|
||||||
|
final _MkPlayer player;
|
||||||
|
final VideoController controller;
|
||||||
|
_MkVideo(this.player, this.controller);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> get ready => controller.platform.future;
|
||||||
|
}
|
||||||
@@ -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,192 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:signals/signals.dart';
|
||||||
|
|
||||||
|
// The media players as the app sees them: mpv instances, their video
|
||||||
|
// outputs, how the video is rendered, mpv's log and the order players are
|
||||||
|
// torn down in. Desktop: media_kit over libmpv
|
||||||
|
// (`platform/desktop/media_kit_host.dart`); nothing else in the app
|
||||||
|
// touches media_kit except the `Video` widget glue.
|
||||||
|
|
||||||
|
/// How the video output renders (shaders need [gpu]).
|
||||||
|
enum RenderMode {
|
||||||
|
unknown,
|
||||||
|
|
||||||
|
/// OpenGL on a real GPU: shaders work.
|
||||||
|
gpu,
|
||||||
|
|
||||||
|
/// OpenGL on a software rasterizer (llvmpipe): shaders crawl.
|
||||||
|
softwareGl,
|
||||||
|
|
||||||
|
/// The video output's own software fallback: shaders are ignored.
|
||||||
|
software,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a player is for: its cache, log level and log routing.
|
||||||
|
enum PlayerRole {
|
||||||
|
/// The session's player (and a fresh one per media): full cache, mpv log
|
||||||
|
/// at `warn` into the app log.
|
||||||
|
session,
|
||||||
|
|
||||||
|
/// The hidden player holding what plays next: small cache, muted, log
|
||||||
|
/// at `error`.
|
||||||
|
preload,
|
||||||
|
|
||||||
|
/// A poster frame for Files: no audio or video output, no cache, no log.
|
||||||
|
thumbnail,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One line of mpv's log.
|
||||||
|
class PlayerLogLine {
|
||||||
|
final String prefix;
|
||||||
|
final String level;
|
||||||
|
final String text;
|
||||||
|
const PlayerLogLine(this.prefix, this.level, this.text);
|
||||||
|
|
||||||
|
bool get isError => level == 'error' || level == 'fatal';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A snapshot of a player's state.
|
||||||
|
class HostedPlayerState {
|
||||||
|
final Duration position;
|
||||||
|
final Duration duration;
|
||||||
|
|
||||||
|
/// End of the cached range (mpv `demuxer-cache-time`).
|
||||||
|
final Duration buffer;
|
||||||
|
final double volume;
|
||||||
|
final bool playing;
|
||||||
|
|
||||||
|
/// At the end of the file.
|
||||||
|
final bool completed;
|
||||||
|
final int? width;
|
||||||
|
final int? height;
|
||||||
|
const HostedPlayerState({
|
||||||
|
this.position = Duration.zero,
|
||||||
|
this.duration = Duration.zero,
|
||||||
|
this.buffer = Duration.zero,
|
||||||
|
this.volume = 100,
|
||||||
|
this.playing = false,
|
||||||
|
this.completed = false,
|
||||||
|
this.width,
|
||||||
|
this.height,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One mpv player. Each media gets its own (see ARCHITECTURE "Player
|
||||||
|
/// lifecycle"); [PlayerHost.retire] / [PlayerHost.disposeAll] end it.
|
||||||
|
abstract interface class HostedPlayer {
|
||||||
|
PlayerRole get role;
|
||||||
|
HostedPlayerState get state;
|
||||||
|
Stream<Duration> get positionStream;
|
||||||
|
Stream<Duration> get durationStream;
|
||||||
|
Stream<Duration> get bufferStream;
|
||||||
|
Stream<double> get volumeStream;
|
||||||
|
Stream<bool> get completedStream;
|
||||||
|
Stream<int?> get widthStream;
|
||||||
|
|
||||||
|
/// mpv's log lines at the player's level.
|
||||||
|
Stream<PlayerLogLine> get logs;
|
||||||
|
|
||||||
|
/// The player is gone (disposed or being disposed): commands do nothing.
|
||||||
|
bool get disposed;
|
||||||
|
|
||||||
|
/// Seeks from the video widget's own controls land here instead of in
|
||||||
|
/// mpv when set ([seek] goes through it too); [syncSeek] always moves
|
||||||
|
/// mpv.
|
||||||
|
void Function(Duration position)? onUserSeek;
|
||||||
|
|
||||||
|
/// Opens [url] (with [headers] on every request), paused unless [play].
|
||||||
|
Future<void> open(String url, {Map<String, String> headers = const {}, bool play = false});
|
||||||
|
Future<void> play();
|
||||||
|
Future<void> pause();
|
||||||
|
Future<void> stop();
|
||||||
|
|
||||||
|
/// A user seek: through [onUserSeek] when set, else [syncSeek].
|
||||||
|
Future<void> seek(Duration position);
|
||||||
|
|
||||||
|
/// Moves mpv (sync corrections, scrub preview).
|
||||||
|
Future<void> syncSeek(Duration position);
|
||||||
|
Future<void> setRate(double rate);
|
||||||
|
Future<void> setVolume(double volume);
|
||||||
|
Future<void> setProperty(String name, String value);
|
||||||
|
Future<String> getProperty(String name);
|
||||||
|
Future<void> observeProperty(String name, Future<void> Function(String value) onChange);
|
||||||
|
Future<void> command(List<String> args);
|
||||||
|
|
||||||
|
/// Sets [name] and reads it back: null when mpv took [value], else
|
||||||
|
/// `name: value -> got` (mpv ignores invalid values silently).
|
||||||
|
Future<String?> pin(String name, String value);
|
||||||
|
|
||||||
|
/// mpv's log level for this player (`warn`, `v`, ...).
|
||||||
|
void setLogLevel(String level);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A player's video output (a texture the `Video` widget shows).
|
||||||
|
abstract interface class HostedVideo {
|
||||||
|
HostedPlayer get player;
|
||||||
|
|
||||||
|
/// Completes once the output is up (its texture registered).
|
||||||
|
Future<void> get ready;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates, owns and disposes the app's players. One per app (the
|
||||||
|
/// composition root's `Ports`); tests make their own.
|
||||||
|
abstract interface class PlayerHost {
|
||||||
|
/// Once at start, before any player.
|
||||||
|
void init();
|
||||||
|
|
||||||
|
/// A new player for [role], set up before it is returned to the caller's
|
||||||
|
/// first await: cache, pins ([pinMismatches]), hwdec, log level.
|
||||||
|
Future<HostedPlayer> create(PlayerRole role, {bool muted = false});
|
||||||
|
|
||||||
|
/// [player] (a preload) becomes a session player: unmuted, the full
|
||||||
|
/// cache pins, the session's log level.
|
||||||
|
Future<void> promote(HostedPlayer player);
|
||||||
|
|
||||||
|
/// [player]'s video output, with the hwdec policy. [probe]: listen to
|
||||||
|
/// mpv's render init to learn [renderMode] (the first output of an app
|
||||||
|
/// start).
|
||||||
|
HostedVideo video(HostedPlayer player, {bool probe = false});
|
||||||
|
|
||||||
|
/// How video renders, as last learned (kept after leaving).
|
||||||
|
ReadonlySignal<RenderMode> get renderMode;
|
||||||
|
|
||||||
|
/// mpv `hwdec` for new players: env `SYNC_RELAY_HWDEC` wins over the
|
||||||
|
/// setting (Windows' auto is `auto-copy`).
|
||||||
|
String? get hwdec;
|
||||||
|
|
||||||
|
/// Pins that did not take on the last session player created.
|
||||||
|
List<String> get pinMismatches;
|
||||||
|
|
||||||
|
/// ffmpeg's HTTP errors (`HTTP error 403 Forbidden`), from whichever
|
||||||
|
/// player ffmpeg logs through (one per process).
|
||||||
|
Stream<String> get httpErrors;
|
||||||
|
|
||||||
|
/// `mpv-version` and `ffmpeg-version` of [player], for diagnostics.
|
||||||
|
Future<String?> versions(HostedPlayer player);
|
||||||
|
|
||||||
|
/// Ends [player] once the view let go of its texture (two frames and a
|
||||||
|
/// route pop); at most one retiring player lingers, a new [create] of a
|
||||||
|
/// session player waits for it ([retiring]).
|
||||||
|
Future<void> retire(HostedPlayer player);
|
||||||
|
|
||||||
|
/// The last retire, until done.
|
||||||
|
Future<void> get retiring;
|
||||||
|
|
||||||
|
/// Disposes [player] at once (no view showed it).
|
||||||
|
Future<void> dispose(HostedPlayer player);
|
||||||
|
|
||||||
|
/// App close: every live player goes, each bounded by [each], before
|
||||||
|
/// the engine does (a live mpv calls back into a dead isolate).
|
||||||
|
Future<void> disposeAll({Duration each});
|
||||||
|
|
||||||
|
/// Live players, for tests and the close.
|
||||||
|
int get live;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [log] says the media could not be opened: before a [duration] is known,
|
||||||
|
/// mpv's fatal "nothing to play" or a demuxer error.
|
||||||
|
bool mpvLoadFailed(PlayerLogLine log, Duration duration) {
|
||||||
|
return duration == Duration.zero &&
|
||||||
|
((log.prefix == 'cplayer' && log.level == 'fatal') || (log.prefix == 'demux' && log.level == 'error'));
|
||||||
|
}
|
||||||
@@ -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,23 +8,27 @@ 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';
|
||||||
|
|
||||||
/// The platform as the app sees it: one set, made in the composition
|
/// The platform as the app sees it: one set, made in the composition
|
||||||
/// root ([SessionState]) and handed on from there.
|
/// root ([AppGraph]) and handed on from there.
|
||||||
class Ports {
|
class Ports {
|
||||||
final ShellPort shell;
|
final ShellPort shell;
|
||||||
final UrlOpener urls;
|
final UrlOpener urls;
|
||||||
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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1,20 +1,8 @@
|
|||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
|
|
||||||
/// Where the app window stands, for auto sit-out
|
import 'window_activity.dart';
|
||||||
/// ([ShellPort.watchActivity]).
|
|
||||||
enum WindowActivity {
|
|
||||||
/// The window has the keyboard focus.
|
|
||||||
focused,
|
|
||||||
|
|
||||||
/// Shown, but another window has the focus.
|
export 'window_activity.dart';
|
||||||
unfocused,
|
|
||||||
|
|
||||||
/// Minimised to the taskbar.
|
|
||||||
minimised,
|
|
||||||
|
|
||||||
/// Hidden (closing, or a platform that hides instead of minimising).
|
|
||||||
hidden,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// What the clipboard holds for a paste: its files, else its text.
|
/// What the clipboard holds for a paste: its files, else its text.
|
||||||
typedef Paste = ({List<String> files, String? text});
|
typedef Paste = ({List<String> files, String? text});
|
||||||
@@ -54,6 +42,10 @@ abstract interface class ShellPort {
|
|||||||
Future<void> hide();
|
Future<void> hide();
|
||||||
Future<void> quit();
|
Future<void> quit();
|
||||||
|
|
||||||
|
/// Brings the window up and gives it the focus (restored when
|
||||||
|
/// minimised, shown when hidden): a click on a system notification.
|
||||||
|
Future<void> focus();
|
||||||
|
|
||||||
/// Points the desktop at the window when it is not focused (taskbar
|
/// 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.
|
||||||
Future<void> requestAttention();
|
Future<void> requestAttention();
|
||||||
|
|||||||
@@ -1,14 +1,23 @@
|
|||||||
/// Notifications outside the window (the desktop's notification center,
|
/// Notifications outside the window (the desktop's notification center,
|
||||||
/// Android's shade). The policy that uses them comes with the Notifier.
|
/// Android's shade). The Notifier decides what goes out here (see
|
||||||
|
/// `noticeFor`); the port only shows them and reports clicks.
|
||||||
abstract interface class SystemNotifications {
|
abstract interface class SystemNotifications {
|
||||||
/// Shows [title] / [body]; replaces the one with the same [tag].
|
/// Shows [title] / [body] with the app's name and icon; one with the
|
||||||
Future<void> show({required String title, required String body, String tag = ''});
|
/// same [tag] replaces it. [payload] comes back to [onClick].
|
||||||
|
Future<void> show({required String title, required String body, String tag = '', String payload = ''});
|
||||||
|
|
||||||
|
/// Called with a notification's payload when the user clicks it (set
|
||||||
|
/// once, by the Notifier).
|
||||||
|
set onClick(void Function(String payload)? handler);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Shows nothing (desktop today).
|
/// Shows nothing (tests, platforms without one).
|
||||||
class NoSystemNotifications implements SystemNotifications {
|
class NoSystemNotifications implements SystemNotifications {
|
||||||
const NoSystemNotifications();
|
const NoSystemNotifications();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> show({required String title, required String body, String tag = ''}) async {}
|
Future<void> show({required String title, required String body, String tag = '', String payload = ''}) async {}
|
||||||
|
|
||||||
|
@override
|
||||||
|
set onClick(void Function(String payload)? handler) {}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
/// Where the app window stands ([ShellPort.watchActivity]): auto sit-out
|
||||||
|
/// and the Notifier (system notifications while it is not focused) read
|
||||||
|
/// it. Pure Dart.
|
||||||
|
enum WindowActivity {
|
||||||
|
/// The window has the keyboard focus.
|
||||||
|
focused,
|
||||||
|
|
||||||
|
/// Shown, but another window has the focus.
|
||||||
|
unfocused,
|
||||||
|
|
||||||
|
/// Minimised to the taskbar.
|
||||||
|
minimised,
|
||||||
|
|
||||||
|
/// Hidden (closing, or a platform that hides instead of minimising).
|
||||||
|
hidden;
|
||||||
|
|
||||||
|
/// Not in front of the user: notices go out as system notifications.
|
||||||
|
bool get background => this != focused;
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart' show debugPrint;
|
||||||
|
|
||||||
|
import '../app/app_graph.dart';
|
||||||
|
import 'app_shutdown.dart';
|
||||||
|
import 'ports/player_host.dart';
|
||||||
|
|
||||||
|
// The close smoke run (CI, `tool/smoke_windows.ps1` / `tool/smoke_linux.sh`):
|
||||||
|
// env `STARSYNC_SMOKE=<media file>` makes the app, once up, open that file
|
||||||
|
// in a session player with a video output through the media pipeline,
|
||||||
|
// play a second and close the normal way ([shutDownApp]). Progress goes to
|
||||||
|
// `STARSYNC_SMOKE_OUT` (one line per step: `start`, `opened <ms> render=<mode>`,
|
||||||
|
// `closing`, or `skip <why>` / `fail <why>`), so the script can tell a
|
||||||
|
// runner that cannot show a window (skip) from a close that hangs or
|
||||||
|
// leaves a process behind (fail).
|
||||||
|
|
||||||
|
/// The smoke run's media, or null for a normal start.
|
||||||
|
String? get smokeMedia {
|
||||||
|
final m = Platform.environment['STARSYNC_SMOKE'];
|
||||||
|
return m == null || m.isEmpty ? null : m;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> runSmoke(AppGraph graph, String media) async {
|
||||||
|
final outPath = Platform.environment['STARSYNC_SMOKE_OUT'];
|
||||||
|
void step(String line) {
|
||||||
|
debugPrint('smoke: $line'); // into the app log too
|
||||||
|
if (outPath == null || outPath.isEmpty) return;
|
||||||
|
try {
|
||||||
|
File(outPath).writeAsStringSync('$line\n', mode: FileMode.append, flush: true);
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
step('start');
|
||||||
|
final sw = Stopwatch()..start();
|
||||||
|
final pipe = graph.coordinator.media;
|
||||||
|
try {
|
||||||
|
if (!await File(media).exists()) {
|
||||||
|
step('fail no media file at $media');
|
||||||
|
} else {
|
||||||
|
await pipe.start();
|
||||||
|
await pipe.playback.setVolume(0);
|
||||||
|
// The video output the session view would make: the render path
|
||||||
|
// (EGL / ANGLE) is what breaks on some machines.
|
||||||
|
final video = pipe.playback.video;
|
||||||
|
await video.ready.timeout(const Duration(seconds: 15));
|
||||||
|
final ok = await pipe.open(media);
|
||||||
|
final end = DateTime.now().add(const Duration(seconds: 20));
|
||||||
|
while (ok && pipe.playback.durationSecs <= 0 && DateTime.now().isBefore(end)) {
|
||||||
|
await Future.delayed(const Duration(milliseconds: 100));
|
||||||
|
}
|
||||||
|
if (pipe.playback.durationSecs <= 0) {
|
||||||
|
step('fail the media did not open in 20 s');
|
||||||
|
} else {
|
||||||
|
await pipe.playback.player.play();
|
||||||
|
await Future.delayed(const Duration(seconds: 1));
|
||||||
|
final mode = graph.ports.players.renderMode.peek();
|
||||||
|
step('opened ${sw.elapsedMilliseconds} render=${mode.name}');
|
||||||
|
if (mode == RenderMode.unknown) await Future.delayed(const Duration(seconds: 2));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} on TimeoutException {
|
||||||
|
step('skip the video output did not come up (no display for this runner?)');
|
||||||
|
} catch (e) {
|
||||||
|
step('fail $e');
|
||||||
|
}
|
||||||
|
step('closing');
|
||||||
|
await shutDownApp();
|
||||||
|
}
|
||||||
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);
|
||||||
|
|||||||
@@ -7,32 +7,36 @@ import 'package:flutter/foundation.dart';
|
|||||||
import 'package:signals/signals_flutter.dart';
|
import 'package:signals/signals_flutter.dart';
|
||||||
import 'package:sync_relay_core/core.dart';
|
import 'package:sync_relay_core/core.dart';
|
||||||
|
|
||||||
import '../files/name_parser.dart';
|
|
||||||
import '../files/thumbnails.dart';
|
import '../files/thumbnails.dart';
|
||||||
import '../jellyfin/accounts.dart';
|
import '../jellyfin/accounts.dart';
|
||||||
import '../jellyfin/format.dart' show pickFor;
|
import '../jellyfin/format.dart' show pickFor;
|
||||||
import '../jellyfin/session_media.dart';
|
import '../jellyfin/session_media.dart';
|
||||||
|
import '../library/media_refs.dart';
|
||||||
import '../screens/home/picked_media.dart';
|
import '../screens/home/picked_media.dart';
|
||||||
import '../session/app_events.dart' show AddedToUpNext;
|
import '../session/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;
|
||||||
@@ -164,15 +193,22 @@ class QueueController {
|
|||||||
if (bytes.length <= RelayConstants.maxPosterBytes) thumb = bytes;
|
if (bytes.length <= RelayConstants.maxPosterBytes) thumb = bytes;
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
final ep = [
|
// The library's name for it when it knows the show (what friends
|
||||||
|
// see in Up next), else the release name's.
|
||||||
|
final what = coord.fileRef?.call(path);
|
||||||
|
final ep = what != null && what.kind == 'episode'
|
||||||
|
? (refEpisodeLine(what) ?? '').replaceAll(' ', '')
|
||||||
|
: [
|
||||||
if (parsed.season != null) 'S${parsed.season}',
|
if (parsed.season != null) 'S${parsed.season}',
|
||||||
if (parsed.episode != null) 'E${parsed.episode}',
|
if (parsed.episode != null) 'E${parsed.episode}',
|
||||||
|
if (parsed.episode == null) ?parsed.extra,
|
||||||
].join();
|
].join();
|
||||||
|
final title = what != null && what.title.isNotEmpty ? what.title : parsed.series.trim();
|
||||||
return (
|
return (
|
||||||
item: QueueItem(
|
item: QueueItem(
|
||||||
media: HelloMedia(name: name, size: await file.length(), duration: 0),
|
media: HelloMedia(name: name, size: await file.length(), duration: 0, ref: what),
|
||||||
how: QueueHow.file,
|
how: QueueHow.file,
|
||||||
title: parsed.series.trim().isEmpty ? name : parsed.series.trim(),
|
title: title.isEmpty ? name : title,
|
||||||
sub: ep,
|
sub: ep,
|
||||||
duration: (shot?.duration.inMilliseconds ?? 0) / 1000,
|
duration: (shot?.duration.inMilliseconds ?? 0) / 1000,
|
||||||
key: _newKey(),
|
key: _newKey(),
|
||||||
@@ -181,13 +217,13 @@ class QueueController {
|
|||||||
);
|
);
|
||||||
case LinkMedia(:final url, :final anyway):
|
case LinkMedia(:final url, :final anyway):
|
||||||
// Alone, a link plays direct: there is nobody to pass it on to.
|
// Alone, a link plays direct: there is nobody to pass it on to.
|
||||||
final relay = coord.solo ? LinkRelay.direct : pick.relay;
|
final relay = coord.alone.peek() ? LinkRelay.direct : pick.relay;
|
||||||
final linked = await mediaForLink(coord.jellyfin, url);
|
final linked = await mediaForLink(coord.jellyfin, url);
|
||||||
final ref = linked.jellyfin;
|
final ref = linked.jellyfin;
|
||||||
if (ref != null) {
|
if (ref != null) {
|
||||||
return (
|
return (
|
||||||
item: QueueItem(
|
item: QueueItem(
|
||||||
media: HelloMedia(name: linked.name, size: 0, duration: 0, jellyfin: ref),
|
media: linked,
|
||||||
how: QueueHow.jellyfin,
|
how: QueueHow.jellyfin,
|
||||||
title: ref.title.isEmpty ? linked.name : ref.title,
|
title: ref.title.isEmpty ? linked.name : ref.title,
|
||||||
sub: ref.subtitle ?? '',
|
sub: ref.subtitle ?? '',
|
||||||
@@ -234,7 +270,8 @@ class QueueController {
|
|||||||
size: 0,
|
size: 0,
|
||||||
duration: 0,
|
duration: 0,
|
||||||
url: how == QueueHow.through ? null : url,
|
url: how == QueueHow.through ? null : url,
|
||||||
relay: how == QueueHow.server ? const MediaRelay(fetch: true) : null),
|
relay: how == QueueHow.server ? const MediaRelay(fetch: true) : null,
|
||||||
|
ref: linkMediaRef(title)),
|
||||||
how: how,
|
how: how,
|
||||||
title: title,
|
title: title,
|
||||||
sub: site,
|
sub: site,
|
||||||
@@ -311,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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -377,75 +406,133 @@ class QueueController {
|
|||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
_performing.removeWhere((_, at) => now.difference(at) > const Duration(minutes: 1));
|
_performing.removeWhere((_, at) => now.difference(at) > const Duration(minutes: 1));
|
||||||
final keys = {for (final q in r.queue) q.key};
|
final keys = {for (final q in r.queue) q.key};
|
||||||
|
// Kept while a restarted server waits for what played: Up next comes
|
||||||
|
// back with it.
|
||||||
|
if (_restoreFrom == -1) {
|
||||||
_local.removeWhere((k, v) => !keys.contains(k) && now.difference(v.at) > const Duration(minutes: 1));
|
_local.removeWhere((k, v) => !keys.contains(k) && now.difference(v.at) > const Duration(minutes: 1));
|
||||||
final ids = {for (final q in r.queue) q.id};
|
}
|
||||||
_skipped.removeWhere((id) => !ids.contains(id));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// [item] should go up now: by this app when it is the one that can;
|
/// The end card from the server's state: the media ended with something
|
||||||
/// nobody can: the lowest member takes it off with the reason.
|
/// next; the seconds of `state.advance` move on with a 1 s timer.
|
||||||
void _bringUp(QueueItem item, StateBroadcast r) {
|
void _syncCountdown(StateBroadcast r) {
|
||||||
final perf = queuePerformer(item, r.members);
|
|
||||||
if (perf.id == null) {
|
|
||||||
final why = perf.why;
|
|
||||||
if (why != null && queueJanitor(r.members) == _me && _skipped.add(item.id)) {
|
|
||||||
coord.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) {
|
||||||
}
|
|
||||||
|
|
||||||
void _stopCountdown() {
|
|
||||||
_timer?.cancel();
|
_timer?.cancel();
|
||||||
_timer = null;
|
_timer = null;
|
||||||
countdown.value = null;
|
if (countdown.peek() != null) countdown.value = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final a = r.advance != null && r.advance!.id == head.id ? r.advance : null;
|
||||||
|
int? left;
|
||||||
|
String? waiting;
|
||||||
|
if (a != null) {
|
||||||
|
left = advanceSecondsLeft(a, coord.connection.serverNow());
|
||||||
|
if (left == 0 && queuePerformer(head, r.members).id == null) {
|
||||||
|
waiting = r.members.where((m) => m.id == head.by).firstOrNull?.name ?? head.byName;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
countdown.value = UpNextCountdown(mediaId: r.mediaId, item: head, secondsLeft: left, waitingFor: waiting);
|
||||||
|
if (a == null) {
|
||||||
|
_timer?.cancel();
|
||||||
|
_timer = null;
|
||||||
|
} else {
|
||||||
|
_timer ??= Timer.periodic(const Duration(seconds: 1), (_) {
|
||||||
|
final now = coord.serverState.peek();
|
||||||
|
if (now != null) _syncCountdown(now);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------ restart
|
||||||
|
|
||||||
|
/// The server restarted without media; what played ([mediaId]) is being
|
||||||
|
/// offered back (-1: that is over).
|
||||||
|
void restoreFrom(int mediaId) => _restoreFrom = mediaId;
|
||||||
|
|
||||||
|
/// What played is back after a server restart: Up next as [before] had
|
||||||
|
/// it comes back too. The app whose offer won ([winner]) adds the items
|
||||||
|
/// anyone can put up (links everyone opens, Jellyfin items); every app
|
||||||
|
/// its own links passed on and files (only it has them; [self] is its
|
||||||
|
/// member id then). A server-fetched link's address stayed with the old
|
||||||
|
/// server, so it is gone; the continuation comes back as usual.
|
||||||
|
void restoreAfterRestart(StateBroadcast before, String self, {required bool winner}) {
|
||||||
|
for (final q in before.queue) {
|
||||||
|
if (q.auto) continue;
|
||||||
|
final ok = switch (q.how) {
|
||||||
|
QueueHow.direct || QueueHow.jellyfin => winner,
|
||||||
|
QueueHow.through || QueueHow.file => self.isNotEmpty && q.by == self && _local.containsKey(q.key),
|
||||||
|
QueueHow.server => false,
|
||||||
|
};
|
||||||
|
if (!ok) continue;
|
||||||
|
final local = _local[q.key];
|
||||||
|
if (local != null) _local[q.key] = (pick: local.pick, at: DateTime.now());
|
||||||
|
final thumb = thumbs.bytesFor(q.thumb);
|
||||||
|
coord.connection.sendQueue(QueueIntent.add(
|
||||||
|
QueueItem(media: q.media, how: q.how, title: q.title, sub: q.sub, duration: q.duration, key: q.key),
|
||||||
|
thumb: thumb == null ? '' : base64Encode(thumb)));
|
||||||
|
}
|
||||||
|
_restoreFrom = -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------- continuation
|
||||||
|
|
||||||
|
/// Adds what continues the media playing (`auto`, PROTOCOL.md "Queue")
|
||||||
|
/// when this app may ([mayAddContinuation]: it put it up, or that
|
||||||
|
/// member is not in and this is the first who is), knows what comes
|
||||||
|
/// next, and it is not there or cancelled yet; with the setting on. A
|
||||||
|
/// Jellyfin episode played with this app's account continues with the
|
||||||
|
/// series' next one, a file this app put up with the next episode of its
|
||||||
|
/// show in the library (numbers skipped on the way said in its line).
|
||||||
|
void _continue(StateBroadcast? r) {
|
||||||
|
if (r == null || r.media == null || !coord.settings.autoAdvance.peek()) return;
|
||||||
|
final id = r.mediaId;
|
||||||
|
if (_continuedFor == id || _hiddenFor.peek() == id) return;
|
||||||
|
if (r.queue.any((q) => q.auto && q.forMedia == id)) {
|
||||||
|
_continuedFor = id;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final swapBy = r.media?['swapBy'];
|
||||||
|
if (!mayAddContinuation(_me, swapBy is String ? swapBy : null, r.members)) return;
|
||||||
|
final ref = refOfStateMedia(r.media);
|
||||||
|
final next = coord.nextEpisode.peek();
|
||||||
|
if (ref != null) {
|
||||||
|
if (next == null || !sameJellyfinItem(ref, coord.currentRef.peek())) return;
|
||||||
|
_continuedFor = id;
|
||||||
|
unawaited(_addContinuation(id, next.pick));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final own = coord.ownFile.peek();
|
||||||
|
final finder = coord.nextFile;
|
||||||
|
if (own == null || own.$1 != id || finder == null) return;
|
||||||
|
_continuedFor = id;
|
||||||
|
unawaited(() async {
|
||||||
|
try {
|
||||||
|
final n = await finder(own.$2);
|
||||||
|
if (n == null) return;
|
||||||
|
final gap = n.skipped;
|
||||||
|
await _addContinuation(id, FileMedia(n.episode.path),
|
||||||
|
note: gap == null ? null : 'skips ${gap.label}');
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('queue: what continues ${own.$2}: $e');
|
||||||
|
}
|
||||||
|
}());
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _addContinuation(int mediaId, PickedMedia pick, {String? note}) async {
|
||||||
|
try {
|
||||||
|
final b = await _build(pick);
|
||||||
|
if (b is! ({QueueItem item, Uint8List? thumb})) return;
|
||||||
|
final r = coord.serverState.peek();
|
||||||
|
if (r == null || r.mediaId != mediaId || _hiddenFor.peek() == mediaId) return;
|
||||||
|
final sub = [b.item.sub, ?note].where((s) => s.isNotEmpty).join(' · ');
|
||||||
|
final item = b.item.copyWith(auto: true, forMedia: mediaId, sub: sub);
|
||||||
|
if (item.key.isNotEmpty) _local[item.key] = (pick: pick, at: DateTime.now());
|
||||||
|
final thumb = b.thumb;
|
||||||
|
coord.connection.sendQueue(QueueIntent.add(item, thumb: thumb == null ? '' : base64Encode(thumb)));
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('queue: adding what continues media $mediaId: $e');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Puts [item] up from here (once per item while it is in flight).
|
/// Puts [item] up from here (once per item while it is in flight).
|
||||||
@@ -522,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) {
|
||||||
@@ -542,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;
|
||||||
@@ -571,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
|
||||||
@@ -581,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) {
|
||||||
@@ -609,7 +693,7 @@ class QueueController {
|
|||||||
/// 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) {
|
||||||
@@ -625,7 +709,7 @@ 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;
|
||||||
unawaited(() async {
|
unawaited(() async {
|
||||||
@@ -634,7 +718,7 @@ class QueueController {
|
|||||||
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));
|
coord.media.feeder.feedWarm(head.id, LinkRelayMedia(Uri.parse(pick.url), ytDlp: y, probe: probe));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('queue: feeding ${head.id}: $e');
|
debugPrint('queue: feeding ${head.id}: $e');
|
||||||
}
|
}
|
||||||
@@ -648,7 +732,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:
|
||||||
@@ -702,7 +786,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));
|
||||||
@@ -722,25 +806,25 @@ 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;
|
||||||
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",
|
||||||
|
|||||||
@@ -9,16 +9,14 @@ import 'package:sync_relay_core/core.dart';
|
|||||||
|
|
||||||
import '../controls/controls_probe.dart' show ControlsHold;
|
import '../controls/controls_probe.dart' show ControlsHold;
|
||||||
import '../controls/minimal/style.dart';
|
import '../controls/minimal/style.dart';
|
||||||
import '../files/scanner.dart' show FileKind, kindOf;
|
|
||||||
import '../jellyfin/images.dart';
|
import '../jellyfin/images.dart';
|
||||||
import '../jellyfin/session_media.dart' show EpisodeLink;
|
import '../jellyfin/session_media.dart' show EpisodeLink;
|
||||||
import '../screens/home/picked_media.dart';
|
import '../screens/home/picked_media.dart';
|
||||||
import '../session/session_coordinator.dart' show autoAdvanceSecs;
|
|
||||||
import '../shell/avatar.dart';
|
import '../shell/avatar.dart';
|
||||||
import '../shell/handoff.dart' show handoffHolds;
|
import '../shell/handoff.dart' show handoffHolds;
|
||||||
import '../shell/library_link.dart' show LinkPreview, cachedLinkPreview;
|
import '../shell/library_link.dart' show LinkPreview, cachedLinkPreview;
|
||||||
import '../shell/motion.dart';
|
import '../shell/motion.dart';
|
||||||
import '../state/session_state.dart';
|
import '../app/app_graph.dart';
|
||||||
import '../widgets/app_menu.dart' show AppGlass, AppMenuItem, AppMenuStyle, showAppMenu;
|
import '../widgets/app_menu.dart' show AppGlass, AppMenuItem, AppMenuStyle, showAppMenu;
|
||||||
import '../widgets/copy_open.dart';
|
import '../widgets/copy_open.dart';
|
||||||
import '../widgets/text_focus.dart' show KeyboardClaim;
|
import '../widgets/text_focus.dart' show KeyboardClaim;
|
||||||
@@ -39,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();
|
||||||
@@ -144,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((_) {
|
||||||
@@ -156,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';
|
||||||
@@ -247,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(
|
||||||
@@ -338,60 +325,35 @@ class UpNextPreview extends StatelessWidget {
|
|||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(_pad),
|
padding: const EdgeInsets.all(_pad),
|
||||||
child: SignalBuilder(builder: (context) {
|
child: SignalBuilder(builder: (context) {
|
||||||
final item = queue.items.value.firstOrNull;
|
final queued = queue.items.value.firstOrNull;
|
||||||
final episode = queue.shownContinuation.value.firstOrNull;
|
final item = queued ?? queue.continues.value;
|
||||||
final warm = queue.warm.value;
|
final warm = queue.warm.value;
|
||||||
const picture = _width - 2 * _pad;
|
const picture = _width - 2 * _pad;
|
||||||
if (item != null) {
|
if (item == null) return const SizedBox.shrink();
|
||||||
final r = queue.coord.serverState.value;
|
final r = queue.coord.serverState.value;
|
||||||
final selfId = queue.coord.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;
|
||||||
|
final kicker = queued == null
|
||||||
|
? 'CONTINUES WITH'
|
||||||
|
: more > 0
|
||||||
|
? 'UP NEXT · $more MORE AFTER IT'
|
||||||
|
: 'UP NEXT';
|
||||||
return Column(
|
return Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
QueueThumb(queue: queue, item: item, width: picture, radius: 8),
|
QueueThumb(queue: queue, item: item, width: picture, radius: 8),
|
||||||
const SizedBox(height: 10),
|
const SizedBox(height: 10),
|
||||||
Text(more > 0 ? 'UP NEXT · $more MORE AFTER IT' : 'UP NEXT', style: _kicker),
|
Text(kicker, style: _kicker),
|
||||||
const SizedBox(height: 3),
|
const SizedBox(height: 3),
|
||||||
Text(item.title, maxLines: 2, overflow: TextOverflow.ellipsis, style: _title),
|
Text(item.title, maxLines: 2, overflow: TextOverflow.ellipsis, style: _title),
|
||||||
if (item.sub.isNotEmpty)
|
if (item.sub.isNotEmpty) Text(item.sub, maxLines: 1, overflow: TextOverflow.ellipsis, style: _sub),
|
||||||
Text(item.sub, maxLines: 1, overflow: TextOverflow.ellipsis, style: _sub),
|
|
||||||
const SizedBox(height: 6),
|
const SizedBox(height: 6),
|
||||||
_MetaLine(item: item, members: r?.members ?? const [], selfId: selfId, wrap: true),
|
_MetaLine(item: item, members: r?.members ?? const [], selfId: selfId, wrap: true),
|
||||||
_footer(ready, ready == null ? performerNote(queue, item, r) : null),
|
_footer(ready, ready == null ? performerNote(queue, item, r) : null),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
|
||||||
if (episode == null) return const SizedBox.shrink();
|
|
||||||
final n = episodeNumbers(episode.item);
|
|
||||||
final runtime = episode.pick.runtime;
|
|
||||||
final sub = [episode.item.seriesName ?? '', if (runtime != null) queueClock(runtime.inSeconds.toDouble())]
|
|
||||||
.where((s) => s.isNotEmpty)
|
|
||||||
.join(' · ');
|
|
||||||
final overview = episode.item.overview?.trim() ?? '';
|
|
||||||
final ready = warm?.id == 'next:${episode.item.id}' ? warm!.state : null;
|
|
||||||
return Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
_EpisodeThumb(queue: queue, episode: episode, width: picture, radius: 8),
|
|
||||||
const SizedBox(height: 10),
|
|
||||||
Text(n == null ? 'NEXT EPISODE' : 'NEXT EPISODE · $n', style: _kicker),
|
|
||||||
const SizedBox(height: 3),
|
|
||||||
Text(episode.item.name, maxLines: 2, overflow: TextOverflow.ellipsis, style: _title),
|
|
||||||
if (sub.isNotEmpty) Text(sub, maxLines: 1, overflow: TextOverflow.ellipsis, style: _sub),
|
|
||||||
if (overview.isNotEmpty) ...[
|
|
||||||
const SizedBox(height: 6),
|
|
||||||
Text(overview,
|
|
||||||
maxLines: 3,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: const TextStyle(color: Colors.white54, fontSize: 12, height: 1.35)),
|
|
||||||
],
|
|
||||||
_footer(ready, null),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -482,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) {
|
||||||
@@ -491,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,
|
||||||
@@ -641,7 +602,7 @@ class _QueuePanelState extends State<QueuePanel> {
|
|||||||
|
|
||||||
/// Ctrl+V: the clipboard's video files, else the links in its text.
|
/// Ctrl+V: the clipboard's video files, else the links in its text.
|
||||||
Future<void> _paste() async {
|
Future<void> _paste() async {
|
||||||
final clip = await (widget.readClipboard ?? SessionState.instance.ports.shell.readPaste)();
|
final clip = await (widget.readClipboard ?? AppScope.of(context).ports.shell.readPaste)();
|
||||||
final picks = _picksFrom([...clip.files, ...?clip.text?.split(RegExp(r'\s+'))]);
|
final picks = _picksFrom([...clip.files, ...?clip.text?.split(RegExp(r'\s+'))]);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
if (picks.isEmpty) {
|
if (picks.isEmpty) {
|
||||||
@@ -682,7 +643,8 @@ class _QueuePanelState extends State<QueuePanel> {
|
|||||||
final row = rows[i];
|
final row = rows[i];
|
||||||
if (p case LinkMedia(:final url)) {
|
if (p case LinkMedia(:final url)) {
|
||||||
// Its title and picture while it resolves (at once when seen before).
|
// Its title and picture while it resolves (at once when seen before).
|
||||||
unawaited((widget.preview ?? cachedLinkPreview)(url).then((v) {
|
final probe = AppScope.of(context).ports.links.probe;
|
||||||
|
unawaited((widget.preview ?? (u) => cachedLinkPreview(u, probe: probe))(url).then((v) {
|
||||||
if (mounted) setState(() => row.preview = v);
|
if (mounted) setState(() => row.preview = v);
|
||||||
}, onError: (Object _) {}));
|
}, onError: (Object _) {}));
|
||||||
}
|
}
|
||||||
@@ -768,13 +730,13 @@ class _QueuePanelState extends State<QueuePanel> {
|
|||||||
case 'remove':
|
case 'remove':
|
||||||
_q.remove(item.id);
|
_q.remove(item.id);
|
||||||
case 'title':
|
case 'title':
|
||||||
await copyToClipboard(item.title, done: 'Title copied');
|
if (context.mounted) await copyToClipboard(context, item.title, done: 'Title copied');
|
||||||
case 'link':
|
case 'link':
|
||||||
await copyToClipboard(link!, done: 'Link copied');
|
if (context.mounted) await copyToClipboard(context, link!, done: 'Link copied');
|
||||||
case 'open':
|
case 'open':
|
||||||
await openLink(link!);
|
if (context.mounted) await openLink(context, link!);
|
||||||
case 'jellyfin':
|
case 'jellyfin':
|
||||||
await openLink(page!);
|
if (context.mounted) await openLink(context, page!);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -814,7 +776,7 @@ class _QueuePanelState extends State<QueuePanel> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final height = MediaQuery.sizeOf(context).height;
|
final height = MediaQuery.sizeOf(context).height;
|
||||||
final drop = SessionState.instance.ports.shell.dropTarget(
|
final drop = AppScope.of(context).ports.shell.dropTarget(
|
||||||
onEnter: () => setState(() => _dragging = true),
|
onEnter: () => setState(() => _dragging = true),
|
||||||
onExit: () => setState(() => _dragging = false),
|
onExit: () => setState(() => _dragging = false),
|
||||||
onDrop: (paths, _) {
|
onDrop: (paths, _) {
|
||||||
@@ -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,6 +931,7 @@ class _QueuePanelState extends State<QueuePanel> {
|
|||||||
const Text('CONTINUES WITH',
|
const Text('CONTINUES WITH',
|
||||||
style: TextStyle(color: Colors.white38, fontSize: 11, letterSpacing: 0.8, fontWeight: FontWeight.w600)),
|
style: TextStyle(color: Colors.white38, fontSize: 11, letterSpacing: 0.8, fontWeight: FontWeight.w600)),
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
|
if (auto != null)
|
||||||
TextButton(
|
TextButton(
|
||||||
key: const ValueKey('queue-clear-continuation'),
|
key: const ValueKey('queue-clear-continuation'),
|
||||||
style: TextButton.styleFrom(foregroundColor: Colors.white54, visualDensity: VisualDensity.compact),
|
style: TextButton.styleFrom(foregroundColor: Colors.white54, visualDensity: VisualDensity.compact),
|
||||||
@@ -973,6 +941,7 @@ class _QueuePanelState extends State<QueuePanel> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
if (auto != null) _ContinuesRow(item: auto, queue: _q, warm: warm),
|
||||||
for (final e in cont) _ContinuationRow(episode: e, queue: _q),
|
for (final e in cont) _ContinuationRow(episode: e, queue: _q),
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -1192,8 +1161,8 @@ IconData howIcon(QueueHow how) => switch (how) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/// Who added it: their picture (still here) or initial, and name.
|
/// Who added it: their picture (still here) or initial, and name.
|
||||||
AvatarInfo _adderAvatar(QueueItem item, List<MemberView> members) {
|
AvatarInfo _adderAvatar(BuildContext context, QueueItem item, List<MemberView> members) {
|
||||||
final coord = SessionState.instance.coordinator;
|
final coord = AppScope.of(context).coordinator;
|
||||||
for (final m in members) {
|
for (final m in members) {
|
||||||
if (m.id == item.by) return memberAvatarInfo(m, coord.serverState.peek(), coord);
|
if (m.id == item.by) return memberAvatarInfo(m, coord.serverState.peek(), coord);
|
||||||
}
|
}
|
||||||
@@ -1227,7 +1196,7 @@ class _MetaLine extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
if (dur.isNotEmpty) ...[Text(dur, style: style), dot],
|
if (dur.isNotEmpty) ...[Text(dur, style: style), dot],
|
||||||
Text('added by ', style: style),
|
Text('added by ', style: style),
|
||||||
StatusAvatar(info: _adderAvatar(item, members), size: fontSize + 3, badges: false, ring: false),
|
StatusAvatar(info: _adderAvatar(context, item, members), size: fontSize + 3, badges: false, ring: false),
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
Text(name, style: style),
|
Text(name, style: style),
|
||||||
dot,
|
dot,
|
||||||
@@ -1243,7 +1212,7 @@ class _MetaLine extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
if (dur.isNotEmpty) ...[Text(dur, style: style), dot],
|
if (dur.isNotEmpty) ...[Text(dur, style: style), dot],
|
||||||
Text('added by ', style: style),
|
Text('added by ', style: style),
|
||||||
StatusAvatar(info: _adderAvatar(item, members), size: fontSize + 3, badges: false, ring: false),
|
StatusAvatar(info: _adderAvatar(context, item, members), size: fontSize + 3, badges: false, ring: false),
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
// Capped rather than flexible: two flexibles split the room in
|
// Capped rather than flexible: two flexibles split the room in
|
||||||
// half and would cut the end off even when it fits.
|
// half and would cut the end off even when it fits.
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -8,8 +8,10 @@ import 'package:sync_relay_core/core.dart';
|
|||||||
|
|
||||||
|
|
||||||
// AniList airing schedule (public GraphQL, no login): every episode airing
|
// AniList airing schedule (public GraphQL, no login): every episode airing
|
||||||
// in a time range, with covers, banners and popularity; and a title search
|
// in a time range, with covers, banners and popularity; a title search
|
||||||
// (cover + banner of a show, for the media art of files and links).
|
// (cover + banner of a show, for the media art of files and links); and
|
||||||
|
// the library's lookups (search with synonyms and relations, one entry by
|
||||||
|
// id; see ARCHITECTURE.md "Media identity").
|
||||||
//
|
//
|
||||||
// AniList allows ~90 requests/min (sometimes degraded to 30), so the raw
|
// AniList allows ~90 requests/min (sometimes degraded to 30), so the raw
|
||||||
// pages are cached: in memory for 10 min (tab switches) and on disk for an
|
// pages are cached: in memory for 10 min (tab switches) and on disk for an
|
||||||
@@ -105,6 +107,177 @@ class AniListMedia {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// An anime as the library matches it ([AniListClient.searchAnime],
|
||||||
|
/// [AniListClient.anime]): titles and synonyms to match on, its format
|
||||||
|
/// and length, art, and the next TV entry of its run ([sequel]) to number
|
||||||
|
/// across split seasons and cours. JSON round-trips for the library's
|
||||||
|
/// cache ([toJson]).
|
||||||
|
class AniListEntry {
|
||||||
|
final int id;
|
||||||
|
final String romaji;
|
||||||
|
final String english;
|
||||||
|
final String native;
|
||||||
|
final List<String> synonyms;
|
||||||
|
/// `TV`, `TV_SHORT`, `MOVIE`, `OVA`, `ONA`, `SPECIAL`, `MUSIC`; '' unknown.
|
||||||
|
final String format;
|
||||||
|
/// Null while it airs with no announced count.
|
||||||
|
final int? episodes;
|
||||||
|
final int? year;
|
||||||
|
final String? cover;
|
||||||
|
final String? coverSmall;
|
||||||
|
final String? banner;
|
||||||
|
/// The next TV / TV_SHORT / ONA entry (`SEQUEL` relation).
|
||||||
|
final int? sequel;
|
||||||
|
final int? prequel;
|
||||||
|
/// Episode titles AniList knows (its streaming episodes), by number.
|
||||||
|
final Map<int, String> episodeTitles;
|
||||||
|
|
||||||
|
const AniListEntry({
|
||||||
|
required this.id,
|
||||||
|
this.romaji = '',
|
||||||
|
this.english = '',
|
||||||
|
this.native = '',
|
||||||
|
this.synonyms = const [],
|
||||||
|
this.format = '',
|
||||||
|
this.episodes,
|
||||||
|
this.year,
|
||||||
|
this.cover,
|
||||||
|
this.coverSmall,
|
||||||
|
this.banner,
|
||||||
|
this.sequel,
|
||||||
|
this.prequel,
|
||||||
|
this.episodeTitles = const {},
|
||||||
|
});
|
||||||
|
|
||||||
|
/// English title, else romaji.
|
||||||
|
String get title => english.isNotEmpty ? english : (romaji.isNotEmpty ? romaji : native);
|
||||||
|
|
||||||
|
/// Every name it goes by, for matching.
|
||||||
|
List<String> get names => [
|
||||||
|
for (final t in [english, romaji, native, ...synonyms])
|
||||||
|
if (t.trim().isNotEmpty) t,
|
||||||
|
];
|
||||||
|
|
||||||
|
bool get isMovie => format == 'MOVIE';
|
||||||
|
|
||||||
|
/// A run of episodes a season or cour is made of.
|
||||||
|
bool get isSeries => format == 'TV' || format == 'TV_SHORT' || format == 'ONA';
|
||||||
|
|
||||||
|
static const graphQLFields = 'id title{romaji english native} synonyms format episodes seasonYear '
|
||||||
|
'coverImage{extraLarge large} bannerImage '
|
||||||
|
'relations{edges{relationType node{id format type}}} streamingEpisodes{title}';
|
||||||
|
|
||||||
|
static final _streamingTitle = RegExp(r'^\s*(?:Episode|Ep\.?)\s*(\d{1,4})\s*[-–:]\s*(.+)$', caseSensitive: false);
|
||||||
|
|
||||||
|
/// From an AniList `Media` object; null when it has no id.
|
||||||
|
static AniListEntry? fromGraphQL(Object? m) {
|
||||||
|
if (m is! Map || m['id'] is! int) return null;
|
||||||
|
String str(Object? v) => v is String ? v : '';
|
||||||
|
final t = m['title'] is Map ? m['title'] as Map : const {};
|
||||||
|
final cov = m['coverImage'] is Map ? m['coverImage'] as Map : const {};
|
||||||
|
final large = cov['large'] is String ? cov['large'] as String : null;
|
||||||
|
int? related(String type) {
|
||||||
|
final edges = m['relations'] is Map ? (m['relations'] as Map)['edges'] : null;
|
||||||
|
if (edges is! List) return null;
|
||||||
|
for (final e in edges) {
|
||||||
|
if (e is! Map || e['relationType'] != type) continue;
|
||||||
|
final node = e['node'];
|
||||||
|
if (node is Map && node['id'] is int && node['type'] != 'MANGA' &&
|
||||||
|
const {'TV', 'TV_SHORT', 'ONA'}.contains(node['format'])) {
|
||||||
|
return node['id'] as int;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final titles = <int, String>{};
|
||||||
|
final streaming = m['streamingEpisodes'];
|
||||||
|
if (streaming is List) {
|
||||||
|
for (final s in streaming) {
|
||||||
|
final match = s is Map && s['title'] is String ? _streamingTitle.firstMatch(s['title'] as String) : null;
|
||||||
|
if (match != null) titles.putIfAbsent(int.parse(match.group(1)!), () => match.group(2)!.trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return AniListEntry(
|
||||||
|
id: m['id'] as int,
|
||||||
|
romaji: str(t['romaji']),
|
||||||
|
english: str(t['english']),
|
||||||
|
native: str(t['native']),
|
||||||
|
synonyms: [
|
||||||
|
if (m['synonyms'] is List)
|
||||||
|
for (final s in m['synonyms'] as List)
|
||||||
|
if (s is String && s.trim().isNotEmpty) s.trim(),
|
||||||
|
],
|
||||||
|
format: str(m['format']),
|
||||||
|
episodes: m['episodes'] is int ? m['episodes'] as int : null,
|
||||||
|
year: m['seasonYear'] is int ? m['seasonYear'] as int : null,
|
||||||
|
cover: cov['extraLarge'] is String ? cov['extraLarge'] as String : large,
|
||||||
|
coverSmall: large,
|
||||||
|
banner: m['bannerImage'] is String ? m['bannerImage'] as String : null,
|
||||||
|
sequel: related('SEQUEL'),
|
||||||
|
prequel: related('PREQUEL'),
|
||||||
|
episodeTitles: titles,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, Object?> toJson() => {
|
||||||
|
'id': id,
|
||||||
|
'romaji': romaji,
|
||||||
|
'english': english,
|
||||||
|
'native': native,
|
||||||
|
'synonyms': synonyms,
|
||||||
|
'format': format,
|
||||||
|
'episodes': episodes,
|
||||||
|
'year': year,
|
||||||
|
'cover': cover,
|
||||||
|
'coverSmall': coverSmall,
|
||||||
|
'banner': banner,
|
||||||
|
'sequel': sequel,
|
||||||
|
'prequel': prequel,
|
||||||
|
'episodeTitles': {for (final e in episodeTitles.entries) '${e.key}': e.value},
|
||||||
|
};
|
||||||
|
|
||||||
|
static AniListEntry? fromJson(Object? j) {
|
||||||
|
if (j is! Map || j['id'] is! int) return null;
|
||||||
|
String str(Object? v) => v is String ? v : '';
|
||||||
|
String? opt(Object? v) => v is String ? v : null;
|
||||||
|
int? n(Object? v) => v is int ? v : null;
|
||||||
|
final titles = j['episodeTitles'];
|
||||||
|
return AniListEntry(
|
||||||
|
id: j['id'] as int,
|
||||||
|
romaji: str(j['romaji']),
|
||||||
|
english: str(j['english']),
|
||||||
|
native: str(j['native']),
|
||||||
|
synonyms: [if (j['synonyms'] is List) ...(j['synonyms'] as List).whereType<String>()],
|
||||||
|
format: str(j['format']),
|
||||||
|
episodes: n(j['episodes']),
|
||||||
|
year: n(j['year']),
|
||||||
|
cover: opt(j['cover']),
|
||||||
|
coverSmall: opt(j['coverSmall']),
|
||||||
|
banner: opt(j['banner']),
|
||||||
|
sequel: n(j['sequel']),
|
||||||
|
prequel: n(j['prequel']),
|
||||||
|
episodeTitles: {
|
||||||
|
if (titles is Map)
|
||||||
|
for (final e in titles.entries)
|
||||||
|
if (int.tryParse('${e.key}') case final k? when e.value is String) k: e.value as String,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => 'AniListEntry($id "$title" $format ${episodes ?? '?'} eps)';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the library asks AniList ([AniListClient]; tests fake it).
|
||||||
|
abstract interface class AniListLookup {
|
||||||
|
/// Anime matching [query], AniList's best first.
|
||||||
|
Future<List<AniListEntry>> searchAnime(String query);
|
||||||
|
|
||||||
|
/// One anime by id; null when AniList has none.
|
||||||
|
Future<AniListEntry?> anime(int id);
|
||||||
|
}
|
||||||
|
|
||||||
/// "#e4a15d" -> opaque [Color]; anything else -> null.
|
/// "#e4a15d" -> opaque [Color]; anything else -> null.
|
||||||
Color? parseHexColor(Object? s) {
|
Color? parseHexColor(Object? s) {
|
||||||
if (s is! String || s.length != 7 || !s.startsWith('#')) return null;
|
if (s is! String || s.length != 7 || !s.startsWith('#')) return null;
|
||||||
@@ -142,7 +315,7 @@ class _Cached {
|
|||||||
const _Cached(this.fetched, this.from, this.to, this.raw);
|
const _Cached(this.fetched, this.from, this.to, this.raw);
|
||||||
}
|
}
|
||||||
|
|
||||||
class AniListClient {
|
class AniListClient implements AniListLookup {
|
||||||
AniListClient({
|
AniListClient({
|
||||||
String url = aniListDefaultUrl,
|
String url = aniListDefaultUrl,
|
||||||
HttpClient? http,
|
HttpClient? http,
|
||||||
@@ -306,6 +479,49 @@ coverImage{extraLarge large} bannerImage}}''';
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static final _listQuery = 'query(\$search:String){Page(perPage:8){media(search:\$search,type:ANIME,'
|
||||||
|
'isAdult:false,sort:SEARCH_MATCH){${AniListEntry.graphQLFields}}}}';
|
||||||
|
static final _byIdQuery = 'query(\$id:Int){Media(id:\$id,type:ANIME){${AniListEntry.graphQLFields}}}';
|
||||||
|
|
||||||
|
/// Up to 8 anime matching [query], AniList's best first (the library's
|
||||||
|
/// matching and its "Wrong show?" search). Not cached here: the library
|
||||||
|
/// keeps what it needs.
|
||||||
|
@override
|
||||||
|
Future<List<AniListEntry>> searchAnime(String query) async {
|
||||||
|
final q = query.trim();
|
||||||
|
if (q.isEmpty) return const [];
|
||||||
|
final (data, _, _) = await _graphql(_listQuery, {'search': q});
|
||||||
|
final page = data?['Page'];
|
||||||
|
final media = page is Map ? page['media'] : null;
|
||||||
|
return [if (media is List) for (final m in media) ?AniListEntry.fromGraphQL(m)];
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<AniListEntry?> anime(int id) async {
|
||||||
|
try {
|
||||||
|
final (data, _, _) = await _graphql(_byIdQuery, {'id': id});
|
||||||
|
return AniListEntry.fromGraphQL(data?['Media']);
|
||||||
|
} on AniListException catch (e) {
|
||||||
|
if (e.statusCode == 404) return null;
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static const byIdArtQuery = r'''
|
||||||
|
query($id:Int){Media(id:$id,type:ANIME){id title{romaji english} coverImage{extraLarge large} bannerImage}}''';
|
||||||
|
|
||||||
|
/// Cover and banner of AniList entry [id] (the media art of a file the
|
||||||
|
/// library matched); null when there is none.
|
||||||
|
Future<AniListMedia?> mediaById(int id) async {
|
||||||
|
try {
|
||||||
|
final (data, _, _) = await _graphql(byIdArtQuery, {'id': id});
|
||||||
|
return AniListMedia.fromJson(data?['Media']);
|
||||||
|
} on AniListException catch (e) {
|
||||||
|
if (e.statusCode == 404) return null;
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
static String _errorText(Object? j) {
|
static String _errorText(Object? j) {
|
||||||
final errs = j is Map ? j['errors'] : null;
|
final errs = j is Map ? j['errors'] : null;
|
||||||
if (errs is! List || errs.isEmpty) return '';
|
if (errs is! List || errs.isEmpty) return '';
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ enum CardAction {
|
|||||||
none,
|
none,
|
||||||
putUp,
|
putUp,
|
||||||
|
|
||||||
/// Available, nobody else watching (offline or solo): plays it here.
|
/// Available, nobody else in the server: plays it here.
|
||||||
play,
|
play,
|
||||||
request,
|
request,
|
||||||
requestSeries,
|
requestSeries,
|
||||||
|
|||||||
@@ -24,9 +24,8 @@ import 'schedule_card.dart';
|
|||||||
enum ScheduleFilter { jellyfin, all }
|
enum ScheduleFilter { jellyfin, all }
|
||||||
|
|
||||||
class SchedulePage extends StatefulWidget {
|
class SchedulePage extends StatefulWidget {
|
||||||
/// Nobody else watches (offline or solo): available episodes say Play
|
/// Nobody else is in the server (D9): available episodes say Play.
|
||||||
/// (offline that starts a solo session).
|
final bool alone;
|
||||||
final bool solo;
|
|
||||||
|
|
||||||
/// Puts a Jellyfin episode up in the server; returns an error to show.
|
/// Puts a Jellyfin episode up in the server; returns an error to show.
|
||||||
final Future<String?> Function(JellyfinMedia pick) onPutUp;
|
final Future<String?> Function(JellyfinMedia pick) onPutUp;
|
||||||
@@ -43,7 +42,7 @@ class SchedulePage extends StatefulWidget {
|
|||||||
|
|
||||||
const SchedulePage({
|
const SchedulePage({
|
||||||
super.key,
|
super.key,
|
||||||
this.solo = false,
|
this.alone = false,
|
||||||
required this.onPutUp,
|
required this.onPutUp,
|
||||||
this.onAddToQueue,
|
this.onAddToQueue,
|
||||||
required this.onToast,
|
required this.onToast,
|
||||||
@@ -228,7 +227,7 @@ class _SchedulePageState extends State<SchedulePage> {
|
|||||||
|
|
||||||
(CardAction, String?) _actionFor(ScheduleEntry e) {
|
(CardAction, String?) _actionFor(ScheduleEntry e) {
|
||||||
if (e.status == ScheduleStatus.available || e.status == ScheduleStatus.inLibrary) {
|
if (e.status == ScheduleStatus.available || e.status == ScheduleStatus.inLibrary) {
|
||||||
final put = widget.solo ? CardAction.play : CardAction.putUp;
|
final put = widget.alone ? CardAction.play : CardAction.putUp;
|
||||||
if (e.jellyfinSeriesId == null) return (put, 'Not linked to a Jellyfin series yet');
|
if (e.jellyfinSeriesId == null) return (put, 'Not linked to a Jellyfin series yet');
|
||||||
return (put, null);
|
return (put, null);
|
||||||
}
|
}
|
||||||
@@ -287,7 +286,7 @@ class _SchedulePageState extends State<SchedulePage> {
|
|||||||
final add = widget.onAddToQueue;
|
final add = widget.onAddToQueue;
|
||||||
if (add == null) return;
|
if (add == null) return;
|
||||||
final pick = await showAppMenu<int>(context, items: [
|
final pick = await showAppMenu<int>(context, items: [
|
||||||
AppMenuItem(value: 0, label: widget.solo ? 'Play' : 'Put it up'),
|
AppMenuItem(value: 0, label: widget.alone ? 'Play' : 'Put it up'),
|
||||||
const AppMenuItem(value: 1, label: 'Add to queue'),
|
const AppMenuItem(value: 1, label: 'Add to queue'),
|
||||||
const AppMenuItem(value: 2, label: 'Play next'),
|
const AppMenuItem(value: 2, label: 'Play next'),
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import 'package:signals/signals_flutter.dart';
|
|||||||
|
|
||||||
import '../../platform/app_log.dart';
|
import '../../platform/app_log.dart';
|
||||||
import '../../shell/mark_problem.dart';
|
import '../../shell/mark_problem.dart';
|
||||||
import '../../state/session_state.dart';
|
import '../../app/app_graph.dart';
|
||||||
import '../../state/settings.dart';
|
import '../../state/settings.dart';
|
||||||
import '../../update/update_controller.dart';
|
import '../../update/update_controller.dart';
|
||||||
import '../../widgets/app_menu.dart';
|
import '../../widgets/app_menu.dart';
|
||||||
@@ -43,10 +43,10 @@ class AboutPanel extends StatelessWidget {
|
|||||||
IconButton(
|
IconButton(
|
||||||
tooltip: ReuseText.copyLink,
|
tooltip: ReuseText.copyLink,
|
||||||
icon: const Icon(Icons.copy, size: 18),
|
icon: const Icon(Icons.copy, size: 18),
|
||||||
onPressed: () => copyToClipboard(repoUrl, done: 'Link copied'),
|
onPressed: () => copyToClipboard(context, repoUrl, done: 'Link copied'),
|
||||||
),
|
),
|
||||||
FilledButton.tonalIcon(
|
FilledButton.tonalIcon(
|
||||||
onPressed: () => openLink(repoUrl),
|
onPressed: () => openLink(context, repoUrl),
|
||||||
icon: const Icon(Icons.open_in_new, size: 18),
|
icon: const Icon(Icons.open_in_new, size: 18),
|
||||||
label: const Text('Source'),
|
label: const Text('Source'),
|
||||||
),
|
),
|
||||||
@@ -75,7 +75,7 @@ class AboutPanel extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
const MarkProblemButton(outlined: true),
|
const MarkProblemButton(outlined: true),
|
||||||
OutlinedButton.icon(
|
OutlinedButton.icon(
|
||||||
onPressed: appLogDir == null ? null : () => _openLogs(),
|
onPressed: appLogDir == null ? null : () => _openLogs(context),
|
||||||
icon: const Icon(Icons.folder_open, size: 18),
|
icon: const Icon(Icons.folder_open, size: 18),
|
||||||
label: const Text('Open logs folder'),
|
label: const Text('Open logs folder'),
|
||||||
),
|
),
|
||||||
@@ -102,7 +102,8 @@ class AboutPanel extends StatelessWidget {
|
|||||||
/// In a session it asks whether to add the server's session log.
|
/// In a session it asks whether to add the server's session log.
|
||||||
static Future<void> _copyDiagnostics(BuildContext context) async {
|
static Future<void> _copyDiagnostics(BuildContext context) async {
|
||||||
var withServer = false;
|
var withServer = false;
|
||||||
if (SessionState.instance.route.peek() == AppRoute.session) {
|
final graph = AppScope.of(context);
|
||||||
|
if (graph.navigation.online.peek()) {
|
||||||
final pick = await showAppMenu<bool>(context, items: const [
|
final pick = await showAppMenu<bool>(context, items: const [
|
||||||
AppMenuItem(value: false, label: "This app's log"),
|
AppMenuItem(value: false, label: "This app's log"),
|
||||||
AppMenuItem(value: true, label: "This app's and the server's session log"),
|
AppMenuItem(value: true, label: "This app's and the server's session log"),
|
||||||
@@ -110,17 +111,20 @@ class AboutPanel extends StatelessWidget {
|
|||||||
if (pick == null) return;
|
if (pick == null) return;
|
||||||
withServer = pick;
|
withServer = pick;
|
||||||
}
|
}
|
||||||
final text = await diagnosticsText(sessionLog: withServer);
|
final text = await diagnosticsText(graph, sessionLog: withServer);
|
||||||
final saved = await saveDiagnostics(text);
|
final saved = await saveDiagnostics(text);
|
||||||
await copyToClipboard(text,
|
if (!context.mounted) return;
|
||||||
|
await copyToClipboard(context, text,
|
||||||
done: saved == null ? 'Diagnostics copied' : 'Diagnostics copied (also saved in the logs folder)');
|
done: saved == null ? 'Diagnostics copied' : 'Diagnostics copied (also saved in the logs folder)');
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<void> _openLogs() async {
|
static Future<void> _openLogs(BuildContext context) async {
|
||||||
final dir = appLogDir;
|
final dir = appLogDir;
|
||||||
if (dir == null) return;
|
if (dir == null) return;
|
||||||
if (!await SessionState.instance.ports.folders.reveal(dir)) {
|
final folders = AppScope.of(context).ports.folders;
|
||||||
await copyToClipboard(dir, done: "Couldn't open it — folder path copied");
|
if (!await folders.reveal(dir)) {
|
||||||
|
if (!context.mounted) return;
|
||||||
|
await copyToClipboard(context, dir, done: "Couldn't open it — folder path copied");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2,26 +2,35 @@ import 'package:flutter/foundation.dart';
|
|||||||
|
|
||||||
import '../../session/connection.dart' show connectErrorText;
|
import '../../session/connection.dart' show connectErrorText;
|
||||||
import '../../session/identity.dart';
|
import '../../session/identity.dart';
|
||||||
|
import '../../session/navigation.dart';
|
||||||
import '../../session/session_coordinator.dart';
|
import '../../session/session_coordinator.dart';
|
||||||
import '../../state/session_state.dart';
|
import '../../state/connect_prefs.dart';
|
||||||
import '../../sync/sync_client.dart' show HelloRefused;
|
import '../../sync/sync_client.dart' show HelloRefused;
|
||||||
|
import 'health_probe.dart' show VersionGateRefused;
|
||||||
|
|
||||||
/// What a card shows under its fields after a failed attempt.
|
/// What the Join card shows under its fields after a failed attempt.
|
||||||
|
/// [offerUpdate]: this app is the older one (the version gate), so the
|
||||||
|
/// card offers the updater.
|
||||||
class CardError {
|
class CardError {
|
||||||
final String title;
|
final String title;
|
||||||
final String message;
|
final String message;
|
||||||
const CardError(this.title, this.message);
|
final bool offerUpdate;
|
||||||
|
const CardError(this.title, this.message, {this.offerUpdate = false});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Host/Join submit state for the strip's two connect cards. One attempt at a time;
|
/// The Join card's submit state, owned by the shell so an attempt and its
|
||||||
/// [cancel] abandons it via [SessionState.cancelPending].
|
/// error outlive the card. One attempt at a time; [cancel] abandons it
|
||||||
|
/// ([Navigation.cancelPending]).
|
||||||
class ConnectFlow extends ChangeNotifier {
|
class ConnectFlow extends ChangeNotifier {
|
||||||
ConnectFlow({SessionState? session}) : _session = session ?? SessionState.instance;
|
ConnectFlow({required this.navigation, required this.forms, this.profile});
|
||||||
final SessionState _session;
|
final Navigation navigation;
|
||||||
|
final ConnectStore forms;
|
||||||
|
/// Name and picture for the hello (default: the app's).
|
||||||
|
final Profile? profile;
|
||||||
|
|
||||||
ConnectMode? busy;
|
bool busy = false;
|
||||||
final errors = <ConnectMode, CardError?>{};
|
CardError? error;
|
||||||
final notices = <ConnectMode, String?>{};
|
String? notice;
|
||||||
int _attempt = 0;
|
int _attempt = 0;
|
||||||
bool _disposed = false;
|
bool _disposed = false;
|
||||||
|
|
||||||
@@ -35,116 +44,101 @@ class ConnectFlow extends ChangeNotifier {
|
|||||||
if (!_disposed) notifyListeners();
|
if (!_disposed) notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
void showError(ConnectMode mode, CardError error) {
|
void showError(CardError e) {
|
||||||
errors[mode] = error;
|
error = e;
|
||||||
notices[mode] = null;
|
notice = null;
|
||||||
_notify();
|
_notify();
|
||||||
}
|
}
|
||||||
|
|
||||||
void clearError(ConnectMode mode) {
|
void clearError() {
|
||||||
if (errors[mode] == null && notices[mode] == null) return;
|
if (error == null && notice == null) return;
|
||||||
errors[mode] = null;
|
error = null;
|
||||||
notices[mode] = null;
|
notice = null;
|
||||||
_notify();
|
_notify();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Validates and builds the params; throws [FormatException] with a
|
/// Validates and builds the params; throws [FormatException] with a
|
||||||
/// user-facing message. [avatar] is the base64 picture (the profile's).
|
/// user-facing message. [avatar] is the base64 picture (the profile's).
|
||||||
/// Hosting puts nothing up: what to watch is picked in the library.
|
static ConnectParams buildParams(ConnectForm f, {String avatar = ''}) {
|
||||||
static ConnectParams buildParams(ConnectMode mode, ConnectForm f, {String avatar = ''}) {
|
final port = int.tryParse(f.port.trim());
|
||||||
final rawPort = mode == ConnectMode.host ? f.hostPort : f.port;
|
|
||||||
final port = int.tryParse(rawPort.trim());
|
|
||||||
if (port == null || port <= 0 || port > 65535) {
|
if (port == null || port <= 0 || port > 65535) {
|
||||||
throw const FormatException('port must be 1–65535');
|
throw const FormatException('port must be 1–65535');
|
||||||
}
|
}
|
||||||
if (f.name.trim().isEmpty) {
|
if (f.name.trim().isEmpty) {
|
||||||
throw const FormatException('set your display name first (click your avatar at the top left)');
|
throw const FormatException('set your display name first (click your avatar at the top left)');
|
||||||
}
|
}
|
||||||
if (mode == ConnectMode.join && f.host.trim().isEmpty) {
|
if (f.host.trim().isEmpty) {
|
||||||
throw const FormatException('enter the server address');
|
throw const FormatException('enter the server address');
|
||||||
}
|
}
|
||||||
return ConnectParams(
|
return ConnectParams(host: f.host.trim(), port: port, secret: f.secret, name: f.name.trim(), avatar: avatar);
|
||||||
host: f.host.trim(),
|
|
||||||
port: port,
|
|
||||||
secret: mode == ConnectMode.host ? f.hostSecret : f.secret,
|
|
||||||
name: f.name.trim(),
|
|
||||||
avatar: avatar,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// [edit] fills the card's fields into a copy of the last form, so the
|
/// [edit] fills the card's fields into a copy of the form; the form is
|
||||||
/// other card's saved values survive.
|
/// saved whatever happens (a failed join is when the user wants it back).
|
||||||
Future<void> submit(ConnectMode mode, void Function(ConnectForm f) edit) async {
|
Future<void> submit(void Function(ConnectForm f) edit) async {
|
||||||
if (busy != null) return;
|
if (busy) return;
|
||||||
final myAttempt = ++_attempt;
|
final myAttempt = ++_attempt;
|
||||||
final form = _session.lastForm.copy();
|
final form = forms.form.copy();
|
||||||
edit(form);
|
edit(form);
|
||||||
form.name = form.name.trim();
|
form.name = form.name.trim();
|
||||||
form.host = form.host.trim();
|
form.host = form.host.trim();
|
||||||
busy = mode;
|
busy = true;
|
||||||
errors[mode] = null;
|
error = null;
|
||||||
notices[mode] = null;
|
notice = null;
|
||||||
_notify();
|
_notify();
|
||||||
|
|
||||||
ConnectParams params;
|
ConnectParams params;
|
||||||
try {
|
try {
|
||||||
final profile = Profile.instance;
|
final profile = this.profile ?? Profile.instance;
|
||||||
await profile.ensureLoaded();
|
await profile.ensureLoaded();
|
||||||
params = buildParams(mode, form, avatar: profile.avatarBase64());
|
params = buildParams(form, avatar: profile.avatarBase64());
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (myAttempt != _attempt) return;
|
if (myAttempt != _attempt) return;
|
||||||
busy = null;
|
busy = false;
|
||||||
errors[mode] = CardError('Check the form',
|
error = CardError('Check the form', e is FormatException ? e.message : 'check the form and try again');
|
||||||
e is FormatException ? e.message : 'check the form and try again');
|
|
||||||
_notify();
|
_notify();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final fromSolo = mode == ConnectMode.host && _session.solo.value;
|
forms.form
|
||||||
|
..host = form.host
|
||||||
|
..port = form.port
|
||||||
|
..secret = form.secret;
|
||||||
try {
|
try {
|
||||||
if (mode == ConnectMode.host) {
|
await navigation.join(params);
|
||||||
await _session.host(params, form);
|
|
||||||
} else {
|
|
||||||
await _session.join(params, form);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (myAttempt != _attempt) return;
|
if (myAttempt != _attempt) return;
|
||||||
busy = null;
|
busy = false;
|
||||||
// A refusal says why in plain words (an older host, a wrong password,
|
// In plain words: the version gate (who has to update), a refusal
|
||||||
// another window).
|
// (an older host, a wrong password, another window), offline.
|
||||||
errors[mode] = CardError('Could not connect', e is HelloRefused ? connectErrorText(e) : '$e');
|
error = switch (e) {
|
||||||
|
VersionGateRefused(:final message, :final youUpdate) =>
|
||||||
|
CardError("Versions don't match", message, offerUpdate: youUpdate),
|
||||||
|
HelloRefused() => CardError('Could not connect', connectErrorText(e)),
|
||||||
|
_ => CardError('Could not connect', '$e'),
|
||||||
|
};
|
||||||
_notify();
|
_notify();
|
||||||
|
await forms.saveQuietly();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (myAttempt != _attempt) {
|
if (myAttempt != _attempt) {
|
||||||
// Cancelled after it got through: back out (an Invite: back to solo).
|
// Cancelled after it got through: back home.
|
||||||
try {
|
try {
|
||||||
if (fromSolo) {
|
await navigation.goHome();
|
||||||
await _session.cancelInvite();
|
|
||||||
} else {
|
|
||||||
await _session.leave();
|
|
||||||
await _session.stopServer();
|
|
||||||
}
|
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
busy = null;
|
busy = false;
|
||||||
_notify();
|
_notify();
|
||||||
await _session.rememberServer(RecentServer(
|
|
||||||
hosted: mode == ConnectMode.host,
|
|
||||||
host: mode == ConnectMode.host ? '' : form.host,
|
|
||||||
port: mode == ConnectMode.host ? form.hostPort.trim() : form.port.trim(),
|
|
||||||
secret: mode == ConnectMode.host ? form.hostSecret : form.secret,
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> cancel() async {
|
Future<void> cancel() async {
|
||||||
final mode = busy;
|
final was = busy;
|
||||||
_attempt++;
|
_attempt++;
|
||||||
try {
|
try {
|
||||||
await _session.cancelPending();
|
await navigation.cancelPending();
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
busy = null;
|
busy = false;
|
||||||
if (mode != null) notices[mode] = 'Cancelled';
|
if (was) notice = 'Cancelled';
|
||||||
_notify();
|
_notify();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,21 +1,20 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import '../../state/session_state.dart';
|
import '../../update/update_controller.dart';
|
||||||
import 'connect_flow.dart';
|
import 'connect_flow.dart';
|
||||||
import 'ui.dart';
|
import 'ui.dart';
|
||||||
|
import 'update_indicator.dart' show runUpdateCheck;
|
||||||
|
|
||||||
/// The submit button for one [ConnectMode], swapped for a progress bar and
|
/// The Join button, swapped for a progress bar and Cancel while the
|
||||||
/// Cancel while that attempt runs. Disabled while the other mode is busy.
|
/// attempt runs.
|
||||||
class ConnectButton extends StatelessWidget {
|
class ConnectButton extends StatelessWidget {
|
||||||
final ConnectFlow flow;
|
final ConnectFlow flow;
|
||||||
final ConnectMode mode;
|
|
||||||
final String label;
|
final String label;
|
||||||
final VoidCallback onSubmit;
|
final VoidCallback onSubmit;
|
||||||
final bool expand;
|
final bool expand;
|
||||||
const ConnectButton({
|
const ConnectButton({
|
||||||
super.key,
|
super.key,
|
||||||
required this.flow,
|
required this.flow,
|
||||||
required this.mode,
|
|
||||||
required this.label,
|
required this.label,
|
||||||
required this.onSubmit,
|
required this.onSubmit,
|
||||||
this.expand = false,
|
this.expand = false,
|
||||||
@@ -23,8 +22,7 @@ class ConnectButton extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final busyHere = flow.busy == mode;
|
if (flow.busy) {
|
||||||
if (busyHere) {
|
|
||||||
return SizedBox(
|
return SizedBox(
|
||||||
width: expand ? null : 220,
|
width: expand ? null : 220,
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -35,10 +33,7 @@ class ConnectButton extends StatelessWidget {
|
|||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(child: Text('Connecting…', style: Theme.of(context).textTheme.bodySmall)),
|
||||||
child: Text(mode == ConnectMode.host ? 'Starting server…' : 'Connecting…',
|
|
||||||
style: Theme.of(context).textTheme.bodySmall),
|
|
||||||
),
|
|
||||||
TextButton(onPressed: flow.cancel, child: const Text('Cancel')),
|
TextButton(onPressed: flow.cancel, child: const Text('Cancel')),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -46,35 +41,48 @@ class ConnectButton extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
final button = FilledButton(
|
final button = FilledButton(onPressed: onSubmit, child: Text(label));
|
||||||
onPressed: flow.busy != null ? null : onSubmit,
|
|
||||||
child: Text(label),
|
|
||||||
);
|
|
||||||
return expand ? SizedBox(width: double.infinity, child: button) : button;
|
return expand ? SizedBox(width: double.infinity, child: button) : button;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Last error or notice for [mode], or nothing.
|
/// The last error or notice, or nothing. A version gate that says this
|
||||||
|
/// app is the older one offers the update ([updates], default the app's
|
||||||
|
/// [UpdateController]).
|
||||||
class ConnectFeedback extends StatelessWidget {
|
class ConnectFeedback extends StatelessWidget {
|
||||||
final ConnectFlow flow;
|
final ConnectFlow flow;
|
||||||
final ConnectMode mode;
|
final UpdateController? updates;
|
||||||
const ConnectFeedback({super.key, required this.flow, required this.mode});
|
const ConnectFeedback({super.key, required this.flow, this.updates});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final error = flow.errors[mode];
|
final error = flow.error;
|
||||||
final notice = flow.notices[mode];
|
final notice = flow.notice;
|
||||||
if (error != null) {
|
if (error != null) {
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.only(top: 12),
|
padding: const EdgeInsets.only(top: 12),
|
||||||
child: ErrorBox(title: error.title, message: error.message),
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
ErrorBox(title: error.title, message: error.message),
|
||||||
|
if (error.offerUpdate)
|
||||||
|
Align(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: TextButton.icon(
|
||||||
|
key: const ValueKey('join-update'),
|
||||||
|
onPressed: () => runUpdateCheck(updates, true),
|
||||||
|
icon: const Icon(Icons.system_update_alt, size: 16),
|
||||||
|
label: const Text('Check for the update'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (notice != null) {
|
if (notice != null) {
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.only(top: 12),
|
padding: const EdgeInsets.only(top: 12),
|
||||||
child: Text(notice,
|
child: Text(notice, style: Theme.of(context).textTheme.bodySmall?.copyWith(color: Colors.grey)),
|
||||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: Colors.grey)),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return const SizedBox.shrink();
|
return const SizedBox.shrink();
|
||||||
|
|||||||
@@ -10,34 +10,76 @@ typedef HealthGetter = Future<String> Function(Uri uri);
|
|||||||
const healthPollEvery = Duration(seconds: 10);
|
const healthPollEvery = Duration(seconds: 10);
|
||||||
const _timeout = Duration(seconds: 4);
|
const _timeout = Duration(seconds: 4);
|
||||||
|
|
||||||
/// What a recent-server tile shows: unknown until the first probe answers.
|
/// Whether this app and a server have a wire version in common.
|
||||||
|
enum VersionFit {
|
||||||
|
/// Their ranges meet: the join can go ahead.
|
||||||
|
ok,
|
||||||
|
/// The server's newest version is older than anything this app speaks.
|
||||||
|
theyUpdate,
|
||||||
|
/// The server's oldest version is newer than anything this app speaks.
|
||||||
|
youUpdate,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [VersionFit] of a server that speaks [min] .. [max] (`/health`
|
||||||
|
/// `protoMin` / `protoMax`), against this build's
|
||||||
|
/// [RelayConstants.protoMin] .. [RelayConstants.protoMax].
|
||||||
|
VersionFit versionFit(int min, int max,
|
||||||
|
{int ownMin = RelayConstants.protoMin, int ownMax = RelayConstants.protoMax}) {
|
||||||
|
if (max < ownMin) return VersionFit.theyUpdate;
|
||||||
|
if (min > ownMax) return VersionFit.youUpdate;
|
||||||
|
return VersionFit.ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a recent-server row and the version gate read from `/health`:
|
||||||
|
/// unknown until the first probe answers.
|
||||||
class ServerHealth {
|
class ServerHealth {
|
||||||
final bool online;
|
final bool online;
|
||||||
final String name;
|
final String name;
|
||||||
final int? proto;
|
/// The server's wire range (`protoMin` .. `protoMax`; a server too old
|
||||||
|
/// to say has only `proto`, used for both). Null when it says nothing.
|
||||||
|
final int? protoMin;
|
||||||
|
final int? protoMax;
|
||||||
final int viewers;
|
final int viewers;
|
||||||
|
/// Its Starsync version (`version`), '' from a server too old to say.
|
||||||
|
final String version;
|
||||||
const ServerHealth.offline()
|
const ServerHealth.offline()
|
||||||
: online = false,
|
: online = false,
|
||||||
name = '',
|
name = '',
|
||||||
proto = null,
|
protoMin = null,
|
||||||
viewers = 0;
|
protoMax = null,
|
||||||
const ServerHealth.online({required this.name, this.proto, this.viewers = 0})
|
viewers = 0,
|
||||||
|
version = '';
|
||||||
|
const ServerHealth.online({required this.name, this.protoMin, this.protoMax, this.viewers = 0, this.version = ''})
|
||||||
: online = true;
|
: online = true;
|
||||||
|
|
||||||
/// True when the server speaks another protocol than this build.
|
/// Null while unknown (offline, or no version given).
|
||||||
bool get needsUpdate =>
|
VersionFit? get fit {
|
||||||
online && proto != null && proto != RelayConstants.protoVersion;
|
final min = protoMin, max = protoMax;
|
||||||
|
if (!online || min == null || max == null) return null;
|
||||||
|
return versionFit(min, max);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True when the server and this app have no version in common.
|
||||||
|
bool get needsUpdate => fit != null && fit != VersionFit.ok;
|
||||||
|
|
||||||
|
/// The server's own name for people ('' for the dedicated server's
|
||||||
|
/// default `server`).
|
||||||
|
String get label => name.isNotEmpty && name != 'server' ? name : '';
|
||||||
|
|
||||||
/// Lenient parse of `GET /health`; anything unexpected reads as offline.
|
/// Lenient parse of `GET /health`; anything unexpected reads as offline.
|
||||||
static ServerHealth parse(String body) {
|
static ServerHealth parse(String body) {
|
||||||
try {
|
try {
|
||||||
final j = jsonDecode(body);
|
final j = jsonDecode(body);
|
||||||
if (j is! Map || j['ok'] != true) return const ServerHealth.offline();
|
if (j is! Map || j['ok'] != true) return const ServerHealth.offline();
|
||||||
final name = j['name'], proto = j['proto'], viewers = j['viewers'];
|
final name = j['name'], proto = j['proto'], viewers = j['viewers'], version = j['version'];
|
||||||
|
final min = j['protoMin'], max = j['protoMax'];
|
||||||
|
final p = proto is num ? proto.toInt() : null;
|
||||||
return ServerHealth.online(
|
return ServerHealth.online(
|
||||||
name: name is String ? name.trim() : '',
|
name: name is String ? name.trim() : '',
|
||||||
proto: proto is num ? proto.toInt() : null,
|
protoMin: min is num ? min.toInt() : p,
|
||||||
|
protoMax: max is num ? max.toInt() : p,
|
||||||
viewers: viewers is num ? viewers.toInt() : 0,
|
viewers: viewers is num ? viewers.toInt() : 0,
|
||||||
|
version: version is String ? version.trim() : '',
|
||||||
);
|
);
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
return const ServerHealth.offline();
|
return const ServerHealth.offline();
|
||||||
@@ -45,6 +87,26 @@ class ServerHealth {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The version gate's message for a server that does not fit: who has
|
||||||
|
/// to update, in plain words ("Bob's server runs Starsync 1.2.1 — they
|
||||||
|
/// need to update").
|
||||||
|
String versionGateText(ServerHealth h) {
|
||||||
|
final who = h.label.isNotEmpty ? h.label : 'The server';
|
||||||
|
final runs = h.version.isNotEmpty ? '$who runs Starsync ${h.version}' : '$who runs ${h.fit == VersionFit.youUpdate ? 'a newer' : 'an older'} Starsync';
|
||||||
|
return h.fit == VersionFit.youUpdate ? '$runs — you need to update' : '$runs — they need to update';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A join stopped before connecting: the server and this app have no
|
||||||
|
/// version in common ([versionGateText]). [youUpdate]: this app is the
|
||||||
|
/// older one, so the updater can help.
|
||||||
|
class VersionGateRefused implements Exception {
|
||||||
|
final String message;
|
||||||
|
final bool youUpdate;
|
||||||
|
const VersionGateRefused(this.message, {required this.youUpdate});
|
||||||
|
@override
|
||||||
|
String toString() => message;
|
||||||
|
}
|
||||||
|
|
||||||
Uri healthUri(String host, String port) {
|
Uri healthUri(String host, String port) {
|
||||||
final h = host.trim();
|
final h = host.trim();
|
||||||
// Bare IPv6 literals need brackets in a URL.
|
// Bare IPv6 literals need brackets in a URL.
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import 'package:sync_relay_core/core.dart' show MediaRef;
|
||||||
|
|
||||||
/// What the media picker selected. Null selection = nothing (hosting
|
/// What the media picker selected. Null selection = nothing (hosting
|
||||||
/// without media is allowed).
|
/// without media is allowed).
|
||||||
sealed class PickedMedia {
|
sealed class PickedMedia {
|
||||||
@@ -72,6 +74,9 @@ final class JellyfinMedia extends PickedMedia {
|
|||||||
/// "Series · S1E6" for episodes, the year for movies; may be empty.
|
/// "Series · S1E6" for episodes, the year for movies; may be empty.
|
||||||
final String subtitle;
|
final String subtitle;
|
||||||
final Duration? runtime;
|
final Duration? runtime;
|
||||||
|
/// What it is (`media.ref`), when it was picked from the item itself
|
||||||
|
/// (with its provider ids); not stored, not part of equality.
|
||||||
|
final MediaRef? ref;
|
||||||
const JellyfinMedia({
|
const JellyfinMedia({
|
||||||
required this.serverId,
|
required this.serverId,
|
||||||
required this.baseUrl,
|
required this.baseUrl,
|
||||||
@@ -80,6 +85,7 @@ final class JellyfinMedia extends PickedMedia {
|
|||||||
required this.title,
|
required this.title,
|
||||||
this.subtitle = '',
|
this.subtitle = '',
|
||||||
this.runtime,
|
this.runtime,
|
||||||
|
this.ref,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:signals/signals_flutter.dart';
|
import 'package:signals/signals_flutter.dart';
|
||||||
|
|
||||||
import '../../state/session_state.dart';
|
import '../../app/app_graph.dart';
|
||||||
import '../../state/settings.dart';
|
import '../../state/settings.dart';
|
||||||
import '../../update/update_controller.dart';
|
import '../../update/update_controller.dart';
|
||||||
import 'ui.dart';
|
import 'ui.dart';
|
||||||
@@ -88,10 +88,11 @@ class SettingsHintCard extends StatelessWidget {
|
|||||||
class CustomShaderFiles extends StatelessWidget {
|
class CustomShaderFiles extends StatelessWidget {
|
||||||
const CustomShaderFiles({super.key});
|
const CustomShaderFiles({super.key});
|
||||||
|
|
||||||
Future<void> _addFiles() async {
|
Future<void> _addFiles(BuildContext context) async {
|
||||||
|
final folders = AppScope.of(context).ports.folders;
|
||||||
final List<String> files;
|
final List<String> files;
|
||||||
try {
|
try {
|
||||||
files = await SessionState.instance.ports.folders.pickFiles(
|
files = await folders.pickFiles(
|
||||||
dialogTitle: 'Pick mpv GLSL shaders',
|
dialogTitle: 'Pick mpv GLSL shaders',
|
||||||
extensions: const ['glsl', 'hook'],
|
extensions: const ['glsl', 'hook'],
|
||||||
);
|
);
|
||||||
@@ -123,7 +124,7 @@ class CustomShaderFiles extends StatelessWidget {
|
|||||||
style: muted),
|
style: muted),
|
||||||
),
|
),
|
||||||
TextButton.icon(
|
TextButton.icon(
|
||||||
onPressed: _addFiles,
|
onPressed: () => _addFiles(context),
|
||||||
icon: const Icon(Icons.add, size: 18),
|
icon: const Icon(Icons.add, size: 18),
|
||||||
label: const Text('Add .glsl files'),
|
label: const Text('Add .glsl files'),
|
||||||
),
|
),
|
||||||
@@ -219,8 +220,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,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 {
|
||||||
|
|||||||
@@ -15,65 +15,71 @@ sealed class AppEvent {
|
|||||||
/// in the activity log.
|
/// in the activity log.
|
||||||
sealed class FeedEvent extends AppEvent {
|
sealed class FeedEvent extends AppEvent {
|
||||||
final FeedMessage feed;
|
final FeedMessage feed;
|
||||||
const FeedEvent(this.feed);
|
/// This member is the actor (its own chat, ready, queue add...).
|
||||||
|
final bool own;
|
||||||
|
const FeedEvent(this.feed, {this.own = false});
|
||||||
|
|
||||||
String get actor => feed.actor;
|
String get actor => feed.actor;
|
||||||
|
|
||||||
/// [feed] as its typed event. [selfId]: this member's id (people are
|
/// [feed] as its typed event. [selfId]: this member's id (people are
|
||||||
/// matched by `actorId` / `targetId`, never by name); [readyCount]:
|
/// matched by `actorId` / `targetId`, never by name); [readyCount]:
|
||||||
/// members ready in the latest state (the pitch of a `ready` sound).
|
/// members ready in the latest state (the pitch of a `ready` sound);
|
||||||
factory FeedEvent.of(FeedMessage f, {String selfId = '', int readyCount = 0}) {
|
/// [queueThumb]: the picture hash of the Up next item a `queue-add`
|
||||||
|
/// added (from the state that came with it), '' when none.
|
||||||
|
factory FeedEvent.of(FeedMessage f, {String selfId = '', int readyCount = 0, String queueThumb = ''}) {
|
||||||
bool isSelf(String id) => selfId.isNotEmpty && id == selfId;
|
bool isSelf(String id) => selfId.isNotEmpty && id == selfId;
|
||||||
|
final own = isSelf(f.actorId);
|
||||||
return switch (f.action) {
|
return switch (f.action) {
|
||||||
'join' => MemberJoined(f),
|
'join' => MemberJoined(f, own: own),
|
||||||
'leave' => MemberLeft(f),
|
'leave' => MemberLeft(f, own: own),
|
||||||
'gone' => MemberGone(f),
|
'gone' => MemberGone(f, own: own),
|
||||||
'return' => MemberReturned(f),
|
'return' => MemberReturned(f, own: own),
|
||||||
'away' => MemberSatOut(f,
|
'away' => MemberSatOut(f,
|
||||||
|
own: own,
|
||||||
atYou: isSelf(f.targetId) && !isSelf(f.actorId),
|
atYou: isSelf(f.targetId) && !isSelf(f.actorId),
|
||||||
autoSelf: isSelf(f.actorId) && f.targetId.isEmpty && f.reason.isNotEmpty),
|
autoSelf: isSelf(f.actorId) && f.targetId.isEmpty && f.reason.isNotEmpty),
|
||||||
'in' => MemberBack(f),
|
'in' => MemberBack(f, own: own),
|
||||||
'ready' => ReadyChanged(f, ready: true, readyIndex: readyCount),
|
'ready' => ReadyChanged(f, own: own, ready: true, readyIndex: readyCount),
|
||||||
'unready' => ReadyChanged(f, ready: false),
|
'unready' => ReadyChanged(f, own: own, ready: false),
|
||||||
'force-ready' => ForcedReady(f, ready: true),
|
'force-ready' => ForcedReady(f, own: own, ready: true),
|
||||||
'force-unready' => ForcedReady(f, ready: false),
|
'force-unready' => ForcedReady(f, own: own, ready: false),
|
||||||
'force-play' => ForcedTransport(f, play: true),
|
'force-play' => ForcedTransport(f, own: own, play: true),
|
||||||
'force-hold' => ForcedTransport(f, play: false),
|
'force-hold' => ForcedTransport(f, own: own, play: false),
|
||||||
'swap' => MediaSwapped(f),
|
'swap' => MediaSwapped(f, own: own),
|
||||||
'seek' => Seeked(f),
|
'seek' => Seeked(f, own: own),
|
||||||
'pause' => Paused(f),
|
'pause' => Paused(f, own: own),
|
||||||
'resume' => Resumed(f),
|
'resume' => Resumed(f, own: own),
|
||||||
'finished' => Finished(f),
|
'finished' => Finished(f, own: own),
|
||||||
'chat' => ChatPosted(f, own: isSelf(f.actorId)),
|
'chat' => ChatPosted(f, own: own),
|
||||||
'rename' => MemberRenamed(f),
|
'rename' => MemberRenamed(f, own: own),
|
||||||
'avatar' => AvatarChanged(f),
|
'avatar' => AvatarChanged(f, own: own),
|
||||||
'queue-add' => QueueAdded(f),
|
'queue-add' => QueueAdded(f, own: own, thumb: queueThumb),
|
||||||
'queue-remove' => QueueRemoved(f),
|
'queue-remove' => QueueRemoved(f, own: own),
|
||||||
'queue-skip' => QueueSkipped(f),
|
'queue-skip' => QueueSkipped(f, own: own),
|
||||||
'fetch-fail' => FetchFailed(f),
|
'fetch-fail' => FetchFailed(f, own: own),
|
||||||
_ => OtherFeed(f),
|
_ => OtherFeed(f, own: own),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class MemberJoined extends FeedEvent {
|
class MemberJoined extends FeedEvent {
|
||||||
const MemberJoined(super.feed);
|
const MemberJoined(super.feed, {super.own});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Left for good (`reason` `bye`: the app closed or Leave; `grace`: gone
|
/// Left for good (`reason` `bye`: the app closed or Leave; `grace`: gone
|
||||||
/// too long).
|
/// too long).
|
||||||
class MemberLeft extends FeedEvent {
|
class MemberLeft extends FeedEvent {
|
||||||
const MemberLeft(super.feed);
|
const MemberLeft(super.feed, {super.own});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Lost signal: gone for a while, kept in its grace period.
|
/// Lost signal: gone for a while, kept in its grace period.
|
||||||
class MemberGone extends FeedEvent {
|
class MemberGone extends FeedEvent {
|
||||||
const MemberGone(super.feed);
|
const MemberGone(super.feed, {super.own});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Back within its grace after a [MemberGone].
|
/// Back within its grace after a [MemberGone].
|
||||||
class MemberReturned extends FeedEvent {
|
class MemberReturned extends FeedEvent {
|
||||||
const MemberReturned(super.feed);
|
const MemberReturned(super.feed, {super.own});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Someone sat out (`away`: the actor itself, or `target` sat out by the
|
/// Someone sat out (`away`: the actor itself, or `target` sat out by the
|
||||||
@@ -83,87 +89,89 @@ class MemberReturned extends FeedEvent {
|
|||||||
class MemberSatOut extends FeedEvent {
|
class MemberSatOut extends FeedEvent {
|
||||||
final bool atYou;
|
final bool atYou;
|
||||||
final bool autoSelf;
|
final bool autoSelf;
|
||||||
const MemberSatOut(super.feed, {this.atYou = false, this.autoSelf = false});
|
const MemberSatOut(super.feed, {super.own, this.atYou = false, this.autoSelf = false});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Back from sitting out (`in`).
|
/// Back from sitting out (`in`).
|
||||||
class MemberBack extends FeedEvent {
|
class MemberBack extends FeedEvent {
|
||||||
const MemberBack(super.feed);
|
const MemberBack(super.feed, {super.own});
|
||||||
}
|
}
|
||||||
|
|
||||||
class ReadyChanged extends FeedEvent {
|
class ReadyChanged extends FeedEvent {
|
||||||
final bool ready;
|
final bool ready;
|
||||||
/// Members ready when it came (pitch of the ready sound).
|
/// Members ready when it came (pitch of the ready sound).
|
||||||
final int readyIndex;
|
final int readyIndex;
|
||||||
const ReadyChanged(super.feed, {required this.ready, this.readyIndex = 0});
|
const ReadyChanged(super.feed, {super.own, required this.ready, this.readyIndex = 0});
|
||||||
}
|
}
|
||||||
|
|
||||||
class ForcedReady extends FeedEvent {
|
class ForcedReady extends FeedEvent {
|
||||||
final bool ready;
|
final bool ready;
|
||||||
const ForcedReady(super.feed, {required this.ready});
|
const ForcedReady(super.feed, {super.own, required this.ready});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Force play / hold.
|
/// Force play / hold.
|
||||||
class ForcedTransport extends FeedEvent {
|
class ForcedTransport extends FeedEvent {
|
||||||
final bool play;
|
final bool play;
|
||||||
const ForcedTransport(super.feed, {required this.play});
|
const ForcedTransport(super.feed, {super.own, required this.play});
|
||||||
}
|
}
|
||||||
|
|
||||||
class MediaSwapped extends FeedEvent {
|
class MediaSwapped extends FeedEvent {
|
||||||
const MediaSwapped(super.feed);
|
const MediaSwapped(super.feed, {super.own});
|
||||||
}
|
}
|
||||||
|
|
||||||
class Seeked extends FeedEvent {
|
class Seeked extends FeedEvent {
|
||||||
const Seeked(super.feed);
|
const Seeked(super.feed, {super.own});
|
||||||
}
|
}
|
||||||
|
|
||||||
class Paused extends FeedEvent {
|
class Paused extends FeedEvent {
|
||||||
const Paused(super.feed);
|
const Paused(super.feed, {super.own});
|
||||||
}
|
}
|
||||||
|
|
||||||
class Resumed extends FeedEvent {
|
class Resumed extends FeedEvent {
|
||||||
const Resumed(super.feed);
|
const Resumed(super.feed, {super.own});
|
||||||
}
|
}
|
||||||
|
|
||||||
class Finished extends FeedEvent {
|
class Finished extends FeedEvent {
|
||||||
const Finished(super.feed);
|
const Finished(super.feed, {super.own});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A chat message (`reason` carries the text); [own]: this member sent it.
|
/// A chat message (`reason` carries the text); [own]: this member sent it.
|
||||||
class ChatPosted extends FeedEvent {
|
class ChatPosted extends FeedEvent {
|
||||||
final bool own;
|
const ChatPosted(super.feed, {super.own});
|
||||||
const ChatPosted(super.feed, {this.own = false});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class MemberRenamed extends FeedEvent {
|
class MemberRenamed extends FeedEvent {
|
||||||
const MemberRenamed(super.feed);
|
const MemberRenamed(super.feed, {super.own});
|
||||||
}
|
}
|
||||||
|
|
||||||
class AvatarChanged extends FeedEvent {
|
class AvatarChanged extends FeedEvent {
|
||||||
const AvatarChanged(super.feed);
|
const AvatarChanged(super.feed, {super.own});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Someone added an item to Up next (`target` its title); [thumb]: the
|
||||||
|
/// item's picture hash ('' when none).
|
||||||
class QueueAdded extends FeedEvent {
|
class QueueAdded extends FeedEvent {
|
||||||
const QueueAdded(super.feed);
|
final String thumb;
|
||||||
|
const QueueAdded(super.feed, {super.own, this.thumb = ''});
|
||||||
}
|
}
|
||||||
|
|
||||||
class QueueRemoved extends FeedEvent {
|
class QueueRemoved extends FeedEvent {
|
||||||
const QueueRemoved(super.feed);
|
const QueueRemoved(super.feed, {super.own});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Up next skipped an item (`target` its title, `reason` why).
|
/// Up next skipped an item (`target` its title, `reason` why).
|
||||||
class QueueSkipped extends FeedEvent {
|
class QueueSkipped extends FeedEvent {
|
||||||
const QueueSkipped(super.feed);
|
const QueueSkipped(super.feed, {super.own});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The server could not fetch the current media.
|
/// The server could not fetch the current media.
|
||||||
class FetchFailed extends FeedEvent {
|
class FetchFailed extends FeedEvent {
|
||||||
const FetchFailed(super.feed);
|
const FetchFailed(super.feed, {super.own});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// An action this app does not know (a newer server).
|
/// An action this app does not know (a newer server).
|
||||||
class OtherFeed extends FeedEvent {
|
class OtherFeed extends FeedEvent {
|
||||||
const OtherFeed(super.feed);
|
const OtherFeed(super.feed, {super.own});
|
||||||
}
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------- session
|
// -------------------------------------------------------------- session
|
||||||
@@ -209,6 +217,38 @@ class ConnectionBack extends AppEvent {
|
|||||||
const ConnectionBack();
|
const ConnectionBack();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The server refused something this app sent (`err`: Up next is full, a
|
||||||
|
/// forbidden action...).
|
||||||
|
class ServerRefused extends AppEvent {
|
||||||
|
final String text;
|
||||||
|
const ServerRefused(this.text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The member passing the current video on has been gone a few seconds
|
||||||
|
/// ([left]: it left the server); viewers play what the server has.
|
||||||
|
class RelaySourceDropped extends AppEvent {
|
||||||
|
final String name;
|
||||||
|
final bool left;
|
||||||
|
const RelaySourceDropped(this.name, {this.left = false});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The current media started playing for the first time ("starting
|
||||||
|
/// now"; [title]: what plays, '' when unknown).
|
||||||
|
class PlaybackStarted extends AppEvent {
|
||||||
|
final String title;
|
||||||
|
const PlaybackStarted(this.title);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A reconnect found the server restarted (another `welcome.instance`).
|
||||||
|
class ServerRestarted extends AppEvent {
|
||||||
|
const ServerRestarted();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Another window of this install took the member over (`replaced`).
|
||||||
|
class Replaced extends AppEvent {
|
||||||
|
const Replaced();
|
||||||
|
}
|
||||||
|
|
||||||
/// Jellyfin progress reports fail (once per media).
|
/// Jellyfin progress reports fail (once per media).
|
||||||
class JellyfinProgressFailed extends AppEvent {
|
class JellyfinProgressFailed extends AppEvent {
|
||||||
final bool signInExpired;
|
final bool signInExpired;
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ class AutoSitOut {
|
|||||||
|
|
||||||
/// Once a second. [self]: this member's presence in the latest state
|
/// Once a second. [self]: this member's presence in the latest state
|
||||||
/// (null: not in one); [alone]: nobody else connected ([isAlone]);
|
/// (null: not in one); [alone]: nobody else connected ([isAlone]);
|
||||||
/// [enabled]: the setting (and not a solo session).
|
/// [enabled]: the setting.
|
||||||
AutoSitOutStep tick(DateTime now,
|
AutoSitOutStep tick(DateTime now,
|
||||||
{required Presence? self, required bool playing, required bool alone, required bool enabled}) {
|
{required Presence? self, required bool playing, required bool alone, required bool enabled}) {
|
||||||
if (playing) _lastActive = now;
|
if (playing) _lastActive = now;
|
||||||
|
|||||||
@@ -142,7 +142,8 @@ final class ConnReconnecting extends ConnState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The server refused this app; nothing retries. [code]: `version` or
|
/// The server refused this app; nothing retries. [code]: `version` or
|
||||||
/// `secret` when the server said so, else empty (offline, timeout,
|
/// `secret` when the server said so, `closed` when the host stopped
|
||||||
|
/// sharing (close 4016), else empty (offline, timeout,
|
||||||
/// anything else on the first dial); [message]: in plain words
|
/// anything else on the first dial); [message]: in plain words
|
||||||
/// ([connectErrorText]).
|
/// ([connectErrorText]).
|
||||||
final class ConnRefused extends ConnState {
|
final class ConnRefused extends ConnState {
|
||||||
@@ -166,6 +167,10 @@ final class ConnReplaced extends ConnState {
|
|||||||
/// [ConnReplaced] in plain words.
|
/// [ConnReplaced] in plain words.
|
||||||
const replacedText = 'Starsync was opened in another window';
|
const replacedText = 'Starsync was opened in another window';
|
||||||
|
|
||||||
|
/// `ConnRefused('closed')`: the host closed their server to friends
|
||||||
|
/// (close 4016).
|
||||||
|
const stoppedSharingText = 'the host stopped sharing their server';
|
||||||
|
|
||||||
/// A version refusal in plain words: [err] is the server's `err`
|
/// A version refusal in plain words: [err] is the server's `err`
|
||||||
/// `version` (its range in `min`/`max`), or a proto 15 server's codeless
|
/// `version` (its range in `min`/`max`), or a proto 15 server's codeless
|
||||||
/// `proto mismatch`, which is always an older host.
|
/// `proto mismatch`, which is always an older host.
|
||||||
@@ -386,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,
|
||||||
@@ -518,6 +524,8 @@ class Connection {
|
|||||||
_end(ConnRefused('version', versionText(null)));
|
_end(ConnRefused('version', versionText(null)));
|
||||||
case CloseCodes.secret:
|
case CloseCodes.secret:
|
||||||
_end(const ConnRefused('secret', 'wrong server password (401)'));
|
_end(const ConnRefused('secret', 'wrong server password (401)'));
|
||||||
|
case CloseCodes.closed:
|
||||||
|
_end(const ConnRefused('closed', stoppedSharingText));
|
||||||
default:
|
default:
|
||||||
_dropped(now());
|
_dropped(now());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,12 @@ import 'party.dart';
|
|||||||
// Who fetches the current media and how it spreads, from the state alone:
|
// Who fetches the current media and how it spreads, from the state alone:
|
||||||
// the members card's source line and each member's route and buffer.
|
// the members card's source line and each member's route and buffer.
|
||||||
|
|
||||||
|
/// `media.relay` of a state, or null when the media is not relayed.
|
||||||
|
MediaRelay? stateMediaRelay(StateBroadcast state) {
|
||||||
|
final r = state.media?['relay'];
|
||||||
|
return r is Map ? MediaRelay.fromJson(Map<String, Object?>.from(r)) : null;
|
||||||
|
}
|
||||||
|
|
||||||
/// "4.1 MB/s", "850 kB/s".
|
/// "4.1 MB/s", "850 kB/s".
|
||||||
String formatRate(int bytesPerSec) {
|
String formatRate(int bytesPerSec) {
|
||||||
if (bytesPerSec >= 1000000) {
|
if (bytesPerSec >= 1000000) {
|
||||||
@@ -50,6 +56,9 @@ enum SourceState {
|
|||||||
slow,
|
slow,
|
||||||
/// Its channel is down (or the server has not read the link yet).
|
/// Its channel is down (or the server has not read the link yet).
|
||||||
waiting,
|
waiting,
|
||||||
|
/// The source member's connection dropped (`gone`, within its grace):
|
||||||
|
/// viewers play what the server has.
|
||||||
|
gone,
|
||||||
/// It left the server.
|
/// It left the server.
|
||||||
left,
|
left,
|
||||||
}
|
}
|
||||||
@@ -114,6 +123,9 @@ class Delivery {
|
|||||||
final String sourceName;
|
final String sourceName;
|
||||||
final bool sourceIsSelf;
|
final bool sourceIsSelf;
|
||||||
final SourceState state;
|
final SourceState state;
|
||||||
|
/// [SourceState.gone]: since when; and the time the line is read at.
|
||||||
|
final DateTime? goneSince;
|
||||||
|
final DateTime? at;
|
||||||
/// `media.relay.rate` / `need` (0 unknown).
|
/// `media.relay.rate` / `need` (0 unknown).
|
||||||
final int rate, need;
|
final int rate, need;
|
||||||
/// Members (not sitting out, not the source) reading from the server,
|
/// Members (not sitting out, not the source) reading from the server,
|
||||||
@@ -125,6 +137,8 @@ class Delivery {
|
|||||||
this.sourceName = '',
|
this.sourceName = '',
|
||||||
this.sourceIsSelf = false,
|
this.sourceIsSelf = false,
|
||||||
this.state = SourceState.ok,
|
this.state = SourceState.ok,
|
||||||
|
this.goneSince,
|
||||||
|
this.at,
|
||||||
this.rate = 0,
|
this.rate = 0,
|
||||||
this.need = 0,
|
this.need = 0,
|
||||||
this.viaServer = 0,
|
this.viaServer = 0,
|
||||||
@@ -155,7 +169,10 @@ class Delivery {
|
|||||||
};
|
};
|
||||||
case DeliveryKind.memberRelay:
|
case DeliveryKind.memberRelay:
|
||||||
return switch (state) {
|
return switch (state) {
|
||||||
SourceState.left => '$sourceName left: playing what the server has',
|
SourceState.left => '$sourceName left — playing what the server has',
|
||||||
|
SourceState.gone => sourceIsSelf
|
||||||
|
? 'Reconnecting you to the server…'
|
||||||
|
: "$sourceName's connection dropped — playing what the server has${_for()}",
|
||||||
SourceState.waiting => sourceIsSelf ? 'Reconnecting you to the server…' : 'Waiting for $who to reconnect',
|
SourceState.waiting => sourceIsSelf ? 'Reconnecting you to the server…' : 'Waiting for $who to reconnect',
|
||||||
SourceState.slow => sourceIsSelf ? 'Your upload is slower than the video' : "$who's upload is slower than the video",
|
SourceState.slow => sourceIsSelf ? 'Your upload is slower than the video' : "$who's upload is slower than the video",
|
||||||
SourceState.ok => sourceIsSelf ? 'You pass it on through the server' : '$who passes it on through the server',
|
SourceState.ok => sourceIsSelf ? 'You pass it on through the server' : '$who passes it on through the server',
|
||||||
@@ -163,6 +180,12 @@ class Delivery {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String _for() {
|
||||||
|
final since = goneSince, now = at;
|
||||||
|
if (since == null || now == null || now.difference(since).inMinutes < 1) return '';
|
||||||
|
return ' (${sinceText(since, now)})';
|
||||||
|
}
|
||||||
|
|
||||||
/// "3 through the server · 4.1 MB/s, needs 2.3 MB/s"; empty when
|
/// "3 through the server · 4.1 MB/s, needs 2.3 MB/s"; empty when
|
||||||
/// there is nothing to add.
|
/// there is nothing to add.
|
||||||
String get detail {
|
String get detail {
|
||||||
@@ -170,7 +193,7 @@ class Delivery {
|
|||||||
if (direct > 0 && (kind == DeliveryKind.jellyfin || kind == DeliveryKind.memberRelay))
|
if (direct > 0 && (kind == DeliveryKind.jellyfin || kind == DeliveryKind.memberRelay))
|
||||||
'$direct with their own account',
|
'$direct with their own account',
|
||||||
if (viaServer > 0 && kind != DeliveryKind.link) '$viaServer through the server',
|
if (viaServer > 0 && kind != DeliveryKind.link) '$viaServer through the server',
|
||||||
if (rate > 0 && state != SourceState.left) need > 0 ? '${formatRate(rate)}, needs ${formatRate(need)}' : formatRate(rate),
|
if (rate > 0 && state != SourceState.left && state != SourceState.gone) need > 0 ? '${formatRate(rate)}, needs ${formatRate(need)}' : formatRate(rate),
|
||||||
];
|
];
|
||||||
return parts.join(' · ');
|
return parts.join(' · ');
|
||||||
}
|
}
|
||||||
@@ -193,8 +216,9 @@ class Delivery {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The current media's [Delivery]; null with nothing up or nobody else in
|
/// The current media's [Delivery]; null with nothing up or nobody else in
|
||||||
/// the server. [names] remembers a source that left.
|
/// the server. [names] remembers a source that left; [now] reads how long
|
||||||
Delivery? deliveryOf(StateBroadcast r, {required String selfId, MemberNames? names}) {
|
/// a gone source has been gone.
|
||||||
|
Delivery? deliveryOf(StateBroadcast r, {required String selfId, MemberNames? names, DateTime? now}) {
|
||||||
final media = r.media;
|
final media = r.media;
|
||||||
if (media == null || r.members.length < 2) return null;
|
if (media == null || r.members.length < 2) return null;
|
||||||
final rel = media['relay'];
|
final rel = media['relay'];
|
||||||
@@ -237,6 +261,8 @@ Delivery? deliveryOf(StateBroadcast r, {required String selfId, MemberNames? nam
|
|||||||
? SourceState.ok
|
? SourceState.ok
|
||||||
: kind == DeliveryKind.memberRelay && source == null
|
: kind == DeliveryKind.memberRelay && source == null
|
||||||
? SourceState.left
|
? SourceState.left
|
||||||
|
: kind == DeliveryKind.memberRelay && (relay.sourceGone || source?.presence == Presence.gone)
|
||||||
|
? SourceState.gone
|
||||||
: !relay.live
|
: !relay.live
|
||||||
? SourceState.waiting
|
? SourceState.waiting
|
||||||
: relay.slow
|
: relay.slow
|
||||||
@@ -248,6 +274,8 @@ Delivery? deliveryOf(StateBroadcast r, {required String selfId, MemberNames? nam
|
|||||||
sourceName: source?.name ?? names?[sourceId] ?? 'The member passing it on',
|
sourceName: source?.name ?? names?[sourceId] ?? 'The member passing it on',
|
||||||
sourceIsSelf: sourceId.isNotEmpty && sourceId == selfId,
|
sourceIsSelf: sourceId.isNotEmpty && sourceId == selfId,
|
||||||
state: state,
|
state: state,
|
||||||
|
goneSince: state == SourceState.gone ? relay?.sourceGoneSince ?? source?.goneSince : null,
|
||||||
|
at: now ?? DateTime.now().toUtc(),
|
||||||
rate: relay?.rate ?? 0,
|
rate: relay?.rate ?? 0,
|
||||||
need: relay?.need ?? 0,
|
need: relay?.need ?? 0,
|
||||||
viaServer: via,
|
viaServer: via,
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
enum HealthSeverity { ok, info, warn, error }
|
|
||||||
|
|
||||||
class HealthStatus {
|
|
||||||
final HealthSeverity severity;
|
|
||||||
final String message;
|
|
||||||
const HealthStatus(this.severity, this.message);
|
|
||||||
}
|
|
||||||
@@ -9,7 +9,6 @@ import 'package:signals/signals_flutter.dart';
|
|||||||
import 'package:sync_relay_core/core.dart' show validClientId;
|
import 'package:sync_relay_core/core.dart' show validClientId;
|
||||||
|
|
||||||
import '../state/connect_prefs.dart';
|
import '../state/connect_prefs.dart';
|
||||||
import '../state/session_state.dart';
|
|
||||||
import '../state/settings.dart' show settingsBoxName;
|
import '../state/settings.dart' show settingsBoxName;
|
||||||
import '../widgets/avatar_pipeline.dart';
|
import '../widgets/avatar_pipeline.dart';
|
||||||
|
|
||||||
@@ -96,17 +95,11 @@ const _keyColor = 'profileColor';
|
|||||||
/// avatar = removed). Swappable so tests can watch it.
|
/// avatar = removed). Swappable so tests can watch it.
|
||||||
typedef ProfilePush = void Function({String? name, String? avatar});
|
typedef ProfilePush = void Function({String? name, String? avatar});
|
||||||
|
|
||||||
/// The one place a profile change reaches the server.
|
/// Display name, picture and colour of this app's user. Backed by the
|
||||||
void pushProfileToSession({String? name, String? avatar}) {
|
/// connect form ([forms]: the name) and the connect-form box (the cropped
|
||||||
final session = SessionState.instance;
|
|
||||||
if (session.route.peek() != AppRoute.session) return;
|
|
||||||
session.coordinator.updateProfile(name: name, avatar: avatar);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Display name, picture and colour of this app's user. Backed by
|
|
||||||
/// [SessionState.lastForm] (name) and the connect-form box (the cropped
|
|
||||||
/// 256 px PNG and the colour); every change is persisted, and while in a
|
/// 256 px PNG and the colour); every change is persisted, and while in a
|
||||||
/// server it is pushed through [push] so everyone sees it at once.
|
/// server it is pushed through [push] so everyone sees it at once. The
|
||||||
|
/// composition root sets both.
|
||||||
class Profile {
|
class Profile {
|
||||||
static final Profile instance = Profile._();
|
static final Profile instance = Profile._();
|
||||||
Profile._();
|
Profile._();
|
||||||
@@ -116,7 +109,8 @@ class Profile {
|
|||||||
final picture = signal<Uint8List?>(null);
|
final picture = signal<Uint8List?>(null);
|
||||||
final color = signal<Color>(profileColors.first);
|
final color = signal<Color>(profileColors.first);
|
||||||
final error = signal<String?>(null);
|
final error = signal<String?>(null);
|
||||||
ProfilePush push = pushProfileToSession;
|
ProfilePush push = ({String? name, String? avatar}) {};
|
||||||
|
ConnectStore forms = ConnectStore();
|
||||||
bool _loaded = false;
|
bool _loaded = false;
|
||||||
Timer? _nameTimer;
|
Timer? _nameTimer;
|
||||||
|
|
||||||
@@ -131,7 +125,7 @@ class Profile {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _load() async {
|
Future<void> _load() async {
|
||||||
final form = SessionState.instance.lastForm;
|
final form = forms.form;
|
||||||
name.value = form.name;
|
name.value = form.name;
|
||||||
try {
|
try {
|
||||||
final box = connectFormBoxIfOpen() ?? await openConnectFormBox();
|
final box = connectFormBoxIfOpen() ?? await openConnectFormBox();
|
||||||
@@ -162,7 +156,7 @@ class Profile {
|
|||||||
final v = value.trim();
|
final v = value.trim();
|
||||||
if (v.isEmpty || v == name.peek()) return;
|
if (v.isEmpty || v == name.peek()) return;
|
||||||
name.value = v;
|
name.value = v;
|
||||||
SessionState.instance.lastForm.name = v;
|
forms.form.name = v;
|
||||||
unawaited(_save());
|
unawaited(_save());
|
||||||
// Typing renames once it settles, not on every key.
|
// Typing renames once it settles, not on every key.
|
||||||
_nameTimer?.cancel();
|
_nameTimer?.cancel();
|
||||||
@@ -187,7 +181,7 @@ class Profile {
|
|||||||
|
|
||||||
Future<void> _save() async {
|
Future<void> _save() async {
|
||||||
try {
|
try {
|
||||||
await SessionState.instance.saveForm();
|
await forms.save();
|
||||||
final box = connectFormBoxIfOpen() ?? await openConnectFormBox();
|
final box = connectFormBoxIfOpen() ?? await openConnectFormBox();
|
||||||
final png = picture.peek();
|
final png = picture.peek();
|
||||||
await box.putAll({
|
await box.putAll({
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart' show debugPrint;
|
||||||
|
|
||||||
|
/// What a friend needs to join your server: where ([host], [port]), what
|
||||||
|
/// it is called ([name]), whether it asks a password ([password]; the
|
||||||
|
/// password itself is never in it) and which Starsync runs it
|
||||||
|
/// ([version]). [fp]: reserved for a pinned certificate fingerprint
|
||||||
|
/// (`wss://` with a self-signed certificate); always null for now.
|
||||||
|
class Invite {
|
||||||
|
final String host;
|
||||||
|
final int port;
|
||||||
|
final String name;
|
||||||
|
final bool password;
|
||||||
|
final String version;
|
||||||
|
final String? fp;
|
||||||
|
const Invite({
|
||||||
|
required this.host,
|
||||||
|
required this.port,
|
||||||
|
this.name = '',
|
||||||
|
this.password = false,
|
||||||
|
this.version = '',
|
||||||
|
this.fp,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// What goes in the Join card's address and port.
|
||||||
|
String get address => '${host.contains(':') ? '[$host]' : host}:$port';
|
||||||
|
|
||||||
|
/// The text Invite copies: the address, whether to ask for the password,
|
||||||
|
/// the version to match.
|
||||||
|
String get text => [
|
||||||
|
'Join me on Starsync: $address',
|
||||||
|
if (password) '(ask me for the password)',
|
||||||
|
if (version.isNotEmpty) '· Starsync $version',
|
||||||
|
].join(' ');
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) =>
|
||||||
|
other is Invite &&
|
||||||
|
other.host == host &&
|
||||||
|
other.port == port &&
|
||||||
|
other.name == name &&
|
||||||
|
other.password == password &&
|
||||||
|
other.version == version &&
|
||||||
|
other.fp == fp;
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode => Object.hash(host, port, name, password, version, fp);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => 'Invite($address${password ? ', password' : ''})';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `host:port` in a pasted invite (or a bare address); null when
|
||||||
|
/// there is none.
|
||||||
|
({String host, String port})? addressIn(String text) {
|
||||||
|
final m = RegExp(r'(\[[0-9a-fA-F:]+\]|[A-Za-z0-9.-]+):(\d{1,5})\b').firstMatch(text);
|
||||||
|
if (m == null) return null;
|
||||||
|
final host = m.group(1)!;
|
||||||
|
return (host: host.startsWith('[') ? host.substring(1, host.length - 1) : host, port: m.group(2)!);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Interface names of container and VM bridges, never a way in for a
|
||||||
|
/// friend (Docker, Podman, libvirt, LXC, VirtualBox, VMware, WSL/Hyper-V).
|
||||||
|
final _virtualName = RegExp(
|
||||||
|
r'^(docker|br-|veth|virbr|vmnet|vboxnet|cni|flannel|podman|lxcbr|lxdbr|vEthernet|VirtualBox|VMware)',
|
||||||
|
caseSensitive: false);
|
||||||
|
|
||||||
|
/// The proxy fake-IP range (198.18.0.0/15, benchmarking space that TUN
|
||||||
|
/// proxies like Clash hand out): this machine's address on it is useless
|
||||||
|
/// to anyone else.
|
||||||
|
bool isFakeIp(List<int> b) => b[0] == 198 && (b[1] == 18 || b[1] == 19);
|
||||||
|
|
||||||
|
bool _isPrivate(List<int> b) =>
|
||||||
|
b[0] == 10 || (b[0] == 192 && b[1] == 168) || (b[0] == 172 && b[1] >= 16 && b[1] < 32);
|
||||||
|
|
||||||
|
/// Shared address space (100.64.0.0/10): mesh VPNs like Tailscale.
|
||||||
|
bool _isShared(List<int> b) => b[0] == 100 && b[1] >= 64 && b[1] < 128;
|
||||||
|
|
||||||
|
/// The addresses of [interfaces] a friend could dial, best first: IPv4,
|
||||||
|
/// no loopback, link-local or fake-IP address, no container or VM bridge;
|
||||||
|
/// home networks (192.168, then 10, then 172.16/12) first, then a mesh
|
||||||
|
/// VPN's, then anything else (a public address). No repeats.
|
||||||
|
List<String> inviteAddresses(Iterable<({String name, InternetAddress address})> interfaces) {
|
||||||
|
int rank(List<int> b) => b[0] == 192 && b[1] == 168
|
||||||
|
? 0
|
||||||
|
: _isPrivate(b)
|
||||||
|
? 1
|
||||||
|
: _isShared(b)
|
||||||
|
? 2
|
||||||
|
: 3;
|
||||||
|
final usable = [
|
||||||
|
for (final i in interfaces)
|
||||||
|
if (i.address.type == InternetAddressType.IPv4 &&
|
||||||
|
!i.address.isLoopback &&
|
||||||
|
!i.address.isLinkLocal &&
|
||||||
|
!(i.address.rawAddress[0] == 169 && i.address.rawAddress[1] == 254) &&
|
||||||
|
!isFakeIp(i.address.rawAddress) &&
|
||||||
|
!_virtualName.hasMatch(i.name))
|
||||||
|
i.address,
|
||||||
|
];
|
||||||
|
final indexed = [for (var n = 0; n < usable.length; n++) (n, usable[n])];
|
||||||
|
indexed.sort((a, b) {
|
||||||
|
final r = rank(a.$2.rawAddress).compareTo(rank(b.$2.rawAddress));
|
||||||
|
return r != 0 ? r : a.$1.compareTo(b.$1);
|
||||||
|
});
|
||||||
|
return {for (final (_, a) in indexed) a.address}.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// This machine's interfaces as [inviteAddresses] takes them (empty when
|
||||||
|
/// they can't be listed).
|
||||||
|
Future<List<({String name, InternetAddress address})>> machineInterfaces() async {
|
||||||
|
try {
|
||||||
|
final list = await NetworkInterface.list(type: InternetAddressType.IPv4);
|
||||||
|
return [
|
||||||
|
for (final i in list)
|
||||||
|
for (final a in i.addresses) (name: i.name, address: a),
|
||||||
|
];
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('list network interfaces failed: $e');
|
||||||
|
return const [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,7 +7,6 @@ import 'package:flutter/foundation.dart';
|
|||||||
import 'package:image/image.dart' as img;
|
import 'package:image/image.dart' as img;
|
||||||
import 'package:sync_relay_core/core.dart';
|
import 'package:sync_relay_core/core.dart';
|
||||||
|
|
||||||
import '../files/name_parser.dart';
|
|
||||||
import '../schedule/anilist.dart';
|
import '../schedule/anilist.dart';
|
||||||
|
|
||||||
// Art for media this client swapped in (`art`, see PROTOCOL.md "Media
|
// Art for media this client swapped in (`art`, see PROTOCOL.md "Media
|
||||||
@@ -58,6 +57,11 @@ Future<ArtBytes?> aniListArt(AniListClient aniList, String name,
|
|||||||
if (!RegExp(r'\p{L}', unicode: true).hasMatch(series)) return null;
|
if (!RegExp(r'\p{L}', unicode: true).hasMatch(series)) return null;
|
||||||
final match = await aniList.searchMedia(series);
|
final match = await aniList.searchMedia(series);
|
||||||
if (match == null) return null;
|
if (match == null) return null;
|
||||||
|
return _artOf(match, fetch);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<ArtBytes> _artOf(AniListMedia match,
|
||||||
|
Future<Uint8List?> Function(Uri url, Map<String, String> headers, int maxBytes) fetch) async {
|
||||||
Future<Uint8List?> get(String? url, int maxBytes) async {
|
Future<Uint8List?> get(String? url, int maxBytes) async {
|
||||||
final uri = url == null ? null : Uri.tryParse(url);
|
final uri = url == null ? null : Uri.tryParse(url);
|
||||||
return uri == null ? null : fetch(uri, const {}, maxBytes);
|
return uri == null ? null : fetch(uri, const {}, maxBytes);
|
||||||
@@ -71,6 +75,14 @@ Future<ArtBytes?> aniListArt(AniListClient aniList, String name,
|
|||||||
return (poster: poster, banner: banner);
|
return (poster: poster, banner: banner);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Cover and banner of AniList entry [id] (a file the library matched);
|
||||||
|
/// null when AniList has none.
|
||||||
|
Future<ArtBytes?> aniListArtById(AniListClient aniList, int id,
|
||||||
|
{Future<Uint8List?> Function(Uri url, Map<String, String> headers, int maxBytes) fetch = fetchImage}) async {
|
||||||
|
final match = await aniList.mediaById(id);
|
||||||
|
return match == null ? null : _artOf(match, fetch);
|
||||||
|
}
|
||||||
|
|
||||||
/// A web video's thumbnail at [url] as both cover and banner (the same
|
/// A web video's thumbnail at [url] as both cover and banner (the same
|
||||||
/// bytes, so the same hash: what tells clients it is a thumbnail, see
|
/// bytes, so the same hash: what tells clients it is a thumbnail, see
|
||||||
/// `NowPlaying.thumbnail`); shrunk to a JPEG under the cover cap when
|
/// `NowPlaying.thumbnail`); shrunk to a JPEG under the cover cap when
|
||||||
|
|||||||
@@ -0,0 +1,899 @@
|
|||||||
|
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 '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.
|
||||||
|
|
||||||
|
/// 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;
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------ 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 (!_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);
|
||||||
|
}
|
||||||
|
return _Target(play.mpvUrl, {...play.headers, ...?headers}, subtitles: play.subtitles);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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;
|
||||||
|
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';
|
||||||
|
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;
|
||||||
|
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):
|
||||||
|
feeder.serve(r.mediaId, LinkRelayMedia(Uri.parse(url), ytDlp: origin.ytDlp, probe: origin.probe),
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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, 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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------- 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);
|
||||||
|
|
||||||
|
/// 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 {});
|
||||||
|
try {
|
||||||
|
final target = await _resolve(url, headers, fresh: fresh);
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [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 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 [];
|
||||||
|
|
||||||
|
/// 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;
|
||||||
|
const _Target(this.url, this.headers, {this.subtitles = const []});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A page could not be resolved: why ([message], yt-dlp's words) and which
|
||||||
|
/// cookies that used ([cookies], D11).
|
||||||
|
class ResolveFailure implements Exception {
|
||||||
|
final String message;
|
||||||
|
final String? cookies;
|
||||||
|
const ResolveFailure(this.message, {this.cookies});
|
||||||
|
@override
|
||||||
|
String toString() => message;
|
||||||
|
}
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
import 'package:sync_relay_core/core.dart';
|
||||||
|
|
||||||
|
import '../jellyfin/session_media.dart' show refOfStateMedia, relayUrlFor;
|
||||||
|
import 'delivery.dart' show stateMediaRelay;
|
||||||
|
import 'party.dart' show isPresent;
|
||||||
|
|
||||||
|
// The plan step of the media pipeline: from the server's state, what this
|
||||||
|
// member serves, its own capabilities and who is present, where this app
|
||||||
|
// reads the current media from. One switch, pure: `media_plan_test` holds
|
||||||
|
// the table.
|
||||||
|
|
||||||
|
/// What this member serves for the media it put up (set at its swap,
|
||||||
|
/// claimed by the state that names it as the relay's source).
|
||||||
|
sealed class MediaOrigin {
|
||||||
|
const MediaOrigin();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A file on this computer, passed on through the relay.
|
||||||
|
final class FileOrigin extends MediaOrigin {
|
||||||
|
final String path;
|
||||||
|
const FileOrigin(this.path);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A link this computer reads and passes on ("through me").
|
||||||
|
final class LinkOrigin extends MediaOrigin {
|
||||||
|
final String url;
|
||||||
|
|
||||||
|
/// What the link is (a page goes through [ytDlp]).
|
||||||
|
final LinkProbe probe;
|
||||||
|
final YtDlp ytDlp;
|
||||||
|
const LinkOrigin(this.url, this.probe, this.ytDlp);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A Jellyfin item: served with this install's account, else with the
|
||||||
|
/// pasted [link] (its api_key stays on this machine).
|
||||||
|
final class JellyfinOrigin extends MediaOrigin {
|
||||||
|
final String? link;
|
||||||
|
const JellyfinOrigin(this.link);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where this app's server is, as this member dialled it.
|
||||||
|
class ServerAddress {
|
||||||
|
final String host;
|
||||||
|
final int port;
|
||||||
|
final String token;
|
||||||
|
const ServerAddress(this.host, this.port, this.token);
|
||||||
|
|
||||||
|
/// This viewer's `/m` URL of your server's file [name].
|
||||||
|
String fileUrl(String name) => 'http://$host:$port/m/default/$token/${Uri.encodeComponent(name)}';
|
||||||
|
|
||||||
|
/// This viewer's `/r` URL of a relay ([key]: the media id, or the queue
|
||||||
|
/// id of one started in Up next); with tracks, a pieces playlist or an
|
||||||
|
/// mpv EDL of every track.
|
||||||
|
String relayUrl(String name, String key, [List<RelayTrackSpec> tracks = const []]) =>
|
||||||
|
relayViewerUrl('http://$host:$port/r/default/$token/$key', name, tracks: tracks);
|
||||||
|
|
||||||
|
/// This member's `/j` URL of [ref] (the server's Jellyfin login).
|
||||||
|
String jellyfinUrl(JellyfinRef ref) => relayUrlFor(host, port, token, ref.itemId);
|
||||||
|
|
||||||
|
/// The relay channel this member feeds ([queueId]: the warm relay of
|
||||||
|
/// that Up next item).
|
||||||
|
Uri sourceChannel([String queueId = '']) => Uri.parse('ws://$host:$port/r/default/$token/source'
|
||||||
|
'${queueId.isEmpty ? '' : '?q=${Uri.encodeQueryComponent(queueId)}'}');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where this app reads the current media from.
|
||||||
|
sealed class SourcePlan {
|
||||||
|
const SourcePlan();
|
||||||
|
|
||||||
|
/// The URL a player opens; null when there is nothing to open yet.
|
||||||
|
String? get url => null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// No media, or nothing this app can open (yet).
|
||||||
|
final class NoSource extends SourcePlan {
|
||||||
|
const NoSource();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// This member passes its file on: the player plays the file itself, the
|
||||||
|
/// relay source feeds the server.
|
||||||
|
final class OwnFile extends SourcePlan {
|
||||||
|
final String path;
|
||||||
|
const OwnFile(this.path);
|
||||||
|
@override
|
||||||
|
String get url => path;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// This member passes a link on: it plays the link itself (a page through
|
||||||
|
/// its yt-dlp), the relay source reads it for the server.
|
||||||
|
final class OwnLink extends SourcePlan {
|
||||||
|
@override
|
||||||
|
final String url;
|
||||||
|
final LinkOrigin origin;
|
||||||
|
const OwnLink(this.url, this.origin);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Your server's own file (`/m`).
|
||||||
|
final class ServerFile extends SourcePlan {
|
||||||
|
@override
|
||||||
|
final String url;
|
||||||
|
const ServerFile(this.url);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A link everyone opens itself; [page]: a web page, resolved by this
|
||||||
|
/// app's yt-dlp (the pipeline's resolve step).
|
||||||
|
final class DirectLink extends SourcePlan {
|
||||||
|
@override
|
||||||
|
final String url;
|
||||||
|
final bool page;
|
||||||
|
const DirectLink(this.url, {required this.page});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The server's relay (`/r`): a member passing it on ([sourceId]) or the
|
||||||
|
/// server's own fetch ([fetch]). [waiting]: nobody feeds it yet, so it opens
|
||||||
|
/// once `relay.live` (mpv gives up on a silent request after 5 s).
|
||||||
|
final class RelayedSource extends SourcePlan {
|
||||||
|
@override
|
||||||
|
final String url;
|
||||||
|
final bool fetch;
|
||||||
|
final String sourceId;
|
||||||
|
final bool waiting;
|
||||||
|
const RelayedSource(this.url, {required this.fetch, required this.sourceId, required this.waiting});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A Jellyfin item: this app's own account first ([ownAccount]), then the
|
||||||
|
/// server's login ([serverLogin], `/j`), then the member who put it up
|
||||||
|
/// ([memberRelay], `/r`); the resolve step decides. [origin]: this member
|
||||||
|
/// put it up (it passes it on to viewers without an account).
|
||||||
|
final class JellyfinSource extends SourcePlan {
|
||||||
|
final JellyfinRef ref;
|
||||||
|
final bool ownAccount;
|
||||||
|
final String? serverLogin;
|
||||||
|
final String? memberRelay;
|
||||||
|
final JellyfinOrigin? origin;
|
||||||
|
const JellyfinSource(this.ref, {required this.ownAccount, this.serverLogin, this.memberRelay, this.origin});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A page link this app cannot resolve (no yt-dlp here: `caps.resolve`
|
||||||
|
/// false): [via] names who can pass it on.
|
||||||
|
final class Unresolvable extends SourcePlan {
|
||||||
|
final String link;
|
||||||
|
final String via;
|
||||||
|
const Unresolvable(this.link, this.via);
|
||||||
|
|
||||||
|
String get reason => "This device can't open web pages; ${via.isEmpty ? 'ask someone' : via} to pass it on (Through me)";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What [planMedia] reads.
|
||||||
|
class PlanInput {
|
||||||
|
final StateBroadcast state;
|
||||||
|
final String selfId;
|
||||||
|
final ClientCaps caps;
|
||||||
|
final ServerAddress server;
|
||||||
|
|
||||||
|
/// What this member serves for [StateBroadcast.mediaId] (null: nothing).
|
||||||
|
final MediaOrigin? origin;
|
||||||
|
|
||||||
|
/// This app plays Jellyfin [serverId] with its own account.
|
||||||
|
final bool Function(String serverId) ownJellyfin;
|
||||||
|
const PlanInput({
|
||||||
|
required this.state,
|
||||||
|
required this.selfId,
|
||||||
|
required this.caps,
|
||||||
|
required this.server,
|
||||||
|
this.origin,
|
||||||
|
required this.ownJellyfin,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A relay nobody feeds yet: the server's fetch before it is read, or a
|
||||||
|
/// member's before its channel is up while that member is still here
|
||||||
|
/// (once it is gone or left, the server's buffer is all there is: open
|
||||||
|
/// that).
|
||||||
|
bool relayWaitsForSource(StateBroadcast r, MediaRelay relay) =>
|
||||||
|
!relay.live && !relay.sourceGone && (relay.fetch || r.members.any((m) => m.id == relay.source));
|
||||||
|
|
||||||
|
/// The members that can resolve a page link (`caps.resolve`), present
|
||||||
|
/// ones, lowest id first.
|
||||||
|
List<MemberView> linkResolvers(StateBroadcast r, {String except = ''}) =>
|
||||||
|
[for (final m in r.members) if (m.caps.resolve && isPresent(m) && m.id != except) m];
|
||||||
|
|
||||||
|
/// Where this app reads [input]'s media from.
|
||||||
|
SourcePlan planMedia(PlanInput input) {
|
||||||
|
final r = input.state;
|
||||||
|
final media = r.media;
|
||||||
|
if (media == null) return const NoSource();
|
||||||
|
final name = (media['name'] as String?) ?? '';
|
||||||
|
final relay = stateMediaRelay(r);
|
||||||
|
final mine = relay != null && relay.source.isNotEmpty && relay.source == input.selfId;
|
||||||
|
final origin = mine ? input.origin : null;
|
||||||
|
final key = relay == null || relay.queueId.isEmpty ? '${r.mediaId}' : relay.queueId;
|
||||||
|
|
||||||
|
final ref = refOfStateMedia(media);
|
||||||
|
if (ref != null) {
|
||||||
|
return JellyfinSource(
|
||||||
|
ref,
|
||||||
|
ownAccount: input.ownJellyfin(ref.serverId),
|
||||||
|
serverLogin: r.relay && input.server.token.isNotEmpty ? input.server.jellyfinUrl(ref) : null,
|
||||||
|
memberRelay: relay != null && !mine ? input.server.relayUrl(name, key) : null,
|
||||||
|
origin: origin is JellyfinOrigin ? origin : (mine ? const JellyfinOrigin(null) : null),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
switch (origin) {
|
||||||
|
case FileOrigin(:final path):
|
||||||
|
return OwnFile(path);
|
||||||
|
case LinkOrigin(:final url):
|
||||||
|
return OwnLink(url, origin);
|
||||||
|
case JellyfinOrigin() || null:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (relay != null) {
|
||||||
|
// Another member's relay or the server's fetch; this member's own
|
||||||
|
// relay without its origin (a restarted app) reads the server's buffer.
|
||||||
|
return RelayedSource(input.server.relayUrl(name, key, relay.tracks),
|
||||||
|
fetch: relay.fetch, sourceId: relay.source, waiting: !mine && relayWaitsForSource(r, relay));
|
||||||
|
}
|
||||||
|
final url = media['url'];
|
||||||
|
if (url is String && url.isNotEmpty) {
|
||||||
|
final uri = Uri.tryParse(url);
|
||||||
|
final web = uri != null && (uri.isScheme('http') || uri.isScheme('https'));
|
||||||
|
final page = web && !hasMediaExtension(uri);
|
||||||
|
if (page && !input.caps.resolve) {
|
||||||
|
final who = linkResolvers(r, except: input.selfId).firstOrNull;
|
||||||
|
return Unresolvable(url, who == null ? '' : 'ask ${who.name}');
|
||||||
|
}
|
||||||
|
return DirectLink(url, page: page);
|
||||||
|
}
|
||||||
|
if (name.isNotEmpty && input.server.token.isNotEmpty) return ServerFile(input.server.fileUrl(name));
|
||||||
|
return const NoSource();
|
||||||
|
}
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart' show debugPrint;
|
||||||
|
import 'package:signals/signals.dart';
|
||||||
|
import 'package:sync_relay_core/core.dart';
|
||||||
|
|
||||||
|
import '../screens/home/health_probe.dart';
|
||||||
|
import '../state/connect_prefs.dart';
|
||||||
|
import 'app_events.dart' show ActionFailed, ServerRefused;
|
||||||
|
import 'connection.dart';
|
||||||
|
import 'identity.dart' show Profile;
|
||||||
|
import 'session_coordinator.dart';
|
||||||
|
import 'your_server.dart';
|
||||||
|
|
||||||
|
/// Which server this app is in.
|
||||||
|
enum Place {
|
||||||
|
/// None yet (starting, or between two servers).
|
||||||
|
none,
|
||||||
|
|
||||||
|
/// Your own server ([YourServer]): home.
|
||||||
|
yours,
|
||||||
|
|
||||||
|
/// A friend's (joined from the Join card).
|
||||||
|
friend,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A join that did not get as far as connecting, in plain words.
|
||||||
|
class JoinFailed implements Exception {
|
||||||
|
final String message;
|
||||||
|
const JoinFailed(this.message);
|
||||||
|
@override
|
||||||
|
String toString() => message;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where this app is: home on your own server, or in a friend's. It
|
||||||
|
/// starts the app at home, switches to a friend's server on a join (after
|
||||||
|
/// the version gate) and back home when you leave theirs, a join fails or
|
||||||
|
/// they stop sharing. Your server stays up all the while; you leave it
|
||||||
|
/// (with `bye`) while you are away. Made by the composition root.
|
||||||
|
class Navigation {
|
||||||
|
Navigation({
|
||||||
|
required this.coordinator,
|
||||||
|
required this.yourServer,
|
||||||
|
required this.forms,
|
||||||
|
this.profile,
|
||||||
|
this.healthGet = defaultHealthGet,
|
||||||
|
}) {
|
||||||
|
yourServer.onCrash = () {
|
||||||
|
if (place.peek() != Place.friend) unawaited(goHome());
|
||||||
|
};
|
||||||
|
// A friend closed their server to friends: back home, and say so.
|
||||||
|
_stops.add(effect(() {
|
||||||
|
final s = coordinator.connection.state.value;
|
||||||
|
if (s is ConnRefused && s.code == 'closed' && untracked(() => place.value) == Place.friend) {
|
||||||
|
// Said once home (a connect clears the toasts).
|
||||||
|
scheduleMicrotask(() => unawaited(goHome().then(
|
||||||
|
(_) => coordinator.notify(const ServerRefused('The host stopped sharing their server')))));
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
final SessionCoordinator coordinator;
|
||||||
|
final YourServer yourServer;
|
||||||
|
final ConnectStore forms;
|
||||||
|
/// Name and picture for your own connection (default: the app's).
|
||||||
|
final Profile? profile;
|
||||||
|
final HealthGetter healthGet;
|
||||||
|
final _stops = <void Function()>[];
|
||||||
|
|
||||||
|
Profile get _me => profile ?? Profile.instance;
|
||||||
|
|
||||||
|
final place = signal(Place.none);
|
||||||
|
|
||||||
|
/// In a server (yours or a friend's).
|
||||||
|
late final Computed<bool> online = computed(() => place.value != Place.none);
|
||||||
|
|
||||||
|
/// On your own server.
|
||||||
|
late final Computed<bool> home = computed(() => place.value == Place.yours);
|
||||||
|
|
||||||
|
/// A friend's server's own name (`/health`), '' for yours, until known
|
||||||
|
/// or when it only has the default name.
|
||||||
|
final serverLabel = signal('');
|
||||||
|
|
||||||
|
/// The server's Starsync version: yours is this app's; a friend's
|
||||||
|
/// comes from its `/health`; '' until known.
|
||||||
|
final serverVersion = signal('');
|
||||||
|
|
||||||
|
/// The address dialled for a friend's server ("host:port"), '' at home.
|
||||||
|
final address = signal('');
|
||||||
|
|
||||||
|
// Bumped by every move; a superseded one stops at its next step.
|
||||||
|
int _op = 0;
|
||||||
|
|
||||||
|
/// The app starts: home, on your own server.
|
||||||
|
Future<void> start() => goHome();
|
||||||
|
|
||||||
|
/// To your own server (started if it is not running). False when it
|
||||||
|
/// could not start or the move was superseded; the reason is a notice.
|
||||||
|
Future<bool> goHome() async {
|
||||||
|
final op = ++_op;
|
||||||
|
_arrive(Place.none);
|
||||||
|
try {
|
||||||
|
await _me.ensureLoaded();
|
||||||
|
final name = _me.name.peek().trim();
|
||||||
|
final port = await yourServer.start(name: name);
|
||||||
|
if (op != _op) return false;
|
||||||
|
coordinator.hostFile = yourServer.setFile;
|
||||||
|
await coordinator.connect(ConnectParams(
|
||||||
|
host: '127.0.0.1',
|
||||||
|
port: port,
|
||||||
|
secret: '',
|
||||||
|
name: name.isEmpty ? 'Me' : name,
|
||||||
|
avatar: _me.avatarBase64(),
|
||||||
|
hostKey: yourServer.hostKey,
|
||||||
|
));
|
||||||
|
if (op != _op) return false;
|
||||||
|
serverVersion.value = appVersion;
|
||||||
|
_arrive(Place.yours);
|
||||||
|
return true;
|
||||||
|
} on ConnectCancelled {
|
||||||
|
return false;
|
||||||
|
} catch (e, st) {
|
||||||
|
debugPrint('your server: $e\n$st');
|
||||||
|
if (op == _op) coordinator.notify(ActionFailed("couldn't start your server: $e"));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _arrive(Place p) {
|
||||||
|
if (p != Place.yours) coordinator.hostFile = null;
|
||||||
|
if (p == Place.none) {
|
||||||
|
serverLabel.value = '';
|
||||||
|
serverVersion.value = '';
|
||||||
|
address.value = '';
|
||||||
|
}
|
||||||
|
place.value = p;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Joins a friend's server. The version gate first (`/health`): an
|
||||||
|
/// unreachable server or one without a version in common stops here,
|
||||||
|
/// still at home ([JoinFailed], [VersionGateRefused]). Then you leave
|
||||||
|
/// your server and connect; when that fails you are back home and the
|
||||||
|
/// error is rethrown (a refusal: [HelloRefused]).
|
||||||
|
Future<void> join(ConnectParams params) async {
|
||||||
|
final op = ++_op;
|
||||||
|
final health = await probeHealth(params.host, '${params.port}', healthGet);
|
||||||
|
if (op != _op) throw const ConnectCancelled();
|
||||||
|
if (!health.online) throw const JoinFailed('host offline — check address and port');
|
||||||
|
final fit = health.fit;
|
||||||
|
if (fit != null && fit != VersionFit.ok) {
|
||||||
|
throw VersionGateRefused(versionGateText(health), youUpdate: fit == VersionFit.youUpdate);
|
||||||
|
}
|
||||||
|
_arrive(Place.none);
|
||||||
|
try {
|
||||||
|
await coordinator.connect(params);
|
||||||
|
if (op != _op) throw const ConnectCancelled();
|
||||||
|
} catch (_) {
|
||||||
|
if (op == _op) unawaited(goHome());
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
serverLabel.value = health.label;
|
||||||
|
serverVersion.value = health.version;
|
||||||
|
address.value = '${params.host}:${params.port}';
|
||||||
|
_arrive(Place.friend);
|
||||||
|
await forms.remember(RecentServer(host: params.host, port: '${params.port}', secret: params.secret));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The Join card's Cancel: stops a join under way; when it had already
|
||||||
|
/// left home, back home.
|
||||||
|
Future<void> cancelPending() async {
|
||||||
|
_op++;
|
||||||
|
try {
|
||||||
|
await coordinator.cancelConnect();
|
||||||
|
} catch (_) {}
|
||||||
|
if (place.peek() != Place.yours) await goHome();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Out of a friend's server (Leave): back home.
|
||||||
|
Future<bool> leaveFriend() => goHome();
|
||||||
|
|
||||||
|
/// The app closes: out of whichever server ([bye] false: Update and
|
||||||
|
/// restart, the member stays `gone` for the restarted app).
|
||||||
|
Future<void> leave({bool bye = true}) async {
|
||||||
|
_op++;
|
||||||
|
await coordinator.leave(bye: bye);
|
||||||
|
_arrive(Place.none);
|
||||||
|
}
|
||||||
|
|
||||||
|
void dispose() {
|
||||||
|
for (final stop in _stops) {
|
||||||
|
stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
// The user's notification choices (Settings → Notifications & sounds), as
|
||||||
|
// the Notifier's policy reads them. Pure Dart; [AppSettings] stores them.
|
||||||
|
|
||||||
|
/// When chat messages pop up as toasts (they always reach the chat).
|
||||||
|
enum ChatPopups {
|
||||||
|
all('All'),
|
||||||
|
paused('Only when paused'),
|
||||||
|
off('Off');
|
||||||
|
|
||||||
|
final String label;
|
||||||
|
const ChatPopups(this.label);
|
||||||
|
|
||||||
|
/// Whether a chat message pops up now ([playing]: the video plays).
|
||||||
|
bool shows({required bool playing}) => switch (this) {
|
||||||
|
ChatPopups.all => true,
|
||||||
|
ChatPopups.paused => !playing,
|
||||||
|
ChatPopups.off => false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the user picked: chat pop-ups, and whether joins and leaves also
|
||||||
|
/// reach them while they sit out or the window is in the background
|
||||||
|
/// ([people]; off by default: then only chat, nudges, "starting now" and
|
||||||
|
/// Up next do).
|
||||||
|
class NotifyPrefs {
|
||||||
|
final ChatPopups chat;
|
||||||
|
final bool people;
|
||||||
|
const NotifyPrefs({this.chat = ChatPopups.all, this.people = false});
|
||||||
|
}
|
||||||
@@ -1,12 +1,16 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:signals/signals_core.dart';
|
import 'package:signals/signals_core.dart';
|
||||||
import 'package:sync_relay_core/core.dart' show FeedMessage;
|
import 'package:sync_relay_core/core.dart' show FeedMessage, Presence;
|
||||||
|
|
||||||
|
import '../platform/ports/system_notifications.dart';
|
||||||
|
import '../platform/ports/window_activity.dart';
|
||||||
import '../sfx/sfx.dart';
|
import '../sfx/sfx.dart';
|
||||||
import 'app_events.dart';
|
import 'app_events.dart';
|
||||||
|
import 'notice_prefs.dart';
|
||||||
|
|
||||||
export 'app_events.dart';
|
export 'app_events.dart';
|
||||||
|
export 'notice_prefs.dart';
|
||||||
|
|
||||||
class ToastItem {
|
class ToastItem {
|
||||||
final int id;
|
final int id;
|
||||||
@@ -17,67 +21,249 @@ class ToastItem {
|
|||||||
final String? actor;
|
final String? actor;
|
||||||
/// [actor]'s member id (feed and chat toasts); '' when unknown.
|
/// [actor]'s member id (feed and chat toasts); '' when unknown.
|
||||||
final String actorId;
|
final String actorId;
|
||||||
const ToastItem({required this.id, required this.text, required this.at, this.actor, this.actorId = ''});
|
/// A chat message: a click opens the chat.
|
||||||
|
final bool chat;
|
||||||
|
/// A picture hash to show (an Up next item's), '' when none.
|
||||||
|
final String thumb;
|
||||||
|
const ToastItem(
|
||||||
|
{required this.id,
|
||||||
|
required this.text,
|
||||||
|
required this.at,
|
||||||
|
this.actor,
|
||||||
|
this.actorId = '',
|
||||||
|
this.chat = false,
|
||||||
|
this.thumb = ''});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a notice is about; the policy filters by it ([noticeFor]).
|
||||||
|
enum NoticeCategory {
|
||||||
|
/// Chat messages.
|
||||||
|
chat,
|
||||||
|
|
||||||
|
/// Someone poked this member.
|
||||||
|
nudge,
|
||||||
|
|
||||||
|
/// The media starts playing for the first time ("starting now").
|
||||||
|
start,
|
||||||
|
|
||||||
|
/// Up next and the video changing (adds, skips, swaps).
|
||||||
|
upNext,
|
||||||
|
|
||||||
|
/// Joins, leaves, lost signal and returns.
|
||||||
|
people,
|
||||||
|
|
||||||
|
/// The rest of what the party does (ready, seeks, pauses, someone sat
|
||||||
|
/// out, renames): the activity log and a few sounds.
|
||||||
|
party,
|
||||||
|
|
||||||
|
/// Something keeps the watch from going smoothly (someone cannot play
|
||||||
|
/// it, the source dropped, the server could not fetch it).
|
||||||
|
problem,
|
||||||
|
|
||||||
|
/// About this member itself (sat out, auto sit-out, the relay it
|
||||||
|
/// serves, its connection, another window took over).
|
||||||
|
self,
|
||||||
|
|
||||||
|
/// The app's own notices (copied, shaders, update, errors): never
|
||||||
|
/// filtered.
|
||||||
|
app;
|
||||||
|
|
||||||
|
/// What reaches you while you sit out or the window is in the
|
||||||
|
/// background (D1): chat, nudges, "starting now" and Up next, plus joins
|
||||||
|
/// and leaves when [NotifyPrefs.people] is on.
|
||||||
|
bool reachesAway(NotifyPrefs prefs) => switch (this) {
|
||||||
|
chat || nudge || start || upNext || self || app => true,
|
||||||
|
people => prefs.people,
|
||||||
|
party || problem => false,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Social: nothing of it plays or pops up while you watch alone (D9).
|
||||||
|
bool get social => this == chat || this == start || this == people || this == party;
|
||||||
|
|
||||||
|
/// May go out as a system notification while the window is in the
|
||||||
|
/// background.
|
||||||
|
bool get system => this == chat || this == nudge || this == start || this == upNext || this == people;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The session as the policy sees it.
|
||||||
|
typedef NoticeSession = ({Presence? self, bool alone, bool playing});
|
||||||
|
|
||||||
|
/// Where the user stands when a notice comes: their presence ([self],
|
||||||
|
/// null outside a session), whether anyone else is there ([alone]), the
|
||||||
|
/// window ([window]), whether the video plays ([playing]) and their
|
||||||
|
/// choices ([prefs]). A replayed feed line says so itself.
|
||||||
|
class NoticeContext {
|
||||||
|
final Presence? self;
|
||||||
|
final bool alone;
|
||||||
|
final WindowActivity window;
|
||||||
|
final bool playing;
|
||||||
|
final NotifyPrefs prefs;
|
||||||
|
const NoticeContext({
|
||||||
|
this.self,
|
||||||
|
this.alone = false,
|
||||||
|
this.window = WindowActivity.focused,
|
||||||
|
this.playing = false,
|
||||||
|
this.prefs = const NotifyPrefs(),
|
||||||
|
});
|
||||||
|
|
||||||
|
bool get away => self == Presence.away;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A notification outside the window ([SystemNotifications]).
|
||||||
|
class SystemNote {
|
||||||
|
final String title;
|
||||||
|
final String body;
|
||||||
|
final String tag;
|
||||||
|
/// [payloadChat]: a click opens the chat.
|
||||||
|
final String payload;
|
||||||
|
const SystemNote({required this.title, required this.body, this.tag = '', this.payload = ''});
|
||||||
|
|
||||||
|
static const payloadChat = 'chat';
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What one [AppEvent] does: a toast ([toast], about [actor]), a sound,
|
/// What one [AppEvent] does: a toast ([toast], about [actor]), a sound,
|
||||||
/// the window asking for attention, an activity-log line.
|
/// the window asking for attention, a system notification, an
|
||||||
|
/// activity-log line.
|
||||||
class Notice {
|
class Notice {
|
||||||
|
final NoticeCategory category;
|
||||||
final String? toast;
|
final String? toast;
|
||||||
final String? actor;
|
final String? actor;
|
||||||
|
/// The toast is a chat message (a click opens the chat).
|
||||||
|
final bool chat;
|
||||||
|
/// A picture hash for the toast (an Up next item's).
|
||||||
|
final String thumb;
|
||||||
final SfxCue? sound;
|
final SfxCue? sound;
|
||||||
final bool attention;
|
final bool attention;
|
||||||
|
final SystemNote? system;
|
||||||
/// The feed line it goes into the activity log as.
|
/// The feed line it goes into the activity log as.
|
||||||
final FeedMessage? log;
|
final FeedMessage? log;
|
||||||
const Notice({this.toast, this.actor, this.sound, this.attention = false, this.log});
|
const Notice({
|
||||||
|
this.category = NoticeCategory.app,
|
||||||
|
this.toast,
|
||||||
|
this.actor,
|
||||||
|
this.chat = false,
|
||||||
|
this.thumb = '',
|
||||||
|
this.sound,
|
||||||
|
this.attention = false,
|
||||||
|
this.system,
|
||||||
|
this.log,
|
||||||
|
});
|
||||||
|
|
||||||
static const none = Notice();
|
static const none = Notice();
|
||||||
|
|
||||||
|
Notice _with({bool toast = true, bool sound = true, SystemNote? system}) => Notice(
|
||||||
|
category: category,
|
||||||
|
toast: toast ? this.toast : null,
|
||||||
|
actor: toast ? actor : null,
|
||||||
|
chat: toast && chat,
|
||||||
|
thumb: toast ? thumb : '',
|
||||||
|
sound: sound ? this.sound : null,
|
||||||
|
attention: attention,
|
||||||
|
system: system,
|
||||||
|
log: log,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The policy: what each event does.
|
String _title(String t) => t.isEmpty ? 'the video' : t;
|
||||||
Notice noticeFor(AppEvent e) => switch (e) {
|
|
||||||
// Lines a reconnect replayed (`welcome.feed`): the log only.
|
/// The event alone: its category, toast, sound and log line.
|
||||||
FeedEvent(:final feed) when feed.replayed => Notice(log: feed),
|
Notice _plain(AppEvent e) => switch (e) {
|
||||||
// The feed: everything is logged; joins, leaves, swaps and chat
|
// The feed: everything is logged.
|
||||||
// pop up; the social actions have a sound.
|
|
||||||
MemberJoined(:final feed) => Notice(
|
MemberJoined(:final feed) => Notice(
|
||||||
log: feed, toast: '${feed.actor} joined', actor: feed.actor, sound: const SfxCue(SfxKind.join)),
|
category: NoticeCategory.people,
|
||||||
|
log: feed,
|
||||||
|
toast: '${feed.actor} joined',
|
||||||
|
actor: feed.actor,
|
||||||
|
sound: const SfxCue(SfxKind.join)),
|
||||||
MemberLeft(:final feed) => Notice(
|
MemberLeft(:final feed) => Notice(
|
||||||
log: feed, toast: '${feed.actor} left', actor: feed.actor, sound: const SfxCue(SfxKind.leave)),
|
category: NoticeCategory.people,
|
||||||
|
log: feed,
|
||||||
|
toast: '${feed.actor} left',
|
||||||
|
actor: feed.actor,
|
||||||
|
sound: const SfxCue(SfxKind.leave)),
|
||||||
|
// After the server's delayed `gone` line (a quick reconnect says
|
||||||
|
// nothing), and back after it.
|
||||||
|
MemberGone(:final feed) => Notice(
|
||||||
|
category: NoticeCategory.people, log: feed, toast: '${feed.actor} lost connection', actor: feed.actor),
|
||||||
|
MemberReturned(:final feed) =>
|
||||||
|
Notice(category: NoticeCategory.people, log: feed, toast: '${feed.actor} is back', actor: feed.actor),
|
||||||
|
// Put back after a server restart: news, but not a change.
|
||||||
|
MediaSwapped(:final feed) when feed.reason == 'restore' => Notice(
|
||||||
|
category: NoticeCategory.upNext,
|
||||||
|
log: feed,
|
||||||
|
toast: '${feed.actor} put back what was playing',
|
||||||
|
actor: feed.actor),
|
||||||
MediaSwapped(:final feed) => Notice(
|
MediaSwapped(:final feed) => Notice(
|
||||||
log: feed, toast: '${feed.actor} changed media', actor: feed.actor, sound: const SfxCue(SfxKind.swap)),
|
category: NoticeCategory.upNext,
|
||||||
ChatPosted(:final feed, :final own) => Notice(
|
log: feed,
|
||||||
|
toast: '${feed.actor} changed media',
|
||||||
|
actor: feed.actor,
|
||||||
|
sound: const SfxCue(SfxKind.swap)),
|
||||||
|
QueueAdded(:final feed, :final thumb) => Notice(
|
||||||
|
category: NoticeCategory.upNext,
|
||||||
|
log: feed,
|
||||||
|
toast: '${feed.actor} added ${_title(feed.target ?? '')} to Up next',
|
||||||
|
actor: feed.actor,
|
||||||
|
thumb: thumb),
|
||||||
|
QueueSkipped(:final feed) => Notice(
|
||||||
|
category: NoticeCategory.upNext,
|
||||||
|
log: feed,
|
||||||
|
toast: 'Skipped ${feed.target ?? 'an item'}${feed.reason.isEmpty ? '' : ': ${feed.reason}'}'),
|
||||||
|
QueueRemoved(:final feed) => Notice(category: NoticeCategory.upNext, log: feed),
|
||||||
|
ChatPosted(:final feed) => Notice(
|
||||||
|
category: NoticeCategory.chat,
|
||||||
log: feed,
|
log: feed,
|
||||||
toast: '${feed.actor}: ${feed.reason}',
|
toast: '${feed.actor}: ${feed.reason}',
|
||||||
actor: feed.actor,
|
actor: feed.actor,
|
||||||
// Your own message needs no sound.
|
chat: true,
|
||||||
sound: own ? null : const SfxCue(SfxKind.chat)),
|
sound: const SfxCue(SfxKind.chat)),
|
||||||
MemberSatOut(:final feed, atYou: true) => Notice(
|
MemberSatOut(:final feed, atYou: true) => Notice(
|
||||||
log: feed, toast: '${feed.actor} sat you out', actor: feed.actor, sound: const SfxCue(SfxKind.kick)),
|
category: NoticeCategory.self,
|
||||||
|
log: feed,
|
||||||
|
toast: '${feed.actor} sat you out',
|
||||||
|
actor: feed.actor,
|
||||||
|
sound: const SfxCue(SfxKind.kick)),
|
||||||
MemberSatOut(:final feed, autoSelf: true) => Notice(
|
MemberSatOut(:final feed, autoSelf: true) => Notice(
|
||||||
|
category: NoticeCategory.self,
|
||||||
log: feed,
|
log: feed,
|
||||||
toast: 'You sit out (${feed.reason == 'minimised' ? 'minimised' : 'idle'}); you are back as soon as you return',
|
toast: 'You sit out (${feed.reason == 'minimised' ? 'minimised' : 'idle'}); you are back as soon as you return',
|
||||||
sound: const SfxCue(SfxKind.warn)),
|
sound: const SfxCue(SfxKind.warn)),
|
||||||
ReadyChanged(:final feed, ready: true, :final readyIndex) =>
|
ReadyChanged(:final feed, ready: true, :final readyIndex) =>
|
||||||
Notice(log: feed, sound: SfxCue(SfxKind.ready, readyIndex: readyIndex)),
|
Notice(category: NoticeCategory.party, log: feed, sound: SfxCue(SfxKind.ready, readyIndex: readyIndex)),
|
||||||
ReadyChanged(:final feed, ready: false) => Notice(log: feed, sound: const SfxCue(SfxKind.unready)),
|
ReadyChanged(:final feed, ready: false) =>
|
||||||
ForcedReady(:final feed, ready: true) => Notice(log: feed, sound: const SfxCue(SfxKind.forceReady)),
|
Notice(category: NoticeCategory.party, log: feed, sound: const SfxCue(SfxKind.unready)),
|
||||||
QueueSkipped(:final feed) => Notice(
|
ForcedReady(:final feed, ready: true) =>
|
||||||
log: feed, toast: 'Skipped ${feed.target ?? 'an item'}${feed.reason.isEmpty ? '' : ': ${feed.reason}'}'),
|
Notice(category: NoticeCategory.party, log: feed, sound: const SfxCue(SfxKind.forceReady)),
|
||||||
FeedEvent(:final feed) => Notice(log: feed),
|
FetchFailed(:final feed) => Notice(category: NoticeCategory.problem, log: feed),
|
||||||
|
FeedEvent(:final feed) => Notice(category: NoticeCategory.party, log: feed),
|
||||||
// The session.
|
// The session.
|
||||||
Nudged(:final name) => Notice(
|
Nudged(:final name) => Notice(
|
||||||
|
category: NoticeCategory.nudge,
|
||||||
toast: '${name.isEmpty ? 'Someone' : name} nudged you',
|
toast: '${name.isEmpty ? 'Someone' : name} nudged you',
|
||||||
actor: name.isEmpty ? null : name,
|
actor: name.isEmpty ? null : name,
|
||||||
sound: const SfxCue(SfxKind.nudge),
|
sound: const SfxCue(SfxKind.nudge),
|
||||||
attention: true),
|
attention: true),
|
||||||
MemberCantPlay(:final name, :final error) => Notice(toast: "$name can't play this: $error", actor: name),
|
PlaybackStarted(:final title) =>
|
||||||
SlowSource(:final text) => Notice(toast: text),
|
Notice(category: NoticeCategory.start, toast: title.isEmpty ? 'Starting now' : 'Starting now: $title'),
|
||||||
RelayStarted() =>
|
MemberCantPlay(:final name, :final error) =>
|
||||||
const Notice(toast: 'Passing the video on through the server. Keep the app open while it plays.'),
|
Notice(category: NoticeCategory.problem, toast: "$name can't play this: $error", actor: name),
|
||||||
RelayFailed(:final error) => Notice(toast: 'Cannot pass the video on: $error'),
|
RelaySourceDropped(:final name, :final left) => Notice(
|
||||||
ConnectionLost() => const Notice(sound: SfxCue(SfxKind.lost)),
|
category: NoticeCategory.problem,
|
||||||
ConnectionBack() => const Notice(sound: SfxCue(SfxKind.back)),
|
toast: left
|
||||||
|
? '$name left: playing what the server has'
|
||||||
|
: "$name's connection dropped: playing what the server has",
|
||||||
|
actor: name),
|
||||||
|
SlowSource(:final text) => Notice(category: NoticeCategory.self, toast: text),
|
||||||
|
RelayStarted() => const Notice(
|
||||||
|
category: NoticeCategory.self,
|
||||||
|
toast: 'Passing the video on through the server. Keep the app open while it plays.'),
|
||||||
|
RelayFailed(:final error) => Notice(category: NoticeCategory.self, toast: 'Cannot pass the video on: $error'),
|
||||||
|
ConnectionLost() => const Notice(category: NoticeCategory.self, sound: SfxCue(SfxKind.lost)),
|
||||||
|
ConnectionBack() => const Notice(category: NoticeCategory.self, sound: SfxCue(SfxKind.back)),
|
||||||
|
ServerRestarted() =>
|
||||||
|
const Notice(category: NoticeCategory.self, toast: 'The server restarted; you are back in'),
|
||||||
|
Replaced() => const Notice(category: NoticeCategory.self, toast: 'Starsync was opened in another window'),
|
||||||
|
ServerRefused(:final text) => Notice(category: NoticeCategory.self, toast: text),
|
||||||
JellyfinProgressFailed(:final signInExpired) =>
|
JellyfinProgressFailed(:final signInExpired) =>
|
||||||
Notice(toast: 'Jellyfin progress not saved: ${signInExpired ? 'sign-in expired' : 'server unreachable'}'),
|
Notice(toast: 'Jellyfin progress not saved: ${signInExpired ? 'sign-in expired' : 'server unreachable'}'),
|
||||||
// The app.
|
// The app.
|
||||||
@@ -89,29 +275,115 @@ Notice noticeFor(AppEvent e) => switch (e) {
|
|||||||
Notice(toast: inSession ? 'Marked in the session log' : "Marked in this app's log"),
|
Notice(toast: inSession ? 'Marked in the session log' : "Marked in this app's log"),
|
||||||
Copied(:final text) => Notice(toast: text),
|
Copied(:final text) => Notice(toast: text),
|
||||||
CopyFailed() => const Notice(toast: "Couldn't copy"),
|
CopyFailed() => const Notice(toast: "Couldn't copy"),
|
||||||
// The update band shows it.
|
// The update band shows it (D8: a clear prompt, never over playback).
|
||||||
UpdateAvailable() => Notice.none,
|
UpdateAvailable() => Notice.none,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// The policy: what [e] does for a user in [c].
|
||||||
|
/// - Lines a reconnect replayed (`welcome.feed`): the activity log only.
|
||||||
|
/// - Your own joins, leaves, swaps and Up next adds: logged, no pop-up or
|
||||||
|
/// sound (the app already said so); your own chat pops up, silently.
|
||||||
|
/// - Alone (D9): no social sounds or pop-ups (nobody is there); the app's
|
||||||
|
/// notices, problems and your own still show.
|
||||||
|
/// - Sitting out (D1): only chat, nudges, "starting now", Up next, your
|
||||||
|
/// own and the app's, plus joins and leaves when chosen
|
||||||
|
/// ([NotifyPrefs.people]); the rest is logged only.
|
||||||
|
/// - "Starting now" pops up only while you sit out or the window is in
|
||||||
|
/// the background (otherwise you see it start).
|
||||||
|
/// - Chat pop-ups follow [NotifyPrefs.chat]; the chat sound has its own
|
||||||
|
/// switch (Settings → Sounds).
|
||||||
|
/// - The window in the background: what reaches you there also goes out
|
||||||
|
/// as a system notification (chat, nudges, "starting now", Up next;
|
||||||
|
/// joins and leaves when chosen).
|
||||||
|
Notice noticeFor(AppEvent e, [NoticeContext c = const NoticeContext()]) {
|
||||||
|
if (e is FeedEvent && e.feed.replayed) return Notice(category: _plain(e).category, log: e.feed);
|
||||||
|
final n = _plain(e);
|
||||||
|
final cat = n.category;
|
||||||
|
final own = e is FeedEvent && e.own;
|
||||||
|
var toast = n.toast != null;
|
||||||
|
var sound = n.sound != null;
|
||||||
|
if (own && (cat == NoticeCategory.people || cat == NoticeCategory.upNext)) {
|
||||||
|
toast = false;
|
||||||
|
sound = false;
|
||||||
|
}
|
||||||
|
if (own && cat == NoticeCategory.chat) sound = false;
|
||||||
|
if (c.alone && cat.social) {
|
||||||
|
toast = false;
|
||||||
|
sound = false;
|
||||||
|
}
|
||||||
|
if (c.away && !cat.reachesAway(c.prefs)) {
|
||||||
|
toast = false;
|
||||||
|
sound = false;
|
||||||
|
}
|
||||||
|
if (cat == NoticeCategory.start && !c.away && !c.window.background) toast = false;
|
||||||
|
if (cat == NoticeCategory.chat && !c.prefs.chat.shows(playing: c.playing)) toast = false;
|
||||||
|
SystemNote? system;
|
||||||
|
if (toast && !own && c.window.background && cat.system && cat.reachesAway(c.prefs)) {
|
||||||
|
final actor = n.actor;
|
||||||
|
final text = n.toast!;
|
||||||
|
final body = actor != null && text.startsWith(actor) ? text.substring(actor.length).replaceFirst(RegExp(r'^:? '), '') : text;
|
||||||
|
system = SystemNote(
|
||||||
|
title: actor ?? 'Starsync',
|
||||||
|
body: actor == null ? text : body,
|
||||||
|
tag: cat == NoticeCategory.chat ? '' : cat.name,
|
||||||
|
payload: cat == NoticeCategory.chat ? SystemNote.payloadChat : '',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return n._with(toast: toast, sound: sound, system: system);
|
||||||
|
}
|
||||||
|
|
||||||
/// The one way news reaches the user: [notify] turns an [AppEvent] into
|
/// The one way news reaches the user: [notify] turns an [AppEvent] into
|
||||||
/// toasts ([toasts]), sounds, an attention request and the activity log
|
/// toasts ([toasts], each dropped after [toastLife] by the Notifier's
|
||||||
/// ([feedLog]) per [noticeFor].
|
/// own timer), sounds, an attention request, a system notification and
|
||||||
|
/// the activity log ([feedLog]) per [noticeFor], in the context the
|
||||||
|
/// session ([session]), the window ([window]) and the user's choices
|
||||||
|
/// ([prefs]) give.
|
||||||
class Notifier {
|
class Notifier {
|
||||||
final SfxService sfx;
|
final SfxService sfx;
|
||||||
final Future<void> Function() attention;
|
final Future<void> Function() attention;
|
||||||
Notifier({this.sfx = const NoopSfxService(), Future<void> Function()? attention})
|
final SystemNotifications system;
|
||||||
: attention = attention ?? _noAttention;
|
/// Brings the window up (a click on a system notification).
|
||||||
|
final Future<void> Function() focusWindow;
|
||||||
|
final Duration toastLife;
|
||||||
|
/// The session's part of the context; set by the coordinator.
|
||||||
|
NoticeSession Function() session = () => (self: null, alone: false, playing: false);
|
||||||
|
NotifyPrefs Function() prefs;
|
||||||
|
/// The window's activity ([ShellPort.watchActivity], wired in `main`).
|
||||||
|
WindowActivity window = WindowActivity.focused;
|
||||||
|
|
||||||
static Future<void> _noAttention() async {}
|
Notifier({
|
||||||
|
this.sfx = const NoopSfxService(),
|
||||||
|
Future<void> Function()? attention,
|
||||||
|
this.system = const NoSystemNotifications(),
|
||||||
|
Future<void> Function()? focusWindow,
|
||||||
|
this.toastLife = const Duration(seconds: 5),
|
||||||
|
NotifyPrefs Function()? prefs,
|
||||||
|
}) : attention = attention ?? _nothing,
|
||||||
|
focusWindow = focusWindow ?? _nothing,
|
||||||
|
prefs = prefs ?? _defaultPrefs {
|
||||||
|
system.onClick = _clicked;
|
||||||
|
}
|
||||||
|
|
||||||
/// Toasts on screen (the shell shows them; [expire] drops old ones).
|
static Future<void> _nothing() async {}
|
||||||
|
static NotifyPrefs _defaultPrefs() => const NotifyPrefs();
|
||||||
|
|
||||||
|
/// Toasts on screen (the shell shows them).
|
||||||
final toasts = signal<List<ToastItem>>([]);
|
final toasts = signal<List<ToastItem>>([]);
|
||||||
/// The activity log: the last 100 feed lines.
|
/// The activity log: the last 100 feed lines.
|
||||||
final feedLog = signal<List<FeedMessage>>([]);
|
final feedLog = signal<List<FeedMessage>>([]);
|
||||||
|
/// Bumped when a click on a chat notification asks for the chat (the
|
||||||
|
/// shell opens it).
|
||||||
|
final chatRequests = signal(0);
|
||||||
int _toastId = 0;
|
int _toastId = 0;
|
||||||
|
final _timers = <int, Timer>{};
|
||||||
|
|
||||||
|
NoticeContext get context {
|
||||||
|
final s = session();
|
||||||
|
return NoticeContext(self: s.self, alone: s.alone, window: window, playing: s.playing, prefs: prefs());
|
||||||
|
}
|
||||||
|
|
||||||
void notify(AppEvent e) {
|
void notify(AppEvent e) {
|
||||||
final n = noticeFor(e);
|
final n = noticeFor(e, context);
|
||||||
final log = n.log;
|
final log = n.log;
|
||||||
if (log != null) {
|
if (log != null) {
|
||||||
final all = [...feedLog.peek(), log];
|
final all = [...feedLog.peek(), log];
|
||||||
@@ -119,29 +391,47 @@ class Notifier {
|
|||||||
}
|
}
|
||||||
final text = n.toast;
|
final text = n.toast;
|
||||||
if (text != null) {
|
if (text != null) {
|
||||||
_toastId++;
|
final id = ++_toastId;
|
||||||
toasts.value = [
|
toasts.value = [
|
||||||
...toasts.peek(),
|
...toasts.peek(),
|
||||||
ToastItem(
|
ToastItem(
|
||||||
id: _toastId,
|
id: id,
|
||||||
text: text,
|
text: text,
|
||||||
at: DateTime.now().toUtc(),
|
at: DateTime.now().toUtc(),
|
||||||
actor: n.actor,
|
actor: n.actor,
|
||||||
actorId: n.actor == null ? '' : n.log?.actorId ?? ''),
|
actorId: n.actor == null ? '' : n.log?.actorId ?? '',
|
||||||
|
chat: n.chat,
|
||||||
|
thumb: n.thumb),
|
||||||
];
|
];
|
||||||
|
_timers[id] = Timer(toastLife, () => dismiss(id));
|
||||||
}
|
}
|
||||||
final sound = n.sound;
|
final sound = n.sound;
|
||||||
if (sound != null) unawaited(sfx.play(sound));
|
if (sound != null) unawaited(sfx.play(sound));
|
||||||
if (n.attention) unawaited(attention());
|
if (n.attention) unawaited(attention());
|
||||||
|
final note = n.system;
|
||||||
|
if (note != null) {
|
||||||
|
unawaited(system.show(title: note.title, body: note.body, tag: note.tag, payload: note.payload));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Drops toasts 4 s old or older.
|
void _clicked(String payload) {
|
||||||
void expire(DateTime now) {
|
unawaited(focusWindow());
|
||||||
toasts.value = toasts.peek().where((t) => now.difference(t.at).inSeconds < 4).toList();
|
if (payload == SystemNote.payloadChat) chatRequests.value++;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Takes toast [id] off the screen (its time is up, or it was clicked).
|
||||||
|
void dismiss(int id) {
|
||||||
|
_timers.remove(id)?.cancel();
|
||||||
|
final now = toasts.peek();
|
||||||
|
if (now.any((t) => t.id == id)) toasts.value = [for (final t in now) if (t.id != id) t];
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A new session: no toasts, an empty log.
|
/// A new session: no toasts, an empty log.
|
||||||
void clear() {
|
void clear() {
|
||||||
|
for (final t in _timers.values) {
|
||||||
|
t.cancel();
|
||||||
|
}
|
||||||
|
_timers.clear();
|
||||||
feedLog.value = [];
|
feedLog.value = [];
|
||||||
toasts.value = [];
|
toasts.value = [];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import 'package:sync_relay_core/core.dart' show hasMediaExtension;
|
import 'package:sync_relay_core/core.dart' show MediaRef, hasMediaExtension, parseReleaseName;
|
||||||
|
|
||||||
import '../files/name_parser.dart';
|
import '../library/media_refs.dart' show refEpisodeLine;
|
||||||
import '../jellyfin/session_media.dart';
|
import '../jellyfin/session_media.dart';
|
||||||
import 'now_playing.dart';
|
import 'now_playing.dart';
|
||||||
|
|
||||||
@@ -26,12 +26,29 @@ NowPlaying? nowPlayingOf(Map<String, Object?>? media) {
|
|||||||
if (web) {
|
if (web) {
|
||||||
return NowPlaying(title: name, posterHash: poster, bannerHash: banner, thumbnail: true);
|
return NowPlaying(title: name, posterHash: poster, bannerHash: banner, thumbnail: true);
|
||||||
}
|
}
|
||||||
|
final what = _mediaRef(media['ref']);
|
||||||
final (title, episode) = ref != null
|
final (title, episode) = ref != null
|
||||||
? jellyfinTitle(ref.title.isNotEmpty ? ref.title : name, ref.subtitle)
|
? jellyfinTitle(ref.title.isNotEmpty ? ref.title : name, ref.subtitle)
|
||||||
|
: what != null && what.title.isNotEmpty
|
||||||
|
? refTitle(what, name)
|
||||||
: fileTitle(name);
|
: fileTitle(name);
|
||||||
return NowPlaying(title: title, episode: episode, posterHash: poster, bannerHash: banner);
|
return NowPlaying(title: title, episode: episode, posterHash: poster, bannerHash: banner);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
MediaRef? _mediaRef(Object? j) {
|
||||||
|
try {
|
||||||
|
return MediaRef.fromJson(j);
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Files and links that say what they are (`media.ref`, from the
|
||||||
|
/// sender's library): its proper title and `S3 E13` / `E108`; an extra
|
||||||
|
/// keeps the name's own marker (OP1, NCED2).
|
||||||
|
(String, String?) refTitle(MediaRef ref, String name) =>
|
||||||
|
(ref.title, refEpisodeLine(ref) ?? (ref.kind == 'extra' ? fileTitle(name).$2 : null));
|
||||||
|
|
||||||
/// [media] (`state.media`, not Jellyfin) is a web page's video, whose art
|
/// [media] (`state.media`, not Jellyfin) is a web page's video, whose art
|
||||||
/// will be its thumbnail: a shared http(s) link that is no media file (the
|
/// will be its thumbnail: a shared http(s) link that is no media file (the
|
||||||
/// everyone-opens-it mode), or a relayed one (passed on by a member or
|
/// everyone-opens-it mode), or a relayed one (passed on by a member or
|
||||||
|
|||||||
@@ -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,150 @@
|
|||||||
|
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;
|
||||||
|
RelayFeeder({required this.server, required this.notify});
|
||||||
|
|
||||||
|
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 [];
|
||||||
|
|
||||||
|
/// 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;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await src.done;
|
||||||
|
if (identical(_warm?.src, src)) _warm = null;
|
||||||
|
}());
|
||||||
|
}
|
||||||
|
|
||||||
|
void stopWarm() {
|
||||||
|
final w = _warm;
|
||||||
|
_warm = null;
|
||||||
|
if (w != null) unawaited(w.src.close());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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,344 @@
|
|||||||
|
import 'package:sync_relay_core/core.dart';
|
||||||
|
|
||||||
|
import 'app_events.dart';
|
||||||
|
import 'connection.dart';
|
||||||
|
import 'delivery.dart';
|
||||||
|
import 'now_playing_info.dart';
|
||||||
|
import 'party.dart';
|
||||||
|
|
||||||
|
// The session's one computed status: what the player's status pill, the
|
||||||
|
// party strip's server line and the members card's source line say, from
|
||||||
|
// facts only (the connection, the server's state with its hold reason and
|
||||||
|
// relay, this player's open/error facts). Nothing writes a status; the
|
||||||
|
// coordinator computes it ([statusOf]) and [StatusWatch] turns the
|
||||||
|
// transitions worth a notice into [AppEvent]s for the Notifier (Status →
|
||||||
|
// Notifier, never back).
|
||||||
|
|
||||||
|
/// How bad it is: [ok] and [info] never colour anything, [warn] amber,
|
||||||
|
/// [error] red.
|
||||||
|
enum StatusLevel { ok, info, warn, error }
|
||||||
|
|
||||||
|
/// What the player's status pill says, most pressing first.
|
||||||
|
enum StatusKind { reconnecting, problem, connecting, fetching, finished, loading, you, cannotPlay, waiting }
|
||||||
|
|
||||||
|
/// The pill's button: stop reconnecting, retry (reconnect), reload the
|
||||||
|
/// video (your player could not open it), or mark yourself ready (the
|
||||||
|
/// whole pill).
|
||||||
|
enum StatusAction { cancel, retry, reload, ready }
|
||||||
|
|
||||||
|
/// One status line: [text], a muted [detail], its [level], the members it
|
||||||
|
/// is about ([who]; [self] for you) and a loading [progress] (0..1).
|
||||||
|
class StatusLine {
|
||||||
|
final StatusKind kind;
|
||||||
|
final String text;
|
||||||
|
final String? detail;
|
||||||
|
final StatusLevel level;
|
||||||
|
final List<MemberView> who;
|
||||||
|
final bool self;
|
||||||
|
final double? progress;
|
||||||
|
final StatusAction? action;
|
||||||
|
/// The mini player's shorter line.
|
||||||
|
final String short;
|
||||||
|
const StatusLine(
|
||||||
|
this.kind,
|
||||||
|
this.text, {
|
||||||
|
this.detail,
|
||||||
|
this.level = StatusLevel.info,
|
||||||
|
this.who = const [],
|
||||||
|
this.self = false,
|
||||||
|
this.progress,
|
||||||
|
this.action,
|
||||||
|
String? short,
|
||||||
|
}) : short = short ?? text;
|
||||||
|
|
||||||
|
/// Coloured: a problem.
|
||||||
|
bool get problem => level == StatusLevel.warn || level == StatusLevel.error;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The facts a status is computed from.
|
||||||
|
class StatusFacts {
|
||||||
|
final ConnState conn;
|
||||||
|
/// The server's latest state (kept through a drop).
|
||||||
|
final StateBroadcast? state;
|
||||||
|
/// This member's id ('' before the welcome).
|
||||||
|
final String selfId;
|
||||||
|
/// Why this player cannot open the current media ([playErrorText]).
|
||||||
|
final String playerError;
|
||||||
|
/// The player itself failed (a reload that failed); '' when fine.
|
||||||
|
final String playerFault;
|
||||||
|
/// This viewer plays the current Jellyfin item with its own account:
|
||||||
|
/// the relay's troubles are not its own.
|
||||||
|
final bool ownPlan;
|
||||||
|
/// The mediaId the server said it could not fetch (`fetch-fail`), -1.
|
||||||
|
final int fetchFailedFor;
|
||||||
|
/// Names of members that left (a relay source that left).
|
||||||
|
final MemberNames? names;
|
||||||
|
/// A reconnect found the server restarted this many times.
|
||||||
|
final int restarts;
|
||||||
|
final DateTime now;
|
||||||
|
const StatusFacts({
|
||||||
|
this.conn = const ConnClosed(),
|
||||||
|
this.state,
|
||||||
|
this.selfId = '',
|
||||||
|
this.playerError = '',
|
||||||
|
this.playerFault = '',
|
||||||
|
this.ownPlan = false,
|
||||||
|
this.fetchFailedFor = -1,
|
||||||
|
this.names,
|
||||||
|
this.restarts = 0,
|
||||||
|
required this.now,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The relay source's trouble, as the transitions watch it.
|
||||||
|
class SourceTrouble {
|
||||||
|
final int mediaId;
|
||||||
|
final String sourceId;
|
||||||
|
final String name;
|
||||||
|
/// Gone since (the source member is `gone`); null when it left.
|
||||||
|
final DateTime? since;
|
||||||
|
bool get left => since == null;
|
||||||
|
const SourceTrouble({required this.mediaId, required this.sourceId, required this.name, this.since});
|
||||||
|
}
|
||||||
|
|
||||||
|
class Status {
|
||||||
|
/// The player's pill; null when there is nothing to say (playing,
|
||||||
|
/// connected, everyone ready).
|
||||||
|
final StatusLine? line;
|
||||||
|
/// The party strip's line under the server name: "connecting…",
|
||||||
|
/// "reconnecting…", "ready 2/3".
|
||||||
|
final String party;
|
||||||
|
/// Where the current media comes from (the members card's source line);
|
||||||
|
/// null with nothing up or nobody else there.
|
||||||
|
final Delivery? delivery;
|
||||||
|
/// The relay source this viewer depends on is gone or left.
|
||||||
|
final SourceTrouble? source;
|
||||||
|
final ConnState conn;
|
||||||
|
final StateBroadcast? state;
|
||||||
|
final int restarts;
|
||||||
|
const Status({
|
||||||
|
this.line,
|
||||||
|
this.party = '',
|
||||||
|
this.delivery,
|
||||||
|
this.source,
|
||||||
|
this.conn = const ConnClosed(),
|
||||||
|
this.state,
|
||||||
|
this.restarts = 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// "Bob", "Bob and Mika", "Bob, Mika and Cleo", "Bob, Mika and 3 others".
|
||||||
|
String nameList(List<String> names) {
|
||||||
|
if (names.length <= 1) return names.join();
|
||||||
|
if (names.length <= 3) return '${names.sublist(0, names.length - 1).join(', ')} and ${names.last}';
|
||||||
|
return '${names[0]}, ${names[1]} and ${names.length - 2} others';
|
||||||
|
}
|
||||||
|
|
||||||
|
String _sentence(String s) => s.isEmpty ? s : s[0].toUpperCase() + s.substring(1);
|
||||||
|
|
||||||
|
/// "(2 min)" from a minute on, nothing before.
|
||||||
|
String _forHow(DateTime since, DateTime now) =>
|
||||||
|
now.difference(since).inMinutes >= 1 ? ' (${sinceText(since, now)})' : '';
|
||||||
|
|
||||||
|
/// "Bob's connection dropped — playing what the server has (2 min)",
|
||||||
|
/// "Bob left — playing what the server has".
|
||||||
|
String sourceTroubleText(SourceTrouble t, DateTime now) => t.left
|
||||||
|
? '${t.name} left — playing what the server has'
|
||||||
|
: "${t.name}'s connection dropped — playing what the server has${_forHow(t.since!, now)}";
|
||||||
|
|
||||||
|
/// The relayed media's source this viewer reads from is gone or left.
|
||||||
|
SourceTrouble? sourceTroubleOf(StateBroadcast r, String selfId, MemberNames? names) {
|
||||||
|
final relay = stateMediaRelay(r);
|
||||||
|
if (relay == null || relay.fetch || relay.source.isEmpty || relay.source == selfId) return null;
|
||||||
|
final m = r.members.where((m) => m.id == relay.source).firstOrNull;
|
||||||
|
final name = m?.name ?? names?[relay.source] ?? 'The member passing it on';
|
||||||
|
if (m == null) return SourceTrouble(mediaId: r.mediaId, sourceId: relay.source, name: name);
|
||||||
|
if (!relay.sourceGone && m.presence != Presence.gone) return null;
|
||||||
|
return SourceTrouble(
|
||||||
|
mediaId: r.mediaId,
|
||||||
|
sourceId: relay.source,
|
||||||
|
name: name,
|
||||||
|
since: relay.sourceGoneSince ?? m.goneSince ?? r.serverTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The status from [f].
|
||||||
|
Status statusOf(StatusFacts f) {
|
||||||
|
final r = f.state;
|
||||||
|
final stale = r != null && (f.conn.retrying || f.conn is ConnReplaced || f.conn is ConnRefused);
|
||||||
|
final counted = (r?.members ?? const <MemberView>[]).where(countsForReady).toList();
|
||||||
|
final party = r == null
|
||||||
|
? 'connecting…'
|
||||||
|
: stale
|
||||||
|
? 'reconnecting…'
|
||||||
|
: 'ready ${counted.where((m) => m.ready).length}/${counted.length}';
|
||||||
|
final self = r == null || f.selfId.isEmpty ? null : r.members.where((m) => m.id == f.selfId).firstOrNull;
|
||||||
|
final source = r == null || f.ownPlan ? null : sourceTroubleOf(r, f.selfId, f.names);
|
||||||
|
return Status(
|
||||||
|
line: _line(f, r, self, source),
|
||||||
|
party: party,
|
||||||
|
delivery: r == null ? null : deliveryOf(r, selfId: f.selfId, names: f.names, now: f.now),
|
||||||
|
source: source,
|
||||||
|
conn: f.conn,
|
||||||
|
state: r,
|
||||||
|
restarts: f.restarts,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
StatusLine? _line(StatusFacts f, StateBroadcast? r, MemberView? self, SourceTrouble? source) {
|
||||||
|
switch (f.conn) {
|
||||||
|
case ConnLost() || ConnReconnecting():
|
||||||
|
return const StatusLine(StatusKind.reconnecting, 'Reconnecting…',
|
||||||
|
level: StatusLevel.warn, action: StatusAction.cancel);
|
||||||
|
case ConnUnstable():
|
||||||
|
return const StatusLine(StatusKind.reconnecting, 'Connection unstable…', level: StatusLevel.warn);
|
||||||
|
case ConnReplaced(:final message) || ConnRefused(:final message):
|
||||||
|
return StatusLine(StatusKind.problem, _sentence(message), level: StatusLevel.error, action: StatusAction.retry);
|
||||||
|
case ConnConnecting():
|
||||||
|
return const StatusLine(StatusKind.connecting, 'Connecting…');
|
||||||
|
case ConnOnline() || ConnClosed():
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
final away = self?.presence == Presence.away;
|
||||||
|
if (r == null || r.media == null) return null;
|
||||||
|
// Sitting out: the join-back card says it; nobody waits for you.
|
||||||
|
if (away) return null;
|
||||||
|
if (f.playerFault.isNotEmpty) {
|
||||||
|
return StatusLine(StatusKind.problem, _sentence(f.playerFault),
|
||||||
|
level: StatusLevel.error, action: StatusAction.reload, short: "Can't play this");
|
||||||
|
}
|
||||||
|
if (f.playerError.isNotEmpty) {
|
||||||
|
return StatusLine(StatusKind.problem, "Can't play this: ${f.playerError}",
|
||||||
|
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.
|
||||||
|
final relay = f.ownPlan ? null : stateMediaRelay(r);
|
||||||
|
if (relay != null && relay.source != f.selfId) {
|
||||||
|
if (relay.fetch && !relay.live) {
|
||||||
|
if (f.fetchFailedFor == r.mediaId) {
|
||||||
|
return const StatusLine(StatusKind.problem, "The server couldn't fetch this video", level: StatusLevel.error);
|
||||||
|
}
|
||||||
|
return const StatusLine(StatusKind.fetching, 'The server is getting the video…');
|
||||||
|
}
|
||||||
|
if (source != null) {
|
||||||
|
return StatusLine(StatusKind.problem, sourceTroubleText(source, f.now),
|
||||||
|
level: StatusLevel.warn,
|
||||||
|
short: source.left ? '${source.name} left' : "${source.name}'s connection dropped");
|
||||||
|
}
|
||||||
|
if (!relay.fetch && !relay.live) {
|
||||||
|
final who = r.members.where((m) => m.id == relay.source).firstOrNull?.name ?? 'the member who put it up';
|
||||||
|
return StatusLine(StatusKind.problem, 'Waiting for $who to pass the video on', level: StatusLevel.warn);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (r.transport == 'playing') return null;
|
||||||
|
if (r.transport == 'finished') {
|
||||||
|
return const StatusLine(StatusKind.finished, 'Finished · seek back or pick the next video', short: 'Finished');
|
||||||
|
}
|
||||||
|
// Members whose player cannot open it: the server does not wait for
|
||||||
|
// them; say why instead of "waiting for".
|
||||||
|
final failed = [
|
||||||
|
for (final m in r.members)
|
||||||
|
if (m.error.isNotEmpty && isPresent(m) && (self == null || m.id != self.id)) m,
|
||||||
|
];
|
||||||
|
// As the server holds playback: someone in the ready set short of the
|
||||||
|
// minimum buffer.
|
||||||
|
final need = r.bufferNeed;
|
||||||
|
for (final m in r.members) {
|
||||||
|
if (m.inSet && m.buffered < need) {
|
||||||
|
final you = self != null && m.id == self.id;
|
||||||
|
final p = (m.buffered / need).clamp(0.0, 1.0);
|
||||||
|
return StatusLine(StatusKind.loading, you ? 'Loading the video…' : '${m.name} is loading…',
|
||||||
|
who: [m], self: you, progress: p, detail: '${(p * 100).round()}%');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (failed.isNotEmpty) {
|
||||||
|
final first = failed.first;
|
||||||
|
final who = nameList([for (final m in failed) m.name]);
|
||||||
|
final unready = self != null && !self.ready;
|
||||||
|
return StatusLine(StatusKind.cannotPlay, "$who can't play this: ${first.error}",
|
||||||
|
who: failed,
|
||||||
|
short: "$who can't play this",
|
||||||
|
detail: unready ? "Press Space when you're ready" : null,
|
||||||
|
action: unready ? StatusAction.ready : null);
|
||||||
|
}
|
||||||
|
if (self != null && !self.ready) {
|
||||||
|
return StatusLine(StatusKind.you, "Press Space when you're ready",
|
||||||
|
who: [self], self: true, action: StatusAction.ready, detail: 'or click here', short: 'Press Space when ready');
|
||||||
|
}
|
||||||
|
// Members sitting out or gone are never waited for nor counted.
|
||||||
|
final active = [for (final m in r.members) if (countsForReady(m)) m];
|
||||||
|
final others = [
|
||||||
|
for (final m in active)
|
||||||
|
if ((self == null || m.id != self.id) && !(m.inSet && m.ready)) m,
|
||||||
|
];
|
||||||
|
if (others.isEmpty) return null;
|
||||||
|
final ready = active.where((m) => m.ready).length;
|
||||||
|
return StatusLine(StatusKind.waiting, 'Waiting for ${nameList([for (final m in others) m.name])}',
|
||||||
|
who: others, detail: '$ready/${active.length} ready');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How long the relay's source must be gone before it is worth a notice
|
||||||
|
/// (a quick reconnect says nothing).
|
||||||
|
const sourceDropNotice = Duration(seconds: 5);
|
||||||
|
|
||||||
|
/// Turns status transitions worth a notice into events: the source this
|
||||||
|
/// viewer reads from gone for [sourceDropNotice] (or left), another window
|
||||||
|
/// took over, the server restarted, the media starting for the first time.
|
||||||
|
/// Idempotent: [step] may run on every status change and every second.
|
||||||
|
class StatusWatch {
|
||||||
|
final _dropped = <String>{};
|
||||||
|
final _seenHeld = <int>{};
|
||||||
|
final _started = <int>{};
|
||||||
|
bool _replaced = false;
|
||||||
|
int _restarts = 0;
|
||||||
|
bool _first = true;
|
||||||
|
|
||||||
|
List<AppEvent> step(Status s, DateTime now) {
|
||||||
|
final out = <AppEvent>[];
|
||||||
|
final src = s.source;
|
||||||
|
if (src != null) {
|
||||||
|
final key = '${src.mediaId}/${src.sourceId}/${src.left ? 'left' : src.since!.toIso8601String()}';
|
||||||
|
final due = src.left || now.difference(src.since!) >= sourceDropNotice;
|
||||||
|
// Once per drop, and once when it left.
|
||||||
|
if (due && _dropped.add(key)) out.add(RelaySourceDropped(src.name, left: src.left));
|
||||||
|
}
|
||||||
|
final replaced = s.conn is ConnReplaced;
|
||||||
|
if (replaced && !_replaced) out.add(const Replaced());
|
||||||
|
_replaced = replaced;
|
||||||
|
if (!_first && s.restarts > _restarts) out.add(const ServerRestarted());
|
||||||
|
_restarts = s.restarts;
|
||||||
|
_first = false;
|
||||||
|
final r = s.state;
|
||||||
|
if (r != null && r.media != null) {
|
||||||
|
if (r.transport == 'held') _seenHeld.add(r.mediaId);
|
||||||
|
if (r.transport == 'playing' && _seenHeld.contains(r.mediaId) && _started.add(r.mediaId)) {
|
||||||
|
final now = nowPlayingOf(r.media);
|
||||||
|
out.add(PlaybackStarted([?now?.title, ?now?.episode].join(' · ')));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A new session.
|
||||||
|
void reset() {
|
||||||
|
_dropped.clear();
|
||||||
|
_seenHeld.clear();
|
||||||
|
_started.clear();
|
||||||
|
_replaced = false;
|
||||||
|
_restarts = 0;
|
||||||
|
_first = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io' show InternetAddress;
|
||||||
|
import 'dart:math';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart' show debugPrint, visibleForTesting;
|
||||||
|
import 'package:signals/signals.dart';
|
||||||
|
import 'package:sync_relay_core/core.dart';
|
||||||
|
|
||||||
|
import '../jellyfin/accounts.dart';
|
||||||
|
import '../jellyfin/session_media.dart' show relayCredentialsFor;
|
||||||
|
import '../state/connect_prefs.dart';
|
||||||
|
import '../state/server_isolate.dart';
|
||||||
|
import 'invite.dart';
|
||||||
|
|
||||||
|
/// 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
|
||||||
|
/// password).
|
||||||
|
String newHostKey() {
|
||||||
|
final r = Random.secure();
|
||||||
|
return base64Url.encode([for (var i = 0; i < 24; i++) r.nextInt(256)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Starts the server isolate (tests may pass their own).
|
||||||
|
typedef ServerStarter = Future<ServerIsolate> Function(
|
||||||
|
AppConfig config, {
|
||||||
|
bool loopbackOnly,
|
||||||
|
String? logDir,
|
||||||
|
required void Function(String line) onLog,
|
||||||
|
required void Function(String error) onCrash,
|
||||||
|
});
|
||||||
|
|
||||||
|
Future<ServerIsolate> _startIsolate(
|
||||||
|
AppConfig config, {
|
||||||
|
bool loopbackOnly = false,
|
||||||
|
String? logDir,
|
||||||
|
required void Function(String line) onLog,
|
||||||
|
required void Function(String error) onCrash,
|
||||||
|
}) =>
|
||||||
|
ServerIsolate.start(config, loopbackOnly: loopbackOnly, logDir: logDir, onLog: onLog, onCrash: onCrash);
|
||||||
|
|
||||||
|
/// "Your server": the one server this app runs for its whole life (a
|
||||||
|
/// [ServerIsolate] running `serve`). It starts on 127.0.0.1 and a free
|
||||||
|
/// port, so only this app reaches it; [invite] attaches a second
|
||||||
|
/// listener on every interface at the configured port (Settings → Party
|
||||||
|
/// & network: port and password) and [stopSharing] detaches it. Members,
|
||||||
|
/// what plays and Up next never restart or reconnect for either. Made
|
||||||
|
/// by the composition root.
|
||||||
|
class YourServer {
|
||||||
|
YourServer({
|
||||||
|
required this.forms,
|
||||||
|
this.jellyfin,
|
||||||
|
Future<String> Function()? ytDlpDir,
|
||||||
|
String? Function()? logDir,
|
||||||
|
ServerStarter? starter,
|
||||||
|
Future<List<({String name, InternetAddress address})>> Function()? interfaces,
|
||||||
|
}) : _ytDlpDir = ytDlpDir ?? (() async => ''),
|
||||||
|
_logDir = logDir ?? (() => null),
|
||||||
|
_starter = starter ?? _startIsolate,
|
||||||
|
_interfaces = interfaces ?? machineInterfaces;
|
||||||
|
|
||||||
|
final ConnectStore forms;
|
||||||
|
/// Linked Jellyfin servers (default: the app's): the relay logins.
|
||||||
|
final JellyfinAccounts? jellyfin;
|
||||||
|
final Future<String> Function() _ytDlpDir;
|
||||||
|
final String? Function() _logDir;
|
||||||
|
final ServerStarter _starter;
|
||||||
|
final Future<List<({String name, InternetAddress address})>> Function() _interfaces;
|
||||||
|
|
||||||
|
/// Sent by this app's own client (see [newHostKey]).
|
||||||
|
final String hostKey = newHostKey();
|
||||||
|
|
||||||
|
/// The loopback port while it runs; null before [start] and after
|
||||||
|
/// [stop] or a crash.
|
||||||
|
final port = signal<int?>(null);
|
||||||
|
|
||||||
|
/// The invite while shared (the listener attached); null when only this
|
||||||
|
/// app can reach it.
|
||||||
|
final shared = signal<Invite?>(null);
|
||||||
|
|
||||||
|
/// An [invite] or [stopSharing] is under way.
|
||||||
|
final busy = signal(false);
|
||||||
|
|
||||||
|
/// Set when the server died without being stopped; the shell says so
|
||||||
|
/// and clears it. The next [start] makes a new one.
|
||||||
|
final crash = signal<String?>(null);
|
||||||
|
|
||||||
|
/// Called after a crash (the navigation reconnects home).
|
||||||
|
void Function()? onCrash;
|
||||||
|
|
||||||
|
ServerIsolate? _server;
|
||||||
|
Future<int>? _starting;
|
||||||
|
|
||||||
|
@visibleForTesting
|
||||||
|
ServerIsolate? get server => _server;
|
||||||
|
|
||||||
|
JellyfinAccounts get _accounts => jellyfin ?? JellyfinAccounts.instance;
|
||||||
|
|
||||||
|
/// Starts it once (later calls get the same port); a new one after a
|
||||||
|
/// crash. [name]: what `/health` calls it ("Bob's server").
|
||||||
|
Future<int> start({String name = ''}) {
|
||||||
|
final running = _server;
|
||||||
|
if (running != null && running.isRunning) return Future.value(running.port);
|
||||||
|
return _starting ??= _start(name).whenComplete(() => _starting = null);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<int> _start(String name) async {
|
||||||
|
final who = name.trim();
|
||||||
|
late final ServerIsolate server;
|
||||||
|
server = await _starter(
|
||||||
|
AppConfig(
|
||||||
|
port: 0,
|
||||||
|
secret: '',
|
||||||
|
name: who.isEmpty ? 'server' : "$who's server",
|
||||||
|
hostKey: hostKey,
|
||||||
|
ytDlpDir: await _ytDlpDir(),
|
||||||
|
),
|
||||||
|
loopbackOnly: true,
|
||||||
|
// Its session log (every member's shared diagnostics and its own
|
||||||
|
// records) goes next to the app's.
|
||||||
|
logDir: _logDir(),
|
||||||
|
// Its records join the app's log as `server.<logger>`.
|
||||||
|
onLog: (line) => relogLine('server', line),
|
||||||
|
onCrash: (cause) => _onCrash(server, cause),
|
||||||
|
);
|
||||||
|
_server = server;
|
||||||
|
port.value = server.port;
|
||||||
|
return server.port;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onCrash(ServerIsolate server, String cause) {
|
||||||
|
if (!identical(_server, server)) return;
|
||||||
|
_server = null;
|
||||||
|
port.value = null;
|
||||||
|
shared.value = null;
|
||||||
|
final message = 'server crashed: $cause';
|
||||||
|
debugPrint('[server] $message');
|
||||||
|
crash.value = message;
|
||||||
|
onCrash?.call();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Opens it to friends (attaching the network listener if it is not
|
||||||
|
/// yet) and returns the invite to hand out. Throws when the port can't
|
||||||
|
/// be opened (in use) or the server is not running.
|
||||||
|
Future<Invite> invite() async {
|
||||||
|
final server = _server;
|
||||||
|
if (server == null || !server.isRunning) throw StateError('your server is not running');
|
||||||
|
final current = shared.peek();
|
||||||
|
if (current != null) return current;
|
||||||
|
busy.value = true;
|
||||||
|
try {
|
||||||
|
final form = forms.form;
|
||||||
|
final want = int.tryParse(form.hostPort.trim()) ?? 8080;
|
||||||
|
await _accounts.ensureLoaded(validate: false);
|
||||||
|
final bound = await server.share(
|
||||||
|
port: want,
|
||||||
|
secret: form.hostSecret,
|
||||||
|
// Guests without an account watch Jellyfin items through the
|
||||||
|
// server, with this app's own sign-in on the item's server.
|
||||||
|
jellyfin: relayCredentialsFor(_accounts),
|
||||||
|
);
|
||||||
|
final invite = await inviteFor(bound, password: form.hostSecret.isNotEmpty);
|
||||||
|
shared.value = invite;
|
||||||
|
return invite;
|
||||||
|
} finally {
|
||||||
|
busy.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The invite for [port] from this machine's best address (see
|
||||||
|
/// [inviteAddresses]); 127.0.0.1 when there is none.
|
||||||
|
Future<Invite> inviteFor(int port, {required bool password}) async {
|
||||||
|
final addresses = inviteAddresses(await _interfaces());
|
||||||
|
return Invite(
|
||||||
|
host: addresses.isEmpty ? '127.0.0.1' : addresses.first,
|
||||||
|
port: port,
|
||||||
|
password: password,
|
||||||
|
version: appVersion,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Closes it to friends again: everyone but you leaves (told, so their
|
||||||
|
/// apps do not retry); you, what plays and Up next stay.
|
||||||
|
Future<void> stopSharing() async {
|
||||||
|
final server = _server;
|
||||||
|
if (server == null || shared.peek() == null) return;
|
||||||
|
busy.value = true;
|
||||||
|
try {
|
||||||
|
await server.unshare();
|
||||||
|
shared.value = null;
|
||||||
|
} finally {
|
||||||
|
busy.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Makes [path] the hosted file (a File put up here; `/m` serves it).
|
||||||
|
Future<({String name, int size})?> setFile(String path) async => _server?.setFile(path);
|
||||||
|
|
||||||
|
/// The app closes: the server stops (sockets closed, port freed).
|
||||||
|
Future<void> stop() async {
|
||||||
|
final server = _server;
|
||||||
|
_server = null;
|
||||||
|
port.value = null;
|
||||||
|
shared.value = null;
|
||||||
|
await server?.stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,8 +37,11 @@ class SynthSfx implements SfxService {
|
|||||||
|
|
||||||
/// Plays [kind] even when it is switched off (Settings' preview), always
|
/// Plays [kind] even when it is switched off (Settings' preview), always
|
||||||
/// freshly rendered: an edited spec plays after a hot reload.
|
/// freshly rendered: an edited spec plays after a hot reload.
|
||||||
Future<void> preview(SfxKind kind, {int readyIndex = 1, SoundPreset? preset}) =>
|
Future<void> preview(SfxKind kind, {int readyIndex = 1, SoundPreset? preset}) {
|
||||||
_play(kind, readyIndex, preset ?? presetFor(kind), fresh: true);
|
// Asked for: try the device again even after a crash kept it closed.
|
||||||
|
engine.retry();
|
||||||
|
return _play(kind, readyIndex, preset ?? presetFor(kind), fresh: true);
|
||||||
|
}
|
||||||
|
|
||||||
/// One slot per sound and ready step: a new preset replaces it.
|
/// One slot per sound and ready step: a new preset replaces it.
|
||||||
Future<void> _play(SfxKind kind, int readyIndex, SoundPreset preset, {bool fresh = false}) {
|
Future<void> _play(SfxKind kind, int readyIndex, SoundPreset preset, {bool fresh = false}) {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import 'package:flutter/services.dart';
|
|||||||
import 'package:signals/signals_flutter.dart';
|
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 '../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';
|
||||||
@@ -13,18 +14,18 @@ import '../jellyfin/browse_scope.dart' show BrowseStart;
|
|||||||
import '../jellyfin/session_media.dart';
|
import '../jellyfin/session_media.dart';
|
||||||
import '../screens/home/connect_flow.dart';
|
import '../screens/home/connect_flow.dart';
|
||||||
import '../session/app_events.dart' show ActionFailed, ScheduleResult;
|
import '../session/app_events.dart' show ActionFailed, ScheduleResult;
|
||||||
import '../session/session_coordinator.dart' show ConnectParams;
|
|
||||||
import '../screens/home/health_probe.dart';
|
import '../screens/home/health_probe.dart';
|
||||||
import '../screens/home/media_picker.dart';
|
import '../screens/home/media_picker.dart';
|
||||||
import '../session/identity.dart';
|
import '../session/identity.dart';
|
||||||
import '../screens/home/update_indicator.dart';
|
import '../screens/home/update_indicator.dart';
|
||||||
import '../state/session_state.dart';
|
import '../app/app_graph.dart';
|
||||||
|
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 'chat_panel.dart';
|
import 'chat_panel.dart';
|
||||||
import 'connect_cards.dart';
|
import 'connect_cards.dart';
|
||||||
import 'handoff.dart';
|
import 'handoff.dart';
|
||||||
import 'library_link.dart' show LinkProber, defaultLinkProber;
|
import 'library_link.dart' show LinkProber;
|
||||||
import 'library_tab.dart';
|
import 'library_tab.dart';
|
||||||
import 'motion.dart';
|
import 'motion.dart';
|
||||||
import 'party_strip.dart';
|
import 'party_strip.dart';
|
||||||
@@ -37,7 +38,8 @@ import 'welcome.dart';
|
|||||||
|
|
||||||
/// The one window: the party strip over a body that fades through between
|
/// The one window: the party strip over a body that fades through between
|
||||||
/// the tabs and the full player, the floating mini player, the chat panel
|
/// the tabs and the full player, the floating mini player, the chat panel
|
||||||
/// and the toasts. Online/offline follows [SessionState.route].
|
/// and the toasts. Where the app is (your server, a friend's) follows
|
||||||
|
/// [Navigation.place].
|
||||||
///
|
///
|
||||||
/// The player ([SessionPlayer], the only `Video`) lives in one layer of
|
/// The player ([SessionPlayer], the only `Video`) lives in one layer of
|
||||||
/// the body: full it fills the body (fullscreen: the window, strip
|
/// the body: full it fills the body (fullscreen: the window, strip
|
||||||
@@ -60,7 +62,7 @@ class AppShell extends StatefulWidget {
|
|||||||
final VideoViewBuilder? videoBuilder;
|
final VideoViewBuilder? videoBuilder;
|
||||||
/// What the Library's Link page asks about a link (tests: a fake, so no
|
/// What the Library's Link page asks about a link (tests: a fake, so no
|
||||||
/// network or yt-dlp runs).
|
/// network or yt-dlp runs).
|
||||||
final LinkProber linkProbe;
|
final LinkProber? linkProbe;
|
||||||
const AppShell({
|
const AppShell({
|
||||||
super.key,
|
super.key,
|
||||||
this.updates,
|
this.updates,
|
||||||
@@ -69,7 +71,7 @@ class AppShell extends StatefulWidget {
|
|||||||
this.healthGet = defaultHealthGet,
|
this.healthGet = defaultHealthGet,
|
||||||
this.jellyfin,
|
this.jellyfin,
|
||||||
this.videoBuilder,
|
this.videoBuilder,
|
||||||
this.linkProbe = defaultLinkProber,
|
this.linkProbe,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -98,6 +100,9 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
|||||||
final _chat = TextEditingController();
|
final _chat = TextEditingController();
|
||||||
final _chatFocus = FocusNode();
|
final _chatFocus = FocusNode();
|
||||||
final _playerKey = GlobalKey();
|
final _playerKey = GlobalKey();
|
||||||
|
/// Whether the full player's controls are up (the player's frame
|
||||||
|
/// reports it): the toasts sit above them.
|
||||||
|
final _controlsShown = ValueNotifier(true);
|
||||||
|
|
||||||
Alignment _corner = Alignment.bottomRight;
|
Alignment _corner = Alignment.bottomRight;
|
||||||
Offset? _drag; // mini top-left while dragging
|
Offset? _drag; // mini top-left while dragging
|
||||||
@@ -108,9 +113,9 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
|||||||
bool _dragging = false;
|
bool _dragging = false;
|
||||||
bool _handoffOpen = false;
|
bool _handoffOpen = false;
|
||||||
|
|
||||||
final _flow = ConnectFlow();
|
late final AppGraph _graph = AppScope.of(context);
|
||||||
late final _join = JoinFields(SessionState.instance.lastForm);
|
late final _flow = ConnectFlow(navigation: _graph.navigation, forms: _graph.forms);
|
||||||
late final _host = HostFields(SessionState.instance.lastForm);
|
late final _join = JoinFields(_graph.forms.form);
|
||||||
late final JellyfinAccounts _jellyfin = widget.jellyfin ?? JellyfinAccounts.instance;
|
late final JellyfinAccounts _jellyfin = widget.jellyfin ?? JellyfinAccounts.instance;
|
||||||
late MediaPickerController _picker = _newPicker();
|
late MediaPickerController _picker = _newPicker();
|
||||||
BrowseStart? _libraryStart;
|
BrowseStart? _libraryStart;
|
||||||
@@ -122,22 +127,17 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
|||||||
bool _dialogOpen = false;
|
bool _dialogOpen = false;
|
||||||
final _stops = <void Function()>[];
|
final _stops = <void Function()>[];
|
||||||
// Rebuild the shell on these flips, not on every state broadcast.
|
// Rebuild the shell on these flips, not on every state broadcast.
|
||||||
final _online = computed(() => SessionState.instance.route.value == AppRoute.session);
|
late final _online = computed(() => _graph.navigation.online.value);
|
||||||
/// Nobody else watches: offline, solo, or solo becoming a server.
|
/// Nobody else is in the server (D9): a plain player.
|
||||||
final _alone = computed(() {
|
late final _alone = computed(() => !_graph.navigation.online.value || _graph.coordinator.alone.value);
|
||||||
final s = SessionState.instance;
|
late final _hasMedia =
|
||||||
return s.route.value != AppRoute.session || s.solo.value || s.switching.value;
|
computed(() => _graph.navigation.online.value && _graph.coordinator.serverState.value?.media != null);
|
||||||
});
|
|
||||||
final _hasMedia = computed(() =>
|
|
||||||
SessionState.instance.route.value == AppRoute.session &&
|
|
||||||
SessionState.instance.coordinator.serverState.value?.media != null);
|
|
||||||
|
|
||||||
/// A video is on in the session (not finished): the update bar stays
|
/// A video is on in the session (not finished): the update bar stays
|
||||||
/// off the full player.
|
/// off the full player.
|
||||||
final _watching = computed(() {
|
late final _watching = computed(() {
|
||||||
final s = SessionState.instance;
|
final r = _graph.coordinator.serverState.value;
|
||||||
final r = s.coordinator.serverState.value;
|
return _graph.navigation.online.value && r?.media != null && r?.transport != 'finished';
|
||||||
return s.route.value == AppRoute.session && r?.media != null && r?.transport != 'finished';
|
|
||||||
});
|
});
|
||||||
|
|
||||||
PlayerMode get mode => _mode;
|
PlayerMode get mode => _mode;
|
||||||
@@ -145,7 +145,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
|||||||
bool get fullscreen => _fullscreen;
|
bool get fullscreen => _fullscreen;
|
||||||
|
|
||||||
MediaPickerController _newPicker({JellyfinMedia? jellyfin, MediaKind? source}) {
|
MediaPickerController _newPicker({JellyfinMedia? jellyfin, MediaKind? source}) {
|
||||||
final f = SessionState.instance.lastForm;
|
final f = _graph.forms.form;
|
||||||
final c = MediaPickerController(mediaPath: f.mediaPath, linkUrl: f.linkUrl, jellyfin: jellyfin, source: source);
|
final c = MediaPickerController(mediaPath: f.mediaPath, linkUrl: f.linkUrl, jellyfin: jellyfin, source: source);
|
||||||
if (jellyfin == null) unawaited(restoreLastPick(c, _jellyfin, () => mounted && identical(c, _picker)));
|
if (jellyfin == null) unawaited(restoreLastPick(c, _jellyfin, () => mounted && identical(c, _picker)));
|
||||||
return c;
|
return c;
|
||||||
@@ -159,14 +159,16 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
|||||||
_jellyfin.ensureLoaded();
|
_jellyfin.ensureLoaded();
|
||||||
if (widget.autoUpdateCheck) _autoCheck();
|
if (widget.autoUpdateCheck) _autoCheck();
|
||||||
HardwareKeyboard.instance.addHandler(_onKey);
|
HardwareKeyboard.instance.addHandler(_onKey);
|
||||||
final session = SessionState.instance;
|
final nav = _graph.navigation;
|
||||||
final coord = session.coordinator;
|
final coord = _graph.coordinator;
|
||||||
_feedSeen = coord.feedLog.peek().length;
|
_feedSeen = coord.feedLog.peek().length;
|
||||||
_stops.add(effect(() {
|
_stops.add(effect(() {
|
||||||
final online = session.route.value == AppRoute.session;
|
final place = nav.place.value;
|
||||||
|
final online = place != Place.none;
|
||||||
// Set now, not later: the media effect below may run first in this
|
// Set now, not later: the media effect below may run first in this
|
||||||
// same flush, and its scheduled show reads these.
|
// same flush, and its scheduled show reads these. A friend's video
|
||||||
_autoFull = online;
|
// opens full; back home, yours waits in the mini player.
|
||||||
|
_autoFull = place == Place.friend;
|
||||||
if (!online) _lastMediaId = -1;
|
if (!online) _lastMediaId = -1;
|
||||||
untracked(() => _later(() => online ? _wentOnline() : _wentOffline()));
|
untracked(() => _later(() => online ? _wentOnline() : _wentOffline()));
|
||||||
}));
|
}));
|
||||||
@@ -174,7 +176,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
|||||||
// after joining or a put-up of ours, else as the mini player).
|
// after joining or a put-up of ours, else as the mini player).
|
||||||
_stops.add(effect(() {
|
_stops.add(effect(() {
|
||||||
final r = coord.serverState.value;
|
final r = coord.serverState.value;
|
||||||
final online = session.route.value == AppRoute.session;
|
final online = nav.online.value;
|
||||||
if (!online || r == null || r.media == null) return;
|
if (!online || r == null || r.media == null) return;
|
||||||
if (r.mediaId == _lastMediaId) return;
|
if (r.mediaId == _lastMediaId) return;
|
||||||
_lastMediaId = r.mediaId;
|
_lastMediaId = r.mediaId;
|
||||||
@@ -187,6 +189,17 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
|||||||
_autoFull = false;
|
_autoFull = false;
|
||||||
});
|
});
|
||||||
}));
|
}));
|
||||||
|
// A click on a chat system notification: the window is up, open the
|
||||||
|
// chat.
|
||||||
|
var chatAsks = coord.notifier.chatRequests.peek();
|
||||||
|
_stops.add(effect(() {
|
||||||
|
final asks = coord.notifier.chatRequests.value;
|
||||||
|
if (asks == chatAsks) return;
|
||||||
|
chatAsks = asks;
|
||||||
|
_later(() {
|
||||||
|
if (!_chatOpen && _online.peek()) _toggleChat();
|
||||||
|
});
|
||||||
|
}));
|
||||||
// Chat that arrives while the panel is closed.
|
// Chat that arrives while the panel is closed.
|
||||||
_stops.add(effect(() {
|
_stops.add(effect(() {
|
||||||
final log = coord.feedLog.value;
|
final log = coord.feedLog.value;
|
||||||
@@ -203,31 +216,21 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
|||||||
var wasOut = false;
|
var wasOut = false;
|
||||||
_stops.add(effect(() {
|
_stops.add(effect(() {
|
||||||
final out = coord.selfAway.value;
|
final out = coord.selfAway.value;
|
||||||
if (wasOut && !out && session.route.peek() == AppRoute.session) {
|
if (wasOut && !out && nav.online.peek()) {
|
||||||
_later(() {
|
_later(() {
|
||||||
if (_mode == PlayerMode.none) _show(PlayerMode.mini);
|
if (_mode == PlayerMode.none) _show(PlayerMode.mini);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
wasOut = out;
|
wasOut = out;
|
||||||
}));
|
}));
|
||||||
// Solo becoming a hosted server: the player closes while the media
|
// Your server died (the navigation starts a new one): say so.
|
||||||
// moves over and comes back the way it was once it is up again.
|
final yours = _graph.yourServer;
|
||||||
_stops.add(effect(() {
|
_stops.add(effect(() {
|
||||||
if (!session.switching.value) return;
|
final crash = yours.crash.value;
|
||||||
_later(() {
|
|
||||||
_lastMediaId = -1;
|
|
||||||
_autoFull = _mode == PlayerMode.full;
|
|
||||||
if (_fullscreen) _toggleFullscreen();
|
|
||||||
_show(PlayerMode.none);
|
|
||||||
});
|
|
||||||
}));
|
|
||||||
// The hosted server died mid-session (SessionState leaves): say so.
|
|
||||||
_stops.add(effect(() {
|
|
||||||
final crash = session.serverCrash.value;
|
|
||||||
if (crash == null) return;
|
if (crash == null) return;
|
||||||
_later(() {
|
_later(() {
|
||||||
session.serverCrash.value = null;
|
yours.crash.value = null;
|
||||||
_alert('Server crashed', crash);
|
_alert('Your server crashed', '$crash\n\nA new one starts; anyone who was in it joins again.');
|
||||||
});
|
});
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
@@ -246,10 +249,10 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
|||||||
_watching.dispose();
|
_watching.dispose();
|
||||||
_flow.dispose();
|
_flow.dispose();
|
||||||
_join.dispose();
|
_join.dispose();
|
||||||
_host.dispose();
|
|
||||||
_picker.dispose();
|
_picker.dispose();
|
||||||
_chat.dispose();
|
_chat.dispose();
|
||||||
_chatFocus.dispose();
|
_chatFocus.dispose();
|
||||||
|
_controlsShown.dispose();
|
||||||
if (_fullscreen) _setWindowFullscreen(false);
|
if (_fullscreen) _setWindowFullscreen(false);
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
@@ -275,8 +278,8 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
|||||||
|
|
||||||
void _wentOnline() {
|
void _wentOnline() {
|
||||||
_unread = 0;
|
_unread = 0;
|
||||||
_feedSeen = SessionState.instance.coordinator.feedLog.peek().length;
|
_feedSeen = _graph.coordinator.feedLog.peek().length;
|
||||||
// Hosting starts with nothing up: the library is where to pick it.
|
// A new server: the library is where to pick something.
|
||||||
setState(() => _tab = ShellTab.library);
|
setState(() => _tab = ShellTab.library);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -322,7 +325,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
|||||||
/// The player's ×: sit out (stay in the server, nothing loaded; the
|
/// The player's ×: sit out (stay in the server, nothing loaded; the
|
||||||
/// player shows the join-back card). Once out, × closes the player.
|
/// player shows the join-back card). Once out, × closes the player.
|
||||||
void _closePlayer() {
|
void _closePlayer() {
|
||||||
final coord = SessionState.instance.coordinator;
|
final coord = _graph.coordinator;
|
||||||
if (coord.selfAway.peek()) {
|
if (coord.selfAway.peek()) {
|
||||||
_show(PlayerMode.none);
|
_show(PlayerMode.none);
|
||||||
} else {
|
} else {
|
||||||
@@ -333,7 +336,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
|||||||
Future<void> _setWindowFullscreen(bool on) async {
|
Future<void> _setWindowFullscreen(bool on) async {
|
||||||
if (!widget.windowControls) return;
|
if (!widget.windowControls) return;
|
||||||
try {
|
try {
|
||||||
await SessionState.instance.ports.shell.setFullScreen(on);
|
await _graph.ports.shell.setFullScreen(on);
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
// No window manager.
|
// No window manager.
|
||||||
}
|
}
|
||||||
@@ -368,7 +371,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
|||||||
/// "Change video": the library, opened on the series of the episode
|
/// "Change video": the library, opened on the series of the episode
|
||||||
/// playing (season selected, episode highlighted), else where it was.
|
/// playing (season selected, episode highlighted), else where it was.
|
||||||
void _changeVideo() {
|
void _changeVideo() {
|
||||||
final coord = SessionState.instance.coordinator;
|
final coord = _graph.coordinator;
|
||||||
coord.markInput();
|
coord.markInput();
|
||||||
final ref = coord.currentRef.peek();
|
final ref = coord.currentRef.peek();
|
||||||
final pick = ref == null ? null : pickForRef(_jellyfin, ref);
|
final pick = ref == null ? null : pickForRef(_jellyfin, ref);
|
||||||
@@ -385,10 +388,10 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
|||||||
_selectTab(ShellTab.library);
|
_selectTab(ShellTab.library);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Library → "Put it up for everyone"; the player opens full. Offline
|
/// Library → "Put it up for everyone" (alone: Play); the player opens
|
||||||
/// it starts a solo session first (just you, on this computer).
|
/// full. Before your server is up (starting) it waits for it.
|
||||||
Future<String?> _putUp(PickedMedia media) async {
|
Future<String?> _putUp(PickedMedia media) async {
|
||||||
final session = SessionState.instance;
|
final coord = _graph.coordinator;
|
||||||
if (media is JellyfinMedia) {
|
if (media is JellyfinMedia) {
|
||||||
try {
|
try {
|
||||||
putUpJellyfinMedia(_jellyfin, media);
|
putUpJellyfinMedia(_jellyfin, media);
|
||||||
@@ -398,47 +401,28 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
|||||||
unawaited(_jellyfin.rememberPick(media));
|
unawaited(_jellyfin.rememberPick(media));
|
||||||
}
|
}
|
||||||
if (!_online.peek()) {
|
if (!_online.peek()) {
|
||||||
final error = await _startSolo();
|
if (!await _graph.navigation.goHome()) return "couldn't start your server";
|
||||||
if (error != null || !mounted) return error;
|
if (!mounted) return null;
|
||||||
}
|
}
|
||||||
// Alone, a link plays direct: there is nobody to pass it on to.
|
// Alone, a link plays direct: there is nobody to pass it on to.
|
||||||
if (media is LinkMedia && session.solo.peek()) {
|
if (media is LinkMedia && coord.alone.peek()) {
|
||||||
media = LinkMedia(media.url, relay: LinkRelay.direct, anyway: media.anyway);
|
media = LinkMedia(media.url, relay: LinkRelay.direct, anyway: media.anyway);
|
||||||
}
|
}
|
||||||
final error = await session.coordinator.swapPicked(media);
|
final error = await coord.swapPicked(media);
|
||||||
if (error != null || !mounted) return error;
|
if (error != null || !mounted) return error;
|
||||||
final f = session.lastForm;
|
final f = _graph.forms.form;
|
||||||
f
|
f
|
||||||
..linkUrl = media is LinkMedia ? media.url : f.linkUrl
|
..linkUrl = media is LinkMedia ? media.url : f.linkUrl
|
||||||
..mediaPath = media is FileMedia ? media.path : f.mediaPath;
|
..mediaPath = media is FileMedia ? media.path : f.mediaPath;
|
||||||
unawaited(session.saveForm().catchError((Object _) {}));
|
unawaited(_graph.forms.save().catchError((Object _) {}));
|
||||||
_autoFull = true;
|
_autoFull = true;
|
||||||
_show(PlayerMode.full);
|
_show(PlayerMode.full);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Starts a solo session; returns an error text, or null.
|
/// The welcome band's "Watch on your own": somewhere to pick from
|
||||||
Future<String?> _startSolo() async {
|
/// (Jellyfin when linked, else Files).
|
||||||
final profile = Profile.instance;
|
void _watchAlone() => _openLibrary(_jellyfin.isEmpty ? MediaKind.file : MediaKind.jellyfin);
|
||||||
try {
|
|
||||||
await profile.ensureLoaded();
|
|
||||||
final name = profile.name.peek().trim();
|
|
||||||
await SessionState.instance.startSolo(ConnectParams(
|
|
||||||
host: '127.0.0.1',
|
|
||||||
port: 0,
|
|
||||||
secret: '',
|
|
||||||
name: name.isEmpty ? 'Me' : name,
|
|
||||||
avatar: profile.avatarBase64(),
|
|
||||||
));
|
|
||||||
return null;
|
|
||||||
} catch (e) {
|
|
||||||
return "couldn't start playing: $e";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Library's "Watch solo": somewhere to pick from (Jellyfin when linked,
|
|
||||||
/// else Files).
|
|
||||||
void _watchSolo() => _openLibrary(_jellyfin.isEmpty ? MediaKind.file : MediaKind.jellyfin);
|
|
||||||
|
|
||||||
// --------------------------------------------------------------- handoff
|
// --------------------------------------------------------------- handoff
|
||||||
|
|
||||||
@@ -448,7 +432,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
|||||||
/// Ctrl+V outside a text field (a focused field pastes on its own).
|
/// Ctrl+V outside a text field (a focused field pastes on its own).
|
||||||
bool _onKey(KeyEvent e) {
|
bool _onKey(KeyEvent e) {
|
||||||
// Any key is activity (auto sit-out).
|
// Any key is activity (auto sit-out).
|
||||||
SessionState.instance.coordinator.markInput();
|
_graph.coordinator.markInput();
|
||||||
if (e is! KeyDownEvent || e.logicalKey != LogicalKeyboardKey.keyV) return false;
|
if (e is! KeyDownEvent || e.logicalKey != LogicalKeyboardKey.keyV) return false;
|
||||||
final keys = HardwareKeyboard.instance;
|
final keys = HardwareKeyboard.instance;
|
||||||
if (!(keys.isControlPressed || keys.isMetaPressed) || keys.isShiftPressed || keys.isAltPressed) return false;
|
if (!(keys.isControlPressed || keys.isMetaPressed) || keys.isShiftPressed || keys.isAltPressed) return false;
|
||||||
@@ -463,7 +447,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
|||||||
/// Clipboard files first, then text (a link or absolute paths). A pasted
|
/// Clipboard files first, then text (a link or absolute paths). A pasted
|
||||||
/// image has no taker yet, so the clipboard's image data is not read.
|
/// image has no taker yet, so the clipboard's image data is not read.
|
||||||
Future<void> _paste() async {
|
Future<void> _paste() async {
|
||||||
final clip = await SessionState.instance.ports.shell.readPaste();
|
final clip = await _graph.ports.shell.readPaste();
|
||||||
await _handOff(classifyHandoff(paths: clip.files, text: clip.text), _pointer);
|
await _handOff(classifyHandoff(paths: clip.files, text: clip.text), _pointer);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -477,7 +461,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
|||||||
final HandoffAction? action;
|
final HandoffAction? action;
|
||||||
try {
|
try {
|
||||||
action = await showPopover<HandoffAction>(context,
|
action = await showPopover<HandoffAction>(context,
|
||||||
at: align, child: HandoffCard(item: item, solo: _alone.peek(), queue: _online.peek()));
|
at: align, child: HandoffCard(item: item, alone: _alone.peek(), queue: _online.peek()));
|
||||||
} finally {
|
} finally {
|
||||||
_handoffOpen = false;
|
_handoffOpen = false;
|
||||||
}
|
}
|
||||||
@@ -486,7 +470,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
|||||||
case (HandoffVideos(:final paths), HandoffAction.putUp):
|
case (HandoffVideos(:final paths), HandoffAction.putUp):
|
||||||
final error = await _putUp(FileMedia(paths.first));
|
final error = await _putUp(FileMedia(paths.first));
|
||||||
if (error != null) {
|
if (error != null) {
|
||||||
SessionState.instance.coordinator.notify(ActionFailed(error));
|
_graph.coordinator.notify(ActionFailed(error));
|
||||||
} else {
|
} else {
|
||||||
await FolderStore.instance.ensureLoaded();
|
await FolderStore.instance.ensureLoaded();
|
||||||
FolderStore.instance.addRecent(paths.first);
|
FolderStore.instance.addRecent(paths.first);
|
||||||
@@ -495,15 +479,15 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
|||||||
// In order: with Play next, the last one added goes first.
|
// In order: with Play next, the last one added goes first.
|
||||||
final next = action == HandoffAction.playNext;
|
final next = action == HandoffAction.playNext;
|
||||||
for (final p in next ? paths.reversed : paths) {
|
for (final p in next ? paths.reversed : paths) {
|
||||||
final error = await SessionState.instance.coordinator.queue.add(FileMedia(p), next: next);
|
final error = await _graph.coordinator.queue.add(FileMedia(p), next: next);
|
||||||
if (error != null) {
|
if (error != null) {
|
||||||
SessionState.instance.coordinator.notify(ActionFailed(error));
|
_graph.coordinator.notify(ActionFailed(error));
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case (HandoffLink(:final url), HandoffAction.queue || HandoffAction.playNext):
|
case (HandoffLink(:final url), HandoffAction.queue || HandoffAction.playNext):
|
||||||
// In the Link page's mode, probed and refused like there.
|
// In the Link page's mode, probed and refused like there.
|
||||||
final coord = SessionState.instance.coordinator;
|
final coord = _graph.coordinator;
|
||||||
final error = await coord.queue
|
final error = await coord.queue
|
||||||
.add(LinkMedia(url, relay: _picker.linkRelay), next: action == HandoffAction.playNext);
|
.add(LinkMedia(url, relay: _picker.linkRelay), next: action == HandoffAction.playNext);
|
||||||
if (error != null) coord.notify(ActionFailed(error));
|
if (error != null) coord.notify(ActionFailed(error));
|
||||||
@@ -537,7 +521,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Off on Settings: its own drop targets (cookies) take the files there.
|
// Off on Settings: its own drop targets (cookies) take the files there.
|
||||||
Widget _dropTarget(Widget child) => SessionState.instance.ports.shell.dropTarget(
|
Widget _dropTarget(Widget child) => _graph.ports.shell.dropTarget(
|
||||||
enable: _tab != ShellTab.settings,
|
enable: _tab != ShellTab.settings,
|
||||||
onEnter: () => setState(() => _dragging = _onTop),
|
onEnter: () => setState(() => _dragging = _onTop),
|
||||||
onExit: () => setState(() => _dragging = false),
|
onExit: () => setState(() => _dragging = false),
|
||||||
@@ -565,18 +549,19 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
|||||||
|
|
||||||
// --------------------------------------------------------------- dialogs
|
// --------------------------------------------------------------- dialogs
|
||||||
|
|
||||||
/// Leave asks first; a solo session just stops.
|
/// A friend's server: Leave asks first, then you are back on yours.
|
||||||
|
/// Yours has nothing to leave: the player closes ([_closePlayer]).
|
||||||
Future<void> _confirmLeave() async {
|
Future<void> _confirmLeave() async {
|
||||||
final session = SessionState.instance;
|
final nav = _graph.navigation;
|
||||||
if (session.solo.peek()) {
|
if (nav.place.peek() != Place.friend) {
|
||||||
await session.leave();
|
_closePlayer();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final ok = await showDialog<bool>(
|
final ok = await showDialog<bool>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) => AlertDialog(
|
builder: (context) => AlertDialog(
|
||||||
title: const Text('Leave server?'),
|
title: const Text('Leave server?'),
|
||||||
content: const Text('You can rejoin right away.'),
|
content: const Text('You go back to your own server. You can rejoin right away.'),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Cancel')),
|
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Cancel')),
|
||||||
TextButton(
|
TextButton(
|
||||||
@@ -587,7 +572,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
if (ok == true) await SessionState.instance.leave();
|
if (ok == true) await nav.leaveFriend();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _alert(String title, String text) async {
|
Future<void> _alert(String title, String text) async {
|
||||||
@@ -607,15 +592,16 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _connect(ConnectMode mode) => showConnectCard(
|
void _joinFriend() => showJoinCard(
|
||||||
context,
|
context,
|
||||||
mode: mode,
|
|
||||||
flow: _flow,
|
flow: _flow,
|
||||||
join: _join,
|
join: _join,
|
||||||
host: _host,
|
|
||||||
healthGet: widget.healthGet,
|
healthGet: widget.healthGet,
|
||||||
|
updates: widget.updates,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
void _invite() => unawaited(inviteFriends(context));
|
||||||
|
|
||||||
// ----------------------------------------------------------------- build
|
// ----------------------------------------------------------------- build
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -642,10 +628,9 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
|||||||
chatOpen: _chatOpen,
|
chatOpen: _chatOpen,
|
||||||
unread: _unread,
|
unread: _unread,
|
||||||
onChat: _toggleChat,
|
onChat: _toggleChat,
|
||||||
onJoin: () => _connect(ConnectMode.join),
|
onJoin: _joinFriend,
|
||||||
onHost: () => _connect(ConnectMode.host),
|
|
||||||
onLeave: _confirmLeave,
|
onLeave: _confirmLeave,
|
||||||
onInvite: () => _connect(ConnectMode.host),
|
onInvite: _invite,
|
||||||
onProfile: () => showProfileCard(context, onOpenSettings: _openSettings, updates: widget.updates),
|
onProfile: () => showProfileCard(context, onOpenSettings: _openSettings, updates: widget.updates),
|
||||||
onShowPlayer: hasMedia && _mode == PlayerMode.none ? () => _show(PlayerMode.mini) : null,
|
onShowPlayer: hasMedia && _mode == PlayerMode.none ? () => _show(PlayerMode.mini) : null,
|
||||||
windowControls: widget.windowControls,
|
windowControls: widget.windowControls,
|
||||||
@@ -678,7 +663,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
// Any pointer use anywhere is activity (auto sit-out).
|
// Any pointer use anywhere is activity (auto sit-out).
|
||||||
final coord = SessionState.instance.coordinator;
|
final coord = _graph.coordinator;
|
||||||
return Listener(
|
return Listener(
|
||||||
onPointerHover: (e) {
|
onPointerHover: (e) {
|
||||||
_pointer = e.localPosition;
|
_pointer = e.localPosition;
|
||||||
@@ -693,41 +678,41 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// No Jellyfin account, no server joined or hosted yet, band not
|
/// No Jellyfin account, no friend's server joined yet, band not
|
||||||
/// dismissed: the welcome band explains the three ways in.
|
/// dismissed: the welcome band explains the three ways in.
|
||||||
bool get _newcomer =>
|
bool get _newcomer =>
|
||||||
!AppSettings.instance.welcomeDismissed.value &&
|
!AppSettings.instance.welcomeDismissed.value && _jellyfin.isEmpty && _graph.forms.recents.value.isEmpty;
|
||||||
_jellyfin.isEmpty &&
|
|
||||||
SessionState.instance.recents.value.isEmpty;
|
|
||||||
|
|
||||||
Widget _body(bool online, bool alone) {
|
Widget _body(bool online, bool alone) {
|
||||||
final coord = SessionState.instance.coordinator;
|
final coord = _graph.coordinator;
|
||||||
|
final friend = _graph.navigation.place.value == Place.friend;
|
||||||
final tabBody = switch (_tab) {
|
final tabBody = switch (_tab) {
|
||||||
ShellTab.library => LibraryTab(
|
ShellTab.library => LibraryTab(
|
||||||
key: ValueKey('library-$_libraryGen'),
|
key: ValueKey('library-$_libraryGen'),
|
||||||
controller: _picker,
|
controller: _picker,
|
||||||
accounts: _jellyfin,
|
accounts: _jellyfin,
|
||||||
solo: alone,
|
alone: alone,
|
||||||
// Offline, a put-up starts a solo session, which hosts.
|
// Your server serves your files itself; a friend's relays them.
|
||||||
isHost: !online || coord.hostFile != null,
|
isHost: !friend,
|
||||||
// Only for newcomers: the strip has Join and Host for everyone else.
|
// Only for newcomers alone at home: the strip has Join and Invite
|
||||||
banner: online || !_newcomer
|
// for everyone else.
|
||||||
|
banner: !alone || friend || !_newcomer
|
||||||
? null
|
? null
|
||||||
: WelcomeBand(
|
: WelcomeBand(
|
||||||
onJoin: () => _connect(ConnectMode.join),
|
onJoin: _joinFriend,
|
||||||
onHost: () => _connect(ConnectMode.host),
|
onInvite: _invite,
|
||||||
onSolo: _watchSolo,
|
onWatch: _watchAlone,
|
||||||
onDismiss: () => setState(AppSettings.instance.dismissWelcome),
|
onDismiss: () => setState(AppSettings.instance.dismissWelcome),
|
||||||
),
|
),
|
||||||
jellyfinStart: _libraryStart,
|
jellyfinStart: _libraryStart,
|
||||||
onPutUp: _putUp,
|
onPutUp: _putUp,
|
||||||
// Up next needs a server (a solo session has one).
|
// Up next needs a server (yours, once it is up).
|
||||||
onAddToQueue: online ? coord.queue.add : null,
|
onAddToQueue: online ? coord.queue.add : null,
|
||||||
onOpenIntegrations: () => _openSettings(SettingsSection.integrations),
|
onOpenIntegrations: () => _openSettings(SettingsSection.integrations),
|
||||||
probe: widget.linkProbe,
|
probe: widget.linkProbe,
|
||||||
),
|
),
|
||||||
ShellTab.schedule => SchedulePage(
|
ShellTab.schedule => SchedulePage(
|
||||||
solo: alone,
|
alone: alone,
|
||||||
onPutUp: _putUp,
|
onPutUp: _putUp,
|
||||||
onAddToQueue: online ? coord.queue.add : null,
|
onAddToQueue: online ? coord.queue.add : null,
|
||||||
onToast: (text) => coord.notify(ScheduleResult(text)),
|
onToast: (text) => coord.notify(ScheduleResult(text)),
|
||||||
@@ -771,16 +756,44 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (online && _shown != PlayerMode.none) _playerLayer(area),
|
if (online && _shown != PlayerMode.none) _playerLayer(area),
|
||||||
Positioned(left: 16, bottom: _fullscreen ? 90 : 16, child: PartyToasts(
|
_toasts(full && online),
|
||||||
onOpenChat: () {
|
|
||||||
if (!_chatOpen) _toggleChat();
|
|
||||||
},
|
|
||||||
)),
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The toasts, bottom left; over the full player above its control
|
||||||
|
/// bar (the bar's height in the skin and size in use), moving up while
|
||||||
|
/// the controls show and down while they are hidden, so they never
|
||||||
|
/// cover the controls (both skins).
|
||||||
|
Widget _toasts(bool overPlayer) {
|
||||||
|
final toasts = PartyToasts(
|
||||||
|
onOpenChat: () {
|
||||||
|
if (!_chatOpen) _toggleChat();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return ValueListenableBuilder<bool>(
|
||||||
|
valueListenable: _controlsShown,
|
||||||
|
builder: (context, shown, child) => SignalBuilder(builder: (context) {
|
||||||
|
final s = AppSettings.instance;
|
||||||
|
final bar = controlsBottomHeight(s.controlsSkin.value, s.controlsSize.value);
|
||||||
|
return AnimatedPositioned(
|
||||||
|
key: const ValueKey('toasts'),
|
||||||
|
duration: easeTime,
|
||||||
|
curve: ease,
|
||||||
|
left: toastInset,
|
||||||
|
bottom: overPlayer && shown ? bar + toastInset : toastInset,
|
||||||
|
child: child!,
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
child: toasts,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The toasts' distance from the body's left and bottom edges (and from
|
||||||
|
/// the player's control bar).
|
||||||
|
static const toastInset = 16.0;
|
||||||
|
|
||||||
Offset _cornerPos(Size area, Alignment a, Size size) => Offset(
|
Offset _cornerPos(Size area, Alignment a, Size size) => Offset(
|
||||||
a.x < 0 ? _miniMargin : (area.width - size.width - _miniMargin).clamp(0, double.infinity),
|
a.x < 0 ? _miniMargin : (area.width - size.width - _miniMargin).clamp(0, double.infinity),
|
||||||
a.y < 0 ? _miniMargin : (area.height - size.height - _miniMargin).clamp(0, double.infinity),
|
a.y < 0 ? _miniMargin : (area.height - size.height - _miniMargin).clamp(0, double.infinity),
|
||||||
@@ -852,6 +865,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
|||||||
onLeave: _confirmLeave,
|
onLeave: _confirmLeave,
|
||||||
videoBuilder: widget.videoBuilder,
|
videoBuilder: widget.videoBuilder,
|
||||||
windowControls: widget.windowControls,
|
windowControls: widget.windowControls,
|
||||||
|
controlsShown: _controlsShown,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import '../session/identity.dart';
|
|||||||
import '../session/delivery.dart';
|
import '../session/delivery.dart';
|
||||||
import '../session/party.dart';
|
import '../session/party.dart';
|
||||||
import '../session/session_coordinator.dart';
|
import '../session/session_coordinator.dart';
|
||||||
import '../state/session_state.dart';
|
import '../app/app_graph.dart';
|
||||||
import 'motion.dart';
|
import 'motion.dart';
|
||||||
|
|
||||||
/// What an avatar shows: who, their picture or initial on [color], and
|
/// What an avatar shows: who, their picture or initial on [color], and
|
||||||
@@ -30,6 +30,8 @@ class AvatarInfo {
|
|||||||
/// How it gets the media and its buffer ([MemberDelivery.detail]), for
|
/// How it gets the media and its buffer ([MemberDelivery.detail]), for
|
||||||
/// the hover; empty when unknown.
|
/// the hover; empty when unknown.
|
||||||
final String delivery;
|
final String delivery;
|
||||||
|
/// Their Starsync version (`members[].app`), for the hover; '' unknown.
|
||||||
|
final String app;
|
||||||
const AvatarInfo({
|
const AvatarInfo({
|
||||||
this.id = '',
|
this.id = '',
|
||||||
required this.name,
|
required this.name,
|
||||||
@@ -44,6 +46,7 @@ class AvatarInfo {
|
|||||||
this.self = false,
|
this.self = false,
|
||||||
this.error = '',
|
this.error = '',
|
||||||
this.delivery = '',
|
this.delivery = '',
|
||||||
|
this.app = '',
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Sitting out.
|
/// Sitting out.
|
||||||
@@ -136,12 +139,12 @@ AvatarInfo memberAvatarInfo(MemberView m, StateBroadcast? r, SessionCoordinator
|
|||||||
self: self,
|
self: self,
|
||||||
error: m.error,
|
error: m.error,
|
||||||
delivery: delivery == null ? '' : memberDeliveryOf(m, r!, delivery).detail,
|
delivery: delivery == null ? '' : memberDeliveryOf(m, r!, delivery).detail,
|
||||||
|
app: m.app,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Everyone in the server, from the latest state.
|
/// Everyone in the server, from the latest state.
|
||||||
List<AvatarInfo> partyAvatars([SessionCoordinator? coord]) {
|
List<AvatarInfo> partyAvatars(SessionCoordinator c) {
|
||||||
final c = coord ?? SessionState.instance.coordinator;
|
|
||||||
final r = c.serverState.value;
|
final r = c.serverState.value;
|
||||||
return [for (final m in r?.members ?? const <MemberView>[]) memberAvatarInfo(m, r, c)];
|
return [for (final m in r?.members ?? const <MemberView>[]) memberAvatarInfo(m, r, c)];
|
||||||
}
|
}
|
||||||
@@ -154,7 +157,7 @@ class PartyWatch extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) => ListenableBuilder(
|
Widget build(BuildContext context) => ListenableBuilder(
|
||||||
listenable: SessionState.instance.coordinator.avatars,
|
listenable: AppScope.of(context).coordinator.avatars,
|
||||||
builder: (context, _) => SignalBuilder(builder: (context) => builder(context)),
|
builder: (context, _) => SignalBuilder(builder: (context) => builder(context)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -332,6 +335,7 @@ class _AvatarStackState extends State<AvatarStack> {
|
|||||||
? visible[i].name
|
? visible[i].name
|
||||||
: "${visible[i].name} can't play this: ${visible[i].error}",
|
: "${visible[i].name} can't play this: ${visible[i].error}",
|
||||||
if (visible[i].delivery.isNotEmpty) visible[i].delivery,
|
if (visible[i].delivery.isNotEmpty) visible[i].delivery,
|
||||||
|
if (visible[i].app.isNotEmpty) 'Starsync ${visible[i].app}',
|
||||||
].join('\n'),
|
].join('\n'),
|
||||||
child: StatusAvatar(info: visible[i], size: size),
|
child: StatusAvatar(info: visible[i], size: size),
|
||||||
),
|
),
|
||||||
@@ -375,8 +379,7 @@ class Pill extends StatelessWidget {
|
|||||||
/// Someone in a feed line or toast: by member [id] when it has one, else
|
/// Someone in a feed line or toast: by member [id] when it has one, else
|
||||||
/// by display [name]; from the latest state when still there, else their
|
/// by display [name]; from the latest state when still there, else their
|
||||||
/// initial on a stable colour.
|
/// initial on a stable colour.
|
||||||
AvatarInfo avatarForName(String name, [SessionCoordinator? coord, String id = '']) {
|
AvatarInfo avatarForName(String name, SessionCoordinator c, [String id = '']) {
|
||||||
final c = coord ?? SessionState.instance.coordinator;
|
|
||||||
final r = c.serverState.value;
|
final r = c.serverState.value;
|
||||||
final members = r?.members ?? const <MemberView>[];
|
final members = r?.members ?? const <MemberView>[];
|
||||||
// By id when the line has one (names change and repeat), else by name.
|
// By id when the line has one (names change and repeat), else by name.
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:sync_relay_core/core.dart';
|
import 'package:sync_relay_core/core.dart';
|
||||||
|
|
||||||
import '../state/session_state.dart';
|
import '../app/app_graph.dart';
|
||||||
import '../widgets/app_menu.dart';
|
import '../widgets/app_menu.dart';
|
||||||
import '../widgets/copy_open.dart';
|
import '../widgets/copy_open.dart';
|
||||||
import '../widgets/text_focus.dart';
|
import '../widgets/text_focus.dart';
|
||||||
@@ -55,7 +55,7 @@ class _ChatPanelState extends State<ChatPanel> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _send() {
|
void _send() {
|
||||||
SessionState.instance.coordinator.sendChat(widget.chat.text);
|
AppScope.of(context).coordinator.sendChat(widget.chat.text);
|
||||||
widget.chat.clear();
|
widget.chat.clear();
|
||||||
widget.chatFocus.requestFocus();
|
widget.chatFocus.requestFocus();
|
||||||
}
|
}
|
||||||
@@ -105,7 +105,7 @@ class _ChatPanelState extends State<ChatPanel> {
|
|||||||
child: ValueListenableBuilder<bool>(
|
child: ValueListenableBuilder<bool>(
|
||||||
valueListenable: showActivity,
|
valueListenable: showActivity,
|
||||||
builder: (context, activity, _) => PartyWatch(builder: (context) {
|
builder: (context, activity, _) => PartyWatch(builder: (context) {
|
||||||
final all = SessionState.instance.coordinator.feedLog.value;
|
final all = AppScope.of(context).coordinator.feedLog.value;
|
||||||
final log = activity ? all : [for (final f in all) if (f.action == 'chat') f];
|
final log = activity ? all : [for (final f in all) if (f.action == 'chat') f];
|
||||||
if (log.isEmpty) {
|
if (log.isEmpty) {
|
||||||
return Center(
|
return Center(
|
||||||
@@ -169,7 +169,7 @@ class ActivityRow extends StatelessWidget {
|
|||||||
anchors: state.contextMenuAnchors,
|
anchors: state.contextMenuAnchors,
|
||||||
buttonItems: [
|
buttonItems: [
|
||||||
...state.contextMenuButtonItems,
|
...state.contextMenuButtonItems,
|
||||||
for (final (label, action) in _messageActions(item.reason, link))
|
for (final (label, action) in _messageActions(context, item.reason, link))
|
||||||
ContextMenuButtonItem(
|
ContextMenuButtonItem(
|
||||||
label: label,
|
label: label,
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
@@ -188,7 +188,7 @@ class ActivityRow extends StatelessWidget {
|
|||||||
child: Row(
|
child: Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
StatusAvatar(info: avatarForName(item.actor, null, item.actorId), size: 34, badges: false),
|
StatusAvatar(info: avatarForName(item.actor, AppScope.of(context).coordinator, item.actorId), size: 34, badges: false),
|
||||||
const SizedBox(width: 10),
|
const SizedBox(width: 10),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -238,17 +238,17 @@ String? firstLinkIn(String text) => RegExp(r'https?://[^\s<>"]+', caseSensitive:
|
|||||||
|
|
||||||
/// What a chat message offers: Copy message, and its link's Copy link and
|
/// What a chat message offers: Copy message, and its link's Copy link and
|
||||||
/// Open in browser.
|
/// Open in browser.
|
||||||
List<(String, VoidCallback)> _messageActions(String text, String? link) => [
|
List<(String, VoidCallback)> _messageActions(BuildContext context, String text, String? link) => [
|
||||||
(ReuseText.copyMessage, () => copyToClipboard(text, done: 'Message copied')),
|
(ReuseText.copyMessage, () => copyToClipboard(context, text, done: 'Message copied')),
|
||||||
if (link != null) ...[
|
if (link != null) ...[
|
||||||
(ReuseText.copyLink, () => copyToClipboard(link, done: 'Link copied')),
|
(ReuseText.copyLink, () => copyToClipboard(context, link, done: 'Link copied')),
|
||||||
(ReuseText.openInBrowser, () => openLink(link)),
|
(ReuseText.openInBrowser, () => openLink(context, link)),
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Right-click beside a chat message: [_messageActions] in the app's menu.
|
/// Right-click beside a chat message: [_messageActions] in the app's menu.
|
||||||
Future<void> _messageMenu(BuildContext context, String text, String? link) async {
|
Future<void> _messageMenu(BuildContext context, String text, String? link) async {
|
||||||
final actions = _messageActions(text, link);
|
final actions = _messageActions(context, text, link);
|
||||||
final pick = await showAppMenu<int>(context, items: [
|
final pick = await showAppMenu<int>(context, items: [
|
||||||
for (final (i, (label, _)) in actions.indexed) AppMenuItem(value: i, label: label),
|
for (final (i, (label, _)) in actions.indexed) AppMenuItem(value: i, label: label),
|
||||||
]);
|
]);
|
||||||
@@ -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'),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -2,12 +2,15 @@ import 'dart:async';
|
|||||||
|
|
||||||
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 RelayConstants;
|
|
||||||
|
|
||||||
|
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 '../state/session_state.dart';
|
import '../session/invite.dart' show addressIn;
|
||||||
|
import '../session/navigation.dart' show Place;
|
||||||
|
import '../state/connect_prefs.dart';
|
||||||
|
import '../update/update_controller.dart';
|
||||||
import 'motion.dart';
|
import 'motion.dart';
|
||||||
|
|
||||||
/// Join card text, owned by the shell so it survives the card closing.
|
/// Join card text, owned by the shell so it survives the card closing.
|
||||||
@@ -27,60 +30,45 @@ class JoinFields {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Host card text, owned by the shell so it survives the card closing.
|
/// The strip's Join a friend: a card over the window. It closes itself
|
||||||
class HostFields {
|
/// once the join got in; a failed one shows its error inside.
|
||||||
HostFields(ConnectForm f)
|
Future<void> showJoinCard(
|
||||||
: port = TextEditingController(text: f.hostPort),
|
|
||||||
secret = TextEditingController(text: f.hostSecret);
|
|
||||||
final TextEditingController port;
|
|
||||||
final TextEditingController secret;
|
|
||||||
|
|
||||||
void dispose() {
|
|
||||||
port.dispose();
|
|
||||||
secret.dispose();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The strip's Join / Host buttons (and solo's Invite friends, the Host
|
|
||||||
/// card): a card over the window. It closes itself once the attempt got
|
|
||||||
/// in; a failed one shows its error inside.
|
|
||||||
Future<void> showConnectCard(
|
|
||||||
BuildContext context, {
|
BuildContext context, {
|
||||||
required ConnectMode mode,
|
|
||||||
required ConnectFlow flow,
|
required ConnectFlow flow,
|
||||||
required JoinFields join,
|
required JoinFields join,
|
||||||
required HostFields host,
|
|
||||||
HealthGetter healthGet = defaultHealthGet,
|
HealthGetter healthGet = defaultHealthGet,
|
||||||
|
UpdateController? updates,
|
||||||
}) =>
|
}) =>
|
||||||
showPopover<void>(
|
showPopover<void>(
|
||||||
context,
|
context,
|
||||||
dim: true,
|
dim: true,
|
||||||
at: const Alignment(0.8, -0.75),
|
at: const Alignment(0.8, -0.75),
|
||||||
child: _CloseWhenOnline(
|
child: _CloseInFriends(
|
||||||
child: mode == ConnectMode.host
|
child: JoinCard(flow: flow, fields: join, healthGet: healthGet, updates: updates),
|
||||||
? HostCard(flow: flow, fields: host)
|
|
||||||
: JoinCard(flow: flow, fields: join, healthGet: healthGet),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
class _CloseWhenOnline extends StatefulWidget {
|
class _CloseInFriends extends StatefulWidget {
|
||||||
final Widget child;
|
final Widget child;
|
||||||
const _CloseWhenOnline({required this.child});
|
const _CloseInFriends({required this.child});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<_CloseWhenOnline> createState() => _CloseWhenOnlineState();
|
State<_CloseInFriends> createState() => _CloseInFriendsState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _CloseWhenOnlineState extends State<_CloseWhenOnline> {
|
class _CloseInFriendsState extends State<_CloseInFriends> {
|
||||||
late final void Function() _stop;
|
late final void Function() _stop;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
final navigation = AppScope.of(context).navigation;
|
||||||
|
var was = navigation.place.peek();
|
||||||
_stop = effect(() {
|
_stop = effect(() {
|
||||||
final s = SessionState.instance;
|
final place = navigation.place.value;
|
||||||
// Solo is in a session too: wait until it is a hosted server.
|
final arrived = place == Place.friend && was != Place.friend;
|
||||||
if (s.route.value != AppRoute.session || s.solo.value || s.switching.value) return;
|
was = place;
|
||||||
|
if (!arrived) return;
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
if (mounted) Navigator.of(context).maybePop();
|
if (mounted) Navigator.of(context).maybePop();
|
||||||
});
|
});
|
||||||
@@ -97,7 +85,7 @@ class _CloseWhenOnlineState extends State<_CloseWhenOnline> {
|
|||||||
Widget build(BuildContext context) => widget.child;
|
Widget build(BuildContext context) => widget.child;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Card frame shared by Join and Host.
|
/// The card frame.
|
||||||
class _Card extends StatelessWidget {
|
class _Card extends StatelessWidget {
|
||||||
final String title;
|
final String title;
|
||||||
final List<Widget> children;
|
final List<Widget> children;
|
||||||
@@ -128,14 +116,15 @@ class _Card extends StatelessWidget {
|
|||||||
const _dense = InputDecoration(isDense: true);
|
const _dense = InputDecoration(isDense: true);
|
||||||
|
|
||||||
/// Recent servers as one-click rows (with live status), then address and
|
/// Recent servers as one-click rows (with live status), then address and
|
||||||
/// password.
|
/// password. Pasting an invite into the address fills the port too.
|
||||||
class JoinCard extends StatelessWidget {
|
class JoinCard extends StatelessWidget {
|
||||||
final ConnectFlow flow;
|
final ConnectFlow flow;
|
||||||
final JoinFields fields;
|
final JoinFields fields;
|
||||||
final HealthGetter healthGet;
|
final HealthGetter healthGet;
|
||||||
const JoinCard({super.key, required this.flow, required this.fields, this.healthGet = defaultHealthGet});
|
final UpdateController? updates;
|
||||||
|
const JoinCard({super.key, required this.flow, required this.fields, this.healthGet = defaultHealthGet, this.updates});
|
||||||
|
|
||||||
void _join() => flow.submit(ConnectMode.join, (f) {
|
void _join() => flow.submit((f) {
|
||||||
f
|
f
|
||||||
..host = fields.host.text
|
..host = fields.host.text
|
||||||
..port = fields.port.text.trim()
|
..port = fields.port.text.trim()
|
||||||
@@ -149,20 +138,27 @@ class JoinCard extends StatelessWidget {
|
|||||||
_join();
|
_join();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// An invite or "host:port" pasted into the address: split it.
|
||||||
|
void _onAddress(String v) {
|
||||||
|
flow.clearError();
|
||||||
|
final a = addressIn(v);
|
||||||
|
if (a == null || v.trim() == a.host) return;
|
||||||
|
fields.host.text = a.host;
|
||||||
|
fields.port.text = a.port;
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final forms = AppScope.of(context).forms;
|
||||||
return ListenableBuilder(
|
return ListenableBuilder(
|
||||||
listenable: flow,
|
listenable: flow,
|
||||||
builder: (context, _) {
|
builder: (context, _) {
|
||||||
final busy = flow.busy == ConnectMode.join;
|
final busy = flow.busy;
|
||||||
return _Card(
|
return _Card(
|
||||||
title: 'Join a friend',
|
title: 'Join a friend',
|
||||||
children: [
|
children: [
|
||||||
SignalBuilder(builder: (context) {
|
SignalBuilder(builder: (context) {
|
||||||
final list = [
|
final list = forms.recents.value;
|
||||||
for (final r in SessionState.instance.recents.value)
|
|
||||||
if (!r.hosted) r
|
|
||||||
];
|
|
||||||
if (list.isEmpty) return const SizedBox.shrink();
|
if (list.isEmpty) return const SizedBox.shrink();
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
@@ -170,10 +166,10 @@ class JoinCard extends StatelessWidget {
|
|||||||
const Text('Recent', style: TextStyle(color: Colors.grey)),
|
const Text('Recent', style: TextStyle(color: Colors.grey)),
|
||||||
RecentServerRows(
|
RecentServerRows(
|
||||||
servers: list,
|
servers: list,
|
||||||
enabled: flow.busy == null,
|
enabled: !busy,
|
||||||
healthGet: healthGet,
|
healthGet: healthGet,
|
||||||
onJoin: _joinRecent,
|
onJoin: _joinRecent,
|
||||||
onForget: SessionState.instance.forgetServer,
|
onForget: forms.forget,
|
||||||
),
|
),
|
||||||
const Divider(),
|
const Divider(),
|
||||||
],
|
],
|
||||||
@@ -192,8 +188,8 @@ class JoinCard extends StatelessWidget {
|
|||||||
key: const ValueKey('join-address'),
|
key: const ValueKey('join-address'),
|
||||||
controller: fields.host,
|
controller: fields.host,
|
||||||
autofocus: true,
|
autofocus: true,
|
||||||
decoration: _dense.copyWith(labelText: 'Address', hintText: 'IP or hostname'),
|
decoration: _dense.copyWith(labelText: 'Address', hintText: 'IP, hostname or invite'),
|
||||||
onChanged: (_) => flow.clearError(ConnectMode.join),
|
onChanged: _onAddress,
|
||||||
onSubmitted: (_) => _join(),
|
onSubmitted: (_) => _join(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -222,12 +218,13 @@ class JoinCard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
ConnectButton(flow: flow, mode: ConnectMode.join, label: 'Join', onSubmit: _join, expand: true),
|
ConnectButton(flow: flow, label: 'Join', onSubmit: _join, expand: true),
|
||||||
ConnectFeedback(flow: flow, mode: ConnectMode.join),
|
ConnectFeedback(flow: flow, updates: updates),
|
||||||
const Padding(
|
const Padding(
|
||||||
padding: EdgeInsets.only(top: 8),
|
padding: EdgeInsets.only(top: 8),
|
||||||
child: Text(
|
child: Text(
|
||||||
'Your friend gives you the address and password. No account or Jellyfin needed.',
|
'Your friend gives you their invite (the address) and the password, if any. '
|
||||||
|
'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),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -238,122 +235,6 @@ class JoinCard extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Port and password; what to watch is picked afterwards, in the library.
|
|
||||||
class HostCard extends StatelessWidget {
|
|
||||||
final ConnectFlow flow;
|
|
||||||
final HostFields fields;
|
|
||||||
const HostCard({super.key, required this.flow, required this.fields});
|
|
||||||
|
|
||||||
void _host() => flow.submit(
|
|
||||||
ConnectMode.host,
|
|
||||||
(f) => f
|
|
||||||
..hostPort = fields.port.text.trim()
|
|
||||||
..hostSecret = fields.secret.text,
|
|
||||||
);
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
// From a solo session this is "Invite friends": same server settings,
|
|
||||||
// and what plays carries over.
|
|
||||||
final solo = SessionState.instance.solo.peek();
|
|
||||||
return ListenableBuilder(
|
|
||||||
listenable: flow,
|
|
||||||
builder: (context, _) => _Card(
|
|
||||||
title: solo ? 'Invite friends' : 'Host a server',
|
|
||||||
children: [
|
|
||||||
AbsorbPointer(
|
|
||||||
absorbing: flow.busy == ConnectMode.host,
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
||||||
children: [
|
|
||||||
TextField(
|
|
||||||
key: const ValueKey('host-port'),
|
|
||||||
controller: fields.port,
|
|
||||||
autofocus: true,
|
|
||||||
decoration: _dense.copyWith(labelText: 'Port', hintText: '8080'),
|
|
||||||
keyboardType: TextInputType.number,
|
|
||||||
onChanged: (_) => flow.clearError(ConnectMode.host),
|
|
||||||
onSubmitted: (_) => _host(),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
ListenableBuilder(
|
|
||||||
listenable: fields.secret,
|
|
||||||
builder: (context, _) => TextField(
|
|
||||||
key: const ValueKey('host-password'),
|
|
||||||
controller: fields.secret,
|
|
||||||
obscureText: true,
|
|
||||||
decoration: _dense.copyWith(
|
|
||||||
labelText: 'Password (optional)',
|
|
||||||
helperText: fields.secret.text.isEmpty ? 'No password — anyone can join' : null,
|
|
||||||
),
|
|
||||||
onSubmitted: (_) => _host(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
_HostedRecents(fields: fields, enabled: flow.busy == null),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
ConnectButton(
|
|
||||||
flow: flow, mode: ConnectMode.host, label: solo ? 'Start hosting' : 'Host', onSubmit: _host, expand: true),
|
|
||||||
ConnectFeedback(flow: flow, mode: ConnectMode.host),
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.only(top: 8),
|
|
||||||
child: Text(
|
|
||||||
solo
|
|
||||||
? 'Your video keeps playing from where it is; friends join with your address.'
|
|
||||||
: 'You pick what to watch afterwards, in the library.',
|
|
||||||
style: const TextStyle(color: Colors.grey, fontSize: 12),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Ports hosted before: one click refills port and password.
|
|
||||||
class _HostedRecents extends StatelessWidget {
|
|
||||||
final HostFields fields;
|
|
||||||
final bool enabled;
|
|
||||||
const _HostedRecents({required this.fields, required this.enabled});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return SignalBuilder(builder: (context) {
|
|
||||||
final list = [
|
|
||||||
for (final r in SessionState.instance.recents.value)
|
|
||||||
if (r.hosted) r
|
|
||||||
];
|
|
||||||
if (list.isEmpty) return const SizedBox.shrink();
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.only(top: 12),
|
|
||||||
child: Wrap(
|
|
||||||
spacing: 8,
|
|
||||||
runSpacing: 8,
|
|
||||||
children: [
|
|
||||||
for (final r in list)
|
|
||||||
InputChip(
|
|
||||||
key: ValueKey('recent-${r.key}'),
|
|
||||||
avatar: Icon(r.secret.isEmpty ? Icons.history : Icons.lock_outline, size: 16),
|
|
||||||
label: Text('Port ${r.port}'),
|
|
||||||
tooltip: 'Use port ${r.port} again',
|
|
||||||
isEnabled: enabled,
|
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
|
||||||
onPressed: () {
|
|
||||||
fields.port.text = r.port;
|
|
||||||
fields.secret.text = r.secret;
|
|
||||||
},
|
|
||||||
deleteButtonTooltipMessage: 'Forget',
|
|
||||||
onDeleted: () => SessionState.instance.forgetServer(r),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Recent servers as rows; polls each one's `/health` every
|
/// Recent servers as rows; polls each one's `/health` every
|
||||||
/// [healthPollEvery] while mounted (i.e. while the Join card is open).
|
/// [healthPollEvery] while mounted (i.e. while the Join card is open).
|
||||||
class RecentServerRows extends StatefulWidget {
|
class RecentServerRows extends StatefulWidget {
|
||||||
@@ -476,8 +357,7 @@ class _RecentRow extends StatelessWidget {
|
|||||||
Text(status, key: const ValueKey('recent-status'), style: TextStyle(color: statusColor)),
|
Text(status, key: const ValueKey('recent-status'), style: TextStyle(color: statusColor)),
|
||||||
if (h != null && h.needsUpdate)
|
if (h != null && h.needsUpdate)
|
||||||
Tooltip(
|
Tooltip(
|
||||||
message: 'Server protocol ${h.proto}, this app '
|
message: versionGateText(h),
|
||||||
'${RelayConstants.protoVersion} — update so both match',
|
|
||||||
child: Container(
|
child: Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
|
||||||
decoration: BoxDecoration(color: scheme.errorContainer, borderRadius: BorderRadius.circular(6)),
|
decoration: BoxDecoration(color: scheme.errorContainer, borderRadius: BorderRadius.circular(6)),
|
||||||
|
|||||||
@@ -8,13 +8,14 @@ import 'motion.dart';
|
|||||||
|
|
||||||
Color _tone(SourceState s, ColorScheme scheme) => switch (s) {
|
Color _tone(SourceState s, ColorScheme scheme) => switch (s) {
|
||||||
SourceState.ok => scheme.primary,
|
SourceState.ok => scheme.primary,
|
||||||
SourceState.slow || SourceState.waiting => Colors.amber,
|
SourceState.slow || SourceState.waiting || SourceState.gone => Colors.amber,
|
||||||
SourceState.left => Colors.redAccent.shade200,
|
SourceState.left => Colors.redAccent.shade200,
|
||||||
};
|
};
|
||||||
|
|
||||||
IconData _icon(Delivery d) => switch (d.state) {
|
IconData _icon(Delivery d) => switch (d.state) {
|
||||||
SourceState.left => Icons.link_off_rounded,
|
SourceState.left => Icons.link_off_rounded,
|
||||||
SourceState.waiting => Icons.hourglass_top_rounded,
|
SourceState.waiting => Icons.hourglass_top_rounded,
|
||||||
|
SourceState.gone => Icons.wifi_off_rounded,
|
||||||
_ => switch (d.kind) {
|
_ => switch (d.kind) {
|
||||||
DeliveryKind.memberRelay => Icons.cell_tower,
|
DeliveryKind.memberRelay => Icons.cell_tower,
|
||||||
DeliveryKind.serverFetch => Icons.cloud_download_outlined,
|
DeliveryKind.serverFetch => Icons.cloud_download_outlined,
|
||||||
@@ -28,8 +29,8 @@ IconData _icon(Delivery d) => switch (d.state) {
|
|||||||
/// Where the current media comes from and how it reaches the others:
|
/// Where the current media comes from and how it reaches the others:
|
||||||
/// "Foxyz passes it on through the server", "3 through the server ·
|
/// "Foxyz passes it on through the server", "3 through the server ·
|
||||||
/// 4.1 MB/s, needs 2.3 MB/s" and a bar of the source's speed against the
|
/// 4.1 MB/s, needs 2.3 MB/s" and a bar of the source's speed against the
|
||||||
/// video's (past the mark: keeping up). Amber when slow or waiting, red
|
/// video's (past the mark: keeping up). Amber when slow, waiting or the
|
||||||
/// when the source left.
|
/// source's connection dropped, red when the source left.
|
||||||
class SourceLine extends StatelessWidget {
|
class SourceLine extends StatelessWidget {
|
||||||
final Delivery delivery;
|
final Delivery delivery;
|
||||||
const SourceLine({super.key, required this.delivery});
|
const SourceLine({super.key, required this.delivery});
|
||||||
@@ -40,7 +41,7 @@ class SourceLine extends StatelessWidget {
|
|||||||
final scheme = Theme.of(context).colorScheme;
|
final scheme = Theme.of(context).colorScheme;
|
||||||
final tone = _tone(d.state, scheme);
|
final tone = _tone(d.state, scheme);
|
||||||
final detail = d.detail;
|
final detail = d.detail;
|
||||||
final headroom = d.state == SourceState.left ? null : d.headroom;
|
final headroom = d.state == SourceState.left || d.state == SourceState.gone ? null : d.headroom;
|
||||||
return Tooltip(
|
return Tooltip(
|
||||||
message: d.explain,
|
message: d.explain,
|
||||||
waitDuration: const Duration(milliseconds: 400),
|
waitDuration: const Duration(milliseconds: 400),
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ Handoff classifyHandoff({
|
|||||||
|
|
||||||
/// What the card's buttons ask the shell to do.
|
/// What the card's buttons ask the shell to do.
|
||||||
enum HandoffAction {
|
enum HandoffAction {
|
||||||
/// Put the (first) video up (offline: watch it solo).
|
/// Put the (first) video up.
|
||||||
putUp,
|
putUp,
|
||||||
/// Add the video's folder (or the folder) to Files and open it.
|
/// Add the video's folder (or the folder) to Files and open it.
|
||||||
addFolder,
|
addFolder,
|
||||||
@@ -115,11 +115,11 @@ enum HandoffAction {
|
|||||||
/// pops the [HandoffAction] chosen.
|
/// pops the [HandoffAction] chosen.
|
||||||
class HandoffCard extends StatelessWidget {
|
class HandoffCard extends StatelessWidget {
|
||||||
final Handoff item;
|
final Handoff item;
|
||||||
/// Nobody else watches (offline or solo): videos play, no relay talk.
|
/// Nobody else is in the server: videos play, no relay talk.
|
||||||
final bool solo;
|
final bool alone;
|
||||||
/// In a server: videos and links can go to Up next.
|
/// In a server: videos and links can go to Up next.
|
||||||
final bool queue;
|
final bool queue;
|
||||||
const HandoffCard({super.key, required this.item, required this.solo, this.queue = false});
|
const HandoffCard({super.key, required this.item, required this.alone, this.queue = false});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -144,7 +144,7 @@ class HandoffCard extends StatelessWidget {
|
|||||||
baseName(paths.first),
|
baseName(paths.first),
|
||||||
paths.length > 1 ? 'and ${paths.length - 1} more · the first one goes up' : 'A video on this computer',
|
paths.length > 1 ? 'and ${paths.length - 1} more · the first one goes up' : 'A video on this computer',
|
||||||
[
|
[
|
||||||
button(HandoffAction.putUp, Icons.play_arrow, solo ? 'Play' : 'Put it up', primary: true),
|
button(HandoffAction.putUp, Icons.play_arrow, alone ? 'Play' : 'Put it up', primary: true),
|
||||||
...queueButtons,
|
...queueButtons,
|
||||||
button(HandoffAction.addFolder, Icons.create_new_folder_outlined, 'Add its folder to Files'),
|
button(HandoffAction.addFolder, Icons.create_new_folder_outlined, 'Add its folder to Files'),
|
||||||
],
|
],
|
||||||
@@ -158,9 +158,9 @@ class HandoffCard extends StatelessWidget {
|
|||||||
HandoffLink(:final url) => (
|
HandoffLink(:final url) => (
|
||||||
Icons.link,
|
Icons.link,
|
||||||
url,
|
url,
|
||||||
solo ? 'Opens it on the Link page' : 'Pick how the others get it on the Link page',
|
alone ? 'Opens it on the Link page' : 'Pick how the others get it on the Link page',
|
||||||
[
|
[
|
||||||
button(HandoffAction.openLink, Icons.link, solo ? 'Open in Link' : 'Put it up as a link…',
|
button(HandoffAction.openLink, Icons.link, alone ? 'Open in Link' : 'Put it up as a link…',
|
||||||
primary: true),
|
primary: true),
|
||||||
...queueButtons,
|
...queueButtons,
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import '../screens/home/media_picker.dart';
|
|||||||
import '../widgets/app_menu.dart';
|
import '../widgets/app_menu.dart';
|
||||||
import '../widgets/copy_open.dart';
|
import '../widgets/copy_open.dart';
|
||||||
import '../state/connect_prefs.dart';
|
import '../state/connect_prefs.dart';
|
||||||
import '../state/session_state.dart';
|
import '../app/app_graph.dart';
|
||||||
import 'motion.dart';
|
import 'motion.dart';
|
||||||
|
|
||||||
// The Library's Link source: a big paste field, a preview of what the link
|
// The Library's Link source: a big paste field, a preview of what the link
|
||||||
@@ -239,7 +239,6 @@ class LinkPreviewStore {
|
|||||||
/// What a link is (see [LinkResolver.probe]).
|
/// What a link is (see [LinkResolver.probe]).
|
||||||
typedef LinkProber = Future<LinkProbe> Function(String url);
|
typedef LinkProber = Future<LinkProbe> Function(String url);
|
||||||
|
|
||||||
Future<LinkProbe> defaultLinkProber(String url) => SessionState.instance.ports.links.probe(url);
|
|
||||||
|
|
||||||
String _host(Uri uri) => uri.host.startsWith('www.') ? uri.host.substring(4) : uri.host;
|
String _host(Uri uri) => uri.host.startsWith('www.') ? uri.host.substring(4) : uri.host;
|
||||||
|
|
||||||
@@ -254,7 +253,7 @@ String clockText(double seconds) {
|
|||||||
/// duration and thumbnail; a media file its file name and host; a link
|
/// duration and thumbnail; a media file its file name and host; a link
|
||||||
/// that does not open, why ([LinkPreview.problem]). Null for text that is
|
/// that does not open, why ([LinkPreview.problem]). Null for text that is
|
||||||
/// not an http(s) link.
|
/// not an http(s) link.
|
||||||
Future<LinkPreview?> previewLink(String url, {LinkProber probe = defaultLinkProber}) async {
|
Future<LinkPreview?> previewLink(String url, {required LinkProber probe}) async {
|
||||||
final uri = Uri.tryParse(url.trim());
|
final uri = Uri.tryParse(url.trim());
|
||||||
if (uri == null || !(uri.scheme == 'http' || uri.scheme == 'https') || uri.host.isEmpty) return null;
|
if (uri == null || !(uri.scheme == 'http' || uri.scheme == 'https') || uri.host.isEmpty) return null;
|
||||||
final host = _host(uri);
|
final host = _host(uri);
|
||||||
@@ -290,7 +289,7 @@ Future<LinkPreview?> previewLink(String url, {LinkProber probe = defaultLinkProb
|
|||||||
/// [url]'s preview at once when it was kept before ([LinkPreviewStore]),
|
/// [url]'s preview at once when it was kept before ([LinkPreviewStore]),
|
||||||
/// else probed and kept (the Up next popover's paste; the Link page has
|
/// else probed and kept (the Up next popover's paste; the Link page has
|
||||||
/// its own refresh rules).
|
/// its own refresh rules).
|
||||||
Future<LinkPreview?> cachedLinkPreview(String url, {RecentLinks? recents, LinkProber probe = defaultLinkProber}) async {
|
Future<LinkPreview?> cachedLinkPreview(String url, {RecentLinks? recents, required LinkProber probe}) async {
|
||||||
final store = (recents ?? RecentLinks.instance).previews;
|
final store = (recents ?? RecentLinks.instance).previews;
|
||||||
await store.ensureLoaded();
|
await store.ensureLoaded();
|
||||||
final kept = store.lookup(url);
|
final kept = store.lookup(url);
|
||||||
@@ -344,27 +343,28 @@ String linkRelayHelp(LinkRelay r) => switch (r) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/// Under Play when nobody else watches.
|
/// Under Play when nobody else watches.
|
||||||
const soloPlayHint = 'Plays just for you. Invite friends any time; it keeps playing.';
|
const alonePlayHint = 'Plays just for you. Invite friends any time; it keeps playing.';
|
||||||
|
|
||||||
/// The Link source. [solo]: nobody else watches (offline or solo), so the
|
/// The Link source. [alone]: nobody else is in the server, so the
|
||||||
/// button says Play and there are no relay modes (the link plays direct).
|
/// button says Play and there are no relay modes (the link plays direct).
|
||||||
class LibraryLinkSource extends StatefulWidget {
|
class LibraryLinkSource extends StatefulWidget {
|
||||||
final MediaPickerController controller;
|
final MediaPickerController controller;
|
||||||
final bool solo;
|
final bool alone;
|
||||||
/// Puts the link up; returns an error text, or null when sent.
|
/// Puts the link up; returns an error text, or null when sent.
|
||||||
final Future<String?> Function(LinkMedia media) onPutUp;
|
final Future<String?> Function(LinkMedia media) onPutUp;
|
||||||
/// Adds the link to Up next in the chosen mode; null: no server.
|
/// Adds the link to Up next in the chosen mode; null: no server.
|
||||||
final AddToQueue? onAddToQueue;
|
final AddToQueue? onAddToQueue;
|
||||||
final RecentLinks recents;
|
final RecentLinks recents;
|
||||||
final LinkProber probe;
|
/// What a link is; defaults to the app's [LinkResolver.probe].
|
||||||
|
final LinkProber? probe;
|
||||||
const LibraryLinkSource({
|
const LibraryLinkSource({
|
||||||
super.key,
|
super.key,
|
||||||
required this.controller,
|
required this.controller,
|
||||||
required this.onPutUp,
|
required this.onPutUp,
|
||||||
this.onAddToQueue,
|
this.onAddToQueue,
|
||||||
required this.recents,
|
required this.recents,
|
||||||
this.solo = false,
|
this.alone = false,
|
||||||
this.probe = defaultLinkProber,
|
this.probe,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -372,6 +372,8 @@ class LibraryLinkSource extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _LibraryLinkSourceState extends State<LibraryLinkSource> {
|
class _LibraryLinkSourceState extends State<LibraryLinkSource> {
|
||||||
|
LinkProber get _probe => widget.probe ?? AppScope.of(context).ports.links.probe;
|
||||||
|
|
||||||
MediaPickerController get _c => widget.controller;
|
MediaPickerController get _c => widget.controller;
|
||||||
final _previews = <String, LinkPreview?>{};
|
final _previews = <String, LinkPreview?>{};
|
||||||
String _previewUrl = '';
|
String _previewUrl = '';
|
||||||
@@ -450,7 +452,7 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
|
|||||||
if (DateTime.now().difference(known.at) > linkPreviewFreshFor) unawaited(_refresh(url));
|
if (DateTime.now().difference(known.at) > linkPreviewFreshFor) unawaited(_refresh(url));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final p = await previewLink(url, probe: widget.probe);
|
final p = await previewLink(url, probe: _probe);
|
||||||
_previews[url] = p;
|
_previews[url] = p;
|
||||||
_keep(url, p);
|
_keep(url, p);
|
||||||
if (mounted) setState(() {});
|
if (mounted) setState(() {});
|
||||||
@@ -459,7 +461,7 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
|
|||||||
/// Probes [url] again behind what is shown; a site out of reach keeps
|
/// Probes [url] again behind what is shown; a site out of reach keeps
|
||||||
/// what was found before. Returns the preview shown afterwards.
|
/// what was found before. Returns the preview shown afterwards.
|
||||||
Future<LinkPreview?> _refresh(String url) async {
|
Future<LinkPreview?> _refresh(String url) async {
|
||||||
final p = await previewLink(url, probe: widget.probe);
|
final p = await previewLink(url, probe: _probe);
|
||||||
if (p == null || p.problem == unreachableText) return _previews[url];
|
if (p == null || p.problem == unreachableText) return _previews[url];
|
||||||
_previews[url] = p;
|
_previews[url] = p;
|
||||||
_keep(url, p);
|
_keep(url, p);
|
||||||
@@ -491,12 +493,12 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
|
|||||||
});
|
});
|
||||||
// What the probe found refuses it here, with "Put it up anyway": the
|
// What the probe found refuses it here, with "Put it up anyway": the
|
||||||
// server or a friend (with cookies) may still open it.
|
// server or a friend (with cookies) may still open it.
|
||||||
final problem = anyway ? null : (_previews[url] ?? await previewLink(url, probe: widget.probe))?.problem;
|
final problem = anyway ? null : (_previews[url] ?? await previewLink(url, probe: _probe))?.problem;
|
||||||
String? error = problem;
|
String? error = problem;
|
||||||
if (problem == null) {
|
if (problem == null) {
|
||||||
try {
|
try {
|
||||||
error = await widget.onPutUp(
|
error = await widget.onPutUp(
|
||||||
LinkMedia(url, relay: widget.solo ? LinkRelay.direct : _c.linkRelay, anyway: anyway));
|
LinkMedia(url, relay: widget.alone ? LinkRelay.direct : _c.linkRelay, anyway: anyway));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error = '$e';
|
error = '$e';
|
||||||
}
|
}
|
||||||
@@ -510,7 +512,7 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
|
|||||||
if (error == null) {
|
if (error == null) {
|
||||||
var p = _previews[url];
|
var p = _previews[url];
|
||||||
if (p == null) {
|
if (p == null) {
|
||||||
p = _previews[url] = await previewLink(url, probe: widget.probe);
|
p = _previews[url] = await previewLink(url, probe: _probe);
|
||||||
_keep(url, p);
|
_keep(url, p);
|
||||||
}
|
}
|
||||||
unawaited(widget.recents.add(RecentLink(
|
unawaited(widget.recents.add(RecentLink(
|
||||||
@@ -542,7 +544,7 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
|
|||||||
});
|
});
|
||||||
String? error;
|
String? error;
|
||||||
try {
|
try {
|
||||||
error = await add(LinkMedia(link, relay: widget.solo ? LinkRelay.direct : _c.linkRelay), next: next);
|
error = await add(LinkMedia(link, relay: widget.alone ? LinkRelay.direct : _c.linkRelay), next: next);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error = '$e';
|
error = '$e';
|
||||||
}
|
}
|
||||||
@@ -556,7 +558,7 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
|
|||||||
/// Right-click on a recent link: put it up or queue it, copy or open it.
|
/// Right-click on a recent link: put it up or queue it, copy or open it.
|
||||||
Future<void> _recentMenu(BuildContext context, RecentLink l) async {
|
Future<void> _recentMenu(BuildContext context, RecentLink l) async {
|
||||||
final pick = await showAppMenu<String>(context, items: [
|
final pick = await showAppMenu<String>(context, items: [
|
||||||
AppMenuItem(value: 'up', label: widget.solo ? 'Play' : 'Put it up'),
|
AppMenuItem(value: 'up', label: widget.alone ? 'Play' : 'Put it up'),
|
||||||
if (widget.onAddToQueue != null) ...const [
|
if (widget.onAddToQueue != null) ...const [
|
||||||
AppMenuItem(value: 'queue', label: 'Add to queue'),
|
AppMenuItem(value: 'queue', label: 'Add to queue'),
|
||||||
AppMenuItem(value: 'next', label: 'Play next'),
|
AppMenuItem(value: 'next', label: 'Play next'),
|
||||||
@@ -565,14 +567,14 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
|
|||||||
const AppMenuItem(value: 'title', label: ReuseText.copyTitle),
|
const AppMenuItem(value: 'title', label: ReuseText.copyTitle),
|
||||||
const AppMenuItem(value: 'open', label: ReuseText.openInBrowser),
|
const AppMenuItem(value: 'open', label: ReuseText.openInBrowser),
|
||||||
]);
|
]);
|
||||||
if (pick == null || !mounted) return;
|
if (pick == null || !mounted || !context.mounted) return;
|
||||||
switch (pick) {
|
switch (pick) {
|
||||||
case 'copy':
|
case 'copy':
|
||||||
return copyToClipboard(l.url, done: 'Link copied');
|
return copyToClipboard(context, l.url, done: 'Link copied');
|
||||||
case 'title':
|
case 'title':
|
||||||
return copyToClipboard(l.title, done: 'Title copied');
|
return copyToClipboard(context, l.title, done: 'Title copied');
|
||||||
case 'open':
|
case 'open':
|
||||||
return openLink(l.url);
|
return openLink(context, l.url);
|
||||||
}
|
}
|
||||||
_c.link.text = l.url;
|
_c.link.text = l.url;
|
||||||
switch (pick) {
|
switch (pick) {
|
||||||
@@ -592,11 +594,11 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
|
|||||||
]);
|
]);
|
||||||
switch (pick) {
|
switch (pick) {
|
||||||
case 'copy':
|
case 'copy':
|
||||||
await copyToClipboard(url, done: 'Link copied');
|
if (context.mounted) await copyToClipboard(context, url, done: 'Link copied');
|
||||||
case 'title':
|
case 'title':
|
||||||
await copyToClipboard(p!.title, done: 'Title copied');
|
if (context.mounted) await copyToClipboard(context, p!.title, done: 'Title copied');
|
||||||
case 'open':
|
case 'open':
|
||||||
await openLink(url);
|
if (context.mounted) await openLink(context, url);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -614,7 +616,7 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final scheme = Theme.of(context).colorScheme;
|
final scheme = Theme.of(context).colorScheme;
|
||||||
final t = Theme.of(context).textTheme;
|
final t = Theme.of(context).textTheme;
|
||||||
final solo = widget.solo;
|
final alone = widget.alone;
|
||||||
final url = _c.link.text.trim();
|
final url = _c.link.text.trim();
|
||||||
final preview = url.isEmpty ? null : _previews[url];
|
final preview = url.isEmpty ? null : _previews[url];
|
||||||
final loading = url.isNotEmpty && !_previews.containsKey(url);
|
final loading = url.isNotEmpty && !_previews.containsKey(url);
|
||||||
@@ -638,7 +640,7 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
|
|||||||
icon: _busy
|
icon: _busy
|
||||||
? const SizedBox.square(dimension: 16, child: CircularProgressIndicator(strokeWidth: 2))
|
? const SizedBox.square(dimension: 16, child: CircularProgressIndicator(strokeWidth: 2))
|
||||||
: const Icon(Icons.play_arrow),
|
: const Icon(Icons.play_arrow),
|
||||||
label: Text(solo ? 'Play' : 'Put it up'),
|
label: Text(alone ? 'Play' : 'Put it up'),
|
||||||
style: FilledButton.styleFrom(
|
style: FilledButton.styleFrom(
|
||||||
minimumSize: const Size(0, 56),
|
minimumSize: const Size(0, 56),
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||||
@@ -705,10 +707,10 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
|
|||||||
if (queue != null) ...[const SizedBox(width: 8), queue],
|
if (queue != null) ...[const SizedBox(width: 8), queue],
|
||||||
]),
|
]),
|
||||||
),
|
),
|
||||||
if (solo || _error != null) ...[
|
if (alone || _error != null) ...[
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Text(
|
||||||
_error ?? soloPlayHint,
|
_error ?? alonePlayHint,
|
||||||
key: const ValueKey('link-hint'),
|
key: const ValueKey('link-hint'),
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: t.bodySmall?.copyWith(color: _error != null ? scheme.error : Colors.grey),
|
style: t.bodySmall?.copyWith(color: _error != null ? scheme.error : Colors.grey),
|
||||||
@@ -718,7 +720,7 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
|
|||||||
child: TextButton(
|
child: TextButton(
|
||||||
key: const ValueKey('link-put-up-anyway'),
|
key: const ValueKey('link-put-up-anyway'),
|
||||||
onPressed: _busy ? null : () => _go(anyway: true),
|
onPressed: _busy ? null : () => _go(anyway: true),
|
||||||
child: Text(solo ? 'Play it anyway' : 'Put it up anyway'),
|
child: Text(alone ? 'Play it anyway' : 'Put it up anyway'),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -737,7 +739,7 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (!solo) ...[
|
if (!alone) ...[
|
||||||
const SizedBox(height: 28),
|
const SizedBox(height: 28),
|
||||||
Text('How the others get it', style: t.titleSmall),
|
Text('How the others get it', style: t.titleSmall),
|
||||||
const SizedBox(height: 10),
|
const SizedBox(height: 10),
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ import '../jellyfin/home_page.dart' show libraryIcon, isVideoLibrary;
|
|||||||
import '../jellyfin/picker_source.dart';
|
import '../jellyfin/picker_source.dart';
|
||||||
import '../jellyfin/session_media.dart';
|
import '../jellyfin/session_media.dart';
|
||||||
import '../screens/home/media_picker.dart';
|
import '../screens/home/media_picker.dart';
|
||||||
import '../state/session_state.dart';
|
import '../session/app_events.dart' show ActionFailed;
|
||||||
|
import '../app/app_graph.dart';
|
||||||
import 'library_link.dart';
|
import 'library_link.dart';
|
||||||
import 'motion.dart';
|
import 'motion.dart';
|
||||||
import 'welcome.dart';
|
import 'welcome.dart';
|
||||||
@@ -67,12 +68,11 @@ const detailsDockFrom = 1000.0;
|
|||||||
/// Jellyfin cards select on click and open their details in a panel at
|
/// Jellyfin cards select on click and open their details in a panel at
|
||||||
/// the right (docked from [detailsDockFrom], floating over the library
|
/// the right (docked from [detailsDockFrom], floating over the library
|
||||||
/// below it); a double-click or the panel's button puts it up. With
|
/// below it); a double-click or the panel's button puts it up. With
|
||||||
/// nobody else watching ([solo]: offline or a solo session) the buttons
|
/// nobody else in the server ([alone], D9) the buttons say Play.
|
||||||
/// say Play and putting something up starts (or uses) a solo session.
|
|
||||||
class LibraryTab extends StatefulWidget {
|
class LibraryTab extends StatefulWidget {
|
||||||
final MediaPickerController controller;
|
final MediaPickerController controller;
|
||||||
final JellyfinAccounts accounts;
|
final JellyfinAccounts accounts;
|
||||||
final bool solo;
|
final bool alone;
|
||||||
/// This app hosts the server: its files stream from here directly.
|
/// This app hosts the server: its files stream from here directly.
|
||||||
final bool isHost;
|
final bool isHost;
|
||||||
final BrowseStart? jellyfinStart;
|
final BrowseStart? jellyfinStart;
|
||||||
@@ -84,9 +84,10 @@ class LibraryTab extends StatefulWidget {
|
|||||||
final VoidCallback? onOpenIntegrations;
|
final VoidCallback? onOpenIntegrations;
|
||||||
/// Defaults to [RecentLinks.instance].
|
/// Defaults to [RecentLinks.instance].
|
||||||
final RecentLinks? recentLinks;
|
final RecentLinks? recentLinks;
|
||||||
final LinkProber probe;
|
/// What a link is; defaults to the app's.
|
||||||
|
final LinkProber? probe;
|
||||||
/// The Files source (tests inject one); defaults to [FilesPage].
|
/// The Files source (tests inject one); defaults to [FilesPage].
|
||||||
final Widget Function(BuildContext context, {required bool solo, required bool relayed,
|
final Widget Function(BuildContext context, {required bool alone, required bool relayed,
|
||||||
required Future<String?> Function(String path) onPlay, String? nowPlayingPath})? filesBuilder;
|
required Future<String?> Function(String path) onPlay, String? nowPlayingPath})? filesBuilder;
|
||||||
/// Clock for the Jellyfin Continue row (tests pin it).
|
/// Clock for the Jellyfin Continue row (tests pin it).
|
||||||
final DateTime Function() now;
|
final DateTime Function() now;
|
||||||
@@ -96,7 +97,7 @@ class LibraryTab extends StatefulWidget {
|
|||||||
super.key,
|
super.key,
|
||||||
required this.controller,
|
required this.controller,
|
||||||
required this.accounts,
|
required this.accounts,
|
||||||
required this.solo,
|
required this.alone,
|
||||||
required this.isHost,
|
required this.isHost,
|
||||||
required this.onPutUp,
|
required this.onPutUp,
|
||||||
this.onAddToQueue,
|
this.onAddToQueue,
|
||||||
@@ -104,7 +105,7 @@ class LibraryTab extends StatefulWidget {
|
|||||||
this.jellyfinStart,
|
this.jellyfinStart,
|
||||||
this.onOpenIntegrations,
|
this.onOpenIntegrations,
|
||||||
this.recentLinks,
|
this.recentLinks,
|
||||||
this.probe = defaultLinkProber,
|
this.probe,
|
||||||
this.filesBuilder,
|
this.filesBuilder,
|
||||||
this.now = DateTime.now,
|
this.now = DateTime.now,
|
||||||
this.banner,
|
this.banner,
|
||||||
@@ -196,21 +197,17 @@ class _LibraryTabState extends State<LibraryTab> {
|
|||||||
return pick is JellyfinMedia ? add(pick, next: next) : pick as String;
|
return pick is JellyfinMedia ? add(pick, next: next) : pick as String;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A card's menu: errors as a snack bar.
|
/// A card's menu: errors as a toast (the Notifier's).
|
||||||
Future<void> _cardQueue(JellyfinSelection sel, {bool next = false}) async {
|
Future<void> _cardQueue(JellyfinSelection sel, {bool next = false}) async {
|
||||||
final error = await _queueJellyfin(sel, next: next);
|
final error = await _queueJellyfin(sel, next: next);
|
||||||
if (error != null && mounted) {
|
if (error != null && mounted) AppScope.of(context).notifier.notify(ActionFailed(error));
|
||||||
ScaffoldMessenger.maybeOf(context)?.showSnackBar(SnackBar(content: Text(error)));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Double-click on a card: put it up, errors as a snack bar.
|
/// Double-click on a card: put it up, errors as a toast.
|
||||||
Future<void> _doublePutUp(JellyfinSelection sel) async {
|
Future<void> _doublePutUp(JellyfinSelection sel) async {
|
||||||
_c.select(sel);
|
_c.select(sel);
|
||||||
final error = await _putUpJellyfin(sel);
|
final error = await _putUpJellyfin(sel);
|
||||||
if (error != null && mounted) {
|
if (error != null && mounted) AppScope.of(context).notifier.notify(ActionFailed(error));
|
||||||
ScaffoldMessenger.maybeOf(context)?.showSnackBar(SnackBar(content: Text(error)));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------- build
|
// -------------------------------------------------------------- build
|
||||||
@@ -274,7 +271,7 @@ class _LibraryTabState extends State<LibraryTab> {
|
|||||||
),
|
),
|
||||||
MediaKind.link => LibraryLinkSource(
|
MediaKind.link => LibraryLinkSource(
|
||||||
controller: _c,
|
controller: _c,
|
||||||
solo: widget.solo,
|
alone: widget.alone,
|
||||||
recents: widget.recentLinks ?? RecentLinks.instance,
|
recents: widget.recentLinks ?? RecentLinks.instance,
|
||||||
probe: widget.probe,
|
probe: widget.probe,
|
||||||
onPutUp: _put,
|
onPutUp: _put,
|
||||||
@@ -308,7 +305,7 @@ class _LibraryTabState extends State<LibraryTab> {
|
|||||||
child: JellyfinDetailsPanel(
|
child: JellyfinDetailsPanel(
|
||||||
controller: _browse,
|
controller: _browse,
|
||||||
selection: shown,
|
selection: shown,
|
||||||
solo: widget.solo,
|
alone: widget.alone,
|
||||||
onPutUp: _putUpJellyfin,
|
onPutUp: _putUpJellyfin,
|
||||||
onAddToQueue: widget.onAddToQueue == null ? null : _queueJellyfin,
|
onAddToQueue: widget.onAddToQueue == null ? null : _queueJellyfin,
|
||||||
onClose: () => _c.select(null),
|
onClose: () => _c.select(null),
|
||||||
@@ -369,17 +366,17 @@ class _LibraryTabState extends State<LibraryTab> {
|
|||||||
final relayed = !widget.isHost;
|
final relayed = !widget.isHost;
|
||||||
Future<String?> play(String path) => _put(FileMedia(path));
|
Future<String?> play(String path) => _put(FileMedia(path));
|
||||||
return SignalBuilder(builder: (context) {
|
return SignalBuilder(builder: (context) {
|
||||||
final session = SessionState.instance;
|
final graph = AppScope.of(context);
|
||||||
final now = session.route.value == AppRoute.session
|
final now = graph.navigation.online.value
|
||||||
? nowPlayingFile(session.coordinator.serverState.value?.media, session.lastForm.mediaPath)
|
? nowPlayingFile(graph.coordinator.serverState.value?.media, graph.forms.form.mediaPath)
|
||||||
: null;
|
: null;
|
||||||
final b = widget.filesBuilder;
|
final b = widget.filesBuilder;
|
||||||
if (b != null) {
|
if (b != null) {
|
||||||
return b(context, solo: widget.solo, relayed: relayed, onPlay: play, nowPlayingPath: now);
|
return b(context, alone: widget.alone, relayed: relayed, onPlay: play, nowPlayingPath: now);
|
||||||
}
|
}
|
||||||
final add = widget.onAddToQueue;
|
final add = widget.onAddToQueue;
|
||||||
return FilesPage(
|
return FilesPage(
|
||||||
solo: widget.solo,
|
alone: widget.alone,
|
||||||
relayed: relayed,
|
relayed: relayed,
|
||||||
onPlay: play,
|
onPlay: play,
|
||||||
onAddToQueue: add == null ? null : (path, {next = false}) => add(FileMedia(path), next: next),
|
onAddToQueue: add == null ? null : (path, {next = false}) => add(FileMedia(path), next: next),
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import 'package:flutter/services.dart';
|
|||||||
import 'package:sync_relay_core/core.dart' show RelayConstants;
|
import 'package:sync_relay_core/core.dart' show RelayConstants;
|
||||||
|
|
||||||
import '../session/app_events.dart' show ProblemMarked;
|
import '../session/app_events.dart' show ProblemMarked;
|
||||||
import '../state/session_state.dart';
|
import '../app/app_graph.dart';
|
||||||
import '../widgets/app_menu.dart' show AppGlass;
|
import '../widgets/app_menu.dart' show AppGlass;
|
||||||
import 'motion.dart';
|
import 'motion.dart';
|
||||||
|
|
||||||
@@ -17,8 +17,8 @@ const markProblemKey = SingleActivator(LogicalKeyboardKey.keyB, control: true, s
|
|||||||
/// Asks for the note, then marks; the toast says where it went.
|
/// Asks for the note, then marks; the toast says where it went.
|
||||||
Future<void> showMarkProblem(BuildContext context) async {
|
Future<void> showMarkProblem(BuildContext context) async {
|
||||||
final note = await showPopover<String>(context, at: Alignment.center, child: const MarkProblemCard());
|
final note = await showPopover<String>(context, at: Alignment.center, child: const MarkProblemCard());
|
||||||
if (note == null) return;
|
if (note == null || !context.mounted) return;
|
||||||
final coord = SessionState.instance.coordinator;
|
final coord = AppScope.of(context).coordinator;
|
||||||
final inSession = await coord.markProblem(note);
|
final inSession = await coord.markProblem(note);
|
||||||
coord.notify(ProblemMarked(inSession: inSession));
|
coord.notify(ProblemMarked(inSession: inSession));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:signals/signals_flutter.dart';
|
import 'package:signals/signals_flutter.dart';
|
||||||
|
|
||||||
import '../session/now_playing.dart';
|
import '../session/now_playing.dart';
|
||||||
import '../state/session_state.dart';
|
import '../app/app_graph.dart';
|
||||||
import '../widgets/app_menu.dart';
|
import '../widgets/app_menu.dart';
|
||||||
import '../widgets/copy_open.dart';
|
import '../widgets/copy_open.dart';
|
||||||
import 'motion.dart';
|
import 'motion.dart';
|
||||||
@@ -15,22 +15,22 @@ Uint8List? Function(String hash)? debugArtBytes;
|
|||||||
/// The one lookup for media art (cover, banner) by content hash: null
|
/// The one lookup for media art (cover, banner) by content hash: null
|
||||||
/// when there is none or it has not arrived yet (listeners of
|
/// when there is none or it has not arrived yet (listeners of
|
||||||
/// [artChanges] hear when it lands).
|
/// [artChanges] hear when it lands).
|
||||||
Uint8List? artBytes(String hash) {
|
Uint8List? artBytes(BuildContext context, String hash) {
|
||||||
if (hash.isEmpty) return null;
|
if (hash.isEmpty) return null;
|
||||||
final debug = debugArtBytes;
|
final debug = debugArtBytes;
|
||||||
if (debug != null) return debug(hash);
|
if (debug != null) return debug(hash);
|
||||||
return SessionState.instance.coordinator.art.bytesFor(hash);
|
return AppScope.of(context).coordinator.art.bytesFor(hash);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Notifies when art arrives.
|
/// Notifies when art arrives.
|
||||||
Listenable artChanges() => SessionState.instance.coordinator.art;
|
Listenable artChanges(BuildContext context) => AppScope.of(context).coordinator.art;
|
||||||
|
|
||||||
final _images = <String, MemoryImage>{};
|
final _images = <String, MemoryImage>{};
|
||||||
|
|
||||||
/// [hash]'s art as an image, the same provider while the bytes are the
|
/// [hash]'s art as an image, the same provider while the bytes are the
|
||||||
/// same (so a rebuild never decodes again).
|
/// same (so a rebuild never decodes again).
|
||||||
ImageProvider? artImage(String hash) {
|
ImageProvider? artImage(BuildContext context, String hash) {
|
||||||
final bytes = artBytes(hash);
|
final bytes = artBytes(context, hash);
|
||||||
if (bytes == null) return null;
|
if (bytes == null) return null;
|
||||||
final hit = _images[hash];
|
final hit = _images[hash];
|
||||||
if (hit != null && identical(hit.bytes, bytes)) return hit;
|
if (hit != null && identical(hit.bytes, bytes)) return hit;
|
||||||
@@ -59,7 +59,7 @@ class ArtCover extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final image = artImage(hash);
|
final image = artImage(context, hash);
|
||||||
final r = BorderRadius.circular(width >= 80 ? 10 : 6);
|
final r = BorderRadius.circular(width >= 80 ? 10 : 6);
|
||||||
return Container(
|
return Container(
|
||||||
width: wide ? width * 1.5 * 16 / 9 : width,
|
width: wide ? width * 1.5 * 16 / 9 : width,
|
||||||
@@ -166,10 +166,10 @@ class NowPlayingTitle extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) => GestureDetector(
|
Widget build(BuildContext context) => GestureDetector(
|
||||||
onSecondaryTap: () => nowPlayingMenu(context),
|
onSecondaryTap: () => nowPlayingMenu(context),
|
||||||
child: ListenableBuilder(
|
child: ListenableBuilder(
|
||||||
listenable: artChanges(),
|
listenable: artChanges(context),
|
||||||
builder: (context, _) => SignalBuilder(
|
builder: (context, _) => SignalBuilder(
|
||||||
builder: (context) => NowPlayingBlock(
|
builder: (context) => NowPlayingBlock(
|
||||||
now: SessionState.instance.coordinator.nowPlaying.value,
|
now: AppScope.of(context).coordinator.nowPlaying.value,
|
||||||
cover: cover,
|
cover: cover,
|
||||||
compact: compact,
|
compact: compact,
|
||||||
),
|
),
|
||||||
@@ -182,7 +182,7 @@ class NowPlayingTitle extends StatelessWidget {
|
|||||||
/// Copy link and Open in browser; a Jellyfin item on a server linked here,
|
/// Copy link and Open in browser; a Jellyfin item on a server linked here,
|
||||||
/// Open in Jellyfin. [context]: where the menu opens.
|
/// Open in Jellyfin. [context]: where the menu opens.
|
||||||
Future<void> nowPlayingMenu(BuildContext context) async {
|
Future<void> nowPlayingMenu(BuildContext context) async {
|
||||||
final coord = SessionState.instance.coordinator;
|
final coord = AppScope.of(context).coordinator;
|
||||||
final now = coord.nowPlaying.peek();
|
final now = coord.nowPlaying.peek();
|
||||||
if (now == null) return;
|
if (now == null) return;
|
||||||
final link = coord.currentLink();
|
final link = coord.currentLink();
|
||||||
@@ -200,12 +200,12 @@ Future<void> nowPlayingMenu(BuildContext context) async {
|
|||||||
]);
|
]);
|
||||||
switch (pick) {
|
switch (pick) {
|
||||||
case 'title':
|
case 'title':
|
||||||
await copyToClipboard(title, done: 'Title copied');
|
if (context.mounted) await copyToClipboard(context, title, done: 'Title copied');
|
||||||
case 'link':
|
case 'link':
|
||||||
await copyToClipboard(link!, done: 'Link copied');
|
if (context.mounted) await copyToClipboard(context, link!, done: 'Link copied');
|
||||||
case 'open':
|
case 'open':
|
||||||
await openLink(link!);
|
if (context.mounted) await openLink(context, link!);
|
||||||
case 'jellyfin':
|
case 'jellyfin':
|
||||||
await openLink(page!);
|
if (context.mounted) await openLink(context, page!);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,3 @@
|
|||||||
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';
|
import 'package:sync_relay_core/core.dart';
|
||||||
@@ -7,7 +5,10 @@ import 'package:sync_relay_core/core.dart';
|
|||||||
import '../screens/home/ui.dart' show appName, displayVersion, shortVersion;
|
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 '../state/session_state.dart';
|
import '../app/app_graph.dart';
|
||||||
|
import '../session/app_events.dart' show ActionFailed;
|
||||||
|
import '../session/invite.dart';
|
||||||
|
import '../session/navigation.dart' show Place;
|
||||||
import '../widgets/app_keys.dart';
|
import '../widgets/app_keys.dart';
|
||||||
import '../widgets/app_menu.dart';
|
import '../widgets/app_menu.dart';
|
||||||
import '../widgets/copy_open.dart';
|
import '../widgets/copy_open.dart';
|
||||||
@@ -36,9 +37,11 @@ const stripAvatarSize = 42.0;
|
|||||||
const stripCompactBelow = 1100.0;
|
const stripCompactBelow = 1100.0;
|
||||||
|
|
||||||
/// The 60 px strip over everything: your avatar (profile), the tabs, and
|
/// The 60 px strip over everything: your avatar (profile), the tabs, and
|
||||||
/// the party: offline Join a friend / Host, solo a "Solo" chip, Invite
|
/// the party. Alone on your own server it is a plain player's: Join a
|
||||||
/// friends and Stop, online the avatars (members card), server and
|
/// friend and Invite (a "Sharing" chip while your server is open to
|
||||||
/// readiness, chat and Leave. Empty space drags the window.
|
/// friends); with others there the avatars (members card), server and
|
||||||
|
/// readiness, chat, and Invite (yours) or Leave (a friend's). Empty space
|
||||||
|
/// drags the window.
|
||||||
class PartyStrip extends StatelessWidget {
|
class PartyStrip extends StatelessWidget {
|
||||||
final ShellTab tab;
|
final ShellTab tab;
|
||||||
final ValueChanged<ShellTab> onTab;
|
final ValueChanged<ShellTab> onTab;
|
||||||
@@ -46,11 +49,11 @@ class PartyStrip extends StatelessWidget {
|
|||||||
final int unread;
|
final int unread;
|
||||||
final VoidCallback onChat;
|
final VoidCallback onChat;
|
||||||
final VoidCallback onJoin;
|
final VoidCallback onJoin;
|
||||||
final VoidCallback onHost;
|
|
||||||
final VoidCallback onLeave;
|
final VoidCallback onLeave;
|
||||||
final VoidCallback onProfile;
|
final VoidCallback onProfile;
|
||||||
/// Solo: host from here, keeping what plays.
|
/// Opens your server to friends (if it is not yet) and copies the
|
||||||
final VoidCallback? onInvite;
|
/// invite.
|
||||||
|
final VoidCallback onInvite;
|
||||||
|
|
||||||
/// Online with something up but the player closed: brings it back.
|
/// Online with something up but the player closed: brings it back.
|
||||||
final VoidCallback? onShowPlayer;
|
final VoidCallback? onShowPlayer;
|
||||||
@@ -65,10 +68,9 @@ class PartyStrip extends StatelessWidget {
|
|||||||
required this.unread,
|
required this.unread,
|
||||||
required this.onChat,
|
required this.onChat,
|
||||||
required this.onJoin,
|
required this.onJoin,
|
||||||
required this.onHost,
|
|
||||||
required this.onLeave,
|
required this.onLeave,
|
||||||
required this.onProfile,
|
required this.onProfile,
|
||||||
this.onInvite,
|
required this.onInvite,
|
||||||
this.onShowPlayer,
|
this.onShowPlayer,
|
||||||
this.windowControls = true,
|
this.windowControls = true,
|
||||||
});
|
});
|
||||||
@@ -79,11 +81,18 @@ class PartyStrip extends StatelessWidget {
|
|||||||
final compact = MediaQuery.sizeOf(context).width < stripCompactBelow;
|
final compact = MediaQuery.sizeOf(context).width < stripCompactBelow;
|
||||||
final row = SignalBuilder(
|
final row = SignalBuilder(
|
||||||
builder: (context) {
|
builder: (context) {
|
||||||
final session = SessionState.instance;
|
final graph = AppScope.of(context);
|
||||||
final coord = session.coordinator;
|
final coord = graph.coordinator;
|
||||||
final online = session.route.value == AppRoute.session;
|
final nav = graph.navigation;
|
||||||
final solo = online && (session.solo.value || session.switching.value);
|
final online = nav.online.value;
|
||||||
final self = online ? coord.selfMember.value : null;
|
final home = nav.home.value;
|
||||||
|
final friend = nav.place.value == Place.friend;
|
||||||
|
// Others there (or a friend's server): the party; else a plain
|
||||||
|
// player (D9).
|
||||||
|
final social = friend || (online && !coord.alone.value);
|
||||||
|
final shared = graph.yourServer.shared.value;
|
||||||
|
final canInvite = graph.yourServer.port.value != null && !graph.yourServer.busy.value;
|
||||||
|
final self = social ? coord.selfMember.value : null;
|
||||||
return Row(
|
return Row(
|
||||||
children: [
|
children: [
|
||||||
const SizedBox(width: 16),
|
const SizedBox(width: 16),
|
||||||
@@ -97,11 +106,11 @@ class PartyStrip extends StatelessWidget {
|
|||||||
child: StatusAvatar(
|
child: StatusAvatar(
|
||||||
info: selfAvatarInfo(
|
info: selfAvatarInfo(
|
||||||
ready: self?.ready ?? false,
|
ready: self?.ready ?? false,
|
||||||
host: online && !solo && coord.hostFile != null,
|
host: social && home,
|
||||||
error: online ? coord.playerError.value : '',
|
error: online ? coord.playerError.value : '',
|
||||||
),
|
),
|
||||||
size: stripAvatarSize,
|
size: stripAvatarSize,
|
||||||
ring: online,
|
ring: social,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -121,10 +130,9 @@ class PartyStrip extends StatelessWidget {
|
|||||||
label: t.label,
|
label: t.label,
|
||||||
selected: t == tab,
|
selected: t == tab,
|
||||||
compact: compact,
|
compact: compact,
|
||||||
// The version: always in sight under Settings,
|
// The version a rest of the mouse away (also at
|
||||||
// the full one a rest of the mouse away.
|
// the bottom of Settings and in About).
|
||||||
tooltip: t == ShellTab.settings ? '$appName ${displayVersion()}' : null,
|
tooltip: t == ShellTab.settings ? '$appName ${displayVersion()}' : null,
|
||||||
caption: t == ShellTab.settings ? shortVersion() : null,
|
|
||||||
onTap: () => onTab(t),
|
onTap: () => onTab(t),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -141,29 +149,7 @@ class PartyStrip extends StatelessWidget {
|
|||||||
onPressed: onShowPlayer,
|
onPressed: onShowPlayer,
|
||||||
icon: const Icon(Icons.smart_display_outlined, size: 20),
|
icon: const Icon(Icons.smart_display_outlined, size: 20),
|
||||||
),
|
),
|
||||||
if (solo) ...[
|
if (social) ...[
|
||||||
_SoloChip(compact: compact, busy: session.switching.value),
|
|
||||||
const SizedBox(width: 10),
|
|
||||||
if (compact)
|
|
||||||
FilledButton(
|
|
||||||
key: const ValueKey('strip-invite'),
|
|
||||||
onPressed: session.switching.value ? null : onInvite,
|
|
||||||
child: const Text('Invite'),
|
|
||||||
)
|
|
||||||
else
|
|
||||||
FilledButton.icon(
|
|
||||||
key: const ValueKey('strip-invite'),
|
|
||||||
onPressed: session.switching.value ? null : onInvite,
|
|
||||||
icon: const Icon(Icons.group_add_outlined, size: 18),
|
|
||||||
label: const Text('Invite friends'),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
OutlinedButton(
|
|
||||||
key: const ValueKey('strip-leave'),
|
|
||||||
onPressed: session.switching.value ? null : onLeave,
|
|
||||||
child: const Text('Stop'),
|
|
||||||
),
|
|
||||||
] else if (online) ...[
|
|
||||||
PartyWatch(
|
PartyWatch(
|
||||||
builder: (context) => AvatarStack(
|
builder: (context) => AvatarStack(
|
||||||
key: const ValueKey('party-avatars'),
|
key: const ValueKey('party-avatars'),
|
||||||
@@ -197,41 +183,30 @@ class PartyStrip extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
|
if (friend)
|
||||||
OutlinedButton(
|
OutlinedButton(
|
||||||
key: const ValueKey('strip-leave'),
|
key: const ValueKey('strip-leave'),
|
||||||
onPressed: onLeave,
|
onPressed: onLeave,
|
||||||
child: const Text('Leave'),
|
child: const Text('Leave'),
|
||||||
),
|
)
|
||||||
|
else
|
||||||
|
_InviteButton(compact: compact, onInvite: canInvite ? onInvite : null),
|
||||||
] else ...[
|
] else ...[
|
||||||
if (!compact) ...[
|
if (shared != null) ...[
|
||||||
Container(
|
_SharingChip(compact: compact, port: shared.port),
|
||||||
width: 7,
|
|
||||||
height: 7,
|
|
||||||
decoration: BoxDecoration(color: scheme.outline, shape: BoxShape.circle),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 6),
|
|
||||||
Text('Offline', style: TextStyle(color: scheme.onSurfaceVariant)),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
],
|
|
||||||
if (compact) ...[
|
|
||||||
FilledButton(key: const ValueKey('strip-join'), onPressed: onJoin, child: const Text('Join')),
|
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
FilledButton.tonal(key: const ValueKey('strip-host'), onPressed: onHost, child: const Text('Host')),
|
],
|
||||||
] else ...[
|
if (compact)
|
||||||
FilledButton.icon(
|
FilledButton.tonal(key: const ValueKey('strip-join'), onPressed: onJoin, child: const Text('Join'))
|
||||||
|
else
|
||||||
|
FilledButton.tonalIcon(
|
||||||
key: const ValueKey('strip-join'),
|
key: const ValueKey('strip-join'),
|
||||||
onPressed: onJoin,
|
onPressed: onJoin,
|
||||||
icon: const Icon(Icons.login_rounded, size: 18),
|
icon: const Icon(Icons.login_rounded, size: 18),
|
||||||
label: const Text('Join a friend'),
|
label: const Text('Join a friend'),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
FilledButton.tonalIcon(
|
_InviteButton(compact: compact, onInvite: canInvite ? onInvite : null),
|
||||||
key: const ValueKey('strip-host'),
|
|
||||||
onPressed: onHost,
|
|
||||||
icon: const Icon(Icons.podcasts_rounded, size: 18),
|
|
||||||
label: const Text('Host'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
],
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
if (windowControls) ...[
|
if (windowControls) ...[
|
||||||
@@ -281,28 +256,21 @@ class _ServerLabel extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) => SignalBuilder(
|
Widget build(BuildContext context) => SignalBuilder(
|
||||||
builder: (context) {
|
builder: (context) {
|
||||||
final coord = SessionState.instance.coordinator;
|
// "connecting…", "reconnecting…", "ready 2/3" (the ready counter
|
||||||
final r = coord.serverState.value;
|
// counts who the presence policy counts), from the one status.
|
||||||
// The ready counter counts who the presence policy counts (not away,
|
final graph = AppScope.of(context);
|
||||||
// not gone, able to play it).
|
final line = graph.coordinator.status.value.party;
|
||||||
final counted = (r?.members ?? const <MemberView>[]).where(countsForReady).toList();
|
|
||||||
final ready = counted.where((m) => m.ready).length;
|
|
||||||
final stale = coord.stateStale.value;
|
|
||||||
return Column(
|
return Column(
|
||||||
key: const ValueKey('server-label'),
|
key: const ValueKey('server-label'),
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
serverName(),
|
serverName(graph),
|
||||||
style: const TextStyle(fontWeight: FontWeight.w600, height: 1.1),
|
style: const TextStyle(fontWeight: FontWeight.w600, height: 1.1),
|
||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
r == null
|
line,
|
||||||
? 'connecting…'
|
|
||||||
: stale
|
|
||||||
? 'reconnecting…'
|
|
||||||
: 'ready $ready/${counted.length}',
|
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: scheme.onSurfaceVariant,
|
color: scheme.onSurfaceVariant,
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
@@ -315,52 +283,76 @@ class _ServerLabel extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// "Solo" alone, "Your server" when hosting, else the joined server's
|
/// "Your server" at home, else the joined server's name (its `--name`),
|
||||||
/// name (its `--name`), or its address while unknown.
|
/// or its address while unknown.
|
||||||
String serverName() {
|
String serverName(AppGraph graph) {
|
||||||
final session = SessionState.instance;
|
final nav = graph.navigation;
|
||||||
if (session.solo.value) return 'Solo';
|
if (nav.home.value) return 'Your server';
|
||||||
if (session.coordinator.hostFile != null) return 'Your server';
|
final label = nav.serverLabel.value;
|
||||||
final label = session.serverLabel.value;
|
return label.isNotEmpty ? label : nav.address.value;
|
||||||
return label.isNotEmpty ? label : session.lastForm.host;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Solo: just you, on your own; a spinner while it becomes a server.
|
/// Invite: opens your server to friends (if it is not yet) and copies
|
||||||
class _SoloChip extends StatelessWidget {
|
/// the invite.
|
||||||
|
class _InviteButton extends StatelessWidget {
|
||||||
final bool compact;
|
final bool compact;
|
||||||
final bool busy;
|
final VoidCallback? onInvite;
|
||||||
const _SoloChip({required this.compact, required this.busy});
|
const _InviteButton({required this.compact, required this.onInvite});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
const tip = 'Copy an invite friends can join with (opens your server to them)';
|
||||||
|
if (compact) {
|
||||||
|
return Tooltip(
|
||||||
|
message: tip,
|
||||||
|
child: FilledButton(key: const ValueKey('strip-invite'), onPressed: onInvite, child: const Text('Invite')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Tooltip(
|
||||||
|
message: tip,
|
||||||
|
child: FilledButton.icon(
|
||||||
|
key: const ValueKey('strip-invite'),
|
||||||
|
onPressed: onInvite,
|
||||||
|
icon: const Icon(Icons.group_add_outlined, size: 18),
|
||||||
|
label: const Text('Invite'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Your server is open to friends and nobody came yet: the port; a click
|
||||||
|
/// opens the members card (the invite, Stop sharing).
|
||||||
|
class _SharingChip extends StatelessWidget {
|
||||||
|
final bool compact;
|
||||||
|
final int port;
|
||||||
|
const _SharingChip({required this.compact, required this.port});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final scheme = Theme.of(context).colorScheme;
|
final scheme = Theme.of(context).colorScheme;
|
||||||
return Tooltip(
|
return Tooltip(
|
||||||
message: busy
|
message: 'Friends can join with your invite. Click for the invite or to stop sharing.',
|
||||||
? 'Starting your server…'
|
child: InkWell(
|
||||||
: 'Watching on your own. Nobody can join until you invite friends.',
|
key: const ValueKey('sharing-chip'),
|
||||||
child: Container(
|
|
||||||
key: const ValueKey('solo-chip'),
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: scheme.surfaceContainerHigh,
|
|
||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: BorderRadius.circular(20),
|
||||||
),
|
onTap: () => showMembersCard(context),
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||||
|
decoration: BoxDecoration(color: scheme.surfaceContainerHigh, borderRadius: BorderRadius.circular(20)),
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
if (busy)
|
Icon(Icons.podcasts_rounded, size: 16, color: scheme.primary),
|
||||||
const SizedBox.square(dimension: 14, child: CircularProgressIndicator(strokeWidth: 2))
|
|
||||||
else
|
|
||||||
Icon(Icons.person_outline_rounded, size: 16, color: scheme.primary),
|
|
||||||
const SizedBox(width: 6),
|
const SizedBox(width: 6),
|
||||||
Text(busy ? 'Inviting…' : 'Solo', style: const TextStyle(fontWeight: FontWeight.w600)),
|
const Text('Sharing', style: TextStyle(fontWeight: FontWeight.w600)),
|
||||||
if (!compact && !busy) ...[
|
if (!compact) ...[
|
||||||
const SizedBox(width: 6),
|
const SizedBox(width: 6),
|
||||||
Text('only you', style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12)),
|
Text('port $port', style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12)),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -373,8 +365,6 @@ class StripTab extends StatelessWidget {
|
|||||||
final VoidCallback onTap;
|
final VoidCallback onTap;
|
||||||
/// Shown after resting on the tab.
|
/// Shown after resting on the tab.
|
||||||
final String? tooltip;
|
final String? tooltip;
|
||||||
/// Small text under the tab (the version under Settings).
|
|
||||||
final String? caption;
|
|
||||||
const StripTab({
|
const StripTab({
|
||||||
super.key,
|
super.key,
|
||||||
required this.label,
|
required this.label,
|
||||||
@@ -382,7 +372,6 @@ class StripTab extends StatelessWidget {
|
|||||||
required this.onTap,
|
required this.onTap,
|
||||||
this.compact = false,
|
this.compact = false,
|
||||||
this.tooltip,
|
this.tooltip,
|
||||||
this.caption,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -399,7 +388,6 @@ class StripTab extends StatelessWidget {
|
|||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
height: partyStripHeight,
|
height: partyStripHeight,
|
||||||
child: Stack(
|
child: Stack(
|
||||||
clipBehavior: Clip.none,
|
|
||||||
alignment: Alignment.center,
|
alignment: Alignment.center,
|
||||||
children: [
|
children: [
|
||||||
Column(
|
Column(
|
||||||
@@ -426,27 +414,6 @@ class StripTab extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
if (caption case final c?)
|
|
||||||
Positioned(
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
bottom: 2,
|
|
||||||
height: 13,
|
|
||||||
child: OverflowBox(
|
|
||||||
maxWidth: 160,
|
|
||||||
child: Text(
|
|
||||||
c,
|
|
||||||
key: const ValueKey('strip-version'),
|
|
||||||
maxLines: 1,
|
|
||||||
softWrap: false,
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 10,
|
|
||||||
height: 1.2,
|
|
||||||
color: scheme.onSurfaceVariant.withValues(alpha: 0.7),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -459,50 +426,16 @@ class StripTab extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Who is in the server, from the avatars: status, host / relay pills,
|
/// Who is in the server, from the avatars: status, host / relay pills,
|
||||||
/// per member force ready, nudge and sit out, and the address to invite
|
/// per member force ready, nudge and sit out, and the invite (yours) or
|
||||||
/// friends.
|
/// the address (a friend's).
|
||||||
Future<void> showMembersCard(BuildContext context) => showPopover<void>(
|
Future<void> showMembersCard(BuildContext context) => showPopover<void>(
|
||||||
context,
|
context,
|
||||||
at: const Alignment(0.7, -0.84),
|
at: const Alignment(0.7, -0.84),
|
||||||
child: const MembersCard(),
|
child: const MembersCard(),
|
||||||
);
|
);
|
||||||
|
|
||||||
/// "ip:port" for each of [addresses] friends could dial: IPv4, neither
|
|
||||||
/// loopback nor link-local, private (LAN) ranges first, no repeats.
|
|
||||||
List<String> inviteAddresses(Iterable<InternetAddress> addresses, String port) {
|
|
||||||
bool private(List<int> b) =>
|
|
||||||
b[0] == 10 || (b[0] == 192 && b[1] == 168) || (b[0] == 172 && b[1] >= 16 && b[1] < 32);
|
|
||||||
final usable = [
|
|
||||||
for (final a in addresses)
|
|
||||||
if (a.type == InternetAddressType.IPv4 &&
|
|
||||||
!a.isLoopback &&
|
|
||||||
!a.isLinkLocal &&
|
|
||||||
!(a.rawAddress[0] == 169 && a.rawAddress[1] == 254))
|
|
||||||
a,
|
|
||||||
];
|
|
||||||
final ordered = [
|
|
||||||
...usable.where((a) => private(a.rawAddress)),
|
|
||||||
...usable.where((a) => !private(a.rawAddress)),
|
|
||||||
];
|
|
||||||
return {for (final a in ordered) '${a.address}:$port'}.toList();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// This machine's [inviteAddresses] (empty when the interfaces can't be
|
|
||||||
/// listed).
|
|
||||||
Future<List<String>> lanInviteAddresses(String port) async {
|
|
||||||
try {
|
|
||||||
final interfaces = await NetworkInterface.list(type: InternetAddressType.IPv4);
|
|
||||||
return inviteAddresses([for (final i in interfaces) ...i.addresses], port);
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint('list network interfaces failed: $e');
|
|
||||||
return const [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class MembersCard extends StatelessWidget {
|
class MembersCard extends StatelessWidget {
|
||||||
/// This machine's invite addresses for a port (tests pass fixed ones).
|
const MembersCard({super.key});
|
||||||
final Future<List<String>> Function(String port) lanAddresses;
|
|
||||||
const MembersCard({super.key, this.lanAddresses = lanInviteAddresses});
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -515,26 +448,38 @@ class MembersCard extends StatelessWidget {
|
|||||||
width: 340,
|
width: 340,
|
||||||
child: PartyWatch(
|
child: PartyWatch(
|
||||||
builder: (context) {
|
builder: (context) {
|
||||||
final session = SessionState.instance;
|
final graph = AppScope.of(context);
|
||||||
final coord = session.coordinator;
|
final coord = graph.coordinator;
|
||||||
|
final nav = graph.navigation;
|
||||||
final r = coord.serverState.value;
|
final r = coord.serverState.value;
|
||||||
final hosting = coord.hostFile != null;
|
final home = nav.home.value;
|
||||||
final address = hosting
|
final address = home ? null : nav.address.value;
|
||||||
? null
|
final delivery = coord.status.value.delivery;
|
||||||
: '${session.lastForm.host}:${session.lastForm.port}';
|
final server = nav.serverVersion.value;
|
||||||
final delivery = r == null
|
|
||||||
? null
|
|
||||||
: deliveryOf(r, selfId: coord.connection.memberId, names: coord.memberNames);
|
|
||||||
return Column(
|
return Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(20, 16, 20, 8),
|
padding: const EdgeInsets.fromLTRB(20, 16, 20, 8),
|
||||||
child: Text(
|
child: Column(
|
||||||
['In the server', ?address].join(' · '),
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
home ? 'Your server' : ['In the server', ?address].join(' · '),
|
||||||
style: const TextStyle(fontWeight: FontWeight.w600),
|
style: const TextStyle(fontWeight: FontWeight.w600),
|
||||||
),
|
),
|
||||||
|
if (server.isNotEmpty)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 2),
|
||||||
|
child: VersionText(
|
||||||
|
key: const ValueKey('server-version'),
|
||||||
|
label: 'Server',
|
||||||
|
version: server,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
if (delivery != null) SourceLine(delivery: delivery),
|
if (delivery != null) SourceLine(delivery: delivery),
|
||||||
for (final m in r?.members ?? const <MemberView>[])
|
for (final m in r?.members ?? const <MemberView>[])
|
||||||
@@ -552,23 +497,20 @@ class MembersCard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const Divider(),
|
const Divider(),
|
||||||
if (address != null)
|
if (home)
|
||||||
|
const YourInvite()
|
||||||
|
else if (address != null && address.isNotEmpty)
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(12, 0, 12, 8),
|
padding: const EdgeInsets.fromLTRB(12, 0, 12, 8),
|
||||||
child: Align(
|
child: Align(
|
||||||
alignment: Alignment.centerLeft,
|
alignment: Alignment.centerLeft,
|
||||||
child: TextButton.icon(
|
child: TextButton.icon(
|
||||||
key: const ValueKey('copy-invite'),
|
key: const ValueKey('copy-invite'),
|
||||||
onPressed: () => copyToClipboard(address, done: 'Invite address copied'),
|
onPressed: () => copyToClipboard(context, address, done: 'Invite address 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 address'),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
|
||||||
else
|
|
||||||
_HostInvite(
|
|
||||||
port: session.lastForm.hostPort,
|
|
||||||
lanAddresses: lanAddresses,
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -592,7 +534,7 @@ class _MemberTile extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final coord = SessionState.instance.coordinator;
|
final coord = AppScope.of(context).coordinator;
|
||||||
final m = member;
|
final m = member;
|
||||||
final since = m.goneSince;
|
final since = m.goneSince;
|
||||||
final status = m.presence == Presence.gone
|
final status = m.presence == Presence.gone
|
||||||
@@ -618,6 +560,7 @@ class _MemberTile extends StatelessWidget {
|
|||||||
crossAxisAlignment: WrapCrossAlignment.center,
|
crossAxisAlignment: WrapCrossAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
Text(info.self ? '${m.name} (you)' : m.name),
|
Text(info.self ? '${m.name} (you)' : m.name),
|
||||||
|
if (m.app.isNotEmpty) VersionText(key: ValueKey('version-${m.id}'), version: m.app),
|
||||||
if (info.host) const Pill('host', Colors.amber),
|
if (info.host) const Pill('host', Colors.amber),
|
||||||
if (info.relaying)
|
if (info.relaying)
|
||||||
const Pill('passing the video on', Colors.lightBlueAccent),
|
const Pill('passing the video on', Colors.lightBlueAccent),
|
||||||
@@ -651,76 +594,119 @@ class _MemberTile extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Hosting: the addresses friends on this network can join, the first
|
/// A Starsync version, quiet ("1.3.0"), amber with a hint when it is not
|
||||||
/// one behind "Copy invite address", and what friends elsewhere need.
|
/// the one this app runs; [label] before it ("Server 1.3.0").
|
||||||
class _HostInvite extends StatefulWidget {
|
class VersionText extends StatelessWidget {
|
||||||
final String port;
|
final String version;
|
||||||
final Future<List<String>> Function(String port) lanAddresses;
|
final String? label;
|
||||||
const _HostInvite({required this.port, required this.lanAddresses});
|
const VersionText({super.key, required this.version, this.label});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<_HostInvite> createState() => _HostInviteState();
|
Widget build(BuildContext context) {
|
||||||
|
final scheme = Theme.of(context).colorScheme;
|
||||||
|
final differs = versionDiffers(version);
|
||||||
|
final text = Text(
|
||||||
|
[?label, version].join(' '),
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 11,
|
||||||
|
color: differs ? Colors.amber.shade300 : scheme.onSurfaceVariant.withValues(alpha: 0.8),
|
||||||
|
fontWeight: differs ? FontWeight.w600 : FontWeight.w400,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (!differs) return text;
|
||||||
|
return Tooltip(message: 'You run ${shortVersion()}', child: text);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _HostInviteState extends State<_HostInvite> {
|
/// [version] is not the one this app runs (a dev run matches anything:
|
||||||
late final Future<List<String>> _addresses = widget.lanAddresses(widget.port);
|
/// it has no version of its own).
|
||||||
|
bool versionDiffers(String version, {String own = appVersion}) =>
|
||||||
|
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
|
||||||
|
/// (Copy invite), a hint for friends outside the network and Stop
|
||||||
|
/// sharing; not shared, Invite (opens it and copies the invite).
|
||||||
|
class YourInvite extends StatelessWidget {
|
||||||
|
const YourInvite({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final muted = Theme.of(context).colorScheme.onSurfaceVariant;
|
final muted = Theme.of(context).colorScheme.onSurfaceVariant;
|
||||||
return FutureBuilder<List<String>>(
|
final graph = AppScope.of(context);
|
||||||
future: _addresses,
|
final yours = graph.yourServer;
|
||||||
builder: (context, snap) {
|
return SignalBuilder(builder: (context) {
|
||||||
final list = snap.data ?? const <String>[];
|
final invite = yours.shared.value;
|
||||||
void copy(String a) => copyToClipboard(a, done: 'Invite address copied');
|
final busy = yours.busy.value || yours.port.value == null;
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(12, 0, 12, 12),
|
padding: const EdgeInsets.fromLTRB(12, 0, 12, 12),
|
||||||
child: Column(
|
child: Column(
|
||||||
key: const ValueKey('host-invite'),
|
key: const ValueKey('your-invite'),
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
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: [
|
children: [
|
||||||
TextButton.icon(
|
TextButton.icon(
|
||||||
key: const ValueKey('copy-invite'),
|
key: const ValueKey('copy-invite'),
|
||||||
onPressed: list.isEmpty ? null : () => copy(list.first),
|
onPressed: () => copyToClipboard(context, invite.text, done: 'Invite 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'),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
key: const ValueKey('stop-sharing'),
|
||||||
|
onPressed: busy ? null : () => yours.stopSharing(),
|
||||||
|
child: const Text('Stop sharing'),
|
||||||
),
|
),
|
||||||
if (snap.connectionState != ConnectionState.done)
|
|
||||||
const SizedBox.shrink()
|
|
||||||
else if (list.isEmpty)
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
|
||||||
child: Text('No network address found', style: TextStyle(color: muted)),
|
|
||||||
)
|
|
||||||
else
|
|
||||||
for (final a in list)
|
|
||||||
InkWell(
|
|
||||||
key: ValueKey('invite-$a'),
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
onTap: () => copy(a),
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Expanded(child: Text(a, style: const TextStyle(fontFamily: 'monospace'))),
|
|
||||||
Icon(Icons.copy, size: 14, color: muted),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
|
||||||
),
|
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(8, 6, 8, 0),
|
padding: const EdgeInsets.fromLTRB(8, 2, 8, 0),
|
||||||
child: Text(
|
child: Text(
|
||||||
'Works for friends on your network. Friends elsewhere need port '
|
'Works for friends on your network. Friends elsewhere need port ${invite.port} '
|
||||||
'${widget.port} forwarded on your router, or a server they can '
|
'forwarded on your router, or a server they can all reach (like a VPS). '
|
||||||
'all reach (like a VPS).',
|
'Stop sharing sends everyone else out; you and Up next stay.',
|
||||||
style: TextStyle(color: muted, fontSize: 12),
|
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}');
|
||||||
|
}
|
||||||
@@ -1,154 +1,22 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:sync_relay_core/core.dart';
|
|
||||||
|
|
||||||
import '../session/health.dart';
|
|
||||||
import '../session/party.dart';
|
|
||||||
import '../session/session_coordinator.dart';
|
import '../session/session_coordinator.dart';
|
||||||
import '../state/session_state.dart';
|
import '../app/app_graph.dart';
|
||||||
import '../widgets/app_menu.dart' show AppGlass;
|
import '../widgets/app_menu.dart' show AppGlass;
|
||||||
import 'avatar.dart';
|
import 'avatar.dart';
|
||||||
import 'motion.dart';
|
import 'motion.dart';
|
||||||
|
|
||||||
/// What the player's status pill says, most pressing first.
|
export '../session/status.dart' show StatusAction, StatusKind, StatusLevel, StatusLine, nameList;
|
||||||
enum StatusKind { reconnecting, problem, connecting, finished, loading, you, cannotPlay, waiting }
|
|
||||||
|
|
||||||
/// The pill's button: stop reconnecting, retry (reconnect), reload the
|
/// The pill's line from the session's [SessionCoordinator.status]
|
||||||
/// video (your player could not open it), or mark yourself ready (the
|
/// (tracked: call inside a builder); "Connecting…" only once it has taken
|
||||||
/// whole pill).
|
/// [PlayerStatus.connectGrace] ([slowConnect]).
|
||||||
enum StatusAction { cancel, retry, reload, ready }
|
StatusLine? playerStatusNow(SessionCoordinator coord, {bool slowConnect = false}) {
|
||||||
|
final line = coord.status.value.line;
|
||||||
/// One status line: [text], a muted [detail], a coloured [tone] for
|
if (line?.kind == StatusKind.connecting && !slowConnect) return null;
|
||||||
/// problems (warn, error), the members it is about ([who]; [self] for you)
|
return line;
|
||||||
/// and a loading [progress] (0..1).
|
|
||||||
class StatusView {
|
|
||||||
final StatusKind kind;
|
|
||||||
final String text;
|
|
||||||
final String? detail;
|
|
||||||
final HealthSeverity? tone;
|
|
||||||
final List<MemberView> who;
|
|
||||||
final bool self;
|
|
||||||
final double? progress;
|
|
||||||
final StatusAction? action;
|
|
||||||
/// The mini player's shorter line.
|
|
||||||
final String short;
|
|
||||||
const StatusView(
|
|
||||||
this.kind,
|
|
||||||
this.text, {
|
|
||||||
this.detail,
|
|
||||||
this.tone,
|
|
||||||
this.who = const [],
|
|
||||||
this.self = false,
|
|
||||||
this.progress,
|
|
||||||
this.action,
|
|
||||||
String? short,
|
|
||||||
}) : short = short ?? text;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// "Bob", "Bob and Mika", "Bob, Mika and Cleo", "Bob, Mika and 3 others".
|
|
||||||
String nameList(List<String> names) {
|
|
||||||
if (names.length <= 1) return names.join();
|
|
||||||
if (names.length <= 3) return '${names.sublist(0, names.length - 1).join(', ')} and ${names.last}';
|
|
||||||
return '${names[0]}, ${names[1]} and ${names.length - 2} others';
|
|
||||||
}
|
|
||||||
|
|
||||||
String _sentence(String s) => s.isEmpty ? s : s[0].toUpperCase() + s.substring(1);
|
|
||||||
|
|
||||||
/// The player's status from the session's signals; null when there is
|
|
||||||
/// nothing to say (playing, connected, everyone ready). [slowConnect]:
|
|
||||||
/// connecting has taken over 2 s. [playerError]: why your player cannot
|
|
||||||
/// open the media (the coordinator's `playerError`).
|
|
||||||
StatusView? playerStatusFor({
|
|
||||||
required HealthStatus health,
|
|
||||||
bool reconnecting = false,
|
|
||||||
bool unstable = false,
|
|
||||||
StateBroadcast? state,
|
|
||||||
MemberView? self,
|
|
||||||
bool slowConnect = false,
|
|
||||||
String playerError = '',
|
|
||||||
}) {
|
|
||||||
if (reconnecting) {
|
|
||||||
return const StatusView(StatusKind.reconnecting, 'Reconnecting…',
|
|
||||||
tone: HealthSeverity.warn, action: StatusAction.cancel);
|
|
||||||
}
|
|
||||||
if (unstable) {
|
|
||||||
return const StatusView(StatusKind.reconnecting, 'Connection unstable…', tone: HealthSeverity.warn);
|
|
||||||
}
|
|
||||||
final away = self?.presence == Presence.away;
|
|
||||||
if (playerError.isNotEmpty && state?.media != null && !away) {
|
|
||||||
return StatusView(StatusKind.problem, "Can't play this: $playerError",
|
|
||||||
tone: HealthSeverity.error, action: StatusAction.reload, short: "Can't play this");
|
|
||||||
}
|
|
||||||
if (health.severity == HealthSeverity.warn || health.severity == HealthSeverity.error) {
|
|
||||||
return StatusView(StatusKind.problem, _sentence(health.message),
|
|
||||||
tone: health.severity, action: StatusAction.retry);
|
|
||||||
}
|
|
||||||
if (slowConnect && health.severity == HealthSeverity.info && health.message.startsWith('connecting')) {
|
|
||||||
return const StatusView(StatusKind.connecting, 'Connecting…');
|
|
||||||
}
|
|
||||||
final r = state;
|
|
||||||
if (r == null || r.media == null || r.transport == 'playing') return null;
|
|
||||||
// Sitting out: the join-back card says it; nobody waits for you.
|
|
||||||
if (away) return null;
|
|
||||||
if (r.transport == 'finished') {
|
|
||||||
return const StatusView(StatusKind.finished, 'Finished · seek back or pick the next video', short: 'Finished');
|
|
||||||
}
|
|
||||||
// Members whose player cannot open it: the server does not wait for
|
|
||||||
// them; say why instead of "waiting for".
|
|
||||||
final failed = [
|
|
||||||
for (final m in r.members)
|
|
||||||
if (m.error.isNotEmpty && isPresent(m) && (self == null || m.id != self.id)) m,
|
|
||||||
];
|
|
||||||
// As the server holds playback: someone in the ready set short of the
|
|
||||||
// minimum buffer.
|
|
||||||
final need = r.bufferNeed;
|
|
||||||
for (final m in r.members) {
|
|
||||||
if (m.inSet && m.buffered < need) {
|
|
||||||
final you = self != null && m.id == self.id;
|
|
||||||
final p = (m.buffered / need).clamp(0.0, 1.0);
|
|
||||||
return StatusView(StatusKind.loading, you ? 'Loading the video…' : '${m.name} is loading…',
|
|
||||||
who: [m], self: you, progress: p, detail: '${(p * 100).round()}%');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (failed.isNotEmpty) {
|
|
||||||
final f = failed.first;
|
|
||||||
final who = nameList([for (final m in failed) m.name]);
|
|
||||||
final unready = self != null && !self.ready;
|
|
||||||
return StatusView(StatusKind.cannotPlay, "$who can't play this: ${f.error}",
|
|
||||||
who: failed,
|
|
||||||
short: "$who can't play this",
|
|
||||||
detail: unready ? "Press Space when you're ready" : null,
|
|
||||||
action: unready ? StatusAction.ready : null);
|
|
||||||
}
|
|
||||||
if (self != null && !self.ready) {
|
|
||||||
return StatusView(StatusKind.you, "Press Space when you're ready",
|
|
||||||
who: [self], self: true, action: StatusAction.ready, detail: 'or click here', short: 'Press Space when ready');
|
|
||||||
}
|
|
||||||
// Members sitting out or gone are never waited for nor counted.
|
|
||||||
final active = [for (final m in r.members) if (countsForReady(m)) m];
|
|
||||||
final others = [
|
|
||||||
for (final m in active)
|
|
||||||
if ((self == null || m.id != self.id) && !(m.inSet && m.ready)) m,
|
|
||||||
];
|
|
||||||
if (others.isEmpty) return null;
|
|
||||||
final ready = active.where((m) => m.ready).length;
|
|
||||||
return StatusView(StatusKind.waiting, 'Waiting for ${nameList([for (final m in others) m.name])}',
|
|
||||||
who: others, detail: '$ready/${active.length} ready');
|
|
||||||
}
|
|
||||||
|
|
||||||
/// [playerStatusFor] from the session (tracked: call inside a builder).
|
|
||||||
StatusView? playerStatusNow({bool slowConnect = false}) {
|
|
||||||
final c = SessionState.instance.coordinator;
|
|
||||||
return playerStatusFor(
|
|
||||||
health: c.health.value,
|
|
||||||
reconnecting: c.reconnecting.value,
|
|
||||||
unstable: c.unstable.value,
|
|
||||||
state: c.serverState.value,
|
|
||||||
self: c.selfMember.value,
|
|
||||||
slowConnect: slowConnect,
|
|
||||||
playerError: c.playerError.value,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The player's one status pill (bottom centre): frosted dark glass with
|
/// The player's one status pill (bottom centre): frosted dark glass with
|
||||||
@@ -167,7 +35,7 @@ class PlayerStatus extends StatefulWidget {
|
|||||||
class _PlayerStatusState extends State<PlayerStatus> {
|
class _PlayerStatusState extends State<PlayerStatus> {
|
||||||
Timer? _connectTimer;
|
Timer? _connectTimer;
|
||||||
bool _slow = false;
|
bool _slow = false;
|
||||||
StatusView? _last;
|
StatusLine? _last;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
@@ -176,8 +44,8 @@ class _PlayerStatusState extends State<PlayerStatus> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// "Connecting…" only once it takes a while.
|
// "Connecting…" only once it takes a while.
|
||||||
void _trackConnecting(HealthStatus h) {
|
void _trackConnecting(StatusLine? line) {
|
||||||
final connecting = h.severity == HealthSeverity.info && h.message.startsWith('connecting');
|
final connecting = line?.kind == StatusKind.connecting;
|
||||||
if (!connecting) {
|
if (!connecting) {
|
||||||
_connectTimer?.cancel();
|
_connectTimer?.cancel();
|
||||||
_connectTimer = null;
|
_connectTimer = null;
|
||||||
@@ -205,9 +73,9 @@ class _PlayerStatusState extends State<PlayerStatus> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return PartyWatch(builder: (context) {
|
return PartyWatch(builder: (context) {
|
||||||
final coord = SessionState.instance.coordinator;
|
final coord = AppScope.of(context).coordinator;
|
||||||
_trackConnecting(coord.health.value);
|
_trackConnecting(coord.status.value.line);
|
||||||
final view = playerStatusNow(slowConnect: _slow);
|
final view = playerStatusNow(coord, slowConnect: _slow);
|
||||||
if (view != null) _last = view;
|
if (view != null) _last = view;
|
||||||
final shown = _last;
|
final shown = _last;
|
||||||
final ready = view?.action == StatusAction.ready;
|
final ready = view?.action == StatusAction.ready;
|
||||||
@@ -262,7 +130,7 @@ class PlayerGlass extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _StatusLine extends StatelessWidget {
|
class _StatusLine extends StatelessWidget {
|
||||||
final StatusView view;
|
final StatusLine view;
|
||||||
final SessionCoordinator coord;
|
final SessionCoordinator coord;
|
||||||
final ValueChanged<StatusAction> onAction;
|
final ValueChanged<StatusAction> onAction;
|
||||||
const _StatusLine({super.key, required this.view, required this.coord, required this.onAction});
|
const _StatusLine({super.key, required this.view, required this.coord, required this.onAction});
|
||||||
@@ -273,12 +141,13 @@ class _StatusLine extends StatelessWidget {
|
|||||||
final v = view;
|
final v = view;
|
||||||
switch (v.kind) {
|
switch (v.kind) {
|
||||||
case StatusKind.problem:
|
case StatusKind.problem:
|
||||||
final error = v.tone == HealthSeverity.error;
|
final error = v.level == StatusLevel.error;
|
||||||
return Icon(error ? Icons.error_outline_rounded : Icons.warning_amber_rounded,
|
return Icon(error ? Icons.error_outline_rounded : Icons.warning_amber_rounded,
|
||||||
size: 20, color: error ? Colors.redAccent.shade100 : Colors.amber);
|
size: 20, color: error ? Colors.redAccent.shade100 : Colors.amber);
|
||||||
case StatusKind.reconnecting:
|
case StatusKind.reconnecting:
|
||||||
return const _Spinner(color: Colors.amber);
|
return const _Spinner(color: Colors.amber);
|
||||||
case StatusKind.connecting:
|
case StatusKind.connecting:
|
||||||
|
case StatusKind.fetching:
|
||||||
return const _Spinner(color: Colors.white70);
|
return const _Spinner(color: Colors.white70);
|
||||||
case StatusKind.finished:
|
case StatusKind.finished:
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import 'package:sync_relay_core/core.dart' show Presence;
|
|||||||
|
|
||||||
import '../jellyfin/accounts.dart';
|
import '../jellyfin/accounts.dart';
|
||||||
import '../session/identity.dart';
|
import '../session/identity.dart';
|
||||||
import '../state/session_state.dart';
|
import '../app/app_graph.dart';
|
||||||
import '../update/update_controller.dart';
|
import '../update/update_controller.dart';
|
||||||
import 'avatar.dart';
|
import 'avatar.dart';
|
||||||
import 'motion.dart';
|
import 'motion.dart';
|
||||||
@@ -74,11 +74,12 @@ class _ProfileCardState extends State<ProfileCard> {
|
|||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
width: 360,
|
width: 360,
|
||||||
child: SignalBuilder(builder: (context) {
|
child: SignalBuilder(builder: (context) {
|
||||||
final session = SessionState.instance;
|
final graph = AppScope.of(context);
|
||||||
final coord = session.coordinator;
|
final coord = graph.coordinator;
|
||||||
final online = session.route.value == AppRoute.session;
|
// Alone it is a plain player: no ring, no crown.
|
||||||
|
final online = graph.navigation.online.value && !coord.alone.value;
|
||||||
final self = online ? coord.selfMember.value : null;
|
final self = online ? coord.selfMember.value : null;
|
||||||
final hosting = online && !session.solo.value && coord.hostFile != null;
|
final hosting = online && graph.navigation.home.value;
|
||||||
final color = _p.color.value;
|
final color = _p.color.value;
|
||||||
final png = _p.picture.value;
|
final png = _p.picture.value;
|
||||||
final out = self?.presence == Presence.away;
|
final out = self?.presence == Presence.away;
|
||||||
@@ -164,7 +165,7 @@ class _ProfileCardState extends State<ProfileCard> {
|
|||||||
runSpacing: 4,
|
runSpacing: 4,
|
||||||
children: [
|
children: [
|
||||||
if (hosting) const Pill('host', Colors.amber),
|
if (hosting) const Pill('host', Colors.amber),
|
||||||
Pill(online ? 'in a server' : 'offline',
|
Pill(online ? 'with others' : 'just you',
|
||||||
online ? Colors.greenAccent : Colors.grey),
|
online ? Colors.greenAccent : Colors.grey),
|
||||||
if (online)
|
if (online)
|
||||||
Pill(out ? 'sitting out' : (self?.ready ?? false) ? 'ready' : 'not ready',
|
Pill(out ? 'sitting out' : (self?.ready ?? false) ? 'ready' : 'not ready',
|
||||||
@@ -231,7 +232,7 @@ class _ProfileCardState extends State<ProfileCard> {
|
|||||||
child: Text(
|
child: Text(
|
||||||
online
|
online
|
||||||
? 'Name and picture update for everyone in the server right away. Colour is yours only.'
|
? 'Name and picture update for everyone in the server right away. Colour is yours only.'
|
||||||
: 'Others see your name and picture when you join. Colour is yours only.',
|
: 'Others see your name and picture when they join you or you join them. Colour is yours only.',
|
||||||
style: small,
|
style: small,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -331,7 +332,7 @@ class _CropPickerState extends State<CropPicker> {
|
|||||||
Future<void> _pick() async {
|
Future<void> _pick() async {
|
||||||
final List<String> files;
|
final List<String> files;
|
||||||
try {
|
try {
|
||||||
files = await SessionState.instance.ports.folders.pickFiles(
|
files = await AppScope.of(context).ports.folders.pickFiles(
|
||||||
dialogTitle: 'Pick a picture',
|
dialogTitle: 'Pick a picture',
|
||||||
extensions: const ['png', 'jpg', 'jpeg', 'webp', 'gif', 'bmp'],
|
extensions: const ['png', 'jpg', 'jpeg', 'webp', 'gif', 'bmp'],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -8,9 +8,12 @@ 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/session_controls.dart';
|
import '../controls/session_controls.dart';
|
||||||
import '../controls/volume.dart';
|
import '../controls/volume.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';
|
||||||
@@ -18,7 +21,7 @@ import '../screens/session/player_controls.dart';
|
|||||||
import '../session/app_events.dart' show ShaderMessage;
|
import '../session/app_events.dart' show ShaderMessage;
|
||||||
import '../session/session_coordinator.dart';
|
import '../session/session_coordinator.dart';
|
||||||
import '../shaders/shader_presets.dart';
|
import '../shaders/shader_presets.dart';
|
||||||
import '../state/session_state.dart';
|
import '../app/app_graph.dart';
|
||||||
import '../state/settings.dart';
|
import '../state/settings.dart';
|
||||||
import '../widgets/app_keys.dart';
|
import '../widgets/app_keys.dart';
|
||||||
import '../widgets/text_focus.dart';
|
import '../widgets/text_focus.dart';
|
||||||
@@ -63,6 +66,9 @@ class SessionPlayer extends StatefulWidget {
|
|||||||
/// Window buttons in the fullscreen top bar (off in tests: they need
|
/// Window buttons in the fullscreen top bar (off in tests: they need
|
||||||
/// window_manager).
|
/// window_manager).
|
||||||
final bool windowControls;
|
final bool windowControls;
|
||||||
|
/// Follows whether the full player's controls are up (the shell lifts
|
||||||
|
/// its toasts above them); the player keeps its own when null.
|
||||||
|
final ValueNotifier<bool>? controlsShown;
|
||||||
const SessionPlayer({
|
const SessionPlayer({
|
||||||
super.key,
|
super.key,
|
||||||
required this.mode,
|
required this.mode,
|
||||||
@@ -79,6 +85,7 @@ class SessionPlayer extends StatefulWidget {
|
|||||||
this.unread = 0,
|
this.unread = 0,
|
||||||
this.videoBuilder,
|
this.videoBuilder,
|
||||||
this.windowControls = true,
|
this.windowControls = true,
|
||||||
|
this.controlsShown,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -96,7 +103,8 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
|||||||
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 _controlsShown = ValueNotifier(true);
|
final _ownShown = ValueNotifier(true);
|
||||||
|
ValueNotifier<bool> get _controlsShown => widget.controlsShown ?? _ownShown;
|
||||||
// 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();
|
||||||
@@ -117,12 +125,14 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
|||||||
onLeave: () => widget.onLeave(),
|
onLeave: () => widget.onLeave(),
|
||||||
);
|
);
|
||||||
|
|
||||||
SessionCoordinator get _coord => SessionState.instance.coordinator;
|
late final SessionCoordinator _coord = AppScope.of(context).coordinator;
|
||||||
bool get _full => widget.mode == PlayerMode.full;
|
bool get _full => widget.mode == PlayerMode.full;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
// The model reads it in dispose too, when the tree is no longer there.
|
||||||
|
_model;
|
||||||
_coord.jellyfin.ensureLoaded();
|
_coord.jellyfin.ensureLoaded();
|
||||||
HardwareKeyboard.instance.addHandler(_globalKey);
|
HardwareKeyboard.instance.addHandler(_globalKey);
|
||||||
// Tab (AppKeys) reaches the chat through here in fullscreen.
|
// Tab (AppKeys) reaches the chat through here in fullscreen.
|
||||||
@@ -155,7 +165,7 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
|||||||
_stopShaderWatch();
|
_stopShaderWatch();
|
||||||
_stopPopoverWatch();
|
_stopPopoverWatch();
|
||||||
_noFocus.dispose();
|
_noFocus.dispose();
|
||||||
_controlsShown.dispose();
|
_ownShown.dispose();
|
||||||
_hold.dispose();
|
_hold.dispose();
|
||||||
_model.dispose();
|
_model.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
@@ -168,8 +178,9 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
|||||||
/// app-wide ([AppKeys]).
|
/// app-wide ([AppKeys]).
|
||||||
bool _globalKey(KeyEvent event) {
|
bool _globalKey(KeyEvent event) {
|
||||||
if (event is! KeyDownEvent || !mounted) return false;
|
if (event is! KeyDownEvent || !mounted) return false;
|
||||||
if (SessionState.instance.route.peek() != AppRoute.session) return false;
|
if (!AppScope.of(context).navigation.online.peek()) return false;
|
||||||
// Mini: Esc is the last step out, asking to leave the server.
|
// Mini: Esc is the last step out (a friend's server: asking to leave
|
||||||
|
// it; yours: the player closes).
|
||||||
if (!_full) {
|
if (!_full) {
|
||||||
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;
|
||||||
@@ -282,7 +293,9 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
|||||||
_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 = _full ? _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).
|
||||||
@@ -296,7 +309,7 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
|||||||
if (_full) ...[
|
if (_full) ...[
|
||||||
_blockedCard(),
|
_blockedCard(),
|
||||||
_actionArea(),
|
_actionArea(),
|
||||||
if (widget.fullscreen) ...[if (!SessionState.instance.solo.value) _pausedParty(), _unreadChip()],
|
if (widget.fullscreen) ...[if (!_coord.alone.value) _pausedParty(), _unreadChip()],
|
||||||
_volumeReadout(),
|
_volumeReadout(),
|
||||||
] else
|
] else
|
||||||
_miniOverlay(),
|
_miniOverlay(),
|
||||||
@@ -313,7 +326,7 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
|||||||
|
|
||||||
Widget _video(VideoControlsBuilder? controls) => Video(
|
Widget _video(VideoControlsBuilder? controls) => Video(
|
||||||
key: _videoKey,
|
key: _videoKey,
|
||||||
controller: _coord.playback.video,
|
controller: videoControllerOf(_coord.playback.video),
|
||||||
subtitleViewConfiguration: const SubtitleViewConfiguration(visible: false),
|
subtitleViewConfiguration: const SubtitleViewConfiguration(visible: false),
|
||||||
controls: controls,
|
controls: controls,
|
||||||
);
|
);
|
||||||
@@ -410,16 +423,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;
|
||||||
@@ -428,18 +439,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,
|
||||||
@@ -573,11 +572,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),
|
||||||
@@ -594,58 +595,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(
|
||||||
|
padding: const EdgeInsets.fromLTRB(12, 6, 6, 0),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
const SizedBox(width: 10),
|
const Expanded(
|
||||||
const Expanded(child: NowPlayingTitle(cover: false, compact: true)),
|
child: Padding(
|
||||||
IconButton(
|
padding: EdgeInsets.only(top: 4),
|
||||||
focusNode: _noFocus,
|
child: NowPlayingTitle(cover: false, compact: true),
|
||||||
onPressed: widget.onExpand,
|
),
|
||||||
|
),
|
||||||
|
MinimalButton(
|
||||||
|
icon: Icons.open_in_full_rounded,
|
||||||
tooltip: 'Expand',
|
tooltip: 'Expand',
|
||||||
icon: const Icon(Icons.open_in_full, color: Colors.white, size: 18),
|
scale: scale,
|
||||||
),
|
onPressed: widget.onExpand),
|
||||||
IconButton(
|
MinimalButton(
|
||||||
focusNode: _noFocus,
|
icon: Icons.close_rounded,
|
||||||
onPressed: widget.onClose,
|
|
||||||
tooltip: 'Sit out (stay in the server)',
|
tooltip: 'Sit out (stay in the server)',
|
||||||
icon: const Icon(Icons.close, color: Colors.white, size: 18),
|
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;
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(8, 0, 12, 2),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
IconButton(
|
if (status != null && status.isNotEmpty)
|
||||||
focusNode: _noFocus,
|
Padding(
|
||||||
tooltip: ready ? 'Ready (click: not ready)' : 'Not ready (click: ready)',
|
padding: const EdgeInsets.fromLTRB(4, 0, 0, 2),
|
||||||
onPressed: coord.toggleReady,
|
child: Text(status,
|
||||||
icon: Icon(
|
|
||||||
ready ? Icons.check_circle : Icons.radio_button_unchecked,
|
|
||||||
color: ready ? Colors.greenAccent : Colors.white,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Text('${formatClock(pos)} / ${formatClock(dur)}',
|
|
||||||
style: const TextStyle(color: Colors.white70, fontSize: 12)),
|
|
||||||
const Spacer(),
|
|
||||||
Flexible(
|
|
||||||
child: Text(playerStatusNow()?.short ?? '',
|
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
style: const TextStyle(color: Colors.amber, fontSize: 12)),
|
style: const TextStyle(color: Color(0xFFFFC857), fontSize: 12)),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 10),
|
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()])),
|
||||||
],
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
_MiniSeekBar(onSeek: (d) => playback.player.seek(d)),
|
_MiniSeekBar(onSeek: playback.userSeek),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -659,6 +678,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).
|
||||||
@@ -773,7 +795,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;
|
||||||
@@ -781,27 +805,60 @@ class _MiniSeekBar extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final playback = SessionState.instance.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(
|
||||||
|
cursor: SystemMouseCursors.click,
|
||||||
|
child: GestureDetector(
|
||||||
behavior: HitTestBehavior.opaque,
|
behavior: HitTestBehavior.opaque,
|
||||||
onTapDown: (e) => seekAt(e.localPosition.dx, box.maxWidth),
|
onTapDown: (e) => seekAt(e.localPosition.dx, box.maxWidth),
|
||||||
onHorizontalDragEnd: null,
|
|
||||||
onHorizontalDragUpdate: (e) => seekAt(e.localPosition.dx, box.maxWidth),
|
onHorizontalDragUpdate: (e) => seekAt(e.localPosition.dx, box.maxWidth),
|
||||||
child: Padding(padding: const EdgeInsets.symmetric(vertical: 6), child: bar),
|
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;
|
||||||
|
}
|
||||||
@@ -13,10 +13,11 @@ 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 '../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';
|
||||||
import '../state/session_state.dart';
|
import '../app/app_graph.dart';
|
||||||
import '../state/settings.dart';
|
import '../state/settings.dart';
|
||||||
import '../update/update_controller.dart';
|
import '../update/update_controller.dart';
|
||||||
import '../widgets/app_menu.dart';
|
import '../widgets/app_menu.dart';
|
||||||
@@ -85,7 +86,7 @@ class SettingsTab extends StatelessWidget {
|
|||||||
];
|
];
|
||||||
case SettingsSection.network:
|
case SettingsSection.network:
|
||||||
return [
|
return [
|
||||||
const HostDefaultsPanel(),
|
const YourServerPanel(),
|
||||||
const RecentServersPanel(),
|
const RecentServersPanel(),
|
||||||
SettingsPanel(
|
SettingsPanel(
|
||||||
title: 'Watching',
|
title: 'Watching',
|
||||||
@@ -151,7 +152,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:
|
||||||
@@ -228,6 +229,19 @@ class SettingsTab extends StatelessWidget {
|
|||||||
Text(section.label, style: Theme.of(context).textTheme.headlineSmall),
|
Text(section.label, style: Theme.of(context).textTheme.headlineSmall),
|
||||||
const SizedBox(height: 14),
|
const SizedBox(height: 14),
|
||||||
..._page(context),
|
..._page(context),
|
||||||
|
// The version, quietly (also the Settings tab's
|
||||||
|
// tooltip and About).
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 8),
|
||||||
|
child: SelectableText(
|
||||||
|
'$appName ${displayVersion()}',
|
||||||
|
key: const ValueKey('settings-version'),
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 11,
|
||||||
|
color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.7),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -244,16 +258,18 @@ class SettingsTab extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Party & network: what the Host card starts with.
|
/// Party & network: your server's port and password, used when you
|
||||||
class HostDefaultsPanel extends StatefulWidget {
|
/// Invite (the next time sharing starts).
|
||||||
const HostDefaultsPanel({super.key});
|
class YourServerPanel extends StatefulWidget {
|
||||||
|
const YourServerPanel({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<HostDefaultsPanel> createState() => _HostDefaultsPanelState();
|
State<YourServerPanel> createState() => _YourServerPanelState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _HostDefaultsPanelState extends State<HostDefaultsPanel> {
|
class _YourServerPanelState extends State<YourServerPanel> {
|
||||||
final _form = SessionState.instance.lastForm;
|
late final _forms = AppScope.of(context).forms;
|
||||||
|
late final _form = _forms.form;
|
||||||
late final _port = TextEditingController(text: _form.hostPort);
|
late final _port = TextEditingController(text: _form.hostPort);
|
||||||
late final _secret = TextEditingController(text: _form.hostSecret);
|
late final _secret = TextEditingController(text: _form.hostSecret);
|
||||||
bool _show = false;
|
bool _show = false;
|
||||||
@@ -272,22 +288,22 @@ class _HostDefaultsPanelState extends State<HostDefaultsPanel> {
|
|||||||
|
|
||||||
Future<void> _save() async {
|
Future<void> _save() async {
|
||||||
try {
|
try {
|
||||||
await SessionState.instance.saveForm();
|
await _forms.save();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('save host defaults failed: $e');
|
debugPrint('save your server settings failed: $e');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return SettingsPanel(
|
return SettingsPanel(
|
||||||
title: 'Hosting',
|
title: 'Your server',
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
SettingRow(
|
SettingRow(
|
||||||
label: 'Default port',
|
label: 'Port',
|
||||||
help: 'Where your server listens when you host; friends dial it',
|
help: 'Where friends reach your server once you Invite (the next time you start sharing)',
|
||||||
control: SizedBox(
|
control: SizedBox(
|
||||||
width: 120,
|
width: 120,
|
||||||
child: TextField(
|
child: TextField(
|
||||||
@@ -306,7 +322,8 @@ class _HostDefaultsPanelState extends State<HostDefaultsPanel> {
|
|||||||
),
|
),
|
||||||
SettingRow(
|
SettingRow(
|
||||||
label: 'Password',
|
label: 'Password',
|
||||||
help: 'Asked of everyone joining your server; blank lets in anyone who reaches the port',
|
help: 'Asked of every friend joining your server; blank lets in anyone who reaches the port '
|
||||||
|
'(the next time you start sharing)',
|
||||||
control: SizedBox(
|
control: SizedBox(
|
||||||
width: 220,
|
width: 220,
|
||||||
child: TextField(
|
child: TextField(
|
||||||
@@ -341,16 +358,16 @@ class RecentServersPanel extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final session = SessionState.instance;
|
final forms = AppScope.of(context).forms;
|
||||||
final muted = Theme.of(context).colorScheme.onSurfaceVariant;
|
final muted = Theme.of(context).colorScheme.onSurfaceVariant;
|
||||||
return SettingsPanel(
|
return SettingsPanel(
|
||||||
title: 'Recent servers',
|
title: 'Recent servers',
|
||||||
child: SignalBuilder(builder: (context) {
|
child: SignalBuilder(builder: (context) {
|
||||||
final list = session.recents.value;
|
final list = forms.recents.value;
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
Text('Join and Host offer these in one click; remove the ones you no longer use',
|
Text('Join offers these in one click; remove the ones you no longer use',
|
||||||
style: TextStyle(color: muted, fontSize: 12)),
|
style: TextStyle(color: muted, fontSize: 12)),
|
||||||
if (list.isEmpty)
|
if (list.isEmpty)
|
||||||
Padding(
|
Padding(
|
||||||
@@ -361,13 +378,13 @@ class RecentServersPanel extends StatelessWidget {
|
|||||||
ListTile(
|
ListTile(
|
||||||
key: ValueKey('recent-${r.key}'),
|
key: ValueKey('recent-${r.key}'),
|
||||||
contentPadding: EdgeInsets.zero,
|
contentPadding: EdgeInsets.zero,
|
||||||
leading: Icon(r.hosted ? Icons.cast_outlined : Icons.dns_outlined),
|
leading: const Icon(Icons.dns_outlined),
|
||||||
title: Text(r.hosted ? 'Hosted on port ${r.port}' : '${r.host}:${r.port}'),
|
title: Text('${r.host}:${r.port}'),
|
||||||
subtitle: r.secret.isEmpty ? null : const Text('password saved'),
|
subtitle: r.secret.isEmpty ? null : const Text('password saved'),
|
||||||
trailing: IconButton(
|
trailing: IconButton(
|
||||||
tooltip: 'Remove',
|
tooltip: 'Remove',
|
||||||
icon: const Icon(Icons.close, size: 18),
|
icon: const Icon(Icons.close, size: 18),
|
||||||
onPressed: () => session.forgetServer(r),
|
onPressed: () => forms.forget(r),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -389,7 +406,7 @@ class YtDlpSettings extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _YtDlpSettingsState extends State<YtDlpSettings> {
|
class _YtDlpSettingsState extends State<YtDlpSettings> {
|
||||||
late final Future<YtDlp> _y = widget.ytDlp ?? SessionState.instance.ports.links.ytDlp;
|
late final Future<YtDlp> _y = widget.ytDlp ?? AppScope.of(context).ports.links.ytDlp;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -474,7 +491,7 @@ class _YtDlpCookieSettingsState extends State<YtDlpCookieSettings> {
|
|||||||
Future<void> _pickFile() async {
|
Future<void> _pickFile() async {
|
||||||
final List<String> files;
|
final List<String> files;
|
||||||
try {
|
try {
|
||||||
files = await SessionState.instance.ports.folders.pickFiles(
|
files = await AppScope.of(context).ports.folders.pickFiles(
|
||||||
dialogTitle: 'Pick a cookies.txt or a JSON cookie export',
|
dialogTitle: 'Pick a cookies.txt or a JSON cookie export',
|
||||||
extensions: const ['txt', 'json'],
|
extensions: const ['txt', 'json'],
|
||||||
);
|
);
|
||||||
@@ -534,7 +551,7 @@ class _YtDlpCookieSettingsState extends State<YtDlpCookieSettings> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final scheme = Theme.of(context).colorScheme;
|
final scheme = Theme.of(context).colorScheme;
|
||||||
return SessionState.instance.ports.shell.dropTarget(
|
return AppScope.of(context).ports.shell.dropTarget(
|
||||||
onEnter: () => setState(() => _dragging = true),
|
onEnter: () => setState(() => _dragging = true),
|
||||||
onExit: () => setState(() => _dragging = false),
|
onExit: () => setState(() => _dragging = false),
|
||||||
onDrop: (paths, _) {
|
onDrop: (paths, _) {
|
||||||
@@ -730,8 +747,9 @@ class _AnimeFlowCardState extends State<AnimeFlowCard> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What pops up (chat pop-ups: [AppSettings.chatPopups]) and what makes
|
/// What pops up (chat pop-ups: [AppSettings.chatPopups]), what reaches
|
||||||
/// a sound.
|
/// you while you sit out or the window is in the background
|
||||||
|
/// ([AppSettings.notifyPeople]), and what makes a sound.
|
||||||
class NotificationsInfo extends StatelessWidget {
|
class NotificationsInfo extends StatelessWidget {
|
||||||
const NotificationsInfo({super.key});
|
const NotificationsInfo({super.key});
|
||||||
|
|
||||||
@@ -753,8 +771,9 @@ class NotificationsInfo extends StatelessWidget {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
line('Joins, leaves and video changes',
|
line('Joins, leaves, lost connections and video changes',
|
||||||
'Pop up bottom left for 5 s, with the person\'s picture'),
|
"Pop up bottom left for 5 s with the person's picture, above the player's controls"),
|
||||||
|
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),
|
||||||
child: Row(
|
child: Row(
|
||||||
@@ -781,7 +800,30 @@ class NotificationsInfo extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
line('Sat out or nudged by someone', 'Only on your end, with their picture'),
|
line('Sat out or nudged by someone', 'Only on your end, with their picture'),
|
||||||
line('Ready, seeks and pauses', 'Only in Chat & activity (Tab), never as a pop-up'),
|
line('Ready, seeks and pauses', 'Only in Chat & activity (Tab), never as a pop-up'),
|
||||||
line('App notices', 'Shader changes, auto sit-out setting you away'),
|
line('Watching alone', 'No pop-ups or sounds about the party: there is nobody else'),
|
||||||
|
line('App notices', 'Shader changes, copies, errors, auto sit-out setting you away'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SettingsPanel(
|
||||||
|
title: 'Sitting out and in the background',
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
line('What still reaches you',
|
||||||
|
'While you sit out, only chat, nudges, "starting now" and Up next pop up. With the window '
|
||||||
|
'in the background or minimised they also go out as system notifications; a click brings '
|
||||||
|
'the window up (a chat one opens the chat)'),
|
||||||
|
SignalBuilder(
|
||||||
|
builder: (context) => SwitchListTile(
|
||||||
|
key: const ValueKey('notify-people'),
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
title: const Text('Joins and leaves too'),
|
||||||
|
subtitle: Text('Who joins, leaves, loses connection or comes back', style: muted),
|
||||||
|
value: AppSettings.instance.notifyPeople.value,
|
||||||
|
onChanged: AppSettings.instance.setNotifyPeople,
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -796,7 +838,8 @@ class NotificationsInfo extends StatelessWidget {
|
|||||||
class SoundSettings extends StatelessWidget {
|
class SoundSettings extends StatelessWidget {
|
||||||
const SoundSettings({super.key});
|
const SoundSettings({super.key});
|
||||||
|
|
||||||
void _preview(SfxKind kind) => unawaited(SessionState.instance.sfx.preview(kind, readyIndex: 2));
|
void _preview(BuildContext context, SfxKind kind) =>
|
||||||
|
unawaited(AppScope.of(context).sfx.preview(kind, readyIndex: 2));
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -820,7 +863,7 @@ class SoundSettings extends StatelessWidget {
|
|||||||
key: const ValueKey('sound-volume'),
|
key: const ValueKey('sound-volume'),
|
||||||
value: volume,
|
value: volume,
|
||||||
onChanged: s.setSoundVolume,
|
onChanged: s.setSoundVolume,
|
||||||
onChangeEnd: (_) => _preview(SfxKind.ready),
|
onChangeEnd: (_) => _preview(context, SfxKind.ready),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
SizedBox(width: 44, child: Text('${(volume * 100).round()}%', textAlign: TextAlign.end)),
|
SizedBox(width: 44, child: Text('${(volume * 100).round()}%', textAlign: TextAlign.end)),
|
||||||
@@ -837,7 +880,7 @@ class SoundSettings extends StatelessWidget {
|
|||||||
value: overall,
|
value: overall,
|
||||||
onChanged: (v) {
|
onChanged: (v) {
|
||||||
s.setSoundPreset(v);
|
s.setSoundPreset(v);
|
||||||
_preview(SfxKind.ready);
|
_preview(context, SfxKind.ready);
|
||||||
},
|
},
|
||||||
items: [
|
items: [
|
||||||
for (final MapEntry(key: p, value: (name, _)) in presetInfo.entries) (p.name, name),
|
for (final MapEntry(key: p, value: (name, _)) in presetInfo.entries) (p.name, name),
|
||||||
@@ -855,7 +898,7 @@ class SoundSettings extends StatelessWidget {
|
|||||||
IconButton(
|
IconButton(
|
||||||
key: ValueKey('sound-play-${kind.name}'),
|
key: ValueKey('sound-play-${kind.name}'),
|
||||||
tooltip: 'Play',
|
tooltip: 'Play',
|
||||||
onPressed: volume == 0 ? null : () => _preview(kind),
|
onPressed: volume == 0 ? null : () => _preview(context, kind),
|
||||||
icon: const Icon(Icons.play_arrow_rounded),
|
icon: const Icon(Icons.play_arrow_rounded),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
@@ -871,7 +914,7 @@ class SoundSettings extends StatelessWidget {
|
|||||||
style: muted,
|
style: muted,
|
||||||
onChanged: (v) {
|
onChanged: (v) {
|
||||||
s.setSoundPresetFor(kind.name, v);
|
s.setSoundPresetFor(kind.name, v);
|
||||||
if (volume > 0) _preview(kind);
|
if (volume > 0) _preview(context, kind);
|
||||||
},
|
},
|
||||||
items: [
|
items: [
|
||||||
(null, 'Same as set'),
|
(null, 'Same as set'),
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:signals/signals_flutter.dart';
|
import 'package:signals/signals_flutter.dart';
|
||||||
|
|
||||||
import '../playback/media_marks.dart' show formatClock;
|
import '../playback/media_marks.dart' show formatClock;
|
||||||
import '../state/session_state.dart';
|
import '../app/app_graph.dart';
|
||||||
import '../sync/state_diff.dart' show serverPositionAt;
|
import '../sync/state_diff.dart' show serverPositionAt;
|
||||||
import 'now_playing_title.dart';
|
import 'now_playing_title.dart';
|
||||||
import 'start_banner.dart';
|
import 'start_banner.dart';
|
||||||
@@ -41,10 +41,10 @@ class _SitOutCardState extends State<SitOutCard> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final coord = SessionState.instance.coordinator;
|
final coord = AppScope.of(context).coordinator;
|
||||||
final small = widget.compact;
|
final small = widget.compact;
|
||||||
return ListenableBuilder(
|
return ListenableBuilder(
|
||||||
listenable: artChanges(),
|
listenable: artChanges(context),
|
||||||
builder: (context, _) => SignalBuilder(builder: (context) {
|
builder: (context, _) => SignalBuilder(builder: (context) {
|
||||||
final r = coord.serverState.value;
|
final r = coord.serverState.value;
|
||||||
final now = coord.nowPlaying.value;
|
final now = coord.nowPlaying.value;
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:signals/signals_flutter.dart';
|
import 'package:signals/signals_flutter.dart';
|
||||||
|
|
||||||
import '../session/now_playing.dart';
|
import '../session/now_playing.dart';
|
||||||
import '../state/session_state.dart';
|
import '../app/app_graph.dart';
|
||||||
import 'motion.dart';
|
import 'motion.dart';
|
||||||
import 'now_playing_title.dart';
|
import 'now_playing_title.dart';
|
||||||
|
|
||||||
@@ -21,8 +21,8 @@ class StartCard extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final banner = artImage(now.bannerHash);
|
final banner = artImage(context, now.bannerHash);
|
||||||
final cover = banner == null ? artImage(now.posterHash) : null;
|
final cover = banner == null ? artImage(context, now.posterHash) : null;
|
||||||
final episode = now.episode;
|
final episode = now.episode;
|
||||||
return LayoutBuilder(builder: (context, box) {
|
return LayoutBuilder(builder: (context, box) {
|
||||||
final h = box.maxHeight;
|
final h = box.maxHeight;
|
||||||
@@ -145,10 +145,10 @@ class _StartBannerState extends State<StartBanner> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final coord = SessionState.instance.coordinator;
|
final coord = AppScope.of(context).coordinator;
|
||||||
return IgnorePointer(
|
return IgnorePointer(
|
||||||
child: ListenableBuilder(
|
child: ListenableBuilder(
|
||||||
listenable: artChanges(),
|
listenable: artChanges(context),
|
||||||
builder: (context, _) => SignalBuilder(builder: (context) {
|
builder: (context, _) => SignalBuilder(builder: (context) {
|
||||||
final r = coord.serverState.value;
|
final r = coord.serverState.value;
|
||||||
final now = coord.nowPlaying.value;
|
final now = coord.nowPlaying.value;
|
||||||
|
|||||||
@@ -4,30 +4,30 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:signals/signals_flutter.dart';
|
import 'package:signals/signals_flutter.dart';
|
||||||
|
|
||||||
import '../session/session_coordinator.dart' show ToastItem;
|
import '../session/session_coordinator.dart' show ToastItem;
|
||||||
import '../state/session_state.dart';
|
import '../app/app_graph.dart';
|
||||||
import '../state/settings.dart';
|
|
||||||
import 'avatar.dart';
|
import 'avatar.dart';
|
||||||
import 'motion.dart';
|
import 'motion.dart';
|
||||||
|
|
||||||
/// One toast on screen: who (null = the app itself), what, and whether it
|
/// One toast on screen: who (null = the app itself), what, a picture
|
||||||
/// is on its way out.
|
/// (an Up next item's), and whether it is on its way out.
|
||||||
class ShellToast {
|
class ShellToast {
|
||||||
final int id;
|
final int id;
|
||||||
final AvatarInfo? who;
|
final AvatarInfo? who;
|
||||||
final IconData? icon;
|
final IconData? icon;
|
||||||
final String text;
|
final String text;
|
||||||
final bool chat;
|
final bool chat;
|
||||||
|
final String thumb;
|
||||||
bool leaving = false;
|
bool leaving = false;
|
||||||
ShellToast({required this.id, required this.text, this.who, this.icon, this.chat = false});
|
ShellToast({required this.id, required this.text, this.who, this.icon, this.chat = false, this.thumb = ''});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Splits a coordinator toast into who and what: feed and chat toasts
|
/// Splits a Notifier toast into who and what: feed and chat toasts name
|
||||||
/// name their [ToastItem.actor] and start with it ("Bob joined", "Bob:
|
/// their [ToastItem.actor] and start with it ("Bob joined", "Bob: hi");
|
||||||
/// hi"); the rest are the app's.
|
/// the rest are the app's.
|
||||||
ShellToast shellToastFor(ToastItem t, AvatarInfo Function(String name) avatar) {
|
ShellToast shellToastFor(ToastItem t, AvatarInfo Function(String name) avatar) {
|
||||||
final n = t.actor;
|
final n = t.actor;
|
||||||
if (n == null || n.isEmpty) return ShellToast(id: t.id, text: t.text, icon: Icons.info_outline);
|
if (n == null || n.isEmpty) return ShellToast(id: t.id, text: t.text, icon: Icons.info_outline, thumb: t.thumb);
|
||||||
if (t.text.startsWith('$n: ')) {
|
if (t.chat && t.text.startsWith('$n: ')) {
|
||||||
return ShellToast(id: t.id, who: avatar(n), text: t.text.substring(n.length + 2), chat: true);
|
return ShellToast(id: t.id, who: avatar(n), text: t.text.substring(n.length + 2), chat: true);
|
||||||
}
|
}
|
||||||
final rest = t.text.startsWith('$n ') ? t.text.substring(n.length + 1) : t.text;
|
final rest = t.text.startsWith('$n ') ? t.text.substring(n.length + 1) : t.text;
|
||||||
@@ -35,24 +35,30 @@ ShellToast shellToastFor(ToastItem t, AvatarInfo Function(String name) avatar) {
|
|||||||
? Icons.login
|
? Icons.login
|
||||||
: rest.startsWith('left')
|
: rest.startsWith('left')
|
||||||
? Icons.logout
|
? Icons.logout
|
||||||
|
: rest.startsWith('lost connection')
|
||||||
|
? Icons.wifi_off_rounded
|
||||||
|
: rest.startsWith('is back')
|
||||||
|
? Icons.wifi_rounded
|
||||||
: rest.startsWith('sat you out')
|
: rest.startsWith('sat you out')
|
||||||
? Icons.bedtime
|
? Icons.bedtime
|
||||||
: rest.startsWith('nudged')
|
: rest.startsWith('nudged')
|
||||||
? Icons.notifications_active_outlined
|
? Icons.notifications_active_outlined
|
||||||
: rest.startsWith('changed')
|
: rest.startsWith('changed')
|
||||||
? Icons.swap_horiz
|
? Icons.swap_horiz
|
||||||
|
: rest.startsWith('added')
|
||||||
|
? Icons.playlist_add_rounded
|
||||||
: Icons.info_outline;
|
: Icons.info_outline;
|
||||||
return ShellToast(id: t.id, who: avatar(n), text: rest, icon: icon);
|
return ShellToast(id: t.id, who: avatar(n), text: rest, icon: icon, thumb: t.thumb);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The coordinator's toasts as cards with avatars, bottom-left: each
|
/// The Notifier's toasts as cards with avatars, bottom-left: each slides
|
||||||
/// slides in, stays [life], then slides out and collapses its gap. A click
|
/// in, stays while the Notifier keeps it (it drops it after its time),
|
||||||
/// on a chat toast opens the chat ([onOpenChat]); the rest let clicks
|
/// then slides out and collapses its gap. A click on a chat toast opens
|
||||||
/// through. Chat toasts follow [AppSettings.chatPopups].
|
/// the chat ([onOpenChat]); the rest let clicks through. What pops up at
|
||||||
|
/// all (chat pop-ups, sitting out, alone) is the Notifier's policy.
|
||||||
class PartyToasts extends StatefulWidget {
|
class PartyToasts extends StatefulWidget {
|
||||||
final Duration life;
|
|
||||||
final VoidCallback? onOpenChat;
|
final VoidCallback? onOpenChat;
|
||||||
const PartyToasts({super.key, this.life = const Duration(seconds: 5), this.onOpenChat});
|
const PartyToasts({super.key, this.onOpenChat});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<PartyToasts> createState() => _PartyToastsState();
|
State<PartyToasts> createState() => _PartyToastsState();
|
||||||
@@ -67,38 +73,45 @@ class _PartyToastsState extends State<PartyToasts> {
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
final coord = SessionState.instance.coordinator;
|
final coord = AppScope.of(context).coordinator;
|
||||||
// What was there before this mounted is old news.
|
// What was there before this mounted is old news.
|
||||||
_seen = {for (final t in coord.toasts.peek()) t.id};
|
_seen = {for (final t in coord.toasts.peek()) t.id};
|
||||||
_stop = effect(() {
|
_stop = effect(() {
|
||||||
final items = coord.toasts.value;
|
final items = coord.toasts.value;
|
||||||
|
final ids = {for (final t in items) t.id};
|
||||||
final fresh = [for (final t in items) if (!_seen.contains(t.id)) t];
|
final fresh = [for (final t in items) if (!_seen.contains(t.id)) t];
|
||||||
_seen = {for (final t in items) t.id};
|
final gone = _seen.difference(ids);
|
||||||
if (fresh.isEmpty) return;
|
_seen = ids;
|
||||||
|
if (fresh.isEmpty && gone.isEmpty) return;
|
||||||
// Only new toasts re-run this, not the party they mention.
|
// Only new toasts re-run this, not the party they mention.
|
||||||
untracked(() {
|
untracked(() {
|
||||||
final playing = coord.serverState.value?.transport == 'playing';
|
|
||||||
final popups = AppSettings.instance.chatPopups.value;
|
|
||||||
for (final t in fresh) {
|
for (final t in fresh) {
|
||||||
final toast = shellToastFor(t, (n) => avatarForName(n, coord, t.actorId));
|
_add(shellToastFor(t, (n) => avatarForName(n, coord, t.actorId)));
|
||||||
// Chat per Settings → Notifications (it is in the chat either way).
|
|
||||||
if (toast.chat && !popups.shows(playing: playing)) continue;
|
|
||||||
_add(toast);
|
|
||||||
}
|
}
|
||||||
|
if (gone.isNotEmpty) _leave(gone);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
void _add(ShellToast t) {
|
void _add(ShellToast t) {
|
||||||
// Not from inside the effect's build-time notification.
|
// Not from inside the effect's build-time notification.
|
||||||
|
scheduleMicrotask(() {
|
||||||
|
if (mounted) setState(() => _shown.add(t));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _leave(Set<int> ids) {
|
||||||
scheduleMicrotask(() {
|
scheduleMicrotask(() {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() => _shown.add(t));
|
final leaving = [for (final t in _shown) if (ids.contains(t.id) && !t.leaving) t];
|
||||||
_timers.add(Timer(widget.life, () {
|
if (leaving.isEmpty) return;
|
||||||
if (mounted) setState(() => t.leaving = true);
|
setState(() {
|
||||||
}));
|
for (final t in leaving) {
|
||||||
_timers.add(Timer(widget.life + easeTime, () {
|
t.leaving = true;
|
||||||
if (mounted) setState(() => _shown.remove(t));
|
}
|
||||||
|
});
|
||||||
|
_timers.add(Timer(easeTime, () {
|
||||||
|
if (mounted) setState(() => _shown.removeWhere(leaving.contains));
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -114,7 +127,7 @@ class _PartyToastsState extends State<PartyToasts> {
|
|||||||
|
|
||||||
void _open(ShellToast t) {
|
void _open(ShellToast t) {
|
||||||
widget.onOpenChat?.call();
|
widget.onOpenChat?.call();
|
||||||
setState(() => t.leaving = true);
|
AppScope.of(context).notifier.dismiss(t.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -206,6 +219,8 @@ class ToastCard extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
if (who != null)
|
if (who != null)
|
||||||
StatusAvatar(info: who, size: 38, badges: false)
|
StatusAvatar(info: who, size: 38, badges: false)
|
||||||
|
else if (toast.thumb.isNotEmpty)
|
||||||
|
_Thumb(hash: toast.thumb)
|
||||||
else
|
else
|
||||||
CircleAvatar(
|
CircleAvatar(
|
||||||
radius: 19,
|
radius: 19,
|
||||||
@@ -237,6 +252,8 @@ class ToastCard extends StatelessWidget {
|
|||||||
],
|
],
|
||||||
Text(
|
Text(
|
||||||
toast.text,
|
toast.text,
|
||||||
|
maxLines: 3,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: toast.chat ? 15 : 14,
|
fontSize: toast.chat ? 15 : 14,
|
||||||
color: toast.chat || who == null ? scheme.onSurface : scheme.onSurfaceVariant,
|
color: toast.chat || who == null ? scheme.onSurface : scheme.onSurfaceVariant,
|
||||||
@@ -245,6 +262,10 @@ class ToastCard extends StatelessWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
if (who != null && toast.thumb.isNotEmpty) ...[
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
_Thumb(hash: toast.thumb),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -252,3 +273,31 @@ class ToastCard extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// An Up next item's picture (16:9, from the queue's picture cache);
|
||||||
|
/// a blank until it is fetched.
|
||||||
|
class _Thumb extends StatelessWidget {
|
||||||
|
final String hash;
|
||||||
|
const _Thumb({required this.hash});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final thumbs = AppScope.of(context).coordinator.queue.thumbs;
|
||||||
|
return ClipRRect(
|
||||||
|
key: const ValueKey('toast-thumb'),
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
child: SizedBox(
|
||||||
|
width: 68,
|
||||||
|
height: 38,
|
||||||
|
child: ListenableBuilder(
|
||||||
|
listenable: thumbs,
|
||||||
|
builder: (context, _) {
|
||||||
|
final bytes = thumbs.bytesFor(hash);
|
||||||
|
if (bytes == null) return ColoredBox(color: Theme.of(context).colorScheme.surfaceContainerHighest);
|
||||||
|
return Image.memory(bytes, fit: BoxFit.cover, gaplessPlayback: true);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,15 +12,16 @@ const appTagline = 'Watch together, in sync';
|
|||||||
/// the app name (else under it).
|
/// the app name (else under it).
|
||||||
const welcomeWideFrom = 980.0;
|
const welcomeWideFrom = 980.0;
|
||||||
|
|
||||||
/// Top of the Library while not in a server: the app's name and the three
|
/// Top of the Library for newcomers alone on their own server: the app's
|
||||||
/// ways in, Join a friend first (what most people came for), then Host and
|
/// name and the three ways in, Join a friend first (what most people came
|
||||||
/// Watch solo. None of them needs Jellyfin.
|
/// for), then Invite friends and Watch on your own. None of them needs
|
||||||
|
/// Jellyfin.
|
||||||
class WelcomeBand extends StatelessWidget {
|
class WelcomeBand extends StatelessWidget {
|
||||||
final VoidCallback onJoin;
|
final VoidCallback onJoin;
|
||||||
final VoidCallback onHost;
|
final VoidCallback onInvite;
|
||||||
final VoidCallback onSolo;
|
final VoidCallback onWatch;
|
||||||
final VoidCallback? onDismiss;
|
final VoidCallback? onDismiss;
|
||||||
const WelcomeBand({super.key, required this.onJoin, required this.onHost, required this.onSolo, this.onDismiss});
|
const WelcomeBand({super.key, required this.onJoin, required this.onInvite, required this.onWatch, this.onDismiss});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -63,23 +64,23 @@ class WelcomeBand extends StatelessWidget {
|
|||||||
key: const ValueKey('way-join'),
|
key: const ValueKey('way-join'),
|
||||||
icon: Icons.login_rounded,
|
icon: Icons.login_rounded,
|
||||||
title: 'Join a friend',
|
title: 'Join a friend',
|
||||||
caption: 'Got an address from a friend? Join with it. Nothing to set up.',
|
caption: 'Got an invite from a friend? Join with it. Nothing to set up.',
|
||||||
primary: true,
|
primary: true,
|
||||||
onTap: onJoin,
|
onTap: onJoin,
|
||||||
),
|
),
|
||||||
WayCard(
|
WayCard(
|
||||||
key: const ValueKey('way-host'),
|
key: const ValueKey('way-invite'),
|
||||||
icon: Icons.podcasts_rounded,
|
icon: Icons.group_add_outlined,
|
||||||
title: 'Host',
|
title: 'Invite friends',
|
||||||
caption: 'You have the video. Friends join you and watch it with you.',
|
caption: 'You have the video. Copy an invite; friends join you and watch it with you.',
|
||||||
onTap: onHost,
|
onTap: onInvite,
|
||||||
),
|
),
|
||||||
WayCard(
|
WayCard(
|
||||||
key: const ValueKey('way-solo'),
|
key: const ValueKey('way-watch'),
|
||||||
icon: Icons.person_outline_rounded,
|
icon: Icons.person_outline_rounded,
|
||||||
title: 'Watch solo',
|
title: 'Watch on your own',
|
||||||
caption: 'Just you: play anything below. Invite friends any time.',
|
caption: 'Just you: play anything below. Invite friends any time.',
|
||||||
onTap: onSolo,
|
onTap: onWatch,
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
Widget row(List<Widget> cards) => IntrinsicHeight(
|
Widget row(List<Widget> cards) => IntrinsicHeight(
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart' show debugPrint;
|
||||||
import 'package:hive_ce/hive.dart';
|
import 'package:hive_ce/hive.dart';
|
||||||
|
import 'package:signals/signals.dart';
|
||||||
|
|
||||||
const connectFormBoxName = 'connect_form';
|
const connectFormBoxName = 'connect_form';
|
||||||
|
|
||||||
@@ -19,3 +23,165 @@ String readString(Box box, String key, String fallback) {
|
|||||||
final v = box.get(key);
|
final v = box.get(key);
|
||||||
return v is String ? v : fallback;
|
return v is String ? v : fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const connectKeyHostPort = 'hostPort';
|
||||||
|
const connectKeyHostSecret = 'hostSecret';
|
||||||
|
const connectKeyRecents = 'recents';
|
||||||
|
const maxRecentServers = 6;
|
||||||
|
|
||||||
|
/// The connect form: the Join card's address ([host]/[port]/[secret]),
|
||||||
|
/// your server's port and password when shared ([hostPort]/[hostSecret]),
|
||||||
|
/// the profile name and the last media picked.
|
||||||
|
class ConnectForm {
|
||||||
|
String host;
|
||||||
|
String port;
|
||||||
|
String secret;
|
||||||
|
String hostPort;
|
||||||
|
String hostSecret;
|
||||||
|
String name;
|
||||||
|
String avatarPath;
|
||||||
|
String mediaPath;
|
||||||
|
String linkUrl;
|
||||||
|
ConnectForm({
|
||||||
|
this.host = '127.0.0.1',
|
||||||
|
this.port = '8080',
|
||||||
|
this.secret = '',
|
||||||
|
this.hostPort = '8080',
|
||||||
|
this.hostSecret = '',
|
||||||
|
this.name = '',
|
||||||
|
this.avatarPath = '',
|
||||||
|
this.mediaPath = '',
|
||||||
|
this.linkUrl = '',
|
||||||
|
});
|
||||||
|
|
||||||
|
ConnectForm copy() => ConnectForm(
|
||||||
|
host: host,
|
||||||
|
port: port,
|
||||||
|
secret: secret,
|
||||||
|
hostPort: hostPort,
|
||||||
|
hostSecret: hostSecret,
|
||||||
|
name: name,
|
||||||
|
avatarPath: avatarPath,
|
||||||
|
mediaPath: mediaPath,
|
||||||
|
linkUrl: linkUrl,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A friend's server the user got into, shown as a Recent row on the
|
||||||
|
/// Join card (one click joins).
|
||||||
|
class RecentServer {
|
||||||
|
final String host;
|
||||||
|
final String port;
|
||||||
|
final String secret;
|
||||||
|
const RecentServer({required this.host, required this.port, this.secret = ''});
|
||||||
|
|
||||||
|
String get key => '${host.toLowerCase()}:$port';
|
||||||
|
|
||||||
|
Map<String, Object> toJson() => {'host': host, 'port': port, 'secret': secret};
|
||||||
|
|
||||||
|
/// Null for anything unreadable and for the hosted entries older
|
||||||
|
/// versions kept (your server has no recents).
|
||||||
|
static RecentServer? fromJson(Object? j) {
|
||||||
|
if (j is! Map || j['hosted'] == true) return null;
|
||||||
|
final host = j['host'], port = j['port'], secret = j['secret'];
|
||||||
|
if (host is! String || port is! String) return null;
|
||||||
|
return RecentServer(host: host, port: port, secret: secret is String ? secret : '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Newest first, one entry per server, capped at [maxRecentServers].
|
||||||
|
List<RecentServer> pushRecent(List<RecentServer> list, RecentServer s) => [
|
||||||
|
s,
|
||||||
|
...list.where((r) => r.key != s.key),
|
||||||
|
].take(maxRecentServers).toList();
|
||||||
|
|
||||||
|
String encodeRecents(List<RecentServer> list) => jsonEncode([for (final r in list) r.toJson()]);
|
||||||
|
|
||||||
|
List<RecentServer> decodeRecents(Object? raw) {
|
||||||
|
if (raw is! String) return const [];
|
||||||
|
try {
|
||||||
|
final list = jsonDecode(raw);
|
||||||
|
if (list is! List) return const [];
|
||||||
|
return [
|
||||||
|
for (final j in list) ?RecentServer.fromJson(j),
|
||||||
|
].take(maxRecentServers).toList();
|
||||||
|
} catch (_) {
|
||||||
|
return const [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Before recents existed only the last form was stored: a saved remote
|
||||||
|
/// address becomes the first Recent row.
|
||||||
|
List<RecentServer> migrateRecents(
|
||||||
|
{required bool hadForm, required String host, required String port, required String secret}) {
|
||||||
|
final h = host.trim().toLowerCase();
|
||||||
|
if (!hadForm || h.isEmpty || h == '127.0.0.1' || h == 'localhost') return const [];
|
||||||
|
return [RecentServer(host: host.trim(), port: port, secret: secret)];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The connect form and the recent servers, kept in the `connect_form`
|
||||||
|
/// box. Made by the composition root; the Join card, your server (its
|
||||||
|
/// port and password), the profile and the library read and write it.
|
||||||
|
class ConnectStore {
|
||||||
|
final form = ConnectForm();
|
||||||
|
final recents = signal<List<RecentServer>>(const []);
|
||||||
|
|
||||||
|
Future<void> load() async {
|
||||||
|
final box = await openConnectFormBox();
|
||||||
|
form
|
||||||
|
..host = readString(box, connectKeyHost, '127.0.0.1')
|
||||||
|
..port = readString(box, connectKeyPort, '8080')
|
||||||
|
..secret = readString(box, connectKeySecret, '');
|
||||||
|
// Old installs had one port/password for both modes.
|
||||||
|
form
|
||||||
|
..hostPort = readString(box, connectKeyHostPort, form.port)
|
||||||
|
..hostSecret = readString(box, connectKeyHostSecret, form.secret)
|
||||||
|
..name = readString(box, connectKeyName, '')
|
||||||
|
..avatarPath = readString(box, connectKeyAvatarPath, '')
|
||||||
|
..mediaPath = readString(box, connectKeyMediaPath, '')
|
||||||
|
..linkUrl = readString(box, connectKeyLinkUrl, '');
|
||||||
|
recents.value = box.containsKey(connectKeyRecents)
|
||||||
|
? decodeRecents(box.get(connectKeyRecents))
|
||||||
|
: migrateRecents(
|
||||||
|
hadForm: box.containsKey(connectKeyHost), host: form.host, port: form.port, secret: form.secret);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> save() async {
|
||||||
|
final box = connectFormBoxIfOpen() ?? await openConnectFormBox();
|
||||||
|
await box.putAll({
|
||||||
|
connectKeyHost: form.host,
|
||||||
|
connectKeyPort: form.port,
|
||||||
|
connectKeySecret: form.secret,
|
||||||
|
connectKeyHostPort: form.hostPort,
|
||||||
|
connectKeyHostSecret: form.hostSecret,
|
||||||
|
connectKeyName: form.name,
|
||||||
|
connectKeyAvatarPath: form.avatarPath,
|
||||||
|
connectKeyMediaPath: form.mediaPath,
|
||||||
|
connectKeyLinkUrl: form.linkUrl,
|
||||||
|
connectKeyRecents: encodeRecents(recents.peek()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [save] whatever happens (a failed join is exactly when the user
|
||||||
|
/// wants the form back); a failure is only logged.
|
||||||
|
Future<void> saveQuietly() async {
|
||||||
|
try {
|
||||||
|
await save();
|
||||||
|
} catch (e, st) {
|
||||||
|
debugPrint('save connect form failed: $e\n$st');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> remember(RecentServer server) async {
|
||||||
|
recents.value = pushRecent(recents.peek(), server);
|
||||||
|
await saveQuietly();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> forget(RecentServer server) async {
|
||||||
|
recents.value = [
|
||||||
|
for (final r in recents.peek())
|
||||||
|
if (r.key != server.key) r,
|
||||||
|
];
|
||||||
|
await saveQuietly();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,7 +8,8 @@ import 'package:sync_relay_server/server.dart';
|
|||||||
Future<void> _serverMain((Map<String, Object?>, SendPort) args) =>
|
Future<void> _serverMain((Map<String, Object?>, SendPort) args) =>
|
||||||
serve(args.$1, events: args.$2);
|
serve(args.$1, events: args.$2);
|
||||||
|
|
||||||
/// The hosted server, running [serve] on its own isolate. Its log lines
|
/// This app's own server ("your server"), running [serve] on its own
|
||||||
|
/// isolate for the whole app run. Its log lines
|
||||||
/// go to [onLog]; if the isolate dies without being asked to stop,
|
/// go to [onLog]; if the isolate dies without being asked to stop,
|
||||||
/// [onCrash] gets the cause once.
|
/// [onCrash] gets the cause once.
|
||||||
class ServerIsolate {
|
class ServerIsolate {
|
||||||
@@ -33,7 +34,7 @@ class ServerIsolate {
|
|||||||
|
|
||||||
/// Spawns the server and waits until it listens. Throws a
|
/// Spawns the server and waits until it listens. Throws a
|
||||||
/// [StateError] with the server's message if it cannot start.
|
/// [StateError] with the server's message if it cannot start.
|
||||||
/// [loopbackOnly]: 127.0.0.1 only (the solo session). [logDir]: where
|
/// [loopbackOnly]: 127.0.0.1 only until [share]. [logDir]: where
|
||||||
/// the server writes its session log (none when null).
|
/// the server writes its session log (none when null).
|
||||||
static Future<ServerIsolate> start(
|
static Future<ServerIsolate> start(
|
||||||
AppConfig config, {
|
AppConfig config, {
|
||||||
@@ -156,6 +157,47 @@ class ServerIsolate {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Opens the server to the network ([SyncServer.share]): a listener on
|
||||||
|
/// every interface at [port], the password [secret], the Jellyfin relay
|
||||||
|
/// logins [jellyfin] (one per Jellyfin server). Returns the bound port;
|
||||||
|
/// throws a [StateError] with
|
||||||
|
/// the server's reason (a port in use) when it cannot.
|
||||||
|
Future<int> share({required int port, required String secret, List<JellyfinCredential>? jellyfin,
|
||||||
|
Duration timeout = const Duration(seconds: 10)}) async {
|
||||||
|
if (_exited.isCompleted) throw StateError('the server is not running');
|
||||||
|
final reply = ReceivePort('server share');
|
||||||
|
try {
|
||||||
|
_stop.send({
|
||||||
|
't': 'share',
|
||||||
|
'port': port,
|
||||||
|
'secret': secret,
|
||||||
|
if (jellyfin != null) 'jellyfin': [for (final c in jellyfin) c.toMap()],
|
||||||
|
'reply': reply.sendPort,
|
||||||
|
});
|
||||||
|
final msg = await reply.first.timeout(timeout);
|
||||||
|
if (msg is Map && msg['ok'] == true) return msg['port'] as int;
|
||||||
|
throw StateError(msg is Map ? '${msg['message']}' : 'the server did not answer');
|
||||||
|
} on TimeoutException {
|
||||||
|
throw StateError('the server did not answer');
|
||||||
|
} finally {
|
||||||
|
reply.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Closes the network listener again ([SyncServer.unshare]): everyone
|
||||||
|
/// but this app's own member leaves; nothing else changes.
|
||||||
|
Future<void> unshare({Duration timeout = const Duration(seconds: 5)}) async {
|
||||||
|
if (_exited.isCompleted) return;
|
||||||
|
final reply = ReceivePort('server unshare');
|
||||||
|
try {
|
||||||
|
_stop.send({'t': 'unshare', 'reply': reply.sendPort});
|
||||||
|
await reply.first.timeout(timeout);
|
||||||
|
} catch (_) {
|
||||||
|
} finally {
|
||||||
|
reply.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Asks the server to close its sockets and exit; kills the isolate if it
|
/// Asks the server to close its sockets and exit; kills the isolate if it
|
||||||
/// has not exited within [timeout].
|
/// has not exited within [timeout].
|
||||||
Future<void> stop({Duration timeout = const Duration(seconds: 3)}) async {
|
Future<void> stop({Duration timeout = const Duration(seconds: 3)}) async {
|
||||||
|
|||||||
@@ -1,538 +0,0 @@
|
|||||||
import 'dart:async';
|
|
||||||
import 'dart:convert';
|
|
||||||
import 'dart:math';
|
|
||||||
|
|
||||||
import 'package:flutter/widgets.dart';
|
|
||||||
import 'package:signals/signals_flutter.dart';
|
|
||||||
import 'package:sync_relay_core/core.dart';
|
|
||||||
|
|
||||||
import '../jellyfin/accounts.dart';
|
|
||||||
import '../jellyfin/session_media.dart';
|
|
||||||
import '../screens/home/health_probe.dart';
|
|
||||||
import '../screens/home/picked_media.dart';
|
|
||||||
import '../platform/app_log.dart' show appLogDir;
|
|
||||||
import '../platform/desktop/desktop_ports.dart';
|
|
||||||
import '../platform/ports/ports.dart';
|
|
||||||
import '../sfx/sfx.dart';
|
|
||||||
import '../sfx/sound_bank.dart';
|
|
||||||
import '../sfx/synth_sfx.dart';
|
|
||||||
import '../session/health.dart';
|
|
||||||
import '../session/notifier.dart';
|
|
||||||
import '../session/connection.dart' show Connection;
|
|
||||||
import '../session/identity.dart' show ClientIdentity;
|
|
||||||
import '../session/session_coordinator.dart';
|
|
||||||
import '../widgets/avatar_pipeline.dart';
|
|
||||||
import 'connect_prefs.dart';
|
|
||||||
import 'server_isolate.dart';
|
|
||||||
import 'settings.dart';
|
|
||||||
|
|
||||||
enum AppRoute {
|
|
||||||
home,
|
|
||||||
session,
|
|
||||||
}
|
|
||||||
|
|
||||||
enum ConnectMode { host, join }
|
|
||||||
|
|
||||||
/// Both Home cards prefill from this. [host]/[port]/[secret] belong to
|
|
||||||
/// the Join card, [hostPort]/[hostSecret] to the Host card.
|
|
||||||
class ConnectForm {
|
|
||||||
String host;
|
|
||||||
String port;
|
|
||||||
String secret;
|
|
||||||
String hostPort;
|
|
||||||
String hostSecret;
|
|
||||||
String name;
|
|
||||||
String avatarPath;
|
|
||||||
String mediaPath;
|
|
||||||
String linkUrl;
|
|
||||||
ConnectForm({
|
|
||||||
this.host = '127.0.0.1',
|
|
||||||
this.port = '8080',
|
|
||||||
this.secret = '',
|
|
||||||
this.hostPort = '8080',
|
|
||||||
this.hostSecret = '',
|
|
||||||
this.name = '',
|
|
||||||
this.avatarPath = '',
|
|
||||||
this.mediaPath = '',
|
|
||||||
this.linkUrl = '',
|
|
||||||
});
|
|
||||||
|
|
||||||
ConnectForm copy() => ConnectForm(
|
|
||||||
host: host,
|
|
||||||
port: port,
|
|
||||||
secret: secret,
|
|
||||||
hostPort: hostPort,
|
|
||||||
hostSecret: hostSecret,
|
|
||||||
name: name,
|
|
||||||
avatarPath: avatarPath,
|
|
||||||
mediaPath: mediaPath,
|
|
||||||
linkUrl: linkUrl,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const connectKeyHostPort = 'hostPort';
|
|
||||||
const connectKeyHostSecret = 'hostSecret';
|
|
||||||
const connectKeyRecents = 'recents';
|
|
||||||
const maxRecentServers = 6;
|
|
||||||
|
|
||||||
/// A server the user got into, shown as a Recent chip on Home. Hosted
|
|
||||||
/// entries refill the Host card; joined ones join in one click.
|
|
||||||
class RecentServer {
|
|
||||||
final bool hosted;
|
|
||||||
final String host;
|
|
||||||
final String port;
|
|
||||||
final String secret;
|
|
||||||
const RecentServer(
|
|
||||||
{required this.hosted,
|
|
||||||
required this.host,
|
|
||||||
required this.port,
|
|
||||||
this.secret = ''});
|
|
||||||
|
|
||||||
String get key => hosted ? 'host:$port' : '${host.toLowerCase()}:$port';
|
|
||||||
|
|
||||||
Map<String, Object> toJson() =>
|
|
||||||
{'hosted': hosted, 'host': host, 'port': port, 'secret': secret};
|
|
||||||
|
|
||||||
static RecentServer? fromJson(Object? j) {
|
|
||||||
if (j is! Map) return null;
|
|
||||||
final host = j['host'], port = j['port'], secret = j['secret'];
|
|
||||||
if (host is! String || port is! String) return null;
|
|
||||||
return RecentServer(
|
|
||||||
hosted: j['hosted'] == true,
|
|
||||||
host: host,
|
|
||||||
port: port,
|
|
||||||
secret: secret is String ? secret : '',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Newest first, one entry per server, capped at [maxRecentServers].
|
|
||||||
List<RecentServer> pushRecent(List<RecentServer> list, RecentServer s) => [
|
|
||||||
s,
|
|
||||||
...list.where((r) => r.key != s.key),
|
|
||||||
].take(maxRecentServers).toList();
|
|
||||||
|
|
||||||
String encodeRecents(List<RecentServer> list) =>
|
|
||||||
jsonEncode([for (final r in list) r.toJson()]);
|
|
||||||
|
|
||||||
List<RecentServer> decodeRecents(Object? raw) {
|
|
||||||
if (raw is! String) return const [];
|
|
||||||
try {
|
|
||||||
final list = jsonDecode(raw);
|
|
||||||
if (list is! List) return const [];
|
|
||||||
return [
|
|
||||||
for (final j in list) ?RecentServer.fromJson(j),
|
|
||||||
].take(maxRecentServers).toList();
|
|
||||||
} catch (_) {
|
|
||||||
return const [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Before recents existed only the last form was stored: a saved remote
|
|
||||||
/// address becomes the first Recent chip.
|
|
||||||
List<RecentServer> migrateRecents(
|
|
||||||
{required bool hadForm,
|
|
||||||
required String host,
|
|
||||||
required String port,
|
|
||||||
required String secret}) {
|
|
||||||
final h = host.trim().toLowerCase();
|
|
||||||
if (!hadForm || h.isEmpty || h == '127.0.0.1' || h == 'localhost') {
|
|
||||||
return const [];
|
|
||||||
}
|
|
||||||
return [RecentServer(hosted: false, host: host.trim(), port: port, secret: secret)];
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The hosted server died during setup; shown on the Host card.
|
|
||||||
class ServerCrashed implements Exception {
|
|
||||||
final String cause;
|
|
||||||
const ServerCrashed(this.cause);
|
|
||||||
@override
|
|
||||||
String toString() => 'server crashed: $cause';
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A fresh per-hosting key: the app's own client sends it in hello to be
|
|
||||||
/// recognised as the host (`members[].host`).
|
|
||||||
String newHostKey() {
|
|
||||||
final r = Random.secure();
|
|
||||||
return base64Url.encode([for (var i = 0; i < 24; i++) r.nextInt(256)]);
|
|
||||||
}
|
|
||||||
|
|
||||||
class SessionState {
|
|
||||||
static final SessionState instance = SessionState._();
|
|
||||||
SessionState._();
|
|
||||||
|
|
||||||
final route = signal(AppRoute.home);
|
|
||||||
final recents = signal<List<RecentServer>>(const []);
|
|
||||||
final status = signal(const HealthStatus(HealthSeverity.info, 'idle'));
|
|
||||||
/// Owns the app's audio device ([SynthSfx.dispose] on close).
|
|
||||||
final SynthSfx sfx = SynthSfx(
|
|
||||||
volume: () => AppSettings.instance.soundVolume.peek(),
|
|
||||||
// A nudge is an explicit poke: it ignores its switch (not the volume).
|
|
||||||
enabled: (k) => k == SfxKind.nudge || !AppSettings.instance.soundsOff.peek().contains(k.name),
|
|
||||||
presetFor: soundPresetFor,
|
|
||||||
);
|
|
||||||
/// The platform (window, links, folders, yt-dlp, notifications).
|
|
||||||
final Ports ports = desktopPorts();
|
|
||||||
/// Every toast, sound and activity-log line goes through it.
|
|
||||||
late final Notifier notifier = Notifier(sfx: sfx, attention: ports.shell.requestAttention);
|
|
||||||
/// The link to the server; lost/back go to the notifier.
|
|
||||||
/// The link to the server, with this install's stored identity
|
|
||||||
/// (`hello.client` / `seq`); lost/back go to the notifier.
|
|
||||||
late final Connection connection = Connection(notify: notifier.notify, identity: ClientIdentity.stored());
|
|
||||||
late final SessionCoordinator coordinator =
|
|
||||||
SessionCoordinator(notifier: notifier, connection: connection, ytDlp: ports.links);
|
|
||||||
final lastForm = ConnectForm();
|
|
||||||
JellyfinAccounts get jellyfin => JellyfinAccounts.instance;
|
|
||||||
/// Set when the hosted server died mid-session; Home shows it on the
|
|
||||||
/// Host card and clears it.
|
|
||||||
final serverCrash = signal<String?>(null);
|
|
||||||
/// In a solo session: this app's own server on 127.0.0.1, only you in
|
|
||||||
/// it. Hosting from it ([host]) keeps what plays and where.
|
|
||||||
final solo = signal(false);
|
|
||||||
/// The joined server's own name (`/health`), '' until known or when it
|
|
||||||
/// only has the default name.
|
|
||||||
final serverLabel = signal('');
|
|
||||||
/// A solo session is becoming a hosted one: the player has nothing open
|
|
||||||
/// until the media is up again on the new server.
|
|
||||||
final switching = signal(false);
|
|
||||||
ConnectParams? _soloParams;
|
|
||||||
|
|
||||||
/// The app's yt-dlp folder for the hosted server; '' (the server's own
|
|
||||||
/// default) when the app-support folder is not available.
|
|
||||||
static Future<String> _sharedYtDlpDir() async {
|
|
||||||
try {
|
|
||||||
return (await instance.ports.links.ytDlp).dir;
|
|
||||||
} catch (_) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ServerIsolate? _server;
|
|
||||||
String? _setupCrash;
|
|
||||||
|
|
||||||
@visibleForTesting
|
|
||||||
ServerIsolate? get hostedServer => _server;
|
|
||||||
// Bumped by host/join/cancelPending: a cancelled setup notices at its
|
|
||||||
// next step and unwinds instead of connecting.
|
|
||||||
int _op = 0;
|
|
||||||
|
|
||||||
Future<void> loadForm() async {
|
|
||||||
final box = await openConnectFormBox();
|
|
||||||
lastForm
|
|
||||||
..host = readString(box, connectKeyHost, '127.0.0.1')
|
|
||||||
..port = readString(box, connectKeyPort, '8080')
|
|
||||||
..secret = readString(box, connectKeySecret, '');
|
|
||||||
// Old installs had one port/password for both modes.
|
|
||||||
lastForm
|
|
||||||
..hostPort = readString(box, connectKeyHostPort, lastForm.port)
|
|
||||||
..hostSecret = readString(box, connectKeyHostSecret, lastForm.secret)
|
|
||||||
..name = readString(box, connectKeyName, '')
|
|
||||||
..avatarPath = readString(box, connectKeyAvatarPath, '')
|
|
||||||
..mediaPath = readString(box, connectKeyMediaPath, '')
|
|
||||||
..linkUrl = readString(box, connectKeyLinkUrl, '');
|
|
||||||
recents.value = box.containsKey(connectKeyRecents)
|
|
||||||
? decodeRecents(box.get(connectKeyRecents))
|
|
||||||
: migrateRecents(
|
|
||||||
hadForm: box.containsKey(connectKeyHost),
|
|
||||||
host: lastForm.host,
|
|
||||||
port: lastForm.port,
|
|
||||||
secret: lastForm.secret);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> saveForm() async {
|
|
||||||
final box = connectFormBoxIfOpen() ?? await openConnectFormBox();
|
|
||||||
await box.putAll({
|
|
||||||
connectKeyHost: lastForm.host,
|
|
||||||
connectKeyPort: lastForm.port,
|
|
||||||
connectKeySecret: lastForm.secret,
|
|
||||||
connectKeyHostPort: lastForm.hostPort,
|
|
||||||
connectKeyHostSecret: lastForm.hostSecret,
|
|
||||||
connectKeyName: lastForm.name,
|
|
||||||
connectKeyAvatarPath: lastForm.avatarPath,
|
|
||||||
connectKeyMediaPath: lastForm.mediaPath,
|
|
||||||
connectKeyLinkUrl: lastForm.linkUrl,
|
|
||||||
connectKeyRecents: encodeRecents(recents.peek()),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<String> loadAvatar(String path) => encodeAvatar(path);
|
|
||||||
|
|
||||||
/// Called by Home after a host/join got through.
|
|
||||||
Future<void> rememberServer(RecentServer server) async {
|
|
||||||
recents.value = pushRecent(recents.peek(), server);
|
|
||||||
await _saveFormQuietly();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> forgetServer(RecentServer server) async {
|
|
||||||
recents.value = [
|
|
||||||
for (final r in recents.peek())
|
|
||||||
if (r.key != server.key) r,
|
|
||||||
];
|
|
||||||
await _saveFormQuietly();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Hosts a server friends can join. From a solo session it keeps the
|
|
||||||
/// media and position: the solo server goes, the new one gets the same
|
|
||||||
/// pick, seeked to where it was; if hosting fails, solo comes back.
|
|
||||||
Future<void> host(ConnectParams params, ConnectForm form) async {
|
|
||||||
if (!solo.peek()) return _host(params, form: form);
|
|
||||||
final carry = _invited = _carry();
|
|
||||||
switching.value = true;
|
|
||||||
try {
|
|
||||||
await coordinator.leave();
|
|
||||||
await stopServer();
|
|
||||||
solo.value = false;
|
|
||||||
coordinator.solo = false;
|
|
||||||
try {
|
|
||||||
await _host(params, form: form);
|
|
||||||
} catch (_) {
|
|
||||||
// Not hosted (failed or cancelled): back to solo, same media.
|
|
||||||
try {
|
|
||||||
await _host(_soloParams ?? params, soloSession: true);
|
|
||||||
if (carry != null) await _resume(carry);
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint('back to solo failed: $e');
|
|
||||||
await leave();
|
|
||||||
}
|
|
||||||
rethrow;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
if (carry != null) await _resume(carry);
|
|
||||||
} catch (e) {
|
|
||||||
coordinator.notify(ActionFailed("couldn't put it up again: $e"));
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
switching.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// What an Invite from solo carried over, for [cancelInvite].
|
|
||||||
({PickedMedia pick, double position})? _invited;
|
|
||||||
|
|
||||||
/// Invite cancelled after the hosted server already came up: back to
|
|
||||||
/// solo with the same media, not out of the session.
|
|
||||||
Future<void> cancelInvite() async {
|
|
||||||
final carry = _invited;
|
|
||||||
switching.value = true;
|
|
||||||
try {
|
|
||||||
await coordinator.leave();
|
|
||||||
await stopServer();
|
|
||||||
await _host(_soloParams!, soloSession: true);
|
|
||||||
if (carry != null) await _resume(carry);
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint('back to solo failed: $e');
|
|
||||||
await leave();
|
|
||||||
} finally {
|
|
||||||
switching.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Watch alone: the same hosted path, on 127.0.0.1 and a free port, no
|
|
||||||
/// password, nothing put up yet. Nobody else can reach it.
|
|
||||||
Future<void> startSolo(ConnectParams params) async {
|
|
||||||
if (route.peek() == AppRoute.session) return;
|
|
||||||
_soloParams = params;
|
|
||||||
await _host(params, soloSession: true);
|
|
||||||
}
|
|
||||||
|
|
||||||
({PickedMedia pick, double position})? _carry() {
|
|
||||||
final pick = coordinator.lastPick;
|
|
||||||
if (pick == null || coordinator.serverState.peek()?.media == null) return null;
|
|
||||||
final pos = coordinator.playback.positionSecs;
|
|
||||||
return (pick: pick, position: pos.isFinite && pos > 0 ? pos : 0.0);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Puts [carry] up on the server just joined and seeks to its position
|
|
||||||
/// once the swap shows in a state.
|
|
||||||
Future<void> _resume(({PickedMedia pick, double position}) carry) async {
|
|
||||||
final before = coordinator.serverState.peek()?.mediaId ?? 0;
|
|
||||||
final error = await coordinator.swapPicked(carry.pick);
|
|
||||||
if (error != null) throw StateError(error);
|
|
||||||
if (carry.position < 1) return;
|
|
||||||
final landed = Completer<void>();
|
|
||||||
final stop = effect(() {
|
|
||||||
final r = coordinator.serverState.value;
|
|
||||||
if (r != null && r.media != null && r.mediaId > before && !landed.isCompleted) landed.complete();
|
|
||||||
});
|
|
||||||
try {
|
|
||||||
await landed.future.timeout(const Duration(seconds: 20));
|
|
||||||
coordinator.connection.sendSeek(carry.position);
|
|
||||||
} on TimeoutException {
|
|
||||||
debugPrint('resume: the media did not come up; not seeking');
|
|
||||||
} finally {
|
|
||||||
stop();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _host(ConnectParams params, {ConnectForm? form, bool soloSession = false}) async {
|
|
||||||
final op = ++_op;
|
|
||||||
if (form != null) _rememberForm(form);
|
|
||||||
try {
|
|
||||||
status.value =
|
|
||||||
const HealthStatus(HealthSeverity.info, 'starting server…');
|
|
||||||
await stopServer();
|
|
||||||
if (op != _op) throw const ConnectCancelled();
|
|
||||||
final hostKey = newHostKey();
|
|
||||||
_setupCrash = null;
|
|
||||||
// A pasted Jellyfin link goes up as a ref like a browser pick.
|
|
||||||
final media = params.media ??
|
|
||||||
(params.linkUrl.isEmpty ? null : await mediaForLink(jellyfin, params.linkUrl));
|
|
||||||
if (op != _op) throw const ConnectCancelled();
|
|
||||||
// Guests without an account watch through the server, with the
|
|
||||||
// host's own sign-in (the hosted item's server, else the default).
|
|
||||||
await jellyfin.ensureLoaded(validate: false);
|
|
||||||
final relay = relayCredentialFor(jellyfin, serverId: media?.jellyfin?.serverId);
|
|
||||||
late final ServerIsolate server;
|
|
||||||
try {
|
|
||||||
server = await ServerIsolate.start(
|
|
||||||
AppConfig(
|
|
||||||
port: soloSession ? 0 : params.port,
|
|
||||||
secret: soloSession ? '' : params.secret,
|
|
||||||
name: 'server',
|
|
||||||
mediaPath: params.mediaPath,
|
|
||||||
hostKey: hostKey,
|
|
||||||
jellyfin: relay,
|
|
||||||
ytDlpDir: await _sharedYtDlpDir(),
|
|
||||||
),
|
|
||||||
loopbackOnly: soloSession,
|
|
||||||
// Its session log (every member's shared diagnostics and its own
|
|
||||||
// records) goes next to the app's; not for a solo session.
|
|
||||||
logDir: soloSession ? null : appLogDir,
|
|
||||||
// Its records join the app's log as `server.<logger>`.
|
|
||||||
onLog: (line) => relogLine('server', line),
|
|
||||||
onCrash: (cause) => _onServerCrash(server, cause),
|
|
||||||
);
|
|
||||||
} catch (e, st) {
|
|
||||||
debugPrint('server start failed: $e\n$st');
|
|
||||||
status.value = HealthStatus(
|
|
||||||
HealthSeverity.error, 'cannot start server on port ${params.port}: $e');
|
|
||||||
rethrow;
|
|
||||||
}
|
|
||||||
if (op != _op) {
|
|
||||||
await server.stop();
|
|
||||||
throw const ConnectCancelled();
|
|
||||||
}
|
|
||||||
_server = server;
|
|
||||||
coordinator.hostFile = (path) async =>
|
|
||||||
identical(_server, server) ? server.setFile(path) : null;
|
|
||||||
status.value = HealthStatus(
|
|
||||||
HealthSeverity.ok, soloSession ? 'watching solo' : 'hosting on port ${server.port}');
|
|
||||||
try {
|
|
||||||
await coordinator.connect(ConnectParams(
|
|
||||||
host: '127.0.0.1',
|
|
||||||
port: server.port,
|
|
||||||
secret: soloSession ? '' : params.secret,
|
|
||||||
name: params.name,
|
|
||||||
avatar: params.avatar,
|
|
||||||
hostKey: hostKey,
|
|
||||||
media: media,
|
|
||||||
));
|
|
||||||
if (op != _op) throw const ConnectCancelled();
|
|
||||||
} catch (e) {
|
|
||||||
// Own join failed or was cancelled: don't leave the port open.
|
|
||||||
if (identical(_server, server)) await stopServer();
|
|
||||||
final crash = _setupCrash;
|
|
||||||
if (crash != null && e is! ConnectCancelled) throw ServerCrashed(crash);
|
|
||||||
rethrow;
|
|
||||||
}
|
|
||||||
solo.value = soloSession;
|
|
||||||
coordinator.solo = soloSession;
|
|
||||||
route.value = AppRoute.session;
|
|
||||||
} finally {
|
|
||||||
if (form != null) await _saveFormQuietly();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The server isolate died without being stopped. In a session: show it
|
|
||||||
/// and leave. During setup the own join fails and [host] reports it.
|
|
||||||
void _onServerCrash(ServerIsolate server, String cause) {
|
|
||||||
if (!identical(_server, server)) return;
|
|
||||||
final message = 'server crashed: $cause';
|
|
||||||
debugPrint('[server] $message');
|
|
||||||
if (route.peek() != AppRoute.session) {
|
|
||||||
_setupCrash = cause;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
coordinator.health.value = HealthStatus(HealthSeverity.error, message);
|
|
||||||
serverCrash.value = message;
|
|
||||||
unawaited(leave().catchError((Object e) => debugPrint('leave after crash failed: $e')));
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> join(ConnectParams params, ConnectForm form) async {
|
|
||||||
final op = ++_op;
|
|
||||||
_rememberForm(form);
|
|
||||||
try {
|
|
||||||
await coordinator.connect(params);
|
|
||||||
if (op != _op) throw const ConnectCancelled();
|
|
||||||
route.value = AppRoute.session;
|
|
||||||
serverLabel.value = '';
|
|
||||||
unawaited(probeHealth(params.host, '${params.port}', defaultHealthGet).then((h) {
|
|
||||||
// 'server' is the dedicated server's default --name: not a name.
|
|
||||||
if (op == _op && h.online && h.name.isNotEmpty && h.name != 'server') serverLabel.value = h.name;
|
|
||||||
}).catchError((Object _) {}));
|
|
||||||
} finally {
|
|
||||||
await _saveFormQuietly();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Cancel button during setup: abort whichever step is running (server
|
|
||||||
/// start, player init, socket dial) without connecting.
|
|
||||||
Future<void> cancelPending() async {
|
|
||||||
_op++;
|
|
||||||
try {
|
|
||||||
await coordinator.cancelConnect();
|
|
||||||
} catch (_) {}
|
|
||||||
// Solo becoming hosted: [host] stops what it started and goes back
|
|
||||||
// to solo itself.
|
|
||||||
if (!switching.peek()) await stopServer();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Saved whatever the outcome: a failed join is exactly when the user
|
|
||||||
/// wants the form back.
|
|
||||||
Future<void> _saveFormQuietly() async {
|
|
||||||
try {
|
|
||||||
await saveForm();
|
|
||||||
} catch (e, st) {
|
|
||||||
debugPrint('save connect form failed: $e\n$st');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _rememberForm(ConnectForm form) {
|
|
||||||
lastForm
|
|
||||||
..host = form.host
|
|
||||||
..port = form.port
|
|
||||||
..secret = form.secret
|
|
||||||
..hostPort = form.hostPort
|
|
||||||
..hostSecret = form.hostSecret
|
|
||||||
..name = form.name
|
|
||||||
..avatarPath = form.avatarPath
|
|
||||||
..mediaPath = form.mediaPath
|
|
||||||
..linkUrl = form.linkUrl;
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> stopServer() async {
|
|
||||||
coordinator.hostFile = null;
|
|
||||||
final server = _server;
|
|
||||||
_server = null;
|
|
||||||
await server?.stop();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Out of the session (Leave, the app closing). [bye] false: Update
|
|
||||||
/// and restart, the restarted app takes the member back within its grace.
|
|
||||||
Future<void> leave({bool bye = true}) async {
|
|
||||||
await coordinator.leave(bye: bye);
|
|
||||||
await stopServer();
|
|
||||||
solo.value = false;
|
|
||||||
coordinator.solo = false;
|
|
||||||
serverLabel.value = '';
|
|
||||||
status.value = const HealthStatus(HealthSeverity.info, 'idle');
|
|
||||||
route.value = AppRoute.home;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The sound set [kind] plays from: its own pick in Settings, else the
|
|
||||||
/// overall one.
|
|
||||||
SoundPreset soundPresetFor(SfxKind kind) {
|
|
||||||
final s = AppSettings.instance;
|
|
||||||
final name = s.soundPresetFor.peek()[kind.name] ?? s.soundPreset.peek();
|
|
||||||
return SoundPreset.values.where((p) => p.name == name).firstOrNull ?? defaultPreset;
|
|
||||||
}
|
|
||||||
@@ -6,8 +6,11 @@ import 'package:signals/signals_flutter.dart';
|
|||||||
|
|
||||||
import '../controls/model/controls_prefs.dart';
|
import '../controls/model/controls_prefs.dart';
|
||||||
import '../shaders/shader_presets.dart';
|
import '../shaders/shader_presets.dart';
|
||||||
|
import '../session/notice_prefs.dart';
|
||||||
import 'settings_schema.dart';
|
import 'settings_schema.dart';
|
||||||
|
|
||||||
|
export '../session/notice_prefs.dart' show ChatPopups, NotifyPrefs;
|
||||||
|
|
||||||
// App preferences (Settings page), one Hive box. Signals start at their
|
// App preferences (Settings page), one Hive box. Signals start at their
|
||||||
// defaults and take the stored values once [AppSettings.ensureLoaded]
|
// defaults and take the stored values once [AppSettings.ensureLoaded]
|
||||||
// finishes; setters write through.
|
// finishes; setters write through.
|
||||||
@@ -39,6 +42,7 @@ const _keySoundPresetFor = 'soundPresetFor';
|
|||||||
const _keyUnreadyOnSwap = 'unreadyOnSwap';
|
const _keyUnreadyOnSwap = 'unreadyOnSwap';
|
||||||
const keyAutoSitOut = 'autoSitOut';
|
const keyAutoSitOut = 'autoSitOut';
|
||||||
const _keyChatPopups = 'chatPopups';
|
const _keyChatPopups = 'chatPopups';
|
||||||
|
const _keyNotifyPeople = 'notifyPeople';
|
||||||
const _keyShareDiagnostics = 'shareDiagnostics';
|
const _keyShareDiagnostics = 'shareDiagnostics';
|
||||||
|
|
||||||
enum HwdecMode { auto, off, custom }
|
enum HwdecMode { auto, off, custom }
|
||||||
@@ -49,30 +53,15 @@ enum HwdecMode { auto, off, custom }
|
|||||||
/// surface there ("Failed to create EGL surface"). Elsewhere 'auto'.
|
/// surface there ("Failed to create EGL surface"). Elsewhere 'auto'.
|
||||||
String get autoHwdec => Platform.isWindows ? 'auto-copy' : 'auto';
|
String get autoHwdec => Platform.isWindows ? 'auto-copy' : 'auto';
|
||||||
|
|
||||||
/// When chat messages pop up as toasts (they always reach the chat).
|
|
||||||
enum ChatPopups {
|
|
||||||
all('All'),
|
|
||||||
paused('Only when paused'),
|
|
||||||
off('Off');
|
|
||||||
|
|
||||||
final String label;
|
|
||||||
const ChatPopups(this.label);
|
|
||||||
|
|
||||||
/// Whether a chat message pops up now ([playing]: the video plays).
|
|
||||||
bool shows({required bool playing}) => switch (this) {
|
|
||||||
ChatPopups.all => true,
|
|
||||||
ChatPopups.paused => !playing,
|
|
||||||
ChatPopups.off => false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
class AppSettings {
|
class AppSettings {
|
||||||
static final AppSettings instance = AppSettings();
|
static final AppSettings instance = AppSettings();
|
||||||
|
|
||||||
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.
|
/// 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).
|
||||||
@@ -100,6 +89,12 @@ class AppSettings {
|
|||||||
final shareDiagnostics = signal(true);
|
final shareDiagnostics = signal(true);
|
||||||
/// 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
|
||||||
|
/// the background (default off). Applies live.
|
||||||
|
final notifyPeople = signal(false);
|
||||||
|
|
||||||
|
/// The notification choices as the Notifier reads them.
|
||||||
|
NotifyPrefs get notifyPrefs => NotifyPrefs(chat: chatPopups.peek(), people: notifyPeople.peek());
|
||||||
/// App sounds (`sfx/`): 0..1, 0 = silent.
|
/// App sounds (`sfx/`): 0..1, 0 = silent.
|
||||||
final soundVolume = signal(1.0);
|
final soundVolume = signal(1.0);
|
||||||
/// `SfxKind` names switched off one by one.
|
/// `SfxKind` names switched off one by one.
|
||||||
@@ -174,6 +169,8 @@ class AppSettings {
|
|||||||
if (share is bool) shareDiagnostics.value = share;
|
if (share is bool) shareDiagnostics.value = share;
|
||||||
final popups = box.get(_keyChatPopups);
|
final popups = box.get(_keyChatPopups);
|
||||||
chatPopups.value = ChatPopups.values.where((m) => m.name == popups).firstOrNull ?? ChatPopups.all;
|
chatPopups.value = ChatPopups.values.where((m) => m.name == popups).firstOrNull ?? ChatPopups.all;
|
||||||
|
final people = box.get(_keyNotifyPeople);
|
||||||
|
if (people is bool) notifyPeople.value = people;
|
||||||
final vol = box.get(_keySoundVolume);
|
final vol = box.get(_keySoundVolume);
|
||||||
if (vol is num) soundVolume.value = vol.toDouble().clamp(0, 1);
|
if (vol is num) soundVolume.value = vol.toDouble().clamp(0, 1);
|
||||||
final off = box.get(_keySoundsOff);
|
final off = box.get(_keySoundsOff);
|
||||||
@@ -278,6 +275,11 @@ class AppSettings {
|
|||||||
_put(_keyChatPopups, mode.name);
|
_put(_keyChatPopups, mode.name);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void setNotifyPeople(bool on) {
|
||||||
|
notifyPeople.value = on;
|
||||||
|
_put(_keyNotifyPeople, on);
|
||||||
|
}
|
||||||
|
|
||||||
void setSoundVolume(double v) {
|
void setSoundVolume(double v) {
|
||||||
soundVolume.value = v.clamp(0, 1);
|
soundVolume.value = v.clamp(0, 1);
|
||||||
_put(_keySoundVolume, soundVolume.value);
|
_put(_keySoundVolume, soundVolume.value);
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
Loaded 100 of 322 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user