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 14520 additions and 1624 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
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,
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
(never over a playing video), with its first changes. "What's new" lists
them all ([CHANGELOG.md](CHANGELOG.md)); the first start after an update
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:
`./starsync-server --upgrade` (no display needed, so it works on a
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
of the Library the first time; none needs Jellyfin or an account.
- **Join a friend** (most people): your friend sends you an invite (an
address) and, maybe, a password. See [Join](#join).
- **Join a friend** (most people): your friend sends you an invite (a
`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.
See [Your server and Invite](#your-server-and-invite).
- **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
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
server the invite with Copy invite and Stop sharing, on a friend's "Copy
invite address"). Beside them the server and "ready n/m" (who is sitting out or
server where friends can reach it, Copy invite link and Stop sharing,
on a friend's "Copy invite link"). Beside them the server and "ready n/m" (who is sitting out or
reconnecting does not count), the chat button with unread
count, and Invite on your server or Leave on a friend's. Empty strip
space drags the window.
@@ -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
party (there is nobody); the app's own notices still show.
**Quick chat.** Enter in the big player opens a small chat box at the
bottom centre without opening the chat panel, also while the video
plays: type (the player's keys wait meanwhile), Enter sends, Esc or an
empty Enter closes it, and it closes by itself after a few seconds
without typing. It is the same draft as the chat panel's field, so a
message started in one is there in the other. What you sent shows for a
moment where the box was (or flies as your own line with danmaku, below);
bottom subtitles move up out of its way and back, top signs stay. The
mouse pointer and the controls stay hidden: typing is not moving the
mouse.
**Danmaku.** Settings → Notifications & sounds → Over the player →
Notifications: Toasts (default) or Danmaku. With Danmaku, over the big
player chat, joins, leaves, Up next and "starting now" fly right to left
across the top of the picture instead of popping up (problems and errors
still pop up, and everything does while the player is small). The look
is yours to tune there, with a live preview: crossing time, how many at
once, which band of the picture they use, opacity, size, boldness,
outline, avatars, and a frame round your own lines.
**While sitting out or in the background.** Sitting out, only chat,
nudges, "starting now" (the video starts for the others) and Up next
reach you; joins and leaves too if you turn on Settings → Notifications &
@@ -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
list): that opens it on your network at the port from Settings → Party &
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 ·
Starsync 1.3.0") to send to friends. Nothing restarts: what plays and Up
next carry on, and you are the host (the crown). The invite picks this
computer's home-network address (not a VPN or proxy one, nor a Docker or
VM bridge). Friends elsewhere need the TCP port forwarded on your router
(or a VPN address, or a server they can all reach, like a VPS). Friends
open nothing. **Stop sharing** (in the member list) closes it again:
join) and copies an invite link (`starsync://203.0.113.7:8080/?name=…`,
just the link) to send to friends. Nothing restarts: what plays and Up next carry on, and you are
the host (the crown).
The link carries your internet address when the app can find it (it asks
a "what is my IP" service such as ipify over HTTPS when you share), else
this computer's home-network address (not a proxy one, nor a Docker or VM
bridge). In the member list, **Copy invite link** copies it again;
**Other addresses** there lets you pick another: **Internet** for friends
elsewhere, **Home network** for friends on the same Wi-Fi or router,
**VPN** (Tailscale and the like). Behind a router, friends elsewhere get
in only when the TCP port is forwarded on your router to this computer
(the card says so); the app does not open ports itself. The link
includes the password when one is set, so friends just click it: share
it only with them. Friends open nothing. **Stop sharing** (in the member list)
closes it again:
everyone else leaves (their app goes back to their own server and says
why), you and Up next stay.
@@ -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
are looked up on AniList for their proper name, cover and banner. A
show's page has its seasons and episodes and a button for the next one
after what you last played here; when episodes are missing on the way
it says so ("66–107 aren't in your folders — skip to 108?"). Matched
after what you last watched here; when episodes are missing on the way
it says so ("66–107 aren't in your folders — skip to 108?"). An
episode you put up continues with the next one at its end (see Up
next). Matched
the wrong show? **Wrong show?** searches AniList and pins the right one
for the show everywhere or just one folder; **Episode numbers…** says
which episode the first file is when the files count differently. Both
@@ -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.
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
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
@@ -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,
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
what is up next with a 10 s countdown (Cancel stops it on your screen)
and it plays; `>` or Shift+N plays it at once. Under the queue,
**Continues with** shows the next episodes of the Jellyfin series
playing (greyed: they play only once Up next is empty); **+** adds one,
**Clear** drops them for this episode.
what is up next with a 10 s countdown everyone sees, and it plays;
Cancel stops it for everyone, and so does seeking back. `>` or Shift+N
plays it at once. Under the queue, **Continues with** shows what
continues the episode playing: its next episode (a Jellyfin episode's,
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
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
server. Up next is kept by the server while it runs; restarting the
server empties it.
Foxyz left"). If you are only gone for a moment, the countdown waits for
you. A Jellyfin item needs someone with an account on that server. Up
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
@@ -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,
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)
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
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
@@ -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
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
right of its controls, and Settings → Player has the same ones (plus the
controls' layout and the custom shader files). Player 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); in the player also the subtitle and
audio track. Settings → Player → Controls: time display (elapsed / total
Player settings live in the player too: ⚙ → Player settings opens a
narrow panel at the right edge of the player that stays open while you
watch (the controls hide and show as usual) until its × or Esc. Its
sections fold: tracks (subtitles, audio), subtitles (size, above the
controls), picture (upscaling shader, sharp video, hardware decoding),
sound (volume), notifications (toasts or danmaku and the danmaku look)
and controls (design, progress line). Settings → Player has the same
ones (plus the controls' layout and the custom shader files). Player
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
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
@@ -380,8 +433,9 @@ Anywhere in a server (unless you are typing): Space = ready / unready,
Tab = chat & activity (Tab never moves between buttons). Anywhere:
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,
PgUp/PgDn = previous / next chapter (as in mpv), F = fullscreen, Esc =
close the chat, then fullscreen, then shrink the player to mini; from the
PgUp/PgDn = previous / next chapter (as in mpv), F = fullscreen, Enter =
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,
`<` / `>` 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.
@@ -473,10 +527,13 @@ Watching:
directly with their own sign-in. Jellyfin tracks progress per account:
your watched state, Continue watching and resume point follow what you
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
it (or reached the ending after half), not where you stopped: opening
a watched episode to check a moment keeps it watched, and a quick peek
leaves no resume point.
change, and when it stops). Watched means you were there while most
of it played (or while half of it played and it reached the ending),
not where it stopped: time you sat out does not count, skipping around
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
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
@@ -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
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
are up. When an
episode ends, a card shows what is next with Play next; with "Play the
next episode automatically" (Settings, on by default) it counts down 10 s
first, and Cancel stops it on your screen (seeking back stops it for
everyone). Anyone with an account can put the next one up; friends
watching via the host cannot.
are up. With "Play the next episode automatically" (Settings, on by
default), the episode you put up continues with the next one: when it
ends, everyone sees a 10 s countdown, and anyone can cancel it (see Up
next). Anyone with an account can put the next one up; friends watching
via the host cannot.
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
+41 -5
View File
@@ -10,16 +10,26 @@ import 'src/app/app_graph.dart';
import 'src/files/thumbnails.dart' show thumbnailPlayers;
import 'src/platform/app_log.dart';
import 'src/platform/app_shutdown.dart' show closeAppWith;
import 'src/platform/desktop/attention.dart' show exitNow, windowLaunchToken;
import 'src/platform/desktop/desktop_app_dirs.dart';
import 'src/platform/desktop/windows_api.dart' show allowSetForegroundWindow;
import 'src/platform/desktop_entry.dart';
import 'src/platform/ports/window_activity.dart';
import 'src/platform/single_instance.dart';
import 'src/platform/url_scheme.dart';
import 'src/platform/smoke.dart';
import 'src/screens/home/ui.dart';
import 'src/session/app_events.dart' show UpdateAvailable;
import 'src/session/invite.dart' show InviteLink;
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 {
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')) {
exitCode = await runUpgrade();
exit(exitCode);
@@ -44,6 +54,15 @@ Future<void> main(List<String> args) async {
);
WidgetsFlutterBinding.ensureInitialized();
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);
Hive.init(dirs.hive);
await AppSettings.instance.ensureLoaded();
@@ -52,30 +71,39 @@ Future<void> main(List<String> args) async {
final ports = graph.ports;
// Close button / Alt+F4: the orderly close ([shutDownApp]), so others
// see "left", your server shuts down and no player outlives the engine.
final close = closeAppWith(graph);
final close = closeAppWith(graph, instance: instance);
await ports.shell.start(
title: dirs.profile == null ? appName : '$appName [${dirs.profile}]', onCloseRequest: () => close());
// Focus, minimise and restore feed auto sit-out and the Notifier (in
// the background, notices go out as system notifications).
// Coming to the front also looks for an invite on the clipboard (the
// join prompt; nothing of it is logged).
ports.shell.watchActivity((a) {
graph.coordinator.windowActivity(a);
graph.notifier.window = a;
if (a == WindowActivity.focused) {
unawaited(ports.shell.readClipboardText().then(graph.joinOffers.fromClipboard));
}
});
try {
await graph.forms.load();
} catch (e, 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.watch.history.ensureLoaded());
ports.players.init();
thumbnailPlayers = ports.players;
// yt-dlp for web page links: downloaded on first run, kept current.
ports.links.warmUp();
// Linux: the launcher entry and icon Wayland needs (release installs).
unawaited(installDesktopEntry());
// Windows: starsync:// links start this install (HKCU, release builds).
unawaited(registerWindowsScheme());
// A found update is an event too (the band shows it).
final updates = UpdateController.instance;
final updates = graph.updates;
updates.status.addListener(() {
final s = updates.status.value;
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
// Invite).
unawaited(graph.navigation.start());
// A clicked invite link: the one this app started with, then each one a
// later start hands over (the window comes to the front for it).
graph.joinOffers.fromArgs(args);
instance.handoffs.listen((h) {
unawaited(ports.shell.focus(activation: h.activation));
graph.joinOffers.fromArgs(h.args);
});
unawaited(ports.shell.readClipboardText().then(graph.joinOffers.fromClipboard));
// The sound device opens once the window is up, not on the first sound.
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 '../library/library_store.dart';
import '../library/local_next.dart';
import '../library/show_matcher.dart';
import '../platform/app_log.dart' show appLogDir;
import '../platform/desktop/desktop_ports.dart';
@@ -11,6 +12,7 @@ import '../schedule/animeflow.dart';
import '../screens/home/health_probe.dart';
import '../session/connection.dart' show Connection;
import '../session/identity.dart' show ClientIdentity, Profile;
import '../session/join_offers.dart';
import '../session/navigation.dart';
import '../session/notifier.dart';
import '../session/session_coordinator.dart';
@@ -20,6 +22,10 @@ import '../sfx/sound_bank.dart';
import '../sfx/synth_sfx.dart';
import '../state/connect_prefs.dart';
import '../state/settings.dart';
import '../update/update_controller.dart';
import '../update/whats_new.dart';
import '../watch/watch_history.dart';
import '../watch/watch_tracker.dart';
/// The composition root: builds the app's systems once (in `main`), wires
/// them through their constructors and disposes them. No logic and no
@@ -51,6 +57,18 @@ class AppGraph {
/// The library's matches (L2): local shows to AniList, pins, offsets.
final ShowMatcher library;
/// Watch tracking and the device's watch history (D3).
final WatchTracker watch;
/// The app's updater (checks, the update band, install and restart).
final UpdateController updates;
/// What's new once after an update; keeps the notes the updater saves.
final WhatsNew whatsNew;
/// The join prompt for a clicked invite link or one on the clipboard.
final JoinOffers joinOffers;
AppGraph._({
required this.ports,
required this.sfx,
@@ -61,10 +79,15 @@ class AppGraph {
required this.yourServer,
required this.navigation,
required this.library,
required this.watch,
required this.updates,
required this.whatsNew,
required this.joinOffers,
});
/// The desktop app's graph. Tests pass their own [ports], [identity]
/// (default: the install's stored one), [healthGet] and server starter.
/// (default: the install's stored one), [healthGet], server starter and
/// internet address lookup ([publicAddress]).
factory AppGraph.build({
Ports? ports,
ClientIdentity? identity,
@@ -72,12 +95,15 @@ class AppGraph {
ServerStarter? serverStarter,
JellyfinAccounts? jellyfin,
ShowMatcher? library,
WatchHistory? history,
Future<String?> Function()? publicAddress,
}) {
final p = ports ?? desktopPorts();
final settings = AppSettings.instance;
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).
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,
);
final notifier = Notifier(
@@ -85,11 +111,19 @@ class AppGraph {
attention: p.shell.requestAttention,
system: p.notifications,
focusWindow: p.shell.focus,
prefs: () => AppSettings.instance.notifyPrefs,
prefs: () => settings.notifyPrefs,
);
final connection = Connection(notify: notifier.notify, identity: identity ?? ClientIdentity.stored());
// Device-owned: the watch history box.
final watch = WatchTracker(history: history ?? WatchHistory());
final coordinator = SessionCoordinator(
notifier: notifier, connection: connection, ytDlp: p.links, jellyfin: jellyfin, players: p.players);
notifier: notifier,
connection: connection,
ytDlp: p.links,
jellyfin: jellyfin,
players: p.players,
settings: settings,
watch: watch);
// Device-owned: the library box; AnimeFlow's runs when signed in.
final matcher = library ??
ShowMatcher(
@@ -97,7 +131,9 @@ class AppGraph {
aniList: AniListClient(),
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 yourServer = YourServer(
forms: forms,
@@ -113,6 +149,7 @@ class AppGraph {
},
logDir: () => appLogDir,
starter: serverStarter,
publicAddress: publicAddress,
);
final navigation = Navigation(
coordinator: coordinator,
@@ -127,6 +164,10 @@ class AppGraph {
..push = ({String? name, String? avatar}) {
if (navigation.online.peek()) coordinator.updateProfile(name: name, avatar: avatar);
};
final updates = UpdateController.instance;
final whatsNew = WhatsNew(store: SettingsWhatsNewStore(settings));
updates.beforeRestart ??= whatsNew.installing;
updates.channelOf ??= () => settings.updateChannel.peek();
return AppGraph._(
ports: p,
sfx: sfx,
@@ -137,12 +178,17 @@ class AppGraph {
yourServer: yourServer,
navigation: navigation,
library: matcher,
watch: watch,
updates: updates,
whatsNew: whatsNew,
joinOffers: JoinOffers(navigation: navigation, yourServer: yourServer, forms: forms),
);
}
Future<void> dispose() async {
navigation.dispose();
await coordinator.dispose();
await watch.dispose();
await yourServer.stop();
await sfx.dispose();
}
@@ -11,7 +11,6 @@ import '../widgets/copy_open.dart';
import 'frame.dart';
import 'model/controls_layout.dart';
import 'model/controls_model.dart';
import 'player_settings.dart';
// 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
@@ -129,7 +128,7 @@ Future<void> runControlAction(BuildContext context, PlayerControlsModel model, C
case ControlAction.markProblem:
await ControlsFrame.holdWhile(context, () => showMarkProblem(context));
case ControlAction.playerSettings:
await showPlayerSettings(context, renderMode: model.renderMode);
model.togglePanel();
case ControlAction.leave:
model.leave();
}
+47 -10
View File
@@ -1,5 +1,6 @@
import 'dart:async';
import 'package:flutter/foundation.dart' show ValueListenable;
import 'package:flutter/gestures.dart' show kPrimaryMouseButton;
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
// 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
// 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 {
/// Draws the controls; [visible] false while they are hidden.
@@ -28,6 +32,11 @@ class ControlsFrame extends StatefulWidget {
/// Keeps them up while held.
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({
super.key,
required this.builder,
@@ -37,6 +46,7 @@ class ControlsFrame extends StatefulWidget {
this.hideAfter = const Duration(seconds: 3),
this.shown,
this.holdBy,
this.suppress,
});
/// 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;
bool _heldBy = false;
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
void initState() {
super.initState();
widget.holdBy?.addListener(_holdByChanged);
widget.suppress?.addListener(_suppressChanged);
// Not during this build: an overlay listening to [shown] may be built.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) widget.shown?.value = true;
if (mounted) widget.shown?.value = visible;
});
_hideLater();
}
@@ -84,12 +101,17 @@ class ControlsFrameState extends State<ControlsFrame> {
widget.holdBy?.addListener(_holdByChanged);
_holdByChanged();
}
if (oldWidget.suppress != widget.suppress) {
oldWidget.suppress?.removeListener(_suppressChanged);
widget.suppress?.addListener(_suppressChanged);
}
if (oldWidget.hideAfter != widget.hideAfter && _visible) _hideLater();
}
@override
void dispose() {
widget.holdBy?.removeListener(_holdByChanged);
widget.suppress?.removeListener(_suppressChanged);
_timer?.cancel();
super.dispose();
}
@@ -101,15 +123,29 @@ class ControlsFrameState extends State<ControlsFrame> {
hold(on);
}
void _suppressChanged() {
if (!mounted) return;
setState(() {});
widget.shown?.value = visible;
}
/// Show the controls and restart the hide countdown.
void wake() {
if (!_visible && mounted) {
setState(() => _visible = true);
widget.shown?.value = true;
widget.shown?.value = visible;
}
_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).
void hold(bool on) {
_holds = on ? _holds + 1 : (_holds - 1).clamp(0, 1 << 20);
@@ -141,9 +177,10 @@ class ControlsFrameState extends State<ControlsFrame> {
Widget build(BuildContext context) {
return MouseRegion(
cursor: _visible ? SystemMouseCursors.basic : SystemMouseCursors.none,
onEnter: (_) => wake(),
onHover: (_) => wake(),
onExit: (_) {
onEnter: (e) => _moved(e.position),
onHover: (e) => _moved(e.position),
onExit: (e) {
_lastPos = e.position;
_timer?.cancel();
_hide();
},
@@ -154,12 +191,12 @@ class ControlsFrameState extends State<ControlsFrame> {
Positioned.fill(
child: DoubleClickListener(
onDoubleClick: widget.onDoubleClick!,
skipTop: _visible ? widget.barTop : 0,
skipBottom: _visible ? widget.barBottom : 0,
skipTop: visible ? widget.barTop : 0,
skipBottom: visible ? widget.barBottom : 0,
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(
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),
SignalBuilder(
builder: (context) {
@@ -28,6 +28,9 @@ abstract final class MinimalTimelineSpec {
/// What is left of it while they are hidden.
static const lineSize = 3.0;
/// The played part's accent tint.
static const played = 0.4;
/// The position line.
static const lineWidth = 2.0;
static const fontSize = 15.0;
@@ -467,13 +470,10 @@ class MinimalTimelinePainter extends CustomPainter {
final w = size.width, h = size.height;
final x = lineX(w);
final band = Offset.zero & size;
// The band, darker as it grows; hidden, a faint track.
canvas.drawRect(band, Paint()..color = Color.lerp(const Color(0x40FFFFFF), MinimalStyle.band, visibility)!);
// Played: an accent tint, solid once the band is a line.
canvas.drawRect(
Rect.fromLTRB(0, 0, x, h),
Paint()..color = accent.withValues(alpha: 0.28 + 0.72 * (1 - visibility)),
);
// The same colours shown and hidden: hidden, it is the band shrunk to
// a line, so growing and shrinking never switches colour.
canvas.drawRect(band, Paint()..color = MinimalStyle.band);
canvas.drawRect(Rect.fromLTRB(0, 0, x, h), Paint()..color = accent.withValues(alpha: MinimalTimelineSpec.played));
_stripes(canvas, w, h);
_chapters(canvas, w, h);
if (visibility > 0) {
@@ -500,7 +500,8 @@ class MinimalTimelinePainter extends CustomPainter {
canvas.save();
canvas.clipRect(Rect.fromLTRB(ax, 0, w, h));
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;
for (var c = (ax / period).floorToDouble() * period - h; c <= w; c += period) {
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) {
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) {
if (f <= 0) continue;
final cx = w * f;
@@ -81,13 +81,16 @@ class PlayerControlsModel {
/// Everything the layout depends on (not the position: that changes
/// four times a second and only the timeline needs it).
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;
return ControlsSnapshot(
play: play.value,
previousLabel: coord.previousEpisode.value?.label,
nextLabel: items.isNotEmpty ? items.first.title : next?.label,
nextIsUpNext: items.isNotEmpty,
// Up next's first item, else the next episode, else what continues
// this (a file's next episode).
nextLabel: queued?.title ?? next?.label ?? continues?.title,
nextIsUpNext: queued != null,
hasChapters: chapters.value.isNotEmpty,
hasDuration: duration.value > Duration.zero,
alone: coord.alone.value,
@@ -112,6 +115,12 @@ class PlayerControlsModel {
/// A menu of the controls is open (the player's Esc closes it).
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();
// -------------------------------------------------------------- actions
@@ -137,9 +146,10 @@ class PlayerControlsModel {
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() {
if (queue.items.peek().isNotEmpty) {
if (queue.head.peek() != null) {
queue.skip();
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: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 '../session/session_coordinator.dart' show SessionCoordinator;
import '../shaders/shader_presets.dart';
import '../app/app_graph.dart';
import '../shell/mark_problem.dart';
import '../shell/motion.dart';
import '../state/settings.dart';
import '../widgets/app_menu.dart';
import 'frame.dart';
import 'model/controls_prefs.dart';
// Everything about the player, defined once: the rows of the player
// settings card (⚙ → Player settings, both skins) and of Settings → Player. Each row reads and
// writes [AppSettings] and applies to a running player right away.
// Everything about the player, defined once: the rows of Settings →
// Player (the player panel, `player_panel.dart`, has compact rows for the
// 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
/// 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
/// running player, if any; the player announces the result.
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).
List<Widget> controlsSettingRows() => [
const TimeDisplaySetting(),
@@ -397,57 +370,38 @@ List<Widget> controlsSettingRows() => [
for (final c in OptionalControl.values) BarButtonSetting(control: c),
];
/// The player rows of Settings → Player and the player settings card, in
/// order.
/// The player rows of Settings → Player, in order (the player panel has
/// its own compact rows for the same settings).
List<Widget> playerSettingRows({ReadonlySignal<RenderMode>? renderMode}) => [
const PlayerDesignSetting(),
const ProgressLineSetting(),
ShaderPresetSetting(renderMode: renderMode),
ScreenResSetting(renderMode: renderMode),
const HwdecSetting(),
const SubScaleSetting(),
const LiftSubtitlesSetting(),
];
/// The player settings card: every player setting, plus the running
/// player's tracks.
class PlayerSettingsCard extends StatelessWidget {
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});
/// Subtitle size (mpv `sub-scale`, [AppSettings.subScale]). Live.
class SubScaleSetting extends StatelessWidget {
const SubScaleSetting({super.key});
@override
Widget build(BuildContext context) {
// The app's glass, like the Up next popover and the menus.
return AppGlass(
key: const ValueKey('player-settings-card'),
child: SizedBox(
width: 420,
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 10),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Text('Player', style: TextStyle(fontWeight: FontWeight.w600, fontSize: 14)),
const SizedBox(height: 4),
...playerSettingRows(renderMode: renderMode),
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(),
],
),
],
final s = AppScope.of(context).coordinator.settings;
return SignalBuilder(
builder: (context) => SettingRow(
label: 'Subtitle size',
help: '${(s.subScale.value * 100).round()} % of what the file sets',
control: SizedBox(
width: 220,
child: Slider(
key: const ValueKey('sub-scale'),
value: s.subScale.value,
min: 0.5,
max: 2,
divisions: 30,
onChanged: s.setSubScale,
),
),
),
@@ -455,14 +409,25 @@ class PlayerSettingsCard extends StatelessWidget {
}
}
/// Opens the player settings card over the player, near the controls'
/// right end; the controls stay up while it is open.
Future<void> showPlayerSettings(BuildContext context, {ReadonlySignal<RenderMode>? renderMode}) =>
ControlsFrame.holdWhile(
context,
() => showPopover<void>(
context,
at: const Alignment(0.96, 0.5),
child: PlayerSettingsCard(renderMode: renderMode),
/// Bottom subtitles above the control bar while it is up
/// ([AppSettings.liftSubtitles]). Live.
class LiftSubtitlesSetting extends StatelessWidget {
const LiftSubtitlesSetting({super.key});
@override
Widget build(BuildContext context) {
final s = AppScope.of(context).coordinator.settings;
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:signals/signals_flutter.dart';
@@ -34,6 +35,9 @@ class SessionControls extends StatelessWidget {
final ControlsHold hold;
final VoidCallback onDoubleClick;
final Widget? windowButtons;
/// Hides them while something takes their place ([ControlsFrame.suppress]).
final ValueListenable<bool>? suppress;
const SessionControls({
super.key,
required this.model,
@@ -41,6 +45,7 @@ class SessionControls extends StatelessWidget {
required this.hold,
required this.onDoubleClick,
this.windowButtons,
this.suppress,
});
@override
@@ -56,6 +61,7 @@ class SessionControls extends StatelessWidget {
key: ValueKey(skin),
shown: shown,
holdBy: hold,
suppress: suppress,
hideAfter: Duration(seconds: s.controlsHideSecs.value),
onDoubleClick: onDoubleClick,
barTop: controlsTopHeight(skin),
@@ -165,7 +165,7 @@ class StandardControls extends StatelessWidget {
return Row(
children: [
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(
builder: (context) {
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 '../../platform/ports/folder_access.dart';
import '../../app/app_graph.dart';
import '../../watch/watch_history.dart';
import '../../widgets/app_menu.dart';
import '../folder_store.dart';
import '../scanner.dart';
@@ -127,6 +128,7 @@ class _FilesPageState extends State<FilesPage> {
late final ThumbCache _thumbs =
widget.thumbnails == null ? ThumbCache.shared : ThumbCache(widget.thumbnails!);
FolderStore get _store => widget.store ?? FolderStore.instance;
WatchHistory get _history => AppScope.of(context).watch.history;
/// Null: Recent; [libraryPlace]: every folder.
String? _open = _lastOpen;
@@ -325,8 +327,15 @@ class _FilesPageState extends State<FilesPage> {
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) {
for (final (key, entry) in _history.entries) {
if (!entry.played || !key.value.startsWith('file:')) continue;
final e = lib.byPath[key.value.substring(5)];
if (e != null && e.showKey == show.key && e.kind == LocalKind.episode) return e.key;
}
for (final p in _store.recent.value) {
final e = lib.byPath[p];
if (e != null && e.showKey == show.key && e.kind == LocalKind.episode) return e.key;
@@ -342,6 +351,7 @@ class _FilesPageState extends State<FilesPage> {
final folders = _store.folders.value;
final recent = _store.recent.value;
_matcher.revision.value;
_history.revision.value;
final open = _open == null || _open == libraryPlace || folders.contains(_open) ? _open : libraryPlace;
final showKey = open == null ? null : _openShows[open];
final main = fadeThrough(
+15 -14
View File
@@ -26,13 +26,14 @@ class PlayerSnapshot {
/// (the session shows one quiet toast) and never touches playback. A 401
/// calls [onUnauthorized] and ends reporting.
///
/// Watched or not is [WatchSession]'s rule, not Jellyfin's position
/// percentages (see ARCHITECTURE.md "Jellyfin in a session"): an item
/// played before ([playedBefore]) stays played (re-marked after Playing,
/// Progress without a position, Stopped without one, then marked played
/// again); otherwise Progress leaves the position out past
/// [_noPositionFrom] of the runtime so Jellyfin cannot mark it played on
/// its own, and Stopped carries what the rule says.
/// Watched or not is [WatchSession]'s rule (time In, D3), not Jellyfin's
/// position percentages (see ARCHITECTURE.md "Watch tracking"): the
/// session's watch tracker feeds [watch] and this reporter only reads its
/// verdict. An item played before ([playedBefore]) stays played
/// (re-marked after Playing, Progress without a position, Stopped without
/// one, then marked played again); otherwise Progress leaves the position
/// out past [_noPositionFrom] of the runtime so Jellyfin cannot mark it
/// played on its own, and Stopped carries what the rule says.
class JellyfinProgressReporter {
final JellyfinClient client;
final String itemId;
@@ -43,7 +44,8 @@ class JellyfinProgressReporter {
final void Function(Object error)? onError;
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;
JellyfinProgressReporter({
@@ -57,12 +59,14 @@ class JellyfinProgressReporter {
this.onError,
this.onUnauthorized,
this.onStopped,
WatchSession? watch,
DateTime Function()? clock,
}) : _clock = clock ?? DateTime.now,
_keepPlayed = playedBefore,
_runtime = (source.runTimeTicks ?? 0) / jellyfinTicksPerSecond,
watch = WatchSession(
playedBefore: playedBefore, resumeBefore: resumeTicksBefore / jellyfinTicksPerSecond);
watch = (watch ?? WatchSession())
..playedBefore = playedBefore
..resumeBefore = resumeTicksBefore / jellyfinTicksPerSecond;
/// Past this share of the runtime Progress carries no position: Jellyfin
/// marks an item played past its "max resume" share (90 % by default).
@@ -126,7 +130,6 @@ class JellyfinProgressReporter {
if (_playing || _dead) return;
_playing = true;
_mark(s);
watch.sample(s.position, playing: !s.paused, at: _clock());
_send('Playing', () => client.reportPlaybackStart(reportFor(s)));
if (_keepPlayed) _send('played', () => client.markPlayed(itemId));
}
@@ -136,7 +139,6 @@ class JellyfinProgressReporter {
if (!_playing || _dead) return;
final last = _last, lastAt = _lastAt, sentAt = _lastSentAt;
final now = _clock();
watch.sample(s.position, playing: !s.paused, at: now);
var due = force || last == null || lastAt == null || sentAt == null;
if (!due) {
final elapsed = now.difference(lastAt).inMilliseconds / 1000;
@@ -163,11 +165,10 @@ class JellyfinProgressReporter {
Future<void> stop(PlayerSnapshot s) {
if (!_playing || _dead) return _chain;
_playing = false;
watch.sample(s.position, playing: !s.paused, at: _clock());
final v = _keepPlayed
? const WatchVerdict(WatchOutcome.keepPlayed)
: 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;
switch (v.outcome) {
case WatchOutcome.played || WatchOutcome.keepPlayed:
@@ -4,7 +4,6 @@ import 'package:flutter/foundation.dart';
import 'package:sync_relay_core/core.dart';
import '../screens/home/picked_media.dart';
import '../session/party.dart' show isPresent;
import 'accounts.dart';
import 'format.dart';
import '../library/media_refs.dart';
@@ -341,26 +340,3 @@ Duration nextPromptStart(JellyfinItem? item, Duration runtime, {Duration? ending
const lead = Duration(minutes: 2);
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.
final _byPath = <String, LocalEpisode>{};
final _showByPath = <String, LocalShow>{};
final _entries = <int, AniListEntry>{};
final _flows = <int, FlowShow?>{};
final _jobs = <String, Future<void> Function()>{};
@@ -152,12 +153,21 @@ class ShowMatcher {
// ------------------------------------------------------------- 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) {
for (final e in library.files) {
_byPath[e.path] = e;
}
for (final s in library.shows) {
for (final e in s.files) {
_showByPath[e.path] = s;
}
}
}
/// The show the file at [path] belongs to, as seen in a scan this run.
LocalShow? showAt(String path) => _showByPath[path];
AniListEntry? entry(int id) {
final known = _entries[id];
@@ -5,6 +5,7 @@ import 'package:flutter/foundation.dart';
import '../files/thumbnails.dart';
import '../app/app_graph.dart';
import 'single_instance.dart';
// 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
@@ -21,10 +22,14 @@ bool get shuttingDown => _shuttingDown;
/// The systems the close stops; `main` hands them over once.
AppGraph? _graph;
/// Makes [graph] the one [shutDownApp] stops and returns the close
/// request handler for the window.
Future<void> Function() closeAppWith(AppGraph graph) {
/// This app's claim on its profile folder (one app at a time).
InstanceLock? _instance;
/// Makes [graph] the one [shutDownApp] stops (and [instance] the claim it
/// gives up) and returns the close request handler for the window.
Future<void> Function() closeAppWith(AppGraph graph, {InstanceLock? instance}) {
_graph = graph;
_instance = instance;
return shutDownApp;
}
@@ -38,6 +43,9 @@ Future<void> shutDownApp({Future<void> Function()? beforeQuit, bool restart = fa
final graph = _graph;
if (_shuttingDown || graph == null) return;
_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 total = Stopwatch()..start();
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.
await _step('leave', graph.navigation.leave(bye: !restart), const Duration(milliseconds: 2500));
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));
debugPrint('close: ready to quit after ${total.elapsedMilliseconds} ms');
await shell.quit();
@@ -1,3 +1,5 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
@@ -17,3 +19,41 @@ Future<void> requestWindowAttention() async {
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
Future<void> focus() async {
Future<void> focus({String activation = ''}) async {
try {
if (await windowManager.isMinimized()) await windowManager.restore();
if (!await windowManager.isVisible()) await windowManager.show();
// Linux: present with the token when there is one (else a
// compositor only marks the window as wanting attention).
if (activation.isNotEmpty && await presentWindow(activation)) return;
await windowManager.focus();
} catch (e) {
debugPrint('window focus failed: $e');
@@ -126,6 +129,15 @@ class DesktopShell implements ShellPort {
return (files: files, text: text);
}
@override
Future<String?> readClipboardText() async {
try {
return await ClipboardX.readText();
} catch (_) {
return null;
}
}
@override
Widget dropTarget({
Key? key,
@@ -75,6 +75,9 @@ class MediaKitHost implements PlayerHost {
: _hwdecSetting = hwdecSetting ?? (() => AppSettings.instance.hwdec.peek());
final _live = <_MkPlayer>{};
final _videos = <_MkVideo>{};
// The size outputs render at ([setOutputSize]); null: the video's own.
(int, int)? _outputSize;
final _renderMode = signal(RenderMode.unknown);
final _http = StreamController<String>.broadcast();
final _throttle = LogThrottle(burst: 5);
@@ -90,6 +93,16 @@ class MediaKitHost implements PlayerHost {
@override
ReadonlySignal<RenderMode> get renderMode => _renderMode;
@override
void setOutputSize(int? width, int? height) {
final size = width != null && height != null && width > 0 && height > 0 ? (width, height) : null;
if (size == _outputSize) return;
_outputSize = size;
for (final v in _videos.toList()) {
unawaited(v.applySize());
}
}
@override
Stream<String> get httpErrors => _http.stream;
@@ -185,6 +198,17 @@ class MediaKitHost implements PlayerHost {
// configured), which used to override SYNC_RELAY_HWDEC.
final controller = VideoController(p.mk, configuration: VideoControllerConfiguration(hwdec: hwdec));
final v = _MkVideo(p, controller);
_videos.add(v);
p._video = v;
if (_outputSize != null) unawaited(v.applySize());
// mpv gets `vo=libmpv` before the output's render context exists; a
// media opened in between fails its video output ("No render context
// set") and plays black. Opens wait for the output (seen on X11, where
// the context comes up later, when joining with media already up).
p._videoReady = controller.platform.future
.then((_) {})
.timeout(const Duration(seconds: 5), onTimeout: () {})
.catchError((Object _) {});
if (probe) unawaited(_probeRender(p, v));
return v;
}
@@ -195,6 +219,20 @@ class MediaKitHost implements PlayerHost {
// refused request makes ffmpeg retry and warn on every try); ffmpeg's
// HTTP status to [httpErrors]; the GL init lines while probing.
void _route(_MkPlayer p, PlayerLogLine log) {
// Windows: the controller can be "ready" before mpv's render context
// exists, and an open in that gap fails its video output ("No render
// context set"). Open the same media again once, a moment later.
if (log.prefix == 'cplayer' && log.text.contains('initializing the selected video_out')) {
final last = p._lastOpen;
if (last != null && !p._voRetried && !p._retired) {
p._voRetried = true;
_mpvLog.info('render: the video output was not up for the open; opening it again');
unawaited(Future.delayed(const Duration(milliseconds: 500), () async {
if (p._retired || !identical(p._lastOpen, last)) return;
await p.mk.open(Media(last.url, httpHeaders: last.headers), play: last.play);
}).catchError((Object e) => _mpvLog.warning('render: opening again failed: $e')));
}
}
if (log.prefix == 'ffmpeg') {
final http = RegExp(r'HTTP error \d{3}.*').firstMatch(log.text);
if (http != null) _http.add(http[0]!.trim());
@@ -318,6 +356,10 @@ class MediaKitHost implements PlayerHost {
final p = player as _MkPlayer;
if (!_live.remove(p)) return;
p._retired = true;
if (p._video case final v?) {
_videos.remove(v);
await v._paramsSub?.cancel();
}
await p._logSub?.cancel();
try {
await p.mk.dispose();
@@ -381,6 +423,11 @@ class _MkPlayer implements HostedPlayer {
StreamSubscription<int?>? _firstFrame;
bool _probing = false;
bool _retired = false;
Future<void>? _videoReady; // set once a video output is attached
// The last open, for one retry when mpv's video output was not up yet.
({String url, Map<String, String> headers, bool play})? _lastOpen;
bool _voRetried = false;
_MkVideo? _video;
dynamic get _mpv => mk.platform;
@@ -426,9 +473,13 @@ class _MkPlayer implements HostedPlayer {
set onUserSeek(void Function(Duration position)? f) => mk.onUserSeek = f;
@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.
mk.open(Media(url, httpHeaders: headers), play: play);
await mk.open(Media(url, httpHeaders: headers), play: play);
}
@override
Future<void> play() => mk.play();
@@ -499,4 +550,56 @@ class _MkVideo implements HostedVideo {
@override
Future<void> get ready => controller.platform.future;
// What this output was last set to: null with [_sized] the video's own
// size again; nothing sent yet while [_sized] is false.
(int, int)? _sent;
bool _sized = false;
StreamSubscription<VideoParams>? _paramsSub;
Future<void> _sizeOp = Future.value();
/// Renders at the host's output size (or the video's own when it has
/// none). media_kit's controller sets the output back to the video's
/// size whenever the video's parameters change (a new file, a track
/// with another size); the fixed size is sent again after it, under
/// the controller's own lock so it lands last.
Future<void> applySize({bool again = false}) => _sizeOp = _sizeOp.then((_) async {
if (player.disposed) return;
try {
// NativeVideoController (Linux, Windows): not exported, reached
// through its public fields.
final dynamic native = await controller.platform.future;
final view = player.host._outputSize;
final params = player.mk.state.videoParams;
final dw = params.dw ?? 0, dh = params.dh ?? 0;
final want = view == null ? null : fitOutputSize(view, dw > 0 && dh > 0 ? (dw, dh) : null);
if (_sized && want == _sent && !again) return;
if (want == null) {
if (!_sized) return; // never resized: already the video's size
await native.setSize();
} else {
// setSize skips a size it believes is set; after media_kit's own
// reset it is not.
native.width = null;
await native.setSize(width: want.$1, height: want.$2);
}
_sent = want;
_sized = true;
// The output takes its new size with the next frame mpv draws;
// paused, none comes: a pan too small to see makes mpv redraw.
if (!player.state.playing) {
await player.setProperty('video-pan-x', '0.000001');
await player.setProperty('video-pan-x', '0');
}
_paramsSub ??= player.mk.stream.videoParams.listen((params) async {
if ((params.dw ?? 0) == 0 || _sent == null) return;
try {
await (native.lock.synchronized(() async {}) as Future);
} catch (_) {}
unawaited(applySize(again: true));
});
} catch (e) {
debugPrint('video output size: $e');
}
});
}
@@ -0,0 +1,60 @@
import 'dart:ffi';
import 'package:ffi/ffi.dart';
// The few Win32 calls the app makes itself (no package wraps them for
// us without a new dependency). Only ever called on Windows.
/// HKEY_CURRENT_USER as the API takes it (sign-extended 0x80000001).
const _hkeyCurrentUser = -0x7FFFFFFF;
const _regSz = 1;
const _rrfRtRegSz = 0x2;
typedef _AllowNative = Int32 Function(Uint32 pid);
typedef _Allow = int Function(int pid);
/// Lets process [pid] take the foreground (a second start does this for
/// the running app before handing over its link: Windows only lets a
/// process that got the user's click bring a window up).
void allowSetForegroundWindow(int pid) {
final user32 = DynamicLibrary.open('user32.dll');
user32.lookupFunction<_AllowNative, _Allow>('AllowSetForegroundWindow')(pid);
}
typedef _SetNative = Int32 Function(
IntPtr key, Pointer<Utf16> subKey, Pointer<Utf16> name, Uint32 type, Pointer<Void> data, Uint32 size);
typedef _Set = int Function(
int key, Pointer<Utf16> subKey, Pointer<Utf16> name, int type, Pointer<Void> data, int size);
typedef _GetNative = Int32 Function(IntPtr key, Pointer<Utf16> subKey, Pointer<Utf16> name, Uint32 flags,
Pointer<Uint32> type, Pointer<Void> data, Pointer<Uint32> size);
typedef _Get = int Function(int key, Pointer<Utf16> subKey, Pointer<Utf16> name, int flags,
Pointer<Uint32> type, Pointer<Void> data, Pointer<Uint32> size);
DynamicLibrary get _advapi => DynamicLibrary.open('advapi32.dll');
/// The string value [name] ('' = the key's default) of
/// HKEY_CURRENT_USER\[subKey]; null when missing.
String? readUserRegistryString(String subKey, String name) {
final get = _advapi.lookupFunction<_GetNative, _Get>('RegGetValueW');
return using((arena) {
final key = subKey.toNativeUtf16(allocator: arena);
final value = name.isEmpty ? nullptr : name.toNativeUtf16(allocator: arena);
final size = arena<Uint32>()..value = 0;
if (get(_hkeyCurrentUser, key, value, _rrfRtRegSz, nullptr, nullptr, size) != 0) return null;
final data = arena<Uint16>(size.value ~/ 2 + 1);
if (get(_hkeyCurrentUser, key, value, _rrfRtRegSz, nullptr, data.cast(), size) != 0) return null;
return data.cast<Utf16>().toDartString();
});
}
/// Sets the string value [name] ('' = the key's default) of
/// HKEY_CURRENT_USER\[subKey], creating the key; false when refused.
bool writeUserRegistryString(String subKey, String name, String data) {
final set = _advapi.lookupFunction<_SetNative, _Set>('RegSetKeyValueW');
return using((arena) {
final key = subKey.toNativeUtf16(allocator: arena);
final value = name.isEmpty ? nullptr : name.toNativeUtf16(allocator: arena);
final bytes = data.toNativeUtf16(allocator: arena);
return set(_hkeyCurrentUser, key, value.cast(), _regSz, bytes.cast(), (data.length + 1) * 2) == 0;
});
}
@@ -3,6 +3,8 @@ import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package: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
/// compositor matches to [desktopFileName] for the window's icon.
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
/// `~/.local/share/applications` (Exec = this binary) and the icon into
/// the hicolor theme, only when something changed (a moved install, a new
/// icon). The entry and icons of [legacyAppId] go. Never in debug runs
/// (unless [force], for tests); failures only log.
Future<void> installDesktopEntry(
{String? executable, String? dataHome, String? home, @visibleForTesting bool force = false}) async {
/// icon). The entry and icons of [legacyAppId] go. The entry also takes
/// `starsync://` links ([schemeMimeType], %u) and the app becomes their
/// default handler ([registerScheme], default [registerLinuxScheme]).
/// Never in debug runs (unless [force], for tests); failures only log.
Future<void> installDesktopEntry({
String? executable,
String? dataHome,
String? home,
@visibleForTesting bool force = false,
Future<void> Function(String desktopFile, String applicationsDir)? registerScheme,
}) async {
if (!Platform.isLinux || (kDebugMode && !force)) return;
try {
final exe = executable ?? Platform.resolvedExecutable;
@@ -41,9 +50,11 @@ Future<void> installDesktopEntry(
}
final entry = desktopEntryText(exe);
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.writeAsString(entry);
}
await (registerScheme ?? registerLinuxScheme)(desktopFileName, file.parent.path);
} catch (e) {
debugPrint('desktop entry: $e');
}
@@ -69,9 +80,11 @@ String desktopEntryText(String exe) => '''[Desktop Entry]
Type=Application
Name=Starsync
Comment=Watch videos in sync with friends
Exec="$exe"
Exec="$exe" %u
Icon=$appId
StartupWMClass=$appId
StartupNotify=true
MimeType=$schemeMimeType;
Categories=AudioVideo;Video;Player;Network;
Terminal=false
''';
@@ -148,6 +148,13 @@ abstract interface class PlayerHost {
/// start).
HostedVideo video(HostedPlayer player, {bool probe = false});
/// The view's pixel size: every video output renders the picture fitted
/// in it ([fitOutputSize]), so mpv scales the picture and draws
/// subtitles at screen resolution (Settings → Player, "Sharp video").
/// Null: each output at its video's own size. Applies to live outputs
/// and to new ones once they are up.
void setOutputSize(int? width, int? height);
/// How video renders, as last learned (kept after leaving).
ReadonlySignal<RenderMode> get renderMode;
@@ -184,9 +191,39 @@ abstract interface class PlayerHost {
int get live;
}
/// [log] says ffmpeg's HTTP stream lost the file's size and so every
/// seek on it fails ("Seek failed (to 3417735, size -38)": -38 is ENOSYS,
/// what ffmpeg answers for the size once a reconnect's or a seek's request
/// failed). Only a fresh open gets it back.
bool mpvStreamLost(PlayerLogLine log) =>
log.prefix == 'ffmpeg' && log.text.contains('Seek failed') && log.text.contains('size -38)');
/// [log] says the media could not be opened: before a [duration] is known,
/// mpv's fatal "nothing to play" or a demuxer error.
bool mpvLoadFailed(PlayerLogLine log, Duration duration) {
return duration == Duration.zero &&
((log.prefix == 'cplayer' && log.level == 'fatal') || (log.prefix == 'demux' && log.level == 'error'));
}
/// The size a video output renders at for a [view] of that many pixels:
/// the [video]'s picture (its display size, aspect included) fitted
/// inside, no letterbox. The texture then has the picture's aspect, so
/// while a resize settles Flutter scales the last one to the new view
/// with no jump (a texture of the whole view would shrink inside its own
/// black bars until the new one came). Drawn 1:1: the fitted side is the
/// view's, the other is rounded down to the view's parity, so the
/// picture sits on whole pixels when centred (a half-pixel offset would
/// blur every line). Unknown video: the view.
(int, int) fitOutputSize((int, int) view, (int, int)? video) {
final (vw, vh) = view;
if (video == null || video.$1 <= 0 || video.$2 <= 0) return view;
final aspect = video.$1 / video.$2;
int under(double x, int full) {
var n = x.floor().clamp(1, full);
if ((full - n).isOdd) n = n > 1 ? n - 1 : n;
return n;
}
if (vw / vh > aspect) return (under(vh * aspect, vw), vh); // pillarbox
return (vw, under(vw / aspect, vh)); // letterbox (or exact)
}
@@ -43,8 +43,10 @@ abstract interface class ShellPort {
Future<void> quit();
/// Brings the window up and gives it the focus (restored when
/// minimised, shown when hidden): a click on a system notification.
Future<void> focus();
/// minimised, shown when hidden): a click on a system notification, a
/// link handed over by a second start ([activation]: that start's
/// desktop activation token, which lets the window take the focus).
Future<void> focus({String activation = ''});
/// Points the desktop at the window when it is not focused (taskbar
/// flash, urgency hint); nothing when it is.
@@ -53,6 +55,10 @@ abstract interface class ShellPort {
/// The clipboard's files, else its text (Ctrl+V outside a text field).
Future<Paste> readPaste();
/// The clipboard's text only (the join prompt looks for an invite in
/// it); null when there is none or it can't be read.
Future<String?> readClipboardText();
/// [child] taking dropped files ([onDrop]: their paths and where they
/// landed); [enable] off lets drops through.
Widget dropTarget({
@@ -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,
// play a second and close the normal way ([shutDownApp]). Progress goes to
// `STARSYNC_SMOKE_OUT` (one line per step: `start`, `opened <ms> render=<mode>`,
// `closing`, or `skip <why>` / `fail <why>`), so the script can tell a
// runner that cannot show a window (skip) from a close that hangs or
// leaves a process behind (fail).
// `closing`, `quitting`, or `skip <why>` / `fail <why>`), so the script can
// tell a runner that cannot show a window (skip) from a close that hangs or
// leaves a process behind, or a process that ends on its own (fail). It
// stays up [smokeUptime] first: a start that dies by itself (the 1.3.0
// Windows exit timer) ends before `closing`.
/// How long the smoke run keeps the app up before closing it.
const smokeUptime = Duration(seconds: 10);
/// The smoke run's media, or null for a normal start.
String? get smokeMedia {
@@ -65,6 +70,9 @@ Future<void> runSmoke(AppGraph graph, String media) async {
} catch (e) {
step('fail $e');
}
final wait = smokeUptime - sw.elapsed;
if (wait > Duration.zero) await Future.delayed(wait);
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].
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
/// instead of to mpv.
void Function(Duration position)? onUserSeek;
@@ -71,6 +75,7 @@ class PlaybackController {
// load error was reported.
String? _httpError;
bool _loadErrorSent = false;
bool _streamLostSent = false;
/// The current player. Only while [alive].
HostedPlayer get player => _player!;
@@ -144,6 +149,7 @@ class PlaybackController {
_chapterJson = '';
_httpError = null;
_loadErrorSent = false;
_streamLostSent = false;
_shaders = '';
_loaded = loaded && p.state.duration > Duration.zero;
_opened = loaded;
@@ -196,6 +202,7 @@ class PlaybackController {
_chapterJson = '';
_httpError = null;
_loadErrorSent = false;
_streamLostSent = false;
_loaded = false;
chapters.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
// (mpv then renders without it).
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())) {
_loadErrorSent = true;
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/media_art.dart';
import '../session/session_coordinator.dart';
import '../state/settings.dart';
import '../sync/avatar_cache.dart';
import 'queue_logic.dart';
// Up next on this client (see ARCHITECTURE.md "Up next"): the server's
// queue as signals, adding to it (probed, with a picture), the end-of-media
// countdown for its first item, putting an item up when this app is the
// one that can, and keeping that item ready.
// What's next on this app (see ARCHITECTURE.md "What's next" and "Up
// next"): the server's queue as signals (Up next and its continuation),
// adding to it (probed, with a picture), the continuation of what this
// 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:
/// no countdown here (auto-advance off, cancelled, or another app puts
/// it up), the card offers Play now only.
/// The end-of-media card for the first item (Up next's, else the
/// continuation). [secondsLeft]: the server's countdown (`state.advance`);
/// 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 {
final int mediaId;
final QueueItem item;
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.
@@ -46,21 +50,33 @@ class QueueController {
late final PictureCache thumbs = PictureCache(coord.connection.client,
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).
late final Computed<List<QueueItem>> items =
computed(() => coord.serverState.value?.queue ?? const <QueueItem>[]);
/// Up next, in order, without the continuation (empty when nothing is
/// queued or not in a server).
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);
/// The first item's id and how ready it is here (null: nothing to get
/// ready on this app).
final warm = signal<({String id, WarmState state})?>(null);
/// The natural continuation of what plays: the next episodes of the
/// Jellyfin series (not queued; shown greyed under Up next).
/// The next episodes of the Jellyfin series playing, on an app that
/// 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 []);
/// The mediaId whose continuation was cleared here.
/// The mediaId whose continuation was cleared (by anyone: `cancel`).
final _hiddenFor = signal(-1);
/// 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.
final Map<String, ({PickedMedia pick, DateTime at})> _local = {};
final Map<String, DateTime> _performing = {};
final Set<String> _skipped = {};
int _cancelledFor = -1;
Timer? _timer;
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 = '';
final _random = Random.secure();
@@ -83,15 +101,26 @@ class QueueController {
if (_attached) return;
_attached = true;
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(() {
final next = coord.nextEpisode.value;
final plan = coord.jellyfinPlan.value;
unawaited(_loadContinuation(next, plan));
});
// The continuation and the state decide what to get ready; the media
// playing having started (or sitting out) lets the preload go.
// What continues this media becomes known (the next episode found, a
// 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(() {
shownContinuation.value;
coord.settledMediaId.value;
coord.selfAway.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() {
final first = items.peek().firstOrNull;
final first = head.peek();
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() {
final c = countdown.peek();
_timer?.cancel();
_timer = null;
if (c == null) return;
_cancelledFor = c.mediaId;
countdown.value = UpNextCountdown(mediaId: c.mediaId, item: c.item);
coord.markInput();
coord.connection.sendQueue(const QueueIntent.cancel());
}
/// "Clear": hides the continuation of what plays now (and its next
/// episode does not come up by itself).
/// "Clear" under "Continues with": the continuation of what plays goes
/// for everyone (`cancel`) and does not come back for this media.
void clearContinuation() {
final id = coord.serverState.peek()?.mediaId;
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
/// not cleared.
bool continuationShown(int mediaId) =>
(coord.serverState.peek()?.queue.isEmpty ?? true) && _hiddenFor.peek() != mediaId;
/// [continuation] unless cleared for the media playing.
/// The further episodes of [continuation] (after the one that continues
/// what plays), unless it was cleared for the media playing.
late final Computed<List<EpisodeLink>> shownContinuation = computed(() {
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
void _onState(StateBroadcast 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);
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;
if (play != null) {
final item = queue.where((q) => q.id == play).firstOrNull;
if (item != null) _bringUp(item, r);
}
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;
final item = r.queue.where((q) => q.id == play).firstOrNull;
if (item != null && queuePerformer(item, r.members).id == _me) _perform(item);
}
_syncCountdown(r);
_continue(r);
_warm(r);
}
@@ -385,75 +406,133 @@ class QueueController {
final now = DateTime.now();
_performing.removeWhere((_, at) => now.difference(at) > const Duration(minutes: 1));
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));
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;
/// nobody can: the lowest member takes it off with the reason.
void _bringUp(QueueItem item, 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.
/// The end card from the server's state: the media ended with something
/// next; the seconds of `state.advance` move on with a 1 s timer.
void _syncCountdown(StateBroadcast r) {
final head = r.queue.firstOrNull;
if (head != null && queuePerformer(head, r.members).id == _me) _perform(head);
}
void _stopCountdown() {
if (r.transport != 'finished' || head == null || r.media == null) {
_timer?.cancel();
_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).
@@ -530,9 +609,8 @@ class QueueController {
bool _ownAccount(String serverId) =>
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)
/// the first continuation episode (only here when this app plays the
/// series with its own account). Every app does, each its own copy (see
/// What this app gets ready: the first item of the queue (Up next's,
/// else the continuation). Every app does, each its own copy (see
/// [_WarmTarget]); while sitting out only the probe of a link this app
/// puts up (its swap reads it). Null: nothing.
_WarmTarget? _warmTarget(StateBroadcast? r) {
@@ -579,9 +657,7 @@ class QueueController {
return performer ? _WarmTarget(head.id) : null;
}
}
final next = shownContinuation.peek().firstOrNull;
if (next == null) return null;
return _WarmTarget('next:${next.item.id}', ref: mediaForPick(next.pick).jellyfin);
return null;
}
/// 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.
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
/// 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))));
} else if (pick is LinkMedia) {
_fedWarm = key;
_feedingLink = pick.url;
unawaited(() async {
try {
// 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 y = await coord.ytDlp.ytDlp;
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) {
debugPrint('queue: feeding ${head.id}: $e');
} finally {
if (_feedingLink == pick.url) {
_feedingLink = null;
_syncPreload(coord.serverState.peek());
}
}
}());
}
@@ -738,6 +824,7 @@ class QueueController {
return;
}
if (!media.preloadEnabled) return;
if (want.url == _feedingLink) return;
final key = '${want.url}|${want.headers}';
final fresh = _preloadFresh;
// 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 '../jellyfin/session_media.dart' show compareMemberIds;
import '../session/party.dart' show isPresent;
// 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));
// Up next's words for an item. Who puts an item up is core's
// `queuePerformer` (whats_next.dart), read the same by the server and
// every app.
/// How [item] plays, in a few words for its row: "through Foxyz" (you:
/// "through you"), "server fetches", "everyone fetches", "Jellyfin",
/// "Foxyz's file".
String queueHowLabel(QueueItem item, {required String selfId}) {
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) {
QueueHow.through => 'through $who',
QueueHow.file => mine ? 'your file' : "$who's file",
+112 -66
View File
@@ -12,7 +12,6 @@ import '../controls/minimal/style.dart';
import '../jellyfin/images.dart';
import '../jellyfin/session_media.dart' show EpisodeLink;
import '../screens/home/picked_media.dart';
import '../session/session_coordinator.dart' show autoAdvanceSecs;
import '../shell/avatar.dart';
import '../shell/handoff.dart' show handoffHolds;
import '../shell/library_link.dart' show LinkPreview, cachedLinkPreview;
@@ -38,17 +37,14 @@ enum UpNextLook { standard, minimal }
/// The next item's picture, "UP NEXT" over its title, a +n count and ⏭
/// (play it now) in the player's top bar, one flat control among its
/// buttons; the title opens the queue, resting on it shows a preview card.
/// Without Up next, the continuation ("NEXT · S1 E8" over its name, ⏭
/// Without Up next, the continuation ("NEXT · S1E8" over its name, ⏭
/// plays it). Nothing when both are empty.
class UpNextButton extends StatefulWidget {
final QueueController queue;
/// Plays the continuation's first episode (the next episode).
final VoidCallback onPlayNextEpisode;
final UpNextLook look;
/// Held while the preview card is up, so the controls stay up with it.
final ControlsHold? hold;
const UpNextButton(
{super.key, required this.queue, required this.onPlayNextEpisode, required this.look, this.hold});
const UpNextButton({super.key, required this.queue, required this.look, this.hold});
@override
State<UpNextButton> createState() => _UpNextButtonState();
@@ -143,9 +139,9 @@ class _UpNextButtonState extends State<UpNextButton> {
final thumbRadius = _minimal ? MinimalStyle.radius - 4 : 6.0;
return SignalBuilder(builder: (context) {
final items = _q.items.value;
final cont = _q.shownContinuation.value;
final auto = _q.continues.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.
if (_preview.isShowing) {
WidgetsBinding.instance.addPostFrameCallback((_) {
@@ -155,21 +151,15 @@ class _UpNextButtonState extends State<UpNextButton> {
return const SizedBox.shrink();
}
final queued = items.isNotEmpty;
final item = items.firstOrNull;
final episode = cont.firstOrNull;
final item = items.firstOrNull ?? auto;
final more = items.length - 1;
final String kicker;
final String title;
final Widget thumb;
if (item != null) {
kicker = 'UP NEXT';
kicker = queued ? 'UP NEXT' : (item.sub.isEmpty ? 'NEXT' : 'NEXT · ${item.sub}');
title = item.title;
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 {
kicker = 'UP NEXT';
title = 'Nothing yet';
@@ -246,10 +236,8 @@ class _UpNextButtonState extends State<UpNextButton> {
),
),
);
final playTip = queued
? 'Play ${item!.title} now ($playNextKeys)'
: 'Play ${episode == null ? '' : episode.label} now ($playNextKeys)';
final VoidCallback? play = queued ? _q.skip : (episode != null ? widget.onPlayNextEpisode : null);
final playTip = 'Play ${item?.title ?? ''} now ($playNextKeys)';
final VoidCallback? play = item == null ? null : _q.skip;
final playButton = _minimal
? MinimalButton(key: const ValueKey('up-next-play'), icon: Icons.skip_next_rounded, tooltip: playTip, onPressed: play)
: Tooltip(
@@ -337,60 +325,35 @@ class UpNextPreview extends StatelessWidget {
child: Padding(
padding: const EdgeInsets.all(_pad),
child: SignalBuilder(builder: (context) {
final item = queue.items.value.firstOrNull;
final episode = queue.shownContinuation.value.firstOrNull;
final queued = queue.items.value.firstOrNull;
final item = queued ?? queue.continues.value;
final warm = queue.warm.value;
const picture = _width - 2 * _pad;
if (item != null) {
if (item == null) return const SizedBox.shrink();
final r = queue.coord.serverState.value;
final selfId = queue.coord.connection.memberId;
final more = queue.items.value.length - 1;
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(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
QueueThumb(queue: queue, item: item, width: picture, radius: 8),
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),
Text(item.title, maxLines: 2, overflow: TextOverflow.ellipsis, style: _title),
if (item.sub.isNotEmpty)
Text(item.sub, maxLines: 1, overflow: TextOverflow.ellipsis, style: _sub),
if (item.sub.isNotEmpty) Text(item.sub, maxLines: 1, overflow: TextOverflow.ellipsis, style: _sub),
const SizedBox(height: 6),
_MetaLine(item: item, members: r?.members ?? const [], selfId: selfId, wrap: true),
_footer(ready, ready == null ? performerNote(queue, item, r) : null),
],
);
}
if (episode == null) return const SizedBox.shrink();
final n = episodeNumbers(episode.item);
final runtime = episode.pick.runtime;
final sub = [episode.item.seriesName ?? '', if (runtime != null) queueClock(runtime.inSeconds.toDouble())]
.where((s) => s.isNotEmpty)
.join(' · ');
final overview = episode.item.overview?.trim() ?? '';
final ready = warm?.id == 'next:${episode.item.id}' ? warm!.state : null;
return Column(
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 EpisodeLink episode;
final double width;
final double radius;
const _EpisodeThumb({required this.queue, required this.episode, required this.width, this.radius = 6});
const _EpisodeThumb({required this.queue, required this.episode, required this.width});
@override
Widget build(BuildContext context) {
@@ -490,7 +452,7 @@ class _EpisodeThumb extends StatelessWidget {
final client = queue.coord.jellyfin.clientFor(e.pick.serverId);
final image = e.item.ownImage(JellyfinImageType.primary) ?? e.item.image(JellyfinImageType.thumb);
return ClipRRect(
borderRadius: BorderRadius.circular(radius),
borderRadius: BorderRadius.circular(6),
child: SizedBox(
width: width,
height: width * 9 / 16,
@@ -838,6 +800,7 @@ class _QueuePanelState extends State<QueuePanel> {
),
child: SignalBuilder(builder: (context) {
final items = _ordered(_q.items.value);
final auto = _q.continues.value;
final cont = _q.shownContinuation.value;
final warm = _q.warm.value;
final r = _q.coord.serverState.value;
@@ -848,7 +811,7 @@ class _QueuePanelState extends State<QueuePanel> {
children: [
_header(items.length),
Flexible(
child: items.isEmpty && cont.isEmpty && _adding.isEmpty
child: items.isEmpty && auto == null && cont.isEmpty && _adding.isEmpty
? _empty()
: ListView(
shrinkWrap: true,
@@ -900,7 +863,9 @@ class _QueuePanelState extends State<QueuePanel> {
adding: 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: EdgeInsets.fromLTRB(12, alone ? 0 : 8, 2, 0),
child: Row(
@@ -964,6 +931,7 @@ class _QueuePanelState extends State<QueuePanel> {
const Text('CONTINUES WITH',
style: TextStyle(color: Colors.white38, fontSize: 11, letterSpacing: 0.8, fontWeight: FontWeight.w600)),
const Spacer(),
if (auto != null)
TextButton(
key: const ValueKey('queue-clear-continuation'),
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),
];
@@ -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 {
final EpisodeLink episode;
final QueueController queue;
@@ -1545,8 +1584,9 @@ class _ContinuationRowState extends State<_ContinuationRow> {
// -------------------------------------------------------------- end card
/// When the media ended with Up next waiting: its picture, title and who
/// added it, the countdown with Cancel, and Play now.
/// When the media ended with something next (Up next's first item, else
/// the continuation): its picture, title and who added it, the server's
/// countdown with Cancel (for everyone), and Play now.
class UpNextCard extends StatelessWidget {
final QueueController queue;
final UpNextCountdown countdown;
@@ -1559,7 +1599,13 @@ class UpNextCard extends StatelessWidget {
final left = c.secondsLeft;
final r = queue.coord.serverState.value;
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(
key: const ValueKey('up-next-card'),
child: SizedBox(
@@ -1579,7 +1625,7 @@ class UpNextCard extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
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)),
const SizedBox(height: 2),
Text(item.title,
@@ -1605,7 +1651,7 @@ class UpNextCard extends StatelessWidget {
ClipRRect(
borderRadius: BorderRadius.circular(2),
child: TweenAnimationBuilder<double>(
tween: Tween(end: left / autoAdvanceSecs),
tween: Tween(end: left / RelayConstants.advanceSecs),
duration: const Duration(seconds: 1),
builder: (context, v, _) => LinearProgressIndicator(
value: v,
@@ -6,6 +6,8 @@ import '../../shell/mark_problem.dart';
import '../../app/app_graph.dart';
import '../../state/settings.dart';
import '../../update/update_controller.dart';
import '../../update/whats_new.dart';
import '../../widgets/whats_new_card.dart';
import '../../widgets/app_menu.dart';
import '../../widgets/copy_open.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 '
'(github.com/tomasklaen/uosc).';
/// Name, version, update state, the source link and the logs (Settings →
/// About).
/// Dev builds only ([devTools]): previews and experiments not decided
/// 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 {
final UpdateController? updates;
const AboutPanel({super.key, this.updates});
final WhatsNew? whatsNew;
const AboutPanel({super.key, this.updates, this.whatsNew});
@override
Widget build(BuildContext context) {
@@ -36,6 +67,23 @@ class AboutPanel extends StatelessWidget {
style: t.bodyMedium?.copyWith(color: Colors.grey)),
const SizedBox(height: 4),
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),
Row(
children: [
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:signals/signals_flutter.dart';
import 'package:sync_relay_core/core.dart' show UpdateChannel, isDevBuild;
import '../../app/app_graph.dart';
import '../../state/settings.dart';
@@ -16,6 +17,7 @@ const sessionShortcuts = [
('Shift+→', 'Skip 1:30 (for everyone)'),
('PgUp / PgDn', 'Previous / next chapter'),
('F', 'Toggle fullscreen'),
('Enter', 'Quick chat box (Enter sends, Esc closes)'),
('Tab', 'Open / close chat & activity'),
('Ctrl+R', 'Reload the video'),
('> or Shift+N', 'Put up the next Jellyfin episode'),
@@ -194,6 +196,7 @@ class UpdateSettings extends StatelessWidget {
],
),
const SizedBox(height: 8),
UpdateChannelSetting(controller: c, settings: s),
SignalBuilder(
builder: (context) => SwitchListTile(
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 {
const AutoAdvanceSetting({super.key});
@@ -220,8 +292,8 @@ class AutoAdvanceSetting extends StatelessWidget {
key: const ValueKey('auto-advance'),
contentPadding: EdgeInsets.zero,
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; '
'anyone can cancel it'),
subtitle: const Text('When you put up an episode (Jellyfin, or a file of a show in Files), the next one '
'continues it after a 10 s countdown everyone sees; anyone can cancel it'),
value: s.autoAdvance.value,
onChanged: s.setAutoAdvance,
),
@@ -1,3 +1,4 @@
import 'package:flutter/foundation.dart' show kDebugMode;
import 'package:flutter/material.dart';
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".
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 kGap = 16.0;
@@ -1,13 +1,16 @@
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/whats_new.dart';
import '../../widgets/update_banner.dart';
import '../../widgets/whats_new_card.dart';
import 'ui.dart';
/// 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
/// Later (gone until the next check or start). The shell keeps it off
/// grabs: "Starsync 1.2.1 is ready", its first changes (or its one-line
/// 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]).
class UpdateBar extends StatelessWidget {
final UpdateController? controller;
@@ -31,6 +34,8 @@ class UpdateBar extends StatelessWidget {
final behind = isDevBuild(current) ? null : 'You are running $current';
final String title;
String detail;
// The offered version's first changes instead of its notes.
var preview = false;
switch (s.phase) {
case UpdatePhase.downloading:
title = 'Downloading $appName ${m.version}… ${((s.progress ?? 0) * 100).round()}%';
@@ -51,9 +56,11 @@ class UpdateBar extends StatelessWidget {
} else if (m.isRequired) {
title = '$appName ${m.version} is required to join servers on it';
detail = m.notes;
preview = m.changes != null;
} else {
title = '$appName ${m.version} is ready';
detail = m.notes;
preview = m.changes != null;
}
detail = [?behind, detail].where((t) => t.isNotEmpty).join(' · ');
}
@@ -88,7 +95,9 @@ class UpdateBar extends StatelessWidget {
maxLines: 1,
overflow: TextOverflow.ellipsis,
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(
message: detail,
waitDuration: const Duration(milliseconds: 600),
@@ -102,6 +111,14 @@ class UpdateBar extends StatelessWidget {
),
if (!busy) ...[
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)
TextButton(
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
/// would render nothing (e.g. "Later").
Future<void> showUpdateDialog(BuildContext context, {UpdateController? controller}) {
@@ -1,8 +1,6 @@
import 'package:flutter/material.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;
// 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
/// an account (never a silent stall).
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 sendForce(String target, bool ready) => client.sendForce(target, ready);
void sendSeek(double position, {int seq = 0}) => client.sendSeek(position, seq: seq);
void sendSwap(HelloMedia media, {String? queueId}) => 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 sendReport({
required double position,
+175 -8
View File
@@ -2,6 +2,12 @@ import 'dart:io';
import 'package:flutter/foundation.dart' show debugPrint;
/// The URL scheme of an invite link ([InviteLink]).
const inviteScheme = 'starsync';
/// The port a link without one means (the app's default sharing port).
const defaultInvitePort = 8080;
/// What a friend needs to join your server: where ([host], [port]), what
/// it is called ([name]), whether it asks a password ([password]; the
/// password itself is never in it) and which Starsync runs it
@@ -26,13 +32,14 @@ class Invite {
/// What goes in the Join card's address and port.
String get address => '${host.contains(':') ? '[$host]' : host}:$port';
/// The text Invite copies: the address, whether to ask for the password,
/// the version to match.
String get text => [
'Join me on Starsync: $address',
if (password) '(ask me for the password)',
if (version.isNotEmpty) '· Starsync $version',
].join(' ');
/// The same invite at another of this machine's addresses.
Invite at(String host) =>
Invite(host: host, port: port, name: name, password: password, version: version, fp: fp);
/// Its `starsync://` link with the server's password ([secret], the
/// host's; empty when none is set): what Invite copies, nothing around
/// it, so a friend just clicks it.
InviteLink link({String secret = ''}) => InviteLink(host: host, port: port, name: name, secret: secret);
@override
bool operator ==(Object other) =>
@@ -51,6 +58,97 @@ class Invite {
String toString() => 'Invite($address${password ? ', password' : ''})';
}
/// A `starsync://host:port/?name=…&pw=…` invite link (PROTOCOL.md "Invite
/// link"): where to join, the server's name for the prompt, and the
/// password when the server has one. [toString] never shows the
/// password; [uri] does.
class InviteLink {
final String host;
final int port;
final String name;
final String secret;
const InviteLink({required this.host, required this.port, this.name = '', this.secret = ''});
String get address => '${host.contains(':') ? '[$host]' : host}:$port';
/// One per server (for "already asked about this one").
String get key => '${host.toLowerCase()}:$port';
/// The link itself (with the password, when it has one).
String get uri {
final query = [
if (name.isNotEmpty) 'name=${Uri.encodeQueryComponent(name)}',
if (secret.isNotEmpty) 'pw=${Uri.encodeQueryComponent(secret)}',
];
return '$inviteScheme://$address${query.isEmpty ? '' : '/?${query.join('&')}'}';
}
/// Null unless [text] is a `starsync://` link with a host and a usable
/// port (none means [defaultInvitePort]).
static InviteLink? parse(String text) {
final t = text.trim();
if (!t.toLowerCase().startsWith('$inviteScheme://')) return null;
final Uri u;
try {
u = Uri.parse(t);
} catch (_) {
return null;
}
final host = u.host.trim();
if (host.isEmpty || !_hostChars.hasMatch(host)) return null;
final port = u.hasPort ? u.port : defaultInvitePort;
if (port <= 0 || port > 65535) return null;
final Map<String, String> q;
try {
q = u.queryParameters;
} catch (_) {
return null;
}
return InviteLink(host: host, port: port, name: _cleanName(q['name'] ?? ''), secret: q['pw'] ?? '');
}
@override
bool operator ==(Object other) =>
other is InviteLink && other.host == host && other.port == port && other.name == name && other.secret == secret;
@override
int get hashCode => Object.hash(host, port, name, secret);
@override
String toString() => 'InviteLink($address${secret.isEmpty ? '' : ', password'})';
}
final _hostChars = RegExp(r'^[A-Za-z0-9.:-]+$');
final _control = RegExp(r'[\x00-\x1f\x7f]');
/// A link's name as shown in the prompt: no control characters, at most
/// 64 characters.
String _cleanName(String raw) {
final n = raw.replaceAll(_control, '').trim();
return n.length > 64 ? n.substring(0, 64) : n;
}
final _linkIn = RegExp('$inviteScheme://[^\\s<>"\'`]+', caseSensitive: false);
final _plainInvite = RegExp(r'Join me on Starsync:\s*(\S+)');
/// Only the start of a long text is searched (a clipboard can hold a book).
const _searchLimit = 4096;
/// The invite in [text]: a `starsync://` link anywhere in it, else the
/// address of a plain invite ("Join me on Starsync: 192.168.1.20:8080",
/// what 1.3.x copied). Null for anything else, a bare address included.
InviteLink? inviteIn(String text) {
final t = text.length > _searchLimit ? text.substring(0, _searchLimit) : text;
final m = _linkIn.firstMatch(t);
if (m != null) return InviteLink.parse(m[0]!);
final plain = _plainInvite.firstMatch(t);
if (plain == null) return null;
final a = addressIn(plain[1]!);
final port = a == null ? null : int.tryParse(a.port);
if (a == null || port == null || port <= 0 || port > 65535) return null;
return InviteLink(host: a.host, port: port);
}
/// The `host:port` in a pasted invite (or a bare address); null when
/// there is none.
({String host, String port})? addressIn(String text) {
@@ -74,9 +172,48 @@ bool isFakeIp(List<int> b) => b[0] == 198 && (b[1] == 18 || b[1] == 19);
bool _isPrivate(List<int> b) =>
b[0] == 10 || (b[0] == 192 && b[1] == 168) || (b[0] == 172 && b[1] >= 16 && b[1] < 32);
/// Shared address space (100.64.0.0/10): mesh VPNs like Tailscale.
/// Shared address space (100.64.0.0/10): mesh VPNs like Tailscale (and
/// carrier-grade NAT).
bool _isShared(List<int> b) => b[0] == 100 && b[1] >= 64 && b[1] < 128;
/// Where an invite address works.
enum AddressKind {
/// The internet: anyone, when the port is open to them.
public,
/// The same home network.
lan,
/// A mesh VPN (Tailscale and the like): everyone on it.
vpn,
}
/// [AddressKind] of an IPv4 address.
AddressKind addressKind(String ip) {
final a = InternetAddress.tryParse(ip);
if (a == null || a.type != InternetAddressType.IPv4) return AddressKind.public;
final b = a.rawAddress;
if (_isPrivate(b)) return AddressKind.lan;
if (_isShared(b)) return AddressKind.vpn;
return AddressKind.public;
}
/// [ip] is an IPv4 address the internet routes to this network: not
/// private, shared, loopback, link-local, fake-IP, multicast or
/// reserved (the documentation ranges pass: tests use them).
bool isPublicIpv4(String ip) {
final a = InternetAddress.tryParse(ip.trim());
if (a == null || a.type != InternetAddressType.IPv4) return false;
final b = a.rawAddress;
return !(b[0] == 0 ||
b[0] == 127 ||
b[0] >= 224 ||
(b[0] == 169 && b[1] == 254) ||
_isPrivate(b) ||
_isShared(b) ||
isFakeIp(b));
}
/// The addresses of [interfaces] a friend could dial, best first: IPv4,
/// no loopback, link-local or fake-IP address, no container or VM bridge;
/// home networks (192.168, then 10, then 172.16/12) first, then a mesh
@@ -107,6 +244,36 @@ List<String> inviteAddresses(Iterable<({String name, InternetAddress address})>
return {for (final (_, a) in indexed) a.address}.toList();
}
/// One address an invite can carry and where it works.
typedef InviteAddress = ({String host, AddressKind kind});
/// What the members card offers for a shared server: the internet
/// address first when known ([public], from [lookUpPublicAddress]), then
/// this machine's own ([local], [inviteAddresses] order). [forwarding]:
/// the internet address is the router's, not this machine's, so friends
/// elsewhere get in only when the port is forwarded to it.
class InviteReach {
final String? public;
final List<String> local;
/// The internet lookup is still under way.
final bool looking;
const InviteReach({this.public, this.local = const [], this.looking = false});
bool get forwarding => public != null && !local.contains(public);
/// Every address, best first, each once: the internet one, then this
/// machine's.
List<InviteAddress> get choices => [
if (public != null) (host: public!, kind: AddressKind.public),
for (final l in local)
if (l != public) (host: l, kind: addressKind(l)),
];
/// The address an invite carries unless the user picks another: the
/// internet one when known, else the best of this machine's.
String? get best => choices.firstOrNull?.host;
}
/// This machine's interfaces as [inviteAddresses] takes them (empty when
/// they can't be listed).
Future<List<({String name, InternetAddress address})>> machineInterfaces() async {
@@ -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 'media_plan.dart';
import 'relay_feeder.dart';
import 'shared_links.dart';
import 'switch_trace.dart';
export 'media_plan.dart';
@@ -37,7 +38,9 @@ final _log = createLogger('session');
// coordinator's report).
//
// 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
/// Jellyfin item resolves and opens through [MediaPipeline.openJellyfin]
@@ -77,6 +80,10 @@ class MediaPipeline {
final PlaybackController playback;
final SwitchTrace trace;
late final RelayFeeder feeder;
/// The links this member passes on, read once for its player and the
/// relay.
final shared = SharedLinks();
MediaSession session;
/// The player could not open the current media (raw words: the
@@ -98,7 +105,10 @@ class MediaPipeline {
}) : playback = playback ?? PlaybackController(host),
trace = trace ?? SwitchTrace(),
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
@@ -202,6 +212,12 @@ class MediaPipeline {
/// stream opened with headers (a Jellyfin item's, with its token). Throws
/// a [ResolveFailure] in plain words, with the cookies used (D11).
Future<_Target> _resolve(String url, Map<String, String>? headers, {bool fresh = false}) async {
if (headers == null || headers.isEmpty) {
// A link this member passes on: the shared reader's loopback URL
// (it resolves an expired URL again itself, so [fresh] is moot).
final link = await shared.playerFor(url);
if (link != null) return _Target(link.source.playerUrl, const {}, subtitles: link.subtitles, shared: true);
}
if (!_isPage(url) || (headers != null && headers.isNotEmpty)) return _Target(url, headers ?? const {});
final l = links;
if (l == null) throw const ResolveFailure('no link resolver here');
@@ -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'),
cookies: probe.cookies);
}
if (headers == null || headers.isEmpty) {
// Played here only: through a reader of its own too, which resumes
// after a cut and gets a fresh address (a CDN cuts mpv's reconnects
// off). Pieces tracks, or a reader that cannot open: the site as is.
final y = await l.ytDlp;
final local =
await shared.local(url, probe, () => LinkRelayMedia(Uri.parse(url), ytDlp: y, probe: probe), fresh: fresh);
if (local != null) {
return _Target(local.source.playerUrl, const {}, subtitles: local.subtitles, shared: true, local: true);
}
}
return _Target(play.mpvUrl, {...play.headers, ...?headers}, subtitles: play.subtitles);
}
/// The [SharedLinks.local] readers the player and the preload use stay;
/// the rest close.
void _trimLocal() => shared.trimLocal({_openUrl, _pre?.url});
/// The page's subtitles on [p] once its media is in (not selected: the
/// usual choice applies).
Future<void> _addSubtitles(HostedPlayer p, List<YtDlpSubtitle> subs) async {
@@ -287,6 +318,7 @@ class MediaPipeline {
_openHeaders = headers;
_openResolve = resolve;
_opening = true;
_trimLocal();
try {
trace.mark(mediaId, SwitchTrace.open);
final promoted = await _openInPlayer(url, headers,
@@ -343,6 +375,8 @@ class MediaPipeline {
};
}
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';
}
@@ -398,6 +432,7 @@ class MediaPipeline {
final name = (r.media?['name'] as String?) ?? '';
final queueId = stateMediaRelay(r)?.queueId ?? '';
final duration = (r.media?['duration'] as num?)?.toDouble() ?? 0;
if (source is! OwnLink) await shared.closeCurrent();
switch (source) {
case JellyfinSource():
return SwitchJellyfin(source);
@@ -407,8 +442,11 @@ class MediaPipeline {
announce: true, queueId: queueId, name: name, duration: duration);
await open(path);
case OwnLink(:final url, :final origin):
feeder.serve(r.mediaId, LinkRelayMedia(Uri.parse(url), ytDlp: origin.ytDlp, probe: origin.probe),
announce: true, queueId: queueId, name: name, duration: duration);
// Read from the site once: the relay serves what the player reads
// (pieces tracks keep the two fetches).
RelayMedia link() => LinkRelayMedia(Uri.parse(url), ytDlp: origin.ytDlp, probe: origin.probe);
final one = await shared.current(url, origin.probe, link, queueId: queueId);
feeder.serve(r.mediaId, one?.upload ?? link(), announce: true, queueId: queueId, name: name, duration: duration);
await open(url);
case RelayedSource(:final url, :final waiting, :final fetch, :final sourceId):
if (waiting) {
@@ -444,6 +482,39 @@ class MediaPipeline {
_openUrl = null;
_openHeaders = null;
await playback.stop();
_trimLocal();
}
/// The same media came back as [r.mediaId] (a restarted server put back
/// what played, `swap.restore`) and the player keeps it open: what this
/// member passes on is served again (its file, its link, the Jellyfin
/// item played with its own account: [ownJellyfin]).
void resumeAs(StateBroadcast r, {({String url, Map<String, String> headers})? ownJellyfin}) {
renumber(r.mediaId);
settledMediaId.value = r.mediaId;
final source = plan(r);
final name = (r.media?['name'] as String?) ?? '';
final queueId = stateMediaRelay(r)?.queueId ?? '';
final duration = (r.media?['duration'] as num?)?.toDouble() ?? 0;
switch (source) {
case OwnFile(:final path):
feeder.serve(r.mediaId, RelayMedia.single(FileRelayBytes(File(path))),
queueId: queueId, name: name, duration: duration);
case OwnLink(:final url, :final origin):
feeder.serve(r.mediaId,
shared.currentFor(url)?.upload ?? LinkRelayMedia(Uri.parse(url), ytDlp: origin.ytDlp, probe: origin.probe),
queueId: queueId, name: name, duration: duration);
case JellyfinSource(:final ref, :final origin):
if (origin == null || ownJellyfin == null) return;
final media = RelayMedia.single(HttpRelayBytes(Uri.parse(ownJellyfin.url), headers: ownJellyfin.headers));
if (jellyfinRelayNeeded(r, ref, session.selfId())) {
feeder.serve(r.mediaId, media, queueId: queueId, name: name, duration: duration);
} else {
_jellyfinFeed = (mediaId: r.mediaId, ref: ref, media: media, queueId: queueId, name: name, duration: duration);
}
default:
break;
}
}
/// The media was settled here (opened, failed, nothing to open).
@@ -493,6 +564,7 @@ class MediaPipeline {
_openHeaders = null;
_recovery = null;
if (ready) await playback.stop();
_trimLocal();
}
/// The next state's media is new to this player: it opens again from
@@ -507,6 +579,7 @@ class MediaPipeline {
_activeOrigin = null;
_jellyfinFeed = null;
feeder.stopAll();
unawaited(shared.closeAll());
}
/// The same media under a restarted server's numbering: the player keeps
@@ -527,6 +600,7 @@ class MediaPipeline {
_jellyfinFeed = null;
settledMediaId.value = -1;
feeder.stopAll();
unawaited(shared.closeAll());
}
// ----------------------------------------------------------- Jellyfin
@@ -707,6 +781,24 @@ class MediaPipeline {
String warmRelayUrl(QueueItem item, MediaRelay relay) =>
session.server().relayUrl(item.media.name.isEmpty ? item.title : item.media.name, item.id, relay.tracks);
/// Feeds the warm relay of Up next item [queueId], the link [url] this
/// member passes on, from one shared reader (pieces tracks: from their
/// own fetch, as [LinkRelayMedia]). A preload of [url] that reads the
/// site itself (it was prepared before the reader existed) is prepared
/// again on the reader, so the item is fetched once.
Future<void> feedWarmLink(String queueId, String url, LinkProbe probe, YtDlp ytDlp) async {
if (feeder.feedsWarm(queueId)) return;
RelayMedia link() => LinkRelayMedia(Uri.parse(url), ytDlp: ytDlp, probe: probe);
final one = await shared.warm(queueId, url, probe, link);
if (feeder.feedsWarm(queueId)) return;
feeder.feedWarm(queueId, one?.upload ?? link());
final pre = _pre;
if (one != null && pre != null && pre.url == url && pre.headers.isEmpty && !pre.shared && !pre.failed) {
_log.info('Up next ${logUrl(url)}: preparing it again on the shared reader');
unawaited(prepare(url, fresh: true));
}
}
/// Opens [url] early in a hidden player (paused at the start, muted, a
/// small cache, its own video output when the view is up), replacing any
/// other; completes once [need] seconds are buffered or the whole media
@@ -721,8 +813,10 @@ class MediaPipeline {
_pre = null;
if (had != null) unawaited(_disposePrepared(had));
final pre = _pre = _Prepared(url, headers ?? const {});
_trimLocal();
try {
final target = await _resolve(url, headers, fresh: fresh);
pre.shared = target.shared && !target.local;
if (!identical(_pre, pre)) {
pre.fail();
return false;
@@ -783,6 +877,7 @@ class MediaPipeline {
await pre.logSub?.cancel();
final p = pre.player;
if (p != null) await host.dispose(p);
if (!identical(_pre, pre)) _trimLocal();
}
/// [pre] becomes the session player; its buffer comes along.
@@ -809,6 +904,7 @@ class MediaPipeline {
Future<void> dispose() async {
await release();
feeder.stopAll();
await shared.closeAll();
await playback.dispose();
}
}
@@ -833,6 +929,9 @@ class _Prepared {
StreamSubscription<PlayerLogLine>? logSub;
List<YtDlpSubtitle> subtitles = const [];
/// It reads a shared reader's loopback URL, not the site.
bool shared = false;
/// Completes once the media is handed to mpv (or it failed).
final opened = Completer<void>();
bool failed = false;
@@ -854,7 +953,13 @@ class _Target {
final String url;
final Map<String, String> headers;
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
+33 -5
View File
@@ -25,6 +25,11 @@ class ToastItem {
final bool chat;
/// A picture hash to show (an Up next item's), '' when none.
final String thumb;
/// What it is about (the shell may show some of it another way:
/// "Notifications as danmaku").
final NoticeCategory category;
/// This member's own (their chat message coming back from the server).
final bool own;
const ToastItem(
{required this.id,
required this.text,
@@ -32,7 +37,9 @@ class ToastItem {
this.actor,
this.actorId = '',
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]).
@@ -98,12 +105,15 @@ class NoticeContext {
final WindowActivity window;
final bool playing;
final NotifyPrefs prefs;
/// The chat panel is open: chat is read there, not in pop-ups.
final bool chatOpen;
const NoticeContext({
this.self,
this.alone = false,
this.window = WindowActivity.focused,
this.playing = false,
this.prefs = const NotifyPrefs(),
this.chatOpen = false,
});
bool get away => self == Presence.away;
@@ -132,6 +142,8 @@ class Notice {
final bool chat;
/// A picture hash for the toast (an Up next item's).
final String thumb;
/// This member is the actor (a feed line of its own).
final bool own;
final SfxCue? sound;
final bool attention;
final SystemNote? system;
@@ -143,6 +155,7 @@ class Notice {
this.actor,
this.chat = false,
this.thumb = '',
this.own = false,
this.sound,
this.attention = false,
this.system,
@@ -151,12 +164,13 @@ class 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,
toast: toast ? this.toast : null,
actor: toast ? actor : null,
chat: toast && chat,
thumb: toast ? thumb : '',
own: own,
sound: sound ? this.sound : null,
attention: attention,
system: system,
@@ -187,6 +201,12 @@ Notice _plain(AppEvent e) => switch (e) {
category: NoticeCategory.people, log: feed, toast: '${feed.actor} lost connection', actor: feed.actor),
MemberReturned(:final feed) =>
Notice(category: NoticeCategory.people, log: feed, toast: '${feed.actor} is back', actor: feed.actor),
// Put back after a server restart: news, but not a change.
MediaSwapped(:final feed) when feed.reason == 'restore' => Notice(
category: NoticeCategory.upNext,
log: feed,
toast: '${feed.actor} put back what was playing',
actor: feed.actor),
MediaSwapped(:final feed) => Notice(
category: NoticeCategory.upNext,
log: feed,
@@ -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.chat && !c.prefs.chat.shows(playing: c.playing)) toast = false;
// The open chat shows it already; the sound still plays.
if (cat == NoticeCategory.chat && c.chatOpen && !c.window.background) toast = false;
SystemNote? system;
if (toast && !own && c.window.background && cat.system && cat.reachesAway(c.prefs)) {
final actor = n.actor;
@@ -323,7 +345,7 @@ Notice noticeFor(AppEvent e, [NoticeContext c = const NoticeContext()]) {
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
@@ -345,6 +367,9 @@ class Notifier {
/// The window's activity ([ShellPort.watchActivity], wired in `main`).
WindowActivity window = WindowActivity.focused;
/// The chat panel is open (set by the shell).
bool chatOpen = false;
Notifier({
this.sfx = const NoopSfxService(),
Future<void> Function()? attention,
@@ -373,7 +398,8 @@ class Notifier {
NoticeContext get context {
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) {
@@ -395,7 +421,9 @@ class Notifier {
actor: n.actor,
actorId: n.actor == null ? '' : n.log?.actorId ?? '',
chat: n.chat,
thumb: n.thumb),
thumb: n.thumb,
category: n.category,
own: n.own),
];
_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);
/// Connected and watching (`in` or `joining`): can do things for the
/// party now (put up an Up next item, the auto-advance swap).
bool isPresent(MemberView m) => m.presence == Presence.watching || m.presence == Presence.joining;
/// party now (put up an Up next item; core's [isPresentMember]).
bool isPresent(MemberView m) => isPresentMember(m);
/// Nobody but [selfId] is connected in [members] (D9, core's [isAlone]).
bool aloneIn(List<MemberView> members, String selfId) =>
@@ -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 {
final ServerAddress Function() server;
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;
int _mediaId = -1;
@@ -33,6 +38,9 @@ class RelayFeeder {
/// relay and has opened them (empty otherwise).
List<RelayTrack> warmTracks(String queueId) => _warm?.queueId == queueId ? _warm!.src.tracks : const [];
/// This member feeds the warm relay of Up next item [queueId].
bool feedsWarm(String queueId) => _warm?.queueId == queueId;
/// Serves [media] for media [mediaId] on this member's relay channel.
/// The server only pulls while someone watches through it. When the
/// media came from Up next item [queueId] whose warm relay this member
@@ -111,18 +119,26 @@ class RelayFeeder {
await src.start();
} catch (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;
}
await src.done;
if (identical(_warm?.src, src)) _warm = null;
if (identical(_warm?.src, src)) {
_warm = null;
onWarmEnded(queueId);
}
}());
}
void stopWarm() {
final w = _warm;
_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.
@@ -23,6 +23,7 @@ import '../schedule/anilist.dart';
import '../screens/home/picked_media.dart';
import '../sfx/sfx.dart' show NoopSfxService, SfxService;
import '../state/settings.dart';
import '../watch/watch_tracker.dart';
import '../sync/avatar_cache.dart';
import '../sync/state_diff.dart';
import '../sync/user_seek.dart';
@@ -37,6 +38,7 @@ import 'notifier.dart';
import 'now_playing.dart';
import 'now_playing_info.dart';
import 'party.dart';
import 'shared_links.dart';
import 'status.dart';
import 'switch_trace.dart';
@@ -52,21 +54,6 @@ final _log = createLogger('session');
/// (Jellyfin-style links carry api_key there and names are broadcast).
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
/// 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
@@ -100,6 +87,14 @@ class SessionCoordinator {
/// What a local file is (`media.ref`, from the library: `ShowMatcher`),
/// set by the composition root; null: files go up without one.
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).
final LinkResolver ytDlp;
/// 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
/// controls are up.
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,
/// 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
// 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`).
({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;
/// This member serves the current media's bytes through the server.
Signal<bool> get relaying => media.feeder.relaying;
@@ -317,8 +311,16 @@ class SessionCoordinator {
JellyfinProgressReporter? _reporter;
PlayerSnapshot? _lastSnap;
bool _jfToastShown = false;
Timer? _advanceTimer;
int _advanceDoneFor = -1;
// The media whose end was recorded as a watch checkpoint.
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 = '';
bool _jellyfinListening = false;
// The mediaId [playerError] belongs to, and the player's duration when
@@ -337,7 +339,9 @@ class SessionCoordinator {
JellyfinAccounts? jellyfin,
TrackPrefs? trackPrefs,
LinkResolver? ytDlp,
PlayerHost? players}) {
PlayerHost? players,
AppSettings? settings,
WatchTracker? watch}) {
final n = notifier ?? Notifier(sfx: sfx);
final links = ytDlp ?? AppYtDlp.instance;
return SessionCoordinator._(
@@ -347,6 +351,8 @@ class SessionCoordinator {
jellyfin: jellyfin ?? JellyfinAccounts.instance,
trackPrefs: trackPrefs ?? TrackPrefs(),
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.jellyfin,
required this.trackPrefs,
required this.media}) {
required this.media,
required this.settings,
required this.watch}) {
connection
..onWelcome = _onWelcome
..caps = () => caps;
@@ -419,6 +427,7 @@ class SessionCoordinator {
if (_playerReady) return;
try {
playback.onLoadError = _setPlayerError;
playback.onStreamLost = _streamLost;
playback.onUserSeek = (d) => _userSeek(d.inMicroseconds / 1e6);
_bufferSub ??= playback.buffered.subscribe((_) => _onBuffered());
await media.start();
@@ -472,6 +481,19 @@ class SessionCoordinator {
}
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;
/// "Reload video" (button, Ctrl+R, status menu): fresh
@@ -510,6 +532,9 @@ class SessionCoordinator {
final epoch = connection.invalidate();
await _teardown(silent: true);
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);
_wsHost = params.host;
_wsPort = params.port;
@@ -614,7 +639,10 @@ class SessionCoordinator {
/// three viewers readying at once). A state that arrives meanwhile
/// replaces any still waiting.
void _onState(StateBroadcast r) {
final hold = _restore;
if (hold != null && r.media != null) _landRestore(hold, r);
serverState.value = r;
_stateSelf = connection.memberId;
if (r.transport == 'playing') switchTrace.mark(r.mediaId, SwitchTrace.playing);
_toastMemberErrors(r);
_warnSlowSource(r);
@@ -641,6 +669,9 @@ class SessionCoordinator {
}
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);
if (_lastAway) {
// 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
// anyway (it was sent first, so this one lands last).
if (unreadyForNewMedia(
enabled: AppSettings.instance.unreadyOnSwap.peek(),
enabled: settings.unreadyOnSwap.peek(),
lastMediaId: last,
mediaId: r.mediaId,
ready: self.ready || _restoringReady)) {
@@ -834,19 +865,54 @@ class SessionCoordinator {
_sendReport(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 (r != null && online) _readyAfterJoinBack(r, now);
_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).
void _tickAutoSitOut(StateBroadcast? r, DateTime now, {required bool online}) {
final step = autoSitOut.tick(now,
self: online ? selfMember.peek()?.presence : null,
playing: r?.transport == 'playing',
alone: r == null || aloneIn(r.members, connection.memberId),
enabled: AppSettings.instance.autoSitOut.peek());
enabled: settings.autoSitOut.peek());
switch (step) {
case AutoAway(:final reason):
_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
/// 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.
if (!connection.state.peek().online) {
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
// coming up by itself: whoever puts it up may be sitting out.
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();
connection.sendPresence(const PresenceIntent(state: Presence.watching));
}
_pendingArt = (media: media, after: serverState.peek()?.mediaId ?? 0, link: link ?? media.url, art: art);
connection.sendSwap(media, queueId: queueId);
_pendingArt =
(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
@@ -1107,12 +1174,20 @@ class SessionCoordinator {
_pendingArt = null;
final link = pending.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));
}
/// The link this app put up as [StateBroadcast.mediaId] (passed on or
/// fetched by the server, so not in the state).
(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
/// 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
/// when sent.
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();
final media = await mediaForLink(jellyfin, url);
if (media.jellyfin != null) {
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;
}
LinkProbe? probe;
@@ -1192,7 +1267,7 @@ class SessionCoordinator {
final name = probe?.title ?? (probe != null || hasMediaExtension(uri) || uri.host.isEmpty ? media.name : uri.host);
final what = linkMediaRef(name);
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;
}
if (relay == LinkRelay.me) {
@@ -1205,11 +1280,21 @@ class SessionCoordinator {
// Up next's item this app already feeds (its warm relay): read.
final warm = queueId == null ? const <RelayTrack>[] : this.media.feeder.warmTracks(queueId);
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;
final List<RelayTrackSpec> specs;
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) {
// A playlist is read (DRM and live refused) but has no size.
final pieces = t.pieces;
@@ -1227,7 +1312,7 @@ class SessionCoordinator {
}
this.media.expectOrigin(LinkOrigin(url, probe, y));
_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;
}
// The server reads it with its own yt-dlp; only a page this app knows
@@ -1243,7 +1328,8 @@ class SessionCoordinator {
url: url,
relay: const MediaRelay(fetch: true),
ref: what),
queueId: queueId);
queueId: queueId,
restore: restore);
return null;
}
@@ -1257,23 +1343,24 @@ class SessionCoordinator {
/// A Jellyfin pick (browser, next / previous episode) goes up as a ref,
/// relayed by this member for viewers without an account.
void swapJellyfin(JellyfinMedia media, {String? queueId}) {
void swapJellyfin(JellyfinMedia media, {String? queueId, SwapRestore? restore}) {
markInput();
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
/// else's goes through the server's relay. Returns an error text, or null
/// when sent.
Future<String?> swapFile(String path, {String? queueId}) async {
Future<String?> swapFile(String path, {String? queueId, SwapRestore? restore}) async {
markInput();
final what = fileRef?.call(path);
final host = hostFile;
if (host != null) {
final file = await host(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;
}
final file = File(path);
@@ -1285,7 +1372,9 @@ class SessionCoordinator {
duration: 0,
relay: const MediaRelay(),
ref: what),
queueId: queueId);
file: path,
queueId: queueId,
restore: restore);
return null;
}
@@ -1302,9 +1391,17 @@ class SessionCoordinator {
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() {
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() {
@@ -1312,16 +1409,6 @@ class SessionCoordinator {
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
/// News for the user, from the session or anywhere in the app (see
@@ -1342,10 +1429,6 @@ class SessionCoordinator {
nextEpisode.value = null;
nextPromptVisible.value = false;
playback.segments.value = const [];
_advanceTimer?.cancel();
_advanceTimer = null;
_advanceDoneFor = -1;
autoAdvance.value = null;
}
/// The current item is being replaced: Stopped goes out (not awaited:
@@ -1449,6 +1532,12 @@ class SessionCoordinator {
if (gen != _jfGen) return;
final before = await userDataF;
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(
client: plan.client,
itemId: ref.itemId,
@@ -1456,6 +1545,7 @@ class SessionCoordinator {
playSessionId: plan.info.playSessionId,
playedBefore: before?.played ?? false,
resumeTicksBefore: before?.playbackPositionTicks ?? 0,
watch: watch.mediaId == r.mediaId ? watch.session : null,
onUnauthorized: () => jellyfin.markExpired(ref.serverId),
onStopped: () => jellyfin.watchedChanged(ref.serverId),
onError: (e) {
@@ -1545,7 +1635,7 @@ class SessionCoordinator {
final next = nextEpisode.peek();
final duration = playback.durationSecs;
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 at = nextPromptStart(item, Duration(milliseconds: (duration * 1000).round()),
endingStart: playback.ending.peek()?.start);
@@ -1554,9 +1644,8 @@ class SessionCoordinator {
nextPromptVisible.value = show;
}
/// Per state: played to the end -> Stopped (Jellyfin marks it watched)
/// and the auto-advance countdown; seeked back from the end -> Playing
/// again.
/// Per state: played to the end -> Stopped (with the watched rule's
/// verdict); seeked back from the end -> Playing again.
void _jellyfinTransport(StateBroadcast r) {
final ref = _jfRef;
if (ref == null || r.mediaId != _jfMediaId) return;
@@ -1571,48 +1660,7 @@ class SessionCoordinator {
reporter.start(snap);
}
}
if (!finished) {
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);
if (finished) nextPromptVisible.value = false;
}
String _accountKey(String serverId) =>
@@ -1667,11 +1715,18 @@ class SessionCoordinator {
final tokenUrl = rejoin.oldToken.isNotEmpty &&
rejoin.oldToken != welcome.token &&
(_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();
} else if (!rejoin.sameServer && _mediaId != -1) {
// 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;
media.renumber(now.mediaId);
_seenMediaId = now.mediaId;
@@ -1679,7 +1734,7 @@ class SessionCoordinator {
// The intent the server lost: ready (unless the video changed and the
// user unreadies then), and sitting out.
var wantReady = _lastReady && !_lastAway;
if (changed && wantReady && _seenMediaId >= 0 && AppSettings.instance.unreadyOnSwap.peek()) {
if (changed && !restoring && wantReady && _seenMediaId >= 0 && settings.unreadyOnSwap.peek()) {
wantReady = 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
/// counts its `mediaId` from 1 again).
static bool _sameMedia(Map<String, Object?>? a, Map<String, Object?>? b) {
@@ -1746,10 +1920,18 @@ class SessionCoordinator {
_seekTimer?.cancel();
_seekTimer = null;
_seekGate.reset();
watch.close();
_watchEndFor = -1;
_restore = null;
_restoreTimer?.cancel();
_restoreTimer = null;
queue.restoreFrom(-1);
await _stopJellyfin();
media.reset();
_pendingArt = null;
_ownLink = null;
_ownFile = null;
ownFile.value = null;
_playerReady = false;
try {
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/server_isolate.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
/// 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
/// listener on every interface at the configured port (Settings → Party
/// & network: port and password) and [stopSharing] detaches it. Members,
/// what plays and Up next never restart or reconnect for either. Made
/// by the composition root.
/// what plays and Up next never restart or reconnect for either. Sharing
/// also looks up the internet address ([reach]). Made by the composition
/// root.
class YourServer {
YourServer({
required this.forms,
@@ -54,10 +56,13 @@ class YourServer {
String? Function()? logDir,
ServerStarter? starter,
Future<List<({String name, InternetAddress address})>> Function()? interfaces,
Future<String?> Function()? publicAddress,
this.publicWait = const Duration(seconds: 4),
}) : _ytDlpDir = ytDlpDir ?? (() async => ''),
_logDir = logDir ?? (() => null),
_starter = starter ?? _startIsolate,
_interfaces = interfaces ?? machineInterfaces;
_interfaces = interfaces ?? machineInterfaces,
_publicAddress = publicAddress ?? lookUpPublicAddress;
final ConnectStore forms;
/// Linked Jellyfin servers (default: the app's): the relay logins.
@@ -66,6 +71,18 @@ class YourServer {
final String? Function() _logDir;
final ServerStarter _starter;
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]).
final String hostKey = newHostKey();
@@ -81,6 +98,13 @@ class YourServer {
/// An [invite] or [stopSharing] is under way.
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
/// and clears it. The next [start] makes a new one.
final crash = signal<String?>(null);
@@ -106,12 +130,13 @@ class YourServer {
Future<int> _start(String name) async {
final who = name.trim();
_name = who.isEmpty ? '' : "$who's server";
late final ServerIsolate server;
server = await _starter(
AppConfig(
port: 0,
secret: '',
name: who.isEmpty ? 'server' : "$who's server",
name: who.isEmpty ? 'server' : _name,
hostKey: hostKey,
ytDlpDir: await _ytDlpDir(),
),
@@ -133,6 +158,7 @@ class YourServer {
_server = null;
port.value = null;
shared.value = null;
reach.value = null;
final message = 'server crashed: $cause';
debugPrint('[server] $message');
crash.value = message;
@@ -152,33 +178,77 @@ class YourServer {
final form = forms.form;
final want = int.tryParse(form.hostPort.trim()) ?? 8080;
await _accounts.ensureLoaded(validate: false);
final secret = form.hostSecret;
final bound = await server.share(
port: want,
secret: form.hostSecret,
secret: secret,
// Guests without an account watch Jellyfin items through the
// server, with this app's own sign-in on the item's server.
jellyfin: relayCredentialsFor(_accounts),
);
final invite = await inviteFor(bound, password: form.hostSecret.isNotEmpty);
_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;
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;
} finally {
busy.value = false;
}
}
/// The invite for [port] from this machine's best address (see
/// [inviteAddresses]); 127.0.0.1 when there is none.
Future<Invite> inviteFor(int port, {required bool password}) async {
final addresses = inviteAddresses(await _interfaces());
/// Bumped by every share and unshare: a lookup of an earlier one is
/// dropped.
int _shareGen = 0;
/// 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(
host: addresses.isEmpty ? '127.0.0.1' : addresses.first,
host: host ?? (local.isEmpty ? '127.0.0.1' : local.first),
port: port,
name: _name,
password: password,
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
/// apps do not retry); you, what plays and Up next stay.
Future<void> stopSharing() async {
@@ -187,7 +257,9 @@ class YourServer {
busy.value = true;
try {
await server.unshare();
_shareGen++;
shared.value = null;
reach.value = null;
} finally {
busy.value = false;
}
@@ -202,6 +274,7 @@ class YourServer {
_server = null;
port.value = null;
shared.value = null;
reach.value = null;
await server?.stop();
}
}
@@ -1,3 +1,4 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter/services.dart';
@@ -7,8 +8,13 @@ import 'shader_presets.dart';
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
/// 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
/// redone). Returns that directory.
Future<Directory> extractAnime4k(
@@ -17,7 +23,7 @@ Future<Directory> extractAnime4k(
}) async {
final dir = Directory(
'${supportDir.path}${Platform.pathSeparator}shaders'
'${Platform.pathSeparator}anime4k-$anime4kVersion');
'${Platform.pathSeparator}anime4k-$anime4kVersion-$anime4kCopyRevision');
final marker = File('${dir.path}${Platform.pathSeparator}.complete');
final files = anime4kAllFiles;
if (await marker.exists() &&
@@ -28,14 +34,68 @@ Future<Directory> extractAnime4k(
final b = bundle ?? rootBundle;
for (final name in [...files, 'LICENSE']) {
final data = await b.load('$anime4kAssetDir/$name');
final bytes = data.buffer.asUint8List(data.offsetInBytes, data.lengthInBytes);
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);
}
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;
}
/// 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].
List<String> anime4kPaths(
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/session_controls.dart' show controlsBottomHeight;
import '../danmaku/danmaku_notices.dart' show fliesAsDanmaku;
import '../danmaku/danmaku_preview.dart';
import '../files/folder_store.dart';
import '../files/ui/files_page.dart' show showFilesFolder;
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/media_picker.dart';
import '../session/identity.dart';
import '../screens/home/ui.dart' show appName;
import '../screens/home/update_indicator.dart';
import '../app/app_graph.dart';
import '../session/navigation.dart' show Place;
import '../state/settings.dart';
import '../update/update_controller.dart';
import '../update/whats_new.dart';
import '../widgets/whats_new_card.dart';
import 'chat_panel.dart';
import 'connect_cards.dart';
import 'handoff.dart';
import 'join_offer_card.dart';
import 'library_link.dart' show LinkProber;
import 'library_tab.dart';
import 'motion.dart';
@@ -33,6 +39,7 @@ import 'profile_card.dart';
import '../schedule/ui/schedule_page.dart';
import 'session_player.dart';
import 'settings_tab.dart';
import 'quick_chat.dart';
import 'toasts.dart';
import 'welcome.dart';
@@ -52,6 +59,9 @@ import 'welcome.dart';
class AppShell extends StatefulWidget {
final UpdateController? updates;
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
/// window_manager.
final bool windowControls;
@@ -67,6 +77,7 @@ class AppShell extends StatefulWidget {
super.key,
this.updates,
this.autoUpdateCheck = true,
this.whatsNew,
this.windowControls = true,
this.healthGet = defaultHealthGet,
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
/// reports it): the toasts sit above them.
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;
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.
_jellyfin.ensureLoaded();
if (widget.autoUpdateCheck) _autoCheck();
if (widget.whatsNew != null || widget.autoUpdateCheck) unawaited(_startWhatsNew());
HardwareKeyboard.instance.addHandler(_onKey);
final nav = _graph.navigation;
final coord = _graph.coordinator;
@@ -240,7 +257,9 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
for (final stop in _stops) {
stop();
}
_danmaku?.dispose();
HardwareKeyboard.instance.removeHandler(_onKey);
_whatsNewSource?.due.removeListener(_maybeWhatsNew);
_updateTimer?.cancel();
_fade.dispose();
_online.dispose();
@@ -250,6 +269,8 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
_flow.dispose();
_join.dispose();
_picker.dispose();
_quickChat.dispose();
_danmakuActive.dispose();
_chat.dispose();
_chatFocus.dispose();
_controlsShown.dispose();
@@ -276,6 +297,36 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
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() {
_unread = 0;
_feedSeen = _graph.coordinator.feedLog.peek().length;
@@ -287,6 +338,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
if (_fullscreen) _toggleFullscreen();
setState(() {
_chatOpen = false;
_graph.notifier.chatOpen = false;
_unread = 0;
});
_show(PlayerMode.none);
@@ -312,11 +364,28 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
}
if (!mounted || seq != _modeSeq) return;
setState(() => _shown = m);
if (m != PlayerMode.full) _later(_maybeWhatsNew);
await _fade.animateTo(1, duration: fadeThroughTime * (1 - fadeOutPart), curve: ease);
}
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() {
if (_fullscreen) _toggleFullscreen();
_show(PlayerMode.mini);
@@ -362,6 +431,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
void _toggleChat() => setState(() {
_chatOpen = !_chatOpen;
_graph.notifier.chatOpen = _chatOpen;
_unread = 0;
if (!_chatOpen) _chatFocus.unfocus();
});
@@ -602,6 +672,22 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
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
@override
@@ -723,6 +809,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
onEditProfile: () => showProfileCard(context, onOpenSettings: _openSettings, updates: widget.updates),
updates: widget.updates,
jellyfin: _jellyfin,
onDanmakuPreview: _openDanmakuPreview,
),
};
final full = _shown == PlayerMode.full;
@@ -757,6 +844,12 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
),
if (online && _shown != PlayerMode.none) _playerLayer(area),
_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: () {
if (!_chatOpen) _toggleChat();
},
skip: (t) => (_danmakuActive.value && fliesAsDanmaku(t)) || _quickChat.echoed(t),
);
return ValueListenableBuilder<bool>(
valueListenable: _controlsShown,
@@ -866,6 +960,9 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
videoBuilder: widget.videoBuilder,
windowControls: widget.windowControls,
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':
return (Icons.fast_forward, scheme.primary, 'seeked');
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':
return (Icons.cloud_off, Colors.orange, 'lost signal');
case 'return':
@@ -296,6 +296,9 @@ Future<void> _messageMenu(BuildContext context, String text, String? link) async
case 'queue-skip':
final why = f.reason.isEmpty ? '' : ' · ${f.reason}';
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':
return (Icons.cloud_off, Colors.red, "couldn't fetch ${f.target ?? 'the video'}");
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) {
'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'),
};
+11 -3
View File
@@ -7,7 +7,7 @@ import '../app/app_graph.dart';
import '../screens/home/connect_flow.dart';
import '../screens/home/connect_status.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 '../state/connect_prefs.dart';
import '../update/update_controller.dart';
@@ -138,9 +138,17 @@ class JoinCard extends StatelessWidget {
_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) {
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);
if (a == null || v.trim() == a.host) return;
fields.host.text = a.host;
@@ -223,7 +231,7 @@ class JoinCard extends StatelessWidget {
const Padding(
padding: EdgeInsets.only(top: 8),
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.',
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/party.dart';
import '../app/app_graph.dart';
import '../session/app_events.dart' show ActionFailed;
import '../session/invite.dart';
import '../session/navigation.dart' show Place;
import '../widgets/app_keys.dart';
@@ -16,6 +15,9 @@ import '../widgets/window_buttons.dart';
import 'avatar.dart';
import 'delivery_line.dart';
import 'motion.dart';
import 'your_invite.dart';
export 'your_invite.dart' show YourInvite, inviteFriends;
/// The shell's tabs, in strip order.
enum ShellTab {
@@ -506,9 +508,13 @@ class MembersCard extends StatelessWidget {
alignment: Alignment.centerLeft,
child: TextButton.icon(
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),
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}) =>
version.isNotEmpty && own != '0.0.0-dev' && version != '0.0.0-dev' && version != own;
/// Your server in the members card: shared, the invite's address
/// (Copy invite), a hint for friends outside the network and Stop
/// sharing; not shared, Invite (opens it and copies the invite).
class YourInvite extends StatelessWidget {
const YourInvite({super.key});
@override
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 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}');
/// The invite link to a friend's server you are in ([address] as dialled,
/// "host:port"), with the password you joined with: the link is the key,
/// as for your own server.
String friendInviteLink(String address, String name, {String secret = ''}) {
final a = addressIn(address);
final port = a == null ? null : int.tryParse(a.port);
if (a == null || port == null) return address;
return InviteLink(host: a.host, port: port, name: name, secret: secret).uri;
}
+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 '../controls/controls_probe.dart';
import '../controls/minimal/style.dart';
import '../controls/model/controls_layout.dart';
import '../controls/model/controls_model.dart';
import '../controls/player_panel.dart';
import '../controls/session_controls.dart';
import '../controls/volume.dart';
import '../danmaku/danmaku.dart' show DanmakuController, DanmakuItem, DanmakuKind;
import '../danmaku/danmaku_notices.dart';
import '../danmaku/danmaku_preview.dart';
import '../platform/desktop/media_kit_host.dart' show videoControllerOf;
import '../queue/queue_ui.dart';
import '../playback/media_marks.dart' show SkipKind, SkipOffer, formatClock;
@@ -28,8 +34,10 @@ import 'avatar.dart';
import 'motion.dart';
import 'now_playing_title.dart';
import 'player_status.dart';
import 'quick_chat.dart';
import 'sit_out_card.dart';
import 'start_banner.dart';
import 'video_view.dart';
/// Where the session's video is shown: nowhere, the floating mini player,
/// 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
/// everything over it. Full: the controls (one model, the skin picked in
/// Settings), the start card, the status pill, Jellyfin overlays and
/// the player keys. 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
/// Settings), the start card, the status pill, Jellyfin overlays, the
/// quick chat box, the danmaku, the player panel and the player keys.
/// 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.
class SessionPlayer extends StatefulWidget {
final PlayerMode mode;
@@ -67,6 +76,15 @@ class SessionPlayer extends StatefulWidget {
/// Follows whether the full player's controls are up (the shell lifts
/// its toasts above them); the player keeps its own when null.
final ValueNotifier<bool>? controlsShown;
/// 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({
super.key,
required this.mode,
@@ -84,6 +102,9 @@ class SessionPlayer extends StatefulWidget {
this.videoBuilder,
this.windowControls = true,
this.controlsShown,
this.quickChat,
this.danmakuActive,
this.danmaku,
});
@override
@@ -98,11 +119,29 @@ class _SessionPlayerState extends State<SessionPlayer> {
final _noFocus = FocusNode(skipTraversal: true, canRequestFocus: false);
DateTime? _lastKeyAt;
late final void Function() _stopShaderWatch;
late final void Function() _stopSubScale;
late final void Function() _stopDanmakuWatch;
late final void Function() _stopLiftWatch;
int _shaderNoticeSeen = 0;
bool _miniHover = false;
// Whether the controls are up (their frame reports it).
final _ownShown = ValueNotifier(true);
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 controls up.
final _hold = ControlsHold();
@@ -124,7 +163,8 @@ class _SessionPlayerState extends State<SessionPlayer> {
);
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
void initState() {
@@ -133,6 +173,7 @@ class _SessionPlayerState extends State<SessionPlayer> {
_model;
_coord.jellyfin.ensureLoaded();
HardwareKeyboard.instance.addHandler(_globalKey);
FocusManager.instance.addEarlyKeyEventHandler(_earlyKey);
// Tab (AppKeys) reaches the chat through here in fullscreen.
AppKeys.addChatToggle(_toggleChat);
// Shader messages (preset changes, load failures) become toasts.
@@ -146,6 +187,32 @@ class _SessionPlayerState extends State<SessionPlayer> {
_stopPopoverWatch = effect(() {
_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
@@ -154,16 +221,48 @@ class _SessionPlayerState extends State<SessionPlayer> {
_fullscreen.value = widget.fullscreen;
_chatOpen.value = widget.chatOpen;
_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
void dispose() {
HardwareKeyboard.instance.removeHandler(_globalKey);
FocusManager.instance.removeEarlyKeyEventHandler(_earlyKey);
AppKeys.removeChatToggle(_toggleChat);
_stopShaderWatch();
_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();
_ownShown.dispose();
_boxUp.dispose();
_lift.dispose();
_lines.dispose();
_hold.dispose();
_model.dispose();
super.dispose();
@@ -171,6 +270,55 @@ class _SessionPlayerState extends State<SessionPlayer> {
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
/// dialog, a card, a text field) is in front of it. Space and Tab are
/// app-wide ([AppKeys]).
@@ -179,7 +327,7 @@ class _SessionPlayerState extends State<SessionPlayer> {
if (!AppScope.of(context).navigation.online.peek()) return false;
// Mini: Esc is the last step out (a friend's server: asking to leave
// it; yours: the player closes).
if (!_full) {
if (!_isFull) {
if (event.logicalKey != LogicalKeyboardKey.escape) return false;
if (ModalRoute.of(context)?.isCurrent == false || textInputFocused()) return false;
widget.onLeave();
@@ -195,12 +343,19 @@ class _SessionPlayerState extends State<SessionPlayer> {
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();
if (_lastKeyAt != null && now.difference(_lastKeyAt!).inMilliseconds < 350) {
return false;
}
final model = _model;
final key = event.logicalKey;
// Ctrl+0..6: Anime4K's own mpv keys (off, modes A..C+A).
final digit = _shaderKeys.indexOf(key);
if (digit >= 0 && HardwareKeyboard.instance.isControlPressed) {
@@ -252,7 +407,9 @@ class _SessionPlayerState extends State<SessionPlayer> {
}
if (key == LogicalKeyboardKey.escape) {
_lastKeyAt = now;
if (widget.chatOpen) {
if (_model.panelOpen.peek()) {
_model.panelOpen.value = false;
} else if (widget.chatOpen) {
widget.onToggleChat();
} else if (widget.fullscreen) {
widget.onToggleFullscreen();
@@ -284,29 +441,35 @@ class _SessionPlayerState extends State<SessionPlayer> {
if (!playback.alive.value) {
return Center(
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) {
_videoGen = gen;
_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);
// The wheel anywhere on the full player steps the volume
// (lists, menus and popovers under the mouse scroll instead).
return VolumeWheel(
enabled: _full,
enabled: _isFull,
onStep: _model.stepVolume,
child: Stack(
fit: StackFit.expand,
children: [
video,
if (_full) ...[
SubtitleLifter(playback: _coord.playback, lift: _lift, active: _isFull),
if (_isFull) ...[
_blockedCard(),
_actionArea(),
if (widget.fullscreen) ...[if (!_coord.alone.value) _pausedParty(), _unreadChip()],
_volumeReadout(),
_quickChatBox(),
_playerPanel(),
if (widget.danmaku case final d?) _danmakuPanel(d),
] else
_miniOverlay(),
// 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,
controller: videoControllerOf(_coord.playback.video),
subtitleViewConfiguration: const SubtitleViewConfiguration(visible: false),
controls: controls,
),
);
/// The quick chat box in the control bar's place, centred (Enter opens
/// it), and the echo of what you sent.
Widget _quickChatBox() => Positioned(
left: _edge,
right: _edge,
bottom: _edge,
child: Stack(
alignment: Alignment.bottomCenter,
children: [
QuickChatEcho(chat: _quick),
QuickChatBox(chat: _quick, onSend: _send, onHeight: _boxHeightChanged),
],
),
);
/// The player panel docked at the right (⚙ → Player settings), between
/// the top bar and the control bar; it stays while they hide.
Widget _playerPanel() => Positioned(
right: _edge,
top: _topBarHeight + 8,
bottom: _bottomControlsHeightNow() + _edge,
child: SignalBuilder(builder: (context) {
final open = _model.panelOpen.value;
return AnimatedSwitcher(
duration: easeTime,
switchInCurve: ease,
switchOutCurve: Curves.easeIn,
layoutBuilder: (current, previous) =>
Stack(alignment: Alignment.topRight, children: [...previous, ?current]),
transitionBuilder: (child, a) => FadeTransition(
opacity: a,
child: SlideTransition(
position: Tween(begin: const Offset(0.12, 0), end: Offset.zero).animate(a),
child: child,
),
),
child: !open
? const SizedBox.shrink(key: ValueKey('no-player-panel'))
: ControlsHoldRegion(
key: const ValueKey('player-panel-hold'),
hold: _hold,
child: PlayerPanel(
onClose: () => _model.panelOpen.value = false,
renderMode: _model.renderMode,
volume: _model.volume,
onVolume: _model.setVolume,
),
),
);
}),
);
/// Fullscreen hides the strip and its window buttons: the top bar
@@ -351,12 +570,30 @@ class _SessionPlayerState extends State<SessionPlayer> {
fit: StackFit.expand,
children: [
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(
model: _model,
shown: _controlsShown,
hold: _hold,
onDoubleClick: widget.onToggleFullscreen,
windowButtons: _fullscreenWindowButtons(),
suppress: _boxUp,
),
],
);
@@ -367,12 +604,33 @@ class _SessionPlayerState extends State<SessionPlayer> {
static const _edge = 16.0;
/// The bottom controls' height in the skin and size in use.
static double _bottomControlsHeight() {
final s = AppSettings.instance;
return controlsBottomHeight(s.controlsSkin.value, s.controlsSize.value);
}
double _bottomControlsHeight() =>
controlsBottomHeight(_settings.controlsSkin.value, _settings.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.
Widget _volumeReadout() => Positioned(
@@ -397,15 +655,20 @@ class _SessionPlayerState extends State<SessionPlayer> {
Widget _actionArea() {
return SignalBuilder(builder: (context) {
final controls = _bottomControlsHeight();
return ValueListenableBuilder<bool>(
valueListenable: _controlsShown,
builder: (context, shown, child) => AnimatedPositioned(
return ListenableBuilder(
listenable: Listenable.merge([_controlsShown, _boxUp]),
builder: (context, child) => AnimatedPositioned(
duration: easeTime,
curve: ease,
left: _edge,
right: _edge,
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: 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
/// next episode; it stays up), else the pills, shown only with the
/// The action area's right end: the end-of-media card (Up next, else
/// the continuation; it stays up), else the pills, shown only with the
/// controls ([PillGate]).
Widget _actionSide() {
final coord = _coord;
return SignalBuilder(
builder: (context) {
final next = coord.nextEpisode.value;
final countdown = coord.autoAdvance.value;
// Up next goes before the next episode.
final upNext = coord.queue.countdown.value;
final finished = coord.serverState.value?.transport == 'finished';
final pill = coord.nextPromptVisible.value;
@@ -437,18 +698,6 @@ class _SessionPlayerState extends State<SessionPlayer> {
if (upNext != null && finished) {
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();
return Column(
mainAxisSize: MainAxisSize.min,
@@ -556,7 +805,7 @@ class _SessionPlayerState extends State<SessionPlayer> {
child: Stack(
fit: StackFit.expand,
children: [
SitOutCard(key: const ValueKey('sit-out-card'), compact: !_full),
SitOutCard(key: const ValueKey('sit-out-card'), compact: !_isFull),
Positioned(
top: 4,
right: 4,
@@ -565,9 +814,9 @@ class _SessionPlayerState extends State<SessionPlayer> {
children: [
IconButton(
focusNode: _noFocus,
onPressed: _full ? widget.onCollapse : widget.onExpand,
tooltip: _full ? 'Small player' : 'Expand',
icon: Icon(_full ? Icons.close_fullscreen : Icons.open_in_full, color: Colors.white, size: 18),
onPressed: _isFull ? widget.onCollapse : widget.onExpand,
tooltip: _isFull ? 'Small player' : 'Expand',
icon: Icon(_isFull ? Icons.close_fullscreen : Icons.open_in_full, color: Colors.white, size: 18),
),
IconButton(
focusNode: _noFocus,
@@ -582,11 +831,13 @@ class _SessionPlayerState extends State<SessionPlayer> {
),
);
/// Mini: title, expand and close on hover; ready, time and a seekable
/// bar at the bottom. A thin progress line stays while not hovered.
/// Mini: title, expand and close on hover; the status, play (the full
/// 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() {
final coord = _coord;
final playback = coord.playback;
const scale = 0.8;
return MouseRegion(
onEnter: (_) => setState(() => _miniHover = true),
onExit: (_) => setState(() => _miniHover = false),
@@ -603,55 +854,73 @@ class _SessionPlayerState extends State<SessionPlayer> {
gradient: LinearGradient(
begin: Alignment.topCenter,
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(
children: [
Row(
Padding(
padding: const EdgeInsets.fromLTRB(12, 6, 6, 0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(width: 10),
const Expanded(child: NowPlayingTitle(cover: false, compact: true)),
IconButton(
focusNode: _noFocus,
onPressed: widget.onExpand,
const Expanded(
child: Padding(
padding: EdgeInsets.only(top: 4),
child: NowPlayingTitle(cover: false, compact: true),
),
),
MinimalButton(
icon: Icons.open_in_full_rounded,
tooltip: 'Expand',
icon: const Icon(Icons.open_in_full, color: Colors.white, size: 18),
),
IconButton(
focusNode: _noFocus,
onPressed: widget.onClose,
scale: scale,
onPressed: widget.onExpand),
MinimalButton(
icon: Icons.close_rounded,
tooltip: 'Sit out (stay in the server)',
icon: const Icon(Icons.close, color: Colors.white, size: 18),
),
scale: scale,
onPressed: widget.onClose),
],
),
),
const Spacer(),
SignalBuilder(builder: (context) {
final ready = coord.selfMember.value?.ready ?? false;
final snap = _model.snapshot.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: [
IconButton(
focusNode: _noFocus,
tooltip: ready ? 'Ready (click: not ready)' : 'Not ready (click: ready)',
onPressed: coord.toggleReady,
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 ?? '',
if (status != null && status.isNotEmpty)
Padding(
padding: const EdgeInsets.fromLTRB(4, 0, 0, 2),
child: Text(status,
maxLines: 1,
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),
@@ -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
/// 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).
@@ -782,7 +1054,9 @@ const _shaderKeys = [
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 {
final bool thin;
final ValueChanged<Duration>? onSeek;
@@ -791,26 +1065,59 @@ class _MiniSeekBar extends StatelessWidget {
@override
Widget build(BuildContext context) {
final playback = AppScope.of(context).coordinator.playback;
final accent = Theme.of(context).colorScheme.primary;
return SignalBuilder(builder: (context) {
final d = playback.duration.value;
final p = playback.position.value;
final v = d.inMilliseconds == 0 ? 0.0 : (p.inMilliseconds / d.inMilliseconds).clamp(0.0, 1.0);
final bar = LinearProgressIndicator(
value: v,
minHeight: thin ? 2 : 4,
backgroundColor: thin ? Colors.transparent : Colors.white24,
double f(Duration x) => d.inMilliseconds == 0 ? 0.0 : (x.inMilliseconds / d.inMilliseconds).clamp(0.0, 1.0);
final bar = SizedBox(
height: thin ? 3 : 4,
width: double.infinity,
child: CustomPaint(
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;
void seekAt(double x, double width) => onSeek!(d * (x / width).clamp(0.0, 1.0));
return LayoutBuilder(
builder: (context, box) => GestureDetector(
builder: (context, box) => MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTapDown: (e) => seekAt(e.localPosition.dx, box.maxWidth),
onHorizontalDragEnd: null,
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;
import '../controls/player_settings.dart';
import '../danmaku/danmaku_settings.dart' show DanmakuSettingsPanel;
import '../jellyfin/accounts.dart';
import '../jellyfin/accounts_panel.dart';
import '../schedule/animeflow.dart';
import '../screens/home/about_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 '../sfx/sfx.dart';
import '../sfx/sound_bank.dart';
@@ -52,6 +53,8 @@ class SettingsTab extends StatelessWidget {
final JellyfinAccounts? jellyfin;
/// AnimeFlow's `/me` for the integrations page (tests pass a fake).
final Future<AnimeFlowMe> Function()? animeFlowMe;
/// Dev builds: About → Developer → Danmaku preview.
final VoidCallback? onDanmakuPreview;
const SettingsTab({
super.key,
required this.section,
@@ -60,6 +63,7 @@ class SettingsTab extends StatelessWidget {
this.updates,
this.jellyfin,
this.animeFlowMe,
this.onDanmakuPreview,
});
List<Widget> _page(BuildContext context) {
@@ -121,7 +125,8 @@ class SettingsTab extends StatelessWidget {
return [
const SettingsHintCard(
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(
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()),
];
case SettingsSection.notifications:
@@ -162,7 +167,10 @@ class SettingsTab extends StatelessWidget {
case SettingsSection.shortcuts:
return const [SettingsPanel(title: 'Keyboard shortcuts', child: ShortcutList())];
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,
children: [
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'),
Padding(
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(
title: 'Sitting out and in the background',
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.
class PartyToasts extends StatefulWidget {
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
State<PartyToasts> createState() => _PartyToastsState();
@@ -86,6 +90,7 @@ class _PartyToastsState extends State<PartyToasts> {
// Only new toasts re-run this, not the party they mention.
untracked(() {
for (final t in fresh) {
if (widget.skip?.call(t) ?? false) continue;
_add(shellToastFor(t, (n) => avatarForName(n, coord, t.actorId)));
}
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();
}
/// 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:hive_ce/hive.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 '../danmaku/danmaku.dart' show DanmakuStyle;
import '../shaders/shader_presets.dart';
import '../session/notice_prefs.dart';
import 'settings_schema.dart';
@@ -44,9 +46,31 @@ const keyAutoSitOut = 'autoSitOut';
const _keyChatPopups = 'chatPopups';
const _keyNotifyPeople = 'notifyPeople';
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 }
/// 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 GPU, frames copied back): media_kit renders through ANGLE, where
/// 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 {
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 hwdecCustom = signal('');
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);
/// Session: send ready=false when the video changes (a swap keeps the
/// 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
/// session (never chat). Off: nothing is sent but a note that it is off.
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.
final chatPopups = signal(ChatPopups.all);
/// 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.
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
/// in the session (Ctrl+0..6, the shader button).
final shaderPreset = signal(ShaderPreset.off);
@@ -129,6 +201,7 @@ class AppSettings {
Future<void> _loadNow() async {
try {
final box = _box = await Hive.openBox(settingsBoxName);
freshInstall = box.isEmpty;
for (final line in await migrateSettings(box)) {
debugPrint(line);
}
@@ -141,6 +214,7 @@ class AppSettings {
if (custom is String) hwdecCustom.value = custom;
final auto = box.get(_keyAutoCheckUpdates);
if (auto is bool) autoCheckUpdates.value = auto;
await _loadUpdateChannel(box);
final advance = box.get(_keyAutoAdvance);
if (advance is bool) autoAdvance.value = advance;
final unready = box.get(_keyUnreadyOnSwap);
@@ -163,6 +237,16 @@ class AppSettings {
if (hideRe is bool) hideReReleases.value = hideRe;
final line = box.get(_keyProgressLine);
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);
if (share is bool) shareDiagnostics.value = share;
final popups = box.get(_keyChatPopups);
@@ -192,6 +276,10 @@ class AppSettings {
shaderQuality.value =
ShaderQuality.values.where((q) => q.name == quality).firstOrNull ??
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);
if (files is List) {
customShaders.value = List.unmodifiable(files.whereType<String>());
@@ -216,6 +304,30 @@ class AppSettings {
_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) {
autoAdvance.value = on;
_put(_keyAutoAdvance, on);
@@ -263,6 +375,32 @@ class AppSettings {
_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) {
shareDiagnostics.value = on;
_put(_keyShareDiagnostics, on);
@@ -326,6 +464,16 @@ class AppSettings {
_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 {
try {
final box = _box ??
+2 -2
View File
@@ -288,8 +288,8 @@ class SyncClient {
_send(PingMessage(clientMs: _clock().millisecondsSinceEpoch).toJson());
}
void sendSwap(HelloMedia media, {String? queueId}) {
_send(SwapIntent(media: media, queueId: queueId).toJson());
void sendSwap(HelloMedia media, {String? queueId, SwapRestore? restore}) {
_send(SwapIntent(media: media, queueId: queueId, restore: restore).toJson());
}
/// One change to Up next (see [QueueIntent]).
@@ -54,23 +54,41 @@ class UpdateController {
final String currentVersion;
final String manifestUrl;
final String devUrl;
final String? executablePath;
final Directory? installRoot;
final RestartFn restart;
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(
{this.currentVersion = appVersion,
this.manifestUrl = updateManifestUrl,
this.devUrl = devManifestUrl,
this.executablePath,
this.installRoot,
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));
/// 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
/// ("Up to date", "Check failed: ..."); null until one finishes.
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;
late final String _exe = executablePath ?? currentExecutablePath();
late final Directory? _root = installRoot ?? findInstallRoot(_exe);
@@ -106,8 +124,19 @@ class UpdateController {
final root = _root;
if (root != null) await cleanupUpdateLeftovers(root);
try {
final ch = channel;
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)) {
_log.info('up to date ($currentVersion, latest ${m.version})');
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) {
UpdatePhase.downloading || UpdatePhase.applying || UpdatePhase.restarting => true,
_ => false,
@@ -173,6 +223,11 @@ class UpdateController {
_log.info('updated to ${m.version}, restarting');
status.value =
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
// binary next to it.
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
#include <gdk/gdkx.h>
#endif
#ifdef GDK_WINDOWING_WAYLAND
#include <gdk/gdkwayland.h>
#endif
#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)));
}
// 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
// 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,
gpointer user_data) {
GtkWindow* window = GTK_WINDOW(user_data);
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)) {
gtk_window_set_urgency_hint(window, TRUE);
}
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 {
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.
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(),
"application-id", APPLICATION_ID, "flags",
G_APPLICATION_NON_UNIQUE, nullptr));
+2 -2
View File
@@ -865,14 +865,14 @@ packages:
path: "../../packages/core"
relative: true
source: path
version: "1.2.0"
version: "1.3.0"
sync_relay_server:
dependency: "direct main"
description:
path: "../../packages/server"
relative: true
source: path
version: "1.2.0"
version: "1.3.0"
synchronized:
dependency: transitive
description:
+1 -1
View File
@@ -1,7 +1,7 @@
name: starsync
description: Starsync desktop app (repo sync_relay_project). One binary.
publish_to: none
version: 1.2.0
version: 1.4.0
environment:
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:hive_ce/hive.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/testing.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 {
final server = SyncServer(const AppConfig(port: 0, secret: '', name: 'cancel'));
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);
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(oldIcon.existsSync(), isFalse);
expect(other.existsSync(), isTrue);
expect(otherIcon.existsSync(), isTrue);
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('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);
} finally {
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:convert';
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/screens/home/picked_media.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
// 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() {
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)),
() async {
MediaKit.ensureInitialized();
@@ -296,6 +296,11 @@ void main() {
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.
a.toggleReady();
await _poll(() => !a.playback.player.state.playing, 'held');
@@ -304,7 +309,7 @@ void main() {
_kind(s) == 'Progress' && s.body?['IsPaused'] == true),
'paused progress');
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
// 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),
'track change reported');
// Near the end: finished -> Stopped, then one auto-advance swap.
a.seekTo(16);
// Alice put the episode up and has the next one: it continues it,
// 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',
timeout: const Duration(seconds: 20));
timeout: const Duration(seconds: 40));
final finishedId = a.serverState.peek()!.mediaId;
await _poll(() => a.autoAdvance.peek() != null && c.autoAdvance.peek() != null,
'both account holders count down');
expect(b.autoAdvance.peek(), isNull);
final swappers = [a.autoAdvance.peek()!.swapper, c.autoAdvance.peek()!.swapper];
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, b].every((x) => (x.queue.countdown.peek()?.secondsLeft ?? 0) > 0),
'everyone sees the countdown');
expect(a.serverState.peek()!.advance!.id, auto.id);
expect(a.queue.countdown.peek()!.item.id, auto.id);
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));
expect(a.serverState.peek()!.mediaId, finishedId + 1, reason: 'no double swap');
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),
'all opened the next episode');
expect(b.jellyfinPlan.peek(), isA<RelayPlay>());
@@ -383,13 +393,17 @@ void main() {
expect(ep1.last, 'Stopped');
expect(ep1.where((k) => k == 'Stopped'), hasLength(1));
final stopped1 = aCalls.lastWhere((s) => s.body?['ItemId'] == _ep1);
// Part of it watched, then a seek to the end: not watched (see
// WatchSession). A resume point capped at 85 % of the 20 s, so
// Jellyfin cannot mark it played on its own.
expect(stopped1.body!['PositionTicks'], lessThan(18 * 10000000),
reason: 'part way: resume point under 90 %, so not marked played');
expect(jelly.calls.any((c) => c.method == 'POST' && c.path == '/UserPlayedItems/$_ep1'), isFalse,
reason: 'seeking to the end is not watching');
// Alice watched it through (time In, see WatchSession): Stopped
// without a position, then marked played.
expect(stopped1.body!.containsKey('PositionTicks'), isFalse, reason: 'watched: Jellyfin marks it played');
bool markedPlayed(String token) => jelly.calls.any((c) =>
c.method == 'POST' &&
c.path == '/UserPlayedItems/$_ep1' &&
_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(ep2.first, 'Playing');
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 accB = JellyfinAccounts(store: MemoryJellyfinStore());
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()));
try {
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 '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/screens/home/media_picker.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/session_coordinator.dart' show AutoAdvance;
import 'package:starsync/src/shell/library_tab.dart' show putUpJellyfinMedia;
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', () {
late JellyfinAccounts accounts;
late DateTime now;
late WatchSession watch;
late JellyfinProgressReporter reporter;
final errors = <Object>[];
var unauthorized = 0;
@@ -224,27 +226,35 @@ void main() {
now = DateTime(2026, 1, 1, 20);
errors.clear();
unauthorized = 0;
watch = WatchSession();
reporter = JellyfinProgressReporter(
client: accounts.clientFor(fixtureServerId)!,
itemId: episodeId,
source: fixtureSource(),
playSessionId: playSessionId,
watch: watch,
clock: () => now,
onError: errors.add,
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))
.then((_) {});
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).
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}) {
now = DateTime(2026, 1, 1, 20).add(Duration(seconds: secs));
reporter.update(PlayerSnapshot(position: pos, paused: paused, aid: aid, sid: sid));
}
void at(int secs, double pos, {bool paused = false, int? aid = 2, int? sid = 1}) =>
tick(secs, pos, paused: paused, aid: aid, sid: sid);
at(1, 0); // resume -> Progress
at(2, 1);
@@ -282,15 +292,14 @@ void main() {
'MediaSourceId': episodeId,
'PlaySessionId': playSessionId,
'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);
});
test('watched to the end: Stopped without a position, then marked played', () async {
reporter.start(const PlayerSnapshot(position: 0, paused: false));
for (var i = 1; i <= 1420; i++) {
now = DateTime(2026, 1, 1, 20).add(Duration(seconds: i));
reporter.update(PlayerSnapshot(position: i.toDouble(), paused: false));
for (var i = 0; i <= 1420; i++) {
tick(i, i.toDouble());
}
await reporter.stop(const PlayerSnapshot(position: 1420, paused: true));
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');
});
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 {
final r = JellyfinProgressReporter(
client: accounts.clientFor(fixtureServerId)!,
@@ -376,21 +398,6 @@ void main() {
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', () {
DeliveryRoute? route(MemberView m, {bool relay = true}) =>
routeOf(m, DeliveryKind.jellyfin, '', episodeRef, relay);
@@ -508,23 +515,36 @@ void main() {
const JellyfinMedia(
serverId: fixtureServerId, baseUrl: 'b', itemId: nextEpisodeId, title: 'Battle with the Heroes'));
testWidgets('end card: Next prompt, countdown with Cancel', (tester) async {
var play = 0, cancel = 0;
await tester.pumpWidget(scoped(_graph, app(EndOfEpisodeCard(
next: next, countdown: null, onPlayNow: () => play++, onCancel: () => cancel++))));
expect(find.text('Next: S1E7 Battle with the Heroes'), findsOneWidget);
expect(find.byKey(const ValueKey('auto-advance-cancel')), findsNothing);
await tester.tap(find.byKey(const ValueKey('play-next')));
expect(play, 1);
testWidgets("end card: the server's countdown with Cancel, Play now; waiting for who puts it up",
(tester) async {
final queue = _graph.coordinator.queue;
const item = QueueItem(
id: 'q4',
media: HelloMedia(name: 'ep7', size: 0, duration: 0, jellyfin: JellyfinRef(serverId: fixtureServerId, itemId: nextEpisodeId)),
how: QueueHow.jellyfin,
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(
next: next,
countdown: AutoAdvance(mediaId: 3, secondsLeft: 7, next: next, swapper: false),
onPlayNow: () => play++,
onCancel: () => cancel++))));
expect(find.text('Next episode in 7 s'), findsOneWidget);
await tester.tap(find.byKey(const ValueKey('auto-advance-cancel')));
expect(cancel, 1);
// Cancelled (for everyone): Play now only.
await tester.pumpWidget(scoped(_graph, app(UpNextCard(
queue: queue, countdown: const UpNextCountdown(mediaId: 3, item: item)))));
expect(find.text('Next'), findsOneWidget);
expect(find.byKey(const ValueKey('up-next-cancel')), findsNothing);
// Ran out while the one who puts it up is gone: the card says so.
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 {
+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 _minimisedAway = NoticeContext(self: Presence.away, window: WindowActivity.minimised);
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 _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('chat from someone', _of(_feed('chat', reason: 'hi')), ChatPosted,
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,
toast: 'Me: hi', actor: 'Me', logged: true),
_Row('rename', _of(_feed('rename', target: 'Annie')), MemberRenamed, logged: true),
+136 -66
View File
@@ -1,12 +1,13 @@
import 'dart:io';
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:signals/signals_flutter.dart' show signal;
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/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/schedule/animeflow.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 {
await tester.ensureVisible(find.byKey(ValueKey(key)));
await tester.pumpAndSettle();
await tester.tap(find.byKey(ValueKey(key)));
await tester.pumpAndSettle();
await tester.tap(find.text(item).last);
@@ -57,6 +60,8 @@ void main() {
s.shaderPreset.value = ShaderPreset.off;
s.shaderQuality.value = ShaderQuality.hq;
s.hwdecMode.value = HwdecMode.auto;
s.liftSubtitles.value = false;
s.notifyStyle.value = NotifyStyle.toasts;
coord.playback.tracks.value = const [];
coord.serverState.value = null;
coord.hostFile = null;
@@ -64,76 +69,66 @@ void main() {
_graph.forms.recents.value = const [];
});
Widget opener() => Builder(
builder: (context) => TextButton(
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 {
group('player panel', () {
testWidgets('tracks, subtitles, picture, sound, notifications and controls; all live', (tester) async {
bigWindow(tester);
coord.playback.tracks.value = const [
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'),
];
await tester.pumpWidget(scoped(_graph, app(Align(alignment: Alignment.bottomRight, child: opener()))));
await tester.tap(find.byKey(const ValueKey('open-settings')));
var volume = 80.0;
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();
final card = find.byKey(const ValueKey('player-settings-card'));
expect(card, findsOneWidget);
for (final label in [
'Player design',
'Progress line when hidden',
'Upscaling shader',
'Hardware decoding',
'Subtitles',
'Audio',
]) {
expect(find.descendant(of: card, matching: find.text(label)), findsOneWidget, reason: label);
final panel = find.byKey(const ValueKey('player-panel'));
expect(panel, findsOneWidget);
for (final label in ['Subtitles', 'Audio', 'Size', 'Upscaling', 'Hardware decoding', 'Volume']) {
expect(find.descendant(of: panel, matching: find.text(label)), findsWidgets, reason: label);
}
// 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);
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);
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');
await pick(tester, 'panel-shader', 'Mode B');
expect(s.shaderPreset.value, ShaderPreset.b);
await pick(tester, 'shader-quality', 'Fast');
expect(s.shaderQuality.value, ShaderQuality.fast);
await pick(tester, 'hwdec', 'Off');
await pick(tester, 'panel-hwdec', 'Off');
expect(s.hwdec.value, 'no');
// Esc closes it.
await tester.sendKeyEvent(LogicalKeyboardKey.escape);
await tester.tap(find.descendant(of: find.byKey(const ValueKey('panel-lift')), matching: find.byType(Switch)));
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();
expect(card, findsNothing);
});
testWidgets('keeps the controls up while open', (tester) async {
bigWindow(tester);
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 pick(tester, 'notify-style', 'Danmaku');
expect(s.notifyStyle.value, NotifyStyle.danmaku);
expect(find.byKey(const ValueKey('danmaku-weight')), findsOneWidget);
await tester.ensureVisible(find.byKey(const ValueKey('panel-section-Controls')));
await tester.tap(find.byKey(const ValueKey('panel-section-Controls')));
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('player-settings-card')), findsOneWidget);
expect(find.text('Standard'), findsOneWidget, reason: 'the current skin');
await tester.pump(const Duration(seconds: 3));
expect(controls.visible, isTrue, reason: 'held while the card is open');
await tester.sendKeyEvent(LogicalKeyboardKey.escape);
await pick(tester, 'panel-design', 'Minimal');
expect(s.controlsSkin.value, ControlsSkin.minimal);
await tester.ensureVisible(find.byKey(const ValueKey('player-panel-close')));
await tester.pumpAndSettle();
await tester.pump(const Duration(seconds: 2));
expect(controls.visible, isFalse);
await tester.tap(find.byKey(const ValueKey('player-panel-close')));
expect(closed, 1);
});
});
@@ -169,6 +164,37 @@ void main() {
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 {
await pumpSettings(tester, SettingsSection.player, size: const Size(1280, 2400));
expect(find.textContaining('also in the player: ⚙ → Player settings'), findsOneWidget);
@@ -177,6 +203,8 @@ void main() {
'progress-line',
'shader-preset',
'hwdec',
'sub-scale',
'lift-subtitles',
'time-display',
'controls-size',
'controls-hide',
@@ -274,12 +302,12 @@ void main() {
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');
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(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('[fe80::1]:8080'), (host: 'fe80::1', port: '8080'));
expect(addressIn('just a host'), isNull);
@@ -336,12 +364,54 @@ void main() {
expect(find.byKey(const ValueKey('stop-sharing')), findsNothing);
yours.shared.value = const Invite(host: '192.168.1.20', port: 8080, version: '1.3.0');
await tester.pumpAndSettle();
expect(find.text('192.168.1.20:8080'), findsOneWidget);
expect(find.text('Copy invite'), findsOneWidget);
yours.reach.value = const InviteReach(local: ['192.168.1.20'], looking: true);
await tester.pump();
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.textContaining('port 8080 forwarded'), findsOneWidget);
expect(find.textContaining('VPS'), findsOneWidget);
// One address so far: nothing else to pick, nothing to say.
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: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';
MemberView member(String id,
{bool gone = false, bool away = false, Map<String, bool> jf = const {}, String name = ''}) =>
MemberView(
id: id,
name: name.isEmpty ? id : name,
ready: false,
inSet: true,
buffered: 0,
presence: gone ? Presence.gone : away ? Presence.away : Presence.watching,
jf: jf);
import 'show_matcher_test.dart' show FakeAniList;
// What's next on the app: an item's words, and what continues a file of
// a local show (who puts an item up and the countdown are core's and the
// server's: core `whats_next_test`, `queue_test`).
QueueItem item(QueueHow how, {String by = 'm2', String byName = 'Bob'}) => QueueItem(
id: 'q1',
media: how == QueueHow.jellyfin
? 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'),
media: const HelloMedia(name: 'x', size: 0, duration: 0, url: 'https://x/y'),
how: how,
title: 'x',
by: by,
@@ -27,49 +22,58 @@ QueueItem item(QueueHow how, {String by = 'm2', String byName = 'Bob'}) => Queue
);
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', () {
expect(queueHowLabel(item(QueueHow.through), selfId: 'm2'), 'through you');
expect(queueHowLabel(item(QueueHow.through), selfId: 'm1'), 'through Bob');
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(65), '1:05');
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/session/session_coordinator.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
// end, over real mpv and a real sync server: two Jellyfin account holders
@@ -244,8 +245,14 @@ void main() {
final hosts = <SessionCoordinator, MediaKitHost>{};
SessionCoordinator coord(JellyfinAccounts acc) {
final host = MediaKitHost()..init();
// Up next alone: no continuation of the episodes (it would be
// queued after the items this test adds and times).
final c = SessionCoordinator(
jellyfin: acc, trackPrefs: TrackPrefs(store: MemoryJellyfinStore()), ytDlp: ytDlp, players: host)
jellyfin: acc,
trackPrefs: TrackPrefs(store: MemoryJellyfinStore()),
ytDlp: ytDlp,
players: host,
settings: AppSettings()..autoAdvance.value = false)
..media.preloadEnabled = preload;
hosts[c] = host;
return c;
@@ -336,6 +343,13 @@ void main() {
final relay = stateMediaRelay(a.serverState.peek()!);
expect(relay?.queueId, id, reason: 'the warm relay became the media\'s');
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) {
for (final x in all.values) {
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 {
final s = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
final requests = <String>[];
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 r = resolveMediaRequest(rangeHeader: req.headers.value('range'), size: size, filename: 'v.mkv');
req.response.statusCode = r.status;
@@ -274,6 +274,12 @@ void main() {
expect(alice.serverState.peek()!.toJson().toString(), isNot(contains('SECRET')));
await playTogether([alice, bob]);
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;
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: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 'test_graph.dart';
@@ -166,6 +167,11 @@ void main() {
final invite = await yours.invite();
expect(invite.port, port);
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(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 {
final support = _fresh('shaders_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']) {
expect(File('${dir.path}/$f').readAsBytesSync(),
File('$anime4kAssetDir/$f').readAsBytesSync(),
final shipped = File('$anime4kAssetDir/$f');
expect(File('${dir.path}/$f').readAsStringSync(),
f.endsWith('.glsl') ? highpAnime4k(shipped.readAsStringSync()) : shipped.readAsStringSync(),
reason: f);
}
// 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 {
Hive.init(_fresh('shaders_hive').path);
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/state/settings.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/navigation.dart' show Place;
@@ -88,7 +89,8 @@ void main() {
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.devicePixelRatio = 1;
addTearDown(tester.view.reset);
@@ -97,6 +99,7 @@ void main() {
home: AppShell(
updates: updates ?? UpdateController(currentVersion: '0.0.0-dev'),
autoUpdateCheck: false,
whatsNew: whatsNew,
windowControls: false,
videoBuilder: video ?? (context, controls) => const FakeVideo(),
// 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', () {
testWidgets('home, alone: a plain player\'s strip, Join a friend and Invite (once your server runs)',
(tester) async {
@@ -418,6 +440,25 @@ void main() {
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 {
coord.playback.alive.value = true;
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.
/// Lower a number when a use goes; never raise one or add a class.
const baseline = {
'AppSettings': 55,
'AppSettings': 47,
'JellyfinAccounts': 10,
'UpdateController': 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
/// until a test calls `navigation.start()` (or `goHome`), so widget tests
/// see the app before your server is up.
AppGraph testGraph({HealthGetter healthGet = defaultHealthGet}) =>
AppGraph.build(identity: ClientIdentity.ephemeral(), healthGet: healthGet);
/// see the app before your server is up. Sharing looks up no internet
/// address unless [publicAddress] gives one.
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].
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:flutter_test/flutter_test.dart';
import 'package:hive_ce/hive.dart';
import 'package:sync_relay_core/core.dart';
import 'package:starsync/src/state/settings.dart';
import 'package:starsync/src/update/update_controller.dart';
// 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(),
'binary-v1');
}, 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;
int ok = 0, forbidden = 0;
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';
@@ -61,12 +69,21 @@ class _Cdn {
await res.close();
return;
}
if (_cut) {
_cut = false;
refused++;
res.statusCode = HttpStatus.preconditionFailed;
await res.close();
return;
}
ok++;
cookies.add(req.headers.value('cookie'));
agents.add(req.headers.value('user-agent'));
final bytes = await File(file).readAsBytes();
var start = 0, end = bytes.length - 1;
final range = RegExp(r'bytes=(\d*)-(\d*)').firstMatch(req.headers.value('range') ?? '');
res.headers.set('accept-ranges', 'bytes');
res.headers.contentType = ContentType('video', 'mp4');
if (range != null) {
start = int.tryParse(range[1]!) ?? 0;
end = int.tryParse(range[2]!) ?? end;
@@ -74,6 +91,23 @@ class _Cdn {
res.headers.set('content-range', 'bytes $start-$end/${bytes.length}');
}
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));
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);
dir.createSync(recursive: true);
final h = headers ? {'Referer': _referer, 'User-Agent': 'fake-agent'} : <String, String>{};
final json = jsonEncode({
'id': 'BV1', 'title': 'Fake page', 'duration': 30, 'extractor': 'fake',
'format_id': 'v+a', 'ext': 'mp4',
'requested_formats': [
final formats = [
{'format_id': 'v', 'url': cdn.url('v.m4s'), 'protocol': 'https', 'ext': 'mp4',
'vcodec': 'avc1.64001F', 'acodec': 'none', 'width': 320, 'height': 180, 'http_headers': h,
'cookies': 'SESSDATA=abc; Domain=.127.0.0.1; Path=/'},
{'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': {
'en': {'url': cdn.url('en.vtt'), 'ext': 'vtt', 'name': 'English'},
'danmaku': {'url': cdn.url('danmaku.xml'), 'ext': 'xml'},
@@ -124,8 +161,9 @@ void main() {
tearDownAll(() => server.close(force: true));
// 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
// yt-dlp hook is off.
// stream URLs (video + audio) read by the app's own reader
// (SharedLinks.local) with the CDN's headers and cookies, mpv on its
// loopback EDL; mpv's yt-dlp hook is off.
test(
"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)),
@@ -146,6 +184,8 @@ void main() {
expect(cdn.forbidden, 0, reason: 'every request had the Referer');
expect(cdn.ok, greaterThanOrEqualTo(2));
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');
await _until(() => p.tracks.peek().any((t) => t.isExternal), 'the subtitle track listed');
} finally {
@@ -185,6 +225,43 @@ void main() {
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.
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));
@@ -203,8 +280,8 @@ void main() {
}, skip: Platform.isLinux ? false : 'fake yt-dlp is a shell script');
}
Future<void> _until(bool Function() cond, String what) async {
final end = DateTime.now().add(const Duration(seconds: 10));
Future<void> _until(bool Function() cond, String what, {int secs = 10}) async {
final end = DateTime.now().add(Duration(seconds: secs));
while (!cond()) {
if (DateTime.now().isAfter(end)) fail('timed out waiting for $what');
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