20 Commits
Author SHA1 Message Date
foxyz 49fee1ca19 Release v1.3.1
ci / updater-windows (push) Skipped
ci / core (push) Successful in 29s
ci / server (push) Failing after 40s
release / linux (push) Successful in 4m17s
release / windows (push) Successful in 6m18s
2026-10-17 00:56:17 +03:00
foxyzandClaude Opus 5.5 c91d57633f Sounds: survive an audio device that crashes the app on open
A Windows friend's 1.3.0 dies about a second in on every start, right
around the 2 s audio warm-up (no Dart error, no close lines). Opening the
device is now logged and leaves audio-init.pending until it returns; a
start that finds it skips sounds instead of crashing again, and a sound
preview in Settings tries once more.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 00:50:26 +03:00
foxyzandClaude Opus 5.5 22ce40a488 tool/windows_report.cmd: double-click crash report for Windows friends (logs, crash events, files, test start, zip on the clipboard)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 00:25:53 +03:00
foxyzandClaude Opus 5.5 07276ce020 Roadmap: P35 notification bursts, P36 stale warm relay re-dial
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 22:35:34 +03:00
foxyzandClaude Opus 5.5 4c8e40769a Mini player cleanup: no full-player controls left under it, the full player's play button, a plain progress bar
media_kit keeps a Video's last controls when given null, so after the
full player the mini kept the Minimal bars (dark bands above and below
the picture, a second hatched line). The mini now passes an empty
builder. Its hover bar uses the full player's play button and time,
the status on its own line, Minimal buttons, and a painted bar instead
of Material's progress indicator (its gap and stop dot at the corner).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 08:14:48 +03:00
foxyzandClaude Opus 5.5 211ba65e5e Roadmap: AniList browser (requests to AnimeFlow, later Sonarr), real cached ranges on the timelines
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 08:04:30 +03:00
foxyzandClaude Opus 5.5 fe4749ec7f Minimal: the hidden line is the band shrunk (same colours, no switch), stronger stripes on it, lighter chapter cuts
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 08:04:09 +03:00
foxyzandClaude Opus 5.5 51af830160 Joining another server no longer opens an empty player
connect() kept the last server's state while dialling the next; the
shell saw its media on arrival and opened the player (full, on a
friend's server) over the new, empty one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 07:53:37 +03:00
foxyzandClaude Opus 5.5 d9d7881747 Roadmap: 1.3.0 released
ci / core (push) Successful in 30s
ci / server (push) Successful in 51s
ci / updater-windows (push) Successful in 6m25s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 07:45:15 +03:00
foxyz 1c915067aa Release v1.3.0
ci / updater-windows (push) Skipped
ci / core (push) Successful in 28s
ci / server (push) Successful in 50s
release / windows (push) Successful in 6m24s
release / linux (push) Successful in 4m20s
2026-10-16 07:36:10 +03:00
foxyzandClaude Opus 5.5 0924309abf Roadmap: tray (close to tray, like Discord)
ci / core (push) Successful in 28s
ci / server (push) Successful in 49s
ci / updater-windows (push) Successful in 6m17s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 07:31:22 +03:00
foxyzandClaude Opus 5.5 cf41a13528 CI: add the C++ ATL component on the Windows runner (notifications plugin needs atlbase.h)
ci / core (push) Successful in 27s
ci / server (push) Successful in 52s
ci / updater-windows (push) Successful in 9m30s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 06:42:20 +03:00
foxyzandClaude Opus 5.5 9bbc594048 Open media only once the video output is up (black video on X11 when joining mid-play)
ci / core (push) Successful in 33s
ci / server (push) Successful in 52s
ci / updater-windows (push) Failing after 7m48s
media_kit sets vo=libmpv before the render context exists; an open in
between failed mpv's video output ("No render context set") and played
black with sound. Seen on X11 joining a server with media already up.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 03:32:45 +03:00
foxyzandClaude Opus 5.5 741b258ac1 Restructure step 6: what's next and watch tracking
ci / core (push) Successful in 29s
ci / server (push) Successful in 53s
ci / updater-windows (push) Failing after 7m45s
- What's next is the server's: state.advance counts down (10 s) when the
  media finishes with an Up next item first, holds while its performer or
  warm source is gone, and sets queuePlay at its end; cancel is shared, a
  seek or swap ends it; the server skips items nobody can put up. Shared
  rules in core whats_next.dart; the app's janitor, per-screen countdown
  and end-of-episode card are gone.
- The continuation (next Jellyfin episode, next file of a local show
  with its gaps) is an auto item under "Continues with", prepared like
  Up next's first item.
- Watch tracking by time In (D3): only time played while this viewer is
  in counts; away, paused, held, jumps and stalls never do (P7, P9).
  WatchTracker + device WatchHistory (files, links); Jellyfin progress
  reads the same watch; records are the AniList seam.
- A server restart no longer loses what played: apps hold their media
  and one offers it back (swap.restore, first offer wins), Up next is
  re-added, the source re-feeds, and the watch carries on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 02:06:04 +03:00
foxyzandClaude Opus 5.5 a87ee98faa Vendor media_kit_video 2.0.1 with the X11/EGL fix (hardware rendering on X11 with Flutter >= 3.38)
ci / core (push) Successful in 28s
ci / server (push) Successful in 51s
ci / updater-windows (push) Failing after 5m59s
Flutter >= 3.38 keeps its EGL contexts on the raster thread, so
media_kit's video output found none on the platform thread and fell
back to software rendering (no shaders, ~5 fps) on X11. The patched
output opens Flutter's EGL display itself, steps GDK's GLX context aside
on X11 and releases its own context. Patch from a friend's agent, kept
in third_party/media_kit_video/starsync/. Wayland/NVIDIA still renders
on the GPU (checked); the X11 path was verified on the friend's machine.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 01:13:14 +03:00
foxyzandClaude Opus 5.5 25b19f2d6a Restructure step 5: one media pipeline on a player host
- PlayerHost port (MediaKitHost): players by role, pins, hwdec policy,
  render probe, log routing, disposal order; the only media_kit user.
- MediaPipeline: plan (one pure switch over state, origin, caps,
  presence) -> resolve -> open (prepared player promoted, else fresh)
  -> ready; commands queued during a switch apply once (P15); viewers
  play the buffer and wait for a dropped source without seek loops (D6).
- Every web page resolved by our yt-dlp into an EDL with headers and
  cookies (danmaku excluded); mpv's ytdl_hook no longer used (ytdl=no).
- D11: sign-in errors say which cookies were used and whether they hold
  any for the site.
- Relay: 256 KiB slices read as they fill, superseded pulls released
  (seek-to-play ~4.2 s -> ~2.8 s, P29); own Jellyfin relay channel only
  when someone needs it (P28).
- STARSYNC_SMOKE run and a Windows CI smoke step after the build.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 01:08:54 +03:00
foxyzandClaude Opus 5.5 8970f2694b Roadmap: no upstream PRs; Windows libmpv update possible on its own
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 00:30:35 +03:00
foxyzandClaude Opus 5.5 72a3b9adac Roadmap: media_kit fork evaluation after the restructure
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 00:18:36 +03:00
foxyzandClaude Opus 5.5 bf9fc193b3 Restructure step 4: local library and media identity
ci / core (push) Successful in 29s
ci / server (push) Successful in 1m8s
ci / updater-windows (push) Failing after 6m2s
- L1 (core, offline): release names with kinds (episode, movie, extra:
  OP/ED/Opening/Ending/NC/PV/OVA…), folder hints (season and name from
  parentheticals, ranges), one show across folders, gaps and next with
  skipped numbers, junk ignored, stable keys.
- L2 (app, device-owned cache): AniList match with synonyms and
  sequel walking, episode mapping across cours (AnimeFlow runs when
  signed in), art and episode titles, "Wrong show?" pins per show or
  folder, episode offsets; lazy, spaced requests, works offline.
- Files tab as a library: poster cards, show pages with banner, gap
  prompt, extras, movies, Wrong show? and Episode numbers.
- media.ref produced for local files, Jellyfin (provider ids) and links,
  carried in swaps and Up next; receivers name media from it.
- Hosting relays each Jellyfin item through its own server's login.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 23:34:39 +03:00
foxyzandClaude Opus 5.5 4d00b7455e Restructure step 3: your server, Invite, version gate, AppGraph
ci / core (push) Successful in 27s
ci / server (push) Successful in 1m7s
ci / updater-windows (push) Failing after 6m4s
- One server per app run (loopback, no password); Invite attaches an
  all-interfaces listener to the same server and Stop sharing detaches
  it (guests told with 4016); members, media and Up next never reset.
  No solo mode: you're home on your own server unless in a friend's.
- Navigation: none / yours / friend; a join passes a version gate on
  /health first (clear "they/you need to update", update offer).
- Invite as a value (host, port, name, password flag, version, fp
  reserved); addresses filtered (loopback, link-local, 198.18/15,
  bridges) and ranked; Sharing chip, Copy invite, Stop sharing.
- Alone looks like a plain player (alone replaces solo).
- AppGraph builds and disposes systems; widgets use AppScope;
  SessionState deleted; a test caps the remaining singletons.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 22:52:52 +03:00
307 changed files with 30061 additions and 5673 deletions

No files matched your search

+10 -1
View File
@@ -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
+107 -65
View File
@@ -60,36 +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 (each member's app version, amber when it differs from yours, member list (each member's app version, amber when it differs from yours,
and the server's at the top; force ready, Nudge, Sit out, "Copy invite address"; when you host it lists this computer's and the server's at the top; force ready, Nudge, Sit out; on your
network addresses with the port, and friends outside your network need server the invite with Copy invite and Stop sharing, on a friend's "Copy
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 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
@@ -160,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,
@@ -206,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
@@ -230,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
@@ -238,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
@@ -302,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
@@ -315,18 +345,22 @@ 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, chat next episode automatically); Notifications & sounds (what pops up, chat
@@ -412,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
@@ -445,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
@@ -464,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
@@ -504,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
+32 -14
View File
@@ -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,24 +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 and the Notifier (in // Focus, minimise and restore feed auto sit-out and the Notifier (in
// the background, notices go out as system notifications). // the background, notices go out as system notifications).
ports.shell.watchActivity((a) { ports.shell.watchActivity((a) {
SessionState.instance.coordinator.windowActivity(a); graph.coordinator.windowActivity(a);
SessionState.instance.notifier.window = 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).
@@ -74,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);
} }
+195
View File
@@ -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;
-196
View File
@@ -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();
+9 -114
View File
@@ -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'[/\\]'));
+12 -16
View File
@@ -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);
} }
} }
+8 -76
View File
@@ -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)),
], ],
), ),
+358 -167
View File
@@ -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,33 +450,77 @@ class _FilesPageState extends State<FilesPage> {
return n == null ? null : '$n ${n == 1 ? 'video' : 'videos'}'; return n == null ? null : '$n ${n == 1 ? 'video' : 'videos'}';
} }
String? _libraryStatus(List<String> folders) {
if (folders.any(_running.containsKey)) return 'scanning…';
if (folders.isEmpty || folders.any((f) => !_scans.containsKey(f))) return null;
final n = _libraryOf(libraryPlace)?.lib.shows.length ?? 0;
return '$n ${n == 1 ? 'show' : 'shows'}';
}
List<Widget> _notices() => [
if (!widget.alone && widget.relayed) const _RelayNotice(),
AnimatedSize(
duration: easeTime,
curve: ease,
alignment: Alignment.topLeft,
child: _error == null
? const SizedBox(width: double.infinity)
: _ErrorBanner(_error!, onClose: () => setState(() => _error = null)),
),
];
Widget _main( Widget _main(
BuildContext context, BuildContext context,
bool narrow, bool narrow,
List<String> folders, List<String> folders,
List<String> recent, List<String> recent,
String? open, String? open,
String? showKey,
) { ) {
final scan = open == null ? null : _scans[open]; final lib = open == null ? null : _libraryOf(open);
final scanning = open != null && _running.containsKey(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>[ final top = <Widget>[
_header(context, open), _header(context, open),
if (narrow) _chips(folders, open), if (narrow) _chips(folders, open),
if (!widget.solo && widget.relayed) const _RelayNotice(), ..._notices(),
AnimatedSize(
duration: easeTime,
curve: ease,
alignment: Alignment.topLeft,
child: _error == null
? const SizedBox(width: double.infinity)
: _ErrorBanner(_error!, onClose: () => setState(() => _error = null)),
),
]; ];
final slivers = <Widget>[ 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,49 +699,55 @@ 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'),
), ),
], ],
), ),
), ),
SliverToBoxAdapter( if (folderScan != null)
child: AnimatedSize(
duration: easeTime,
curve: ease,
alignment: Alignment.topLeft,
child: !_showHidden
? const SizedBox(width: double.infinity)
: Column(
children: [
for (final f in scan.hiddenExamples)
ListTile(
dense: true,
enabled: false,
leading: const Icon(Icons.insert_drive_file_outlined, size: 18),
title: Text(f, maxLines: 1, overflow: TextOverflow.ellipsis),
trailing: Text(
kindOf(f) == FileKind.video ? 'sample' : 'not a video',
style: const TextStyle(fontSize: 12),
),
),
if (scan.hiddenCount > scan.hiddenExamples.length)
ListTile(
dense: true,
enabled: false,
title: Text('and ${scan.hiddenCount - scan.hiddenExamples.length} more'),
),
],
),
),
),
if (scan.videos.isEmpty)
SliverToBoxAdapter( SliverToBoxAdapter(
child: _folders.folderExists(dir) child: AnimatedSize(
duration: easeTime,
curve: ease,
alignment: Alignment.topLeft,
child: !_showHidden
? const SizedBox(width: double.infinity)
: Column(
children: [
for (final f in folderScan.hiddenExamples)
ListTile(
dense: true,
enabled: false,
leading: const Icon(Icons.insert_drive_file_outlined, size: 18),
title: Text(f, maxLines: 1, overflow: TextOverflow.ellipsis),
trailing: Text(
kindOf(f) == FileKind.video ? 'sample' : 'not a video',
style: const TextStyle(fontSize: 12),
),
),
if (folderScan.hiddenCount > folderScan.hiddenExamples.length)
ListTile(
dense: true,
enabled: false,
title: Text('and ${folderScan.hiddenCount - folderScan.hiddenExamples.length} more'),
),
],
),
),
),
if (lib.videos == 0)
SliverToBoxAdapter(
child: place == libraryPlace || _folders.folderExists(place)
? const FilesEmpty( ? 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( if (movies.isNotEmpty) ...[
key: ValueKey('series:${group.key}'), SliverToBoxAdapter(child: _section(context, 'Movies', count: '${movies.length}')),
group: group, SliverGrid.builder(
thumbs: _thumbs, gridDelegate: posterGrid(),
expanded: _expanded.contains(id), itemCount: movies.length,
onTap: () => setState(() => _expanded.contains(id) ? _expanded.remove(id) : _expanded.add(id)), itemBuilder: (context, i) => _movieCard(movies[i]),
); ),
} ],
return _menuable( if (other.isNotEmpty) ...[
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) ...[
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),
); );
} }
+15 -14
View File
@@ -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 [
serverId: a.serverId, for (final a in [?first, ...accounts.accounts.where((a) => a.serverId != first?.serverId)])
token: a.token, if (accounts.statusOf(a.serverId) != JellyfinAccountStatus.expired)
client: info.client, JellyfinCredential(
device: info.device, baseUrl: a.baseUrl,
deviceId: info.deviceId, serverId: a.serverId,
version: info.version, token: a.token,
); client: info.client,
device: info.device,
deviceId: info.deviceId,
version: info.version,
),
];
} }
/// `/j/...` URL of the current Jellyfin media on the host this client /// `/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;
}
+5 -5
View File
@@ -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 ---")
+23 -11
View File
@@ -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.
@@ -4,6 +4,7 @@ import 'desktop_folders.dart';
import 'desktop_notifications.dart'; import 'desktop_notifications.dart';
import 'desktop_shell.dart'; import 'desktop_shell.dart';
import 'desktop_urls.dart'; import 'desktop_urls.dart';
import 'media_kit_host.dart';
/// The desktop's [Ports]. /// The desktop's [Ports].
Ports desktopPorts() { Ports desktopPorts() {
@@ -14,5 +15,6 @@ Ports desktopPorts() {
folders: const DesktopFolders(urls), folders: const DesktopFolders(urls),
links: AppYtDlp.instance, links: AppYtDlp.instance,
notifications: DesktopNotifications(), notifications: DesktopNotifications(),
players: MediaKitHost(),
); );
} }
@@ -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,
}); });
} }
+70
View File
@@ -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
+2 -21
View File
@@ -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);
+229 -145
View File
@@ -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
if (parsed.season != null) 'S${parsed.season}', // see in Up next), else the release name's.
if (parsed.episode != null) 'E${parsed.episode}', final what = coord.fileRef?.call(path);
].join(); final ep = what != null && what.kind == 'episode'
? (refEpisodeLine(what) ?? '').replaceAll(' ', '')
: [
if (parsed.season != null) 'S${parsed.season}',
if (parsed.episode != null) 'E${parsed.episode}',
if (parsed.episode == null) ?parsed.extra,
].join();
final title = what != null && what.title.isNotEmpty ? what.title : parsed.series.trim();
return ( 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};
_local.removeWhere((k, v) => !keys.contains(k) && now.difference(v.at) > const Duration(minutes: 1)); // Kept while a restarted server waits for what played: Up next comes
final ids = {for (final q in r.queue) q.id}; // back with it.
_skipped.removeWhere((id) => !ids.contains(id)); if (_restoreFrom == -1) {
_local.removeWhere((k, v) => !keys.contains(k) && now.difference(v.at) > const Duration(minutes: 1));
}
} }
/// [item] should go up now: by this app when it is the one that can; /// The end card from the server's state: the media ended with something
/// nobody can: the lowest member takes it off with the reason. /// next; the seconds of `state.advance` move on with a 1 s timer.
void _bringUp(QueueItem item, StateBroadcast r) { void _syncCountdown(StateBroadcast r) {
final perf = queuePerformer(item, r.members);
if (perf.id == null) {
final why = perf.why;
if (why != null && queueJanitor(r.members) == _me && _skipped.add(item.id)) {
coord.connection.sendQueue(QueueIntent.remove(item.id, why: why));
}
return;
}
if (perf.id == _me) _perform(item);
}
/// The media finished with Up next waiting: the card, and with
/// auto-advance on the countdown, for its first item.
void _atEnd(StateBroadcast r) {
// Up next goes before the next episode, even one already counting.
if (coord.autoAdvance.peek() != null) coord.cancelAutoAdvance();
final head = r.queue.first;
final perf = queuePerformer(head, r.members);
if (perf.id == null && perf.why != null) {
// Taken off by [_bringUp]'s rule; the next state shows the next one.
_bringUp(head, r);
return;
}
final c = countdown.peek();
if (c != null && c.mediaId == r.mediaId) {
if (c.item.id != head.id || c.item.thumb != head.thumb) {
countdown.value = UpNextCountdown(mediaId: c.mediaId, item: head, secondsLeft: c.secondsLeft);
}
return;
}
final auto = AppSettings.instance.autoAdvance.peek() && _cancelledFor != r.mediaId;
countdown.value = UpNextCountdown(mediaId: r.mediaId, item: head, secondsLeft: auto ? autoAdvanceSecs : null);
_timer?.cancel();
_timer = auto ? Timer.periodic(const Duration(seconds: 1), (_) => _tick()) : null;
}
void _tick() {
final c = countdown.peek();
final r = coord.serverState.peek();
final left = c?.secondsLeft;
if (c == null || left == null || r == null || r.mediaId != c.mediaId || r.transport != 'finished') {
_stopCountdown();
return;
}
if (left > 1) {
countdown.value = UpNextCountdown(mediaId: c.mediaId, item: c.item, secondsLeft: left - 1);
return;
}
_timer?.cancel();
_timer = null;
_cancelledFor = c.mediaId;
countdown.value = UpNextCountdown(mediaId: c.mediaId, item: c.item);
// Decided on the latest state: only the app that can puts it up.
final head = r.queue.firstOrNull; final head = r.queue.firstOrNull;
if (head != null && queuePerformer(head, r.members).id == _me) _perform(head); if (r.transport != 'finished' || head == null || r.media == null) {
_timer?.cancel();
_timer = null;
if (countdown.peek() != null) countdown.value = null;
return;
}
final a = r.advance != null && r.advance!.id == head.id ? r.advance : null;
int? left;
String? waiting;
if (a != null) {
left = advanceSecondsLeft(a, coord.connection.serverNow());
if (left == 0 && queuePerformer(head, r.members).id == null) {
waiting = r.members.where((m) => m.id == head.by).firstOrNull?.name ?? head.byName;
}
}
countdown.value = UpNextCountdown(mediaId: r.mediaId, item: head, secondsLeft: left, waitingFor: waiting);
if (a == null) {
_timer?.cancel();
_timer = null;
} else {
_timer ??= Timer.periodic(const Duration(seconds: 1), (_) {
final now = coord.serverState.peek();
if (now != null) _syncCountdown(now);
});
}
} }
void _stopCountdown() { // ------------------------------------------------------------ restart
_timer?.cancel();
_timer = null; /// The server restarted without media; what played ([mediaId]) is being
countdown.value = null; /// offered back (-1: that is over).
void restoreFrom(int mediaId) => _restoreFrom = mediaId;
/// What played is back after a server restart: Up next as [before] had
/// it comes back too. The app whose offer won ([winner]) adds the items
/// anyone can put up (links everyone opens, Jellyfin items); every app
/// its own links passed on and files (only it has them; [self] is its
/// member id then). A server-fetched link's address stayed with the old
/// server, so it is gone; the continuation comes back as usual.
void restoreAfterRestart(StateBroadcast before, String self, {required bool winner}) {
for (final q in before.queue) {
if (q.auto) continue;
final ok = switch (q.how) {
QueueHow.direct || QueueHow.jellyfin => winner,
QueueHow.through || QueueHow.file => self.isNotEmpty && q.by == self && _local.containsKey(q.key),
QueueHow.server => false,
};
if (!ok) continue;
final local = _local[q.key];
if (local != null) _local[q.key] = (pick: local.pick, at: DateTime.now());
final thumb = thumbs.bytesFor(q.thumb);
coord.connection.sendQueue(QueueIntent.add(
QueueItem(media: q.media, how: q.how, title: q.title, sub: q.sub, duration: q.duration, key: q.key),
thumb: thumb == null ? '' : base64Encode(thumb)));
}
_restoreFrom = -1;
}
// ------------------------------------------------------- continuation
/// Adds what continues the media playing (`auto`, PROTOCOL.md "Queue")
/// when this app may ([mayAddContinuation]: it put it up, or that
/// member is not in and this is the first who is), knows what comes
/// next, and it is not there or cancelled yet; with the setting on. A
/// Jellyfin episode played with this app's account continues with the
/// series' next one, a file this app put up with the next episode of its
/// show in the library (numbers skipped on the way said in its line).
void _continue(StateBroadcast? r) {
if (r == null || r.media == null || !coord.settings.autoAdvance.peek()) return;
final id = r.mediaId;
if (_continuedFor == id || _hiddenFor.peek() == id) return;
if (r.queue.any((q) => q.auto && q.forMedia == id)) {
_continuedFor = id;
return;
}
final swapBy = r.media?['swapBy'];
if (!mayAddContinuation(_me, swapBy is String ? swapBy : null, r.members)) return;
final ref = refOfStateMedia(r.media);
final next = coord.nextEpisode.peek();
if (ref != null) {
if (next == null || !sameJellyfinItem(ref, coord.currentRef.peek())) return;
_continuedFor = id;
unawaited(_addContinuation(id, next.pick));
return;
}
final own = coord.ownFile.peek();
final finder = coord.nextFile;
if (own == null || own.$1 != id || finder == null) return;
_continuedFor = id;
unawaited(() async {
try {
final n = await finder(own.$2);
if (n == null) return;
final gap = n.skipped;
await _addContinuation(id, FileMedia(n.episode.path),
note: gap == null ? null : 'skips ${gap.label}');
} catch (e) {
debugPrint('queue: what continues ${own.$2}: $e');
}
}());
}
Future<void> _addContinuation(int mediaId, PickedMedia pick, {String? note}) async {
try {
final b = await _build(pick);
if (b is! ({QueueItem item, Uint8List? thumb})) return;
final r = coord.serverState.peek();
if (r == null || r.mediaId != mediaId || _hiddenFor.peek() == mediaId) return;
final sub = [b.item.sub, ?note].where((s) => s.isNotEmpty).join(' · ');
final item = b.item.copyWith(auto: true, forMedia: mediaId, sub: sub);
if (item.key.isNotEmpty) _local[item.key] = (pick: pick, at: DateTime.now());
final thumb = b.thumb;
coord.connection.sendQueue(QueueIntent.add(item, thumb: thumb == null ? '' : base64Encode(thumb)));
} catch (e) {
debugPrint('queue: adding what continues media $mediaId: $e');
}
} }
/// Puts [item] up from here (once per item while it is in flight). /// Puts [item] up from here (once per item while it is in flight).
@@ -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;
+4 -55
View File
@@ -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",
+140 -94
View 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,58 +325,33 @@ class UpNextPreview extends StatelessWidget {
child: Padding( child: Padding(
padding: const EdgeInsets.all(_pad), padding: const EdgeInsets.all(_pad),
child: SignalBuilder(builder: (context) { child: SignalBuilder(builder: (context) {
final item = queue.items.value.firstOrNull; final queued = queue.items.value.firstOrNull;
final episode = queue.shownContinuation.value.firstOrNull; final item = queued ?? queue.continues.value;
final warm = queue.warm.value; final warm = queue.warm.value;
const picture = _width - 2 * _pad; const picture = _width - 2 * _pad;
if (item != null) { if (item == null) return const SizedBox.shrink();
final r = queue.coord.serverState.value; final r = queue.coord.serverState.value;
final selfId = queue.coord.connection.memberId; final selfId = queue.coord.connection.memberId;
final more = queue.items.value.length - 1; final more = queue.items.value.length - 1;
final ready = warm?.id == item.id ? warm!.state : null; final ready = warm?.id == item.id ? warm!.state : null;
return Column( final kicker = queued == null
mainAxisSize: MainAxisSize.min, ? 'CONTINUES WITH'
crossAxisAlignment: CrossAxisAlignment.start, : more > 0
children: [ ? 'UP NEXT · $more MORE AFTER IT'
QueueThumb(queue: queue, item: item, width: picture, radius: 8), : 'UP NEXT';
const SizedBox(height: 10),
Text(more > 0 ? 'UP NEXT · $more MORE AFTER IT' : 'UP NEXT', style: _kicker),
const SizedBox(height: 3),
Text(item.title, maxLines: 2, overflow: TextOverflow.ellipsis, style: _title),
if (item.sub.isNotEmpty)
Text(item.sub, maxLines: 1, overflow: TextOverflow.ellipsis, style: _sub),
const SizedBox(height: 6),
_MetaLine(item: item, members: r?.members ?? const [], selfId: selfId, wrap: true),
_footer(ready, ready == null ? performerNote(queue, item, r) : null),
],
);
}
if (episode == null) return const SizedBox.shrink();
final n = episodeNumbers(episode.item);
final runtime = episode.pick.runtime;
final sub = [episode.item.seriesName ?? '', if (runtime != null) queueClock(runtime.inSeconds.toDouble())]
.where((s) => s.isNotEmpty)
.join(' · ');
final overview = episode.item.overview?.trim() ?? '';
final ready = warm?.id == 'next:${episode.item.id}' ? warm!.state : null;
return Column( return Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
_EpisodeThumb(queue: queue, episode: episode, width: picture, radius: 8), QueueThumb(queue: queue, item: item, width: picture, radius: 8),
const SizedBox(height: 10), const SizedBox(height: 10),
Text(n == null ? 'NEXT EPISODE' : 'NEXT EPISODE · $n', style: _kicker), Text(kicker, style: _kicker),
const SizedBox(height: 3), const SizedBox(height: 3),
Text(episode.item.name, maxLines: 2, overflow: TextOverflow.ellipsis, style: _title), Text(item.title, maxLines: 2, overflow: TextOverflow.ellipsis, style: _title),
if (sub.isNotEmpty) Text(sub, maxLines: 1, overflow: TextOverflow.ellipsis, style: _sub), if (item.sub.isNotEmpty) Text(item.sub, maxLines: 1, overflow: TextOverflow.ellipsis, style: _sub),
if (overview.isNotEmpty) ...[ const SizedBox(height: 6),
const SizedBox(height: 6), _MetaLine(item: item, members: r?.members ?? const [], selfId: selfId, wrap: true),
Text(overview, _footer(ready, ready == null ? performerNote(queue, item, r) : null),
maxLines: 3,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Colors.white54, fontSize: 12, height: 1.35)),
],
_footer(ready, null),
], ],
); );
}), }),
@@ -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,15 +931,17 @@ class _QueuePanelState extends State<QueuePanel> {
const Text('CONTINUES WITH', const Text('CONTINUES WITH',
style: TextStyle(color: Colors.white38, fontSize: 11, letterSpacing: 0.8, fontWeight: FontWeight.w600)), style: TextStyle(color: Colors.white38, fontSize: 11, letterSpacing: 0.8, fontWeight: FontWeight.w600)),
const Spacer(), const Spacer(),
TextButton( if (auto != null)
key: const ValueKey('queue-clear-continuation'), TextButton(
style: TextButton.styleFrom(foregroundColor: Colors.white54, visualDensity: VisualDensity.compact), key: const ValueKey('queue-clear-continuation'),
onPressed: _q.clearContinuation, style: TextButton.styleFrom(foregroundColor: Colors.white54, visualDensity: VisualDensity.compact),
child: const Text('Clear', style: TextStyle(fontSize: 12)), onPressed: _q.clearContinuation,
), child: const Text('Clear', style: TextStyle(fontSize: 12)),
),
], ],
), ),
), ),
if (auto != null) _ContinuesRow(item: auto, queue: _q, warm: warm),
for (final e in cont) _ContinuationRow(episode: e, queue: _q), for (final e in cont) _ContinuationRow(episode: e, queue: _q),
]; ];
@@ -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,
+219 -3
View File
@@ -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,26 +10,61 @@ 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. /// Its Starsync version (`version`), '' from a server too old to say.
final String version; final String version;
const ServerHealth.offline() const ServerHealth.offline()
: online = false, : online = false,
name = '', name = '',
proto = null, protoMin = null,
protoMax = null,
viewers = 0, viewers = 0,
version = ''; version = '';
const ServerHealth.online({required this.name, this.proto, this.viewers = 0, this.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) {
@@ -37,9 +72,12 @@ class ServerHealth {
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'], version = j['version']; 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() : '', version: version is String ? version.trim() : '',
); );
@@ -49,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 {
@@ -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;
+10 -2
View File
@@ -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());
} }
+9 -15
View File
@@ -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({
+123
View File
@@ -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 [];
}
}
+13 -1
View File
@@ -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();
}
}
}
@@ -187,6 +187,12 @@ Notice _plain(AppEvent e) => switch (e) {
category: NoticeCategory.people, log: feed, toast: '${feed.actor} lost connection', actor: feed.actor), category: NoticeCategory.people, log: feed, toast: '${feed.actor} lost connection', actor: feed.actor),
MemberReturned(:final feed) => MemberReturned(:final feed) =>
Notice(category: NoticeCategory.people, log: feed, toast: '${feed.actor} is back', actor: feed.actor), Notice(category: NoticeCategory.people, log: feed, toast: '${feed.actor} is back', actor: feed.actor),
// Put back after a server restart: news, but not a change.
MediaSwapped(:final feed) when feed.reason == 'restore' => Notice(
category: NoticeCategory.upNext,
log: feed,
toast: '${feed.actor} put back what was playing',
actor: feed.actor),
MediaSwapped(:final feed) => Notice( MediaSwapped(:final feed) => Notice(
category: NoticeCategory.upNext, category: NoticeCategory.upNext,
log: feed, log: feed,
@@ -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)
: fileTitle(name); : what != null && what.title.isNotEmpty
? refTitle(what, 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
+2 -2
View File
@@ -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
+14
View File
@@ -210,6 +210,20 @@ StatusLine? _line(StatusFacts f, StateBroadcast? r, MemberView? self, SourceTrou
return StatusLine(StatusKind.problem, "Can't play this: ${f.playerError}", return StatusLine(StatusKind.problem, "Can't play this: ${f.playerError}",
level: StatusLevel.error, action: StatusAction.reload, short: "Can't play this"); level: StatusLevel.error, action: StatusAction.reload, short: "Can't play this");
} }
// The server holds everyone for the relay's source (D6): a viewer's
// buffer ran dry while it is gone.
if (r.transport == 'held' && r.hold?.reason == HoldReason.source) {
final relayed = stateMediaRelay(r);
final id = relayed?.source ?? '';
final name = source?.name ??
r.members.where((m) => m.id == id).firstOrNull?.name ??
f.names?[id] ??
'the member passing it on';
return StatusLine(StatusKind.waiting, 'Waiting for $name',
detail: "$name's connection dropped; what the server had has played",
level: StatusLevel.warn,
short: 'Waiting for $name');
}
// The relay: what everyone reads comes through the server. // The relay: what everyone reads comes through the server.
final relay = f.ownPlan ? null : stateMediaRelay(r); final relay = f.ownPlan ? null : stateMediaRelay(r);
if (relay != null && relay.source != f.selfId) { if (relay != null && relay.source != f.selfId) {
@@ -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();
}
}
+5 -2
View File
@@ -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}) {
+87 -118
View File
@@ -14,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';
@@ -38,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
@@ -61,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,
@@ -70,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
@@ -112,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;
@@ -126,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;
@@ -149,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;
@@ -163,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()));
})); }));
@@ -178,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;
@@ -218,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.');
}); });
})); }));
} }
@@ -261,7 +249,6 @@ 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();
@@ -291,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);
} }
@@ -338,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 {
@@ -349,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.
} }
@@ -384,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);
@@ -401,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);
@@ -414,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
@@ -464,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;
@@ -479,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);
} }
@@ -493,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;
} }
@@ -502,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);
@@ -511,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));
@@ -553,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),
@@ -581,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(
@@ -603,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 {
@@ -623,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
@@ -658,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,
@@ -694,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;
@@ -709,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)),
+4 -6
View File
@@ -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
@@ -144,8 +144,7 @@ AvatarInfo memberAvatarInfo(MemberView m, StateBroadcast? r, SessionCoordinator
} }
/// 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)];
} }
@@ -158,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)),
); );
} }
@@ -380,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.
+22 -12
View File
@@ -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'),
}; };
+47 -167
View File
@@ -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)),
+7 -7
View File
@@ -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,
], ],
+32 -30
View File
@@ -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),
+17 -17
View File
@@ -14,7 +14,7 @@ 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 '../session/app_events.dart' show ActionFailed; import '../session/app_events.dart' show ActionFailed;
import '../state/session_state.dart'; 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';
@@ -68,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;
@@ -85,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;
@@ -97,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,
@@ -105,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,
@@ -200,14 +200,14 @@ class _LibraryTabState extends State<LibraryTab> {
/// A card's menu: errors as a toast (the Notifier's). /// 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) SessionState.instance.notifier.notify(ActionFailed(error)); if (error != null && mounted) AppScope.of(context).notifier.notify(ActionFailed(error));
} }
/// Double-click on a card: put it up, errors as a toast. /// 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) SessionState.instance.notifier.notify(ActionFailed(error)); if (error != null && mounted) AppScope.of(context).notifier.notify(ActionFailed(error));
} }
// -------------------------------------------------------------- build // -------------------------------------------------------------- build
@@ -271,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,
@@ -305,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),
@@ -366,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 -3
View File
@@ -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!);
} }
} }
+193 -216
View File
@@ -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,
), ),
), ),
), ),
@@ -140,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'),
@@ -196,41 +183,30 @@ class PartyStrip extends StatelessWidget {
), ),
), ),
const SizedBox(width: 4), const SizedBox(width: 4),
OutlinedButton( if (friend)
key: const ValueKey('strip-leave'), OutlinedButton(
onPressed: onLeave, key: const ValueKey('strip-leave'),
child: const Text('Leave'), onPressed: onLeave,
), 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) ...[
@@ -282,14 +258,15 @@ class _ServerLabel extends StatelessWidget {
builder: (context) { builder: (context) {
// "connecting…", "reconnecting…", "ready 2/3" (the ready counter // "connecting…", "reconnecting…", "ready 2/3" (the ready counter
// counts who the presence policy counts), from the one status. // counts who the presence policy counts), from the one status.
final line = SessionState.instance.coordinator.status.value.party; final graph = AppScope.of(context);
final line = graph.coordinator.status.value.party;
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(
@@ -306,50 +283,74 @@ 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( borderRadius: BorderRadius.circular(20),
key: const ValueKey('solo-chip'), onTap: () => showMembersCard(context),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), child: Container(
decoration: BoxDecoration( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
color: scheme.surfaceContainerHigh, decoration: BoxDecoration(color: scheme.surfaceContainerHigh, borderRadius: BorderRadius.circular(20)),
borderRadius: BorderRadius.circular(20), child: Row(
), mainAxisSize: MainAxisSize.min,
child: Row( children: [
mainAxisSize: MainAxisSize.min, Icon(Icons.podcasts_rounded, size: 16, color: scheme.primary),
children: [
if (busy)
const SizedBox.square(dimension: 14, child: CircularProgressIndicator(strokeWidth: 2))
else
Icon(Icons.person_outline_rounded, size: 16, color: scheme.primary),
const SizedBox(width: 6),
Text(busy ? 'Inviting…' : 'Solo', style: const TextStyle(fontWeight: FontWeight.w600)),
if (!compact && !busy) ...[
const SizedBox(width: 6), const SizedBox(width: 6),
Text('only you', style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12)), const Text('Sharing', style: TextStyle(fontWeight: FontWeight.w600)),
if (!compact) ...[
const SizedBox(width: 6),
Text('port $port', style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12)),
],
], ],
], ),
), ),
), ),
); );
@@ -425,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) {
@@ -481,15 +448,14 @@ 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
: '${session.lastForm.host}:${session.lastForm.port}';
final delivery = coord.status.value.delivery; final delivery = coord.status.value.delivery;
final server = session.serverVersion.value; final server = nav.serverVersion.value;
return Column( return Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
@@ -500,7 +466,7 @@ class MembersCard extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
['In the server', ?address].join(' · '), home ? 'Your server' : ['In the server', ?address].join(' · '),
style: const TextStyle(fontWeight: FontWeight.w600), style: const TextStyle(fontWeight: FontWeight.w600),
), ),
if (server.isNotEmpty) if (server.isNotEmpty)
@@ -531,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,
), ),
], ],
); );
@@ -571,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
@@ -660,76 +623,90 @@ class VersionText extends StatelessWidget {
bool versionDiffers(String version, {String own = appVersion}) => bool versionDiffers(String version, {String own = appVersion}) =>
version.isNotEmpty && own != '0.0.0-dev' && version != '0.0.0-dev' && version != own; version.isNotEmpty && own != '0.0.0-dev' && version != '0.0.0-dev' && version != own;
/// Hosting: the addresses friends on this network can join, the first /// Your server in the members card: shared, the invite's address
/// one behind "Copy invite address", and what friends elsewhere need. /// (Copy invite), a hint for friends outside the network and Stop
class _HostInvite extends StatefulWidget { /// sharing; not shared, Invite (opens it and copies the invite).
final String port; class YourInvite extends StatelessWidget {
final Future<List<String>> Function(String port) lanAddresses; const YourInvite({super.key});
const _HostInvite({required this.port, required this.lanAddresses});
@override
State<_HostInvite> createState() => _HostInviteState();
}
class _HostInviteState extends State<_HostInvite> {
late final Future<List<String>> _addresses = widget.lanAddresses(widget.port);
@override @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: [ 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( TextButton.icon(
key: const ValueKey('copy-invite'), key: const ValueKey('copy-invite'),
onPressed: list.isEmpty ? null : () => copy(list.first), onPressed: busy ? null : () => inviteFriends(context),
icon: const Icon(Icons.copy, size: 16), icon: const Icon(Icons.group_add_outlined, size: 16),
label: const Text('Copy invite address'), label: const Text('Invite'),
), ),
if (snap.connectionState != ConnectionState.done) ] else ...[
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.symmetric(horizontal: 8),
child: Text(invite.address,
key: const ValueKey('invite-address'), style: const TextStyle(fontFamily: 'monospace')),
),
Wrap(
spacing: 4,
children: [
TextButton.icon(
key: const ValueKey('copy-invite'),
onPressed: () => copyToClipboard(context, invite.text, done: 'Invite copied'),
icon: const Icon(Icons.copy, size: 16),
label: const Text('Copy invite'),
),
TextButton(
key: const ValueKey('stop-sharing'),
onPressed: busy ? null : () => yours.stopSharing(),
child: const Text('Stop sharing'),
),
],
),
Padding(
padding: const EdgeInsets.fromLTRB(8, 2, 8, 0),
child: Text( 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}');
}
@@ -3,7 +3,7 @@ import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.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';
@@ -13,8 +13,8 @@ export '../session/status.dart' show StatusAction, StatusKind, StatusLevel, Stat
/// The pill's line from the session's [SessionCoordinator.status] /// The pill's line from the session's [SessionCoordinator.status]
/// (tracked: call inside a builder); "Connecting…" only once it has taken /// (tracked: call inside a builder); "Connecting…" only once it has taken
/// [PlayerStatus.connectGrace] ([slowConnect]). /// [PlayerStatus.connectGrace] ([slowConnect]).
StatusLine? playerStatusNow({bool slowConnect = false}) { StatusLine? playerStatusNow(SessionCoordinator coord, {bool slowConnect = false}) {
final line = SessionState.instance.coordinator.status.value.line; final line = coord.status.value.line;
if (line?.kind == StatusKind.connecting && !slowConnect) return null; if (line?.kind == StatusKind.connecting && !slowConnect) return null;
return line; return line;
} }
@@ -73,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.status.value.line); _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;
+9 -8
View File
@@ -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'],
); );
+132 -80
View File
@@ -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';
@@ -122,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.
@@ -173,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;
@@ -287,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).
@@ -301,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(),
@@ -318,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,
); );
@@ -415,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;
@@ -433,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,
@@ -578,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),
@@ -599,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(
children: [ padding: const EdgeInsets.fromLTRB(12, 6, 6, 0),
const SizedBox(width: 10), child: Row(
const Expanded(child: NowPlayingTitle(cover: false, compact: true)), crossAxisAlignment: CrossAxisAlignment.start,
IconButton( children: [
focusNode: _noFocus, const Expanded(
onPressed: widget.onExpand, child: Padding(
tooltip: 'Expand', padding: EdgeInsets.only(top: 4),
icon: const Icon(Icons.open_in_full, color: Colors.white, size: 18), child: NowPlayingTitle(cover: false, compact: true),
), ),
IconButton( ),
focusNode: _noFocus, MinimalButton(
onPressed: widget.onClose, icon: Icons.open_in_full_rounded,
tooltip: 'Sit out (stay in the server)', tooltip: 'Expand',
icon: const Icon(Icons.close, color: Colors.white, size: 18), scale: scale,
), onPressed: widget.onExpand),
], MinimalButton(
icon: Icons.close_rounded,
tooltip: 'Sit out (stay in the server)',
scale: scale,
onPressed: widget.onClose),
],
),
), ),
const Spacer(), const Spacer(),
SignalBuilder(builder: (context) { SignalBuilder(builder: (context) {
final ready = coord.selfMember.value?.ready ?? false; final snap = _model.snapshot.value;
final pos = playback.position.value, dur = playback.duration.value; final pos = playback.position.value, dur = playback.duration.value;
return Row( final status = playerStatusNow(coord)?.short;
children: [ return Padding(
IconButton( padding: const EdgeInsets.fromLTRB(8, 0, 12, 2),
focusNode: _noFocus, child: Column(
tooltip: ready ? 'Ready (click: not ready)' : 'Not ready (click: ready)', crossAxisAlignment: CrossAxisAlignment.start,
onPressed: coord.toggleReady, children: [
icon: Icon( if (status != null && status.isNotEmpty)
ready ? Icons.check_circle : Icons.radio_button_unchecked, Padding(
color: ready ? Colors.greenAccent : Colors.white, padding: const EdgeInsets.fromLTRB(4, 0, 0, 2),
child: Text(status,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Color(0xFFFFC857), fontSize: 12)),
),
Row(
children: [
MinimalButton(
icon: controlIcon(BarControl.playPause, snap),
tooltip: controlTooltip(BarControl.playPause, snap),
accent: true,
scale: scale,
onPressed: _model.togglePlay,
),
const SizedBox(width: 8),
Text('${formatClock(pos)} / ${formatClock(dur)}',
style: const TextStyle(
color: MinimalStyle.dim,
fontSize: 12,
fontFeatures: [FontFeature.tabularFigures()])),
],
), ),
), ],
Text('${formatClock(pos)} / ${formatClock(dur)}', ),
style: const TextStyle(color: Colors.white70, fontSize: 12)),
const Spacer(),
Flexible(
child: Text(playerStatusNow()?.short ?? '',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Colors.amber, fontSize: 12)),
),
const SizedBox(width: 10),
],
); );
}), }),
_MiniSeekBar(onSeek: (d) => playback.player.seek(d)), _MiniSeekBar(onSeek: playback.userSeek),
], ],
), ),
), ),
@@ -664,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).
@@ -778,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;
@@ -786,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(
behavior: HitTestBehavior.opaque, cursor: SystemMouseCursors.click,
onTapDown: (e) => seekAt(e.localPosition.dx, box.maxWidth), child: GestureDetector(
onHorizontalDragEnd: null, behavior: HitTestBehavior.opaque,
onHorizontalDragUpdate: (e) => seekAt(e.localPosition.dx, box.maxWidth), onTapDown: (e) => seekAt(e.localPosition.dx, box.maxWidth),
child: Padding(padding: const EdgeInsets.symmetric(vertical: 6), child: bar), onHorizontalDragUpdate: (e) => seekAt(e.localPosition.dx, box.maxWidth),
child: Padding(padding: const EdgeInsets.only(top: 8), child: bar),
),
), ),
); );
}); });
} }
} }
class _MiniBarPainter extends CustomPainter {
final double played;
/// End of the cache ahead (a track is drawn only with it).
final double? cached;
final Color accent;
const _MiniBarPainter({required this.played, required this.cached, required this.accent});
@override
void paint(Canvas canvas, Size size) {
final w = size.width, h = size.height;
final c = cached;
if (c != null) {
canvas.drawRect(Offset.zero & size, Paint()..color = Colors.white.withValues(alpha: 0.18));
if (c > played) {
canvas.drawRect(Rect.fromLTRB(w * played, 0, w * c, h), Paint()..color = Colors.white.withValues(alpha: 0.3));
}
}
canvas.drawRect(Rect.fromLTRB(0, 0, w * played, h), Paint()..color = accent);
}
@override
bool shouldRepaint(_MiniBarPainter old) => old.played != played || old.cached != cached || old.accent != accent;
}
+33 -29
View File
@@ -17,7 +17,7 @@ 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';
@@ -86,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',
@@ -152,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:
@@ -258,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;
@@ -286,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(
@@ -320,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(
@@ -355,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(
@@ -375,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),
), ),
), ),
], ],
@@ -403,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) {
@@ -488,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'],
); );
@@ -548,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, _) {
@@ -835,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) {
@@ -859,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)),
@@ -876,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),
@@ -894,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),
@@ -910,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'),
+3 -3
View File
@@ -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;
+5 -5
View File
@@ -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 -4
View File
@@ -4,7 +4,7 @@ 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 'avatar.dart'; import 'avatar.dart';
import 'motion.dart'; import 'motion.dart';
@@ -73,7 +73,7 @@ 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(() {
@@ -127,7 +127,7 @@ class _PartyToastsState extends State<PartyToasts> {
void _open(ShellToast t) { void _open(ShellToast t) {
widget.onOpenChat?.call(); widget.onOpenChat?.call();
SessionState.instance.notifier.dismiss(t.id); AppScope.of(context).notifier.dismiss(t.id);
} }
@override @override
@@ -282,7 +282,7 @@ class _Thumb extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final thumbs = SessionState.instance.coordinator.queue.thumbs; final thumbs = AppScope.of(context).coordinator.queue.thumbs;
return ClipRRect( return ClipRRect(
key: const ValueKey('toast-thumb'), key: const ValueKey('toast-thumb'),
borderRadius: BorderRadius.circular(6), borderRadius: BorderRadius.circular(6),
+16 -15
View File
@@ -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();
}
}
+44 -2
View File
@@ -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,543 +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/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 []);
/// 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, system notification and activity-log line goes
/// through it.
late final Notifier notifier = Notifier(
sfx: sfx,
attention: ports.shell.requestAttention,
system: ports.notifications,
focusWindow: ports.shell.focus,
prefs: () => AppSettings.instance.notifyPrefs,
);
/// 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('');
/// The server's Starsync version: a hosted (or solo) server is this
/// app's own; a joined one's comes from its `/health`; '' until known.
final serverVersion = 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 {
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');
rethrow;
}
if (op != _op) {
await server.stop();
throw const ConnectCancelled();
}
_server = server;
coordinator.hostFile = (path) async =>
identical(_server, server) ? server.setFile(path) : null;
serverVersion.value = appVersion;
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;
}
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 = '';
serverVersion.value = '';
unawaited(probeHealth(params.host, '${params.port}', defaultHealthGet).then((h) {
if (op != _op || !h.online) return;
// 'server' is the dedicated server's default --name: not a name.
if (h.name.isNotEmpty && h.name != 'server') serverLabel.value = h.name;
serverVersion.value = h.version;
}).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 = '';
serverVersion.value = '';
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;
}
+3 -1
View File
@@ -59,7 +59,9 @@ class 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).
+2 -2
View File
@@ -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]).
-21
View File
@@ -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:
@@ -0,0 +1,187 @@
import 'package:flutter/foundation.dart';
import 'package:hive_ce/hive.dart';
import 'package:signals/signals_flutter.dart';
import 'package:sync_relay_core/core.dart';
// The device's watch history (device-owned, one Hive box): what this app
// watched of files and links, by [WatchKey], with the verdict of the
// watched rule (`WatchSession`, D3). Jellyfin keeps its own (reported by
// `JellyfinProgressReporter`); AniList later reads the tracker's records.
// See ARCHITECTURE.md "Watch tracking".
const watchHistoryBoxName = 'watch_history';
/// What a watch is about, independent of the opening: a file by its path
/// on this computer, a link by its URL, a Jellyfin item by its ids, else
/// the media's name and size (someone else's file, a link passed on).
class WatchKey {
final String value;
const WatchKey._(this.value);
factory WatchKey.file(String path) => WatchKey._('file:$path');
factory WatchKey.link(String url) => WatchKey._('link:$url');
factory WatchKey.jellyfin(JellyfinRef ref) => WatchKey._('jf:${ref.serverId}/${ref.itemId}');
factory WatchKey.media(String name, int size) => WatchKey._('media:$name|$size');
bool get isJellyfin => value.startsWith('jf:');
/// The key of `state.media` [media]: [ownFile] / [ownLink] when this app
/// put it up from there, else its Jellyfin ref, its shared link, its
/// name and size.
static WatchKey? of(Map<String, Object?>? media, {String? ownFile, String? ownLink}) {
if (media == null) return null;
if (ownFile != null) return WatchKey.file(ownFile);
final jf = media['jellyfin'];
if (jf is Map) {
final ref = JellyfinRef.fromJson(Map<String, Object?>.from(jf));
if (ref.serverId.isNotEmpty && ref.itemId.isNotEmpty) return WatchKey.jellyfin(ref);
}
if (ownLink != null) return WatchKey.link(ownLink);
final url = media['url'];
if (url is String && (url.startsWith('http://') || url.startsWith('https://'))) return WatchKey.link(url);
final name = media['name'];
final size = media['size'];
return WatchKey.media(name is String ? name : '', size is num ? size.toInt() : 0);
}
@override
bool operator ==(Object other) => other is WatchKey && other.value == value;
@override
int get hashCode => value.hashCode;
@override
String toString() => value;
}
/// One closed watch: what the tracker hands its sinks (the device history,
/// AniList later).
class WatchRecord {
final WatchKey key;
final String title;
final MediaRef? ref;
final WatchVerdict verdict;
/// Seconds the media played while this viewer was In, and its runtime
/// (0 when unknown).
final double timeIn;
final double runtime;
final DateTime at;
const WatchRecord({
required this.key,
required this.title,
required this.verdict,
required this.timeIn,
required this.runtime,
required this.at,
this.ref,
});
@override
String toString() => 'WatchRecord($key, $verdict, ${timeIn.round()} s In of ${runtime.round()} s)';
}
/// What the history holds for a key: watched (played), or where to
/// resume, and when.
class WatchEntry {
final bool played;
/// Seconds; 0 when played or none.
final double resumeAt;
final String title;
final MediaRef? ref;
final DateTime at;
const WatchEntry({required this.played, this.resumeAt = 0, this.title = '', this.ref, required this.at});
Map<String, Object?> toJson() => {
'played': played,
if (resumeAt > 0) 'resumeAt': resumeAt,
if (title.isNotEmpty) 'title': title,
if (ref != null) 'ref': ref!.toJson(),
'at': at.toUtc().toIso8601String(),
};
static WatchEntry? fromJson(Object? j) {
if (j is! Map) return null;
final at = DateTime.tryParse('${j['at']}');
if (at == null) return null;
final resume = j['resumeAt'];
MediaRef? ref;
try {
ref = MediaRef.fromJson(j['ref']);
} catch (_) {}
return WatchEntry(
played: j['played'] == true,
resumeAt: resume is num && resume.isFinite && resume > 0 ? resume.toDouble() : 0,
title: j['title'] is String ? j['title'] as String : '',
ref: ref,
at: at,
);
}
}
/// The device's watch history: [entry] per key, [record] from the
/// tracker. [revision] bumps on every change (pages that show it listen).
class WatchHistory {
final Map<String, Object?> _memory = {};
/// False: memory only (tests).
final bool persist;
Box? _box;
Future<void>? _load;
final revision = signal(0);
WatchHistory({this.persist = true});
Future<void> ensureLoaded() => _load ??= () async {
if (!persist) return;
try {
final box = _box = await Hive.openBox(watchHistoryBoxName);
for (final e in _memory.entries) {
await box.put(e.key, e.value);
}
_memory.clear();
revision.value++;
} catch (e, st) {
debugPrint('open watch history failed: $e\n$st');
}
}();
WatchEntry? entry(WatchKey key) => WatchEntry.fromJson(_box?.get(key.value) ?? _memory[key.value]);
/// A closed watch: played -> played (the resume point goes); part way
/// -> its resume point, unless it was played before (it stays played);
/// a peek changes nothing. Jellyfin items are not kept here.
void record(WatchRecord r) {
if (r.key.isJellyfin) return;
final before = entry(r.key);
final WatchEntry next;
switch (r.verdict.outcome) {
case WatchOutcome.played || WatchOutcome.keepPlayed:
next = WatchEntry(played: true, title: r.title, ref: r.ref, at: r.at);
case WatchOutcome.partial:
next = WatchEntry(
played: before?.played ?? false,
resumeAt: (before?.played ?? false) ? 0 : r.verdict.resumeAt ?? 0,
title: r.title,
ref: r.ref,
at: r.at);
case WatchOutcome.peek:
return;
}
final json = next.toJson();
final box = _box;
if (box == null) {
_memory[r.key.value] = json;
} else {
box.put(r.key.value, json).catchError((Object e) => debugPrint('watch history put ${r.key}: $e'));
}
revision.value++;
}
/// Every entry, newest first.
List<(WatchKey, WatchEntry)> get entries {
final raw = <String, Object?>{..._memory, if (_box != null) for (final k in _box!.keys) '$k': _box!.get(k)};
final out = [
for (final e in raw.entries)
if (WatchEntry.fromJson(e.value) case final w?) (WatchKey._(e.key), w),
];
out.sort((a, b) => b.$2.at.compareTo(a.$2.at));
return out;
}
}
@@ -0,0 +1,159 @@
import 'dart:async';
import 'package:sync_relay_core/core.dart';
import 'watch_history.dart';
// Watch tracking (ARCHITECTURE.md "Watch tracking", D3): one watch per
// media of the server (its `mediaId`), fed every second by the session
// with what this app sees, judged by core's `WatchSession` when it ends.
// The verdict goes to the device history (files and links) and out on
// [WatchTracker.records] (the seam for AniList later); Jellyfin's
// played/resume reports read the same session.
final _log = createLogger('watch');
/// What the session hands the tracker each second.
class WatchSample {
/// The server's media and its id.
final int mediaId;
final Map<String, Object?>? media;
/// This app's own source of it: the file it put up, the link it put up.
final String? ownFile;
final String? ownLink;
final double position;
/// The media plays here: the server plays it and this player has it.
final bool playing;
/// This viewer is In (`in`): not away, joining or gone.
final bool present;
/// The player's duration (0 while unknown) and where the ending starts.
final double duration;
final double? endingStart;
final DateTime at;
const WatchSample({
required this.mediaId,
required this.media,
required this.position,
required this.playing,
required this.present,
required this.at,
this.ownFile,
this.ownLink,
this.duration = 0,
this.endingStart,
});
}
class WatchTracker {
final WatchHistory history;
WatchTracker({WatchHistory? history}) : history = history ?? WatchHistory(persist: false);
final _records = StreamController<WatchRecord>.broadcast();
/// Every closed watch (peeks too): the device history takes files and
/// links; AniList progress later listens here.
Stream<WatchRecord> get records => _records.stream;
int _mediaId = -1;
WatchKey? _key;
String _title = '';
MediaRef? _ref;
double _runtime = 0;
double? _endingStart;
WatchSession? _session;
/// The watch of the media playing (null before the first sample).
WatchSession? get session => _session;
WatchKey? get key => _key;
/// One second of the session. A new media closes the watch of the one
/// before.
void sample(WatchSample s) {
if (s.mediaId != _mediaId) {
close(at: s.at);
if (s.media == null) return;
_open(s);
}
final session = _session;
if (session == null) return;
// A file or link this app put up is known once its swap landed.
final key = WatchKey.of(s.media, ownFile: s.ownFile, ownLink: s.ownLink);
if (key != null && key != _key && !(_key?.isJellyfin ?? false)) _key = key;
if (s.duration > 0) _runtime = s.duration;
_endingStart = s.endingStart;
session.sample(s.position, playing: s.playing, present: s.present, at: s.at);
}
void _open(WatchSample s) {
final key = WatchKey.of(s.media, ownFile: s.ownFile, ownLink: s.ownLink);
final before = key == null ? null : history.entry(key);
_mediaId = s.mediaId;
_key = key;
_title = '${s.media?['name'] ?? ''}';
try {
_ref = MediaRef.fromJson(s.media?['ref']);
} catch (_) {
_ref = null;
}
final d = s.media?['duration'];
_runtime = d is num && d > 0 ? d.toDouble() : 0;
_endingStart = null;
_session = WatchSession(playedBefore: before?.played ?? false, resumeBefore: before?.resumeAt ?? 0);
}
/// The same media goes on under another [mediaId] (a restarted server
/// numbers it anew): the watch carries on.
void renumber(int from, int to) {
if (_mediaId == from) _mediaId = to;
}
/// What Jellyfin says about the item before this opening (its played
/// state and resume point, read once it is open).
void before({required bool played, double resumeAt = 0}) {
final session = _session;
if (session == null) return;
session
..playedBefore = played
..resumeBefore = resumeAt;
}
/// The verdict so far (the runtime as last seen); null without a watch.
WatchVerdict? get verdict => _session?.verdict(runtime: _runtime, endingStart: _endingStart);
/// Records the watch as it stands (the end of the media; it may go on
/// after a seek back and be recorded again).
WatchRecord? checkpoint({DateTime? at}) {
final session = _session, key = _key;
if (session == null || key == null) return null;
final v = session.verdict(runtime: _runtime, endingStart: _endingStart);
final r = WatchRecord(
key: key,
title: _title,
ref: _ref,
verdict: v,
timeIn: session.timeIn,
runtime: _runtime,
at: at ?? DateTime.now().toUtc());
_log.info('$key: ${v.outcome.name} (${session.timeIn.round()} s In of ${_runtime.round()} s)');
history.record(r);
_records.add(r);
return r;
}
/// The watch ends (another media, leaving): recorded and forgotten.
WatchRecord? close({DateTime? at}) {
final r = _session == null ? null : checkpoint(at: at);
_mediaId = -1;
_key = null;
_session = null;
_ref = null;
_runtime = 0;
_endingStart = null;
return r;
}
/// The media the current watch is of (-1: none).
int get mediaId => _mediaId;
Future<void> dispose() => _records.close();
}
+3 -3
View File
@@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import '../shell/mark_problem.dart'; import '../shell/mark_problem.dart';
import '../state/session_state.dart'; import '../app/app_graph.dart';
import 'text_focus.dart'; import 'text_focus.dart';
/// Tab and Shift+Tab never move focus, and Space never presses the /// Tab and Shift+Tab never move focus, and Space never presses the
@@ -61,13 +61,13 @@ class _AppKeysState extends State<AppKeys> {
return true; return true;
} }
if (key != LogicalKeyboardKey.space && key != LogicalKeyboardKey.tab) return false; if (key != LogicalKeyboardKey.space && key != LogicalKeyboardKey.tab) return false;
if (SessionState.instance.route.peek() != AppRoute.session) return false; if (!AppScope.of(context).navigation.online.peek()) return false;
if (ModalRoute.of(context)?.isCurrent == false) return false; if (ModalRoute.of(context)?.isCurrent == false) return false;
if (textInputFocused()) return false; if (textInputFocused()) return false;
final keys = HardwareKeyboard.instance; final keys = HardwareKeyboard.instance;
if (keys.isControlPressed || keys.isAltPressed || keys.isMetaPressed) return false; if (keys.isControlPressed || keys.isAltPressed || keys.isMetaPressed) return false;
if (key == LogicalKeyboardKey.space) { if (key == LogicalKeyboardKey.space) {
(widget.onToggleReady ?? SessionState.instance.coordinator.toggleReady)(); (widget.onToggleReady ?? AppScope.of(context).coordinator.toggleReady)();
return true; return true;
} }
final toggles = AppKeys._chatToggles; final toggles = AppKeys._chatToggles;
+20 -11
View File
@@ -1,8 +1,8 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import '../session/notifier.dart' show AppEvent, Copied, CopyFailed, noticeFor; import '../session/notifier.dart' show AppEvent, Copied, CopyFailed, noticeFor;
import '../state/session_state.dart'; import '../app/app_graph.dart';
// Reusing what the app shows elsewhere: copying a link or a title, opening // Reusing what the app shows elsewhere: copying a link or a title, opening
// a page in the browser. One wording and one confirmation (a small toast) // a page in the browser. One wording and one confirmation (a small toast)
@@ -21,27 +21,36 @@ abstract final class ReuseText {
@visibleForTesting @visibleForTesting
void Function(String text)? debugReuseToast; void Function(String text)? debugReuseToast;
void _notify(AppEvent event) { void Function(AppEvent event) _notifier(BuildContext context) {
final debug = debugReuseToast; final debug = debugReuseToast;
if (debug != null) return debug(noticeFor(event).toast ?? ''); if (debug != null) return (event) => debug(noticeFor(event).toast ?? '');
SessionState.instance.coordinator.notify(event); return AppScope.of(context).notifier.notify;
} }
/// Puts [text] on the clipboard and confirms it with a toast ([done]: /// Puts [text] on the clipboard and confirms it with a toast ([done]:
/// its text, e.g. "Link copied"). /// its text, e.g. "Link copied").
Future<void> copyToClipboard(String text, {String done = 'Copied'}) async { Future<void> copyToClipboard(BuildContext context, String text, {String done = 'Copied'}) async {
final notify = _notifier(context);
try { try {
await Clipboard.setData(ClipboardData(text: text)); await Clipboard.setData(ClipboardData(text: text));
} catch (_) { } catch (_) {
_notify(const CopyFailed()); notify(const CopyFailed());
return; return;
} }
_notify(Copied(done)); notify(Copied(done));
} }
/// Opens [url] in the browser; when none opens, copies it instead and /// Opens [url] in the browser; when none opens, copies it instead and
/// says so. /// says so.
Future<void> openLink(String url) async { Future<void> openLink(BuildContext context, String url) async {
if (await SessionState.instance.ports.urls.open(url)) return; final urls = AppScope.of(context).ports.urls;
await copyToClipboard(url, done: "Couldn't open a browser — link copied"); final notify = _notifier(context);
if (await urls.open(url)) return;
try {
await Clipboard.setData(ClipboardData(text: url));
} catch (_) {
notify(const CopyFailed());
return;
}
notify(const Copied("Couldn't open a browser — link copied"));
} }
@@ -1,6 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../state/session_state.dart'; import '../app/app_graph.dart';
class WindowButtons extends StatefulWidget { class WindowButtons extends StatefulWidget {
final FocusNode? focusNode; final FocusNode? focusNode;
@@ -18,7 +18,7 @@ class WindowButtons extends StatefulWidget {
} }
class _WindowButtonsState extends State<WindowButtons> { class _WindowButtonsState extends State<WindowButtons> {
final _shell = SessionState.instance.ports.shell; late final _shell = AppScope.of(context).ports.shell;
bool _maximized = false; bool _maximized = false;
late final void Function() _stopWatch; late final void Function() _stopWatch;
@@ -53,8 +53,9 @@ class _WindowButtonsState extends State<WindowButtons> {
Future<void> _close() async { Future<void> _close() async {
try { try {
await SessionState.instance.coordinator.leave(); final graph = AppScope.of(context);
await SessionState.instance.stopServer(); await graph.navigation.leave();
await graph.yourServer.stop();
} catch (_) {} } catch (_) {}
await _shell.requestClose(); await _shell.requestClose();
} }
@@ -133,7 +134,7 @@ class WindowDragArea extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final shell = SessionState.instance.ports.shell; final shell = AppScope.of(context).ports.shell;
return GestureDetector( return GestureDetector(
behavior: HitTestBehavior.translucent, behavior: HitTestBehavior.translucent,
onPanStart: (_) => shell.startDragging(), onPanStart: (_) => shell.startDragging(),
+5 -6
View File
@@ -538,10 +538,9 @@ packages:
media_kit_video: media_kit_video:
dependency: "direct main" dependency: "direct main"
description: description:
name: media_kit_video path: "../../third_party/media_kit_video"
sha256: afaa509e7b7e0bf247557a3a740cde903a52c34ace9810f94500e127bd7b043d relative: true
url: "https://pub.dev" source: path
source: hosted
version: "2.0.1" version: "2.0.1"
meta: meta:
dependency: transitive dependency: transitive
@@ -866,14 +865,14 @@ packages:
path: "../../packages/core" path: "../../packages/core"
relative: true relative: true
source: path source: path
version: "1.2.0" version: "1.3.0"
sync_relay_server: sync_relay_server:
dependency: "direct main" dependency: "direct main"
description: description:
path: "../../packages/server" path: "../../packages/server"
relative: true relative: true
source: path source: path
version: "1.2.0" version: "1.3.0"
synchronized: synchronized:
dependency: transitive dependency: transitive
description: description:
+4 -2
View File
@@ -1,7 +1,7 @@
name: starsync name: starsync
description: Starsync desktop app (repo sync_relay_project). One binary. description: Starsync desktop app (repo sync_relay_project). One binary.
publish_to: none publish_to: none
version: 1.2.0 version: 1.3.1
environment: environment:
sdk: '>=3.13.0-0 <4.0.0' sdk: '>=3.13.0-0 <4.0.0'
@@ -10,7 +10,9 @@ dependencies:
sdk: flutter sdk: flutter
signals: ^7.0.0 signals: ^7.0.0
media_kit: ^1.1.0 media_kit: ^1.1.0
media_kit_video: ^2.0.1 # Vendored with Starsync's patches (third_party/media_kit_video/starsync).
media_kit_video:
path: ../../third_party/media_kit_video
media_kit_libs_video: ^1.0.0 media_kit_libs_video: ^1.0.0
path_provider: ^2.1.0 path_provider: ^2.1.0
web_socket_channel: ^3.0.0 web_socket_channel: ^3.0.0
+14 -8
View File
@@ -3,17 +3,23 @@ import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:sync_relay_core/core.dart'; import 'package:sync_relay_core/core.dart';
import 'package:starsync/src/shell/app_shell.dart'; import 'package:starsync/src/shell/app_shell.dart';
import 'package:starsync/src/state/session_state.dart';
import 'package:starsync/src/update/update_controller.dart'; import 'package:starsync/src/update/update_controller.dart';
import 'package:starsync/src/widgets/app_keys.dart'; import 'package:starsync/src/widgets/app_keys.dart';
import 'package:starsync/src/widgets/text_focus.dart'; import 'package:starsync/src/widgets/text_focus.dart';
import 'package:starsync/src/session/navigation.dart' show Place;
import 'test_graph.dart';
/// This file's composition root (see test_graph.dart).
final _graph = testGraph();
void main() { void main() {
final session = SessionState.instance; final session = _graph;
final coord = session.coordinator; final coord = session.coordinator;
tearDown(() { tearDown(() {
session.route.value = AppRoute.home; session.navigation.place.value = Place.none;
coord.serverState.value = null; coord.serverState.value = null;
}); });
@@ -24,7 +30,7 @@ void main() {
tester.view.devicePixelRatio = 1; tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset); addTearDown(tester.view.reset);
var readies = 0; var readies = 0;
await tester.pumpWidget(MaterialApp( await tester.pumpWidget(scoped(_graph, MaterialApp(
theme: ThemeData.dark(), theme: ThemeData.dark(),
shortcuts: appShortcuts, shortcuts: appShortcuts,
home: AppKeys( home: AppKeys(
@@ -36,7 +42,7 @@ void main() {
videoBuilder: (context, controls) => const SizedBox(), videoBuilder: (context, controls) => const SizedBox(),
), ),
), ),
)); )));
coord.serverState.value = StateBroadcast( coord.serverState.value = StateBroadcast(
eventId: 1, eventId: 1,
transport: 'held', transport: 'held',
@@ -45,7 +51,7 @@ void main() {
mediaId: 0, mediaId: 0,
members: [MemberView(id: 'a', name: 'Alex', ready: false, inSet: false, buffered: 0)], members: [MemberView(id: 'a', name: 'Alex', ready: false, inSet: false, buffered: 0)],
); );
session.route.value = AppRoute.session; session.navigation.place.value = Place.friend;
await tester.pumpAndSettle(); await tester.pumpAndSettle();
final panel = find.byKey(const ValueKey('chat-panel')); final panel = find.byKey(const ValueKey('chat-panel'));
@@ -67,11 +73,11 @@ void main() {
}); });
testWidgets('Ctrl+Shift+B opens Mark a problem; Esc closes it unmarked', (tester) async { testWidgets('Ctrl+Shift+B opens Mark a problem; Esc closes it unmarked', (tester) async {
await tester.pumpWidget(MaterialApp( await tester.pumpWidget(scoped(_graph, MaterialApp(
theme: ThemeData.dark(), theme: ThemeData.dark(),
shortcuts: appShortcuts, shortcuts: appShortcuts,
home: const AppKeys(child: Scaffold(body: SizedBox())), home: const AppKeys(child: Scaffold(body: SizedBox())),
)); )));
final card = find.byKey(const ValueKey('mark-problem-card')); final card = find.byKey(const ValueKey('mark-problem-card'));
await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft);
await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft); await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft);
+10 -6
View File
@@ -7,9 +7,13 @@ import 'package:starsync/src/controls/model/controls_model.dart';
import 'package:starsync/src/controls/model/controls_prefs.dart'; import 'package:starsync/src/controls/model/controls_prefs.dart';
import 'package:starsync/src/controls/session_controls.dart'; import 'package:starsync/src/controls/session_controls.dart';
import 'package:starsync/src/playback/media_marks.dart'; import 'package:starsync/src/playback/media_marks.dart';
import 'package:starsync/src/state/session_state.dart';
import 'package:starsync/src/state/settings.dart'; import 'package:starsync/src/state/settings.dart';
import 'test_graph.dart';
/// This file's composition root (see test_graph.dart).
final _graph = testGraph();
// The controls model and its layout spec: which controls the bar has, in // The controls model and its layout spec: which controls the bar has, in
// which order, what goes to ⚙; and both skins drawing the same order. // which order, what goes to ⚙; and both skins drawing the same order.
@@ -64,7 +68,7 @@ void main() {
final all = menuActions(const ControlsSnapshot(hasChapters: true, hasDuration: true, hasLink: true), none); final all = menuActions(const ControlsSnapshot(hasChapters: true, hasDuration: true, hasLink: true), none);
expect(all.first, ControlAction.chapters); expect(all.first, ControlAction.chapters);
expect(all, containsAllInOrder([ControlAction.skipForward, ControlAction.copyLink])); expect(all, containsAllInOrder([ControlAction.skipForward, ControlAction.copyLink]));
expect(menuActions(const ControlsSnapshot(solo: true), none), isNot(contains(ControlAction.markAllReady)), expect(menuActions(const ControlsSnapshot(alone: true), none), isNot(contains(ControlAction.markAllReady)),
reason: 'nobody to mark ready'); reason: 'nobody to mark ready');
}); });
@@ -74,7 +78,7 @@ void main() {
expect(controlIcon(BarControl.playPause, const ControlsSnapshot(play: PlayState.waiting)), Icons.check_rounded); expect(controlIcon(BarControl.playPause, const ControlsSnapshot(play: PlayState.waiting)), Icons.check_rounded);
expect(controlTooltip(BarControl.playPause, const ControlsSnapshot(play: PlayState.playing)), expect(controlTooltip(BarControl.playPause, const ControlsSnapshot(play: PlayState.playing)),
'Pause for everyone (Space)'); 'Pause for everyone (Space)');
expect(controlTooltip(BarControl.playPause, const ControlsSnapshot(solo: true)), 'Play (Space)'); expect(controlTooltip(BarControl.playPause, const ControlsSnapshot(alone: true)), 'Play (Space)');
}); });
test('episode buttons: the label and the keys', () { test('episode buttons: the label and the keys', () {
@@ -98,7 +102,7 @@ void main() {
group('both skins draw the model', () { group('both skins draw the model', () {
final s = AppSettings.instance; final s = AppSettings.instance;
final coord = SessionState.instance.coordinator; final coord = _graph.coordinator;
tearDown(() { tearDown(() {
s.controlsSkin.value = ControlsSkin.standard; s.controlsSkin.value = ControlsSkin.standard;
@@ -131,13 +135,13 @@ void main() {
shown.dispose(); shown.dispose();
hold.dispose(); hold.dispose();
}); });
await tester.pumpWidget(MaterialApp( await tester.pumpWidget(scoped(_graph, MaterialApp(
theme: ThemeData.dark(), theme: ThemeData.dark(),
home: Scaffold( home: Scaffold(
backgroundColor: Colors.black, backgroundColor: Colors.black,
body: SessionControls(model: model, shown: shown, hold: hold, onDoubleClick: () {}), body: SessionControls(model: model, shown: shown, hold: hold, onDoubleClick: () {}),
), ),
)); )));
await tester.pump(); await tester.pump();
return model; return model;
} }
+133 -9
View File
@@ -6,6 +6,7 @@ import 'dart:io';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:hive_ce/hive.dart'; import 'package:hive_ce/hive.dart';
import 'package:media_kit/media_kit.dart'; import 'package:media_kit/media_kit.dart';
import 'package:signals/signals.dart' show effect;
import 'package:sync_relay_core/core.dart'; import 'package:sync_relay_core/core.dart';
import 'package:sync_relay_core/testing.dart'; import 'package:sync_relay_core/testing.dart';
import 'package:sync_relay_server/server.dart'; import 'package:sync_relay_server/server.dart';
@@ -18,9 +19,13 @@ import 'package:starsync/src/session/identity.dart';
import 'package:starsync/src/session/notifier.dart'; import 'package:starsync/src/session/notifier.dart';
import 'package:starsync/src/session/session_coordinator.dart'; import 'package:starsync/src/session/session_coordinator.dart';
import 'package:starsync/src/sfx/sfx.dart'; import 'package:starsync/src/sfx/sfx.dart';
import 'package:starsync/src/state/session_state.dart';
import 'package:starsync/src/sync/sync_client.dart'; import 'package:starsync/src/sync/sync_client.dart';
import 'test_graph.dart';
/// This file's composition root (see test_graph.dart).
final _graph = testGraph();
final _tmp = Platform.environment['WT_TEST_TMP'] ?? Directory.systemTemp.path; final _tmp = Platform.environment['WT_TEST_TMP'] ?? Directory.systemTemp.path;
Future<void> _poll( Future<void> _poll(
@@ -358,6 +363,120 @@ void main() {
} }
}); });
test('a restarted server comes back empty: what played is offered back where it was, nothing reopens, '
'the watch goes on, Up next comes back', timeout: const Timeout(Duration(minutes: 2)), () async {
final first = SyncServer(const AppConfig(port: 0, secret: 'pw', name: 'restart'));
await first.start();
final port = first.boundPort;
final origin = await _origin(await _media());
SyncServer? second;
try {
final alice = await member(port, 'alice');
final bob = await member(port, 'bob');
await playTogether(alice, [alice, bob], 'http://127.0.0.1:${origin.port}/ep.mkv');
expect(await bob.queue.add(LinkMedia('http://127.0.0.1:${origin.port}/next.mkv', anyway: true)), isNull);
await _poll(() => alice.queue.items.peek().length == 1, 'Up next has an item');
await Future.delayed(const Duration(seconds: 4));
final opens = [alice.opens, bob.opens];
final watch = alice.watch.session;
final watched = watch!.timeIn;
expect(watched, greaterThan(2));
await first.stop();
await _poll(() => alice.reconnecting.peek() && bob.reconnecting.peek(), 'both reconnecting');
second = SyncServer(AppConfig(port: port, secret: 'pw', name: 'restart'));
await second.start();
final engine = second.hub.engine;
await _poll(() => engine.media?.name == 'ep.mkv', 'the media offered back', timeout: const Duration(seconds: 30));
expect(engine.mediaId, 2, reason: 'one offer taken: the server started at 1');
expect(engine.positionAt(), greaterThan(4), reason: 'where it was, not the start');
await _poll(() => [alice, bob].every((x) => x.playback.player.state.playing && x.serverState.peek()?.transport == 'playing'),
'playing again', timeout: const Duration(seconds: 20));
expect([alice.opens, bob.opens], opens, reason: 'the players kept it: nothing reopened');
await _poll(() => engine.queue.items.length == 1, 'Up next back');
expect(engine.queue.items.single.media.url, endsWith('/next.mkv'));
// The watch went on through the restart (one watch, still counting).
await Future.delayed(const Duration(seconds: 2));
expect(identical(alice.watch.session, watch), isTrue);
expect(alice.watch.mediaId, engine.mediaId);
expect(watch.timeIn, greaterThan(watched + 1));
} finally {
await second?.stop();
await origin.close(force: true);
}
});
test('a restarted server comes back empty: the member passing its file on puts it back and feeds it again',
timeout: const Timeout(Duration(minutes: 2)), () async {
final first = SyncServer(const AppConfig(port: 0, secret: 'pw', name: 'restart'));
await first.start();
final port = first.boundPort;
final file = await _media();
SyncServer? second;
try {
final alice = await member(port, 'alice');
final bob = await member(port, 'bob');
expect(await alice.swapPicked(FileMedia(file)), isNull);
await _poll(() => [alice, bob].every((x) => x.playback.durationSecs > 100), 'both opened it',
timeout: const Duration(seconds: 30));
for (final x in [alice, bob]) {
x.toggleReady();
}
await _poll(() => [alice, bob].every((x) => x.playback.player.state.playing), 'both playing');
await Future.delayed(const Duration(seconds: 3));
final aliceOpens = alice.opens;
await first.stop();
await _poll(() => alice.reconnecting.peek() && bob.reconnecting.peek(), 'both reconnecting');
second = SyncServer(AppConfig(port: port, secret: 'pw', name: 'restart'));
await second.start();
final hub = second.hub;
await _poll(() => hub.engine.media?.name == 'coord_presence_120s.mkv', 'the file offered back',
timeout: const Duration(seconds: 30));
expect(hub.engine.positionAt(), greaterThan(3));
await _poll(() => hub.relay?.sourceToken == alice.connection.token && alice.relaying.peek(), 'alice feeds it again');
await _poll(() => [alice, bob].every((x) => x.playback.player.state.playing && x.serverState.peek()?.transport == 'playing'),
'playing again', timeout: const Duration(seconds: 30));
expect(alice.opens, aliceOpens, reason: 'her own file stayed open');
expect(hub.relay!.buffers.single.bytesToViewers, greaterThan(0), reason: 'bob reads the new relay');
} finally {
await second?.stop();
}
});
test('moving to another server never shows the last one\'s media', timeout: const Timeout(Duration(minutes: 1)),
() async {
final home = SyncServer(const AppConfig(port: 0, secret: 'pw', name: 'home'));
final empty = SyncServer(const AppConfig(port: 0, secret: 'pw', name: 'empty'));
await home.start();
await empty.start();
final origin = await _origin(await _media());
try {
final alice = await member(home.boundPort, 'alice');
expect(await alice.swapPicked(LinkMedia('http://127.0.0.1:${origin.port}/ep.mkv', anyway: true)), isNull);
await _poll(() => alice.serverState.peek()?.media != null, 'media up at home');
// The shell opens the player on any state with media as soon as the
// app is in a server again: nothing from the last one may be left
// while the new one is dialled.
final seen = <String>[];
final stop = effect(() {
if (alice.connection.state.value is ConnConnecting) {
seen.add(alice.serverState.peek()?.media?['name'] as String? ?? '');
}
});
await alice.connect(ConnectParams(host: '127.0.0.1', port: empty.boundPort, secret: 'pw', name: 'alice'));
await _poll(() => alice.serverState.peek() != null, 'the new server\'s state');
stop();
expect(seen, isNotEmpty);
expect(seen.where((n) => n.isNotEmpty), isEmpty, reason: 'no media from the last server');
expect(alice.serverState.peek()?.media, isNull);
} finally {
await home.stop();
await empty.stop();
await origin.close(force: true);
}
});
test('cancel during connect setup aborts without joining', timeout: const Timeout(Duration(minutes: 1)), () async { test('cancel during connect setup aborts without joining', timeout: const Timeout(Duration(minutes: 1)), () async {
final server = SyncServer(const AppConfig(port: 0, secret: '', name: 'cancel')); final server = SyncServer(const AppConfig(port: 0, secret: '', name: 'cancel'));
await server.start(); await server.start();
@@ -376,22 +495,27 @@ void main() {
} }
}); });
test('cancelling a host setup releases the port', timeout: const Timeout(Duration(minutes: 1)), () async { test('stop sharing releases the port; stopping your server frees its own', timeout: const Timeout(Duration(minutes: 1)),
() async {
Hive.init('$_tmp/hive_coord_test'); Hive.init('$_tmp/hive_coord_test');
final probe = await ServerSocket.bind(InternetAddress.anyIPv4, 0); final probe = await ServerSocket.bind(InternetAddress.anyIPv4, 0);
final port = probe.port; final port = probe.port;
await probe.close(); await probe.close();
final session = SessionState.instance; final yours = _graph.yourServer;
_graph.forms.form
..hostPort = '$port'
..hostSecret = '';
try { try {
final pending = session.host(ConnectParams(host: '', port: port, secret: '', name: 'host'), ConnectForm()); final own = await yours.start(name: 'me');
await session.cancelPending(); expect((await yours.invite()).port, port);
await expectLater(pending, throwsA(isA<ConnectCancelled>())); await yours.stopSharing();
expect(session.route.peek(), isNot(AppRoute.session));
final rebind = await ServerSocket.bind(InternetAddress.anyIPv4, port); final rebind = await ServerSocket.bind(InternetAddress.anyIPv4, port);
await rebind.close(); await rebind.close();
await yours.stop();
final again = await ServerSocket.bind(InternetAddress.loopbackIPv4, own);
await again.close();
} finally { } finally {
await session.coordinator.leave(silent: true); await yours.stop();
await session.stopServer();
} }
}); });
Loaded 100 of 307 files, more files were not shown because too many files have changed in this diff. Show more