35 Commits
Author SHA1 Message Date
foxyz f1ac4c3e64 Release v1.4.0
ci / updater-windows (push) Skipped
ci / core (push) Successful in 27s
release / windows (push) Successful in 5m1s
ci / server (push) Successful in 1m6s
release / linux (push) Successful in 3m51s
2026-10-18 18:31:44 +03:00
foxyzandClaude Opus 5.5 96bb7e90f4 Roadmap: hosting without a VPS (IPv6, UPnP later, hole punching as a spike), access-code invites
ci / core (push) Successful in 29s
ci / server (push) Successful in 1m3s
ci / updater-windows (push) Successful in 9m44s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 18:18:30 +03:00
foxyzandClaude Opus 5.5 6e14061a8a Invite: a friend's server's copied link carries the password you joined with
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 18:18:15 +03:00
foxyzandClaude Opus 5.5 cf61453e28 Invite: just the link (password included), one Copy button, a note only when needed, other addresses behind a toggle
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 18:11:07 +03:00
foxyzandClaude Opus 5.5 5116923858 Update channel setting: AppSettings passed in (singletons_test back at its baseline)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 17:33:32 +03:00
foxyzandClaude Opus 5.5 ad53b6822a Shaders: Anime4K at full precision on OpenGL ES; sharp video renders the fitted picture
Colours and grid: media_kit renders through OpenGL ES, where mpv's
mediump samplers made Anime4K's networks compute at half precision
(fp16 on NVIDIA); the VL networks of HQ overflow and round there,
strongest once the upscalers run at screen size. The extracted copies
route every network read through a highp identity function; on GLES the
result now equals desktop OpenGL's.

Resize: the output is the picture fitted in the view (no bars, centred
on whole pixels), so Flutter scales the last texture to the new view
without the shrink-then-jump; the resize wait is 120 ms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 17:33:31 +03:00
foxyzandClaude Opus 5.5 4c8ddeb034 Page links played here only also go through a reader of their own: Bilibili's cuts never end playback
Alone, or with Everyone fetches it, a page link with plain-file tracks
plays through SharedLinks.local (a SharedSource without the upload): it
resumes after a cut, backs off on 412/429/5xx and resolves the page again
on 403/410/412, where ffmpeg's own reconnect gave up for good. Pieces
tracks and a reader that cannot open keep yt-dlp's URLs as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 17:33:31 +03:00
foxyzandClaude Opus 5.5 f2721d125c Updater: update channel Stable / Dev in Settings → Updates; Dev stays on test builds after a release
Dev reads the dev and stable manifests and offers the newer by semver;
a test build stores Dev on its first start, so the release that updates
it keeps the channel. Switching to Stable in a test build asks before
going back to an older release. tool/dev_release.sh's one-time command
also writes the update-channel file that sets Dev.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 17:33:30 +03:00
foxyzandClaude Opus 5.5 69fae18fdc Player: quick chat box, danmaku notifications, the player panel, smooth subtitle lift
- Quick chat (Enter in the full player, whatever plays): a box at the
  bottom centre in the control bar's place, compact and widening with
  the text, sharing its draft with the chat panel; Enter sends, Esc or an
  empty Enter closes, it closes by itself after 8 s without typing (the
  draft stays). Keys go to it; the Enter that opened it no longer reaches
  a focused button. Typing never shows the cursor or the controls: the
  frame wakes only on real mouse movement and holds the controls hidden
  while the box or its echo is up. Your sent line shows as an echo (or
  flies as your own danmaku line); your own chat toast is not repeated.
- Subtitle lift: always for the box, for the control bar as a setting
  (off); 4 eased-out sub-pos steps 42 ms apart instead of one per frame.
- Notifications: Toasts / Danmaku (off by default): chat and short notices
  fly over the full player, problems stay toasts; every knob of the look
  plus boldness, persisted, with a live preview strip in Settings.
- Player panel: ⚙ → Player settings docks a foldable glass panel at the
  right (tracks, subtitle size and lift, picture, volume, notifications,
  controls) in place of the card; Esc closes it.
- Danmaku preview: the mock dialogue follows the lift; the stand-in page
  has the chat box too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 17:29:13 +03:00
foxyzandClaude Opus 5.5 6048fbe7d1 Invite links: starsync:// opens the app and asks to join; one app per profile; real invite address; clipboard pick-up
- starsync://host:port/?name=&pw= (PROTOCOL.md "Invite link"); the password only when ticked, never in logs
- Linux: launcher entry takes x-scheme-handler/starsync (%u, StartupNotify) and becomes its xdg-mime default; Windows: HKCU\Software\Classes\starsync -> starsync.exe
- InstanceLock: lock file + loopback handoff per profile folder; the running app comes up with the second start's activation token (Wayland/X11) or AllowSetForegroundWindow (Windows)
- Sharing looks up the internet address (HTTPS services with fallbacks); members card picks Internet / Home network / VPN and says when the port needs forwarding
- JoinOffers: link and clipboard prompt with /health, joins through ConnectFlow; dismissed clipboard invites kept as fingerprints

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 17:18:25 +03:00
foxyzandClaude Opus 5.5 d48915dc47 What's new and the updater come from AppGraph (no new singleton; singletons_test back at its baseline)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 02:22:03 +03:00
foxyzandClaude Opus 5.5 5bc252bf02 Player: sharp video and subtitles at screen size; dev tools: danmaku preview, subtitle lift with a stand-in chat line
- "Sharp video and subtitles" (Settings → Player and the player settings
  card, on by default): the video output renders at the view's physical
  size (debounced, GPU rendering only), so libass, mpv's scaler and the
  shaders work at screen resolution; re-sent after media_kit's own reset
  on new video params, redrawn while paused.
- Dev builds only (Settings → About → Developer): a danmaku preview
  (DanmakuLayer: free lanes in an upper band, density cap with a queue,
  equal crossing time, outlined text, avatars, own-line frame) over the
  real player or a stand-in picture, with samples, a random stream, live
  toasts, every knob and mock subtitles; and "Lift subtitles above the
  chat line and controls": Enter opens a stand-in chat line, bottom
  subtitles move up via mpv sub-pos while it or the control bar is up.
- tool/make_sub_test.sh: a test video with a top \an8 sign, bottom
  dialogue and an SRT track.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 02:19:40 +03:00
foxyzandClaude Opus 5.5 638a30e16c Changelog and What's new: CHANGELOG.md cut into each release, shown in the update band, a What's new card and once after updating
CHANGELOG.md keeps an Unreleased section (user-facing changes add a line in
the same commit); tool/release.sh cuts it into the version, the manifest
gets a structured "changes" field next to the one-line "notes" older
apps read, and the Gitea release text is that section. The app's update
band shows the top items, What's new opens a card (Added / Changed /
Fixed), and the first start after an update shows it once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 02:11:37 +03:00
foxyzandClaude Opus 5.5 ec0849eb01 Read once: a link passed on is fetched from the site once, for the putter's player and the relay
Before, the member passing a page link on downloaded it twice, independently:
their mpv straight from the CDN and the relay's pulls again, with a
different format pick and without yt-dlp's cookies. The two drifted (the
putter played on while viewers stalled, or the reverse) and doubled the
connections the CDN saw. Now one SharedSource per track reads the site
once (resuming after cuts, backing off on 412/429/5xx and silence) into a
bounded buffer (64 MiB ahead, 16 behind) that serves both the putter's
mpv through a loopback endpoint and the server's pulls; the putter plays
the relay's own format pick, and the relay sends yt-dlp's cookies.
Up next's warm relay uses the same reader. Pieces tracks (HLS/DASH) keep
two fetches for now. Measured in the opt-in e2e: half the CDN traffic, no
412s under a lockout CDN, viewer seeks 0.29 s instead of 4 s there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 02:05:03 +03:00
foxyzandClaude Opus 5.5 7715dc479a Chat: no pop-ups while the chat panel is open (the sound still plays)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 01:29:58 +03:00
foxyzandClaude Opus 5.5 e91539d113 Relay: viewers keep the file size (headers within 4 s, connection: close), retries back off, 412 re-resolves
Two server behaviours left ffmpeg (8.1/9.0) without the file size, after
which every seek failed with "size -38" until a reload:
- dart:io holds response headers until body bytes exist: a stalled
  source meant no headers within mpv's 5 s timeout. Relay byte responses
  are now written on the raw connection, headers with the first bytes or
  after 4 s, and a swap ends waiting reads with a plain close.
- every response closed its socket without saying connection: close, so
  ffmpeg 8.x reused a dead connection for its next seek (pol's X clips).
  The server now says it whenever the request asks.
A failed pull waits 250 ms doubling to 8 s instead of re-pulling at once
(the ~130 ms storm that drew Bilibili's 412/403); 412 counts as an expired
URL and re-resolves the page, at most once per 60 s. The app reloads a
player once per media when ffmpeg reports the lost size anyway. P37: a
read that waits past ffmpeg's retry budget still ends the file early.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 01:27:34 +03:00
foxyzandClaude Opus 5.5 5e3834e6ba Player: open again once when mpv's video output was not up yet (Windows join with media up)
Fun Maker joined on Windows while a video played: the open landed before
mpv's render context existed (media_kit reports the controller ready
first there), the video output failed and the media stayed unopened.
The host now opens the same media again once, 500 ms later.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 03:54:58 +03:00
foxyz 81b9d05de6 Release v1.3.2
ci / updater-windows (push) Skipped
ci / core (push) Successful in 29s
ci / server (push) Successful in 50s
release / linux (push) Successful in 4m26s
release / windows (push) Successful in 6m30s
2026-10-17 02:14:55 +03:00
foxyzandClaude Opus 5.5 f9b79aa47c Server test: flaky_link's silence limit 1.5 s (600 ms marked a reporting client gone on a busy CI runner)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 02:05:44 +03:00
foxyzandClaude Opus 5.5 e16cf50f2c Windows: every start ended itself after 3 s (1.3.0, 1.3.1)
ci / core (push) Successful in 28s
ci / server (push) Successful in 51s
ci / updater-windows (push) Successful in 6m27s
Win32Window::Create calls Destroy() before it makes the window, which
ran FlutterWindow::OnDestroy and armed the 3 s exit timer at launch: the
window showed, then the process ended with code 0, no crash, no close
lines. The timer is only armed with an engine to tear down. Found and
fixed by Fun Maker's agent on Windows (a debugger caught the exit on the
timer's thread).

The smoke runs missed it (they closed at ~4 s anyway): the app now stays
up 10 s, and the scripts require 'closing' and 'quitting' from its own
close.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 02:04:55 +03:00
foxyz 8aa1186d29 Revert "Sounds: survive an audio device that crashes the app on open"
This reverts commit c91d57633f.
2026-10-17 02:04:33 +03:00
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
154 changed files with 14519 additions and 1623 deletions

No files matched your search

+99
View File
@@ -0,0 +1,99 @@
# Changelog
What changed in each Starsync version, newest first, in plain words. The
app shows a version's section when it offers the update and once after it
(`What's new`); the release page shows it too. How entries are added and
cut into a release: [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md#changelog).
## Unreleased
### Added
### Changed
### Fixed
## 1.4.0
Links you put up are downloaded once for everyone, quick chat and danmaku in the player, plus What's new and fixes for Windows, the relay and chat.
### Added
- What's new: the update band shows what changed, and after an update the app shows the full list once (also in Settings → About)
- Update channel in Settings → Updates: Stable, or Dev for test builds; Dev stays on test builds after a release and still gets the newer release when there is one
- Sharper subtitles and picture: the video and its subtitles are drawn at your screen's resolution instead of the video's (Settings → Player → Sharp video and subtitles, on by default)
- Invite links: Invite copies just a starsync:// link, password included, so a friend just clicks it (or copies it and switches to Starsync) and the app asks "Join Bob's server?"
- Invite finds your internet address on its own and says when your router needs the port forwarded; Other addresses picks your home-network or VPN address instead
- Starsync runs once: a link clicked while it is open goes to the open window
- Quick chat: press Enter in the big player to type a message in a small box at the bottom, without opening the chat panel and without the mouse pointer or the controls popping up; it shares its draft with the chat panel, closes by itself when you stop typing, and subtitles move up out of its way
- Danmaku notifications: chat, joins, leaves and Up next can fly across the top of the picture instead of popping up (Settings → Notifications & sounds → Notifications: Danmaku, off by default), with speed, how many at once, where, opacity, size, boldness, outline and avatars all adjustable and a live preview
- Player panel: ⚙ → Player settings opens a panel at the right of the player that stays open while you watch, with tracks, subtitle size, picture, volume and notification settings
- Subtitles can move up above the controls while they show (Settings → Player → Subtitles above the controls, off by default), and their size can be changed
### Changed
- A link you put up is downloaded once: you and everyone else watch the same stream, so nobody drifts ahead or stalls alone, and sites like Bilibili are far less likely to block it
### Fixed
- Upscaling shaders (Anime4K, the HQ modes) no longer shift the colours or draw a grid over the picture, which showed most in a large window or fullscreen
- Sharp video: resizing the window or opening the chat panel no longer shrinks the picture for a moment before it jumps back
- Joining while something plays on Windows: the video opens again by itself when it was too early
- Relay: players no longer get stuck after a slow or stalled source
- Relay: X (Twitter) clips play through the relay
- Relay: gentler retries, so Bilibili stops blocking the server
- Relay: a source that refuses the old link (412) gets a fresh address and keeps playing
- Chat pop-ups stay away while the chat panel is open (the sound still plays)
- Bilibili and other sites no longer stop a video halfway when you watch alone or with "Everyone fetches it": a cut connection picks up where it left off
## 1.3.2
Fixes Windows closing by itself about 3 seconds after every start.
### Fixed
- Windows: the app no longer closes by itself about 3 seconds after starting (1.3.0 and 1.3.1)
## 1.3.1
Joining another server no longer opens an empty player, a cleaner mini player and a softer Minimal progress line.
### Changed
- A cleaner mini player: just the picture, play and a plain progress bar
- Minimal controls: the progress line shown while the controls hide is softer and easier on the eyes
### Fixed
- Joining another server no longer opens an empty player
## 1.3.0
The app is now called Starsync and was rebuilt inside for stability: a shared countdown to what plays next, honest watched marks, faster relay seeks and many fixes. Everyone must update to keep watching together.
### Added
- A shared countdown to what plays next, and anyone can cancel it
- The next episode of your local files continues by itself ("Continues with")
- System notifications while the window is in the background
- Your own server is always running: Invite your friends straight in
- See the server's version and everyone's app version
- The library matches your shows with AniList
- A new sound engine and new sounds
### Changed
- The app is now called Starsync (your settings and data come along)
- "Watched" counts only the time you actually watched (Jellyfin too)
- Joining no longer pauses everyone
- A dropped connection comes back as the same member, not a new one
- A server restart no longer loses what was playing
- Faster seeks over the relay
### Fixed
- The app no longer freezes on exit
- Shaders on Windows
- X11: video renders properly again (also with software rendering)
- Black video when joining mid-play on X11
- Many stability fixes from the rebuild
+103 -47
View File
@@ -46,9 +46,11 @@ Documents folder) and leaves the originals where they were.
Update from the app: the app looks for a new version at start and every Update from the app: the app looks for a new version at start and every
few hours, and when one exists a band across the top of the window says so few hours, and when one exists a band across the top of the window says so
(never over a playing video). "Update and restart" downloads, (never over a playing video), with its first changes. "What's new" lists
verifies, applies in place and restarts; "Later" hides it until the next them all ([CHANGELOG.md](CHANGELOG.md)); the first start after an update
check; the install folder must be writable by you. The version you run is shows that list once, and Settings → About has it again. "Update and
restart" downloads, verifies, applies in place and restarts; "Later" hides
it until the next check; the install folder must be writable by you. The version you run is
at the bottom of every Settings page (and in the Settings tab's tooltip). Settings has Check now and an auto-check toggle. From a terminal: at the bottom of every Settings page (and in the Settings tab's tooltip). Settings has Check now and an auto-check toggle. From a terminal:
`./starsync-server --upgrade` (no display needed, so it works on a `./starsync-server --upgrade` (no display needed, so it works on a
server box too), `./starsync --upgrade`, or the shipped `update.sh` / server box too), `./starsync --upgrade`, or the shipped `update.sh` /
@@ -64,8 +66,9 @@ The app always runs its own server, quietly: your server. When it starts
you are on it, alone, and it is a plain player. Three ways in, at the top you are on it, alone, and it is a plain player. Three ways in, at the top
of the Library the first time; none needs Jellyfin or an account. of the Library the first time; none needs Jellyfin or an account.
- **Join a friend** (most people): your friend sends you an invite (an - **Join a friend** (most people): your friend sends you an invite (a
address) and, maybe, a password. See [Join](#join). `starsync://` link) and, maybe, a password. Click the link, or just
copy it: the app asks "Join <their server>?". See [Join](#join).
- **Invite friends**: you have the video and friends watch it with you. - **Invite friends**: you have the video and friends watch it with you.
See [Your server and Invite](#your-server-and-invite). See [Your server and Invite](#your-server-and-invite).
- **Watch on your own**: just play something from the Library, with - **Watch on your own**: just play something from the Library, with
@@ -86,8 +89,8 @@ out; a crown on whoever
hosts, a tower on whoever passes the video on). Click the avatars for the hosts, a tower on whoever passes the video on). Click the avatars for the
member list (each member's app version, amber when it differs from yours, member list (each member's app version, amber when it differs from yours,
and the server's at the top; force ready, Nudge, Sit out; on your and the server's at the top; force ready, Nudge, Sit out; on your
server the invite with Copy invite and Stop sharing, on a friend's "Copy server where friends can reach it, Copy invite link and Stop sharing,
invite address"). Beside them the server and "ready n/m" (who is sitting out or on a friend's "Copy invite link"). Beside them the server and "ready n/m" (who is sitting out or
reconnecting does not count), the chat button with unread reconnecting does not count), the chat button with unread
count, and Invite on your server or Leave on a friend's. Empty strip count, and Invite on your server or Leave on a friend's. Empty strip
space drags the window. space drags the window.
@@ -119,6 +122,26 @@ by default, or Only when paused, or Off; messages always reach the chat,
with their sound). Watching alone, nothing pops up or sounds about the with their sound). Watching alone, nothing pops up or sounds about the
party (there is nobody); the app's own notices still show. party (there is nobody); the app's own notices still show.
**Quick chat.** Enter in the big player opens a small chat box at the
bottom centre without opening the chat panel, also while the video
plays: type (the player's keys wait meanwhile), Enter sends, Esc or an
empty Enter closes it, and it closes by itself after a few seconds
without typing. It is the same draft as the chat panel's field, so a
message started in one is there in the other. What you sent shows for a
moment where the box was (or flies as your own line with danmaku, below);
bottom subtitles move up out of its way and back, top signs stay. The
mouse pointer and the controls stay hidden: typing is not moving the
mouse.
**Danmaku.** Settings → Notifications & sounds → Over the player →
Notifications: Toasts (default) or Danmaku. With Danmaku, over the big
player chat, joins, leaves, Up next and "starting now" fly right to left
across the top of the picture instead of popping up (problems and errors
still pop up, and everything does while the player is small). The look
is yours to tune there, with a live preview: crossing time, how many at
once, which band of the picture they use, opacity, size, boldness,
outline, avatars, and a frame round your own lines.
**While sitting out or in the background.** Sitting out, only chat, **While sitting out or in the background.** Sitting out, only chat,
nudges, "starting now" (the video starts for the others) and Up next nudges, "starting now" (the video starts for the others) and Up next
reach you; joins and leaves too if you turn on Settings → Notifications & reach you; joins and leaves too if you turn on Settings → Notifications &
@@ -167,13 +190,22 @@ Your server starts with the app and stops when it closes. Only this app
can reach it until you press **Invite** (in the strip, or in the member can reach it until you press **Invite** (in the strip, or in the member
list): that opens it on your network at the port from Settings → Party & list): that opens it on your network at the port from Settings → Party &
network (default 8080, with the password set there; blank = anyone can network (default 8080, with the password set there; blank = anyone can
join) and copies an invite ("Join me on Starsync: 192.168.1.20:8080 · join) and copies an invite link (`starsync://203.0.113.7:8080/?name=…`,
Starsync 1.3.0") to send to friends. Nothing restarts: what plays and Up just the link) to send to friends. Nothing restarts: what plays and Up next carry on, and you are
next carry on, and you are the host (the crown). The invite picks this the host (the crown).
computer's home-network address (not a VPN or proxy one, nor a Docker or
VM bridge). Friends elsewhere need the TCP port forwarded on your router The link carries your internet address when the app can find it (it asks
(or a VPN address, or a server they can all reach, like a VPS). Friends a "what is my IP" service such as ipify over HTTPS when you share), else
open nothing. **Stop sharing** (in the member list) closes it again: this computer's home-network address (not a proxy one, nor a Docker or VM
bridge). In the member list, **Copy invite link** copies it again;
**Other addresses** there lets you pick another: **Internet** for friends
elsewhere, **Home network** for friends on the same Wi-Fi or router,
**VPN** (Tailscale and the like). Behind a router, friends elsewhere get
in only when the TCP port is forwarded on your router to this computer
(the card says so); the app does not open ports itself. The link
includes the password when one is set, so friends just click it: share
it only with them. Friends open nothing. **Stop sharing** (in the member list)
closes it again:
everyone else leaves (their app goes back to their own server and says everyone else leaves (their app goes back to their own server and says
why), you and Up next stay. why), you and Up next stay.
@@ -224,8 +256,10 @@ Up next, add the folder to Files, or fill Link).
"Show (Season 3 - Alicization)" gives the season and its name. Shows "Show (Season 3 - Alicization)" gives the season and its name. Shows
are looked up on AniList for their proper name, cover and banner. A are looked up on AniList for their proper name, cover and banner. A
show's page has its seasons and episodes and a button for the next one show's page has its seasons and episodes and a button for the next one
after what you last played here; when episodes are missing on the way after what you last watched here; when episodes are missing on the way
it says so ("66–107 aren't in your folders — skip to 108?"). Matched it says so ("66–107 aren't in your folders — skip to 108?"). An
episode you put up continues with the next one at its end (see Up
next). Matched
the wrong show? **Wrong show?** searches AniList and pins the right one the wrong show? **Wrong show?** searches AniList and pins the right one
for the show everywhere or just one folder; **Episode numbers…** says for the show everywhere or just one folder; **Episode numbers…** says
which episode the first file is when the files count differently. Both which episode the first file is when the files count differently. Both
@@ -253,7 +287,7 @@ app open for that. Everyone shares one Up next and anyone can change it; what
you added stays when you leave. you added stays when you leave.
In the player, Up next sits in the top bar, right of the title: the In the player, Up next sits in the top bar, right of the title: the
next item's picture and title (or the next episode's). Rest the mouse on next item's picture and title (or what continues the episode). Rest the mouse on
it for a larger preview (who added it, its length, whether it is ready it for a larger preview (who added it, its length, whether it is ready
to start); ⏭ plays it now, a click opens the queue: drag rows to reorder, hover a to start); ⏭ plays it now, a click opens the queue: drag rows to reorder, hover a
row to play it now (▶) or remove it (×), right-click it for more (copy its row to play it now (▶) or remove it (×), right-click it for more (copy its
@@ -261,17 +295,22 @@ title or link, open it in the browser or in Jellyfin). To add, press
Ctrl+V with the queue open (a copied link or video file goes in at once, Ctrl+V with the queue open (a copied link or video file goes in at once,
its title and picture showing while it is checked), type one after its title and picture showing while it is checked), type one after
**+ Add link**, or drop a link or a video on it. When a video ends, a card shows **+ Add link**, or drop a link or a video on it. When a video ends, a card shows
what is up next with a 10 s countdown (Cancel stops it on your screen) what is up next with a 10 s countdown everyone sees, and it plays;
and it plays; `>` or Shift+N plays it at once. Under the queue, Cancel stops it for everyone, and so does seeking back. `>` or Shift+N
**Continues with** shows the next episodes of the Jellyfin series plays it at once. Under the queue, **Continues with** shows what
playing (greyed: they play only once Up next is empty); **+** adds one, continues the episode playing: its next episode (a Jellyfin episode's,
**Clear** drops them for this episode. or a file's next one in your Files library, saying which numbers it
skips), added by whoever put the episode up, and greyed because it
plays only once Up next is empty; **Clear** drops it for everyone. The
further episodes of a Jellyfin series show under it; **+** adds one.
A link you pass on ("through me") or a file of yours can only be played A link you pass on ("through me") or a file of yours can only be played
by your app: if you left when its turn comes, it is skipped ("Skipped …: by your app: if you left when its turn comes, it is skipped ("Skipped …:
Foxyz left"). A Jellyfin item needs someone with an account on that Foxyz left"). If you are only gone for a moment, the countdown waits for
server. Up next is kept by the server while it runs; restarting the you. A Jellyfin item needs someone with an account on that server. Up
server empties it. next is kept by the server while it runs. If the server restarts, the
apps put back what was playing, where it was, and Up next with it (a
link the server fetched has to be added again).
## Schedule ## Schedule
@@ -342,7 +381,15 @@ Join a friend in the strip. Servers you got into are listed first (up
to 6), with live status (online and how many are watching, or offline, to 6), with live status (online and how many are watching, or offline,
plus "needs update" when its version and yours do not match): click one plus "needs update" when its version and yours do not match): click one
to rejoin, x to forget it. Or paste the invite (or the address and port) to rejoin, x to forget it. Or paste the invite (or the address and port)
and the password if there is one, then Enter or Join. Before connecting and the password if there is one, then Enter or Join.
Faster: click a friend's `starsync://` link, or copy their invite and
switch to Starsync. The app asks "Join Bob's server (203.0.113.7:8080)?"
with the server's version and who is watching; Join joins (and adds it
to your recent servers), Not now leaves it (a copied invite is then not
offered again). The clipboard is only looked at for an invite when the
window comes to the front, and never saved or logged. The app runs once:
a link clicked while it runs goes to the open window. Before connecting
the app checks the server's version: when they do not match it says who the app checks the server's version: when they do not match it says who
has to update ("Bob's server runs Starsync 1.2.1 — they need to has to update ("Bob's server runs Starsync 1.2.1 — they need to
update"), and offers the update when it is you. Your name and picture update"), and offers the update when it is you. Your name and picture
@@ -362,15 +409,21 @@ volume and sound set, and every sound with a preview, its own set and a switch);
(the version, also at the bottom of every Settings page and in the (the version, also at the bottom of every Settings page and in the
Settings tab's tooltip; and the credits, e.g. that the Minimal player design is inspired by uosc). Settings tab's tooltip; and the credits, e.g. that the Minimal player design is inspired by uosc).
Player settings live in the player too: ⚙ → Player settings, at the Player settings live in the player too: ⚙ → Player settings opens a
right of its controls, and Settings → Player has the same ones (plus the narrow panel at the right edge of the player that stays open while you
controls' layout and the custom shader files). Player design: Standard or watch (the controls hide and show as usual) until its × or Esc. Its
Minimal (below); progress line when hidden (on by default: a thin line at sections fold: tracks (subtitles, audio), subtitles (size, above the
the bottom of the video while the controls are hidden); upscaling shader controls), picture (upscaling shader, sharp video, hardware decoding),
(see below); hardware decoding (auto, off or custom; auto is mpv's sound (volume), notifications (toasts or danmaku and the danmaku look)
`auto-copy` on Windows, `auto` elsewhere; the env var and controls (design, progress line). Settings → Player has the same
`SYNC_RELAY_HWDEC` overrides it); in the player also the subtitle and ones (plus the controls' layout and the custom shader files). Player
audio track. Settings → Player → Controls: time display (elapsed / total design: Standard or Minimal (below); progress line when hidden (on by
default: a thin line at the bottom of the video while the controls are
hidden); upscaling shader (see below); hardware decoding (auto, off or
custom; auto is mpv's `auto-copy` on Windows, `auto` elsewhere; the env
var `SYNC_RELAY_HWDEC` overrides it); subtitle size; subtitles above the
controls (off by default: bottom lines move up while the control bar
shows; the quick chat box always moves them). Settings → Player → Controls: time display (elapsed / total
or remaining), controls size (normal or large), hide the controls after or remaining), controls size (normal or large), hide the controls after
2, 3 or 5 s, and which optional buttons sit in the bar (chapter buttons, 2, 3 or 5 s, and which optional buttons sit in the bar (chapter buttons,
subtitles, audio, chat; hidden ones are still in ⚙). Every change subtitles, audio, chat; hidden ones are still in ⚙). Every change
@@ -380,8 +433,9 @@ Anywhere in a server (unless you are typing): Space = ready / unready,
Tab = chat & activity (Tab never moves between buttons). Anywhere: Tab = chat & activity (Tab never moves between buttons). Anywhere:
Ctrl+Shift+B = mark a problem in the session log. Keys while the Ctrl+Shift+B = mark a problem in the session log. Keys while the
player fills the window: Left/Right = seek 10 s, Up/Down = volume, Shift+Right = skip 1:30, player fills the window: Left/Right = seek 10 s, Up/Down = volume, Shift+Right = skip 1:30,
PgUp/PgDn = previous / next chapter (as in mpv), F = fullscreen, Esc = PgUp/PgDn = previous / next chapter (as in mpv), F = fullscreen, Enter =
close the chat, then fullscreen, then shrink the player to mini; from the quick chat, Esc = close the player panel, then
the chat, then fullscreen, then shrink the player to mini; from the
mini it asks to leave, Ctrl+R = reload video, mini it asks to leave, Ctrl+R = reload video,
`<` / `>` or Shift+P / Shift+N = previous / next Jellyfin episode, `<` / `>` or Shift+P / Shift+N = previous / next Jellyfin episode,
Ctrl+1..6 = Anime4K mode A, B, C, A+A, B+B, C+A, Ctrl+0 = shaders off. Ctrl+1..6 = Anime4K mode A, B, C, A+A, B+B, C+A, Ctrl+0 = shaders off.
@@ -473,10 +527,13 @@ Watching:
directly with their own sign-in. Jellyfin tracks progress per account: directly with their own sign-in. Jellyfin tracks progress per account:
your watched state, Continue watching and resume point follow what you your watched state, Continue watching and resume point follow what you
saw (reported on start, every 10 s, on pause, resume, seek and track saw (reported on start, every 10 s, on pause, resume, seek and track
change, and when it stops). Watched means you actually played most of change, and when it stops). Watched means you were there while most
it (or reached the ending after half), not where you stopped: opening of it played (or while half of it played and it reached the ending),
a watched episode to check a moment keeps it watched, and a quick peek not where it stopped: time you sat out does not count, skipping around
leaves no resume point. does not count, opening a watched episode to check a moment keeps it
watched, and a quick peek leaves no resume point. Files and links you
watch are remembered the same way on your computer (a show's next
episode in Files follows them).
- Friends without an account watch through the server: whoever put the - Friends without an account watch through the server: whoever put the
item up passes it on automatically, with their own sign-in, which never item up passes it on automatically, with their own sign-in, which never
leaves their computer (only the current item, only to people in the leaves their computer (only the current item, only to people in the
@@ -493,12 +550,11 @@ Episodes: the ⏮ / ⏭ buttons next to play (or `<` / `>`,
Shift+P / Shift+N) put up the previous or next episode for everyone. From Shift+P / Shift+N) put up the previous or next episode for everyone. From
the ending (when it is known, see Skipping above; else in the last two the ending (when it is known, see Skipping above; else in the last two
minutes) a "Next episode" button floats in the corner while the controls minutes) a "Next episode" button floats in the corner while the controls
are up. When an are up. With "Play the next episode automatically" (Settings, on by
episode ends, a card shows what is next with Play next; with "Play the default), the episode you put up continues with the next one: when it
next episode automatically" (Settings, on by default) it counts down 10 s ends, everyone sees a 10 s countdown, and anyone can cancel it (see Up
first, and Cancel stops it on your screen (seeking back stops it for next). Anyone with an account can put the next one up; friends watching
everyone). Anyone with an account can put the next one up; friends via the host cannot.
watching via the host cannot.
Audio and subtitles: the Audio and Subtitles buttons in the controls pick tracks. Audio and subtitles: the Audio and Subtitles buttons in the controls pick tracks.
Your choice is remembered per series (and as a default) and the closest Your choice is remembered per series (and as a default) and the closest
+41 -5
View File
@@ -10,16 +10,26 @@ import 'src/app/app_graph.dart';
import 'src/files/thumbnails.dart' show thumbnailPlayers; import 'src/files/thumbnails.dart' show thumbnailPlayers;
import 'src/platform/app_log.dart'; import 'src/platform/app_log.dart';
import 'src/platform/app_shutdown.dart' show closeAppWith; import 'src/platform/app_shutdown.dart' show closeAppWith;
import 'src/platform/desktop/attention.dart' show exitNow, windowLaunchToken;
import 'src/platform/desktop/desktop_app_dirs.dart'; import 'src/platform/desktop/desktop_app_dirs.dart';
import 'src/platform/desktop/windows_api.dart' show allowSetForegroundWindow;
import 'src/platform/desktop_entry.dart'; import 'src/platform/desktop_entry.dart';
import 'src/platform/ports/window_activity.dart';
import 'src/platform/single_instance.dart';
import 'src/platform/url_scheme.dart';
import 'src/platform/smoke.dart'; import 'src/platform/smoke.dart';
import 'src/screens/home/ui.dart'; import 'src/screens/home/ui.dart';
import 'src/session/app_events.dart' show UpdateAvailable; import 'src/session/app_events.dart' show UpdateAvailable;
import 'src/session/invite.dart' show InviteLink;
import 'src/state/settings.dart'; import 'src/state/settings.dart';
import 'src/update/update_controller.dart' show UpdateController, UpdatePhase, launchArgs; import 'src/update/update_controller.dart' show UpdatePhase, launchArgs;
Future<void> main(List<String> args) async { Future<void> main(List<String> args) async {
launchArgs = List.unmodifiable(args); // An invite link is handled once: Update and restart does not pass it on.
launchArgs = List.unmodifiable([
for (final a in args)
if (InviteLink.parse(a) == null) a,
]);
if (args.contains('--upgrade')) { if (args.contains('--upgrade')) {
exitCode = await runUpgrade(); exitCode = await runUpgrade();
exit(exitCode); exit(exitCode);
@@ -44,6 +54,15 @@ Future<void> main(List<String> args) async {
); );
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
final dirs = await const DesktopAppDirs().resolve(); final dirs = await const DesktopAppDirs().resolve();
// One app per profile: a second start (a clicked starsync:// link) hands
// its arguments to the running one and ends before its window shows.
final instance = await InstanceLock.claim(
Directory(dirs.hive).parent.path,
args: args,
activation: await windowLaunchToken(),
beforeSend: Platform.isWindows ? allowSetForegroundWindow : null,
);
if (instance == null) await exitNow();
await startAppLogging(dirs); await startAppLogging(dirs);
Hive.init(dirs.hive); Hive.init(dirs.hive);
await AppSettings.instance.ensureLoaded(); await AppSettings.instance.ensureLoaded();
@@ -52,30 +71,39 @@ Future<void> main(List<String> args) async {
final ports = graph.ports; final ports = graph.ports;
// Close button / Alt+F4: the orderly close ([shutDownApp]), so others // Close button / Alt+F4: the orderly close ([shutDownApp]), so others
// see "left", your server shuts down and no player outlives the engine. // see "left", your server shuts down and no player outlives the engine.
final close = closeAppWith(graph); final close = closeAppWith(graph, instance: instance);
await ports.shell.start( await ports.shell.start(
title: dirs.profile == null ? appName : '$appName [${dirs.profile}]', onCloseRequest: () => close()); title: dirs.profile == null ? appName : '$appName [${dirs.profile}]', onCloseRequest: () => close());
// Focus, minimise and restore feed auto sit-out and the Notifier (in // Focus, minimise and restore feed auto sit-out and the Notifier (in
// the background, notices go out as system notifications). // the background, notices go out as system notifications).
// Coming to the front also looks for an invite on the clipboard (the
// join prompt; nothing of it is logged).
ports.shell.watchActivity((a) { ports.shell.watchActivity((a) {
graph.coordinator.windowActivity(a); graph.coordinator.windowActivity(a);
graph.notifier.window = a; graph.notifier.window = a;
if (a == WindowActivity.focused) {
unawaited(ports.shell.readClipboardText().then(graph.joinOffers.fromClipboard));
}
}); });
try { try {
await graph.forms.load(); await graph.forms.load();
} catch (e, st) { } catch (e, st) {
debugPrint('load connect form failed: $e\n$st'); debugPrint('load connect form failed: $e\n$st');
} }
// The library's matches, pins and offsets (device-owned). // The library's matches, pins and offsets, and the watch history
// (device-owned).
unawaited(graph.library.ensureLoaded()); unawaited(graph.library.ensureLoaded());
unawaited(graph.watch.history.ensureLoaded());
ports.players.init(); ports.players.init();
thumbnailPlayers = ports.players; thumbnailPlayers = ports.players;
// yt-dlp for web page links: downloaded on first run, kept current. // yt-dlp for web page links: downloaded on first run, kept current.
ports.links.warmUp(); ports.links.warmUp();
// Linux: the launcher entry and icon Wayland needs (release installs). // Linux: the launcher entry and icon Wayland needs (release installs).
unawaited(installDesktopEntry()); unawaited(installDesktopEntry());
// Windows: starsync:// links start this install (HKCU, release builds).
unawaited(registerWindowsScheme());
// A found update is an event too (the band shows it). // A found update is an event too (the band shows it).
final updates = UpdateController.instance; final updates = graph.updates;
updates.status.addListener(() { updates.status.addListener(() {
final s = updates.status.value; final s = updates.status.value;
final version = s.manifest?.version; final version = s.manifest?.version;
@@ -92,6 +120,14 @@ Future<void> main(List<String> args) async {
// Home: your own server, quietly (only this app can reach it until // Home: your own server, quietly (only this app can reach it until
// Invite). // Invite).
unawaited(graph.navigation.start()); unawaited(graph.navigation.start());
// A clicked invite link: the one this app started with, then each one a
// later start hands over (the window comes to the front for it).
graph.joinOffers.fromArgs(args);
instance.handoffs.listen((h) {
unawaited(ports.shell.focus(activation: h.activation));
graph.joinOffers.fromArgs(h.args);
});
unawaited(ports.shell.readClipboardText().then(graph.joinOffers.fromClipboard));
// The sound device opens once the window is up, not on the first sound. // The sound device opens once the window is up, not on the first sound.
Future.delayed(const Duration(seconds: 2), graph.sfx.warmUp); Future.delayed(const Duration(seconds: 2), graph.sfx.warmUp);
} }
+52 -6
View File
@@ -2,6 +2,7 @@ import 'package:flutter/widgets.dart';
import '../jellyfin/accounts.dart'; import '../jellyfin/accounts.dart';
import '../library/library_store.dart'; import '../library/library_store.dart';
import '../library/local_next.dart';
import '../library/show_matcher.dart'; import '../library/show_matcher.dart';
import '../platform/app_log.dart' show appLogDir; import '../platform/app_log.dart' show appLogDir;
import '../platform/desktop/desktop_ports.dart'; import '../platform/desktop/desktop_ports.dart';
@@ -11,6 +12,7 @@ import '../schedule/animeflow.dart';
import '../screens/home/health_probe.dart'; import '../screens/home/health_probe.dart';
import '../session/connection.dart' show Connection; import '../session/connection.dart' show Connection;
import '../session/identity.dart' show ClientIdentity, Profile; import '../session/identity.dart' show ClientIdentity, Profile;
import '../session/join_offers.dart';
import '../session/navigation.dart'; import '../session/navigation.dart';
import '../session/notifier.dart'; import '../session/notifier.dart';
import '../session/session_coordinator.dart'; import '../session/session_coordinator.dart';
@@ -20,6 +22,10 @@ import '../sfx/sound_bank.dart';
import '../sfx/synth_sfx.dart'; import '../sfx/synth_sfx.dart';
import '../state/connect_prefs.dart'; import '../state/connect_prefs.dart';
import '../state/settings.dart'; import '../state/settings.dart';
import '../update/update_controller.dart';
import '../update/whats_new.dart';
import '../watch/watch_history.dart';
import '../watch/watch_tracker.dart';
/// The composition root: builds the app's systems once (in `main`), wires /// The composition root: builds the app's systems once (in `main`), wires
/// them through their constructors and disposes them. No logic and no /// them through their constructors and disposes them. No logic and no
@@ -51,6 +57,18 @@ class AppGraph {
/// The library's matches (L2): local shows to AniList, pins, offsets. /// The library's matches (L2): local shows to AniList, pins, offsets.
final ShowMatcher library; final ShowMatcher library;
/// Watch tracking and the device's watch history (D3).
final WatchTracker watch;
/// The app's updater (checks, the update band, install and restart).
final UpdateController updates;
/// What's new once after an update; keeps the notes the updater saves.
final WhatsNew whatsNew;
/// The join prompt for a clicked invite link or one on the clipboard.
final JoinOffers joinOffers;
AppGraph._({ AppGraph._({
required this.ports, required this.ports,
required this.sfx, required this.sfx,
@@ -61,10 +79,15 @@ class AppGraph {
required this.yourServer, required this.yourServer,
required this.navigation, required this.navigation,
required this.library, required this.library,
required this.watch,
required this.updates,
required this.whatsNew,
required this.joinOffers,
}); });
/// The desktop app's graph. Tests pass their own [ports], [identity] /// The desktop app's graph. Tests pass their own [ports], [identity]
/// (default: the install's stored one), [healthGet] and server starter. /// (default: the install's stored one), [healthGet], server starter and
/// internet address lookup ([publicAddress]).
factory AppGraph.build({ factory AppGraph.build({
Ports? ports, Ports? ports,
ClientIdentity? identity, ClientIdentity? identity,
@@ -72,12 +95,15 @@ class AppGraph {
ServerStarter? serverStarter, ServerStarter? serverStarter,
JellyfinAccounts? jellyfin, JellyfinAccounts? jellyfin,
ShowMatcher? library, ShowMatcher? library,
WatchHistory? history,
Future<String?> Function()? publicAddress,
}) { }) {
final p = ports ?? desktopPorts(); final p = ports ?? desktopPorts();
final settings = AppSettings.instance;
final sfx = SynthSfx( final sfx = SynthSfx(
volume: () => AppSettings.instance.soundVolume.peek(), volume: () => settings.soundVolume.peek(),
// A nudge is an explicit poke: it ignores its switch (not the volume). // A nudge is an explicit poke: it ignores its switch (not the volume).
enabled: (k) => k == SfxKind.nudge || !AppSettings.instance.soundsOff.peek().contains(k.name), enabled: (k) => k == SfxKind.nudge || !settings.soundsOff.peek().contains(k.name),
presetFor: soundPresetFor, presetFor: soundPresetFor,
); );
final notifier = Notifier( final notifier = Notifier(
@@ -85,11 +111,19 @@ class AppGraph {
attention: p.shell.requestAttention, attention: p.shell.requestAttention,
system: p.notifications, system: p.notifications,
focusWindow: p.shell.focus, focusWindow: p.shell.focus,
prefs: () => AppSettings.instance.notifyPrefs, prefs: () => settings.notifyPrefs,
); );
final connection = Connection(notify: notifier.notify, identity: identity ?? ClientIdentity.stored()); final connection = Connection(notify: notifier.notify, identity: identity ?? ClientIdentity.stored());
// Device-owned: the watch history box.
final watch = WatchTracker(history: history ?? WatchHistory());
final coordinator = SessionCoordinator( final coordinator = SessionCoordinator(
notifier: notifier, connection: connection, ytDlp: p.links, jellyfin: jellyfin, players: p.players); notifier: notifier,
connection: connection,
ytDlp: p.links,
jellyfin: jellyfin,
players: p.players,
settings: settings,
watch: watch);
// Device-owned: the library box; AnimeFlow's runs when signed in. // Device-owned: the library box; AnimeFlow's runs when signed in.
final matcher = library ?? final matcher = library ??
ShowMatcher( ShowMatcher(
@@ -97,7 +131,9 @@ class AppGraph {
aniList: AniListClient(), aniList: AniListClient(),
flow: AnimeFlowLookup(AnimeFlowClient.fromAccounts(coordinator.jellyfin)), flow: AnimeFlowLookup(AnimeFlowClient.fromAccounts(coordinator.jellyfin)),
); );
coordinator.fileRef = matcher.refForPath; coordinator
..fileRef = matcher.refForPath
..nextFile = LocalNextFinder(matcher: matcher, scan: p.folders.scan).nextAfter;
final forms = ConnectStore(); final forms = ConnectStore();
final yourServer = YourServer( final yourServer = YourServer(
forms: forms, forms: forms,
@@ -113,6 +149,7 @@ class AppGraph {
}, },
logDir: () => appLogDir, logDir: () => appLogDir,
starter: serverStarter, starter: serverStarter,
publicAddress: publicAddress,
); );
final navigation = Navigation( final navigation = Navigation(
coordinator: coordinator, coordinator: coordinator,
@@ -127,6 +164,10 @@ class AppGraph {
..push = ({String? name, String? avatar}) { ..push = ({String? name, String? avatar}) {
if (navigation.online.peek()) coordinator.updateProfile(name: name, avatar: avatar); if (navigation.online.peek()) coordinator.updateProfile(name: name, avatar: avatar);
}; };
final updates = UpdateController.instance;
final whatsNew = WhatsNew(store: SettingsWhatsNewStore(settings));
updates.beforeRestart ??= whatsNew.installing;
updates.channelOf ??= () => settings.updateChannel.peek();
return AppGraph._( return AppGraph._(
ports: p, ports: p,
sfx: sfx, sfx: sfx,
@@ -137,12 +178,17 @@ class AppGraph {
yourServer: yourServer, yourServer: yourServer,
navigation: navigation, navigation: navigation,
library: matcher, library: matcher,
watch: watch,
updates: updates,
whatsNew: whatsNew,
joinOffers: JoinOffers(navigation: navigation, yourServer: yourServer, forms: forms),
); );
} }
Future<void> dispose() async { Future<void> dispose() async {
navigation.dispose(); navigation.dispose();
await coordinator.dispose(); await coordinator.dispose();
await watch.dispose();
await yourServer.stop(); await yourServer.stop();
await sfx.dispose(); await sfx.dispose();
} }
@@ -11,7 +11,6 @@ import '../widgets/copy_open.dart';
import 'frame.dart'; import 'frame.dart';
import 'model/controls_layout.dart'; import 'model/controls_layout.dart';
import 'model/controls_model.dart'; import 'model/controls_model.dart';
import 'player_settings.dart';
// The controls' menus, shared by both skins: the track and chapter lists // The controls' menus, shared by both skins: the track and chapter lists
// and the ⚙ menu, all on the app's menu (`showAppMenu`), anchored on the // and the ⚙ menu, all on the app's menu (`showAppMenu`), anchored on the
@@ -129,7 +128,7 @@ Future<void> runControlAction(BuildContext context, PlayerControlsModel model, C
case ControlAction.markProblem: case ControlAction.markProblem:
await ControlsFrame.holdWhile(context, () => showMarkProblem(context)); await ControlsFrame.holdWhile(context, () => showMarkProblem(context));
case ControlAction.playerSettings: case ControlAction.playerSettings:
await showPlayerSettings(context, renderMode: model.renderMode); model.togglePanel();
case ControlAction.leave: case ControlAction.leave:
model.leave(); model.leave();
} }
+47 -10
View File
@@ -1,5 +1,6 @@
import 'dart:async'; import 'dart:async';
import 'package:flutter/foundation.dart' show ValueListenable;
import 'package:flutter/gestures.dart' show kPrimaryMouseButton; import 'package:flutter/gestures.dart' show kPrimaryMouseButton;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@@ -10,7 +11,10 @@ import 'controls_probe.dart' show ControlsHold;
// without any (or the mouse leaving) hides them and the cursor. Holds keep // without any (or the mouse leaving) hides them and the cursor. Holds keep
// them up: a seek drag (no hover events arrive), an open menu (a route // them up: a seek drag (no hover events arrive), an open menu (a route
// takes the mouse), and the session's [ControlsHold] (the mouse on a // takes the mouse), and the session's [ControlsHold] (the mouse on a
// floating pill or card, the Up next popover). // floating pill or card, the Up next popover). Only real mouse movement
// wakes them: a widget appearing or going under a still pointer (the
// quick chat box) does not, nor does typing. [ControlsFrame.suppress]
// hides them while something takes their place (the quick chat box).
class ControlsFrame extends StatefulWidget { class ControlsFrame extends StatefulWidget {
/// Draws the controls; [visible] false while they are hidden. /// Draws the controls; [visible] false while they are hidden.
@@ -28,6 +32,11 @@ class ControlsFrame extends StatefulWidget {
/// Keeps them up while held. /// Keeps them up while held.
final ControlsHold? holdBy; final ControlsHold? holdBy;
/// While true the controls are drawn hidden and [shown] says so; the
/// cursor still follows the mouse. They come back as they would have
/// been once it is false again.
final ValueListenable<bool>? suppress;
const ControlsFrame({ const ControlsFrame({
super.key, super.key,
required this.builder, required this.builder,
@@ -37,6 +46,7 @@ class ControlsFrame extends StatefulWidget {
this.hideAfter = const Duration(seconds: 3), this.hideAfter = const Duration(seconds: 3),
this.shown, this.shown,
this.holdBy, this.holdBy,
this.suppress,
}); });
/// The frame around [context], for widgets that keep it up (a drag, a /// The frame around [context], for widgets that keep it up (a drag, a
@@ -62,16 +72,23 @@ class ControlsFrameState extends State<ControlsFrame> {
int _holds = 0; int _holds = 0;
bool _heldBy = false; bool _heldBy = false;
Timer? _timer; Timer? _timer;
// Where the mouse was last seen, to tell real movement from a widget
// appearing or going under it.
Offset? _lastPos;
bool get visible => _visible; bool get _suppressed => widget.suppress?.value ?? false;
/// The controls are drawn (up and not suppressed).
bool get visible => _visible && !_suppressed;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
widget.holdBy?.addListener(_holdByChanged); widget.holdBy?.addListener(_holdByChanged);
widget.suppress?.addListener(_suppressChanged);
// Not during this build: an overlay listening to [shown] may be built. // Not during this build: an overlay listening to [shown] may be built.
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) widget.shown?.value = true; if (mounted) widget.shown?.value = visible;
}); });
_hideLater(); _hideLater();
} }
@@ -84,12 +101,17 @@ class ControlsFrameState extends State<ControlsFrame> {
widget.holdBy?.addListener(_holdByChanged); widget.holdBy?.addListener(_holdByChanged);
_holdByChanged(); _holdByChanged();
} }
if (oldWidget.suppress != widget.suppress) {
oldWidget.suppress?.removeListener(_suppressChanged);
widget.suppress?.addListener(_suppressChanged);
}
if (oldWidget.hideAfter != widget.hideAfter && _visible) _hideLater(); if (oldWidget.hideAfter != widget.hideAfter && _visible) _hideLater();
} }
@override @override
void dispose() { void dispose() {
widget.holdBy?.removeListener(_holdByChanged); widget.holdBy?.removeListener(_holdByChanged);
widget.suppress?.removeListener(_suppressChanged);
_timer?.cancel(); _timer?.cancel();
super.dispose(); super.dispose();
} }
@@ -101,15 +123,29 @@ class ControlsFrameState extends State<ControlsFrame> {
hold(on); hold(on);
} }
void _suppressChanged() {
if (!mounted) return;
setState(() {});
widget.shown?.value = visible;
}
/// Show the controls and restart the hide countdown. /// Show the controls and restart the hide countdown.
void wake() { void wake() {
if (!_visible && mounted) { if (!_visible && mounted) {
setState(() => _visible = true); setState(() => _visible = true);
widget.shown?.value = true; widget.shown?.value = visible;
} }
_hideLater(); _hideLater();
} }
/// A hover or enter at [pos]: wakes only when the mouse really moved.
void _moved(Offset pos) {
final last = _lastPos;
_lastPos = pos;
if (last != null && (pos - last).distance < 0.5) return;
wake();
}
/// Keep the controls up while [on] (paired calls). /// Keep the controls up while [on] (paired calls).
void hold(bool on) { void hold(bool on) {
_holds = on ? _holds + 1 : (_holds - 1).clamp(0, 1 << 20); _holds = on ? _holds + 1 : (_holds - 1).clamp(0, 1 << 20);
@@ -141,9 +177,10 @@ class ControlsFrameState extends State<ControlsFrame> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return MouseRegion( return MouseRegion(
cursor: _visible ? SystemMouseCursors.basic : SystemMouseCursors.none, cursor: _visible ? SystemMouseCursors.basic : SystemMouseCursors.none,
onEnter: (_) => wake(), onEnter: (e) => _moved(e.position),
onHover: (_) => wake(), onHover: (e) => _moved(e.position),
onExit: (_) { onExit: (e) {
_lastPos = e.position;
_timer?.cancel(); _timer?.cancel();
_hide(); _hide();
}, },
@@ -154,12 +191,12 @@ class ControlsFrameState extends State<ControlsFrame> {
Positioned.fill( Positioned.fill(
child: DoubleClickListener( child: DoubleClickListener(
onDoubleClick: widget.onDoubleClick!, onDoubleClick: widget.onDoubleClick!,
skipTop: _visible ? widget.barTop : 0, skipTop: visible ? widget.barTop : 0,
skipBottom: _visible ? widget.barBottom : 0, skipBottom: visible ? widget.barBottom : 0,
child: const ColoredBox(color: Color(0x00000000), child: SizedBox.expand()), child: const ColoredBox(color: Color(0x00000000), child: SizedBox.expand()),
), ),
), ),
widget.builder(context, _visible), widget.builder(context, visible),
], ],
), ),
); );
@@ -148,7 +148,7 @@ class MinimalControls extends StatelessWidget {
const Expanded( const Expanded(
child: Align(alignment: AlignmentDirectional.centerStart, child: NowPlayingTitle()), child: Align(alignment: AlignmentDirectional.centerStart, child: NowPlayingTitle()),
), ),
UpNextButton(queue: m.queue, onPlayNextEpisode: m.playNextEpisode, look: UpNextLook.minimal, hold: hold), UpNextButton(queue: m.queue, look: UpNextLook.minimal, hold: hold),
const SizedBox(width: 4), const SizedBox(width: 4),
SignalBuilder( SignalBuilder(
builder: (context) { builder: (context) {
@@ -28,6 +28,9 @@ abstract final class MinimalTimelineSpec {
/// What is left of it while they are hidden. /// What is left of it while they are hidden.
static const lineSize = 3.0; static const lineSize = 3.0;
/// The played part's accent tint.
static const played = 0.4;
/// The position line. /// The position line.
static const lineWidth = 2.0; static const lineWidth = 2.0;
static const fontSize = 15.0; static const fontSize = 15.0;
@@ -467,13 +470,10 @@ class MinimalTimelinePainter extends CustomPainter {
final w = size.width, h = size.height; final w = size.width, h = size.height;
final x = lineX(w); final x = lineX(w);
final band = Offset.zero & size; final band = Offset.zero & size;
// The band, darker as it grows; hidden, a faint track. // The same colours shown and hidden: hidden, it is the band shrunk to
canvas.drawRect(band, Paint()..color = Color.lerp(const Color(0x40FFFFFF), MinimalStyle.band, visibility)!); // a line, so growing and shrinking never switches colour.
// Played: an accent tint, solid once the band is a line. canvas.drawRect(band, Paint()..color = MinimalStyle.band);
canvas.drawRect( canvas.drawRect(Rect.fromLTRB(0, 0, x, h), Paint()..color = accent.withValues(alpha: MinimalTimelineSpec.played));
Rect.fromLTRB(0, 0, x, h),
Paint()..color = accent.withValues(alpha: 0.28 + 0.72 * (1 - visibility)),
);
_stripes(canvas, w, h); _stripes(canvas, w, h);
_chapters(canvas, w, h); _chapters(canvas, w, h);
if (visibility > 0) { if (visibility > 0) {
@@ -500,7 +500,8 @@ class MinimalTimelinePainter extends CustomPainter {
canvas.save(); canvas.save();
canvas.clipRect(Rect.fromLTRB(ax, 0, w, h)); canvas.clipRect(Rect.fromLTRB(ax, 0, w, h));
final paint = Paint() final paint = Paint()
..color = Colors.white.withValues(alpha: 0.10 + 0.08 * (1 - visibility)) // Stronger on the line, where only a few pixels of each show.
..color = Colors.white.withValues(alpha: 0.12 + 0.22 * (1 - visibility))
..strokeWidth = 1.5 * scale; ..strokeWidth = 1.5 * scale;
for (var c = (ax / period).floorToDouble() * period - h; c <= w; c += period) { for (var c = (ax / period).floorToDouble() * period - h; c <= w; c += period) {
canvas.drawLine(Offset(c, h), Offset(c + h, 0), paint); canvas.drawLine(Offset(c, h), Offset(c + h, 0), paint);
@@ -509,7 +510,7 @@ class MinimalTimelinePainter extends CustomPainter {
} }
void _chapters(Canvas canvas, double w, double h) { void _chapters(Canvas canvas, double w, double h) {
final cut = Paint()..color = Colors.black.withValues(alpha: 0.55); final cut = Paint()..color = Colors.black.withValues(alpha: 0.3);
for (final f in chapters) { for (final f in chapters) {
if (f <= 0) continue; if (f <= 0) continue;
final cx = w * f; final cx = w * f;
@@ -81,13 +81,16 @@ class PlayerControlsModel {
/// Everything the layout depends on (not the position: that changes /// Everything the layout depends on (not the position: that changes
/// four times a second and only the timeline needs it). /// four times a second and only the timeline needs it).
late final Computed<ControlsSnapshot> snapshot = computed(() { late final Computed<ControlsSnapshot> snapshot = computed(() {
final items = queue.items.value; final queued = queue.items.value.firstOrNull;
final continues = queue.continues.value;
final next = coord.nextEpisode.value; final next = coord.nextEpisode.value;
return ControlsSnapshot( return ControlsSnapshot(
play: play.value, play: play.value,
previousLabel: coord.previousEpisode.value?.label, previousLabel: coord.previousEpisode.value?.label,
nextLabel: items.isNotEmpty ? items.first.title : next?.label, // Up next's first item, else the next episode, else what continues
nextIsUpNext: items.isNotEmpty, // this (a file's next episode).
nextLabel: queued?.title ?? next?.label ?? continues?.title,
nextIsUpNext: queued != null,
hasChapters: chapters.value.isNotEmpty, hasChapters: chapters.value.isNotEmpty,
hasDuration: duration.value > Duration.zero, hasDuration: duration.value > Duration.zero,
alone: coord.alone.value, alone: coord.alone.value,
@@ -112,6 +115,12 @@ class PlayerControlsModel {
/// A menu of the controls is open (the player's Esc closes it). /// A menu of the controls is open (the player's Esc closes it).
int menusOpen = 0; int menusOpen = 0;
/// The player panel docked at the right (⚙ → Player settings; Esc or
/// its × closes it). Stays open while the controls hide and show.
final panelOpen = signal(false);
void togglePanel() => panelOpen.value = !panelOpen.peek();
void dispose() => volumeFlash.dispose(); void dispose() => volumeFlash.dispose();
// -------------------------------------------------------------- actions // -------------------------------------------------------------- actions
@@ -137,9 +146,10 @@ class PlayerControlsModel {
void pickChapter(MediaChapter c) => coord.seekTo(c.start.inMicroseconds / 1e6); void pickChapter(MediaChapter c) => coord.seekTo(c.start.inMicroseconds / 1e6);
/// Up next's first item, else the next episode. False when neither. /// What plays next (Up next's first item, else the continuation), else
/// the next episode. False when neither.
bool next() { bool next() {
if (queue.items.peek().isNotEmpty) { if (queue.head.peek() != null) {
queue.skip(); queue.skip();
return true; return true;
} }
@@ -0,0 +1,454 @@
import 'package:flutter/material.dart';
import 'package:signals/signals_flutter.dart';
import 'package:sync_relay_core/core.dart' show JellyfinStreamType;
import '../app/app_graph.dart';
import '../danmaku/danmaku_settings.dart' show DanmakuKnobs, NotifyStyleSelect;
import '../playback/playback_controller.dart' show RenderMode;
import '../shaders/shader_presets.dart';
import '../shell/mark_problem.dart' show MarkProblemButton;
import '../shell/motion.dart';
import '../state/settings.dart';
import '../widgets/app_menu.dart';
import 'model/controls_prefs.dart';
import 'player_settings.dart' show applyHwdecLive, applyShaderChoice;
import 'volume.dart' as vol;
// The player panel (⚙ → Player settings, both skins): a narrow glass
// column docked at the right edge of the full player that stays open
// while the controls hide and show, until its × or Esc. What is useful
// while watching, in collapsible sections: tracks, subtitles, picture,
// sound, notifications (toasts or danmaku, the danmaku look) and the
// controls. Everything applies live and is saved; the rest of Settings →
// Player stays there.
/// Sections folded by the user, kept for the app's run.
final _folded = <String>{'Notifications', 'Controls'};
class PlayerPanel extends StatelessWidget {
static const width = 312.0;
final VoidCallback onClose;
/// The running player's render mode (shader and sharp video notes).
final ReadonlySignal<RenderMode>? renderMode;
/// The player's volume (0..[vol.maxVolume]) and how to set it; the
/// Sound section only with both.
final ReadonlySignal<double>? volume;
final ValueChanged<double>? onVolume;
const PlayerPanel({super.key, required this.onClose, this.renderMode, this.volume, this.onVolume});
@override
Widget build(BuildContext context) {
final graph = AppScope.of(context);
final s = graph.coordinator.settings;
return AppGlass(
key: const ValueKey('player-panel'),
child: SizedBox(
width: width,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 6, 2),
child: Row(
children: [
const Icon(Icons.tune_rounded, size: 18, color: Colors.white70),
const SizedBox(width: 10),
const Expanded(
child: Text('Player', style: TextStyle(fontWeight: FontWeight.w600, fontSize: 14)),
),
IconButton(
key: const ValueKey('player-panel-close'),
tooltip: 'Close (Esc)',
visualDensity: VisualDensity.compact,
iconSize: 18,
onPressed: onClose,
icon: const Icon(Icons.close_rounded),
),
],
),
),
Flexible(
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 10),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const _Section(title: 'Tracks', children: [
_TrackRow(type: JellyfinStreamType.subtitle),
_TrackRow(type: JellyfinStreamType.audio),
]),
_Section(title: 'Subtitles', children: [
SignalBuilder(
builder: (context) => _SliderRow(
key: const ValueKey('panel-sub-scale'),
label: 'Size',
value: s.subScale.value,
min: 0.5,
max: 2,
divisions: 30,
readout: '${(s.subScale.value * 100).round()} %',
onChanged: s.setSubScale,
),
),
SignalBuilder(
builder: (context) => _SwitchRow(
key: const ValueKey('panel-lift'),
label: 'Above the controls',
help: 'Bottom lines move up while the control bar shows (the chat box always lifts them)',
value: s.liftSubtitles.value,
onChanged: s.setLiftSubtitles,
),
),
]),
_Section(title: 'Picture', children: [
_ShaderRow(renderMode: renderMode),
SignalBuilder(builder: (context) {
final m = renderMode?.value;
final software = m == RenderMode.software || m == RenderMode.softwareGl;
return _SwitchRow(
key: const ValueKey('panel-sharp'),
label: 'Sharp video and subtitles',
help: software ? 'No effect: software rendering' : "At your screen's resolution",
value: s.screenResVideo.value,
onChanged: s.setScreenResVideo,
);
}),
SignalBuilder(
builder: (context) => _Row(
label: 'Hardware decoding',
control: AppSelect<HwdecMode>(
key: const ValueKey('panel-hwdec'),
value: s.hwdecMode.value,
items: const [(HwdecMode.auto, 'Auto'), (HwdecMode.off, 'Off'), (HwdecMode.custom, 'Custom')],
onChanged: (m) {
s.setHwdecMode(m);
applyHwdecLive(graph.coordinator.playback);
},
),
),
),
]),
if (volume case final v?)
_Section(title: 'Sound', children: [
SignalBuilder(
builder: (context) => _SliderRow(
key: const ValueKey('panel-volume'),
label: 'Volume',
value: v.value,
min: 0,
max: vol.maxVolume,
readout: '${v.value.round()} %',
onChanged: onVolume,
),
),
]),
_Section(title: 'Notifications', children: [
_Row(label: 'Show as', control: NotifyStyleSelect(settings: s)),
SignalBuilder(
builder: (context) => AnimatedSize(
duration: easeTime,
curve: ease,
alignment: Alignment.topCenter,
child: s.notifyStyle.value == NotifyStyle.danmaku
? DanmakuKnobs(settings: s, dense: true)
: const SizedBox(width: double.infinity),
),
),
]),
_Section(title: 'Controls', children: [
SignalBuilder(
builder: (context) => _Row(
label: 'Design',
control: AppSelect<ControlsSkin>(
key: const ValueKey('panel-design'),
value: s.controlsSkin.value,
items: [for (final k in ControlsSkin.values) (k, k.label)],
onChanged: s.setControlsSkin,
),
),
),
SignalBuilder(
builder: (context) => _SwitchRow(
key: const ValueKey('panel-progress-line'),
label: 'Progress line when hidden',
value: s.progressLine.value,
onChanged: s.setProgressLine,
),
),
]),
const SizedBox(height: 6),
Row(
children: [
Expanded(
child: Text(
'More in Settings → Player',
style: TextStyle(color: Colors.white.withValues(alpha: 0.5), fontSize: 12),
),
),
const MarkProblemButton(),
],
),
],
),
),
),
],
),
),
);
}
}
/// A titled group that folds (its title is the toggle).
class _Section extends StatefulWidget {
final String title;
final List<Widget> children;
const _Section({required this.title, required this.children});
@override
State<_Section> createState() => _SectionState();
}
class _SectionState extends State<_Section> {
late bool _open = !_folded.contains(widget.title);
void _toggle() {
setState(() => _open = !_open);
_open ? _folded.remove(widget.title) : _folded.add(widget.title);
}
@override
Widget build(BuildContext context) {
final muted = Colors.white.withValues(alpha: 0.55);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
InkWell(
key: ValueKey('panel-section-${widget.title}'),
borderRadius: BorderRadius.circular(AppMenuStyle.rowRadius),
onTap: _toggle,
child: Padding(
padding: const EdgeInsets.only(top: 10, bottom: 4),
child: Row(
children: [
Expanded(
child: Text(widget.title.toUpperCase(),
style: TextStyle(fontSize: 11, letterSpacing: 0.8, fontWeight: FontWeight.w600, color: muted)),
),
AnimatedRotation(
turns: _open ? 0 : -0.25,
duration: easeTime,
curve: ease,
child: Icon(Icons.expand_more_rounded, size: 18, color: muted),
),
],
),
),
),
AnimatedSize(
duration: easeTime,
curve: ease,
alignment: Alignment.topCenter,
child: _open
? Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: widget.children)
: const SizedBox(width: double.infinity),
),
],
);
}
}
class _Row extends StatelessWidget {
final String label;
final Widget control;
const _Row({required this.label, required this.control});
@override
Widget build(BuildContext context) => SizedBox(
height: 40,
child: Row(
children: [
Expanded(child: Text(label, style: const TextStyle(fontSize: 13))),
Flexible(flex: 2, child: Align(alignment: Alignment.centerRight, child: control)),
],
),
);
}
class _SwitchRow extends StatelessWidget {
final String label;
final String? help;
final bool value;
final ValueChanged<bool> onChanged;
const _SwitchRow({super.key, required this.label, this.help, required this.value, required this.onChanged});
@override
Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: const TextStyle(fontSize: 13)),
if (help != null)
Text(help!, style: TextStyle(fontSize: 11.5, color: Colors.white.withValues(alpha: 0.5))),
],
),
),
Transform.scale(
scale: 0.8,
alignment: Alignment.centerRight,
child: Switch(value: value, onChanged: onChanged),
),
],
),
);
}
class _SliderRow extends StatelessWidget {
final String label;
final double value, min, max;
final int? divisions;
final String readout;
final ValueChanged<double>? onChanged;
const _SliderRow({
super.key,
required this.label,
required this.value,
required this.min,
required this.max,
this.divisions,
required this.readout,
required this.onChanged,
});
@override
Widget build(BuildContext context) => SizedBox(
height: 38,
child: Row(
children: [
SizedBox(width: 84, child: Text(label, style: const TextStyle(fontSize: 13))),
Expanded(
child: SliderTheme(
data: SliderTheme.of(context).copyWith(
trackHeight: 3,
overlayShape: SliderComponentShape.noOverlay,
thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 7),
tickMarkShape: SliderTickMarkShape.noTickMark,
),
child: Slider(value: value.clamp(min, max), min: min, max: max, divisions: divisions, onChanged: onChanged),
),
),
SizedBox(
width: 52,
child: Text(readout,
textAlign: TextAlign.right,
style: TextStyle(
fontSize: 12,
color: Colors.white.withValues(alpha: 0.7),
fontFeatures: const [FontFeature.tabularFigures()],
)),
),
],
),
);
}
/// Subtitle or audio track of the running player; remembered for the
/// next episode.
class _TrackRow extends StatelessWidget {
final JellyfinStreamType type;
const _TrackRow({required this.type});
bool get _audio => type == JellyfinStreamType.audio;
@override
Widget build(BuildContext context) {
final coord = AppScope.of(context).coordinator;
return SignalBuilder(builder: (context) {
final list = [for (final t in coord.playback.tracks.value) if (t.type == type) t];
final selected = list.where((t) => t.selected).firstOrNull?.id ?? (_audio ? null : -1);
final items = <(int, String)>[
if (!_audio) (-1, 'Off'),
for (final t in list) (t.id, t.label),
];
final key = ValueKey(_audio ? 'panel-audio' : 'panel-subtitles');
return _Row(
label: _audio ? 'Audio' : 'Subtitles',
control: items.isEmpty || selected == null
? Text(items.isEmpty ? 'None' : 'Pick one…', key: key, style: const TextStyle(fontSize: 13))
: AppSelect<int>(
key: key,
value: selected,
expanded: true,
items: items,
onChanged: (id) {
coord.markInput();
if (id == -1) {
coord.selectTrack(subtitlesOff: true);
} else if (_audio) {
coord.selectTrack(aid: id);
} else {
coord.selectTrack(sid: id);
}
},
),
);
});
}
}
/// The upscaling shader: Anime4K mode (or custom files) and HQ / Fast.
class _ShaderRow extends StatelessWidget {
final ReadonlySignal<RenderMode>? renderMode;
const _ShaderRow({this.renderMode});
@override
Widget build(BuildContext context) {
final coord = AppScope.of(context).coordinator;
final s = coord.settings;
return SignalBuilder(builder: (context) {
final preset = s.shaderPreset.value;
final hasCustom = s.customShaders.value.isNotEmpty;
final software = switch (renderMode?.value) {
RenderMode.software || RenderMode.softwareGl => true,
_ => false,
};
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_Row(
label: 'Upscaling',
control: AppSelect<ShaderPreset>(
key: const ValueKey('panel-shader'),
value: preset,
items: [
for (final p in ShaderPreset.values)
if (p != ShaderPreset.custom || hasCustom || preset == p) (p, p.label),
],
onChanged: (p) => applyShaderChoice(coord, p, null),
),
),
if (preset.isAnime4k)
_Row(
label: 'Shader quality',
control: AppSelect<ShaderQuality>(
key: const ValueKey('panel-shader-quality'),
value: s.shaderQuality.value,
items: [for (final q in ShaderQuality.values) (q, q.label)],
onChanged: (q) => applyShaderChoice(coord, preset, q),
),
),
if (software && preset != ShaderPreset.off)
const Text('Software rendering: shaders have little or no effect',
style: TextStyle(fontSize: 11.5, color: Colors.orange)),
],
);
});
}
}
@@ -2,23 +2,19 @@ import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:signals/signals_flutter.dart'; import 'package:signals/signals_flutter.dart';
import 'package:sync_relay_core/core.dart' show JellyfinStreamType;
import '../jellyfin/track_prefs.dart' show MpvTrack;
import '../playback/playback_controller.dart' show PlaybackController, RenderMode; import '../playback/playback_controller.dart' show PlaybackController, RenderMode;
import '../session/session_coordinator.dart' show SessionCoordinator; import '../session/session_coordinator.dart' show SessionCoordinator;
import '../shaders/shader_presets.dart'; import '../shaders/shader_presets.dart';
import '../app/app_graph.dart'; import '../app/app_graph.dart';
import '../shell/mark_problem.dart';
import '../shell/motion.dart';
import '../state/settings.dart'; import '../state/settings.dart';
import '../widgets/app_menu.dart'; import '../widgets/app_menu.dart';
import 'frame.dart';
import 'model/controls_prefs.dart'; import 'model/controls_prefs.dart';
// Everything about the player, defined once: the rows of the player // Everything about the player, defined once: the rows of Settings →
// settings card (⚙ → Player settings, both skins) and of Settings → Player. Each row reads and // Player (the player panel, `player_panel.dart`, has compact rows for the
// writes [AppSettings] and applies to a running player right away. // same settings). Each row reads and writes [AppSettings] and applies to
// a running player right away.
/// One setting: a label, one line of help under it, the control at the /// One setting: a label, one line of help under it, the control at the
/// right ([below]: an extra line under the row, e.g. a text field). /// right ([below]: an extra line under the row, e.g. a text field).
@@ -199,6 +195,35 @@ class ProgressLineSetting extends StatelessWidget {
} }
} }
/// The video output at the player's size on screen ([AppSettings.screenResVideo]).
class ScreenResSetting extends StatelessWidget {
final ReadonlySignal<RenderMode>? renderMode;
const ScreenResSetting({super.key, this.renderMode});
@override
Widget build(BuildContext context) {
final s = AppScope.of(context).coordinator.settings;
return SignalBuilder(builder: (context) {
final software = switch (renderMode?.value) {
RenderMode.software || RenderMode.softwareGl => true,
_ => false,
};
return SettingRow(
label: 'Sharp video and subtitles',
help: software
? 'No effect: video uses software rendering'
: "Scale the picture and draw subtitles at your screen's resolution, not the video's. "
'Off: lighter on a weak GPU',
control: Switch(
key: const ValueKey('screen-res-video'),
value: s.screenResVideo.value,
onChanged: s.setScreenResVideo,
),
);
});
}
}
/// Saves a shader choice (also used on the next join) and puts it on the /// Saves a shader choice (also used on the next join) and puts it on the
/// running player, if any; the player announces the result. /// running player, if any; the player announces the result.
void applyShaderChoice(SessionCoordinator coord, ShaderPreset preset, ShaderQuality? quality) { void applyShaderChoice(SessionCoordinator coord, ShaderPreset preset, ShaderQuality? quality) {
@@ -333,58 +358,6 @@ class _HwdecSettingState extends State<HwdecSetting> {
} }
} }
/// Audio or subtitle track of the running player (in the player only).
/// The pick is remembered for the next Jellyfin item.
class TrackSetting extends StatelessWidget {
final JellyfinStreamType type;
const TrackSetting({super.key, required this.type});
bool get _audio => type == JellyfinStreamType.audio;
@override
Widget build(BuildContext context) {
final coord = AppScope.of(context).coordinator;
return SignalBuilder(builder: (context) {
final list = [
for (final t in coord.playback.tracks.value)
if (t.type == type) t,
];
// -1: subtitles off.
final selected = list.where((t) => t.selected).firstOrNull?.id ?? (_audio ? null : -1);
final items = <(int, String)>[
if (!_audio) (-1, 'Off'),
for (final MpvTrack t in list) (t.id, t.label),
];
return SettingRow(
label: _audio ? 'Audio' : 'Subtitles',
help: 'Remembered for the next episode',
control: SizedBox(
width: 190,
child: items.isEmpty || selected == null
? Text(items.isEmpty ? 'None' : 'Pick one…',
key: ValueKey(_audio ? 'audio-track' : 'subtitle-track'), textAlign: TextAlign.end)
: _dropdown<int>(
key: ValueKey(_audio ? 'audio-track' : 'subtitle-track'),
value: selected,
expanded: true,
items: items,
onChanged: (id) {
coord.markInput();
if (id == -1) {
coord.selectTrack(subtitlesOff: true);
} else if (_audio) {
coord.selectTrack(aid: id);
} else {
coord.selectTrack(sid: id);
}
},
),
),
);
});
}
}
/// The controls' own choices (Settings → Player → Controls). /// The controls' own choices (Settings → Player → Controls).
List<Widget> controlsSettingRows() => [ List<Widget> controlsSettingRows() => [
const TimeDisplaySetting(), const TimeDisplaySetting(),
@@ -397,57 +370,38 @@ List<Widget> controlsSettingRows() => [
for (final c in OptionalControl.values) BarButtonSetting(control: c), for (final c in OptionalControl.values) BarButtonSetting(control: c),
]; ];
/// The player rows of Settings → Player and the player settings card, in /// The player rows of Settings → Player, in order (the player panel has
/// order. /// its own compact rows for the same settings).
List<Widget> playerSettingRows({ReadonlySignal<RenderMode>? renderMode}) => [ List<Widget> playerSettingRows({ReadonlySignal<RenderMode>? renderMode}) => [
const PlayerDesignSetting(), const PlayerDesignSetting(),
const ProgressLineSetting(), const ProgressLineSetting(),
ShaderPresetSetting(renderMode: renderMode), ShaderPresetSetting(renderMode: renderMode),
ScreenResSetting(renderMode: renderMode),
const HwdecSetting(), const HwdecSetting(),
const SubScaleSetting(),
const LiftSubtitlesSetting(),
]; ];
/// The player settings card: every player setting, plus the running /// Subtitle size (mpv `sub-scale`, [AppSettings.subScale]). Live.
/// player's tracks. class SubScaleSetting extends StatelessWidget {
class PlayerSettingsCard extends StatelessWidget { const SubScaleSetting({super.key});
final ReadonlySignal<RenderMode>? renderMode;
/// Show the audio / subtitle pickers (a player is running).
final bool tracks;
const PlayerSettingsCard({super.key, this.renderMode, this.tracks = true});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
// The app's glass, like the Up next popover and the menus. final s = AppScope.of(context).coordinator.settings;
return AppGlass( return SignalBuilder(
key: const ValueKey('player-settings-card'), builder: (context) => SettingRow(
child: SizedBox( label: 'Subtitle size',
width: 420, help: '${(s.subScale.value * 100).round()} % of what the file sets',
child: Padding( control: SizedBox(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 10), width: 220,
child: Column( child: Slider(
mainAxisSize: MainAxisSize.min, key: const ValueKey('sub-scale'),
crossAxisAlignment: CrossAxisAlignment.stretch, value: s.subScale.value,
children: [ min: 0.5,
const Text('Player', style: TextStyle(fontWeight: FontWeight.w600, fontSize: 14)), max: 2,
const SizedBox(height: 4), divisions: 30,
...playerSettingRows(renderMode: renderMode), onChanged: s.setSubScale,
if (tracks) ...[
const Divider(),
const TrackSetting(type: JellyfinStreamType.subtitle),
const TrackSetting(type: JellyfinStreamType.audio),
],
const SizedBox(height: 4),
Row(
children: [
Expanded(
child: Text(
'Controls layout and custom shaders: Settings → Player',
style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant, fontSize: 12),
),
),
const MarkProblemButton(),
],
),
],
), ),
), ),
), ),
@@ -455,14 +409,25 @@ class PlayerSettingsCard extends StatelessWidget {
} }
} }
/// Opens the player settings card over the player, near the controls' /// Bottom subtitles above the control bar while it is up
/// right end; the controls stay up while it is open. /// ([AppSettings.liftSubtitles]). Live.
Future<void> showPlayerSettings(BuildContext context, {ReadonlySignal<RenderMode>? renderMode}) => class LiftSubtitlesSetting extends StatelessWidget {
ControlsFrame.holdWhile( const LiftSubtitlesSetting({super.key});
context,
() => showPopover<void>( @override
context, Widget build(BuildContext context) {
at: const Alignment(0.96, 0.5), final s = AppScope.of(context).coordinator.settings;
child: PlayerSettingsCard(renderMode: renderMode), return SignalBuilder(
builder: (context) => SettingRow(
label: 'Subtitles above the controls',
help: 'Bottom lines move up while the control bar shows; signs at the top stay. '
'The chat box always moves them',
control: Switch(
key: const ValueKey('lift-subtitles'),
value: s.liftSubtitles.value,
onChanged: s.setLiftSubtitles,
),
), ),
); );
}
}
@@ -1,3 +1,4 @@
import 'package:flutter/foundation.dart' show ValueListenable;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:signals/signals_flutter.dart'; import 'package:signals/signals_flutter.dart';
@@ -34,6 +35,9 @@ class SessionControls extends StatelessWidget {
final ControlsHold hold; final ControlsHold hold;
final VoidCallback onDoubleClick; final VoidCallback onDoubleClick;
final Widget? windowButtons; final Widget? windowButtons;
/// Hides them while something takes their place ([ControlsFrame.suppress]).
final ValueListenable<bool>? suppress;
const SessionControls({ const SessionControls({
super.key, super.key,
required this.model, required this.model,
@@ -41,6 +45,7 @@ class SessionControls extends StatelessWidget {
required this.hold, required this.hold,
required this.onDoubleClick, required this.onDoubleClick,
this.windowButtons, this.windowButtons,
this.suppress,
}); });
@override @override
@@ -56,6 +61,7 @@ class SessionControls extends StatelessWidget {
key: ValueKey(skin), key: ValueKey(skin),
shown: shown, shown: shown,
holdBy: hold, holdBy: hold,
suppress: suppress,
hideAfter: Duration(seconds: s.controlsHideSecs.value), hideAfter: Duration(seconds: s.controlsHideSecs.value),
onDoubleClick: onDoubleClick, onDoubleClick: onDoubleClick,
barTop: controlsTopHeight(skin), barTop: controlsTopHeight(skin),
@@ -165,7 +165,7 @@ class StandardControls extends StatelessWidget {
return Row( return Row(
children: [ children: [
const Expanded(child: NowPlayingTitle()), const Expanded(child: NowPlayingTitle()),
UpNextButton(queue: m.queue, onPlayNextEpisode: m.playNextEpisode, look: UpNextLook.standard, hold: hold), UpNextButton(queue: m.queue, look: UpNextLook.standard, hold: hold),
SignalBuilder( SignalBuilder(
builder: (context) { builder: (context) {
final full = m.fullscreen.value; final full = m.fullscreen.value;
+582
View File
@@ -0,0 +1,582 @@
import 'dart:collection';
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import '../shell/avatar.dart' show AvatarInfo, StatusAvatar;
// Danmaku (Bilibili / niconico style): short lines that fly right to left
// across the upper part of the picture. Each line takes a free lane (no
// two ever touch), at most [DanmakuStyle.maxOnScreen] fly at once and the
// rest wait their turn; every line crosses in the same time, so a long
// one moves faster. The clock is the layer's own: lines keep flying while
// the video is paused. The session's notifications use it when the user
// picks "Notifications as danmaku" (`danmaku_notices.dart`); the dev
// preview tries it out (DEVELOPMENT.md "Dev tools").
/// What a line is: a chat message or a short notice.
enum DanmakuKind { chat, join, leave, upNext, starting }
/// One line: [sender] (a coloured prefix, [senderColor]) and [text];
/// [own] lines (this member's) get a hairline frame. [avatar]: the
/// sender's picture (or initial) before it; the app's own notices have
/// none.
@immutable
class DanmakuItem {
final DanmakuKind kind;
final String? sender;
final Color? senderColor;
final String text;
final bool own;
final AvatarInfo? avatar;
const DanmakuItem(
{required this.kind, required this.text, this.sender, this.senderColor, this.own = false, this.avatar});
}
/// How lines look and move. Persisted as [toJson] (Settings →
/// Notifications & sounds); [DanmakuStyle.fromJson] clamps every value to
/// its range ([limits]), so a hand-edited or older box never breaks it.
@immutable
class DanmakuStyle {
/// Seconds a line takes from the right edge until it has left on the
/// left, whatever its length.
final double crossSecs;
/// Lines on screen at once; more wait.
final int maxOnScreen;
/// The band the lanes fill, as fractions of the height from the top:
/// below where top signs sit, never down into the dialogue.
final double bandTop;
final double bandBottom;
final double opacity;
/// Text size relative to the picture's height ([fontSizeFor]).
final double size;
/// Font weight of the text, 400 (regular) to 900 (black); the sender's
/// name is a step bolder.
final int weight;
/// Width of the dark outline round the white text, logical px.
final double outline;
final bool highlightOwn;
/// The sender's avatar before the line.
final bool avatars;
const DanmakuStyle({
this.crossSecs = 8,
this.maxOnScreen = 12,
this.bandTop = 0.10,
this.bandBottom = 0.45,
this.opacity = 0.92,
this.size = 1,
this.weight = 600,
this.outline = 1.4,
this.highlightOwn = true,
this.avatars = true,
});
/// The range of each knob (the settings' sliders use them too).
static const limits = (
crossSecs: (4.0, 16.0),
maxOnScreen: (1, 40),
band: (0.0, 0.9),
minBand: 0.05,
opacity: (0.3, 1.0),
size: (0.6, 1.8),
weight: (400, 900),
outline: (0.0, 4.0),
);
DanmakuStyle copyWith({
double? crossSecs,
int? maxOnScreen,
double? bandTop,
double? bandBottom,
double? opacity,
double? size,
int? weight,
double? outline,
bool? highlightOwn,
bool? avatars,
}) =>
DanmakuStyle(
crossSecs: crossSecs ?? this.crossSecs,
maxOnScreen: maxOnScreen ?? this.maxOnScreen,
bandTop: bandTop ?? this.bandTop,
bandBottom: bandBottom ?? this.bandBottom,
opacity: opacity ?? this.opacity,
size: size ?? this.size,
weight: weight ?? this.weight,
outline: outline ?? this.outline,
highlightOwn: highlightOwn ?? this.highlightOwn,
avatars: avatars ?? this.avatars,
);
Map<String, Object> toJson() => {
'crossSecs': crossSecs,
'maxOnScreen': maxOnScreen,
'bandTop': bandTop,
'bandBottom': bandBottom,
'opacity': opacity,
'size': size,
'weight': weight,
'outline': outline,
'highlightOwn': highlightOwn,
'avatars': avatars,
};
/// [json] as stored ([toJson]); missing or wrong values take the
/// default, out-of-range ones the nearest end of their range.
factory DanmakuStyle.fromJson(Map<Object?, Object?> json) {
const d = DanmakuStyle();
const l = limits;
double num_(String k, double def, (double, double) r) {
final v = json[k];
return v is num && v.isFinite ? v.toDouble().clamp(r.$1, r.$2) : def;
}
int int_(String k, int def, (int, int) r) {
final v = json[k];
return v is num && v.isFinite ? v.round().clamp(r.$1, r.$2) : def;
}
bool bool_(String k, bool def) {
final v = json[k];
return v is bool ? v : def;
}
var top = num_('bandTop', d.bandTop, l.band);
var bottom = num_('bandBottom', d.bandBottom, l.band);
if (bottom - top < l.minBand) (top, bottom) = (d.bandTop, d.bandBottom);
return DanmakuStyle(
crossSecs: num_('crossSecs', d.crossSecs, l.crossSecs),
maxOnScreen: int_('maxOnScreen', d.maxOnScreen, l.maxOnScreen),
bandTop: top,
bandBottom: bottom,
opacity: num_('opacity', d.opacity, l.opacity),
size: num_('size', d.size, l.size),
weight: (int_('weight', d.weight, l.weight) / 100).round() * 100,
outline: num_('outline', d.outline, l.outline),
highlightOwn: bool_('highlightOwn', d.highlightOwn),
avatars: bool_('avatars', d.avatars),
);
}
/// [weight] as a [FontWeight] (400..900 in steps of 100).
FontWeight get fontWeight => FontWeight.values[((weight.clamp(100, 900) / 100).round() - 1)];
/// The sender's name: a step bolder than the text.
FontWeight get nameWeight =>
FontWeight.values[math.min(FontWeight.values.length - 1, (weight.clamp(100, 900) / 100).round())];
/// The text size in a picture [height] px tall: 4.2 % of it (about 30 px
/// on a 720 px player), 13 to 44 px, times [size].
double fontSizeFor(double height) => (height * 0.042).clamp(13.0, 44.0) * size;
/// A lane: the line's height plus a little air.
double laneHeightFor(double height) => fontSizeFor(height) * 1.5;
/// Lanes in the band of a picture [height] px tall (at least one).
int lanesFor(double height) {
final band = (bandBottom - bandTop).clamp(0.0, 1.0) * height;
return math.max(1, (band / laneHeightFor(height)).floor());
}
@override
bool operator ==(Object other) =>
other is DanmakuStyle &&
other.crossSecs == crossSecs &&
other.maxOnScreen == maxOnScreen &&
other.bandTop == bandTop &&
other.bandBottom == bandBottom &&
other.opacity == opacity &&
other.size == size &&
other.weight == weight &&
other.outline == outline &&
other.highlightOwn == highlightOwn &&
other.avatars == avatars;
@override
int get hashCode =>
Object.hash(crossSecs, maxOnScreen, bandTop, bandBottom, opacity, size, weight, outline, highlightOwn, avatars);
}
/// One line in flight in [lane], from [start] (seconds on the layer's
/// clock), [width] px wide, crossing in [crossSecs].
class DanmakuFlight<T> {
final T item;
final int lane;
final double start;
final double width;
final double crossSecs;
DanmakuFlight({required this.item, required this.lane, required this.start, required this.width, required this.crossSecs});
/// Its left edge at [t] in a picture [w] px wide: [w] at the start,
/// -[width] at the end.
double x(double t, double w) => w - (w + width) * ((t - start) / crossSecs);
double end() => start + crossSecs;
bool doneAt(double t) => t >= end();
}
/// Lane assignment, pure (no widgets, a clock passed in): lines queue in
/// order of arrival; [tick] lets the waiting ones in while a lane is free
/// and fewer than the cap fly. A lane is free for a new line when the last
/// line in it has fully entered with [gap] behind it and the new one, at
/// its own speed, cannot catch up with it before it has left.
class DanmakuLanes<T> {
/// Space kept between two lines in a lane, px.
final double gap;
/// A line waiting longer than this is dropped (a burst on a crowded
/// picture would otherwise fly long after it was said).
final double maxWaitSecs;
/// Lines kept waiting at most; older ones go first when it is full.
final int maxWaiting;
DanmakuLanes({this.gap = 24, this.maxWaitSecs = 15, this.maxWaiting = 60});
final List<DanmakuFlight<T>> flights = [];
final Queue<(T, double)> _waiting = Queue();
int dropped = 0;
int get waiting => _waiting.length;
/// Everything flying and waiting goes; returns it.
List<T> clear() {
final all = [for (final f in flights) f.item, for (final w in _waiting) w.$1];
flights.clear();
_waiting.clear();
return all;
}
/// [item] arrived at [t]; it flies on the next [tick] that has room.
void add(T item, double t) {
_waiting.add((item, t));
while (_waiting.length > maxWaiting) {
_waiting.removeFirst();
dropped++;
}
}
/// Advances to [t] in a picture [w] px wide with [lanes] lanes: drops
/// what has left and what waited too long, then places waiting lines in
/// order (the topmost free lane each) until the cap or no lane is free.
/// [measure] gives a line's width. Returns the lines placed.
List<DanmakuFlight<T>> tick(double t,
{required double w, required int lanes, required int cap, required double crossSecs, required double Function(T) measure}) {
flights.removeWhere((f) => f.doneAt(t));
while (_waiting.isNotEmpty && t - _waiting.first.$2 > maxWaitSecs) {
_waiting.removeFirst();
dropped++;
}
final placed = <DanmakuFlight<T>>[];
while (_waiting.isNotEmpty && flights.length < cap) {
final item = _waiting.first.$1;
final width = measure(item);
final lane = freeLane(t, w: w, lanes: lanes, width: width, crossSecs: crossSecs);
if (lane == null) break;
_waiting.removeFirst();
final f = DanmakuFlight(item: item, lane: lane, start: t, width: width, crossSecs: crossSecs);
flights.add(f);
placed.add(f);
}
return placed;
}
/// The topmost lane a line [width] px wide can start in at [t], or null.
int? freeLane(double t, {required double w, required int lanes, required double width, required double crossSecs}) {
final last = <int, DanmakuFlight<T>>{};
for (final f in flights) {
final l = last[f.lane];
if (l == null || f.start > l.start) last[f.lane] = f;
}
for (var lane = 0; lane < lanes; lane++) {
final prev = last[lane];
if (prev == null || fits(prev, t, w: w, width: width, crossSecs: crossSecs, gap: gap)) return lane;
}
return null;
}
/// A new line ([width], [crossSecs]) may follow [prev] in its lane at [t].
/// Both move linearly, so checking now and the moment [prev] has left is
/// enough.
static bool fits(DanmakuFlight<Object?> prev, double t,
{required double w, required double width, required double crossSecs, required double gap}) {
// Fully in, with the gap behind it.
if (prev.x(t, w) + prev.width + gap > w) return false;
final leaves = prev.end();
if (leaves <= t) return true;
// Where the new line's head is when prev's tail reaches the left edge.
final head = w - (w + width) * ((leaves - t) / crossSecs);
return head >= gap;
}
}
/// Feeds a [DanmakuLayer]; [onScreen] / [waiting] follow what the layer
/// does with the lines (for a status readout).
class DanmakuController extends ChangeNotifier {
final _inbox = <DanmakuItem>[];
final onScreen = ValueNotifier(0);
final waiting = ValueNotifier(0);
final dropped = ValueNotifier(0);
void add(DanmakuItem item) {
_inbox.add(item);
notifyListeners();
}
/// Everything in flight and waiting goes.
void clear() {
_clear = true;
_inbox.clear();
notifyListeners();
}
bool _clear = false;
List<DanmakuItem> _take() {
final items = List.of(_inbox);
_inbox.clear();
return items;
}
@override
void dispose() {
onScreen.dispose();
waiting.dispose();
dropped.dispose();
super.dispose();
}
}
/// The lines over a picture: fills its box, lets every click through.
/// Each line is a widget (avatar, outlined text, own frame) moved by a
/// [Flow] on every frame: only paint runs per frame, a build only when a
/// line comes or goes.
class DanmakuLayer extends StatefulWidget {
final DanmakuController controller;
final DanmakuStyle style;
/// The accent of [DanmakuItem.own] lines' frame and of "starting now".
final Color accent;
const DanmakuLayer({super.key, required this.controller, this.style = const DanmakuStyle(), required this.accent});
@override
State<DanmakuLayer> createState() => _DanmakuLayerState();
}
/// A line measured once (its width decides its speed and its lane) and
/// its widget, built once.
class _Line {
final DanmakuItem item;
final double width;
final double height;
final Widget widget;
_Line(this.item, this.width, this.height, this.widget);
}
class _DanmakuLayerState extends State<DanmakuLayer> with SingleTickerProviderStateMixin {
late final Ticker _ticker = createTicker(_onTick);
final _lanes = DanmakuLanes<_Line>();
final _repaint = ValueNotifier(0);
// The layer's clock: [_base] from earlier runs of the ticker, which
// only runs while lines fly or wait (an idle layer asks for no frames).
Duration _base = Duration.zero;
Duration _elapsed = Duration.zero;
Size _size = Size.zero;
var _pending = <DanmakuItem>[];
var _shown = <DanmakuFlight<_Line>>[];
double get _t => (_base + _elapsed).inMicroseconds / 1e6;
@override
void initState() {
super.initState();
widget.controller.addListener(_onAdd);
_onAdd();
}
@override
void didUpdateWidget(DanmakuLayer old) {
super.didUpdateWidget(old);
if (old.controller != widget.controller) {
old.controller.removeListener(_onAdd);
widget.controller.addListener(_onAdd);
}
}
@override
void dispose() {
widget.controller.removeListener(_onAdd);
_ticker.dispose();
_repaint.dispose();
super.dispose();
}
void _onAdd() {
final c = widget.controller;
if (c._clear) {
c._clear = false;
_lanes.clear();
_pending = [];
}
_pending.addAll(c._take());
if (!_ticker.isActive) _ticker.start();
}
_Line _build(DanmakuItem item) {
final s = widget.style;
final size = s.fontSizeFor(_size.height);
final base = TextStyle(
fontSize: size,
height: 1.2,
fontWeight: s.fontWeight,
color: Colors.white,
letterSpacing: 0.2,
shadows: const [],
);
InlineSpan span({Paint? stroke}) {
final name = item.sender;
final accent = item.kind == DanmakuKind.starting && stroke == null ? widget.accent : null;
return TextSpan(style: stroke == null ? base : base.copyWith(foreground: stroke, color: null), children: [
if (name != null)
TextSpan(
text: item.kind == DanmakuKind.chat ? '$name ' : '$name ',
style: TextStyle(
fontWeight: s.nameWeight,
color: stroke == null ? (item.senderColor ?? Colors.white) : null,
),
),
TextSpan(text: item.text, style: accent == null ? null : TextStyle(color: accent)),
]);
}
final measure = TextPainter(text: span(), textDirection: TextDirection.ltr, maxLines: 1)..layout();
final textW = measure.width, textH = measure.height;
measure.dispose();
final avatar = s.avatars ? item.avatar : null;
final avatarSize = size * 1.15;
final avatarGap = size * 0.3;
final frame = item.own && s.highlightOwn;
final padX = frame ? size * 0.4 : 0.0;
final width = textW + padX * 2 + (avatar == null ? 0 : avatarSize + avatarGap);
Widget text = Text.rich(span(), maxLines: 1, softWrap: false);
if (s.outline > 0) {
final stroke = Paint()
..style = PaintingStyle.stroke
..strokeWidth = s.outline * 2
..strokeJoin = StrokeJoin.round
..color = const Color(0xE6000000);
text = Stack(children: [Text.rich(span(stroke: stroke), maxLines: 1, softWrap: false), text]);
}
Widget line = Row(
mainAxisSize: MainAxisSize.min,
children: [
if (avatar != null) ...[
StatusAvatar(info: avatar, size: avatarSize, badges: false, ring: false),
SizedBox(width: avatarGap),
],
text,
],
);
if (frame) {
line = DecoratedBox(
decoration: BoxDecoration(
color: widget.accent.withValues(alpha: 0.14),
borderRadius: BorderRadius.circular(999),
border: Border.all(color: widget.accent.withValues(alpha: 0.85), width: 1.2),
),
child: Padding(padding: EdgeInsets.symmetric(horizontal: padX), child: line),
);
}
final height = math.max(textH, avatar == null ? 0.0 : avatarSize);
return _Line(item, width, height, SizedBox(width: width, height: height, child: Center(child: line)));
}
void _onTick(Duration elapsed) {
_elapsed = elapsed;
if (_size.isEmpty) return;
final t = _t;
for (final item in _pending) {
_lanes.add(_build(item), t);
}
_pending = [];
final s = widget.style;
_lanes.tick(t,
w: _size.width,
lanes: s.lanesFor(_size.height),
cap: s.maxOnScreen,
crossSecs: s.crossSecs,
measure: (l) => l.width);
final c = widget.controller;
c.onScreen.value = _lanes.flights.length;
c.waiting.value = _lanes.waiting;
c.dropped.value = _lanes.dropped;
final now = List.of(_lanes.flights);
if (now.length != _shown.length || !_sameFlights(now, _shown)) {
setState(() => _shown = now);
} else if (now.isNotEmpty) {
_repaint.value++;
}
if (now.isEmpty && _lanes.waiting == 0 && _pending.isEmpty) {
_base += _elapsed;
_elapsed = Duration.zero;
_ticker.stop();
}
}
static bool _sameFlights(List<DanmakuFlight<_Line>> a, List<DanmakuFlight<_Line>> b) {
for (var i = 0; i < a.length; i++) {
if (!identical(a[i], b[i])) return false;
}
return true;
}
@override
Widget build(BuildContext context) {
return IgnorePointer(
child: LayoutBuilder(builder: (context, box) {
_size = box.biggest;
return Opacity(
opacity: widget.style.opacity.clamp(0.0, 1.0),
child: Flow(
delegate: _DanmakuFlow(this, _repaint),
children: [for (final f in _shown) KeyedSubtree(key: ObjectKey(f), child: f.item.widget)],
),
);
}),
);
}
}
class _DanmakuFlow extends FlowDelegate {
final _DanmakuLayerState state;
_DanmakuFlow(this.state, Listenable repaint) : super(repaint: repaint);
@override
BoxConstraints getConstraintsForChild(int i, BoxConstraints constraints) => const BoxConstraints();
@override
void paintChildren(FlowPaintingContext context) {
final s = state.widget.style;
final size = context.size;
final t = state._t;
final top = s.bandTop * size.height;
final lane = s.laneHeightFor(size.height);
final flights = state._shown;
for (var i = 0; i < flights.length && i < context.childCount; i++) {
final f = flights[i];
final x = f.x(t, size.width);
final y = top + f.lane * lane + (lane - f.item.height) / 2;
context.paintChild(i, transform: Matrix4.translationValues(x, y, 0));
}
}
@override
bool shouldRepaint(_DanmakuFlow old) => true;
}
@@ -0,0 +1,111 @@
import 'package:flutter/material.dart';
import 'package:signals/signals_flutter.dart';
import '../session/notifier.dart' show NoticeCategory, ToastItem;
import '../shell/avatar.dart' show AvatarInfo, colorForName;
import 'danmaku.dart';
// "Notifications: Danmaku" (Settings → Notifications & sounds): over the
// full player, chat and short notices fly across the picture instead of
// popping up as toasts. Problems, errors and the app's own notices stay
// toasts, and so does everything while the player is small or closed.
/// Whether [t] flies as a line (when danmaku is the style in use): chat,
/// joins / leaves / lost connection / back, "starting now", and Up next
/// news someone caused (added, changed the video). Nudges, problems, your
/// own state and the app's notices stay toasts.
bool fliesAsDanmaku(ToastItem t) => switch (t.category) {
NoticeCategory.chat || NoticeCategory.people || NoticeCategory.start => true,
NoticeCategory.upNext => t.actor != null && t.actor!.isNotEmpty,
_ => false,
};
/// [t] as a line: the sender (their avatar from [avatar], their colour)
/// before what they said or did; "starting now" without one. Null for
/// what does not fly ([fliesAsDanmaku]).
DanmakuItem? danmakuForToast(ToastItem t, {AvatarInfo Function(String name, String id)? avatar}) {
if (!fliesAsDanmaku(t)) return null;
final who = t.actor;
if (who == null || who.isEmpty) {
return DanmakuItem(kind: DanmakuKind.starting, text: '▶ ${t.text}');
}
final pic = avatar?.call(who, t.actorId) ?? AvatarInfo(name: who, color: colorForName(who));
final color = pic.color;
if (t.chat && t.text.startsWith('$who: ')) {
return DanmakuItem(
kind: DanmakuKind.chat,
sender: who,
senderColor: color,
text: t.text.substring(who.length + 2),
own: t.own,
avatar: pic);
}
final rest = t.text.startsWith('$who ') ? t.text.substring(who.length + 1) : t.text;
final kind = switch (t.category) {
NoticeCategory.upNext => DanmakuKind.upNext,
_ when rest.startsWith('left') || rest.startsWith('lost') => DanmakuKind.leave,
_ => DanmakuKind.join,
};
return DanmakuItem(kind: kind, sender: who, senderColor: color, text: rest, avatar: pic);
}
/// The session's lines over the picture: fresh toasts that fly
/// ([fliesAsDanmaku], and not [skip]) go to [controller] while this is
/// mounted (the player mounts it only while danmaku is the style and the
/// full player is up); what was there before it mounted is old news.
class DanmakuNotices extends StatefulWidget {
final ReadonlySignal<List<ToastItem>> toasts;
final ReadonlySignal<DanmakuStyle> style;
final DanmakuController controller;
final AvatarInfo Function(String name, String id)? avatar;
/// Toasts shown another way already (your own quick chat echo).
final bool Function(ToastItem t)? skip;
const DanmakuNotices({
super.key,
required this.toasts,
required this.style,
required this.controller,
this.avatar,
this.skip,
});
@override
State<DanmakuNotices> createState() => _DanmakuNoticesState();
}
class _DanmakuNoticesState extends State<DanmakuNotices> {
late Set<int> _seen = {for (final t in widget.toasts.peek()) t.id};
late final void Function() _stop;
@override
void initState() {
super.initState();
_stop = effect(() {
final items = widget.toasts.value;
untracked(() {
for (final t in items) {
if (_seen.contains(t.id) || (widget.skip?.call(t) ?? false)) continue;
final line = danmakuForToast(t, avatar: widget.avatar);
if (line != null) widget.controller.add(line);
}
_seen = {for (final t in items) t.id};
});
});
}
@override
void dispose() {
_stop();
super.dispose();
}
@override
Widget build(BuildContext context) => SignalBuilder(
builder: (context) => DanmakuLayer(
controller: widget.controller,
style: widget.style.value,
accent: Theme.of(context).colorScheme.primary,
),
);
}
@@ -0,0 +1,851 @@
import 'dart:async';
import 'dart:math' as math;
import 'package:flutter/foundation.dart' show ValueListenable;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:signals/signals_flutter.dart';
import '../controls/minimal/style.dart';
import '../controls/session_controls.dart' show controlsBottomHeight;
import '../session/notifier.dart' show ToastItem;
import '../session/session_coordinator.dart' show SessionCoordinator;
import '../shell/avatar.dart' show AvatarInfo, avatarForName, colorForName, memberAvatarInfo, selfAvatarInfo;
import '../shell/motion.dart';
import '../shell/quick_chat.dart';
import '../shell/video_view.dart' show SubtitleLift;
import '../app/app_graph.dart' show AppScope;
import '../widgets/app_menu.dart' show AppGlass, AppMenuStyle;
import 'danmaku.dart';
import 'danmaku_notices.dart' show danmakuForToast;
// Dev builds only (Settings → About → Developer): try the danmaku look on
// the real player (when something is loaded) or on a stand-in picture,
// fed with samples, a random stream or this session's own toasts, with
// every knob of [DanmakuStyle] live and mock subtitles to judge overlap.
/// The preview's state, owned by the shell while the app runs.
class DanmakuPreview {
final controller = DanmakuController();
/// Shown over the full session player.
final active = signal(false);
final Signal<DanmakuStyle> style;
/// A random stream: lines per minute, lengths from one word to a long
/// sentence, CJK and emoji among them.
final auto = signal(false);
final autoRate = signal(40.0);
/// Mock subtitles: a top sign and bottom dialogue.
final mockTop = signal(false);
final mockBottom = signal(false);
/// The stand-in picture's control band (the real player has its own).
final controlsBand = signal(true);
/// This session's chat and notice toasts fly too (session only).
final live = signal(false);
/// In a session: the party's real avatars for the samples and the
/// session's toasts (set while the session panel is up).
SessionCoordinator? coord;
final _rng = math.Random();
Timer? _autoTimer;
late final void Function() _stopAuto;
/// Starts from [style] (the user's look, Settings → Notifications &
/// sounds); the preview's knobs change only the preview.
DanmakuPreview({DanmakuStyle style = const DanmakuStyle()}) : style = signal(style) {
_stopAuto = effect(() {
final on = auto.value;
autoRate.value;
untracked(() {
_autoTimer?.cancel();
if (on) _scheduleAuto();
});
});
}
void dispose() {
_stopAuto();
_autoTimer?.cancel();
controller.dispose();
}
void _scheduleAuto() {
// Poisson arrivals: bursts and lulls like a real chat.
final perSec = autoRate.peek() / 60;
final wait = -math.log(1 - _rng.nextDouble()) / perSec;
_autoTimer = Timer(Duration(milliseconds: (wait * 1000).round().clamp(30, 60000)), () {
if (!auto.peek()) return;
controller.add(randomSample());
_scheduleAuto();
});
}
/// One sample line of [kind]; [own]: as if this member wrote it.
/// Senders come from the party in a session (their pictures), else
/// from [sampleNames] (initials).
DanmakuItem sample(DanmakuKind kind, {bool own = false}) {
final a = own ? selfAvatarInfo() : _sampleSender();
final who = a.name;
final color = own ? a.color : colorForName(who);
return switch (kind) {
DanmakuKind.chat =>
DanmakuItem(kind: kind, sender: who, senderColor: color, text: _pick(sampleChat), own: own, avatar: a),
DanmakuKind.join => DanmakuItem(kind: kind, sender: who, senderColor: color, text: 'joined', avatar: a),
DanmakuKind.leave => DanmakuItem(kind: kind, sender: who, senderColor: color, text: 'left', avatar: a),
DanmakuKind.upNext => DanmakuItem(
kind: kind, sender: who, senderColor: color, text: 'added ${_pick(sampleTitles)} to Up next', avatar: a),
DanmakuKind.starting => const DanmakuItem(kind: DanmakuKind.starting, text: '▶ Starting now'),
};
}
/// [text] as this member's own chat line.
DanmakuItem own(String text) {
final a = selfAvatarInfo();
return DanmakuItem(kind: DanmakuKind.chat, sender: a.name, senderColor: a.color, text: text, own: true, avatar: a);
}
AvatarInfo _sampleSender() {
final c = coord;
final r = c?.serverState.peek();
final others = [
if (c != null && r != null)
for (final m in r.members)
if (m.id != c.connection.memberId) memberAvatarInfo(m, r, c),
];
// The party's real ones half the time, so initials show up too.
if (others.isNotEmpty && _rng.nextBool()) return _pick(others);
final name = _pick(sampleNames);
return AvatarInfo(name: name, color: colorForName(name));
}
/// What the random stream sends: mostly chat, some of it yours, now and
/// then a notice.
DanmakuItem randomSample() {
final r = _rng.nextDouble();
if (r < 0.70) return sample(DanmakuKind.chat);
if (r < 0.80) return sample(DanmakuKind.chat, own: true);
if (r < 0.88) return sample(DanmakuKind.join);
if (r < 0.94) return sample(DanmakuKind.leave);
return sample(DanmakuKind.upNext);
}
/// A toast of this session as a line, as "Notifications: Danmaku" maps
/// it ([danmakuForToast]); the rest (problems) stay toasts.
DanmakuItem? fromToast(ToastItem t) {
final c = coord;
return danmakuForToast(t, avatar: c == null ? null : (name, id) => avatarForName(name, c, id));
}
/// Follows [toasts] while [live] is on; returns the stop.
void Function() watchToasts(ReadonlySignal<List<ToastItem>> toasts) {
var seen = {for (final t in toasts.peek()) t.id};
return effect(() {
final items = toasts.value;
final on = live.value;
untracked(() {
for (final t in items) {
if (!seen.add(t.id) || !on) continue;
final line = fromToast(t);
if (line != null) controller.add(line);
}
seen = {for (final t in items) t.id};
});
});
}
T _pick<T>(List<T> from) => from[_rng.nextInt(from.length)];
static const sampleNames = ['Alice', 'Bob', 'Chiyo', '小雪', 'Kenji', 'Mara'];
static const sampleTitles = ['Frieren · 12', 'Bocchi the Rock! · 5', 'Dungeon Meshi · 20', 'Mob Psycho 100 · 3'];
static const sampleChat = [
'lol',
'www',
'草',
'8888888',
'NICE',
'it\'s so over',
'she\'s back!!',
'wait, who is that?',
'op skip pls 🙏',
'the animation in this scene is insane',
'I can\'t believe they actually adapted this arc, the manga panel looked exactly like this, chills honestly',
'brb getting snacks, don\'t wait for me (actually do wait)',
'前方高能预警!!!',
'このシーン最高すぎる',
'神回確定',
'画质好清晰啊',
'🔥🔥🔥',
'😭😭 my heart',
'🎉 finally',
'Himmel would have done the same 🥲',
];
}
/// The danmaku with this preview's style and mock subtitles over a
/// picture; lets clicks through.
class DanmakuPreviewLayers extends StatelessWidget {
final DanmakuPreview preview;
/// The covered bottom the mock dialogue moves above, as the real
/// subtitles do ([SubtitleLift]).
final ValueListenable<double>? lift;
const DanmakuPreviewLayers({super.key, required this.preview, this.lift});
@override
Widget build(BuildContext context) {
final accent = Theme.of(context).colorScheme.primary;
return SignalBuilder(
builder: (context) => Stack(
fit: StackFit.expand,
children: [
MockSubtitles(top: preview.mockTop.value, bottom: preview.mockBottom.value, lift: lift),
DanmakuLayer(controller: preview.controller, style: preview.style.value, accent: accent),
],
),
);
}
}
/// Stand-ins for subtitles where real ones sit: an ASS sign at the top
/// (`\an8`, yellow serif) and two lines of dialogue at the bottom. The
/// dialogue moves up by [lift] (px covered at the bottom) in the same
/// steps as mpv's `sub-pos` moves the real ones; the sign stays.
class MockSubtitles extends StatelessWidget {
final bool top;
final bool bottom;
final ValueListenable<double>? lift;
const MockSubtitles({super.key, required this.top, required this.bottom, this.lift});
static Widget _outlined(String text, TextStyle style, double outline) => Stack(
children: [
Text(text,
textAlign: TextAlign.center,
style: style.copyWith(
color: null,
foreground: Paint()
..style = PaintingStyle.stroke
..strokeWidth = outline
..strokeJoin = StrokeJoin.round
..color = const Color(0xFF1A1206),
)),
Text(text, textAlign: TextAlign.center, style: style),
],
);
@override
Widget build(BuildContext context) {
if (!top && !bottom) return const SizedBox.shrink();
return IgnorePointer(
child: LayoutBuilder(builder: (context, box) {
final h = box.maxHeight;
final dialogue = (h * 0.05).clamp(12.0, 52.0);
return Stack(
children: [
if (top)
Positioned(
left: 0,
right: 0,
top: h * 0.035,
child: Center(
child: _outlined(
'TOP SIGN · 第3新東京市',
TextStyle(
fontSize: dialogue * 0.85,
fontWeight: FontWeight.w800,
color: const Color(0xFFFFE14D),
),
dialogue * 0.14,
),
),
),
if (bottom)
ValueListenableBuilder<double>(
valueListenable: lift ?? const AlwaysStoppedAnimation(0.0),
builder: (context, up, child) => Positioned(
key: const ValueKey('mock-dialogue'),
left: 0,
right: 0,
bottom: h * 0.05 + up,
child: child!,
),
child: Center(
child: _outlined(
'I told you the controls would cover this line.\nThey never do now — or do they?',
TextStyle(fontSize: dialogue, height: 1.15, color: Colors.white, fontWeight: FontWeight.w500),
dialogue * 0.12,
),
),
),
],
);
}),
);
}
}
/// A moving picture for the preview when nothing is loaded: slow colour
/// fields drifting over a dusk gradient, so motion and contrast behind
/// the lines are realistic.
class DanmakuStandIn extends StatefulWidget {
/// False: a still picture (the settings' strip while it rests).
final bool animate;
const DanmakuStandIn({super.key, this.animate = true});
@override
State<DanmakuStandIn> createState() => _DanmakuStandInState();
}
class _DanmakuStandInState extends State<DanmakuStandIn> with SingleTickerProviderStateMixin {
late final AnimationController _clock = AnimationController(vsync: this, duration: const Duration(seconds: 60));
@override
void initState() {
super.initState();
if (widget.animate) _clock.repeat();
}
@override
void didUpdateWidget(DanmakuStandIn old) {
super.didUpdateWidget(old);
if (widget.animate && !_clock.isAnimating) _clock.repeat();
if (!widget.animate && _clock.isAnimating) _clock.stop();
}
@override
void dispose() {
_clock.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) => RepaintBoundary(
child: CustomPaint(painter: _StandInPainter(_clock), child: const SizedBox.expand()),
);
}
class _StandInPainter extends CustomPainter {
final Animation<double> clock;
_StandInPainter(this.clock) : super(repaint: clock);
static const _blobs = [
(Color(0xFFFF7A59), 0.9, 0.0, 0.42),
(Color(0xFF7C5CFF), 1.3, 1.7, 0.5),
(Color(0xFF2EC5CE), 0.7, 3.1, 0.38),
(Color(0xFFFFC857), 1.1, 4.4, 0.3),
(Color(0xFFE85D9E), 0.5, 5.2, 0.45),
];
@override
void paint(Canvas canvas, Size size) {
final rect = Offset.zero & size;
canvas.drawRect(
rect,
Paint()
..shader = const LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0xFF0B1030), Color(0xFF2A1B4A), Color(0xFF5A2F4F), Color(0xFF14141C)],
stops: [0, 0.45, 0.72, 1],
).createShader(rect),
);
final t = clock.value * 2 * math.pi;
final r = size.shortestSide;
for (final (color, speed, phase, radius) in _blobs) {
final c = Offset(
size.width * (0.5 + 0.38 * math.sin(t * speed + phase)),
size.height * (0.5 + 0.32 * math.cos(t * speed * 0.8 + phase * 1.3)),
);
canvas.drawCircle(
c,
r * radius,
Paint()
..shader = RadialGradient(colors: [color.withValues(alpha: 0.55), color.withValues(alpha: 0)])
.createShader(Rect.fromCircle(center: c, radius: r * radius)),
);
}
// A bright horizon line and some stars: hard edges behind the text.
final horizon = size.height * 0.68;
canvas.drawRect(
Rect.fromLTWH(0, horizon, size.width, size.height - horizon),
Paint()..color = const Color(0xCC0D0D14),
);
canvas.drawLine(Offset(0, horizon), Offset(size.width, horizon),
Paint()
..color = const Color(0x88FFD7A8)
..strokeWidth = 1.5);
final stars = math.Random(7);
for (var i = 0; i < 90; i++) {
final x = stars.nextDouble() * size.width;
final y = stars.nextDouble() * horizon;
final twinkle = 0.4 + 0.6 * (0.5 + 0.5 * math.sin(t * 8 + i));
canvas.drawCircle(Offset(x, y), 0.6 + stars.nextDouble() * 1.2,
Paint()..color = Colors.white.withValues(alpha: 0.5 * twinkle));
}
}
@override
bool shouldRepaint(_StandInPainter old) => false;
}
/// The preview's knobs on the app's glass: fire samples, the random
/// stream, the look, mock subtitles. [onClose] ends the preview; it
/// collapses to its title bar.
class DanmakuPanel extends StatefulWidget {
final DanmakuPreview preview;
final VoidCallback onClose;
/// In a session: offer this session's own toasts as a source.
final bool session;
const DanmakuPanel({super.key, required this.preview, required this.onClose, this.session = false});
@override
State<DanmakuPanel> createState() => _DanmakuPanelState();
}
class _DanmakuPanelState extends State<DanmakuPanel> {
bool _open = true;
DanmakuPreview get p => widget.preview;
void _style(DanmakuStyle Function(DanmakuStyle s) f) => p.style.value = f(p.style.peek());
@override
Widget build(BuildContext context) {
final muted = Colors.white.withValues(alpha: 0.6);
return ExcludeFocus(
child: AppGlass(
child: SizedBox(
width: 300,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(14, 8, 6, 4),
child: Row(
children: [
const Expanded(
child: Text('Danmaku preview', style: TextStyle(fontWeight: FontWeight.w600, fontSize: 14)),
),
_Counts(controller: p.controller),
IconButton(
tooltip: _open ? 'Collapse' : 'Expand',
visualDensity: VisualDensity.compact,
iconSize: 18,
onPressed: () => setState(() => _open = !_open),
icon: AnimatedRotation(
turns: _open ? 0 : 0.5,
duration: easeTime,
curve: ease,
child: const Icon(Icons.expand_less),
),
),
IconButton(
tooltip: 'Close the preview',
visualDensity: VisualDensity.compact,
iconSize: 18,
onPressed: widget.onClose,
icon: const Icon(Icons.close),
),
],
),
),
AnimatedSize(
duration: easeTime,
curve: ease,
alignment: Alignment.topCenter,
child: !_open
? const SizedBox(width: double.infinity)
: ConstrainedBox(
constraints: BoxConstraints(maxHeight: MediaQuery.sizeOf(context).height * 0.7),
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(14, 0, 14, 12),
child: SignalBuilder(builder: (context) => _body(muted)),
),
),
),
],
),
),
),
);
}
Widget _body(Color muted) {
final s = p.style.value;
Widget fire(IconData icon, String label, DanmakuItem Function() item) => MinimalButton(
icon: icon,
label: label,
tooltip: '',
scale: 0.8,
onPressed: () => p.controller.add(item()),
);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_Head('Send', muted),
Wrap(
spacing: 4,
runSpacing: 4,
children: [
fire(Icons.chat_bubble_outline, 'Chat', () => p.sample(DanmakuKind.chat)),
fire(Icons.person_outline, 'Mine', () => p.sample(DanmakuKind.chat, own: true)),
fire(Icons.login, 'Join', () => p.sample(DanmakuKind.join)),
fire(Icons.logout, 'Leave', () => p.sample(DanmakuKind.leave)),
fire(Icons.playlist_add_rounded, 'Up next', () => p.sample(DanmakuKind.upNext)),
fire(Icons.play_arrow_rounded, 'Starting', () => p.sample(DanmakuKind.starting)),
fire(Icons.bolt, 'Burst ×12', () {
for (var i = 0; i < 11; i++) {
p.controller.add(p.randomSample());
}
return p.randomSample();
}),
MinimalButton(
icon: Icons.clear_all,
label: 'Clear',
tooltip: '',
scale: 0.8,
onPressed: p.controller.clear,
),
],
),
_Toggle('Random stream', p.auto.value, (v) => p.auto.value = v),
_Slider('Rate', p.autoRate.value, 5, 240, '${p.autoRate.value.round()}/min', (v) => p.autoRate.value = v,
enabled: p.auto.value),
if (widget.session) _Toggle("This session's chat and notices", p.live.value, (v) => p.live.value = v),
_Head('Look', muted),
_Slider('Crossing', s.crossSecs, 4, 16, '${s.crossSecs.toStringAsFixed(1)} s',
(v) => _style((s) => s.copyWith(crossSecs: v))),
_Slider('At most', s.maxOnScreen.toDouble(), 1, 40, '${s.maxOnScreen} lines',
(v) => _style((s) => s.copyWith(maxOnScreen: v.round()))),
_Range('Lanes', s.bandTop, s.bandBottom, '${(s.bandTop * 100).round()}–${(s.bandBottom * 100).round()} %',
(a, b) => _style((s) => s.copyWith(bandTop: a, bandBottom: b))),
_Slider('Opacity', s.opacity, 0.3, 1, '${(s.opacity * 100).round()} %',
(v) => _style((s) => s.copyWith(opacity: v))),
_Slider('Size', s.size, 0.6, 1.8, '×${s.size.toStringAsFixed(2)}', (v) => _style((s) => s.copyWith(size: v))),
_Slider('Bold', s.weight.toDouble(), 400, 900, '${s.weight}',
(v) => _style((s) => s.copyWith(weight: (v / 100).round() * 100))),
_Slider('Outline', s.outline, 0, 4, '${s.outline.toStringAsFixed(1)} px',
(v) => _style((s) => s.copyWith(outline: v))),
_Toggle('Avatars', s.avatars, (v) => _style((s) => s.copyWith(avatars: v))),
_Toggle('Frame my own lines', s.highlightOwn, (v) => _style((s) => s.copyWith(highlightOwn: v))),
_Head('Subtitles (mock)', muted),
_Toggle('Top sign', p.mockTop.value, (v) => p.mockTop.value = v),
_Toggle('Bottom dialogue', p.mockBottom.value, (v) => p.mockBottom.value = v),
if (!widget.session) _Toggle('Control bar', p.controlsBand.value, (v) => p.controlsBand.value = v),
],
);
}
}
class _Counts extends StatelessWidget {
final DanmakuController controller;
const _Counts({required this.controller});
@override
Widget build(BuildContext context) => ListenableBuilder(
listenable: Listenable.merge([controller.onScreen, controller.waiting]),
builder: (context, _) => Tooltip(
message: 'Flying · waiting for a lane',
child: Text(
'${controller.onScreen.value} · ${controller.waiting.value}',
style: TextStyle(
fontSize: 12,
color: Colors.white.withValues(alpha: 0.6),
fontFeatures: const [FontFeature.tabularFigures()],
),
),
),
);
}
class _Head extends StatelessWidget {
final String text;
final Color color;
const _Head(this.text, this.color);
@override
Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.only(top: 10, bottom: 4),
child: Text(text.toUpperCase(),
style: TextStyle(fontSize: 11, letterSpacing: 0.8, fontWeight: FontWeight.w600, color: color)),
);
}
class _Toggle extends StatelessWidget {
final String label;
final bool value;
final ValueChanged<bool> onChanged;
const _Toggle(this.label, this.value, this.onChanged);
@override
Widget build(BuildContext context) => SizedBox(
height: 34,
child: Row(
children: [
Expanded(child: Text(label, style: const TextStyle(fontSize: 13))),
Transform.scale(scale: 0.75, alignment: Alignment.centerRight, child: Switch(value: value, onChanged: onChanged)),
],
),
);
}
class _Slider extends StatelessWidget {
final String label;
final double value, min, max;
final String readout;
final ValueChanged<double> onChanged;
final bool enabled;
const _Slider(this.label, this.value, this.min, this.max, this.readout, this.onChanged, {this.enabled = true});
@override
Widget build(BuildContext context) => _SliderFrame(
label: label,
readout: readout,
child: Slider(value: value.clamp(min, max), min: min, max: max, onChanged: enabled ? onChanged : null),
);
}
class _Range extends StatelessWidget {
final String label;
final double a, b;
final String readout;
final void Function(double a, double b) onChanged;
const _Range(this.label, this.a, this.b, this.readout, this.onChanged);
@override
Widget build(BuildContext context) => _SliderFrame(
label: label,
readout: readout,
child: RangeSlider(
values: RangeValues(a, b),
max: 0.9,
onChanged: (v) {
if (v.end - v.start >= 0.05) onChanged(v.start, v.end);
},
),
);
}
class _SliderFrame extends StatelessWidget {
final String label;
final String readout;
final Widget child;
const _SliderFrame({required this.label, required this.readout, required this.child});
@override
Widget build(BuildContext context) => SizedBox(
height: 34,
child: Row(
children: [
SizedBox(width: 64, child: Text(label, style: const TextStyle(fontSize: 13))),
Expanded(
child: SliderTheme(
data: SliderTheme.of(context).copyWith(
trackHeight: 3,
overlayShape: SliderComponentShape.noOverlay,
thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 6),
rangeThumbShape: const RoundRangeSliderThumbShape(enabledThumbRadius: 6),
),
child: child,
),
),
SizedBox(
width: 76,
child: Text(
readout,
textAlign: TextAlign.right,
style: TextStyle(
fontSize: 12,
color: Colors.white.withValues(alpha: 0.7),
fontFeatures: const [FontFeature.tabularFigures()],
),
),
),
],
),
);
}
/// The preview without a session player: the stand-in picture, a mock
/// control bar of the skin in use, the lines and the panel, over the
/// whole window (Esc or × closes it).
Future<void> showDanmakuPreviewPage(BuildContext context, DanmakuPreview preview) => showGeneralDialog<void>(
context: context,
barrierDismissible: false,
barrierLabel: 'Danmaku preview',
barrierColor: Colors.black,
transitionDuration: easeTime,
pageBuilder: (context, _, _) => DanmakuPreviewPage(preview: preview),
transitionBuilder: (context, anim, _, child) => FadeTransition(
opacity: CurvedAnimation(parent: anim, curve: ease, reverseCurve: Curves.easeIn),
child: child,
),
);
class DanmakuPreviewPage extends StatefulWidget {
final DanmakuPreview preview;
const DanmakuPreviewPage({super.key, required this.preview});
@override
State<DanmakuPreviewPage> createState() => _DanmakuPreviewPageState();
}
/// The page also has the quick chat box (Enter): what you send flies as
/// your own line, and the mock dialogue moves above the box in the lift's
/// steps, as the real subtitles do over the player.
class _DanmakuPreviewPageState extends State<DanmakuPreviewPage> {
final _chat = QuickChat();
final _lift = SubtitleLift();
final _pageFocus = FocusNode(debugLabel: 'danmaku preview');
double _boxH = QuickChatBox.height;
DanmakuPreview get preview => widget.preview;
@override
void initState() {
super.initState();
_chat.open.addListener(_retarget);
WidgetsBinding.instance.addPostFrameCallback((_) => _pageFocus.requestFocus());
}
@override
void dispose() {
_chat.open.removeListener(_retarget);
_chat.dispose();
_lift.dispose();
_pageFocus.dispose();
super.dispose();
}
void _retarget() {
final s = AppScope.of(context).coordinator.settings;
final bar = preview.controlsBand.peek() && s.liftSubtitles.peek()
? controlsBottomHeight(s.controlsSkin.peek(), s.controlsSize.peek()) + 8
: 0.0;
final box = _chat.open.value ? 16 + _boxH + 8 : 0.0;
_lift.animateTo(box > bar ? box : bar);
if (!_chat.open.value) _pageFocus.requestFocus();
}
KeyEventResult _key(FocusNode node, KeyEvent e) {
if (e is! KeyDownEvent) return KeyEventResult.ignored;
if (e.logicalKey == LogicalKeyboardKey.enter || e.logicalKey == LogicalKeyboardKey.numpadEnter) {
_chat.show();
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
}
@override
Widget build(BuildContext context) {
return Focus(
focusNode: _pageFocus,
onKeyEvent: _key,
child: Material(
color: Colors.black,
child: Stack(
fit: StackFit.expand,
children: [
const DanmakuStandIn(),
DanmakuPreviewLayers(preview: preview, lift: _lift),
SignalBuilder(builder: (context) {
if (!preview.controlsBand.value) return const SizedBox.shrink();
final s = AppScope.of(context).coordinator.settings;
final h = controlsBottomHeight(s.controlsSkin.value, s.controlsSize.value);
return Align(
alignment: Alignment.bottomCenter,
child: IgnorePointer(
child: ValueListenableBuilder<bool>(
valueListenable: _chat.open,
builder: (context, open, child) =>
AnimatedOpacity(opacity: open ? 0 : 1, duration: easeTime, curve: ease, child: child),
child: Container(
height: h,
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0x00000000), MinimalStyle.band],
),
border: Border(top: BorderSide(color: AppMenuStyle.border, width: 0.5)),
),
alignment: Alignment.center,
child: Text('control bar',
style: TextStyle(color: Colors.white.withValues(alpha: 0.35), fontSize: 12)),
),
),
),
);
}),
Positioned(
left: 16,
right: 16,
bottom: 16,
child: QuickChatBox(
chat: _chat,
onSend: (text) => preview.controller.add(preview.own(text)),
onHeight: (h) {
_boxH = h;
if (_chat.open.value) _retarget();
},
),
),
Positioned(
left: 16,
top: 14,
child: Text('Stand-in picture: load something to preview on the real player · Enter: chat box',
style: TextStyle(color: Colors.white.withValues(alpha: 0.55), fontSize: 12)),
),
Positioned(
right: 16,
top: 16,
child: DanmakuPanel(preview: preview, onClose: () => Navigator.of(context).pop()),
),
],
),
),
);
}
}
/// The panel over the session player; while mounted, this session's
/// toasts reach the preview when its "chat and notices" switch is on.
class DanmakuSessionPanel extends StatefulWidget {
final DanmakuPreview preview;
final SessionCoordinator coord;
const DanmakuSessionPanel({super.key, required this.preview, required this.coord});
@override
State<DanmakuSessionPanel> createState() => _DanmakuSessionPanelState();
}
class _DanmakuSessionPanelState extends State<DanmakuSessionPanel> {
late final void Function() _stop = widget.preview.watchToasts(widget.coord.toasts);
@override
void initState() {
super.initState();
widget.preview.coord = widget.coord;
_stop;
}
@override
void dispose() {
_stop();
widget.preview.coord = null;
super.dispose();
}
@override
Widget build(BuildContext context) => DanmakuPanel(
preview: widget.preview,
session: true,
onClose: () => widget.preview.active.value = false,
);
}
@@ -0,0 +1,342 @@
import 'dart:async';
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:signals/signals_flutter.dart';
import '../shell/avatar.dart' show AvatarInfo, colorForName, selfAvatarInfo;
import '../state/settings.dart';
import '../widgets/app_menu.dart' show AppSelect;
import 'danmaku.dart';
import 'danmaku_preview.dart' show DanmakuPreview, DanmakuStandIn, MockSubtitles;
// Settings → Notifications & sounds: how notifications show over the full
// player (Toasts or Danmaku) and every knob of the danmaku look, with a
// live preview strip. The player's side panel shows the style and the
// knobs too ([DanmakuKnobs]).
/// Names of the weights the boldness slider steps through.
const danmakuWeightNames = {
400: 'Regular',
500: 'Medium',
600: 'Semibold',
700: 'Bold',
800: 'Extra bold',
900: 'Black',
};
/// "Notifications: Toasts / Danmaku" ([AppSettings.notifyStyle]).
class NotifyStyleSelect extends StatelessWidget {
final AppSettings settings;
const NotifyStyleSelect({super.key, required this.settings});
@override
Widget build(BuildContext context) => SignalBuilder(
builder: (context) => AppSelect<NotifyStyle>(
key: const ValueKey('notify-style'),
value: settings.notifyStyle.value,
onChanged: settings.setNotifyStyle,
items: [for (final m in NotifyStyle.values) (m, m.label)],
),
);
}
/// The card: the style picker, the preview strip and the knobs.
class DanmakuSettingsPanel extends StatelessWidget {
final AppSettings settings;
const DanmakuSettingsPanel({super.key, required this.settings});
@override
Widget build(BuildContext context) {
final muted = TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant, fontSize: 12);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Notifications'),
Text(
'Danmaku: over the full player, chat, joins, leaves, Up next and "starting now" fly across '
'the top of the picture instead of popping up. Problems and errors stay pop-ups, and so does '
'everything with the player small',
style: muted),
],
),
),
const SizedBox(width: 12),
NotifyStyleSelect(settings: settings),
],
),
const SizedBox(height: 12),
DanmakuPreviewStrip(settings: settings),
const SizedBox(height: 6),
DanmakuKnobs(settings: settings),
],
);
}
}
/// A 16:9-ish strip with a moving picture, a top sign and bottom
/// dialogue, and sample lines in the current look, a few a second.
class DanmakuPreviewStrip extends StatefulWidget {
final AppSettings settings;
final double height;
const DanmakuPreviewStrip({super.key, required this.settings, this.height = 190});
@override
State<DanmakuPreviewStrip> createState() => _DanmakuPreviewStripState();
}
class _DanmakuPreviewStripState extends State<DanmakuPreviewStrip> {
/// The strip plays this long after it opens or a knob moves, then
/// rests (a click plays it again): a settings page left open costs
/// nothing.
static const playFor = Duration(seconds: 16);
final _controller = DanmakuController();
final _rng = math.Random(3);
Timer? _timer;
Timer? _rest;
bool _playing = false;
int _n = 0;
late final void Function() _stop;
@override
void initState() {
super.initState();
_stop = effect(() {
widget.settings.danmakuStyle.value;
untracked(_play);
});
}
@override
void dispose() {
_stop();
_timer?.cancel();
_rest?.cancel();
_controller.dispose();
super.dispose();
}
void _play() {
_rest?.cancel();
_rest = Timer(playFor, () {
_timer?.cancel();
_timer = null;
if (mounted) setState(() => _playing = false);
});
if (_timer != null) return;
_send();
_timer = Timer.periodic(const Duration(milliseconds: 1100), (_) => _send());
if (_playing) return;
_playing = true;
if (_built && mounted) setState(() {});
}
bool _built = false;
void _send() {
final i = _n++;
if (i % 7 == 3) {
final me = selfAvatarInfo();
_controller.add(DanmakuItem(
kind: DanmakuKind.chat, sender: me.name, senderColor: me.color, text: 'this is my line', own: true, avatar: me));
return;
}
final name = DanmakuPreview.sampleNames[_rng.nextInt(DanmakuPreview.sampleNames.length)];
final who = AvatarInfo(name: name, color: colorForName(name));
if (i % 9 == 5) {
_controller.add(DanmakuItem(kind: DanmakuKind.join, sender: name, senderColor: who.color, text: 'joined', avatar: who));
return;
}
// Short and medium lines: the long ones crowd a small strip.
final pool = [for (final t in DanmakuPreview.sampleChat) if (t.length < 48) t];
_controller.add(DanmakuItem(
kind: DanmakuKind.chat,
sender: name,
senderColor: who.color,
text: pool[_rng.nextInt(pool.length)],
avatar: who));
}
@override
Widget build(BuildContext context) {
_built = true;
final accent = Theme.of(context).colorScheme.primary;
return ClipRRect(
key: const ValueKey('danmaku-preview-strip'),
borderRadius: BorderRadius.circular(12),
child: SizedBox(
height: widget.height,
child: Stack(
fit: StackFit.expand,
children: [
DanmakuStandIn(animate: _playing),
const MockSubtitles(top: true, bottom: true),
SignalBuilder(
builder: (context) =>
DanmakuLayer(controller: _controller, style: widget.settings.danmakuStyle.value, accent: accent),
),
Center(
child: AnimatedOpacity(
opacity: _playing ? 0 : 1,
duration: const Duration(milliseconds: 200),
child: IgnorePointer(
ignoring: _playing,
child: FilledButton.tonalIcon(
key: const ValueKey('danmaku-preview-play'),
onPressed: _play,
icon: const Icon(Icons.play_arrow_rounded, size: 18),
label: const Text('Play preview'),
),
),
),
),
],
),
),
);
}
}
/// Every knob of the danmaku look, live and saved: crossing time, how
/// many at once, the lane band, opacity, size, boldness, outline,
/// avatars, your own lines framed; and a reset.
class DanmakuKnobs extends StatelessWidget {
final AppSettings settings;
/// Narrow (the player's side panel): shorter labels column.
final bool dense;
const DanmakuKnobs({super.key, required this.settings, this.dense = false});
void _set(DanmakuStyle Function(DanmakuStyle s) f) => settings.setDanmakuStyle(f(settings.danmakuStyle.peek()));
@override
Widget build(BuildContext context) {
const l = DanmakuStyle.limits;
return SignalBuilder(builder: (context) {
final s = settings.danmakuStyle.value;
final labelW = dense ? 84.0 : 150.0;
Widget slider(String key, String label, double v, (num, num) r, String readout, ValueChanged<double> f,
{int? divisions}) =>
_Knob(
label: label,
labelWidth: labelW,
readout: readout,
child: Slider(
key: ValueKey('danmaku-$key'),
value: v.clamp(r.$1.toDouble(), r.$2.toDouble()),
min: r.$1.toDouble(),
max: r.$2.toDouble(),
divisions: divisions,
onChanged: f,
),
);
Widget toggle(String key, String label, bool v, ValueChanged<bool> f) => _Knob(
label: label,
labelWidth: double.infinity,
child: Switch(key: ValueKey('danmaku-$key'), value: v, onChanged: f),
);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
slider('cross', dense ? 'Crossing' : 'Crossing time', s.crossSecs, l.crossSecs,
'${s.crossSecs.toStringAsFixed(1)} s', (v) => _set((s) => s.copyWith(crossSecs: v))),
slider('cap', dense ? 'At most' : 'At most on screen', s.maxOnScreen.toDouble(), l.maxOnScreen,
'${s.maxOnScreen} lines', (v) => _set((s) => s.copyWith(maxOnScreen: v.round())),
divisions: l.maxOnScreen.$2 - l.maxOnScreen.$1),
_Knob(
label: dense ? 'Lanes' : 'Lanes (from the top)',
labelWidth: labelW,
readout: '${(s.bandTop * 100).round()}–${(s.bandBottom * 100).round()} %',
child: RangeSlider(
key: const ValueKey('danmaku-band'),
values: RangeValues(s.bandTop, s.bandBottom),
min: l.band.$1,
max: l.band.$2,
onChanged: (v) {
if (v.end - v.start >= l.minBand) _set((s) => s.copyWith(bandTop: v.start, bandBottom: v.end));
},
),
),
slider('opacity', 'Opacity', s.opacity, l.opacity, '${(s.opacity * 100).round()} %',
(v) => _set((s) => s.copyWith(opacity: v))),
slider('size', 'Size', s.size, l.size, '×${s.size.toStringAsFixed(2)}', (v) => _set((s) => s.copyWith(size: v))),
slider('weight', 'Boldness', s.weight.toDouble(), l.weight, danmakuWeightNames[s.weight] ?? '${s.weight}',
(v) => _set((s) => s.copyWith(weight: (v / 100).round() * 100)),
divisions: (l.weight.$2 - l.weight.$1) ~/ 100),
slider('outline', 'Outline', s.outline, l.outline,
s.outline < 0.05 ? 'none' : '${s.outline.toStringAsFixed(1)} px', (v) => _set((s) => s.copyWith(outline: v))),
toggle('avatars', 'Avatars before the lines', s.avatars, (v) => _set((s) => s.copyWith(avatars: v))),
toggle('own', 'Frame my own lines', s.highlightOwn, (v) => _set((s) => s.copyWith(highlightOwn: v))),
Align(
alignment: Alignment.centerRight,
child: TextButton.icon(
key: const ValueKey('danmaku-reset'),
onPressed: s == const DanmakuStyle() ? null : () => settings.setDanmakuStyle(const DanmakuStyle()),
icon: const Icon(Icons.restart_alt, size: 18),
label: const Text('Reset look'),
),
),
],
);
});
}
}
class _Knob extends StatelessWidget {
final String label;
final double labelWidth;
final String? readout;
final Widget child;
const _Knob({required this.label, required this.labelWidth, this.readout, required this.child});
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final text = Text(label, style: const TextStyle(fontSize: 13));
if (labelWidth == double.infinity) {
return SizedBox(
height: 38,
child: Row(children: [Expanded(child: text), Transform.scale(scale: 0.8, alignment: Alignment.centerRight, child: child)]),
);
}
return SizedBox(
height: 36,
child: Row(
children: [
SizedBox(width: labelWidth, child: text),
Expanded(
child: SliderTheme(
data: SliderTheme.of(context).copyWith(
trackHeight: 3,
overlayShape: SliderComponentShape.noOverlay,
thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 7),
rangeThumbShape: const RoundRangeSliderThumbShape(enabledThumbRadius: 7),
showValueIndicator: ShowValueIndicator.never,
tickMarkShape: SliderTickMarkShape.noTickMark,
),
child: child,
),
),
SizedBox(
width: 78,
child: Text(
readout ?? '',
textAlign: TextAlign.right,
style: TextStyle(
fontSize: 12,
color: scheme.onSurfaceVariant,
fontFeatures: const [FontFeature.tabularFigures()],
),
),
),
],
),
);
}
}
+11 -1
View File
@@ -9,6 +9,7 @@ import '../../library/show_matcher.dart';
import '../../shell/motion.dart'; import '../../shell/motion.dart';
import '../../platform/ports/folder_access.dart'; import '../../platform/ports/folder_access.dart';
import '../../app/app_graph.dart'; import '../../app/app_graph.dart';
import '../../watch/watch_history.dart';
import '../../widgets/app_menu.dart'; import '../../widgets/app_menu.dart';
import '../folder_store.dart'; import '../folder_store.dart';
import '../scanner.dart'; import '../scanner.dart';
@@ -127,6 +128,7 @@ class _FilesPageState extends State<FilesPage> {
late final ThumbCache _thumbs = late final ThumbCache _thumbs =
widget.thumbnails == null ? ThumbCache.shared : ThumbCache(widget.thumbnails!); widget.thumbnails == null ? ThumbCache.shared : ThumbCache(widget.thumbnails!);
FolderStore get _store => widget.store ?? FolderStore.instance; FolderStore get _store => widget.store ?? FolderStore.instance;
WatchHistory get _history => AppScope.of(context).watch.history;
/// Null: Recent; [libraryPlace]: every folder. /// Null: Recent; [libraryPlace]: every folder.
String? _open = _lastOpen; String? _open = _lastOpen;
@@ -325,8 +327,15 @@ class _FilesPageState extends State<FilesPage> {
thumbs: _thumbs, thumbs: _thumbs,
); );
/// The last episode of [show] played here (Recent), by key. /// The last episode of [show] watched here (the watch history: the
/// newest one watched to the end), else the last one opened (Recent), by
/// key.
String? _lastPlayed(_Library lib, LocalShow show) { String? _lastPlayed(_Library lib, LocalShow show) {
for (final (key, entry) in _history.entries) {
if (!entry.played || !key.value.startsWith('file:')) continue;
final e = lib.byPath[key.value.substring(5)];
if (e != null && e.showKey == show.key && e.kind == LocalKind.episode) return e.key;
}
for (final p in _store.recent.value) { for (final p in _store.recent.value) {
final e = lib.byPath[p]; final e = lib.byPath[p];
if (e != null && e.showKey == show.key && e.kind == LocalKind.episode) return e.key; if (e != null && e.showKey == show.key && e.kind == LocalKind.episode) return e.key;
@@ -342,6 +351,7 @@ class _FilesPageState extends State<FilesPage> {
final folders = _store.folders.value; final folders = _store.folders.value;
final recent = _store.recent.value; final recent = _store.recent.value;
_matcher.revision.value; _matcher.revision.value;
_history.revision.value;
final open = _open == null || _open == libraryPlace || folders.contains(_open) ? _open : libraryPlace; final open = _open == null || _open == libraryPlace || folders.contains(_open) ? _open : libraryPlace;
final showKey = open == null ? null : _openShows[open]; final showKey = open == null ? null : _openShows[open];
final main = fadeThrough( final main = fadeThrough(
+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,7 +4,6 @@ import 'package:flutter/foundation.dart';
import 'package:sync_relay_core/core.dart'; import 'package:sync_relay_core/core.dart';
import '../screens/home/picked_media.dart'; import '../screens/home/picked_media.dart';
import '../session/party.dart' show isPresent;
import 'accounts.dart'; import 'accounts.dart';
import 'format.dart'; import 'format.dart';
import '../library/media_refs.dart'; import '../library/media_refs.dart';
@@ -341,26 +340,3 @@ Duration nextPromptStart(JellyfinItem? item, Duration runtime, {Duration? ending
const lead = Duration(minutes: 2); const lead = Duration(minutes: 2);
return runtime > lead ? runtime - lead : Duration.zero; return runtime > lead ? runtime - lead : Duration.zero;
} }
/// Who performs the auto-advance swap, so several counting clients do not
/// swap twice: the lowest member id among members that play the item with
/// their own account (`jf[serverId] == true`, present: `in` or joining). Only what every
/// client sees in `state` decides (who put the item up is not in it: a
/// late joiner never saw that swap), so all clients agree.
String? autoAdvanceSwapper(List<MemberView> members, String serverId) {
final eligible = [
for (final m in members)
if (isPresent(m) && m.jf[serverId] == true) m
];
if (eligible.isEmpty) return null;
eligible.sort((a, b) => compareMemberIds(a.id, b.id));
return eligible.first.id;
}
/// "m2" before "m10": numeric suffix order, then plain text.
int compareMemberIds(String a, String b) {
final na = int.tryParse(a.replaceFirst(RegExp(r'^\D+'), ''));
final nb = int.tryParse(b.replaceFirst(RegExp(r'^\D+'), ''));
if (na != null && nb != null && na != nb) return na.compareTo(nb);
return a.compareTo(b);
}
@@ -0,0 +1,34 @@
import 'dart:io';
import 'package:sync_relay_core/core.dart';
import '../files/scanner.dart' show FolderScan;
import 'show_matcher.dart';
// The next episode of a local show (the library's `nextAfter`, numbers
// skipped on the way included): what continues a file this app put up
// (see ARCHITECTURE.md "What's next").
/// [path]'s next episode: from the library the Files page saw this run
/// ([ShowMatcher.showAt]), else from the file's own folder read now (one
/// [scan], grouped by `groupLocal` with that folder's name as a hint). Null when there is none (the last
/// episode, not an episode, an unreadable folder).
class LocalNextFinder {
final ShowMatcher matcher;
final Future<FolderScan> Function(String dir) scan;
LocalNextFinder({required this.matcher, required this.scan});
Future<LocalNext?> nextAfter(String path) async {
var show = matcher.showAt(path);
if (show == null) {
final dir = File(path).parent.path;
final found = await scan(dir);
// The folder itself is a hint (a bare `01.mkv` in `Bleach`).
final lib = groupLocal(found.videos, roots: [Directory(dir).parent.path]);
show = lib.shows.where((s) => s.files.any((e) => e.path == path)).firstOrNull;
}
final at = show?.files.where((e) => e.path == path).firstOrNull;
if (show == null || at == null || at.kind != LocalKind.episode) return null;
return show.nextAfter(at.key);
}
}
+11 -1
View File
@@ -129,6 +129,7 @@ class ShowMatcher {
// Files seen in scans this run, for refs of files put up. // Files seen in scans this run, for refs of files put up.
final _byPath = <String, LocalEpisode>{}; final _byPath = <String, LocalEpisode>{};
final _showByPath = <String, LocalShow>{};
final _entries = <int, AniListEntry>{}; final _entries = <int, AniListEntry>{};
final _flows = <int, FlowShow?>{}; final _flows = <int, FlowShow?>{};
final _jobs = <String, Future<void> Function()>{}; final _jobs = <String, Future<void> Function()>{};
@@ -152,12 +153,21 @@ class ShowMatcher {
// ------------------------------------------------------------- reads // ------------------------------------------------------------- reads
/// Remembers [library]'s files, so [refForPath] knows their show. /// Remembers [library]'s files, so [refForPath] and [showAt] know
/// their show.
void remember(LocalLibrary library) { void remember(LocalLibrary library) {
for (final e in library.files) { for (final e in library.files) {
_byPath[e.path] = e; _byPath[e.path] = e;
} }
for (final s in library.shows) {
for (final e in s.files) {
_showByPath[e.path] = s;
} }
}
}
/// The show the file at [path] belongs to, as seen in a scan this run.
LocalShow? showAt(String path) => _showByPath[path];
AniListEntry? entry(int id) { AniListEntry? entry(int id) {
final known = _entries[id]; final known = _entries[id];
@@ -5,6 +5,7 @@ import 'package:flutter/foundation.dart';
import '../files/thumbnails.dart'; import '../files/thumbnails.dart';
import '../app/app_graph.dart'; import '../app/app_graph.dart';
import 'single_instance.dart';
// The one way out of the app (close button, Alt+F4, Update and restart). // The one way out of the app (close button, Alt+F4, Update and restart).
// Order matters: every mpv player goes before the engine does (a live one // Order matters: every mpv player goes before the engine does (a live one
@@ -21,10 +22,14 @@ bool get shuttingDown => _shuttingDown;
/// The systems the close stops; `main` hands them over once. /// The systems the close stops; `main` hands them over once.
AppGraph? _graph; AppGraph? _graph;
/// Makes [graph] the one [shutDownApp] stops and returns the close /// This app's claim on its profile folder (one app at a time).
/// request handler for the window. InstanceLock? _instance;
Future<void> Function() closeAppWith(AppGraph graph) {
/// Makes [graph] the one [shutDownApp] stops (and [instance] the claim it
/// gives up) and returns the close request handler for the window.
Future<void> Function() closeAppWith(AppGraph graph, {InstanceLock? instance}) {
_graph = graph; _graph = graph;
_instance = instance;
return shutDownApp; return shutDownApp;
} }
@@ -38,6 +43,9 @@ Future<void> shutDownApp({Future<void> Function()? beforeQuit, bool restart = fa
final graph = _graph; final graph = _graph;
if (_shuttingDown || graph == null) return; if (_shuttingDown || graph == null) return;
_shuttingDown = true; _shuttingDown = true;
// A start from now on waits for this app to end instead of handing its
// link to it.
_instance?.closing();
final shell = graph.ports.shell; final shell = graph.ports.shell;
final total = Stopwatch()..start(); final total = Stopwatch()..start();
await _step('window save', shell.saveWindow(), const Duration(milliseconds: 500)); await _step('window save', shell.saveWindow(), const Duration(milliseconds: 500));
@@ -57,6 +65,8 @@ Future<void> shutDownApp({Future<void> Function()? beforeQuit, bool restart = fa
// server: 3 s in all. // server: 3 s in all.
await _step('leave', graph.navigation.leave(bye: !restart), const Duration(milliseconds: 2500)); await _step('leave', graph.navigation.leave(bye: !restart), const Duration(milliseconds: 2500));
await _step('server stop', graph.yourServer.stop(), const Duration(milliseconds: 500)); await _step('server stop', graph.yourServer.stop(), const Duration(milliseconds: 500));
// Update and restart: the new binary claims the folder at once.
if (restart) await _step('instance', _instance?.release(), const Duration(milliseconds: 500));
if (beforeQuit != null) await _step('before quit', beforeQuit(), const Duration(seconds: 3)); if (beforeQuit != null) await _step('before quit', beforeQuit(), const Duration(seconds: 3));
debugPrint('close: ready to quit after ${total.elapsedMilliseconds} ms'); debugPrint('close: ready to quit after ${total.elapsedMilliseconds} ms');
await shell.quit(); await shell.quit();
@@ -1,3 +1,5 @@
import 'dart:io';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
@@ -17,3 +19,41 @@ Future<void> requestWindowAttention() async {
debugPrint('window attention: $e'); debugPrint('window attention: $e');
} }
} }
/// The desktop's activation token this process started with (Linux:
/// `XDG_ACTIVATION_TOKEN` or `DESKTOP_STARTUP_ID`, kept by the runner
/// before GTK used it); '' when none or elsewhere.
Future<String> windowLaunchToken() async {
if (!Platform.isLinux) return '';
try {
return await _window.invokeMethod<String>('launchToken') ?? '';
} catch (_) {
return '';
}
}
/// Linux: brings the window up with another start's activation token
/// ([windowLaunchToken] of the start that handed over). False where the
/// runner lacks it.
Future<bool> presentWindow(String token) async {
if (!Platform.isLinux) return false;
try {
await _window.invokeMethod<void>('present', token);
return true;
} catch (_) {
return false;
}
}
/// Ends this process at once with no exit handlers (a second start that
/// handed over; its window never showed). The runner does it; where it
/// cannot, a kill.
Future<Never> exitNow() async {
try {
await _window.invokeMethod<void>('exit');
} catch (_) {}
Process.killPid(pid, ProcessSignal.sigkill);
// The kill lands before anything else runs.
await Future<void>.delayed(const Duration(days: 1));
throw StateError('unreachable');
}
@@ -98,10 +98,13 @@ class DesktopShell implements ShellPort {
} }
@override @override
Future<void> focus() async { Future<void> focus({String activation = ''}) async {
try { try {
if (await windowManager.isMinimized()) await windowManager.restore(); if (await windowManager.isMinimized()) await windowManager.restore();
if (!await windowManager.isVisible()) await windowManager.show(); if (!await windowManager.isVisible()) await windowManager.show();
// Linux: present with the token when there is one (else a
// compositor only marks the window as wanting attention).
if (activation.isNotEmpty && await presentWindow(activation)) return;
await windowManager.focus(); await windowManager.focus();
} catch (e) { } catch (e) {
debugPrint('window focus failed: $e'); debugPrint('window focus failed: $e');
@@ -126,6 +129,15 @@ class DesktopShell implements ShellPort {
return (files: files, text: text); return (files: files, text: text);
} }
@override
Future<String?> readClipboardText() async {
try {
return await ClipboardX.readText();
} catch (_) {
return null;
}
}
@override @override
Widget dropTarget({ Widget dropTarget({
Key? key, Key? key,
@@ -75,6 +75,9 @@ class MediaKitHost implements PlayerHost {
: _hwdecSetting = hwdecSetting ?? (() => AppSettings.instance.hwdec.peek()); : _hwdecSetting = hwdecSetting ?? (() => AppSettings.instance.hwdec.peek());
final _live = <_MkPlayer>{}; final _live = <_MkPlayer>{};
final _videos = <_MkVideo>{};
// The size outputs render at ([setOutputSize]); null: the video's own.
(int, int)? _outputSize;
final _renderMode = signal(RenderMode.unknown); final _renderMode = signal(RenderMode.unknown);
final _http = StreamController<String>.broadcast(); final _http = StreamController<String>.broadcast();
final _throttle = LogThrottle(burst: 5); final _throttle = LogThrottle(burst: 5);
@@ -90,6 +93,16 @@ class MediaKitHost implements PlayerHost {
@override @override
ReadonlySignal<RenderMode> get renderMode => _renderMode; ReadonlySignal<RenderMode> get renderMode => _renderMode;
@override
void setOutputSize(int? width, int? height) {
final size = width != null && height != null && width > 0 && height > 0 ? (width, height) : null;
if (size == _outputSize) return;
_outputSize = size;
for (final v in _videos.toList()) {
unawaited(v.applySize());
}
}
@override @override
Stream<String> get httpErrors => _http.stream; Stream<String> get httpErrors => _http.stream;
@@ -185,6 +198,17 @@ class MediaKitHost implements PlayerHost {
// configured), which used to override SYNC_RELAY_HWDEC. // configured), which used to override SYNC_RELAY_HWDEC.
final controller = VideoController(p.mk, configuration: VideoControllerConfiguration(hwdec: hwdec)); final controller = VideoController(p.mk, configuration: VideoControllerConfiguration(hwdec: hwdec));
final v = _MkVideo(p, controller); final v = _MkVideo(p, controller);
_videos.add(v);
p._video = v;
if (_outputSize != null) unawaited(v.applySize());
// mpv gets `vo=libmpv` before the output's render context exists; a
// media opened in between fails its video output ("No render context
// set") and plays black. Opens wait for the output (seen on X11, where
// the context comes up later, when joining with media already up).
p._videoReady = controller.platform.future
.then((_) {})
.timeout(const Duration(seconds: 5), onTimeout: () {})
.catchError((Object _) {});
if (probe) unawaited(_probeRender(p, v)); if (probe) unawaited(_probeRender(p, v));
return v; return v;
} }
@@ -195,6 +219,20 @@ class MediaKitHost implements PlayerHost {
// refused request makes ffmpeg retry and warn on every try); ffmpeg's // refused request makes ffmpeg retry and warn on every try); ffmpeg's
// HTTP status to [httpErrors]; the GL init lines while probing. // HTTP status to [httpErrors]; the GL init lines while probing.
void _route(_MkPlayer p, PlayerLogLine log) { void _route(_MkPlayer p, PlayerLogLine log) {
// Windows: the controller can be "ready" before mpv's render context
// exists, and an open in that gap fails its video output ("No render
// context set"). Open the same media again once, a moment later.
if (log.prefix == 'cplayer' && log.text.contains('initializing the selected video_out')) {
final last = p._lastOpen;
if (last != null && !p._voRetried && !p._retired) {
p._voRetried = true;
_mpvLog.info('render: the video output was not up for the open; opening it again');
unawaited(Future.delayed(const Duration(milliseconds: 500), () async {
if (p._retired || !identical(p._lastOpen, last)) return;
await p.mk.open(Media(last.url, httpHeaders: last.headers), play: last.play);
}).catchError((Object e) => _mpvLog.warning('render: opening again failed: $e')));
}
}
if (log.prefix == 'ffmpeg') { if (log.prefix == 'ffmpeg') {
final http = RegExp(r'HTTP error \d{3}.*').firstMatch(log.text); final http = RegExp(r'HTTP error \d{3}.*').firstMatch(log.text);
if (http != null) _http.add(http[0]!.trim()); if (http != null) _http.add(http[0]!.trim());
@@ -318,6 +356,10 @@ class MediaKitHost implements PlayerHost {
final p = player as _MkPlayer; final p = player as _MkPlayer;
if (!_live.remove(p)) return; if (!_live.remove(p)) return;
p._retired = true; p._retired = true;
if (p._video case final v?) {
_videos.remove(v);
await v._paramsSub?.cancel();
}
await p._logSub?.cancel(); await p._logSub?.cancel();
try { try {
await p.mk.dispose(); await p.mk.dispose();
@@ -381,6 +423,11 @@ class _MkPlayer implements HostedPlayer {
StreamSubscription<int?>? _firstFrame; StreamSubscription<int?>? _firstFrame;
bool _probing = false; bool _probing = false;
bool _retired = false; bool _retired = false;
Future<void>? _videoReady; // set once a video output is attached
// The last open, for one retry when mpv's video output was not up yet.
({String url, Map<String, String> headers, bool play})? _lastOpen;
bool _voRetried = false;
_MkVideo? _video;
dynamic get _mpv => mk.platform; dynamic get _mpv => mk.platform;
@@ -426,9 +473,13 @@ class _MkPlayer implements HostedPlayer {
set onUserSeek(void Function(Duration position)? f) => mk.onUserSeek = f; set onUserSeek(void Function(Duration position)? f) => mk.onUserSeek = f;
@override @override
Future<void> open(String url, {Map<String, String> headers = const {}, bool play = false}) => Future<void> open(String url, {Map<String, String> headers = const {}, bool play = false}) async {
if (_videoReady case final ready?) await ready;
_lastOpen = (url: url, headers: headers, play: play);
_voRetried = false;
// An empty map, not null: media_kit caches headers per URI. // An empty map, not null: media_kit caches headers per URI.
mk.open(Media(url, httpHeaders: headers), play: play); await mk.open(Media(url, httpHeaders: headers), play: play);
}
@override @override
Future<void> play() => mk.play(); Future<void> play() => mk.play();
@@ -499,4 +550,56 @@ class _MkVideo implements HostedVideo {
@override @override
Future<void> get ready => controller.platform.future; Future<void> get ready => controller.platform.future;
// What this output was last set to: null with [_sized] the video's own
// size again; nothing sent yet while [_sized] is false.
(int, int)? _sent;
bool _sized = false;
StreamSubscription<VideoParams>? _paramsSub;
Future<void> _sizeOp = Future.value();
/// Renders at the host's output size (or the video's own when it has
/// none). media_kit's controller sets the output back to the video's
/// size whenever the video's parameters change (a new file, a track
/// with another size); the fixed size is sent again after it, under
/// the controller's own lock so it lands last.
Future<void> applySize({bool again = false}) => _sizeOp = _sizeOp.then((_) async {
if (player.disposed) return;
try {
// NativeVideoController (Linux, Windows): not exported, reached
// through its public fields.
final dynamic native = await controller.platform.future;
final view = player.host._outputSize;
final params = player.mk.state.videoParams;
final dw = params.dw ?? 0, dh = params.dh ?? 0;
final want = view == null ? null : fitOutputSize(view, dw > 0 && dh > 0 ? (dw, dh) : null);
if (_sized && want == _sent && !again) return;
if (want == null) {
if (!_sized) return; // never resized: already the video's size
await native.setSize();
} else {
// setSize skips a size it believes is set; after media_kit's own
// reset it is not.
native.width = null;
await native.setSize(width: want.$1, height: want.$2);
}
_sent = want;
_sized = true;
// The output takes its new size with the next frame mpv draws;
// paused, none comes: a pan too small to see makes mpv redraw.
if (!player.state.playing) {
await player.setProperty('video-pan-x', '0.000001');
await player.setProperty('video-pan-x', '0');
}
_paramsSub ??= player.mk.stream.videoParams.listen((params) async {
if ((params.dw ?? 0) == 0 || _sent == null) return;
try {
await (native.lock.synchronized(() async {}) as Future);
} catch (_) {}
unawaited(applySize(again: true));
});
} catch (e) {
debugPrint('video output size: $e');
}
});
} }
@@ -0,0 +1,60 @@
import 'dart:ffi';
import 'package:ffi/ffi.dart';
// The few Win32 calls the app makes itself (no package wraps them for
// us without a new dependency). Only ever called on Windows.
/// HKEY_CURRENT_USER as the API takes it (sign-extended 0x80000001).
const _hkeyCurrentUser = -0x7FFFFFFF;
const _regSz = 1;
const _rrfRtRegSz = 0x2;
typedef _AllowNative = Int32 Function(Uint32 pid);
typedef _Allow = int Function(int pid);
/// Lets process [pid] take the foreground (a second start does this for
/// the running app before handing over its link: Windows only lets a
/// process that got the user's click bring a window up).
void allowSetForegroundWindow(int pid) {
final user32 = DynamicLibrary.open('user32.dll');
user32.lookupFunction<_AllowNative, _Allow>('AllowSetForegroundWindow')(pid);
}
typedef _SetNative = Int32 Function(
IntPtr key, Pointer<Utf16> subKey, Pointer<Utf16> name, Uint32 type, Pointer<Void> data, Uint32 size);
typedef _Set = int Function(
int key, Pointer<Utf16> subKey, Pointer<Utf16> name, int type, Pointer<Void> data, int size);
typedef _GetNative = Int32 Function(IntPtr key, Pointer<Utf16> subKey, Pointer<Utf16> name, Uint32 flags,
Pointer<Uint32> type, Pointer<Void> data, Pointer<Uint32> size);
typedef _Get = int Function(int key, Pointer<Utf16> subKey, Pointer<Utf16> name, int flags,
Pointer<Uint32> type, Pointer<Void> data, Pointer<Uint32> size);
DynamicLibrary get _advapi => DynamicLibrary.open('advapi32.dll');
/// The string value [name] ('' = the key's default) of
/// HKEY_CURRENT_USER\[subKey]; null when missing.
String? readUserRegistryString(String subKey, String name) {
final get = _advapi.lookupFunction<_GetNative, _Get>('RegGetValueW');
return using((arena) {
final key = subKey.toNativeUtf16(allocator: arena);
final value = name.isEmpty ? nullptr : name.toNativeUtf16(allocator: arena);
final size = arena<Uint32>()..value = 0;
if (get(_hkeyCurrentUser, key, value, _rrfRtRegSz, nullptr, nullptr, size) != 0) return null;
final data = arena<Uint16>(size.value ~/ 2 + 1);
if (get(_hkeyCurrentUser, key, value, _rrfRtRegSz, nullptr, data.cast(), size) != 0) return null;
return data.cast<Utf16>().toDartString();
});
}
/// Sets the string value [name] ('' = the key's default) of
/// HKEY_CURRENT_USER\[subKey], creating the key; false when refused.
bool writeUserRegistryString(String subKey, String name, String data) {
final set = _advapi.lookupFunction<_SetNative, _Set>('RegSetKeyValueW');
return using((arena) {
final key = subKey.toNativeUtf16(allocator: arena);
final value = name.isEmpty ? nullptr : name.toNativeUtf16(allocator: arena);
final bytes = data.toNativeUtf16(allocator: arena);
return set(_hkeyCurrentUser, key, value.cast(), _regSz, bytes.cast(), (data.length + 1) * 2) == 0;
});
}
@@ -3,6 +3,8 @@ import 'dart:io';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:sync_relay_core/core.dart' show findInstallRoot; import 'package:sync_relay_core/core.dart' show findInstallRoot;
import 'url_scheme.dart';
/// The app's id: GTK's application id, so the Wayland `app_id` the /// The app's id: GTK's application id, so the Wayland `app_id` the
/// compositor matches to [desktopFileName] for the window's icon. /// compositor matches to [desktopFileName] for the window's icon.
const appId = 'dev.foxyz.starsync'; const appId = 'dev.foxyz.starsync';
@@ -18,10 +20,17 @@ const _iconSizes = [16, 32, 48, 64, 128, 256, 512];
/// Wayland shows a generic icon. On start: write the `.desktop` file into /// Wayland shows a generic icon. On start: write the `.desktop` file into
/// `~/.local/share/applications` (Exec = this binary) and the icon into /// `~/.local/share/applications` (Exec = this binary) and the icon into
/// the hicolor theme, only when something changed (a moved install, a new /// the hicolor theme, only when something changed (a moved install, a new
/// icon). The entry and icons of [legacyAppId] go. Never in debug runs /// icon). The entry and icons of [legacyAppId] go. The entry also takes
/// (unless [force], for tests); failures only log. /// `starsync://` links ([schemeMimeType], %u) and the app becomes their
Future<void> installDesktopEntry( /// default handler ([registerScheme], default [registerLinuxScheme]).
{String? executable, String? dataHome, String? home, @visibleForTesting bool force = false}) async { /// Never in debug runs (unless [force], for tests); failures only log.
Future<void> installDesktopEntry({
String? executable,
String? dataHome,
String? home,
@visibleForTesting bool force = false,
Future<void> Function(String desktopFile, String applicationsDir)? registerScheme,
}) async {
if (!Platform.isLinux || (kDebugMode && !force)) return; if (!Platform.isLinux || (kDebugMode && !force)) return;
try { try {
final exe = executable ?? Platform.resolvedExecutable; final exe = executable ?? Platform.resolvedExecutable;
@@ -41,9 +50,11 @@ Future<void> installDesktopEntry(
} }
final entry = desktopEntryText(exe); final entry = desktopEntryText(exe);
final file = File('$share/applications/$desktopFileName'); final file = File('$share/applications/$desktopFileName');
if (file.existsSync() && file.readAsStringSync() == entry) return; if (!file.existsSync() || file.readAsStringSync() != entry) {
await file.parent.create(recursive: true); await file.parent.create(recursive: true);
await file.writeAsString(entry); await file.writeAsString(entry);
}
await (registerScheme ?? registerLinuxScheme)(desktopFileName, file.parent.path);
} catch (e) { } catch (e) {
debugPrint('desktop entry: $e'); debugPrint('desktop entry: $e');
} }
@@ -69,9 +80,11 @@ String desktopEntryText(String exe) => '''[Desktop Entry]
Type=Application Type=Application
Name=Starsync Name=Starsync
Comment=Watch videos in sync with friends Comment=Watch videos in sync with friends
Exec="$exe" Exec="$exe" %u
Icon=$appId Icon=$appId
StartupWMClass=$appId StartupWMClass=$appId
StartupNotify=true
MimeType=$schemeMimeType;
Categories=AudioVideo;Video;Player;Network; Categories=AudioVideo;Video;Player;Network;
Terminal=false Terminal=false
'''; ''';
@@ -148,6 +148,13 @@ abstract interface class PlayerHost {
/// start). /// start).
HostedVideo video(HostedPlayer player, {bool probe = false}); HostedVideo video(HostedPlayer player, {bool probe = false});
/// The view's pixel size: every video output renders the picture fitted
/// in it ([fitOutputSize]), so mpv scales the picture and draws
/// subtitles at screen resolution (Settings → Player, "Sharp video").
/// Null: each output at its video's own size. Applies to live outputs
/// and to new ones once they are up.
void setOutputSize(int? width, int? height);
/// How video renders, as last learned (kept after leaving). /// How video renders, as last learned (kept after leaving).
ReadonlySignal<RenderMode> get renderMode; ReadonlySignal<RenderMode> get renderMode;
@@ -184,9 +191,39 @@ abstract interface class PlayerHost {
int get live; int get live;
} }
/// [log] says ffmpeg's HTTP stream lost the file's size and so every
/// seek on it fails ("Seek failed (to 3417735, size -38)": -38 is ENOSYS,
/// what ffmpeg answers for the size once a reconnect's or a seek's request
/// failed). Only a fresh open gets it back.
bool mpvStreamLost(PlayerLogLine log) =>
log.prefix == 'ffmpeg' && log.text.contains('Seek failed') && log.text.contains('size -38)');
/// [log] says the media could not be opened: before a [duration] is known, /// [log] says the media could not be opened: before a [duration] is known,
/// mpv's fatal "nothing to play" or a demuxer error. /// mpv's fatal "nothing to play" or a demuxer error.
bool mpvLoadFailed(PlayerLogLine log, Duration duration) { bool mpvLoadFailed(PlayerLogLine log, Duration duration) {
return duration == Duration.zero && return duration == Duration.zero &&
((log.prefix == 'cplayer' && log.level == 'fatal') || (log.prefix == 'demux' && log.level == 'error')); ((log.prefix == 'cplayer' && log.level == 'fatal') || (log.prefix == 'demux' && log.level == 'error'));
} }
/// The size a video output renders at for a [view] of that many pixels:
/// the [video]'s picture (its display size, aspect included) fitted
/// inside, no letterbox. The texture then has the picture's aspect, so
/// while a resize settles Flutter scales the last one to the new view
/// with no jump (a texture of the whole view would shrink inside its own
/// black bars until the new one came). Drawn 1:1: the fitted side is the
/// view's, the other is rounded down to the view's parity, so the
/// picture sits on whole pixels when centred (a half-pixel offset would
/// blur every line). Unknown video: the view.
(int, int) fitOutputSize((int, int) view, (int, int)? video) {
final (vw, vh) = view;
if (video == null || video.$1 <= 0 || video.$2 <= 0) return view;
final aspect = video.$1 / video.$2;
int under(double x, int full) {
var n = x.floor().clamp(1, full);
if ((full - n).isOdd) n = n > 1 ? n - 1 : n;
return n;
}
if (vw / vh > aspect) return (under(vh * aspect, vw), vh); // pillarbox
return (vw, under(vw / aspect, vh)); // letterbox (or exact)
}
@@ -43,8 +43,10 @@ abstract interface class ShellPort {
Future<void> quit(); Future<void> quit();
/// Brings the window up and gives it the focus (restored when /// Brings the window up and gives it the focus (restored when
/// minimised, shown when hidden): a click on a system notification. /// minimised, shown when hidden): a click on a system notification, a
Future<void> focus(); /// link handed over by a second start ([activation]: that start's
/// desktop activation token, which lets the window take the focus).
Future<void> focus({String activation = ''});
/// Points the desktop at the window when it is not focused (taskbar /// Points the desktop at the window when it is not focused (taskbar
/// flash, urgency hint); nothing when it is. /// flash, urgency hint); nothing when it is.
@@ -53,6 +55,10 @@ abstract interface class ShellPort {
/// The clipboard's files, else its text (Ctrl+V outside a text field). /// The clipboard's files, else its text (Ctrl+V outside a text field).
Future<Paste> readPaste(); Future<Paste> readPaste();
/// The clipboard's text only (the join prompt looks for an invite in
/// it); null when there is none or it can't be read.
Future<String?> readClipboardText();
/// [child] taking dropped files ([onDrop]: their paths and where they /// [child] taking dropped files ([onDrop]: their paths and where they
/// landed); [enable] off lets drops through. /// landed); [enable] off lets drops through.
Widget dropTarget({ Widget dropTarget({
@@ -0,0 +1,205 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:math';
import 'package:flutter/foundation.dart' show debugPrint, visibleForTesting;
/// What a second start hands to the running app: its arguments (a
/// clicked `starsync://` link) and the desktop's activation token for
/// bringing the window up (Linux: `XDG_ACTIVATION_TOKEN` /
/// `DESKTOP_STARTUP_ID`; '' when none).
typedef Handoff = ({List<String> args, String activation});
const _lockName = 'instance.lock';
const _infoName = 'instance.json';
/// Lock paths this process holds: a second claim from the same process
/// (tests) sees them held, as another process would.
final _heldHere = <String>{};
/// One app per profile folder at a time. The first start holds an
/// exclusive lock on `instance.lock` in [dir] (the OS drops it when the
/// process ends, a crash too) and listens on a loopback port written to
/// `instance.json` with a random token. A later start finds the lock
/// held, sends its [Handoff] there and ends; the running app comes to
/// the front and handles it ([handoffs]). Pure dart:io: the same on
/// Linux (Wayland and X11) and Windows.
class InstanceLock {
InstanceLock._(this.dir, this._lock, this._server, this._token);
/// The profile folder (each `STARSYNC_PROFILE` has its own, so each is
/// its own instance).
final String dir;
final RandomAccessFile? _lock;
final ServerSocket? _server;
final String _token;
final _handoffs = StreamController<Handoff>();
bool _closing = false;
bool _released = false;
/// What later starts handed over, in order (buffered until listened to).
Stream<Handoff> get handoffs => _handoffs.stream;
/// False when the lock could not be had nor the running app reached
/// (this app runs anyway, as before single instance).
bool get held => _lock != null;
/// Claims [dir] for this process, or hands [args] to the app that holds
/// it: null when handed over (this start should end now). A holder
/// that is closing (or not listening yet) is retried for [retryFor]
/// ([closingWait] once it said it is closing, so a start during a close
/// or an update's restart takes over after it). [beforeSend] gets the
/// holder's process id first (Windows: allow it to take the
/// foreground).
static Future<InstanceLock?> claim(
String dir, {
List<String> args = const [],
String activation = '',
void Function(int pid)? beforeSend,
Duration retryFor = const Duration(seconds: 5),
Duration closingWait = const Duration(seconds: 20),
Duration retryEvery = const Duration(milliseconds: 200),
}) async {
await Directory(dir).create(recursive: true);
final started = DateTime.now();
var closingSeen = false;
while (true) {
final lock = await _tryLock(dir);
if (lock != null) return _listen(dir, lock);
final answer = await _send(dir, (args: args, activation: activation), beforeSend);
if (answer == _Answer.ok) return null;
if (answer == _Answer.closing) closingSeen = true;
final waited = DateTime.now().difference(started);
if (waited > (closingSeen ? closingWait : retryFor)) {
debugPrint('single instance: no lock and no answer after ${waited.inMilliseconds} ms; starting anyway');
return InstanceLock._(dir, null, null, '');
}
await Future.delayed(retryEvery);
}
}
static Future<RandomAccessFile?> _tryLock(String dir) async {
final path = '$dir${Platform.pathSeparator}$_lockName';
if (_heldHere.contains(path)) return null;
RandomAccessFile? raf;
try {
raf = await File(path).open(mode: FileMode.append);
await raf.lock(FileLock.exclusive);
_heldHere.add(path);
return raf;
} catch (_) {
if (raf != null) await _quiet(raf.close());
return null;
}
}
static Future<InstanceLock> _listen(String dir, RandomAccessFile lock) async {
final token = base64Url.encode([for (var i = 0; i < 18; i++) Random.secure().nextInt(256)]);
ServerSocket? server;
try {
server = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0);
await File('$dir${Platform.pathSeparator}$_infoName')
.writeAsString(jsonEncode({'port': server.port, 'token': token, 'pid': pid}), flush: true);
} catch (e) {
debugPrint('single instance: cannot listen for later starts: $e');
}
final self = InstanceLock._(dir, lock, server, token);
server?.listen(self._serve, onError: (Object e) => debugPrint('single instance: $e'));
return self;
}
Future<void> _serve(Socket socket) async {
try {
final line = await utf8.decoder
.bind(socket)
.transform(const LineSplitter())
.first
.timeout(const Duration(seconds: 3));
if (line.length > 64 * 1024) throw const FormatException('too long');
final j = jsonDecode(line);
if (j is! Map || j['token'] != _token) {
socket.writeln(jsonEncode({'ok': false}));
} else if (_closing) {
socket.writeln(jsonEncode({'closing': true}));
} else {
final args = j['args'];
final activation = j['activation'];
_handoffs.add((
args: [if (args is List) for (final a in args) if (a is String) a],
activation: activation is String ? activation : '',
));
socket.writeln(jsonEncode({'ok': true}));
}
await socket.flush();
} catch (e) {
debugPrint('single instance: a later start sent nothing usable (${e.runtimeType})');
} finally {
await _quiet(socket.close());
}
}
static Future<_Answer> _send(String dir, Handoff h, void Function(int pid)? beforeSend) async {
Socket? socket;
try {
final info = jsonDecode(await File('$dir${Platform.pathSeparator}$_infoName').readAsString());
final port = info is Map ? info['port'] : null;
final token = info is Map ? info['token'] : null;
final holder = info is Map ? info['pid'] : null;
if (port is! int || token is! String) return _Answer.none;
if (holder is int) beforeSend?.call(holder);
socket = await Socket.connect(InternetAddress.loopbackIPv4, port, timeout: const Duration(seconds: 2));
socket.writeln(jsonEncode({'token': token, 'args': h.args, 'activation': h.activation}));
await socket.flush();
final reply = await utf8.decoder
.bind(socket)
.transform(const LineSplitter())
.first
.timeout(const Duration(seconds: 3));
final r = jsonDecode(reply);
if (r is Map && r['ok'] == true) return _Answer.ok;
if (r is Map && r['closing'] == true) return _Answer.closing;
return _Answer.none;
} catch (_) {
// Not written yet, a stale one from a crash, or not listening yet.
return _Answer.none;
} finally {
socket?.destroy();
}
}
/// The app is closing: later starts are told so and wait for the lock
/// instead of handing over to an app on its way out.
void closing() => _closing = true;
/// Gives the folder up before this process ends (Update and restart:
/// the new binary claims it at once).
Future<void> release() async {
if (_released) return;
_released = true;
_closing = true;
await _server?.close();
final lock = _lock;
if (lock == null) return;
try {
await File('$dir${Platform.pathSeparator}$_infoName').delete();
} catch (_) {}
try {
await lock.unlock();
} catch (_) {}
await _quiet(lock.close());
_heldHere.remove('$dir${Platform.pathSeparator}$_lockName');
unawaited(_handoffs.close());
}
@visibleForTesting
int? get port => _server?.port;
}
enum _Answer { ok, closing, none }
Future<void> _quiet(Future<Object?> f) async {
try {
await f;
} catch (_) {}
}
+12 -4
View File
@@ -12,9 +12,14 @@ import 'ports/player_host.dart';
// in a session player with a video output through the media pipeline, // in a session player with a video output through the media pipeline,
// play a second and close the normal way ([shutDownApp]). Progress goes to // play a second and close the normal way ([shutDownApp]). Progress goes to
// `STARSYNC_SMOKE_OUT` (one line per step: `start`, `opened <ms> render=<mode>`, // `STARSYNC_SMOKE_OUT` (one line per step: `start`, `opened <ms> render=<mode>`,
// `closing`, or `skip <why>` / `fail <why>`), so the script can tell a // `closing`, `quitting`, or `skip <why>` / `fail <why>`), so the script can
// runner that cannot show a window (skip) from a close that hangs or // tell a runner that cannot show a window (skip) from a close that hangs or
// leaves a process behind (fail). // leaves a process behind, or a process that ends on its own (fail). It
// stays up [smokeUptime] first: a start that dies by itself (the 1.3.0
// Windows exit timer) ends before `closing`.
/// How long the smoke run keeps the app up before closing it.
const smokeUptime = Duration(seconds: 10);
/// The smoke run's media, or null for a normal start. /// The smoke run's media, or null for a normal start.
String? get smokeMedia { String? get smokeMedia {
@@ -65,6 +70,9 @@ Future<void> runSmoke(AppGraph graph, String media) async {
} catch (e) { } catch (e) {
step('fail $e'); step('fail $e');
} }
final wait = smokeUptime - sw.elapsed;
if (wait > Duration.zero) await Future.delayed(wait);
step('closing'); step('closing');
await shutDownApp(); // Reached only through the app's own close (not a kill on the way).
await shutDownApp(beforeQuit: () async => step('quitting'));
} }
@@ -0,0 +1,81 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:sync_relay_core/core.dart' show findInstallRoot;
import '../session/invite.dart' show inviteScheme;
import 'desktop/windows_api.dart';
/// The MIME type desktops look up for a `starsync://` link (Linux).
const schemeMimeType = 'x-scheme-handler/$inviteScheme';
/// HKCU\Software\Classes\starsync: what Windows opens a link with.
const windowsSchemeKey = r'Software\Classes\' + inviteScheme;
/// The binary a link should start: `starsync.exe` next to an old-named
/// copy (`watch_together.exe`, kept for 1.2.x updaters) when it is there,
/// so links always start the current name.
String schemeExecutable(String exe, {bool Function(String path)? exists}) {
final has = exists ?? (p) => File(p).existsSync();
final sep = exe.contains(r'\') ? r'\' : '/';
final cut = exe.lastIndexOf(sep);
final name = exe.substring(cut + 1).toLowerCase();
if (name != 'watch_together.exe' && name != 'watch_together') return exe;
final current = '${exe.substring(0, cut + 1)}starsync${name.endsWith('.exe') ? '.exe' : ''}';
return has(current) ? current : exe;
}
/// The registry values of the Windows link handler for [exe]: (key under
/// HKEY_CURRENT_USER, value name ('' = default), data).
List<(String, String, String)> windowsSchemeValues(String exe) => [
(windowsSchemeKey, '', 'URL:Starsync invite'),
(windowsSchemeKey, 'URL Protocol', ''),
('$windowsSchemeKey\\DefaultIcon', '', '"$exe",0'),
('$windowsSchemeKey\\shell\\open\\command', '', '"$exe" "%1"'),
];
/// Windows: makes `starsync://` links start this install (per user, no
/// admin: HKCU\Software\Classes), only when the command there is not this
/// one already (first start, a moved install, an update from an old
/// name). Release installs only (never a debug run); failures only log.
Future<void> registerWindowsScheme({String? executable}) async {
if (!Platform.isWindows || kDebugMode) return;
try {
final exe = schemeExecutable(executable ?? Platform.resolvedExecutable);
if (findInstallRoot(exe, windows: true) == null) return;
final values = windowsSchemeValues(exe);
final command = values.last;
if (readUserRegistryString(command.$1, command.$2) == command.$3) return;
for (final (key, name, data) in values) {
if (!writeUserRegistryString(key, name, data)) {
debugPrint('url scheme: cannot write HKCU\\$key');
return;
}
}
debugPrint('url scheme: starsync:// links open $exe');
} catch (e) {
debugPrint('url scheme: $e');
}
}
/// Linux: makes this app the default for [schemeMimeType] (xdg-mime,
/// `~/.config/mimeapps.list`) unless it already is. [desktopFile]: the
/// launcher entry's file name, which lists the type.
Future<void> registerLinuxScheme(String desktopFile, String applicationsDir) async {
try {
final q = await Process.run('xdg-mime', ['query', 'default', schemeMimeType]);
if (q.exitCode == 0 && '${q.stdout}'.trim() == desktopFile) return;
final r = await Process.run('xdg-mime', ['default', desktopFile, schemeMimeType]);
if (r.exitCode != 0) {
debugPrint('url scheme: xdg-mime default failed: ${'${r.stderr}'.trim()}');
return;
}
// The MIME cache some desktops read (missing on some systems: fine).
try {
await Process.run('update-desktop-database', ['-q', applicationsDir]);
} catch (_) {}
debugPrint('url scheme: starsync:// links open $desktopFile');
} catch (e) {
debugPrint('url scheme: $e');
}
}
@@ -63,6 +63,10 @@ class PlaybackController {
/// e.g. a CDN's 403), at most once per [beginOpen]. /// e.g. a CDN's 403), at most once per [beginOpen].
void Function(String error)? onLoadError; void Function(String error)? onLoadError;
/// The current media's HTTP stream lost its size (see [mpvStreamLost]):
/// at most once per player and media; the coordinator opens it again.
void Function()? onStreamLost;
/// Seeks from the controls (seek bar, the video widget's own) go here /// Seeks from the controls (seek bar, the video widget's own) go here
/// instead of to mpv. /// instead of to mpv.
void Function(Duration position)? onUserSeek; void Function(Duration position)? onUserSeek;
@@ -71,6 +75,7 @@ class PlaybackController {
// load error was reported. // load error was reported.
String? _httpError; String? _httpError;
bool _loadErrorSent = false; bool _loadErrorSent = false;
bool _streamLostSent = false;
/// The current player. Only while [alive]. /// The current player. Only while [alive].
HostedPlayer get player => _player!; HostedPlayer get player => _player!;
@@ -144,6 +149,7 @@ class PlaybackController {
_chapterJson = ''; _chapterJson = '';
_httpError = null; _httpError = null;
_loadErrorSent = false; _loadErrorSent = false;
_streamLostSent = false;
_shaders = ''; _shaders = '';
_loaded = loaded && p.state.duration > Duration.zero; _loaded = loaded && p.state.duration > Duration.zero;
_opened = loaded; _opened = loaded;
@@ -196,6 +202,7 @@ class PlaybackController {
_chapterJson = ''; _chapterJson = '';
_httpError = null; _httpError = null;
_loadErrorSent = false; _loadErrorSent = false;
_streamLostSent = false;
_loaded = false; _loaded = false;
chapters.value = const []; chapters.value = const [];
tracks.value = const []; tracks.value = const [];
@@ -286,7 +293,14 @@ class PlaybackController {
// error while shaders are on is a shader that failed to load or compile // error while shaders are on is a shader that failed to load or compile
// (mpv then renders without it). // (mpv then renders without it).
void _onLog(PlayerLogLine log) { void _onLog(PlayerLogLine log) {
if (log.prefix == 'ffmpeg') return; if (log.prefix == 'ffmpeg') {
if (!_streamLostSent && mpvStreamLost(log)) {
_streamLostSent = true;
_log.warning('the media stream lost its size, every seek fails: [ffmpeg] ${log.text.trim()}');
onStreamLost?.call();
}
return;
}
if (!_loadErrorSent && mpvLoadFailed(log, duration.peek())) { if (!_loadErrorSent && mpvLoadFailed(log, duration.peek())) {
_loadErrorSent = true; _loadErrorSent = true;
final http = _httpError; final http = _httpError;
+210 -123
View File
@@ -16,23 +16,27 @@ import '../screens/home/picked_media.dart';
import '../session/app_events.dart' show AddedToUpNext; import '../session/app_events.dart' show AddedToUpNext;
import '../session/media_art.dart'; import '../session/media_art.dart';
import '../session/session_coordinator.dart'; import '../session/session_coordinator.dart';
import '../state/settings.dart';
import '../sync/avatar_cache.dart'; import '../sync/avatar_cache.dart';
import 'queue_logic.dart';
// Up next on this client (see ARCHITECTURE.md "Up next"): the server's // What's next on this app (see ARCHITECTURE.md "What's next" and "Up
// queue as signals, adding to it (probed, with a picture), the end-of-media // next"): the server's queue as signals (Up next and its continuation),
// countdown for its first item, putting an item up when this app is the // adding to it (probed, with a picture), the continuation of what this
// one that can, and keeping that item ready. // app put up (the next Jellyfin episode, the next file of a local show),
// the server's countdown as the end card shows it, putting an item up
// when the server asks this app (`queuePlay`), and keeping the item that
// plays next ready (the media pipeline prepares it).
/// The end-of-media card for the first Up next item. [secondsLeft] null: /// The end-of-media card for the first item (Up next's, else the
/// no countdown here (auto-advance off, cancelled, or another app puts /// continuation). [secondsLeft]: the server's countdown (`state.advance`);
/// it up), the card offers Play now only. /// null when none runs (cancelled, or nothing counts down), then the card
/// offers Play now only. [waitingFor]: the countdown ran out and the one
/// who puts the item up is not here (its name).
class UpNextCountdown { class UpNextCountdown {
final int mediaId; final int mediaId;
final QueueItem item; final QueueItem item;
final int? secondsLeft; final int? secondsLeft;
const UpNextCountdown({required this.mediaId, required this.item, this.secondsLeft}); final String? waitingFor;
const UpNextCountdown({required this.mediaId, required this.item, this.secondsLeft, this.waitingFor});
} }
/// The first item's readiness on this app. /// The first item's readiness on this app.
@@ -46,21 +50,33 @@ class QueueController {
late final PictureCache thumbs = PictureCache(coord.connection.client, late final PictureCache thumbs = PictureCache(coord.connection.client,
route: 'i', keep: RelayConstants.maxQueueItems + 8, live: (s) => [for (final q in s.queue) q.thumb]); route: 'i', keep: RelayConstants.maxQueueItems + 8, live: (s) => [for (final q in s.queue) q.thumb]);
/// Up next, in order (empty when nothing is queued or not in a server). /// Up next, in order, without the continuation (empty when nothing is
late final Computed<List<QueueItem>> items = /// queued or not in a server).
computed(() => coord.serverState.value?.queue ?? const <QueueItem>[]); late final Computed<List<QueueItem>> items = computed(
() => [for (final q in coord.serverState.value?.queue ?? const <QueueItem>[]) if (!q.auto) q]);
/// The continuation of what plays (`auto`: the next episode), shared by
/// everyone; null when none.
late final Computed<QueueItem?> continues =
computed(() => coord.serverState.value?.queue.where((q) => q.auto).firstOrNull);
/// What plays next: Up next's first item, else the continuation.
late final Computed<QueueItem?> head = computed(() => coord.serverState.value?.queue.firstOrNull);
/// The end card while the media has ended with something next (see
/// [UpNextCountdown]).
final countdown = signal<UpNextCountdown?>(null); final countdown = signal<UpNextCountdown?>(null);
/// The first item's id and how ready it is here (null: nothing to get /// The first item's id and how ready it is here (null: nothing to get
/// ready on this app). /// ready on this app).
final warm = signal<({String id, WarmState state})?>(null); final warm = signal<({String id, WarmState state})?>(null);
/// The natural continuation of what plays: the next episodes of the /// The next episodes of the Jellyfin series playing, on an app that
/// Jellyfin series (not queued; shown greyed under Up next). /// plays it with its own account (the first one is what continues it;
/// the rest show greyed under "Continues with", "+" adds one).
final continuation = signal<List<EpisodeLink>>(const []); final continuation = signal<List<EpisodeLink>>(const []);
/// The mediaId whose continuation was cleared here. /// The mediaId whose continuation was cleared (by anyone: `cancel`).
final _hiddenFor = signal(-1); final _hiddenFor = signal(-1);
/// The queue popover is open: the Up next button holds the controls up. /// The queue popover is open: the Up next button holds the controls up.
@@ -70,10 +86,12 @@ class QueueController {
// here, a file here). Never leaves this app. // here, a file here). Never leaves this app.
final Map<String, ({PickedMedia pick, DateTime at})> _local = {}; final Map<String, ({PickedMedia pick, DateTime at})> _local = {};
final Map<String, DateTime> _performing = {}; final Map<String, DateTime> _performing = {};
final Set<String> _skipped = {};
int _cancelledFor = -1;
Timer? _timer; Timer? _timer;
bool _attached = false; bool _attached = false;
// The mediaId whose continuation this app added (or is adding).
int _continuedFor = -1;
// A restarted server waits for what played (that mediaId) to come back.
int _restoreFrom = -1;
String _continuationFor = ''; String _continuationFor = '';
final _random = Random.secure(); final _random = Random.secure();
@@ -83,15 +101,26 @@ class QueueController {
if (_attached) return; if (_attached) return;
_attached = true; _attached = true;
coord.connection.states.listen(_onState); coord.connection.states.listen(_onState);
// `cancel` from anyone: no continuation for that media any more.
coord.connection.feeds.listen((f) {
final id = coord.serverState.peek()?.mediaId;
if (f.action == 'advance-cancel' && id != null) _hiddenFor.value = id;
});
effect(() { effect(() {
final next = coord.nextEpisode.value; final next = coord.nextEpisode.value;
final plan = coord.jellyfinPlan.value; final plan = coord.jellyfinPlan.value;
unawaited(_loadContinuation(next, plan)); unawaited(_loadContinuation(next, plan));
}); });
// The continuation and the state decide what to get ready; the media // What continues this media becomes known (the next episode found, a
// playing having started (or sitting out) lets the preload go. // file of ours up): add it.
effect(() {
coord.nextEpisode.value;
coord.ownFile.value;
untracked(() => _continue(coord.serverState.peek()));
});
// The state decides what to get ready; the media playing having
// started (or sitting out) lets the preload go.
effect(() { effect(() {
shownContinuation.value;
coord.settledMediaId.value; coord.settledMediaId.value;
coord.selfAway.value; coord.selfAway.value;
final r = coord.serverState.value; final r = coord.serverState.value;
@@ -319,62 +348,54 @@ class QueueController {
} }
} }
/// Plays the first Up next item now. /// Plays what comes next now (Up next's first item, else the
/// continuation).
void skip() { void skip() {
final first = items.peek().firstOrNull; final first = head.peek();
if (first != null) playNow(first.id); if (first != null) playNow(first.id);
} }
/// Cancel on the end-of-media card: no countdown here for this media. /// Cancel on the end card: the countdown stops for everyone, and the
/// continuation goes (`cancel`).
void cancelCountdown() { void cancelCountdown() {
final c = countdown.peek(); coord.markInput();
_timer?.cancel(); coord.connection.sendQueue(const QueueIntent.cancel());
_timer = null;
if (c == null) return;
_cancelledFor = c.mediaId;
countdown.value = UpNextCountdown(mediaId: c.mediaId, item: c.item);
} }
/// "Clear": hides the continuation of what plays now (and its next /// "Clear" under "Continues with": the continuation of what plays goes
/// episode does not come up by itself). /// for everyone (`cancel`) and does not come back for this media.
void clearContinuation() { void clearContinuation() {
final id = coord.serverState.peek()?.mediaId; final id = coord.serverState.peek()?.mediaId;
if (id != null) _hiddenFor.value = id; if (id != null) _hiddenFor.value = id;
if (coord.autoAdvance.peek() != null) coord.cancelAutoAdvance(); cancelCountdown();
} }
/// The continuation applies to [mediaId]: nothing is queued and it was /// The further episodes of [continuation] (after the one that continues
/// not cleared. /// what plays), unless it was cleared for the media playing.
bool continuationShown(int mediaId) =>
(coord.serverState.peek()?.queue.isEmpty ?? true) && _hiddenFor.peek() != mediaId;
/// [continuation] unless cleared for the media playing.
late final Computed<List<EpisodeLink>> shownContinuation = computed(() { late final Computed<List<EpisodeLink>> shownContinuation = computed(() {
final id = coord.serverState.value?.mediaId; final id = coord.serverState.value?.mediaId;
return id != null && _hiddenFor.value == id ? const <EpisodeLink>[] : continuation.value; if (id == null || _hiddenFor.value == id) return const <EpisodeLink>[];
final auto = continues.value?.media.jellyfin?.itemId;
return [for (final e in continuation.value) if (e.item.id != auto) e];
}); });
// --------------------------------------------------------- per state // --------------------------------------------------------- per state
void _onState(StateBroadcast r) { void _onState(StateBroadcast r) {
_lastState = r; _lastState = r;
// What played came back after a restart under a new mediaId: a
// continuation cleared for it stays cleared.
if (_restoreFrom != -1 && r.media != null && _hiddenFor.peek() == _restoreFrom) _hiddenFor.value = r.mediaId;
_prune(r); _prune(r);
final queue = r.queue; // The server asks for an item (a countdown ran out, Play now from
// another app, nothing up yet): the app that puts it up does.
final play = r.queuePlay; final play = r.queuePlay;
if (play != null) { if (play != null) {
final item = queue.where((q) => q.id == play).firstOrNull; final item = r.queue.where((q) => q.id == play).firstOrNull;
if (item != null) _bringUp(item, r); if (item != null && queuePerformer(item, r.members).id == _me) _perform(item);
}
final finished = r.transport == 'finished';
if (queue.isNotEmpty && r.media == null) {
// Nothing up yet: the first item goes up at once.
_bringUp(queue.first, r);
} else if (finished && queue.isNotEmpty) {
_atEnd(r);
} else {
if (countdown.peek() != null) _stopCountdown();
if (!finished) _cancelledFor = -1;
} }
_syncCountdown(r);
_continue(r);
_warm(r); _warm(r);
} }
@@ -385,75 +406,133 @@ class QueueController {
final now = DateTime.now(); final now = DateTime.now();
_performing.removeWhere((_, at) => now.difference(at) > const Duration(minutes: 1)); _performing.removeWhere((_, at) => now.difference(at) > const Duration(minutes: 1));
final keys = {for (final q in r.queue) q.key}; final keys = {for (final q in r.queue) q.key};
// Kept while a restarted server waits for what played: Up next comes
// back with it.
if (_restoreFrom == -1) {
_local.removeWhere((k, v) => !keys.contains(k) && now.difference(v.at) > const Duration(minutes: 1)); _local.removeWhere((k, v) => !keys.contains(k) && now.difference(v.at) > const Duration(minutes: 1));
final ids = {for (final q in r.queue) q.id}; }
_skipped.removeWhere((id) => !ids.contains(id));
} }
/// [item] should go up now: by this app when it is the one that can; /// The end card from the server's state: the media ended with something
/// nobody can: the lowest member takes it off with the reason. /// next; the seconds of `state.advance` move on with a 1 s timer.
void _bringUp(QueueItem item, StateBroadcast r) { void _syncCountdown(StateBroadcast r) {
final perf = queuePerformer(item, r.members);
if (perf.id == null) {
final why = perf.why;
if (why != null && queueJanitor(r.members) == _me && _skipped.add(item.id)) {
coord.connection.sendQueue(QueueIntent.remove(item.id, why: why));
}
return;
}
if (perf.id == _me) _perform(item);
}
/// The media finished with Up next waiting: the card, and with
/// auto-advance on the countdown, for its first item.
void _atEnd(StateBroadcast r) {
// Up next goes before the next episode, even one already counting.
if (coord.autoAdvance.peek() != null) coord.cancelAutoAdvance();
final head = r.queue.first;
final perf = queuePerformer(head, r.members);
if (perf.id == null && perf.why != null) {
// Taken off by [_bringUp]'s rule; the next state shows the next one.
_bringUp(head, r);
return;
}
final c = countdown.peek();
if (c != null && c.mediaId == r.mediaId) {
if (c.item.id != head.id || c.item.thumb != head.thumb) {
countdown.value = UpNextCountdown(mediaId: c.mediaId, item: head, secondsLeft: c.secondsLeft);
}
return;
}
final auto = AppSettings.instance.autoAdvance.peek() && _cancelledFor != r.mediaId;
countdown.value = UpNextCountdown(mediaId: r.mediaId, item: head, secondsLeft: auto ? autoAdvanceSecs : null);
_timer?.cancel();
_timer = auto ? Timer.periodic(const Duration(seconds: 1), (_) => _tick()) : null;
}
void _tick() {
final c = countdown.peek();
final r = coord.serverState.peek();
final left = c?.secondsLeft;
if (c == null || left == null || r == null || r.mediaId != c.mediaId || r.transport != 'finished') {
_stopCountdown();
return;
}
if (left > 1) {
countdown.value = UpNextCountdown(mediaId: c.mediaId, item: c.item, secondsLeft: left - 1);
return;
}
_timer?.cancel();
_timer = null;
_cancelledFor = c.mediaId;
countdown.value = UpNextCountdown(mediaId: c.mediaId, item: c.item);
// Decided on the latest state: only the app that can puts it up.
final head = r.queue.firstOrNull; final head = r.queue.firstOrNull;
if (head != null && queuePerformer(head, r.members).id == _me) _perform(head); if (r.transport != 'finished' || head == null || r.media == null) {
}
void _stopCountdown() {
_timer?.cancel(); _timer?.cancel();
_timer = null; _timer = null;
countdown.value = null; if (countdown.peek() != null) countdown.value = null;
return;
}
final a = r.advance != null && r.advance!.id == head.id ? r.advance : null;
int? left;
String? waiting;
if (a != null) {
left = advanceSecondsLeft(a, coord.connection.serverNow());
if (left == 0 && queuePerformer(head, r.members).id == null) {
waiting = r.members.where((m) => m.id == head.by).firstOrNull?.name ?? head.byName;
}
}
countdown.value = UpNextCountdown(mediaId: r.mediaId, item: head, secondsLeft: left, waitingFor: waiting);
if (a == null) {
_timer?.cancel();
_timer = null;
} else {
_timer ??= Timer.periodic(const Duration(seconds: 1), (_) {
final now = coord.serverState.peek();
if (now != null) _syncCountdown(now);
});
}
}
// ------------------------------------------------------------ restart
/// The server restarted without media; what played ([mediaId]) is being
/// offered back (-1: that is over).
void restoreFrom(int mediaId) => _restoreFrom = mediaId;
/// What played is back after a server restart: Up next as [before] had
/// it comes back too. The app whose offer won ([winner]) adds the items
/// anyone can put up (links everyone opens, Jellyfin items); every app
/// its own links passed on and files (only it has them; [self] is its
/// member id then). A server-fetched link's address stayed with the old
/// server, so it is gone; the continuation comes back as usual.
void restoreAfterRestart(StateBroadcast before, String self, {required bool winner}) {
for (final q in before.queue) {
if (q.auto) continue;
final ok = switch (q.how) {
QueueHow.direct || QueueHow.jellyfin => winner,
QueueHow.through || QueueHow.file => self.isNotEmpty && q.by == self && _local.containsKey(q.key),
QueueHow.server => false,
};
if (!ok) continue;
final local = _local[q.key];
if (local != null) _local[q.key] = (pick: local.pick, at: DateTime.now());
final thumb = thumbs.bytesFor(q.thumb);
coord.connection.sendQueue(QueueIntent.add(
QueueItem(media: q.media, how: q.how, title: q.title, sub: q.sub, duration: q.duration, key: q.key),
thumb: thumb == null ? '' : base64Encode(thumb)));
}
_restoreFrom = -1;
}
// ------------------------------------------------------- continuation
/// Adds what continues the media playing (`auto`, PROTOCOL.md "Queue")
/// when this app may ([mayAddContinuation]: it put it up, or that
/// member is not in and this is the first who is), knows what comes
/// next, and it is not there or cancelled yet; with the setting on. A
/// Jellyfin episode played with this app's account continues with the
/// series' next one, a file this app put up with the next episode of its
/// show in the library (numbers skipped on the way said in its line).
void _continue(StateBroadcast? r) {
if (r == null || r.media == null || !coord.settings.autoAdvance.peek()) return;
final id = r.mediaId;
if (_continuedFor == id || _hiddenFor.peek() == id) return;
if (r.queue.any((q) => q.auto && q.forMedia == id)) {
_continuedFor = id;
return;
}
final swapBy = r.media?['swapBy'];
if (!mayAddContinuation(_me, swapBy is String ? swapBy : null, r.members)) return;
final ref = refOfStateMedia(r.media);
final next = coord.nextEpisode.peek();
if (ref != null) {
if (next == null || !sameJellyfinItem(ref, coord.currentRef.peek())) return;
_continuedFor = id;
unawaited(_addContinuation(id, next.pick));
return;
}
final own = coord.ownFile.peek();
final finder = coord.nextFile;
if (own == null || own.$1 != id || finder == null) return;
_continuedFor = id;
unawaited(() async {
try {
final n = await finder(own.$2);
if (n == null) return;
final gap = n.skipped;
await _addContinuation(id, FileMedia(n.episode.path),
note: gap == null ? null : 'skips ${gap.label}');
} catch (e) {
debugPrint('queue: what continues ${own.$2}: $e');
}
}());
}
Future<void> _addContinuation(int mediaId, PickedMedia pick, {String? note}) async {
try {
final b = await _build(pick);
if (b is! ({QueueItem item, Uint8List? thumb})) return;
final r = coord.serverState.peek();
if (r == null || r.mediaId != mediaId || _hiddenFor.peek() == mediaId) return;
final sub = [b.item.sub, ?note].where((s) => s.isNotEmpty).join(' · ');
final item = b.item.copyWith(auto: true, forMedia: mediaId, sub: sub);
if (item.key.isNotEmpty) _local[item.key] = (pick: pick, at: DateTime.now());
final thumb = b.thumb;
coord.connection.sendQueue(QueueIntent.add(item, thumb: thumb == null ? '' : base64Encode(thumb)));
} catch (e) {
debugPrint('queue: adding what continues media $mediaId: $e');
}
} }
/// Puts [item] up from here (once per item while it is in flight). /// Puts [item] up from here (once per item while it is in flight).
@@ -530,9 +609,8 @@ class QueueController {
bool _ownAccount(String serverId) => bool _ownAccount(String serverId) =>
coord.jellyfin.account(serverId) != null && coord.jellyfin.statusOf(serverId) != JellyfinAccountStatus.expired; coord.jellyfin.account(serverId) != null && coord.jellyfin.statusOf(serverId) != JellyfinAccountStatus.expired;
/// What this app gets ready: Up next's first item, else (Up next empty) /// What this app gets ready: the first item of the queue (Up next's,
/// the first continuation episode (only here when this app plays the /// else the continuation). Every app does, each its own copy (see
/// series with its own account). Every app does, each its own copy (see
/// [_WarmTarget]); while sitting out only the probe of a link this app /// [_WarmTarget]); while sitting out only the probe of a link this app
/// puts up (its swap reads it). Null: nothing. /// puts up (its swap reads it). Null: nothing.
_WarmTarget? _warmTarget(StateBroadcast? r) { _WarmTarget? _warmTarget(StateBroadcast? r) {
@@ -579,9 +657,7 @@ class QueueController {
return performer ? _WarmTarget(head.id) : null; return performer ? _WarmTarget(head.id) : null;
} }
} }
final next = shownContinuation.peek().firstOrNull; return null;
if (next == null) return null;
return _WarmTarget('next:${next.item.id}', ref: mediaForPick(next.pick).jellyfin);
} }
/// Keeps the one item that plays next ready on this app, so a skip or /// Keeps the one item that plays next ready on this app, so a skip or
@@ -613,6 +689,9 @@ class QueueController {
// The warm relay this app feeds (`<queue id>|<token>`), started once. // The warm relay this app feeds (`<queue id>|<token>`), started once.
String _fedWarm = ''; String _fedWarm = '';
// The link whose warm reader is being set up: its preload waits for it
// (it then reads that reader, not the site).
String? _feedingLink;
/// Feeds the server's warm relay of Up next's first item from here when /// Feeds the server's warm relay of Up next's first item from here when
/// it names this member as its source (a link passed on, a file), or a /// it names this member as its source (a link passed on, a file), or a
@@ -636,15 +715,22 @@ class QueueController {
coord.media.feeder.feedWarm(head.id, RelayMedia.single(FileRelayBytes(File(pick.path)))); coord.media.feeder.feedWarm(head.id, RelayMedia.single(FileRelayBytes(File(pick.path))));
} else if (pick is LinkMedia) { } else if (pick is LinkMedia) {
_fedWarm = key; _fedWarm = key;
_feedingLink = pick.url;
unawaited(() async { unawaited(() async {
try { try {
// The probe [_resolveWarm] keeps: no second yt-dlp run. // The probe [_resolveWarm] keeps: no second yt-dlp run.
final probe = await coord.ytDlp.probe(pick.url, keep: _warmKeep).timeout(const Duration(seconds: 60)); final probe = await coord.ytDlp.probe(pick.url, keep: _warmKeep).timeout(const Duration(seconds: 60));
final y = await coord.ytDlp.ytDlp; final y = await coord.ytDlp.ytDlp;
if (_fedWarm != key) return; if (_fedWarm != key) return;
coord.media.feeder.feedWarm(head.id, LinkRelayMedia(Uri.parse(pick.url), ytDlp: y, probe: probe)); // Read once for the preload and the warm relay.
await coord.media.feedWarmLink(head.id, pick.url, probe, y);
} catch (e) { } catch (e) {
debugPrint('queue: feeding ${head.id}: $e'); debugPrint('queue: feeding ${head.id}: $e');
} finally {
if (_feedingLink == pick.url) {
_feedingLink = null;
_syncPreload(coord.serverState.peek());
}
} }
}()); }());
} }
@@ -738,6 +824,7 @@ class QueueController {
return; return;
} }
if (!media.preloadEnabled) return; if (!media.preloadEnabled) return;
if (want.url == _feedingLink) return;
final key = '${want.url}|${want.headers}'; final key = '${want.url}|${want.headers}';
final fresh = _preloadFresh; final fresh = _preloadFresh;
// Asked for already: in flight, there, or failed (tried again with the // Asked for already: in flight, there, or failed (tried again with the
+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",
+111 -65
View File
@@ -12,7 +12,6 @@ import '../controls/minimal/style.dart';
import '../jellyfin/images.dart'; import '../jellyfin/images.dart';
import '../jellyfin/session_media.dart' show EpisodeLink; import '../jellyfin/session_media.dart' show EpisodeLink;
import '../screens/home/picked_media.dart'; import '../screens/home/picked_media.dart';
import '../session/session_coordinator.dart' show autoAdvanceSecs;
import '../shell/avatar.dart'; import '../shell/avatar.dart';
import '../shell/handoff.dart' show handoffHolds; import '../shell/handoff.dart' show handoffHolds;
import '../shell/library_link.dart' show LinkPreview, cachedLinkPreview; import '../shell/library_link.dart' show LinkPreview, cachedLinkPreview;
@@ -42,13 +41,10 @@ enum UpNextLook { standard, minimal }
/// plays it). Nothing when both are empty. /// plays it). Nothing when both are empty.
class UpNextButton extends StatefulWidget { class UpNextButton extends StatefulWidget {
final QueueController queue; final QueueController queue;
/// Plays the continuation's first episode (the next episode).
final VoidCallback onPlayNextEpisode;
final UpNextLook look; final UpNextLook look;
/// Held while the preview card is up, so the controls stay up with it. /// Held while the preview card is up, so the controls stay up with it.
final ControlsHold? hold; final ControlsHold? hold;
const UpNextButton( const UpNextButton({super.key, required this.queue, required this.look, this.hold});
{super.key, required this.queue, required this.onPlayNextEpisode, required this.look, this.hold});
@override @override
State<UpNextButton> createState() => _UpNextButtonState(); State<UpNextButton> createState() => _UpNextButtonState();
@@ -143,9 +139,9 @@ class _UpNextButtonState extends State<UpNextButton> {
final thumbRadius = _minimal ? MinimalStyle.radius - 4 : 6.0; final thumbRadius = _minimal ? MinimalStyle.radius - 4 : 6.0;
return SignalBuilder(builder: (context) { return SignalBuilder(builder: (context) {
final items = _q.items.value; final items = _q.items.value;
final cont = _q.shownContinuation.value; final auto = _q.continues.value;
final open = _q.popoverOpen.value; final open = _q.popoverOpen.value;
if (items.isEmpty && cont.isEmpty && !open) { if (items.isEmpty && auto == null && !open) {
// Gone with the preview up: let go of the controls. // Gone with the preview up: let go of the controls.
if (_preview.isShowing) { if (_preview.isShowing) {
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
@@ -155,21 +151,15 @@ class _UpNextButtonState extends State<UpNextButton> {
return const SizedBox.shrink(); return const SizedBox.shrink();
} }
final queued = items.isNotEmpty; final queued = items.isNotEmpty;
final item = items.firstOrNull; final item = items.firstOrNull ?? auto;
final episode = cont.firstOrNull;
final more = items.length - 1; final more = items.length - 1;
final String kicker; final String kicker;
final String title; final String title;
final Widget thumb; final Widget thumb;
if (item != null) { if (item != null) {
kicker = 'UP NEXT'; kicker = queued ? 'UP NEXT' : (item.sub.isEmpty ? 'NEXT' : 'NEXT · ${item.sub}');
title = item.title; title = item.title;
thumb = QueueThumb(queue: _q, item: item, width: thumbWidth, radius: thumbRadius); thumb = QueueThumb(queue: _q, item: item, width: thumbWidth, radius: thumbRadius);
} else if (episode != null) {
final n = episodeNumbers(episode.item);
kicker = n == null ? 'NEXT' : 'NEXT · $n';
title = episode.item.name;
thumb = _EpisodeThumb(queue: _q, episode: episode, width: thumbWidth, radius: thumbRadius);
} else { } else {
kicker = 'UP NEXT'; kicker = 'UP NEXT';
title = 'Nothing yet'; title = 'Nothing yet';
@@ -246,10 +236,8 @@ class _UpNextButtonState extends State<UpNextButton> {
), ),
), ),
); );
final playTip = queued final playTip = 'Play ${item?.title ?? ''} now ($playNextKeys)';
? 'Play ${item!.title} now ($playNextKeys)' final VoidCallback? play = item == null ? null : _q.skip;
: 'Play ${episode == null ? '' : episode.label} now ($playNextKeys)';
final VoidCallback? play = queued ? _q.skip : (episode != null ? widget.onPlayNextEpisode : null);
final playButton = _minimal final playButton = _minimal
? MinimalButton(key: const ValueKey('up-next-play'), icon: Icons.skip_next_rounded, tooltip: playTip, onPressed: play) ? MinimalButton(key: const ValueKey('up-next-play'), icon: Icons.skip_next_rounded, tooltip: playTip, onPressed: play)
: Tooltip( : Tooltip(
@@ -337,60 +325,35 @@ class UpNextPreview extends StatelessWidget {
child: Padding( child: Padding(
padding: const EdgeInsets.all(_pad), padding: const EdgeInsets.all(_pad),
child: SignalBuilder(builder: (context) { child: SignalBuilder(builder: (context) {
final item = queue.items.value.firstOrNull; final queued = queue.items.value.firstOrNull;
final episode = queue.shownContinuation.value.firstOrNull; final item = queued ?? queue.continues.value;
final warm = queue.warm.value; final warm = queue.warm.value;
const picture = _width - 2 * _pad; const picture = _width - 2 * _pad;
if (item != null) { if (item == null) return const SizedBox.shrink();
final r = queue.coord.serverState.value; final r = queue.coord.serverState.value;
final selfId = queue.coord.connection.memberId; final selfId = queue.coord.connection.memberId;
final more = queue.items.value.length - 1; final more = queue.items.value.length - 1;
final ready = warm?.id == item.id ? warm!.state : null; final ready = warm?.id == item.id ? warm!.state : null;
final kicker = queued == null
? 'CONTINUES WITH'
: more > 0
? 'UP NEXT · $more MORE AFTER IT'
: 'UP NEXT';
return Column( return Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
QueueThumb(queue: queue, item: item, width: picture, radius: 8), QueueThumb(queue: queue, item: item, width: picture, radius: 8),
const SizedBox(height: 10), const SizedBox(height: 10),
Text(more > 0 ? 'UP NEXT · $more MORE AFTER IT' : 'UP NEXT', style: _kicker), Text(kicker, style: _kicker),
const SizedBox(height: 3), const SizedBox(height: 3),
Text(item.title, maxLines: 2, overflow: TextOverflow.ellipsis, style: _title), Text(item.title, maxLines: 2, overflow: TextOverflow.ellipsis, style: _title),
if (item.sub.isNotEmpty) if (item.sub.isNotEmpty) Text(item.sub, maxLines: 1, overflow: TextOverflow.ellipsis, style: _sub),
Text(item.sub, maxLines: 1, overflow: TextOverflow.ellipsis, style: _sub),
const SizedBox(height: 6), const SizedBox(height: 6),
_MetaLine(item: item, members: r?.members ?? const [], selfId: selfId, wrap: true), _MetaLine(item: item, members: r?.members ?? const [], selfId: selfId, wrap: true),
_footer(ready, ready == null ? performerNote(queue, item, r) : null), _footer(ready, ready == null ? performerNote(queue, item, r) : null),
], ],
); );
}
if (episode == null) return const SizedBox.shrink();
final n = episodeNumbers(episode.item);
final runtime = episode.pick.runtime;
final sub = [episode.item.seriesName ?? '', if (runtime != null) queueClock(runtime.inSeconds.toDouble())]
.where((s) => s.isNotEmpty)
.join(' · ');
final overview = episode.item.overview?.trim() ?? '';
final ready = warm?.id == 'next:${episode.item.id}' ? warm!.state : null;
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_EpisodeThumb(queue: queue, episode: episode, width: picture, radius: 8),
const SizedBox(height: 10),
Text(n == null ? 'NEXT EPISODE' : 'NEXT EPISODE · $n', style: _kicker),
const SizedBox(height: 3),
Text(episode.item.name, maxLines: 2, overflow: TextOverflow.ellipsis, style: _title),
if (sub.isNotEmpty) Text(sub, maxLines: 1, overflow: TextOverflow.ellipsis, style: _sub),
if (overview.isNotEmpty) ...[
const SizedBox(height: 6),
Text(overview,
maxLines: 3,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Colors.white54, fontSize: 12, height: 1.35)),
],
_footer(ready, null),
],
);
}), }),
), ),
), ),
@@ -481,8 +444,7 @@ class _EpisodeThumb extends StatelessWidget {
final QueueController queue; final QueueController queue;
final EpisodeLink episode; final EpisodeLink episode;
final double width; final double width;
final double radius; const _EpisodeThumb({required this.queue, required this.episode, required this.width});
const _EpisodeThumb({required this.queue, required this.episode, required this.width, this.radius = 6});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -490,7 +452,7 @@ class _EpisodeThumb extends StatelessWidget {
final client = queue.coord.jellyfin.clientFor(e.pick.serverId); final client = queue.coord.jellyfin.clientFor(e.pick.serverId);
final image = e.item.ownImage(JellyfinImageType.primary) ?? e.item.image(JellyfinImageType.thumb); final image = e.item.ownImage(JellyfinImageType.primary) ?? e.item.image(JellyfinImageType.thumb);
return ClipRRect( return ClipRRect(
borderRadius: BorderRadius.circular(radius), borderRadius: BorderRadius.circular(6),
child: SizedBox( child: SizedBox(
width: width, width: width,
height: width * 9 / 16, height: width * 9 / 16,
@@ -838,6 +800,7 @@ class _QueuePanelState extends State<QueuePanel> {
), ),
child: SignalBuilder(builder: (context) { child: SignalBuilder(builder: (context) {
final items = _ordered(_q.items.value); final items = _ordered(_q.items.value);
final auto = _q.continues.value;
final cont = _q.shownContinuation.value; final cont = _q.shownContinuation.value;
final warm = _q.warm.value; final warm = _q.warm.value;
final r = _q.coord.serverState.value; final r = _q.coord.serverState.value;
@@ -848,7 +811,7 @@ class _QueuePanelState extends State<QueuePanel> {
children: [ children: [
_header(items.length), _header(items.length),
Flexible( Flexible(
child: items.isEmpty && cont.isEmpty && _adding.isEmpty child: items.isEmpty && auto == null && cont.isEmpty && _adding.isEmpty
? _empty() ? _empty()
: ListView( : ListView(
shrinkWrap: true, shrinkWrap: true,
@@ -900,7 +863,9 @@ class _QueuePanelState extends State<QueuePanel> {
adding: a, adding: a,
onDismiss: () => setState(() => _adding.remove(a)), onDismiss: () => setState(() => _adding.remove(a)),
), ),
if (cont.isNotEmpty) ..._continuation(cont, items.isEmpty && _adding.isEmpty), if (auto != null || cont.isNotEmpty)
..._continuation(auto, cont, items.isEmpty && _adding.isEmpty,
warm: auto != null && warm?.id == auto.id ? warm!.state : null),
], ],
), ),
), ),
@@ -956,7 +921,9 @@ class _QueuePanelState extends State<QueuePanel> {
), ),
); );
List<Widget> _continuation(List<EpisodeLink> cont, bool alone) => [ /// "Continues with": the continuation everyone shares ([auto], Clear
/// drops it for everyone), then the series' further episodes known here.
List<Widget> _continuation(QueueItem? auto, List<EpisodeLink> cont, bool alone, {WarmState? warm}) => [
Padding( Padding(
padding: EdgeInsets.fromLTRB(12, alone ? 0 : 8, 2, 0), padding: EdgeInsets.fromLTRB(12, alone ? 0 : 8, 2, 0),
child: Row( child: Row(
@@ -964,6 +931,7 @@ class _QueuePanelState extends State<QueuePanel> {
const Text('CONTINUES WITH', const Text('CONTINUES WITH',
style: TextStyle(color: Colors.white38, fontSize: 11, letterSpacing: 0.8, fontWeight: FontWeight.w600)), style: TextStyle(color: Colors.white38, fontSize: 11, letterSpacing: 0.8, fontWeight: FontWeight.w600)),
const Spacer(), const Spacer(),
if (auto != null)
TextButton( TextButton(
key: const ValueKey('queue-clear-continuation'), key: const ValueKey('queue-clear-continuation'),
style: TextButton.styleFrom(foregroundColor: Colors.white54, visualDensity: VisualDensity.compact), style: TextButton.styleFrom(foregroundColor: Colors.white54, visualDensity: VisualDensity.compact),
@@ -973,6 +941,7 @@ class _QueuePanelState extends State<QueuePanel> {
], ],
), ),
), ),
if (auto != null) _ContinuesRow(item: auto, queue: _q, warm: warm),
for (final e in cont) _ContinuationRow(episode: e, queue: _q), for (final e in cont) _ContinuationRow(episode: e, queue: _q),
]; ];
@@ -1479,6 +1448,76 @@ class _WarmDotState extends State<_WarmDot> with SingleTickerProviderStateMixin
} }
} }
/// The continuation's row (the `auto` item): greyed like what is not
/// queued yet, its picture with how ready it is here, ▶ plays it now.
class _ContinuesRow extends StatefulWidget {
final QueueItem item;
final QueueController queue;
final WarmState? warm;
const _ContinuesRow({required this.item, required this.queue, required this.warm});
@override
State<_ContinuesRow> createState() => _ContinuesRowState();
}
class _ContinuesRowState extends State<_ContinuesRow> {
bool _hover = false;
@override
Widget build(BuildContext context) {
final item = widget.item;
return MouseRegion(
onEnter: (_) => setState(() => _hover = true),
onExit: (_) => setState(() => _hover = false),
child: AnimatedOpacity(
duration: const Duration(milliseconds: 120),
opacity: _hover ? 0.95 : 0.7,
child: Container(
key: ValueKey('queue-continues-${item.id}'),
height: queueRowHeight,
padding: const EdgeInsets.only(left: _lead, right: 8),
decoration: BoxDecoration(
color: _hover ? AppMenuStyle.hover : Colors.transparent,
borderRadius: BorderRadius.circular(AppMenuStyle.rowRadius),
),
child: Row(
children: [
Stack(
children: [
QueueThumb(queue: widget.queue, item: item, width: _thumb),
if (widget.warm != null) Positioned(top: 3, right: 3, child: _WarmDot(state: widget.warm!)),
],
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(item.title, maxLines: 1, overflow: TextOverflow.ellipsis, style: _titleStyle),
Text(
[item.sub, queueClock(item.duration)].where((s) => s.isNotEmpty).join(' · '),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: _subStyle,
),
],
),
),
_RoundIcon(
key: ValueKey('queue-play-${item.id}'),
icon: Icons.play_arrow_rounded,
tooltip: 'Play now',
onPressed: () => widget.queue.playNow(item.id),
),
],
),
),
),
);
}
}
class _ContinuationRow extends StatefulWidget { class _ContinuationRow extends StatefulWidget {
final EpisodeLink episode; final EpisodeLink episode;
final QueueController queue; final QueueController queue;
@@ -1545,8 +1584,9 @@ class _ContinuationRowState extends State<_ContinuationRow> {
// -------------------------------------------------------------- end card // -------------------------------------------------------------- end card
/// When the media ended with Up next waiting: its picture, title and who /// When the media ended with something next (Up next's first item, else
/// added it, the countdown with Cancel, and Play now. /// the continuation): its picture, title and who added it, the server's
/// countdown with Cancel (for everyone), and Play now.
class UpNextCard extends StatelessWidget { class UpNextCard extends StatelessWidget {
final QueueController queue; final QueueController queue;
final UpNextCountdown countdown; final UpNextCountdown countdown;
@@ -1559,7 +1599,13 @@ class UpNextCard extends StatelessWidget {
final left = c.secondsLeft; final left = c.secondsLeft;
final r = queue.coord.serverState.value; final r = queue.coord.serverState.value;
final selfId = queue.coord.connection.memberId; final selfId = queue.coord.connection.memberId;
final waiting = left == null ? performerNote(queue, item, r) : null; final waitingFor = c.waitingFor;
final waiting = waitingFor != null
? 'Waiting for $waitingFor'
: left == null || left == 0
? performerNote(queue, item, r)
: null;
final what = item.auto ? 'Next' : 'Up next';
return AppGlass( return AppGlass(
key: const ValueKey('up-next-card'), key: const ValueKey('up-next-card'),
child: SizedBox( child: SizedBox(
@@ -1579,7 +1625,7 @@ class UpNextCard extends StatelessWidget {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text(left == null ? 'Up next' : 'Up next in $left s', Text(left == null || left == 0 ? what : '$what in $left s',
style: const TextStyle(color: Colors.white60, fontSize: 12)), style: const TextStyle(color: Colors.white60, fontSize: 12)),
const SizedBox(height: 2), const SizedBox(height: 2),
Text(item.title, Text(item.title,
@@ -1605,7 +1651,7 @@ class UpNextCard extends StatelessWidget {
ClipRRect( ClipRRect(
borderRadius: BorderRadius.circular(2), borderRadius: BorderRadius.circular(2),
child: TweenAnimationBuilder<double>( child: TweenAnimationBuilder<double>(
tween: Tween(end: left / autoAdvanceSecs), tween: Tween(end: left / RelayConstants.advanceSecs),
duration: const Duration(seconds: 1), duration: const Duration(seconds: 1),
builder: (context, v, _) => LinearProgressIndicator( builder: (context, v, _) => LinearProgressIndicator(
value: v, value: v,
@@ -6,6 +6,8 @@ import '../../shell/mark_problem.dart';
import '../../app/app_graph.dart'; import '../../app/app_graph.dart';
import '../../state/settings.dart'; import '../../state/settings.dart';
import '../../update/update_controller.dart'; import '../../update/update_controller.dart';
import '../../update/whats_new.dart';
import '../../widgets/whats_new_card.dart';
import '../../widgets/app_menu.dart'; import '../../widgets/app_menu.dart';
import '../../widgets/copy_open.dart'; import '../../widgets/copy_open.dart';
import 'settings_page.dart'; import 'settings_page.dart';
@@ -16,11 +18,40 @@ import 'update_indicator.dart';
const uoscCredit = 'Player timeline and controls inspired by uosc by tomasklaen, LGPL-2.1 ' const uoscCredit = 'Player timeline and controls inspired by uosc by tomasklaen, LGPL-2.1 '
'(github.com/tomasklaen/uosc).'; '(github.com/tomasklaen/uosc).';
/// Name, version, update state, the source link and the logs (Settings → /// Dev builds only ([devTools]): previews and experiments not decided
/// About). /// yet (DEVELOPMENT.md "Dev tools").
class DeveloperPanel extends StatelessWidget {
final VoidCallback? onDanmakuPreview;
const DeveloperPanel({super.key, this.onDanmakuPreview});
@override
Widget build(BuildContext context) {
final t = Theme.of(context).textTheme;
return SettingsPanel(
title: 'Developer',
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Test builds only: try what is not decided yet.', style: t.bodySmall?.copyWith(color: Colors.grey)),
const SizedBox(height: 8),
FilledButton.tonalIcon(
key: const ValueKey('danmaku-preview'),
onPressed: onDanmakuPreview,
icon: const Icon(Icons.subtitles_outlined, size: 18),
label: const Text('Danmaku preview'),
),
],
),
);
}
}
/// Name, version, update state, What's new in this version, the source
/// link and the logs (Settings → About).
class AboutPanel extends StatelessWidget { class AboutPanel extends StatelessWidget {
final UpdateController? updates; final UpdateController? updates;
const AboutPanel({super.key, this.updates}); final WhatsNew? whatsNew;
const AboutPanel({super.key, this.updates, this.whatsNew});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -36,6 +67,23 @@ class AboutPanel extends StatelessWidget {
style: t.bodyMedium?.copyWith(color: Colors.grey)), style: t.bodyMedium?.copyWith(color: Colors.grey)),
const SizedBox(height: 4), const SizedBox(height: 4),
UpdateStatusLine(controller: updates), UpdateStatusLine(controller: updates),
// This version's notes, once kept (the install, a check).
ValueListenableBuilder(
valueListenable: (updates ?? AppScope.of(context).updates).latest,
builder: (context, _, _) {
final notes = (whatsNew ?? AppScope.of(context).whatsNew).current;
if (notes == null) return const SizedBox.shrink();
return Padding(
padding: const EdgeInsets.only(top: 8),
child: FilledButton.tonalIcon(
key: const ValueKey('about-whats-new'),
onPressed: () => showWhatsNew(context, notes),
icon: const Icon(Icons.auto_awesome, size: 18),
label: Text("What's new in ${notes.version}"),
),
);
},
),
const SizedBox(height: kGap), const SizedBox(height: kGap),
Row( Row(
children: [ children: [
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:signals/signals_flutter.dart'; import 'package:signals/signals_flutter.dart';
import 'package:sync_relay_core/core.dart' show UpdateChannel, isDevBuild;
import '../../app/app_graph.dart'; import '../../app/app_graph.dart';
import '../../state/settings.dart'; import '../../state/settings.dart';
@@ -16,6 +17,7 @@ const sessionShortcuts = [
('Shift+→', 'Skip 1:30 (for everyone)'), ('Shift+→', 'Skip 1:30 (for everyone)'),
('PgUp / PgDn', 'Previous / next chapter'), ('PgUp / PgDn', 'Previous / next chapter'),
('F', 'Toggle fullscreen'), ('F', 'Toggle fullscreen'),
('Enter', 'Quick chat box (Enter sends, Esc closes)'),
('Tab', 'Open / close chat & activity'), ('Tab', 'Open / close chat & activity'),
('Ctrl+R', 'Reload the video'), ('Ctrl+R', 'Reload the video'),
('> or Shift+N', 'Put up the next Jellyfin episode'), ('> or Shift+N', 'Put up the next Jellyfin episode'),
@@ -194,6 +196,7 @@ class UpdateSettings extends StatelessWidget {
], ],
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
UpdateChannelSetting(controller: c, settings: s),
SignalBuilder( SignalBuilder(
builder: (context) => SwitchListTile( builder: (context) => SwitchListTile(
key: const ValueKey('auto-check-updates'), key: const ValueKey('auto-check-updates'),
@@ -209,6 +212,75 @@ class UpdateSettings extends StatelessWidget {
} }
} }
/// Stable or Dev (test builds). Leaving Dev in a test build asks whether
/// to go back to the latest release now when that one is older.
class UpdateChannelSetting extends StatelessWidget {
final UpdateController controller;
final AppSettings settings;
const UpdateChannelSetting({super.key, required this.controller, required this.settings});
Future<void> _choose(BuildContext context, UpdateChannel ch) async {
final s = settings;
if (ch == s.updateChannel.peek()) return;
s.setUpdateChannel(ch);
if (ch == UpdateChannel.stable && isDevBuild(controller.currentVersion)) {
final m = await controller.stableToGoBackTo();
if (m != null && context.mounted) {
final back = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text('Go back to stable ${m.version}?'),
content: Text('This is test build ${controller.currentVersion}, newer than the latest release. '
'Going back installs ${m.version} and restarts the app. Or stay on this build: '
'the next release newer than it is offered as usual.'),
actions: [
TextButton(
key: const ValueKey('stay-on-test-build'),
onPressed: () => Navigator.pop(context, false),
child: const Text('Stay on this build'),
),
TextButton(
key: const ValueKey('go-back-to-stable'),
onPressed: () => Navigator.pop(context, true),
child: Text('Go back to ${m.version}'),
),
],
),
);
if (back == true) {
await controller.goBackTo(m);
return;
}
}
}
await runUpdateCheck(controller, true);
}
@override
Widget build(BuildContext context) {
final s = settings;
return SignalBuilder(builder: (context) {
final ch = s.updateChannel.value;
return ListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Update channel'),
subtitle: Text('On ${ch == UpdateChannel.dev ? 'Dev' : 'Stable'}, running ${controller.currentVersion}. '
'${ch == UpdateChannel.dev ? 'Test builds and releases, whichever is newer; stays on Dev after a release' : 'Releases only'}'),
trailing: SegmentedButton<UpdateChannel>(
key: const ValueKey('update-channel'),
showSelectedIcon: false,
segments: const [
ButtonSegment(value: UpdateChannel.stable, label: Text('Stable')),
ButtonSegment(value: UpdateChannel.dev, label: Text('Dev')),
],
selected: {ch},
onSelectionChanged: (sel) => _choose(context, sel.first),
),
);
});
}
}
class AutoAdvanceSetting extends StatelessWidget { class AutoAdvanceSetting extends StatelessWidget {
const AutoAdvanceSetting({super.key}); const AutoAdvanceSetting({super.key});
@@ -220,8 +292,8 @@ class AutoAdvanceSetting extends StatelessWidget {
key: const ValueKey('auto-advance'), key: const ValueKey('auto-advance'),
contentPadding: EdgeInsets.zero, contentPadding: EdgeInsets.zero,
title: const Text('Play the next episode automatically'), title: const Text('Play the next episode automatically'),
subtitle: const Text('When a Jellyfin episode ends, a 10 s countdown puts up the next one; ' subtitle: const Text('When you put up an episode (Jellyfin, or a file of a show in Files), the next one '
'anyone can cancel it'), 'continues it after a 10 s countdown everyone sees; anyone can cancel it'),
value: s.autoAdvance.value, value: s.autoAdvance.value,
onChanged: s.setAutoAdvance, onChanged: s.setAutoAdvance,
), ),
@@ -1,3 +1,4 @@
import 'package:flutter/foundation.dart' show kDebugMode;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:sync_relay_core/core.dart' show appVersion, isDevBuild; import 'package:sync_relay_core/core.dart' show appVersion, isDevBuild;
@@ -26,6 +27,14 @@ String displayVersion({String version = appVersion, String pubspec = _pubspecVer
/// Settings tab): "1.2.1", "1.2.1-dev.2", "dev run". /// Settings tab): "1.2.1", "1.2.1-dev.2", "dev run".
String shortVersion({String version = appVersion}) => version == '0.0.0-dev' ? 'dev run' : version; String shortVersion({String version = appVersion}) => version == '0.0.0-dev' ? 'dev run' : version;
/// Dev tools (Settings → About → Developer, the experiments they switch
/// on) only in test builds and runs from source (a `-dev` version) and
/// debug runs, never in a release.
bool devToolsFor({String version = appVersion, bool debug = kDebugMode}) => debug || version.contains('-dev');
/// [devToolsFor] this build.
final bool devTools = devToolsFor();
const kRadius = 12.0; const kRadius = 12.0;
const kGap = 16.0; const kGap = 16.0;
@@ -1,13 +1,16 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:sync_relay_core/core.dart' show isDevBuild; import 'package:sync_relay_core/core.dart' show UpdateManifest, isDevBuild;
import '../../update/update_controller.dart'; import '../../update/update_controller.dart';
import '../../update/whats_new.dart';
import '../../widgets/update_banner.dart'; import '../../widgets/update_banner.dart';
import '../../widgets/whats_new_card.dart';
import 'ui.dart'; import 'ui.dart';
/// The band across the top of the window while an update is up for /// The band across the top of the window while an update is up for
/// grabs: "Starsync 1.2.1 is ready", its notes, Update and restart and /// grabs: "Starsync 1.2.1 is ready", its first changes (or its one-line
/// Later (gone until the next check or start). The shell keeps it off /// notes), What's new (the whole list, [showWhatsNew]), Update and restart
/// and Later (gone until the next check or start). The shell keeps it off
/// the full player while a video is on ([AppShell]). /// the full player while a video is on ([AppShell]).
class UpdateBar extends StatelessWidget { class UpdateBar extends StatelessWidget {
final UpdateController? controller; final UpdateController? controller;
@@ -31,6 +34,8 @@ class UpdateBar extends StatelessWidget {
final behind = isDevBuild(current) ? null : 'You are running $current'; final behind = isDevBuild(current) ? null : 'You are running $current';
final String title; final String title;
String detail; String detail;
// The offered version's first changes instead of its notes.
var preview = false;
switch (s.phase) { switch (s.phase) {
case UpdatePhase.downloading: case UpdatePhase.downloading:
title = 'Downloading $appName ${m.version}… ${((s.progress ?? 0) * 100).round()}%'; title = 'Downloading $appName ${m.version}… ${((s.progress ?? 0) * 100).round()}%';
@@ -51,9 +56,11 @@ class UpdateBar extends StatelessWidget {
} else if (m.isRequired) { } else if (m.isRequired) {
title = '$appName ${m.version} is required to join servers on it'; title = '$appName ${m.version} is required to join servers on it';
detail = m.notes; detail = m.notes;
preview = m.changes != null;
} else { } else {
title = '$appName ${m.version} is ready'; title = '$appName ${m.version} is ready';
detail = m.notes; detail = m.notes;
preview = m.changes != null;
} }
detail = [?behind, detail].where((t) => t.isNotEmpty).join(' · '); detail = [?behind, detail].where((t) => t.isNotEmpty).join(' · ');
} }
@@ -88,7 +95,9 @@ class UpdateBar extends StatelessWidget {
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: TextStyle(color: fg, fontWeight: FontWeight.w600)), style: TextStyle(color: fg, fontWeight: FontWeight.w600)),
if (detail.isNotEmpty) if (preview)
WhatsNewPreview(changes: m.changes!, color: fg, lead: behind)
else if (detail.isNotEmpty)
Tooltip( Tooltip(
message: detail, message: detail,
waitDuration: const Duration(milliseconds: 600), waitDuration: const Duration(milliseconds: 600),
@@ -102,6 +111,14 @@ class UpdateBar extends StatelessWidget {
), ),
if (!busy) ...[ if (!busy) ...[
const SizedBox(width: 12), const SizedBox(width: 12),
if (m.changes != null || m.notes.trim().isNotEmpty)
TextButton.icon(
key: const ValueKey('update-whats-new'),
style: TextButton.styleFrom(foregroundColor: fg),
onPressed: () => _openWhatsNew(context, c, m, s.canInstall),
icon: const Icon(Icons.auto_awesome, size: 16),
label: const Text("What's new"),
),
if (!m.isRequired) if (!m.isRequired)
TextButton( TextButton(
key: const ValueKey('update-later'), key: const ValueKey('update-later'),
@@ -127,6 +144,27 @@ class UpdateBar extends StatelessWidget {
} }
} }
/// The offered version's card, with Update and restart when it installs here.
void _openWhatsNew(BuildContext context, UpdateController c, UpdateManifest m, bool canInstall) {
showWhatsNew(
context,
ReleaseNotes.of(m),
overline: canInstall ? '$appName · ready to install' : '$appName · new release',
action: canInstall
? Builder(
builder: (context) => FilledButton(
key: const ValueKey('whats-new-install'),
onPressed: () {
Navigator.of(context).maybePop();
c.install();
},
child: const Text('Update and restart'),
),
)
: null,
);
}
/// The existing update card in a dialog; closes itself when the card /// The existing update card in a dialog; closes itself when the card
/// would render nothing (e.g. "Later"). /// would render nothing (e.g. "Later").
Future<void> showUpdateDialog(BuildContext context, {UpdateController? controller}) { Future<void> showUpdateDialog(BuildContext context, {UpdateController? controller}) {
@@ -1,8 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../jellyfin/session_media.dart'; import '../../jellyfin/session_media.dart';
import '../../session/session_coordinator.dart' show AutoAdvance, autoAdvanceSecs;
import '../../widgets/app_menu.dart' show AppGlass;
import 'player_controls.dart' show FloatingPill; import 'player_controls.dart' show FloatingPill;
// Jellyfin pieces of the session player. Plain widgets fed by the // Jellyfin pieces of the session player. Plain widgets fed by the
@@ -24,80 +22,6 @@ class NextEpisodePill extends StatelessWidget {
); );
} }
/// At the end of an episode: "Next: S1E7 title" with Play now, and while
/// the auto-advance countdown runs, the seconds left and Cancel.
class EndOfEpisodeCard extends StatelessWidget {
final EpisodeLink next;
final AutoAdvance? countdown;
final VoidCallback onPlayNow;
final VoidCallback onCancel;
const EndOfEpisodeCard({
super.key,
required this.next,
required this.countdown,
required this.onPlayNow,
required this.onCancel,
});
@override
Widget build(BuildContext context) {
final c = countdown;
// The same card as Up next's (`UpNextCard`): the app's glass, 400 wide.
return AppGlass(
key: const ValueKey('end-of-episode'),
child: SizedBox(
width: 400,
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(c == null ? 'Up next' : 'Next episode in ${c.secondsLeft} s',
style: const TextStyle(color: Colors.white60, fontSize: 12)),
const SizedBox(height: 2),
Text('Next: ${next.label}',
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Colors.white, fontSize: 15, fontWeight: FontWeight.w600, height: 1.25)),
if (c != null) ...[
const SizedBox(height: 10),
ClipRRect(
borderRadius: BorderRadius.circular(2),
child: TweenAnimationBuilder<double>(
tween: Tween(end: c.secondsLeft / autoAdvanceSecs),
duration: const Duration(seconds: 1),
builder: (context, v, _) =>
LinearProgressIndicator(value: v, minHeight: 3, backgroundColor: const Color(0x1FFFFFFF)),
),
),
],
const SizedBox(height: 10),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
if (c != null)
TextButton(
key: const ValueKey('auto-advance-cancel'),
onPressed: onCancel,
child: const Text('Cancel')),
const SizedBox(width: 8),
FilledButton.icon(
key: const ValueKey('play-next'),
onPressed: onPlayNow,
icon: const Icon(Icons.skip_next_rounded, size: 18),
label: Text(c == null ? 'Play next' : 'Play now'),
),
],
),
],
),
),
),
);
}
}
/// This viewer cannot play the Jellyfin media: say why and offer to link /// This viewer cannot play the Jellyfin media: say why and offer to link
/// an account (never a silent stall). /// an account (never a silent stall).
class JellyfinBlockedCard extends StatelessWidget { class JellyfinBlockedCard extends StatelessWidget {
+2 -1
View File
@@ -391,7 +391,8 @@ class Connection {
void sendReady(bool ready, {String source = 'manual'}) => client.sendReady(ready, source: source); void sendReady(bool ready, {String source = 'manual'}) => client.sendReady(ready, source: source);
void sendForce(String target, bool ready) => client.sendForce(target, ready); void sendForce(String target, bool ready) => client.sendForce(target, ready);
void sendSeek(double position, {int seq = 0}) => client.sendSeek(position, seq: seq); void sendSeek(double position, {int seq = 0}) => client.sendSeek(position, seq: seq);
void sendSwap(HelloMedia media, {String? queueId}) => client.sendSwap(media, queueId: queueId); void sendSwap(HelloMedia media, {String? queueId, SwapRestore? restore}) =>
client.sendSwap(media, queueId: queueId, restore: restore);
void sendQueue(QueueIntent intent) => client.sendQueue(intent); void sendQueue(QueueIntent intent) => client.sendQueue(intent);
void sendReport({ void sendReport({
required double position, required double position,
+175 -8
View File
@@ -2,6 +2,12 @@ import 'dart:io';
import 'package:flutter/foundation.dart' show debugPrint; import 'package:flutter/foundation.dart' show debugPrint;
/// The URL scheme of an invite link ([InviteLink]).
const inviteScheme = 'starsync';
/// The port a link without one means (the app's default sharing port).
const defaultInvitePort = 8080;
/// What a friend needs to join your server: where ([host], [port]), what /// What a friend needs to join your server: where ([host], [port]), what
/// it is called ([name]), whether it asks a password ([password]; the /// it is called ([name]), whether it asks a password ([password]; the
/// password itself is never in it) and which Starsync runs it /// password itself is never in it) and which Starsync runs it
@@ -26,13 +32,14 @@ class Invite {
/// What goes in the Join card's address and port. /// What goes in the Join card's address and port.
String get address => '${host.contains(':') ? '[$host]' : host}:$port'; String get address => '${host.contains(':') ? '[$host]' : host}:$port';
/// The text Invite copies: the address, whether to ask for the password, /// The same invite at another of this machine's addresses.
/// the version to match. Invite at(String host) =>
String get text => [ Invite(host: host, port: port, name: name, password: password, version: version, fp: fp);
'Join me on Starsync: $address',
if (password) '(ask me for the password)', /// Its `starsync://` link with the server's password ([secret], the
if (version.isNotEmpty) '· Starsync $version', /// host's; empty when none is set): what Invite copies, nothing around
].join(' '); /// it, so a friend just clicks it.
InviteLink link({String secret = ''}) => InviteLink(host: host, port: port, name: name, secret: secret);
@override @override
bool operator ==(Object other) => bool operator ==(Object other) =>
@@ -51,6 +58,97 @@ class Invite {
String toString() => 'Invite($address${password ? ', password' : ''})'; String toString() => 'Invite($address${password ? ', password' : ''})';
} }
/// A `starsync://host:port/?name=…&pw=…` invite link (PROTOCOL.md "Invite
/// link"): where to join, the server's name for the prompt, and the
/// password when the server has one. [toString] never shows the
/// password; [uri] does.
class InviteLink {
final String host;
final int port;
final String name;
final String secret;
const InviteLink({required this.host, required this.port, this.name = '', this.secret = ''});
String get address => '${host.contains(':') ? '[$host]' : host}:$port';
/// One per server (for "already asked about this one").
String get key => '${host.toLowerCase()}:$port';
/// The link itself (with the password, when it has one).
String get uri {
final query = [
if (name.isNotEmpty) 'name=${Uri.encodeQueryComponent(name)}',
if (secret.isNotEmpty) 'pw=${Uri.encodeQueryComponent(secret)}',
];
return '$inviteScheme://$address${query.isEmpty ? '' : '/?${query.join('&')}'}';
}
/// Null unless [text] is a `starsync://` link with a host and a usable
/// port (none means [defaultInvitePort]).
static InviteLink? parse(String text) {
final t = text.trim();
if (!t.toLowerCase().startsWith('$inviteScheme://')) return null;
final Uri u;
try {
u = Uri.parse(t);
} catch (_) {
return null;
}
final host = u.host.trim();
if (host.isEmpty || !_hostChars.hasMatch(host)) return null;
final port = u.hasPort ? u.port : defaultInvitePort;
if (port <= 0 || port > 65535) return null;
final Map<String, String> q;
try {
q = u.queryParameters;
} catch (_) {
return null;
}
return InviteLink(host: host, port: port, name: _cleanName(q['name'] ?? ''), secret: q['pw'] ?? '');
}
@override
bool operator ==(Object other) =>
other is InviteLink && other.host == host && other.port == port && other.name == name && other.secret == secret;
@override
int get hashCode => Object.hash(host, port, name, secret);
@override
String toString() => 'InviteLink($address${secret.isEmpty ? '' : ', password'})';
}
final _hostChars = RegExp(r'^[A-Za-z0-9.:-]+$');
final _control = RegExp(r'[\x00-\x1f\x7f]');
/// A link's name as shown in the prompt: no control characters, at most
/// 64 characters.
String _cleanName(String raw) {
final n = raw.replaceAll(_control, '').trim();
return n.length > 64 ? n.substring(0, 64) : n;
}
final _linkIn = RegExp('$inviteScheme://[^\\s<>"\'`]+', caseSensitive: false);
final _plainInvite = RegExp(r'Join me on Starsync:\s*(\S+)');
/// Only the start of a long text is searched (a clipboard can hold a book).
const _searchLimit = 4096;
/// The invite in [text]: a `starsync://` link anywhere in it, else the
/// address of a plain invite ("Join me on Starsync: 192.168.1.20:8080",
/// what 1.3.x copied). Null for anything else, a bare address included.
InviteLink? inviteIn(String text) {
final t = text.length > _searchLimit ? text.substring(0, _searchLimit) : text;
final m = _linkIn.firstMatch(t);
if (m != null) return InviteLink.parse(m[0]!);
final plain = _plainInvite.firstMatch(t);
if (plain == null) return null;
final a = addressIn(plain[1]!);
final port = a == null ? null : int.tryParse(a.port);
if (a == null || port == null || port <= 0 || port > 65535) return null;
return InviteLink(host: a.host, port: port);
}
/// The `host:port` in a pasted invite (or a bare address); null when /// The `host:port` in a pasted invite (or a bare address); null when
/// there is none. /// there is none.
({String host, String port})? addressIn(String text) { ({String host, String port})? addressIn(String text) {
@@ -74,9 +172,48 @@ bool isFakeIp(List<int> b) => b[0] == 198 && (b[1] == 18 || b[1] == 19);
bool _isPrivate(List<int> b) => bool _isPrivate(List<int> b) =>
b[0] == 10 || (b[0] == 192 && b[1] == 168) || (b[0] == 172 && b[1] >= 16 && b[1] < 32); b[0] == 10 || (b[0] == 192 && b[1] == 168) || (b[0] == 172 && b[1] >= 16 && b[1] < 32);
/// Shared address space (100.64.0.0/10): mesh VPNs like Tailscale. /// Shared address space (100.64.0.0/10): mesh VPNs like Tailscale (and
/// carrier-grade NAT).
bool _isShared(List<int> b) => b[0] == 100 && b[1] >= 64 && b[1] < 128; bool _isShared(List<int> b) => b[0] == 100 && b[1] >= 64 && b[1] < 128;
/// Where an invite address works.
enum AddressKind {
/// The internet: anyone, when the port is open to them.
public,
/// The same home network.
lan,
/// A mesh VPN (Tailscale and the like): everyone on it.
vpn,
}
/// [AddressKind] of an IPv4 address.
AddressKind addressKind(String ip) {
final a = InternetAddress.tryParse(ip);
if (a == null || a.type != InternetAddressType.IPv4) return AddressKind.public;
final b = a.rawAddress;
if (_isPrivate(b)) return AddressKind.lan;
if (_isShared(b)) return AddressKind.vpn;
return AddressKind.public;
}
/// [ip] is an IPv4 address the internet routes to this network: not
/// private, shared, loopback, link-local, fake-IP, multicast or
/// reserved (the documentation ranges pass: tests use them).
bool isPublicIpv4(String ip) {
final a = InternetAddress.tryParse(ip.trim());
if (a == null || a.type != InternetAddressType.IPv4) return false;
final b = a.rawAddress;
return !(b[0] == 0 ||
b[0] == 127 ||
b[0] >= 224 ||
(b[0] == 169 && b[1] == 254) ||
_isPrivate(b) ||
_isShared(b) ||
isFakeIp(b));
}
/// The addresses of [interfaces] a friend could dial, best first: IPv4, /// The addresses of [interfaces] a friend could dial, best first: IPv4,
/// no loopback, link-local or fake-IP address, no container or VM bridge; /// no loopback, link-local or fake-IP address, no container or VM bridge;
/// home networks (192.168, then 10, then 172.16/12) first, then a mesh /// home networks (192.168, then 10, then 172.16/12) first, then a mesh
@@ -107,6 +244,36 @@ List<String> inviteAddresses(Iterable<({String name, InternetAddress address})>
return {for (final (_, a) in indexed) a.address}.toList(); return {for (final (_, a) in indexed) a.address}.toList();
} }
/// One address an invite can carry and where it works.
typedef InviteAddress = ({String host, AddressKind kind});
/// What the members card offers for a shared server: the internet
/// address first when known ([public], from [lookUpPublicAddress]), then
/// this machine's own ([local], [inviteAddresses] order). [forwarding]:
/// the internet address is the router's, not this machine's, so friends
/// elsewhere get in only when the port is forwarded to it.
class InviteReach {
final String? public;
final List<String> local;
/// The internet lookup is still under way.
final bool looking;
const InviteReach({this.public, this.local = const [], this.looking = false});
bool get forwarding => public != null && !local.contains(public);
/// Every address, best first, each once: the internet one, then this
/// machine's.
List<InviteAddress> get choices => [
if (public != null) (host: public!, kind: AddressKind.public),
for (final l in local)
if (l != public) (host: l, kind: addressKind(l)),
];
/// The address an invite carries unless the user picks another: the
/// internet one when known, else the best of this machine's.
String? get best => choices.firstOrNull?.host;
}
/// This machine's interfaces as [inviteAddresses] takes them (empty when /// This machine's interfaces as [inviteAddresses] takes them (empty when
/// they can't be listed). /// they can't be listed).
Future<List<({String name, InternetAddress address})>> machineInterfaces() async { Future<List<({String name, InternetAddress address})>> machineInterfaces() async {
@@ -0,0 +1,141 @@
import 'dart:async';
import 'dart:convert';
import 'package:crypto/crypto.dart';
import 'package:signals/signals.dart';
import '../screens/home/health_probe.dart';
import '../state/connect_prefs.dart';
import 'invite.dart';
import 'navigation.dart';
import 'your_server.dart';
/// Where a join prompt came from.
enum OfferSource {
/// A clicked `starsync://` link (this app started with it, or a second
/// start handed it over).
link,
/// An invite found on the clipboard when the window came up.
clipboard,
}
/// A server the app offers to join: "Join Bob's server (1.2.3.4:8080)?".
class JoinOffer {
final InviteLink link;
final OfferSource source;
/// Its `/health` once read (name, version gate); null while asking.
final ServerHealth? health;
const JoinOffer(this.link, this.source, {this.health});
JoinOffer withHealth(ServerHealth h) => JoinOffer(link, source, health: h);
/// The server's own name (`/health`), else the link's, else ''.
String get name {
final own = health?.label ?? '';
return own.isNotEmpty ? own : link.name;
}
/// "Join Bob's server (1.2.3.4:8080)?"
String get question => name.isEmpty ? 'Join ${link.address}?' : 'Join $name (${link.address})?';
/// What `/health` said, in plain words: '' while asking.
String get detail {
final h = health;
if (h == null) return '';
if (!h.online) return "Can't reach it right now";
if (h.needsUpdate) return versionGateText(h);
return [
if (h.version.isNotEmpty) 'Starsync ${h.version}',
if (h.viewers > 0) '${h.viewers} watching',
].join(' · ');
}
}
/// A fingerprint of [link] (password included) for "already asked": the
/// link itself is never stored.
String inviteFingerprint(InviteLink link) =>
sha256.convert(utf8.encode('starsync-invite:${link.uri}')).toString().substring(0, 24);
/// The join prompt: a clicked invite link, or one found on the
/// clipboard, becomes an [offer] (`Join <name> (host:port)?`) with the
/// server's `/health`; [accept] hands back what to join, [dismiss] drops
/// it. A clipboard invite is offered once: joined or dismissed, the same
/// link is not offered again (a fingerprint is kept, never the link).
/// Links to your own server, or to the one you are in, are not offered.
/// Nothing here logs what the clipboard holds. Made by the composition
/// root.
class JoinOffers {
JoinOffers({required this.navigation, required this.yourServer, required this.forms});
final Navigation navigation;
final YourServer yourServer;
final ConnectStore forms;
final offer = signal<JoinOffer?>(null);
/// The clipboard text last looked at (its hash): the same text is not
/// parsed twice in a row.
int? _lastClip;
/// The app's start arguments, or a second start's: the first
/// `starsync://` link among them becomes an offer.
void fromArgs(List<String> args) {
for (final a in args) {
final link = InviteLink.parse(a);
if (link != null) {
_offer(link, OfferSource.link);
return;
}
}
}
/// The clipboard's text when the window came up ([text] null: none).
void fromClipboard(String? text) {
if (text == null || text.isEmpty) return;
final h = text.hashCode;
if (h == _lastClip) return;
_lastClip = h;
final link = inviteIn(text);
if (link == null || forms.seenInvite(inviteFingerprint(link))) return;
_offer(link, OfferSource.clipboard);
}
/// This app put [text] on the clipboard (Copy invite): never offered
/// back to it.
void copied(String text) => _lastClip = text.hashCode;
void _offer(InviteLink link, OfferSource source) {
if (yourServer.isOwn(link)) return;
if (navigation.place.peek() == Place.friend && navigation.address.peek() == '${link.host}:${link.port}') return;
final current = offer.peek();
if (current != null && current.link == link) return;
final o = JoinOffer(link, source);
offer.value = o;
unawaited(probeHealth(link.host, '${link.port}', navigation.healthGet).then((h) {
if (identical(offer.peek(), o)) offer.value = o.withHealth(h);
}));
}
/// Not now: a clipboard invite is not offered again.
Future<void> dismiss() async {
final o = offer.peek();
if (o == null) return;
offer.value = null;
if (o.source == OfferSource.clipboard) await forms.rememberInvite(inviteFingerprint(o.link));
}
/// Join: the link to join (its password, else the one saved for that
/// recent server); the invite is not offered again. Null when there
/// was none.
InviteLink? accept() {
final o = offer.peek();
if (o == null) return null;
offer.value = null;
unawaited(forms.rememberInvite(inviteFingerprint(o.link)));
final l = o.link;
if (l.secret.isNotEmpty) return l;
return InviteLink(host: l.host, port: l.port, name: l.name, secret: forms.secretFor(l.host, l.port));
}
}
@@ -15,6 +15,7 @@ import 'app_events.dart';
import 'delivery.dart' show stateMediaRelay; import 'delivery.dart' show stateMediaRelay;
import 'media_plan.dart'; import 'media_plan.dart';
import 'relay_feeder.dart'; import 'relay_feeder.dart';
import 'shared_links.dart';
import 'switch_trace.dart'; import 'switch_trace.dart';
export 'media_plan.dart'; export 'media_plan.dart';
@@ -37,7 +38,9 @@ final _log = createLogger('session');
// coordinator's report). // coordinator's report).
// //
// Being a relay source ([RelayFeeder]) and the viewer's side of a source // Being a relay source ([RelayFeeder]) and the viewer's side of a source
// that drops ([recover]) live here too. // that drops ([recover]) live here too. A link this member passes on is
// read from the site once ([SharedLinks]): its player opens the shared
// reader's loopback URL and the relay serves the same bytes.
/// What the coordinator does next after [MediaPipeline.switchTo]: a /// What the coordinator does next after [MediaPipeline.switchTo]: a
/// Jellyfin item resolves and opens through [MediaPipeline.openJellyfin] /// Jellyfin item resolves and opens through [MediaPipeline.openJellyfin]
@@ -77,6 +80,10 @@ class MediaPipeline {
final PlaybackController playback; final PlaybackController playback;
final SwitchTrace trace; final SwitchTrace trace;
late final RelayFeeder feeder; late final RelayFeeder feeder;
/// The links this member passes on, read once for its player and the
/// relay.
final shared = SharedLinks();
MediaSession session; MediaSession session;
/// The player could not open the current media (raw words: the /// The player could not open the current media (raw words: the
@@ -98,7 +105,10 @@ class MediaPipeline {
}) : playback = playback ?? PlaybackController(host), }) : playback = playback ?? PlaybackController(host),
trace = trace ?? SwitchTrace(), trace = trace ?? SwitchTrace(),
session = session ?? MediaSession.none { session = session ?? MediaSession.none {
feeder = RelayFeeder(server: () => this.session.server(), notify: (e) => notify(e)); feeder = RelayFeeder(
server: () => this.session.server(),
notify: (e) => notify(e),
onWarmEnded: (queueId) => shared.dropWarm(queueId));
} }
// ------------------------------------------------------------ players // ------------------------------------------------------------ players
@@ -202,6 +212,12 @@ class MediaPipeline {
/// stream opened with headers (a Jellyfin item's, with its token). Throws /// stream opened with headers (a Jellyfin item's, with its token). Throws
/// a [ResolveFailure] in plain words, with the cookies used (D11). /// a [ResolveFailure] in plain words, with the cookies used (D11).
Future<_Target> _resolve(String url, Map<String, String>? headers, {bool fresh = false}) async { Future<_Target> _resolve(String url, Map<String, String>? headers, {bool fresh = false}) async {
if (headers == null || headers.isEmpty) {
// A link this member passes on: the shared reader's loopback URL
// (it resolves an expired URL again itself, so [fresh] is moot).
final link = await shared.playerFor(url);
if (link != null) return _Target(link.source.playerUrl, const {}, subtitles: link.subtitles, shared: true);
}
if (!_isPage(url) || (headers != null && headers.isNotEmpty)) return _Target(url, headers ?? const {}); if (!_isPage(url) || (headers != null && headers.isNotEmpty)) return _Target(url, headers ?? const {});
final l = links; final l = links;
if (l == null) throw const ResolveFailure('no link resolver here'); if (l == null) throw const ResolveFailure('no link resolver here');
@@ -217,9 +233,24 @@ class MediaPipeline {
throw ResolveFailure(probe.ytDlpError ?? (probe.info == null ? 'Not a video page yt-dlp knows' : 'no format a player opens'), throw ResolveFailure(probe.ytDlpError ?? (probe.info == null ? 'Not a video page yt-dlp knows' : 'no format a player opens'),
cookies: probe.cookies); cookies: probe.cookies);
} }
if (headers == null || headers.isEmpty) {
// Played here only: through a reader of its own too, which resumes
// after a cut and gets a fresh address (a CDN cuts mpv's reconnects
// off). Pieces tracks, or a reader that cannot open: the site as is.
final y = await l.ytDlp;
final local =
await shared.local(url, probe, () => LinkRelayMedia(Uri.parse(url), ytDlp: y, probe: probe), fresh: fresh);
if (local != null) {
return _Target(local.source.playerUrl, const {}, subtitles: local.subtitles, shared: true, local: true);
}
}
return _Target(play.mpvUrl, {...play.headers, ...?headers}, subtitles: play.subtitles); return _Target(play.mpvUrl, {...play.headers, ...?headers}, subtitles: play.subtitles);
} }
/// The [SharedLinks.local] readers the player and the preload use stay;
/// the rest close.
void _trimLocal() => shared.trimLocal({_openUrl, _pre?.url});
/// The page's subtitles on [p] once its media is in (not selected: the /// The page's subtitles on [p] once its media is in (not selected: the
/// usual choice applies). /// usual choice applies).
Future<void> _addSubtitles(HostedPlayer p, List<YtDlpSubtitle> subs) async { Future<void> _addSubtitles(HostedPlayer p, List<YtDlpSubtitle> subs) async {
@@ -287,6 +318,7 @@ class MediaPipeline {
_openHeaders = headers; _openHeaders = headers;
_openResolve = resolve; _openResolve = resolve;
_opening = true; _opening = true;
_trimLocal();
try { try {
trace.mark(mediaId, SwitchTrace.open); trace.mark(mediaId, SwitchTrace.open);
final promoted = await _openInPlayer(url, headers, final promoted = await _openInPlayer(url, headers,
@@ -343,6 +375,8 @@ class MediaPipeline {
}; };
} }
if (headers != null && headers.isNotEmpty) return 'Jellyfin, own sign-in'; if (headers != null && headers.isNotEmpty) return 'Jellyfin, own sign-in';
if (shared.has(url)) return 'link passed on, read once here';
if (shared.hasLocal(url)) return 'link, read here with resume';
return 'link, fetched here'; return 'link, fetched here';
} }
@@ -398,6 +432,7 @@ class MediaPipeline {
final name = (r.media?['name'] as String?) ?? ''; final name = (r.media?['name'] as String?) ?? '';
final queueId = stateMediaRelay(r)?.queueId ?? ''; final queueId = stateMediaRelay(r)?.queueId ?? '';
final duration = (r.media?['duration'] as num?)?.toDouble() ?? 0; final duration = (r.media?['duration'] as num?)?.toDouble() ?? 0;
if (source is! OwnLink) await shared.closeCurrent();
switch (source) { switch (source) {
case JellyfinSource(): case JellyfinSource():
return SwitchJellyfin(source); return SwitchJellyfin(source);
@@ -407,8 +442,11 @@ class MediaPipeline {
announce: true, queueId: queueId, name: name, duration: duration); announce: true, queueId: queueId, name: name, duration: duration);
await open(path); await open(path);
case OwnLink(:final url, :final origin): case OwnLink(:final url, :final origin):
feeder.serve(r.mediaId, LinkRelayMedia(Uri.parse(url), ytDlp: origin.ytDlp, probe: origin.probe), // Read from the site once: the relay serves what the player reads
announce: true, queueId: queueId, name: name, duration: duration); // (pieces tracks keep the two fetches).
RelayMedia link() => LinkRelayMedia(Uri.parse(url), ytDlp: origin.ytDlp, probe: origin.probe);
final one = await shared.current(url, origin.probe, link, queueId: queueId);
feeder.serve(r.mediaId, one?.upload ?? link(), announce: true, queueId: queueId, name: name, duration: duration);
await open(url); await open(url);
case RelayedSource(:final url, :final waiting, :final fetch, :final sourceId): case RelayedSource(:final url, :final waiting, :final fetch, :final sourceId):
if (waiting) { if (waiting) {
@@ -444,6 +482,39 @@ class MediaPipeline {
_openUrl = null; _openUrl = null;
_openHeaders = null; _openHeaders = null;
await playback.stop(); await playback.stop();
_trimLocal();
}
/// The same media came back as [r.mediaId] (a restarted server put back
/// what played, `swap.restore`) and the player keeps it open: what this
/// member passes on is served again (its file, its link, the Jellyfin
/// item played with its own account: [ownJellyfin]).
void resumeAs(StateBroadcast r, {({String url, Map<String, String> headers})? ownJellyfin}) {
renumber(r.mediaId);
settledMediaId.value = r.mediaId;
final source = plan(r);
final name = (r.media?['name'] as String?) ?? '';
final queueId = stateMediaRelay(r)?.queueId ?? '';
final duration = (r.media?['duration'] as num?)?.toDouble() ?? 0;
switch (source) {
case OwnFile(:final path):
feeder.serve(r.mediaId, RelayMedia.single(FileRelayBytes(File(path))),
queueId: queueId, name: name, duration: duration);
case OwnLink(:final url, :final origin):
feeder.serve(r.mediaId,
shared.currentFor(url)?.upload ?? LinkRelayMedia(Uri.parse(url), ytDlp: origin.ytDlp, probe: origin.probe),
queueId: queueId, name: name, duration: duration);
case JellyfinSource(:final ref, :final origin):
if (origin == null || ownJellyfin == null) return;
final media = RelayMedia.single(HttpRelayBytes(Uri.parse(ownJellyfin.url), headers: ownJellyfin.headers));
if (jellyfinRelayNeeded(r, ref, session.selfId())) {
feeder.serve(r.mediaId, media, queueId: queueId, name: name, duration: duration);
} else {
_jellyfinFeed = (mediaId: r.mediaId, ref: ref, media: media, queueId: queueId, name: name, duration: duration);
}
default:
break;
}
} }
/// The media was settled here (opened, failed, nothing to open). /// The media was settled here (opened, failed, nothing to open).
@@ -493,6 +564,7 @@ class MediaPipeline {
_openHeaders = null; _openHeaders = null;
_recovery = null; _recovery = null;
if (ready) await playback.stop(); if (ready) await playback.stop();
_trimLocal();
} }
/// The next state's media is new to this player: it opens again from /// The next state's media is new to this player: it opens again from
@@ -507,6 +579,7 @@ class MediaPipeline {
_activeOrigin = null; _activeOrigin = null;
_jellyfinFeed = null; _jellyfinFeed = null;
feeder.stopAll(); feeder.stopAll();
unawaited(shared.closeAll());
} }
/// The same media under a restarted server's numbering: the player keeps /// The same media under a restarted server's numbering: the player keeps
@@ -527,6 +600,7 @@ class MediaPipeline {
_jellyfinFeed = null; _jellyfinFeed = null;
settledMediaId.value = -1; settledMediaId.value = -1;
feeder.stopAll(); feeder.stopAll();
unawaited(shared.closeAll());
} }
// ----------------------------------------------------------- Jellyfin // ----------------------------------------------------------- Jellyfin
@@ -707,6 +781,24 @@ class MediaPipeline {
String warmRelayUrl(QueueItem item, MediaRelay relay) => String warmRelayUrl(QueueItem item, MediaRelay relay) =>
session.server().relayUrl(item.media.name.isEmpty ? item.title : item.media.name, item.id, relay.tracks); session.server().relayUrl(item.media.name.isEmpty ? item.title : item.media.name, item.id, relay.tracks);
/// Feeds the warm relay of Up next item [queueId], the link [url] this
/// member passes on, from one shared reader (pieces tracks: from their
/// own fetch, as [LinkRelayMedia]). A preload of [url] that reads the
/// site itself (it was prepared before the reader existed) is prepared
/// again on the reader, so the item is fetched once.
Future<void> feedWarmLink(String queueId, String url, LinkProbe probe, YtDlp ytDlp) async {
if (feeder.feedsWarm(queueId)) return;
RelayMedia link() => LinkRelayMedia(Uri.parse(url), ytDlp: ytDlp, probe: probe);
final one = await shared.warm(queueId, url, probe, link);
if (feeder.feedsWarm(queueId)) return;
feeder.feedWarm(queueId, one?.upload ?? link());
final pre = _pre;
if (one != null && pre != null && pre.url == url && pre.headers.isEmpty && !pre.shared && !pre.failed) {
_log.info('Up next ${logUrl(url)}: preparing it again on the shared reader');
unawaited(prepare(url, fresh: true));
}
}
/// Opens [url] early in a hidden player (paused at the start, muted, a /// 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 /// small cache, its own video output when the view is up), replacing any
/// other; completes once [need] seconds are buffered or the whole media /// other; completes once [need] seconds are buffered or the whole media
@@ -721,8 +813,10 @@ class MediaPipeline {
_pre = null; _pre = null;
if (had != null) unawaited(_disposePrepared(had)); if (had != null) unawaited(_disposePrepared(had));
final pre = _pre = _Prepared(url, headers ?? const {}); final pre = _pre = _Prepared(url, headers ?? const {});
_trimLocal();
try { try {
final target = await _resolve(url, headers, fresh: fresh); final target = await _resolve(url, headers, fresh: fresh);
pre.shared = target.shared && !target.local;
if (!identical(_pre, pre)) { if (!identical(_pre, pre)) {
pre.fail(); pre.fail();
return false; return false;
@@ -783,6 +877,7 @@ class MediaPipeline {
await pre.logSub?.cancel(); await pre.logSub?.cancel();
final p = pre.player; final p = pre.player;
if (p != null) await host.dispose(p); if (p != null) await host.dispose(p);
if (!identical(_pre, pre)) _trimLocal();
} }
/// [pre] becomes the session player; its buffer comes along. /// [pre] becomes the session player; its buffer comes along.
@@ -809,6 +904,7 @@ class MediaPipeline {
Future<void> dispose() async { Future<void> dispose() async {
await release(); await release();
feeder.stopAll(); feeder.stopAll();
await shared.closeAll();
await playback.dispose(); await playback.dispose();
} }
} }
@@ -833,6 +929,9 @@ class _Prepared {
StreamSubscription<PlayerLogLine>? logSub; StreamSubscription<PlayerLogLine>? logSub;
List<YtDlpSubtitle> subtitles = const []; List<YtDlpSubtitle> subtitles = const [];
/// It reads a shared reader's loopback URL, not the site.
bool shared = false;
/// Completes once the media is handed to mpv (or it failed). /// Completes once the media is handed to mpv (or it failed).
final opened = Completer<void>(); final opened = Completer<void>();
bool failed = false; bool failed = false;
@@ -854,7 +953,13 @@ class _Target {
final String url; final String url;
final Map<String, String> headers; final Map<String, String> headers;
final List<YtDlpSubtitle> subtitles; final List<YtDlpSubtitle> subtitles;
const _Target(this.url, this.headers, {this.subtitles = const []});
/// A link this member passes on, read through its shared reader.
final bool shared;
/// Read through a [SharedLinks.local] reader (played here only).
final bool local;
const _Target(this.url, this.headers, {this.subtitles = const [], this.shared = false, this.local = false});
} }
/// A page could not be resolved: why ([message], yt-dlp's words) and which /// A page could not be resolved: why ([message], yt-dlp's words) and which
+33 -5
View File
@@ -25,6 +25,11 @@ class ToastItem {
final bool chat; final bool chat;
/// A picture hash to show (an Up next item's), '' when none. /// A picture hash to show (an Up next item's), '' when none.
final String thumb; final String thumb;
/// What it is about (the shell may show some of it another way:
/// "Notifications as danmaku").
final NoticeCategory category;
/// This member's own (their chat message coming back from the server).
final bool own;
const ToastItem( const ToastItem(
{required this.id, {required this.id,
required this.text, required this.text,
@@ -32,7 +37,9 @@ class ToastItem {
this.actor, this.actor,
this.actorId = '', this.actorId = '',
this.chat = false, this.chat = false,
this.thumb = ''}); this.thumb = '',
this.category = NoticeCategory.app,
this.own = false});
} }
/// What a notice is about; the policy filters by it ([noticeFor]). /// What a notice is about; the policy filters by it ([noticeFor]).
@@ -98,12 +105,15 @@ class NoticeContext {
final WindowActivity window; final WindowActivity window;
final bool playing; final bool playing;
final NotifyPrefs prefs; final NotifyPrefs prefs;
/// The chat panel is open: chat is read there, not in pop-ups.
final bool chatOpen;
const NoticeContext({ const NoticeContext({
this.self, this.self,
this.alone = false, this.alone = false,
this.window = WindowActivity.focused, this.window = WindowActivity.focused,
this.playing = false, this.playing = false,
this.prefs = const NotifyPrefs(), this.prefs = const NotifyPrefs(),
this.chatOpen = false,
}); });
bool get away => self == Presence.away; bool get away => self == Presence.away;
@@ -132,6 +142,8 @@ class Notice {
final bool chat; final bool chat;
/// A picture hash for the toast (an Up next item's). /// A picture hash for the toast (an Up next item's).
final String thumb; final String thumb;
/// This member is the actor (a feed line of its own).
final bool own;
final SfxCue? sound; final SfxCue? sound;
final bool attention; final bool attention;
final SystemNote? system; final SystemNote? system;
@@ -143,6 +155,7 @@ class Notice {
this.actor, this.actor,
this.chat = false, this.chat = false,
this.thumb = '', this.thumb = '',
this.own = false,
this.sound, this.sound,
this.attention = false, this.attention = false,
this.system, this.system,
@@ -151,12 +164,13 @@ class Notice {
static const none = Notice(); static const none = Notice();
Notice _with({bool toast = true, bool sound = true, SystemNote? system}) => Notice( Notice _with({bool toast = true, bool sound = true, SystemNote? system, bool own = false}) => Notice(
category: category, category: category,
toast: toast ? this.toast : null, toast: toast ? this.toast : null,
actor: toast ? actor : null, actor: toast ? actor : null,
chat: toast && chat, chat: toast && chat,
thumb: toast ? thumb : '', thumb: toast ? thumb : '',
own: own,
sound: sound ? this.sound : null, sound: sound ? this.sound : null,
attention: attention, attention: attention,
system: system, system: system,
@@ -187,6 +201,12 @@ Notice _plain(AppEvent e) => switch (e) {
category: NoticeCategory.people, log: feed, toast: '${feed.actor} lost connection', actor: feed.actor), category: NoticeCategory.people, log: feed, toast: '${feed.actor} lost connection', actor: feed.actor),
MemberReturned(:final feed) => MemberReturned(:final feed) =>
Notice(category: NoticeCategory.people, log: feed, toast: '${feed.actor} is back', actor: feed.actor), Notice(category: NoticeCategory.people, log: feed, toast: '${feed.actor} is back', actor: feed.actor),
// Put back after a server restart: news, but not a change.
MediaSwapped(:final feed) when feed.reason == 'restore' => Notice(
category: NoticeCategory.upNext,
log: feed,
toast: '${feed.actor} put back what was playing',
actor: feed.actor),
MediaSwapped(:final feed) => Notice( MediaSwapped(:final feed) => Notice(
category: NoticeCategory.upNext, category: NoticeCategory.upNext,
log: feed, log: feed,
@@ -311,6 +331,8 @@ Notice noticeFor(AppEvent e, [NoticeContext c = const NoticeContext()]) {
} }
if (cat == NoticeCategory.start && !c.away && !c.window.background) toast = false; if (cat == NoticeCategory.start && !c.away && !c.window.background) toast = false;
if (cat == NoticeCategory.chat && !c.prefs.chat.shows(playing: c.playing)) toast = false; if (cat == NoticeCategory.chat && !c.prefs.chat.shows(playing: c.playing)) toast = false;
// The open chat shows it already; the sound still plays.
if (cat == NoticeCategory.chat && c.chatOpen && !c.window.background) toast = false;
SystemNote? system; SystemNote? system;
if (toast && !own && c.window.background && cat.system && cat.reachesAway(c.prefs)) { if (toast && !own && c.window.background && cat.system && cat.reachesAway(c.prefs)) {
final actor = n.actor; final actor = n.actor;
@@ -323,7 +345,7 @@ Notice noticeFor(AppEvent e, [NoticeContext c = const NoticeContext()]) {
payload: cat == NoticeCategory.chat ? SystemNote.payloadChat : '', payload: cat == NoticeCategory.chat ? SystemNote.payloadChat : '',
); );
} }
return n._with(toast: toast, sound: sound, system: system); return n._with(toast: toast, sound: sound, system: system, own: own);
} }
/// The one way news reaches the user: [notify] turns an [AppEvent] into /// The one way news reaches the user: [notify] turns an [AppEvent] into
@@ -345,6 +367,9 @@ class Notifier {
/// The window's activity ([ShellPort.watchActivity], wired in `main`). /// The window's activity ([ShellPort.watchActivity], wired in `main`).
WindowActivity window = WindowActivity.focused; WindowActivity window = WindowActivity.focused;
/// The chat panel is open (set by the shell).
bool chatOpen = false;
Notifier({ Notifier({
this.sfx = const NoopSfxService(), this.sfx = const NoopSfxService(),
Future<void> Function()? attention, Future<void> Function()? attention,
@@ -373,7 +398,8 @@ class Notifier {
NoticeContext get context { NoticeContext get context {
final s = session(); final s = session();
return NoticeContext(self: s.self, alone: s.alone, window: window, playing: s.playing, prefs: prefs()); return NoticeContext(
self: s.self, alone: s.alone, window: window, playing: s.playing, prefs: prefs(), chatOpen: chatOpen);
} }
void notify(AppEvent e) { void notify(AppEvent e) {
@@ -395,7 +421,9 @@ class Notifier {
actor: n.actor, actor: n.actor,
actorId: n.actor == null ? '' : n.log?.actorId ?? '', actorId: n.actor == null ? '' : n.log?.actorId ?? '',
chat: n.chat, chat: n.chat,
thumb: n.thumb), thumb: n.thumb,
category: n.category,
own: n.own),
]; ];
_timers[id] = Timer(toastLife, () => dismiss(id)); _timers[id] = Timer(toastLife, () => dismiss(id));
} }
+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,66 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter/foundation.dart' show debugPrint;
import 'invite.dart' show isPublicIpv4;
/// Fetches a URL's body as text; injectable so tests fake the services.
typedef TextGet = Future<String> Function(Uri uri);
/// "What is my IP" services, asked in order until one answers with a
/// public IPv4 address: each answers the plain address over HTTPS.
final publicAddressServices = [
Uri.parse('https://api.ipify.org'),
Uri.parse('https://ipv4.icanhazip.com'),
Uri.parse('https://checkip.amazonaws.com'),
Uri.parse('https://ifconfig.me/ip'),
];
const _eachTimeout = Duration(seconds: 3);
/// This network's internet (IPv4) address as the [services] see it: the
/// first that answers with a public address wins; a failing, slow or odd
/// one is skipped. Null when none does (offline, all blocked).
Future<String?> lookUpPublicAddress({
TextGet get = defaultTextGet,
List<Uri>? services,
Duration each = _eachTimeout,
}) async {
for (final uri in services ?? publicAddressServices) {
try {
final body = (await get(uri).timeout(each)).trim();
// Some answer "ip\n", a few with a label: take the first word.
final ip = body.split(RegExp(r'\s+')).first;
if (isPublicIpv4(ip)) return ip;
debugPrint('public address: ${uri.host} gave no public IPv4 address');
} catch (e) {
debugPrint('public address: ${uri.host} failed: ${e.runtimeType}');
}
}
return null;
}
/// Plain dart:io GET of a small text body; non-200 throws.
Future<String> defaultTextGet(Uri uri) async {
final client = HttpClient()
..connectionTimeout = _eachTimeout
..userAgent = 'Starsync';
try {
final req = await client.getUrl(uri);
final res = await req.close().timeout(_eachTimeout);
if (res.statusCode != 200) {
await res.drain<void>();
throw HttpException('status ${res.statusCode}', uri: uri);
}
final body = StringBuffer();
await for (final chunk in res.transform(utf8.decoder).timeout(_eachTimeout)) {
body.write(chunk);
if (body.length > 256) break; // an address, not a page
}
return body.toString();
} finally {
client.close(force: true);
}
}
+20 -4
View File
@@ -17,7 +17,12 @@ import 'party.dart' show isPresent;
class RelayFeeder { class RelayFeeder {
final ServerAddress Function() server; final ServerAddress Function() server;
final void Function(AppEvent event) notify; final void Function(AppEvent event) notify;
RelayFeeder({required this.server, required this.notify});
/// The warm relay of Up next item `queueId` stopped without being
/// carried on as the media's (the server dropped it, or [stopWarm]).
final void Function(String queueId) onWarmEnded;
RelayFeeder({required this.server, required this.notify, void Function(String queueId)? onWarmEnded})
: onWarmEnded = onWarmEnded ?? ((_) {});
RelaySource? _source; RelaySource? _source;
int _mediaId = -1; int _mediaId = -1;
@@ -33,6 +38,9 @@ class RelayFeeder {
/// relay and has opened them (empty otherwise). /// relay and has opened them (empty otherwise).
List<RelayTrack> warmTracks(String queueId) => _warm?.queueId == queueId ? _warm!.src.tracks : const []; List<RelayTrack> warmTracks(String queueId) => _warm?.queueId == queueId ? _warm!.src.tracks : const [];
/// This member feeds the warm relay of Up next item [queueId].
bool feedsWarm(String queueId) => _warm?.queueId == queueId;
/// Serves [media] for media [mediaId] on this member's relay channel. /// Serves [media] for media [mediaId] on this member's relay channel.
/// The server only pulls while someone watches through it. When the /// The server only pulls while someone watches through it. When the
/// media came from Up next item [queueId] whose warm relay this member /// media came from Up next item [queueId] whose warm relay this member
@@ -111,18 +119,26 @@ class RelayFeeder {
await src.start(); await src.start();
} catch (e) { } catch (e) {
debugPrint('warm relay source of $queueId failed: $e'); debugPrint('warm relay source of $queueId failed: $e');
if (identical(_warm?.src, src)) _warm = null; if (identical(_warm?.src, src)) {
_warm = null;
onWarmEnded(queueId);
}
return; return;
} }
await src.done; await src.done;
if (identical(_warm?.src, src)) _warm = null; if (identical(_warm?.src, src)) {
_warm = null;
onWarmEnded(queueId);
}
}()); }());
} }
void stopWarm() { void stopWarm() {
final w = _warm; final w = _warm;
_warm = null; _warm = null;
if (w != null) unawaited(w.src.close()); if (w == null) return;
unawaited(w.src.close());
onWarmEnded(w.queueId);
} }
/// Leave, or a media this member no longer serves. /// Leave, or a media this member no longer serves.
@@ -23,6 +23,7 @@ import '../schedule/anilist.dart';
import '../screens/home/picked_media.dart'; import '../screens/home/picked_media.dart';
import '../sfx/sfx.dart' show NoopSfxService, SfxService; import '../sfx/sfx.dart' show NoopSfxService, SfxService;
import '../state/settings.dart'; import '../state/settings.dart';
import '../watch/watch_tracker.dart';
import '../sync/avatar_cache.dart'; import '../sync/avatar_cache.dart';
import '../sync/state_diff.dart'; import '../sync/state_diff.dart';
import '../sync/user_seek.dart'; import '../sync/user_seek.dart';
@@ -37,6 +38,7 @@ import 'notifier.dart';
import 'now_playing.dart'; import 'now_playing.dart';
import 'now_playing_info.dart'; import 'now_playing_info.dart';
import 'party.dart'; import 'party.dart';
import 'shared_links.dart';
import 'status.dart'; import 'status.dart';
import 'switch_trace.dart'; import 'switch_trace.dart';
@@ -52,21 +54,6 @@ final _log = createLogger('session');
/// (Jellyfin-style links carry api_key there and names are broadcast). /// (Jellyfin-style links carry api_key there and names are broadcast).
String linkDisplayName(String url) => linkName(url); String linkDisplayName(String url) => linkName(url);
/// The end-of-episode countdown (auto-advance). Every client with the
/// setting on shows it; only [swapper] puts the next episode up.
class AutoAdvance {
final int mediaId;
final int secondsLeft;
final EpisodeLink next;
/// This client performs the swap (see [autoAdvanceSwapper]).
final bool swapper;
const AutoAdvance(
{required this.mediaId, required this.secondsLeft, required this.next, required this.swapper});
}
/// Seconds of the end-of-episode countdown.
const autoAdvanceSecs = 10;
/// "Unready when the video changes": a state with a newer [mediaId] than /// "Unready when the video changes": a state with a newer [mediaId] than
/// the last one this session saw ([lastMediaId], -1 before the first) while /// the last one this session saw ([lastMediaId], -1 before the first) while
/// self is ready (or a fresh member after a reconnect is restoring it). A /// self is ready (or a fresh member after a reconnect is restoring it). A
@@ -100,6 +87,14 @@ class SessionCoordinator {
/// What a local file is (`media.ref`, from the library: `ShowMatcher`), /// What a local file is (`media.ref`, from the library: `ShowMatcher`),
/// set by the composition root; null: files go up without one. /// set by the composition root; null: files go up without one.
MediaRef? Function(String path)? fileRef; MediaRef? Function(String path)? fileRef;
/// The next episode of the local show a file belongs to (the library's
/// `nextAfter`, `LocalNextFinder`), set by the composition root; null:
/// files have no continuation.
Future<LocalNext?> Function(String path)? nextFile;
/// The app's preferences (the composition root's).
final AppSettings settings;
/// Watch tracking: one watch per media, fed every second (D3).
final WatchTracker watch;
/// The app's yt-dlp and link probes (tests pass their own). /// The app's yt-dlp and link probes (tests pass their own).
final LinkResolver ytDlp; final LinkResolver ytDlp;
/// The link to the server (socket, reconnects, clock offset, its /// The link to the server (socket, reconnects, clock offset, its
@@ -217,8 +212,6 @@ class SessionCoordinator {
/// else the last two minutes); the pill shows in it only while the /// else the last two minutes); the pill shows in it only while the
/// controls are up. /// controls are up.
final nextPromptVisible = signal(false); final nextPromptVisible = signal(false);
/// End-of-episode countdown; null when none runs here.
final autoAdvance = signal<AutoAdvance?>(null);
/// The skip pill for the current position ([skipOfferAt]: Skip OP, /// The skip pill for the current position ([skipOfferAt]: Skip OP,
/// Skip ED, or Skip 1:30 without opening info or chapters); null while nothing /// Skip ED, or Skip 1:30 without opening info or chapters); null while nothing
@@ -284,7 +277,8 @@ class SessionCoordinator {
// This client's last swap, until a state shows it landed (a newer // This client's last swap, until a state shows it landed (a newer
// mediaId than [after] with this member as `swapBy`): its art goes up then. // mediaId than [after] with this member as `swapBy`): its art goes up then.
// [link]: the web link it came from (a relayed one has no `url`). // [link]: the web link it came from (a relayed one has no `url`).
({HelloMedia media, int after, String? link, Uint8List? art})? _pendingArt; // [file]: the local file it came from.
({HelloMedia media, int after, String? link, String? file, Uint8List? art})? _pendingArt;
AniListClient? _aniList; AniListClient? _aniList;
/// This member serves the current media's bytes through the server. /// This member serves the current media's bytes through the server.
Signal<bool> get relaying => media.feeder.relaying; Signal<bool> get relaying => media.feeder.relaying;
@@ -317,8 +311,16 @@ class SessionCoordinator {
JellyfinProgressReporter? _reporter; JellyfinProgressReporter? _reporter;
PlayerSnapshot? _lastSnap; PlayerSnapshot? _lastSnap;
bool _jfToastShown = false; bool _jfToastShown = false;
Timer? _advanceTimer; // The media whose end was recorded as a watch checkpoint.
int _advanceDoneFor = -1; int _watchEndFor = -1;
// A restarted server came back without media while this app still had
// what played: that state, this member's id in it and the token then,
// until a state has media again (see [_beginRestore]).
({StateBroadcast before, String self, String oldToken, DateTime at})? _restore;
Timer? _restoreTimer;
static const _restoreGiveUp = Duration(seconds: 30);
// The member id the latest state was seen as (the old one, at a welcome).
String _stateSelf = '';
String _blockedKey = ''; String _blockedKey = '';
bool _jellyfinListening = false; bool _jellyfinListening = false;
// The mediaId [playerError] belongs to, and the player's duration when // The mediaId [playerError] belongs to, and the player's duration when
@@ -337,7 +339,9 @@ class SessionCoordinator {
JellyfinAccounts? jellyfin, JellyfinAccounts? jellyfin,
TrackPrefs? trackPrefs, TrackPrefs? trackPrefs,
LinkResolver? ytDlp, LinkResolver? ytDlp,
PlayerHost? players}) { PlayerHost? players,
AppSettings? settings,
WatchTracker? watch}) {
final n = notifier ?? Notifier(sfx: sfx); final n = notifier ?? Notifier(sfx: sfx);
final links = ytDlp ?? AppYtDlp.instance; final links = ytDlp ?? AppYtDlp.instance;
return SessionCoordinator._( return SessionCoordinator._(
@@ -347,6 +351,8 @@ class SessionCoordinator {
jellyfin: jellyfin ?? JellyfinAccounts.instance, jellyfin: jellyfin ?? JellyfinAccounts.instance,
trackPrefs: trackPrefs ?? TrackPrefs(), trackPrefs: trackPrefs ?? TrackPrefs(),
media: MediaPipeline(host: players ?? (MediaKitHost()..init()), links: links), media: MediaPipeline(host: players ?? (MediaKitHost()..init()), links: links),
settings: settings ?? AppSettings.instance,
watch: watch ?? WatchTracker(),
); );
} }
@@ -356,7 +362,9 @@ class SessionCoordinator {
required this.ytDlp, required this.ytDlp,
required this.jellyfin, required this.jellyfin,
required this.trackPrefs, required this.trackPrefs,
required this.media}) { required this.media,
required this.settings,
required this.watch}) {
connection connection
..onWelcome = _onWelcome ..onWelcome = _onWelcome
..caps = () => caps; ..caps = () => caps;
@@ -419,6 +427,7 @@ class SessionCoordinator {
if (_playerReady) return; if (_playerReady) return;
try { try {
playback.onLoadError = _setPlayerError; playback.onLoadError = _setPlayerError;
playback.onStreamLost = _streamLost;
playback.onUserSeek = (d) => _userSeek(d.inMicroseconds / 1e6); playback.onUserSeek = (d) => _userSeek(d.inMicroseconds / 1e6);
_bufferSub ??= playback.buffered.subscribe((_) => _onBuffered()); _bufferSub ??= playback.buffered.subscribe((_) => _onBuffered());
await media.start(); await media.start();
@@ -472,6 +481,19 @@ class SessionCoordinator {
} }
bool _reloadingVideo = false; bool _reloadingVideo = false;
// The media whose lost stream was reloaded already (once per media).
int _streamReloadedFor = -1;
/// The player's HTTP stream lost the file's size (an old server, a
/// request that timed out): every seek on it would fail until a fresh
/// open. "Reload video" once per media, as the user would.
void _streamLost() {
if (_streamReloadedFor == _mediaId || _mediaId < 0) return;
_streamReloadedFor = _mediaId;
_log.info('media $_mediaId: the stream lost its size; reloading the video once');
unawaited(reloadVideo());
}
void Function()? _bufferSub; void Function()? _bufferSub;
/// "Reload video" (button, Ctrl+R, status menu): fresh /// "Reload video" (button, Ctrl+R, status menu): fresh
@@ -510,6 +532,9 @@ class SessionCoordinator {
final epoch = connection.invalidate(); final epoch = connection.invalidate();
await _teardown(silent: true); await _teardown(silent: true);
if (!connection.isCurrent(epoch)) throw const ConnectCancelled(); if (!connection.isCurrent(epoch)) throw const ConnectCancelled();
// The last server's state is not this one's: kept, its media opened the
// player (full, on a friend's server) over an empty new server.
serverState.value = null;
connection.begin(params); connection.begin(params);
_wsHost = params.host; _wsHost = params.host;
_wsPort = params.port; _wsPort = params.port;
@@ -614,7 +639,10 @@ class SessionCoordinator {
/// three viewers readying at once). A state that arrives meanwhile /// three viewers readying at once). A state that arrives meanwhile
/// replaces any still waiting. /// replaces any still waiting.
void _onState(StateBroadcast r) { void _onState(StateBroadcast r) {
final hold = _restore;
if (hold != null && r.media != null) _landRestore(hold, r);
serverState.value = r; serverState.value = r;
_stateSelf = connection.memberId;
if (r.transport == 'playing') switchTrace.mark(r.mediaId, SwitchTrace.playing); if (r.transport == 'playing') switchTrace.mark(r.mediaId, SwitchTrace.playing);
_toastMemberErrors(r); _toastMemberErrors(r);
_warnSlowSource(r); _warnSlowSource(r);
@@ -641,6 +669,9 @@ class SessionCoordinator {
} }
Future<void> _applyState(StateBroadcast r) async { Future<void> _applyState(StateBroadcast r) async {
// A restarted server without media yet: the player goes on with what
// played until it is back (or given up).
if (_restore != null && r.media == null) return;
_trackSelfReady(r); _trackSelfReady(r);
if (_lastAway) { if (_lastAway) {
// A newer media than the one this member serves: that relay ended. // A newer media than the one this member serves: that relay ended.
@@ -737,7 +768,7 @@ class SessionCoordinator {
// A ready restored by a reconnect may not have echoed yet: unready // A ready restored by a reconnect may not have echoed yet: unready
// anyway (it was sent first, so this one lands last). // anyway (it was sent first, so this one lands last).
if (unreadyForNewMedia( if (unreadyForNewMedia(
enabled: AppSettings.instance.unreadyOnSwap.peek(), enabled: settings.unreadyOnSwap.peek(),
lastMediaId: last, lastMediaId: last,
mediaId: r.mediaId, mediaId: r.mediaId,
ready: self.ready || _restoringReady)) { ready: self.ready || _restoringReady)) {
@@ -834,19 +865,54 @@ class SessionCoordinator {
_sendReport(r); _sendReport(r);
if (!_lastAway) _checkSeekLanded(r); if (!_lastAway) _checkSeekLanded(r);
} }
// The Jellyfin progress goes on through a drop (the player does). // The Jellyfin progress and the watch go on through a drop (the
// player does).
if (r != null) _sampleWatch(r, now);
if (connection.token.isNotEmpty && r != null && _playerReady && !_lastAway) unawaited(_tickJellyfin(r)); if (connection.token.isNotEmpty && r != null && _playerReady && !_lastAway) unawaited(_tickJellyfin(r));
if (r != null && online) _readyAfterJoinBack(r, now); if (r != null && online) _readyAfterJoinBack(r, now);
_tickAutoSitOut(r, now, online: online); _tickAutoSitOut(r, now, online: online);
} }
/// One second of the watch of what plays ([WatchTracker]): its position
/// while this player holds it, whether it plays here, whether this
/// viewer is In; its end is recorded once (it may go on after a seek
/// back).
void _sampleWatch(StateBroadcast state, DateTime now) {
// A restarted server waiting for what played to come back: the watch
// goes on with it (the player plays on); a reconnect never ends it.
final hold = _restore;
final waiting = hold != null && state.media == null;
final r = waiting ? hold.before : state;
final holds = _playerReady && !_lastAway && !_opening && _openUrl != null && media.playerMediaId == r.mediaId;
final ending = playback.ending.peek()?.start;
watch.sample(WatchSample(
mediaId: r.mediaId,
media: r.media,
ownFile: _ownFile?.$1 == r.mediaId ? _ownFile!.$2 : null,
ownLink: _ownLink?.$1 == r.mediaId ? _ownLink!.$2 : null,
position: holds ? playback.positionSecs : 0,
playing: holds && (waiting || r.transport == 'playing') && playback.playing,
present: selfMember.peek()?.presence == Presence.watching,
duration: holds ? playback.durationSecs : 0,
endingStart: ending == null ? null : ending.inMilliseconds / 1000,
at: now,
));
if (waiting) return;
if (r.transport == 'finished' && _watchEndFor != r.mediaId) {
_watchEndFor = r.mediaId;
watch.checkpoint(at: now);
} else if (r.transport != 'finished' && _watchEndFor == r.mediaId) {
_watchEndFor = -1;
}
}
/// Auto sit-out's second (only while online: the state is current). /// Auto sit-out's second (only while online: the state is current).
void _tickAutoSitOut(StateBroadcast? r, DateTime now, {required bool online}) { void _tickAutoSitOut(StateBroadcast? r, DateTime now, {required bool online}) {
final step = autoSitOut.tick(now, final step = autoSitOut.tick(now,
self: online ? selfMember.peek()?.presence : null, self: online ? selfMember.peek()?.presence : null,
playing: r?.transport == 'playing', playing: r?.transport == 'playing',
alone: r == null || aloneIn(r.members, connection.memberId), alone: r == null || aloneIn(r.members, connection.memberId),
enabled: AppSettings.instance.autoSitOut.peek()); enabled: settings.autoSitOut.peek());
switch (step) { switch (step) {
case AutoAway(:final reason): case AutoAway(:final reason):
_log.info('auto sit-out: away (${reason.name})'); _log.info('auto sit-out: away (${reason.name})');
@@ -1075,7 +1141,7 @@ class SessionCoordinator {
/// [queueId]: the Up next item this puts up (the server takes it off /// [queueId]: the Up next item this puts up (the server takes it off
/// the queue); [art]: a picture to send as its art (a queued item's). /// the queue); [art]: a picture to send as its art (a queued item's).
void _swap(HelloMedia media, {String? link, String? queueId, Uint8List? art}) { void _swap(HelloMedia media, {String? link, String? file, String? queueId, Uint8List? art, SwapRestore? restore}) {
// A swap sent into a dropped link would be lost without a word. // A swap sent into a dropped link would be lost without a word.
if (!connection.state.peek().online) { if (!connection.state.peek().online) {
notify(const ActionFailed('Reconnecting: put it up again once back')); notify(const ActionFailed('Reconnecting: put it up again once back'));
@@ -1084,12 +1150,13 @@ class SessionCoordinator {
// Putting something up means watching it: back first. Not for Up next // Putting something up means watching it: back first. Not for Up next
// coming up by itself: whoever puts it up may be sitting out. // coming up by itself: whoever puts it up may be sitting out.
final self = selfMember.peek(); final self = selfMember.peek();
if (self != null && self.presence == Presence.away && queueId == null) { if (self != null && self.presence == Presence.away && queueId == null && restore == null) {
autoSitOut.manual(); autoSitOut.manual();
connection.sendPresence(const PresenceIntent(state: Presence.watching)); connection.sendPresence(const PresenceIntent(state: Presence.watching));
} }
_pendingArt = (media: media, after: serverState.peek()?.mediaId ?? 0, link: link ?? media.url, art: art); _pendingArt =
connection.sendSwap(media, queueId: queueId); (media: media, after: serverState.peek()?.mediaId ?? 0, link: link ?? media.url, file: file, art: art);
connection.sendSwap(media, queueId: queueId, restore: restore);
} }
/// An Up next item put up as queued, for one this app cannot build /// An Up next item put up as queued, for one this app cannot build
@@ -1107,12 +1174,20 @@ class SessionCoordinator {
_pendingArt = null; _pendingArt = null;
final link = pending.link; final link = pending.link;
_ownLink = link == null || link.isEmpty ? null : (r.mediaId, link); _ownLink = link == null || link.isEmpty ? null : (r.mediaId, link);
final file = pending.file;
_ownFile = file == null ? null : (r.mediaId, file);
ownFile.value = _ownFile;
unawaited(_sendArt(r.mediaId, pending.media, pending.link, given: pending.art)); unawaited(_sendArt(r.mediaId, pending.media, pending.link, given: pending.art));
} }
/// The link this app put up as [StateBroadcast.mediaId] (passed on or /// The link this app put up as [StateBroadcast.mediaId] (passed on or
/// fetched by the server, so not in the state). /// fetched by the server, so not in the state).
(int, String)? _ownLink; (int, String)? _ownLink;
(int, String)? _ownFile;
/// The local file this app put up, with the mediaId it went up as (null:
/// none); Up next's continuation of a show reads it.
final ownFile = signal<(int, String)?>(null);
/// The link of what plays, as this app may hand it out (Copy link, Open /// The link of what plays, as this app may hand it out (Copy link, Open
/// in browser): one this app put up, else one everyone opens (it is in /// in browser): one this app put up, else one everyone opens (it is in
@@ -1170,12 +1245,12 @@ class SessionCoordinator {
/// reason in plain words unless [anyway]. Returns an error text, or null /// reason in plain words unless [anyway]. Returns an error text, or null
/// when sent. /// when sent.
Future<String?> swapLink(String url, Future<String?> swapLink(String url,
{LinkRelay relay = LinkRelay.direct, bool anyway = false, String? queueId}) async { {LinkRelay relay = LinkRelay.direct, bool anyway = false, String? queueId, SwapRestore? restore}) async {
markInput(); markInput();
final media = await mediaForLink(jellyfin, url); final media = await mediaForLink(jellyfin, url);
if (media.jellyfin != null) { if (media.jellyfin != null) {
this.media.expectOrigin(JellyfinOrigin(url)); this.media.expectOrigin(JellyfinOrigin(url));
_swap(media.copyWith(relay: const MediaRelay()), queueId: queueId); _swap(media.copyWith(relay: const MediaRelay()), queueId: queueId, restore: restore);
return null; return null;
} }
LinkProbe? probe; LinkProbe? probe;
@@ -1192,7 +1267,7 @@ class SessionCoordinator {
final name = probe?.title ?? (probe != null || hasMediaExtension(uri) || uri.host.isEmpty ? media.name : uri.host); final name = probe?.title ?? (probe != null || hasMediaExtension(uri) || uri.host.isEmpty ? media.name : uri.host);
final what = linkMediaRef(name); final what = linkMediaRef(name);
if (relay == LinkRelay.direct) { if (relay == LinkRelay.direct) {
_swap(HelloMedia(name: name, size: 0, duration: 0, url: url, ref: what), queueId: queueId); _swap(HelloMedia(name: name, size: 0, duration: 0, url: url, ref: what), queueId: queueId, restore: restore);
return null; return null;
} }
if (relay == LinkRelay.me) { if (relay == LinkRelay.me) {
@@ -1205,11 +1280,21 @@ class SessionCoordinator {
// Up next's item this app already feeds (its warm relay): read. // Up next's item this app already feeds (its warm relay): read.
final warm = queueId == null ? const <RelayTrack>[] : this.media.feeder.warmTracks(queueId); final warm = queueId == null ? const <RelayTrack>[] : this.media.feeder.warmTracks(queueId);
final fed = warm.isNotEmpty; final fed = warm.isNotEmpty;
final check = fed ? null : LinkRelayMedia(uri, ytDlp: y, probe: probe); // Read once for the player and the relay (SharedLinks): the check's
// reader is the one the media then uses. Pieces tracks: a check of
// their own.
final share = !fed && SharedLinks.fits(probe);
final check = fed || share ? null : LinkRelayMedia(uri, ytDlp: y, probe: probe);
var size = 0; var size = 0;
final List<RelayTrackSpec> specs; final List<RelayTrackSpec> specs;
try { try {
final tracks = fed ? warm : await check!.open().timeout(const Duration(seconds: 15)); final tracks = fed
? warm
: share
? await (await this.media.shared.forSwap(url, LinkRelayMedia(uri, ytDlp: y, probe: probe), probe))
.upload
.open()
: await check!.open().timeout(const Duration(seconds: 15));
for (final t in tracks) { for (final t in tracks) {
// A playlist is read (DRM and live refused) but has no size. // A playlist is read (DRM and live refused) but has no size.
final pieces = t.pieces; final pieces = t.pieces;
@@ -1227,7 +1312,7 @@ class SessionCoordinator {
} }
this.media.expectOrigin(LinkOrigin(url, probe, y)); this.media.expectOrigin(LinkOrigin(url, probe, y));
_swap(HelloMedia(name: name, size: size, duration: 0, relay: MediaRelay(tracks: specs), ref: what), _swap(HelloMedia(name: name, size: size, duration: 0, relay: MediaRelay(tracks: specs), ref: what),
link: url, queueId: queueId); link: url, queueId: queueId, restore: restore);
return null; return null;
} }
// The server reads it with its own yt-dlp; only a page this app knows // The server reads it with its own yt-dlp; only a page this app knows
@@ -1243,7 +1328,8 @@ class SessionCoordinator {
url: url, url: url,
relay: const MediaRelay(fetch: true), relay: const MediaRelay(fetch: true),
ref: what), ref: what),
queueId: queueId); queueId: queueId,
restore: restore);
return null; return null;
} }
@@ -1257,23 +1343,24 @@ class SessionCoordinator {
/// A Jellyfin pick (browser, next / previous episode) goes up as a ref, /// A Jellyfin pick (browser, next / previous episode) goes up as a ref,
/// relayed by this member for viewers without an account. /// relayed by this member for viewers without an account.
void swapJellyfin(JellyfinMedia media, {String? queueId}) { void swapJellyfin(JellyfinMedia media, {String? queueId, SwapRestore? restore}) {
markInput(); markInput();
this.media.expectOrigin(const JellyfinOrigin(null)); this.media.expectOrigin(const JellyfinOrigin(null));
_swap(mediaForPick(media).copyWith(relay: const MediaRelay()), queueId: queueId); _swap(mediaForPick(media).copyWith(relay: const MediaRelay()), queueId: queueId, restore: restore);
} }
/// A file on this computer. The host's app serves it itself; anyone /// A file on this computer. The host's app serves it itself; anyone
/// else's goes through the server's relay. Returns an error text, or null /// else's goes through the server's relay. Returns an error text, or null
/// when sent. /// when sent.
Future<String?> swapFile(String path, {String? queueId}) async { Future<String?> swapFile(String path, {String? queueId, SwapRestore? restore}) async {
markInput(); markInput();
final what = fileRef?.call(path); final what = fileRef?.call(path);
final host = hostFile; final host = hostFile;
if (host != null) { if (host != null) {
final file = await host(path); final file = await host(path);
if (file == null) return 'file not found — check the path'; if (file == null) return 'file not found — check the path';
_swap(HelloMedia(name: file.name, size: file.size, duration: 0, ref: what), queueId: queueId); _swap(HelloMedia(name: file.name, size: file.size, duration: 0, ref: what),
file: path, queueId: queueId, restore: restore);
return null; return null;
} }
final file = File(path); final file = File(path);
@@ -1285,7 +1372,9 @@ class SessionCoordinator {
duration: 0, duration: 0,
relay: const MediaRelay(), relay: const MediaRelay(),
ref: what), ref: what),
queueId: queueId); file: path,
queueId: queueId,
restore: restore);
return null; return null;
} }
@@ -1302,9 +1391,17 @@ class SessionCoordinator {
return null; return null;
} }
/// The next episode now: through Up next when it is the continuation
/// there (its preload and relay carry on), else a swap.
void playNextEpisode() { void playNextEpisode() {
final n = nextEpisode.peek(); final n = nextEpisode.peek();
if (n != null) swapJellyfin(n.pick); if (n == null) return;
final auto = queue.continues.peek();
if (auto != null && auto.media.jellyfin?.itemId == n.item.id) {
queue.playNow(auto.id);
} else {
swapJellyfin(n.pick);
}
} }
void playPreviousEpisode() { void playPreviousEpisode() {
@@ -1312,16 +1409,6 @@ class SessionCoordinator {
if (p != null) swapJellyfin(p.pick); if (p != null) swapJellyfin(p.pick);
} }
/// Hides the countdown here; this client will not auto-advance this
/// item (if it is the swapper, nobody does).
void cancelAutoAdvance() {
_advanceTimer?.cancel();
_advanceTimer = null;
final a = autoAdvance.peek();
if (a != null) _advanceDoneFor = a.mediaId;
autoAdvance.value = null;
}
// ------------------------------------------------------------- jellyfin // ------------------------------------------------------------- jellyfin
/// News for the user, from the session or anywhere in the app (see /// News for the user, from the session or anywhere in the app (see
@@ -1342,10 +1429,6 @@ class SessionCoordinator {
nextEpisode.value = null; nextEpisode.value = null;
nextPromptVisible.value = false; nextPromptVisible.value = false;
playback.segments.value = const []; playback.segments.value = const [];
_advanceTimer?.cancel();
_advanceTimer = null;
_advanceDoneFor = -1;
autoAdvance.value = null;
} }
/// The current item is being replaced: Stopped goes out (not awaited: /// The current item is being replaced: Stopped goes out (not awaited:
@@ -1449,6 +1532,12 @@ class SessionCoordinator {
if (gen != _jfGen) return; if (gen != _jfGen) return;
final before = await userDataF; final before = await userDataF;
if (gen != _jfGen) return; if (gen != _jfGen) return;
// The watch of this media (the tracker feeds it; Jellyfin's played
// state and resume point are what came before).
_sampleWatch(r, DateTime.now().toUtc());
watch.before(
played: before?.played ?? false,
resumeAt: (before?.playbackPositionTicks ?? 0) / jellyfinTicksPerSecond);
final reporter = JellyfinProgressReporter( final reporter = JellyfinProgressReporter(
client: plan.client, client: plan.client,
itemId: ref.itemId, itemId: ref.itemId,
@@ -1456,6 +1545,7 @@ class SessionCoordinator {
playSessionId: plan.info.playSessionId, playSessionId: plan.info.playSessionId,
playedBefore: before?.played ?? false, playedBefore: before?.played ?? false,
resumeTicksBefore: before?.playbackPositionTicks ?? 0, resumeTicksBefore: before?.playbackPositionTicks ?? 0,
watch: watch.mediaId == r.mediaId ? watch.session : null,
onUnauthorized: () => jellyfin.markExpired(ref.serverId), onUnauthorized: () => jellyfin.markExpired(ref.serverId),
onStopped: () => jellyfin.watchedChanged(ref.serverId), onStopped: () => jellyfin.watchedChanged(ref.serverId),
onError: (e) { onError: (e) {
@@ -1545,7 +1635,7 @@ class SessionCoordinator {
final next = nextEpisode.peek(); final next = nextEpisode.peek();
final duration = playback.durationSecs; final duration = playback.durationSecs;
var show = false; var show = false;
if (next != null && duration > 0 && autoAdvance.peek() == null && r.transport != 'finished') { if (next != null && duration > 0 && r.transport != 'finished') {
final item = (jellyfinPlan.peek() as OwnPlay?)?.item; final item = (jellyfinPlan.peek() as OwnPlay?)?.item;
final at = nextPromptStart(item, Duration(milliseconds: (duration * 1000).round()), final at = nextPromptStart(item, Duration(milliseconds: (duration * 1000).round()),
endingStart: playback.ending.peek()?.start); endingStart: playback.ending.peek()?.start);
@@ -1554,9 +1644,8 @@ class SessionCoordinator {
nextPromptVisible.value = show; nextPromptVisible.value = show;
} }
/// Per state: played to the end -> Stopped (Jellyfin marks it watched) /// Per state: played to the end -> Stopped (with the watched rule's
/// and the auto-advance countdown; seeked back from the end -> Playing /// verdict); seeked back from the end -> Playing again.
/// again.
void _jellyfinTransport(StateBroadcast r) { void _jellyfinTransport(StateBroadcast r) {
final ref = _jfRef; final ref = _jfRef;
if (ref == null || r.mediaId != _jfMediaId) return; if (ref == null || r.mediaId != _jfMediaId) return;
@@ -1571,48 +1660,7 @@ class SessionCoordinator {
reporter.start(snap); reporter.start(snap);
} }
} }
if (!finished) { if (finished) nextPromptVisible.value = false;
if (autoAdvance.peek() != null) cancelAutoAdvance();
_advanceDoneFor = -1;
return;
}
nextPromptVisible.value = false;
final next = nextEpisode.peek();
// Up next goes first; "Clear" in it hides the next episode here.
if (next == null ||
!queue.continuationShown(r.mediaId) ||
!AppSettings.instance.autoAdvance.peek() ||
_advanceDoneFor == r.mediaId ||
autoAdvance.peek()?.mediaId == r.mediaId) {
return;
}
autoAdvance.value = AutoAdvance(
mediaId: r.mediaId, secondsLeft: autoAdvanceSecs, next: next, swapper: _isSwapper(r, ref));
_advanceTimer?.cancel();
_advanceTimer = Timer.periodic(const Duration(seconds: 1), (_) => _advanceTick());
}
bool _isSwapper(StateBroadcast r, JellyfinRef ref) =>
autoAdvanceSwapper(r.members, ref.serverId) == connection.memberId;
void _advanceTick() {
final a = autoAdvance.peek();
final r = serverState.peek();
final ref = _jfRef;
if (a == null || r == null || ref == null || r.mediaId != a.mediaId || r.transport != 'finished') {
cancelAutoAdvance();
return;
}
final left = a.secondsLeft - 1;
if (left > 0) {
autoAdvance.value = AutoAdvance(
mediaId: a.mediaId, secondsLeft: left, next: a.next, swapper: _isSwapper(r, ref));
return;
}
// Decided on the latest state: one swapper among the clients counting.
final swap = _isSwapper(r, ref);
cancelAutoAdvance();
if (swap) swapJellyfin(a.next.pick);
} }
String _accountKey(String serverId) => String _accountKey(String serverId) =>
@@ -1667,11 +1715,18 @@ class SessionCoordinator {
final tokenUrl = rejoin.oldToken.isNotEmpty && final tokenUrl = rejoin.oldToken.isNotEmpty &&
rejoin.oldToken != welcome.token && rejoin.oldToken != welcome.token &&
(_openUrl?.contains('/${rejoin.oldToken}/') ?? false); (_openUrl?.contains('/${rejoin.oldToken}/') ?? false);
if (changed || tokenUrl) { // A restarted server without media while this app had some: it is
// offered back (and the player keeps playing it meanwhile).
final restoring = !rejoin.sameServer && before?.media != null && now != null && now.media == null;
if (restoring) {
_beginRestore(before!, rejoin.oldToken);
} else if (changed || tokenUrl) {
_forgetMedia(); _forgetMedia();
} else if (!rejoin.sameServer && _mediaId != -1) { } else if (!rejoin.sameServer && _mediaId != -1) {
// The same media under a restarted server's numbering: the player // The same media under a restarted server's numbering: the player
// keeps it. // keeps it, and so do the watch and the Jellyfin reports.
watch.renumber(before.mediaId, now.mediaId);
if (_jfMediaId == before.mediaId) _jfMediaId = now.mediaId;
_mediaId = now.mediaId; _mediaId = now.mediaId;
media.renumber(now.mediaId); media.renumber(now.mediaId);
_seenMediaId = now.mediaId; _seenMediaId = now.mediaId;
@@ -1679,7 +1734,7 @@ class SessionCoordinator {
// The intent the server lost: ready (unless the video changed and the // The intent the server lost: ready (unless the video changed and the
// user unreadies then), and sitting out. // user unreadies then), and sitting out.
var wantReady = _lastReady && !_lastAway; var wantReady = _lastReady && !_lastAway;
if (changed && wantReady && _seenMediaId >= 0 && AppSettings.instance.unreadyOnSwap.peek()) { if (changed && !restoring && wantReady && _seenMediaId >= 0 && settings.unreadyOnSwap.peek()) {
wantReady = false; wantReady = false;
_lastReady = false; _lastReady = false;
} }
@@ -1693,6 +1748,125 @@ class SessionCoordinator {
} }
} }
/// A restarted server answered without media while this app had [before]
/// up: one app puts it back (`swap.restore`; the server keeps the first
/// offer). Who offers is decided from the old state alone, so the apps
/// agree: who put it up or passes it on first, then the others by their
/// old member id, 1.5 s apart; each only while the server is still
/// empty and only what it can put up (see [_offerBack]). Given up after
/// [_restoreGiveUp]: then the next state is applied as it is.
void _beginRestore(StateBroadcast before, String oldToken) {
final self = _stateSelf;
_restore = (before: before, self: self, oldToken: oldToken, at: DateTime.now());
queue.restoreFrom(before.mediaId);
final swapBy = before.media?['swapBy'];
final source = stateMediaRelay(before)?.source;
final first = self.isNotEmpty && (self == swapBy || self == source);
final others = [
for (final m in before.members)
if (m.id != swapBy && m.id != source && isPresent(m)) m.id,
]..sort(compareMemberIds);
final rank = others.indexOf(self);
final wait = first
? const Duration(milliseconds: 500)
: Duration(milliseconds: 3000 + 1500 * (rank < 0 ? others.length : rank));
_log.info('the server restarted without media: offering "${before.media?['name']}" back in '
'${wait.inMilliseconds} ms unless someone does first');
_restoreTimer?.cancel();
_restoreTimer = Timer(wait, () => unawaited(_offerBack()));
}
/// Puts what played back up the way it went up, from here, when this app
/// can: a Jellyfin item with its own account (it passes it on again),
/// its file, the link it put up (passed on, fetched by the server, or
/// opened by everyone), a link everyone opens. Where it was: this
/// player's position, else the old server's clock; and whether it
/// played.
Future<void> _offerBack() async {
final hold = _restore;
if (hold == null) return;
_restoreTimer = Timer(_restoreGiveUp - DateTime.now().difference(hold.at), _giveUpRestore);
final r = serverState.peek();
if (r == null || r.media != null || !connection.state.peek().online) return;
final before = hold.before;
final m = before.media!;
final holds = _playerReady && media.playerMediaId == before.mediaId && _openUrl != null;
final position = holds ? playback.positionSecs : serverPositionAt(before, connection.serverNow());
final restore = SwapRestore(position: position, transport: before.transport);
final relay = stateMediaRelay(before);
final ownFile = _ownFile?.$1 == before.mediaId ? _ownFile!.$2 : null;
final ownLink = _ownLink?.$1 == before.mediaId ? _ownLink!.$2 : null;
final ref = refOfStateMedia(m);
String? error = 'nothing here to put it back from';
if (ref != null) {
final pick = pickForRef(jellyfin, ref);
if (pick != null) {
swapJellyfin(pick, restore: restore);
error = null;
}
} else if (ownFile != null) {
error = await swapFile(ownFile, restore: restore);
} else {
final url = ownLink ?? stateMediaUrl(before);
final mode = relay == null ? LinkRelay.direct : (relay.fetch ? LinkRelay.server : LinkRelay.me);
if (url != null && (url.startsWith('http://') || url.startsWith('https://')) && (mode != LinkRelay.me || ownLink != null)) {
error = await swapLink(url, relay: mode, anyway: true, restore: restore);
}
}
_log.info(error == null
? 'offered "${m['name']}" back at ${position.toStringAsFixed(1)} s (${before.transport})'
: 'cannot offer "${m['name']}" back: $error');
}
/// Nobody put it back: the player lets go of it with the next state.
void _giveUpRestore() {
if (_restore == null) return;
_log.info('nothing came back after the restart: letting go of what played');
_restore = null;
_restoreTimer = null;
queue.restoreFrom(-1);
_forgetMedia();
final r = serverState.peek();
if (r != null) _onState(r);
}
/// What played is up again ([r], a new mediaId) or other media is: the
/// same media carries on here, the player keeping it unless its URL
/// named the old server's token (a relay, the host's file: opened
/// again); the watch, the Jellyfin reports and what this member passes
/// on go on with it. Up next comes back too ([QueueController]).
void _landRestore(({StateBroadcast before, String self, String oldToken, DateTime at}) hold, StateBroadcast r) {
_restore = null;
_restoreTimer?.cancel();
_restoreTimer = null;
final before = hold.before;
if (!_sameMedia(before.media, r.media)) {
_log.info('after the restart the server has other media: "${r.media?['name']}"');
queue.restoreFrom(-1);
_forgetMedia();
return;
}
_log.info('"${r.media?['name']}" is back as media ${r.mediaId}');
watch.renumber(before.mediaId, r.mediaId);
if (_watchEndFor == before.mediaId) _watchEndFor = r.mediaId;
_seenMediaId = r.mediaId;
queue.restoreAfterRestart(before, hold.self, winner: r.media?['swapBy'] == connection.memberId);
final url = _openUrl;
final keep = _mediaId == before.mediaId &&
media.playerMediaId == before.mediaId &&
url != null &&
(hold.oldToken.isEmpty || !url.contains('/${hold.oldToken}/'));
if (!keep) {
_forgetMedia();
return;
}
if (_jfMediaId == before.mediaId) _jfMediaId = r.mediaId;
_mediaId = r.mediaId;
diff.reset();
final plan = jellyfinPlan.peek();
media.resumeAs(r, ownJellyfin: plan is OwnPlay ? (url: plan.url, headers: plan.headers) : null);
}
/// Whether two states' `media` are the same media (a restarted server /// Whether two states' `media` are the same media (a restarted server
/// counts its `mediaId` from 1 again). /// counts its `mediaId` from 1 again).
static bool _sameMedia(Map<String, Object?>? a, Map<String, Object?>? b) { static bool _sameMedia(Map<String, Object?>? a, Map<String, Object?>? b) {
@@ -1746,10 +1920,18 @@ class SessionCoordinator {
_seekTimer?.cancel(); _seekTimer?.cancel();
_seekTimer = null; _seekTimer = null;
_seekGate.reset(); _seekGate.reset();
watch.close();
_watchEndFor = -1;
_restore = null;
_restoreTimer?.cancel();
_restoreTimer = null;
queue.restoreFrom(-1);
await _stopJellyfin(); await _stopJellyfin();
media.reset(); media.reset();
_pendingArt = null; _pendingArt = null;
_ownLink = null; _ownLink = null;
_ownFile = null;
ownFile.value = null;
_playerReady = false; _playerReady = false;
try { try {
await media.release(); await media.release();
@@ -0,0 +1,215 @@
import 'dart:async';
import 'package:sync_relay_core/core.dart';
import 'package:sync_relay_server/server.dart' show SharedSource;
final _log = createLogger('session');
/// A link this member passes on, read from the site once ([SharedSource]):
/// its page URL, the reader, and the page's subtitles for the player.
class SharedLink {
final String url;
final SharedSource source;
final List<YtDlpSubtitle> subtitles;
SharedLink(this.url, this.source, {this.subtitles = const []});
}
/// The links this member passes on and reads once, by page URL: the one a
/// swap check read (until the swap's state takes it), Up next's warm one
/// (until the swap that puts it up takes it), and the current media's.
/// Each one's player opens [SharedSource.playerUrl] and its relay serves
/// [SharedSource.upload]. A page with a pieces track (HLS/DASH) is not
/// shared ([fits]): its player and relay keep their own fetches.
/// Page links played here only ([local]) get a reader of their own too,
/// for its resume after cuts.
class SharedLinks {
SharedLink? _current;
({String queueId, SharedLink link})? _warm;
SharedLink? _pending;
Timer? _pendingTimer;
/// A link that can be shared: a media file, or a page whose relay tracks
/// are all plain files (one, or a video and an audio).
static bool fits(LinkProbe probe) =>
probe.relayProblem == null && (!probe.isPage || (probe.info?.tracks.every((t) => !t.format.isPieces) ?? false));
static List<YtDlpSubtitle> _subs(LinkProbe? probe) => probe?.info?.play?.subtitles ?? const [];
/// The swap check of a link this member passes on: a shared source
/// for [url] over [media], started (the tracks' sizes read). Kept for
/// the swap's state ([current]) for a minute, then dropped.
Future<SharedSource> forSwap(String url, RelayMedia media, LinkProbe probe,
{Duration timeout = const Duration(seconds: 15)}) async {
final s = SharedSource(media);
try {
await s.start().timeout(timeout);
} catch (_) {
unawaited(s.close());
rethrow;
}
_dropPending();
_pending = SharedLink(url, s, subtitles: _subs(probe));
_pendingTimer = Timer(const Duration(seconds: 60), _dropPending);
return s;
}
void _dropPending() {
_pendingTimer?.cancel();
_pendingTimer = null;
final p = _pending;
_pending = null;
if (p != null && !identical(p, _current)) unawaited(p.source.close());
}
/// The current media's shared source for [url]: the one it has, else
/// Up next's warm one for [queueId], else the swap check's, else a new
/// one over [media] (opened, its sizes read meanwhile). Null when the
/// link cannot be shared ([fits], or it does not open): the caller
/// keeps today's two fetches. Any other current one is closed.
Future<SharedSource?> current(String url, LinkProbe probe, RelayMedia Function() media,
{String queueId = ''}) async {
final had = _current;
if (had != null && had.url == url) return had.source;
await closeCurrent();
SharedLink? link;
final pending = _pending, warm = _warm;
if (warm != null && warm.link.url == url && queueId.isNotEmpty && warm.queueId == queueId) {
// The swap carries the warm relay's source on: the player reads the
// reader that source serves.
_warm = null;
link = warm.link;
if (pending != null && pending.url == url) _dropPending();
} else if (pending != null && pending.url == url) {
_pendingTimer?.cancel();
_pending = null;
link = pending;
} else if (fits(probe)) {
final s = SharedSource(media());
try {
await s.open();
} catch (e) {
_log.info('link ${logUrl(url)} not read once here: $e');
unawaited(s.close());
return null;
}
link = SharedLink(url, s, subtitles: _subs(probe));
}
_current = link;
return link?.source;
}
/// The current media's shared source when it reads [url].
SharedSource? currentFor(String url) => _current?.url == url ? _current!.source : null;
/// Up next item [queueId]'s shared source for [url] (fed to its warm
/// relay), made over [media] and opened; the one it has is kept. Null
/// when the link cannot be shared.
Future<SharedSource?> warm(String queueId, String url, LinkProbe probe, RelayMedia Function() media) async {
final had = _warm;
if (had != null && had.queueId == queueId && had.link.url == url) return had.link.source;
dropWarm();
if (!fits(probe)) return null;
final s = SharedSource(media());
final link = SharedLink(url, s, subtitles: _subs(probe));
_warm = (queueId: queueId, link: link);
try {
await s.open();
} catch (e) {
_log.info('Up next ${logUrl(url)} not read once here: $e');
if (identical(_warm?.link, link)) _warm = null;
unawaited(s.close());
return null;
}
return identical(_warm?.link, link) ? s : null;
}
/// The warm one goes (its relay ended, the item is no longer first);
/// with [queueId], only that item's.
void dropWarm([String? queueId]) {
final w = _warm;
if (w == null || (queueId != null && w.queueId != queueId)) return;
_warm = null;
unawaited(w.link.source.close());
}
/// What a player opens for [url] when it is shared here (the current
/// media's or the warm one), opened: null otherwise.
Future<SharedLink?> playerFor(String url) async {
final link = _current?.url == url ? _current : (_warm?.link.url == url ? _warm!.link : null);
if (link == null) return null;
try {
await link.source.open();
return link;
} catch (_) {
return null;
}
}
/// [url] is shared here (the current media's or the warm one).
bool has(String url) => _current?.url == url || _warm?.link.url == url;
// Page links this app plays without passing them on (alone, or
// everyone fetches it), by page URL: the session player's and the
// preload's.
final _local = <String, Future<SharedLink?>>{};
/// A page link played here only: read through its own [SharedSource]
/// over [media] too, so the player gets the reader's resume after a cut,
/// back-off and fresh address (403/410/412) instead of ffmpeg's
/// reconnect, which a CDN like Bilibili cuts off for good. The one [url]
/// has is kept unless [fresh]. Null when the page does not fit ([fits]:
/// a pieces track) or the reader cannot open it: the player reads the
/// site itself. [trimLocal] closes the ones no longer needed.
Future<SharedLink?> local(String url, LinkProbe probe, RelayMedia Function() media,
{bool fresh = false, Duration timeout = const Duration(seconds: 15)}) {
final had = _local[url];
if (had != null && !fresh) return had;
if (had != null) _local.remove(url)!.then((l) => l?.source.close());
if (!fits(probe)) return Future.value();
Future<SharedLink?> make() async {
final s = SharedSource(media());
try {
// The sizes too: a format URL the site refuses (403) fails here,
// and the player then gets the site's own answer.
await s.start().timeout(timeout);
return SharedLink(url, s, subtitles: _subs(probe));
} catch (e) {
_log.info('link ${logUrl(url)} read by the player itself: $e');
unawaited(s.close());
return null;
}
}
final f = _local[url] = make();
unawaited(f.then((l) {
if (l == null && identical(_local[url], f)) _local.remove(url);
}));
return f;
}
/// [url] is read here through a [local] reader.
bool hasLocal(String url) => _local.containsKey(url);
/// Closes the [local] readers of every URL but [keep] (the player's and
/// the preload's).
void trimLocal(Set<String?> keep) {
for (final url in [..._local.keys]) {
if (keep.contains(url)) continue;
unawaited(_local.remove(url)!.then((l) => l?.source.close()));
}
}
Future<void> closeCurrent() async {
final c = _current;
_current = null;
if (c != null) await c.source.close();
}
/// Leave, a new session: everything goes.
Future<void> closeAll() async {
_dropPending();
dropWarm();
trimLocal(const {});
await closeCurrent();
}
}
+84 -11
View File
@@ -12,6 +12,7 @@ import '../jellyfin/session_media.dart' show relayCredentialsFor;
import '../state/connect_prefs.dart'; import '../state/connect_prefs.dart';
import '../state/server_isolate.dart'; import '../state/server_isolate.dart';
import 'invite.dart'; import 'invite.dart';
import 'public_address.dart';
/// A fresh key per app run: this app's own client sends it in hello to be /// A fresh key per app run: this app's own client sends it in hello to be
/// recognised as the host (`members[].host`; it also stands in for the /// recognised as the host (`members[].host`; it also stands in for the
@@ -44,8 +45,9 @@ Future<ServerIsolate> _startIsolate(
/// port, so only this app reaches it; [invite] attaches a second /// port, so only this app reaches it; [invite] attaches a second
/// listener on every interface at the configured port (Settings → Party /// listener on every interface at the configured port (Settings → Party
/// & network: port and password) and [stopSharing] detaches it. Members, /// & network: port and password) and [stopSharing] detaches it. Members,
/// what plays and Up next never restart or reconnect for either. Made /// what plays and Up next never restart or reconnect for either. Sharing
/// by the composition root. /// also looks up the internet address ([reach]). Made by the composition
/// root.
class YourServer { class YourServer {
YourServer({ YourServer({
required this.forms, required this.forms,
@@ -54,10 +56,13 @@ class YourServer {
String? Function()? logDir, String? Function()? logDir,
ServerStarter? starter, ServerStarter? starter,
Future<List<({String name, InternetAddress address})>> Function()? interfaces, Future<List<({String name, InternetAddress address})>> Function()? interfaces,
Future<String?> Function()? publicAddress,
this.publicWait = const Duration(seconds: 4),
}) : _ytDlpDir = ytDlpDir ?? (() async => ''), }) : _ytDlpDir = ytDlpDir ?? (() async => ''),
_logDir = logDir ?? (() => null), _logDir = logDir ?? (() => null),
_starter = starter ?? _startIsolate, _starter = starter ?? _startIsolate,
_interfaces = interfaces ?? machineInterfaces; _interfaces = interfaces ?? machineInterfaces,
_publicAddress = publicAddress ?? lookUpPublicAddress;
final ConnectStore forms; final ConnectStore forms;
/// Linked Jellyfin servers (default: the app's): the relay logins. /// Linked Jellyfin servers (default: the app's): the relay logins.
@@ -66,6 +71,18 @@ class YourServer {
final String? Function() _logDir; final String? Function() _logDir;
final ServerStarter _starter; final ServerStarter _starter;
final Future<List<({String name, InternetAddress address})>> Function() _interfaces; final Future<List<({String name, InternetAddress address})>> Function() _interfaces;
final Future<String?> Function() _publicAddress;
/// How long [invite] waits for the internet address before it hands
/// out a local one (the lookup goes on; [reach] and [shared] follow).
final Duration publicWait;
/// Where friends can reach it while shared: the internet address (once
/// looked up) and this machine's; null when not shared.
final reach = signal<InviteReach?>(null);
/// What `/health` calls it ("Bob's server"), the invite's name.
String _name = '';
/// Sent by this app's own client (see [newHostKey]). /// Sent by this app's own client (see [newHostKey]).
final String hostKey = newHostKey(); final String hostKey = newHostKey();
@@ -81,6 +98,13 @@ class YourServer {
/// An [invite] or [stopSharing] is under way. /// An [invite] or [stopSharing] is under way.
final busy = signal(false); final busy = signal(false);
/// The password the server was shared with (empty: none).
String _secret = '';
/// What Invite copies: [invite]'s bare `starsync://` link with the
/// password the server was shared with, so a friend just clicks it.
String inviteLink(Invite invite) => invite.link(secret: _secret).uri;
/// Set when the server died without being stopped; the shell says so /// Set when the server died without being stopped; the shell says so
/// and clears it. The next [start] makes a new one. /// and clears it. The next [start] makes a new one.
final crash = signal<String?>(null); final crash = signal<String?>(null);
@@ -106,12 +130,13 @@ class YourServer {
Future<int> _start(String name) async { Future<int> _start(String name) async {
final who = name.trim(); final who = name.trim();
_name = who.isEmpty ? '' : "$who's server";
late final ServerIsolate server; late final ServerIsolate server;
server = await _starter( server = await _starter(
AppConfig( AppConfig(
port: 0, port: 0,
secret: '', secret: '',
name: who.isEmpty ? 'server' : "$who's server", name: who.isEmpty ? 'server' : _name,
hostKey: hostKey, hostKey: hostKey,
ytDlpDir: await _ytDlpDir(), ytDlpDir: await _ytDlpDir(),
), ),
@@ -133,6 +158,7 @@ class YourServer {
_server = null; _server = null;
port.value = null; port.value = null;
shared.value = null; shared.value = null;
reach.value = null;
final message = 'server crashed: $cause'; final message = 'server crashed: $cause';
debugPrint('[server] $message'); debugPrint('[server] $message');
crash.value = message; crash.value = message;
@@ -152,33 +178,77 @@ class YourServer {
final form = forms.form; final form = forms.form;
final want = int.tryParse(form.hostPort.trim()) ?? 8080; final want = int.tryParse(form.hostPort.trim()) ?? 8080;
await _accounts.ensureLoaded(validate: false); await _accounts.ensureLoaded(validate: false);
final secret = form.hostSecret;
final bound = await server.share( final bound = await server.share(
port: want, port: want,
secret: form.hostSecret, secret: secret,
// Guests without an account watch Jellyfin items through the // Guests without an account watch Jellyfin items through the
// server, with this app's own sign-in on the item's server. // server, with this app's own sign-in on the item's server.
jellyfin: relayCredentialsFor(_accounts), jellyfin: relayCredentialsFor(_accounts),
); );
final invite = await inviteFor(bound, password: form.hostSecret.isNotEmpty); _secret = secret;
final local = inviteAddresses(await _interfaces());
final gen = ++_shareGen;
reach.value = InviteReach(local: local, looking: true);
final lookup = _lookUp(local, gen);
// The internet address when it comes quickly; else a local one now
// and the internet one when it lands.
final public = await lookup.timeout(publicWait, onTimeout: () => null);
final invite = inviteFor(bound, password: secret.isNotEmpty, host: reach.peek()?.best);
shared.value = invite; shared.value = invite;
if (public == null) {
unawaited(lookup.then((ip) {
final now = shared.peek();
if (ip != null && gen == _shareGen && identical(now, invite)) shared.value = now!.at(ip);
}));
}
return invite; return invite;
} finally { } finally {
busy.value = false; busy.value = false;
} }
} }
/// The invite for [port] from this machine's best address (see /// Bumped by every share and unshare: a lookup of an earlier one is
/// [inviteAddresses]); 127.0.0.1 when there is none. /// dropped.
Future<Invite> inviteFor(int port, {required bool password}) async { int _shareGen = 0;
final addresses = inviteAddresses(await _interfaces());
/// The internet address into [reach] (its own failure is no address).
Future<String?> _lookUp(List<String> local, int gen) async {
String? ip;
try {
ip = await _publicAddress();
} catch (e) {
debugPrint('public address lookup failed: $e');
}
if (gen != _shareGen || reach.peek() == null) return null;
reach.value = InviteReach(public: ip, local: local);
return ip;
}
/// The invite for [port] at [host] (else this machine's best address,
/// see [inviteAddresses]; 127.0.0.1 when there is none).
Invite inviteFor(int port, {required bool password, String? host}) {
final local = reach.peek()?.local ?? const [];
return Invite( return Invite(
host: addresses.isEmpty ? '127.0.0.1' : addresses.first, host: host ?? (local.isEmpty ? '127.0.0.1' : local.first),
port: port, port: port,
name: _name,
password: password, password: password,
version: appVersion, version: appVersion,
); );
} }
/// [link] points at this server (one of its invite addresses, or this
/// machine, at its shared or loopback port).
bool isOwn(InviteLink link) {
final host = link.host.toLowerCase();
final local = host == 'localhost' || host == '127.0.0.1' || host == '::1';
if (local && link.port == port.peek()) return true;
final s = shared.peek();
if (s == null || link.port != s.port) return false;
return local || host == s.host || (reach.peek()?.choices.any((c) => c.host == host) ?? false);
}
/// Closes it to friends again: everyone but you leaves (told, so their /// Closes it to friends again: everyone but you leaves (told, so their
/// apps do not retry); you, what plays and Up next stay. /// apps do not retry); you, what plays and Up next stay.
Future<void> stopSharing() async { Future<void> stopSharing() async {
@@ -187,7 +257,9 @@ class YourServer {
busy.value = true; busy.value = true;
try { try {
await server.unshare(); await server.unshare();
_shareGen++;
shared.value = null; shared.value = null;
reach.value = null;
} finally { } finally {
busy.value = false; busy.value = false;
} }
@@ -202,6 +274,7 @@ class YourServer {
_server = null; _server = null;
port.value = null; port.value = null;
shared.value = null; shared.value = null;
reach.value = null;
await server?.stop(); await server?.stop();
} }
} }
@@ -1,3 +1,4 @@
import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
@@ -7,8 +8,13 @@ import 'shader_presets.dart';
const anime4kAssetDir = 'assets/shaders/anime4k'; const anime4kAssetDir = 'assets/shaders/anime4k';
/// The extracted copy's layout: bumped when [highpAnime4k] changes what
/// is written, so an older copy is written again.
const anime4kCopyRevision = 'hp1';
/// mpv needs real files: copies the shipped Anime4K shaders out of the /// mpv needs real files: copies the shipped Anime4K shaders out of the
/// asset bundle into `<supportDir>/shaders/anime4k-<version>`, once per /// asset bundle into `<supportDir>/shaders/anime4k-<version>-<revision>`,
/// each made full precision on OpenGL ES ([highpAnime4k]), once per
/// version (a `.complete` marker is written last, so a cut-short copy is /// version (a `.complete` marker is written last, so a cut-short copy is
/// redone). Returns that directory. /// redone). Returns that directory.
Future<Directory> extractAnime4k( Future<Directory> extractAnime4k(
@@ -17,7 +23,7 @@ Future<Directory> extractAnime4k(
}) async { }) async {
final dir = Directory( final dir = Directory(
'${supportDir.path}${Platform.pathSeparator}shaders' '${supportDir.path}${Platform.pathSeparator}shaders'
'${Platform.pathSeparator}anime4k-$anime4kVersion'); '${Platform.pathSeparator}anime4k-$anime4kVersion-$anime4kCopyRevision');
final marker = File('${dir.path}${Platform.pathSeparator}.complete'); final marker = File('${dir.path}${Platform.pathSeparator}.complete');
final files = anime4kAllFiles; final files = anime4kAllFiles;
if (await marker.exists() && if (await marker.exists() &&
@@ -28,14 +34,68 @@ Future<Directory> extractAnime4k(
final b = bundle ?? rootBundle; final b = bundle ?? rootBundle;
for (final name in [...files, 'LICENSE']) { for (final name in [...files, 'LICENSE']) {
final data = await b.load('$anime4kAssetDir/$name'); final data = await b.load('$anime4kAssetDir/$name');
final bytes = data.buffer.asUint8List(data.offsetInBytes, data.lengthInBytes);
await File(_join(dir, name)).writeAsBytes( await File(_join(dir, name)).writeAsBytes(
data.buffer.asUint8List(data.offsetInBytes, data.lengthInBytes), name.endsWith('.glsl') ? utf8.encode(highpAnime4k(utf8.decode(bytes))) : bytes,
flush: true); flush: true);
} }
await marker.writeAsString(anime4kVersion, flush: true); await marker.writeAsString(anime4kVersion, flush: true);
// Older copies (another version or revision) are not read again.
try {
await for (final e in dir.parent.list()) {
final name = e.uri.pathSegments.where((s) => s.isNotEmpty).last;
if (e is Directory && name.startsWith('anime4k-') && e.path != dir.path) {
await e.delete(recursive: true);
}
}
} catch (_) {}
return dir; return dir;
} }
/// The name of the helper [highpAnime4k] adds to each pass.
const _hp = 'starsync_hp';
/// [glsl] (an Anime4K file) with its convolutions computed at full
/// precision on an OpenGL ES context, as they are on desktop OpenGL.
///
/// media_kit renders through OpenGL ES (EGL on Linux, ANGLE on Windows),
/// where mpv declares `precision mediump sampler2D`: a texture read is
/// mediump, and Anime4K's weights are bare literals, so every
/// `mat4(...) * go_0(...)` of a network ran at half precision (fp16 on
/// NVIDIA). The large (VL) networks overflow and round there: shifted
/// colours, posterised areas and a regular grid, worse once the
/// upscalers run at screen size. Each pass gets an identity function
/// with a highp parameter and result, and every `go_N` / `g_N` read goes
/// through it, so the products and sums that follow are highp. Desktop
/// GLSL ignores precision qualifiers, so nothing changes there. Anything
/// that is not such a read is left as it is.
String highpAnime4k(String glsl) {
final read = RegExp(r'\b\w+_tex(?:Off)?\((?:[^()]|\([^()]*\))*\)');
final define = RegExp(r'^#define g(?:o)?_\d+\b');
final out = <String>[];
var inHeader = false, helper = false;
for (final line in const LineSplitter().convert(glsl)) {
if (line.startsWith('//!')) {
// A new pass starts at the first `//!` line after code.
if (!inHeader) helper = false;
inHeader = true;
out.add(line);
continue;
}
inHeader = false;
if (define.hasMatch(line)) {
if (!helper) {
out.add('highp vec4 $_hp(highp vec4 v) { return v; }');
helper = true;
}
out.add(line.replaceAllMapped(read, (m) => '$_hp(${m[0]})'));
continue;
}
out.add(line);
}
return '${out.join('\n')}\n';
}
/// Absolute paths of [preset]'s Anime4K files inside [dir]. /// Absolute paths of [preset]'s Anime4K files inside [dir].
List<String> anime4kPaths( List<String> anime4kPaths(
Directory dir, ShaderPreset preset, ShaderQuality quality) => Directory dir, ShaderPreset preset, ShaderQuality quality) =>
+97
View File
@@ -7,6 +7,8 @@ import 'package:signals/signals_flutter.dart';
import '../controls/frame.dart' show DoubleClickListener; import '../controls/frame.dart' show DoubleClickListener;
import '../controls/session_controls.dart' show controlsBottomHeight; import '../controls/session_controls.dart' show controlsBottomHeight;
import '../danmaku/danmaku_notices.dart' show fliesAsDanmaku;
import '../danmaku/danmaku_preview.dart';
import '../files/folder_store.dart'; import '../files/folder_store.dart';
import '../files/ui/files_page.dart' show showFilesFolder; import '../files/ui/files_page.dart' show showFilesFolder;
import '../jellyfin/accounts.dart'; import '../jellyfin/accounts.dart';
@@ -17,14 +19,18 @@ import '../session/app_events.dart' show ActionFailed, ScheduleResult;
import '../screens/home/health_probe.dart'; import '../screens/home/health_probe.dart';
import '../screens/home/media_picker.dart'; import '../screens/home/media_picker.dart';
import '../session/identity.dart'; import '../session/identity.dart';
import '../screens/home/ui.dart' show appName;
import '../screens/home/update_indicator.dart'; import '../screens/home/update_indicator.dart';
import '../app/app_graph.dart'; import '../app/app_graph.dart';
import '../session/navigation.dart' show Place; import '../session/navigation.dart' show Place;
import '../state/settings.dart'; import '../state/settings.dart';
import '../update/update_controller.dart'; import '../update/update_controller.dart';
import '../update/whats_new.dart';
import '../widgets/whats_new_card.dart';
import 'chat_panel.dart'; import 'chat_panel.dart';
import 'connect_cards.dart'; import 'connect_cards.dart';
import 'handoff.dart'; import 'handoff.dart';
import 'join_offer_card.dart';
import 'library_link.dart' show LinkProber; import 'library_link.dart' show LinkProber;
import 'library_tab.dart'; import 'library_tab.dart';
import 'motion.dart'; import 'motion.dart';
@@ -33,6 +39,7 @@ import 'profile_card.dart';
import '../schedule/ui/schedule_page.dart'; import '../schedule/ui/schedule_page.dart';
import 'session_player.dart'; import 'session_player.dart';
import 'settings_tab.dart'; import 'settings_tab.dart';
import 'quick_chat.dart';
import 'toasts.dart'; import 'toasts.dart';
import 'welcome.dart'; import 'welcome.dart';
@@ -52,6 +59,9 @@ import 'welcome.dart';
class AppShell extends StatefulWidget { class AppShell extends StatefulWidget {
final UpdateController? updates; final UpdateController? updates;
final bool autoUpdateCheck; final bool autoUpdateCheck;
/// The card once after an update; defaults to the graph's [WhatsNew] when
/// [autoUpdateCheck] is on (off in tests unless given).
final WhatsNew? whatsNew;
/// Off in tests: the drag area, window buttons and fullscreen need /// Off in tests: the drag area, window buttons and fullscreen need
/// window_manager. /// window_manager.
final bool windowControls; final bool windowControls;
@@ -67,6 +77,7 @@ class AppShell extends StatefulWidget {
super.key, super.key,
this.updates, this.updates,
this.autoUpdateCheck = true, this.autoUpdateCheck = true,
this.whatsNew,
this.windowControls = true, this.windowControls = true,
this.healthGet = defaultHealthGet, this.healthGet = defaultHealthGet,
this.jellyfin, this.jellyfin,
@@ -103,6 +114,11 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
/// Whether the full player's controls are up (the player's frame /// Whether the full player's controls are up (the player's frame
/// reports it): the toasts sit above them. /// reports it): the toasts sit above them.
final _controlsShown = ValueNotifier(true); final _controlsShown = ValueNotifier(true);
/// The player's quick chat box (Enter); its draft is the chat panel's.
late final _quickChat = QuickChat(draft: _chat);
/// The player flies notifications as danmaku: those stay out of the
/// toasts.
final _danmakuActive = ValueNotifier(false);
Alignment _corner = Alignment.bottomRight; Alignment _corner = Alignment.bottomRight;
Offset? _drag; // mini top-left while dragging Offset? _drag; // mini top-left while dragging
@@ -158,6 +174,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
// Reads linked servers and checks their tokens in the background. // Reads linked servers and checks their tokens in the background.
_jellyfin.ensureLoaded(); _jellyfin.ensureLoaded();
if (widget.autoUpdateCheck) _autoCheck(); if (widget.autoUpdateCheck) _autoCheck();
if (widget.whatsNew != null || widget.autoUpdateCheck) unawaited(_startWhatsNew());
HardwareKeyboard.instance.addHandler(_onKey); HardwareKeyboard.instance.addHandler(_onKey);
final nav = _graph.navigation; final nav = _graph.navigation;
final coord = _graph.coordinator; final coord = _graph.coordinator;
@@ -240,7 +257,9 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
for (final stop in _stops) { for (final stop in _stops) {
stop(); stop();
} }
_danmaku?.dispose();
HardwareKeyboard.instance.removeHandler(_onKey); HardwareKeyboard.instance.removeHandler(_onKey);
_whatsNewSource?.due.removeListener(_maybeWhatsNew);
_updateTimer?.cancel(); _updateTimer?.cancel();
_fade.dispose(); _fade.dispose();
_online.dispose(); _online.dispose();
@@ -250,6 +269,8 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
_flow.dispose(); _flow.dispose();
_join.dispose(); _join.dispose();
_picker.dispose(); _picker.dispose();
_quickChat.dispose();
_danmakuActive.dispose();
_chat.dispose(); _chat.dispose();
_chatFocus.dispose(); _chatFocus.dispose();
_controlsShown.dispose(); _controlsShown.dispose();
@@ -276,6 +297,36 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
if (s.autoCheckUpdates.peek()) await runUpdateCheck(widget.updates); if (s.autoCheckUpdates.peek()) await runUpdateCheck(widget.updates);
} }
WhatsNew? _whatsNewSource;
bool _whatsNewOpen = false;
/// What's new once after an update: due now (notes kept by the install)
/// or once a check reads this version's manifest.
Future<void> _startWhatsNew() async {
await _graph.coordinator.settings.ensureLoaded();
if (!mounted) return;
final w = _whatsNewSource = widget.whatsNew ?? _graph.whatsNew;
w.due.addListener(_maybeWhatsNew);
// Not over the full player while a video is on: once it ends or minimises.
_stops.add(effect(() {
_watching.value;
untracked(() => _later(_maybeWhatsNew));
}));
w.start(widget.updates ?? _graph.updates);
_maybeWhatsNew();
}
void _maybeWhatsNew() {
final w = _whatsNewSource;
final notes = w?.due.value;
if (w == null || notes == null || !mounted || _whatsNewOpen) return;
if (_shown == PlayerMode.full && _watching.peek()) return;
_whatsNewOpen = true;
w.shown();
unawaited(showWhatsNew(context, notes, overline: '$appName · updated')
.whenComplete(() => _whatsNewOpen = false));
}
void _wentOnline() { void _wentOnline() {
_unread = 0; _unread = 0;
_feedSeen = _graph.coordinator.feedLog.peek().length; _feedSeen = _graph.coordinator.feedLog.peek().length;
@@ -287,6 +338,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
if (_fullscreen) _toggleFullscreen(); if (_fullscreen) _toggleFullscreen();
setState(() { setState(() {
_chatOpen = false; _chatOpen = false;
_graph.notifier.chatOpen = false;
_unread = 0; _unread = 0;
}); });
_show(PlayerMode.none); _show(PlayerMode.none);
@@ -312,11 +364,28 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
} }
if (!mounted || seq != _modeSeq) return; if (!mounted || seq != _modeSeq) return;
setState(() => _shown = m); setState(() => _shown = m);
if (m != PlayerMode.full) _later(_maybeWhatsNew);
await _fade.animateTo(1, duration: fadeThroughTime * (1 - fadeOutPart), curve: ease); await _fade.animateTo(1, duration: fadeThroughTime * (1 - fadeOutPart), curve: ease);
} }
void _expand() => _show(PlayerMode.full); void _expand() => _show(PlayerMode.full);
/// Dev builds: the danmaku preview, made on first use.
DanmakuPreview? _danmaku;
/// Over the full player when a player is up, else on its own page with
/// a stand-in picture.
void _openDanmakuPreview() {
final d = _danmaku ??= DanmakuPreview(style: _graph.coordinator.settings.danmakuStyle.peek());
if (_online.peek() && _graph.coordinator.playback.alive.peek()) {
setState(() => d.active.value = true);
_show(PlayerMode.full);
} else {
d.active.value = false;
showDanmakuPreviewPage(context, d);
}
}
void _collapse() { void _collapse() {
if (_fullscreen) _toggleFullscreen(); if (_fullscreen) _toggleFullscreen();
_show(PlayerMode.mini); _show(PlayerMode.mini);
@@ -362,6 +431,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
void _toggleChat() => setState(() { void _toggleChat() => setState(() {
_chatOpen = !_chatOpen; _chatOpen = !_chatOpen;
_graph.notifier.chatOpen = _chatOpen;
_unread = 0; _unread = 0;
if (!_chatOpen) _chatFocus.unfocus(); if (!_chatOpen) _chatFocus.unfocus();
}); });
@@ -602,6 +672,22 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
void _invite() => unawaited(inviteFriends(context)); void _invite() => unawaited(inviteFriends(context));
/// The join prompt's Join: the invite into the Join card's fields, then
/// the join (version gate and all); a failure opens the card with it.
Future<void> _acceptOffer() async {
final link = _graph.joinOffers.accept();
if (link == null) return;
final port = '${link.port}';
_join.host.text = link.host;
_join.port.text = port;
_join.secret.text = link.secret;
await _flow.submit((f) => f
..host = link.host
..port = port
..secret = link.secret);
if (mounted && _flow.error != null) _joinFriend();
}
// ----------------------------------------------------------------- build // ----------------------------------------------------------------- build
@override @override
@@ -723,6 +809,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
onEditProfile: () => showProfileCard(context, onOpenSettings: _openSettings, updates: widget.updates), onEditProfile: () => showProfileCard(context, onOpenSettings: _openSettings, updates: widget.updates),
updates: widget.updates, updates: widget.updates,
jellyfin: _jellyfin, jellyfin: _jellyfin,
onDanmakuPreview: _openDanmakuPreview,
), ),
}; };
final full = _shown == PlayerMode.full; final full = _shown == PlayerMode.full;
@@ -757,6 +844,12 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
), ),
if (online && _shown != PlayerMode.none) _playerLayer(area), if (online && _shown != PlayerMode.none) _playerLayer(area),
_toasts(full && online), _toasts(full && online),
Positioned(
top: 12,
left: 16,
right: 16,
child: Center(child: JoinOfferCard(offers: _graph.joinOffers, onJoin: _acceptOffer)),
),
], ],
); );
}); });
@@ -771,6 +864,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
onOpenChat: () { onOpenChat: () {
if (!_chatOpen) _toggleChat(); if (!_chatOpen) _toggleChat();
}, },
skip: (t) => (_danmakuActive.value && fliesAsDanmaku(t)) || _quickChat.echoed(t),
); );
return ValueListenableBuilder<bool>( return ValueListenableBuilder<bool>(
valueListenable: _controlsShown, valueListenable: _controlsShown,
@@ -866,6 +960,9 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
videoBuilder: widget.videoBuilder, videoBuilder: widget.videoBuilder,
windowControls: widget.windowControls, windowControls: widget.windowControls,
controlsShown: _controlsShown, controlsShown: _controlsShown,
quickChat: _quickChat,
danmakuActive: _danmakuActive,
danmaku: _danmaku,
), ),
), ),
), ),
+12 -2
View File
@@ -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'),
}; };
+11 -3
View File
@@ -7,7 +7,7 @@ import '../app/app_graph.dart';
import '../screens/home/connect_flow.dart'; import '../screens/home/connect_flow.dart';
import '../screens/home/connect_status.dart'; import '../screens/home/connect_status.dart';
import '../screens/home/health_probe.dart'; import '../screens/home/health_probe.dart';
import '../session/invite.dart' show addressIn; import '../session/invite.dart' show addressIn, inviteIn;
import '../session/navigation.dart' show Place; import '../session/navigation.dart' show Place;
import '../state/connect_prefs.dart'; import '../state/connect_prefs.dart';
import '../update/update_controller.dart'; import '../update/update_controller.dart';
@@ -138,9 +138,17 @@ class JoinCard extends StatelessWidget {
_join(); _join();
} }
/// An invite or "host:port" pasted into the address: split it. /// An invite (link or text) or "host:port" pasted into the address:
/// split it (a link's password goes into the password field).
void _onAddress(String v) { void _onAddress(String v) {
flow.clearError(); flow.clearError();
final link = inviteIn(v);
if (link != null) {
fields.host.text = link.host;
fields.port.text = '${link.port}';
if (link.secret.isNotEmpty) fields.secret.text = link.secret;
return;
}
final a = addressIn(v); final a = addressIn(v);
if (a == null || v.trim() == a.host) return; if (a == null || v.trim() == a.host) return;
fields.host.text = a.host; fields.host.text = a.host;
@@ -223,7 +231,7 @@ class JoinCard extends StatelessWidget {
const Padding( const Padding(
padding: EdgeInsets.only(top: 8), padding: EdgeInsets.only(top: 8),
child: Text( child: Text(
'Your friend gives you their invite (the address) and the password, if any. ' 'Paste your friend\'s invite link (it carries the password), or their address and password. '
'No account or Jellyfin needed. Your own server stays as it is.', 'No account or Jellyfin needed. Your own server stays as it is.',
style: TextStyle(color: Colors.grey, fontSize: 12), style: TextStyle(color: Colors.grey, fontSize: 12),
), ),
@@ -0,0 +1,98 @@
import 'package:flutter/material.dart';
import 'package:signals/signals_flutter.dart';
import '../session/join_offers.dart';
import 'motion.dart';
/// The join prompt, top centre over the body: "Join Bob's server
/// (1.2.3.4:8080)?" with what its `/health` says (version, who is in,
/// the version gate, unreachable), Not now and Join. Shown while
/// [offers] has an offer (a clicked invite link, or one on the
/// clipboard).
class JoinOfferCard extends StatelessWidget {
final JoinOffers offers;
final VoidCallback onJoin;
const JoinOfferCard({super.key, required this.offers, required this.onJoin});
@override
Widget build(BuildContext context) => SignalBuilder(builder: (context) {
final o = offers.offer.value;
return AnimatedSwitcher(
duration: easeTime,
switchInCurve: ease,
switchOutCurve: ease,
transitionBuilder: (child, a) => FadeTransition(
opacity: a,
child: SlideTransition(
position: Tween(begin: const Offset(0, -0.2), end: Offset.zero).animate(a),
child: child,
),
),
child: o == null
? const SizedBox.shrink()
: _OfferBody(key: ValueKey(o.link), offer: o, onJoin: onJoin, onDismiss: offers.dismiss),
);
});
}
class _OfferBody extends StatelessWidget {
final JoinOffer offer;
final VoidCallback onJoin;
final VoidCallback onDismiss;
const _OfferBody({super.key, required this.offer, required this.onJoin, required this.onDismiss});
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final h = offer.health;
final problem = h != null && (!h.online || h.needsUpdate);
final detail = h == null ? 'Checking the server…' : offer.detail;
return Material(
key: const ValueKey('join-offer'),
elevation: 12,
color: scheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(14),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 560),
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 10, 12),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(offer.source == OfferSource.clipboard ? Icons.content_paste_go : Icons.link,
color: scheme.primary),
const SizedBox(width: 12),
Flexible(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(offer.question,
key: const ValueKey('join-offer-question'),
style: const TextStyle(fontWeight: FontWeight.w600)),
if (detail.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 2),
child: Text(
[
if (offer.source == OfferSource.clipboard) 'Invite on your clipboard',
detail,
].join(' · '),
style: TextStyle(
fontSize: 12, color: problem ? Colors.amber.shade300 : scheme.onSurfaceVariant),
),
),
],
),
),
const SizedBox(width: 12),
TextButton(key: const ValueKey('join-offer-dismiss'), onPressed: onDismiss, child: const Text('Not now')),
const SizedBox(width: 4),
FilledButton(key: const ValueKey('join-offer-join'), onPressed: onJoin, child: const Text('Join')),
],
),
),
),
);
}
}
+17 -89
View File
@@ -6,7 +6,6 @@ import '../screens/home/ui.dart' show appName, displayVersion, shortVersion;
import '../session/delivery.dart'; import '../session/delivery.dart';
import '../session/party.dart'; import '../session/party.dart';
import '../app/app_graph.dart'; import '../app/app_graph.dart';
import '../session/app_events.dart' show ActionFailed;
import '../session/invite.dart'; import '../session/invite.dart';
import '../session/navigation.dart' show Place; import '../session/navigation.dart' show Place;
import '../widgets/app_keys.dart'; import '../widgets/app_keys.dart';
@@ -16,6 +15,9 @@ import '../widgets/window_buttons.dart';
import 'avatar.dart'; import 'avatar.dart';
import 'delivery_line.dart'; import 'delivery_line.dart';
import 'motion.dart'; import 'motion.dart';
import 'your_invite.dart';
export 'your_invite.dart' show YourInvite, inviteFriends;
/// The shell's tabs, in strip order. /// The shell's tabs, in strip order.
enum ShellTab { enum ShellTab {
@@ -506,9 +508,13 @@ class MembersCard extends StatelessWidget {
alignment: Alignment.centerLeft, alignment: Alignment.centerLeft,
child: TextButton.icon( child: TextButton.icon(
key: const ValueKey('copy-invite'), key: const ValueKey('copy-invite'),
onPressed: () => copyToClipboard(context, address, done: 'Invite address copied'), onPressed: () => copyToClipboard(
context,
friendInviteLink(address, nav.serverLabel.peek(),
secret: AppScope.of(context).connection.params?.secret ?? ''),
done: 'Invite link copied'),
icon: const Icon(Icons.copy, size: 16), icon: const Icon(Icons.copy, size: 16),
label: const Text('Copy invite address'), label: const Text('Copy invite link'),
), ),
), ),
), ),
@@ -623,90 +629,12 @@ class VersionText extends StatelessWidget {
bool versionDiffers(String version, {String own = appVersion}) => bool versionDiffers(String version, {String own = appVersion}) =>
version.isNotEmpty && own != '0.0.0-dev' && version != '0.0.0-dev' && version != own; version.isNotEmpty && own != '0.0.0-dev' && version != '0.0.0-dev' && version != own;
/// Your server in the members card: shared, the invite's address /// The invite link to a friend's server you are in ([address] as dialled,
/// (Copy invite), a hint for friends outside the network and Stop /// "host:port"), with the password you joined with: the link is the key,
/// sharing; not shared, Invite (opens it and copies the invite). /// as for your own server.
class YourInvite extends StatelessWidget { String friendInviteLink(String address, String name, {String secret = ''}) {
const YourInvite({super.key}); final a = addressIn(address);
final port = a == null ? null : int.tryParse(a.port);
@override if (a == null || port == null) return address;
Widget build(BuildContext context) { return InviteLink(host: a.host, port: port, name: name, secret: secret).uri;
final muted = Theme.of(context).colorScheme.onSurfaceVariant;
final graph = AppScope.of(context);
final yours = graph.yourServer;
return SignalBuilder(builder: (context) {
final invite = yours.shared.value;
final busy = yours.busy.value || yours.port.value == null;
return Padding(
padding: const EdgeInsets.fromLTRB(12, 0, 12, 12),
child: Column(
key: const ValueKey('your-invite'),
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (invite == null) ...[
Padding(
padding: const EdgeInsets.fromLTRB(8, 0, 8, 6),
child: Text('Only you can reach it. Invite opens it to friends; nothing you watch stops.',
style: TextStyle(color: muted, fontSize: 12)),
),
TextButton.icon(
key: const ValueKey('copy-invite'),
onPressed: busy ? null : () => inviteFriends(context),
icon: const Icon(Icons.group_add_outlined, size: 16),
label: const Text('Invite'),
),
] else ...[
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Text(invite.address,
key: const ValueKey('invite-address'), style: const TextStyle(fontFamily: 'monospace')),
),
Wrap(
spacing: 4,
children: [
TextButton.icon(
key: const ValueKey('copy-invite'),
onPressed: () => copyToClipboard(context, invite.text, done: 'Invite copied'),
icon: const Icon(Icons.copy, size: 16),
label: const Text('Copy invite'),
),
TextButton(
key: const ValueKey('stop-sharing'),
onPressed: busy ? null : () => yours.stopSharing(),
child: const Text('Stop sharing'),
),
],
),
Padding(
padding: const EdgeInsets.fromLTRB(8, 2, 8, 0),
child: Text(
'Works for friends on your network. Friends elsewhere need port ${invite.port} '
'forwarded on your router, or a server they can all reach (like a VPS). '
'Stop sharing sends everyone else out; you and Up next stay.',
style: TextStyle(color: muted, fontSize: 12),
),
),
],
],
),
);
});
}
}
/// Invite: opens your server to friends if it is not yet and copies the
/// invite ("Invite copied"); a port that can't be opened is a notice.
Future<void> inviteFriends(BuildContext context) async {
final graph = AppScope.of(context);
final Invite invite;
try {
invite = await graph.yourServer.invite();
} catch (e) {
final port = graph.forms.form.hostPort;
graph.notifier.notify(ActionFailed(
"couldn't open your server on port $port: $e — pick another port in Settings → Party & network"));
return;
}
if (!context.mounted) return;
await copyToClipboard(context, invite.text, done: 'Invite copied · ${invite.address}');
} }
+397
View File
@@ -0,0 +1,397 @@
import 'dart:async';
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
import '../session/notifier.dart' show ToastItem;
import '../widgets/app_menu.dart' show AppMenuStyle;
import 'avatar.dart';
import 'motion.dart';
// The quick chat box: Enter in the full player opens a small box at the
// bottom centre, in the control bar's place, without opening the chat
// panel. It types into the same draft as the chat panel's field.
/// The box's state, owned by the shell (it outlives the player's modes):
/// open or not, the draft ([draft], the chat panel's controller), its
/// focus, the echo of what was just sent, and the sends whose own chat
/// toast should not show again ([echoed]).
class QuickChat {
/// Closes by itself after this long without typing; the draft stays.
static const idleClose = Duration(seconds: 8);
/// How long the echo of a sent line stays where the box was.
static const echoTime = Duration(milliseconds: 2600);
/// The draft, shared with the chat panel's field.
final TextEditingController draft;
final bool _ownsDraft;
final focus = FocusNode(debugLabel: 'quick chat');
final open = ValueNotifier(false);
/// The line just sent, shown briefly in the box's place (null: none).
final echo = ValueNotifier<String?>(null);
/// Bumped on each send ([lastSent]): the player flies your own line
/// when danmaku is the notification style (and drops the echo).
final sends = ValueNotifier(0);
String lastSent = '';
/// Without typing this long, the box closes ([idleClose]).
final Duration idle;
Timer? _idleTimer;
Timer? _echoTimer;
final _recent = <(String, DateTime)>[];
QuickChat({TextEditingController? draft, this.idle = idleClose})
: draft = draft ?? TextEditingController(),
_ownsDraft = draft == null {
this.draft.addListener(_typed);
}
void dispose() {
draft.removeListener(_typed);
if (_ownsDraft) draft.dispose();
_idleTimer?.cancel();
_echoTimer?.cancel();
focus.dispose();
open.dispose();
echo.dispose();
sends.dispose();
}
/// Opens the box with the draft as it was, caret at its end, and gives
/// it the keyboard at once (keys typed right after go to it).
void show() {
_echoTimer?.cancel();
echo.value = null;
open.value = true;
draft.selection = TextSelection.collapsed(offset: draft.text.length);
focus.requestFocus();
_armIdle();
}
/// Closes the box; the draft stays for the next Enter (or the panel).
void close() {
_idleTimer?.cancel();
if (!open.value) return;
open.value = false;
focus.unfocus();
}
void _typed() {
if (open.value) _armIdle();
}
void _armIdle() {
_idleTimer?.cancel();
_idleTimer = Timer(idle, close);
}
/// Enter: the trimmed draft to send (cleared, the box closes and the
/// echo shows it), or null when blank (the box just closes).
String? submit() {
final text = draft.text.trim();
close();
if (text.isEmpty) return null;
draft.clear();
final now = DateTime.now();
_recent.removeWhere((r) => now.difference(r.$2) > const Duration(seconds: 20));
_recent.add((text, now));
lastSent = text;
echo.value = text;
_echoTimer?.cancel();
_echoTimer = Timer(echoTime, () => echo.value = null);
sends.value++;
return text;
}
/// [t] is your own chat toast for a line this box already showed (as
/// its echo or your own danmaku line): it should not pop up again.
bool echoed(ToastItem t) {
if (!t.chat || !t.own) return false;
final now = DateTime.now();
return _recent.any((r) => now.difference(r.$2) < const Duration(seconds: 20) && t.text.endsWith(': ${r.$1}'));
}
}
/// The box: a glass pill at the bottom centre that starts compact
/// ([minWidth]) and widens with the text up to [maxWidth], then wraps to
/// up to four lines, growing upward. Enter sends ([onSend], empty Enter
/// closes), Esc closes. Your avatar leads it. While closed it stays
/// mounted, invisible and out of the way, so [QuickChat.show] focuses it
/// at once. The pointer over it stays hidden until the mouse moves.
class QuickChatBox extends StatefulWidget {
static const minWidth = 300.0;
static const height = 44.0;
final QuickChat chat;
final double maxWidth;
final void Function(String text) onSend;
/// Its height whenever it changes (the subtitle lift follows it).
final ValueChanged<double>? onHeight;
const QuickChatBox({super.key, required this.chat, required this.onSend, this.maxWidth = 640, this.onHeight});
@override
State<QuickChatBox> createState() => _QuickChatBoxState();
}
class _QuickChatBoxState extends State<QuickChatBox> {
static const _text = TextStyle(color: Colors.white, fontSize: 15, height: 1.3);
bool _pointerMoved = false;
Offset? _pointerAt;
QuickChat get c => widget.chat;
@override
void initState() {
super.initState();
c.draft.addListener(_changed);
c.open.addListener(_openChanged);
}
@override
void didUpdateWidget(QuickChatBox old) {
super.didUpdateWidget(old);
if (old.chat != widget.chat) {
old.chat.draft.removeListener(_changed);
old.chat.open.removeListener(_openChanged);
c.draft.addListener(_changed);
c.open.addListener(_openChanged);
}
}
@override
void dispose() {
c.draft.removeListener(_changed);
c.open.removeListener(_openChanged);
super.dispose();
}
void _changed() => setState(() {});
void _openChanged() {
if (c.open.value) _pointerMoved = false;
setState(() {});
}
KeyEventResult _key(FocusNode node, KeyEvent e) {
if (e is! KeyDownEvent && e is! KeyRepeatEvent) return KeyEventResult.ignored;
final k = e.logicalKey;
if (k == LogicalKeyboardKey.escape) {
c.close();
return KeyEventResult.handled;
}
if (k == LogicalKeyboardKey.enter || k == LogicalKeyboardKey.numpadEnter) {
// An input method composing a word takes Enter itself.
if (c.draft.value.composing.isValid) return KeyEventResult.ignored;
if (e is KeyRepeatEvent) return KeyEventResult.handled;
final text = c.submit();
if (text != null) widget.onSend(text);
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
}
/// Wide enough for the draft on one line, between [QuickChatBox.minWidth]
/// and the room there is.
double _width(double room) {
final max = math.max(QuickChatBox.minWidth, math.min(widget.maxWidth, room));
final tp = TextPainter(
text: TextSpan(text: c.draft.text.isEmpty ? ' ' : c.draft.text, style: _text),
textDirection: TextDirection.ltr,
maxLines: 1,
)..layout();
// Avatar, gaps and padding around the text, plus room for the caret.
final w = tp.width + 22 + 12 + 18 + 18 + 40;
tp.dispose();
return w.clamp(QuickChatBox.minWidth, max);
}
@override
Widget build(BuildContext context) {
final open = c.open.value;
final me = selfAvatarInfo();
return LayoutBuilder(builder: (context, box) {
final width = _width(box.maxWidth);
final field = Focus(
canRequestFocus: false,
skipTraversal: true,
onKeyEvent: _key,
child: TextField(
key: const ValueKey('quick-chat-field'),
controller: c.draft,
focusNode: c.focus,
minLines: 1,
maxLines: 4,
keyboardType: TextInputType.text,
textInputAction: TextInputAction.send,
maxLength: 500,
maxLengthEnforcement: MaxLengthEnforcement.enforced,
// Enter is ours (above); a platform send action lands here.
onSubmitted: (_) {
final text = c.submit();
if (text != null) widget.onSend(text);
},
mouseCursor: _pointerMoved ? SystemMouseCursors.text : SystemMouseCursors.none,
style: _text,
cursorColor: Colors.white,
cursorWidth: 1.6,
decoration: InputDecoration(
isDense: true,
counterText: '',
border: InputBorder.none,
contentPadding: const EdgeInsets.symmetric(vertical: 4),
hintText: 'Say something',
hintStyle: _text.copyWith(color: Colors.white.withValues(alpha: 0.42)),
),
),
);
return Align(
alignment: Alignment.bottomCenter,
child: IgnorePointer(
ignoring: !open,
child: AnimatedSlide(
offset: open ? Offset.zero : const Offset(0, 0.35),
duration: easeTime,
curve: ease,
child: AnimatedOpacity(
opacity: open ? 1 : 0,
duration: open ? easeTime : const Duration(milliseconds: 160),
curve: ease,
child: MouseRegion(
onHover: (e) {
final at = _pointerAt;
_pointerAt = e.position;
if (!_pointerMoved && at != null && (e.position - at).distance > 0.5) {
setState(() => _pointerMoved = true);
}
},
child: _ReportHeight(
onHeight: widget.onHeight,
child: AnimatedContainer(
key: const ValueKey('quick-chat'),
duration: const Duration(milliseconds: 160),
curve: Curves.easeOutCubic,
width: width,
constraints: const BoxConstraints(minHeight: QuickChatBox.height),
padding: const EdgeInsets.fromLTRB(11, 6, 16, 6),
decoration: BoxDecoration(
color: AppMenuStyle.glass,
borderRadius: BorderRadius.circular(QuickChatBox.height / 2),
border: Border.all(color: Colors.white.withValues(alpha: open ? 0.22 : 0.1), width: 0.8),
boxShadow: const [BoxShadow(color: Color(0x55000000), blurRadius: 18, offset: Offset(0, 6))],
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
StatusAvatar(info: me, size: 22, badges: false, ring: false),
const SizedBox(width: 12),
Expanded(child: field),
const SizedBox(width: 8),
Icon(Icons.keyboard_return_rounded, size: 16, color: Colors.white.withValues(alpha: 0.38)),
],
),
),
),
),
),
),
),
);
});
}
}
/// What you just sent, in the box's place for [QuickChat.echoTime]: your
/// avatar and the line on the same glass, then it fades. Shown when the
/// notifications are toasts (with danmaku your own line flies instead).
class QuickChatEcho extends StatelessWidget {
final QuickChat chat;
const QuickChatEcho({super.key, required this.chat});
@override
Widget build(BuildContext context) {
final accent = Theme.of(context).colorScheme.primary;
return IgnorePointer(
child: ValueListenableBuilder<String?>(
valueListenable: chat.echo,
builder: (context, text, _) => AnimatedSwitcher(
duration: easeTime,
switchInCurve: ease,
switchOutCurve: Curves.easeIn,
transitionBuilder: (child, a) => FadeTransition(
opacity: a,
child: SlideTransition(
position: Tween(begin: const Offset(0, 0.25), end: Offset.zero).animate(a),
child: child,
),
),
child: text == null
? const SizedBox.shrink(key: ValueKey('no-echo'))
: Align(
key: ValueKey('echo-$text'),
alignment: Alignment.bottomCenter,
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 640),
child: Container(
key: const ValueKey('quick-chat-echo'),
padding: const EdgeInsets.fromLTRB(11, 10, 16, 10),
decoration: BoxDecoration(
color: AppMenuStyle.glass,
borderRadius: BorderRadius.circular(QuickChatBox.height / 2),
border: Border.all(color: accent.withValues(alpha: 0.7), width: 1),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
StatusAvatar(info: selfAvatarInfo(), size: 22, badges: false, ring: false),
const SizedBox(width: 12),
Flexible(
child: Text(text,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Colors.white, fontSize: 15, height: 1.3)),
),
const SizedBox(width: 10),
Icon(Icons.check_rounded, size: 16, color: accent),
],
),
),
),
),
),
),
);
}
}
/// Reports its child's height after layout when it changes.
class _ReportHeight extends SingleChildRenderObjectWidget {
final ValueChanged<double>? onHeight;
const _ReportHeight({required this.onHeight, required super.child});
@override
RenderObject createRenderObject(BuildContext context) => _RenderReportHeight(onHeight);
@override
void updateRenderObject(BuildContext context, _RenderReportHeight r) => r.onHeight = onHeight;
}
class _RenderReportHeight extends RenderProxyBox {
ValueChanged<double>? onHeight;
double? _last;
_RenderReportHeight(this.onHeight);
@override
void performLayout() {
super.performLayout();
final h = size.height;
if (h == _last) return;
_last = h;
final f = onHeight;
if (f != null) WidgetsBinding.instance.addPostFrameCallback((_) => f(h));
}
}
+392 -85
View File
@@ -8,9 +8,15 @@ import 'package:media_kit_video/media_kit_video.dart';
import 'package:signals/signals_flutter.dart'; import 'package:signals/signals_flutter.dart';
import '../controls/controls_probe.dart'; import '../controls/controls_probe.dart';
import '../controls/minimal/style.dart';
import '../controls/model/controls_layout.dart';
import '../controls/model/controls_model.dart'; import '../controls/model/controls_model.dart';
import '../controls/player_panel.dart';
import '../controls/session_controls.dart'; import '../controls/session_controls.dart';
import '../controls/volume.dart'; import '../controls/volume.dart';
import '../danmaku/danmaku.dart' show DanmakuController, DanmakuItem, DanmakuKind;
import '../danmaku/danmaku_notices.dart';
import '../danmaku/danmaku_preview.dart';
import '../platform/desktop/media_kit_host.dart' show videoControllerOf; import '../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;
@@ -28,8 +34,10 @@ import 'avatar.dart';
import 'motion.dart'; import 'motion.dart';
import 'now_playing_title.dart'; import 'now_playing_title.dart';
import 'player_status.dart'; import 'player_status.dart';
import 'quick_chat.dart';
import 'sit_out_card.dart'; import 'sit_out_card.dart';
import 'start_banner.dart'; import 'start_banner.dart';
import 'video_view.dart';
/// Where the session's video is shown: nowhere, the floating mini player, /// Where the session's video is shown: nowhere, the floating mini player,
/// or filling the body (and, with fullscreen, the window). /// or filling the body (and, with fullscreen, the window).
@@ -42,9 +50,10 @@ typedef VideoViewBuilder = Widget Function(BuildContext context, VideoControlsBu
/// The session's player: the one `Video` of the current player and /// The session's player: the one `Video` of the current player and
/// everything over it. Full: the controls (one model, the skin picked in /// everything over it. Full: the controls (one model, the skin picked in
/// Settings), the start card, the status pill, Jellyfin overlays and /// Settings), the start card, the status pill, Jellyfin overlays, the
/// the player keys. Mini: hover controls and a progress line. The shell keeps this /// quick chat box, the danmaku, the player panel and the player keys.
/// widget mounted at one place in the tree while the mode changes, so the /// Mini: hover controls and a progress line. The shell keeps this widget
/// mounted at one place in the tree while the mode changes, so the
/// `Video` is never mounted twice nor rebuilt for a move. /// `Video` is never mounted twice nor rebuilt for a move.
class SessionPlayer extends StatefulWidget { class SessionPlayer extends StatefulWidget {
final PlayerMode mode; final PlayerMode mode;
@@ -67,6 +76,15 @@ class SessionPlayer extends StatefulWidget {
/// Follows whether the full player's controls are up (the shell lifts /// Follows whether the full player's controls are up (the shell lifts
/// its toasts above them); the player keeps its own when null. /// its toasts above them); the player keeps its own when null.
final ValueNotifier<bool>? controlsShown; final ValueNotifier<bool>? controlsShown;
/// The quick chat box's state (the shell's: its draft is the chat
/// panel's); the player keeps its own when null.
final QuickChat? quickChat;
/// Set while the session's notifications fly as danmaku here (the
/// shell then leaves those out of its toasts).
final ValueNotifier<bool>? danmakuActive;
/// Dev builds: the danmaku preview, shown over the full player while
/// it is active.
final DanmakuPreview? danmaku;
const SessionPlayer({ const SessionPlayer({
super.key, super.key,
required this.mode, required this.mode,
@@ -84,6 +102,9 @@ class SessionPlayer extends StatefulWidget {
this.videoBuilder, this.videoBuilder,
this.windowControls = true, this.windowControls = true,
this.controlsShown, this.controlsShown,
this.quickChat,
this.danmakuActive,
this.danmaku,
}); });
@override @override
@@ -98,11 +119,29 @@ class _SessionPlayerState extends State<SessionPlayer> {
final _noFocus = FocusNode(skipTraversal: true, canRequestFocus: false); final _noFocus = FocusNode(skipTraversal: true, canRequestFocus: false);
DateTime? _lastKeyAt; DateTime? _lastKeyAt;
late final void Function() _stopShaderWatch; late final void Function() _stopShaderWatch;
late final void Function() _stopSubScale;
late final void Function() _stopDanmakuWatch;
late final void Function() _stopLiftWatch;
int _shaderNoticeSeen = 0; int _shaderNoticeSeen = 0;
bool _miniHover = false; bool _miniHover = false;
// Whether the controls are up (their frame reports it). // Whether the controls are up (their frame reports it).
final _ownShown = ValueNotifier(true); final _ownShown = ValueNotifier(true);
ValueNotifier<bool> get _controlsShown => widget.controlsShown ?? _ownShown; ValueNotifier<bool> get _controlsShown => widget.controlsShown ?? _ownShown;
// The quick chat box (Enter): the shell's, else one of our own.
QuickChat? _ownChat;
QuickChat get _quick => widget.quickChat ?? (_ownChat ??= QuickChat());
// The box or its echo sits in the control bar's place: the controls
// stay hidden meanwhile.
final _boxUp = ValueNotifier(false);
double _boxHeight = QuickChatBox.height;
// What covers the bottom, as bottom subtitles move above it.
final _lift = SubtitleLift();
// The Enter that opened the box: kept from the focused widget.
KeyEvent? _swallow;
// The session's danmaku lines (and your own sent ones).
final _lines = DanmakuController();
final _danmakuOn = signal(false);
late final _full = signal(widget.mode == PlayerMode.full);
// The mouse on a floating element, or the Up next popover open, keeps // The mouse on a floating element, or the Up next popover open, keeps
// the controls up. // the controls up.
final _hold = ControlsHold(); final _hold = ControlsHold();
@@ -124,7 +163,8 @@ class _SessionPlayerState extends State<SessionPlayer> {
); );
late final SessionCoordinator _coord = AppScope.of(context).coordinator; late final SessionCoordinator _coord = AppScope.of(context).coordinator;
bool get _full => widget.mode == PlayerMode.full; AppSettings get _settings => _coord.settings;
bool get _isFull => widget.mode == PlayerMode.full;
@override @override
void initState() { void initState() {
@@ -133,6 +173,7 @@ class _SessionPlayerState extends State<SessionPlayer> {
_model; _model;
_coord.jellyfin.ensureLoaded(); _coord.jellyfin.ensureLoaded();
HardwareKeyboard.instance.addHandler(_globalKey); HardwareKeyboard.instance.addHandler(_globalKey);
FocusManager.instance.addEarlyKeyEventHandler(_earlyKey);
// Tab (AppKeys) reaches the chat through here in fullscreen. // Tab (AppKeys) reaches the chat through here in fullscreen.
AppKeys.addChatToggle(_toggleChat); AppKeys.addChatToggle(_toggleChat);
// Shader messages (preset changes, load failures) become toasts. // Shader messages (preset changes, load failures) become toasts.
@@ -146,6 +187,32 @@ class _SessionPlayerState extends State<SessionPlayer> {
_stopPopoverWatch = effect(() { _stopPopoverWatch = effect(() {
_hold.set(#upNextPopover, _coord.queue.popoverOpen.value); _hold.set(#upNextPopover, _coord.queue.popoverOpen.value);
}); });
// Subtitle size on every player, live.
_stopSubScale = effect(() {
final scale = _settings.subScale.value;
_coord.playback.generation.value;
final p = untracked(() => _coord.playback.current);
if (p == null || p.disposed) return;
unawaited(p.setProperty('sub-scale', scale.toStringAsFixed(2)).catchError((Object _) {}));
});
// Danmaku is the style and the full player is up (not sitting out).
_stopDanmakuWatch = effect(() {
final on = _full.value && _settings.notifyStyle.value == NotifyStyle.danmaku && !_coord.selfAway.value;
untracked(() {
_danmakuOn.value = on;
widget.danmakuActive?.value = on;
});
});
_stopLiftWatch = effect(() {
_settings.liftSubtitles.value;
_settings.controlsSkin.value;
_settings.controlsSize.value;
untracked(_retargetLift);
});
_quick.open.addListener(_boxChanged);
_quick.echo.addListener(_boxChanged);
_quick.sends.addListener(_sent);
_controlsShown.addListener(_retargetLift);
} }
@override @override
@@ -154,16 +221,48 @@ class _SessionPlayerState extends State<SessionPlayer> {
_fullscreen.value = widget.fullscreen; _fullscreen.value = widget.fullscreen;
_chatOpen.value = widget.chatOpen; _chatOpen.value = widget.chatOpen;
_unread.value = widget.unread; _unread.value = widget.unread;
_full.value = _isFull;
if (!_isFull) {
_quick.close();
_model.panelOpen.value = false;
}
if (oldWidget.quickChat != widget.quickChat) {
final old = oldWidget.quickChat ?? _ownChat;
old?.open.removeListener(_boxChanged);
old?.echo.removeListener(_boxChanged);
old?.sends.removeListener(_sent);
_quick.open.addListener(_boxChanged);
_quick.echo.addListener(_boxChanged);
_quick.sends.addListener(_sent);
}
if (oldWidget.controlsShown != widget.controlsShown) {
(oldWidget.controlsShown ?? _ownShown).removeListener(_retargetLift);
_controlsShown.addListener(_retargetLift);
}
} }
@override @override
void dispose() { void dispose() {
HardwareKeyboard.instance.removeHandler(_globalKey); HardwareKeyboard.instance.removeHandler(_globalKey);
FocusManager.instance.removeEarlyKeyEventHandler(_earlyKey);
AppKeys.removeChatToggle(_toggleChat); AppKeys.removeChatToggle(_toggleChat);
_stopShaderWatch(); _stopShaderWatch();
_stopPopoverWatch(); _stopPopoverWatch();
_stopSubScale();
_stopDanmakuWatch();
_stopLiftWatch();
widget.danmakuActive?.value = false;
_quick.open.removeListener(_boxChanged);
_quick.echo.removeListener(_boxChanged);
_quick.sends.removeListener(_sent);
_controlsShown.removeListener(_retargetLift);
_quick.close();
_ownChat?.dispose();
_noFocus.dispose(); _noFocus.dispose();
_ownShown.dispose(); _ownShown.dispose();
_boxUp.dispose();
_lift.dispose();
_lines.dispose();
_hold.dispose(); _hold.dispose();
_model.dispose(); _model.dispose();
super.dispose(); super.dispose();
@@ -171,6 +270,55 @@ class _SessionPlayerState extends State<SessionPlayer> {
void _toggleChat() => _model.toggleChat(); void _toggleChat() => _model.toggleChat();
// ------------------------------------------------------- quick chat box
void _boxChanged() {
_boxUp.value = _quick.open.value || _quick.echo.value != null;
_retargetLift();
}
/// What covers the bottom of the full player, for the subtitle lift:
/// the box (or its echo) from 16 px up, else the control bar while it is
/// up when "Subtitles above the controls" is on; 8 px above it.
void _retargetLift() {
if (!mounted) return;
final box = _boxUp.value ? _edge + _boxHeight + 8 : 0.0;
final bar = _settings.liftSubtitles.peek() && _controlsShown.value ? _bottomControlsHeight() + 8 : 0.0;
_lift.animateTo(math.max(box, bar));
}
void _boxHeightChanged(double h) {
if (h == _boxHeight) return;
_boxHeight = h;
_retargetLift();
setState(() {});
}
/// Enter in the box with text: to the chat; your own line flies right
/// away when danmaku is the style (the echo is for toasts).
void _send(String text) => _coord.sendChat(text);
void _sent() {
if (!_danmakuOn.peek()) return;
_quick.echo.value = null;
final me = selfAvatarInfo();
_lines.add(DanmakuItem(
kind: DanmakuKind.chat, sender: me.name, senderColor: me.color, text: _quick.lastSent, own: true, avatar: me));
}
/// Swallows the Enter that opened the box before the focused widget
/// (a button keeps focus after a click) acts on it too.
KeyEventResult _earlyKey(KeyEvent event) {
final s = _swallow;
if (s == null || event.logicalKey != s.logicalKey) return KeyEventResult.ignored;
if (event is KeyDownEvent && !identical(event, s)) {
_swallow = null;
return KeyEventResult.ignored;
}
if (event is KeyUpEvent) _swallow = null;
return KeyEventResult.handled;
}
/// The player keys, while the player fills the body and nothing (a /// The player keys, while the player fills the body and nothing (a
/// dialog, a card, a text field) is in front of it. Space and Tab are /// dialog, a card, a text field) is in front of it. Space and Tab are
/// app-wide ([AppKeys]). /// app-wide ([AppKeys]).
@@ -179,7 +327,7 @@ class _SessionPlayerState extends State<SessionPlayer> {
if (!AppScope.of(context).navigation.online.peek()) return false; if (!AppScope.of(context).navigation.online.peek()) return false;
// Mini: Esc is the last step out (a friend's server: asking to leave // Mini: Esc is the last step out (a friend's server: asking to leave
// it; yours: the player closes). // it; yours: the player closes).
if (!_full) { if (!_isFull) {
if (event.logicalKey != LogicalKeyboardKey.escape) return false; if (event.logicalKey != LogicalKeyboardKey.escape) return false;
if (ModalRoute.of(context)?.isCurrent == false || textInputFocused()) return false; if (ModalRoute.of(context)?.isCurrent == false || textInputFocused()) return false;
widget.onLeave(); widget.onLeave();
@@ -195,12 +343,19 @@ class _SessionPlayerState extends State<SessionPlayer> {
return false; return false;
} }
if (textInputFocused()) return false; if (textInputFocused()) return false;
final key = event.logicalKey;
// Enter: the quick chat box (the chat panel's draft), whatever plays.
if (key == LogicalKeyboardKey.enter || key == LogicalKeyboardKey.numpadEnter) {
if (_coord.selfAway.peek()) return false;
_swallow = event;
_quick.show();
return true;
}
final now = DateTime.now().toUtc(); final now = DateTime.now().toUtc();
if (_lastKeyAt != null && now.difference(_lastKeyAt!).inMilliseconds < 350) { if (_lastKeyAt != null && now.difference(_lastKeyAt!).inMilliseconds < 350) {
return false; return false;
} }
final model = _model; final model = _model;
final key = event.logicalKey;
// Ctrl+0..6: Anime4K's own mpv keys (off, modes A..C+A). // Ctrl+0..6: Anime4K's own mpv keys (off, modes A..C+A).
final digit = _shaderKeys.indexOf(key); final digit = _shaderKeys.indexOf(key);
if (digit >= 0 && HardwareKeyboard.instance.isControlPressed) { if (digit >= 0 && HardwareKeyboard.instance.isControlPressed) {
@@ -252,7 +407,9 @@ class _SessionPlayerState extends State<SessionPlayer> {
} }
if (key == LogicalKeyboardKey.escape) { if (key == LogicalKeyboardKey.escape) {
_lastKeyAt = now; _lastKeyAt = now;
if (widget.chatOpen) { if (_model.panelOpen.peek()) {
_model.panelOpen.value = false;
} else if (widget.chatOpen) {
widget.onToggleChat(); widget.onToggleChat();
} else if (widget.fullscreen) { } else if (widget.fullscreen) {
widget.onToggleFullscreen(); widget.onToggleFullscreen();
@@ -284,29 +441,35 @@ class _SessionPlayerState extends State<SessionPlayer> {
if (!playback.alive.value) { if (!playback.alive.value) {
return Center( return Center(
child: Text('player starting…', child: Text('player starting…',
style: TextStyle(color: Colors.white70, fontSize: _full ? 14 : 12)), style: TextStyle(color: Colors.white70, fontSize: _isFull ? 14 : 12)),
); );
} }
if (gen != _videoGen) { if (gen != _videoGen) {
_videoGen = gen; _videoGen = gen;
_videoKey = GlobalKey<VideoState>(); _videoKey = GlobalKey<VideoState>();
} }
final VideoControlsBuilder? controls = _full ? _sessionControls : null; // Never null for the mini: media_kit keeps the last controls
// when given null, so the full player's would stay under it.
final VideoControlsBuilder controls = _isFull ? _sessionControls : _noControls;
final video = widget.videoBuilder?.call(context, controls) ?? _video(controls); final video = widget.videoBuilder?.call(context, controls) ?? _video(controls);
// The wheel anywhere on the full player steps the volume // The wheel anywhere on the full player steps the volume
// (lists, menus and popovers under the mouse scroll instead). // (lists, menus and popovers under the mouse scroll instead).
return VolumeWheel( return VolumeWheel(
enabled: _full, enabled: _isFull,
onStep: _model.stepVolume, onStep: _model.stepVolume,
child: Stack( child: Stack(
fit: StackFit.expand, fit: StackFit.expand,
children: [ children: [
video, video,
if (_full) ...[ SubtitleLifter(playback: _coord.playback, lift: _lift, active: _isFull),
if (_isFull) ...[
_blockedCard(), _blockedCard(),
_actionArea(), _actionArea(),
if (widget.fullscreen) ...[if (!_coord.alone.value) _pausedParty(), _unreadChip()], if (widget.fullscreen) ...[if (!_coord.alone.value) _pausedParty(), _unreadChip()],
_volumeReadout(), _volumeReadout(),
_quickChatBox(),
_playerPanel(),
if (widget.danmaku case final d?) _danmakuPanel(d),
] else ] else
_miniOverlay(), _miniOverlay(),
// Sitting out: nothing is loaded; the card covers the // Sitting out: nothing is loaded; the card covers the
@@ -320,11 +483,67 @@ class _SessionPlayerState extends State<SessionPlayer> {
); );
} }
Widget _video(VideoControlsBuilder? controls) => Video( Widget _video(VideoControlsBuilder? controls) => VideoOutputSizer(
playback: _coord.playback,
sharp: _settings.screenResVideo,
child: Video(
key: _videoKey, key: _videoKey,
controller: videoControllerOf(_coord.playback.video), controller: videoControllerOf(_coord.playback.video),
subtitleViewConfiguration: const SubtitleViewConfiguration(visible: false), subtitleViewConfiguration: const SubtitleViewConfiguration(visible: false),
controls: controls, controls: controls,
),
);
/// The quick chat box in the control bar's place, centred (Enter opens
/// it), and the echo of what you sent.
Widget _quickChatBox() => Positioned(
left: _edge,
right: _edge,
bottom: _edge,
child: Stack(
alignment: Alignment.bottomCenter,
children: [
QuickChatEcho(chat: _quick),
QuickChatBox(chat: _quick, onSend: _send, onHeight: _boxHeightChanged),
],
),
);
/// The player panel docked at the right (⚙ → Player settings), between
/// the top bar and the control bar; it stays while they hide.
Widget _playerPanel() => Positioned(
right: _edge,
top: _topBarHeight + 8,
bottom: _bottomControlsHeightNow() + _edge,
child: SignalBuilder(builder: (context) {
final open = _model.panelOpen.value;
return AnimatedSwitcher(
duration: easeTime,
switchInCurve: ease,
switchOutCurve: Curves.easeIn,
layoutBuilder: (current, previous) =>
Stack(alignment: Alignment.topRight, children: [...previous, ?current]),
transitionBuilder: (child, a) => FadeTransition(
opacity: a,
child: SlideTransition(
position: Tween(begin: const Offset(0.12, 0), end: Offset.zero).animate(a),
child: child,
),
),
child: !open
? const SizedBox.shrink(key: ValueKey('no-player-panel'))
: ControlsHoldRegion(
key: const ValueKey('player-panel-hold'),
hold: _hold,
child: PlayerPanel(
onClose: () => _model.panelOpen.value = false,
renderMode: _model.renderMode,
volume: _model.volume,
onVolume: _model.setVolume,
),
),
);
}),
); );
/// Fullscreen hides the strip and its window buttons: the top bar /// Fullscreen hides the strip and its window buttons: the top bar
@@ -351,12 +570,30 @@ class _SessionPlayerState extends State<SessionPlayer> {
fit: StackFit.expand, fit: StackFit.expand,
children: [ children: [
const StartBanner(), const StartBanner(),
// Under the controls, over the picture.
SignalBuilder(
builder: (context) => _danmakuOn.value
? DanmakuNotices(
toasts: _coord.toasts,
style: _settings.danmakuStyle,
controller: _lines,
avatar: (name, id) => avatarForName(name, _coord, id),
skip: _quick.echoed,
)
: const SizedBox.shrink(),
),
if (widget.danmaku case final d?)
SignalBuilder(
builder: (context) =>
d.active.value ? DanmakuPreviewLayers(preview: d, lift: _lift) : const SizedBox.shrink(),
),
SessionControls( SessionControls(
model: _model, model: _model,
shown: _controlsShown, shown: _controlsShown,
hold: _hold, hold: _hold,
onDoubleClick: widget.onToggleFullscreen, onDoubleClick: widget.onToggleFullscreen,
windowButtons: _fullscreenWindowButtons(), windowButtons: _fullscreenWindowButtons(),
suppress: _boxUp,
), ),
], ],
); );
@@ -367,12 +604,33 @@ class _SessionPlayerState extends State<SessionPlayer> {
static const _edge = 16.0; static const _edge = 16.0;
/// The bottom controls' height in the skin and size in use. /// The bottom controls' height in the skin and size in use.
static double _bottomControlsHeight() { double _bottomControlsHeight() =>
final s = AppSettings.instance; controlsBottomHeight(_settings.controlsSkin.value, _settings.controlsSize.value);
return controlsBottomHeight(s.controlsSkin.value, s.controlsSize.value);
}
static double get _topBarHeight => controlsTopHeight(AppSettings.instance.controlsSkin.peek()); double _bottomControlsHeightNow() =>
controlsBottomHeight(_settings.controlsSkin.peek(), _settings.controlsSize.peek());
double get _topBarHeight => controlsTopHeight(_settings.controlsSkin.peek());
/// The danmaku preview's panel, top right under the top bar; the mouse
/// on it keeps the controls up.
Widget _danmakuPanel(DanmakuPreview d) => Positioned(
right: _edge,
top: _topBarHeight + 8,
child: SignalBuilder(
builder: (context) => fadeThrough(
expand: false,
alignment: Alignment.topRight,
d.active.value
? ControlsHoldRegion(
key: const ValueKey('danmaku-panel'),
hold: _hold,
child: DanmakuSessionPanel(preview: d, coord: _coord),
)
: const SizedBox.shrink(key: ValueKey('danmaku-none')),
),
),
);
/// The volume flash after a wheel or key step: a pill under the top bar. /// The volume flash after a wheel or key step: a pill under the top bar.
Widget _volumeReadout() => Positioned( Widget _volumeReadout() => Positioned(
@@ -397,15 +655,20 @@ class _SessionPlayerState extends State<SessionPlayer> {
Widget _actionArea() { Widget _actionArea() {
return SignalBuilder(builder: (context) { return SignalBuilder(builder: (context) {
final controls = _bottomControlsHeight(); final controls = _bottomControlsHeight();
return ValueListenableBuilder<bool>( return ListenableBuilder(
valueListenable: _controlsShown, listenable: Listenable.merge([_controlsShown, _boxUp]),
builder: (context, shown, child) => AnimatedPositioned( builder: (context, child) => AnimatedPositioned(
duration: easeTime, duration: easeTime,
curve: ease, curve: ease,
left: _edge, left: _edge,
right: _edge, right: _edge,
top: 0, top: 0,
bottom: shown ? controls + _edge : _edge, // Above the quick chat box while it (or its echo) is up.
bottom: _boxUp.value
? _boxHeight + _edge * 2
: _controlsShown.value
? controls + _edge
: _edge,
child: child!, child: child!,
), ),
child: CustomMultiChildLayout( child: CustomMultiChildLayout(
@@ -419,16 +682,14 @@ class _SessionPlayerState extends State<SessionPlayer> {
}); });
} }
/// The action area's right end: the end-of-media card (Up next, else the /// The action area's right end: the end-of-media card (Up next, else
/// next episode; it stays up), else the pills, shown only with the /// the continuation; it stays up), else the pills, shown only with the
/// controls ([PillGate]). /// controls ([PillGate]).
Widget _actionSide() { Widget _actionSide() {
final coord = _coord; final coord = _coord;
return SignalBuilder( return SignalBuilder(
builder: (context) { builder: (context) {
final next = coord.nextEpisode.value; final next = coord.nextEpisode.value;
final countdown = coord.autoAdvance.value;
// Up next goes before the next episode.
final upNext = coord.queue.countdown.value; final upNext = coord.queue.countdown.value;
final finished = coord.serverState.value?.transport == 'finished'; final finished = coord.serverState.value?.transport == 'finished';
final pill = coord.nextPromptVisible.value; final pill = coord.nextPromptVisible.value;
@@ -437,18 +698,6 @@ class _SessionPlayerState extends State<SessionPlayer> {
if (upNext != null && finished) { if (upNext != null && finished) {
return ControlsHoldRegion(hold: _hold, child: UpNextCard(queue: coord.queue, countdown: upNext)); return ControlsHoldRegion(hold: _hold, child: UpNextCard(queue: coord.queue, countdown: upNext));
} }
// "Clear" in Up next hides the next episode for this media.
if (next != null && (finished || countdown != null) && coord.queue.shownContinuation.value.isNotEmpty) {
return ControlsHoldRegion(
hold: _hold,
child: EndOfEpisodeCard(
next: next,
countdown: countdown,
onPlayNow: coord.playNextEpisode,
onCancel: coord.cancelAutoAdvance,
),
);
}
if (!showNext && skip == null) return const SizedBox.shrink(); if (!showNext && skip == null) return const SizedBox.shrink();
return Column( return Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
@@ -556,7 +805,7 @@ class _SessionPlayerState extends State<SessionPlayer> {
child: Stack( child: Stack(
fit: StackFit.expand, fit: StackFit.expand,
children: [ children: [
SitOutCard(key: const ValueKey('sit-out-card'), compact: !_full), SitOutCard(key: const ValueKey('sit-out-card'), compact: !_isFull),
Positioned( Positioned(
top: 4, top: 4,
right: 4, right: 4,
@@ -565,9 +814,9 @@ class _SessionPlayerState extends State<SessionPlayer> {
children: [ children: [
IconButton( IconButton(
focusNode: _noFocus, focusNode: _noFocus,
onPressed: _full ? widget.onCollapse : widget.onExpand, onPressed: _isFull ? widget.onCollapse : widget.onExpand,
tooltip: _full ? 'Small player' : 'Expand', tooltip: _isFull ? 'Small player' : 'Expand',
icon: Icon(_full ? Icons.close_fullscreen : Icons.open_in_full, color: Colors.white, size: 18), icon: Icon(_isFull ? Icons.close_fullscreen : Icons.open_in_full, color: Colors.white, size: 18),
), ),
IconButton( IconButton(
focusNode: _noFocus, focusNode: _noFocus,
@@ -582,11 +831,13 @@ class _SessionPlayerState extends State<SessionPlayer> {
), ),
); );
/// Mini: title, expand and close on hover; ready, time and a seekable /// Mini: title, expand and close on hover; the status, play (the full
/// bar at the bottom. A thin progress line stays while not hovered. /// player's play button: ready in a server) and the time, and a bar that
/// seeks. A thin progress line stays while not hovered.
Widget _miniOverlay() { Widget _miniOverlay() {
final coord = _coord; final coord = _coord;
final playback = coord.playback; final playback = coord.playback;
const scale = 0.8;
return MouseRegion( return MouseRegion(
onEnter: (_) => setState(() => _miniHover = true), onEnter: (_) => setState(() => _miniHover = true),
onExit: (_) => setState(() => _miniHover = false), onExit: (_) => setState(() => _miniHover = false),
@@ -603,55 +854,73 @@ class _SessionPlayerState extends State<SessionPlayer> {
gradient: LinearGradient( gradient: LinearGradient(
begin: Alignment.topCenter, begin: Alignment.topCenter,
end: Alignment.bottomCenter, end: Alignment.bottomCenter,
colors: [Colors.black54, Colors.transparent, Colors.black87], stops: [0, 0.35, 0.55, 1],
colors: [Color(0x99000000), Color(0x00000000), Color(0x00000000), Color(0xCC000000)],
), ),
), ),
child: Column( child: Column(
children: [ children: [
Row( Padding(
padding: const EdgeInsets.fromLTRB(12, 6, 6, 0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
const SizedBox(width: 10), const Expanded(
const Expanded(child: NowPlayingTitle(cover: false, compact: true)), child: Padding(
IconButton( padding: EdgeInsets.only(top: 4),
focusNode: _noFocus, child: NowPlayingTitle(cover: false, compact: true),
onPressed: widget.onExpand, ),
),
MinimalButton(
icon: Icons.open_in_full_rounded,
tooltip: 'Expand', tooltip: 'Expand',
icon: const Icon(Icons.open_in_full, color: Colors.white, size: 18), scale: scale,
), onPressed: widget.onExpand),
IconButton( MinimalButton(
focusNode: _noFocus, icon: Icons.close_rounded,
onPressed: widget.onClose,
tooltip: 'Sit out (stay in the server)', tooltip: 'Sit out (stay in the server)',
icon: const Icon(Icons.close, color: Colors.white, size: 18), scale: scale,
), onPressed: widget.onClose),
], ],
), ),
),
const Spacer(), const Spacer(),
SignalBuilder(builder: (context) { SignalBuilder(builder: (context) {
final ready = coord.selfMember.value?.ready ?? false; final snap = _model.snapshot.value;
final pos = playback.position.value, dur = playback.duration.value; final pos = playback.position.value, dur = playback.duration.value;
return Row( final status = playerStatusNow(coord)?.short;
return Padding(
padding: const EdgeInsets.fromLTRB(8, 0, 12, 2),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
IconButton( if (status != null && status.isNotEmpty)
focusNode: _noFocus, Padding(
tooltip: ready ? 'Ready (click: not ready)' : 'Not ready (click: ready)', padding: const EdgeInsets.fromLTRB(4, 0, 0, 2),
onPressed: coord.toggleReady, child: Text(status,
icon: Icon(
ready ? Icons.check_circle : Icons.radio_button_unchecked,
color: ready ? Colors.greenAccent : Colors.white,
),
),
Text('${formatClock(pos)} / ${formatClock(dur)}',
style: const TextStyle(color: Colors.white70, fontSize: 12)),
const Spacer(),
Flexible(
child: Text(playerStatusNow(_coord)?.short ?? '',
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Colors.amber, fontSize: 12)), style: const TextStyle(color: Color(0xFFFFC857), fontSize: 12)),
), ),
const SizedBox(width: 10), Row(
children: [
MinimalButton(
icon: controlIcon(BarControl.playPause, snap),
tooltip: controlTooltip(BarControl.playPause, snap),
accent: true,
scale: scale,
onPressed: _model.togglePlay,
),
const SizedBox(width: 8),
Text('${formatClock(pos)} / ${formatClock(dur)}',
style: const TextStyle(
color: MinimalStyle.dim,
fontSize: 12,
fontFeatures: [FontFeature.tabularFigures()])),
], ],
),
],
),
); );
}), }),
_MiniSeekBar(onSeek: playback.userSeek), _MiniSeekBar(onSeek: playback.userSeek),
@@ -668,6 +937,9 @@ class _SessionPlayerState extends State<SessionPlayer> {
} }
} }
/// The mini player's empty controls slot (see [SessionPlayer]'s video).
Widget _noControls(VideoState state) => const SizedBox.shrink();
/// A floating pill's visibility: only while the controls are up (the /// A floating pill's visibility: only while the controls are up (the
/// mouse moved) or the mouse is on it. With [announce] it is also up for /// mouse moved) or the mouse is on it. With [announce] it is also up for
/// [firstShown] whenever [kind] changes (a new offer: Skip OP, Skip 1:30). /// [firstShown] whenever [kind] changes (a new offer: Skip OP, Skip 1:30).
@@ -782,7 +1054,9 @@ const _shaderKeys = [
LogicalKeyboardKey.digit6, LogicalKeyboardKey.digit6,
]; ];
/// Mini player progress: a 2 px line, or (hovered) a 4 px bar that seeks. /// Mini player progress across the bottom edge: a 3 px line, or (hovered)
/// a 4 px bar with its track and the cached part that seeks (taller to
/// hit). Plain rectangles: the player's rounded clip shapes the ends.
class _MiniSeekBar extends StatelessWidget { class _MiniSeekBar extends StatelessWidget {
final bool thin; final bool thin;
final ValueChanged<Duration>? onSeek; final ValueChanged<Duration>? onSeek;
@@ -791,26 +1065,59 @@ class _MiniSeekBar extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final playback = AppScope.of(context).coordinator.playback; final playback = AppScope.of(context).coordinator.playback;
final accent = Theme.of(context).colorScheme.primary;
return SignalBuilder(builder: (context) { return SignalBuilder(builder: (context) {
final d = playback.duration.value; final d = playback.duration.value;
final p = playback.position.value; double f(Duration x) => d.inMilliseconds == 0 ? 0.0 : (x.inMilliseconds / d.inMilliseconds).clamp(0.0, 1.0);
final v = d.inMilliseconds == 0 ? 0.0 : (p.inMilliseconds / d.inMilliseconds).clamp(0.0, 1.0); final bar = SizedBox(
final bar = LinearProgressIndicator( height: thin ? 3 : 4,
value: v, width: double.infinity,
minHeight: thin ? 2 : 4, child: CustomPaint(
backgroundColor: thin ? Colors.transparent : Colors.white24, painter: _MiniBarPainter(
played: f(playback.position.value),
cached: thin ? null : f(playback.buffered.value),
accent: accent,
),
),
); );
if (thin || onSeek == null || d == Duration.zero) return bar; if (thin || onSeek == null || d == Duration.zero) return bar;
void seekAt(double x, double width) => onSeek!(d * (x / width).clamp(0.0, 1.0)); void seekAt(double x, double width) => onSeek!(d * (x / width).clamp(0.0, 1.0));
return LayoutBuilder( return LayoutBuilder(
builder: (context, box) => GestureDetector( builder: (context, box) => MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
behavior: HitTestBehavior.opaque, behavior: HitTestBehavior.opaque,
onTapDown: (e) => seekAt(e.localPosition.dx, box.maxWidth), onTapDown: (e) => seekAt(e.localPosition.dx, box.maxWidth),
onHorizontalDragEnd: null,
onHorizontalDragUpdate: (e) => seekAt(e.localPosition.dx, box.maxWidth), onHorizontalDragUpdate: (e) => seekAt(e.localPosition.dx, box.maxWidth),
child: Padding(padding: const EdgeInsets.symmetric(vertical: 6), child: bar), child: Padding(padding: const EdgeInsets.only(top: 8), child: bar),
),
), ),
); );
}); });
} }
} }
class _MiniBarPainter extends CustomPainter {
final double played;
/// End of the cache ahead (a track is drawn only with it).
final double? cached;
final Color accent;
const _MiniBarPainter({required this.played, required this.cached, required this.accent});
@override
void paint(Canvas canvas, Size size) {
final w = size.width, h = size.height;
final c = cached;
if (c != null) {
canvas.drawRect(Offset.zero & size, Paint()..color = Colors.white.withValues(alpha: 0.18));
if (c > played) {
canvas.drawRect(Rect.fromLTRB(w * played, 0, w * c, h), Paint()..color = Colors.white.withValues(alpha: 0.3));
}
}
canvas.drawRect(Rect.fromLTRB(0, 0, w * played, h), Paint()..color = accent);
}
@override
bool shouldRepaint(_MiniBarPainter old) => old.played != played || old.cached != cached || old.accent != accent;
}
+19 -5
View File
@@ -8,12 +8,13 @@ import 'package:sync_relay_core/core.dart'
show CookieBrowser, CookieFile, NetscapeCookie, YtDlp, YtDlpCookies, cookieBrowsers, parseCookies; show CookieBrowser, CookieFile, NetscapeCookie, YtDlp, YtDlpCookies, cookieBrowsers, parseCookies;
import '../controls/player_settings.dart'; import '../controls/player_settings.dart';
import '../danmaku/danmaku_settings.dart' show DanmakuSettingsPanel;
import '../jellyfin/accounts.dart'; import '../jellyfin/accounts.dart';
import '../jellyfin/accounts_panel.dart'; import '../jellyfin/accounts_panel.dart';
import '../schedule/animeflow.dart'; import '../schedule/animeflow.dart';
import '../screens/home/about_page.dart'; import '../screens/home/about_page.dart';
import '../screens/home/settings_page.dart'; import '../screens/home/settings_page.dart';
import '../screens/home/ui.dart' show appName, displayVersion; import '../screens/home/ui.dart' show appName, devTools, displayVersion;
import '../session/auto_sit_out.dart' show AutoSitOutTimes; import '../session/auto_sit_out.dart' show AutoSitOutTimes;
import '../sfx/sfx.dart'; import '../sfx/sfx.dart';
import '../sfx/sound_bank.dart'; import '../sfx/sound_bank.dart';
@@ -52,6 +53,8 @@ class SettingsTab extends StatelessWidget {
final JellyfinAccounts? jellyfin; final JellyfinAccounts? jellyfin;
/// AnimeFlow's `/me` for the integrations page (tests pass a fake). /// AnimeFlow's `/me` for the integrations page (tests pass a fake).
final Future<AnimeFlowMe> Function()? animeFlowMe; final Future<AnimeFlowMe> Function()? animeFlowMe;
/// Dev builds: About → Developer → Danmaku preview.
final VoidCallback? onDanmakuPreview;
const SettingsTab({ const SettingsTab({
super.key, super.key,
required this.section, required this.section,
@@ -60,6 +63,7 @@ class SettingsTab extends StatelessWidget {
this.updates, this.updates,
this.jellyfin, this.jellyfin,
this.animeFlowMe, this.animeFlowMe,
this.onDanmakuPreview,
}); });
List<Widget> _page(BuildContext context) { List<Widget> _page(BuildContext context) {
@@ -121,7 +125,8 @@ class SettingsTab extends StatelessWidget {
return [ return [
const SettingsHintCard( const SettingsHintCard(
icon: Icons.tune, icon: Icons.tune,
text: "Player settings are also in the player: ⚙ → Player settings, at the right of its controls.", text: "Player settings are also in the player: ⚙ → Player settings opens a panel at its right edge "
"that stays while you watch.",
), ),
SettingsPanel( SettingsPanel(
title: 'Player', title: 'Player',
@@ -152,7 +157,7 @@ class SettingsTab extends StatelessWidget {
} }
}, },
), ),
const SettingsPanel(title: 'Jellyfin playback', child: AutoAdvanceSetting()), const SettingsPanel(title: 'Episodes', child: AutoAdvanceSetting()),
const SettingsPanel(title: 'Jellyfin library', child: HideReReleasesSetting()), const SettingsPanel(title: 'Jellyfin library', child: HideReReleasesSetting()),
]; ];
case SettingsSection.notifications: case SettingsSection.notifications:
@@ -162,7 +167,10 @@ class SettingsTab extends StatelessWidget {
case SettingsSection.shortcuts: case SettingsSection.shortcuts:
return const [SettingsPanel(title: 'Keyboard shortcuts', child: ShortcutList())]; return const [SettingsPanel(title: 'Keyboard shortcuts', child: ShortcutList())];
case SettingsSection.about: case SettingsSection.about:
return [AboutPanel(updates: updates)]; return [
AboutPanel(updates: updates),
if (devTools) DeveloperPanel(onDanmakuPreview: onDanmakuPreview),
];
} }
} }
@@ -772,7 +780,8 @@ class NotificationsInfo extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
line('Joins, leaves, lost connections and video changes', line('Joins, leaves, lost connections and video changes',
"Pop up bottom left for 5 s with the person's picture, above the player's controls"), "Pop up bottom left for 5 s with the person's picture, above the player's controls "
'(or fly across the picture: Danmaku, below)'),
line('Added to Up next', 'With the title, its picture and who added it'), line('Added to Up next', 'With the title, its picture and who added it'),
Padding( Padding(
padding: const EdgeInsets.symmetric(vertical: 6), padding: const EdgeInsets.symmetric(vertical: 6),
@@ -805,6 +814,11 @@ class NotificationsInfo extends StatelessWidget {
], ],
), ),
), ),
SettingsPanel(
key: const ValueKey('danmaku-settings'),
title: 'Over the player',
child: DanmakuSettingsPanel(settings: AppScope.of(context).coordinator.settings),
),
SettingsPanel( SettingsPanel(
title: 'Sitting out and in the background', title: 'Sitting out and in the background',
child: Column( child: Column(
+6 -1
View File
@@ -58,7 +58,11 @@ ShellToast shellToastFor(ToastItem t, AvatarInfo Function(String name) avatar) {
/// all (chat pop-ups, sitting out, alone) is the Notifier's policy. /// all (chat pop-ups, sitting out, alone) is the Notifier's policy.
class PartyToasts extends StatefulWidget { class PartyToasts extends StatefulWidget {
final VoidCallback? onOpenChat; final VoidCallback? onOpenChat;
const PartyToasts({super.key, this.onOpenChat});
/// Toasts shown another way (flying as danmaku, your quick chat echo):
/// left out when they come.
final bool Function(ToastItem t)? skip;
const PartyToasts({super.key, this.onOpenChat, this.skip});
@override @override
State<PartyToasts> createState() => _PartyToastsState(); State<PartyToasts> createState() => _PartyToastsState();
@@ -86,6 +90,7 @@ class _PartyToastsState extends State<PartyToasts> {
// Only new toasts re-run this, not the party they mention. // Only new toasts re-run this, not the party they mention.
untracked(() { untracked(() {
for (final t in fresh) { for (final t in fresh) {
if (widget.skip?.call(t) ?? false) continue;
_add(shellToastFor(t, (n) => avatarForName(n, coord, t.actorId))); _add(shellToastFor(t, (n) => avatarForName(n, coord, t.actorId)));
} }
if (gone.isNotEmpty) _leave(gone); if (gone.isNotEmpty) _leave(gone);
+273
View File
@@ -0,0 +1,273 @@
import 'dart:async';
import 'dart:math' as math;
import 'package:flutter/foundation.dart' show ValueListenable;
import 'package:flutter/widgets.dart';
import 'package:signals/signals_flutter.dart';
import '../playback/playback_controller.dart';
// What the session's video view tells mpv about where it is shown: the
// size its output renders at ("Sharp video and subtitles") and how far
// bottom subtitles move up while something covers the bottom of the
// picture (the quick chat box; the control bar when chosen).
/// The view size the host fits each video output into ([fitOutputSize])
/// for a view of [logical] size at [dpr]: its
/// physical pixels when [on] and video renders on the GPU, else null (the
/// video's own size). Software rendering keeps the video's size: every
/// pixel there is CPU work.
(int, int)? videoOutputSize(Size logical, double dpr, {required bool on, required RenderMode mode}) {
if (!on || mode != RenderMode.gpu) return null;
final w = (logical.width * dpr).round(), h = (logical.height * dpr).round();
if (w < 16 || h < 16) return null;
return (w, h);
}
/// How long a view size must hold before the output follows it (a
/// window drag resizes many times a second; each resize reallocates the
/// output's texture).
const outputResizeDelay = Duration(milliseconds: 120);
/// Sends the size of the view around [child] to the player host (see
/// [videoOutputSize]): at once the first time, then once a resize has
/// settled for [outputResizeDelay]. Follows the setting and the render
/// mode live.
class VideoOutputSizer extends StatefulWidget {
final PlaybackController playback;
/// The setting ([AppSettings.screenResVideo]).
final ReadonlySignal<bool> sharp;
final Widget child;
const VideoOutputSizer({super.key, required this.playback, required this.sharp, required this.child});
@override
State<VideoOutputSizer> createState() => _VideoOutputSizerState();
}
class _VideoOutputSizerState extends State<VideoOutputSizer> {
Size? _view;
double _dpr = 1;
Timer? _debounce;
late final void Function() _stop;
var _first = true;
@override
void initState() {
super.initState();
_stop = effect(() {
widget.sharp.value;
widget.playback.renderMode.value;
untracked(_send);
});
}
@override
void dispose() {
_debounce?.cancel();
_stop();
super.dispose();
}
void _send() {
final view = _view;
if (view == null) return;
final size = videoOutputSize(view, _dpr,
on: widget.sharp.peek(), mode: widget.playback.renderMode.peek());
widget.playback.host.setOutputSize(size?.$1, size?.$2);
}
void _resized(Size view, double dpr) {
if (view == _view && dpr == _dpr) return;
_view = view;
_dpr = dpr;
_debounce?.cancel();
if (_first) {
_first = false;
_send();
return;
}
_debounce = Timer(outputResizeDelay, _send);
}
@override
Widget build(BuildContext context) {
final dpr = MediaQuery.devicePixelRatioOf(context);
return LayoutBuilder(builder: (context, box) {
_resized(box.biggest, dpr);
return widget.child;
});
}
}
/// mpv `sub-pos` (100 = where the file puts them) that lifts bottom
/// subtitles clear of what covers the bottom of the view ([controls]
/// logical px tall: the control bar or the chat line, plus [gap]) in a [view] showing a [videoW] x [videoH] video fitted
/// inside it: only as far as the letterbox under the picture does not
/// already make room. `sub-pos` moves bottom-aligned lines by a share of
/// the picture's height; top-aligned ASS signs stay put.
double subtitleLiftPos({required Size view, int? videoW, int? videoH, required double controls, double gap = 8}) {
if (view.height <= 0 || view.width <= 0) return 100;
var picH = view.height;
if (videoW != null && videoH != null && videoW > 0 && videoH > 0) {
picH = math.min(view.height, view.width * videoH / videoW);
}
final bar = (view.height - picH) / 2;
final shift = math.max(0.0, controls + gap - bar);
return (100 - shift / picH * 100).clamp(50.0, 100.0);
}
/// The subtitle lift moves in [liftStepCount] steps [liftStepGap] apart
/// (about 130 ms): each `sub-pos` change makes mpv re-render the
/// subtitles, and a playing video shows a change only with its next
/// frame, so a change per screen frame (60 a second against a video's 24)
/// landed unevenly and looked choppy. One step per frame of a 24 fps
/// video, eased out (most of the way in the first step), reads as one
/// quick, smooth move.
const liftStepCount = 4;
const liftStepGap = Duration(milliseconds: 42);
/// The covered heights a lift from [from] to [to] passes through, one per
/// step, eased out; the last is exactly [to]. Empty when there is nothing
/// to move.
List<double> liftSteps(double from, double to, {int count = liftStepCount}) {
if ((to - from).abs() < 0.5) return const [];
return [
for (var i = 1; i <= count; i++) i == count ? to : from + (to - from) * Curves.easeOutQuad.transform(i / count),
];
}
/// How much of the bottom of the view is covered, as it moves: [value]
/// the covered height in logical px (0: nothing), stepping toward the
/// latest [animateTo] target ([liftSteps], [liftStepGap] apart; a new
/// target starts from where it is). mpv's `sub-pos` follows it
/// ([SubtitleLifter]), and so do the danmaku preview's mock subtitles.
class SubtitleLift extends ChangeNotifier implements ValueListenable<double> {
double _value = 0;
double _target = 0;
List<double> _steps = const [];
int _next = 0;
Timer? _timer;
@override
double get value => _value;
double get target => _target;
/// Moves toward [target] (px); the first step at once.
void animateTo(double target) {
target = math.max(0, target);
if ((target - _target).abs() < 0.5) return;
_target = target;
_timer?.cancel();
_timer = null;
_steps = liftSteps(_value, target);
_next = 0;
if (_steps.isEmpty) return;
_step();
if (_next < _steps.length) _timer = Timer.periodic(liftStepGap, (_) => _step());
}
void _step() {
if (_next >= _steps.length) return;
_value = _steps[_next++];
if (_next >= _steps.length) {
_timer?.cancel();
_timer = null;
}
notifyListeners();
}
@override
void dispose() {
_timer?.cancel();
super.dispose();
}
}
/// Bottom subtitles of the current player sit above what covers the
/// bottom of the full player ([lift]: the quick chat box, and the control
/// bar while it is up when [AppSettings.liftSubtitles] is on): mpv
/// `sub-pos` follows each step of [lift], only as far as the letterbox
/// under the picture leaves no room ([subtitleLiftPos]). Top-aligned
/// signs stay put. Draws nothing.
class SubtitleLifter extends StatefulWidget {
final PlaybackController playback;
final SubtitleLift lift;
/// Only the full player lifts.
final bool active;
const SubtitleLifter({super.key, required this.playback, required this.lift, required this.active});
@override
State<SubtitleLifter> createState() => _SubtitleLifterState();
}
class _SubtitleLifterState extends State<SubtitleLifter> {
static const _home = '100.0';
String _sent = _home;
HostedPlayer? _player;
Size _view = Size.zero;
late final void Function() _stop;
@override
void initState() {
super.initState();
widget.lift.addListener(_apply);
// A new player starts where the file wants its subtitles.
_stop = effect(() {
widget.playback.generation.value;
untracked(_apply);
});
}
@override
void didUpdateWidget(SubtitleLifter old) {
super.didUpdateWidget(old);
if (old.lift != widget.lift) {
old.lift.removeListener(_apply);
widget.lift.addListener(_apply);
}
if (old.active != widget.active) _apply();
}
@override
void dispose() {
widget.lift.removeListener(_apply);
_stop();
// Leave the player as the file wants it.
if (_sent != _home) _send(_home);
super.dispose();
}
void _apply() {
if (!mounted) return;
final p = widget.playback.current;
if (!identical(p, _player)) {
_player = p;
_sent = _home;
}
final cover = widget.active ? widget.lift.value : 0.0;
final st = p?.state;
final pos = cover <= 0
? 100.0
: subtitleLiftPos(view: _view, videoW: st?.width, videoH: st?.height, controls: cover, gap: 0);
final v = pos.toStringAsFixed(1);
if (v != _sent) _send(v);
}
void _send(String pos) {
_sent = pos;
final p = widget.playback.current;
if (p == null || p.disposed) return;
unawaited(p.setProperty('sub-pos', pos).catchError((Object _) {}));
}
@override
Widget build(BuildContext context) => LayoutBuilder(builder: (context, box) {
if (box.biggest != _view) {
_view = box.biggest;
WidgetsBinding.instance.addPostFrameCallback((_) => _apply());
}
return const SizedBox.shrink();
});
}
+203
View File
@@ -0,0 +1,203 @@
import 'package:flutter/material.dart';
import 'package:signals/signals_flutter.dart';
import '../app/app_graph.dart';
import '../session/app_events.dart' show ActionFailed;
import '../session/invite.dart';
import '../widgets/copy_open.dart';
/// Your server in the members card. Not shared: "Only you can reach it"
/// and Invite (opens it and copies the invite link). Shared: Copy invite
/// link (the bare `starsync://` link with the password, at the address
/// picked: the internet one when known, else this machine's), a short
/// line only when something needs the user ([YourServer.reach]: the port
/// needs forwarding, no internet address), Stop sharing, and the other
/// addresses (Internet / Home network / VPN) folded under a toggle.
class YourInvite extends StatefulWidget {
const YourInvite({super.key});
@override
State<YourInvite> createState() => _YourInviteState();
}
class _YourInviteState extends State<YourInvite> {
/// The address picked (null: the invite's own, the best one).
String? _picked;
bool _others = false;
static String _kindLabel(AddressKind k) => switch (k) {
AddressKind.public => 'Internet',
AddressKind.lan => 'Home network',
AddressKind.vpn => 'VPN',
};
@override
Widget build(BuildContext context) {
final muted = Theme.of(context).colorScheme.onSurfaceVariant;
final graph = AppScope.of(context);
final yours = graph.yourServer;
return SignalBuilder(builder: (context) {
final invite = yours.shared.value;
final reach = yours.reach.value;
final busy = yours.busy.value || yours.port.value == null;
final small = TextStyle(color: muted, fontSize: 12);
if (invite == null) {
return Padding(
padding: const EdgeInsets.fromLTRB(12, 0, 12, 12),
child: Column(
key: const ValueKey('your-invite'),
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(8, 0, 8, 6),
child: Text('Only you can reach it. Invite opens it to friends; nothing you watch stops.',
style: small),
),
TextButton.icon(
key: const ValueKey('copy-invite'),
onPressed: busy ? null : () => inviteFriends(context),
icon: const Icon(Icons.group_add_outlined, size: 16),
label: const Text('Invite'),
),
],
),
);
}
final choices = reach?.choices ?? [(host: invite.host, kind: addressKind(invite.host))];
final picked = choices.any((c) => c.host == _picked) ? _picked! : invite.host;
final kind = choices.where((c) => c.host == picked).firstOrNull?.kind ?? addressKind(picked);
final looking = reach?.looking ?? false;
final note = switch (kind) {
AddressKind.public when reach?.forwarding ?? false =>
'Friends outside your home network need port ${invite.port} forwarded to this computer.',
AddressKind.public => null,
_ when reach != null && !looking && reach.public == null =>
"Couldn't find your internet address: this link works on your home network only.",
_ => null,
};
return Padding(
padding: const EdgeInsets.fromLTRB(12, 0, 12, 12),
child: Column(
key: const ValueKey('your-invite'),
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Wrap(
spacing: 8,
runSpacing: 4,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
Tooltip(
message: invite.at(picked).address,
child: FilledButton.icon(
key: const ValueKey('copy-invite'),
onPressed: () {
final text = yours.inviteLink(invite.at(picked));
graph.joinOffers.copied(text);
copyToClipboard(context, text, done: 'Invite link copied');
},
icon: const Icon(Icons.link, size: 18),
label: const Text('Copy invite link'),
),
),
Tooltip(
message: 'Sends everyone else out; you and Up next stay',
child: TextButton(
key: const ValueKey('stop-sharing'),
onPressed: busy ? null : () => yours.stopSharing(),
child: const Text('Stop sharing'),
),
),
],
),
if (looking)
Padding(
padding: const EdgeInsets.fromLTRB(4, 8, 4, 0),
child: Row(children: [
const SizedBox(width: 12, height: 12, child: CircularProgressIndicator(strokeWidth: 2)),
const SizedBox(width: 8),
Flexible(child: Text('Looking up your internet address…', style: small)),
]),
)
else if (note != null)
Padding(
padding: const EdgeInsets.fromLTRB(4, 8, 4, 0),
child: Text(note, key: const ValueKey('invite-note'), style: small),
),
if (choices.length > 1) ...[
const SizedBox(height: 4),
TextButton.icon(
key: const ValueKey('invite-other-addresses'),
style: TextButton.styleFrom(
foregroundColor: muted,
visualDensity: VisualDensity.compact,
textStyle: Theme.of(context).textTheme.bodySmall,
),
onPressed: () => setState(() => _others = !_others),
icon: Icon(_others ? Icons.expand_less : Icons.expand_more, size: 16),
label: const Text('Other addresses'),
),
if (_others)
for (final c in choices)
_AddressRow(
key: ValueKey('invite-address-${c.host}'),
label: _kindLabel(c.kind),
address: invite.at(c.host).address,
selected: c.host == picked,
onTap: () => setState(() => _picked = c.host),
),
],
],
),
);
});
}
}
class _AddressRow extends StatelessWidget {
final String label;
final String address;
final bool selected;
final VoidCallback onTap;
const _AddressRow(
{super.key, required this.label, required this.address, required this.selected, required this.onTap});
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return InkWell(
borderRadius: BorderRadius.circular(8),
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
child: Row(children: [
Icon(selected ? Icons.radio_button_checked : Icons.radio_button_off,
size: 18, color: selected ? scheme.primary : scheme.onSurfaceVariant),
const SizedBox(width: 8),
SizedBox(width: 96, child: Text(label, style: TextStyle(fontSize: 12, color: scheme.onSurfaceVariant))),
Expanded(child: Text(address, style: const TextStyle(fontFamily: 'monospace'))),
]),
),
);
}
}
/// Invite: opens your server to friends if it is not yet and copies the
/// bare invite link (with the password, when one is set) at the best
/// address (the internet one when found, else this machine's); a port
/// that can't be opened is a notice.
Future<void> inviteFriends(BuildContext context) async {
final graph = AppScope.of(context);
final Invite invite;
try {
invite = await graph.yourServer.invite();
} catch (e) {
final port = graph.forms.form.hostPort;
graph.notifier.notify(ActionFailed(
"couldn't open your server on port $port: $e — pick another port in Settings → Party & network"));
return;
}
if (!context.mounted) return;
final text = graph.yourServer.inviteLink(invite);
graph.joinOffers.copied(text);
await copyToClipboard(context, text, done: 'Invite link copied');
}
@@ -184,4 +184,46 @@ class ConnectStore {
]; ];
await saveQuietly(); await saveQuietly();
} }
/// The saved password of a recent server at [host]:[port], '' when none.
String secretFor(String host, int port) =>
recents.peek().where((r) => r.key == '${host.toLowerCase()}:$port').firstOrNull?.secret ?? '';
/// Invites the join prompt already asked about (joined or dismissed),
/// as fingerprints ([inviteFingerprint]), newest last; never the links.
final _seenInvites = <String>[];
bool seenInvite(String fingerprint) {
_loadSeen();
return _seenInvites.contains(fingerprint);
} }
Future<void> rememberInvite(String fingerprint) async {
_loadSeen();
_seenInvites
..remove(fingerprint)
..add(fingerprint);
while (_seenInvites.length > maxSeenInvites) {
_seenInvites.removeAt(0);
}
try {
final box = connectFormBoxIfOpen() ?? await openConnectFormBox();
await box.put(connectKeySeenInvites, [..._seenInvites]);
} catch (e) {
debugPrint('save seen invites failed: $e');
}
}
bool _seenLoaded = false;
void _loadSeen() {
if (_seenLoaded) return;
final box = connectFormBoxIfOpen();
if (box == null) return;
_seenLoaded = true;
final raw = box.get(connectKeySeenInvites);
if (raw is List) _seenInvites.insertAll(0, [for (final v in raw) if (v is String && !_seenInvites.contains(v)) v]);
}
}
const connectKeySeenInvites = 'seenInvites';
const maxSeenInvites = 30;
+150 -2
View File
@@ -1,10 +1,12 @@
import 'dart:io' show Platform; import 'dart:io' show File, Platform;
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:hive_ce/hive.dart'; import 'package:hive_ce/hive.dart';
import 'package:signals/signals_flutter.dart'; import 'package:signals/signals_flutter.dart';
import 'package:sync_relay_core/core.dart' show UpdateChannel, appVersion, defaultUpdateChannel;
import '../controls/model/controls_prefs.dart'; import '../controls/model/controls_prefs.dart';
import '../danmaku/danmaku.dart' show DanmakuStyle;
import '../shaders/shader_presets.dart'; import '../shaders/shader_presets.dart';
import '../session/notice_prefs.dart'; import '../session/notice_prefs.dart';
import 'settings_schema.dart'; import 'settings_schema.dart';
@@ -44,9 +46,31 @@ const keyAutoSitOut = 'autoSitOut';
const _keyChatPopups = 'chatPopups'; const _keyChatPopups = 'chatPopups';
const _keyNotifyPeople = 'notifyPeople'; const _keyNotifyPeople = 'notifyPeople';
const _keyShareDiagnostics = 'shareDiagnostics'; const _keyShareDiagnostics = 'shareDiagnostics';
const _keyWhatsNewSeen = 'whatsNewSeen';
const _keyWhatsNewNotes = 'whatsNewNotes';
const _keyScreenResVideo = 'screenResVideo';
const _keyLiftSubtitles = 'liftSubtitles';
const _keyNotifyStyle = 'notifyStyle';
const _keySubScale = 'subScale';
const _keyDanmakuStyle = 'danmakuStyle';
const _keyUpdateChannel = 'updateChannel';
/// A file next to the binary naming a channel (`dev` / `stable`): the
/// app takes it as the update channel on its next start and deletes it.
/// The one-time command `tool/dev_release.sh` prints writes it.
const updateChannelFileName = 'update-channel';
enum HwdecMode { auto, off, custom } enum HwdecMode { auto, off, custom }
/// How notifications show over the full player ([AppSettings.notifyStyle]).
enum NotifyStyle {
toasts('Toasts'),
danmaku('Danmaku');
final String label;
const NotifyStyle(this.label);
}
/// The mpv `hwdec` of [HwdecMode.auto]. Windows: 'auto-copy' (decoded on /// The mpv `hwdec` of [HwdecMode.auto]. Windows: 'auto-copy' (decoded on
/// the GPU, frames copied back): media_kit renders through ANGLE, where /// the GPU, frames copied back): media_kit renders through ANGLE, where
/// plain 'auto' picks the dxva2 interop, which cannot create its EGL /// plain 'auto' picks the dxva2 interop, which cannot create its EGL
@@ -56,10 +80,33 @@ String get autoHwdec => Platform.isWindows ? 'auto-copy' : 'auto';
class AppSettings { class AppSettings {
static final AppSettings instance = AppSettings(); static final AppSettings instance = AppSettings();
/// The running build's version: picks the default [updateChannel].
final String runningVersion;
/// The [updateChannelFileName] file to adopt at load (null: none).
final File? channelFile;
AppSettings({this.runningVersion = appVersion, File? channelFile})
: channelFile = channelFile ?? _channelFileNextToBinary();
static File? _channelFileNextToBinary() {
try {
return File('${File(Platform.resolvedExecutable).parent.path}/$updateChannelFileName');
} catch (_) {
return null;
}
}
final hwdecMode = signal(HwdecMode.auto); final hwdecMode = signal(HwdecMode.auto);
final hwdecCustom = signal(''); final hwdecCustom = signal('');
final autoCheckUpdates = signal(true); final autoCheckUpdates = signal(true);
/// Jellyfin episodes: at the end, count down 10 s and put up the next. /// Where updates come from (Settings → Updates). Never chosen: dev for
/// a test build, else stable; a test build stores dev on its first
/// start, so the install stays on test builds after a release (also
/// when that release updated it).
late final updateChannel = signal(defaultUpdateChannel(runningVersion));
/// An episode this app puts up (Jellyfin, a file of a local show) is
/// continued by the next one (Up next's `auto` item, the server's
/// countdown at its end).
final autoAdvance = signal(true); final autoAdvance = signal(true);
/// Session: send ready=false when the video changes (a swap keeps the /// Session: send ready=false when the video changes (a swap keeps the
/// server's ready flags). /// server's ready flags).
@@ -85,6 +132,24 @@ class AppSettings {
/// Send this app's log lines to the server's session log while in a /// Send this app's log lines to the server's session log while in a
/// session (never chat). Off: nothing is sent but a note that it is off. /// session (never chat). Off: nothing is sent but a note that it is off.
final shareDiagnostics = signal(true); final shareDiagnostics = signal(true);
/// The video output renders at the player's size on screen, so mpv
/// scales the picture (and runs shaders) and draws subtitles at screen
/// resolution; off: at the video's own size, scaled by Flutter. Applies
/// live (GPU rendering only).
final screenResVideo = signal(true);
/// Bottom subtitles also move up above the control bar while it is up
/// (mpv `sub-pos`; the quick chat box always lifts them). Default off.
/// Applies live.
final liftSubtitles = signal(false);
/// Subtitle size, mpv `sub-scale` (1: as the file has them), 0.5..2.
/// Applies live and to each new player.
final subScale = signal(1.0);
/// How notifications show over the full player: toasts (the default),
/// or chat and short notices flying across the picture as danmaku
/// (`danmaku/danmaku_notices.dart`; problems stay toasts). Applies live.
final notifyStyle = signal(NotifyStyle.toasts);
/// How those lines look and move (stored as one map, [DanmakuStyle.toJson]).
final danmakuStyle = signal(const DanmakuStyle());
/// Chat message toasts (default: all). Applies live. /// Chat message toasts (default: all). Applies live.
final chatPopups = signal(ChatPopups.all); final chatPopups = signal(ChatPopups.all);
/// Joins and leaves also notify while sitting out or with the window in /// Joins and leaves also notify while sitting out or with the window in
@@ -105,6 +170,13 @@ class AppSettings {
/// `SfxKind` name -> `SoundPreset` name, per-sound overrides. /// `SfxKind` name -> `SoundPreset` name, per-sound overrides.
final soundPresetFor = signal<Map<String, String>>(const {}); final soundPresetFor = signal<Map<String, String>>(const {});
/// What's new (`update/whats_new.dart`): the version whose card was
/// shown, and the notes last kept (a version's `ReleaseNotes` as JSON).
String? whatsNewSeen;
Map<String, Object?>? whatsNewNotes;
/// The box was empty when this run opened it: a new install.
bool freshInstall = false;
/// Video shaders: one choice, used on join and changed both here and /// Video shaders: one choice, used on join and changed both here and
/// in the session (Ctrl+0..6, the shader button). /// in the session (Ctrl+0..6, the shader button).
final shaderPreset = signal(ShaderPreset.off); final shaderPreset = signal(ShaderPreset.off);
@@ -129,6 +201,7 @@ class AppSettings {
Future<void> _loadNow() async { Future<void> _loadNow() async {
try { try {
final box = _box = await Hive.openBox(settingsBoxName); final box = _box = await Hive.openBox(settingsBoxName);
freshInstall = box.isEmpty;
for (final line in await migrateSettings(box)) { for (final line in await migrateSettings(box)) {
debugPrint(line); debugPrint(line);
} }
@@ -141,6 +214,7 @@ class AppSettings {
if (custom is String) hwdecCustom.value = custom; if (custom is String) hwdecCustom.value = custom;
final auto = box.get(_keyAutoCheckUpdates); final auto = box.get(_keyAutoCheckUpdates);
if (auto is bool) autoCheckUpdates.value = auto; if (auto is bool) autoCheckUpdates.value = auto;
await _loadUpdateChannel(box);
final advance = box.get(_keyAutoAdvance); final advance = box.get(_keyAutoAdvance);
if (advance is bool) autoAdvance.value = advance; if (advance is bool) autoAdvance.value = advance;
final unready = box.get(_keyUnreadyOnSwap); final unready = box.get(_keyUnreadyOnSwap);
@@ -163,6 +237,16 @@ class AppSettings {
if (hideRe is bool) hideReReleases.value = hideRe; if (hideRe is bool) hideReReleases.value = hideRe;
final line = box.get(_keyProgressLine); final line = box.get(_keyProgressLine);
if (line is bool) progressLine.value = line; if (line is bool) progressLine.value = line;
final screenRes = box.get(_keyScreenResVideo);
if (screenRes is bool) screenResVideo.value = screenRes;
final lift = box.get(_keyLiftSubtitles);
if (lift is bool) liftSubtitles.value = lift;
final scale = box.get(_keySubScale);
if (scale is num && scale.isFinite) subScale.value = scale.toDouble().clamp(0.5, 2.0);
final notifyAs = box.get(_keyNotifyStyle);
notifyStyle.value = NotifyStyle.values.where((m) => m.name == notifyAs).firstOrNull ?? NotifyStyle.toasts;
final style = box.get(_keyDanmakuStyle);
if (style is Map) danmakuStyle.value = DanmakuStyle.fromJson(style);
final share = box.get(_keyShareDiagnostics); final share = box.get(_keyShareDiagnostics);
if (share is bool) shareDiagnostics.value = share; if (share is bool) shareDiagnostics.value = share;
final popups = box.get(_keyChatPopups); final popups = box.get(_keyChatPopups);
@@ -192,6 +276,10 @@ class AppSettings {
shaderQuality.value = shaderQuality.value =
ShaderQuality.values.where((q) => q.name == quality).firstOrNull ?? ShaderQuality.values.where((q) => q.name == quality).firstOrNull ??
ShaderQuality.hq; ShaderQuality.hq;
final seen = box.get(_keyWhatsNewSeen);
if (seen is String) whatsNewSeen = seen;
final notes = box.get(_keyWhatsNewNotes);
if (notes is Map) whatsNewNotes = {for (final e in notes.entries) '${e.key}': e.value};
final files = box.get(_keyCustomShaders); final files = box.get(_keyCustomShaders);
if (files is List) { if (files is List) {
customShaders.value = List.unmodifiable(files.whereType<String>()); customShaders.value = List.unmodifiable(files.whereType<String>());
@@ -216,6 +304,30 @@ class AppSettings {
_put(_keyAutoCheckUpdates, on); _put(_keyAutoCheckUpdates, on);
} }
Future<void> _loadUpdateChannel(Box box) async {
UpdateChannel? requested;
final file = channelFile;
try {
if (file != null && file.existsSync()) {
requested = UpdateChannel.fromName(file.readAsStringSync().trim());
file.deleteSync();
}
} catch (e) {
debugPrint('update channel file: $e');
}
final stored = requested ?? UpdateChannel.fromName(box.get(_keyUpdateChannel));
final channel = stored ?? defaultUpdateChannel(runningVersion);
updateChannel.value = channel;
if (channel.name != box.get(_keyUpdateChannel) && (requested != null || channel == UpdateChannel.dev)) {
await box.put(_keyUpdateChannel, channel.name);
}
}
void setUpdateChannel(UpdateChannel channel) {
updateChannel.value = channel;
_put(_keyUpdateChannel, channel.name);
}
void setAutoAdvance(bool on) { void setAutoAdvance(bool on) {
autoAdvance.value = on; autoAdvance.value = on;
_put(_keyAutoAdvance, on); _put(_keyAutoAdvance, on);
@@ -263,6 +375,32 @@ class AppSettings {
_put(_keyProgressLine, on); _put(_keyProgressLine, on);
} }
void setScreenResVideo(bool on) {
screenResVideo.value = on;
_put(_keyScreenResVideo, on);
}
void setLiftSubtitles(bool on) {
liftSubtitles.value = on;
_put(_keyLiftSubtitles, on);
}
void setSubScale(double v) {
subScale.value = v.clamp(0.5, 2.0);
_put(_keySubScale, subScale.value);
}
void setNotifyStyle(NotifyStyle style) {
notifyStyle.value = style;
_put(_keyNotifyStyle, style.name);
}
void setDanmakuStyle(DanmakuStyle style) {
if (style == danmakuStyle.peek()) return;
danmakuStyle.value = style;
_put(_keyDanmakuStyle, style.toJson());
}
void setShareDiagnostics(bool on) { void setShareDiagnostics(bool on) {
shareDiagnostics.value = on; shareDiagnostics.value = on;
_put(_keyShareDiagnostics, on); _put(_keyShareDiagnostics, on);
@@ -326,6 +464,16 @@ class AppSettings {
_put(_keyCustomShaders, [...paths]); _put(_keyCustomShaders, [...paths]);
} }
Future<void> setWhatsNewSeen(String version) {
whatsNewSeen = version;
return _put(_keyWhatsNewSeen, version);
}
Future<void> setWhatsNewNotes(Map<String, Object?> notes) {
whatsNewNotes = notes;
return _put(_keyWhatsNewNotes, notes);
}
Future<void> _put(String key, Object value) async { Future<void> _put(String key, Object value) async {
try { try {
final box = _box ?? final box = _box ??
+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]).
@@ -54,23 +54,41 @@ class UpdateController {
final String currentVersion; final String currentVersion;
final String manifestUrl; final String manifestUrl;
final String devUrl;
final String? executablePath; final String? executablePath;
final Directory? installRoot; final Directory? installRoot;
final RestartFn restart; final RestartFn restart;
final bool skipDevBuilds; final bool skipDevBuilds;
/// Awaited after the apply, before [restart]: keeps the new version's
/// notes for its first start ([WhatsNew.installing], wired by `AppGraph`).
Future<void> Function(UpdateManifest manifest)? beforeRestart;
/// The update channel each check reads (the setting, wired by
/// `AppGraph`); null: [defaultUpdateChannel] of [currentVersion].
UpdateChannel Function()? channelOf;
UpdateController( UpdateController(
{this.currentVersion = appVersion, {this.currentVersion = appVersion,
this.manifestUrl = updateManifestUrl, this.manifestUrl = updateManifestUrl,
this.devUrl = devManifestUrl,
this.executablePath, this.executablePath,
this.installRoot, this.installRoot,
this.restart = _closeAndRestart, this.restart = _closeAndRestart,
this.skipDevBuilds = true}); this.skipDevBuilds = true,
this.beforeRestart,
this.channelOf});
UpdateChannel get channel => channelOf?.call() ?? defaultUpdateChannel(currentVersion);
final status = ValueNotifier(const UpdateStatus(UpdatePhase.idle)); final status = ValueNotifier(const UpdateStatus(UpdatePhase.idle));
/// The manifest of the last check that read one, newer or not (What's
/// new picks up this version's notes from it).
final latest = ValueNotifier<UpdateManifest?>(null);
/// Outcome of the last finished check, for Settings/About wording /// Outcome of the last finished check, for Settings/About wording
/// ("Up to date", "Check failed: ..."); null until one finishes. /// ("Up to date", "Check failed: ..."); null until one finishes.
final lastCheck = ValueNotifier<String?>(null); final lastCheck = ValueNotifier<String?>(null);
/// On the stable channel in a test build: the latest release when it
/// is older than this build (offered only by [goBackTo], after asking).
final stableBehind = ValueNotifier<UpdateManifest?>(null);
Future<void>? _check; Future<void>? _check;
late final String _exe = executablePath ?? currentExecutablePath(); late final String _exe = executablePath ?? currentExecutablePath();
late final Directory? _root = installRoot ?? findInstallRoot(_exe); late final Directory? _root = installRoot ?? findInstallRoot(_exe);
@@ -106,8 +124,19 @@ class UpdateController {
final root = _root; final root = _root;
if (root != null) await cleanupUpdateLeftovers(root); if (root != null) await cleanupUpdateLeftovers(root);
try { try {
final ch = channel;
final m = await fetchLatestManifest( final m = await fetchLatestManifest(
currentVersion: currentVersion, url: manifestUrl); currentVersion: currentVersion, channel: ch, url: manifestUrl, devUrl: devUrl);
latest.value = m;
stableBehind.value = ch == UpdateChannel.stable && isStableBehind(currentVersion, m) ? m : null;
if (stableBehind.value != null) {
_log.info('stable channel: latest release ${m.version} is older than $currentVersion');
lastCheck.value = 'Stable is at ${m.version}, older than this test build';
if (!_installing && status.value.manifest?.version != m.version) {
status.value = const UpdateStatus(UpdatePhase.idle);
}
return;
}
if (!isNewerVersion(currentVersion, m.version)) { if (!isNewerVersion(currentVersion, m.version)) {
_log.info('up to date ($currentVersion, latest ${m.version})'); _log.info('up to date ($currentVersion, latest ${m.version})');
lastCheck.value = 'Up to date'; lastCheck.value = 'Up to date';
@@ -125,6 +154,27 @@ class UpdateController {
} }
} }
/// The latest release when it is older than this test build (the
/// question before going back to stable); null when it is not, or the
/// check failed.
Future<UpdateManifest?> stableToGoBackTo() async {
try {
final m = await fetchManifest(url: manifestUrl);
return isStableBehind(currentVersion, m) ? m : null;
} catch (e) {
_log.warning('stable manifest: $e');
return null;
}
}
/// Installs [m] although it is older (going back to stable, asked
/// first): offered as an update, then [install].
Future<void> goBackTo(UpdateManifest m) async {
if (_installing) return;
status.value = UpdateStatus(UpdatePhase.available, manifest: m, canInstall: _root != null);
if (_root != null) await install();
}
bool get _installing => switch (status.value.phase) { bool get _installing => switch (status.value.phase) {
UpdatePhase.downloading || UpdatePhase.applying || UpdatePhase.restarting => true, UpdatePhase.downloading || UpdatePhase.applying || UpdatePhase.restarting => true,
_ => false, _ => false,
@@ -173,6 +223,11 @@ class UpdateController {
_log.info('updated to ${m.version}, restarting'); _log.info('updated to ${m.version}, restarting');
status.value = status.value =
UpdateStatus(UpdatePhase.restarting, manifest: m, canInstall: true); UpdateStatus(UpdatePhase.restarting, manifest: m, canInstall: true);
try {
await beforeRestart?.call(m);
} catch (e) {
_log.warning('keeping the notes of ${m.version}: $e');
}
// Started by an old name (1.2.x's restart, an old launcher): the new // Started by an old name (1.2.x's restart, an old launcher): the new
// binary next to it. // binary next to it.
await restart(relaunchExecutable(_exe), launchArgs); await restart(relaunchExecutable(_exe), launchArgs);
+150
View File
@@ -0,0 +1,150 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:sync_relay_core/core.dart';
import '../state/settings.dart';
import 'update_controller.dart';
// "What's new in X.Y.Z" once after an update. The notes come from the
// manifest: the one installed (kept before the restart) or, for an update
// made another way (1.3.x's updater, `--upgrade`, by hand), the manifest of
// the first check that finds this very version. A fresh install shows
// nothing; Settings → About opens the card again.
/// What the card shows for one version.
class ReleaseNotes {
final String version;
/// The one-line notes (all a manifest from before the changelog has).
final String notes;
final ReleaseChanges? changes;
const ReleaseNotes({required this.version, this.notes = '', this.changes});
factory ReleaseNotes.of(UpdateManifest m) => ReleaseNotes(version: m.version, notes: m.notes, changes: m.changes);
/// Null for anything unreadable.
static ReleaseNotes? fromJson(Object? json) {
if (json is! Map) return null;
final version = json['version'];
if (version is! String || version.isEmpty) return null;
final notes = json['notes'];
return ReleaseNotes(
version: version,
notes: notes is String ? notes : '',
changes: ReleaseChanges.fromJson(json['changes']),
);
}
Map<String, Object?> toJson() => {
'version': version,
'notes': notes,
if (changes != null) 'changes': changes!.toJson(),
};
bool get isEmpty => notes.trim().isEmpty && (changes?.isEmpty ?? true);
}
/// Where [WhatsNew] keeps its two values (settings box; tests: memory).
abstract class WhatsNewStore {
/// The version whose card was shown (or that was installed fresh).
String? get seen;
Future<void> setSeen(String version);
/// The notes last known for an installed or current version.
ReleaseNotes? get notes;
Future<void> setNotes(ReleaseNotes notes);
/// The settings box was empty at start: a fresh install.
bool get fresh;
}
class SettingsWhatsNewStore implements WhatsNewStore {
final AppSettings settings;
SettingsWhatsNewStore(this.settings);
@override
String? get seen => settings.whatsNewSeen;
@override
Future<void> setSeen(String version) => settings.setWhatsNewSeen(version);
@override
ReleaseNotes? get notes => ReleaseNotes.fromJson(settings.whatsNewNotes);
@override
Future<void> setNotes(ReleaseNotes notes) => settings.setWhatsNewNotes(notes.toJson());
@override
bool get fresh => settings.freshInstall;
}
class MemoryWhatsNewStore implements WhatsNewStore {
@override
String? seen;
@override
ReleaseNotes? notes;
@override
bool fresh;
MemoryWhatsNewStore({this.seen, this.notes, this.fresh = false});
@override
Future<void> setSeen(String version) async => seen = version;
@override
Future<void> setNotes(ReleaseNotes notes) async => this.notes = notes;
}
/// Made once by `AppGraph` (the shell and About read it from there).
class WhatsNew {
final String currentVersion;
final WhatsNewStore store;
WhatsNew({this.currentVersion = appVersion, required this.store});
/// The card to show now (once); null when there is none.
final due = ValueNotifier<ReleaseNotes?>(null);
UpdateController? _updates;
/// At start, settings loaded: a pending card for this version, and
/// [updates]' checks from now on.
void start(UpdateController updates) {
if (!identical(_updates, updates)) {
_updates?.latest.removeListener(_onManifest);
_updates = updates..latest.addListener(_onManifest);
}
final s = store;
if (s.seen == currentVersion) return;
if (s.seen == null && s.fresh) {
// A new install: nothing changed for this person.
unawaited(s.setSeen(currentVersion));
return;
}
final known = s.notes;
if (known != null && known.version == currentVersion && !known.isEmpty) due.value = known;
}
void _onManifest() {
final m = _updates?.latest.value;
if (m != null) offer(ReleaseNotes.of(m));
}
/// Notes for some version (an update check's manifest). Kept when they
/// are this version's; due when its card was not shown yet.
void offer(ReleaseNotes n) {
if (n.version != currentVersion || n.isEmpty) return;
final s = store;
unawaited(s.setNotes(n));
if (s.seen != currentVersion && !s.fresh) due.value = n;
}
/// Before the restart into [m]: its notes for the first start.
Future<void> installing(UpdateManifest m) => store.setNotes(ReleaseNotes.of(m));
/// The card was shown: never again for this version.
void shown() {
due.value = null;
unawaited(store.setSeen(currentVersion));
}
/// This version's notes, when known (Settings → About).
ReleaseNotes? get current {
final n = store.notes;
return n != null && n.version == currentVersion && !n.isEmpty ? n : null;
}
}
@@ -0,0 +1,187 @@
import 'package:flutter/foundation.dart';
import 'package:hive_ce/hive.dart';
import 'package:signals/signals_flutter.dart';
import 'package:sync_relay_core/core.dart';
// The device's watch history (device-owned, one Hive box): what this app
// watched of files and links, by [WatchKey], with the verdict of the
// watched rule (`WatchSession`, D3). Jellyfin keeps its own (reported by
// `JellyfinProgressReporter`); AniList later reads the tracker's records.
// See ARCHITECTURE.md "Watch tracking".
const watchHistoryBoxName = 'watch_history';
/// What a watch is about, independent of the opening: a file by its path
/// on this computer, a link by its URL, a Jellyfin item by its ids, else
/// the media's name and size (someone else's file, a link passed on).
class WatchKey {
final String value;
const WatchKey._(this.value);
factory WatchKey.file(String path) => WatchKey._('file:$path');
factory WatchKey.link(String url) => WatchKey._('link:$url');
factory WatchKey.jellyfin(JellyfinRef ref) => WatchKey._('jf:${ref.serverId}/${ref.itemId}');
factory WatchKey.media(String name, int size) => WatchKey._('media:$name|$size');
bool get isJellyfin => value.startsWith('jf:');
/// The key of `state.media` [media]: [ownFile] / [ownLink] when this app
/// put it up from there, else its Jellyfin ref, its shared link, its
/// name and size.
static WatchKey? of(Map<String, Object?>? media, {String? ownFile, String? ownLink}) {
if (media == null) return null;
if (ownFile != null) return WatchKey.file(ownFile);
final jf = media['jellyfin'];
if (jf is Map) {
final ref = JellyfinRef.fromJson(Map<String, Object?>.from(jf));
if (ref.serverId.isNotEmpty && ref.itemId.isNotEmpty) return WatchKey.jellyfin(ref);
}
if (ownLink != null) return WatchKey.link(ownLink);
final url = media['url'];
if (url is String && (url.startsWith('http://') || url.startsWith('https://'))) return WatchKey.link(url);
final name = media['name'];
final size = media['size'];
return WatchKey.media(name is String ? name : '', size is num ? size.toInt() : 0);
}
@override
bool operator ==(Object other) => other is WatchKey && other.value == value;
@override
int get hashCode => value.hashCode;
@override
String toString() => value;
}
/// One closed watch: what the tracker hands its sinks (the device history,
/// AniList later).
class WatchRecord {
final WatchKey key;
final String title;
final MediaRef? ref;
final WatchVerdict verdict;
/// Seconds the media played while this viewer was In, and its runtime
/// (0 when unknown).
final double timeIn;
final double runtime;
final DateTime at;
const WatchRecord({
required this.key,
required this.title,
required this.verdict,
required this.timeIn,
required this.runtime,
required this.at,
this.ref,
});
@override
String toString() => 'WatchRecord($key, $verdict, ${timeIn.round()} s In of ${runtime.round()} s)';
}
/// What the history holds for a key: watched (played), or where to
/// resume, and when.
class WatchEntry {
final bool played;
/// Seconds; 0 when played or none.
final double resumeAt;
final String title;
final MediaRef? ref;
final DateTime at;
const WatchEntry({required this.played, this.resumeAt = 0, this.title = '', this.ref, required this.at});
Map<String, Object?> toJson() => {
'played': played,
if (resumeAt > 0) 'resumeAt': resumeAt,
if (title.isNotEmpty) 'title': title,
if (ref != null) 'ref': ref!.toJson(),
'at': at.toUtc().toIso8601String(),
};
static WatchEntry? fromJson(Object? j) {
if (j is! Map) return null;
final at = DateTime.tryParse('${j['at']}');
if (at == null) return null;
final resume = j['resumeAt'];
MediaRef? ref;
try {
ref = MediaRef.fromJson(j['ref']);
} catch (_) {}
return WatchEntry(
played: j['played'] == true,
resumeAt: resume is num && resume.isFinite && resume > 0 ? resume.toDouble() : 0,
title: j['title'] is String ? j['title'] as String : '',
ref: ref,
at: at,
);
}
}
/// The device's watch history: [entry] per key, [record] from the
/// tracker. [revision] bumps on every change (pages that show it listen).
class WatchHistory {
final Map<String, Object?> _memory = {};
/// False: memory only (tests).
final bool persist;
Box? _box;
Future<void>? _load;
final revision = signal(0);
WatchHistory({this.persist = true});
Future<void> ensureLoaded() => _load ??= () async {
if (!persist) return;
try {
final box = _box = await Hive.openBox(watchHistoryBoxName);
for (final e in _memory.entries) {
await box.put(e.key, e.value);
}
_memory.clear();
revision.value++;
} catch (e, st) {
debugPrint('open watch history failed: $e\n$st');
}
}();
WatchEntry? entry(WatchKey key) => WatchEntry.fromJson(_box?.get(key.value) ?? _memory[key.value]);
/// A closed watch: played -> played (the resume point goes); part way
/// -> its resume point, unless it was played before (it stays played);
/// a peek changes nothing. Jellyfin items are not kept here.
void record(WatchRecord r) {
if (r.key.isJellyfin) return;
final before = entry(r.key);
final WatchEntry next;
switch (r.verdict.outcome) {
case WatchOutcome.played || WatchOutcome.keepPlayed:
next = WatchEntry(played: true, title: r.title, ref: r.ref, at: r.at);
case WatchOutcome.partial:
next = WatchEntry(
played: before?.played ?? false,
resumeAt: (before?.played ?? false) ? 0 : r.verdict.resumeAt ?? 0,
title: r.title,
ref: r.ref,
at: r.at);
case WatchOutcome.peek:
return;
}
final json = next.toJson();
final box = _box;
if (box == null) {
_memory[r.key.value] = json;
} else {
box.put(r.key.value, json).catchError((Object e) => debugPrint('watch history put ${r.key}: $e'));
}
revision.value++;
}
/// Every entry, newest first.
List<(WatchKey, WatchEntry)> get entries {
final raw = <String, Object?>{..._memory, if (_box != null) for (final k in _box!.keys) '$k': _box!.get(k)};
final out = [
for (final e in raw.entries)
if (WatchEntry.fromJson(e.value) case final w?) (WatchKey._(e.key), w),
];
out.sort((a, b) => b.$2.at.compareTo(a.$2.at));
return out;
}
}
@@ -0,0 +1,159 @@
import 'dart:async';
import 'package:sync_relay_core/core.dart';
import 'watch_history.dart';
// Watch tracking (ARCHITECTURE.md "Watch tracking", D3): one watch per
// media of the server (its `mediaId`), fed every second by the session
// with what this app sees, judged by core's `WatchSession` when it ends.
// The verdict goes to the device history (files and links) and out on
// [WatchTracker.records] (the seam for AniList later); Jellyfin's
// played/resume reports read the same session.
final _log = createLogger('watch');
/// What the session hands the tracker each second.
class WatchSample {
/// The server's media and its id.
final int mediaId;
final Map<String, Object?>? media;
/// This app's own source of it: the file it put up, the link it put up.
final String? ownFile;
final String? ownLink;
final double position;
/// The media plays here: the server plays it and this player has it.
final bool playing;
/// This viewer is In (`in`): not away, joining or gone.
final bool present;
/// The player's duration (0 while unknown) and where the ending starts.
final double duration;
final double? endingStart;
final DateTime at;
const WatchSample({
required this.mediaId,
required this.media,
required this.position,
required this.playing,
required this.present,
required this.at,
this.ownFile,
this.ownLink,
this.duration = 0,
this.endingStart,
});
}
class WatchTracker {
final WatchHistory history;
WatchTracker({WatchHistory? history}) : history = history ?? WatchHistory(persist: false);
final _records = StreamController<WatchRecord>.broadcast();
/// Every closed watch (peeks too): the device history takes files and
/// links; AniList progress later listens here.
Stream<WatchRecord> get records => _records.stream;
int _mediaId = -1;
WatchKey? _key;
String _title = '';
MediaRef? _ref;
double _runtime = 0;
double? _endingStart;
WatchSession? _session;
/// The watch of the media playing (null before the first sample).
WatchSession? get session => _session;
WatchKey? get key => _key;
/// One second of the session. A new media closes the watch of the one
/// before.
void sample(WatchSample s) {
if (s.mediaId != _mediaId) {
close(at: s.at);
if (s.media == null) return;
_open(s);
}
final session = _session;
if (session == null) return;
// A file or link this app put up is known once its swap landed.
final key = WatchKey.of(s.media, ownFile: s.ownFile, ownLink: s.ownLink);
if (key != null && key != _key && !(_key?.isJellyfin ?? false)) _key = key;
if (s.duration > 0) _runtime = s.duration;
_endingStart = s.endingStart;
session.sample(s.position, playing: s.playing, present: s.present, at: s.at);
}
void _open(WatchSample s) {
final key = WatchKey.of(s.media, ownFile: s.ownFile, ownLink: s.ownLink);
final before = key == null ? null : history.entry(key);
_mediaId = s.mediaId;
_key = key;
_title = '${s.media?['name'] ?? ''}';
try {
_ref = MediaRef.fromJson(s.media?['ref']);
} catch (_) {
_ref = null;
}
final d = s.media?['duration'];
_runtime = d is num && d > 0 ? d.toDouble() : 0;
_endingStart = null;
_session = WatchSession(playedBefore: before?.played ?? false, resumeBefore: before?.resumeAt ?? 0);
}
/// The same media goes on under another [mediaId] (a restarted server
/// numbers it anew): the watch carries on.
void renumber(int from, int to) {
if (_mediaId == from) _mediaId = to;
}
/// What Jellyfin says about the item before this opening (its played
/// state and resume point, read once it is open).
void before({required bool played, double resumeAt = 0}) {
final session = _session;
if (session == null) return;
session
..playedBefore = played
..resumeBefore = resumeAt;
}
/// The verdict so far (the runtime as last seen); null without a watch.
WatchVerdict? get verdict => _session?.verdict(runtime: _runtime, endingStart: _endingStart);
/// Records the watch as it stands (the end of the media; it may go on
/// after a seek back and be recorded again).
WatchRecord? checkpoint({DateTime? at}) {
final session = _session, key = _key;
if (session == null || key == null) return null;
final v = session.verdict(runtime: _runtime, endingStart: _endingStart);
final r = WatchRecord(
key: key,
title: _title,
ref: _ref,
verdict: v,
timeIn: session.timeIn,
runtime: _runtime,
at: at ?? DateTime.now().toUtc());
_log.info('$key: ${v.outcome.name} (${session.timeIn.round()} s In of ${_runtime.round()} s)');
history.record(r);
_records.add(r);
return r;
}
/// The watch ends (another media, leaving): recorded and forgotten.
WatchRecord? close({DateTime? at}) {
final r = _session == null ? null : checkpoint(at: at);
_mediaId = -1;
_key = null;
_session = null;
_ref = null;
_runtime = 0;
_endingStart = null;
return r;
}
/// The media the current watch is of (-1: none).
int get mediaId => _mediaId;
Future<void> dispose() => _records.close();
}
@@ -0,0 +1,326 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:sync_relay_core/core.dart';
import '../screens/home/ui.dart' show appName;
import '../shell/motion.dart';
import '../update/whats_new.dart';
import 'app_menu.dart';
// The "What's new" card: a version's changes (CHANGELOG.md, through the
// manifest's `changes`) as Added / Changed / Fixed sections, each with its
// icon and colour, on the app's glass. The update band opens it for the
// offered version, the first start after an update for the new one
// ([WhatsNew]), Settings → About for the running one. Notes without
// `changes` (older manifests) show as one paragraph.
enum ChangeKind {
added('Added', Icons.auto_awesome_rounded, Icons.add_rounded, Color(0xFF7EE0A6)),
changed('Changed', Icons.tune_rounded, Icons.arrow_forward_rounded, Color(0xFF8DB8FF)),
fixed('Fixed', Icons.handyman_rounded, Icons.check_rounded, Color(0xFFFFC772));
final String label;
final IconData icon;
/// Before each item.
final IconData bullet;
/// Reads on the dark glass.
final Color color;
const ChangeKind(this.label, this.icon, this.bullet, this.color);
List<String> of(ReleaseChanges c) => switch (this) {
ChangeKind.added => c.added,
ChangeKind.changed => c.changed,
ChangeKind.fixed => c.fixed,
};
}
/// The first [n] items, Added before Changed before Fixed.
List<(ChangeKind, String)> topChanges(ReleaseChanges c, int n) => [
for (final k in ChangeKind.values)
for (final item in k.of(c)) (k, item),
].take(n).toList();
/// Opens the card over the window. [action]: a button beside Close (the
/// band's "Update and restart").
Future<void> showWhatsNew(BuildContext context, ReleaseNotes notes, {String? overline, Widget? action}) =>
showPopover<void>(
context,
at: Alignment.center,
dim: true,
child: WhatsNewCard(notes: notes, overline: overline, action: action),
);
class WhatsNewCard extends StatefulWidget {
final ReleaseNotes notes;
/// The small line over the title (default: the app's name).
final String? overline;
final Widget? action;
/// Default: closes the route.
final VoidCallback? onClose;
const WhatsNewCard({super.key, required this.notes, this.overline, this.action, this.onClose});
@override
State<WhatsNewCard> createState() => _WhatsNewCardState();
}
class _WhatsNewCardState extends State<WhatsNewCard> with SingleTickerProviderStateMixin {
late final _intro = AnimationController(
vsync: this,
duration: easeTime + Duration(milliseconds: 45 * math.min(_rows, 12)),
)..forward();
int get _rows => widget.notes.changes?.length ?? 1;
@override
void dispose() {
_intro.dispose();
super.dispose();
}
/// Row [i] of [_rows] slides up and fades in a little after the one above.
Widget _staggered(int i, Widget child) {
final start = math.min(i, 12) * 0.06;
final a = CurvedAnimation(
parent: _intro,
curve: Interval(math.min(start, 0.6), 1, curve: ease),
);
return FadeTransition(
opacity: a,
child: SlideTransition(
position: Tween(begin: const Offset(0, 0.25), end: Offset.zero).animate(a),
child: child,
),
);
}
void _close() => widget.onClose != null ? widget.onClose!() : Navigator.of(context).maybePop();
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final scheme = theme.colorScheme;
const muted = Color(0xB3FFFFFF);
final n = widget.notes;
final changes = n.changes;
final screen = MediaQuery.sizeOf(context);
final maxHeight = math.min(620.0, screen.height - 64);
var row = 0;
final body = <Widget>[
if (changes == null)
_staggered(row++, Text(n.notes, style: const TextStyle(fontSize: 13.5, height: 1.45)))
else
for (final k in ChangeKind.values)
if (k.of(changes).isNotEmpty) ...[
_staggered(row++, _SectionHead(kind: k, count: k.of(changes).length)),
const SizedBox(height: 6),
for (final item in k.of(changes)) _staggered(row++, _Item(kind: k, text: item)),
const SizedBox(height: 10),
],
];
return AppGlass(
key: const ValueKey('whats-new-card'),
radius: 16,
child: ConstrainedBox(
constraints: BoxConstraints.tightFor(width: math.min(460.0, screen.width - 32)).copyWith(maxHeight: maxHeight),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(18, 16, 8, 0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
DecoratedBox(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [scheme.primary, scheme.tertiary.withValues(alpha: 0.85)],
),
boxShadow: [BoxShadow(color: scheme.primary.withValues(alpha: 0.35), blurRadius: 14)],
),
child: const SizedBox.square(
dimension: 40,
child: Icon(Icons.auto_awesome, color: Colors.white, size: 22),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
(widget.overline ?? appName).toUpperCase(),
style: TextStyle(
color: scheme.primary,
fontSize: 10.5,
fontWeight: FontWeight.w700,
letterSpacing: 1.4,
),
),
const SizedBox(height: 2),
Text(
"What's new in ${n.version}",
key: const ValueKey('whats-new-title'),
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w700, height: 1.2),
),
],
),
),
IconButton(
tooltip: 'Close',
visualDensity: VisualDensity.compact,
icon: const Icon(Icons.close, size: 18, color: muted),
onPressed: _close,
),
],
),
),
if (changes != null && n.notes.trim().isNotEmpty)
Padding(
padding: const EdgeInsets.fromLTRB(18, 10, 18, 0),
child: Text(n.notes, style: const TextStyle(color: muted, fontSize: 13, height: 1.4)),
),
const SizedBox(height: 12),
const Divider(height: 1, thickness: 0.5, color: AppMenuStyle.border),
Flexible(
child: SingleChildScrollView(
key: const ValueKey('whats-new-list'),
padding: const EdgeInsets.fromLTRB(18, 14, 18, 4),
child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: body),
),
),
const Divider(height: 1, thickness: 0.5, color: AppMenuStyle.border),
Padding(
padding: const EdgeInsets.fromLTRB(12, 10, 12, 10),
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
if (widget.action != null) ...[
TextButton(key: const ValueKey('whats-new-close'), onPressed: _close, child: const Text('Close')),
const SizedBox(width: 8),
widget.action!,
] else
FilledButton.tonal(
key: const ValueKey('whats-new-close'),
onPressed: _close,
child: const Text('Nice'),
),
],
),
),
],
),
),
);
}
}
class _SectionHead extends StatelessWidget {
final ChangeKind kind;
final int count;
const _SectionHead({required this.kind, required this.count});
@override
Widget build(BuildContext context) => Align(
alignment: AlignmentDirectional.centerStart,
child: DecoratedBox(
key: ValueKey('whats-new-${kind.name}'),
decoration: BoxDecoration(
color: kind.color.withValues(alpha: 0.14),
borderRadius: BorderRadius.circular(999),
border: Border.all(color: kind.color.withValues(alpha: 0.28), width: 0.5),
),
child: Padding(
padding: const EdgeInsets.fromLTRB(8, 3, 10, 3),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(kind.icon, size: 14, color: kind.color),
const SizedBox(width: 6),
Text(
kind.label,
style: TextStyle(color: kind.color, fontSize: 12, fontWeight: FontWeight.w700, letterSpacing: 0.3),
),
const SizedBox(width: 6),
Text('$count', style: TextStyle(color: kind.color.withValues(alpha: 0.7), fontSize: 11.5)),
],
),
),
),
);
}
class _Item extends StatelessWidget {
final ChangeKind kind;
final String text;
const _Item({required this.kind, required this.text});
@override
Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(top: 2, left: 2),
child: DecoratedBox(
decoration: BoxDecoration(color: kind.color.withValues(alpha: 0.16), shape: BoxShape.circle),
child: SizedBox.square(
dimension: 16,
child: Icon(
kind.bullet,
size: 12,
color: kind.color,
),
),
),
),
const SizedBox(width: 10),
Expanded(child: Text(text, style: const TextStyle(fontSize: 13.5, height: 1.4))),
],
),
);
}
/// The band's line: the first items with their kind's icon, in [color].
class WhatsNewPreview extends StatelessWidget {
final ReleaseChanges changes;
final Color color;
final String? lead;
const WhatsNewPreview({super.key, required this.changes, required this.color, this.lead});
@override
Widget build(BuildContext context) {
final top = topChanges(changes, 3);
final style = TextStyle(color: color.withValues(alpha: 0.85), fontSize: 12);
return Text.rich(
key: const ValueKey('whats-new-preview'),
TextSpan(
children: [
if (lead != null) TextSpan(text: '$lead · '),
for (final (i, (k, item)) in top.indexed) ...[
if (i > 0) const TextSpan(text: ' '),
WidgetSpan(
alignment: PlaceholderAlignment.middle,
child: Padding(
padding: const EdgeInsets.only(right: 4),
child: Icon(k.icon, size: 13, color: color.withValues(alpha: 0.85)),
),
),
TextSpan(text: item),
],
],
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: style,
);
}
}
+56 -2
View File
@@ -7,6 +7,9 @@
#ifdef GDK_WINDOWING_X11 #ifdef GDK_WINDOWING_X11
#include <gdk/gdkx.h> #include <gdk/gdkx.h>
#endif #endif
#ifdef GDK_WINDOWING_WAYLAND
#include <gdk/gdkwayland.h>
#endif
#include "flutter/generated_plugin_registrant.h" #include "flutter/generated_plugin_registrant.h"
@@ -24,17 +27,63 @@ static void first_frame_cb(MyApplication* self, FlView* view) {
gtk_widget_show(gtk_widget_get_toplevel(GTK_WIDGET(view))); gtk_widget_show(gtk_widget_get_toplevel(GTK_WIDGET(view)));
} }
// The desktop's activation token this process was started with
// (XDG_ACTIVATION_TOKEN on Wayland, DESKTOP_STARTUP_ID on X11), read
// before GTK starts and consumes it. A second start hands it to the
// running app (lib/src/platform/single_instance.dart), which brings its
// window up with it: compositors only let a window take the focus with
// a fresh token.
static gchar* launch_token = nullptr;
// Brings the window up with an activation token from another process.
static void present_with_token(GtkWindow* window, const gchar* token) {
if (token != nullptr && token[0] != '\0') {
GdkDisplay* display = gtk_widget_get_display(GTK_WIDGET(window));
#ifdef GDK_WINDOWING_WAYLAND
if (GDK_IS_WAYLAND_DISPLAY(display)) {
gdk_wayland_display_set_startup_notification_id(display, token);
}
#endif
#ifdef GDK_WINDOWING_X11
if (GDK_IS_X11_DISPLAY(display)) {
gtk_window_set_startup_id(window, token);
}
#endif
}
gtk_window_present(window);
}
// "starsync/window": `attention` sets the urgency hint while the window is // "starsync/window": `attention` sets the urgency hint while the window is
// not focused; focusing it clears the hint. // not focused (focusing it clears the hint); `launchToken` is this
// process's activation token ('' when none); `present` brings the window
// up with a token (argument, may be empty); `exit` ends a second start
// that handed over (no exit handlers, see main.cc).
static void window_method_cb(FlMethodChannel* channel, FlMethodCall* call, static void window_method_cb(FlMethodChannel* channel, FlMethodCall* call,
gpointer user_data) { gpointer user_data) {
GtkWindow* window = GTK_WINDOW(user_data); GtkWindow* window = GTK_WINDOW(user_data);
g_autoptr(FlMethodResponse) response = nullptr; g_autoptr(FlMethodResponse) response = nullptr;
if (g_strcmp0(fl_method_call_get_name(call), "attention") == 0) { const gchar* method = fl_method_call_get_name(call);
if (g_strcmp0(method, "attention") == 0) {
if (!gtk_window_is_active(window)) { if (!gtk_window_is_active(window)) {
gtk_window_set_urgency_hint(window, TRUE); gtk_window_set_urgency_hint(window, TRUE);
} }
response = FL_METHOD_RESPONSE(fl_method_success_response_new(nullptr)); response = FL_METHOD_RESPONSE(fl_method_success_response_new(nullptr));
} else if (g_strcmp0(method, "launchToken") == 0) {
g_autoptr(FlValue) value =
fl_value_new_string(launch_token != nullptr ? launch_token : "");
response = FL_METHOD_RESPONSE(fl_method_success_response_new(value));
} else if (g_strcmp0(method, "present") == 0) {
FlValue* args = fl_method_call_get_args(call);
const gchar* token = nullptr;
if (args != nullptr && fl_value_get_type(args) == FL_VALUE_TYPE_STRING) {
token = fl_value_get_string(args);
}
present_with_token(window, token);
response = FL_METHOD_RESPONSE(fl_method_success_response_new(nullptr));
} else if (g_strcmp0(method, "exit") == 0) {
fflush(stdout);
fflush(stderr);
_exit(0);
} else { } else {
response = FL_METHOD_RESPONSE(fl_method_not_implemented_response_new()); response = FL_METHOD_RESPONSE(fl_method_not_implemented_response_new());
} }
@@ -219,6 +268,11 @@ MyApplication* my_application_new() {
// the application to be recognized beyond its binary name. // the application to be recognized beyond its binary name.
g_set_prgname(APPLICATION_ID); g_set_prgname(APPLICATION_ID);
// Before GTK starts (it takes these out of the environment).
const gchar* token = g_getenv("XDG_ACTIVATION_TOKEN");
if (token == nullptr || token[0] == '\0') token = g_getenv("DESKTOP_STARTUP_ID");
launch_token = g_strdup(token);
return MY_APPLICATION(g_object_new(my_application_get_type(), return MY_APPLICATION(g_object_new(my_application_get_type(),
"application-id", APPLICATION_ID, "flags", "application-id", APPLICATION_ID, "flags",
G_APPLICATION_NON_UNIQUE, nullptr)); G_APPLICATION_NON_UNIQUE, nullptr));
+2 -2
View File
@@ -865,14 +865,14 @@ packages:
path: "../../packages/core" path: "../../packages/core"
relative: true relative: true
source: path source: path
version: "1.2.0" version: "1.3.0"
sync_relay_server: sync_relay_server:
dependency: "direct main" dependency: "direct main"
description: description:
path: "../../packages/server" path: "../../packages/server"
relative: true relative: true
source: path source: path
version: "1.2.0" version: "1.3.0"
synchronized: synchronized:
dependency: transitive dependency: transitive
description: description:
+1 -1
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.4.0
environment: environment:
sdk: '>=3.13.0-0 <4.0.0' sdk: '>=3.13.0-0 <4.0.0'
+115
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';
@@ -362,6 +363,120 @@ void main() {
} }
}); });
test('a restarted server comes back empty: what played is offered back where it was, nothing reopens, '
'the watch goes on, Up next comes back', timeout: const Timeout(Duration(minutes: 2)), () async {
final first = SyncServer(const AppConfig(port: 0, secret: 'pw', name: 'restart'));
await first.start();
final port = first.boundPort;
final origin = await _origin(await _media());
SyncServer? second;
try {
final alice = await member(port, 'alice');
final bob = await member(port, 'bob');
await playTogether(alice, [alice, bob], 'http://127.0.0.1:${origin.port}/ep.mkv');
expect(await bob.queue.add(LinkMedia('http://127.0.0.1:${origin.port}/next.mkv', anyway: true)), isNull);
await _poll(() => alice.queue.items.peek().length == 1, 'Up next has an item');
await Future.delayed(const Duration(seconds: 4));
final opens = [alice.opens, bob.opens];
final watch = alice.watch.session;
final watched = watch!.timeIn;
expect(watched, greaterThan(2));
await first.stop();
await _poll(() => alice.reconnecting.peek() && bob.reconnecting.peek(), 'both reconnecting');
second = SyncServer(AppConfig(port: port, secret: 'pw', name: 'restart'));
await second.start();
final engine = second.hub.engine;
await _poll(() => engine.media?.name == 'ep.mkv', 'the media offered back', timeout: const Duration(seconds: 30));
expect(engine.mediaId, 2, reason: 'one offer taken: the server started at 1');
expect(engine.positionAt(), greaterThan(4), reason: 'where it was, not the start');
await _poll(() => [alice, bob].every((x) => x.playback.player.state.playing && x.serverState.peek()?.transport == 'playing'),
'playing again', timeout: const Duration(seconds: 20));
expect([alice.opens, bob.opens], opens, reason: 'the players kept it: nothing reopened');
await _poll(() => engine.queue.items.length == 1, 'Up next back');
expect(engine.queue.items.single.media.url, endsWith('/next.mkv'));
// The watch went on through the restart (one watch, still counting).
await Future.delayed(const Duration(seconds: 2));
expect(identical(alice.watch.session, watch), isTrue);
expect(alice.watch.mediaId, engine.mediaId);
expect(watch.timeIn, greaterThan(watched + 1));
} finally {
await second?.stop();
await origin.close(force: true);
}
});
test('a restarted server comes back empty: the member passing its file on puts it back and feeds it again',
timeout: const Timeout(Duration(minutes: 2)), () async {
final first = SyncServer(const AppConfig(port: 0, secret: 'pw', name: 'restart'));
await first.start();
final port = first.boundPort;
final file = await _media();
SyncServer? second;
try {
final alice = await member(port, 'alice');
final bob = await member(port, 'bob');
expect(await alice.swapPicked(FileMedia(file)), isNull);
await _poll(() => [alice, bob].every((x) => x.playback.durationSecs > 100), 'both opened it',
timeout: const Duration(seconds: 30));
for (final x in [alice, bob]) {
x.toggleReady();
}
await _poll(() => [alice, bob].every((x) => x.playback.player.state.playing), 'both playing');
await Future.delayed(const Duration(seconds: 3));
final aliceOpens = alice.opens;
await first.stop();
await _poll(() => alice.reconnecting.peek() && bob.reconnecting.peek(), 'both reconnecting');
second = SyncServer(AppConfig(port: port, secret: 'pw', name: 'restart'));
await second.start();
final hub = second.hub;
await _poll(() => hub.engine.media?.name == 'coord_presence_120s.mkv', 'the file offered back',
timeout: const Duration(seconds: 30));
expect(hub.engine.positionAt(), greaterThan(3));
await _poll(() => hub.relay?.sourceToken == alice.connection.token && alice.relaying.peek(), 'alice feeds it again');
await _poll(() => [alice, bob].every((x) => x.playback.player.state.playing && x.serverState.peek()?.transport == 'playing'),
'playing again', timeout: const Duration(seconds: 30));
expect(alice.opens, aliceOpens, reason: 'her own file stayed open');
expect(hub.relay!.buffers.single.bytesToViewers, greaterThan(0), reason: 'bob reads the new relay');
} finally {
await second?.stop();
}
});
test('moving to another server never shows the last one\'s media', timeout: const Timeout(Duration(minutes: 1)),
() async {
final home = SyncServer(const AppConfig(port: 0, secret: 'pw', name: 'home'));
final empty = SyncServer(const AppConfig(port: 0, secret: 'pw', name: 'empty'));
await home.start();
await empty.start();
final origin = await _origin(await _media());
try {
final alice = await member(home.boundPort, 'alice');
expect(await alice.swapPicked(LinkMedia('http://127.0.0.1:${origin.port}/ep.mkv', anyway: true)), isNull);
await _poll(() => alice.serverState.peek()?.media != null, 'media up at home');
// The shell opens the player on any state with media as soon as the
// app is in a server again: nothing from the last one may be left
// while the new one is dialled.
final seen = <String>[];
final stop = effect(() {
if (alice.connection.state.value is ConnConnecting) {
seen.add(alice.serverState.peek()?.media?['name'] as String? ?? '');
}
});
await alice.connect(ConnectParams(host: '127.0.0.1', port: empty.boundPort, secret: 'pw', name: 'alice'));
await _poll(() => alice.serverState.peek() != null, 'the new server\'s state');
stop();
expect(seen, isNotEmpty);
expect(seen.where((n) => n.isNotEmpty), isEmpty, reason: 'no media from the last server');
expect(alice.serverState.peek()?.media, isNull);
} finally {
await home.stop();
await empty.stop();
await origin.close(force: true);
}
});
test('cancel during connect setup aborts without joining', timeout: const Timeout(Duration(minutes: 1)), () async { test('cancel during connect setup aborts without joining', timeout: const Timeout(Duration(minutes: 1)), () async {
final server = SyncServer(const AppConfig(port: 0, secret: '', name: 'cancel')); final server = SyncServer(const AppConfig(port: 0, secret: '', name: 'cancel'));
await server.start(); await server.start();
+261
View File
@@ -0,0 +1,261 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:starsync/src/danmaku/danmaku.dart';
import 'package:starsync/src/danmaku/danmaku_notices.dart';
import 'package:starsync/src/session/notifier.dart' show NoticeCategory, ToastItem;
import 'package:starsync/src/shell/avatar.dart' show AvatarInfo;
// Danmaku lanes: free lanes only, no two lines in a lane ever touch, the
// density cap with a queue in arrival order, and the layer over a real
// widget clock.
const w = 1000.0;
/// Lines in one lane never overlap (gap kept) at any sampled time.
void expectNoOverlap(DanmakuLanes<int> lanes, double t, {double gap = 0}) {
final byLane = <int, List<DanmakuFlight<int>>>{};
for (final f in lanes.flights) {
byLane.putIfAbsent(f.lane, () => []).add(f);
}
for (final fs in byLane.values) {
fs.sort((a, b) => a.start.compareTo(b.start));
for (var i = 1; i < fs.length; i++) {
final ahead = fs[i - 1], behind = fs[i];
final tail = ahead.x(t, w) + ahead.width;
// Only while both are on screen.
if (tail <= 0 || behind.x(t, w) >= w) continue;
expect(behind.x(t, w), greaterThanOrEqualTo(tail + gap - 1e-6),
reason: 'lane ${behind.lane} at t=$t: #${behind.item} reaches #${ahead.item}');
}
}
}
List<DanmakuFlight<int>> tick(DanmakuLanes<int> lanes, double t, Map<int, double> widths,
{int lanesN = 5, int cap = 100, double cross = 8}) =>
lanes.tick(t, w: w, lanes: lanesN, cap: cap, crossSecs: cross, measure: (i) => widths[i]!);
void main() {
group('DanmakuLanes', () {
test('lines at the same moment take the lanes top down', () {
final lanes = DanmakuLanes<int>();
final widths = {for (var i = 0; i < 3; i++) i: 200.0};
for (var i = 0; i < 3; i++) {
lanes.add(i, 0);
}
final placed = tick(lanes, 0, widths);
expect([for (final f in placed) f.lane], [0, 1, 2]);
});
test('a lane is reused once the line in it has fully entered', () {
final lanes = DanmakuLanes<int>(gap: 20);
final widths = {0: 200.0, 1: 200.0};
lanes.add(0, 0);
tick(lanes, 0, widths, lanesN: 1);
lanes.add(1, 0.1);
// 200 px + gap at 150 px/s: not yet in.
expect(tick(lanes, 0.1, widths, lanesN: 1), isEmpty);
expect(lanes.waiting, 1);
// Same length, same speed: it may follow once 220 px have entered.
final placed = tick(lanes, 1.6, widths, lanesN: 1);
expect(placed.single.lane, 0);
});
test('a long (faster) line never catches a short one ahead in its lane', () {
final lanes = DanmakuLanes<int>(gap: 20);
final widths = {0: 60.0, 1: 900.0};
lanes.add(0, 0);
tick(lanes, 0, widths, lanesN: 1);
lanes.add(1, 0.5);
// The short one is in, but the long one would run into it: wait.
for (var t = 0.5; t < 8; t += 0.1) {
final placed = tick(lanes, t, widths, lanesN: 1);
if (placed.isNotEmpty) {
// Placed: from then on they never touch.
for (var s = t; s < t + 8; s += 0.05) {
expectNoOverlap(lanes, s, gap: 20);
}
return;
}
}
fail('the long line never got the lane');
});
test('a random stream of mixed lengths never overlaps', () {
final rng = math.Random(3);
final lanes = DanmakuLanes<int>(gap: 24, maxWaitSecs: 1000, maxWaiting: 10000);
final widths = <int, double>{};
var n = 0;
for (var t = 0.0; t < 120; t += 0.05) {
if (rng.nextDouble() < 0.15) {
widths[n] = 20 + rng.nextDouble() * 900;
lanes.add(n++, t);
}
tick(lanes, t, widths, lanesN: 6, cap: 14, cross: 6 + rng.nextDouble() * 0.0);
expectNoOverlap(lanes, t, gap: 24);
expect(lanes.flights.length, lessThanOrEqualTo(14));
}
expect(n, greaterThan(300));
});
test('the density cap holds the rest in arrival order', () {
final lanes = DanmakuLanes<int>();
final widths = {for (var i = 0; i < 10; i++) i: 100.0};
for (var i = 0; i < 10; i++) {
lanes.add(i, 0);
}
final first = tick(lanes, 0, widths, lanesN: 20, cap: 4);
expect([for (final f in first) f.item], [0, 1, 2, 3]);
expect(lanes.waiting, 6);
// Nothing leaves before the crossing time: still capped.
expect(tick(lanes, 7.9, widths, lanesN: 20, cap: 4), isEmpty);
final next = tick(lanes, 8, widths, lanesN: 20, cap: 4);
expect([for (final f in next) f.item], [4, 5, 6, 7]);
});
test('a line that waited too long is dropped; the queue is bounded', () {
final lanes = DanmakuLanes<int>(maxWaitSecs: 5, maxWaiting: 3);
final widths = {for (var i = 0; i < 6; i++) i: 100.0};
for (var i = 0; i < 5; i++) {
lanes.add(i, 0);
}
expect(lanes.waiting, 3);
expect(lanes.dropped, 2);
tick(lanes, 6, widths, cap: 0);
expect(lanes.waiting, 0);
expect(lanes.dropped, 5);
});
});
group('DanmakuStyle', () {
test('lanes fill the band at the text size', () {
const s = DanmakuStyle(bandTop: 0.1, bandBottom: 0.45);
// 720 px: 30.24 px text, 45.36 px lanes in a 252 px band.
expect(s.lanesFor(720), 5);
expect(const DanmakuStyle(bandTop: 0.4, bandBottom: 0.41).lanesFor(720), 1);
});
});
testWidgets('the layer flies lines under the cap and lets the rest wait', (tester) async {
final c = DanmakuController();
addTearDown(c.dispose);
await tester.pumpWidget(Directionality(
textDirection: TextDirection.ltr,
child: SizedBox(
width: 800,
height: 450,
child: DanmakuLayer(
controller: c,
accent: Colors.purple,
style: const DanmakuStyle(maxOnScreen: 3, crossSecs: 4),
),
),
));
for (var i = 0; i < 7; i++) {
c.add(DanmakuItem(
kind: DanmakuKind.chat,
sender: 'Bob',
text: 'line $i',
own: i == 0,
avatar: i.isEven ? const AvatarInfo(name: 'Bob', color: Colors.teal) : null));
}
await tester.pump(const Duration(milliseconds: 16));
await tester.pump(const Duration(milliseconds: 16));
expect(c.onScreen.value, 3);
expect(c.waiting.value, 4);
// After a crossing the next ones fly.
await tester.pump(const Duration(seconds: 4));
await tester.pump(const Duration(milliseconds: 16));
expect(c.onScreen.value, 3);
expect(c.waiting.value, 1);
c.clear();
await tester.pump(const Duration(milliseconds: 16));
expect(c.onScreen.value, 0);
expect(c.waiting.value, 0);
await tester.pumpWidget(const SizedBox());
});
group('DanmakuStyle as a setting', () {
test('round-trips through its map', () {
const s = DanmakuStyle(
crossSecs: 6, maxOnScreen: 5, bandTop: 0.2, bandBottom: 0.5, opacity: 0.7, size: 1.3, weight: 800,
outline: 2, highlightOwn: false, avatars: false);
expect(DanmakuStyle.fromJson(s.toJson()), s);
});
test('junk and out-of-range values: defaults and the nearest ends', () {
expect(DanmakuStyle.fromJson(const {}), const DanmakuStyle());
final s = DanmakuStyle.fromJson(const {
'crossSecs': 99,
'maxOnScreen': 0,
'opacity': 'x',
'weight': 1234,
'bandTop': 0.6,
'bandBottom': 0.61,
'avatars': 1,
});
expect(s.crossSecs, 16);
expect(s.maxOnScreen, 1);
expect(s.opacity, const DanmakuStyle().opacity);
expect(s.weight, 900);
expect((s.bandTop, s.bandBottom), (0.10, 0.45), reason: 'a band too thin takes the default');
expect(s.avatars, isTrue);
});
test('boldness: the text at its weight, the name a step bolder', () {
expect(const DanmakuStyle(weight: 400).fontWeight, FontWeight.w400);
expect(const DanmakuStyle(weight: 400).nameWeight, FontWeight.w500);
expect(const DanmakuStyle(weight: 900).nameWeight, FontWeight.w900);
});
});
group('notifications as danmaku', () {
ToastItem toast(String text,
{String? actor, NoticeCategory category = NoticeCategory.app, bool chat = false, bool own = false}) =>
ToastItem(id: 1, text: text, at: DateTime(2026), actor: actor, chat: chat, own: own, category: category);
test('chat and short notices fly; problems, nudges and the app stay toasts', () {
expect(fliesAsDanmaku(toast('Bob: hi', actor: 'Bob', chat: true, category: NoticeCategory.chat)), isTrue);
expect(fliesAsDanmaku(toast('Bob joined', actor: 'Bob', category: NoticeCategory.people)), isTrue);
expect(fliesAsDanmaku(toast('Starting now', category: NoticeCategory.start)), isTrue);
expect(fliesAsDanmaku(toast('Bob added X to Up next', actor: 'Bob', category: NoticeCategory.upNext)), isTrue);
expect(fliesAsDanmaku(toast('Skipped X: gone', category: NoticeCategory.upNext)), isFalse);
expect(fliesAsDanmaku(toast("Bob can't play this", actor: 'Bob', category: NoticeCategory.problem)), isFalse);
expect(fliesAsDanmaku(toast('Bob nudged you', actor: 'Bob', category: NoticeCategory.nudge)), isFalse);
expect(fliesAsDanmaku(toast('Link copied')), isFalse);
expect(fliesAsDanmaku(toast('The server restarted', category: NoticeCategory.self)), isFalse);
});
test('lines: the sender before the message, own chat framed', () {
final chat = danmakuForToast(toast('Me: hello', actor: 'Me', chat: true, own: true, category: NoticeCategory.chat))!;
expect((chat.kind, chat.sender, chat.text, chat.own), (DanmakuKind.chat, 'Me', 'hello', true));
final left = danmakuForToast(toast('Bob lost connection', actor: 'Bob', category: NoticeCategory.people))!;
expect((left.kind, left.text), (DanmakuKind.leave, 'lost connection'));
final start = danmakuForToast(toast('Starting now: Frieren', category: NoticeCategory.start))!;
expect((start.kind, start.sender), (DanmakuKind.starting, null));
expect(danmakuForToast(toast('Copied')), isNull);
});
});
testWidgets('an idle layer asks for no frames', (tester) async {
final c = DanmakuController();
addTearDown(c.dispose);
await tester.pumpWidget(Directionality(
textDirection: TextDirection.ltr,
child: SizedBox(width: 800, height: 450, child: DanmakuLayer(controller: c, accent: Colors.purple)),
));
await tester.pump(const Duration(milliseconds: 16));
expect(tester.binding.hasScheduledFrame, isFalse);
c.add(const DanmakuItem(kind: DanmakuKind.chat, sender: 'Bob', text: 'hi'));
await tester.pump(const Duration(milliseconds: 16));
expect(c.onScreen.value, 1);
expect(tester.binding.hasScheduledFrame, isTrue);
await tester.pump(const Duration(seconds: 9));
await tester.pump(const Duration(milliseconds: 16));
expect(c.onScreen.value, 0);
expect(tester.binding.hasScheduledFrame, isFalse);
await tester.pumpWidget(const SizedBox());
});
}
+11 -2
View File
@@ -19,16 +19,25 @@ void main() {
..createSync(recursive: true); ..createSync(recursive: true);
final otherIcon = File('$share/icons/hicolor/48x48/apps/other.png')..createSync(); final otherIcon = File('$share/icons/hicolor/48x48/apps/other.png')..createSync();
await installDesktopEntry(executable: '$root/starsync', dataHome: share, force: true); final registered = <String>[];
await installDesktopEntry(
executable: '$root/starsync',
dataHome: share,
force: true,
registerScheme: (file, dir) async => registered.add('$file in $dir'));
expect(oldEntry.existsSync(), isFalse); expect(oldEntry.existsSync(), isFalse);
expect(oldIcon.existsSync(), isFalse); expect(oldIcon.existsSync(), isFalse);
expect(other.existsSync(), isTrue); expect(other.existsSync(), isTrue);
expect(otherIcon.existsSync(), isTrue); expect(otherIcon.existsSync(), isTrue);
final entry = File('${apps.path}/dev.foxyz.starsync.desktop').readAsStringSync(); final entry = File('${apps.path}/dev.foxyz.starsync.desktop').readAsStringSync();
expect(entry, contains('Exec="$root/starsync"'));
expect(entry, contains('StartupWMClass=dev.foxyz.starsync')); expect(entry, contains('StartupWMClass=dev.foxyz.starsync'));
expect(entry, contains('Icon=dev.foxyz.starsync')); expect(entry, contains('Icon=dev.foxyz.starsync'));
// starsync:// links: the entry takes them and becomes their default.
expect(entry, contains('Exec="$root/starsync" %u'));
expect(entry, contains('MimeType=x-scheme-handler/starsync;'));
expect(entry, contains('StartupNotify=true'));
expect(registered, ['dev.foxyz.starsync.desktop in ${apps.path}']);
expect(File('$share/icons/hicolor/48x48/apps/dev.foxyz.starsync.png').existsSync(), isTrue); expect(File('$share/icons/hicolor/48x48/apps/dev.foxyz.starsync.png').existsSync(), isTrue);
} finally { } finally {
await tmp.delete(recursive: true); await tmp.delete(recursive: true);
+301
View File
@@ -0,0 +1,301 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:sync_relay_core/core.dart';
import 'package:starsync/src/platform/single_instance.dart';
import 'package:starsync/src/platform/url_scheme.dart';
import 'package:starsync/src/session/invite.dart';
import 'package:starsync/src/session/join_offers.dart';
import 'package:starsync/src/session/navigation.dart' show Place;
import 'package:starsync/src/session/public_address.dart';
import 'package:starsync/src/shell/party_strip.dart' show friendInviteLink;
import 'package:starsync/src/shell/join_offer_card.dart';
import 'package:starsync/src/state/connect_prefs.dart' show RecentServer;
import 'test_graph.dart';
// starsync:// invite links, the internet address lookup, the join
// prompt (links and the clipboard), one app per profile and the link
// handler's registration values.
String health({String name = "Bob's server", String version = '1.3.2', int viewers = 2}) => jsonEncode({
'ok': true,
'name': name,
'proto': RelayConstants.protoMax,
'protoMin': RelayConstants.protoMin,
'protoMax': RelayConstants.protoMax,
'version': version,
'viewers': viewers,
});
final _graph = testGraph(healthGet: (uri) async => health());
void main() {
test('a friend\'s server: the copied link carries the password you joined with', () {
final link = friendInviteLink('example.org:6969', "Bob's server", secret: 'hunter2');
final back = InviteLink.parse(link)!;
expect([back.host, back.port, back.secret], ['example.org', 6969, 'hunter2']);
expect(friendInviteLink('example.org:6969', 'Bob'), isNot(contains('pw=')));
});
group('invite link', () {
test('round trip: address, name and password, which never shows in toString', () {
const link = InviteLink(host: '203.0.113.7', port: 9000, name: "Bob's server & co", secret: 'p@ss w&rd=1');
expect(link.uri, startsWith('starsync://203.0.113.7:9000/?name='));
expect(InviteLink.parse(link.uri), link);
expect(link.toString(), isNot(contains('p@ss')));
expect(link.toString(), contains('password'));
expect(redactLog('opening ${link.uri}'), isNot(contains('w%26rd')));
});
test('bare links, IPv6, the default port; anything else is no link', () {
expect(InviteLink.parse('starsync://192.168.1.20:8080'), const InviteLink(host: '192.168.1.20', port: 8080));
expect(const InviteLink(host: '192.168.1.20', port: 8080).uri, 'starsync://192.168.1.20:8080');
expect(InviteLink.parse('STARSYNC://vps.example.com'), const InviteLink(host: 'vps.example.com', port: 8080));
const v6 = InviteLink(host: 'fe80::1', port: 8080);
expect(v6.uri, 'starsync://[fe80::1]:8080');
expect(InviteLink.parse(v6.uri)?.port, 8080);
expect(InviteLink.parse('starsync://host:99999'), isNull);
expect(InviteLink.parse('starsync://'), isNull);
expect(InviteLink.parse('https://192.168.1.20:8080'), isNull);
expect(InviteLink.parse('starsync://a b:1'), isNull);
});
test('the invite is just its link, with the password when one is set', () {
const invite = Invite(host: '192.168.1.20', port: 8080, name: "Bob's server", password: true, version: '1.3.2');
final link = invite.link(secret: 'hunter2');
expect(link.uri, 'starsync://192.168.1.20:8080/?name=Bob%27s+server&pw=hunter2');
expect(inviteIn(link.uri), link);
expect(link.toString(), isNot(contains('hunter2')));
expect(const Invite(host: '192.168.1.20', port: 8080).link().uri, 'starsync://192.168.1.20:8080');
// A 1.3.x app pasting it into the Join card's address.
expect(addressIn(link.uri), (host: '192.168.1.20', port: '8080'));
expect(invite.at('203.0.113.7').address, '203.0.113.7:8080');
});
test('found in a text: a link anywhere, an old plain invite, never a bare address', () {
expect(inviteIn('hey, come watch: starsync://10.0.0.5:8081/?name=Ann see you')?.address, '10.0.0.5:8081');
expect(inviteIn('Join me on Starsync: 192.168.1.20:8080 (ask me for the password) · Starsync 1.3.0'),
const InviteLink(host: '192.168.1.20', port: 8080));
expect(inviteIn('192.168.1.20:8080'), isNull);
expect(inviteIn('see https://example.com:443/x'), isNull);
expect(inviteIn('${'x' * 5000} starsync://1.2.3.4:1'), isNull, reason: 'only the start of a long text');
});
test('reach: the internet address first; forwarding when it is the router\'s', () {
const behindRouter = InviteReach(public: '203.0.113.7', local: ['192.168.1.20', '100.64.1.2']);
expect(behindRouter.best, '203.0.113.7');
expect(behindRouter.forwarding, isTrue);
expect([for (final c in behindRouter.choices) c.kind], [AddressKind.public, AddressKind.lan, AddressKind.vpn]);
const direct = InviteReach(public: '203.0.113.7', local: ['203.0.113.7']);
expect(direct.forwarding, isFalse);
expect(direct.choices.length, 1);
expect(const InviteReach(local: ['192.168.1.20']).best, '192.168.1.20');
expect(isPublicIpv4('8.8.8.8'), isTrue);
for (final ip in ['10.1.2.3', '192.168.0.1', '172.20.0.1', '100.100.1.1', '127.0.0.1', '198.18.0.1', '::1', 'x']) {
expect(isPublicIpv4(ip), isFalse, reason: ip);
}
});
});
group('internet address', () {
final a = Uri.parse('https://a.test'), b = Uri.parse('https://b.test'), c = Uri.parse('https://c.test');
test('the first service with a public address wins; failing, private or odd answers are skipped', () async {
final asked = <String>[];
final ip = await lookUpPublicAddress(
services: [a, b, c],
get: (uri) async {
asked.add(uri.host);
return switch (uri.host) {
'a.test' => throw const SocketException('blocked'),
'b.test' => '192.168.1.1\n',
_ => '203.0.113.7\n',
};
},
);
expect(ip, '203.0.113.7');
expect(asked, ['a.test', 'b.test', 'c.test']);
});
test('a slow service is given up on; none answering is no address', () async {
final ip = await lookUpPublicAddress(
services: [a, b],
each: const Duration(milliseconds: 50),
get: (uri) => uri.host == 'a.test' ? Completer<String>().future : Future.value('203.0.113.9'),
);
expect(ip, '203.0.113.9');
expect(await lookUpPublicAddress(services: [a], get: (_) async => '<html>nope</html>'), isNull);
});
});
group('join prompt', () {
final offers = _graph.joinOffers;
final yours = _graph.yourServer;
tearDown(() {
offers.offer.value = null;
yours.shared.value = null;
yours.reach.value = null;
_graph.forms.recents.value = const [];
});
test('a clicked link becomes an offer with what /health says', () async {
offers.fromArgs(['--whatever', 'starsync://203.0.113.7:8080/?name=Bob']);
expect(offers.offer.peek()?.source, OfferSource.link);
expect(offers.offer.peek()?.question, 'Join Bob (203.0.113.7:8080)?');
await pumpEventQueue();
final o = offers.offer.peek()!;
expect(o.question, "Join Bob's server (203.0.113.7:8080)?");
expect(o.detail, 'Starsync 1.3.2 · 2 watching');
});
test('the clipboard: offered once; Not now and Join both stop it coming back', () async {
offers.fromClipboard('come: starsync://203.0.113.20:8080');
expect(offers.offer.peek()?.source, OfferSource.clipboard);
await offers.dismiss();
expect(offers.offer.peek(), isNull);
offers.fromClipboard('something else');
offers.fromClipboard('come: starsync://203.0.113.20:8080');
expect(offers.offer.peek(), isNull, reason: 'dismissed');
offers.fromClipboard('starsync://203.0.113.21:8080');
_graph.forms.recents.value = const [RecentServer(host: '203.0.113.21', port: '8080', secret: 'saved')];
final link = offers.accept();
expect(link?.secret, 'saved', reason: 'a link without its password takes the saved one');
offers.fromClipboard('x');
offers.fromClipboard('starsync://203.0.113.21:8080');
expect(offers.offer.peek(), isNull, reason: 'joined');
});
test('never your own invite, nor the server you are in, nor plain text', () {
yours.port.value = 40000;
addTearDown(() => yours.port.value = null);
yours.shared.value = const Invite(host: '203.0.113.7', port: 8080);
yours.reach.value = const InviteReach(public: '203.0.113.7', local: ['192.168.1.20']);
offers.fromClipboard('starsync://192.168.1.20:8080');
offers.fromClipboard('starsync://127.0.0.1:40000');
expect(offers.offer.peek(), isNull);
// What this app just copied is not offered back to it.
const copied = 'starsync://198.51.100.4:8080';
offers.copied(copied);
offers.fromClipboard(copied);
expect(offers.offer.peek(), isNull);
offers.fromClipboard('just some text 10.0.0.1:80');
expect(offers.offer.peek(), isNull);
final nav = _graph.navigation;
nav.place.value = Place.friend;
nav.address.value = '198.51.100.9:8080';
addTearDown(() {
nav.place.value = Place.none;
nav.address.value = '';
});
offers.fromArgs(['starsync://198.51.100.9:8080']);
expect(offers.offer.peek(), isNull);
});
testWidgets('the card: the question, Join and Not now', (tester) async {
var joined = 0;
await tester.pumpWidget(scoped(
_graph,
MaterialApp(
theme: ThemeData.dark(),
home: Scaffold(body: JoinOfferCard(offers: offers, onJoin: () => joined++)))));
expect(find.byKey(const ValueKey('join-offer')), findsNothing);
offers.offer.value = const JoinOffer(InviteLink(host: '203.0.113.7', port: 8080, name: 'Ann'), OfferSource.clipboard);
await tester.pumpAndSettle();
expect(find.text('Join Ann (203.0.113.7:8080)?'), findsOneWidget);
expect(find.text('Invite on your clipboard · Checking the server…'), findsOneWidget);
await tester.tap(find.byKey(const ValueKey('join-offer-join')));
expect(joined, 1);
await tester.tap(find.byKey(const ValueKey('join-offer-dismiss')));
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('join-offer')), findsNothing);
});
});
group('one app per profile', () {
late Directory tmp;
setUp(() async => tmp = await Directory.systemTemp.createTemp('starsync-instance'));
tearDown(() async => tmp.delete(recursive: true));
test('a second start hands its arguments and token over and ends', () async {
final first = await InstanceLock.claim(tmp.path);
expect(first, isNotNull);
expect(first!.held, isTrue);
final got = first.handoffs.first;
int? allowed;
final second = await InstanceLock.claim(tmp.path,
args: ['starsync://1.2.3.4:8080/?pw=x'], activation: 'tok', beforeSend: (p) => allowed = p);
expect(second, isNull, reason: 'handed over');
expect(allowed, pid);
final h = await got;
expect(h.args, ['starsync://1.2.3.4:8080/?pw=x']);
expect(h.activation, 'tok');
await first.release();
});
test('each profile folder is its own instance', () async {
final a = await InstanceLock.claim('${tmp.path}/a');
final b = await InstanceLock.claim('${tmp.path}/b');
expect(a?.held, isTrue);
expect(b?.held, isTrue);
await a!.release();
await b!.release();
});
test('a start while the app closes waits and takes over once it is gone', () async {
final first = (await InstanceLock.claim(tmp.path))!;
first.closing();
final second = InstanceLock.claim(tmp.path, retryEvery: const Duration(milliseconds: 20));
await Future<void>.delayed(const Duration(milliseconds: 150));
await first.release();
final taken = await second;
expect(taken?.held, isTrue);
await taken!.release();
});
test('a wrong token is refused (nothing handed over)', () async {
final first = (await InstanceLock.claim(tmp.path))!;
var handed = false;
first.handoffs.listen((_) => handed = true);
final s = await Socket.connect(InternetAddress.loopbackIPv4, first.port!);
s.writeln(jsonEncode({'token': 'guess', 'args': ['x']}));
final reply = await utf8.decoder.bind(s).join();
expect(reply, contains('"ok":false'));
expect(handed, isFalse);
s.destroy();
await first.release();
});
test('no lock and nobody answering: starts anyway after a while', () async {
final first = (await InstanceLock.claim(tmp.path))!;
first.closing();
final started = await InstanceLock.claim(tmp.path,
retryFor: const Duration(milliseconds: 100),
closingWait: const Duration(milliseconds: 100),
retryEvery: const Duration(milliseconds: 20));
expect(started?.held, isFalse);
await first.release();
});
});
group('link handler', () {
test('Windows: HKCU\\Software\\Classes\\starsync opens starsync.exe with the link', () {
final v = windowsSchemeValues(r'C:\Apps\Starsync\starsync.exe');
expect(v.first, (r'Software\Classes\starsync', '', 'URL:Starsync invite'));
expect(v, contains((r'Software\Classes\starsync', 'URL Protocol', '')));
expect(v.last, (r'Software\Classes\starsync\shell\open\command', '', r'"C:\Apps\Starsync\starsync.exe" "%1"'));
});
test('an old-named copy points links at starsync next to it', () {
expect(schemeExecutable(r'C:\S\watch_together.exe', exists: (_) => true), r'C:\S\starsync.exe');
expect(schemeExecutable(r'C:\S\watch_together.exe', exists: (_) => false), r'C:\S\watch_together.exe');
expect(schemeExecutable('/opt/s/watch_together', exists: (_) => true), '/opt/s/starsync');
expect(schemeExecutable('/opt/s/starsync', exists: (_) => true), '/opt/s/starsync');
});
});
}
+42 -23
View File
@@ -1,5 +1,3 @@
// restructure: rewrite in step 6 (Watch tracking): the progress-reporting
// checks; the guest-through-the-relay flow stays.
import 'dart:async'; import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:io'; import 'dart:io';
@@ -14,6 +12,7 @@ import 'package:starsync/src/jellyfin/track_prefs.dart';
import 'package:starsync/src/playback/media_marks.dart'; import 'package:starsync/src/playback/media_marks.dart';
import 'package:starsync/src/screens/home/picked_media.dart'; import 'package:starsync/src/screens/home/picked_media.dart';
import 'package:starsync/src/session/session_coordinator.dart'; import 'package:starsync/src/session/session_coordinator.dart';
import 'package:starsync/src/state/settings.dart';
// Two account holders and a guest watch Jellyfin episodes together over a // Two account holders and a guest watch Jellyfin episodes together over a
// real sync server, real mpv and a local fake Jellyfin (never a real one): // real sync server, real mpv and a local fake Jellyfin (never a real one):
@@ -220,7 +219,8 @@ String _kind(_Call c) => c.path.split('/').last;
void main() { void main() {
test( test(
'account holders direct-play and report, the guest plays through the relay, all in sync', 'account holders direct-play and report what they watched, the guest plays through the relay, '
'the next episode continues it for everyone',
timeout: const Timeout(Duration(minutes: 4)), timeout: const Timeout(Duration(minutes: 4)),
() async { () async {
MediaKit.ensureInitialized(); MediaKit.ensureInitialized();
@@ -296,6 +296,11 @@ void main() {
expect((p - pos.first).abs(), lessThan(1.0), reason: 'in sync: $pos'); expect((p - pos.first).abs(), lessThan(1.0), reason: 'in sync: $pos');
} }
// Carol sits out for the rest of the episode: she is not tracked as
// having watched it (P7).
c.sitOut();
await _poll(() => c.selfAway.peek(), 'carol away');
// Pause (A unreadies): an immediate Progress with IsPaused. // Pause (A unreadies): an immediate Progress with IsPaused.
a.toggleReady(); a.toggleReady();
await _poll(() => !a.playback.player.state.playing, 'held'); await _poll(() => !a.playback.player.state.playing, 'held');
@@ -304,7 +309,7 @@ void main() {
_kind(s) == 'Progress' && s.body?['IsPaused'] == true), _kind(s) == 'Progress' && s.body?['IsPaused'] == true),
'paused progress'); 'paused progress');
a.toggleReady(); a.toggleReady();
await _poll(() => all.every((x) => x.playback.player.state.playing), 'playing again'); await _poll(() => [a, b].every((x) => x.playback.player.state.playing), 'playing again');
// Account holders see the "Next episode" prompt (this item is // Account holders see the "Next episode" prompt (this item is
// shorter than its 2 min lead); the guest cannot put it up. // shorter than its 2 min lead); the guest cannot put it up.
@@ -319,23 +324,28 @@ void main() {
_kind(s) == 'Progress' && s.body?['AudioStreamIndex'] == 2), _kind(s) == 'Progress' && s.body?['AudioStreamIndex'] == 2),
'track change reported'); 'track change reported');
// Near the end: finished -> Stopped, then one auto-advance swap. // Alice put the episode up and has the next one: it continues it,
a.seekTo(16); // in Up next for everyone.
await _poll(() => b.queue.continues.peek()?.media.jellyfin?.itemId == _ep2, 'the continuation');
final auto = b.queue.continues.peek()!;
expect(auto.by, a.connection.memberId);
// Played to the end: finished -> Stopped, the server's countdown
// on every screen, then one swap.
await _poll(() => a.serverState.peek()?.transport == 'finished', 'finished', await _poll(() => a.serverState.peek()?.transport == 'finished', 'finished',
timeout: const Duration(seconds: 20)); timeout: const Duration(seconds: 40));
final finishedId = a.serverState.peek()!.mediaId; final finishedId = a.serverState.peek()!.mediaId;
await _poll(() => a.autoAdvance.peek() != null && c.autoAdvance.peek() != null, await _poll(() => [a, b].every((x) => (x.queue.countdown.peek()?.secondsLeft ?? 0) > 0),
'both account holders count down'); 'everyone sees the countdown');
expect(b.autoAdvance.peek(), isNull); expect(a.serverState.peek()!.advance!.id, auto.id);
final swappers = [a.autoAdvance.peek()!.swapper, c.autoAdvance.peek()!.swapper]; expect(a.queue.countdown.peek()!.item.id, auto.id);
expect(swappers.where((s) => s), hasLength(1),
reason: 'exactly one designated swapper: the lowest member id (alice, m1)');
expect(a.autoAdvance.peek()!.swapper, isTrue);
await _poll(() => (a.serverState.peek()?.mediaId ?? 0) > finishedId, 'advanced', await _poll(() => (a.serverState.peek()?.mediaId ?? 0) > finishedId, 'advanced',
timeout: const Duration(seconds: 20)); timeout: const Duration(seconds: 25));
await Future.delayed(const Duration(seconds: 3)); await Future.delayed(const Duration(seconds: 3));
expect(a.serverState.peek()!.mediaId, finishedId + 1, reason: 'no double swap'); expect(a.serverState.peek()!.mediaId, finishedId + 1, reason: 'no double swap');
expect(refOfStateMedia(a.serverState.peek()!.media)!.itemId, _ep2); expect(refOfStateMedia(a.serverState.peek()!.media)!.itemId, _ep2);
expect(a.serverState.peek()!.queue, isEmpty, reason: 'the swap took the continuation');
c.joinBack();
await _poll(() => all.every((x) => x.playback.durationSecs > 19 && x.jellyfinPlan.peek() != null), await _poll(() => all.every((x) => x.playback.durationSecs > 19 && x.jellyfinPlan.peek() != null),
'all opened the next episode'); 'all opened the next episode');
expect(b.jellyfinPlan.peek(), isA<RelayPlay>()); expect(b.jellyfinPlan.peek(), isA<RelayPlay>());
@@ -383,13 +393,17 @@ void main() {
expect(ep1.last, 'Stopped'); expect(ep1.last, 'Stopped');
expect(ep1.where((k) => k == 'Stopped'), hasLength(1)); expect(ep1.where((k) => k == 'Stopped'), hasLength(1));
final stopped1 = aCalls.lastWhere((s) => s.body?['ItemId'] == _ep1); final stopped1 = aCalls.lastWhere((s) => s.body?['ItemId'] == _ep1);
// Part of it watched, then a seek to the end: not watched (see // Alice watched it through (time In, see WatchSession): Stopped
// WatchSession). A resume point capped at 85 % of the 20 s, so // without a position, then marked played.
// Jellyfin cannot mark it played on its own. expect(stopped1.body!.containsKey('PositionTicks'), isFalse, reason: 'watched: Jellyfin marks it played');
expect(stopped1.body!['PositionTicks'], lessThan(18 * 10000000), bool markedPlayed(String token) => jelly.calls.any((c) =>
reason: 'part way: resume point under 90 %, so not marked played'); c.method == 'POST' &&
expect(jelly.calls.any((c) => c.method == 'POST' && c.path == '/UserPlayedItems/$_ep1'), isFalse, c.path == '/UserPlayedItems/$_ep1' &&
reason: 'seeking to the end is not watching'); _FakeJellyfin.authFields(c.auth)['Token'] == token);
expect(markedPlayed('ATOKEN'), isTrue);
expect(markedPlayed('CTOKEN'), isFalse, reason: 'carol sat out most of it (P7)');
final carol1 = jelly.sessions(token: 'CTOKEN').lastWhere((s) => s.body?['ItemId'] == _ep1);
expect(_kind(carol1), 'Stopped', reason: 'sitting out stops her reports');
expect(stopped1.body!['PlaySessionId'], 'ps-ua-$_ep1'); expect(stopped1.body!['PlaySessionId'], 'ps-ua-$_ep1');
expect(ep2.first, 'Playing'); expect(ep2.first, 'Playing');
expect(ep2.last, 'Stopped', reason: 'leaving reports Stopped'); expect(ep2.last, 'Stopped', reason: 'leaving reports Stopped');
@@ -442,7 +456,12 @@ void main() {
final accA = await _account(jelly.base, 'ATOKEN', 'ua', 'A-PC'); final accA = await _account(jelly.base, 'ATOKEN', 'ua', 'A-PC');
final accB = JellyfinAccounts(store: MemoryJellyfinStore()); final accB = JellyfinAccounts(store: MemoryJellyfinStore());
await accB.ensureLoaded(validate: false); await accB.ensureLoaded(validate: false);
final a = SessionCoordinator(jellyfin: accA, trackPrefs: TrackPrefs(store: MemoryJellyfinStore())); // No continuation: its warm relay would be read while the test stops
// (a cut relay response fails a test, see DEVELOPMENT "Traps").
final a = SessionCoordinator(
jellyfin: accA,
trackPrefs: TrackPrefs(store: MemoryJellyfinStore()),
settings: AppSettings()..autoAdvance.value = false);
final b = SessionCoordinator(jellyfin: accB, trackPrefs: TrackPrefs(store: MemoryJellyfinStore())); final b = SessionCoordinator(jellyfin: accB, trackPrefs: TrackPrefs(store: MemoryJellyfinStore()));
try { try {
await a.connect(ConnectParams( await a.connect(ConnectParams(
+62 -42
View File
@@ -1,5 +1,3 @@
// restructure: rewrite in step 6 (What's next + Watch tracking): the
// "progress" and "episodes" groups (reporting, auto-advance swapper).
import 'dart:convert'; import 'dart:convert';
import 'package:flutter/gestures.dart'; import 'package:flutter/gestures.dart';
@@ -14,8 +12,9 @@ import 'package:starsync/src/jellyfin/session_media.dart';
import 'package:starsync/src/jellyfin/track_prefs.dart'; import 'package:starsync/src/jellyfin/track_prefs.dart';
import 'package:starsync/src/screens/home/media_picker.dart'; import 'package:starsync/src/screens/home/media_picker.dart';
import 'package:starsync/src/screens/session/jellyfin_widgets.dart'; import 'package:starsync/src/screens/session/jellyfin_widgets.dart';
import 'package:starsync/src/queue/queue_controller.dart' show UpNextCountdown;
import 'package:starsync/src/queue/queue_ui.dart' show UpNextCard;
import 'package:starsync/src/session/delivery.dart'; import 'package:starsync/src/session/delivery.dart';
import 'package:starsync/src/session/session_coordinator.dart' show AutoAdvance;
import 'package:starsync/src/shell/library_tab.dart' show putUpJellyfinMedia; import 'package:starsync/src/shell/library_tab.dart' show putUpJellyfinMedia;
import 'jellyfin_accounts_test.dart'; import 'jellyfin_accounts_test.dart';
@@ -212,9 +211,12 @@ void main() {
}); });
}); });
// The reporter reads the watch the session's tracker feeds (time In,
// D3); here the test feeds it.
group('progress', () { group('progress', () {
late JellyfinAccounts accounts; late JellyfinAccounts accounts;
late DateTime now; late DateTime now;
late WatchSession watch;
late JellyfinProgressReporter reporter; late JellyfinProgressReporter reporter;
final errors = <Object>[]; final errors = <Object>[];
var unauthorized = 0; var unauthorized = 0;
@@ -224,27 +226,35 @@ void main() {
now = DateTime(2026, 1, 1, 20); now = DateTime(2026, 1, 1, 20);
errors.clear(); errors.clear();
unauthorized = 0; unauthorized = 0;
watch = WatchSession();
reporter = JellyfinProgressReporter( reporter = JellyfinProgressReporter(
client: accounts.clientFor(fixtureServerId)!, client: accounts.clientFor(fixtureServerId)!,
itemId: episodeId, itemId: episodeId,
source: fixtureSource(), source: fixtureSource(),
playSessionId: playSessionId, playSessionId: playSessionId,
watch: watch,
clock: () => now, clock: () => now,
onError: errors.add, onError: errors.add,
onUnauthorized: () => unauthorized++, onUnauthorized: () => unauthorized++,
); );
}); });
/// One second of the session at wall second [secs]: the watch gets its
/// sample (In unless [away]), the reporter its snapshot.
void tick(int secs, double pos, {bool paused = false, bool away = false, int? aid, int? sid}) {
now = DateTime(2026, 1, 1, 20).add(Duration(seconds: secs));
watch.sample(pos, playing: !paused, present: !away, at: now);
reporter.update(PlayerSnapshot(position: pos, paused: paused, aid: aid, sid: sid));
}
Future<void> idle() => reporter.stop(const PlayerSnapshot(position: 0, paused: true)) Future<void> idle() => reporter.stop(const PlayerSnapshot(position: 0, paused: true))
.then((_) {}); .then((_) {});
test('Playing, Progress every 10 s and on pause / seek / track change, Stopped', () async { test('Playing, Progress every 10 s and on pause / seek / track change, Stopped', () async {
// English audio (mpv aid 2 = Jellyfin index 2), English full subs (sid 1 = 3). // English audio (mpv aid 2 = Jellyfin index 2), English full subs (sid 1 = 3).
reporter.start(const PlayerSnapshot(position: 0, paused: true, aid: 2, sid: 1)); reporter.start(const PlayerSnapshot(position: 0, paused: true, aid: 2, sid: 1));
void at(int secs, double pos, {bool paused = false, int? aid = 2, int? sid = 1}) { void at(int secs, double pos, {bool paused = false, int? aid = 2, int? sid = 1}) =>
now = DateTime(2026, 1, 1, 20).add(Duration(seconds: secs)); tick(secs, pos, paused: paused, aid: aid, sid: sid);
reporter.update(PlayerSnapshot(position: pos, paused: paused, aid: aid, sid: sid));
}
at(1, 0); // resume -> Progress at(1, 0); // resume -> Progress
at(2, 1); at(2, 1);
@@ -282,15 +292,14 @@ void main() {
'MediaSourceId': episodeId, 'MediaSourceId': episodeId,
'PlaySessionId': playSessionId, 'PlaySessionId': playSessionId,
'PositionTicks': 0, 'PositionTicks': 0,
}, reason: '11 s watched is a peek: the resume point from before (none) stays'); }, reason: '11 s In is a peek: the resume point from before (none) stays');
expect(errors, isEmpty); expect(errors, isEmpty);
}); });
test('watched to the end: Stopped without a position, then marked played', () async { test('watched to the end: Stopped without a position, then marked played', () async {
reporter.start(const PlayerSnapshot(position: 0, paused: false)); reporter.start(const PlayerSnapshot(position: 0, paused: false));
for (var i = 1; i <= 1420; i++) { for (var i = 0; i <= 1420; i++) {
now = DateTime(2026, 1, 1, 20).add(Duration(seconds: i)); tick(i, i.toDouble());
reporter.update(PlayerSnapshot(position: i.toDouble(), paused: false));
} }
await reporter.stop(const PlayerSnapshot(position: 1420, paused: true)); await reporter.stop(const PlayerSnapshot(position: 1420, paused: true));
expect(reports.map((r) => r.path.split('/').last).toList().sublist(reports.length - 3), expect(reports.map((r) => r.path.split('/').last).toList().sublist(reports.length - 3),
@@ -302,6 +311,19 @@ void main() {
expect(nearEnd, isEmpty, reason: 'near the end Progress carries no position: Jellyfin must not decide'); expect(nearEnd, isEmpty, reason: 'near the end Progress carries no position: Jellyfin must not decide');
}); });
test('sat out for most of it: a resume point, never played (P7)', () async {
reporter.start(const PlayerSnapshot(position: 0, paused: false));
for (var i = 0; i <= 1420; i++) {
// In for five minutes, then away while the others watch on.
tick(i, i.toDouble(), away: i > 300);
}
await reporter.stop(const PlayerSnapshot(position: 1420, paused: true));
expect(reports.map((r) => r.path.split('/').last), isNot(contains('UserPlayedItems')));
final stopped = reports.lastWhere((r) => r.path.endsWith('Stopped')).body;
expect(stopped['PositionTicks'], 300 * jellyfinTicksPerSecond,
reason: 'it resumes where this viewer stopped watching');
});
test('played before: a peek never unmarks it', () async { test('played before: a peek never unmarks it', () async {
final r = JellyfinProgressReporter( final r = JellyfinProgressReporter(
client: accounts.clientFor(fixtureServerId)!, client: accounts.clientFor(fixtureServerId)!,
@@ -376,21 +398,6 @@ void main() {
expect(media.jellyfin!.subtitle, 'Black Summoner · S1E7'); expect(media.jellyfin!.subtitle, 'Black Summoner · S1E7');
}); });
test('one auto-advance swapper: the lowest id among members with their own account', () {
final members = [
member('m10', 'Ann', jf: {fixtureServerId: true}),
member('m2', 'Bob', jf: {fixtureServerId: true}),
member('m1', 'Guest', jf: {fixtureServerId: false}),
member('m0', 'Old', jf: {fixtureServerId: true}, gone: true),
member('m3', 'Fresh'),
];
expect(autoAdvanceSwapper(members, fixtureServerId), 'm2');
expect(autoAdvanceSwapper(members.reversed.toList(), fixtureServerId), 'm2',
reason: 'order does not matter: every client agrees');
expect(autoAdvanceSwapper(members, 'other-server'), isNull);
expect(compareMemberIds('m9', 'm10'), lessThan(0));
});
test('member route: via server only for relay viewers of the current item', () { test('member route: via server only for relay viewers of the current item', () {
DeliveryRoute? route(MemberView m, {bool relay = true}) => DeliveryRoute? route(MemberView m, {bool relay = true}) =>
routeOf(m, DeliveryKind.jellyfin, '', episodeRef, relay); routeOf(m, DeliveryKind.jellyfin, '', episodeRef, relay);
@@ -508,23 +515,36 @@ void main() {
const JellyfinMedia( const JellyfinMedia(
serverId: fixtureServerId, baseUrl: 'b', itemId: nextEpisodeId, title: 'Battle with the Heroes')); serverId: fixtureServerId, baseUrl: 'b', itemId: nextEpisodeId, title: 'Battle with the Heroes'));
testWidgets('end card: Next prompt, countdown with Cancel', (tester) async { testWidgets("end card: the server's countdown with Cancel, Play now; waiting for who puts it up",
var play = 0, cancel = 0; (tester) async {
await tester.pumpWidget(scoped(_graph, app(EndOfEpisodeCard( final queue = _graph.coordinator.queue;
next: next, countdown: null, onPlayNow: () => play++, onCancel: () => cancel++)))); const item = QueueItem(
expect(find.text('Next: S1E7 Battle with the Heroes'), findsOneWidget); id: 'q4',
expect(find.byKey(const ValueKey('auto-advance-cancel')), findsNothing); media: HelloMedia(name: 'ep7', size: 0, duration: 0, jellyfin: JellyfinRef(serverId: fixtureServerId, itemId: nextEpisodeId)),
await tester.tap(find.byKey(const ValueKey('play-next'))); how: QueueHow.jellyfin,
expect(play, 1); title: 'Battle with the Heroes',
sub: 'S1E7',
by: 'm1',
byName: 'Ann',
auto: true,
forMedia: 3);
await tester.pumpWidget(scoped(_graph, app(UpNextCard(
queue: queue, countdown: const UpNextCountdown(mediaId: 3, item: item, secondsLeft: 7)))));
expect(find.text('Next in 7 s'), findsOneWidget);
expect(find.text('Battle with the Heroes'), findsOneWidget);
expect(find.byKey(const ValueKey('up-next-cancel')), findsOneWidget);
expect(find.byKey(const ValueKey('up-next-play-now')), findsOneWidget);
await tester.pumpWidget(scoped(_graph, app(EndOfEpisodeCard( // Cancelled (for everyone): Play now only.
next: next, await tester.pumpWidget(scoped(_graph, app(UpNextCard(
countdown: AutoAdvance(mediaId: 3, secondsLeft: 7, next: next, swapper: false), queue: queue, countdown: const UpNextCountdown(mediaId: 3, item: item)))));
onPlayNow: () => play++, expect(find.text('Next'), findsOneWidget);
onCancel: () => cancel++)))); expect(find.byKey(const ValueKey('up-next-cancel')), findsNothing);
expect(find.text('Next episode in 7 s'), findsOneWidget);
await tester.tap(find.byKey(const ValueKey('auto-advance-cancel'))); // Ran out while the one who puts it up is gone: the card says so.
expect(cancel, 1); await tester.pumpWidget(scoped(_graph, app(UpNextCard(
queue: queue, countdown: const UpNextCountdown(mediaId: 3, item: item, secondsLeft: 0, waitingFor: 'Ann')))));
expect(find.text('Waiting for Ann'), findsOneWidget);
}); });
testWidgets('next-episode pill: faint at rest, lit on hover, click puts it up', (tester) async { testWidgets('next-episode pill: faint at rest, lit on hover, click puts it up', (tester) async {
+6
View File
@@ -66,6 +66,8 @@ const _alone = NoticeContext(self: Presence.watching, alone: true);
const _background = NoticeContext(self: Presence.watching, window: WindowActivity.unfocused); const _background = NoticeContext(self: Presence.watching, window: WindowActivity.unfocused);
const _minimisedAway = NoticeContext(self: Presence.away, window: WindowActivity.minimised); const _minimisedAway = NoticeContext(self: Presence.away, window: WindowActivity.minimised);
const _playing = NoticeContext(self: Presence.watching, playing: true); const _playing = NoticeContext(self: Presence.watching, playing: true);
const _chatOpen = NoticeContext(self: Presence.watching, chatOpen: true);
const _chatOpenBackground = NoticeContext(self: Presence.watching, chatOpen: true, window: WindowActivity.unfocused);
const _chatPaused = NoticeContext(self: Presence.watching, playing: true, prefs: NotifyPrefs(chat: ChatPopups.paused)); const _chatPaused = NoticeContext(self: Presence.watching, playing: true, prefs: NotifyPrefs(chat: ChatPopups.paused));
const _chatOff = NoticeContext(self: Presence.watching, window: WindowActivity.unfocused, prefs: NotifyPrefs(chat: ChatPopups.off)); const _chatOff = NoticeContext(self: Presence.watching, window: WindowActivity.unfocused, prefs: NotifyPrefs(chat: ChatPopups.off));
@@ -115,6 +117,10 @@ final _rows = <_Row>[
_Row('finished', _of(_feed('finished', actor: 'server')), Finished, logged: true), _Row('finished', _of(_feed('finished', actor: 'server')), Finished, logged: true),
_Row('chat from someone', _of(_feed('chat', reason: 'hi')), ChatPosted, _Row('chat from someone', _of(_feed('chat', reason: 'hi')), ChatPosted,
toast: 'Ann: hi', actor: 'Ann', sound: SfxKind.chat, logged: true), toast: 'Ann: hi', actor: 'Ann', sound: SfxKind.chat, logged: true),
_Row('chat with the chat open: only its sound', _of(_feed('chat', reason: 'hi')), ChatPosted,
context: _chatOpen, sound: SfxKind.chat, logged: true),
_Row('chat open but the window in the background: pops up and goes out', _of(_feed('chat', reason: 'hi')), ChatPosted,
context: _chatOpenBackground, toast: 'Ann: hi', actor: 'Ann', sound: SfxKind.chat, logged: true, system: ('Ann', 'hi')),
_Row('own chat', _of(_feed('chat', actor: 'Me', reason: 'hi')), ChatPosted, _Row('own chat', _of(_feed('chat', actor: 'Me', reason: 'hi')), ChatPosted,
toast: 'Me: hi', actor: 'Me', logged: true), toast: 'Me: hi', actor: 'Me', logged: true),
_Row('rename', _of(_feed('rename', target: 'Annie')), MemberRenamed, logged: true), _Row('rename', _of(_feed('rename', target: 'Annie')), MemberRenamed, logged: true),
+136 -66
View File
@@ -1,12 +1,13 @@
import 'dart:io'; import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart' show SystemChannels;
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:signals/signals_flutter.dart' show signal;
import 'package:sync_relay_core/core.dart'; import 'package:sync_relay_core/core.dart';
import 'package:starsync/src/controls/frame.dart';
import 'package:starsync/src/controls/model/controls_prefs.dart'; import 'package:starsync/src/controls/model/controls_prefs.dart';
import 'package:starsync/src/controls/player_settings.dart'; import 'package:starsync/src/controls/player_panel.dart';
import 'package:starsync/src/danmaku/danmaku.dart' show DanmakuStyle;
import 'package:starsync/src/jellyfin/track_prefs.dart'; import 'package:starsync/src/jellyfin/track_prefs.dart';
import 'package:starsync/src/schedule/animeflow.dart'; import 'package:starsync/src/schedule/animeflow.dart';
import 'package:starsync/src/shaders/shader_presets.dart'; import 'package:starsync/src/shaders/shader_presets.dart';
@@ -37,6 +38,8 @@ void bigWindow(WidgetTester tester, [Size size = const Size(1280, 1100)]) {
} }
Future<void> pick(WidgetTester tester, String key, String item) async { Future<void> pick(WidgetTester tester, String key, String item) async {
await tester.ensureVisible(find.byKey(ValueKey(key)));
await tester.pumpAndSettle();
await tester.tap(find.byKey(ValueKey(key))); await tester.tap(find.byKey(ValueKey(key)));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.tap(find.text(item).last); await tester.tap(find.text(item).last);
@@ -57,6 +60,8 @@ void main() {
s.shaderPreset.value = ShaderPreset.off; s.shaderPreset.value = ShaderPreset.off;
s.shaderQuality.value = ShaderQuality.hq; s.shaderQuality.value = ShaderQuality.hq;
s.hwdecMode.value = HwdecMode.auto; s.hwdecMode.value = HwdecMode.auto;
s.liftSubtitles.value = false;
s.notifyStyle.value = NotifyStyle.toasts;
coord.playback.tracks.value = const []; coord.playback.tracks.value = const [];
coord.serverState.value = null; coord.serverState.value = null;
coord.hostFile = null; coord.hostFile = null;
@@ -64,76 +69,66 @@ void main() {
_graph.forms.recents.value = const []; _graph.forms.recents.value = const [];
}); });
Widget opener() => Builder( group('player panel', () {
builder: (context) => TextButton( testWidgets('tracks, subtitles, picture, sound, notifications and controls; all live', (tester) async {
key: const ValueKey('open-settings'),
onPressed: () => showPlayerSettings(context),
child: const Text('settings'),
),
);
group('player settings card', () {
testWidgets('lists every player setting, changes apply live', (tester) async {
bigWindow(tester); bigWindow(tester);
coord.playback.tracks.value = const [ coord.playback.tracks.value = const [
MpvTrack(id: 1, type: JellyfinStreamType.audio, language: 'jpn', codec: 'eac3', channels: 2, selected: true), MpvTrack(id: 1, type: JellyfinStreamType.audio, language: 'jpn', codec: 'eac3', channels: 2, selected: true),
MpvTrack(id: 1, type: JellyfinStreamType.subtitle, language: 'eng', codec: 'ass', title: 'Full'), MpvTrack(id: 1, type: JellyfinStreamType.subtitle, language: 'eng', codec: 'ass', title: 'Full'),
]; ];
await tester.pumpWidget(scoped(_graph, app(Align(alignment: Alignment.bottomRight, child: opener())))); var volume = 80.0;
await tester.tap(find.byKey(const ValueKey('open-settings'))); var closed = 0;
final vol = signal(volume);
await tester.pumpWidget(scoped(
_graph,
app(Align(
alignment: Alignment.topRight,
child: SizedBox(
height: 1000,
child: PlayerPanel(
onClose: () => closed++,
volume: vol,
onVolume: (v) => vol.value = volume = v,
),
),
))));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
final card = find.byKey(const ValueKey('player-settings-card')); final panel = find.byKey(const ValueKey('player-panel'));
expect(card, findsOneWidget); expect(panel, findsOneWidget);
for (final label in [ for (final label in ['Subtitles', 'Audio', 'Size', 'Upscaling', 'Hardware decoding', 'Volume']) {
'Player design', expect(find.descendant(of: panel, matching: find.text(label)), findsWidgets, reason: label);
'Progress line when hidden',
'Upscaling shader',
'Hardware decoding',
'Subtitles',
'Audio',
]) {
expect(find.descendant(of: card, matching: find.text(label)), findsOneWidget, reason: label);
} }
// Subtitles off (none selected), audio on the selected track. // Subtitles off (none selected), audio on the selected track.
expect(find.descendant(of: find.byKey(const ValueKey('subtitle-track')), matching: find.text('Off')), expect(find.descendant(of: find.byKey(const ValueKey('panel-subtitles')), matching: find.text('Off')),
findsOneWidget); findsOneWidget);
expect(find.descendant(of: find.byKey(const ValueKey('audio-track')), matching: find.textContaining('Japanese')), expect(find.descendant(of: find.byKey(const ValueKey('panel-audio')), matching: find.textContaining('Japanese')),
findsOneWidget); findsOneWidget);
await pick(tester, 'panel-shader', 'Mode B');
await pick(tester, 'player-design', 'Minimal');
expect(s.controlsSkin.value, ControlsSkin.minimal);
await tester.tap(find.byKey(const ValueKey('progress-line')));
await tester.pump();
expect(s.progressLine.value, isFalse);
await pick(tester, 'shader-preset', 'Mode B');
expect(s.shaderPreset.value, ShaderPreset.b); expect(s.shaderPreset.value, ShaderPreset.b);
await pick(tester, 'shader-quality', 'Fast'); await pick(tester, 'panel-hwdec', 'Off');
expect(s.shaderQuality.value, ShaderQuality.fast);
await pick(tester, 'hwdec', 'Off');
expect(s.hwdec.value, 'no'); expect(s.hwdec.value, 'no');
// Esc closes it. await tester.tap(find.descendant(of: find.byKey(const ValueKey('panel-lift')), matching: find.byType(Switch)));
await tester.sendKeyEvent(LogicalKeyboardKey.escape); await tester.pump();
expect(s.liftSubtitles.value, isTrue);
await tester.drag(find.descendant(of: find.byKey(const ValueKey('panel-volume')), matching: find.byType(Slider)),
const Offset(-60, 0));
expect(volume, lessThan(80));
// Folded sections open with a click: notifications, then danmaku's knobs.
expect(find.byKey(const ValueKey('notify-style')), findsNothing);
await tester.tap(find.byKey(const ValueKey('panel-section-Notifications')));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(card, findsNothing); await pick(tester, 'notify-style', 'Danmaku');
}); expect(s.notifyStyle.value, NotifyStyle.danmaku);
expect(find.byKey(const ValueKey('danmaku-weight')), findsOneWidget);
testWidgets('keeps the controls up while open', (tester) async { await tester.ensureVisible(find.byKey(const ValueKey('panel-section-Controls')));
bigWindow(tester); await tester.tap(find.byKey(const ValueKey('panel-section-Controls')));
await tester.pumpWidget(scoped(_graph, app(ControlsFrame(
hideAfter: const Duration(seconds: 1),
builder: (context, visible) => Align(alignment: Alignment.bottomRight, child: opener()),
))));
final controls = tester.state<ControlsFrameState>(find.byType(ControlsFrame));
await tester.tap(find.byKey(const ValueKey('open-settings')));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('player-settings-card')), findsOneWidget); await pick(tester, 'panel-design', 'Minimal');
expect(find.text('Standard'), findsOneWidget, reason: 'the current skin'); expect(s.controlsSkin.value, ControlsSkin.minimal);
await tester.pump(const Duration(seconds: 3)); await tester.ensureVisible(find.byKey(const ValueKey('player-panel-close')));
expect(controls.visible, isTrue, reason: 'held while the card is open');
await tester.sendKeyEvent(LogicalKeyboardKey.escape);
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.pump(const Duration(seconds: 2)); await tester.tap(find.byKey(const ValueKey('player-panel-close')));
expect(controls.visible, isFalse); expect(closed, 1);
}); });
}); });
@@ -169,6 +164,37 @@ void main() {
expect(find.byIcon(Icons.hub_outlined), findsOneWidget); expect(find.byIcon(Icons.hub_outlined), findsOneWidget);
}); });
testWidgets('Notifications: Toasts / Danmaku, a preview strip and every knob of the look', (tester) async {
addTearDown(() => s.danmakuStyle.value = const DanmakuStyle());
await pumpSettings(tester, SettingsSection.notifications, size: const Size(1280, 2600));
final card = find.byKey(const ValueKey('danmaku-settings'));
expect(card, findsOneWidget);
expect(find.byKey(const ValueKey('danmaku-preview-strip')), findsOneWidget);
expect(s.notifyStyle.value, NotifyStyle.toasts, reason: 'toasts by default');
await pick(tester, 'notify-style', 'Danmaku');
expect(s.notifyStyle.value, NotifyStyle.danmaku);
for (final k in ['cross', 'cap', 'band', 'opacity', 'size', 'weight', 'outline', 'avatars', 'own']) {
expect(find.byKey(ValueKey('danmaku-$k')), findsOneWidget, reason: k);
}
// Boldness: one step right of Semibold is Bold.
final weight = find.byKey(const ValueKey('danmaku-weight'));
final box = tester.getRect(weight);
await tester.tapAt(Offset(box.left + box.width * 0.6, box.center.dy));
await tester.pump();
expect(s.danmakuStyle.value.weight, 700);
expect(find.text('Bold'), findsOneWidget);
await tester.tap(find.byKey(const ValueKey('danmaku-avatars')));
await tester.pump();
expect(s.danmakuStyle.value.avatars, isFalse);
await tester.tap(find.byKey(const ValueKey('danmaku-reset')));
await tester.pump();
expect(s.danmakuStyle.value, const DanmakuStyle());
// The strip rests after a while and plays again on a click.
await tester.pump(const Duration(seconds: 20));
expect(find.byKey(const ValueKey('danmaku-preview-play')), findsOneWidget);
await tester.pumpAndSettle();
});
testWidgets('Player: the card\'s rows, a pointer to it, and the controls\' choices', (tester) async { testWidgets('Player: the card\'s rows, a pointer to it, and the controls\' choices', (tester) async {
await pumpSettings(tester, SettingsSection.player, size: const Size(1280, 2400)); await pumpSettings(tester, SettingsSection.player, size: const Size(1280, 2400));
expect(find.textContaining('also in the player: ⚙ → Player settings'), findsOneWidget); expect(find.textContaining('also in the player: ⚙ → Player settings'), findsOneWidget);
@@ -177,6 +203,8 @@ void main() {
'progress-line', 'progress-line',
'shader-preset', 'shader-preset',
'hwdec', 'hwdec',
'sub-scale',
'lift-subtitles',
'time-display', 'time-display',
'controls-size', 'controls-size',
'controls-hide', 'controls-hide',
@@ -274,12 +302,12 @@ void main() {
expect(isFakeIp(InternetAddress('198.20.1.1').rawAddress), isFalse); expect(isFakeIp(InternetAddress('198.20.1.1').rawAddress), isFalse);
}); });
test('an invite: the address, whether to ask for a password, the version; pasted back', () { test('an invite: the address, its link; pasted back', () {
const invite = Invite(host: '192.168.1.20', port: 8080, password: true, version: '1.3.0'); const invite = Invite(host: '192.168.1.20', port: 8080, password: true, version: '1.3.0');
expect(invite.address, '192.168.1.20:8080'); expect(invite.address, '192.168.1.20:8080');
expect(invite.text, 'Join me on Starsync: 192.168.1.20:8080 (ask me for the password) · Starsync 1.3.0'); expect(invite.link().uri, 'starsync://192.168.1.20:8080');
expect(invite.fp, isNull, reason: 'reserved for pinned certificates'); expect(invite.fp, isNull, reason: 'reserved for pinned certificates');
expect(addressIn(invite.text), (host: '192.168.1.20', port: '8080')); expect(addressIn(invite.link().uri), (host: '192.168.1.20', port: '8080'));
expect(addressIn('vps.example.com:9000'), (host: 'vps.example.com', port: '9000')); expect(addressIn('vps.example.com:9000'), (host: 'vps.example.com', port: '9000'));
expect(addressIn('[fe80::1]:8080'), (host: 'fe80::1', port: '8080')); expect(addressIn('[fe80::1]:8080'), (host: 'fe80::1', port: '8080'));
expect(addressIn('just a host'), isNull); expect(addressIn('just a host'), isNull);
@@ -336,12 +364,54 @@ void main() {
expect(find.byKey(const ValueKey('stop-sharing')), findsNothing); expect(find.byKey(const ValueKey('stop-sharing')), findsNothing);
yours.shared.value = const Invite(host: '192.168.1.20', port: 8080, version: '1.3.0'); yours.shared.value = const Invite(host: '192.168.1.20', port: 8080, version: '1.3.0');
await tester.pumpAndSettle(); yours.reach.value = const InviteReach(local: ['192.168.1.20'], looking: true);
expect(find.text('192.168.1.20:8080'), findsOneWidget); await tester.pump();
expect(find.text('Copy invite'), findsOneWidget); await tester.pump(const Duration(milliseconds: 400));
expect(find.text('Looking up your internet address…'), findsOneWidget);
expect(find.text('Copy invite link'), findsOneWidget);
expect(find.byKey(const ValueKey('stop-sharing')), findsOneWidget); expect(find.byKey(const ValueKey('stop-sharing')), findsOneWidget);
expect(find.textContaining('port 8080 forwarded'), findsOneWidget); // One address so far: nothing else to pick, nothing to say.
expect(find.textContaining('VPS'), findsOneWidget); expect(find.byKey(const ValueKey('invite-other-addresses')), findsNothing);
expect(find.byKey(const ValueKey('invite-note')), findsNothing);
// Behind a router: the internet address, one line on forwarding;
// the home address waits behind Other addresses.
yours.shared.value = const Invite(host: '203.0.113.7', port: 8080, version: '1.3.0');
yours.reach.value = const InviteReach(public: '203.0.113.7', local: ['192.168.1.20']);
await tester.pumpAndSettle();
expect(find.text('Looking up your internet address…'), findsNothing);
expect(find.text('Friends outside your home network need port 8080 forwarded to this computer.'),
findsOneWidget);
expect(find.text('192.168.1.20:8080'), findsNothing);
Future<String?> copy() async {
String? copied;
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, (call) async {
if (call.method == 'Clipboard.setData') copied = (call.arguments as Map)['text'] as String;
return null;
});
addTearDown(() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, null));
await tester.tap(find.byKey(const ValueKey('copy-invite')));
await tester.pumpAndSettle();
return copied;
}
// Just the link, nothing around it.
expect(await copy(), matches(RegExp(r'^starsync://203\.0\.113\.7:8080(/\?name=\S+)?$')));
await tester.tap(find.byKey(const ValueKey('invite-other-addresses')));
await tester.pumpAndSettle();
expect(find.text('203.0.113.7:8080'), findsOneWidget);
await tester.tap(find.text('192.168.1.20:8080'));
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('invite-note')), findsNothing, reason: 'home network picked');
expect(await copy(), startsWith('starsync://192.168.1.20:8080'));
// No internet address found: one line saying so.
yours.reach.value = const InviteReach(local: ['192.168.1.20']);
await tester.pumpAndSettle();
expect(find.textContaining("Couldn't find your internet address"), findsOneWidget);
expect(_graph.notifier.toasts.peek().map((t) => t.text), contains('Invite link copied'));
await tester.pump(const Duration(seconds: 6)); // the toasts go
}); });
}); });
} }
+56 -52
View File
@@ -1,25 +1,20 @@
// restructure: rewrite in step 6 (What's next): who performs an item and
// the janitor become server state.
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:sync_relay_core/core.dart'; import 'package:sync_relay_core/core.dart';
import 'package:starsync/src/files/scanner.dart' show FolderScan;
import 'package:starsync/src/library/library_store.dart';
import 'package:starsync/src/library/local_next.dart';
import 'package:starsync/src/library/show_matcher.dart';
import 'package:starsync/src/queue/queue_logic.dart'; import 'package:starsync/src/queue/queue_logic.dart';
MemberView member(String id, import 'show_matcher_test.dart' show FakeAniList;
{bool gone = false, bool away = false, Map<String, bool> jf = const {}, String name = ''}) =>
MemberView( // What's next on the app: an item's words, and what continues a file of
id: id, // a local show (who puts an item up and the countdown are core's and the
name: name.isEmpty ? id : name, // server's: core `whats_next_test`, `queue_test`).
ready: false,
inSet: true,
buffered: 0,
presence: gone ? Presence.gone : away ? Presence.away : Presence.watching,
jf: jf);
QueueItem item(QueueHow how, {String by = 'm2', String byName = 'Bob'}) => QueueItem( QueueItem item(QueueHow how, {String by = 'm2', String byName = 'Bob'}) => QueueItem(
id: 'q1', id: 'q1',
media: how == QueueHow.jellyfin media: const HelloMedia(name: 'x', size: 0, duration: 0, url: 'https://x/y'),
? const HelloMedia(name: 'ep', size: 0, duration: 0, jellyfin: JellyfinRef(serverId: 'S', itemId: 'i'))
: const HelloMedia(name: 'x', size: 0, duration: 0, url: 'https://x/y'),
how: how, how: how,
title: 'x', title: 'x',
by: by, by: by,
@@ -27,49 +22,58 @@ QueueItem item(QueueHow how, {String by = 'm2', String byName = 'Bob'}) => Queue
); );
void main() { void main() {
group('queuePerformer', () {
test('a link passed on or a file: only its adder; gone waits for it, left means skipped', () {
for (final how in [QueueHow.through, QueueHow.file]) {
expect(queuePerformer(item(how), [member('m1'), member('m2', away: true)]).id, 'm2');
final waits = queuePerformer(item(how), [member('m1'), member('m2', gone: true)]);
expect((waits.id, waits.why), (null, null), reason: 'a gone adder may come back within its grace');
final left = queuePerformer(item(how), [member('m1'), member('m3')]);
expect(left.id, isNull);
expect(left.why, 'Bob left');
}
});
test('Jellyfin: the adder with access, else the lowest member with access, else skipped', () {
final access = {'S': true};
expect(queuePerformer(item(QueueHow.jellyfin), [member('m1', jf: access), member('m2', jf: access)]).id, 'm2');
expect(
queuePerformer(item(QueueHow.jellyfin), [member('m10', jf: access), member('m3', jf: access), member('m2')])
.id,
'm3');
expect(queuePerformer(item(QueueHow.jellyfin), [member('m3', jf: access, away: true), member('m2')]).why,
isNotNull);
});
test('a link everyone opens or the server fetches: the adder, else the lowest active member', () {
for (final how in [QueueHow.direct, QueueHow.server]) {
expect(queuePerformer(item(how), [member('m1'), member('m2')]).id, 'm2');
expect(queuePerformer(item(how), [member('m4'), member('m2', gone: true), member('m3')]).id, 'm3');
final nobody = queuePerformer(item(how), [member('m2', away: true)]);
expect((nobody.id, nobody.why), (null, null), reason: 'nobody around is not a skip');
}
});
});
test('the janitor is the lowest member that is not gone', () {
expect(queueJanitor([member('m9'), member('m1', gone: true), member('m2')]), 'm2');
});
test('labels', () { test('labels', () {
expect(queueHowLabel(item(QueueHow.through), selfId: 'm2'), 'through you'); expect(queueHowLabel(item(QueueHow.through), selfId: 'm2'), 'through you');
expect(queueHowLabel(item(QueueHow.through), selfId: 'm1'), 'through Bob'); expect(queueHowLabel(item(QueueHow.through), selfId: 'm1'), 'through Bob');
expect(queueHowLabel(item(QueueHow.file), selfId: 'm1'), "Bob's file"); expect(queueHowLabel(item(QueueHow.file), selfId: 'm1'), "Bob's file");
expect(queueHowLabel(item(QueueHow.file, byName: ''), selfId: 'm1'), "who added it's file");
expect(queueClock(3725), '1:02:05'); expect(queueClock(3725), '1:02:05');
expect(queueClock(65), '1:05'); expect(queueClock(65), '1:05');
expect(queueClock(0), ''); expect(queueClock(0), '');
}); });
group('what continues a file of a local show', () {
const dir = '/lib/Bleach';
String ep(int n) => '$dir/[Judas] Bleach - ${n.toString().padLeft(3, '0')}.mkv';
// 064 and 065 are there, 066..107 are not.
final files = [for (final n in [63, 64, 65, 108, 109]) LocalFile(ep(n), 1 << 30, DateTime(2026))];
late List<String> scanned;
late LocalNextFinder finder;
late ShowMatcher matcher;
setUp(() {
scanned = [];
matcher = ShowMatcher(store: MemoryLibraryStore(), aniList: FakeAniList(), delay: (_) async {});
finder = LocalNextFinder(
matcher: matcher,
scan: (d) async {
scanned.add(d);
return FolderScan(videos: files, hiddenCount: 0, hiddenExamples: const [], subtitles: const {}, truncated: false);
});
});
test('from its own folder when the library has not seen it this run', () async {
final next = await finder.nextAfter(ep(64));
expect(next!.episode.path, ep(65));
expect(next.skipped, isNull);
expect(scanned, [dir]);
});
test('numbers skipped on the way are said', () async {
final next = await finder.nextAfter(ep(65));
expect(next!.episode.path, ep(108));
expect(next.skipped!.label, '66–107');
});
test('the last one, or not an episode: nothing continues it', () async {
expect(await finder.nextAfter(ep(109)), isNull);
expect(await finder.nextAfter('$dir/notes.mkv'), isNull);
});
test('the library the Files page saw is used without a scan', () async {
matcher.remember(groupLocal(files, roots: const ['/lib']));
expect((await finder.nextAfter(ep(63)))!.episode.path, ep(64));
expect(scanned, isEmpty);
});
});
} }
+15 -1
View File
@@ -14,6 +14,7 @@ import 'package:starsync/src/queue/queue_controller.dart' show WarmState;
import 'package:starsync/src/screens/home/picked_media.dart'; import 'package:starsync/src/screens/home/picked_media.dart';
import 'package:starsync/src/session/session_coordinator.dart'; import 'package:starsync/src/session/session_coordinator.dart';
import 'package:starsync/src/session/switch_trace.dart'; import 'package:starsync/src/session/switch_trace.dart';
import 'package:starsync/src/state/settings.dart';
// How long an Up next switch takes through the media pipeline, end to // How long an Up next switch takes through the media pipeline, end to
// end, over real mpv and a real sync server: two Jellyfin account holders // end, over real mpv and a real sync server: two Jellyfin account holders
@@ -244,8 +245,14 @@ void main() {
final hosts = <SessionCoordinator, MediaKitHost>{}; final hosts = <SessionCoordinator, MediaKitHost>{};
SessionCoordinator coord(JellyfinAccounts acc) { SessionCoordinator coord(JellyfinAccounts acc) {
final host = MediaKitHost()..init(); final host = MediaKitHost()..init();
// Up next alone: no continuation of the episodes (it would be
// queued after the items this test adds and times).
final c = SessionCoordinator( final c = SessionCoordinator(
jellyfin: acc, trackPrefs: TrackPrefs(store: MemoryJellyfinStore()), ytDlp: ytDlp, players: host) jellyfin: acc,
trackPrefs: TrackPrefs(store: MemoryJellyfinStore()),
ytDlp: ytDlp,
players: host,
settings: AppSettings()..autoAdvance.value = false)
..media.preloadEnabled = preload; ..media.preloadEnabled = preload;
hosts[c] = host; hosts[c] = host;
return c; return c;
@@ -336,6 +343,13 @@ void main() {
final relay = stateMediaRelay(a.serverState.peek()!); final relay = stateMediaRelay(a.serverState.peek()!);
expect(relay?.queueId, id, reason: 'the warm relay became the media\'s'); expect(relay?.queueId, id, reason: 'the warm relay became the media\'s');
expect(relay?.fetch, mode == LinkRelay.server); expect(relay?.fetch, mode == LinkRelay.server);
if (mode == LinkRelay.me) {
// Read once: alice's preload and the warm relay read one shared
// reader, and the swap carried it on as the media's.
final one = a.media.shared.currentFor(linkUrl);
expect(one, isNotNull, reason: 'alice reads the link once');
expect(one!.bytesIn, greaterThan(0));
}
if (preload) { if (preload) {
for (final x in all.values) { for (final x in all.values) {
expect(x.switchTrace.of(x.serverState.peek()!.mediaId), contains(SwitchTrace.preloaded), expect(x.switchTrace.of(x.serverState.peek()!.mediaId), contains(SwitchTrace.preloaded),
+226
View File
@@ -0,0 +1,226 @@
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:media_kit/media_kit.dart';
import 'package:media_kit_video/media_kit_video.dart' show VideoState;
import 'package:starsync/src/controls/frame.dart';
import 'package:starsync/src/danmaku/danmaku.dart';
import 'package:starsync/src/session/navigation.dart' show Place;
import 'package:starsync/src/session/notifier.dart' show NoticeCategory, ToastItem;
import 'package:starsync/src/shell/quick_chat.dart';
import 'package:starsync/src/shell/session_player.dart';
import 'package:starsync/src/state/settings.dart';
import 'package:starsync/src/widgets/app_keys.dart';
import 'test_graph.dart';
/// This file's composition root (see test_graph.dart).
final _graph = testGraph();
// The quick chat box over the full player: Enter opens it whatever plays,
// keys go to it (not to the player), the cursor and the controls stay as
// the mouse left them, a send shows your own line (echo, or danmaku), Esc
// and the idle time close it with the draft kept, the draft is the chat
// panel's. The picture is a black box with the session's controls on it
// (never a media_kit `Video`).
void main() {
final coord = _graph.coordinator;
final s = coord.settings;
setUpAll(() async {
MediaKit.ensureInitialized();
await coord.media.start();
_graph.navigation.place.value = Place.yours;
});
late TextEditingController draft;
late QuickChat quick;
var fullscreenToggles = 0;
var readyToggles = 0;
setUp(() {
draft = TextEditingController();
quick = QuickChat(draft: draft);
fullscreenToggles = 0;
readyToggles = 0;
});
tearDown(() {
s.notifyStyle.value = NotifyStyle.toasts;
});
Future<void> mount(WidgetTester tester) async {
tester.view.physicalSize = const Size(1280, 720);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await tester.pumpWidget(scoped(
_graph,
MaterialApp(
theme: ThemeData.dark(),
home: AppKeys(
onToggleReady: () => readyToggles++,
child: Scaffold(
body: SessionPlayer(
mode: PlayerMode.full,
fullscreen: false,
onExpand: () {},
onCollapse: () {},
onClose: () {},
onToggleFullscreen: () => fullscreenToggles++,
onToggleChat: () {},
onChangeVideo: () {},
onOpenIntegrations: () {},
onLeave: () {},
windowControls: false,
quickChat: quick,
videoBuilder: (context, controls) => Stack(fit: StackFit.expand, children: [
const ColoredBox(color: Colors.black),
if (controls != null) controls(VideoState()),
]),
),
),
),
),
));
await tester.pump();
}
Future<void> unmount(WidgetTester tester) async {
await tester.pumpWidget(scoped(_graph, const SizedBox()));
await tester.pump(const Duration(seconds: 5));
quick.dispose();
draft.dispose();
}
ControlsFrameState frame(WidgetTester tester) => tester.state<ControlsFrameState>(find.byType(ControlsFrame));
MouseCursor frameCursor(WidgetTester tester) => tester
.widget<MouseRegion>(find.descendant(of: find.byType(ControlsFrame), matching: find.byType(MouseRegion)).first)
.cursor;
final field = find.byKey(const ValueKey('quick-chat-field'));
bool fieldFocused() => quick.focus.hasFocus;
Future<void> hideControls(WidgetTester tester) async {
await tester.pump(Duration(seconds: s.controlsHideSecs.value + 1));
await tester.pump(const Duration(milliseconds: 500));
}
testWidgets('Enter opens the box; keys go to it; the cursor and controls stay hidden', (tester) async {
await mount(tester);
// A still mouse over the bottom centre, where the box comes up.
final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse);
await mouse.addPointer(location: const Offset(640, 690));
addTearDown(mouse.removePointer);
await tester.pump();
await hideControls(tester);
expect(frame(tester).visible, isFalse);
expect(frameCursor(tester), SystemMouseCursors.none);
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
await tester.pump();
expect(quick.open.value, isTrue);
expect(fieldFocused(), isTrue, reason: 'the box has the keyboard at once');
await tester.pump(const Duration(milliseconds: 400));
expect(frame(tester).visible, isFalse, reason: 'typing is not mouse activity');
expect(frameCursor(tester), SystemMouseCursors.none);
final fieldWidget = tester.widget<TextField>(field);
expect(fieldWidget.mouseCursor, SystemMouseCursors.none, reason: 'no I-beam over a still mouse');
// Space and F reach the box, not ready or fullscreen.
await tester.sendKeyEvent(LogicalKeyboardKey.space);
await tester.sendKeyEvent(LogicalKeyboardKey.keyF);
await tester.pump();
expect(readyToggles, 0);
expect(fullscreenToggles, 0);
// Esc closes; the draft stays; the controls do not come back.
await tester.enterText(field, 'half a thought');
await tester.sendKeyEvent(LogicalKeyboardKey.escape);
await tester.pump(const Duration(milliseconds: 400));
expect(quick.open.value, isFalse);
expect(draft.text, 'half a thought');
expect(frame(tester).visible, isFalse, reason: 'the box going is not mouse movement');
expect(frameCursor(tester), SystemMouseCursors.none);
// The player's keys are back.
await tester.sendKeyEvent(LogicalKeyboardKey.space);
expect(readyToggles, 1);
await tester.pump(const Duration(milliseconds: 400));
await tester.sendKeyEvent(LogicalKeyboardKey.keyF);
expect(fullscreenToggles, 1);
// Enter brings the draft back.
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
await tester.pump();
expect(quick.open.value, isTrue);
expect(tester.widget<TextField>(field).controller!.text, 'half a thought');
await unmount(tester);
});
testWidgets('a send shows your own line: the echo with toasts, and the controls stay hidden', (tester) async {
await mount(tester);
await hideControls(tester);
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
await tester.pump();
await tester.enterText(field, 'hello there');
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
await tester.pump();
expect(quick.open.value, isFalse);
expect(draft.text, isEmpty, reason: 'sent: the shared draft is cleared');
await tester.pump(const Duration(milliseconds: 400));
final echo = find.byKey(const ValueKey('quick-chat-echo'));
expect(echo, findsOneWidget);
expect(find.descendant(of: echo, matching: find.text('hello there')), findsOneWidget);
expect(frame(tester).visible, isFalse, reason: 'held down while the echo is in their place');
// Your own chat toast coming back is not shown again.
final own = ToastItem(
id: 99, text: 'Me: hello there', at: DateTime.now(), actor: 'Me', chat: true, own: true,
category: NoticeCategory.chat);
expect(quick.echoed(own), isTrue);
await tester.pump(QuickChat.echoTime);
await tester.pump(const Duration(milliseconds: 400));
expect(echo, findsNothing);
expect(frame(tester).visible, isFalse);
// Empty Enter just closes.
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
await tester.pump();
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
await tester.pump(const Duration(milliseconds: 400));
expect(quick.open.value, isFalse);
expect(echo, findsNothing);
await unmount(tester);
});
testWidgets('with danmaku your own line flies instead of the echo', (tester) async {
s.notifyStyle.value = NotifyStyle.danmaku;
await mount(tester);
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
await tester.pump();
await tester.enterText(field, 'flying');
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
await tester.pump(const Duration(milliseconds: 16));
await tester.pump(const Duration(milliseconds: 16));
expect(find.byKey(const ValueKey('quick-chat-echo')), findsNothing);
final line = find.descendant(of: find.byType(DanmakuLayer), matching: find.textContaining('flying', findRichText: true));
expect(line, findsWidgets);
await tester.pump(const Duration(seconds: 10));
await unmount(tester);
});
testWidgets('the box hides by itself after a while without typing; the draft stays', (tester) async {
await mount(tester);
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
await tester.pump();
await tester.enterText(field, 'later');
await tester.pump(QuickChat.idleClose - const Duration(seconds: 1));
expect(quick.open.value, isTrue);
await tester.enterText(field, 'later, maybe');
await tester.pump(QuickChat.idleClose - const Duration(seconds: 1));
expect(quick.open.value, isTrue, reason: 'typing restarts the wait');
await tester.pump(const Duration(seconds: 2));
expect(quick.open.value, isFalse);
expect(draft.text, 'later, maybe');
await unmount(tester);
});
}
+8 -2
View File
@@ -159,12 +159,12 @@ class _BareSource {
} }
} }
/// Serves one file with ranges; [requests] counts hits. /// Serves one file with ranges; [requests] lists hits (user agent, URL).
Future<(HttpServer, List<String>)> _origin(String file) async { Future<(HttpServer, List<String>)> _origin(String file) async {
final s = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); final s = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
final requests = <String>[]; final requests = <String>[];
s.listen((req) async { s.listen((req) async {
requests.add(req.uri.toString()); requests.add('${req.headers.value('user-agent')} ${req.uri}');
final size = await File(file).length(); final size = await File(file).length();
final r = resolveMediaRequest(rangeHeader: req.headers.value('range'), size: size, filename: 'v.mkv'); final r = resolveMediaRequest(rangeHeader: req.headers.value('range'), size: size, filename: 'v.mkv');
req.response.statusCode = r.status; req.response.statusCode = r.status;
@@ -274,6 +274,12 @@ void main() {
expect(alice.serverState.peek()!.toJson().toString(), isNot(contains('SECRET'))); expect(alice.serverState.peek()!.toJson().toString(), isNot(contains('SECRET')));
await playTogether([alice, bob]); await playTogether([alice, bob]);
expect(server.hub.relay!.buffers.single.bytesToViewers, greaterThan(0)); expect(server.hub.relay!.buffers.single.bytesToViewers, greaterThan(0));
// Read once: bob's player plays his shared reader's loopback, so
// every hit at the origin is that reader's (Dart's), none mpv's.
final one = bob.media.shared.currentFor(link);
expect(one, isNotNull);
expect(one!.bytesIn, greaterThan(0));
expect(hits.where((h) => !h.startsWith('Dart/')), isEmpty, reason: '$hits');
final before = hits.length; final before = hits.length;
expect(await alice.swapPicked(LinkMedia(link, relay: LinkRelay.server)), isNull); expect(await alice.swapPicked(LinkMedia(link, relay: LinkRelay.server)), isNull);
@@ -14,6 +14,7 @@ import 'package:starsync/src/session/session_coordinator.dart';
import 'package:starsync/src/state/server_isolate.dart'; import 'package:starsync/src/state/server_isolate.dart';
import 'package:sync_relay_server/server.dart' show SyncServer; import 'package:sync_relay_server/server.dart' show SyncServer;
import 'package:starsync/src/session/invite.dart' show InviteLink;
import 'package:starsync/src/session/navigation.dart' show Place; import 'package:starsync/src/session/navigation.dart' show Place;
import 'test_graph.dart'; import 'test_graph.dart';
@@ -166,6 +167,11 @@ void main() {
final invite = await yours.invite(); final invite = await yours.invite();
expect(invite.port, port); expect(invite.port, port);
expect(invite.password, isTrue); expect(invite.password, isTrue);
// The link is the key: the password it was shared with, even when
// the setting changes later.
graph.forms.form.hostSecret = 'changed';
expect(InviteLink.parse(yours.inviteLink(invite))?.secret, 'pw');
graph.forms.form.hostSecret = 'pw';
expect(yours.shared.peek(), invite); expect(yours.shared.peek(), invite);
expect(identical(await yours.invite(), invite), isTrue, reason: 'already shared: the same invite'); expect(identical(await yours.invite(), invite), isTrue, reason: 'already shared: the same invite');
+34 -3
View File
@@ -103,10 +103,11 @@ void main() {
test('copies the bundle once per version, redoes a broken copy', () async { test('copies the bundle once per version, redoes a broken copy', () async {
final support = _fresh('shaders_support'); final support = _fresh('shaders_support');
final dir = await extractAnime4k(support); final dir = await extractAnime4k(support);
expect(dir.path, endsWith('shaders/anime4k-$anime4kVersion')); expect(dir.path, endsWith('shaders/anime4k-$anime4kVersion-$anime4kCopyRevision'));
for (final f in [...anime4kAllFiles, 'LICENSE']) { for (final f in [...anime4kAllFiles, 'LICENSE']) {
expect(File('${dir.path}/$f').readAsBytesSync(), final shipped = File('$anime4kAssetDir/$f');
File('$anime4kAssetDir/$f').readAsBytesSync(), expect(File('${dir.path}/$f').readAsStringSync(),
f.endsWith('.glsl') ? highpAnime4k(shipped.readAsStringSync()) : shipped.readAsStringSync(),
reason: f); reason: f);
} }
// Second run is a no-op: a marker-tagged copy stays untouched. // Second run is a no-op: a marker-tagged copy stays untouched.
@@ -132,6 +133,36 @@ void main() {
}); });
}); });
test('Anime4K reads go through a highp helper, once per pass', () {
const src = '''// header
//!DESC pass 1
//!HOOK MAIN
//!BIND MAIN
#define go_0(x_off, y_off) (MAIN_texOff(vec2(x_off, y_off)))
vec4 hook() { return mat4(1.0) * go_0(0.0, 0.0); }
//!DESC pass 2
//!HOOK MAIN
//!BIND conv2d_tf
#define go_0(x_off, y_off) (max((conv2d_tf_texOff(vec2(x_off, y_off))), 0.0))
#define go_1(x_off, y_off) (max(-(conv2d_tf_texOff(vec2(x_off, y_off))), 0.0))
#define g_0 (max((conv2d_tf_tex(conv2d_tf_pos)), 0.0))
vec4 hook() { return go_0(0.0, 0.0) + go_1(0.0, 0.0) + g_0 + MAIN_tex(MAIN_pos); }
''';
final out = highpAnime4k(src);
expect('highp vec4 starsync_hp('.allMatches(out), hasLength(2), reason: 'one helper per pass');
expect(out, contains('#define go_0(x_off, y_off) (starsync_hp(MAIN_texOff(vec2(x_off, y_off))))'));
expect(out, contains('(max(-(starsync_hp(conv2d_tf_texOff(vec2(x_off, y_off)))), 0.0))'));
expect(out, contains('#define g_0 (max((starsync_hp(conv2d_tf_tex(conv2d_tf_pos))), 0.0))'));
expect(out, contains('+ g_0 + MAIN_tex(MAIN_pos); }'), reason: 'other code stays as it is');
// Every shipped file: every network read wrapped, the helper before its first use.
for (final f in anime4kAllFiles) {
final o = highpAnime4k(File('$anime4kAssetDir/$f').readAsStringSync());
for (final line in o.split('\n').where((l) => RegExp(r'^#define g(o)?_\d').hasMatch(l))) {
expect(line, contains('starsync_hp('), reason: '$f: $line');
}
}
});
test('shader settings persist across a reload', () async { test('shader settings persist across a reload', () async {
Hive.init(_fresh('shaders_hive').path); Hive.init(_fresh('shaders_hive').path);
final a = AppSettings(); final a = AppSettings();
+42 -1
View File
@@ -19,6 +19,7 @@ import 'package:starsync/src/shell/toasts.dart';
import 'package:starsync/src/session/session_coordinator.dart' show ToastItem; import 'package:starsync/src/session/session_coordinator.dart' show ToastItem;
import 'package:starsync/src/state/settings.dart'; import 'package:starsync/src/state/settings.dart';
import 'package:starsync/src/update/update_controller.dart'; import 'package:starsync/src/update/update_controller.dart';
import 'package:starsync/src/update/whats_new.dart';
import 'package:starsync/src/session/invite.dart' show Invite; import 'package:starsync/src/session/invite.dart' show Invite;
import 'package:starsync/src/session/navigation.dart' show Place; import 'package:starsync/src/session/navigation.dart' show Place;
@@ -88,7 +89,8 @@ void main() {
session.notifier.clear(); session.notifier.clear();
}); });
Future<void> pumpShell(WidgetTester tester, Size size, {UpdateController? updates, VideoViewBuilder? video}) async { Future<void> pumpShell(WidgetTester tester, Size size,
{UpdateController? updates, VideoViewBuilder? video, WhatsNew? whatsNew}) async {
tester.view.physicalSize = size; tester.view.physicalSize = size;
tester.view.devicePixelRatio = 1; tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset); addTearDown(tester.view.reset);
@@ -97,6 +99,7 @@ void main() {
home: AppShell( home: AppShell(
updates: updates ?? UpdateController(currentVersion: '0.0.0-dev'), updates: updates ?? UpdateController(currentVersion: '0.0.0-dev'),
autoUpdateCheck: false, autoUpdateCheck: false,
whatsNew: whatsNew,
windowControls: false, windowControls: false,
videoBuilder: video ?? (context, controls) => const FakeVideo(), videoBuilder: video ?? (context, controls) => const FakeVideo(),
// A real probe (network, yt-dlp) never answers on the test clock. // A real probe (network, yt-dlp) never answers on the test clock.
@@ -152,6 +155,25 @@ void main() {
}); });
}); });
group('what\'s new', () {
testWidgets('the first start after an update shows the card once', (tester) async {
final store = MemoryWhatsNewStore(
seen: '1.3.2',
notes: const ReleaseNotes(version: '1.3.3', changes: ReleaseChanges(fixed: ['Various bug fixes'])));
await pumpShell(tester, const Size(1280, 800), whatsNew: WhatsNew(currentVersion: '1.3.3', store: store));
expect(find.text("What's new in 1.3.3"), findsOneWidget);
expect(find.text('STARSYNC · UPDATED'), findsOneWidget);
expect(store.seen, '1.3.3');
await tester.tap(find.byKey(const ValueKey('whats-new-close')));
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('whats-new-card')), findsNothing);
// The next start: nothing.
await tester.pumpWidget(const SizedBox());
await pumpShell(tester, const Size(1280, 800), whatsNew: WhatsNew(currentVersion: '1.3.3', store: store));
expect(find.byKey(const ValueKey('whats-new-card')), findsNothing);
});
});
group('party strip', () { group('party strip', () {
testWidgets('home, alone: a plain player\'s strip, Join a friend and Invite (once your server runs)', testWidgets('home, alone: a plain player\'s strip, Join a friend and Invite (once your server runs)',
(tester) async { (tester) async {
@@ -418,6 +440,25 @@ void main() {
expect(tester.takeException(), isNull); expect(tester.takeException(), isNull);
}); });
testWidgets('the mini player clears the full player\'s controls (media_kit keeps the last ones on null)',
(tester) async {
coord.playback.alive.value = true;
final given = <PlayerMode, Object?>{};
PlayerMode mode() => tester.state<AppShellState>(find.byType(AppShell)).mode;
await pumpShell(tester, const Size(1280, 800), video: (context, controls) {
given[mode()] = controls;
return const ColoredBox(color: Color(0xFF7A3B2E));
});
online(party);
await tester.pumpAndSettle();
await tester.tap(find.byKey(const ValueKey('tab-settings')));
await tester.pumpAndSettle();
expect(mode(), PlayerMode.mini);
expect(given[PlayerMode.full], isNotNull);
expect(given[PlayerMode.mini], isNotNull, reason: 'null would keep the full controls');
expect(given[PlayerMode.mini], isNot(same(given[PlayerMode.full])));
});
testWidgets('status pill: who is not ready, with the ready count', (tester) async { testWidgets('status pill: who is not ready, with the ready count', (tester) async {
coord.playback.alive.value = true; coord.playback.alive.value = true;
await pumpShell(tester, const Size(1280, 800)); await pumpShell(tester, const Size(1280, 800));
+1 -1
View File
@@ -7,7 +7,7 @@ import 'package:flutter_test/flutter_test.dart';
/// (`AppGraph`, widgets through `AppScope`); this list may only shrink. /// (`AppGraph`, widgets through `AppScope`); this list may only shrink.
/// Lower a number when a use goes; never raise one or add a class. /// Lower a number when a use goes; never raise one or add a class.
const baseline = { const baseline = {
'AppSettings': 55, 'AppSettings': 47,
'JellyfinAccounts': 10, 'JellyfinAccounts': 10,
'UpdateController': 8, 'UpdateController': 8,
'Profile': 8, 'Profile': 8,
+20
View File
@@ -0,0 +1,20 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:starsync/src/platform/ports/player_host.dart';
// ffmpeg's lost stream (its HTTP context lost the file's size, every seek
// fails until a fresh open): the lines the coordinator reloads the video
// on, once per media.
void main() {
test('a seek that failed for want of the size is a lost stream', () {
expect(mpvStreamLost(const PlayerLogLine('ffmpeg', 'error', 'Seek failed (to 3417735, size -38)')), isTrue);
expect(mpvStreamLost(const PlayerLogLine('ffmpeg', 'warn', 'Seek failed (to 11997, size -38)\n')), isTrue);
});
test('anything else is not', () {
// A seek that failed with the size known, a reconnect, another prefix.
expect(mpvStreamLost(const PlayerLogLine('ffmpeg', 'error', 'Seek failed (to 20851, size 1023081)')), isFalse);
expect(mpvStreamLost(const PlayerLogLine('ffmpeg', 'warn', 'http: Will reconnect at 3407872 in 0 second(s)')),
isFalse);
expect(mpvStreamLost(const PlayerLogLine('lavf', 'warn', 'Seek failed (to 1, size -38)')), isFalse);
});
}
+7 -3
View File
@@ -6,9 +6,13 @@ import 'package:starsync/src/session/identity.dart' show ClientIdentity;
/// One graph per test file, like the app's one per run: nothing starts /// One graph per test file, like the app's one per run: nothing starts
/// until a test calls `navigation.start()` (or `goHome`), so widget tests /// until a test calls `navigation.start()` (or `goHome`), so widget tests
/// see the app before your server is up. /// see the app before your server is up. Sharing looks up no internet
AppGraph testGraph({HealthGetter healthGet = defaultHealthGet}) => /// address unless [publicAddress] gives one.
AppGraph.build(identity: ClientIdentity.ephemeral(), healthGet: healthGet); AppGraph testGraph({HealthGetter healthGet = defaultHealthGet, Future<String?> Function()? publicAddress}) =>
AppGraph.build(
identity: ClientIdentity.ephemeral(),
healthGet: healthGet,
publicAddress: publicAddress ?? () async => null);
/// [child] under [graph]'s [AppScope]. /// [child] under [graph]'s [AppScope].
Widget scoped(AppGraph graph, Widget child) => AppScope(graph: graph, child: child); Widget scoped(AppGraph graph, Widget child) => AppScope(graph: graph, child: child);
@@ -3,7 +3,9 @@ import 'dart:io';
import 'package:crypto/crypto.dart'; import 'package:crypto/crypto.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:hive_ce/hive.dart';
import 'package:sync_relay_core/core.dart'; import 'package:sync_relay_core/core.dart';
import 'package:starsync/src/state/settings.dart';
import 'package:starsync/src/update/update_controller.dart'; import 'package:starsync/src/update/update_controller.dart';
// Work dir base: WT_TEST_TMP or the system temp. Staging lives inside // Work dir base: WT_TEST_TMP or the system temp. Staging lives inside
@@ -257,5 +259,85 @@ void main() {
expect(await File('${root.path}/watch_together').readAsString(), expect(await File('${root.path}/watch_together').readAsString(),
'binary-v1'); 'binary-v1');
}, skip: _posixOnly); }, skip: _posixOnly);
test('channel: dev offers a newer release; stable in a test build asks before going back', () async {
var channel = UpdateChannel.dev;
final root = await _fakeRoot(work);
final restarts = <String>[];
// The dev manifest is missing (404): only the stable 0.2.0 is read.
final c = UpdateController(
currentVersion: '0.2.0-dev.3',
manifestUrl: server.manifestUrl,
devUrl: '${server.base}/dev.json',
executablePath: '${root.path}/watch_together',
restart: (exe, _) async => restarts.add(exe))
..channelOf = () => channel;
await c.checkOnce();
expect(c.status.value.phase, UpdatePhase.available);
expect(c.status.value.manifest!.version, '0.2.0');
expect(c.stableBehind.value, isNull);
final behind = UpdateController(
currentVersion: '0.3.0-dev.1',
manifestUrl: server.manifestUrl,
devUrl: '${server.base}/dev.json',
executablePath: '${root.path}/watch_together',
restart: (exe, _) async => restarts.add(exe))
..channelOf = () => channel;
await behind.checkOnce();
expect(behind.status.value.phase, UpdatePhase.idle); // dev: 0.2.0 is older
expect(behind.stableBehind.value, isNull);
channel = UpdateChannel.stable;
await behind.checkNow();
expect(behind.status.value.phase, UpdatePhase.idle); // never offered by itself
expect(behind.stableBehind.value?.version, '0.2.0');
expect(behind.lastCheck.value, contains('older than this test build'));
final m = await behind.stableToGoBackTo();
expect(m?.version, '0.2.0');
await behind.goBackTo(m!);
expect(behind.status.value.phase, UpdatePhase.restarting);
expect(await File('${root.path}/starsync').readAsString(), 'binary-v2');
expect(restarts, hasLength(1));
}, skip: _posixOnly);
});
group('update channel setting', () {
late Directory dir;
setUp(() async {
dir = await _work();
Hive.init('${dir.path}/hive');
});
tearDown(() async {
await Hive.close();
await dir.delete(recursive: true);
});
Future<AppSettings> load(String version) async {
await Hive.close();
final s = AppSettings(runningVersion: version, channelFile: File('${dir.path}/update-channel'));
await s.ensureLoaded();
return s;
}
test('a test build keeps Dev across an update to a release; a choice stays', () async {
expect((await load('1.3.2')).updateChannel.value, UpdateChannel.stable);
// Stable by default is not stored: a test build later still starts on Dev.
expect((await load('1.4.0-dev.1')).updateChannel.value, UpdateChannel.dev);
// Updated to the release: stays on Dev.
final s = await load('1.4.0');
expect(s.updateChannel.value, UpdateChannel.dev);
s.setUpdateChannel(UpdateChannel.stable);
await Future<void>.delayed(const Duration(milliseconds: 50));
expect((await load('1.4.1-dev.1')).updateChannel.value, UpdateChannel.stable);
});
test('the update-channel file sets the channel once and is deleted', () async {
expect((await load('1.3.2')).updateChannel.value, UpdateChannel.stable);
final f = File('${dir.path}/update-channel');
await f.writeAsString('dev\n');
expect((await load('1.3.2')).updateChannel.value, UpdateChannel.dev);
expect(f.existsSync(), isFalse);
expect((await load('1.3.2')).updateChannel.value, UpdateChannel.dev);
});
}); });
} }
+116
View File
@@ -0,0 +1,116 @@
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:starsync/src/platform/ports/player_host.dart' show RenderMode, fitOutputSize;
import 'package:starsync/src/screens/home/ui.dart' show devToolsFor;
import 'package:starsync/src/shell/video_view.dart';
// The video view's asks of mpv: the output size ("Sharp video and
// subtitles") and the dev experiment's subtitle lift.
void main() {
group('videoOutputSize', () {
test('the view in physical pixels on the GPU', () {
expect(videoOutputSize(const Size(1280, 720), 1.5, on: true, mode: RenderMode.gpu), (1920, 1080));
expect(videoOutputSize(const Size(384, 216), 2, on: true, mode: RenderMode.gpu), (768, 432));
});
test("the video's own size when off, not on the GPU yet, or in software", () {
expect(videoOutputSize(const Size(1280, 720), 1, on: false, mode: RenderMode.gpu), isNull);
for (final m in [RenderMode.unknown, RenderMode.software, RenderMode.softwareGl]) {
expect(videoOutputSize(const Size(1280, 720), 1, on: true, mode: m), isNull, reason: m.name);
}
expect(videoOutputSize(const Size(4, 4), 1, on: true, mode: RenderMode.gpu), isNull);
});
});
group('fitOutputSize', () {
test('the picture fitted in the view, no bars', () {
expect(fitOutputSize((1920, 1080), (1280, 720)), (1920, 1080));
// 16:9 on a 2560x1080 screen: pillarboxed, full height.
expect(fitOutputSize((2560, 1080), (1280, 720)), (1920, 1080));
// The chat panel narrows the view: letterboxed, full width.
expect(fitOutputSize((880, 720), (1280, 720)), (880, 494));
expect(fitOutputSize((1280, 720), null), (1280, 720));
expect(fitOutputSize((1280, 720), (0, 0)), (1280, 720));
});
test('the bars split evenly: the picture sits on whole pixels', () {
for (final (view, video) in [((881, 720), (1280, 720)), ((1000, 999), (1920, 800)), ((1366, 768), (720, 576))]) {
final (w, h) = fitOutputSize(view, video);
expect(w <= view.$1 && h <= view.$2, isTrue);
expect((view.$1 - w).isEven && (view.$2 - h).isEven, isTrue, reason: '$view $video -> ($w, $h)');
expect(w == view.$1 || h == view.$2, isTrue, reason: 'one side fills the view');
}
});
});
group('subtitleLiftPos', () {
test('a picture filling the view lifts by the controls', () {
// 108 px of controls + 8 px gap in a 1080 px picture: 10.74 %.
final pos = subtitleLiftPos(view: const Size(1920, 1080), videoW: 1920, videoH: 1080, controls: 108);
expect(pos, closeTo(100 - 116 / 1080 * 100, 1e-9));
});
test('a letterbox under the picture counts toward the room', () {
// 2.39:1 in 16:9: 803 px picture, 138 px bar below it.
final none = subtitleLiftPos(view: const Size(1920, 1080), videoW: 1920, videoH: 803, controls: 100);
expect(none, 100);
final some = subtitleLiftPos(view: const Size(1920, 1080), videoW: 1920, videoH: 803, controls: 200);
expect(some, lessThan(100));
});
test('unknown video size: as if it filled the view; never past half', () {
expect(subtitleLiftPos(view: const Size(800, 450), controls: 90), closeTo(100 - 98 / 450 * 100, 1e-9));
expect(subtitleLiftPos(view: const Size(800, 100), controls: 500), 50);
expect(subtitleLiftPos(view: Size.zero, controls: 90), 100);
});
});
group('subtitle lift steps', () {
test('a few large eased-out steps, the last exactly on target', () {
final up = liftSteps(0, 100);
expect(up, hasLength(liftStepCount));
expect(up.last, 100);
// Eased out: the first step moves most, each one less.
for (var i = 1; i < up.length; i++) {
expect(up[i], greaterThan(up[i - 1]));
if (i > 1) expect(up[i] - up[i - 1], lessThan(up[i - 1] - (i > 1 ? up[i - 2] : 0)));
}
expect(up.first, greaterThan(30), reason: 'most of the way at once');
final down = liftSteps(100, 0);
expect(down.last, 0);
expect(down.first, lessThan(70));
expect(liftSteps(40, 40.2), isEmpty);
});
testWidgets('SubtitleLift steps toward the latest target, liftStepGap apart', (tester) async {
final lift = SubtitleLift();
addTearDown(lift.dispose);
final seen = <double>[];
lift.addListener(() => seen.add(lift.value));
lift.animateTo(80);
expect(seen, hasLength(1), reason: 'the first step at once');
await tester.pump(liftStepGap * (liftStepCount - 1));
expect(seen, liftSteps(0, 80));
expect(lift.value, 80);
await tester.pump(liftStepGap * 4);
expect(seen, hasLength(liftStepCount), reason: 'nothing after the target');
// A new target mid-way starts from where it is.
seen.clear();
lift.animateTo(0);
await tester.pump(liftStepGap);
final from = lift.value, n = seen.length;
expect(from, inExclusiveRange(0, 80));
lift.animateTo(80);
await tester.pump(liftStepGap * liftStepCount);
expect(seen.sublist(n), liftSteps(from, 80));
});
});
test('dev tools: dev versions and debug runs only', () {
expect(devToolsFor(version: '1.3.3-dev.2', debug: false), isTrue);
expect(devToolsFor(version: '0.0.0-dev', debug: false), isTrue);
expect(devToolsFor(version: '1.3.2', debug: true), isTrue);
expect(devToolsFor(version: '1.3.2', debug: false), isFalse);
});
}
+116
View File
@@ -0,0 +1,116 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:sync_relay_core/core.dart';
import 'package:starsync/src/watch/watch_history.dart';
import 'package:starsync/src/watch/watch_tracker.dart';
// Watch tracking (D3): one watch per media of the server, time In from the
// presence the server reports, the device history for files and links,
// every closed watch out on `records` (the seam for AniList later).
final _t0 = DateTime.utc(2026, 1, 1);
void main() {
late WatchTracker tracker;
late List<WatchRecord> records;
setUp(() {
tracker = WatchTracker(history: WatchHistory(persist: false));
records = [];
tracker.records.listen(records.add);
});
tearDown(() => tracker.dispose());
const episode = {'name': 'Bleach - 064.mkv', 'size': 100, 'duration': 1440, 'ref': {'kind': 'episode', 'title': 'Bleach', 'absolute': 64}};
/// [secs] seconds of media [mediaId] from wall second [t], playing,
/// with self [present] or not.
int run(int mediaId, Map<String, Object?> media, int t, int secs,
{bool present = true, bool playing = true, String? ownFile, double from = 0}) {
for (var i = 0; i <= secs; i++) {
tracker.sample(WatchSample(
mediaId: mediaId,
media: media,
ownFile: ownFile,
position: from + i,
playing: playing,
present: present,
duration: 1440,
at: _t0.add(Duration(seconds: t + i)),
));
}
return t + secs + 1;
}
test('a file watched through goes into the history as watched; the next media closes the watch', () async {
const path = '/lib/Bleach/Bleach - 064.mkv';
final t = run(2, episode, 0, 1200, ownFile: path);
run(3, const {'name': 'next', 'size': 1}, t, 1);
await Future<void>.delayed(Duration.zero);
expect(records.first.key, WatchKey.file(path));
expect(records.first.verdict.outcome, WatchOutcome.played);
expect(records.first.ref?.absolute, 64);
final e = tracker.history.entry(WatchKey.file(path))!;
expect((e.played, e.resumeAt, e.title), (true, 0.0, 'Bleach - 064.mkv'));
expect(tracker.mediaId, 3);
});
test('sat out for most of it: not watched (P7), a resume point instead', () async {
const url = 'https://example.com/v.mp4';
final media = {'name': 'v', 'size': 0, 'duration': 1440, 'url': url};
var t = run(2, media, 0, 300);
t = run(2, media, t, 1100, present: false, from: 300);
tracker.close(at: _t0.add(Duration(seconds: t)));
await Future<void>.delayed(Duration.zero);
expect(records.single.verdict.outcome, WatchOutcome.partial);
expect(records.single.timeIn, closeTo(300, 2));
final e = tracker.history.entry(WatchKey.link(url))!;
expect(e.played, isFalse);
expect(e.resumeAt, closeTo(300, 1), reason: 'where this viewer was when it sat out');
});
test('a peek changes nothing; a played item stays played', () async {
const path = '/lib/x.mkv';
run(2, episode, 0, 1200, ownFile: path);
tracker.close();
expect(tracker.history.entry(WatchKey.file(path))!.played, isTrue);
run(4, episode, 0, 20, ownFile: path);
expect(tracker.session!.playedBefore, isTrue, reason: 'the history says it was watched');
tracker.close();
expect(tracker.history.entry(WatchKey.file(path))!.played, isTrue);
final other = WatchKey.of(const {'name': 'peeked', 'size': 5})!;
run(5, const {'name': 'peeked', 'size': 5, 'duration': 1440}, 0, 20);
tracker.close();
expect(tracker.history.entry(other), isNull);
});
test('Jellyfin: the watch is shared with its reports, not kept in the device history', () async {
final media = {
'name': 'ep',
'size': 0,
'duration': 1440,
'jellyfin': {'serverId': 'S', 'itemId': 'i'},
};
run(2, media, 0, 10);
tracker.before(played: true, resumeAt: 0);
expect(tracker.verdict!.outcome, WatchOutcome.keepPlayed);
tracker.close();
await Future<void>.delayed(Duration.zero);
expect(records.single.key.isJellyfin, isTrue);
expect(tracker.history.entries, isEmpty);
});
test('keys: own file, Jellyfin, own or shared link, else name and size', () {
expect(WatchKey.of(const {'name': 'a', 'size': 1}, ownFile: '/x.mkv'), WatchKey.file('/x.mkv'));
expect(
WatchKey.of(const {
'name': 'a',
'jellyfin': {'serverId': 'S', 'itemId': 'i'}
}),
WatchKey.jellyfin(const JellyfinRef(serverId: 'S', itemId: 'i')));
expect(WatchKey.of(const {'name': 'a', 'relay': {}}, ownLink: 'https://x/y'), WatchKey.link('https://x/y'));
expect(WatchKey.of(const {'name': 'a', 'url': 'https://x/z'}), WatchKey.link('https://x/z'));
expect(WatchKey.of(const {'name': 'a', 'size': 3}), WatchKey.media('a', 3));
expect(WatchKey.of(null), isNull);
});
}
+178
View File
@@ -0,0 +1,178 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:sync_relay_core/core.dart';
import 'package:starsync/src/screens/home/update_indicator.dart';
import 'package:starsync/src/update/update_controller.dart';
import 'package:starsync/src/update/whats_new.dart';
import 'package:starsync/src/widgets/whats_new_card.dart';
const _changes = ReleaseChanges(
added: ['Danmaku style notifications', 'Bilibili style chat box'],
changed: ['A cleaner mini player'],
fixed: ['Optimized relay', 'Various bug fixes'],
);
UpdateManifest _manifest({String version = '1.3.3', ReleaseChanges? changes = _changes, String notes = 'Chat, relay'}) =>
UpdateManifest(version: version, protocol: 1, url: 'http://x/a.tgz', notes: notes, changes: changes);
UpdateController _controller({String current = '1.3.2'}) => UpdateController(
currentVersion: current,
executablePath: '/nowhere/starsync',
restart: (_, _) async {},
beforeRestart: (_) async {});
Widget _app(Widget child) => MaterialApp(theme: ThemeData.dark(), home: Scaffold(body: child));
void main() {
group('card', () {
testWidgets('sections in order with their items, the summary, Nice closes', (tester) async {
await tester.pumpWidget(_app(Builder(
builder: (context) => TextButton(
onPressed: () => showWhatsNew(
context, const ReleaseNotes(version: '1.3.3', notes: 'Chat, relay', changes: _changes)),
child: const Text('open'),
),
)));
await tester.tap(find.text('open'));
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('whats-new-card')), findsOneWidget);
expect(find.text("What's new in 1.3.3"), findsOneWidget);
expect(find.text('Chat, relay'), findsOneWidget);
final heads = [for (final k in ChangeKind.values) tester.getTopLeft(find.byKey(ValueKey('whats-new-${k.name}'))).dy];
expect(heads, orderedEquals([...heads]..sort()), reason: 'Added, Changed, Fixed');
for (final item in [..._changes.added, ..._changes.changed, ..._changes.fixed]) {
expect(find.text(item), findsOneWidget);
}
await tester.tap(find.byKey(const ValueKey('whats-new-close')));
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('whats-new-card')), findsNothing);
});
testWidgets('notes only (a manifest from before the changelog): one paragraph, no sections', (tester) async {
await tester.pumpWidget(_app(const Center(
child: WhatsNewCard(notes: ReleaseNotes(version: '1.3.2', notes: 'Fixes Windows closing by itself.')))));
await tester.pumpAndSettle();
expect(find.text('Fixes Windows closing by itself.'), findsOneWidget);
for (final k in ChangeKind.values) {
expect(find.byKey(ValueKey('whats-new-${k.name}')), findsNothing);
}
});
testWidgets('a long list scrolls inside a short window, no overflow', (tester) async {
tester.view.physicalSize = const Size(800, 420);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
final many = ReleaseChanges(fixed: [for (var i = 0; i < 40; i++) 'Fix number $i']);
await tester.pumpWidget(_app(Center(child: WhatsNewCard(notes: ReleaseNotes(version: '2.0.0', changes: many)))));
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
expect(tester.getSize(find.byKey(const ValueKey('whats-new-card'))).height, lessThanOrEqualTo(420 - 64));
await tester.drag(find.byKey(const ValueKey('whats-new-list')), const Offset(0, -2000));
await tester.pumpAndSettle();
expect(find.text('Fix number 39'), findsOneWidget);
});
});
group('band', () {
Future<UpdateController> pump(WidgetTester tester, UpdateManifest m) async {
tester.view.physicalSize = const Size(1280, 800);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
final c = _controller();
c.status.value = UpdateStatus(UpdatePhase.available, manifest: m, canInstall: true);
await tester.pumpWidget(_app(Column(children: [UpdateBar(controller: c)])));
return c;
}
testWidgets('with changes: the first items, What\'s new opens the card with Update and restart', (tester) async {
await pump(tester, _manifest());
final preview = tester.widget<Text>(find.byKey(const ValueKey('whats-new-preview'))).textSpan!.toPlainText();
expect(preview, contains('You are running 1.3.2'));
expect(preview, contains('Danmaku style notifications'));
expect(preview, contains('A cleaner mini player'));
expect(preview, isNot(contains('Optimized relay')), reason: 'three at most');
expect(find.text('You are running 1.3.2 · Chat, relay'), findsNothing);
await tester.tap(find.byKey(const ValueKey('update-whats-new')));
await tester.pumpAndSettle();
expect(find.text("What's new in 1.3.3"), findsOneWidget);
expect(find.text('Various bug fixes'), findsOneWidget);
expect(find.byKey(const ValueKey('whats-new-install')), findsOneWidget);
});
testWidgets('without changes (old manifest): the notes line, What\'s new shows them', (tester) async {
await pump(tester, _manifest(changes: null));
expect(find.byKey(const ValueKey('whats-new-preview')), findsNothing);
expect(find.text('You are running 1.3.2 · Chat, relay'), findsOneWidget);
await tester.tap(find.byKey(const ValueKey('update-whats-new')));
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('whats-new-card')), findsOneWidget);
expect(find.text('Chat, relay'), findsWidgets);
});
testWidgets('nothing to tell: no What\'s new button', (tester) async {
await pump(tester, _manifest(changes: null, notes: ''));
expect(find.byKey(const ValueKey('update-whats-new')), findsNothing);
expect(find.byKey(const ValueKey('update-install')), findsOneWidget);
});
});
group('show once', () {
test('after an install: due at the first start, never again once shown', () async {
final store = MemoryWhatsNewStore(seen: '1.3.2');
// The old version keeps the new one's notes before restarting.
await WhatsNew(currentVersion: '1.3.2', store: store).installing(_manifest());
expect(store.notes!.version, '1.3.3');
final first = WhatsNew(currentVersion: '1.3.3', store: store)..start(_controller(current: '1.3.3'));
expect(first.due.value?.version, '1.3.3');
expect(first.due.value?.changes?.fixed, _changes.fixed);
first.shown();
expect(first.due.value, isNull);
expect(store.seen, '1.3.3');
final second = WhatsNew(currentVersion: '1.3.3', store: store)..start(_controller(current: '1.3.3'));
expect(second.due.value, isNull);
expect(second.current?.version, '1.3.3', reason: 'About still opens it');
});
test('updated another way (1.3.x updater): the first check reading this version brings it', () {
final store = MemoryWhatsNewStore();
final c = _controller(current: '1.3.3');
final w = WhatsNew(currentVersion: '1.3.3', store: store)..start(c);
expect(w.due.value, isNull);
c.latest.value = _manifest(version: '1.3.4');
expect(w.due.value, isNull, reason: 'another version');
c.latest.value = _manifest();
expect(w.due.value?.version, '1.3.3');
expect(store.notes?.version, '1.3.3');
});
test('a fresh install shows nothing, but About has the notes once known', () {
final store = MemoryWhatsNewStore(fresh: true);
final c = _controller(current: '1.3.3');
final w = WhatsNew(currentVersion: '1.3.3', store: store)..start(c);
expect(store.seen, '1.3.3');
c.latest.value = _manifest();
expect(w.due.value, isNull);
expect(w.current?.version, '1.3.3');
});
test('notes of another version or empty notes are never due', () {
final store = MemoryWhatsNewStore(
seen: '1.3.2', notes: const ReleaseNotes(version: '1.3.4', changes: _changes));
expect((WhatsNew(currentVersion: '1.3.3', store: store)..start(_controller())).due.value, isNull);
final empty = MemoryWhatsNewStore(seen: '1.3.2', notes: const ReleaseNotes(version: '1.3.3'));
expect((WhatsNew(currentVersion: '1.3.3', store: empty)..start(_controller())).due.value, isNull);
});
test('kept notes survive a round trip through settings JSON', () {
final n = ReleaseNotes.of(_manifest());
final back = ReleaseNotes.fromJson(n.toJson())!;
expect(back.version, '1.3.3');
expect(back.notes, 'Chat, relay');
expect(back.changes!.toJson(), _changes.toJson());
expect(ReleaseNotes.fromJson({'notes': 'no version'}), isNull);
expect(ReleaseNotes.fromJson('junk'), isNull);
});
});
}
+146
View File
@@ -0,0 +1,146 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:media_kit/media_kit.dart';
import 'package:sync_relay_core/core.dart';
import 'package:sync_relay_server/server.dart';
import 'package:starsync/src/files/scanner.dart' show scanFolder;
import 'package:starsync/src/jellyfin/accounts.dart';
import 'package:starsync/src/jellyfin/track_prefs.dart';
import 'package:starsync/src/library/library_store.dart';
import 'package:starsync/src/library/local_next.dart';
import 'package:starsync/src/library/show_matcher.dart';
import 'package:starsync/src/screens/home/picked_media.dart';
import 'package:starsync/src/session/session_coordinator.dart';
import 'package:starsync/src/watch/watch_history.dart';
import 'package:starsync/src/watch/watch_tracker.dart';
import 'show_matcher_test.dart' show FakeAniList;
// What's next with real coordinators, real mpv and a dedicated server: a
// file of a local show put up by a member is continued by the show's next
// episode (numbers skipped on the way said), the server's countdown runs
// on every screen, the member who has the file puts it up, a cancel is
// shared, and what was watched goes into the device's watch history.
final _tmp = Platform.environment['WT_TEST_TMP'] ?? Directory.systemTemp.path;
final _show = '$_tmp/whats_next_e2e/Show';
/// `Show - NN.mp4`, 6 s each, with audio (headless mpv needs it) and the
/// index first.
Future<String> _episode(int n) async {
final path = '$_show/Show - ${n.toString().padLeft(2, '0')}.mp4';
if (await File(path).exists()) return path;
await Directory(_show).create(recursive: true);
final r = await Process.run('ffmpeg', [
'-y', '-v', 'error',
'-f', 'lavfi', '-i', 'testsrc2=size=320x180:rate=24',
'-f', 'lavfi', '-i', 'sine=frequency=${300 + 100 * n}:sample_rate=48000',
'-t', '6', '-c:v', 'libx264', '-preset', 'ultrafast', '-g', '24', '-pix_fmt', 'yuv420p',
'-c:a', 'aac', '-movflags', '+faststart', path,
]);
if (r.exitCode != 0) fail('ffmpeg failed: ${r.stderr}');
return path;
}
Future<void> _poll(FutureOr<bool> Function() cond, String what,
{Duration timeout = const Duration(seconds: 30)}) async {
final deadline = DateTime.now().add(timeout);
while (DateTime.now().isBefore(deadline)) {
if (await cond()) return;
await Future.delayed(const Duration(milliseconds: 200));
}
fail('timed out waiting for $what');
}
void main() {
late SyncServer server;
late List<SessionCoordinator> all;
late List<Object> serverErrors;
Future<SessionCoordinator> member(String name, {Future<LocalNext?> Function(String)? nextFile}) async {
final acc = JellyfinAccounts(store: MemoryJellyfinStore());
await acc.ensureLoaded(validate: false);
final c = SessionCoordinator(
jellyfin: acc,
trackPrefs: TrackPrefs(store: MemoryJellyfinStore()),
watch: WatchTracker(history: WatchHistory(persist: false)))
..nextFile = nextFile;
all.add(c);
await c.connect(ConnectParams(host: '127.0.0.1', port: server.boundPort, secret: 'pw', name: name));
await c.playback.player.setVolume(0);
return c;
}
setUp(() async {
MediaKit.ensureInitialized();
all = [];
server = SyncServer(const AppConfig(port: 0, secret: 'pw', name: 'vps'));
// A relay read cut short at a swap or the stop is reported to the
// zone the server started in (DEVELOPMENT "Traps").
serverErrors = [];
await runZonedGuarded(server.start, (e, _) {
if ('$e'.contains('Content size below specified contentLength') || '$e'.contains('did not arrive')) return;
serverErrors.add(e);
});
});
tearDown(() async {
for (final x in all) {
await x.dispose();
}
await server.stop();
expect(serverErrors, isEmpty, reason: 'server errors');
});
test('a file of a show continues with its next episode; the countdown and a cancel are shared',
timeout: const Timeout(Duration(minutes: 3)), () async {
final ep1 = await _episode(1);
for (final n in [3, 4]) {
await _episode(n);
}
final matcher = ShowMatcher(store: MemoryLibraryStore(), aniList: FakeAniList(), delay: (_) async {});
final alice = await member('alice', nextFile: LocalNextFinder(matcher: matcher, scan: scanFolder).nextAfter);
final bob = await member('bob');
expect(await alice.swapPicked(FileMedia(ep1)), isNull);
await _poll(() => bob.queue.continues.peek() != null, 'the continuation, for everyone');
final next = bob.queue.continues.peek()!;
expect((next.how, next.by, next.auto), (QueueHow.file, alice.connection.memberId, true));
expect(next.media.name, 'Show - 03.mp4');
expect(next.sub, contains('skips 2'));
expect(bob.queue.items.peek(), isEmpty, reason: 'it is not an Up next item of its own');
// Both ready: it plays to its end; the countdown is on both screens.
await _poll(() => [alice, bob].every((x) => x.playback.durationSecs > 5), 'both opened episode 1');
final first = alice.serverState.peek()!.mediaId;
for (final x in [alice, bob]) {
x.toggleReady();
}
await _poll(() => alice.serverState.peek()?.transport == 'finished', 'episode 1 ended');
await _poll(() => [alice, bob].every((x) => (x.queue.countdown.peek()?.secondsLeft ?? 0) > 0), 'the countdown');
expect(bob.queue.countdown.peek()!.item.id, next.id);
await _poll(() => (bob.serverState.peek()?.mediaId ?? 0) > first, 'episode 3 up', timeout: const Duration(seconds: 20));
expect(bob.serverState.peek()!.media!['name'], 'Show - 03.mp4');
expect(bob.serverState.peek()!.media!['swapBy'], alice.connection.memberId, reason: 'who has the file put it up');
// Watched through: in the device history of both (alice's by its path,
// bob's by its name and size), recorded as the next one came up.
await _poll(() => alice.watch.history.entry(WatchKey.file(ep1))?.played ?? false, "alice's history");
await _poll(() => bob.watch.history.entries.any((e) => e.$2.played), "bob's history");
// Episode 3 continues with 4; at its end bob cancels: for everyone.
await _poll(() => alice.queue.continues.peek()?.media.name == 'Show - 04.mp4', 'episode 4 next');
await _poll(() => [alice, bob].every((x) => x.playback.durationSecs > 5 && x.playback.player.state.playing),
'both play episode 3', timeout: const Duration(seconds: 20));
final third = alice.serverState.peek()!.mediaId;
await _poll(() => (alice.queue.countdown.peek()?.secondsLeft ?? 0) > 0, 'the countdown again');
bob.queue.cancelCountdown();
await _poll(() => alice.queue.countdown.peek()?.secondsLeft == null && alice.queue.continues.peek() == null,
'cancelled for alice too');
await Future.delayed(const Duration(seconds: 12));
expect(alice.serverState.peek()!.mediaId, third, reason: 'nothing came up');
expect(alice.serverState.peek()!.transport, 'finished');
});
}
+87 -10
View File
@@ -34,6 +34,14 @@ class _Cdn {
final Map<String, String> _files; final Map<String, String> _files;
int ok = 0, forbidden = 0; int ok = 0, forbidden = 0;
final cookies = <String?>[]; final cookies = <String?>[];
final agents = <String?>[];
/// Like Bilibili cutting a long read: a response stops after this many
/// bytes (the connection dropped), and the next request after a cut is
/// refused with 412 (where ffmpeg's reconnect gives up). Null: whole.
int? cutAfter;
bool _cut = false;
int cuts = 0, refused = 0;
String url(String name) => 'http://127.0.0.1:${_server.port}/$name'; String url(String name) => 'http://127.0.0.1:${_server.port}/$name';
@@ -61,12 +69,21 @@ class _Cdn {
await res.close(); await res.close();
return; return;
} }
if (_cut) {
_cut = false;
refused++;
res.statusCode = HttpStatus.preconditionFailed;
await res.close();
return;
}
ok++; ok++;
cookies.add(req.headers.value('cookie')); cookies.add(req.headers.value('cookie'));
agents.add(req.headers.value('user-agent'));
final bytes = await File(file).readAsBytes(); final bytes = await File(file).readAsBytes();
var start = 0, end = bytes.length - 1; var start = 0, end = bytes.length - 1;
final range = RegExp(r'bytes=(\d*)-(\d*)').firstMatch(req.headers.value('range') ?? ''); final range = RegExp(r'bytes=(\d*)-(\d*)').firstMatch(req.headers.value('range') ?? '');
res.headers.set('accept-ranges', 'bytes'); res.headers.set('accept-ranges', 'bytes');
res.headers.contentType = ContentType('video', 'mp4');
if (range != null) { if (range != null) {
start = int.tryParse(range[1]!) ?? 0; start = int.tryParse(range[1]!) ?? 0;
end = int.tryParse(range[2]!) ?? end; end = int.tryParse(range[2]!) ?? end;
@@ -74,6 +91,23 @@ class _Cdn {
res.headers.set('content-range', 'bytes $start-$end/${bytes.length}'); res.headers.set('content-range', 'bytes $start-$end/${bytes.length}');
} }
res.contentLength = end - start + 1; res.contentLength = end - start + 1;
final cut = cutAfter;
if (cut != null && end - start + 1 > cut) {
// Headers by hand on the raw socket, part of the body, then dropped.
final socket = await res.detachSocket(writeHeaders: false);
final status = range == null ? '200 OK' : '206 Partial Content';
socket.add(utf8.encode('HTTP/1.1 $status\r\naccept-ranges: bytes\r\ncontent-type: video/mp4\r\n'
'${range == null ? '' : 'content-range: bytes $start-$end/${bytes.length}\r\n'}'
'content-length: ${end - start + 1}\r\nconnection: close\r\n\r\n'));
socket.add(bytes.sublist(start, start + cut));
_cut = true;
cuts++;
try {
await socket.flush();
} catch (_) {}
socket.destroy();
return;
}
res.add(bytes.sublist(start, end + 1)); res.add(bytes.sublist(start, end + 1));
await res.close(); await res.close();
} }
@@ -88,16 +122,19 @@ Future<AppYtDlp> _fakeYtDlp(_Cdn cdn, {required bool headers, bool fail = false}
if (dir.existsSync()) dir.deleteSync(recursive: true); if (dir.existsSync()) dir.deleteSync(recursive: true);
dir.createSync(recursive: true); dir.createSync(recursive: true);
final h = headers ? {'Referer': _referer, 'User-Agent': 'fake-agent'} : <String, String>{}; final h = headers ? {'Referer': _referer, 'User-Agent': 'fake-agent'} : <String, String>{};
final json = jsonEncode({ final formats = [
'id': 'BV1', 'title': 'Fake page', 'duration': 30, 'extractor': 'fake',
'format_id': 'v+a', 'ext': 'mp4',
'requested_formats': [
{'format_id': 'v', 'url': cdn.url('v.m4s'), 'protocol': 'https', 'ext': 'mp4', {'format_id': 'v', 'url': cdn.url('v.m4s'), 'protocol': 'https', 'ext': 'mp4',
'vcodec': 'avc1.64001F', 'acodec': 'none', 'width': 320, 'height': 180, 'http_headers': h, 'vcodec': 'avc1.64001F', 'acodec': 'none', 'width': 320, 'height': 180, 'http_headers': h,
'cookies': 'SESSDATA=abc; Domain=.127.0.0.1; Path=/'}, 'cookies': 'SESSDATA=abc; Domain=.127.0.0.1; Path=/'},
{'format_id': 'a', 'url': cdn.url('a.m4s'), 'protocol': 'https', 'ext': 'm4a', {'format_id': 'a', 'url': cdn.url('a.m4s'), 'protocol': 'https', 'ext': 'm4a',
'vcodec': 'none', 'acodec': 'mp4a.40.2', 'http_headers': h}, 'vcodec': 'none', 'acodec': 'mp4a.40.2', 'http_headers': h,
], 'cookies': 'SESSDATA=abc; Domain=.127.0.0.1; Path=/'},
];
final json = jsonEncode({
'id': 'BV1', 'title': 'Fake page', 'duration': 30, 'extractor': 'fake',
'format_id': 'v+a', 'ext': 'mp4',
'formats': formats,
'requested_formats': formats,
'requested_subtitles': { 'requested_subtitles': {
'en': {'url': cdn.url('en.vtt'), 'ext': 'vtt', 'name': 'English'}, 'en': {'url': cdn.url('en.vtt'), 'ext': 'vtt', 'name': 'English'},
'danmaku': {'url': cdn.url('danmaku.xml'), 'ext': 'xml'}, 'danmaku': {'url': cdn.url('danmaku.xml'), 'ext': 'xml'},
@@ -124,8 +161,9 @@ void main() {
tearDownAll(() => server.close(force: true)); tearDownAll(() => server.close(force: true));
// A page "everyone fetches" opens through the app's own resolve: its // A page "everyone fetches" opens through the app's own resolve: its
// stream URLs (an EDL of video + audio) with the CDN's headers; mpv's // stream URLs (video + audio) read by the app's own reader
// yt-dlp hook is off. // (SharedLinks.local) with the CDN's headers and cookies, mpv on its
// loopback EDL; mpv's yt-dlp hook is off.
test( test(
"a page plays from yt-dlp's stream URLs with each CDN's Referer and the cookies; subtitles but danmaku", "a page plays from yt-dlp's stream URLs with each CDN's Referer and the cookies; subtitles but danmaku",
timeout: const Timeout(Duration(minutes: 1)), timeout: const Timeout(Duration(minutes: 1)),
@@ -146,6 +184,8 @@ void main() {
expect(cdn.forbidden, 0, reason: 'every request had the Referer'); expect(cdn.forbidden, 0, reason: 'every request had the Referer');
expect(cdn.ok, greaterThanOrEqualTo(2)); expect(cdn.ok, greaterThanOrEqualTo(2));
expect(cdn.cookies, everyElement('SESSDATA=abc')); expect(cdn.cookies, everyElement('SESSDATA=abc'));
expect(m.shared.hasLocal(cdn.page('BV1fake')), isTrue);
expect(cdn.agents, everyElement(startsWith('fake-agent')), reason: 'the reader sends the format\'s headers');
expect(cdn.subtitles, 1, reason: 'the page\'s subtitles are added, danmaku is not'); expect(cdn.subtitles, 1, reason: 'the page\'s subtitles are added, danmaku is not');
await _until(() => p.tracks.peek().any((t) => t.isExternal), 'the subtitle track listed'); await _until(() => p.tracks.peek().any((t) => t.isExternal), 'the subtitle track listed');
} finally { } finally {
@@ -185,6 +225,43 @@ void main() {
skip: Platform.isLinux ? false : 'fake yt-dlp is a shell script', skip: Platform.isLinux ? false : 'fake yt-dlp is a shell script',
); );
// Bilibili cuts a long read and refuses the reconnect right after it
// (412): ffmpeg's own reconnect ends playback there ("Stream ends
// prematurely", "Operation not permitted"); the reader resumes after a
// back-off and the player never sees the cut.
test(
'a CDN that cuts reads and refuses the reconnect (412): played to the end without an error',
timeout: const Timeout(Duration(minutes: 1)),
() async {
final m = MediaPipeline(host: MediaKitHost()..init(), links: await _fakeYtDlp(cdn, headers: true));
final p = m.playback;
final errors = <String>[];
p.onLoadError = errors.add;
m.onError = (raw, {cookies}) => errors.add(raw);
cdn.cutAfter = 48 * 1024;
final cutsBefore = cdn.cuts;
try {
await m.start();
final page = cdn.page('BV4fake');
expect(await m.open(page), isTrue);
await _until(() => p.duration.peek() > Duration.zero, 'opened', secs: 20);
await p.player.setProperty('speed', '8');
await p.player.play();
await _until(() => p.positionSecs > 25 || p.player.state.completed, 'played to the end', secs: 30);
// ignore: avoid_print
print('cuts: ${cdn.cuts - cutsBefore}, refused: ${cdn.refused}, errors: $errors');
expect(cdn.cuts - cutsBefore, greaterThan(2), reason: 'the CDN did cut');
expect(cdn.refused, greaterThan(0));
expect(errors, isEmpty);
expect(m.shared.hasLocal(page), isTrue);
} finally {
cdn.cutAfter = null;
await m.dispose();
}
},
skip: Platform.isLinux ? false : 'fake yt-dlp is a shell script',
);
// D11: yt-dlp finds no video; the error names the cookies it used. // D11: yt-dlp finds no video; the error names the cookies it used.
test('a page yt-dlp finds no video on: the error says which cookies were used', () async { test('a page yt-dlp finds no video on: the error says which cookies were used', () async {
final m = MediaPipeline(host: MediaKitHost()..init(), links: await _fakeYtDlp(cdn, headers: true, fail: true)); final m = MediaPipeline(host: MediaKitHost()..init(), links: await _fakeYtDlp(cdn, headers: true, fail: true));
@@ -203,8 +280,8 @@ void main() {
}, skip: Platform.isLinux ? false : 'fake yt-dlp is a shell script'); }, skip: Platform.isLinux ? false : 'fake yt-dlp is a shell script');
} }
Future<void> _until(bool Function() cond, String what) async { Future<void> _until(bool Function() cond, String what, {int secs = 10}) async {
final end = DateTime.now().add(const Duration(seconds: 10)); final end = DateTime.now().add(Duration(seconds: secs));
while (!cond()) { while (!cond()) {
if (DateTime.now().isAfter(end)) fail('timed out waiting for $what'); if (DateTime.now().isAfter(end)) fail('timed out waiting for $what');
await Future.delayed(const Duration(milliseconds: 100)); await Future.delayed(const Duration(milliseconds: 100));
Loaded 100 of 154 files, more files were not shown because too many files have changed in this diff. Show more