30 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
119 changed files with 11848 additions and 748 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
+73 -26
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.
@@ -349,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
@@ -369,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
@@ -387,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.
+38 -4
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,14 +71,19 @@ 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();
@@ -76,8 +100,10 @@ Future<void> main(List<String> args) async {
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;
@@ -94,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);
}
+26 -1
View File
@@ -12,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';
@@ -21,6 +22,8 @@ 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';
@@ -57,6 +60,15 @@ class AppGraph {
/// 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,
@@ -68,10 +80,14 @@ class AppGraph {
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,
@@ -80,6 +96,7 @@ class AppGraph {
JellyfinAccounts? jellyfin,
ShowMatcher? library,
WatchHistory? history,
Future<String?> Function()? publicAddress,
}) {
final p = ports ?? desktopPorts();
final settings = AppSettings.instance;
@@ -132,6 +149,7 @@ class AppGraph {
},
logDir: () => appLogDir,
starter: serverStarter,
publicAddress: publicAddress,
);
final navigation = Navigation(
coordinator: coordinator,
@@ -146,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,
@@ -157,6 +179,9 @@ class AppGraph {
navigation: navigation,
library: matcher,
watch: watch,
updates: updates,
whatsNew: whatsNew,
joinOffers: JoinOffers(navigation: navigation, yourServer: yourServer, forms: forms),
);
}
@@ -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),
],
),
);
@@ -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;
@@ -115,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
@@ -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),
+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()],
),
),
),
],
),
);
}
}
@@ -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,9 @@ 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
@@ -203,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());
@@ -326,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();
@@ -390,6 +424,10 @@ class _MkPlayer implements HostedPlayer {
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;
@@ -437,6 +475,8 @@ class _MkPlayer implements HostedPlayer {
@override
Future<void> open(String url, {Map<String, String> headers = const {}, bool play = false}) async {
if (_videoReady case final ready?) await ready;
_lastOpen = (url: url, headers: headers, play: play);
_voRetried = false;
// An empty map, not null: media_kit caches headers per URI.
await mk.open(Media(url, httpHeaders: headers), play: play);
}
@@ -510,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;
await file.parent.create(recursive: true);
await file.writeAsString(entry);
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;
@@ -689,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
@@ -712,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());
}
}
}());
}
@@ -814,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
@@ -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});
@@ -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}) {
+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,7 @@ class MediaPipeline {
_openUrl = null;
_openHeaders = null;
await playback.stop();
_trimLocal();
}
/// The same media came back as [r.mediaId] (a restarted server put back
@@ -462,7 +501,8 @@ class MediaPipeline {
feeder.serve(r.mediaId, RelayMedia.single(FileRelayBytes(File(path))),
queueId: queueId, name: name, duration: duration);
case OwnLink(:final url, :final origin):
feeder.serve(r.mediaId, LinkRelayMedia(Uri.parse(url), ytDlp: origin.ytDlp, probe: origin.probe),
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;
@@ -524,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
@@ -538,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
@@ -558,6 +600,7 @@ class MediaPipeline {
_jellyfinFeed = null;
settledMediaId.value = -1;
feeder.stopAll();
unawaited(shared.closeAll());
}
// ----------------------------------------------------------- Jellyfin
@@ -738,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
@@ -752,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;
@@ -814,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.
@@ -840,6 +904,7 @@ class MediaPipeline {
Future<void> dispose() async {
await release();
feeder.stopAll();
await shared.closeAll();
await playback.dispose();
}
}
@@ -864,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;
@@ -885,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
+27 -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,
@@ -317,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;
@@ -329,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
@@ -351,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,
@@ -379,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) {
@@ -401,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));
}
@@ -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.
@@ -38,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';
@@ -426,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();
@@ -479,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
@@ -517,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;
@@ -1262,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;
@@ -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,
),
),
),
+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));
}
}
+405 -84
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(
key: _videoKey,
controller: videoControllerOf(_coord.playback.video),
subtitleViewConfiguration: const SubtitleViewConfiguration(visible: false),
controls: controls,
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(
@@ -542,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,
@@ -551,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,
@@ -568,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),
@@ -589,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(
children: [
const SizedBox(width: 10),
const Expanded(child: NowPlayingTitle(cover: false, compact: true)),
IconButton(
focusNode: _noFocus,
onPressed: widget.onExpand,
tooltip: 'Expand',
icon: const Icon(Icons.open_in_full, color: Colors.white, size: 18),
),
IconButton(
focusNode: _noFocus,
onPressed: widget.onClose,
tooltip: 'Sit out (stay in the server)',
icon: const Icon(Icons.close, color: Colors.white, size: 18),
),
],
Padding(
padding: const EdgeInsets.fromLTRB(12, 6, 6, 0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Expanded(
child: Padding(
padding: EdgeInsets.only(top: 4),
child: NowPlayingTitle(cover: false, compact: true),
),
),
MinimalButton(
icon: Icons.open_in_full_rounded,
tooltip: 'Expand',
scale: scale,
onPressed: widget.onExpand),
MinimalButton(
icon: Icons.close_rounded,
tooltip: 'Sit out (stay in the server)',
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(
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,
final status = playerStatusNow(coord)?.short;
return Padding(
padding: const EdgeInsets.fromLTRB(8, 0, 12, 2),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
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: Color(0xFFFFC857), fontSize: 12)),
),
Row(
children: [
MinimalButton(
icon: controlIcon(BarControl.playPause, snap),
tooltip: controlTooltip(BarControl.playPause, snap),
accent: true,
scale: scale,
onPressed: _model.togglePlay,
),
const SizedBox(width: 8),
Text('${formatClock(pos)} / ${formatClock(dur)}',
style: const TextStyle(
color: MinimalStyle.dim,
fontSize: 12,
fontFeatures: [FontFeature.tabularFigures()])),
],
),
),
Text('${formatClock(pos)} / ${formatClock(dur)}',
style: const TextStyle(color: Colors.white70, fontSize: 12)),
const Spacer(),
Flexible(
child: Text(playerStatusNow(_coord)?.short ?? '',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Colors.amber, fontSize: 12)),
),
const SizedBox(width: 10),
],
],
),
);
}),
_MiniSeekBar(onSeek: playback.userSeek),
@@ -654,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).
@@ -768,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;
@@ -777,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(
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),
builder: (context, box) => MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTapDown: (e) => seekAt(e.localPosition.dx, box.maxWidth),
onHorizontalDragUpdate: (e) => seekAt(e.localPosition.dx, box.maxWidth),
child: Padding(padding: const EdgeInsets.only(top: 8), child: bar),
),
),
);
});
}
}
class _MiniBarPainter extends CustomPainter {
final double played;
/// End of the cache ahead (a track is drawn only with it).
final double? cached;
final Color accent;
const _MiniBarPainter({required this.played, required this.cached, required this.accent});
@override
void paint(Canvas canvas, Size size) {
final w = size.width, h = size.height;
final c = cached;
if (c != null) {
canvas.drawRect(Offset.zero & size, Paint()..color = Colors.white.withValues(alpha: 0.18));
if (c > played) {
canvas.drawRect(Rect.fromLTRB(w * played, 0, w * c, h), Paint()..color = Colors.white.withValues(alpha: 0.3));
}
}
canvas.drawRect(Rect.fromLTRB(0, 0, w * played, h), Paint()..color = accent);
}
@override
bool shouldRepaint(_MiniBarPainter old) => old.played != played || old.cached != cached || old.accent != accent;
}
+18 -4
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',
@@ -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;
+147 -1
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,9 +80,30 @@ 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);
/// 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).
@@ -87,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
@@ -107,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);
@@ -131,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);
}
@@ -143,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);
@@ -165,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);
@@ -194,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>());
@@ -218,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);
@@ -265,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);
@@ -328,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 ??
@@ -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,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.3.0
version: 1.4.0
environment:
sdk: '>=3.13.0-0 <4.0.0'
@@ -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';
@@ -443,6 +444,39 @@ void main() {
}
});
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());
});
}
+12 -3
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('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');
});
});
}
+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
});
});
}
@@ -343,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': 49,
'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);
});
}
+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);
});
});
}
+88 -11
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 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,
'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',
'requested_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},
],
'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));
+12 -1
View File
@@ -52,6 +52,13 @@ bool FlutterWindow::OnCreate() {
window_channel_->SetMethodCallHandler(
[this](const flutter::MethodCall<flutter::EncodableValue>& call,
std::unique_ptr<flutter::MethodResult<flutter::EncodableValue>> result) {
// A second start that handed its link to the running app ends
// here, before its window ever shows (no CRT exit, no DLL detach).
if (call.method_name() == "exit") {
fflush(nullptr);
::TerminateProcess(::GetCurrentProcess(), 0);
return;
}
if (call.method_name() != "attention") {
result->NotImplemented();
return;
@@ -80,7 +87,11 @@ bool FlutterWindow::OnCreate() {
void FlutterWindow::OnDestroy() {
// The engine and the plugins go now; the app released its players first.
ExitAfter(3000);
// Win32Window::Create runs this too, before there is an engine: only a
// real teardown may end the process (else every start dies at 3 s).
if (flutter_controller_) {
ExitAfter(3000);
}
window_channel_ = nullptr;
if (flutter_controller_) {
flutter_controller_ = nullptr;
+479 -74
View File
@@ -61,21 +61,53 @@ attach and detach the network listener (below).
its reason; nothing else changes.
- **Invite** (`session/invite.dart`): `YourServer.invite()` shares the
server if it is not yet and returns an `Invite`: the host, the port, the
name, whether a password is needed (never the password), the version
and an `fp` field reserved for a pinned certificate (`wss://` with a
self-signed one, later; always null). The host is the best of
`inviteAddresses` over `NetworkInterface.list`: IPv4 only, no loopback,
link-local (169.254/16) or proxy fake-IP (198.18.0.0/15, what TUN
proxies like Clash hand out) address and no container or VM bridge
(`docker*`, `br-*`, `veth*`, `virbr*`, `vmnet*`, `vboxnet*`, `cni*`,
`podman*`, `lxcbr*`, `lxdbr*`, `vEthernet`, VirtualBox, VMware); 192.168
first, then 10/8 and 172.16/12, then a mesh VPN's 100.64/10, then any
other (public) address. No public-address lookup: the members card says
that friends elsewhere need the port forwarded or a server they can all
reach. `Invite.text` is what Invite copies ("Join me on Starsync:
192.168.1.20:8080 (ask me for the password) · Starsync 1.3.0");
`addressIn` reads the address back out of a pasted invite (the Join
card's address field). `stopSharing` unshares.
name ("<your name>'s server"), whether a password is needed (not the
password itself), the version and an `fp` field reserved for a pinned
certificate (`wss://` with a self-signed one, later; always null). This
machine's addresses are `inviteAddresses` over `NetworkInterface.list`:
IPv4 only, no loopback, link-local (169.254/16) or proxy fake-IP
(198.18.0.0/15, what TUN proxies like Clash hand out) address and no
container or VM bridge (`docker*`, `br-*`, `veth*`, `virbr*`, `vmnet*`,
`vboxnet*`, `cni*`, `podman*`, `lxcbr*`, `lxdbr*`, `vEthernet`,
VirtualBox, VMware); 192.168 first, then 10/8 and 172.16/12, then a mesh
VPN's 100.64/10, then any other (public) address. Sharing also looks up
the internet address (`lookUpPublicAddress`, `session/public_address.dart`):
plain-text "what is my IP" services over HTTPS (ipify, icanhazip,
checkip.amazonaws, ifconfig.me), asked in order until one answers a
public IPv4 address, 3 s each; that tells those services this network's
address, nothing else. `YourServer.reach` (`InviteReach`) holds both: the
internet address first, then this machine's, each with its
`AddressKind` (internet, home network, VPN); `forwarding` when the
internet address is not on this machine (behind a router: friends
elsewhere get in only with the port forwarded). No UPnP. The invite
carries the internet address when the lookup answers within 4 s
(`publicWait`), else the best local one, switched to the internet one
when it lands. `YourServer.inviteLink(invite)` is what Invite copies:
the bare link with the password the server was shared with
(`starsync://203.0.113.7:8080/?name=Bob%27s+server&pw=…`; the link is
the key, nothing around it). The link format:
[PROTOCOL.md "Invite link"](PROTOCOL.md#invite-link) (`InviteLink`:
`parse`, `uri`, a `toString` without the password). `inviteIn` finds an
invite in a text (a link anywhere, or a 1.3.x plain invite's address;
never a bare address); `addressIn` reads `host:port` out of anything
(the Join card's address field; a 1.3.x app pasting a link reads its
address this way). `stopSharing` unshares.
- **Links and the join prompt** (`session/join_offers.dart`, `JoinOffers`,
in the composition root): a clicked `starsync://` link (the app's start
arguments, or a second start's, see "Single instance" under "App
events and platform ports") and an invite on the clipboard when the
window gains the focus (and at start) become `offer`: "Join Bob's server
(203.0.113.7:8080)?" with the server's `/health` (its name, version,
how many watch, the version gate's sentence, or unreachable). Not
offered: links to your own server (`YourServer.isOwn`), to the server
you are in, and the text this app just copied (`copied`). Join
(`accept`) fills the Join card's fields (a link without a password
takes the one saved for that recent server) and joins through
`ConnectFlow` and `Navigation.join` (version gate, recents); a failure
opens the Join card with its error. A clipboard invite joined or
dismissed (Not now) is not offered again: the connect form box keeps the
last 30 as fingerprints (a hash of the link), never the links; the
clipboard's text is only parsed, never logged or stored.
- **Where you are** (`session/navigation.dart`, `Navigation`): `place` is
`none` (starting, or between two servers), `yours` or `friend`. The app
starts at home: your server, joined over 127.0.0.1 with the host key
@@ -136,7 +168,7 @@ only stopping sharing sends guests out.
`Ports` (the player host among them, handed to the coordinator), the sounds (`SynthSfx`), the `Notifier`, the `Connection`, the
`SessionCoordinator` (handed the settings and the watch tracker), the
connect form and recents (`ConnectStore`, `state/connect_prefs.dart`),
`YourServer`, `Navigation`, the library's matches (`ShowMatcher`,
`YourServer`, `Navigation`, the join prompt (`JoinOffers`), the library's matches (`ShowMatcher`,
"Library", whose `refForPath` it hands the coordinator as `fileRef`, and
a `LocalNextFinder` over it as `nextFile`) and watch tracking
(`WatchTracker` with the device's `WatchHistory`, "Watch tracking"); it
@@ -543,8 +575,20 @@ controls' ⚙ → Upscaling shader menu, Ctrl+0..6, both also save it). The Anim
v4.0.1 files live in `apps/desktop/assets/shaders/anime4k/` (only the 14
the presets use, plus its MIT LICENSE); mpv needs real paths, so
`extractAnime4k` copies them once per version to
`<app support>/shaders/anime4k-<version>/` (a `.complete` marker is written
last). `anime4kFiles` maps each mode to the file list of Anime4K's mpv
`<app support>/shaders/anime4k-<version>-<revision>/` (a `.complete`
marker is written last; other `anime4k-*` copies are deleted), each
rewritten by `highpAnime4k`. 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 a network's `mat4(...) * go_0(...)` sums ran at half
precision (fp16 on NVIDIA). The VL networks of HQ overflow and round
there: shifted, posterised colours and a regular grid, strongest once
the upscalers run (output over 1.2x the video, so mostly with "Sharp
video" at a large size). The rewrite adds a highp identity function to
each pass and routes every `go_N` / `g_N` read through it; the result on
OpenGL ES then equals desktop OpenGL's (an mpv CLI frame grab,
`--opengl-es=yes` vs `no`, identical). Desktop GLSL ignores the
qualifiers. Custom shaders are passed as they are. `anime4kFiles` maps each mode to the file list of Anime4K's mpv
templates: HQ = the high-end GPU set (VL + M networks), Fast = the low-end
set (M + S). A missing file, a path containing the list separator, a
read-back mismatch, or an mpv `libmpv_render` error while shaders are on
@@ -554,7 +598,50 @@ A player with a video output opens media only once that output is up:
media_kit sets mpv's `vo=libmpv` before the output's render context
exists, and a media opened in between gets no video output ("No render
context set", black picture with sound). On X11 the context comes up
later, which widens that gap (joining with media already up).
later, which widens that gap (joining with media already up). On Windows
the controller can report ready before mpv's render context exists, so
an open that still fails its video output that way (`cplayer`: "Error
opening/initializing the selected video_out") is opened again once,
500 ms later (`render: the video output was not up for the open`); the
player error it raised clears when the media loads.
A player whose HTTP stream lost the file's size (ffmpeg's "Seek failed
(to N, size -38)": a request of its that got no headers in time, from a
server without the relay's header rule, or the connection reuse above)
fails every seek until a fresh open, so the coordinator runs "Reload
video" once per media for it (`mpvStreamLost`, `onStreamLost`).
Output size ("Sharp video and subtitles", `AppSettings.screenResVideo`,
on by default, live; Settings → Player and the player panel):
media_kit renders mpv into a texture the size of the video and Flutter
scales it, so libass subtitles, mpv's scaler and the shaders all work on
a 720p image for a 720p file. With the setting on and the GPU renderer
(`renderMode` gpu; never in software rendering, where every pixel is CPU
work, nor before the probe knows), the session view
(`shell/video_view.dart`, `VideoOutputSizer` around the `Video`) sends its
size in physical pixels to the host (`PlayerHost.setOutputSize`): at
once the first time, then once a resize has held 120 ms
(`outputResizeDelay`: a window drag, the chat panel, fullscreen, full ↔
mini). The host renders each output at the picture fitted in that view
(`fitOutputSize` over the video's display size, re-fitted when it
changes), not the whole view: the texture has the picture's aspect, so
while a resize settles Flutter scales the last texture to the new view
without a jump (a texture of the whole view, bars included, shrank
inside its own bars until the new one came, about 0.2 s later), and the
shaders work on no bar pixels. The side that fills the view is exact and
the other is rounded down to the view's parity, so the centred picture
sits on whole pixels and is drawn 1:1. It applies to every live output
and to each new one once it is up (`NativeVideoController.setSize`,
which only changes the size the next frame is drawn at); subtitles are
drawn at that size, inside the picture. media_kit's controller sets an output back to the video's
size whenever the video's parameters change (each new file), so the host
sends the size again after it, under the controller's own lock; a fresh
player's first frame or two show at the video's size. A paused player
draws no frame for the new size: the host nudges `video-pan-x` by a
millionth and back so mpv redraws. Off: each output at its video's own
size again (`setOutputSize(null)`). Measured on a GTX 1080, 1280x720
H.264 at 2560x1080 fullscreen: no difference past the noise in the
process's GPU (`nvidia-smi pmon`, 9 to 12 % either way) or CPU.
Render mode: media_kit's Linux/Windows video output creates mpv's render
context (OpenGL, else its software renderer) when the `VideoController` is
@@ -595,7 +682,8 @@ their own): creates players by role (`session`: the full cache, `warn`;
no outputs, no cache, no log) with every pin set before the caller's
first command (`pinMismatches` lists the read-backs that differ), gives
a player its video output (`HostedVideo`, with the hwdec policy:
`SYNC_RELAY_HWDEC`, else the setting, `auto-copy` on Windows), runs the
`SYNC_RELAY_HWDEC`, else the setting, `auto-copy` on Windows) at the
view's size when asked (`setOutputSize`, see "Output size"), runs the
render-mode probe and its diagnostics (`renderMode`, also after leaving),
routes mpv's log, and owns the order players go in: `retire` (pause, let
the view drop the texture: two frames and a route pop, then dispose; a
@@ -627,8 +715,9 @@ ready.
can, so this is Android's plug-in point).
- Resolve: a Jellyfin item through this app's account (or the plan
resolved ahead for Up next, `warmJellyfin`), else the server's login,
else the member passing it on, else blocked (`openJellyfin`); a web
page through the app's own yt-dlp (`LinkResolver.probe`: yt-dlp's
else the member passing it on, else blocked (`openJellyfin`); a link
this member passes on through its shared reader's loopback URL ("Read
once" under "Relay"); a web page through the app's own yt-dlp (`LinkResolver.probe`: yt-dlp's
stream URLs, an EDL of video and audio, the CDN's headers with its
cookies, the subtitles; see "yt-dlp"), failing in plain words with the
cookies that were used (`ResolveFailure`, D11); a relay not fed yet is
@@ -670,6 +759,10 @@ ready.
- Being a relay source (`RelayFeeder`, `session/relay_feeder.dart`): the
current media's channel and Up next's warm relay (see "Relay"), with
the step-1 re-dial (a source rides through this app's own drop). A
link it passes on is read once for its player and the relay
(`SharedLinks`, `session/shared_links.dart`; "Read once" under
"Relay"): the swap check's reader, else Up next's warm one, else a new
one; it lives while the media does (a sit-out keeps it for the relay). A
Jellyfin item this member put up is passed on only once someone
present needs it (`jellyfinRelayNeeded`: the server's login does not
cover it and a present member does not play that server with its own
@@ -829,8 +922,8 @@ and URL, played together by viewers as an mpv EDL
`relayViewerUrl` / `mpvEdl`). media_kit opens an `edl://` URL unchanged
(it writes it to a playlist and `loadlist`s it) and mpv keeps both
streams in sync across seeks; one URL keeps the app's "one media URL"
path (sync, swap, reopen) as is. The source member plays the page
itself (its own resolve, see "yt-dlp") like any other link.
path (sync, swap, reopen) as is. The source member's own player reads
the same tracks through its shared reader ("Read once" below).
A page with only segmented streams (HLS `m3u8_native`, DASH
`http_dash_segments`, e.g. X's HLS, generic `.m3u8`/`.mpd`) is relayed as
@@ -862,6 +955,64 @@ How bytes move:
this machine). Viewers only ever talk HTTP to the server, so the source
never sees mpv's request pattern. The tracks of a media share the
channel and its flow control (they compete for the same uplink).
- **Read once** (a link passed on: `SharedSource`,
`packages/server/lib/src/shared_source.dart`, kept by the app's
`SharedLinks`): the source reads each track from the site once. Per
track a `RelayBuffer` on the source (64 MiB ahead and 16 MiB behind
for all tracks, split by size like the server's, plus the pins:
`sharedAheadBytes` / `sharedBackBytes`), filled by one sequential
upstream reader, serves two readers: the source's own player, through
a loopback endpoint (`http://127.0.0.1:<port>/<random secret>/<track>`,
an mpv EDL for several tracks, no headers: the site's stay in the
reader) answered with the server's own byte-answer code
(`writeRelayRange`, below: headers within 4 s, `connection: close`),
and the server's pulls (`SharedSource.upload`, served by the usual
`RelaySource`). The player opens at once; the endpoint waits for the
tracks' sizes. The two readers are kept within one window
(`RelayBuffer.together`): while both have a request open, the one more
than the track's `ahead` past the other waits without asking upstream,
so one sequential read serves both instead of handing over at every
slice. The server's read-ahead thus stops about 64 MiB past the
source's player, and a source whose upload lags holds its own player
there (viewers would wait at that point anyway); either reader alone
reads freely, pinned slices always come. A seek (shared, so both
readers go together) is a new upstream read there, as on the server.
Upstream (`ResumingBytes`): a cut resumes from the byte it reached
after 100 ms; an answer without a byte (412, 429, 5xx, a refused or
dead connection, nothing for 20 s) backs off 0.5 s doubling to 8 s;
60 s without a byte fails the read, and the buffer's own backoff asks
again. 403/410/412 first resolve the page again (see "yt-dlp"). The
source's player plays the relay's tracks, so the source and the
viewers watch one format, and the reader sends the headers and cookies
yt-dlp gave for it. The putter's swap check is this reader (its sizes
are the media's: no second read); an Up next link passed on feeds its
warm relay from its own reader, the preload player reads its loopback
URL (one prepared on the site before the reader existed is prepared
again), and the swap carries both on. Reload video opens the loopback
URL again (the reader resolves an expired page itself). Not read once:
a page with a pieces track (HLS/DASH, X's): its player plays yt-dlp's
pick from the site and the relay reads the pieces itself, two fetches;
a file passed on (disk) and a Jellyfin item (see "Media pipeline")
keep their own paths. Measured (server `single_fetch_e2e`, opt-in: a
real server and `RelaySource`, mpv 0.41 with the app's pins on
loopback, a fake CDN with Bilibili's Referer rule and two tracks:
60 s of 720p h264 at 4 Mbit/s and 128 kbit/s aac; 20 s of play, then
a seek to 45 s and back to 5 s):
| CDN / source | First frame: source, viewer | Viewer stalls | Viewer seek to 45 s | CDN requests (player + relay) | CDN MiB | 412s |
|---|---|---|---|---|---|---|
| clean, two fetches | 0.22 s, 0.46 s | 0 | 0.27 s | 8 (2 + 6) | 51.8 | 0 |
| clean, read once | 0.27 s, 0.33 s | 0 | 0.29 s | 6 (0 + 6) | 25.9 | 0 |
| cuts every 0 to 2 MiB, two fetches | 0.22 s, 0.38 s | 1 | 0.27 s | 64 (28 + 36) | 55.9 | 0 |
| cuts, read once (29 resumes) | 0.22 s, 0.27 s | 0 | 0.27 s | 33 (0 + 33) | 25.9 | 0 |
| cuts + 412 lockout (> 40 requests in 10 s), two fetches | 0.21 s, 0.39 s | 1 | 4.03 s | 49 (23 + 26) | 35.3 | 6 |
| cuts + lockout, read once | 0.26 s, 0.33 s | 0 | 0.29 s | 33 (0 + 33) | 25.9 | 0 |
| 768 KiB/s per connection, two fetches | 0.40 s, 0.59 s | 1 | 0.80 s | 12 (5 + 7) | 34.0 | 0 |
| 768 KiB/s, read once | 0.40 s, 0.47 s | 1 | 0.76 s | 7 (0 + 7) | 16.9 | 0 |
The source's own seeks took about what the viewer's did; back to 5 s
took 0.25 to 0.29 s everywhere. The source's player stalled only on
the slow CDN (once, in both modes).
- Upstream GETs (`HttpRelayBytes.read`) are split into back-to-back
ranges of at most yt-dlp's `http_chunk_size` for the format, else
10 MiB (`relayUpstreamChunkBytes`); a buffer pull stays one read.
@@ -894,7 +1045,7 @@ How bytes move:
kept; segments are evicted by distance (behind costs 4x), sizes
estimated from the bytes per second of the pieces in so far (before
that 4 Mbit/s video, 160 kbit/s audio, which also splits the budget).
A failed pull is retried after 1 s. Measured (mpv 0.41 through the
A failed pull is retried with backoff, as for bytes. Measured (mpv 0.41 through the
dedicated server, loopback, ~30 MB/s upstream): Mux's TS HLS (1080p,
one muxed playlist) opened in 0.8 s, seeks 0.07 to 1.1 s; Akamai's DASH
(1080p + aac, two playlists, EDL) 1.3 s, seeks 0.03 to 1.4 s; an X
@@ -903,7 +1054,13 @@ How bytes move:
opened in 0.02 s from the buffer.
- Filling a byte buffer: a viewer blocked on a slice first (oldest wait
first, handing the source over at slice boundaries), then the pinned
slices, then read-ahead from the slowest viewer. A viewer reads the
slices, then read-ahead from the slowest viewer. A failed pull (the
source's upstream dropping, a CDN refusing) is followed by the next
one after 250 ms, doubling up to 8 s (`relayRetryDelay`), back to none
once a slice or a pull completes: a failing CDN is not asked again
every round trip (that drew Bilibili's 412/403). Read-ahead never
fetches the slice that would be evicted next, and a long pull evicts
as its slices come, so a buffer stays within its budget. A viewer reads the
slice being filled as its bytes arrive, not once it is whole (mpv's
demuxer, blocked in a read, takes a seek only once that read returns).
Nothing is fetched while nobody watches through the relay (a Jellyfin
@@ -964,7 +1121,23 @@ How bytes move:
its source before opening its own player, and the source reads its
tracks' sizes at once.
- Source away (D6): viewers keep playing from the buffer; a request
waits up to 60 s for missing bytes. The source member going `gone`
waits up to 60 s for missing bytes.
- A viewer's byte answer is written on the raw connection (relic
`Hijack`, `_rangeResponse`): its headers go out with the first bytes,
or after 4 s without them (`relayHeaderWaitMs`). ffmpeg gives up on a
request whose headers do not come within mpv's 5 s `network-timeout`
and then has lost the file's size for good (8.x and 9.0 do not restore
it): every later seek fails with "Seek failed (to N, size -38)". With
the headers in time, a stalled read only times out and is reconnected
(`reconnect_*`, see "Media pipeline"), and plays on once the bytes come.
The answer always says `connection: close` and closes after its body:
ffmpeg asks for close, and ffmpeg 8.x, not told so, sends its next seek
on the connection after reading a range to its end (an mp4 with a box
after `mdat`, like X's clips: the 58-byte tail, then back to the
start) and fails it the same way. Other answers say so too when asked
(`closeWhenAsked`, every server route). A swap that ends the relay
closes its viewers' answers plainly (`RelayEnded`, not logged as a
broken read). The source member going `gone`
does not end the relay: it lives through the source's grace
(`media.relay.sourceGone`, `ServerHub.refreshRelaySource`). The source
(`RelaySource`, kept by the media pipeline's `RelayFeeder` through the
@@ -1095,7 +1268,8 @@ all three link modes. Nothing is bundled or installed by hand.
format's `http_headers` plus its `cookies` as a Cookie header; the
requested subtitles but Bilibili's danmaku, an XML comment feed); and
what a relay reads (`pickTracks`), each format with its URL,
`http_headers` and `http_chunk_size`. For a relay only plain http(s)
`http_headers` plus its `cookies` as a Cookie header (what a player
sends) and `http_chunk_size`. For a relay only plain http(s)
formats without DRM count:
- the best single file (`pickSingleFile`: video and audio not `none`, a
video container, tallest up to 1080p, else the shortest above), unless
@@ -1130,10 +1304,21 @@ all three link modes. Nothing is bundled or installed by hand.
`swapLink` refuses it the same way for any other caller.
- **Everyone fetches it**: the swap sends the page URL. Every app
resolves it with its own yt-dlp (the media pipeline's resolve step,
`LinkResolver.probe`, waiting for the download on first use) and opens
what yt-dlp picked (`YtDlpPlay.mpvUrl`) with its headers as mpv's
`LinkResolver.probe`, waiting for the download on first use). A page
whose relay tracks are plain files (`SharedLinks.fits`) plays through
a reader of this app's own, as alone with no relay
(`SharedLinks.local`: a `SharedSource` without the upload, "Read once"
under "Relay"): its tracks with yt-dlp's headers and cookies, resumed
after a cut, backed off on 412/429/5xx, the page resolved again on
403/410/412, so a CDN cutting a long read (Bilibili: "Stream ends
prematurely", then a refused reconnect that ffmpeg treats as fatal)
never reaches the player. The reader is opened with its sizes (15 s);
one that cannot (a refusal, a pieces track, a live stream) leaves the
player on what yt-dlp picked (`YtDlpPlay.mpvUrl`) with its headers as mpv's
`http-header-fields` (media_kit's `Media.httpHeaders`), for every
track of the EDL: Bilibili's CDN answers 403 without the Referer. Its
track of the EDL: Bilibili's CDN answers 403 without the Referer. The
session player's and the preload's readers stay, any other closes
(`trimLocal`); Reload video makes a new one. Its
subtitles are added once the media is in (`sub-add <url> auto`). mpv's
own Lua yt-dlp hook is off (`ytdl=no`): one resolve path for every mode,
cookies and headers always the app's, and a libmpv without Lua plays
@@ -1145,9 +1330,14 @@ all three link modes. Nothing is bundled or installed by hand.
track's playlist must be readable, else the swap is refused ("Live
streams can't be passed on", DRM, or nothing a relay can read) with a
hint to use "Everyone fetches it". The swap names the tracks; its relay
source reads them with yt-dlp's headers (`PageRelayMedia`). On 403/410
(expired format URLs, 6 h on YouTube; signed segment URLs) from any
track it resolves the page again once for all tracks, finds the same
source reads them with yt-dlp's headers and cookies (`PageRelayMedia`),
and the putter's own player plays those same tracks through the one
reader ("Read once" under "Relay"), not yt-dlp's `requested_formats`:
the putter sees the relay's pick (HEVC first) like the viewers. On
403/410/412 (expired format URLs, 6 h on YouTube; signed segment URLs;
Bilibili's 412) from any track it resolves the page again once for all
tracks (not again within 60 s: a further refusal fails the read and is
backed off), finds the same
format ids with the same sizes and piece counts and roles (playlists
re-read and re-mapped by index; else "the video changed on the page")
and goes on from where it stopped. Format URLs are bound to the
@@ -1642,8 +1832,10 @@ background, default off). The coordinator gives the session's part
like, "starting now"); the app's notices, problems and your own show.
- "Starting now" pops up only while you sit out or the window is in the
background.
- Chat pop-ups follow the setting (it is in the chat either way); the
chat sound has its own switch.
- Chat pop-ups follow the setting (it is in the chat either way), and
none show while the chat panel is open in a focused window
(`Notifier.chatOpen`, set by the shell); the chat sound has its own
switch and still plays.
- The window in the background (unfocused, minimised, hidden): what
reaches a member who sits out (chat, nudges, "starting now", Up next,
joins and leaves when chosen) also goes out as a system notification
@@ -1669,8 +1861,10 @@ handed on from there (the coordinator gets its `Notifier`, `Connection` and
(`watchActivity`: focused, unfocused, minimised, hidden; window_manager's
focus, blur, minimise and restore, and the shell's own `hide`, wired to
`SessionCoordinator.windowActivity` and `Notifier.window` in `main`),
bringing the window up and focusing it (`focus`), dragging (`WindowDragArea`), close, the way out
(save, hide, quit), attention, the clipboard paste (files, else text)
bringing the window up and focusing it (`focus`, with another start's
activation token, see "Single instance" below), dragging (`WindowDragArea`), close, the way out
(save, hide, quit), attention, the clipboard paste (files, else text), the clipboard's
text alone (`readClipboardText`, for the join prompt)
and drop targets (fox_utils).
- `UrlOpener` (`DesktopUrls`): `xdg-open`, `open`, Windows' `url.dll`.
- `FolderAccess` (`DesktopFolders`): folder and file dialogs
@@ -1695,6 +1889,47 @@ handed on from there (the coordinator gets its `Notifier`, `Connection` and
(`AppDirs`, "Desktop UI"), and the copy of the data from before the
rename; resolved in `main` before the composition root exists, so not a
`Ports` field.
**Single instance** (`platform/single_instance.dart`, `InstanceLock`): one
app per profile folder (`<app support>`, or `profiles/<name>/` with
`STARSYNC_PROFILE`, so each profile is its own instance). `main` claims
it right after resolving the folders, before the log or the window: an
exclusive `RandomAccessFile.lock` on `instance.lock` (the OS drops it
when the process ends, a crash too) and a loopback listener whose port,
a random token and the pid go in `instance.json`. A start that finds the
lock held sends its arguments and activation token there (one JSON line
with the token) and ends at once through the runner (`exit` on the
`starsync/window` channel: `_exit` / `TerminateProcess`, no exit
handlers) before its window ever shows; the running app brings its
window up (`ShellPort.focus(activation:)`) and hands the arguments to
`JoinOffers`. Bringing a window up from another process needs the
desktop's permission: on Linux the second start's `XDG_ACTIVATION_TOKEN`
(Wayland) or `DESKTOP_STARTUP_ID` (X11), which the runner keeps before
GTK takes it (`launchToken`) and the running app presents its window
with (`present`: `gdk_wayland_display_set_startup_notification_id` /
`gtk_window_set_startup_id`, then `gtk_window_present`; the launcher
entry has `StartupNotify=true` so launchers hand one out); on Windows
the second start calls `AllowSetForegroundWindow` for the holder's pid
(`platform/desktop/windows_api.dart`). While the app closes it answers
"closing" and a new start waits for the lock (up to 20 s; 5 s without
any answer), then starts anyway; Update and restart gives the lock up
before starting the new binary. Invite links among the arguments are
not passed on to that restart (`launchArgs`).
**Link handler** (`platform/url_scheme.dart`): on Linux the launcher
entry ("App icon and launcher entry") lists
`MimeType=x-scheme-handler/starsync;` with `Exec=... %u`, and
`xdg-mime default dev.foxyz.starsync.desktop x-scheme-handler/starsync`
runs when `xdg-mime query default` names another (then
`update-desktop-database` on the applications folder). On Windows
(release builds) `HKCU\Software\Classes\starsync`: default value
`URL:Starsync invite`, `URL Protocol` empty, `DefaultIcon` and
`shell\open\command` = `"<install>\starsync.exe" "%1"` (`starsync.exe`
next to an old-named `watch_together.exe` copy when it is there),
written per user (no admin) through `RegSetKeyValueW` at start only when
the command differs (first start, a moved install, an update from an old
name).
Still direct: the updater's restart
(`Process.start`), the session's own file reads for what it plays or
relays (`QueueController`, thumbnails, cookies import), app-private
@@ -1757,8 +1992,9 @@ panel) and chat messages (Copy message, and a link in it).
`PresencePolicy`, so not away, gone or errored; "connecting…",
"reconnecting…"; hidden under 1100 px), chat with an unread badge, then
Invite on yours or Leave on a friend's (asks first, then you are home).
Invite (`inviteFriends`) shares your server if it is not yet and copies
the invite ("Invite copied · 192.168.1.20:8080"); a port it cannot open
Invite (`inviteFriends`, `shell/your_invite.dart`) shares your server if
it is not yet and copies the invite link ("Invite link copied"); a port
it cannot open
is a notice pointing at Settings. The stack
opens the members card (the server's version under its title: yours is
this app's, a joined one's from `/health.version`; the source
@@ -1768,9 +2004,16 @@ panel) and chat messages (Copy message, and a link in it).
"reconnecting… (2 min)" with how long; per member force ready, Nudge and Sit
out (anyone, the host too), your own row Sit out / Join back; at the
bottom, on yours: not shared, "Only you can reach it" and Invite;
shared, the invite's address, Copy invite, Stop sharing and a note
that friends outside the LAN need a forwarded port or a reachable
server; on a friend's, "Copy invite address" (the dialled address)).
shared (`YourInvite`), Copy invite link (filled; the address the app
picks, `InviteReach.best`, in its tooltip) and Stop sharing; one line
only when something needs the user ("Looking up your internet
address…"; the router's address: the port needs forwarding to this
computer; no internet address found: the link works on the home network
only); "Other addresses" (folded, only with more than one) lists each
address of `YourServer.reach` (Internet, Home network, VPN) to pick
what Copy copies; on a friend's, "Copy invite link" (the dialled address,
its name and the password you joined with: the link is the key there
too))).
Empty strip space is the window
drag area; the window buttons sit at its end.
- **Avatars** (`avatar.dart`): `StatusAvatar` draws a ring outside the
@@ -1792,8 +2035,8 @@ panel) and chat messages (Copy message, and a link in it).
owned by the shell so an attempt and its error outlive the card; the
version gate's refusal says who has to update and, when it is you,
offers "Check for the update". The card closes itself once in the
friend's server. It says that a friend gives the invite and the
password, that no account or Jellyfin is needed and that your own
friend's server. It says to paste a friend's invite link (it carries
the password) or their address and password, that no account or Jellyfin is needed and that your own
server stays as it is.
- **Profile** (`profile_card.dart`, `session/identity.dart`): a card
tinted with your colour: picture (hover "Change" opens `CropPicker`:
@@ -1909,7 +2152,8 @@ panel) and chat messages (Copy message, and a link in it).
Jellyfin & integrations (linked servers, AnimeFlow's `/me` via
`AnimeFlowClient.fromAccounts`, "Play the next episode automatically":
whether this app adds the continuation, see "What's next"), notifications & sounds
(what pops up, chat pop-ups, joins and leaves while sitting out or in
(what pops up, chat pop-ups, toasts or danmaku over the player with the
danmaku look, joins and leaves while sitting out or in
the background), updates, shortcuts, about.
- **Drop and paste** (`handoff.dart`, `fox_utils`): the shell is one
`DropTarget` (a "Drop to open" overlay while dragging) and a
@@ -1958,7 +2202,13 @@ panel) and chat messages (Copy message, and a link in it).
snaps to the nearest corner with the shared curve). A mode change fades
the layer out, moves it while invisible and fades it in (with the tabs
when the full player comes or goes), so the video is never mounted twice
nor remounted. A tab sends the full player to mini; × on the mini sits
nor remounted. The mini has no control skin: its `Video` gets an empty
controls builder (never null: media_kit keeps the last controls on
null, which left the full player's bars under the mini). Hovered it
shows the title with expand and ×, the status line, the full player's
play button (`controlIcon`/`togglePlay`: ready in a server) with the
time, and a 4 px bar (track, cached, played) that seeks; otherwise a
3 px played line along the bottom edge. A tab sends the full player to mini; × on the mini sits
out (the player unloads and shows the join-back card, `SitOutCard`, over
the still mounted `Video`); × on that card closes the player, the strip
brings it back, and joining back with it closed shows the mini. Fullscreen
@@ -2000,13 +2250,28 @@ panel) and chat messages (Copy message, and a link in it).
and the members card says "can't play this: ...". 34 px avatars, a main line
and a muted detail line. The pill glass, colour only for problems; it fades through
between kinds. The mini shows its short line in its hover bar. ⚙ →
Player settings (both skins) opens the player settings card (`controls/
player_settings.dart`, `showPopover`, on `AppGlass`): player design, progress line
when hidden, upscaling shader, hardware decoding (set live on mpv's
`hwdec`), subtitle and audio track. The rows (`playerSettingRows`) are
the same widgets Settings → Player shows; all apply live. Settings →
Player also has the controls' own choices (`controlsSettingRows`, see
"Player controls"). The progress line (`AppSettings.progressLine`) is
Player settings (both skins) toggles the player panel
(`controls/player_panel.dart`, `PlayerControlsModel.panelOpen`): a
312 px `AppGlass` column docked at the right of the full player,
between the top bar and the control bar (it never covers the bar),
sliding and fading in from the right; it stays open while the controls
hide and show, until its × or Esc (the player's Esc closes it first),
and leaving the full player closes it. The mouse on it holds the
controls up (`ControlsHoldRegion`). Its sections fold (their titles
toggle; folded ones are kept for the app's run, Notifications and
Controls start folded): tracks (subtitle, audio; remembered as from
the track menus), subtitles (size, mpv `sub-scale` from
`AppSettings.subScale`, set on every player; above the controls, see
"Subtitle lift"), picture (upscaling shader and quality, sharp video
and subtitles, see "Media", hardware decoding, set live on mpv's
`hwdec`), sound (volume), notifications (Toasts / Danmaku and, with
Danmaku, the look's knobs, see "Danmaku notifications") and controls
(design, progress line); a pointer to Settings → Player and Mark a
problem at its foot. Compact rows of its own over the same settings and
actions; Settings → Player shows the full rows (`playerSettingRows`:
design, progress line, shader, sharp video, hardware decoding, subtitle
size, subtitles above the controls) and the controls' own choices
(`controlsSettingRows`, see "Player controls"); all apply live. The progress line (`AppSettings.progressLine`) is
what the bottom controls leave while hidden: Standard a 2 px line,
Minimal its collapsed timeline. Fullscreen adds the
`WindowButtons` to the top bar while the controls are up (restore
@@ -2042,7 +2307,8 @@ panel) and chat messages (Copy message, and a link in it).
- **Chat** (`chat_panel.dart`): a 340 px panel sliding in at the right
(strip button or Tab): chat with avatars; the "Activity" chip (off by
default, `showActivity`, kept for the app's run only) adds the other
feed lines between them. Opening focuses the field; Tab or Esc in it
feed lines between them. Its field shares the quick chat box's draft
(see "Quick chat"). Opening focuses the field; Tab or Esc in it
close the panel and focus goes back where it was. Unread chat badges
the strip's and the player controls' chat buttons; in fullscreen with
the controls down and the video not playing a glass chip
@@ -2060,7 +2326,96 @@ panel) and chat messages (Copy message, and a link in it).
are up and come down to 16 px from the edge while they are hidden
(`AnimatedPositioned`, the shell's `ease`; the player reports its
controls through the shell's `controlsShown`); elsewhere 16 px from the
bottom left. What pops up at all is the Notifier's policy.
bottom left. What pops up at all is the Notifier's policy. Each
`ToastItem` carries its `NoticeCategory` and whether it is your own
(`own`); the shell leaves out those flying as danmaku and your own chat
coming back for a line the quick chat box already showed
(`PartyToasts.skip`).
- **Quick chat** (`shell/quick_chat.dart`): Enter in the full player
(not the mini: it is a corner thumbnail while you are elsewhere in the
app, where Enter belongs to that page; nor while sitting out) opens a
small box at the bottom centre, 16 px up, in the control bar's place,
without opening the chat panel. Enter is a `HardwareKeyboard` handler
of the player, so it works whatever plays and whatever button kept
focus; a `FocusManager` early handler swallows that Enter so the
focused button does not act on it too. `QuickChat` (the shell's, it
outlives the player's modes) holds the state: the draft is the chat
panel's `TextEditingController` (typing in either shows in both, a send
from either clears both), its `FocusNode` takes focus at once (keys
typed right after go to the box; `textInputFocused` keeps Space, Tab,
F and the arrows off the player meanwhile), and it closes by itself
after `idleClose` (8 s) without typing, Esc, or an empty Enter, keeping
the draft for the next Enter. Enter with text (an input method's
composing Enter excepted) sends it (`sendChat`) and closes. The box
(`QuickChatBox`) starts 300 px wide and widens with the text up to
640 px (or the room there is), then wraps up to four lines, growing
upward; your avatar leads it; it fades and slides in on the shell's
curve and stays mounted while closed (invisible, ignoring the pointer)
so focus is immediate. What you sent shows where the box was for
`echoTime` (2.6 s, `QuickChatEcho`, with an accent hairline), or, with
danmaku notifications, flies at once as your own line; your own chat
toast coming back from the server is not shown again
(`QuickChat.echoed`). While the box or its echo is up the controls are
held hidden (`ControlsFrame.suppress`: drawn hidden, `shown` false, the
cursor still follows the mouse) and the action area rides above the
box. Typing is not mouse activity: the frame wakes the controls only
on real pointer movement (an enter or hover at the last seen position,
as when the box comes or goes under a still pointer, does nothing), and
the field's I-beam stays hidden until the mouse moves over it, so
Enter, typing and sending never show the cursor or the controls.
- **Subtitle lift** (`video_view.dart`): bottom subtitles move above what
covers the bottom of the full player: the quick chat box or its echo
(always), and the control bar while it is up when
`AppSettings.liftSubtitles` is on (off by default; Settings → Player
and the player panel, "Subtitles above the controls"); 8 px above it.
`SubtitleLift` steps the covered height toward each new target in
`liftStepCount` (4) eased-out steps `liftStepGap` (42 ms) apart, the
first at once: about one step per frame of a 24 fps video, so each
lands on its own video frame (a `sub-pos` change per screen frame
landed unevenly on the video's frames and looked choppy).
`SubtitleLifter` turns each step into mpv `sub-pos` (`subtitleLiftPos`:
only as far as the letterbox under the picture leaves no room, at most
half the picture), back to 100 when nothing covers and for a new
player. What each mpv property does, on `tool/make_sub_test.sh`'s file
(mpv 0.41, libmpv as the app uses it): `sub-pos` moves bottom ASS
dialogue and SRT lines up and leaves `\an8` signs in place (with
`sub-ass-override` at its default `scale` or `yes`; `no` ignores it
for ASS); `sub-margin-y` moves SRT only (ASS only with
`sub-ass-override=force`, which restyles every line, signs too);
`sub-ass-style-overrides=MarginV=...` moves top signs down as well;
`video-margin-ratio-bottom` shrinks and moves the picture itself (with
`sub-ass-force-margins` the dialogue goes into the new black band).
- **Danmaku notifications** (`danmaku/`): Settings → Notifications &
sounds → Over the player → Notifications: Toasts (default) or Danmaku
(`AppSettings.notifyStyle`, live; also in the player panel). With
Danmaku and the full player up (not sitting out), what
`fliesAsDanmaku` picks (chat, joins, leaves, lost connection and back,
"starting now", Up next news someone caused) flies right to left
across the picture (`DanmakuNotices` under the controls feeds new
toasts to a `DanmakuLayer` through `danmakuForToast`); nudges,
problems, your own state and the app's notices stay toasts, and so does
everything with the player small. The player sets the shell's
`danmakuActive` so the toasts leave the flying ones out. The layer:
lanes in a band of the height (`DanmakuStyle.bandTop` / `bandBottom`,
default 10 to 45 %, under top signs, never down into the dialogue);
each line takes the topmost free lane (`DanmakuLanes`, pure: the last
line in a lane must have fully entered with a gap, and the new one, at
its own speed, cannot catch it before it leaves); at most
`maxOnScreen` fly, the rest wait in order (dropped after 15 s, 60 at
most); every line crosses in `crossSecs`, so long ones move faster;
white text at the chosen weight (`weight`, 400 to 900, the name a step
bolder) with a dark outline and no box, the sender's avatar and
coloured name first, the app's own notices without one, own lines in
an accent frame; the layer's own clock (lines fly on while paused),
ticking only while lines fly or wait. Each line is a widget moved by a
`Flow`: per frame only paint runs. The look (`AppSettings.danmakuStyle`,
one map, `DanmakuStyle.toJson` / `fromJson`, which clamps every value
to `DanmakuStyle.limits`) has every knob in the settings card
(`danmaku_settings.dart`: crossing time, at most on screen, lane band,
opacity, size, boldness, outline, avatars, frame my own lines, reset)
over a preview strip (a moving stand-in picture, a top sign, bottom
dialogue and sample lines in the current look; it plays for 16 s after
it opens or a knob moves, then rests until clicked).
Settings persist in two Hive boxes: `connect_form` (last form, recent
servers and links, the profile picture and colour; saved even when a join
@@ -2180,7 +2535,8 @@ Player controls (`lib/src/controls/`): one controls model, two skins.
mark a problem, player settings, leave. Icons, tooltips (with keys) and
the clock text (`clockText`) are defined there too, once.
- **Shared pieces**: `ControlsFrame` (`frame.dart`): when the controls
are up (mouse movement shows them, `controlsHideSecs` without any or the
are up (real mouse movement shows them, see "Quick chat"; `suppress`
holds them hidden; `controlsHideSecs` without movement or the
mouse leaving hides them and the cursor), holds (`hold` during a seek
drag, `holdDuring` / `holdWhile` around menus and popovers, `holdBy`
the session's `ControlsHold`), and the double-click on the picture
@@ -2204,7 +2560,7 @@ Player controls (`lib/src/controls/`): one controls model, two skins.
glass capsules (`MinimalCapsule`, the pills' tint and hairline;
`MinimalButton` rounded squares, the play button a filled accent
circle) above `MinimalTimeline`, a 36 px band across the bottom: the
played part tinted in the theme's primary, the position a white line,
played part tinted in the theme's primary (40 %), the position a white line,
the part not downloaded yet (from `buffered`, or the position when
that is behind, to the end) in thin stripes, chapter starts as dots on
the top edge with a dark cut through the band (a press within reach of
@@ -2214,7 +2570,11 @@ Player controls (`lib/src/controls/`): one controls model, two skins.
not reach the end). Hover: a faint line and a glass tip with the time
(the chapter title above it). A press seeks, every drag move seeks,
after release the line stays at the target until the position catches
up (at most 2 s). Hidden, it shrinks (120 ms) to a 3 px progress line.
up (at most 2 s). Hidden, it shrinks (120 ms) to a 3 px progress line
that is the band itself, smaller: the same dark band, the same played
tint (40 % of the accent), so growing or shrinking never changes colour;
the stripes are stronger on the line (a few pixels of each show) so the
part not downloaded still reads; chapter cuts are a light dark line.
We know only the end of mpv's cache, so a dropped back buffer is not
shown.
- **Settings** (`AppSettings`, `model/controls_prefs.dart`, all live):
@@ -2222,7 +2582,7 @@ Player controls (`lib/src/controls/`): one controls model, two skins.
`mpvControls: true` from before becomes minimal), `timeDisplay`,
`controlsSize` (normal / large: the bottom bar ×1.25),
`controlsHideSecs` (2 / 3 / 5), `hiddenControls`, `progressLine`.
The player settings card has the skin and the progress line; Settings →
The player panel has the skin and the progress line; Settings →
Player → Controls has the rest.
- Pills (Skip OP / ED / 1:30, Next episode) and cards live outside the
controls, in the action area ("Player overlay"). The cards stay up; a
@@ -2283,7 +2643,9 @@ app kills itself (`Process.killPid`, no exit handlers; it only gets there
if the close did not go through); the runners end the process 15 s after
the first close request (Linux: a `delete-event` handler connected before
window_manager's; Windows: the first `WM_CLOSE`), and Windows also 3 s
after the window is destroyed, while the engine and plugins go.
after the window is destroyed, while the engine and plugins go (only
with an engine: `Win32Window::Create` runs `OnDestroy` before the window
exists, and a timer armed there ended every start at 3 s).
## Logging
@@ -2314,7 +2676,8 @@ dedicated server (`package:logging` loggers named by area: `relay`,
once Flutter is up) are kept and written first.
- **Redaction** (`redactLog`): every line, in one place
(`formatLogLine`), before console or file: URL query strings cut to
their parameter names (signed CDN links, Jellyfin `api_key`),
their parameter names (signed CDN links, Jellyfin `api_key`, an invite
link's `pw`: `starsync://` counts as a URL),
`user:pass@` in URLs, member tokens in `/r /m /j /a /i /l` paths, and the
values of passwords, secrets, tokens, API keys, cookies,
`Authorization` / `X-Emby-*` headers (`name=value`, `"name":"value"`,
@@ -2371,7 +2734,7 @@ dedicated server (`package:logging` loggers named by area: `relay`,
- **Opting out**: Settings → About → "Share diagnostics with the server"
(default on). Off: no lines are kept or sent; one batch per connection
says so (`share:false`). The local log is unchanged.
- **Mark a problem**: Ctrl+Shift+B (`AppKeys`), the player's ⚙ card and
- **Mark a problem**: Ctrl+Shift+B (`AppKeys`), the player panel and
Settings → About open a small card with an optional note (200
characters, one line); the mark goes at once with the lines waiting,
opted out or not, and the server writes `[who] === PROBLEM marked by
@@ -2411,7 +2774,9 @@ take the icon from the `.desktop` file of that id. A tarball install has
none, so on start a Linux release build (`platform/desktop_entry.dart`,
skipped in debug) writes
`~/.local/share/applications/dev.foxyz.starsync.desktop` (Exec = the
running binary, `StartupWMClass` and `Icon` = the id) and the icons into
running binary with `%u`, `StartupWMClass` and `Icon` = the id,
`StartupNotify`, the `starsync://` link type: "Link handler" under "App
events and platform ports") and the icons into
the hicolor theme, only when they changed, and deletes the entry and
icons 1.2.x wrote under its id `moe.senkochan.watch_together` (those exact
files only). The runner also sets the window icon from the bundle (X11).
@@ -2424,8 +2789,8 @@ company `foxyz`, product `Starsync`; the window class is
One implementation, `packages/core/lib/src/updater.dart` (pure Dart, tested
in `packages/core/test/updater_test.dart`), with two front ends: the app and
the dedicated server. Both binaries ship in the same archive, so either one
updates the whole install folder. Reads a manifest (`version`, `protocol`, `url`, optional `sha256`, `notes`)
from `updateManifestUrl`, compares versions, downloads the tarball, checks
updates the whole install folder. Reads a manifest (`version`, `protocol`, `url`, optional `sha256`, `notes`,
optional `changes`) from `updateManifestUrl`, compares versions, downloads the tarball, checks
sha256 when present, then extracts into a staging dir inside the install
folder (same filesystem, so moves are renames; the folder must be
user-writable). Each top-level entry is moved aside and the new one moved in,
@@ -2466,9 +2831,11 @@ does the same from the app binary. In the app, `UpdateController`
(`apps/desktop/lib/src/update/`) runs the same code for the update bar
(`UpdateBar`, `screens/home/update_indicator.dart`): a band across the
window under the strip while an update is available: "Starsync X.Y.Z is
ready", the notes, Later (hidden until the next check or start) and
"Update and restart" (download, apply, then the app close above with Hive
closed and the new binary started), its progress in the band. A release
ready", the first three changes (each with its kind's icon; the notes
when the manifest has no `changes`), What's new, Later (hidden until the
next check or start) and "Update and restart" (download, apply, then the
app close above with Hive closed and the new binary started), its
progress in the band. A release
that is behind also says which version it runs; a copy that is not a
packaged install says to replace the folder by hand. The shell keeps the
band off the full player while a video is on (not finished); it shows on
@@ -2477,11 +2844,49 @@ update has no Later. Nothing downloads before the click. The app checks at
start and every 4 hours while running (`updateCheckEvery`), both with the
auto-check setting on; a re-check never touches an install under way.
Settings has Check now and the auto-check toggle. The manifest is `updates/manifest.json` in
this repo, written by `tool/release.sh`. Test builds (`X.Y.Z-dev.N`,
`isDevBuild`) also read `devManifestUrl`, the `manifest.json` of the `dev`
prerelease (`tool/dev_release.sh`), and take the newer of the two
(`fetchLatestManifest`); the release X.Y.Z outranks its test builds, so
testers land back on stable. `0.0.0-dev` (a dev run) checks nothing.
this repo, written by `tool/release.sh`.
**Update channel.** `UpdateChannel` stable or dev, the setting
`updateChannel` (Settings → Updates, "Update channel", which also says
the channel and version running). Dev also reads `devManifestUrl`, the
`manifest.json` of the `dev` prerelease (`tool/dev_release.sh`), and takes
the newer of the two by semver (`pickManifest`, `fetchLatestManifest`): a
release 1.4.0 is offered over 1.4.0-dev.3, and after it 1.4.1-dev.1 is,
the install staying on Dev. Stable reads only the stable manifest. Never
chosen, the channel is `defaultUpdateChannel`: dev for a test build
(`X.Y.Z-dev.N`, `isDevBuild`), else stable; a test build stores dev on its
first start, so the release that later updates it stays on Dev (stable by
default is not stored). A file `update-channel` next to the binary (`dev`
/ `stable`, written by the one-time command `tool/dev_release.sh` prints)
is taken as the channel at the next start and deleted. Switching the
setting re-checks at once. On stable in a test build, a release older than
the build (`isStableBehind`) is never offered by itself
(`UpdateController.stableBehind`, "Stable is at X, older than this test
build"); switching to Stable there asks "Go back to stable X?": going
back installs it (`goBackTo`), staying keeps the build until a newer
release. `--upgrade` uses the default channel of the binary's version.
`0.0.0-dev` (a dev run) checks nothing.
**What's new.** The manifest's `notes` is one line (all 1.2.x and 1.3.x
read; they ignore other keys) and `changes` (`{"added": [...], "changed":
[...], "fixed": [...]}`, `ReleaseChanges`, read leniently: anything
malformed is no `changes`) the version's lists; both come from
`CHANGELOG.md` ([Changelog](DEVELOPMENT.md#changelog)). The card
(`WhatsNewCard`, `widgets/whats_new_card.dart`) shows them as Added /
Changed / Fixed sections, each with its icon and colour, on `AppGlass` in
a popover, the rows easing in one after another, the list scrolling in a
short window; notes alone show as one paragraph. Three ways open it: the
band's What's new (the offered version, with Update and restart), the
first start of a new version, and Settings → About ("What's new in X.Y.Z",
once this version's notes are known). The first start (`WhatsNew`,
`update/whats_new.dart`): before restarting, the update keeps the new
version's notes in settings (`UpdateController.beforeRestart`); an update
made otherwise (1.3.x's updater, `--upgrade`, by hand) gets them from the
first check whose manifest is this very version (`UpdateController.latest`).
The card shows once per version (`whatsNewSeen` in settings), never on a
fresh install (the settings box was empty), and not over the full player
while a video is on (it waits until the video ends or the player
leaves full).
## Decisions
+106 -26
View File
@@ -61,8 +61,9 @@ the timings of each media change (`SwitchTrace`).
Two instances on one machine: `STARSYNC_PROFILE=<name>` (letters, digits,
`-`, `_`; at most 32) gives an instance its own Hive boxes (settings,
profile, connect form, window bounds, folders, Jellyfin sign-ins, the
client id: two instances without it are one install, and the second takes
the first's member over) and logs under
client id: without it a second start is the same install, so it hands
its arguments to the running app and ends; each profile is its own
instance) and logs under
`<app support>/profiles/<name>/`, and ` [<name>]` in the window title;
yt-dlp, shaders and caches stay shared (`AppDirs`, ARCHITECTURE "Desktop
UI"). Without it nothing changes. Run one plain instance and one with
@@ -75,6 +76,33 @@ Harnesses in `tool/` (own `pubspec.yaml`, `dart run tool/<name>.dart`):
`range_logger` (serve a file and log every Range request), `shaper`
(bandwidth cap, latency, `--kill-after` for outage tests), `fake_viewer`
and `e2e_check` (simulated viewers against a live server).
`tool/make_sub_test.sh [out.mkv] [seconds]` (ffmpeg) writes a 720p test
video for subtitle placement and sharpness (default
`~/Videos/starsync-sub-test.mkv`): an .ass track with a top sign
(`\an8`) and bottom dialogue (English and Japanese), and an SRT track.
## Dev tools
Test builds and runs from source (a version with `-dev`) and debug runs
(`devTools`, `screens/home/ui.dart`) get Settings → About → Developer;
releases never show it:
- **Danmaku preview** (`danmaku/danmaku_preview.dart`): the danmaku layer
of "Notifications: Danmaku" (ARCHITECTURE "Danmaku notifications") to
play with, starting from your own look; its knobs change only the
preview. The button opens it over the full player when one is up
(under the controls; the panel top right holds them up), else on a
page with a moving stand-in picture and a mock control bar of the skin
in use. The panel (`DanmakuPanel`) fires samples (chat, own, join,
leave, Up next, starting, a burst), a random stream (Poisson arrivals
at a rate, short to long lines, CJK and emoji; in a session half the
senders are the party's real members), this session's own chat and
notice toasts (mapped as the real feature maps them), every
`DanmakuStyle` knob, and mock subtitles (a top sign and bottom
dialogue). The mock dialogue moves with the subtitle lift's steps as
the real subtitles do: over the player when the quick chat box opens,
and on the stand-in page, which has its own quick chat box (Enter;
what you send flies as your own line). Tests: `danmaku_test`.
## Tests
@@ -94,7 +122,10 @@ Three suites, run from each package's folder:
- `cd packages/server && dart test` (well under a minute): live servers
on loopback: auth and version refusal, identity, takeover and grace
(`presence_test` on a fake clock), media and relay byte serving with
source drops, Jellyfin relay against a fake, queue (and the shared
source drops, the read-once source (`shared_source_test`: its loopback
range answers, a cut and resume with backoff, seeks of either reader,
the two kept within one window, its memory bound), Jellyfin relay
against a fake, queue (and the shared
countdown on a fake clock), session log,
isolate `serve`, sharing (`share_test`: a listener attached and detached
under the same members and Up next, the host key standing in for the
@@ -103,10 +134,17 @@ Three suites, run from each package's folder:
controls, settings, schedule), pure app logic (`StateDiff`, the library's L2 matching against a
fake AniList (`show_matcher_test`), `media.ref` producers,
shader list, sound engine with a fake backend, invite addresses, the
version gate, the media plan's table with the Jellyfin relay rule and
`starsync://` links, the internet address lookup against fake
services, the join prompt and one app per profile
(`invite_links_test`), the version gate, the media plan's table with the Jellyfin relay rule and
the "Waiting for" status (`media_plan_test`), watch tracking and the
device history (`watch_tracker_test`), what continues a file of a
local show (`queue_logic_test`)), `singletons_test` (the `.instance` count, see
local show (`queue_logic_test`), the video view's output size and
subtitle lift steps and the dev-tools gate (`video_view_test`), danmaku
lanes, style, the toasts that fly and an idle layer
(`danmaku_test`), the quick chat box over the player (`quick_chat_test`),
the player panel and the danmaku settings card (`player_settings_test`)),
`singletons_test` (the `.instance` count, see
ARCHITECTURE "Composition root"; lower its baseline when a use goes) and the real-mpv `*_e2e`
tests plus `coord_presence`. The whole suite takes a couple of minutes;
run it only before a restructure step lands, otherwise only the files
@@ -114,7 +152,10 @@ Three suites, run from each package's folder:
Opt-in, skipped unless set: `JELLYFIN_TEST_*` (core `jellyfin_live` and
server `jellyfin_relay_live` against a real Jellyfin; read their headers),
`YTDLP_LIVE=1` (downloads yt-dlp and resolves a real tweet). Everything
`YTDLP_LIVE=1` (downloads yt-dlp and resolves a real tweet),
`WT_SINGLE_FETCH=1` (server `single_fetch_e2e`: a link passed on read
once against twice, two real mpv against a fake CDN that cuts and locks
out; minutes; `WT_SF_SCENARIOS`, `WT_SF_PLAY` narrow it). Everything
else is offline: Jellyfin and link hosts are local fakes.
Real-mpv tests: `sync_playback_e2e`, `coord_presence` (drops through a
@@ -129,7 +170,9 @@ stand-in yt-dlp and a Referer-checking CDN: the EDL, the headers and
cookies, subtitles without danmaku, a 403, the D11 error),
`media_switch_e2e` (commands during a switch wait and apply once, no mpv
errors), `relay_e2e` (a dedicated server with no file of its
own: a guest's file, links passed on or fetched by the server, a late
own: a guest's file, links passed on (read once: the source's player on
its loopback reader, nothing from mpv at the origin) or fetched by the
server, a late
source, a source that drops and resumes; a seek past what a slow source
has sent, timed; a source that drops while a viewer seeks past
the buffer and another joins: no seek loop, "Waiting for", all resume
@@ -210,10 +253,12 @@ updater tests, the release build and the close smoke run on that build
`STARSYNC_SMOKE=<clip>` (a 5 s clip from ffmpeg, else a WAV the script
writes) under its own profile (`STARSYNC_PROFILE=smoke`), opens the clip
in a player with a video output through the media pipeline
(`platform/smoke.dart`), plays a second and closes the normal way; the
steps go to `STARSYNC_SMOKE_OUT` (`start`, `opened <ms> render=<mode>`,
`closing`, or `skip` / `fail` with why). It fails when the app does not
exit within 45 s or leaves a `starsync` process, skips with a warning
(`platform/smoke.dart`), plays it, stays up 10 s from its start and
closes the normal way; the steps go to `STARSYNC_SMOKE_OUT` (`start`,
`opened <ms> render=<mode>`, `closing`, `quitting` just before the
window closes, or `skip` / `fail` with why). It fails when the app ends
by itself before `closing` or never reaches `quitting`, does not exit
within 45 s or leaves a `starsync` process, skips with a warning
when the runner cannot bring the app or its video output up, and prints
the `smoke:`, `close:` and `mpv: render` lines of the app log.
`tool/smoke_linux.sh` is the same on Linux (needs a display; after
@@ -225,11 +270,12 @@ Traps:
so shell tests run the real player layout without it; otherwise test pure
logic (`StateDiff`, coordinator without widgets).
- A `SyncServer` started inside a test: relic reports an error writing a
response (a relay response cut short when its relay is dropped at a
swap or stop) to the zone the server started in, which fails the
test; outside tests it is only logged. Start it in `runZonedGuarded`
and fail on anything else (`queue_switch_e2e`, `relay_e2e`): a viewer's
read is still in flight at the `stop` in tearDown.
response (one cut short at `stop`: `/m`, `/j`; the relay's byte
answers are written on the raw connection and end on their own) to
the zone the server started in, which fails the test; outside tests it
is only logged. Start it in `runZonedGuarded` and fail on anything
else (`queue_switch_e2e`, `relay_e2e`): a viewer's read can still be
in flight at the `stop` in tearDown.
- The Link page probes a filled-in link (network, yt-dlp), which never
answers on the widget-test clock: pass `AppShell(linkProbe:)` /
`LibraryTab(probe:)` a fake, or `pumpAndSettle` never returns.
@@ -243,17 +289,22 @@ Traps:
User pushes tags; CI does the rest.
1. `tool/release.sh [patch|minor|major] [--dry-run]`: derives the next
version from the latest tag, bumps the three pubspecs in lockstep, writes
`updates/manifest.json` (per-platform `assets` URLs are predictable; `url`
stays the Linux tarball for builds <= 0.2.1), commits `Release vX.Y.Z`,
makes an annotated tag.
version from the latest tag, refuses when the [changelog](#changelog)'s
Unreleased has no items (warns when it has no summary) and prints it,
renames Unreleased to `X.Y.Z` under a fresh empty Unreleased, bumps the
three pubspecs in lockstep, writes `updates/manifest.json` (per-platform
`assets` URLs are predictable; `url` stays the Linux tarball for builds
<= 0.2.1; `notes` is the section's summary, `changes` its lists), commits
`Release vX.Y.Z` (with `CHANGELOG.md`), makes an annotated tag.
`--dry-run` stops after printing.
2. Review, then `git push --follow-tags`.
3. `.gitea/workflows/release.yml` runs one job per platform: Linux builds
with `tool/package_linux.sh`, Windows (runner `yuuka-windows`, host mode,
PowerShell only; `tool/setup_flutter_windows.ps1` also adds Visual
Studio's C++ ATL component once, which the notifications plugin needs) with `tool/package_windows.ps1`. Each then runs
`dart tool/release_publish.dart vX.Y.Z <archive>`, which creates the
release if the other job hasn't yet and uploads (or replaces) its asset.
release if the other job hasn't yet (its text: the version's changelog
section) and uploads (or replaces) its asset.
By hand: package, then the same `dart tool/release_publish.dart` line.
Test builds (Linux, no tag, no CI): `tool/dev_release.sh [--minor|--major]
@@ -263,17 +314,38 @@ repository read/write) builds the working tree here as `<next>-dev.N` (N
one past the published one; `<next>` the pubspec's next patch, or next
minor / major when asked, and once such a line is published later runs
follow it until a release moves the pubspec) and replaces the assets of the
`dev` prerelease with the tarball and its `manifest.json`. Dev builds
update from it ([Updater](ARCHITECTURE.md#updater)). A stable install
moves onto it once with the command the script prints (run with the app
open: it finds the install from the running process and copies the new
files over it).
`dev` prerelease with the tarball and its `manifest.json` (`changes`:
the changelog's Unreleased, which also becomes the prerelease's text). Dev builds
update from it on the Dev update channel, which a test build starts on
and keeps after a release ([Updater](ARCHITECTURE.md#updater)). Any
install switches channel in Settings → Updates; the command the script
prints is a shortcut that moves a stable install onto the test build at
once (run with the app open: it finds the install from the running
process, copies the new files over it and writes the `update-channel`
file that sets Dev).
Tokens come from the environment (`RELEASE_TOKEN`, or Actions-provided
`GITHUB_TOKEN`/`GITEA_TOKEN`). The manifest has no `sha256` by default; the
updater skips the check when absent. Workflows live in `.gitea/workflows`
(the forge reads `.gitea` before `.github`).
### Changelog
`CHANGELOG.md` is what people read: the update band's first items, the
What's new card ([Updater](ARCHITECTURE.md#updater)) and the release page.
A user-facing change adds a line to its `## Unreleased` section in the same
commit, under `### Added`, `### Changed` or `### Fixed` (no other
headings), as a `- ` item in plain words for the people watching, not
commit-speak ("Chat pop-ups stay away while the chat panel is open", not
"Notifier: suppress toasts when panel open"). Internal changes (tests,
refactors, docs, CI) get no line. The paragraph under the version heading
is an optional one-line summary: it becomes the manifest's `notes`, the
only thing 1.2.x/1.3.x apps show (without it, the first items); refresh it
before cutting. Versions carry no date (the tag and release page have it).
`tool/changelog.dart` reads and cuts the file (`cut X.Y.Z`, `json
<version|unreleased>`, `markdown <version|unreleased>`); its tests are
`tool/test/changelog_test.dart` (`cd tool && dart test`).
## Debugging your server
The app's own server runs on its own isolate (`ServerIsolate`,
@@ -302,6 +374,14 @@ in the same file; a dedicated server prints its folder at start
(`--log-dir`). Users send theirs with Settings → About → "Copy
diagnostics".
A Windows friend without a terminal habit: `tool/windows_report.cmd`
(one file: a cmd header that runs the PowerShell below it). Dropped into
the Starsync folder and double-clicked, it collects the app's logs,
Windows' crash events (1000/1001/1026), that install's file list (and
any other found on Desktop, Downloads, Documents, AppData, Program
Files) and a 20 s test start with its exit code, zips them to the
Desktop and copies the zip (Ctrl+V into Discord).
Start with the session log when there is one: `session-YYYY-MM-DD.log`
in the server's folder (the hosting app's logs folder, or the dedicated
server's `--log-dir`), every member's lines and the server's merged in
+4 -1
View File
@@ -45,7 +45,10 @@ A five-minute tour. Details: [ARCHITECTURE.md](ARCHITECTURE.md), wire format:
not in a friend's, so watching alone works like a session (progress,
next episode). Invite opens it to friends on your network (a second
listener on the same server: nothing restarts, the video and Up next
carry on) and copies an invite; Stop sharing closes it again.
carry on) and copies an invite link (`starsync://`, with your internet
address when the app finds it and the password when one is set); Stop
sharing closes it again. A friend clicks the link, or just copies it,
and their app asks to join.
- **Dedicated server**: `starsync-server`, a separate program in the
release tarball, for an always-on box. See the README.
+31 -2
View File
@@ -543,8 +543,12 @@ anything else means one file.
one-file URL of a media with `tracks`) or piece (or `p/` of a byte
track), 410 unknown session, 503 + `retry-after` the source never
connected or the server could not read the link (60 s), or a piece did
not arrive within 60 s. A byte request whose bytes do not arrive within
60 s ends early. Bytes count toward `bytesServed`.
not arrive within 60 s. A byte request's headers come with its first
bytes, or after 4 s without them (under mpv's 5 s timeout); its body
then waits for missing bytes, up to 60 s for each, else the connection
is reset. Every byte answer says `connection: close` and closes after
its body; a swap that ends the relay closes it early without a reset.
Bytes count toward `bytesServed`.
- `GET /r/<sid>/<token>/source` (WebSocket): the source's relay channel,
dialled by the source (works behind NAT); `?q=<queueId>` for the warm
relay of that item (or the media's relay started from it). Only that
@@ -609,6 +613,31 @@ media URL unchanged, its ready flag and presence kept; after a
non-resumed `welcome` (grace over, server restarted) the client sends
its intent again (`ready`, `presence`).
## Invite link
Not a WebSocket message: what one app hands another (clipboard, chat,
a clicked link), so every version reads the same format.
```
starsync://203.0.113.7:8080/?name=Bob%27s+server&pw=hunter2
starsync://192.168.1.20:8080
starsync://[fe80::1]:8080
```
- Scheme `starsync`, the server's host (IPv6 in brackets) and port; no
port means 8080. The path is `/` or empty.
- `name` (optional): the server's name for the prompt before its
`/health` answers (at most 64 characters are shown, control characters
dropped).
- `pw` (optional): the server password, always there when the server has
one: the link is the key, a friend just clicks it. Logs never show it (`redactLog` cuts a `starsync://` query to its
names).
- Unknown query keys are ignored; anything else (no host, a port outside
1–65535) is no link.
- Invite copies the bare link, nothing around it. An app also takes a
1.3.x plain invite ("Join me on Starsync: 192.168.1.20:8080 …"), and a
1.3.x app reads the `host:port` out of a link.
## Queue (proto 12)
Up next: what plays after the current media, held by the server for
+54 -13
View File
@@ -52,6 +52,20 @@ Problems are numbered; delete a line when its fix ships.
the `close:` log lines.
- P33 No volume readout on wheel: likely fixed by the controls rework
(1.2.1-dev.1; a test covers both skins); confirm in real use.
- P35 System notifications in a burst hit KDE Plasma's limit
(`ExcessNotificationGeneration`, 3 refused within 3 s after a swap while
the window was in the background): throttle or merge them app side.
- P36 After a reconnect the app re-dials the warm relay of an Up next
item that was already played ("relay channel refused: not the source of
Up next item q8"): harmless, the feeder should end with its item.
- P37 A relay viewer whose read waits on bytes the source does not send
for longer than ffmpeg's reconnect budget (4 tries per read, about
40 s with the 4 s header rule) gets an I/O error that mpv takes as the
end of the file. Measured with mpv 0.41 / ffmpeg 9.0.2 and the source
away about a minute across a seek: it played on; one read stalled past
the budget ends the file. Options: a longer `reconnect_delay_max` for
relay URLs only, or the end-of-file path reopening a relay viewer
that stopped short while the server plays.
**Restructure** (in order; reviewed against the code). Truth lives in
three places: the server (members, presence, gate, transport, queue incl.
@@ -59,9 +73,8 @@ the automatic continuation and countdown, relay state), the device
(client id, library and folders, match corrections, watch history,
settings, notification prefs, what this app is sourcing) and the
integrations (Jellyfin played/resume, AniList list).
7. Release 1.3.0: publish the manifest (protocol 16 makes it a required
update for 1.2.x) and update the VPS dedicated server in the same
window; check a 1.2.x app gets a clear "update the app" message.
Released as 1.3.0. Left: check that a 1.2.x app gets a clear "update
the app" message and updates (Windows: the first real use, backlog 5).
Composition root (in place, ARCHITECTURE "Composition root"): the
remaining `.instance` singletons move into `AppGraph` as the steps touch
them; `singletons_test` holds the count (today: AppSettings 49,
@@ -99,8 +112,11 @@ ever happen mid-session (D8 says never during playback).
Left of step 3 (your server): a human check of Invite (Linux and, at
1.3.0, Windows' firewall prompt, now on Invite rather than at start;
that the invite skips the proxy's 198.18.x here) and of the update band
from 1.2.1-dev.3 in real use. No public address lookup or UPnP: the
members card only says friends elsewhere need the port forwarded. Your
from 1.2.1-dev.3 in real use. No UPnP: the members card says when
friends elsewhere need the port forwarded. Human checks of the
`starsync://` links: clicked in a browser and in Discord on KDE Wayland,
X11 and Windows (the window coming to the front, the registry key after
first start and after an update from a `watch_together.exe` copy). Your
server's `/health` name is the profile name at start (a rename shows
after a restart). Joining a friend while others are on
your server leaves them there without you, unwarned; a join refused
@@ -157,6 +173,17 @@ seek or a new link; the start of a freshly put-up link was never
measured on its own. Levers if it still shows: start the window from
the last rate, prefetch from the source as soon as a link goes up,
a smaller gate buffer when only the relay is behind.
Left of the read-once source (ARCHITECTURE "Relay", "Read once"): a
human check against real Bilibili and YouTube (the 412 storm gone, the
putter's picture the relay's format, a Windows putter's loopback), and
the source's first frame and seeks over a 12 Mbit/s uplink
(`tool/shaper`): a putter whose upload lags now holds its own player
about 64 MiB past it. Not built: pieces tracks (HLS/DASH, X's) are
still fetched twice (the putter's player from the site, the relay's
pieces on their own; the same idea needs a `PieceBuffer` and a loopback
playlist); Jellyfin items a member passes on are still streamed twice
when the relay is needed. The e2e's media (26 MiB) fits the putter's
buffer whole, so the 64 + 16 MiB bound is only unit-tested.
Left of step 4 (library): a human check of the Files library on the
real folder (`/mnt/torrents/Torrents/Binux/`: Bleach over three folders
with 66–107 missing, SAO S3 Alicization with OP/ED, Re.Zero S04E17, the
@@ -246,6 +273,10 @@ core and app. Still open on the wire, frozen at the 1.3.0 release:
3. **AniList account**: sign in (OAuth via a localhost callback), "My list"
filter on the Schedule from the user's AniList lists. This is the
Schedule's personal tracking; AnimeFlow has no per-user follow.
**AniList browser** (wanted next once 1.3.0 holds up): view shows and
browse older ones (search, seasons, details), and request one from
there on AnimeFlow; later on Sonarr too, which is what a public
release of the app would use.
4. **Ops**: Check shaders on Windows; run the relay e2e "the source drops
uncleanly and comes back" a few times under the full suite (its
@@ -289,15 +320,25 @@ core and app. Still open on the wire, frozen at the 1.3.0 release:
shows none without an extension), so close-to-tray needs a fallback
to quitting where no tray exists.
8. **Jellyfin** (epic, below).
8. **Hosting without a VPS** (friends host from their own connection,
on their own bandwidth; a tunnel through the VPS is out: everyone's
traffic would land on it). In order: IPv6 (try a public IPv6 address
first, no NAT); UPnP / NAT-PMP port opening later; hole punching
(the VPS only introduces the two sides) only as a research spike: it
needs UDP (WebRTC or QUIC) under the sync connection, a local HTTP
stand-in so mpv can read relayed video, and still fails behind CGNAT
and symmetric NAT. Also: an invite with a generated access code
instead of the password (`starsync://host:port/<code>`, a "New link"
button revokes old links; the server accepts code or password).
9. **Smaller items**
- Video output robustness: if media_kit's hardware path regresses to
software (see ARCHITECTURE, Media: render mode), vendor a
media_kit_video fix that builds the EGL display from GDK's native
display. The video freeze after GPU idle is not explained by this;
next suspect is media_kit's context switching on the raster thread
with NVIDIA.
9. **Jellyfin** (epic, below).
10. **Smaller items**
- The video freeze after GPU idle: next suspect is media_kit's context
switching on the raster thread with NVIDIA.
- Timelines: draw mpv's real cached ranges (`demuxer-cache-state`
`seekable-ranges`, read through media_kit's `observeProperty`), not
only the end of the cache ahead, like uosc.
- Link conveniences: a link swap in session updates the remembered media;
show the open link with an open-in-browser button.
- Time UI: remaining time, scrubber preview.
+12
View File
@@ -31,6 +31,18 @@ abstract class RelayConstants {
/// A viewer request waits this long for its bytes (source away) before
/// the server gives up on it.
static const int relayWaitSecs = 60;
/// Failed pulls in a row back off: 250 ms doubling up to this, back to
/// none once bytes come again.
static const int relayRetryMaxMs = 8000;
/// A relay viewer's response headers wait at most this long for its
/// first bytes, under mpv's 5 s `network-timeout`: a header that never
/// comes in time costs ffmpeg the file's size for good.
static const int relayHeaderWaitMs = 4000;
/// The source's own buffer of a link it reads once for its player and
/// the upload (`SharedSource`), all tracks together besides the pins:
/// the two read within [sharedAheadBytes] of each other.
static const int sharedAheadBytes = 64 << 20;
static const int sharedBackBytes = 16 << 20;
/// The warm relay (Up next's first relayed item, before it goes up)
/// keeps this much ahead of and behind its viewers' preloads, besides
/// the pins: about what a preload player reads ahead (48 MiB), never the
+25 -11
View File
@@ -282,9 +282,12 @@ Future<String> _readText(HttpClientResponse res) async {
/// audio, or segmented playlists). A byte track is range-read with
/// yt-dlp's headers in GETs of the format's `http_chunk_size`; a pieces
/// track's playlist (or DASH fragments) is read on first use, then each
/// piece is one GET. Format URLs expire: on 403/410 from any track the page
/// is resolved again once for all tracks (same format ids, same sizes and
/// piece counts) and the read goes on from where it stopped.
/// piece is one GET. Format URLs expire: on 403/410/412 from any track the
/// page is resolved again once for all tracks (same format ids, same sizes
/// and piece counts) and the read goes on from where it stopped. Another
/// expiry within [refreshGap] of that resolve fails the read instead (the
/// server backs off and asks again), so a site refusing the fresh URLs too
/// is not resolved again and again.
class PageRelayMedia implements RelayMedia {
final Uri page;
final YtDlp ytDlp;
@@ -296,7 +299,10 @@ class PageRelayMedia implements RelayMedia {
final _http = HttpClient();
var _gen = 0;
Future<void>? _refreshing;
PageRelayMedia(this.page, this.ytDlp, {List<YtDlpTrack>? tracks}) : _picked = tracks;
DateTime? _refreshedAt;
final Duration refreshGap;
PageRelayMedia(this.page, this.ytDlp, {List<YtDlpTrack>? tracks, this.refreshGap = const Duration(seconds: 60)})
: _picked = tracks;
Future<YtDlpInfo> _info() async {
final info = await ytDlp.resolve(page.toString());
@@ -335,14 +341,22 @@ class PageRelayMedia implements RelayMedia {
return fetchHlsPlaylist(_http, f.url, f.headers);
}
static bool _expired(Object e) => e is UpstreamHttpException && (e.status == 403 || e.status == 410);
/// A format URL the site no longer takes: 403, 410, or Bilibili's 412.
static bool _expired(Object e) =>
e is UpstreamHttpException && (e.status == 403 || e.status == 410 || e.status == 412);
/// Resolves the page again: the same format ids with the same sizes and
/// piece shapes, else the video changed.
Future<void> _refresh(int gen) {
Future<void> _refresh(int gen, Object cause) {
if (gen != _gen) return Future.value();
final last = _refreshedAt;
if (_refreshing == null && last != null && DateTime.now().difference(last) < refreshGap) {
// Resolved a moment ago and refused again: not once more.
return Future.error(cause);
}
return _refreshing ??= () async {
_log.info('relay of ${logUrl(page)}: a format URL expired (403/410), resolving the page again');
_refreshedAt = DateTime.now();
_log.info('relay of ${logUrl(page)}: a format URL expired (HTTP ${cause is UpstreamHttpException ? cause.status : '?'}), resolving the page again');
try {
const changed = LinkProbeException('the video changed on the page');
final info = await _info();
@@ -384,7 +398,7 @@ class PageRelayMedia implements RelayMedia {
return _sizes[i] = await _open(i).size();
} catch (e) {
if (!_expired(e)) rethrow;
await _refresh(gen);
await _refresh(gen, e);
return _sizes[i] = await _open(i).size();
}
}
@@ -397,7 +411,7 @@ class PageRelayMedia implements RelayMedia {
return _pieces[i] = await _loadPieces(_picked![i].format);
} catch (e) {
if (!_expired(e)) rethrow;
await _refresh(gen);
await _refresh(gen, e);
return _pieces[i] ??= await _loadPieces(_picked![i].format);
}
}
@@ -416,7 +430,7 @@ class PageRelayMedia implements RelayMedia {
} catch (e) {
if (retried || !_expired(e)) rethrow;
retried = true;
await _refresh(gen);
await _refresh(gen, e);
}
}
}
@@ -435,7 +449,7 @@ class PageRelayMedia implements RelayMedia {
} catch (e) {
if (retried || !_expired(e)) rethrow;
retried = true;
await _refresh(gen);
await _refresh(gen, e);
}
}
}
+3 -2
View File
@@ -117,7 +117,7 @@ void relogLine(String tag, String line) {
// `"name":"value"` or `Name="value"` (Jellyfin's MediaBrowser header).
const _secretNames = r'(?:x-emby-token|x-mediabrowser-token|x-emby-authorization|authorization|set-cookie|cookie|'
r'api_?key|access_?token|refresh_?token|id_?token|auth_?token|token|password|passwd|secret|host_?key|'
r'sessdata|bili_jct|client_?secret|client_?id|client)';
r'sessdata|bili_jct|client_?secret|client_?id|client|pw)';
final _jsonSecret = RegExp('"($_secretNames)"\\s*:\\s*"(?:[^"\\\\]|\\\\.)*"', caseSensitive: false);
final _quotedSecret = RegExp('\\b($_secretNames)\\s*=\\s*"[^"]*"', caseSensitive: false);
// Header-like `Name: value` runs to the end of the line (a cookie
@@ -127,7 +127,8 @@ final _headerSecret = RegExp(
caseSensitive: false);
final _pairSecret = RegExp('\\b($_secretNames)\\s*=\\s*[^&\\s"\',;)}\\]]+', caseSensitive: false);
final _bearer = RegExp(r'\b(Bearer|Basic)\s+[A-Za-z0-9._~+/=-]{8,}', caseSensitive: false);
final _url = RegExp(r'''\b(?:https?|wss?)://[^\s"'<>]+''', caseSensitive: false);
// starsync:// is an invite link (its `pw` is a server password).
final _url = RegExp(r'''\b(?:https?|wss?|starsync)://[^\s"'<>]+''', caseSensitive: false);
// A member token in a server path: /r/<sid>/<token>/..., /m, /j, /a, /i, /l.
final _pathToken = RegExp(r'(/[rmjail]/[^/\s?#]+/)[0-9a-fA-F]{32}\b');
final _userInfo = RegExp(r'^([a-z]+://)[^/@\s]+@', caseSensitive: false);
+83 -12
View File
@@ -78,18 +78,50 @@ const _stagingDir = '.update-staging';
const _backupDir = '.update-backup';
const _downloadFile = '.update-download';
/// What a release changed, in the words of CHANGELOG.md: the manifest's
/// `changes` ({"added": [...], "changed": [...], "fixed": [...]}).
/// Manifests from before it have only `notes`.
class ReleaseChanges {
final List<String> added;
final List<String> changed;
final List<String> fixed;
const ReleaseChanges({this.added = const [], this.changed = const [], this.fixed = const []});
/// Null for anything that is not a map, or one with no items; entries
/// that are not strings (or blank) are dropped, unknown keys ignored.
static ReleaseChanges? fromJson(Object? json) {
if (json is! Map) return null;
List<String> list(String key) => List.unmodifiable([
if (json[key] case final List l)
for (final item in l)
if (item is String && item.trim().isNotEmpty) item.trim(),
]);
final c = ReleaseChanges(added: list('added'), changed: list('changed'), fixed: list('fixed'));
return c.isEmpty ? null : c;
}
Map<String, Object?> toJson() => {'added': added, 'changed': changed, 'fixed': fixed};
bool get isEmpty => added.isEmpty && changed.isEmpty && fixed.isEmpty;
int get length => added.length + changed.length + fixed.length;
}
class UpdateManifest {
final String version;
final int protocol;
final String url;
final String? sha256;
/// One line for every app (1.2.x and 1.3.x show only this).
final String notes;
/// Null in manifests from before the changelog (show [notes] then).
final ReleaseChanges? changes;
const UpdateManifest(
{required this.version,
required this.protocol,
required this.url,
this.sha256,
required this.notes});
required this.notes,
this.changes});
// `assets` maps platform → archive URL. Top-level `url` is the Linux
// tarball, kept for builds from before `assets` existed (<= 0.2.1).
@@ -115,7 +147,8 @@ class UpdateManifest {
protocol: protocol,
url: url,
sha256: sha is String && sha.isNotEmpty ? sha : null,
notes: (json['notes'] as String?) ?? '');
notes: json['notes'] is String ? json['notes'] as String : '',
changes: ReleaseChanges.fromJson(json['changes']));
}
// Newer wire protocol: this build can't join servers running it.
@@ -204,16 +237,58 @@ Future<UpdateManifest> fetchManifest(
/// A test build (`1.1.3-dev.4`); `0.0.0-dev` (a dev run) is not one.
bool isDevBuild(String version) => version.contains('-dev.');
/// The manifest to update from: the stable one, and for a [isDevBuild]
/// also the dev one, whichever is newer (either alone if the other fails).
/// A release outranks its own test builds, so testers return to stable.
/// Where an install takes its updates from. [stable]: releases only.
/// [dev]: test builds as well (`devManifestUrl`), whichever of the two is
/// newer, so a release newer than the running test build is installed and
/// the next test line after it still is.
enum UpdateChannel {
stable,
dev;
static UpdateChannel? fromName(Object? name) {
for (final c in values) {
if (c.name == name) return c;
}
return null;
}
}
/// The channel of an install that never chose one: [UpdateChannel.dev]
/// for a test build, else [UpdateChannel.stable].
UpdateChannel defaultUpdateChannel(String version) =>
isDevBuild(version) ? UpdateChannel.dev : UpdateChannel.stable;
/// Of the stable and dev manifests read (null: that one failed), the one
/// [channel] updates from: on stable the stable one, on dev the newer of
/// the two by semver (a release outranks its own test builds).
UpdateManifest? pickManifest(UpdateChannel channel,
{UpdateManifest? stable, UpdateManifest? dev}) {
if (channel == UpdateChannel.stable || dev == null) return stable;
if (stable == null) return dev;
return isNewerVersion(stable.version, dev.version) ? dev : stable;
}
/// A stable [latest] that is older than the running test build
/// [currentVersion]: on [UpdateChannel.stable] it is offered only after
/// asking ("go back to stable X?"), never as an update.
bool isStableBehind(String currentVersion, UpdateManifest latest) =>
isDevBuild(currentVersion) &&
!isDevBuild(latest.version) &&
compareVersions(latest.version, currentVersion) < 0;
/// The manifest to update from on [channel] (default by [currentVersion]:
/// [defaultUpdateChannel]): the stable one, and on dev also the dev one,
/// the newer of the two ([pickManifest]; either alone if the other fails).
Future<UpdateManifest> fetchLatestManifest(
{HttpClient? http,
String currentVersion = appVersion,
UpdateChannel? channel,
String url = updateManifestUrl,
String devUrl = devManifestUrl}) async {
final stable = fetchManifest(http: http, url: url);
if (!isDevBuild(currentVersion)) return stable;
if ((channel ?? defaultUpdateChannel(currentVersion)) == UpdateChannel.stable) {
return stable;
}
stable.ignore(); // awaited below; don't let an early failure go uncaught
final dev = await fetchManifest(http: http, url: devUrl)
.then<UpdateManifest?>((m) => m, onError: (Object e) {
@@ -221,12 +296,8 @@ Future<UpdateManifest> fetchLatestManifest(
return null;
});
if (dev == null) return stable;
try {
final s = await stable;
return isNewerVersion(s.version, dev.version) ? dev : s;
} catch (_) {
return dev;
}
final s = await stable.then<UpdateManifest?>((m) => m, onError: (Object _) => null);
return pickManifest(UpdateChannel.dev, stable: s, dev: dev)!;
}
typedef DownloadProgress = void Function(int received, int? total);
+7 -1
View File
@@ -65,15 +65,21 @@ class YtDlpFormat {
/// Relayed as pieces (see relay_pieces.dart), not as a byte stream.
bool get isPieces => _piecesProtocols.contains(protocol);
/// From one of yt-dlp's formats: its `http_headers` plus its `cookies`
/// as a Cookie header, the same a player gets ([YtDlpPlay.parse]).
static YtDlpFormat _of(Map f) {
final headers = f['http_headers'];
final opts = f['downloader_options'];
final chunk = opts is Map ? opts['http_chunk_size'] : null;
final h = f['height'] is num ? (f['height'] as num).toInt() : 0;
final cookie = cookieHeaderOf(f['cookies']);
return YtDlpFormat(
id: '${f['format_id'] ?? ''}',
url: Uri.parse(f['url'] as String),
headers: headers is Map ? {for (final e in headers.entries) '${e.key}': '${e.value}'} : const {},
headers: {
if (headers is Map) for (final e in headers.entries) '${e.key}': '${e.value}',
if (cookie.isNotEmpty) 'Cookie': cookie,
},
height: h == 0 ? null : h,
ext: '${f['ext']}',
chunkSize: chunk is num && chunk > 0 ? chunk.toInt() : null,
+1 -1
View File
@@ -1,6 +1,6 @@
name: sync_relay_core
description: Pure-Dart protocol, sync engine, media-source abstractions. No Flutter.
version: 1.3.0
version: 1.4.0
environment:
sdk: ^3.0.0
dependencies:
+6
View File
@@ -24,6 +24,12 @@ void main() {
expect(redactLog('http://user:pw@host/x#frag'), 'http://***@host/x');
});
test('an invite link keeps its address, never its password', () {
expect(redactLog('link starsync://203.0.113.7:8080/?name=Bob&pw=hunter2'),
'link starsync://203.0.113.7:8080/?name&pw=***');
expect(redactLog('pw=hunter2 port=1'), 'pw=*** port=1');
});
test('member tokens in server paths', () {
expect(redactLog('open http://1.2.3.4:8080/r/default/$tok/2/0/name'), 'open http://1.2.3.4:8080/r/default/***/2/0/name');
expect(redactLog('ws://h:1/r/default/$tok/source?q=7'), 'ws://h:1/r/default/***/source?q=…');
+107
View File
@@ -0,0 +1,107 @@
import 'dart:io';
import 'dart:typed_data';
import 'package:sync_relay_core/core.dart';
import 'package:test/test.dart';
// A page relayed by its formats: an expired format URL (403/410/412) is
// resolved again once, and not again and again when the site keeps
// refusing.
/// yt-dlp that resolves the page to the origin's current format URL.
class _CountingYtDlp extends YtDlp {
final Uri Function() url;
var resolves = 0;
_CountingYtDlp(super.dir, this.url);
@override
Future<YtDlpInfo> resolve(String page, {Duration timeout = const Duration(seconds: 25)}) async {
resolves++;
final f = YtDlpFormat(id: 'v', url: url());
return YtDlpInfo(title: 'page', tracks: [(kind: '', format: f)], formats: {'v': f});
}
}
void main() {
late HttpServer origin;
late Directory tmp;
// Only `/v<gen>.mp4` of the current generation is served; older ones get
// [refusal] (a format URL that expired).
var gen = 1;
var refusal = 412;
final bytes = Uint8List.fromList(List.generate(100000, (i) => i % 251));
setUp(() async {
gen = 1;
refusal = 412;
tmp = await Directory.systemTemp.createTemp('page-relay');
origin = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
origin.listen((req) async {
if (req.uri.path != '/v$gen.mp4') {
req.response.statusCode = refusal;
await req.response.close();
return;
}
final m = RegExp(r'bytes=(\d+)-(\d+)').firstMatch(req.headers.value('range') ?? '')!;
final start = int.parse(m.group(1)!), end = int.parse(m.group(2)!);
req.response
..statusCode = 206
..headers.contentType = ContentType('video', 'mp4')
..headers.set('content-range', 'bytes $start-$end/${bytes.length}')
..add(bytes.sublist(start, end + 1));
await req.response.close();
});
});
tearDown(() async {
await origin.close(force: true);
await tmp.delete(recursive: true);
});
Uri current() => Uri.parse('http://127.0.0.1:${origin.port}/v$gen.mp4');
Future<List<int>> read(RelayTrack t, int start, int end) async =>
[for (final c in await t.bytes!.read(start, end).toList()) ...c];
for (final status in [403, 410, 412]) {
test('HTTP $status: the page is resolved again once and the read goes on', () async {
refusal = status;
final y = _CountingYtDlp(tmp.path, current);
final media = PageRelayMedia(Uri.parse('https://example.com/watch'), y);
addTearDown(media.close);
final t = (await media.open()).single;
expect(await t.bytes!.size(), bytes.length);
expect(await read(t, 0, 999), bytes.sublist(0, 1000));
gen = 2; // the first URL expired
expect(await read(t, 5000, 5999), bytes.sublist(5000, 6000));
expect(y.resolves, 2);
});
}
test('refused again right after a resolve: the read fails, no resolve storm', () async {
final y = _CountingYtDlp(tmp.path, current);
final media = PageRelayMedia(Uri.parse('https://example.com/watch'), y);
addTearDown(media.close);
final t = (await media.open()).single;
await t.bytes!.size();
gen = 2;
expect(await read(t, 0, 9), bytes.sublist(0, 10));
gen = 3; // the fresh URL is refused too
for (var i = 0; i < 5; i++) {
await expectLater(read(t, 0, 9), throwsA(isA<UpstreamHttpException>().having((e) => e.status, 'status', 412)));
}
expect(y.resolves, 2);
});
test('another expiry after the gap resolves again', () async {
final y = _CountingYtDlp(tmp.path, current);
final media = PageRelayMedia(Uri.parse('https://example.com/watch'), y, refreshGap: Duration.zero);
addTearDown(media.close);
final t = (await media.open()).single;
await t.bytes!.size();
for (gen = 2; gen <= 4; gen++) {
expect(await read(t, 100, 199), bytes.sublist(100, 200));
}
expect(y.resolves, 4);
});
}
+70 -3
View File
@@ -190,6 +190,47 @@ void main() {
isTrue);
});
test('manifest changes: structured lists, notes kept, old manifests without', () {
final base = {
'version': '1.3.3',
'protocol': RelayConstants.protoVersion,
'url': 'https://x/y.tar.gz',
'notes': 'One line for older apps.',
};
final old = UpdateManifest.fromJson(base);
expect(old.changes, isNull);
expect(old.notes, 'One line for older apps.');
final m = UpdateManifest.fromJson({
...base,
'changes': {
'added': ['Danmaku style notifications', ' ', 7],
'fixed': ['Various bug fixes'],
'later': ['unknown kinds are ignored'],
},
});
expect(m.notes, 'One line for older apps.');
expect(m.changes!.added, ['Danmaku style notifications']);
expect(m.changes!.changed, isEmpty);
expect(m.changes!.fixed, ['Various bug fixes']);
expect(m.changes!.length, 2);
expect(ReleaseChanges.fromJson(m.changes!.toJson())!.toJson(), m.changes!.toJson());
// Junk or empty `changes` (and a non-string `notes`) do not break the manifest.
for (final junk in [null, 'text', [], {}, {'added': 'no list'}]) {
expect(UpdateManifest.fromJson({...base, 'changes': junk}).changes, isNull);
}
expect(UpdateManifest.fromJson({...base, 'notes': 3}).notes, '');
});
test('the repo manifest parses, and keeps a one-line string notes for 1.2.x/1.3.x', () {
final json = jsonDecode(File('../../updates/manifest.json').readAsStringSync()) as Map<String, Object?>;
// Older apps read `(json['notes'] as String?)`: it must stay a string.
expect(json['notes'], isA<String>());
expect((json['notes'] as String).contains('\n'), isFalse);
expect(UpdateManifest.fromJson(json, platform: 'linux-x64').version, json['version']);
});
test('manifest picks the asset for this platform', () {
final json = {
'version': '0.2.2',
@@ -237,21 +278,47 @@ void main() {
await req.response.close();
});
final base = 'http://127.0.0.1:${http.port}';
Future<String> latest(String current) async => (await fetchLatestManifest(
currentVersion: current, url: '$base/stable', devUrl: '$base/dev'))
Future<String> latest(String current, [UpdateChannel? channel]) async => (await fetchLatestManifest(
currentVersion: current, channel: channel, url: '$base/stable', devUrl: '$base/dev'))
.version;
try {
expect(await latest('1.1.2'), '1.1.2'); // stable builds never see dev
expect(await latest('1.1.3-dev.1'), '1.1.3-dev.2');
versions['/stable'] = '1.1.3'; // the release outranks its test builds
expect(await latest('1.1.3-dev.2'), '1.1.3');
// A release installed on the dev channel still sees the next test line.
versions['/dev'] = '1.1.4-dev.1';
expect(await latest('1.1.3', UpdateChannel.dev), '1.1.4-dev.1');
expect(await latest('1.1.3', UpdateChannel.stable), '1.1.3');
expect(await latest('1.1.4-dev.1', UpdateChannel.stable), '1.1.3');
versions.remove('/stable');
expect(await latest('1.1.3-dev.1'), '1.1.3-dev.2');
expect(await latest('1.1.3-dev.1'), '1.1.4-dev.1');
} finally {
await http.close(force: true);
}
});
test('update channel: default, pick by semver, a stable behind a test build', () {
UpdateManifest m(String v) => UpdateManifest(version: v, protocol: 1, url: 'http://x/$v', notes: '');
expect(defaultUpdateChannel('1.4.0-dev.3'), UpdateChannel.dev);
expect(defaultUpdateChannel('1.4.0'), UpdateChannel.stable);
expect(defaultUpdateChannel('0.0.0-dev'), UpdateChannel.stable);
expect(UpdateChannel.fromName('dev'), UpdateChannel.dev);
expect(UpdateChannel.fromName('beta'), isNull);
String? pick(UpdateChannel ch, String? s, String? d) =>
pickManifest(ch, stable: s == null ? null : m(s), dev: d == null ? null : m(d))?.version;
expect(pick(UpdateChannel.dev, '1.4.0', '1.4.0-dev.3'), '1.4.0');
expect(pick(UpdateChannel.dev, '1.4.0', '1.4.1-dev.1'), '1.4.1-dev.1');
expect(pick(UpdateChannel.dev, '1.3.2', '1.4.0-dev.3'), '1.4.0-dev.3');
expect(pick(UpdateChannel.dev, null, '1.4.0-dev.3'), '1.4.0-dev.3');
expect(pick(UpdateChannel.dev, '1.3.2', null), '1.3.2');
expect(pick(UpdateChannel.stable, '1.3.2', '1.4.0-dev.3'), '1.3.2');
expect(isStableBehind('1.4.0-dev.3', m('1.3.2')), isTrue);
expect(isStableBehind('1.4.0-dev.3', m('1.4.0')), isFalse);
expect(isStableBehind('1.3.2', m('1.3.1')), isFalse); // not a test build
expect(isStableBehind('1.4.0-dev.3', m('1.4.0-dev.2')), isFalse);
});
group('upgrade flow', () {
late Directory work;
late _Server server;
+19
View File
@@ -262,6 +262,25 @@ void main() {
expect(pick(['avc1.640033', 'vp09.00.40.08']), 'v1');
});
test('what a relay reads sends what a player sends: each format\'s headers with its cookies', () {
const cookies = 'SESSDATA=abc; Domain=.bilibili.com; Path=/; Secure; Expires=1999999999; buvid3=xyz; Domain=.bilibili.com';
Map<String, Object?> f(String id, String vcodec, String acodec, int? height) => {
'format_id': id, 'url': 'https://cdn/$id.m4s', 'protocol': 'https', 'ext': 'mp4',
'vcodec': vcodec, 'acodec': acodec, 'height': height,
'http_headers': {'Referer': 'https://www.bilibili.com/', 'User-Agent': 'UA'},
'cookies': cookies,
};
final v = f('30080', 'avc1.640032', 'none', 1080), a = f('30280', 'none', 'mp4a.40.2', null);
final info = YtDlpInfo.parse({'title': 'Page', 'formats': [v, a], 'requested_formats': [v, a]});
const want = {'Referer': 'https://www.bilibili.com/', 'User-Agent': 'UA', 'Cookie': 'SESSDATA=abc; buvid3=xyz'};
expect([for (final t in info.tracks) t.format.id], ['30080', '30280']);
for (final t in info.tracks) {
expect(t.format.headers, want, reason: t.kind);
}
expect(info.play!.headers, want);
expect(info.formats['30080']!.describe(), isNot(contains('SESSDATA')), reason: 'the log names headers only');
});
test('what a player opens: yt-dlp\'s pick, an EDL for video + audio, headers with the cookies, subtitles but danmaku', () {
final split = YtDlpInfo.parse({
'title': 'Page',
+1
View File
@@ -8,3 +8,4 @@ export 'src/relay_buffer.dart';
export 'src/relay_routes.dart';
export 'src/relay_stats.dart';
export 'src/session_log.dart';
export 'src/shared_source.dart';
@@ -249,3 +249,23 @@ MimeType _mimeOf(String? contentType) {
return MimeType.octetStream;
}
}
/// Says `connection: close` on a response to a request that asked for it
/// (ffmpeg always does). dart:io closes such a connection after the
/// response but, behind relic, without saying so; ffmpeg (8.x) then sends
/// its next seek on that dead connection after reading a range to its end
/// (an mp4's tail) and the seek fails for good ("size -38").
Handler closeWhenAsked(Handler next) => (req) async {
final res = await next(req);
if (res is! Response) return res;
bool asked;
try {
asked = req.headers.connection?.isClose ?? false;
} catch (_) {
asked = false;
}
if (!asked) return res;
return res.copyWith(
headers: res.headers.transform(
(h) => h.connection = const ConnectionHeader.directives([ConnectionHeaderType.close])));
};
+10 -2
View File
@@ -81,6 +81,7 @@ class PieceBuffer implements TrackBuffer {
/// null before a minute of it has arrived.
double? get bytesPerSec => _knownSecs >= 60 ? _knownBytes / _knownSecs : null;
DateTime? _retryAt;
var _failures = 0; // failed pulls in a row (backoff)
var _disposed = false;
Completer<void> _kick = Completer();
final Completer<void> _ready = Completer();
@@ -167,6 +168,7 @@ class PieceBuffer implements TrackBuffer {
final pl = _pull;
if (pl == null || pl.id != id || _disposed) return;
_pull = null;
_failures = 0;
final data = pl.buf.takeBytes();
final i = pl.piece;
_pieces[i] = data;
@@ -182,12 +184,18 @@ class PieceBuffer implements TrackBuffer {
_wake();
}
static final _pullFailThrottle = LogThrottle(burst: 3);
@override
void feedFail(int id, String reason) {
if (_pull?.id != id) return;
_log.warning('relay piece pull $id failed: $reason');
_pull = null;
_retryAt = DateTime.now().add(const Duration(seconds: 1));
final delay = relayRetryDelay(++_failures);
_retryAt = DateTime.now().add(delay);
final pass = _pullFailThrottle.allow('${identityHashCode(this)}');
if (pass != null) {
_log.warning('${pass}relay piece pull $id failed ($_failures in a row, next in ${delay.inMilliseconds} ms): $reason');
}
_wake();
}
+128 -18
View File
@@ -9,6 +9,21 @@ import 'relay_stats.dart';
final _log = createLogger('relay');
/// A viewer's read ended because its relay went (a swap, server stop):
/// not a failure, the response just ends.
class RelayEnded implements Exception {
const RelayEnded();
@override
String toString() => 'relay ended';
}
/// The wait before the next pull after [failures] failed ones in a row:
/// 250 ms doubling to [RelayConstants.relayRetryMaxMs] (a source whose
/// upstream keeps failing must not be asked again and again at once).
Duration relayRetryDelay(int failures) => failures <= 0
? Duration.zero
: Duration(milliseconds: min(RelayConstants.relayRetryMaxMs, 250 * (1 << min(failures - 1, 10))));
/// One track's buffer on the server: a byte stream ([RelayBuffer]) or
/// pieces ([PieceBuffer]).
abstract interface class TrackBuffer {
@@ -143,11 +158,13 @@ class _Pull {
}
class _Viewer {
final String name;
int pos = 0;
int open = 0; // requests in flight
int newest = 0; // sequence of its newest request
(int slice, int req)? blocking; // where its newest request waits
DateTime lastActive = DateTime.now();
_Viewer(this.name);
}
/// The server side of the relay for one media: an in-memory buffer of
@@ -166,6 +183,9 @@ class _Viewer {
/// - Only a viewer's newest request moves its position or asks the feed
/// for anything; older ones still get bytes once they are buffered, and
/// stop holding the feed once superseded (a seek elsewhere).
/// - [together]: two viewers kept within [ahead] of each other (see the
/// field).
/// - Read-ahead never fetches a slice that would be the next one evicted.
/// Everything lives in memory: dropping the buffer is the cleanup.
class RelayBuffer implements TrackBuffer {
/// Set by the relay when a media's tracks share the budget.
@@ -175,11 +195,22 @@ class RelayBuffer implements TrackBuffer {
final Duration waitLimit;
/// Pull ids; the tracks of one media share one sequence (one channel).
final int Function() _newId;
/// Two viewers that read one stream at about one position (the source's
/// own player and its upload to the server, see [SharedSource]) and
/// must not pull the feed two ways: while both have a request open, a
/// read of the one more than [ahead] past the other waits without
/// asking the feed for anything (and without the wait limit), and its
/// position does not count for read-ahead or eviction. So one
/// sequential feed serves both, from one window, instead of handing
/// over at every slice boundary. Pinned slices are always read. Null:
/// every viewer drives on its own (the server's viewers).
final (String, String)? together;
RelayBuffer({
this.ahead = RelayConstants.relayAheadBytes,
this.back = RelayConstants.relayBackBytes,
this.pin = RelayConstants.relayPinBytes,
this.waitLimit = const Duration(seconds: RelayConstants.relayWaitSecs),
this.together,
int Function()? nextId,
}) : _newId = nextId ?? _counter();
@@ -198,6 +229,8 @@ class RelayBuffer implements TrackBuffer {
// The driving requests waiting on each blocked slice.
final _blockedBy = <int, Set<int>>{};
final _viewers = <String, _Viewer>{};
// Reads held back by [together], woken when a position moves.
var _moved = Completer<void>();
@override
FeedClock clock = FeedClock();
_Pull? _pullNow;
@@ -209,6 +242,9 @@ class RelayBuffer implements TrackBuffer {
var _reqSeq = 0;
var _disposed = false;
// Failed pulls in a row, and when the next pull may start (backoff).
var _failures = 0;
DateTime? _retryAt;
Completer<void> _kick = Completer();
final Completer<void> _ready = Completer();
@@ -264,12 +300,30 @@ class RelayBuffer implements TrackBuffer {
_waiters.clear();
if (!_ready.isCompleted) _ready.complete();
_wake();
_wakeHeld();
}
void _wake() {
if (!_kick.isCompleted) _kick.complete();
}
void _wakeHeld() {
if (together == null) return;
final m = _moved;
_moved = Completer<void>();
m.complete();
}
/// [v] is one of [together] and more than [ahead] past the other while
/// that one has a request open: it does not drive.
bool _held(_Viewer v) {
final t = together;
if (t == null) return false;
final other = v.name == t.$1 ? t.$2 : (v.name == t.$2 ? t.$1 : null);
final o = other == null ? null : _viewers[other];
return o != null && o.open > 0 && v.pos > o.pos + ahead;
}
// ------------------------------------------------------------ feed input
@override
@@ -292,7 +346,13 @@ class RelayBuffer implements TrackBuffer {
off += take;
pl.pos += take;
final done = pl.pos == _sliceEnd(s) + 1;
if (done) _slices[s] = pl.takeSlice();
if (done) {
_slices[s] = pl.takeSlice();
// A whole slice came: the source works again.
_failures = 0;
// A long pull stays within the budget as it goes.
_evict();
}
// Whole, or more of it: whoever waits on it reads on.
for (final c in _waiters.remove(s) ?? const <Completer<void>>[]) {
c.complete();
@@ -309,17 +369,27 @@ class RelayBuffer implements TrackBuffer {
final pl = _pull;
if (pl != null && pl.id == id) {
_pull = null;
_failures = 0;
_wake();
}
}
@override
void feedFail(int id, String reason) {
if (_pull?.id != id) return;
_log.warning('relay pull $id failed: $reason');
feedEnd(id);
final pl = _pull;
if (pl == null || pl.id != id) return;
_pull = null;
final delay = relayRetryDelay(++_failures);
_retryAt = DateTime.now().add(delay);
final pass = _pullFailThrottle.allow('${identityHashCode(this)}');
if (pass != null) {
_log.warning('${pass}relay pull $id failed ($_failures in a row, next in ${delay.inMilliseconds} ms): $reason');
}
_wake();
}
static final _pullFailThrottle = LogThrottle(burst: 3);
// ------------------------------------------------------------- scheduling
bool _shouldStop(_Pull pl, int done) {
@@ -330,7 +400,24 @@ class RelayBuffer implements TrackBuffer {
return true;
}
if (_slices.containsKey(next)) return true;
return !_wanted(next);
if (!_wanted(next)) return true;
// Read-ahead nobody waits on stops where the next slice would be the
// next one evicted (else a full buffer fetches and drops it in turn).
final s = next - 1;
return !_blocked.containsKey(s) && !_blocked.containsKey(next) && !_roomFor(next);
}
/// Slice [s] can come in without being the next one evicted: the
/// buffer has room, or holds a slice farther from the viewers.
bool _roomFor(int s) {
if ((_slices.length + 1) * slice <= ahead + back + 2 * pin) return true;
final at = _positions();
final d = _distance(s, at);
if (d < 0) return true;
for (final c in _slices.keys) {
if (!_waiters.containsKey(c) && _distance(c, at) > d) return true;
}
return false;
}
void _endPull(_Pull pl) {
@@ -342,8 +429,8 @@ class RelayBuffer implements TrackBuffer {
Iterable<_Viewer> get _active {
final now = DateTime.now();
return _viewers.values
.where((v) => v.open > 0 || now.difference(v.lastActive) < const Duration(seconds: 60));
return _viewers.values.where(
(v) => (v.open > 0 || now.difference(v.lastActive) < const Duration(seconds: 60)) && !_held(v));
}
bool _pinned(int s) => s * slice < pin || s * slice >= size - pin;
@@ -356,13 +443,17 @@ class RelayBuffer implements TrackBuffer {
return off >= pos.reduce(min) - back && off <= pos.reduce(max) + ahead;
}
int _distance(int s) {
/// The active viewers' positions, for [_distance].
List<int> _positions() => [for (final v in _active) v.pos];
/// How far slice [s] is from the viewers at [positions]; -1 pinned.
int _distance(int s, List<int> positions) {
if (_pinned(s)) return -1;
final off = s * slice;
var d = 1 << 62;
for (final v in _active) {
for (final p in positions) {
// Behind a viewer costs more than ahead (it was already watched).
d = min(d, off >= v.pos ? off - v.pos : (v.pos - off) * 4);
d = min(d, off >= p ? off - p : (p - off) * 4);
}
return d;
}
@@ -371,9 +462,11 @@ class RelayBuffer implements TrackBuffer {
final limit = ahead + back + 2 * pin;
var total = _slices.length * slice;
if (total <= limit) return;
final order = _slices.keys.toList()..sort((a, b) => _distance(b).compareTo(_distance(a)));
final at = _positions();
final dist = {for (final s in _slices.keys) s: _distance(s, at)};
final order = dist.keys.toList()..sort((a, b) => dist[b]!.compareTo(dist[a]!));
for (final s in order) {
if (total <= limit || _distance(s) < 0) break;
if (total <= limit || dist[s]! < 0) break;
if (_waiters.containsKey(s)) continue;
_slices.remove(s);
total -= slice;
@@ -395,7 +488,7 @@ class RelayBuffer implements TrackBuffer {
final starts = active.map((v) => v.pos).toList()..sort();
for (final p in starts) {
for (var s = p ~/ slice; s * slice <= p + ahead && s < _nSlices; s++) {
if (!_slices.containsKey(s)) return s;
if (!_slices.containsKey(s)) return _roomFor(s) ? s : null;
}
}
return null;
@@ -412,6 +505,12 @@ class RelayBuffer implements TrackBuffer {
await _sleep();
continue;
}
final retry = _retryAt;
if (retry != null && DateTime.now().isBefore(retry)) {
await _sleep(retry.difference(DateTime.now()));
continue;
}
_retryAt = null;
_evict();
final s = _nextMissing();
if (s == null) {
@@ -431,10 +530,10 @@ class RelayBuffer implements TrackBuffer {
}
}
Future<void> _sleep() async {
Future<void> _sleep([Duration limit = const Duration(seconds: 5)]) async {
_kick = Completer();
// Also wake periodically: viewers age out of [_active].
await _kick.future.timeout(const Duration(seconds: 5), onTimeout: () {});
await _kick.future.timeout(limit, onTimeout: () {});
}
/// The bytes of slice [s] from [from] (offset in the slice) as soon as
@@ -443,7 +542,7 @@ class RelayBuffer implements TrackBuffer {
/// request is its viewer's newest ([driving] says while it still is), so
/// it may move the feed.
Future<Uint8List?> _waitBytes(_Viewer v, int s, int from, int req, bool Function() driving) async {
final deadline = DateTime.now().add(waitLimit);
var deadline = DateTime.now().add(waitLimit);
try {
while (true) {
final whole = _slices[s];
@@ -454,6 +553,13 @@ class RelayBuffer implements TrackBuffer {
if (part != null) return part;
}
if (_disposed || DateTime.now().isAfter(deadline)) return null;
if (!_pinned(s) && driving() && _held(v)) {
// Too far past its partner: waits for it, asking nothing.
_unblock(s, req);
deadline = DateTime.now().add(waitLimit);
await _moved.future.timeout(const Duration(seconds: 2), onTimeout: () {});
continue;
}
if (driving()) {
_blocked.putIfAbsent(s, () => DateTime.now().microsecondsSinceEpoch);
_blockedBy.putIfAbsent(s, () => {}).add(req);
@@ -499,9 +605,9 @@ class RelayBuffer implements TrackBuffer {
/// Streams [start]..[end] to [viewer]. Cancelling the subscription (the
/// viewer went away) ends it; a slice that never arrives ends it with an
/// error after [waitLimit].
/// error after [waitLimit]; the buffer's [dispose] with [RelayEnded].
Stream<Uint8List> read(String viewer, int start, int end, {void Function(int n)? onSent}) async* {
final v = _viewers.putIfAbsent(viewer, _Viewer.new);
final v = _viewers.putIfAbsent(viewer, () => _Viewer(viewer));
final tail = start >= size - pin;
final me = ++_reqSeq;
if (!tail) {
@@ -515,11 +621,13 @@ class RelayBuffer implements TrackBuffer {
v.open++;
v.lastActive = DateTime.now();
_wake();
_wakeHeld();
try {
var off = start;
while (off <= end) {
final s = off ~/ slice;
final data = await _waitBytes(v, s, off - s * slice, me, () => tail || v.newest == me);
if (_disposed) throw const RelayEnded();
if (data == null) throw StateError('relay: bytes at $off did not arrive');
final n = min(data.length, end - off + 1);
if (n <= 0) continue;
@@ -530,10 +638,12 @@ class RelayBuffer implements TrackBuffer {
v.lastActive = DateTime.now();
if (!tail && v.newest == me) v.pos = off;
_wake();
_wakeHeld();
}
} finally {
v.open--;
v.lastActive = DateTime.now();
_wakeHeld();
}
}
}
+106 -29
View File
@@ -1,5 +1,6 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io' show HttpDate, IOSink, Socket;
import 'dart:math';
import 'dart:typed_data';
@@ -491,7 +492,7 @@ void mountRelayRoutes(
});
});
Future<Response> viewer(Request req, {required bool tracked, bool piece = false}) async {
Future<Result> viewer(Request req, {required bool tracked, bool piece = false}) async {
final stopwatch = Stopwatch()..start();
final sid = req.pathParameters.raw[#sid] ?? '';
final token = req.pathParameters.raw[#token] ?? '';
@@ -506,17 +507,18 @@ void mountRelayRoutes(
}
// Every outcome goes to the log (throttled per member there); a 503
// after a long wait is a source that never came up.
void note(int status, {int bytes = 0}) => onLog?.call(
token,
MediaLog(
method: req.method.name,
path: '/r/$sid/$key/${track == null ? '' : '$track/'}${pieceIndex == null ? '' : 'p/$pieceIndex'}',
rangeHeader: rangeHeader,
status: status,
bytesWritten: bytes,
durationMs: stopwatch.elapsedMilliseconds,
));
Response logged(Response res, {int bytes = 0}) {
onLog?.call(
token,
MediaLog(
method: req.method.name,
path: '/r/$sid/$key/${track == null ? '' : '$track/'}${pieceIndex == null ? '' : 'p/$pieceIndex'}',
rangeHeader: rangeHeader,
status: res.statusCode,
bytesWritten: bytes,
durationMs: stopwatch.elapsedMilliseconds,
));
note(res.statusCode, bytes: bytes);
return res;
}
@@ -571,24 +573,17 @@ void mountRelayRoutes(
return logged(Response(res.status, body: Body.fromString('status ${res.status}'), headers: headers));
}
final end = res.end ?? size - 1;
final Stream<Uint8List> body = buffer
.read(token, res.start, end, onSent: (n) => session.addBytes(token, n))
.transform(StreamTransformer.fromHandlers(handleError: (e, st, sink) {
_readFails(token, '${r.name} track ${track ?? 0} from ${res.start}: $e');
sink.addError(e, st);
}));
MimeType mime;
try {
mime = MimeType.parse(res.headers['content-type'] ?? '');
} catch (_) {
mime = MimeType.octetStream;
}
return logged(
Response(res.status,
body: Body.fromDataStream(body, mimeType: mime, contentLength: end - res.start + 1),
headers: headers),
bytes: end - res.start + 1);
}
final length = end - res.start + 1;
note(res.status, bytes: length);
final what = '${r.name} track ${track ?? 0} from ${res.start}';
return _rangeResponse(res.status, res.headers, length,
req.method == Method.head
? null
: buffer.read(token, res.start, end, onSent: (n) => session.addBytes(token, n)),
onBroke: (e) => e is RelayEnded
? _log.fine('relay ended under a viewer\'s read: $what')
: _readFails(token, '$what: $e'));
}
// `:seg` is the name of a single file, the track of several (the router
// wants one parameter name per level).
@@ -597,6 +592,88 @@ void mountRelayRoutes(
app.get('/r/:sid/:token/:mediaId/:seg/p/:piece', (final req) => viewer(req, tracked: true, piece: true));
}
/// A relayed byte range on the raw connection ([writeRelayRange]).
Hijack _rangeResponse(int status, Map<String, String> headers, int length, Stream<Uint8List>? body,
{required void Function(Object e) onBroke}) =>
Hijack((ch) => writeRelayRange(ch.stream, ch.sink, status, headers, length, body, onBroke: onBroke));
/// Writes a relayed byte range on a raw connection ([input] / [out]: the
/// server's `/r` answers, and the source's own loopback endpoint in
/// shared_source.dart): the status line and headers go out with the
/// first body bytes, or after [headerWait] without them (an HTTP stack
/// would hold them until body bytes come, and an ffmpeg whose request
/// gets no headers within its 5 s timeout has lost the file's size:
/// every later seek fails with "size -38"). Then the body as the buffer
/// has it; a read waiting on missing bytes keeps the connection open
/// meanwhile. Always `connection: close` and closed after the body:
/// ffmpeg asks for close, and one that is not told so reuses the
/// connection the server closed for its next seek and fails it the same
/// way. A relay that ends ([RelayEnded]) closes the connection plainly;
/// any other failure ([onBroke]) resets it. The client going away cancels
/// the read. Never throws.
Future<void> writeRelayRange(Stream<List<int>> input, StreamSink<List<int>> out, int status,
Map<String, String> headers, int length, Stream<Uint8List>? body,
{required void Function(Object e) onBroke,
Duration headerWait = const Duration(milliseconds: RelayConstants.relayHeaderWaitMs)}) async {
final head = StringBuffer('HTTP/1.1 $status ${_reason(status)}\r\n')
..write('date: ${HttpDate.format(DateTime.now())}\r\n');
headers.forEach((k, v) {
if (k != 'content-length' && k != 'connection') head.write('$k: $v\r\n');
});
head.write('content-length: $length\r\nconnection: close\r\n\r\n');
final it = body == null ? null : StreamIterator(body);
// A read cancelled while it waits ends with what it was waiting for
// (its relay ending, say): moot then.
Future<void> stop() async => it?.cancel().catchError((_) {});
// The client closing (a seek elsewhere, the player gone) ends the read.
final listening = input.listen(null,
onDone: () => unawaited(stop()), onError: (_) => unawaited(stop()), cancelOnError: true);
var reset = false;
// The first bytes go out with the headers when they come in time.
Future<bool>? first = it?.moveNext();
if (first != null) {
await Future.any([first.then((_) {}, onError: (_) {}), Future<void>.delayed(headerWait)]);
}
try {
out.add(latin1.encode('$head'));
if (out is IOSink) await out.flush();
while (it != null) {
final bool more;
try {
final next = first ?? it.moveNext();
first = null;
more = await next;
} catch (e) {
onBroke(e);
reset = e is! RelayEnded;
break;
}
if (!more) break;
out.add(it.current);
if (out is IOSink) await out.flush();
}
} catch (_) {
// The client went away mid-write: nothing to say.
reset = true;
} finally {
await listening.cancel().catchError((_) {});
await stop();
if (reset && out is Socket) {
out.destroy();
} else {
await out.close().catchError((_) {});
}
}
}
String _reason(int status) => switch (status) {
200 => 'OK',
206 => 'Partial Content',
404 => 'Not Found',
416 => 'Range Not Satisfiable',
_ => 'Status',
};
Response _plain(int status) => Response(status, body: Body.fromString('status $status'));
final _readFailThrottle = LogThrottle(burst: 3);
Loaded 100 of 119 files, more files were not shown because too many files have changed in this diff. Show more