18 Commits
Author SHA1 Message Date
foxyzandClaude Opus 5.5 9f2586b7ff Restructure step 1: lasting identity, presence and protocol 16
ci / core (push) Successful in 28s
ci / server (push) Successful in 1m5s
ci / updater-windows (push) Successful in 5m48s
Server and core: version ranges on both sides with a coded refusal; a
client id maps to the same member and token for the server's life; a
drop is `gone` for a 10-minute grace keeping ready, presence, queue
items, relay and avatar; `bye` leaves at once; ordered takeover (seq,
replaced/stale); presence joining/in/away/gone with one PresencePolicy;
joiners never pause playback and hold a held gate at most ~15 s;
state.hold; feed ids, seq and replay; the relay lives through its
source's grace and continues on re-dial; queue `by` lasting, auto
items and cancel; MediaRef carried; client ids never logged.

App: persisted client id and seq; Connection with unstable/lost/
replaced states, backoff with jitter, no lost/back pair per flap; a drop
keeps the session (state, player, media, relay source); presence in the
strip and members card; sit out = away intent; feed matched by id;
auto sit-out (idle when not playing, minimised for a while; never when
alone) replaces auto-unready; bye on close and Leave, not on update
restart; relay source re-dials through its grace.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 21:39:47 +03:00
foxyzandClaude Opus 5.5 51c1c5ab78 Restructure step R: the app is Starsync (dev.foxyz.starsync)
ci / core (push) Successful in 31s
ci / server (push) Successful in 1m3s
ci / updater-windows (push) Successful in 6m6s
- Linux app id dev.foxyz.starsync, binary starsync; Windows starsync.exe,
  company foxyz; server starsync-server; Dart package starsync; archives
  starsync-<ver>-*.
- 1.2.x compatibility: watch_together and watch-together-server
  launchers (Linux) / copies (Windows) in the archive; the updater
  accepts both names and relaunches the new binary; update.sh/.bat use
  whichever server exists.
- First start copies (never moves) the old support folder and this
  app's Hive boxes out of ~/Documents into <support>/hive; a marker
  makes it run once; AppDirs is a port; settings schema version with
  ordered migrations.
- Launcher entry dev.foxyz.starsync.desktop; the old one removed.
- Repository name and URLs unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 20:21:20 +03:00
foxyzandClaude Opus 5.5 f4ca02c3cf Restructure step 0 done: notice table test, flaky-link proxy, liveness on the engine clock, STARSYNC_PROFILE
ci / core (push) Successful in 27s
ci / server (push) Successful in 1m4s
ci / updater-windows (push) Successful in 5m56s
- notice_table_test: one row per AppEvent variant through noticeFor.
- FlakyProxy (core/testing.dart): pass, blackhole (half-open), delay,
  hold, cut after N bytes, reset; demo tests against today's server.
- Liveness (stale/drop/ping) on the engine clock, so grace can be tested
  with a fake clock; Connection takes an injectable clock.
- AppDirs with STARSYNC_PROFILE=<name>: separate data and logs, so two
  instances run on one machine.
- DEVELOPMENT: scenario list per restructure step; test tags follow the
  new step order.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 20:05:52 +03:00
foxyzandClaude Opus 5.5 eac45746ce Restructure step 0: Connection extracted from the coordinator
ci / core (push) Successful in 27s
ci / updater-windows (push) Successful in 6m3s
ci / server (push) Successful in 1m6s
Connection owns the socket lifecycle, connect/dial with epochs,
reconnect, the clock offset, diagnostics upload and a sealed ConnState
signal; the hello is built by an injectable builder, the resume token
stays private, and the reconnect delay and silence limit are parameters.
The coordinator maps ConnState to the same health texts and keeps the
reconnect restore and drop handling (step 1 replaces them).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 19:52:49 +03:00
foxyzandClaude Opus 5.5 a52133b313 Roadmap: second review applied (rename before step 1, drops keep the session, pipeline before what's next, release step, AppGraph root, protocol 16 additions)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 19:44:17 +03:00
foxyzandClaude Opus 5.5 a1b270fd4e Restructure step 0: test safety net, AppEvent/Notifier seam, platform ports, protocol 16 planned
ci / core (push) Successful in 29s
ci / server (push) Successful in 1m9s
ci / updater-windows (push) Successful in 5m58s
- Tests: the four known failures fixed (test-side causes), one duplicate
  file and four detail-pinning cases removed, every test that pins
  behaviour the restructure changes tagged with its step.
- Every toast, sound and activity-log line goes through Notifier.notify
  (AppEvent), mapped one-to-one to today's behaviour.
- Platform ports (shell, URLs, folders, link resolver, system
  notifications) with desktop implementations; window_manager, Process
  and file_picker now live only in platform/desktop/.
- ROADMAP: protocol 16 planned; app id dev.foxyz.starsync.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 19:24:11 +03:00
foxyzandClaude Opus 5.5 7e98ebd1db Roadmap: no Windows dev builds; Windows friends move at 1.3.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 19:10:19 +03:00
foxyzandClaude Opus 5.5 0b0c67acb7 Roadmap: how the restructure runs (incremental on main, signals, 1.3.0-dev), rooms out, Windows dev builds in step 0, rename as its own step
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 19:07:43 +03:00
foxyzandClaude Opus 5.5 622df58295 Roadmap: ws allowed, wss preferred; test audit in step 0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 18:57:24 +03:00
foxyzandClaude Opus 5.5 df6f6ef893 Roadmap: joiners don't pause (D2), auto sit-out, queue in memory, rooms and starting together open, wss with pinned self-signed certificates, Windows dev builds (P35)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 18:45:14 +03:00
foxyzandClaude Opus 5.5 ea607c1a52 Stabilisation quick fixes: orderly close, visible version and update band, chat pop-ups on, Windows shaders and hwdec, render logs
ci / core (push) Successful in 27s
ci / server (push) Successful in 1m4s
ci / updater-windows (push) Successful in 5m50s
- Close: hide the window, dispose every player, thumbnail job and the
  sound engine, leave, then close normally; Linux runner _exit (the
  NVIDIA EGL exit handler crashed 10/10 closes, 0/41 now); Windows tears
  down inside the message loop, then TerminateProcess; watchdogs.
- Version under the Settings tab; an update band instead of a dot,
  never over a video; checks at start and every 4 hours.
- Chat pop-ups default to All again.
- Shader read-back compared as a list (old mpv joins with ','), so
  Windows stops turning shaders off; Windows hwdec auto-copy.
- Render diagnostics (`mpv: render` lines); RAM-only demuxer cache pin
  silences the file-cache warning; a volume readout test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 18:01:44 +03:00
foxyzandClaude Opus 5.5 e537f9d5f8 Roadmap: restructure revised after review (three-way truth, step 0 seams, proto 16 once, your server and media identity earlier) and open decisions
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 17:50:11 +03:00
foxyzandClaude Opus 5.5 cf59251449 Roadmap: P32 is mostly a Windows/others report
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 17:46:08 +03:00
foxyzandClaude Opus 5.5 ef562342e2 Roadmap: P34 test library
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 17:42:42 +03:00
foxyzandClaude Opus 5.5 3d81342725 Roadmap: stabilisation section with the agreed decisions and the numbered problem list
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 17:30:59 +03:00
foxyzandClaude Opus 5.5 b949951ef9 Sounds play on flutter_soloud instead of three mpv players
ci / core (push) Successful in 27s
ci / server (push) Successful in 1m3s
ci / updater-windows (push) Successful in 10m15s
SoundEngine (platform/audio) opens the device once (2 s after start,
or on the first sound), loads each synthesized sound into memory (no
temp files), plays overlapping voices and is disposed on window close,
before leaving the server. The SfxService interface and its callers
are unchanged; loudness matches the old mpv path (v^6). WAV only, so
no codec libraries are bundled and no build packages are needed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 17:18:39 +03:00
foxyzandClaude Opus 5.5 fd8064611a Player controls: one model, two skins (Standard, Minimal); conventional layout; controls settings
PlayerControlsModel holds the controls' state and actions once; the
Standard skin (our own, no more media_kit controls or private-state
reach-ins) and Minimal (the former uosc design, made the app's own and
credited) only draw it. One layout spec for both: play first, then
previous/next, volume, time; subtitles, audio, settings, chat,
fullscreen last; everything else in the settings menu. Settings ->
Player -> Controls: time display, size, hide delay, optional buttons.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 17:03:15 +03:00
foxyzandClaude Opus 5.5 959eed38a4 CI: skip the Windows job on Release commits (release.yml needs the one Windows runner)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 02:05:06 +03:00
222 changed files with 13712 additions and 6495 deletions

No files matched your search

+5 -2
View File
@@ -35,14 +35,17 @@ jobs:
- run: dart test - run: dart test
working-directory: packages/server working-directory: packages/server
# Compiling the dedicated server proves it stays Flutter-free. # Compiling the dedicated server proves it stays Flutter-free.
- run: mkdir -p build && dart compile exe bin/server.dart -o build/watch-together-server - run: mkdir -p build && dart compile exe bin/server.dart -o build/starsync-server
working-directory: packages/server working-directory: packages/server
- run: build/watch-together-server --help - run: build/starsync-server --help
working-directory: packages/server working-directory: packages/server
# The updater's Windows apply path (running exe parked as .old) only # The updater's Windows apply path (running exe parked as .old) only
# means something on real NTFS. Runner notes: see release.yml. # means something on real NTFS. Runner notes: see release.yml.
updater-windows: updater-windows:
# Not for a "Release v…" commit: release.yml builds it on the same
# (only) Windows runner, which this job would hold up.
if: ${{ !startsWith(github.event.head_commit.message, 'Release v') }}
runs-on: windows-latest runs-on: windows-latest
steps: steps:
- name: Checkout - name: Checkout
+2 -2
View File
@@ -38,7 +38,7 @@ jobs:
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }} RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
run: | run: |
VER="${GITHUB_REF_NAME#v}" VER="${GITHUB_REF_NAME#v}"
dart tool/release_publish.dart "$GITHUB_REF_NAME" "dist/watch-together-$VER-linux-x64.tar.gz" dart tool/release_publish.dart "$GITHUB_REF_NAME" "dist/starsync-$VER-linux-x64.tar.gz"
windows: windows:
# yuuka-windows: host-mode runner (no containers), runs as SYSTEM, has # yuuka-windows: host-mode runner (no containers), runs as SYSTEM, has
@@ -73,5 +73,5 @@ jobs:
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }} RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
run: | run: |
$Ver = $env:GITHUB_REF_NAME.TrimStart('v') $Ver = $env:GITHUB_REF_NAME.TrimStart('v')
dart tool/release_publish.dart $env:GITHUB_REF_NAME "dist/watch-together-$Ver-windows-x64.zip" dart tool/release_publish.dart $env:GITHUB_REF_NAME "dist/starsync-$Ver-windows-x64.zip"
if ($LASTEXITCODE) { exit 1 } if ($LASTEXITCODE) { exit 1 }
+109 -59
View File
@@ -11,9 +11,9 @@ everyone's play, pause and seek stay together.
Why not Jellyfin SyncPlay: it runs in the browser and its sync is poor. Why not Jellyfin SyncPlay: it runs in the browser and its sync is poor.
This is a desktop app that drives mpv. This is a desktop app that drives mpv.
Name: `watch-together` is a placeholder and the final name is undecided Up to 1.2.x the app was called `watch_together` (its files
(`sync-relay` is the working name). It appears in file names and the `watch-together-*`); the repository and the update manifest keep their old
update manifest until that is settled. addresses for now.
- Desktop only (Linux and Windows), always mpv. No web clients. - Desktop only (Linux and Windows), always mpv. No web clients.
- No transcoding. Everyone plays the original file. - No transcoding. Everyone plays the original file.
@@ -25,19 +25,33 @@ How it fits together: [docs/HOW-IT-WORKS.md](docs/HOW-IT-WORKS.md).
From the Releases page of this repo: From the Releases page of this repo:
- Linux: `watch-together-<version>-linux-x64.tar.gz`. Extract, run - Linux: `starsync-<version>-linux-x64.tar.gz`. Extract, run
`watch_together`. On start it adds itself to your app launcher (and its `starsync`. On start it adds itself to your app launcher (and its
icon), so the window shows its own icon on Wayland. Needs mpv's library installed (`mpv` or `libmpv2`). icon), so the window shows its own icon on Wayland. Needs mpv's library installed (`mpv` or `libmpv2`).
- Windows: `watch-together-<version>-windows-x64.zip`. Extract, run - Windows: `starsync-<version>-windows-x64.zip`. Extract, run
`watch_together.exe`. mpv is bundled. New and not yet tried on a real `starsync.exe`. mpv is bundled. New and not yet tried on a real
Windows desktop; reports welcome. Windows desktop; reports welcome.
Update from the app: when a newer version exists, an update indicator The folder also holds `watch_together(.exe)` and
appears in the strip at the top of the window and opens the update card. "Update and restart" downloads, `watch-together-server(.exe)`, the names up to 1.2.x: they start the new
verifies, applies in place and restarts; the install folder must be writable programs, so an update from 1.2.x restarts fine and old shortcuts and
by you. Settings has Check now and an auto-check toggle. From a terminal: service files keep working.
`./watch-together-server --upgrade` (no display needed, so it works on a
server box too), `watch_together --upgrade`, or the shipped `update.sh` / Your data (settings, sign-ins, folders, logs, yt-dlp and its cookies) lives
in `~/.local/share/dev.foxyz.starsync` on Linux and
`%APPDATA%\foxyz\Starsync` on Windows. The first start after 1.2.x copies
it over from the old places (`~/.local/share/moe.senkochan.watch_together`
or `%APPDATA%\com.example\watch_together`, and the app's files in your
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
under the Settings tab. 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` /
`update.bat` (which run the first). Each updates the whole folder, app and `update.bat` (which run the first). Each updates the whole folder, app and
server; restart whichever is running to use the new version. A server; restart whichever is running to use the new version. A
release with a newer protocol is a required update: everyone on a server release with a newer protocol is a required update: everyone on a server
@@ -67,11 +81,13 @@ settings), the Library / Schedule / Settings tabs, and the party. Offline
the party side has Join a friend and Host; watching solo it says Solo, the party side has Join a friend and Host; watching solo it says Solo,
with Invite friends and Stop; in a server it shows everyone's avatar with Invite friends and Stop; in a server it shows everyone's avatar
with a ring for their state (green ready, amber buffering, grey not ready, with a ring for their state (green ready, amber buffering, grey not ready,
dimmed reconnecting, dimmed with a moon sitting out; a crown on whoever a little faded joining, dimmed reconnecting, dimmed with a moon sitting
out; a crown on whoever
hosts, a tower on whoever passes the video on). Click the avatars for the hosts, a tower on whoever passes the video on). Click the avatars for the
member list (force ready, Nudge, Sit out, "Copy invite address"; when you host it lists this computer's member list (force ready, Nudge, Sit out, "Copy invite address"; when you host it lists this computer's
network addresses with the port, and friends outside your network need network addresses with the port, and friends outside your network need
that port forwarded or a server they can all reach, like a VPS). Beside them the server and "ready n/m", the chat button with unread that port forwarded or a server they can all reach, like a VPS). Beside them the server and "ready n/m" (who is sitting out or
reconnecting does not count), the chat button with unread
count, and Leave. Empty strip space drags the window. count, and Leave. Empty strip space drags the window.
The player fills the window under the strip. Pick a tab and it shrinks to The player fills the window under the strip. Pick a tab and it shrinks to
@@ -93,8 +109,8 @@ right-click for Copy message and, with a link in it, Copy link / Open in
browser), and its "Activity" toggle adds browser), and its "Activity" toggle adds
joins, ready, seeks and the like between them. Joins, leaves, chat and joins, ready, seeks and the like between them. Joins, leaves, chat and
other news also pop up bottom left with the person's avatar; click a message to open the chat other news also pop up bottom left with the person's avatar; click a message to open the chat
(Settings → Notifications → Chat messages: Only when paused by default, (Settings → Notifications → Chat messages: All by default, or Only when
or All, or Off; messages always reach the chat). Unread chat shows as a count on the chat paused, or Off; messages always reach the chat, with their sound). Unread chat shows as a count on the chat
buttons and, in fullscreen with the controls hidden and the video paused, buttons and, in fullscreen with the controls hidden and the video paused,
as a small chip top right. as a small chip top right.
@@ -106,8 +122,25 @@ row in the member list, or × on the mini player) unloads the video on your
end, and nobody waits for you any more; you stay in chat. Anyone can sit end, and nobody waits for you any more; you stay in chat. Anyone can sit
anyone out, the host too ("Alice sat you out" tells you who). The player anyone out, the host too ("Alice sat you out" tells you who). The player
then shows the cover, title and where the others are; Join back (or Space) then shows the cover, title and where the others are; Join back (or Space)
loads the video there and readies you. Settings → Party & network → "When idle, loads the video there and readies you, in the background while the others
sit out instead of just unready" does it after 10 idle minutes. play on (nobody is paused for you).
**Auto sit-out.** Idle for 5 minutes while nothing plays, or the window
minimised for 10 minutes (playing or not), sits you out by itself, so
nobody waits for you; you are back as soon as you return (a key, the
mouse, the window's focus). It never happens when you are alone, and an
away you chose (or someone else's) stays until you join back. Settings →
Party & network → "Auto sit-out" turns it off.
**Reconnects.** A bad connection does not throw you out: the video keeps
playing, the pill says "Connection unstable…" and then "Reconnecting…",
and the app comes back on its own as the same member (ready, sitting out,
your Up next items and the video you pass on all kept) for up to 10
minutes; the others see you dimmed, "reconnecting…". Only a drop of a
few seconds or more makes the lost / back sounds. Leave and closing the
app tell the others at once; Update and restart keeps your place for the
restarted app. Opening Starsync a second time (another window with the
same profile) takes your place over, and the first one says so and stops.
**Nudge.** Nudge in the member list pokes someone, sitting out or not: **Nudge.** Nudge in the member list pokes someone, sitting out or not:
only they hear it (a sound, a pop-up, and the taskbar entry asks for only they hear it (a sound, a pop-up, and the taskbar entry asks for
@@ -182,7 +215,7 @@ everyone has its start before it goes up; whoever added it keeps the
app open for that. Everyone shares one Up next and anyone can change it; what app open for that. Everyone shares one Up next and anyone can change it; what
you added stays when you leave. you added stays when you leave.
In the player, Up next sits in the top bar next to the chat button: the In the player, Up next sits in the top bar, right of the title: the
next item's picture and title (or the next episode's). Rest the mouse on next item's picture and title (or the next episode's). Rest the mouse on
it for a larger preview (who added it, its length, whether it is ready it for a larger preview (who added it, its length, whether it is ready
to start); ⏭ plays it now, a click opens the queue: drag rows to reorder, hover a to start); ⏭ plays it now, a click opens the queue: drag rows to reorder, hover a
@@ -219,11 +252,11 @@ cover; one you started shows how far you got.
## Dedicated server ## Dedicated server
For an always-on box (no display needed): the release tarball also contains For an always-on box (no display needed): the release tarball also contains
`watch-together-server`, a small standalone server without the app. Run it, `starsync-server`, a small standalone server without the app. Run it,
then everyone (you included) uses Join. then everyone (you included) uses Join.
``` ```
./watch-together-server --port 8080 --secret <password> ./starsync-server --port 8080 --secret <password>
``` ```
| Flag | Default | Meaning | | Flag | Default | Meaning |
@@ -243,9 +276,11 @@ then everyone (you included) uses Join.
It prints `READY port=... name=...` once listening and stops cleanly on It prints `READY port=... name=...` once listening and stops cleanly on
Ctrl+C or SIGTERM. A dedicated server has no host. Ctrl+C or SIGTERM. A dedicated server has no host.
To run it as a service, unpack the tarball to `/opt/watch-together` and use To run it as a service, unpack the tarball to `/opt/starsync` and use
[deploy/watch-together.service](deploy/watch-together.service) (copy it to [deploy/starsync.service](deploy/starsync.service) (copy it to
`/etc/systemd/system/`, edit the flags, `systemctl enable --now watch-together`). `/etc/systemd/system/`, edit the flags, `systemctl enable --now starsync`).
A service set up before the rename (`/opt/watch-together`, running
`watch-together-server`) keeps working as it is.
Something not playing? Settings → About → **Copy diagnostics** puts the Something not playing? Settings → About → **Copy diagnostics** puts the
app's recent log (passwords, tokens and cookies left out) on the app's recent log (passwords, tokens and cookies left out) on the
@@ -282,16 +317,22 @@ integrations (linked Jellyfin servers, below, the AnimeFlow connection:
"connected as <name>" with what you may do, or why not, and playing the "connected as <name>" with what you may do, or why not, and playing the
next episode automatically); Notifications & sounds (what pops up; the sound next episode automatically); Notifications & sounds (what pops up; the sound
volume and sound set, and every sound with a preview, its own set and a switch); Updates; Keyboard shortcuts (read-only); About volume and sound set, and every sound with a preview, its own set and a switch); Updates; Keyboard shortcuts (read-only); About
(the version; resting the mouse on the Settings tab shows it too). (the version; resting the mouse on the Settings tab shows it too; and the
credits, e.g. that the Minimal player design is inspired by uosc).
Player settings live in the player: ⚙ at the right end of its controls Player settings live in the player too: ⚙ → Player settings, at the
(both designs) opens them, and Settings → Player has the same ones (plus right of its controls, and Settings → Player has the same ones (plus the
the custom shader files). Player design: Standard or uosc (below); controls' layout and the custom shader files). Player design: Standard or
progress line when hidden (on by default: a 2 px line at the bottom of Minimal (below); progress line when hidden (on by default: a thin line at
the video while the controls are hidden); upscaling shader (see below); the bottom of the video while the controls are hidden); upscaling shader
hardware decoding (auto, off or custom; the env var `SYNC_RELAY_HWDEC` (see below); hardware decoding (auto, off or custom; auto is mpv's
overrides it); in the player also the subtitle and audio track. Every `auto-copy` on Windows, `auto` elsewhere; the env var
change applies right away. `SYNC_RELAY_HWDEC` overrides it); in the player also the subtitle and
audio track. 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
applies right away.
Anywhere in a server (unless you are typing): Space = ready / unready, Anywhere in a server (unless you are typing): Space = ready / unready,
Tab = chat & activity (Tab never moves between buttons). Anywhere: Tab = chat & activity (Tab never moves between buttons). Anywhere:
@@ -304,28 +345,37 @@ mini it asks to leave, Ctrl+R = reload video,
Ctrl+1..6 = Anime4K mode A, B, C, A+A, B+B, C+A, Ctrl+0 = shaders off. Ctrl+1..6 = Anime4K mode A, B, C, A+A, B+B, C+A, Ctrl+0 = shaders off.
Every seek, skip and chapter jump moves everyone. Every seek, skip and chapter jump moves everyone.
Controls: the seek bar marks chapter starts with a diamond; hovering it Controls: both designs have the same buttons in the same places, only
shows the time and the chapter title. The chapters button lists them the look differs. Bottom left: play / pause first (it is your ready, like
(current one checked; click to jump). "1:30" skips ahead 1:30. Audio and Space: ✓ while you are ready and the others are not), previous / next
Subtitles buttons (with the track count) list tracks as "Japanese · E-AC3 episode (when there is one; next plays Up next first), previous / next
2.0 · title"; subtitles have Off. Volume (scroll anywhere on the video, chapter (when the video has chapters), volume and the time (click it to
hover the speaker, or Up/Down) goes to 150 % like mpv: past the tick at switch between elapsed / total and the time left). Bottom right:
100 % the bar turns amber and mpv amplifies; a scroll or key shows the Subtitles and Audio (with the track count; tracks listed as "Japanese ·
volume for a moment. Lists and menus under the mouse scroll as usual. E-AC3 2.0 · title", subtitles with Off), ⚙, chat and fullscreen last.
Everything else is in ⚙: chapters (current one checked; click to jump),
Skip 1:30, upscaling shader, change video, copy link, reload video,
mark everyone ready, mark a problem, player settings and leave. The top
bar has the title, Up next and back to the library (in fullscreen: exit
fullscreen and the window buttons). Volume (scroll anywhere on the
video, hover the speaker, or Up/Down) goes to 150 % like mpv: past the
tick at 100 % the bar turns amber and mpv amplifies; a scroll or key
shows the volume for a moment. Lists and menus under the mouse scroll
as usual.
The uosc player design (⚙ → Player design, Standard by default, Standard (the default): a seek bar that marks chapter starts with a
switches right away): video controls modeled on mpv's uosc theme. A diamond (hovering it shows the time and the chapter title) over a row
translucent timeline across the bottom marks the position with a thin of round buttons. Minimal (⚙ → Player settings → Player design, switches
line, hatches the part not downloaded yet, shows chapter starts as right away): a translucent timeline across the bottom tints what has
diamonds (click one to jump), the elapsed time left, the time remaining played, stripes the part not downloaded yet, marks chapter starts with
right and, while less than a minute is buffered, the buffered seconds dots (click one to jump), shows the elapsed time left and the total or
next to the position; hovering it shows the time there and the chapter the time left right (click it to switch) and, while less than a minute
title. With the controls hidden it stays as a thin line (unless is buffered, the buffered seconds next to the position; hovering it shows
"Progress line when hidden" is off). Above it flat buttons: ≡ menu, Subtitles and Audio (with the the time there and the chapter title. With the controls hidden it stays
track count), the shader badge (the active Anime4K mode, e.g. "A HQ"), as a thin line (unless "Progress line when hidden" is off). Its buttons
ready, chapters, 1:30, previous / next episode, change video, volume, ⚙ sit in two glass capsules above it. Minimal's timeline and controls are
and fullscreen; the title top left, Up next, chat and back-to-library top right. Keys and skip buttons work inspired by [uosc](https://github.com/tomasklaen/uosc) by tomasklaen
the same. (LGPL-2.1).
Skipping: during the opening a "Skip OP" button shows bottom right, just Skipping: during the opening a "Skip OP" button shows bottom right, just
above the controls (it above the controls (it
@@ -395,7 +445,7 @@ Watching:
"No Jellyfin account here for ..." with a button to link one; it plays as "No Jellyfin account here for ..." with a button to link one; it plays as
soon as you are signed in. soon as you are signed in.
Episodes: the ⏮ / ⏭ buttons next to the controls (or `<` / `>`, Episodes: the ⏮ / ⏭ buttons next to play (or `<` / `>`,
Shift+P / Shift+N) put up the previous or next episode for everyone. From Shift+P / Shift+N) put up the previous or next episode for everyone. From
the ending (when it is known, see Skipping above; else in the last two the ending (when it is known, see Skipping above; else in the last two
minutes) a "Next episode" button floats in the corner while the controls minutes) a "Next episode" button floats in the corner while the controls
@@ -411,7 +461,7 @@ Your choice is remembered per series (and as a default) and the closest
match (language, then title, then codec and channels) is selected on the match (language, then title, then codec and channels) is selected on the
next episode. This is per person; nobody else is affected. next episode. This is per person; nobody else is affected.
"Change video" in the player's controls opens the Library tab (the player ⚙ → Change video in the player opens the Library tab (the player
goes mini): the library starts on the series you are watching with the goes mini): the library starts on the series you are watching with the
current season, else where you left it. current season, else where you left it.
@@ -467,9 +517,9 @@ affected. The app ships [Anime4K](https://github.com/bloc97/Anime4K) v4.0.1
C (480p), A+A, B+B, C+A, each as HQ (strong GPUs, GTX 1080 and up) or Fast C (480p), A+A, B+B, C+A, each as HQ (strong GPUs, GTX 1080 and up) or Fast
(the guide's low-end set). "Custom shaders" runs your own `.glsl` files. (the guide's low-end set). "Custom shaders" runs your own `.glsl` files.
Pick one with the ✨ button in the player's control bar (also in Pick one with ⚙ → Upscaling shader in the player (also in
fullscreen) or Ctrl+0..6; the change applies live and is saved, so the next fullscreen) or Ctrl+0..6; the change applies live and is saved, so the next
session starts with it. The player's ⚙ and Settings → Player set it too, session starts with it. ⚙ → Player settings and Settings → Player set it too,
with HQ or Fast (used by Ctrl+1..6); Settings → Player holds the custom with HQ or Fast (used by Ctrl+1..6); Settings → Player holds the custom
files. Shaders cost GPU time files. Shaders cost GPU time
every frame and need hardware video rendering: on media_kit's software every frame and need hardware video rendering: on media_kit's software
+27 -46
View File
@@ -5,17 +5,17 @@ import 'package:flutter/material.dart';
import 'package:hive_ce_flutter/hive_flutter.dart'; import 'package:hive_ce_flutter/hive_flutter.dart';
import 'package:media_kit/media_kit.dart'; import 'package:media_kit/media_kit.dart';
import 'package:sync_relay_core/core.dart'; import 'package:sync_relay_core/core.dart';
import 'package:window_manager/window_manager.dart';
import 'app.dart'; import 'app.dart';
import 'src/playback/yt_dlp_app.dart';
import 'src/platform/app_log.dart'; import 'src/platform/app_log.dart';
import 'src/platform/app_shutdown.dart';
import 'src/platform/desktop/desktop_app_dirs.dart';
import 'src/platform/desktop_entry.dart'; import 'src/platform/desktop_entry.dart';
import 'src/platform/window_memory.dart';
import 'src/screens/home/ui.dart'; import 'src/screens/home/ui.dart';
import 'src/session/app_events.dart' show UpdateAvailable;
import 'src/state/session_state.dart'; import 'src/state/session_state.dart';
import 'src/state/settings.dart'; import 'src/state/settings.dart';
import 'src/update/update_controller.dart' show launchArgs; import 'src/update/update_controller.dart' show UpdateController, UpdatePhase, launchArgs;
Future<void> main(List<String> args) async { Future<void> main(List<String> args) async {
launchArgs = List.unmodifiable(args); launchArgs = List.unmodifiable(args);
@@ -42,26 +42,18 @@ Future<void> main(List<String> args) async {
), ),
); );
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
await startAppLogging(); final dirs = await const DesktopAppDirs().resolve();
await Hive.initFlutter(); await startAppLogging(dirs);
Hive.init(dirs.hive);
await AppSettings.instance.ensureLoaded(); await AppSettings.instance.ensureLoaded();
await windowManager.ensureInitialized(); // Close button / Alt+F4: the orderly close ([shutDownApp]), so others
await windowManager.setPreventClose(true); // see "left", a hosted server shuts down and no player outlives the
final memory = WindowMemory(Hive.isBoxOpen(settingsBoxName) ? Hive.box(settingsBoxName) : null); // engine.
windowManager.addListener(_CloseGuard(memory)); final ports = SessionState.instance.ports;
final windowOptions = WindowOptions( await ports.shell.start(
size: memory.size, title: dirs.profile == null ? appName : '$appName [${dirs.profile}]', onCloseRequest: shutDownApp);
minimumSize: const Size(160, 90), // Focus, minimise and restore feed auto sit-out.
center: !memory.hasPosition, ports.shell.watchActivity(SessionState.instance.coordinator.windowActivity);
backgroundColor: const Color(0xFF000000),
titleBarStyle: TitleBarStyle.hidden,
title: appName,
);
windowManager.waitUntilReadyToShow(windowOptions, () async {
await memory.restore();
await windowManager.show();
await windowManager.focus();
});
try { try {
await SessionState.instance.loadForm(); await SessionState.instance.loadForm();
} catch (e, st) { } catch (e, st) {
@@ -69,30 +61,19 @@ Future<void> main(List<String> args) async {
} }
MediaKit.ensureInitialized(); MediaKit.ensureInitialized();
// yt-dlp for web page links: downloaded on first run, kept current. // yt-dlp for web page links: downloaded on first run, kept current.
AppYtDlp.instance.warmUp(); ports.links.warmUp();
// Linux: the launcher entry and icon Wayland needs (release installs). // Linux: the launcher entry and icon Wayland needs (release installs).
unawaited(installDesktopEntry()); unawaited(installDesktopEntry());
// A found update is an event too (the band shows it).
final updates = UpdateController.instance;
updates.status.addListener(() {
final s = updates.status.value;
final version = s.manifest?.version;
if (s.phase == UpdatePhase.available && version != null) {
SessionState.instance.notifier.notify(UpdateAvailable(version));
}
});
runApp(const WatchTogetherApp()); runApp(const WatchTogetherApp());
} // The sound device opens once the window is up, not on the first sound.
Future.delayed(const Duration(seconds: 2), SessionState.instance.sfx.warmUp);
/// Close button / Alt+F4: leave gracefully first (others see "left", a
/// hosted server shuts down), bounded so a hung socket can't keep the
/// window open.
class _CloseGuard with WindowListener {
final WindowMemory memory;
bool _closing = false;
_CloseGuard(this.memory);
@override
Future<void> onWindowClose() async {
if (_closing) return;
_closing = true;
await memory.save();
try {
await SessionState.instance.leave().timeout(const Duration(seconds: 2));
} catch (e) {
debugPrint('leave on close failed: $e');
}
await windowManager.destroy();
}
} }
@@ -0,0 +1,136 @@
import 'package:flutter/material.dart';
import 'package:sync_relay_core/core.dart' show JellyfinStreamType;
import '../jellyfin/track_prefs.dart' show MpvTrack;
import '../playback/media_marks.dart';
import '../shaders/shader_menu.dart';
import '../shell/mark_problem.dart';
import '../state/settings.dart';
import '../widgets/app_menu.dart';
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
// button that opens them. Each keeps the controls up while open.
/// How many tracks of [type] there are.
int trackCount(List<MpvTrack> tracks, JellyfinStreamType type) => tracks.where((t) => t.type == type).length;
/// The audio or subtitle tracks ("Japanese · E-AC3 2.0 · title"), mpv's
/// selection checked; subtitles start with Off.
List<AppMenuItem<TrackPick>> trackMenuItems(JellyfinStreamType type, List<MpvTrack> tracks) {
final audio = type == JellyfinStreamType.audio;
final list = tracks.where((t) => t.type == type).toList();
return [
if (!audio)
AppMenuItem(
key: const ValueKey('track-off'),
value: (aid: null, sid: null, subtitlesOff: true),
label: 'Off',
checked: !list.any((t) => t.selected),
),
if (audio && list.isEmpty)
const AppMenuItem(value: (aid: null, sid: null, subtitlesOff: false), label: 'None', enabled: false),
for (final t in list)
AppMenuItem(
key: ValueKey('track-${t.type.name}-${t.id}'),
value: audio ? (aid: t.id, sid: null, subtitlesOff: false) : (aid: null, sid: t.id, subtitlesOff: false),
label: t.label,
checked: t.selected,
),
];
}
/// Chapters with their start times, the playing one checked.
List<AppMenuItem<MediaChapter>> chapterMenuItems(List<MediaChapter> chapters, int? current) => [
for (final c in chapters)
AppMenuItem(
key: ValueKey('chapter-${c.index}'),
value: c,
label: c.title.isEmpty ? 'Chapter ${c.index + 1}' : c.title,
hint: formatClock(c.start),
checked: c.index == current,
),
];
Future<T?> _menu<T>(BuildContext context, PlayerControlsModel model, List<AppMenuItem<T>> items) async {
model.menusOpen++;
try {
return await showAppMenu<T>(context, items: items);
} finally {
model.menusOpen--;
}
}
/// The audio or subtitle list; a pick is applied (and remembered).
Future<void> openTrackMenu(BuildContext context, PlayerControlsModel model, JellyfinStreamType type) async {
final pick = await _menu(context, model, trackMenuItems(type, model.tracks.peek()));
if (pick != null) model.pickTrack(pick);
}
/// The chapter list; a pick jumps there for everyone.
Future<void> openChapterMenu(BuildContext context, PlayerControlsModel model) async {
final pick = await _menu(context, model, chapterMenuItems(model.chapters.peek(), model.currentChapter.peek()));
if (pick != null) model.pickChapter(pick);
}
/// The ⚙ menu: every action that is not a bar button.
Future<void> openControlsMenu(BuildContext context, PlayerControlsModel model) async {
final s = AppSettings.instance;
final shader = shaderShortLabel(s.shaderPreset.peek(), s.shaderQuality.peek()) ?? 'Off';
final pick = await _menu(context, model, [
for (final a in model.menu())
AppMenuItem(
key: ValueKey('controls-menu-${a.name}'),
value: a,
label: a.label,
hint: a == ControlAction.shaders ? shader : a.hint,
),
]);
if (pick != null && context.mounted) await runControlAction(context, model, pick);
}
/// Runs a ⚙ menu action; [context] anchors what it opens next.
Future<void> runControlAction(BuildContext context, PlayerControlsModel model, ControlAction action) async {
switch (action) {
case ControlAction.chapters:
await openChapterMenu(context, model);
case ControlAction.subtitles:
await openTrackMenu(context, model, JellyfinStreamType.subtitle);
case ControlAction.audio:
await openTrackMenu(context, model, JellyfinStreamType.audio);
case ControlAction.skipForward:
model.skipForward();
case ControlAction.shaders:
model.menusOpen++;
try {
await ControlsFrame.holdWhile(
context,
() => showShaderMenu(context, renderMode: model.renderMode, onPick: model.pickShader),
);
} finally {
model.menusOpen--;
}
case ControlAction.chat:
model.toggleChat();
case ControlAction.changeVideo:
model.changeVideo();
case ControlAction.copyLink:
final link = model.link();
if (link != null) await copyToClipboard(link, done: 'Link copied');
case ControlAction.reload:
model.reload();
case ControlAction.markAllReady:
model.markAllReady();
case ControlAction.markProblem:
await ControlsFrame.holdWhile(context, () => showMarkProblem(context));
case ControlAction.playerSettings:
await showPlayerSettings(context, renderMode: model.renderMode);
case ControlAction.leave:
model.leave();
}
}
@@ -6,8 +6,7 @@ import 'package:flutter/material.dart';
/// mouse on a floating element over the picture (a pill, a card, the /// mouse on a floating element over the picture (a pill, a card, the
/// status pill: [ControlsHoldRegion]) or an open popover. While anything /// status pill: [ControlsHoldRegion]) or an open popover. While anything
/// holds, the controls stay up; on release they hide on their usual /// holds, the controls stay up; on release they hide on their usual
/// countdown. Both designs follow it ([ControlsHoverProbe] with /// countdown. Both skins follow it (through `ControlsFrame`).
/// media_kit's controls, `UoscControls`).
class ControlsHold extends ChangeNotifier { class ControlsHold extends ChangeNotifier {
final _by = <Object>{}; final _by = <Object>{};
bool _told = false; bool _told = false;
@@ -74,109 +73,3 @@ class _ControlsHoldRegionState extends State<ControlsHoldRegion> {
); );
} }
} }
/// Follows whether media_kit's desktop controls around [child] are up,
/// by the same rule they use: up on mount and on any mouse movement,
/// down [hideAfter] later or when the mouse leaves. media_kit does not
/// expose its visibility; this lets the player draw a progress line while
/// they are down. With [hold] held it keeps them (and itself) up. Never
/// takes input.
class ControlsHoverProbe extends StatefulWidget {
final ValueNotifier<bool> shown;
final Duration hideAfter;
final ControlsHold? hold;
final Widget child;
const ControlsHoverProbe(
{super.key, required this.shown, required this.hideAfter, this.hold, required this.child});
@override
State<ControlsHoverProbe> createState() => _ControlsHoverProbeState();
}
class _ControlsHoverProbeState extends State<ControlsHoverProbe> {
Timer? _timer;
// While held: media_kit's own countdown is restarted before it ends.
Timer? _keep;
bool get _held => widget.hold?.held ?? false;
@override
void initState() {
super.initState();
widget.hold?.addListener(_holdChanged);
// Not during this build: the line listening to [shown] may be built.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _wake();
});
}
@override
void didUpdateWidget(ControlsHoverProbe oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.hold != widget.hold) {
oldWidget.hold?.removeListener(_holdChanged);
widget.hold?.addListener(_holdChanged);
_holdChanged();
}
}
@override
void dispose() {
widget.hold?.removeListener(_holdChanged);
_timer?.cancel();
_keep?.cancel();
super.dispose();
}
void _holdChanged() {
if (!mounted) return;
_keep?.cancel();
_keep = null;
if (_held) {
_keep = Timer.periodic(widget.hideAfter * 0.5, (_) => _pokeControls());
}
_pokeControls();
_wake();
}
/// media_kit's controls state is private: a guarded dynamic call to its
/// hover handler, as a mouse movement on it would make.
void _pokeControls() {
if (!mounted) return;
void visit(Element e) {
if (e is StatefulElement && e.state.runtimeType.toString() == '_MaterialDesktopVideoControlsState') {
try {
(e.state as dynamic).onHover();
} catch (_) {}
return;
}
e.visitChildElements(visit);
}
context.visitChildElements(visit);
}
void _wake() {
widget.shown.value = true;
_timer?.cancel();
_timer = Timer(widget.hideAfter, _hide);
}
void _hide() {
_timer?.cancel();
if (!mounted || _held) return;
widget.shown.value = false;
}
@override
Widget build(BuildContext context) => MouseRegion(
opaque: false,
hitTestBehavior: HitTestBehavior.translucent,
onEnter: (_) => _wake(),
onHover: (_) => _wake(),
// Held (the mouse went onto a pill or the popover): media_kit's
// controls just hid on their own exit; bring them straight back.
onExit: (_) => _held ? _pokeControls() : _hide(),
child: widget.child,
);
}
+216
View File
@@ -0,0 +1,216 @@
import 'dart:async';
import 'package:flutter/gestures.dart' show kPrimaryMouseButton;
import 'package:flutter/material.dart';
import 'controls_probe.dart' show ControlsHold;
// The frame both control skins live in: when the controls are up, and the
// double-click on the picture. Mouse movement shows them; [hideAfter]
// 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).
class ControlsFrame extends StatefulWidget {
/// Draws the controls; [visible] false while they are hidden.
final Widget Function(BuildContext context, bool visible) builder;
final VoidCallback? onDoubleClick;
/// Bands at the top and bottom where a double-click is the bars', not
/// the picture's.
final double barTop;
final double barBottom;
final Duration hideAfter;
/// Follows whether the controls are up (for overlays outside them).
final ValueNotifier<bool>? shown;
/// Keeps them up while held.
final ControlsHold? holdBy;
const ControlsFrame({
super.key,
required this.builder,
this.onDoubleClick,
this.barTop = 0,
this.barBottom = 0,
this.hideAfter = const Duration(seconds: 3),
this.shown,
this.holdBy,
});
/// The frame around [context], for widgets that keep it up (a drag, a
/// menu).
static ControlsFrameState? maybeOf(BuildContext context) => context.findAncestorStateOfType<ControlsFrameState>();
/// Runs [open] (a menu, a popover) with the controls around [context]
/// held up, if there are any.
static Future<T> holdWhile<T>(BuildContext context, Future<T> Function() open) {
final frame = maybeOf(context);
return frame == null ? open() : frame.holdDuring(open);
}
/// The fade of the controls.
static const fade = Duration(milliseconds: 150);
@override
State<ControlsFrame> createState() => ControlsFrameState();
}
class ControlsFrameState extends State<ControlsFrame> {
bool _visible = true;
int _holds = 0;
bool _heldBy = false;
Timer? _timer;
bool get visible => _visible;
@override
void initState() {
super.initState();
widget.holdBy?.addListener(_holdByChanged);
// Not during this build: an overlay listening to [shown] may be built.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) widget.shown?.value = true;
});
_hideLater();
}
@override
void didUpdateWidget(ControlsFrame oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.holdBy != widget.holdBy) {
oldWidget.holdBy?.removeListener(_holdByChanged);
widget.holdBy?.addListener(_holdByChanged);
_holdByChanged();
}
if (oldWidget.hideAfter != widget.hideAfter && _visible) _hideLater();
}
@override
void dispose() {
widget.holdBy?.removeListener(_holdByChanged);
_timer?.cancel();
super.dispose();
}
void _holdByChanged() {
final on = widget.holdBy?.held ?? false;
if (on == _heldBy || !mounted) return;
_heldBy = on;
hold(on);
}
/// Show the controls and restart the hide countdown.
void wake() {
if (!_visible && mounted) {
setState(() => _visible = true);
widget.shown?.value = true;
}
_hideLater();
}
/// Keep the controls up while [on] (paired calls).
void hold(bool on) {
_holds = on ? _holds + 1 : (_holds - 1).clamp(0, 1 << 20);
wake();
}
/// Runs [open] (a menu) with the controls held up.
Future<T> holdDuring<T>(Future<T> Function() open) async {
hold(true);
try {
return await open();
} finally {
if (mounted) hold(false);
}
}
void _hideLater() {
_timer?.cancel();
_timer = Timer(widget.hideAfter, _hide);
}
void _hide() {
if (!mounted || _holds > 0 || !_visible) return;
setState(() => _visible = false);
widget.shown?.value = false;
}
@override
Widget build(BuildContext context) {
return MouseRegion(
cursor: _visible ? SystemMouseCursors.basic : SystemMouseCursors.none,
onEnter: (_) => wake(),
onHover: (_) => wake(),
onExit: (_) {
_timer?.cancel();
_hide();
},
child: Stack(
fit: StackFit.expand,
children: [
if (widget.onDoubleClick != null)
Positioned.fill(
child: DoubleClickListener(
onDoubleClick: widget.onDoubleClick!,
skipTop: _visible ? widget.barTop : 0,
skipBottom: _visible ? widget.barBottom : 0,
child: const ColoredBox(color: Color(0x00000000), child: SizedBox.expand()),
),
),
widget.builder(context, _visible),
],
),
);
}
}
/// Double-click on the picture (not on the bars) without joining the
/// gesture arena, so single clicks on buttons stay instant.
class DoubleClickListener extends StatefulWidget {
final VoidCallback onDoubleClick;
final double skipTop, skipBottom;
final Widget child;
const DoubleClickListener({
super.key,
required this.onDoubleClick,
this.skipTop = 0,
this.skipBottom = 0,
required this.child,
});
@override
State<DoubleClickListener> createState() => _DoubleClickListenerState();
}
class _DoubleClickListenerState extends State<DoubleClickListener> {
DateTime? _last;
Offset? _lastAt;
void _down(PointerDownEvent e) {
if (e.buttons != kPrimaryMouseButton) return;
final h = context.size?.height ?? 0;
final y = e.localPosition.dy;
if (y < widget.skipTop || y > h - widget.skipBottom) {
_last = null;
return;
}
final now = DateTime.now();
final last = _last, at = _lastAt;
if (last != null &&
at != null &&
now.difference(last) < const Duration(milliseconds: 400) &&
(e.localPosition - at).distance < 24) {
_last = null;
widget.onDoubleClick();
return;
}
_last = now;
_lastAt = e.localPosition;
}
@override
Widget build(BuildContext context) =>
Listener(behavior: HitTestBehavior.translucent, onPointerDown: _down, child: widget.child);
}
@@ -0,0 +1,250 @@
import 'package:flutter/material.dart';
import 'package:signals/signals_flutter.dart';
import 'package:sync_relay_core/core.dart' show JellyfinStreamType;
import '../../queue/queue_ui.dart';
import '../../shell/now_playing_title.dart';
import '../../state/settings.dart';
import '../control_menus.dart';
import '../controls_probe.dart' show ControlsHold;
import '../frame.dart';
import '../model/controls_layout.dart';
import '../model/controls_model.dart';
import '../model/controls_prefs.dart';
import '../volume.dart';
import 'style.dart';
import 'timeline.dart';
// The Minimal skin (inspired by mpv's uosc): a timeline band across the
// bottom that carries the clock, and above it the buttons in two glass
// capsules, in the layout's order. Top: what plays, Up next and the window
// controls. Hidden, the band shrinks to a thin progress line (unless
// switched off).
class MinimalControls extends StatelessWidget {
final PlayerControlsModel model;
final bool visible;
final double scale;
final ControlsHold? hold;
final Widget? windowButtons;
const MinimalControls({
super.key,
required this.model,
required this.visible,
this.scale = 1,
this.hold,
this.windowButtons,
});
static const _rowGap = 8.0;
/// The button row's height at [scale] (a capsule).
static double rowHeight(double scale) => MinimalStyle.button * scale + 2 * MinimalStyle.capsulePad;
/// Buttons and timeline at [scale].
static double bottomHeight(double scale) => rowHeight(scale) + _rowGap + MinimalTimeline.heightFor(scale);
/// The top bar's height.
static const topHeight = 72.0;
@override
Widget build(BuildContext context) {
const fade = ControlsFrame.fade;
Widget shown(Widget child, {Key? key}) => IgnorePointer(
key: key,
ignoring: !visible,
child: AnimatedOpacity(duration: fade, opacity: visible ? 1 : 0, child: child),
);
return Stack(
children: [
Positioned(
left: 0,
right: 0,
top: 0,
child: shown(
key: const ValueKey('minimal-top'),
DecoratedBox(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0x99000000), Color(0x00000000)],
),
),
child: SizedBox(
height: topHeight + 24,
child: Padding(padding: const EdgeInsets.fromLTRB(16, 0, 10, 24), child: _top()),
),
),
),
),
Positioned(
left: 0,
right: 0,
bottom: 0,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
shown(
key: const ValueKey('minimal-buttons'),
Padding(
padding: const EdgeInsets.fromLTRB(12, 0, 12, _rowGap),
child: SizedBox(
height: rowHeight(scale),
child: SignalBuilder(
builder: (context) {
final s = model.snapshot.value;
final layout = model.bar.value;
// The clock lives on the timeline.
List<Widget> cluster(List<BarControl> cs) => [
for (final c in cs)
if (c != BarControl.time) _control(context, c, s),
];
return Row(
children: [
MinimalCapsule(scale: scale, children: cluster(layout.left)),
const Spacer(),
MinimalCapsule(scale: scale, children: cluster(layout.right)),
],
);
},
),
),
),
),
SignalBuilder(
builder: (context) {
final line = AppSettings.instance.progressLine.value;
return AnimatedOpacity(
key: const ValueKey('minimal-timeline-wrap'),
duration: fade,
opacity: visible || line ? 1 : 0,
child: MinimalTimeline(
position: model.position.value,
duration: model.duration.value,
buffered: model.buffered.value,
chapters: model.chapters.value,
expanded: visible,
remaining: model.settings.timeDisplay.value == TimeDisplay.remaining,
scale: scale,
onSeek: model.seek,
onToggleTime: model.toggleTimeDisplay,
),
);
},
),
],
),
),
],
);
}
Widget _top() {
final m = model;
return Row(
children: [
const Expanded(
child: Align(alignment: AlignmentDirectional.centerStart, child: NowPlayingTitle()),
),
UpNextButton(queue: m.queue, onPlayNextEpisode: m.playNextEpisode, look: UpNextLook.minimal, hold: hold),
const SizedBox(width: 4),
SignalBuilder(
builder: (context) {
final full = m.fullscreen.value;
return MinimalButton(
key: const ValueKey('minimal-collapse'),
icon: full ? Icons.fullscreen_exit_rounded : Icons.close_fullscreen_rounded,
tooltip: full ? 'Exit fullscreen (Esc)' : 'Back to the library (keeps playing)',
onPressed: full ? m.toggleFullscreen : m.collapse,
);
},
),
?windowButtons,
],
);
}
Widget _control(BuildContext context, BarControl c, ControlsSnapshot s) {
final m = model;
final key = ValueKey('control-${c.name}');
final enabled = controlEnabled(c, s);
Widget button(VoidCallback? onPressed, {int? badge, bool accent = false}) => MinimalButton(
key: key,
icon: controlIcon(c, s),
tooltip: controlTooltip(c, s),
badge: badge,
accent: accent,
scale: scale,
onPressed: enabled ? onPressed : null,
);
switch (c) {
case BarControl.playPause:
return Padding(
padding: EdgeInsets.only(right: 4 * scale),
child: button(m.togglePlay, accent: true),
);
case BarControl.previous:
return button(m.previous);
case BarControl.next:
return button(m.next);
case BarControl.chapterPrevious:
return button(() => m.stepChapter(-1));
case BarControl.chapterNext:
return button(() => m.stepChapter(1));
case BarControl.volume:
return SignalBuilder(
key: key,
builder: (context) => VolumeControl(
volume: m.volume.value,
onChanged: m.setVolume,
onToggleMute: m.toggleMute,
sliderColor: Theme.of(context).colorScheme.primary,
trackColor: MinimalStyle.dim.withValues(alpha: 0.4),
button: (context, i, color, tip, onPressed) =>
MinimalButton(icon: i, tooltip: tip, color: color, scale: scale, onPressed: onPressed),
),
);
case BarControl.time:
return const SizedBox.shrink();
case BarControl.subtitles || BarControl.audio:
final type = c == BarControl.audio ? JellyfinStreamType.audio : JellyfinStreamType.subtitle;
return SignalBuilder(
key: key,
builder: (context) => Builder(
builder: (context) => MinimalButton(
icon: controlIcon(c, s),
tooltip: controlTooltip(c, s),
badge: trackCount(m.tracks.value, type),
scale: scale,
onPressed: () => openTrackMenu(context, m, type),
),
),
);
case BarControl.settings:
return Builder(
key: key,
builder: (context) => MinimalButton(
icon: controlIcon(c, s),
tooltip: controlTooltip(c, s),
scale: scale,
onPressed: () => openControlsMenu(context, m),
),
);
case BarControl.chat:
return SignalBuilder(
key: key,
builder: (context) => MinimalButton(
icon: controlIcon(c, s),
tooltip: controlTooltip(c, s),
badge: m.unread.value,
scale: scale,
onPressed: m.toggleChat,
),
);
case BarControl.fullscreen:
return button(m.toggleFullscreen);
}
}
}
@@ -0,0 +1,186 @@
import 'package:flutter/material.dart';
import '../../widgets/app_menu.dart' show AppMenuStyle;
// The Minimal skin's look: the app's glass and accent (the theme's primary)
// on a thick timeline band, with soft rounded buttons in two glass capsules.
abstract final class MinimalStyle {
/// The timeline band (no blur: it spans the width over the picture).
static const band = Color(0xCC0E0E12);
/// The capsules holding the buttons: the player pills' glass.
static const capsule = AppMenuStyle.pillGlass;
static const hover = Color(0x1FFFFFFF);
static const text = Colors.white;
static const dim = Color(0xB3FFFFFF);
static const radius = 10.0;
static const fontSize = 13.0;
/// The tooltips and the timeline's hover tip.
static const tip = Color(0xEB18181C);
/// A button's box at scale 1.
static const button = 38.0;
/// The capsules' padding around the buttons.
static const capsulePad = 4.0;
}
/// The Minimal skin's tooltip: the app's dark glass colour, above.
class MinimalTooltip extends StatelessWidget {
final String message;
final Widget child;
const MinimalTooltip({super.key, required this.message, required this.child});
@override
Widget build(BuildContext context) {
if (message.isEmpty) return child;
return Tooltip(
message: message,
preferBelow: false,
verticalOffset: 28,
waitDuration: const Duration(milliseconds: 300),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
decoration: BoxDecoration(
color: MinimalStyle.tip,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: AppMenuStyle.border, width: 0.5),
),
textStyle: const TextStyle(color: MinimalStyle.text, fontSize: 12),
child: child,
);
}
}
/// A Minimal button: a white icon on a rounded square that lights on hover
/// ([accent]: filled with the theme's primary, the play button), an
/// optional [label] and a count [badge]. Never takes focus.
class MinimalButton extends StatefulWidget {
final IconData icon;
final String tooltip;
final VoidCallback? onPressed;
final String? label;
final int? badge;
final bool accent;
final Color? color;
final double scale;
const MinimalButton({
super.key,
required this.icon,
required this.tooltip,
required this.onPressed,
this.label,
this.badge,
this.accent = false,
this.color,
this.scale = 1,
});
@override
State<MinimalButton> createState() => _MinimalButtonState();
}
class _MinimalButtonState extends State<MinimalButton> {
bool _hover = false;
@override
Widget build(BuildContext context) {
final w = widget;
final primary = Theme.of(context).colorScheme.primary;
final onPrimary = Theme.of(context).colorScheme.onPrimary;
final enabled = w.onPressed != null;
final size = MinimalStyle.button * w.scale;
final fg = !enabled
? MinimalStyle.dim.withValues(alpha: 0.35)
: w.accent
? onPrimary
: (w.color ?? MinimalStyle.text);
final bg = w.accent
? (_hover ? Color.lerp(primary, Colors.white, 0.18)! : primary)
: (_hover && enabled ? MinimalStyle.hover : Colors.transparent);
final badge = w.badge;
return MinimalTooltip(
message: w.tooltip,
child: MouseRegion(
cursor: enabled ? SystemMouseCursors.click : SystemMouseCursors.basic,
onEnter: (_) => setState(() => _hover = true),
onExit: (_) => setState(() => _hover = false),
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: w.onPressed,
child: AnimatedContainer(
duration: const Duration(milliseconds: 120),
height: size,
constraints: BoxConstraints(minWidth: size),
padding: EdgeInsets.symmetric(horizontal: w.label == null ? 0 : 10 * w.scale),
decoration: BoxDecoration(
color: bg,
borderRadius: BorderRadius.circular(w.accent ? size / 2 : MinimalStyle.radius * w.scale),
),
child: Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Stack(
clipBehavior: Clip.none,
children: [
Icon(w.icon, color: fg, size: (w.accent ? 26 : 22) * w.scale),
if (badge != null && badge > 0)
Positioned(
right: -7,
top: -5,
child: Container(
key: const ValueKey('minimal-badge'),
padding: const EdgeInsets.symmetric(horizontal: 4),
decoration: BoxDecoration(color: primary, borderRadius: BorderRadius.circular(999)),
child: Text(
'$badge',
style: TextStyle(color: onPrimary, fontSize: 9, height: 1.35, fontWeight: FontWeight.w700),
),
),
),
],
),
if (w.label != null) ...[
SizedBox(width: 6 * w.scale),
Text(
w.label!,
style: TextStyle(
color: fg,
fontSize: MinimalStyle.fontSize * w.scale,
fontFeatures: const [FontFeature.tabularFigures()],
),
),
],
],
),
),
),
),
);
}
}
/// A glass capsule around a row of Minimal buttons.
class MinimalCapsule extends StatelessWidget {
final List<Widget> children;
final double scale;
const MinimalCapsule({super.key, required this.children, this.scale = 1});
@override
Widget build(BuildContext context) {
final r = BorderRadius.circular(MinimalStyle.radius * scale + MinimalStyle.capsulePad);
return DecoratedBox(
decoration: BoxDecoration(
color: MinimalStyle.capsule,
borderRadius: r,
border: Border.all(color: AppMenuStyle.border, width: 0.5),
),
child: Padding(
padding: const EdgeInsets.all(MinimalStyle.capsulePad),
child: Row(mainAxisSize: MainAxisSize.min, spacing: 2, children: children),
),
);
}
}
@@ -0,0 +1,578 @@
import 'dart:async';
import 'dart:math' as math;
import 'package:flutter/material.dart';
import '../../playback/media_marks.dart';
import '../frame.dart';
import 'style.dart';
// The Minimal skin's timeline: a band across the bottom with the played
// part tinted in the accent and the position as a line, the part not
// downloaded yet striped, chapter starts as dots on the top edge (click one
// to jump), the elapsed time left and the total or the time left right,
// the buffered seconds by the position while under a minute, and a tip
// with the time (and the chapter title) under the mouse. Hidden, it shrinks
// to a thin progress line.
//
// Inspired by the timeline of uosc (github.com/tomasklaen/uosc, LGPL-2.1,
// by tomasklaen): the striped unbuffered part, the remaining time and the
// thin line when hidden come from there. The drawing, geometry and values
// here are our own.
/// Sizes at scale 1.
abstract final class MinimalTimelineSpec {
/// The band's height while the controls are up.
static const size = 36.0;
/// What is left of it while they are hidden.
static const lineSize = 3.0;
/// The position line.
static const lineWidth = 2.0;
static const fontSize = 15.0;
/// The times' inset from the ends.
static const inset = 12.0;
/// A chapter dot's radius (doubled-ish while hovered).
static const dot = 3.5;
/// The cache label shows while less than this is buffered ahead.
static const bufferedThreshold = Duration(seconds: 60);
/// Collapse / grow.
static const animation = Duration(milliseconds: 120);
}
/// Fraction of [total] at [d], 0..1 (0 without a duration).
double timelineFraction(Duration d, Duration total) {
final t = total.inMicroseconds;
if (t <= 0) return 0;
return (d.inMicroseconds / t).clamp(0.0, 1.0);
}
/// Where the part not downloaded yet starts (it runs to the end), as a
/// fraction; null when nothing known is missing. We know only the end of
/// mpv's cache ahead ([buffered]); a cache within half a second of the end
/// leaves nothing.
double? uncachedFrom({required Duration position, required Duration buffered, required Duration duration}) {
if (duration <= Duration.zero || buffered <= Duration.zero) return null;
final from = buffered > position ? buffered : position;
if (duration - from <= const Duration(milliseconds: 500)) return null;
return timelineFraction(from, duration);
}
/// The cache label by the position ("12s"): the seconds buffered ahead,
/// while under [MinimalTimelineSpec.bufferedThreshold] and the cache does
/// not reach the end.
String? cacheLabel({required Duration position, required Duration buffered, required Duration duration}) {
if (duration <= Duration.zero || buffered <= Duration.zero) return null;
if (duration - buffered <= const Duration(milliseconds: 500)) return null;
final ahead = buffered > position ? buffered - position : Duration.zero;
if (ahead >= MinimalTimelineSpec.bufferedThreshold) return null;
return '${(ahead.inMilliseconds / 1000).round()}s';
}
/// The band's height while it animates between hidden (0) and shown (1).
double minimalBandSize(double visibility, double scale) =>
MinimalTimelineSpec.lineSize +
((MinimalTimelineSpec.size * scale - MinimalTimelineSpec.lineSize) * visibility).roundToDouble();
/// The Minimal timeline. [expanded] false (the controls hidden) shrinks it,
/// animated, to the progress line. Seeks ([onSeek]) on press, on every drag
/// move and on release; a press on a hovered chapter dot jumps to that
/// chapter, one on the right-hand time switches it ([onToggleTime]).
class MinimalTimeline extends StatefulWidget {
final Duration position;
final Duration duration;
/// End of the cached range.
final Duration buffered;
final List<MediaChapter> chapters;
final bool expanded;
/// The right-hand time: the time left instead of the total.
final bool remaining;
final double scale;
final ValueChanged<Duration> onSeek;
final VoidCallback? onToggleTime;
const MinimalTimeline({
super.key,
required this.position,
required this.duration,
this.buffered = Duration.zero,
this.chapters = const [],
this.expanded = true,
this.remaining = true,
this.scale = 1,
required this.onSeek,
this.onToggleTime,
});
static double heightFor(double scale) => MinimalTimelineSpec.size * scale;
static const collapsedHeight = MinimalTimelineSpec.lineSize;
@override
State<MinimalTimeline> createState() => _MinimalTimelineState();
}
class _MinimalTimelineState extends State<MinimalTimeline> {
Offset? _hover;
bool _dragging = false;
double _dragFraction = 0;
Duration? _lastSent;
// A released seek, shown until the position catches up (else the line
// jumps back to the old position for a moment).
double? _pending;
Timer? _pendingTimer;
double get _scale => widget.scale;
Duration _at(double fraction) => widget.duration * fraction.clamp(0.0, 1.0);
void _send(Duration d) {
if (d == _lastSent) return;
_lastSent = d;
widget.onSeek(d);
}
void _drag(bool on) {
if (_dragging == on) return;
setState(() => _dragging = on);
ControlsFrame.maybeOf(context)?.hold(on);
}
void _settle(double fraction) {
_pending = fraction;
_pendingTimer?.cancel();
_pendingTimer = Timer(const Duration(seconds: 2), () {
if (mounted) setState(() => _pending = null);
});
}
@override
void didUpdateWidget(MinimalTimeline old) {
super.didUpdateWidget(old);
if (!widget.expanded && old.expanded) _hover = null;
final p = _pending;
if (p != null && widget.duration > Duration.zero) {
final off = (widget.position - _at(p)).abs();
if (off < const Duration(milliseconds: 1500)) {
_pending = null;
_pendingTimer?.cancel();
}
}
}
@override
void dispose() {
_pendingTimer?.cancel();
// A drag cut short by a rebuild must not keep the controls up.
if (_dragging) ControlsFrame.maybeOf(context)?.hold(false);
super.dispose();
}
double _dotRadius(bool hovered) => MinimalTimelineSpec.dot * _scale * (hovered ? 1.7 : 1);
/// The chapter whose dot is within reach of [at].
int? _hoveredChapter(double width, List<MediaChapter> marks, Offset? at) {
if (at == null || _dragging) return null;
final reach = _dotRadius(false) * 2.5;
int? best;
var bestD = double.infinity;
for (var i = 0; i < marks.length; i++) {
final cx = width * timelineFraction(marks[i].start, widget.duration);
final d = (Offset(cx, 0) - at).distance;
if (d <= reach && d < bestD) {
best = i;
bestD = d;
}
}
return best;
}
String? _rightText(Duration shown) {
final d = widget.duration;
if (d <= Duration.zero) return null;
if (!widget.remaining) return formatClock(d);
final left = d - shown;
return '-${formatClock(left.isNegative ? Duration.zero : left)}';
}
// The painter's text takes the app's font (a TextPainter does not
// inherit it).
String? _font;
TextStyle get _timeStyle => TextStyle(
fontFamily: _font,
color: MinimalStyle.text,
fontSize: MinimalTimelineSpec.fontSize * _scale,
height: 1,
fontWeight: FontWeight.w500,
fontFeatures: const [FontFeature.tabularFigures()],
);
/// Where the right-hand time sits (a press there switches it).
bool _onRightTime(Offset at, double width, String? text) {
if (text == null || widget.onToggleTime == null) return false;
final tp = TextPainter(
text: TextSpan(text: text, style: _timeStyle),
textDirection: TextDirection.ltr,
)..layout();
return at.dx >= width - MinimalTimelineSpec.inset * _scale - tp.width - 6;
}
@override
Widget build(BuildContext context) {
_font = DefaultTextStyle.of(context).style.fontFamily ?? Theme.of(context).textTheme.bodyMedium?.fontFamily;
return LayoutBuilder(
builder: (context, constraints) => TweenAnimationBuilder<double>(
tween: Tween(end: widget.expanded ? 1 : 0),
duration: MinimalTimelineSpec.animation,
builder: (context, visibility, _) => _band(context, constraints.maxWidth, visibility),
),
);
}
Widget _band(BuildContext context, double width, double visibility) {
final expanded = widget.expanded;
final duration = widget.duration;
final size = minimalBandSize(visibility, _scale);
final progress = _dragging ? _dragFraction : (_pending ?? timelineFraction(widget.position, duration));
final shown = _at(progress);
final marks = duration > Duration.zero ? widget.chapters : const <MediaChapter>[];
final hover = expanded ? _hover : null;
final hovered = _hoveredChapter(width, marks, hover);
final hoverFraction = hovered != null
? timelineFraction(marks[hovered].start, duration)
: (hover == null || duration <= Duration.zero || width <= 0 ? null : (hover.dx / width).clamp(0.0, 1.0));
final hoverAt = hoverFraction == null ? null : _at(hoverFraction);
final cursorX = hoverFraction == null ? null : width * hoverFraction;
final hoverChapter = hoverAt == null ? null : chapterIndexAt(widget.chapters, hoverAt);
final hoverTitle = hoverChapter == null ? '' : widget.chapters[hoverChapter].title;
// Text fades out before it would overflow the shrinking band.
final minText = MinimalTimelineSpec.fontSize * _scale;
final textOpacity = ((size - minText) / (minText / 2)).clamp(0.0, 1.0);
final right = _rightText(shown);
final primary = Theme.of(context).colorScheme.primary;
final painter = MinimalTimelinePainter(
progress: progress,
visibility: visibility,
accent: primary,
scale: _scale,
uncached: uncachedFrom(position: widget.position, buffered: widget.buffered, duration: duration),
chapters: [for (final c in marks) timelineFraction(c.start, duration)],
hoveredChapter: hovered,
hoverX: hovered == null ? cursorX : null,
textOpacity: textOpacity,
elapsed: duration > Duration.zero ? formatClock(shown) : null,
right: right,
cache: cacheLabel(position: widget.position, buffered: widget.buffered, duration: duration),
timeStyle: _timeStyle,
);
final bar = SizedBox(
key: const ValueKey('minimal-timeline'),
width: width,
height: size,
child: Stack(
clipBehavior: Clip.none,
children: [
Positioned.fill(
child: RepaintBoundary(child: CustomPaint(painter: painter)),
),
if (hoverAt != null && cursorX != null)
Positioned(
left: 0,
right: 0,
bottom: size + 8,
height: 120,
child: IgnorePointer(
child: CustomMultiChildLayout(
delegate: _TipLayout(x: cursorX),
children: [
LayoutId(
id: #tip,
child: _HoverTip(time: formatClock(hoverAt), chapter: hoverTitle, scale: _scale),
),
],
),
),
),
],
),
);
if (!expanded) return IgnorePointer(child: bar);
Offset clampTo(Offset p) => Offset(p.dx.clamp(0.0, width), p.dy);
return MouseRegion(
cursor: SystemMouseCursors.click,
onEnter: (e) => setState(() => _hover = clampTo(e.localPosition)),
onHover: (e) => setState(() => _hover = clampTo(e.localPosition)),
onExit: (_) {
if (!_dragging) setState(() => _hover = null);
},
child: Listener(
behavior: HitTestBehavior.opaque,
onPointerDown: (e) {
if (width <= 0) return;
final at = clampTo(e.localPosition);
if (_onRightTime(at, width, right)) {
widget.onToggleTime?.call();
return;
}
if (duration <= Duration.zero) return;
final chapter = _hoveredChapter(width, marks, at);
_lastSent = null;
if (chapter != null) {
_settle(timelineFraction(marks[chapter].start, duration));
_send(marks[chapter].start);
setState(() {});
return;
}
_pending = null;
_dragFraction = (at.dx / width).clamp(0.0, 1.0);
_drag(true);
_send(_at(_dragFraction));
},
onPointerMove: (e) {
if (!_dragging) return;
final at = clampTo(e.localPosition);
setState(() {
_hover = at;
_dragFraction = (at.dx / width).clamp(0.0, 1.0);
});
_send(_at(_dragFraction));
},
onPointerUp: (e) {
if (!_dragging) return;
_send(_at(_dragFraction));
_settle(_dragFraction);
final p = e.localPosition;
if (p.dx < 0 || p.dx > width || p.dy < 0 || p.dy > size) _hover = null;
_drag(false);
},
onPointerCancel: (_) => _drag(false),
child: bar,
),
);
}
}
class _HoverTip extends StatelessWidget {
final String time;
final String chapter;
final double scale;
const _HoverTip({required this.time, required this.chapter, required this.scale});
@override
Widget build(BuildContext context) {
return Container(
constraints: const BoxConstraints(maxWidth: 360),
padding: EdgeInsets.symmetric(horizontal: 10 * scale, vertical: 5 * scale),
decoration: BoxDecoration(
color: MinimalStyle.tip,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: const Color(0x1FFFFFFF), width: 0.5),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (chapter.isNotEmpty)
Text(
chapter,
key: const ValueKey('minimal-hover-chapter'),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: MinimalStyle.text, fontSize: 13 * scale, fontWeight: FontWeight.w600),
),
Text(
time,
key: const ValueKey('minimal-hover-time'),
style: TextStyle(
color: chapter.isEmpty ? MinimalStyle.text : MinimalStyle.dim,
fontSize: 13 * scale,
fontFeatures: const [FontFeature.tabularFigures()],
),
),
],
),
);
}
}
/// The hover tip centred on [x], kept inside the band's ends.
class _TipLayout extends MultiChildLayoutDelegate {
final double x;
_TipLayout({required this.x});
@override
void performLayout(Size size) {
const gap = 4.0;
final tip = layoutChild(#tip, BoxConstraints.loose(size));
final left = (x - tip.width / 2).clamp(gap, math.max(gap, size.width - gap - tip.width)).toDouble();
positionChild(#tip, Offset(left, size.height - tip.height));
}
@override
bool shouldRelayout(_TipLayout old) => old.x != x;
}
/// Paints the band: background, the played tint and the (white) position
/// line, the stripes over the part not downloaded, chapter marks, the
/// times, the hover line.
class MinimalTimelinePainter extends CustomPainter {
/// The playing position, 0..1.
final double progress;
/// 0 hidden .. 1 shown.
final double visibility;
final Color accent;
final double scale;
/// Start of the part not downloaded (to the end), as a fraction.
final double? uncached;
/// Chapter starts, as fractions.
final List<double> chapters;
final int? hoveredChapter;
final double? hoverX;
final double textOpacity;
final String? elapsed;
final String? right;
final String? cache;
final TextStyle timeStyle;
const MinimalTimelinePainter({
required this.progress,
this.visibility = 1,
this.accent = Colors.white,
this.scale = 1,
this.uncached,
this.chapters = const [],
this.hoveredChapter,
this.hoverX,
this.textOpacity = 1,
this.elapsed,
this.right,
this.cache,
this.timeStyle = const TextStyle(fontSize: MinimalTimelineSpec.fontSize),
});
/// x of the position line's centre.
double lineX(double width) {
final lw = MinimalTimelineSpec.lineWidth * scale;
return (width * progress).clamp(lw / 2, math.max(lw / 2, width - lw / 2));
}
@override
void paint(Canvas canvas, Size size) {
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)),
);
_stripes(canvas, w, h);
_chapters(canvas, w, h);
if (visibility > 0) {
final lw = MinimalTimelineSpec.lineWidth * scale;
canvas.drawRect(
Rect.fromLTRB(x - lw / 2, 0, x + lw / 2, h),
Paint()..color = Colors.white.withValues(alpha: visibility),
);
}
if (textOpacity > 0) _times(canvas, w, h, x);
final hx = hoverX;
if (hx != null) {
canvas.drawRect(Rect.fromLTRB(hx - 0.5, 0, hx + 0.5, h), Paint()..color = Colors.white.withValues(alpha: 0.45));
}
}
/// Thin 45° stripes over the part not downloaded yet.
void _stripes(Canvas canvas, double w, double h) {
final from = uncached;
if (from == null) return;
final ax = (w * from).floorToDouble();
if (ax >= w) return;
final period = 7.0 * scale;
canvas.save();
canvas.clipRect(Rect.fromLTRB(ax, 0, w, h));
final paint = Paint()
..color = Colors.white.withValues(alpha: 0.10 + 0.08 * (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);
}
canvas.restore();
}
void _chapters(Canvas canvas, double w, double h) {
final cut = Paint()..color = Colors.black.withValues(alpha: 0.55);
for (final f in chapters) {
if (f <= 0) continue;
final cx = w * f;
canvas.drawRect(Rect.fromLTRB(cx - 0.5, 0, cx + 0.5, h), cut);
}
if (visibility <= 0) return;
final r = MinimalTimelineSpec.dot * scale * visibility;
void dot(double f, double radius, Color fill) {
final c = Offset(w * f, 0);
canvas.drawCircle(c, radius + 1, Paint()..color = Colors.black.withValues(alpha: 0.7));
canvas.drawCircle(c, radius, Paint()..color = fill);
}
// Not at 0: the band's start says that already.
for (var i = 0; i < chapters.length; i++) {
if (i != hoveredChapter && chapters[i] > 0) dot(chapters[i], r, Colors.white.withValues(alpha: 0.9));
}
final hv = hoveredChapter;
if (hv != null && hv < chapters.length) dot(chapters[hv], r * 1.7, accent);
}
TextPainter _text(String s, TextStyle style) => TextPainter(
text: TextSpan(text: s, style: style),
textDirection: TextDirection.ltr,
)..layout();
/// White text with a soft shadow, so it reads over the accent tint too.
void _label(Canvas canvas, String s, TextStyle style, Offset at) {
final o = textOpacity;
_text(
s,
style.copyWith(
color: (style.color ?? Colors.white).withValues(alpha: o),
shadows: [Shadow(color: Colors.black.withValues(alpha: 0.8 * o), blurRadius: 3)],
),
).paint(canvas, at);
}
void _times(Canvas canvas, double w, double h, double lineX) {
final inset = MinimalTimelineSpec.inset * scale;
final fs = timeStyle.fontSize ?? MinimalTimelineSpec.fontSize;
final cy = h / 2;
final e = elapsed, r = right;
final ew = e == null ? 0.0 : _text(e, timeStyle).width;
final rw = r == null ? 0.0 : _text(r, timeStyle).width;
final c = cache;
if (c != null) {
final cs = timeStyle.copyWith(fontSize: fs * 0.8, color: MinimalStyle.dim);
final cw = _text(c, cs).width;
const margin = 6.0;
var x = lineX + margin;
final minX = inset + margin + ew, maxX = w - inset - margin - rw;
if (x < minX) {
x = minX;
} else if (x + cw > maxX) {
x = maxX - cw;
}
_label(canvas, c, cs, Offset(x, cy - fs * 0.4));
}
if (e != null) _label(canvas, e, timeStyle, Offset(inset, cy - fs / 2));
if (r != null) _label(canvas, r, timeStyle, Offset(w - inset - rw, cy - fs / 2));
}
@override
bool shouldRepaint(MinimalTimelinePainter old) => true;
}
@@ -0,0 +1,235 @@
import 'package:flutter/material.dart' show IconData, Icons;
import '../../playback/media_marks.dart' show formatClock;
import 'controls_prefs.dart';
// The controls layout, defined once for both skins: which controls the
// bottom bar has, in which order, which actions sit in the ⚙ menu instead,
// and their icons, tooltips and labels. Pure: everything here is a
// function of a [ControlsSnapshot] and the user's choices, so tests cover
// it without a player.
/// What playback does as this viewer sees it. In a server "play" is being
/// ready: the video plays once everyone is.
enum PlayState {
/// The video plays (the button pauses: you go unready).
playing,
/// You are ready; the others are not yet (the button unreadies you).
waiting,
/// You are not ready (the button readies you).
paused,
/// The media ended.
finished,
}
/// A control in the bottom bar.
enum BarControl {
playPause,
/// Previous episode.
previous,
/// Up next's first item, else the next episode.
next,
chapterPrevious,
chapterNext,
volume,
time,
subtitles,
audio,
/// ⚙: everything that is not a bar button.
settings,
chat,
fullscreen,
}
/// An action in the ⚙ menu.
enum ControlAction {
chapters('Chapters…', null),
subtitles('Subtitles…', null),
audio('Audio…', null),
skipForward('Skip 1:30', 'Shift+Right'),
shaders('Upscaling shader…', 'Ctrl+0…6'),
chat('Chat & activity', 'Tab'),
changeVideo('Change video', null),
copyLink('Copy link', null),
reload('Reload video', 'Ctrl+R'),
markAllReady('Mark everyone ready', null),
markProblem('Mark a problem', 'Ctrl+Shift+B'),
playerSettings('Player settings…', null),
leave('Leave server', null);
final String label;
final String? hint;
const ControlAction(this.label, this.hint);
}
/// The bar's order, left cluster then right cluster. The play button is
/// always first and fullscreen always last; controls that do not apply
/// (no episode, no chapters) or that the user hid are left out, the rest
/// keep their order.
abstract final class ControlsLayout {
static const left = [
BarControl.playPause,
BarControl.previous,
BarControl.next,
BarControl.chapterPrevious,
BarControl.chapterNext,
BarControl.volume,
BarControl.time,
];
static const right = [
BarControl.subtitles,
BarControl.audio,
BarControl.settings,
BarControl.chat,
BarControl.fullscreen,
];
/// The ⚙ menu's order.
static const menu = ControlAction.values;
}
/// What the layout depends on, at one moment.
class ControlsSnapshot {
final PlayState play;
/// The previous episode's label; null: none.
final String? previousLabel;
/// Up next's first item's title, else the next episode's label; null:
/// nothing next.
final String? nextLabel;
/// [nextLabel] is Up next (not an episode).
final bool nextIsUpNext;
final bool hasChapters;
/// The media's length is known (seeks and skips make sense).
final bool hasDuration;
/// Watching alone (no one to mark ready).
final bool solo;
/// A link to copy (`SessionCoordinator.currentLink`).
final bool hasLink;
final bool fullscreen;
final bool chatOpen;
const ControlsSnapshot({
this.play = PlayState.paused,
this.previousLabel,
this.nextLabel,
this.nextIsUpNext = false,
this.hasChapters = false,
this.hasDuration = false,
this.solo = false,
this.hasLink = false,
this.fullscreen = false,
this.chatOpen = false,
});
}
/// The bar's two clusters for [s], in [ControlsLayout] order.
({List<BarControl> left, List<BarControl> right}) barControls(ControlsSnapshot s, Set<OptionalControl> hidden) {
bool on(BarControl c) => switch (c) {
BarControl.previous => s.previousLabel != null,
BarControl.next => s.nextLabel != null,
BarControl.chapterPrevious || BarControl.chapterNext => s.hasChapters && !hidden.contains(OptionalControl.chapters),
BarControl.subtitles => !hidden.contains(OptionalControl.subtitles),
BarControl.audio => !hidden.contains(OptionalControl.audio),
BarControl.chat => !hidden.contains(OptionalControl.chat),
_ => true,
};
return (
left: [
for (final c in ControlsLayout.left)
if (on(c)) c,
],
right: [
for (final c in ControlsLayout.right)
if (on(c)) c,
],
);
}
/// The ⚙ menu for [s]: every action that applies, minus those the bar
/// already shows.
List<ControlAction> menuActions(ControlsSnapshot s, Set<OptionalControl> hidden) => [
for (final a in ControlsLayout.menu)
if (switch (a) {
ControlAction.chapters => s.hasChapters,
ControlAction.subtitles => hidden.contains(OptionalControl.subtitles),
ControlAction.audio => hidden.contains(OptionalControl.audio),
ControlAction.skipForward => s.hasDuration,
ControlAction.chat => hidden.contains(OptionalControl.chat),
ControlAction.copyLink => s.hasLink,
ControlAction.markAllReady => !s.solo,
_ => true,
})
a,
];
/// Whether a bar control does something now (a skin dims it otherwise).
bool controlEnabled(BarControl c, ControlsSnapshot s) => switch (c) {
BarControl.chapterPrevious || BarControl.chapterNext => s.hasChapters && s.hasDuration,
BarControl.previous => s.previousLabel != null,
BarControl.next => s.nextLabel != null,
_ => true,
};
/// The icon of a bar control (volume and the clock draw their own).
IconData controlIcon(BarControl c, ControlsSnapshot s) => switch (c) {
BarControl.playPause => switch (s.play) {
PlayState.playing => Icons.pause_rounded,
// Ready: the check says so; a click unreadies.
PlayState.waiting => Icons.check_rounded,
PlayState.finished => Icons.replay_rounded,
PlayState.paused => Icons.play_arrow_rounded,
},
BarControl.previous => Icons.skip_previous_rounded,
BarControl.next => Icons.skip_next_rounded,
BarControl.chapterPrevious => Icons.chevron_left_rounded,
BarControl.chapterNext => Icons.chevron_right_rounded,
BarControl.volume => Icons.volume_up_rounded,
BarControl.time => Icons.schedule_rounded,
BarControl.subtitles => Icons.subtitles_outlined,
BarControl.audio => Icons.graphic_eq_rounded,
BarControl.settings => Icons.settings_outlined,
BarControl.chat => s.chatOpen ? Icons.chat_bubble_rounded : Icons.chat_bubble_outline_rounded,
BarControl.fullscreen => s.fullscreen ? Icons.fullscreen_exit_rounded : Icons.fullscreen_rounded,
};
/// The tooltip of a bar control, with its key.
String controlTooltip(BarControl c, ControlsSnapshot s) => switch (c) {
BarControl.playPause => switch (s.play) {
PlayState.playing => 'Pause for everyone (Space)',
PlayState.waiting => 'Ready, waiting for the others (Space: not ready)',
PlayState.paused => s.solo ? 'Play (Space)' : 'Ready: play when everyone is (Space)',
PlayState.finished => 'Ready (Space)',
},
BarControl.previous => 'Previous: ${s.previousLabel ?? ''} (< or Shift+P)',
BarControl.next => '${s.nextIsUpNext ? 'Up next' : 'Next'}: ${s.nextLabel ?? ''} (> or Shift+N)',
BarControl.chapterPrevious => 'Previous chapter (PgUp)',
BarControl.chapterNext => 'Next chapter (PgDn)',
BarControl.volume => 'Volume (scroll, ↑ / ↓)',
BarControl.time => 'Elapsed / remaining (click to switch)',
BarControl.subtitles => 'Subtitles',
BarControl.audio => 'Audio',
BarControl.settings => 'Settings and more',
BarControl.chat => 'Chat & activity (Tab)',
BarControl.fullscreen => s.fullscreen ? 'Exit fullscreen (F, Esc)' : 'Fullscreen (F)',
};
/// The bar's clock: "6:00 / 24:00", or the time left ("-18:00"); "0:00"
/// parts while the length is unknown.
String clockText(Duration position, Duration duration, TimeDisplay display) {
if (display == TimeDisplay.remaining && duration > Duration.zero) {
final left = duration - position;
return '-${formatClock(left.isNegative ? Duration.zero : left)}';
}
return '${formatClock(position)} / ${formatClock(duration)}';
}
@@ -0,0 +1,214 @@
import 'package:flutter/foundation.dart';
import 'package:signals/signals_flutter.dart';
import '../../jellyfin/track_prefs.dart' show MpvTrack;
import '../../playback/media_marks.dart';
import '../../playback/playback_controller.dart';
import '../../queue/queue_controller.dart';
import '../../session/session_coordinator.dart';
import '../../shaders/shader_presets.dart';
import '../../state/session_state.dart';
import '../../state/settings.dart';
import '../player_settings.dart' show applyShaderChoice;
import '../volume.dart' as vol;
import 'controls_layout.dart';
/// A track pick from a track menu: audio id, subtitle id, or off.
typedef TrackPick = ({int? aid, int? sid, bool subtitlesOff});
/// The player controls, independent of how they look: what the controls
/// show (state, as signals) and what they do (actions), wired to the
/// session once. Both skins only draw this and call its actions, so a
/// control added here shows in both. Built by the session player per
/// mount; [fullscreen], [chatOpen] and [unread] are the shell's, passed in.
class PlayerControlsModel {
final SessionCoordinator coord;
final ReadonlySignal<bool> fullscreen;
final ReadonlySignal<bool> chatOpen;
final ReadonlySignal<int> unread;
final VoidCallback _toggleFullscreen;
final VoidCallback _toggleChat;
final VoidCallback _changeVideo;
final VoidCallback _collapse;
final VoidCallback _leave;
PlayerControlsModel({
required this.coord,
required this.fullscreen,
required this.chatOpen,
required this.unread,
required VoidCallback onToggleFullscreen,
required VoidCallback onToggleChat,
required VoidCallback onChangeVideo,
required VoidCallback onCollapse,
required VoidCallback onLeave,
}) : _toggleFullscreen = onToggleFullscreen,
_toggleChat = onToggleChat,
_changeVideo = onChangeVideo,
_collapse = onCollapse,
_leave = onLeave;
PlaybackController get playback => coord.playback;
QueueController get queue => coord.queue;
AppSettings get settings => AppSettings.instance;
// ---------------------------------------------------------------- state
ReadonlySignal<Duration> get position => playback.position;
ReadonlySignal<Duration> get duration => playback.duration;
/// End of the cached range ahead of the position.
ReadonlySignal<Duration> get buffered => playback.buffered;
ReadonlySignal<List<MediaChapter>> get chapters => playback.chapters;
ReadonlySignal<int?> get currentChapter => playback.currentChapter;
ReadonlySignal<List<MpvTrack>> get tracks => playback.tracks;
/// 0..`maxVolume`.
ReadonlySignal<double> get volume => playback.volume;
ReadonlySignal<SkipOffer?> get skipOffer => coord.skipOffer;
ReadonlySignal<RenderMode> get renderMode => playback.renderMode;
/// Play / pause as the session sees it ([PlayState]).
late final Computed<PlayState> play = computed(() {
final r = coord.serverState.value;
final self = coord.selfMember.value;
return switch (r?.transport) {
'playing' => PlayState.playing,
'finished' => PlayState.finished,
_ => (self?.ready ?? false) && (self?.inSet ?? false) ? PlayState.waiting : PlayState.paused,
};
});
/// Everything the layout depends on (not the position: that changes
/// four times a second and only the timeline needs it).
late final Computed<ControlsSnapshot> snapshot = computed(() {
final items = queue.items.value;
final next = coord.nextEpisode.value;
return ControlsSnapshot(
play: play.value,
previousLabel: coord.previousEpisode.value?.label,
nextLabel: items.isNotEmpty ? items.first.title : next?.label,
nextIsUpNext: items.isNotEmpty,
hasChapters: chapters.value.isNotEmpty,
hasDuration: duration.value > Duration.zero,
solo: SessionState.instance.solo.value,
// Not a signal: read again whenever the state changes.
hasLink: coord.serverState.value != null && coord.currentLink() != null,
fullscreen: fullscreen.value,
chatOpen: chatOpen.value,
);
});
/// The bar's clusters, in the layout's order.
late final Computed<({List<BarControl> left, List<BarControl> right})> bar = computed(
() => barControls(snapshot.value, settings.hiddenControls.value),
);
/// What the ⚙ menu lists now.
List<ControlAction> menu() => menuActions(snapshot.peek(), settings.hiddenControls.peek());
/// Ticks on each wheel / key volume step: the skins flash the volume.
final volumeFlash = ValueNotifier(0);
/// A menu of the controls is open (the player's Esc closes it).
int menusOpen = 0;
void dispose() => volumeFlash.dispose();
// -------------------------------------------------------------- actions
/// Space: ready / not ready (being ready is "play" in a server).
void togglePlay() => coord.toggleReady();
/// A seek bar's press or drag; through `player.seek`, the user-seek
/// path, so everyone follows.
void seek(Duration to) => playback.player.seek(to);
/// Arrow keys: ±[secs], relative to a seek still in flight.
void nudge(double secs) => coord.nudge(secs);
/// +1:30 for everyone.
void skipForward() => coord.skipForward();
/// A skip pill (Skip OP / ED / 1:30).
void skip(SkipOffer offer) => coord.skip(offer);
/// PgDn (+1) / PgUp (-1). False when there is no chapter to go to.
bool stepChapter(int delta) => coord.stepChapter(delta);
void pickChapter(MediaChapter c) => coord.seekTo(c.start.inMicroseconds / 1e6);
/// Up next's first item, else the next episode. False when neither.
bool next() {
if (queue.items.peek().isNotEmpty) {
queue.skip();
return true;
}
if (coord.nextEpisode.peek() == null) return false;
coord.playNextEpisode();
return true;
}
/// The previous episode. False without one.
bool previous() {
if (coord.previousEpisode.peek() == null) return false;
coord.playPreviousEpisode();
return true;
}
void playNextEpisode() => coord.playNextEpisode();
void setVolume(double v) => playback.player.setVolume(v.clamp(0.0, vol.maxVolume));
/// One volume step up (+1) or down (-1) from the wheel or ↑ / ↓;
/// flashes the volume readout.
void stepVolume(int step) {
setVolume(vol.stepVolume(volume.peek(), step * vol.volumeStep));
volumeFlash.value++;
}
double _beforeMute = 100;
/// The speaker: mute, or back to the volume before.
void toggleMute() {
final v = volume.peek();
if (v > 0) {
_beforeMute = v;
setVolume(0);
} else {
setVolume(_beforeMute <= 0 ? 100 : _beforeMute);
}
}
void pickTrack(TrackPick p) => coord.selectTrack(aid: p.aid, sid: p.sid, subtitlesOff: p.subtitlesOff);
/// Saved as the choice and applied live; a null quality keeps the
/// saved one.
void pickShader(ShaderPreset preset, ShaderQuality? quality) => applyShaderChoice(preset, quality);
void toggleTimeDisplay() => settings.setTimeDisplay(settings.timeDisplay.peek().toggled);
void toggleFullscreen() => _toggleFullscreen();
void toggleChat() {
coord.markInput();
_toggleChat();
}
void changeVideo() => _changeVideo();
/// Back to the library (keeps playing in the mini player).
void collapse() => _collapse();
/// Asks before leaving the server.
void leave() => _leave();
void reload() {
coord.markInput();
coord.reloadVideo();
}
void markAllReady() => coord.forcePlayAll();
String? link() => coord.currentLink();
}
@@ -0,0 +1,64 @@
// The player controls' user choices (Settings → Player, stored in
// `AppSettings`). Plain values: the layout spec and both skins read them.
/// The controls' look. Both skins draw the same controls model in the same
/// order ([ControlsLayout]); only the look differs.
enum ControlsSkin {
/// Material-style bar: a seek bar over a row of round icon buttons.
standard('Standard'),
/// The app's own timeline skin (inspired by mpv's uosc): a thick band
/// with the buffered part, chapters and times, flat glass buttons.
minimal('Minimal');
final String label;
const ControlsSkin(this.label);
/// A stored name; the old `mpvControls` flag (true: the uosc design)
/// when there is none.
static ControlsSkin fromStored(Object? name, {Object? legacyMpvControls}) {
for (final s in values) {
if (s.name == name) return s;
}
return legacyMpvControls == true ? minimal : standard;
}
}
/// The bar's clock: "6:00 / 24:00" or the time left ("-18:00").
enum TimeDisplay {
elapsed('Elapsed / total'),
remaining('Remaining');
final String label;
const TimeDisplay(this.label);
TimeDisplay get toggled => this == elapsed ? remaining : elapsed;
}
/// Bar and button size.
enum ControlsSize {
normal('Normal', 1.0),
large('Large', 1.25);
final String label;
final double scale;
const ControlsSize(this.label, this.scale);
}
/// Bar buttons the user may hide; hidden ones are still in ⚙.
enum OptionalControl {
chapters('Chapter buttons', 'Previous / next chapter (PgUp / PgDn)'),
subtitles('Subtitles', 'The subtitle track list'),
audio('Audio', 'The audio track list'),
chat('Chat', 'Chat & activity (Tab)');
final String label;
final String help;
const OptionalControl(this.label, this.help);
}
/// The auto-hide delays offered (seconds).
const controlsHideChoices = [2, 3, 5];
/// The default auto-hide delay (seconds).
const defaultControlsHideSecs = 3;
@@ -12,11 +12,11 @@ import '../shell/mark_problem.dart';
import '../shell/motion.dart'; import '../shell/motion.dart';
import '../state/settings.dart'; import '../state/settings.dart';
import '../widgets/app_menu.dart'; import '../widgets/app_menu.dart';
import 'uosc/buttons.dart'; import 'frame.dart';
import 'uosc/controls.dart'; import 'model/controls_prefs.dart';
// Everything about the player, defined once: the rows of the ⚙ card in // Everything about the player, defined once: the rows of the player
// both control designs and of Settings → Player. Each row reads and // settings card (⚙ → Player settings, both skins) and of Settings → Player. Each row reads and
// writes [AppSettings] and applies to a running player right away. // writes [AppSettings] and applies to a running player right away.
/// One setting: a label, one line of help under it, the control at the /// One setting: a label, one line of help under it, the control at the
@@ -67,7 +67,7 @@ AppSelect<T> _dropdown<T>({
}) => }) =>
AppSelect<T>(key: key, value: value, items: items, onChanged: onChanged, expanded: expanded); AppSelect<T>(key: key, value: value, items: items, onChanged: onChanged, expanded: expanded);
/// Standard (our bars over media_kit's controls) or uosc. Live. /// The controls' skin: Standard or Minimal. Live.
class PlayerDesignSetting extends StatelessWidget { class PlayerDesignSetting extends StatelessWidget {
const PlayerDesignSetting({super.key}); const PlayerDesignSetting({super.key});
@@ -77,12 +77,100 @@ class PlayerDesignSetting extends StatelessWidget {
return SignalBuilder( return SignalBuilder(
builder: (context) => SettingRow( builder: (context) => SettingRow(
label: 'Player design', label: 'Player design',
help: 'uosc is modeled on mpv: a thick timeline with chapters and what is cached', help: 'Minimal has a thick timeline with chapters and what is downloaded yet',
control: _dropdown<bool>( control: _dropdown<ControlsSkin>(
key: const ValueKey('player-design'), key: const ValueKey('player-design'),
value: s.mpvControls.value, value: s.controlsSkin.value,
items: const [(false, 'Standard'), (true, 'uosc')], items: [for (final k in ControlsSkin.values) (k, k.label)],
onChanged: s.setMpvControls, onChanged: s.setControlsSkin,
),
),
);
}
}
/// The bar's clock: elapsed / total or the time left. Live.
class TimeDisplaySetting extends StatelessWidget {
const TimeDisplaySetting({super.key});
@override
Widget build(BuildContext context) {
final s = AppSettings.instance;
return SignalBuilder(
builder: (context) => SettingRow(
label: 'Time display',
help: 'Also switched by a click on the time',
control: _dropdown<TimeDisplay>(
key: const ValueKey('time-display'),
value: s.timeDisplay.value,
items: [for (final t in TimeDisplay.values) (t, t.label)],
onChanged: s.setTimeDisplay,
),
),
);
}
}
/// Normal or large bar and buttons. Live.
class ControlsSizeSetting extends StatelessWidget {
const ControlsSizeSetting({super.key});
@override
Widget build(BuildContext context) {
final s = AppSettings.instance;
return SignalBuilder(
builder: (context) => SettingRow(
label: 'Controls size',
help: 'Large makes the bar and its buttons a quarter bigger',
control: _dropdown<ControlsSize>(
key: const ValueKey('controls-size'),
value: s.controlsSize.value,
items: [for (final z in ControlsSize.values) (z, z.label)],
onChanged: s.setControlsSize,
),
),
);
}
}
/// How long the controls stay up without mouse movement. Live.
class HideDelaySetting extends StatelessWidget {
const HideDelaySetting({super.key});
@override
Widget build(BuildContext context) {
final s = AppSettings.instance;
return SignalBuilder(
builder: (context) => SettingRow(
label: 'Hide the controls after',
help: 'Without mouse movement over the video',
control: _dropdown<int>(
key: const ValueKey('controls-hide'),
value: s.controlsHideSecs.value,
items: [for (final secs in controlsHideChoices) (secs, '$secs s')],
onChanged: s.setControlsHideSecs,
),
),
);
}
}
/// One optional bar button on or off (off: it is still in ⚙). Live.
class BarButtonSetting extends StatelessWidget {
final OptionalControl control;
const BarButtonSetting({super.key, required this.control});
@override
Widget build(BuildContext context) {
final s = AppSettings.instance;
return SignalBuilder(
builder: (context) => SettingRow(
label: control.label,
help: control.help,
control: Switch(
key: ValueKey('bar-${control.name}'),
value: !s.hiddenControls.value.contains(control),
onChanged: (on) => s.setControlShown(control, on),
), ),
), ),
); );
@@ -296,7 +384,20 @@ class TrackSetting extends StatelessWidget {
} }
} }
/// The player rows of Settings → Player and the ⚙ card, in order. /// The controls' own choices (Settings → Player → Controls).
List<Widget> controlsSettingRows() => [
const TimeDisplaySetting(),
const ControlsSizeSetting(),
const HideDelaySetting(),
const Padding(
padding: EdgeInsets.only(top: 10, bottom: 2),
child: Text('Buttons in the bar (hidden ones stay in ⚙)', style: TextStyle(fontWeight: FontWeight.w600)),
),
for (final c in OptionalControl.values) BarButtonSetting(control: c),
];
/// The player rows of Settings → Player and the player settings card, in
/// order.
List<Widget> playerSettingRows({ReadonlySignal<RenderMode>? renderMode}) => [ List<Widget> playerSettingRows({ReadonlySignal<RenderMode>? renderMode}) => [
const PlayerDesignSetting(), const PlayerDesignSetting(),
const ProgressLineSetting(), const ProgressLineSetting(),
@@ -304,7 +405,8 @@ List<Widget> playerSettingRows({ReadonlySignal<RenderMode>? renderMode}) => [
const HwdecSetting(), const HwdecSetting(),
]; ];
/// The ⚙ card: every player setting, plus the running player's tracks. /// The player settings card: every player setting, plus the running
/// player's tracks.
class PlayerSettingsCard extends StatelessWidget { class PlayerSettingsCard extends StatelessWidget {
final ReadonlySignal<RenderMode>? renderMode; final ReadonlySignal<RenderMode>? renderMode;
/// Show the audio / subtitle pickers (a player is running). /// Show the audio / subtitle pickers (a player is running).
@@ -337,7 +439,7 @@ class PlayerSettingsCard extends StatelessWidget {
children: [ children: [
Expanded( Expanded(
child: Text( child: Text(
'Custom shader files: Settings → Player', 'Controls layout and custom shaders: Settings → Player',
style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant, fontSize: 12), style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant, fontSize: 12),
), ),
), ),
@@ -352,51 +454,14 @@ class PlayerSettingsCard extends StatelessWidget {
} }
} }
/// Opens the ⚙ card over the player, near the controls' right end. The /// Opens the player settings card over the player, near the controls'
/// uosc controls stay up while it is open. /// right end; the controls stay up while it is open.
Future<void> showPlayerSettings(BuildContext context, {ReadonlySignal<RenderMode>? renderMode}) { Future<void> showPlayerSettings(BuildContext context, {ReadonlySignal<RenderMode>? renderMode}) =>
Future<void> open() => showPopover<void>( ControlsFrame.holdWhile(
context,
() => showPopover<void>(
context, context,
at: const Alignment(0.96, 0.5), at: const Alignment(0.96, 0.5),
child: PlayerSettingsCard(renderMode: renderMode), child: PlayerSettingsCard(renderMode: renderMode),
),
); );
final controls = UoscControls.maybeOf(context);
return controls == null ? open() : controls.holdDuring(open);
}
/// ⚙ in the standard controls' bottom bar.
class PlayerSettingsButton extends StatelessWidget {
final FocusNode? focusNode;
final ReadonlySignal<RenderMode>? renderMode;
const PlayerSettingsButton({super.key, this.focusNode, this.renderMode});
@override
Widget build(BuildContext context) {
return IconButton(
key: const ValueKey('player-settings'),
focusNode: focusNode,
tooltip: 'Player settings',
color: Colors.white,
iconSize: 20,
visualDensity: VisualDensity.compact,
icon: const Icon(Icons.tune),
onPressed: () => showPlayerSettings(context, renderMode: renderMode),
);
}
}
/// ⚙ in the uosc controls' button row.
class UoscPlayerSettingsButton extends StatelessWidget {
final ReadonlySignal<RenderMode>? renderMode;
const UoscPlayerSettingsButton({super.key, this.renderMode});
@override
Widget build(BuildContext context) {
return UoscButton(
key: const ValueKey('uosc-player-settings'),
icon: Icons.tune_rounded,
tooltip: 'Player settings',
onPressed: () => showPlayerSettings(context, renderMode: renderMode),
);
}
}
@@ -0,0 +1,83 @@
import 'package:flutter/material.dart';
import 'package:signals/signals_flutter.dart';
import '../state/settings.dart';
import 'controls_probe.dart' show ControlsHold;
import 'frame.dart';
import 'minimal/minimal_controls.dart';
import 'model/controls_model.dart';
import 'model/controls_prefs.dart';
import 'standard/standard_controls.dart';
// The session's controls: one [ControlsFrame] (when they are up) around the
// skin the settings pick, both drawing the same [PlayerControlsModel].
// Skin, size and the hide delay follow the settings live.
/// The bottom controls' height in [skin] at [size] (the player's floating
/// pills sit just above it).
double controlsBottomHeight(ControlsSkin skin, ControlsSize size) => switch (skin) {
ControlsSkin.standard => StandardControls.bottomHeight(size.scale),
ControlsSkin.minimal => MinimalControls.bottomHeight(size.scale),
};
/// The top bar's height in [skin].
double controlsTopHeight(ControlsSkin skin) => switch (skin) {
ControlsSkin.standard => StandardControls.topHeight,
ControlsSkin.minimal => MinimalControls.topHeight,
};
class SessionControls extends StatelessWidget {
final PlayerControlsModel model;
/// Follows whether the controls are up.
final ValueNotifier<bool> shown;
final ControlsHold hold;
final VoidCallback onDoubleClick;
final Widget? windowButtons;
const SessionControls({
super.key,
required this.model,
required this.shown,
required this.hold,
required this.onDoubleClick,
this.windowButtons,
});
@override
Widget build(BuildContext context) {
final s = AppSettings.instance;
return SignalBuilder(
builder: (context) {
final skin = s.controlsSkin.value;
final size = s.controlsSize.value;
final scale = size.scale;
return ControlsFrame(
// A new frame per skin: each starts up, like a fresh mount.
key: ValueKey(skin),
shown: shown,
holdBy: hold,
hideAfter: Duration(seconds: s.controlsHideSecs.value),
onDoubleClick: onDoubleClick,
barTop: controlsTopHeight(skin),
barBottom: controlsBottomHeight(skin, size),
builder: (context, visible) => switch (skin) {
ControlsSkin.standard => StandardControls(
model: model,
visible: visible,
scale: scale,
hold: hold,
windowButtons: windowButtons,
),
ControlsSkin.minimal => MinimalControls(
model: model,
visible: visible,
scale: scale,
hold: hold,
windowButtons: windowButtons,
),
},
);
},
);
}
}
@@ -0,0 +1,229 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import '../../playback/media_marks.dart';
import '../frame.dart';
// The Standard skin's seek bar: a Material-style track with the played and
// buffered parts, chapter marks and a hover tip.
/// The seek bar: played / buffered track, a diamond at each chapter start
/// (not the one at 0), and on hover the time with the chapter title above
/// it. Like media_kit's bar it reports a seek on every drag move and on
/// release ([onSeek]); the session routes those through the user-seek path
/// (debounced, everyone follows).
class ChapterSeekBar extends StatefulWidget {
final Duration position;
final Duration duration;
final Duration buffer;
final List<MediaChapter> chapters;
final ValueChanged<Duration> onSeek;
/// The bar's height (its hit area); the track is centred in it.
final double height;
const ChapterSeekBar({
super.key,
required this.position,
required this.duration,
this.buffer = Duration.zero,
this.chapters = const [],
required this.onSeek,
this.height = ChapterSeekBar.defaultHeight,
});
static const defaultHeight = 28.0;
@override
State<ChapterSeekBar> createState() => _ChapterSeekBarState();
}
class _ChapterSeekBarState extends State<ChapterSeekBar> {
double? _hoverX;
bool _dragging = false;
double _dragFraction = 0;
double _fraction(Duration d) {
final total = widget.duration.inMicroseconds;
if (total <= 0) return 0;
return (d.inMicroseconds / total).clamp(0.0, 1.0);
}
Duration _at(double fraction) => widget.duration * fraction.clamp(0.0, 1.0);
// A drag sends no hover events: the controls are held up through it.
void _drag(bool on) {
if (_dragging == on) return;
setState(() => _dragging = on);
ControlsFrame.maybeOf(context)?.hold(on);
}
@override
void dispose() {
if (_dragging) ControlsFrame.maybeOf(context)?.hold(false);
super.dispose();
}
void _seekTo(double x, double width) {
if (widget.duration <= Duration.zero || width <= 0) return;
_dragFraction = (x / width).clamp(0.0, 1.0);
widget.onSeek(_at(_dragFraction));
}
@override
Widget build(BuildContext context) {
final primary = Theme.of(context).colorScheme.primary;
return LayoutBuilder(
builder: (context, constraints) {
final width = constraints.maxWidth;
final active = _hoverX != null || _dragging;
final barHeight = active ? 5.0 : 3.0;
final played = _dragging ? _dragFraction : _fraction(widget.position);
final mid = widget.height / 2;
final hoverX = _hoverX;
final hoverAt = hoverX == null || width <= 0 ? null : _at(hoverX / width);
final hoverChapter = hoverAt == null ? null : chapterIndexAt(widget.chapters, hoverAt);
final hoverTitle = hoverChapter == null ? '' : widget.chapters[hoverChapter].title;
return MouseRegion(
cursor: SystemMouseCursors.click,
onEnter: (e) => setState(() => _hoverX = e.localPosition.dx.clamp(0, width)),
onHover: (e) => setState(() => _hoverX = e.localPosition.dx.clamp(0, width)),
onExit: (_) => setState(() => _hoverX = null),
child: Listener(
behavior: HitTestBehavior.opaque,
onPointerDown: (e) {
_dragFraction = width <= 0 ? 0 : (e.localPosition.dx / width).clamp(0.0, 1.0);
_drag(true);
},
onPointerMove: (e) {
setState(() => _hoverX = e.localPosition.dx.clamp(0, width));
_seekTo(e.localPosition.dx, width);
},
onPointerUp: (e) {
_seekTo(e.localPosition.dx, width);
_drag(false);
},
onPointerCancel: (_) => _drag(false),
child: SizedBox(
key: const ValueKey('seek-bar'),
width: width,
height: widget.height,
child: Stack(
clipBehavior: Clip.none,
children: [
Positioned(
left: 0,
right: 0,
top: mid - barHeight / 2,
height: barHeight,
child: Container(color: Colors.white24),
),
Positioned(
left: 0,
top: mid - barHeight / 2,
height: barHeight,
width: width * _fraction(widget.buffer),
child: Container(color: Colors.white24),
),
if (hoverX != null && !_dragging)
Positioned(
left: 0,
top: mid - barHeight / 2,
height: barHeight,
width: hoverX,
child: Container(color: Colors.white24),
),
Positioned(
left: 0,
top: mid - barHeight / 2,
height: barHeight,
width: width * played,
child: Container(color: primary),
),
for (final c in widget.chapters)
if (c.start > Duration.zero && widget.duration > Duration.zero)
Positioned(
key: ValueKey('chapter-mark-${c.index}'),
left: width * _fraction(c.start) - 4,
top: mid - 4,
width: 8,
height: 8,
child: IgnorePointer(
child: Transform.rotate(
angle: math.pi / 4,
child: Container(
margin: const EdgeInsets.all(1),
decoration: BoxDecoration(
color: Colors.white,
border: Border.all(color: Colors.black54, width: 0.8),
),
),
),
),
),
if (active)
Positioned(
left: width * played - 6,
top: mid - 6,
width: 12,
height: 12,
child: IgnorePointer(
child: DecoratedBox(
decoration: BoxDecoration(color: primary, shape: BoxShape.circle),
),
),
),
if (hoverAt != null)
Positioned(
// Kept clear of the window edges (the tip is centred).
left: hoverX!.clamp(math.min(60.0, width / 2), math.max(width - 60, width / 2)),
bottom: widget.height - 2,
child: IgnorePointer(
child: FractionalTranslation(
translation: const Offset(-0.5, 0),
child: _HoverTip(title: hoverTitle, time: formatClock(hoverAt)),
),
),
),
],
),
),
),
);
},
);
}
}
class _HoverTip extends StatelessWidget {
final String title;
final String time;
const _HoverTip({required this.title, required this.time});
@override
Widget build(BuildContext context) {
return Container(
constraints: const BoxConstraints(maxWidth: 320),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(color: Colors.black.withValues(alpha: 0.8), borderRadius: BorderRadius.circular(6)),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (title.isNotEmpty)
Text(
title,
key: const ValueKey('seek-hover-chapter'),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Colors.white, fontSize: 12, fontWeight: FontWeight.w600),
),
Text(
time,
key: const ValueKey('seek-hover-time'),
style: const TextStyle(color: Colors.white70, fontSize: 11),
),
],
),
);
}
}
@@ -0,0 +1,340 @@
import 'package:flutter/material.dart';
import 'package:signals/signals_flutter.dart';
import 'package:sync_relay_core/core.dart' show JellyfinStreamType;
import '../../queue/queue_ui.dart';
import '../../shell/now_playing_title.dart';
import '../../state/settings.dart';
import '../control_menus.dart';
import '../controls_probe.dart' show ControlsHold;
import '../frame.dart';
import '../model/controls_layout.dart';
import '../model/controls_model.dart';
import '../volume.dart';
import 'seek_bar.dart';
// The Standard skin: Material-style bars over the picture. Top: what plays,
// Up next and the window controls; bottom: the seek bar (chapter marks,
// hover time) over a row of round icon buttons in the layout's order.
// Hidden, a thin progress line stays (unless switched off).
class StandardControls extends StatelessWidget {
final PlayerControlsModel model;
final bool visible;
final double scale;
final ControlsHold? hold;
/// Right of the top bar's own buttons (the window buttons in fullscreen).
final Widget? windowButtons;
const StandardControls({
super.key,
required this.model,
required this.visible,
this.scale = 1,
this.hold,
this.windowButtons,
});
/// The top bar: room for the cover (2:3, 60 px tall).
static const topHeight = 72.0;
static const _rowHeight = 56.0;
/// The bottom bar (seek bar and buttons) at [scale].
static double bottomHeight(double scale) => (_rowHeight + ChapterSeekBar.defaultHeight) * scale;
@override
Widget build(BuildContext context) {
const fade = ControlsFrame.fade;
Widget bar(Widget child) => IgnorePointer(
ignoring: !visible,
child: AnimatedOpacity(duration: fade, opacity: visible ? 1 : 0, child: child),
);
// No button here takes focus (Space is the ready key); the top bar's
// Up next keeps its own (its popover has a text field).
return Stack(
children: [
Positioned(
left: 0,
right: 0,
top: 0,
child: bar(
DecoratedBox(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0x99000000), Color(0x00000000)],
),
),
child: SizedBox(
key: const ValueKey('standard-top'),
height: topHeight,
child: Padding(padding: const EdgeInsets.only(left: 16, right: 8), child: _top()),
),
),
),
),
Positioned(
left: 0,
right: 0,
bottom: 0,
child: bar(
DecoratedBox(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0x00000000), Color(0xB3000000)],
),
),
child: Padding(
padding: EdgeInsets.only(top: 24 * scale),
child: SizedBox(
key: const ValueKey('standard-bottom'),
height: bottomHeight(scale),
child: Column(
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: SignalBuilder(
builder: (context) => ChapterSeekBar(
position: model.position.value,
duration: model.duration.value,
buffer: model.buffered.value,
chapters: model.chapters.value,
height: ChapterSeekBar.defaultHeight * scale,
onSeek: model.seek,
),
),
),
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: ExcludeFocus(
child: SignalBuilder(
builder: (context) {
final s = model.snapshot.value;
final layout = model.bar.value;
return Row(
key: const ValueKey('standard-buttons'),
children: [
for (final c in layout.left) _control(context, c, s),
const Spacer(),
for (final c in layout.right) _control(context, c, s),
],
);
},
),
),
),
),
],
),
),
),
),
),
),
// Hidden: the thin progress line.
Positioned(
left: 0,
right: 0,
bottom: 0,
child: IgnorePointer(
child: SignalBuilder(
builder: (context) {
if (visible || !AppSettings.instance.progressLine.value) return const SizedBox.shrink();
final d = model.duration.value, p = model.position.value;
final v = d.inMilliseconds == 0 ? 0.0 : (p.inMilliseconds / d.inMilliseconds).clamp(0.0, 1.0);
return LinearProgressIndicator(
key: const ValueKey('progress-line-standard'),
value: v,
minHeight: 2,
backgroundColor: Colors.transparent,
);
},
),
),
),
],
);
}
Widget _top() {
final m = model;
return Row(
children: [
const Expanded(child: NowPlayingTitle()),
UpNextButton(queue: m.queue, onPlayNextEpisode: m.playNextEpisode, look: UpNextLook.standard, hold: hold),
SignalBuilder(
builder: (context) {
final full = m.fullscreen.value;
return ExcludeFocus(
child: _IconButton(
key: const ValueKey('standard-collapse'),
icon: full ? Icons.fullscreen_exit_rounded : Icons.close_fullscreen_rounded,
tooltip: full ? 'Exit fullscreen (Esc)' : 'Back to the library (keeps playing)',
onPressed: full ? m.toggleFullscreen : m.collapse,
),
);
},
),
?windowButtons,
],
);
}
Widget _control(BuildContext context, BarControl c, ControlsSnapshot s) {
final m = model;
final key = ValueKey('control-${c.name}');
final enabled = controlEnabled(c, s);
Widget icon(VoidCallback? onPressed, {int badge = 0, double size = 24, Color? color}) => _IconButton(
key: key,
icon: controlIcon(c, s),
tooltip: controlTooltip(c, s),
badge: badge,
size: size * scale,
color: color,
onPressed: enabled ? onPressed : null,
);
switch (c) {
case BarControl.playPause:
final waiting = s.play == PlayState.waiting;
return icon(m.togglePlay, size: 30, color: waiting ? Theme.of(context).colorScheme.primary : null);
case BarControl.previous:
return icon(m.previous);
case BarControl.next:
return icon(m.next);
case BarControl.chapterPrevious:
return icon(() => m.stepChapter(-1));
case BarControl.chapterNext:
return icon(() => m.stepChapter(1));
case BarControl.volume:
return SignalBuilder(
key: key,
builder: (context) => VolumeControl(
volume: m.volume.value,
onChanged: m.setVolume,
onToggleMute: m.toggleMute,
button: (context, i, color, tip, onPressed) =>
_IconButton(icon: i, tooltip: tip, color: color, size: 24 * scale, onPressed: onPressed),
),
);
case BarControl.time:
return SignalBuilder(
key: key,
builder: (context) => _Clock(
text: clockText(m.position.value, m.duration.value, m.settings.timeDisplay.value),
tooltip: controlTooltip(c, s),
scale: scale,
onPressed: m.toggleTimeDisplay,
),
);
case BarControl.subtitles || BarControl.audio:
final type = c == BarControl.audio ? JellyfinStreamType.audio : JellyfinStreamType.subtitle;
return SignalBuilder(
key: key,
builder: (context) => Builder(
builder: (context) => _IconButton(
icon: controlIcon(c, s),
tooltip: controlTooltip(c, s),
badge: trackCount(m.tracks.value, type),
size: 22 * scale,
onPressed: () => openTrackMenu(context, m, type),
),
),
);
case BarControl.settings:
return Builder(
key: key,
builder: (context) => _IconButton(
icon: controlIcon(c, s),
tooltip: controlTooltip(c, s),
size: 22 * scale,
onPressed: () => openControlsMenu(context, m),
),
);
case BarControl.chat:
return SignalBuilder(
key: key,
builder: (context) => _IconButton(
icon: controlIcon(c, s),
tooltip: controlTooltip(c, s),
badge: m.unread.value,
size: 22 * scale,
onPressed: m.toggleChat,
),
);
case BarControl.fullscreen:
return icon(m.toggleFullscreen, size: 26);
}
}
}
/// A round white icon button with an optional count badge (the Material
/// look of media_kit's bar).
class _IconButton extends StatelessWidget {
final IconData icon;
final String tooltip;
final VoidCallback? onPressed;
final int badge;
final double size;
final Color? color;
const _IconButton({
super.key,
required this.icon,
required this.tooltip,
required this.onPressed,
this.badge = 0,
this.size = 24,
this.color,
});
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return IconButton(
tooltip: tooltip,
iconSize: size,
color: color ?? Colors.white,
disabledColor: Colors.white38,
onPressed: onPressed,
icon: Badge(
isLabelVisible: badge > 0,
backgroundColor: scheme.primary,
textColor: scheme.onPrimary,
label: Text('$badge'),
child: Icon(icon),
),
);
}
}
/// The clock: "6:00 / 24:00" or "-18:00"; a click switches.
class _Clock extends StatelessWidget {
final String text;
final String tooltip;
final double scale;
final VoidCallback onPressed;
const _Clock({required this.text, required this.tooltip, required this.scale, required this.onPressed});
@override
Widget build(BuildContext context) {
return Tooltip(
message: tooltip,
waitDuration: const Duration(milliseconds: 500),
child: TextButton(
style: TextButton.styleFrom(
foregroundColor: Colors.white,
padding: EdgeInsets.symmetric(horizontal: 10 * scale),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
onPressed: onPressed,
child: Text(
text,
style: TextStyle(fontSize: 14 * scale, fontFeatures: const [FontFeature.tabularFigures()]),
),
),
);
}
}
@@ -1,359 +0,0 @@
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/media_marks.dart';
import '../../playback/playback_controller.dart' show RenderMode;
import '../../screens/session/player_controls.dart' show TrackPick;
import '../../shaders/shader_menu.dart';
import '../../shaders/shader_presets.dart';
import '../../state/settings.dart';
import '../volume.dart';
import 'controls.dart';
import 'menu.dart';
import 'style.dart';
/// Flat uosc button: a white icon (optionally a [label] beside it and a
/// small count [badge] in the corner), a faint highlight on hover, a dark
/// tooltip. Never takes focus (the session's keys are global).
class UoscButton extends StatefulWidget {
final IconData icon;
final String tooltip;
final VoidCallback? onPressed;
final String? label;
final int? badge;
/// Tint for an "on" state (ready, shaders on).
final Color? color;
const UoscButton({
super.key,
required this.icon,
required this.tooltip,
required this.onPressed,
this.label,
this.badge,
this.color,
});
@override
State<UoscButton> createState() => _UoscButtonState();
}
class _UoscButtonState extends State<UoscButton> {
bool _hover = false;
@override
Widget build(BuildContext context) {
final w = widget;
final color = w.onPressed == null ? UoscStyle.dim.withValues(alpha: 0.4) : (w.color ?? UoscStyle.text);
final badge = w.badge;
return UoscTooltip(
message: w.tooltip,
child: MouseRegion(
cursor: w.onPressed == null ? SystemMouseCursors.basic : SystemMouseCursors.click,
onEnter: (_) => setState(() => _hover = true),
onExit: (_) => setState(() => _hover = false),
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: w.onPressed,
child: Container(
height: UoscControls.buttonRowHeight,
constraints: const BoxConstraints(minWidth: UoscControls.buttonRowHeight),
padding: EdgeInsets.symmetric(horizontal: w.label == null ? 0 : 8),
decoration: BoxDecoration(
color: _hover && w.onPressed != null ? UoscStyle.hover : Colors.transparent,
borderRadius: BorderRadius.circular(UoscStyle.radius),
),
child: Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Stack(
clipBehavior: Clip.none,
children: [
Icon(w.icon, color: color, size: 22),
if (badge != null && badge > 0)
Positioned(
right: -8,
bottom: -5,
child: Container(
key: const ValueKey('uosc-badge'),
padding: const EdgeInsets.symmetric(horizontal: 3),
decoration: BoxDecoration(
color: UoscStyle.text,
borderRadius: BorderRadius.circular(3),
),
child: Text('$badge',
style: const TextStyle(
color: Colors.black, fontSize: 9, height: 1.3, fontWeight: FontWeight.w700)),
),
),
],
),
if (w.label != null) ...[
const SizedBox(width: 6),
Text(w.label!, style: TextStyle(color: color, fontSize: UoscStyle.fontSize)),
],
],
),
),
),
),
);
}
}
/// Badge-style text button ("HQ" in uosc): a small outlined box with a
/// short label, filled when [active].
class UoscBadgeButton extends StatefulWidget {
final String text;
final String tooltip;
final bool active;
final VoidCallback onPressed;
const UoscBadgeButton({
super.key,
required this.text,
required this.tooltip,
required this.active,
required this.onPressed,
});
@override
State<UoscBadgeButton> createState() => _UoscBadgeButtonState();
}
class _UoscBadgeButtonState extends State<UoscBadgeButton> {
bool _hover = false;
@override
Widget build(BuildContext context) {
final w = widget;
return UoscTooltip(
message: w.tooltip,
child: MouseRegion(
cursor: SystemMouseCursors.click,
onEnter: (_) => setState(() => _hover = true),
onExit: (_) => setState(() => _hover = false),
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: w.onPressed,
child: Container(
height: UoscControls.buttonRowHeight,
padding: const EdgeInsets.symmetric(horizontal: 6),
decoration: BoxDecoration(
color: _hover ? UoscStyle.hover : Colors.transparent,
borderRadius: BorderRadius.circular(UoscStyle.radius),
),
alignment: Alignment.center,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1),
decoration: BoxDecoration(
color: w.active ? UoscStyle.text : Colors.transparent,
border: Border.all(color: w.active ? UoscStyle.text : UoscStyle.dim, width: 1.4),
borderRadius: BorderRadius.circular(3),
),
child: Text(w.text,
style: TextStyle(
color: w.active ? Colors.black : UoscStyle.dim,
fontSize: 11,
fontWeight: FontWeight.w800,
letterSpacing: 0.3)),
),
),
),
),
);
}
}
/// Volume: click mutes / unmutes, hovering shows a [VolumeBar] up to
/// [maxVolume], the wheel steps [volumeStep].
class UoscVolumeButton extends StatefulWidget {
/// 0..[maxVolume].
final double volume;
final ValueChanged<double> onChanged;
const UoscVolumeButton({super.key, required this.volume, required this.onChanged});
@override
State<UoscVolumeButton> createState() => _UoscVolumeButtonState();
}
class _UoscVolumeButtonState extends State<UoscVolumeButton> {
final _steps = WheelSteps();
double _beforeMute = 100;
bool _hover = false;
@override
Widget build(BuildContext context) {
final v = widget.volume;
final icon = v <= 0 ? Icons.volume_off_rounded : (v < 50 ? Icons.volume_down_rounded : Icons.volume_up_rounded);
return MouseRegion(
onEnter: (_) => setState(() => _hover = true),
onExit: (_) => setState(() => _hover = false),
child: Listener(
// Claimed here, so the player's own wheel handler skips it.
onPointerSignal: (e) => _steps.handle(e, (step) => widget.onChanged(stepVolume(v, step * volumeStep))),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
AnimatedSize(
duration: const Duration(milliseconds: 150),
child: _hover
? Padding(
padding: const EdgeInsets.only(left: 6, right: 2),
child: VolumeBar(
key: const ValueKey('uosc-volume-bar'),
volume: v,
onChanged: widget.onChanged,
color: UoscStyle.text,
trackColor: UoscStyle.dim.withValues(alpha: 0.5),
),
)
: const SizedBox.shrink(),
),
UoscButton(
key: const ValueKey('uosc-volume'),
icon: icon,
color: v > 100 ? boostColor : null,
tooltip: 'Volume ${v.round()} % (scroll to change)',
onPressed: () {
if (v > 0) {
_beforeMute = v;
widget.onChanged(0);
} else {
widget.onChanged(_beforeMute <= 0 ? 100 : _beforeMute);
}
},
),
],
),
),
);
}
}
/// Subtitles or audio: the icon with the track count; the menu lists the
/// tracks (mpv's selection active).
class UoscTracksButton extends StatelessWidget {
final JellyfinStreamType type;
final List<MpvTrack> tracks;
final void Function(TrackPick pick) onPick;
final ValueChanged<bool>? onMenuOpen;
const UoscTracksButton({super.key, required this.type, required this.tracks, required this.onPick, this.onMenuOpen});
@override
Widget build(BuildContext context) {
final audio = type == JellyfinStreamType.audio;
final count = tracks.where((t) => t.type == type).length;
return UoscButton(
key: ValueKey(audio ? 'uosc-audio' : 'uosc-subtitles'),
icon: audio ? Icons.graphic_eq_rounded : Icons.subtitles_outlined,
tooltip: audio ? 'Audio' : 'Subtitles',
badge: count,
onPressed: () async {
onMenuOpen?.call(true);
final pick = await showUoscMenu<TrackPick>(context,
title: audio ? 'Audio' : 'Subtitles', items: trackMenuItems(type, tracks));
onMenuOpen?.call(false);
if (pick != null) onPick(pick);
},
);
}
}
/// Chapters: the list with start times, the playing one active; a pick
/// jumps there. Nothing without chapters.
class UoscChaptersButton extends StatelessWidget {
final List<MediaChapter> chapters;
final int? current;
final Duration duration;
final ValueChanged<MediaChapter> onPick;
final ValueChanged<bool>? onMenuOpen;
const UoscChaptersButton({
super.key,
required this.chapters,
required this.current,
required this.duration,
required this.onPick,
this.onMenuOpen,
});
@override
Widget build(BuildContext context) {
if (chapters.isEmpty) return const SizedBox.shrink();
return UoscButton(
key: const ValueKey('uosc-chapters'),
icon: Icons.bookmarks_outlined,
tooltip: 'Chapters (PgUp / PgDn)',
onPressed: () async {
onMenuOpen?.call(true);
final pick = await showUoscMenu<MediaChapter>(context,
title: 'Chapters', items: chapterMenuItems(chapters, current, duration));
onMenuOpen?.call(false);
if (pick != null) onPick(pick);
},
);
}
}
/// Shaders as uosc's quality badge: the active Anime4K preset ("A HQ"),
/// "4K" dimmed when off. Opens the shader menu.
class UoscShaderButton extends StatelessWidget {
final ReadonlySignal<RenderMode> renderMode;
final void Function(ShaderPreset preset, ShaderQuality? quality) onPick;
final ValueChanged<bool>? onMenuOpen;
const UoscShaderButton({super.key, required this.renderMode, required this.onPick, this.onMenuOpen});
@override
Widget build(BuildContext context) {
final s = AppSettings.instance;
return SignalBuilder(builder: (context) {
final preset = s.shaderPreset.value;
final quality = s.shaderQuality.value;
final short = shaderShortLabel(preset, quality);
return Builder(
builder: (context) => UoscBadgeButton(
key: const ValueKey('uosc-shaders'),
text: short ?? '4K',
active: short != null,
tooltip: short == null
? 'Shaders: off (Ctrl+1..6)'
: 'Shaders: ${shaderLabel(preset, quality)} (Ctrl+0 off)',
onPressed: () {
final controls = UoscControls.maybeOf(context);
Future<void> open() =>
showShaderMenu(context, renderMode: renderMode, onPick: onPick, onMenuOpen: onMenuOpen);
controls == null ? open() : controls.holdDuring(open);
},
),
);
});
}
}
/// One entry of the ≡ menu.
typedef UoscAction = ({String title, String? hint, VoidCallback run});
/// ≡: the session's actions as a menu.
class UoscMainMenuButton extends StatelessWidget {
final List<UoscAction> actions;
final ValueChanged<bool>? onMenuOpen;
const UoscMainMenuButton({super.key, required this.actions, this.onMenuOpen});
@override
Widget build(BuildContext context) {
return UoscButton(
key: const ValueKey('uosc-menu-button'),
icon: Icons.menu_rounded,
tooltip: 'Menu',
onPressed: () async {
onMenuOpen?.call(true);
final pick = await showUoscMenu<UoscAction>(context, items: [
for (final a in actions) UoscMenuItem(value: a, title: a.title, hint: a.hint),
]);
onMenuOpen?.call(false);
pick?.run();
},
);
}
}
@@ -1,293 +0,0 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:signals/signals_flutter.dart';
import '../../state/settings.dart';
import '../controls_probe.dart' show ControlsHold;
// The mpv-style (uosc) controls frame: a full-width timeline at the
// bottom with a row of flat buttons above it, the title top left and a
// corner widget top right.
// Mouse movement shows them; after [UoscControls.hideAfter] without any
// they fade out, the timeline shrinks to uosc's progress line (gone when
// "Progress line when hidden" is off) and the cursor hides. Our own frame: no
// media_kit controls state to reach into.
class UoscControls extends StatefulWidget {
/// The timeline; [expanded] is false while the controls are hidden.
final Widget Function(BuildContext context, bool expanded) timeline;
/// Buttons left and right of the row above the timeline.
final List<Widget> left;
final List<Widget> right;
/// Top-right corner (chat, back to the library), shown with the controls.
final Widget? top;
/// Top left (what is playing), shown with the controls; gets the width
/// [top] leaves.
final Widget? topLeft;
final VoidCallback? onDoubleTap;
final Duration hideAfter;
/// Follows whether the controls are up (for overlays outside them).
final ValueNotifier<bool>? shown;
/// Keeps them up while held (the mouse on an overlay, an open popover).
final ControlsHold? holdBy;
const UoscControls({
super.key,
required this.timeline,
this.left = const [],
this.right = const [],
this.top,
this.topLeft,
this.onDoubleTap,
this.hideAfter = const Duration(seconds: 3),
this.shown,
this.holdBy,
});
static const buttonRowHeight = 40.0;
/// Under the button row, above the timeline.
static const rowBottomPadding = 4.0;
/// The controls around [context], for widgets that need them kept up
/// (a timeline drag, an open menu).
static UoscControlsState? maybeOf(BuildContext context) =>
context.findAncestorStateOfType<UoscControlsState>();
@override
State<UoscControls> createState() => UoscControlsState();
}
class UoscControlsState extends State<UoscControls> {
bool _visible = true;
int _holds = 0;
bool _heldBy = false;
Timer? _timer;
bool get visible => _visible;
@override
void initState() {
super.initState();
widget.holdBy?.addListener(_holdByChanged);
// Not during this build: an overlay listening to [shown] may be built.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) widget.shown?.value = true;
});
_hideLater();
}
@override
void didUpdateWidget(UoscControls oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.holdBy != widget.holdBy) {
oldWidget.holdBy?.removeListener(_holdByChanged);
widget.holdBy?.addListener(_holdByChanged);
_holdByChanged();
}
}
@override
void dispose() {
widget.holdBy?.removeListener(_holdByChanged);
_timer?.cancel();
super.dispose();
}
void _holdByChanged() {
final on = widget.holdBy?.held ?? false;
if (on == _heldBy || !mounted) return;
_heldBy = on;
hold(on);
}
/// Show the controls and restart the hide countdown.
void wake() {
if (!_visible && mounted) {
setState(() => _visible = true);
widget.shown?.value = true;
}
_hideLater();
}
/// Keep the controls up while [on] (paired calls): a drag sends no
/// hover events and a menu route takes the mouse.
void hold(bool on) {
_holds = on ? _holds + 1 : (_holds - 1).clamp(0, 1 << 20);
wake();
}
/// Runs [open] (a menu) with the controls held up.
Future<T> holdDuring<T>(Future<T> Function() open) async {
hold(true);
try {
return await open();
} finally {
if (mounted) hold(false);
}
}
void _hideLater() {
_timer?.cancel();
_timer = Timer(widget.hideAfter, _hide);
}
void _hide() {
if (!mounted || _holds > 0 || !_visible) return;
setState(() => _visible = false);
widget.shown?.value = false;
}
@override
Widget build(BuildContext context) {
final visible = _visible;
const fade = Duration(milliseconds: 150);
return MouseRegion(
cursor: visible ? SystemMouseCursors.basic : SystemMouseCursors.none,
onEnter: (_) => wake(),
onHover: (_) => wake(),
onExit: (_) {
_timer?.cancel();
_hide();
},
child: Stack(
children: [
Positioned.fill(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onDoubleTap: widget.onDoubleTap,
),
),
Positioned(
left: 0,
right: 0,
bottom: 0,
height: 160,
child: IgnorePointer(
child: AnimatedOpacity(
duration: fade,
opacity: visible ? 1 : 0,
child: const DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0x00000000), Color(0x8C000000)],
),
),
),
),
),
),
if (widget.topLeft != null)
Positioned(
left: 0,
right: 0,
top: 0,
height: 120,
child: IgnorePointer(
child: AnimatedOpacity(
duration: fade,
opacity: visible ? 1 : 0,
child: const DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0x8C000000), Color(0x00000000)],
),
),
),
),
),
),
if (widget.top != null || widget.topLeft != null)
Positioned(
top: 0,
left: 0,
right: 0,
child: IgnorePointer(
key: const ValueKey('uosc-top'),
ignoring: !visible,
child: AnimatedOpacity(
duration: fade,
opacity: visible ? 1 : 0,
// The buttons centred on the title, like the standard
// design's top bar.
child: Row(
children: [
Expanded(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
child: widget.topLeft == null
? const SizedBox.shrink()
: Align(alignment: AlignmentDirectional.centerStart, child: widget.topLeft),
),
),
if (widget.top != null)
Padding(padding: const EdgeInsets.fromLTRB(0, 12, 6, 0), child: widget.top),
],
),
),
),
),
Positioned(
left: 0,
right: 0,
bottom: 0,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
IgnorePointer(
key: const ValueKey('uosc-buttons'),
ignoring: !visible,
child: AnimatedOpacity(
duration: fade,
opacity: visible ? 1 : 0,
child: Padding(
padding: const EdgeInsets.fromLTRB(6, 0, 6, UoscControls.rowBottomPadding),
child: SizedBox(
height: UoscControls.buttonRowHeight,
child: Row(children: [...widget.left, const Spacer(), ...widget.right]),
),
),
),
),
// Hidden: the collapsed timeline is the progress line,
// unless the player settings turn it off.
SignalBuilder(
builder: (context) {
final line = AppSettings.instance.progressLine.value;
return AnimatedOpacity(
key: const ValueKey('uosc-timeline-wrap'),
duration: fade,
opacity: visible || line ? 1 : 0,
child: widget.timeline(context, visible),
);
},
),
],
),
),
],
),
);
}
}
/// The session's controls: the standard ones ([material]) or, with the
/// "Player design" setting on uosc, the uosc ones ([mpv]). Switches live
/// when the setting changes.
class SessionControlsSwitch extends StatelessWidget {
final WidgetBuilder material;
final WidgetBuilder mpv;
const SessionControlsSwitch({super.key, required this.material, required this.mpv});
@override
Widget build(BuildContext context) {
return SignalBuilder(
builder: (context) =>
AppSettings.instance.mpvControls.value ? mpv(context) : material(context),
);
}
}
@@ -1,249 +0,0 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:sync_relay_core/core.dart' show JellyfinStreamType;
import '../../jellyfin/track_prefs.dart' show MpvTrack;
import '../../playback/media_marks.dart';
import '../../screens/session/player_controls.dart' show TrackPick;
import 'controls.dart';
import 'style.dart';
/// One row of a uosc menu: [title], a dim [hint] on the right (a time, a
/// key), [active] = the current one (highlighted).
class UoscMenuItem<T> {
final T value;
final String title;
final String? hint;
final bool active;
final bool enabled;
const UoscMenuItem({
required this.value,
required this.title,
this.hint,
this.active = false,
this.enabled = true,
});
}
/// A flat dark menu over the controls, above the widget of [context] (the
/// button that opens it), with an optional [title] row. Resolves to the
/// picked value, or null when dismissed (click outside, Esc). Keeps the
/// controls up while open.
Future<T?> showUoscMenu<T>(
BuildContext context, {
String? title,
required List<UoscMenuItem<T>> items,
}) {
final navigator = Navigator.of(context);
final overlay = navigator.overlay!.context.findRenderObject() as RenderBox;
final box = context.findRenderObject() as RenderBox;
final anchor = Rect.fromPoints(
box.localToGlobal(Offset.zero, ancestor: overlay),
box.localToGlobal(box.size.bottomRight(Offset.zero), ancestor: overlay),
);
Future<T?> open() => navigator.push<T>(_UoscMenuRoute<T>(anchor: anchor, title: title, items: items));
final controls = UoscControls.maybeOf(context);
return controls == null ? open() : controls.holdDuring(open);
}
class _UoscMenuRoute<T> extends PopupRoute<T> {
final Rect anchor;
final String? title;
final List<UoscMenuItem<T>> items;
_UoscMenuRoute({required this.anchor, required this.title, required this.items});
@override
Color? get barrierColor => null;
@override
bool get barrierDismissible => true;
@override
String? get barrierLabel => 'Close menu';
@override
Duration get transitionDuration => Duration.zero;
@override
Widget buildPage(BuildContext context, Animation<double> animation, Animation<double> secondaryAnimation) {
return LayoutBuilder(builder: (context, constraints) {
const width = 360.0;
final screen = constraints.biggest;
final left = anchor.left.clamp(8.0, math.max(8.0, screen.width - width - 8));
final maxHeight = math.max(120.0, anchor.top - 16);
return Stack(
children: [
Positioned(
left: left.toDouble(),
bottom: screen.height - anchor.top + 6,
width: math.min(width, screen.width - 16),
child: ConstrainedBox(
constraints: BoxConstraints(maxHeight: maxHeight),
child: UoscMenuPanel<T>(
title: title,
items: items,
onPick: (v) => Navigator.of(context).pop(v),
),
),
),
],
);
});
}
}
/// The menu's panel alone (tests pump it without a route).
class UoscMenuPanel<T> extends StatefulWidget {
final String? title;
final List<UoscMenuItem<T>> items;
final ValueChanged<T> onPick;
const UoscMenuPanel({super.key, this.title, required this.items, required this.onPick});
static const rowHeight = 36.0;
@override
State<UoscMenuPanel<T>> createState() => _UoscMenuPanelState<T>();
}
class _UoscMenuPanelState<T> extends State<UoscMenuPanel<T>> {
// Long lists open on the current row.
late final _scroll = ScrollController(
initialScrollOffset: math.max(0, widget.items.indexWhere((i) => i.active) - 3) * UoscMenuPanel.rowHeight);
@override
void dispose() {
_scroll.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final title = widget.title;
return Material(
key: const ValueKey('uosc-menu'),
color: UoscStyle.panel,
borderRadius: BorderRadius.circular(UoscStyle.radius * 2),
clipBehavior: Clip.antiAlias,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (title != null)
Container(
height: 34,
alignment: Alignment.center,
decoration: const BoxDecoration(border: Border(bottom: BorderSide(color: Color(0x22FFFFFF)))),
child: Text(title,
style: const TextStyle(color: UoscStyle.dim, fontSize: UoscStyle.fontSize, fontWeight: FontWeight.w600)),
),
Flexible(
child: ListView.builder(
shrinkWrap: true,
padding: const EdgeInsets.all(4),
controller: _scroll,
itemCount: widget.items.length,
itemExtent: UoscMenuPanel.rowHeight,
itemBuilder: (context, i) => _Row<T>(item: widget.items[i], onPick: widget.onPick),
),
),
],
),
);
}
}
class _Row<T> extends StatefulWidget {
final UoscMenuItem<T> item;
final ValueChanged<T> onPick;
const _Row({super.key, required this.item, required this.onPick});
@override
State<_Row<T>> createState() => _RowState<T>();
}
class _RowState<T> extends State<_Row<T>> {
bool _hover = false;
@override
Widget build(BuildContext context) {
final item = widget.item;
final color = item.active
? UoscStyle.fill
: _hover && item.enabled
? UoscStyle.hover
: Colors.transparent;
final textColor = item.enabled ? UoscStyle.text : UoscStyle.dim.withValues(alpha: 0.4);
return MouseRegion(
cursor: item.enabled ? SystemMouseCursors.click : SystemMouseCursors.basic,
onEnter: (_) => setState(() => _hover = true),
onExit: (_) => setState(() => _hover = false),
child: GestureDetector(
key: ValueKey('uosc-menu-item-${item.title}'),
behavior: HitTestBehavior.opaque,
onTap: item.enabled ? () => widget.onPick(item.value) : null,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10),
decoration: BoxDecoration(color: color, borderRadius: BorderRadius.circular(UoscStyle.radius)),
child: Row(
children: [
Expanded(
child: Text(item.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: textColor,
fontSize: UoscStyle.fontSize,
fontWeight: item.active ? FontWeight.w600 : FontWeight.normal)),
),
if (item.hint != null) ...[
const SizedBox(width: 12),
Text(item.hint!,
style: TextStyle(
color: item.active ? UoscStyle.text : UoscStyle.dim,
fontSize: 12,
fontFeatures: const [FontFeature.tabularFigures()])),
],
],
),
),
),
);
}
}
/// Chapters with their start times, the playing one active.
List<UoscMenuItem<MediaChapter>> chapterMenuItems(List<MediaChapter> chapters, int? current, Duration duration) {
final withHours = duration >= const Duration(hours: 1);
return [
for (final c in chapters)
UoscMenuItem(
value: c,
title: c.title.isEmpty ? 'Chapter ${c.index + 1}' : c.title,
hint: formatUoscTime(c.start, withHours: withHours),
active: c.index == current,
),
];
}
/// The audio or subtitle tracks ("Japanese · E-AC3 2.0 · title"), mpv's
/// selection active; subtitles start with Off.
List<UoscMenuItem<TrackPick>> trackMenuItems(JellyfinStreamType type, List<MpvTrack> tracks) {
final audio = type == JellyfinStreamType.audio;
final list = tracks.where((t) => t.type == type).toList();
return [
if (!audio)
UoscMenuItem(
value: (aid: null, sid: null, subtitlesOff: true),
title: 'Off',
active: !list.any((t) => t.selected),
),
for (final t in list)
UoscMenuItem(
value: audio ? (aid: t.id, sid: null, subtitlesOff: false) : (aid: null, sid: t.id, subtitlesOff: false),
title: t.label,
hint: '#${t.id}',
active: t.selected,
),
];
}
@@ -1,77 +0,0 @@
import 'package:flutter/material.dart';
// Look of the experimental mpv-style controls, after the uosc theme as the
// owner runs it: flat black panels, white icons, a muted blue fill.
abstract final class UoscStyle {
/// The active menu row.
static const fill = Color(0xFF4C6A8E);
/// Menus and tooltips.
static const panel = Color(0xF0101010);
static const hover = Color(0x24FFFFFF);
static const text = Colors.white;
static const dim = Color(0xA6FFFFFF);
static const radius = 4.0;
static const fontSize = 13.0;
}
/// uosc's clock: "01:51", "1:02:03" style but zero-padded ("01:02:03")
/// once [withHours] (the media is an hour or longer).
String formatUoscTime(Duration d, {bool withHours = false}) {
final t = d.isNegative ? Duration.zero : d;
String two(int n) => n.toString().padLeft(2, '0');
final h = t.inHours, m = t.inMinutes % 60, s = t.inSeconds % 60;
return withHours || h > 0 ? '${two(h)}:${two(m)}:${two(s)}' : '${two(m)}:${two(s)}';
}
/// Dark rounded hover tip ("Subtitles"), shown above its child.
class UoscTooltip extends StatelessWidget {
final String message;
final Widget child;
const UoscTooltip({super.key, required this.message, required this.child});
@override
Widget build(BuildContext context) {
if (message.isEmpty) return child;
return Tooltip(
message: message,
preferBelow: false,
verticalOffset: 26,
waitDuration: const Duration(milliseconds: 250),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: UoscStyle.panel,
borderRadius: BorderRadius.circular(UoscStyle.radius),
),
textStyle: const TextStyle(color: UoscStyle.text, fontSize: 12),
child: child,
);
}
}
/// A small dark box with one line of text (the timeline's hover time and
/// chapter title).
class UoscBox extends StatelessWidget {
final String text;
final Color color;
final TextStyle style;
const UoscBox(
this.text, {
super.key,
this.color = UoscStyle.panel,
this.style = const TextStyle(color: UoscStyle.text, fontSize: UoscStyle.fontSize),
});
@override
Widget build(BuildContext context) {
return Container(
constraints: const BoxConstraints(maxWidth: 360),
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 3),
decoration: BoxDecoration(color: color, borderRadius: BorderRadius.circular(UoscStyle.radius)),
child: Text(text,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: style.copyWith(fontFeatures: const [FontFeature.tabularFigures()])),
);
}
}
@@ -1,645 +0,0 @@
import 'dart:async';
import 'dart:math' as math;
import 'package:flutter/material.dart';
import '../../playback/media_marks.dart';
import 'controls.dart';
import 'style.dart';
// A port of uosc's timeline (`elements/Timeline.lua`, defaults from
// `uosc.conf` / `main.lua`), style `line`, scale 1: a translucent black
// band, the position a thin white line (no filled progress), the
// uncached part hatched, chapter diamonds on the top edge, elapsed time
// left and the time remaining right, the buffered seconds next to the
// position while under the threshold, and a tooltip with the time (and
// the chapter title) under the mouse.
/// uosc's defaults at scale 1.
abstract final class UoscTimelineSpec {
/// `timeline_size`: the band's height when expanded.
static const size = 40.0;
/// `timeline_border`: band above the line, also the diamonds' border.
static const border = 1.0;
/// `timeline_line_width`: the position line.
static const lineWidth = 2.0;
/// `progress_size`: what is left when the controls hide.
static const progressSize = 2.0;
/// `progress_line_width`: the position line once minimized.
static const progressLineWidth = 20.0;
/// floor(min((size + 60) * 0.2, size * 0.96)).
static const fontSize = 20.0;
/// max(floor((size - fontSize) / 2.5), 4): the times' inset.
static const spacing = 8.0;
/// `text_border`: the outline of text drawn on the band.
static const textBorder = 1.2;
/// `opacity.timeline` (the band) and `opacity.chapters` (diamond fill).
static const opacity = 0.9;
static const chaptersOpacity = 0.8;
/// Gap between the band and its tooltips, and between tooltips.
static const tooltipGap = 2.0;
/// `buffered_time_threshold`: the cache label shows below this.
static const bufferedThreshold = Duration(seconds: 60);
/// max((size + border) / 10, 3): diamond radius cap.
static const chapterSize = (size + border) / 10;
}
/// uosc's `format_time(seconds, duration)`: mpv's "HH:MM:SS", trimmed to
/// "MM:SS" for media under an hour and "SS" under a minute; negative
/// values keep their minus ("-01:47:30").
String uoscClock(Duration d, Duration max) {
final neg = d.isNegative && d.inSeconds != 0;
final t = d.abs();
String two(int n) => n.toString().padLeft(2, '0');
final full = '${two(t.inHours)}:${two(t.inMinutes % 60)}:${two(t.inSeconds % 60)}';
final trim = max < const Duration(minutes: 1) ? 6 : (max < const Duration(hours: 1) ? 3 : 0);
return '${neg ? '-' : ''}${full.substring(trim)}';
}
/// Fraction of [total] at [d], 0..1 (0 without a duration).
double timelineFraction(Duration d, Duration total) {
final t = total.inMicroseconds;
if (t <= 0) return 0;
return (d.inMicroseconds / t).clamp(0.0, 1.0);
}
/// Where the uncached part starts (it runs to the end), as a fraction;
/// null when nothing known is uncached. We know only the end of mpv's
/// cache ahead ([buffered]); without it (0) uosc draws no ranges either,
/// and a cache that reaches the end (within uosc's 0.5 s) leaves none.
double? uncachedFrom({required Duration position, required Duration buffered, required Duration duration}) {
if (duration <= Duration.zero || buffered <= Duration.zero) return null;
final from = buffered > position ? buffered : position;
if (duration - from <= const Duration(milliseconds: 500)) return null;
return timelineFraction(from, duration);
}
/// The cache label next to the position ("12s"): the seconds buffered
/// ahead, shown while under [UoscTimelineSpec.bufferedThreshold] and the
/// cache does not reach the end (uosc drops it at eof-cached).
String? cacheLabel({required Duration position, required Duration buffered, required Duration duration}) {
if (duration <= Duration.zero || buffered <= Duration.zero) return null;
if (duration - buffered <= const Duration(milliseconds: 500)) return null;
final ahead = buffered > position ? buffered - position : Duration.zero;
if (ahead >= UoscTimelineSpec.bufferedThreshold) return null;
return '${(ahead.inMilliseconds / 1000).round()}s';
}
/// The band's geometry at one size, as uosc lays it out: the position
/// line takes [lineWidth] pixels, and times map around it (`t2x`) so no
/// time lies inside the line.
class UoscTimelineGeometry {
final double width;
/// The foreground height (without the top border).
final double size;
/// The playing position, 0..1.
final double progress;
final double lineWidth;
const UoscTimelineGeometry({
required this.width,
required this.size,
required this.progress,
this.lineWidth = UoscTimelineSpec.lineWidth,
});
/// [size] while the band animates between hidden (0) and shown (1)
/// (`get_effective_size`).
static double sizeAt(double visibility) =>
UoscTimelineSpec.progressSize +
((UoscTimelineSpec.size - UoscTimelineSpec.progressSize) * visibility).ceilToDouble();
/// The position line's width at [size]: it grows to
/// [UoscTimelineSpec.progressLineWidth] over the last eighth of the
/// collapse.
static double lineWidthAt(double size) {
const p = UoscTimelineSpec.progressSize;
final minimized = 1 - math.min((size - p) / ((UoscTimelineSpec.size - p) / 8), 1.0);
return UoscTimelineSpec.lineWidth + (UoscTimelineSpec.progressLineWidth - UoscTimelineSpec.lineWidth) * minimized;
}
double get height => size + UoscTimelineSpec.border;
/// Top of the foreground (below the border).
double get top => UoscTimelineSpec.border;
double get lineLeft => ((width - lineWidth) * progress).roundToDouble();
double get lineRight => ((width - lineWidth) * progress + lineWidth).roundToDouble();
/// x of [fraction] (uosc's `t2x`): past the position, shifted over the line.
double x(double fraction) {
final lw = lineWidth - 1;
final at = 0.5 + (width - lw - 1) * fraction;
return fraction <= progress ? at : at + lw;
}
/// The fraction at [x] (uosc's `get_time_at_x`): x on the line is the
/// position.
double fractionAt(double x) {
const lw = UoscTimelineSpec.lineWidth - 1;
final timeWidth = width - lw - 1;
if (timeWidth <= 0) return 0;
final a = timeWidth * progress, b = a + lw;
var at = x - 0.5;
if (at > b) {
at -= lw;
} else if (at > a) {
at = a;
}
return (at / timeWidth).clamp(0.0, 1.0);
}
/// Chapter diamond radius at this size; doubled while hovered.
double get diamondRadius => math.min(math.max(1.0, size * 0.8), UoscTimelineSpec.chapterSize);
}
/// The uosc timeline. [expanded] false (the controls hidden) shrinks it,
/// animated, to the progress line. Seeks ([onSeek]) on press, on every
/// drag move and on release; a press on a hovered chapter diamond jumps
/// to that chapter. The session sends those through the user-seek path.
class UoscTimeline extends StatefulWidget {
final Duration position;
final Duration duration;
/// End of the cached range ([PlaybackController.buffered]).
final Duration buffered;
final List<MediaChapter> chapters;
final bool expanded;
final ValueChanged<Duration> onSeek;
const UoscTimeline({
super.key,
required this.position,
required this.duration,
this.buffered = Duration.zero,
this.chapters = const [],
this.expanded = true,
required this.onSeek,
});
static const height = UoscTimelineSpec.size + UoscTimelineSpec.border;
static const collapsedHeight = UoscTimelineSpec.progressSize + UoscTimelineSpec.border;
/// uosc's `animation_duration`.
static const animation = Duration(milliseconds: 100);
@override
State<UoscTimeline> createState() => _UoscTimelineState();
}
class _UoscTimelineState extends State<UoscTimeline> {
Offset? _hover;
bool _dragging = false;
double _dragFraction = 0;
Duration? _lastSent;
// A released seek, shown until the position catches up (else the line
// jumps back to the old position for a moment).
double? _pending;
Timer? _pendingTimer;
Duration _at(double fraction) => widget.duration * fraction.clamp(0.0, 1.0);
void _send(Duration d) {
if (d == _lastSent) return;
_lastSent = d;
widget.onSeek(d);
}
void _drag(bool on) {
if (_dragging == on) return;
setState(() => _dragging = on);
UoscControls.maybeOf(context)?.hold(on);
}
void _settle(double fraction) {
_pending = fraction;
_pendingTimer?.cancel();
_pendingTimer = Timer(const Duration(seconds: 2), () {
if (mounted) setState(() => _pending = null);
});
}
@override
void didUpdateWidget(UoscTimeline old) {
super.didUpdateWidget(old);
if (!widget.expanded && old.expanded) _hover = null;
final p = _pending;
if (p != null && widget.duration > Duration.zero) {
final off = (widget.position - _at(p)).abs();
if (off < const Duration(milliseconds: 1500)) {
_pending = null;
_pendingTimer?.cancel();
}
}
}
@override
void dispose() {
_pendingTimer?.cancel();
// A drag cut short by a rebuild must not keep the controls up.
if (_dragging) UoscControls.maybeOf(context)?.hold(false);
super.dispose();
}
/// The chapter whose diamond is within twice its radius of [at].
int? _hoveredChapter(UoscTimelineGeometry geo, List<MediaChapter> marks, Offset? at) {
if (at == null || _dragging) return null;
final reach = geo.diamondRadius * 2;
int? best;
var bestD = double.infinity;
for (var i = 0; i < marks.length; i++) {
final cx = geo.x(timelineFraction(marks[i].start, widget.duration));
final d = (Offset(cx, 0) - at).distance;
if (d <= reach && d < bestD) {
best = i;
bestD = d;
}
}
return best;
}
@override
Widget build(BuildContext context) {
final font = DefaultTextStyle.of(context).style.fontFamily ?? Theme.of(context).textTheme.bodyMedium?.fontFamily;
return LayoutBuilder(
builder: (context, constraints) {
final width = constraints.maxWidth;
return TweenAnimationBuilder<double>(
tween: Tween(end: widget.expanded ? 1 : 0),
duration: UoscTimeline.animation,
builder: (context, visibility, _) => _band(width, visibility, font),
);
},
);
}
Widget _band(double width, double visibility, String? font) {
final expanded = widget.expanded;
final duration = widget.duration;
final size = UoscTimelineGeometry.sizeAt(visibility);
final progress = _dragging ? _dragFraction : (_pending ?? timelineFraction(widget.position, duration));
final shown = _at(progress);
final geo = UoscTimelineGeometry(
width: width,
size: size,
progress: progress,
lineWidth: UoscTimelineGeometry.lineWidthAt(size),
);
final marks = duration > Duration.zero ? widget.chapters : const <MediaChapter>[];
final hover = expanded ? _hover : null;
final hovered = _hoveredChapter(geo, marks, hover);
final hoverFraction = hovered != null
? timelineFraction(marks[hovered].start, duration)
: (hover == null || duration <= Duration.zero ? null : geo.fractionAt(hover.dx));
final hoverAt = hoverFraction == null ? null : _at(hoverFraction);
final cursorX = hovered != null ? geo.x(hoverFraction!) : hover?.dx;
final hoverChapter = hoverAt == null ? null : chapterIndexAt(widget.chapters, hoverAt);
final hoverTitle = hoverChapter == null ? '' : widget.chapters[hoverChapter].title;
// Text fades out just before it would overflow the shrinking band.
const hideBelow = UoscTimelineSpec.fontSize * 0.8;
final textOpacity = (size - hideBelow).clamp(0.0, hideBelow / 2) / (hideBelow / 2);
final radius = geo.diamondRadius;
final timestampGap = UoscTimelineSpec.tooltipGap + (hovered != null ? radius * 2 : radius).roundToDouble();
final painter = UoscTimelinePainter(
geometry: geo,
visibility: visibility,
uncached: uncachedFrom(position: widget.position, buffered: widget.buffered, duration: duration),
chapters: [for (final c in marks) timelineFraction(c.start, duration)],
hoveredChapter: hovered,
hoverX: cursorX,
textOpacity: textOpacity,
elapsed: duration > Duration.zero ? uoscClock(shown, duration) : null,
remaining: duration > Duration.zero ? uoscClock(shown - duration, duration) : null,
cache: cacheLabel(position: widget.position, buffered: widget.buffered, duration: duration),
fontFamily: font,
);
final bar = SizedBox(
key: const ValueKey('uosc-timeline'),
width: width,
height: geo.height,
child: Stack(
clipBehavior: Clip.none,
children: [
Positioned.fill(
child: RepaintBoundary(child: CustomPaint(painter: painter)),
),
if (hoverAt != null && cursorX != null)
Positioned(
left: 0,
right: 0,
bottom: geo.height + timestampGap,
height: 160,
child: IgnorePointer(
child: CustomMultiChildLayout(
delegate: _TooltipLayout(x: cursorX),
children: [
LayoutId(
id: #time,
child: UoscBox(
uoscClock(hoverAt, duration),
key: const ValueKey('uosc-hover-time'),
color: Colors.black,
style: _tipStyle,
),
),
if (hoverTitle.isNotEmpty)
LayoutId(
id: #chapter,
child: UoscBox(
hoverTitle,
key: const ValueKey('uosc-hover-chapter'),
color: Colors.black,
style: _tipStyle.copyWith(fontWeight: FontWeight.bold),
),
),
],
),
),
),
],
),
);
if (!expanded) return IgnorePointer(child: bar);
Offset clampTo(Offset p) => Offset(p.dx.clamp(0.0, width), p.dy);
return MouseRegion(
cursor: SystemMouseCursors.click,
onEnter: (e) => setState(() => _hover = clampTo(e.localPosition)),
onHover: (e) => setState(() => _hover = clampTo(e.localPosition)),
onExit: (_) {
if (!_dragging) setState(() => _hover = null);
},
child: Listener(
behavior: HitTestBehavior.opaque,
onPointerDown: (e) {
if (duration <= Duration.zero || width <= 0) return;
final at = clampTo(e.localPosition);
final chapter = _hoveredChapter(geo, marks, at);
_lastSent = null;
if (chapter != null) {
final f = timelineFraction(marks[chapter].start, duration);
_settle(f);
_send(marks[chapter].start);
setState(() {});
return;
}
_pending = null;
_dragFraction = geo.fractionAt(at.dx);
_drag(true);
_send(_at(_dragFraction));
},
onPointerMove: (e) {
if (!_dragging) return;
final at = clampTo(e.localPosition);
setState(() {
_hover = at;
_dragFraction = geo.fractionAt(at.dx);
});
_send(_at(_dragFraction));
},
onPointerUp: (e) {
if (!_dragging) return;
_send(_at(_dragFraction));
_settle(_dragFraction);
final p = e.localPosition;
if (p.dx < 0 || p.dx > width || p.dy < 0 || p.dy > geo.height) _hover = null;
_drag(false);
},
onPointerCancel: (_) => _drag(false),
child: bar,
),
);
}
static const _tipStyle = TextStyle(color: UoscStyle.text, fontSize: UoscTimelineSpec.fontSize, height: 1);
}
/// The hover tooltips: the time centred on [x], the chapter title over
/// it; each kept [UoscTimelineSpec.tooltipGap] inside the band's ends.
class _TooltipLayout extends MultiChildLayoutDelegate {
final double x;
_TooltipLayout({required this.x});
@override
void performLayout(Size size) {
const gap = UoscTimelineSpec.tooltipGap;
double left(double w) => (x - w / 2).clamp(gap, math.max(gap, size.width - gap - w));
final loose = BoxConstraints.loose(size);
final time = layoutChild(#time, loose);
final timeTop = size.height - time.height;
positionChild(#time, Offset(left(time.width), timeTop));
if (hasChild(#chapter)) {
final chapter = layoutChild(#chapter, loose);
positionChild(#chapter, Offset(left(chapter.width), timeTop - gap - chapter.height));
}
}
@override
bool shouldRelayout(_TooltipLayout old) => old.x != x;
}
/// Paints the band as uosc does: background, the position line, the
/// uncached hatching, chapter diamonds, the times, the hover line.
class UoscTimelinePainter extends CustomPainter {
final UoscTimelineGeometry geometry;
/// 0 hidden .. 1 shown (picks the hatching texture and its strength).
final double visibility;
/// Start of the uncached part (to the end), as a fraction.
final double? uncached;
/// Chapter starts, as fractions.
final List<double> chapters;
final int? hoveredChapter;
/// The hover line (the mouse, or the hovered chapter).
final double? hoverX;
final double textOpacity;
final String? elapsed;
final String? remaining;
final String? cache;
final String? fontFamily;
const UoscTimelinePainter({
required this.geometry,
this.visibility = 1,
this.uncached,
this.chapters = const [],
this.hoveredChapter,
this.hoverX,
this.textOpacity = 1,
this.elapsed,
this.remaining,
this.cache,
this.fontFamily,
});
double get played => geometry.progress;
@override
void paint(Canvas canvas, Size size) {
final g = geometry;
final w = size.width, bottom = size.height, top = g.top;
final line = Rect.fromLTRB(g.lineLeft, top, g.lineRight, bottom);
// Background (`opacity.timeline`) around the position line.
final bg = Path()
..fillType = PathFillType.evenOdd
..addRect(Offset.zero & size)
..addRect(line);
canvas.drawPath(bg, Paint()..color = Colors.black.withValues(alpha: UoscTimelineSpec.opacity));
canvas.drawRect(line, Paint()..color = Colors.white);
_hatch(canvas, w, top, bottom);
_chapters(canvas);
if (textOpacity > 0) _times(canvas, w, top, bottom, line);
final x = hoverX;
if (x != null) {
final onLine = line.left - 0.5 < x && x < line.right + 0.5;
canvas.drawRect(
Rect.fromLTRB(x - 0.5, top, x + 0.5, bottom),
Paint()..color = (onLine ? Colors.black : Colors.white).withValues(alpha: 0.33),
);
}
}
/// uosc's texture "b" (shown) or "a" (hidden) at 80 px: "/" stripes
/// 1.6 px thick, white then black shifted by size / 24 (or / 28).
void _hatch(Canvas canvas, double w, double top, double bottom) {
final from = uncached;
if (from == null) return;
final ax = from * (geometry.width) < 0.5 ? 0.0 : geometry.x(from).floorToDouble();
if (ax >= w) return;
final shown = visibility > 0;
const tile = 80.0, unit = tile / 100;
final period = (shown ? 100 / 12 : 100 / 14) * unit;
final phase = shown ? 0.0 : 5.727 * unit;
final offset = tile / (shown ? 24 : 28);
final h = bottom - top;
canvas.save();
canvas.clipRect(Rect.fromLTRB(ax, top, w, bottom));
void stripes(Color color, double opacity, double anchor) {
final paint = Paint()
..color = color.withValues(alpha: opacity)
..strokeWidth = 2 * unit;
// x + (y - bottom) = anchor + phase + k * period.
final base = anchor + phase;
final k0 = ((ax - h - base) / period).floor() - 1;
for (var k = k0; base + k * period <= w + 1; k++) {
final c = base + k * period;
canvas.drawLine(Offset(c, bottom), Offset(c + h, top), paint);
}
}
stripes(Colors.white, 0.4 - 0.2 * visibility, 0);
stripes(Colors.black, 0.6 - 0.2 * visibility, offset);
canvas.restore();
}
void _chapters(Canvas canvas) {
final g = geometry;
final r = g.diamondRadius;
if (r <= 0) return;
void diamond(double fraction, double radius) {
final c = Offset(g.x(fraction), 0);
Path shape(double r) => Path()
..moveTo(c.dx - r, c.dy)
..lineTo(c.dx, c.dy - r)
..lineTo(c.dx + r, c.dy)
..lineTo(c.dx, c.dy + r)
..close();
final inner = shape(radius);
// A 1 px border outside the fill (`\bord`), the fill translucent.
final outer = shape(radius + UoscTimelineSpec.border * math.sqrt2);
canvas.drawPath(Path.combine(PathOperation.difference, outer, inner), Paint()..color = Colors.black);
canvas.drawPath(inner, Paint()..color = Colors.white.withValues(alpha: UoscTimelineSpec.chaptersOpacity));
}
for (var i = 0; i < chapters.length; i++) {
if (i != hoveredChapter) diamond(chapters[i], r);
}
final h = hoveredChapter;
if (h != null && h < chapters.length) diamond(chapters[h], r * 2);
}
TextPainter _text(String s, double fontSize, Color color, {Paint? foreground}) => TextPainter(
text: TextSpan(
text: s,
style: TextStyle(
fontFamily: fontFamily,
fontSize: fontSize,
height: 1,
color: foreground == null ? color : null,
foreground: foreground,
fontFeatures: const [FontFeature.tabularFigures()],
),
),
textDirection: TextDirection.ltr,
)..layout();
/// Text on the band: white with a black outline, inverted where it
/// crosses the position line (uosc clips one copy to each).
void _label(Canvas canvas, String s, double fontSize, double opacity, Offset at, Rect line, Size size) {
void copy(Color fill, Color outline) {
_text(
s,
fontSize,
fill,
foreground: Paint()
..style = PaintingStyle.stroke
..strokeWidth = UoscTimelineSpec.textBorder * 2
..strokeJoin = StrokeJoin.round
..color = outline.withValues(alpha: opacity),
).paint(canvas, at);
_text(s, fontSize, fill.withValues(alpha: opacity)).paint(canvas, at);
}
canvas.save();
canvas.clipPath(
Path()
..fillType = PathFillType.evenOdd
..addRect(Rect.fromLTWH(-20, -20, size.width + 40, size.height + 40))
..addRect(line),
);
copy(Colors.white, Colors.black);
canvas.restore();
canvas.save();
canvas.clipRect(line);
copy(Colors.black, Colors.white);
canvas.restore();
}
void _times(Canvas canvas, double w, double top, double bottom, Rect line) {
const fs = UoscTimelineSpec.fontSize, spacing = UoscTimelineSpec.spacing;
final size = Size(w, bottom);
final cy = top + (bottom - top) / 2;
final e = elapsed, r = remaining;
final ew = e == null ? 0.0 : _text(e, fs, Colors.white).width;
final rw = r == null ? 0.0 : _text(r, fs, Colors.white).width;
final c = cache;
if (c != null) {
const cfs = fs * 0.8, margin = 5.0;
final cw = _text(c, cfs, Colors.white).width;
var x = line.right + margin;
final minX = spacing + margin + ew, maxX = w - spacing - margin - rw;
if (x < minX) {
x = minX;
} else if (x + cw > maxX) {
x = maxX - cw;
}
_label(canvas, c, cfs, textOpacity * 0.6, Offset(x, cy - cfs / 2), line, size);
}
if (e != null) _label(canvas, e, fs, textOpacity, Offset(spacing, cy - fs / 2), line, size);
if (r != null) _label(canvas, r, fs, textOpacity, Offset(w - spacing - rw, cy - fs / 2), line, size);
}
@override
bool shouldRepaint(UoscTimelinePainter old) => true;
}
@@ -1,128 +0,0 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import '../volume.dart' show maxVolume;
// uosc's volume slider (`elements/Volume.lua`) as it flashes on a volume
// change: a vertical band at the right edge, black at `opacity.slider`
// with a white gauge from the bottom, notches at 100 %, the value at the
// bottom (black on the gauge, white off it).
/// The flashed uosc volume slider; size it with [UoscVolumeSlider.width]
/// and the height uosc gives it (see [UoscVolumeSlider.heightFor]).
class UoscVolumeSlider extends StatelessWidget {
/// 0..[maxVolume].
final double volume;
const UoscVolumeSlider({super.key, required this.volume});
/// `volume_size`.
static const width = 40.0;
/// uosc: min(size * 8, 80 % of the room between the bars), less the
/// mute button (one [width]) below it.
static double heightFor(double room) => math.min(width * 8, room * 0.8) - width;
@override
Widget build(BuildContext context) => CustomPaint(
key: const ValueKey('uosc-volume-flash'),
painter: UoscVolumePainter(
volume: volume,
fontFamily: DefaultTextStyle.of(context).style.fontFamily ?? Theme.of(context).textTheme.bodyMedium?.fontFamily,
),
);
}
class UoscVolumePainter extends CustomPainter {
final double volume;
final String? fontFamily;
const UoscVolumePainter({required this.volume, this.fontFamily});
/// `volume_border`, `border_radius`.
static const border = 1.0;
static const radius = 4.0;
@override
void paint(Canvas canvas, Size size) {
final w = size.width, h = size.height;
if (w <= 0 || h <= w) return;
final v = volume.clamp(0.0, maxVolume);
final nudgeY = h - (h * (100 / maxVolume)).roundToDouble();
final nudge = (w * 0.18).roundToDouble();
final spacing = (w * 0.2).roundToDouble();
Path notches(double inset) {
final n = nudge - inset;
return Path()
..moveTo(0, nudgeY - nudge)
..lineTo(inset + n, nudgeY)
..lineTo(0, nudgeY + nudge)
..close()
..moveTo(w, nudgeY - nudge)
..lineTo(w - inset - n, nudgeY)
..lineTo(w, nudgeY + nudge)
..close();
}
final bg = RRect.fromRectAndRadius(Offset.zero & size, const Radius.circular(radius + border));
final volumeY = border + (h - border * 2) * (1 - math.min(v / maxVolume, 1));
final gauge = RRect.fromRectAndRadius(
Rect.fromLTRB(border, math.min(volumeY, h - border), w - border, h - border),
const Radius.circular(radius),
);
// The notches cut into both.
canvas.save();
canvas.clipPath(
Path()
..fillType = PathFillType.evenOdd
..addRect(Offset.zero & size)
..addPath(notches(0), Offset.zero),
);
canvas.drawPath(
Path()
..fillType = PathFillType.evenOdd
..addRRect(bg)
..addRRect(gauge),
Paint()..color = Colors.black.withValues(alpha: 0.9),
);
canvas.restore();
canvas.save();
canvas.clipPath(
Path()
..fillType = PathFillType.evenOdd
..addRect(Offset.zero & size)
..addPath(notches(border), Offset.zero),
);
canvas.drawRRect(gauge, Paint()..color = Colors.white);
canvas.restore();
// round(volume * 10) / 10, as Lua prints it ("85", "85.5").
final tenths = (v * 10).round();
final text = tenths % 10 == 0 ? '${tenths ~/ 10}' : (tenths / 10).toString();
final fontSize = (w * 0.6 - text.length * (w / 20)).roundToDouble();
TextPainter label(Color color) => TextPainter(
text: TextSpan(
text: text,
style: TextStyle(fontFamily: fontFamily, fontSize: fontSize, height: 1, color: color),
),
textDirection: TextDirection.ltr,
)..layout();
final dark = label(Colors.black), light = label(Colors.white);
final at = Offset((w - dark.width) / 2, h - spacing - dark.height);
canvas.save();
canvas.clipRRect(gauge);
dark.paint(canvas, at);
canvas.restore();
canvas.save();
canvas.clipPath(
Path()
..fillType = PathFillType.evenOdd
..addRect(Offset.zero & size)
..addRRect(gauge),
);
light.paint(canvas, at);
canvas.restore();
}
@override
bool shouldRepaint(UoscVolumePainter old) => old.volume != volume || old.fontFamily != fontFamily;
}
+58 -47
View File
@@ -3,7 +3,6 @@ import 'dart:async';
import 'package:flutter/foundation.dart' show ValueListenable; import 'package:flutter/foundation.dart' show ValueListenable;
import 'package:flutter/gestures.dart'; import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:media_kit/media_kit.dart';
import '../playback/playback_controller.dart' show PlaybackController; import '../playback/playback_controller.dart' show PlaybackController;
import '../widgets/app_menu.dart' show AppGlass; import '../widgets/app_menu.dart' show AppGlass;
@@ -90,7 +89,7 @@ class VolumeFlash extends StatefulWidget {
final WidgetBuilder builder; final WidgetBuilder builder;
const VolumeFlash({super.key, required this.trigger, required this.builder}); const VolumeFlash({super.key, required this.trigger, required this.builder});
/// uosc's `flash_duration`. /// How long the readout stays after the last step.
static const hold = Duration(seconds: 1); static const hold = Duration(seconds: 1);
@override @override
@@ -142,7 +141,7 @@ class _VolumeFlashState extends State<VolumeFlash> {
); );
} }
/// The standard design's volume readout for [VolumeFlash]: a glass pill /// The volume readout for [VolumeFlash] (both skins): a glass pill
/// (the player's pill look) with the icon, a [VolumeBar] and the /// (the player's pill look) with the icon, a [VolumeBar] and the
/// percentage. /// percentage.
class VolumeReadout extends StatelessWidget { class VolumeReadout extends StatelessWidget {
@@ -159,11 +158,7 @@ class VolumeReadout extends StatelessWidget {
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Icon( Icon(volumeIcon(v), size: 20, color: v > 100 ? boostColor : Colors.white),
v <= 0 ? Icons.volume_off : (v < 50 ? Icons.volume_down : Icons.volume_up),
size: 20,
color: v > 100 ? boostColor : Colors.white,
),
const SizedBox(width: 8), const SizedBox(width: 8),
VolumeBar(volume: v, onChanged: (_) {}, width: 96), VolumeBar(volume: v, onChanged: (_) {}, width: 96),
const SizedBox(width: 10), const SizedBox(width: 10),
@@ -246,64 +241,82 @@ class _VolumePainter extends CustomPainter {
bool shouldRepaint(_VolumePainter old) => old.volume != volume || old.color != color || old.trackColor != trackColor; bool shouldRepaint(_VolumePainter old) => old.volume != volume || old.color != color || old.trackColor != trackColor;
} }
/// Volume for the standard bar (instead of media_kit's, which stops at /// The speaker icon for [volume].
/// 100): the icon mutes / unmutes, hovering shows a [VolumeBar], the IconData volumeIcon(double volume) =>
/// wheel steps [volumeStep]. volume <= 0 ? Icons.volume_off_rounded : (volume < 50 ? Icons.volume_down_rounded : Icons.volume_up_rounded);
class VolumeButton extends StatefulWidget {
final Player player; /// Builds a skin's button for the [VolumeControl].
final FocusNode? focusNode; typedef VolumeButtonBuilder = Widget Function(
const VolumeButton({super.key, required this.player, this.focusNode}); BuildContext context,
IconData icon,
Color? color,
String tooltip,
VoidCallback onPressed,
);
/// The bar's volume, in both skins: the skin's speaker button (click
/// mutes / unmutes), a [VolumeBar] sliding out on hover, the wheel on it
/// stepping [volumeStep] (claimed, so the player's wheel skips it).
class VolumeControl extends StatefulWidget {
/// 0..[maxVolume].
final double volume;
final ValueChanged<double> onChanged;
final VoidCallback onToggleMute;
final VolumeButtonBuilder button;
final Color sliderColor;
final Color trackColor;
final double sliderWidth;
const VolumeControl({
super.key,
required this.volume,
required this.onChanged,
required this.onToggleMute,
required this.button,
this.sliderColor = Colors.white,
this.trackColor = Colors.white30,
this.sliderWidth = 72,
});
@override @override
State<VolumeButton> createState() => _VolumeButtonState(); State<VolumeControl> createState() => _VolumeControlState();
} }
class _VolumeButtonState extends State<VolumeButton> { class _VolumeControlState extends State<VolumeControl> {
final _steps = WheelSteps(); final _steps = WheelSteps();
bool _hover = false; bool _hover = false;
double _beforeMute = 100;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final player = widget.player; final v = widget.volume;
return StreamBuilder<double>(
stream: player.stream.volume,
initialData: player.state.volume,
builder: (context, snap) {
final v = snap.data ?? 100;
return MouseRegion( return MouseRegion(
onEnter: (_) => setState(() => _hover = true), onEnter: (_) => setState(() => _hover = true),
onExit: (_) => setState(() => _hover = false), onExit: (_) => setState(() => _hover = false),
child: Listener( child: Listener(
// Claimed here, so the player's own wheel handler skips it. onPointerSignal: (e) => _steps.handle(e, (step) => widget.onChanged(stepVolume(v, step * volumeStep))),
onPointerSignal: (e) =>
_steps.handle(e, (step) => player.setVolume(stepVolume(player.state.volume, step * volumeStep))),
child: Row( child: Row(
key: const ValueKey('volume-control'),
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
const SizedBox(width: 4), widget.button(
IconButton( context,
key: const ValueKey('volume-button'), volumeIcon(v),
focusNode: widget.focusNode, v > 100 ? boostColor : null,
tooltip: 'Volume ${v.round()} %', 'Volume ${v.round()} % (scroll, ↑ / ↓)',
iconSize: 22, widget.onToggleMute,
color: Colors.white,
onPressed: () {
if (v > 0) {
_beforeMute = v;
player.setVolume(0);
} else {
player.setVolume(_beforeMute <= 0 ? 100 : _beforeMute);
}
},
icon: Icon(v <= 0 ? Icons.volume_off : (v < 50 ? Icons.volume_down : Icons.volume_up)),
), ),
AnimatedSize( AnimatedSize(
duration: const Duration(milliseconds: 150), duration: const Duration(milliseconds: 150),
child: _hover child: _hover
? Padding( ? Padding(
padding: const EdgeInsets.only(left: 4, right: 12), padding: const EdgeInsets.only(left: 4, right: 8),
child: VolumeBar(volume: v, onChanged: player.setVolume), child: VolumeBar(
key: const ValueKey('volume-bar'),
volume: v,
onChanged: widget.onChanged,
width: widget.sliderWidth,
color: widget.sliderColor,
trackColor: widget.trackColor,
),
) )
: const SizedBox.shrink(), : const SizedBox.shrink(),
), ),
@@ -311,7 +324,5 @@ class _VolumeButtonState extends State<VolumeButton> {
), ),
), ),
); );
},
);
} }
} }
+25 -2
View File
@@ -33,15 +33,36 @@ const _timeout = Duration(seconds: 20);
int _running = 0; int _running = 0;
final _waiting = <Completer<void>>[]; final _waiting = <Completer<void>>[];
final _inFlight = <String, Future<Thumbnail?>>{}; final _inFlight = <String, Future<Thumbnail?>>{};
final _players = <Player>{};
bool _closed = false;
/// App close: no new thumbnails, running ones stop at their next check
/// and dispose their player. Players still up after [deadline] are
/// disposed here, so no mpv outlives the engine.
Future<void> cancelAllThumbnails({
Duration deadline = const Duration(milliseconds: 800),
}) async {
_closed = true;
try {
await Future.wait(_inFlight.values.toList()).timeout(deadline);
} catch (_) {}
for (final p in _players.toList()) {
_players.remove(p);
try {
await p.dispose().timeout(deadline);
} catch (_) {}
}
}
/// The cached or freshly made thumbnail of the video at [path], or null /// The cached or freshly made thumbnail of the video at [path], or null
/// when it can't be made (not a video, unreadable, timed out, cancelled). /// when it can't be made (not a video, unreadable, timed out, cancelled).
/// Never throws. [cancelled] is checked while queued and before the /// Never throws. [cancelled] is checked while queued and before the
/// expensive steps: a scrolled-away tile returns null without decoding. /// expensive steps: a scrolled-away tile returns null without decoding.
Future<Thumbnail?> thumbnailFor(String path, {bool Function()? cancelled}) { Future<Thumbnail?> thumbnailFor(String path, {bool Function()? cancelled}) {
if (_closed) return Future.value(null);
final existing = _inFlight[path]; final existing = _inFlight[path];
if (existing != null) return existing; if (existing != null) return existing;
final f = _thumbnailFor(path, cancelled ?? () => false).catchError(( final f = _thumbnailFor(path, () => _closed || (cancelled?.call() ?? false)).catchError((
Object e, Object e,
StackTrace st, StackTrace st,
) { ) {
@@ -136,6 +157,7 @@ Future<Thumbnail?> _generate(
bufferSize: 4 << 20, bufferSize: 4 << 20,
), ),
); );
_players.add(player);
try { try {
final mpv = player.platform as NativePlayer; final mpv = player.platform as NativePlayer;
for (final (k, v) in [ for (final (k, v) in [
@@ -176,6 +198,7 @@ Future<Thumbnail?> _generate(
} }
return null; return null;
} finally { } finally {
await player.dispose(); // [cancelAllThumbnails] may have taken it already.
if (_players.remove(player)) await player.dispose();
} }
} }
+11 -12
View File
@@ -1,11 +1,12 @@
import 'dart:async'; import 'dart:async';
import 'dart:io'; import 'dart:io';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:signals/signals_flutter.dart'; import 'package:signals/signals_flutter.dart';
import '../../shell/motion.dart'; import '../../shell/motion.dart';
import '../../platform/ports/folder_access.dart';
import '../../state/session_state.dart';
import '../../widgets/app_menu.dart'; import '../../widgets/app_menu.dart';
import '../folder_store.dart'; import '../folder_store.dart';
import '../name_parser.dart'; import '../name_parser.dart';
@@ -32,6 +33,8 @@ String? _lastOpen;
final _expanded = <String>{}; final _expanded = <String>{};
final _showRequests = ValueNotifier(0); final _showRequests = ValueNotifier(0);
FolderAccess get _folders => SessionState.instance.ports.folders;
/// Opens [dir] (null: Recent) in the Files page: now if one is showing, /// Opens [dir] (null: Recent) in the Files page: now if one is showing,
/// else when it next opens. /// else when it next opens.
void showFilesFolder(String? dir) { void showFilesFolder(String? dir) {
@@ -60,7 +63,7 @@ class FilesPage extends StatefulWidget {
final String? nowPlayingPath; final String? nowPlayingPath;
/// Defaults to [thumbnailFor]. /// Defaults to [thumbnailFor].
final ThumbnailLoader? thumbnails; final ThumbnailLoader? thumbnails;
/// Defaults to [scanFolder]. /// Defaults to [FolderAccess.scan].
final Future<FolderScan> Function(String dir)? scan; final Future<FolderScan> Function(String dir)? scan;
/// Defaults to [FolderStore.instance]. /// Defaults to [FolderStore.instance].
final FolderStore? store; final FolderStore? store;
@@ -142,7 +145,7 @@ class _FilesPageState extends State<FilesPage> {
void _shown() => _select(_lastOpen); void _shown() => _select(_lastOpen);
Future<FolderScan> _runScan(String dir) => (widget.scan ?? scanFolder)(dir); Future<FolderScan> _runScan(String dir) => (widget.scan ?? _folders.scan)(dir);
void _select(String? dir) { void _select(String? dir) {
setState(() { setState(() {
@@ -176,7 +179,7 @@ class _FilesPageState extends State<FilesPage> {
String? dir; String? dir;
try { try {
dir = await (widget.pickFolder ?? dir = await (widget.pickFolder ??
() => FilePicker.getDirectoryPath(dialogTitle: 'Add a folder with videos'))(); () => _folders.pickFolder(dialogTitle: 'Add a folder with videos'))();
} catch (e) { } catch (e) {
_showError('Folder dialog unavailable (${e.runtimeType})'); _showError('Folder dialog unavailable (${e.runtimeType})');
} }
@@ -196,12 +199,8 @@ class _FilesPageState extends State<FilesPage> {
} }
static Future<String?> _pickVideo() async { static Future<String?> _pickVideo() async {
final files = await FilePicker.pickFiles( final files = await _folders.pickFiles(dialogTitle: 'Pick a video', extensions: videoExtensions.toList());
dialogTitle: 'Pick a video', return files.firstOrNull;
type: FileType.custom,
allowedExtensions: videoExtensions.toList(),
);
return files.isEmpty ? null : files.first.path;
} }
void _removeFolder(String dir) { void _removeFolder(String dir) {
@@ -589,7 +588,7 @@ class _FilesPageState extends State<FilesPage> {
), ),
if (scan.videos.isEmpty) if (scan.videos.isEmpty)
SliverToBoxAdapter( SliverToBoxAdapter(
child: Directory(dir).existsSync() child: _folders.folderExists(dir)
? const FilesEmpty( ? const FilesEmpty(
Icons.search_off, Icons.search_off,
'No videos here', 'No videos here',
@@ -701,7 +700,7 @@ class _RecentRowState extends State<_RecentRow> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
File(widget.path).stat().then((s) { _folders.stat(widget.path).then((s) {
if (mounted) setState(() => _stat = s); if (mounted) setState(() => _stat = s);
}, onError: (Object _) {}); }, onError: (Object _) {});
} }
@@ -4,6 +4,7 @@ import 'package:flutter/foundation.dart';
import 'package:sync_relay_core/core.dart'; import 'package:sync_relay_core/core.dart';
import '../screens/home/picked_media.dart'; import '../screens/home/picked_media.dart';
import '../session/party.dart' show isPresent;
import 'accounts.dart'; import 'accounts.dart';
import 'format.dart'; import 'format.dart';
@@ -333,13 +334,13 @@ Duration nextPromptStart(JellyfinItem? item, Duration runtime, {Duration? ending
/// Who performs the auto-advance swap, so several counting clients do not /// Who performs the auto-advance swap, so several counting clients do not
/// swap twice: the lowest member id among members that play the item with /// swap twice: the lowest member id among members that play the item with
/// their own account (`jf[serverId] == true`, not stale, not out). Only what every /// their own account (`jf[serverId] == true`, present: `in` or joining). Only what every
/// client sees in `state` decides (who put the item up is not in it: a /// client sees in `state` decides (who put the item up is not in it: a
/// late joiner never saw that swap), so all clients agree. /// late joiner never saw that swap), so all clients agree.
String? autoAdvanceSwapper(List<MemberView> members, String serverId) { String? autoAdvanceSwapper(List<MemberView> members, String serverId) {
final eligible = [ final eligible = [
for (final m in members) for (final m in members)
if (!m.stale && !m.out && m.jf[serverId] == true) m if (isPresent(m) && m.jf[serverId] == true) m
]; ];
if (eligible.isEmpty) return null; if (eligible.isEmpty) return null;
eligible.sort((a, b) => compareMemberIds(a.id, b.id)); eligible.sort((a, b) => compareMemberIds(a.id, b.id));
+13 -10
View File
@@ -2,11 +2,10 @@ import 'dart:async';
import 'dart:io'; import 'dart:io';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:path_provider/path_provider.dart';
import 'package:sync_relay_core/core.dart'; import 'package:sync_relay_core/core.dart';
import '../playback/yt_dlp_app.dart';
import '../screens/home/ui.dart' show appName, displayVersion; import '../screens/home/ui.dart' show appName, displayVersion;
import 'ports/app_dirs.dart';
import '../state/session_state.dart'; import '../state/session_state.dart';
// The app's log on disk (<app support>/logs/app-YYYY-MM-DD.log; core's // The app's log on disk (<app support>/logs/app-YYYY-MM-DD.log; core's
@@ -18,13 +17,11 @@ final _appLog = createLogger('app');
/// The logs folder, once [startAppLogging] ran. /// The logs folder, once [startAppLogging] ran.
String? appLogDir; String? appLogDir;
/// Opens the log files and routes `debugPrint` and uncaught errors into /// Opens the log files in [dirs]' log folder and routes `debugPrint` and
/// the log. Needs Flutter's binding (path_provider). /// uncaught errors into the log.
Future<void> startAppLogging() async { Future<void> startAppLogging(AppDirs dirs) async {
try { try {
final base = await getApplicationSupportDirectory(); if (startLogFiles(dirs.logs) != null) appLogDir = dirs.logs;
final dir = '${base.path}${Platform.pathSeparator}logs';
if (startLogFiles(dir) != null) appLogDir = dir;
} catch (e) { } catch (e) {
_appLog.warning('no log files: $e'); _appLog.warning('no log files: $e');
} }
@@ -40,7 +37,13 @@ Future<void> startAppLogging() async {
return false; return false;
}; };
_appLog.info('$appName ${displayVersion()} on ${Platform.operatingSystem} ' _appLog.info('$appName ${displayVersion()} on ${Platform.operatingSystem} '
'${Platform.operatingSystemVersion}, pid $pid, Dart ${Platform.version.split(' ').first}'); '${Platform.operatingSystemVersion}, pid $pid, Dart ${Platform.version.split(' ').first}'
'${dirs.profile == null ? '' : ', profile ${dirs.profile}'}');
for (final line in dirs.migrated) {
_appLog.info(line);
}
final bad = AppDirs.invalidProfile(Platform.environment);
if (bad != null) _appLog.warning('$profileEnv="$bad" ignored: use letters, digits, - and _ (at most 32)');
} }
/// The text "Copy diagnostics" gives: versions, then the last [since] of /// The text "Copy diagnostics" gives: versions, then the last [since] of
@@ -59,7 +62,7 @@ Future<String> diagnosticsText({Duration since = const Duration(hours: 6), bool
b.writeln('mpv: unknown'); b.writeln('mpv: unknown');
} }
try { try {
final y = await AppYtDlp.instance.ytDlp.timeout(const Duration(seconds: 2)); final y = await SessionState.instance.ports.links.ytDlp.timeout(const Duration(seconds: 2));
final cookies = y.cookies.read(count: false); final cookies = y.cookies.read(count: false);
b.writeln('yt-dlp: ${y.version ?? 'not ready'}${y.lastError == null ? '' : ' (${y.lastError})'}, ' b.writeln('yt-dlp: ${y.version ?? 'not ready'}${y.lastError == null ? '' : ' (${y.lastError})'}, '
'cookies: ${cookies == null ? 'none' : 'set'}'); 'cookies: ${cookies == null ? 'none' : 'set'}');
@@ -0,0 +1,71 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/foundation.dart';
import '../files/thumbnails.dart';
import '../state/session_state.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
// calls a dead Dart callback), then the network goodbye, then the window
// closes through the platform's normal path so the engine is torn down
// inside the message loop. The runners end the process right after
// (`linux/runner/main.cc`, `windows/runner/main.cpp`).
bool _shuttingDown = false;
/// True once [shutDownApp] started.
bool get shuttingDown => _shuttingDown;
/// Hides the window, releases every player and the sound device, leaves
/// (the `bye` goes out first, so others see "left" at once; a hosted
/// server stops), runs [beforeQuit], then closes the window. [restart]
/// (Update and restart) leaves without the `bye`: the member stays
/// `gone`, and the restarted app, same client id, takes it back within
/// the grace. Every step is bounded; a second call does nothing.
Future<void> shutDownApp({Future<void> Function()? beforeQuit, bool restart = false}) async {
if (_shuttingDown) return;
_shuttingDown = true;
final session = SessionState.instance;
final shell = session.ports.shell;
final total = Stopwatch()..start();
await _step('window save', shell.saveWindow(), const Duration(milliseconds: 500));
await _step('hide', shell.hide(), const Duration(milliseconds: 500));
await Future.wait([
_step('players', session.coordinator.releasePlayers(), const Duration(seconds: 3)),
_step('thumbnails', cancelAllThumbnails(), const Duration(milliseconds: 1500)),
_step('sounds', session.sfx.dispose(), const Duration(milliseconds: 500)),
]);
// Windows: media_kit_video frees a player's video output on a thread of
// its own after dispose returns; it must be done before the engine
// stops, or the plugin's teardown waits on it for good.
if (Platform.isWindows) await Future.delayed(const Duration(milliseconds: 300));
// Jellyfin stop report, last diagnostics, the bye (flushed), then the
// hosted server: 3 s in all.
await _step('leave', session.leave(bye: !restart), const Duration(milliseconds: 2500));
await _step('server stop', session.stopServer(), 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();
// The engine stops with the window, so this only fires when the close
// did not go through. A kill, not exit(): no exit handlers (NVIDIA's
// EGL one segfaults, Windows DLL detach can hang).
Timer(const Duration(seconds: 3), () {
debugPrint('close: the window did not close; ending the process');
Process.killPid(pid, ProcessSignal.sigkill);
});
}
Future<void> _step(String what, Future<void>? f, Duration limit) async {
if (f == null) return;
final sw = Stopwatch()..start();
try {
await f.timeout(limit);
} on TimeoutException {
debugPrint('close: $what did not finish in ${limit.inMilliseconds} ms');
} catch (e) {
debugPrint('close: $what failed: $e');
}
if (sw.elapsedMilliseconds > 200) debugPrint('close: $what took ${sw.elapsedMilliseconds} ms');
}
@@ -0,0 +1,133 @@
import 'dart:async';
import 'dart:typed_data';
import 'package:flutter_soloud/flutter_soloud.dart';
import 'package:sync_relay_core/core.dart';
final _log = createLogger('sound');
/// What [SoundEngine] needs from an audio library: [SoLoudBackend] in the
/// app, a fake in tests. A sound handle is whatever [load] returns.
abstract class AudioBackend {
Future<void> init();
/// Decodes [wav] (a whole WAV file) into memory under [name].
Future<Object> load(String name, Uint8List wav);
/// Starts one voice of [sound] at [volume] (linear gain, 0..1); voices
/// overlap.
void play(Object sound, double volume);
Future<void> unload(Object sound);
Future<void> deinit();
}
/// flutter_soloud (SoLoud mixing into miniaudio's output device).
class SoLoudBackend implements AudioBackend {
SoLoud get _s => SoLoud.instance;
@override
Future<void> init() => _s.init();
@override
Future<Object> load(String name, Uint8List wav) => _s.loadMem(name, wav);
@override
void play(Object sound, double volume) => _s.play(sound as AudioSource, volume: volume);
@override
Future<void> unload(Object sound) => _s.disposeSource(sound as AudioSource);
@override
Future<void> deinit() => _s.deinitAsync();
}
/// The app's short in-memory sounds. The audio device opens on [warmUp] or
/// the first [play]; if it can't (no device, a broken driver) the engine
/// logs once and every play is silently dropped. Each sound is loaded once
/// per slot from the WAV bytes its renderer gives; a new key in a slot
/// replaces (and frees) what the slot held.
class SoundEngine {
final AudioBackend _backend;
SoundEngine({AudioBackend? backend}) : _backend = backend ?? SoLoudBackend();
Future<bool>? _ready;
var _disposed = false;
/// Per slot: the loaded key and its sound (null: failed to load).
final Map<String, (String, Future<Object?>)> _slots = {};
/// Load names are unique: SoLoud hands back the old sound for a name it
/// already holds, which would defeat a re-render.
var _loads = 0;
/// Opens the audio device now, so the first sound has no wait.
Future<bool> warmUp() => _ready ??= _init();
Future<bool> _init() async {
if (_disposed) return false;
try {
await _backend.init();
return true;
} catch (e) {
_log.warning('no sounds: audio init failed: $e');
return false;
}
}
/// Plays the sound of [slot] at [volume] (linear gain). [key] names what
/// [wav] renders: the same key reuses the loaded sound; another key, or
/// [fresh], renders and loads it again.
Future<void> play(String slot, String key, Future<Uint8List> Function() wav,
{required double volume, bool fresh = false}) async {
if (_disposed || volume <= 0) return;
if (!await warmUp() || _disposed) return;
var held = _slots[slot];
if (held == null || held.$1 != key || fresh) {
if (held != null) unawaited(_unload(held.$2));
held = (key, _load(key, wav));
_slots[slot] = held;
}
final sound = await held.$2;
if (sound == null) {
// Retry on the next play.
if (identical(_slots[slot], held)) _slots.remove(slot);
return;
}
if (_disposed || !identical(_slots[slot], held)) return;
try {
_backend.play(sound, volume.clamp(0.0, 1.0));
} catch (e) {
_log.warning('sound $key: $e');
}
}
Future<Object?> _load(String key, Future<Uint8List> Function() wav) async {
try {
return await _backend.load('$key#${_loads++}', await wav());
} catch (e) {
_log.warning('sound $key: $e');
return null;
}
}
Future<void> _unload(Future<Object?> sound) async {
try {
final s = await sound;
if (s != null && !_disposed) await _backend.unload(s);
} catch (e) {
_log.fine('unload: $e');
}
}
/// Frees every sound and closes the audio device. Plays after it are
/// dropped.
Future<void> dispose() async {
if (_disposed) return;
_disposed = true;
_slots.clear();
final ready = _ready;
if (ready == null || !await ready) return;
try {
// Deinit frees the loaded sounds with the engine.
await _backend.deinit();
} catch (e) {
_log.warning('audio deinit: $e');
}
}
}
@@ -0,0 +1,116 @@
import 'dart:convert';
import 'dart:io';
// First start of the renamed app (Starsync, app id dev.foxyz.starsync):
// copy what the old names left, never move, so a 1.2.x install keeps
// working next to it. Runs once (the marker file), then never again.
/// Written into the new support folder once the migration ran (whether or
/// not anything was found): `migratedFrom` (the old support folder or
/// null), `hiveFrom` (the folder the boxes came from or null), `boxes`,
/// `files`.
const migrationMarker = 'migration.json';
/// The Hive boxes the app opens, by name (settings, connect form, Files,
/// Jellyfin sign-ins, track choices). Only these leave the documents
/// folder, which other apps' boxes share.
const appHiveBoxes = ['settings', 'connect_form', 'files', 'jellyfin', 'track_prefs'];
/// Support folders of the old names for the new [support] folder, the
/// most likely first. Linux: `$XDG_DATA_HOME/moe.senkochan.watch_together`
/// (the GTK application id up to 1.2.x), then `watch_together` (the
/// executable name path_provider falls back to). Windows:
/// `%APPDATA%\com.example\watch_together` (company and product name of
/// the exe up to 1.2.x).
List<String> legacySupportDirs(String support, {bool? windows}) {
final win = windows ?? Platform.isWindows;
final sep = win ? r'\' : '/';
String parent(String p) {
final norm = p.endsWith(sep) ? p.substring(0, p.length - 1) : p;
final i = norm.lastIndexOf(sep);
return i <= 0 ? norm : norm.substring(0, i);
}
if (win) return ['${parent(parent(support))}${sep}com.example${sep}watch_together'];
final share = parent(support);
return ['$share${sep}moe.senkochan.watch_together', '$share${sep}watch_together'];
}
/// Copies the first existing [oldSupport] folder into [support] and the
/// [appHiveBoxes] (`.hive` and `.lock`) from [documents] into [hive],
/// skipping anything already there; the originals stay. Once per support
/// folder ([migrationMarker]). Returns one log line per thing copied
/// (paths and counts, never contents); empty when it ran before.
Future<List<String>> migrateLegacyData(
{required String support, required String hive, required List<String> oldSupport, String? documents}) async {
final sep = Platform.pathSeparator;
final marker = File('$support$sep$migrationMarker');
if (marker.existsSync()) return const [];
final lines = <String>[];
String? from;
var files = 0;
for (final old in oldSupport) {
final dir = Directory(old);
if (!dir.existsSync() || _same(old, support)) continue;
files = await _copyTree(dir, Directory(support));
from = old;
lines.add('migration: copied $files files from $old to $support');
break;
}
final boxes = <String>[];
if (documents != null && Directory(documents).existsSync() && !_same(documents, hive)) {
await Directory(hive).create(recursive: true);
for (final box in appHiveBoxes) {
for (final ext in const ['hive', 'lock']) {
final src = File('$documents$sep$box.$ext');
final dst = File('$hive$sep$box.$ext');
if (!src.existsSync() || dst.existsSync()) continue;
await src.copy(dst.path);
if (ext == 'hive') boxes.add(box);
}
}
if (boxes.isNotEmpty) {
lines.add('migration: copied ${boxes.length} Hive boxes (${boxes.join(', ')}) from $documents to $hive');
}
}
if (lines.isEmpty) lines.add('migration: no data from before the rename');
await Directory(support).create(recursive: true);
await marker.writeAsString(jsonEncode({
'migratedFrom': from,
'hiveFrom': boxes.isEmpty ? null : documents,
'boxes': boxes,
'files': files,
}));
return lines;
}
bool _same(String a, String b) {
try {
return FileSystemEntity.identicalSync(a, b);
} catch (_) {
return false;
}
}
// Files (File.copy keeps the mode on POSIX: cookies.txt stays 0600, yt-dlp
// executable) and symlinks as they are; existing targets win.
Future<int> _copyTree(Directory from, Directory to) async {
var n = 0;
await to.create(recursive: true);
await for (final e in from.list(recursive: true, followLinks: false)) {
final target = '${to.path}${e.path.substring(from.path.length)}';
if (e is Directory) {
await Directory(target).create(recursive: true);
} else if (await FileSystemEntity.type(target, followLinks: false) != FileSystemEntityType.notFound) {
continue;
} else if (e is Link) {
await Directory(File(target).parent.path).create(recursive: true);
await Link(target).create(await e.target());
} else if (e is File) {
await Directory(File(target).parent.path).create(recursive: true);
await e.copy(target);
n++;
}
}
return n;
}
@@ -0,0 +1,35 @@
import 'dart:io';
import 'package:path_provider/path_provider.dart';
import '../data_migration.dart';
import '../ports/app_dirs.dart';
/// The desktop's [AppDirsPort]: the app support folder from path_provider
/// (Linux `$XDG_DATA_HOME/dev.foxyz.starsync`, Windows
/// `%APPDATA%\foxyz\Starsync`); on the first start it copies the data of
/// the old names in ([migrateLegacyData]). Needs Flutter's binding.
class DesktopAppDirs implements AppDirsPort {
const DesktopAppDirs();
@override
Future<AppDirs> resolve() async {
final support = (await getApplicationSupportDirectory()).path;
final base = AppDirs.fromEnv(support: support, env: const {});
List<String> migrated;
try {
String? documents;
try {
// Where Hive.initFlutter() kept the boxes up to 1.2.x.
documents = (await getApplicationDocumentsDirectory()).path;
} catch (_) {}
migrated = await migrateLegacyData(
support: support, hive: base.hive, oldSupport: legacySupportDirs(support), documents: documents);
} catch (e) {
migrated = ['migration failed: $e'];
}
final dirs = AppDirs.fromEnv(support: support, env: Platform.environment, migrated: migrated);
await Directory(dirs.hive).create(recursive: true);
return dirs;
}
}
@@ -0,0 +1,40 @@
import 'dart:io';
import 'package:file_picker/file_picker.dart';
import '../../files/scanner.dart';
import '../ports/folder_access.dart';
import '../ports/url_opener.dart';
/// [FolderAccess] on a desktop file system: file_picker's dialogs, the
/// [scanFolder] walk, the file manager through [UrlOpener].
class DesktopFolders implements FolderAccess {
final UrlOpener urls;
const DesktopFolders(this.urls);
@override
Future<String?> pickFolder({required String dialogTitle}) => FilePicker.getDirectoryPath(dialogTitle: dialogTitle);
@override
Future<List<String>> pickFiles({required String dialogTitle, required List<String> extensions}) async {
final files = await FilePicker.pickFiles(
dialogTitle: dialogTitle,
type: FileType.custom,
allowedExtensions: extensions,
);
return [for (final f in files) ?f.path];
}
@override
Future<FolderScan> scan(String dir) => scanFolder(dir);
@override
bool folderExists(String dir) => Directory(dir).existsSync();
@override
Future<FileStat> stat(String path) => File(path).stat();
// The file manager opens a folder path the way a browser opens a link.
@override
Future<bool> reveal(String dir) => urls.open(Uri.directory(dir).toString());
}
@@ -0,0 +1,17 @@
import '../../playback/yt_dlp_app.dart';
import '../ports/ports.dart';
import 'desktop_folders.dart';
import 'desktop_shell.dart';
import 'desktop_urls.dart';
/// The desktop's [Ports].
Ports desktopPorts() {
const urls = DesktopUrls();
return Ports(
shell: DesktopShell(),
urls: urls,
folders: const DesktopFolders(urls),
links: AppYtDlp.instance,
notifications: const NoSystemNotifications(),
);
}
@@ -0,0 +1,167 @@
import 'dart:async';
import 'package:flutter/widgets.dart';
import 'package:fox_utils/fox_utils.dart' show ClipboardX, DesktopDropX, DropTarget;
import 'package:hive_ce_flutter/hive_flutter.dart';
import 'package:window_manager/window_manager.dart';
import '../../state/settings.dart' show settingsBoxName;
import 'attention.dart';
import '../ports/shell_port.dart';
import 'window_memory.dart';
/// [ShellPort] on Linux and Windows: window_manager for the window,
/// [WindowMemory] for its size and place, the runner's channel for
/// attention, fox_utils for drops and the clipboard.
class DesktopShell implements ShellPort {
WindowMemory? _memory;
@override
Future<void> start({required String title, required Future<void> Function() onCloseRequest}) async {
await windowManager.ensureInitialized();
await windowManager.setPreventClose(true);
final memory = _memory = WindowMemory(Hive.isBoxOpen(settingsBoxName) ? Hive.box(settingsBoxName) : null);
windowManager.addListener(_CloseGuard(onCloseRequest));
final windowOptions = WindowOptions(
size: memory.size,
minimumSize: const Size(160, 90),
center: !memory.hasPosition,
backgroundColor: const Color(0xFF000000),
titleBarStyle: TitleBarStyle.hidden,
title: title,
);
unawaited(windowManager.waitUntilReadyToShow(windowOptions, () async {
await memory.restore();
await windowManager.show();
await windowManager.focus();
}));
}
@override
Future<void> setFullScreen(bool on) => windowManager.setFullScreen(on);
@override
Future<void> minimize() => windowManager.minimize();
@override
Future<bool> isMaximized() => windowManager.isMaximized();
@override
Future<void> maximize() => windowManager.maximize();
@override
Future<void> unmaximize() => windowManager.unmaximize();
@override
void Function() watchMaximized(void Function(bool maximized) onChange) {
final l = _MaximizeWatch(onChange);
windowManager.addListener(l);
return () => windowManager.removeListener(l);
}
final _activity = <void Function(WindowActivity)>{};
@override
void Function() watchActivity(void Function(WindowActivity activity) onChange) {
final l = _ActivityWatch(onChange);
_activity.add(onChange);
windowManager.addListener(l);
return () {
_activity.remove(onChange);
windowManager.removeListener(l);
};
}
@override
Future<void> startDragging() => windowManager.startDragging();
@override
Future<void> requestClose() => windowManager.close();
@override
Future<void> saveWindow() async => _memory?.save();
@override
Future<void> hide() async {
// window_manager has no hide event: tell the watchers here.
for (final f in [..._activity]) {
f(WindowActivity.hidden);
}
await windowManager.hide();
}
@override
Future<void> quit() async {
try {
await windowManager.setPreventClose(false);
await windowManager.close();
} catch (e) {
debugPrint('window close failed: $e');
await windowManager.destroy();
}
}
@override
Future<void> requestAttention() => requestWindowAttention();
@override
Future<Paste> readPaste() async {
var files = const <String>[];
String? text;
try {
files = await ClipboardX.readFiles();
} catch (_) {}
if (files.isEmpty) {
try {
text = await ClipboardX.readText();
} catch (_) {}
}
return (files: files, text: text);
}
@override
Widget dropTarget({
Key? key,
bool enable = true,
VoidCallback? onEnter,
VoidCallback? onExit,
required void Function(List<String> paths, Offset at) onDrop,
required Widget child,
}) =>
DropTarget(
key: key,
enable: enable,
onDragEntered: onEnter == null ? null : (_) => onEnter(),
onDragExited: onExit == null ? null : (_) => onExit(),
onDragDone: (d) => onDrop(DesktopDropX.pathsOf(d.files), d.localPosition),
child: child,
);
}
/// Close button / Alt+F4 run the orderly close instead.
class _CloseGuard with WindowListener {
final Future<void> Function() onClose;
_CloseGuard(this.onClose);
@override
void onWindowClose() => unawaited(onClose());
}
class _MaximizeWatch with WindowListener {
final void Function(bool) onChange;
_MaximizeWatch(this.onChange);
@override
void onWindowMaximize() => onChange(true);
@override
void onWindowUnmaximize() => onChange(false);
}
/// Focus, blur, minimise and restore as [WindowActivity] (a restored
/// window is shown; its focus event follows when it gets the keyboard).
class _ActivityWatch with WindowListener {
final void Function(WindowActivity) onChange;
_ActivityWatch(this.onChange);
@override
void onWindowFocus() => onChange(WindowActivity.focused);
@override
void onWindowBlur() => onChange(WindowActivity.unfocused);
@override
void onWindowMinimize() => onChange(WindowActivity.minimised);
@override
void onWindowRestore() => onChange(WindowActivity.unfocused);
}
@@ -0,0 +1,26 @@
import 'dart:io';
import '../ports/url_opener.dart';
/// [UrlOpener] without url_launcher: `xdg-open`, `open` or Windows'
/// `url.dll` handler.
class DesktopUrls implements UrlOpener {
const DesktopUrls();
@override
Future<bool> open(String url) async {
try {
final ProcessResult r;
if (Platform.isWindows) {
r = await Process.run('rundll32', ['url.dll,FileProtocolHandler', url]);
} else if (Platform.isMacOS) {
r = await Process.run('open', [url]);
} else {
r = await Process.run('xdg-open', [url]);
}
return r.exitCode == 0;
} catch (_) {
return false;
}
}
}
@@ -5,16 +5,24 @@ import 'package:sync_relay_core/core.dart' show findInstallRoot;
/// The app's id: GTK's application id, so the Wayland `app_id` the /// The app's id: GTK's application id, so the Wayland `app_id` the
/// compositor matches to [desktopFileName] for the window's icon. /// compositor matches to [desktopFileName] for the window's icon.
const appId = 'moe.senkochan.watch_together'; const appId = 'dev.foxyz.starsync';
const desktopFileName = '$appId.desktop'; const desktopFileName = '$appId.desktop';
/// The app id up to 1.2.x: its entry and icons are removed (only those
/// exact files, which that version wrote).
const legacyAppId = 'moe.senkochan.watch_together';
const _iconSizes = [16, 32, 48, 64, 128, 256, 512];
/// Linux release installs (an unpacked tarball) carry no launcher entry, so /// Linux release installs (an unpacked tarball) carry no launcher entry, so
/// Wayland shows a generic icon. On start: write the `.desktop` file into /// Wayland shows a generic icon. On start: write the `.desktop` file into
/// `~/.local/share/applications` (Exec = this binary) and the icon into /// `~/.local/share/applications` (Exec = this binary) and the icon into
/// the hicolor theme, only when something changed (a moved install, a new /// the hicolor theme, only when something changed (a moved install, a new
/// icon). Never in debug runs; failures only log. /// icon). The entry and icons of [legacyAppId] go. Never in debug runs
Future<void> installDesktopEntry({String? executable, String? dataHome, String? home}) async { /// (unless [force], for tests); failures only log.
if (!Platform.isLinux || kDebugMode) return; Future<void> installDesktopEntry(
{String? executable, String? dataHome, String? home, @visibleForTesting bool force = false}) async {
if (!Platform.isLinux || (kDebugMode && !force)) return;
try { try {
final exe = executable ?? Platform.resolvedExecutable; final exe = executable ?? Platform.resolvedExecutable;
final root = findInstallRoot(exe, windows: false); final root = findInstallRoot(exe, windows: false);
@@ -22,7 +30,8 @@ Future<void> installDesktopEntry({String? executable, String? dataHome, String?
final share = dataHome ?? Platform.environment['XDG_DATA_HOME'] ?? final share = dataHome ?? Platform.environment['XDG_DATA_HOME'] ??
'${home ?? Platform.environment['HOME']}/.local/share'; '${home ?? Platform.environment['HOME']}/.local/share';
final icons = '${root.path}/data/flutter_assets/assets/icon'; final icons = '${root.path}/data/flutter_assets/assets/icon';
for (final size in const [16, 32, 48, 64, 128, 256, 512]) { await _removeLegacy(share);
for (final size in _iconSizes) {
final src = File('$icons/icon_$size.png'); final src = File('$icons/icon_$size.png');
if (!src.existsSync()) continue; if (!src.existsSync()) continue;
final dst = File('$share/icons/hicolor/${size}x$size/apps/$appId.png'); final dst = File('$share/icons/hicolor/${size}x$size/apps/$appId.png');
@@ -40,6 +49,21 @@ Future<void> installDesktopEntry({String? executable, String? dataHome, String?
} }
} }
Future<void> _removeLegacy(String share) async {
for (final f in [
File('$share/applications/$legacyAppId.desktop'),
for (final size in _iconSizes) File('$share/icons/hicolor/${size}x$size/apps/$legacyAppId.png'),
]) {
if (!f.existsSync()) continue;
try {
await f.delete();
debugPrint('desktop entry: removed ${f.path}');
} catch (e) {
debugPrint('desktop entry: cannot remove ${f.path}: $e');
}
}
}
@visibleForTesting @visibleForTesting
String desktopEntryText(String exe) => '''[Desktop Entry] String desktopEntryText(String exe) => '''[Desktop Entry]
Type=Application Type=Application
@@ -0,0 +1,69 @@
import 'dart:io';
/// The environment variable that picks a separate profile (DEVELOPMENT.md
/// "Dev loop": two instances on one machine).
const profileEnv = 'STARSYNC_PROFILE';
/// Where the app keeps its own files, resolved once in `main` through an
/// [AppDirsPort] and handed on from there.
///
/// Everything lives in the app support folder: Hive's boxes in
/// `<app support>/hive`, the logs in `<app support>/logs`. With
/// `STARSYNC_PROFILE=<name>` (letters, digits, `-`, `_`; at most 32) the
/// per-install data moves to `<app support>/profiles/<name>/`: the Hive
/// boxes (settings and so the profile, connect form, window bounds,
/// folders, Jellyfin sign-ins; the client id from restructure step 1 on)
/// under `hive/`, the logs under `logs/`. Downloads and caches stay
/// shared: yt-dlp and its cookies, the extracted shaders, thumbnails, the
/// AniList cache.
class AppDirs {
/// The profile name; null for the normal app.
final String? profile;
/// The app support folder (yt-dlp, shaders; Hive and logs without a
/// profile).
final String support;
/// Hive's folder.
final String hive;
/// The app's log folder.
final String logs;
/// What resolving copied over from before the rename, one line each
/// (paths and counts), for the log once it is open.
final List<String> migrated;
const AppDirs(
{required this.profile,
required this.support,
required this.hive,
required this.logs,
this.migrated = const []});
/// The layout for [support] (the app support folder) and [env]; an
/// invalid profile name is ignored ([invalidProfile] says which).
static AppDirs fromEnv(
{required String support, required Map<String, String> env, List<String> migrated = const []}) {
final sep = Platform.pathSeparator;
final name = validProfile(env[profileEnv]);
final root = name == null ? support : '$support${sep}profiles$sep$name';
return AppDirs(
profile: name, support: support, hive: '$root${sep}hive', logs: '$root${sep}logs', migrated: migrated);
}
/// [raw] when it is a usable profile name, else null.
static String? validProfile(String? raw) {
final v = raw?.trim() ?? '';
return RegExp(r'^[A-Za-z0-9_-]{1,32}$').hasMatch(v) ? v : null;
}
/// The env value when it is set but not a usable name (for a warning).
static String? invalidProfile(Map<String, String> env) {
final raw = env[profileEnv];
return raw != null && raw.trim().isNotEmpty && validProfile(raw) == null ? raw : null;
}
}
/// Finds the app's folders on this platform (and, the first time, brings
/// over the data of the names before the rename).
abstract interface class AppDirsPort {
/// The layout for this process; its Hive folder exists afterwards.
Future<AppDirs> resolve();
}
@@ -0,0 +1,28 @@
import 'dart:io' show FileStat;
import '../../files/scanner.dart' show FolderScan;
/// The user's folders and files: picking them, listing videos, looking a
/// path up. Paths for now; a platform without them (Android's tree URIs)
/// brings its own implementation.
abstract interface class FolderAccess {
/// The system folder dialog; null when cancelled.
Future<String?> pickFolder({required String dialogTitle});
/// The system file dialog limited to [extensions]; the picked paths
/// (empty when cancelled).
Future<List<String>> pickFiles({required String dialogTitle, required List<String> extensions});
/// The videos under [dir] (see [FolderScan]); never throws.
Future<FolderScan> scan(String dir);
/// Whether the folder [dir] exists.
bool folderExists(String dir);
/// Size and dates of [path]; throws when it is gone.
Future<FileStat> stat(String path);
/// Shows the folder [dir] in the file manager; false when nothing
/// opened it.
Future<bool> reveal(String dir);
}
@@ -0,0 +1,21 @@
import 'package:sync_relay_core/core.dart' show LinkProbe, YtDlp;
/// Web page links: what they are and how they play, through the app's
/// yt-dlp (a process on desktop).
abstract interface class LinkResolver {
/// The yt-dlp behind it (relaying a link, cookies, the hosted server's
/// shared folder).
Future<YtDlp> get ytDlp;
/// Gets it ready in the background (download on first run, updates).
void warmUp();
/// mpv's `ytdl-raw-options` (sub languages, cookies).
Future<String> mpvRawOptions();
/// What [url] is, remembered for a while ([keep]); [fresh] asks again.
Future<LinkProbe> probe(String url, {Duration keep, bool fresh});
/// [url] no longer needs keeping longer than usual.
void release(String url);
}
@@ -0,0 +1,29 @@
import 'folder_access.dart';
import 'link_resolver.dart';
import 'shell_port.dart';
import 'system_notifications.dart';
import 'url_opener.dart';
export 'app_dirs.dart';
export 'folder_access.dart';
export 'link_resolver.dart';
export 'shell_port.dart';
export 'system_notifications.dart';
export 'url_opener.dart';
/// The platform as the app sees it: one set, made in the composition
/// root ([SessionState]) and handed on from there.
class Ports {
final ShellPort shell;
final UrlOpener urls;
final FolderAccess folders;
final LinkResolver links;
final SystemNotifications notifications;
const Ports({
required this.shell,
required this.urls,
required this.folders,
required this.links,
required this.notifications,
});
}
@@ -0,0 +1,74 @@
import 'package:flutter/widgets.dart';
/// Where the app window stands, for auto sit-out
/// ([ShellPort.watchActivity]).
enum WindowActivity {
/// The window has the keyboard focus.
focused,
/// Shown, but another window has the focus.
unfocused,
/// Minimised to the taskbar.
minimised,
/// Hidden (closing, or a platform that hides instead of minimising).
hidden,
}
/// What the clipboard holds for a paste: its files, else its text.
typedef Paste = ({List<String> files, String? text});
/// The app window and what the desktop hands it (drops, pastes). The
/// desktop implementation wraps window_manager and fox_utils
/// ([DesktopShell]); another platform brings its own.
abstract interface class ShellPort {
/// Before the first frame: the window shown at its remembered size and
/// place; a close request (close button, Alt+F4) runs [onCloseRequest]
/// instead of closing.
Future<void> start({required String title, required Future<void> Function() onCloseRequest});
Future<void> setFullScreen(bool on);
Future<void> minimize();
Future<bool> isMaximized();
Future<void> maximize();
Future<void> unmaximize();
/// Calls [onChange] whenever the window is maximized or restored;
/// returns the call that stops it.
void Function() watchMaximized(void Function(bool maximized) onChange);
/// Calls [onChange] whenever the window gains or loses the focus, is
/// minimised, restored or hidden; returns the call that stops it.
void Function() watchActivity(void Function(WindowActivity activity) onChange);
/// Moves the window with the pointer (a press on the title area).
Future<void> startDragging();
/// Asks to close the window: the same path as the close button.
Future<void> requestClose();
/// The way out: remembers the window, hides it ([hide]), and later
/// closes it for good ([quit]).
Future<void> saveWindow();
Future<void> hide();
Future<void> quit();
/// Points the desktop at the window when it is not focused (taskbar
/// flash, urgency hint); nothing when it is.
Future<void> requestAttention();
/// The clipboard's files, else its text (Ctrl+V outside a text field).
Future<Paste> readPaste();
/// [child] taking dropped files ([onDrop]: their paths and where they
/// landed); [enable] off lets drops through.
Widget dropTarget({
Key? key,
bool enable = true,
VoidCallback? onEnter,
VoidCallback? onExit,
required void Function(List<String> paths, Offset at) onDrop,
required Widget child,
});
}
@@ -0,0 +1,14 @@
/// Notifications outside the window (the desktop's notification center,
/// Android's shade). The policy that uses them comes with the Notifier.
abstract interface class SystemNotifications {
/// Shows [title] / [body]; replaces the one with the same [tag].
Future<void> show({required String title, required String body, String tag = ''});
}
/// Shows nothing (desktop today).
class NoSystemNotifications implements SystemNotifications {
const NoSystemNotifications();
@override
Future<void> show({required String title, required String body, String tag = ''}) async {}
}
@@ -0,0 +1,6 @@
/// Opens links outside the app.
abstract interface class UrlOpener {
/// Opens [url] with the system's handler (a browser for web links, the
/// file manager for a `file:` folder); false when nothing opened it.
Future<bool> open(String url);
}
@@ -30,7 +30,9 @@ class PlaybackController {
static const readAheadSecs = 150; static const readAheadSecs = 150;
/// Forward demuxer cache. Holds [readAheadSecs] up to ~14 Mbit/s (a /// Forward demuxer cache. Holds [readAheadSecs] up to ~14 Mbit/s (a
/// 1.4 GB / 24 min file is ~8); higher bitrates get less time. In RAM: /// 1.4 GB / 24 min file is ~8); higher bitrates get less time. In RAM:
/// media_kit sets cache-on-disk but no cache-dir, so mpv falls back. /// `cache-on-disk=no` (media_kit sets it on but no cache-dir, so mpv
/// fell back to RAM anyway, warning "Failed to create file cache." per
/// open).
static const maxBytes = 256 * 1024 * 1024; static const maxBytes = 256 * 1024 * 1024;
/// Back buffer (media_kit sets it to bufferSize too): enough for a short /// Back buffer (media_kit sets it to bufferSize too): enough for a short
/// seek back without holding another 256 MiB. /// seek back without holding another 256 MiB.
@@ -149,6 +151,9 @@ class PlaybackController {
/// `demuxer-cache-time`, media_kit's `buffer`), in 250 ms steps. /// `demuxer-cache-time`, media_kit's `buffer`), in 250 ms steps.
final buffered = signal(Duration.zero); final buffered = signal(Duration.zero);
/// The player's volume, 0..[maxVolume] (media_kit's `volume`).
final volume = signal(100.0);
/// The opening ([openingRange]: Intro segment, else an OP-titled /// The opening ([openingRange]: Intro segment, else an OP-titled
/// chapter) and the ending ([endingRange]: Outro, else ED-titled). /// chapter) and the ending ([endingRange]: Outro, else ED-titled).
late final Computed<SkipRange?> opening = late final Computed<SkipRange?> opening =
@@ -172,6 +177,7 @@ class PlaybackController {
final existing = _video; final existing = _video;
if (existing != null) return existing; if (existing != null) return existing;
final hwdec = _hwdecOverride; final hwdec = _hwdecOverride;
_logRenderEnv();
_requestLog(player, 'v'); // back to [logLevel] by _detectRenderMode _requestLog(player, 'v'); // back to [logLevel] by _detectRenderMode
// media_kit's video output sets hwdec itself on attach ('auto' unless // media_kit's video output sets hwdec itself on attach ('auto' unless
// configured), which used to override SYNC_RELAY_HWDEC. // configured), which used to override SYNC_RELAY_HWDEC.
@@ -250,6 +256,7 @@ class PlaybackController {
await _pin('cache-secs', '$readAheadSecs', on); await _pin('cache-secs', '$readAheadSecs', on);
await _pin('demuxer-max-bytes', '$ahead', on); await _pin('demuxer-max-bytes', '$ahead', on);
await _pin('demuxer-max-back-bytes', '$back', on); await _pin('demuxer-max-back-bytes', '$back', on);
await _pin('cache-on-disk', 'no', on);
final hwdec = _hwdecOverride; final hwdec = _hwdecOverride;
if (hwdec != null) { if (hwdec != null) {
await _pin('hwdec', hwdec, on); await _pin('hwdec', hwdec, on);
@@ -307,13 +314,12 @@ class PlaybackController {
} }
if (!identical(p, _player)) return; if (!identical(p, _player)) return;
if (problem != null) paths = const []; if (problem != null) paths = const [];
final value = joinShaderPaths(paths); if (joinShaderPaths(paths) != _shaders || problem != null) {
if (value != _shaders || problem != null) { final took = await setShaderPaths(paths);
final got = await setShaderValue(value);
if (!identical(p, _player)) return; if (!identical(p, _player)) return;
if (problem == null && got != value) { if (problem == null && !took) {
problem = 'mpv did not take them'; problem = 'mpv did not take them';
await setShaderValue(''); await setShaderPaths(const []);
} }
} }
if (problem != null) { if (problem != null) {
@@ -323,20 +329,23 @@ class PlaybackController {
} }
} }
/// Sets mpv `glsl-shaders` on the current player and returns the /// Sets mpv `glsl-shaders` to [paths] on the current player; true when
/// read-back (null on error). /// mpv's read-back is that list ([shaderListMatches]: old mpv prints it
/// joined with ',').
@visibleForTesting @visibleForTesting
Future<String?> setShaderValue(String value) async { Future<bool> setShaderPaths(List<String> paths) async {
final value = joinShaderPaths(paths);
try { try {
final dynamic platform = player.platform; final dynamic platform = player.platform;
await platform?.setProperty('glsl-shaders', value); await platform?.setProperty('glsl-shaders', value);
final got = '${await platform?.getProperty('glsl-shaders')}'; final got = '${await platform?.getProperty('glsl-shaders')}';
_shaders = got; final took = shaderListMatches(paths, got);
if (got != value) debugPrint('glsl-shaders=$value not applied: "$got"'); _shaders = took ? value : got;
return got; if (!took) debugPrint('glsl-shaders=$value not applied: "$got"');
return took;
} catch (e, st) { } catch (e, st) {
debugPrint('glsl-shaders=$value failed: $e\n$st'); debugPrint('glsl-shaders=$value failed: $e\n$st');
return null; return false;
} }
} }
@@ -387,6 +396,9 @@ class PlaybackController {
return; return;
} }
if (log.prefix != 'libmpv_render') return; if (log.prefix != 'libmpv_render') return;
// Which GL the video output got (verbose, heard only during the
// render-mode probe): kept in the app log.
if (_glInfo.hasMatch(log.text)) _mpvLog.info('render: [libmpv_render] ${log.text.trim()}');
if (log.text.contains('suspected software renderer')) { if (log.text.contains('suspected software renderer')) {
_setRenderMode(RenderMode.softwareGl); _setRenderMode(RenderMode.softwareGl);
} else if (log.text.startsWith('GL_VERSION') && } else if (log.text.startsWith('GL_VERSION') &&
@@ -399,7 +411,7 @@ class PlaybackController {
_shaders = ''; _shaders = '';
_shaderOp = _shaderOp.then((_) async { _shaderOp = _shaderOp.then((_) async {
if (!identical(p, _player)) return; if (!identical(p, _player)) return;
await setShaderValue(''); await setShaderPaths(const []);
_turnShadersOff('Shaders failed in mpv (${log.text}) — turned off'); _turnShadersOff('Shaders failed in mpv (${log.text}) — turned off');
}); });
} }
@@ -411,6 +423,52 @@ class PlaybackController {
static final _mpvLog = createLogger('mpv'); static final _mpvLog = createLogger('mpv');
static final _mpvThrottle = LogThrottle(burst: 5); static final _mpvThrottle = LogThrottle(burst: 5);
// mpv's GL init lines (`GL_VENDOR='...'`, `Detected desktop OpenGL 4.6.`).
static final _glInfo =
RegExp(r'^\s*(GL_(VENDOR|RENDERER|VERSION|SHADING_LANGUAGE_VERSION)=|Detected (GLES|desktop OpenGL))');
// Once per app start: what decides how media_kit and mpv render (the
// display server, Flutter's and Mesa's overrides). Not secrets.
static bool _renderEnvLogged = false;
static void _logRenderEnv() {
if (_renderEnvLogged || !Platform.isLinux) return;
_renderEnvLogged = true;
const names = [
'XDG_SESSION_TYPE',
'WAYLAND_DISPLAY',
'DISPLAY',
'GDK_BACKEND',
'FLUTTER_LINUX_RENDERER',
'LIBGL_ALWAYS_SOFTWARE',
'__EGL_VENDOR_LIBRARY_FILENAMES',
];
final env = Platform.environment;
_mpvLog.info('render env: ${[for (final n in names) '$n=${env[n] ?? '-'}'].join(' ')}');
}
// Once per player, after its first frame: the decoder mpv picked; the
// first time per app start also mpv's and ffmpeg's versions.
static bool _versionsLogged = false;
Player? _frameLoggedFor;
Future<void> _logFirstFrame(Player p, int? width) async {
if (width == null || width <= 0 || identical(p, _frameLoggedFor)) return;
_frameLoggedFor = p;
try {
final dynamic platform = p.platform;
final current = await platform?.getProperty('hwdec-current');
final wanted = await platform?.getProperty('hwdec');
_mpvLog.info('render: first frame ${p.state.width}x${p.state.height}, '
'hwdec-current=${'$current'.isEmpty ? 'no' : current} (hwdec=$wanted), mode ${renderMode.peek().name}');
if (!_versionsLogged) {
_versionsLogged = true;
_mpvLog.info('render: ${await platform?.getProperty('mpv-version')}, '
'ffmpeg ${await platform?.getProperty('ffmpeg-version')}');
}
} catch (e) {
debugPrint('first frame info: $e');
}
}
static void _toAppLog(PlayerLog log, {String player = ''}) { static void _toAppLog(PlayerLog log, {String player = ''}) {
final level = log.level; final level = log.level;
if (level != 'warn' && level != 'error' && level != 'fatal') return; if (level != 'warn' && level != 'error' && level != 'fatal') return;
@@ -448,11 +506,15 @@ class PlaybackController {
} finally { } finally {
if (identical(p, _player)) _requestLog(p, logLevel); if (identical(p, _player)) _requestLog(p, logLevel);
} }
if (!identical(p, _player) || renderMode.peek() != RenderMode.unknown) { if (!identical(p, _player)) return;
final mode = renderMode.peek();
if (mode != RenderMode.unknown) {
_mpvLog.info('render: ${mode.name} (${mode == RenderMode.gpu ? 'OpenGL on the GPU' : 'OpenGL on the CPU'})');
return; return;
} }
_setRenderMode(RenderMode.software); _setRenderMode(RenderMode.software);
debugPrint('video output: software rendering (no GL init logged)'); _mpvLog.info("render: software (no GL init logged: media_kit's own fallback; "
'its reason is on stderr, "media_kit: VideoOutput: ..." when run from a terminal)');
if (_shaders.isNotEmpty) { if (_shaders.isNotEmpty) {
_notice('Shaders have no effect: video uses software rendering'); _notice('Shaders have no effect: video uses software rendering');
} }
@@ -504,9 +566,11 @@ class PlaybackController {
} }
} }
// Position / duration from media_kit's streams; chapter-list and // Position, duration and volume from media_kit's streams; chapter-list and
// track-list observed in mpv (read back as JSON). // track-list observed in mpv (read back as JSON).
void _watchMarks(SyncPlayer p) { void _watchMarks(SyncPlayer p) {
volume.value = p.state.volume;
_timeSubs.add(p.stream.volume.listen((v) => volume.value = v));
_timeSubs.add(p.stream.position.listen((d) { _timeSubs.add(p.stream.position.listen((d) {
// At the end the bar shows full: mpv's last position is the last // At the end the bar shows full: mpv's last position is the last
// frame's start (and the 250 ms steps round down), short of duration. // frame's start (and the 250 ms steps round down), short of duration.
@@ -520,6 +584,8 @@ class PlaybackController {
_timeSubs.add(p.stream.completed.listen((done) { _timeSubs.add(p.stream.completed.listen((done) {
if (done && duration.value > Duration.zero) position.value = duration.value; if (done && duration.value > Duration.zero) position.value = duration.value;
})); }));
_timeSubs.add(p.stream.width.listen((w) => _logFirstFrame(p, w)));
unawaited(_logFirstFrame(p, p.state.width)); // a promoted preload has one already
_timeSubs.add(p.stream.buffer.listen((d) { _timeSubs.add(p.stream.buffer.listen((d) {
final ms = d.inMilliseconds; final ms = d.inMilliseconds;
buffered.value = Duration(milliseconds: ms - ms % 250); buffered.value = Duration(milliseconds: ms - ms % 250);
@@ -686,6 +752,24 @@ class PlaybackController {
} catch (_) {} } catch (_) {}
} }
/// App close: every mpv this controller holds goes before the engine
/// does (a live one calls back into a dead isolate): the preload, the
/// session player and the one still retiring, each bounded by [each].
/// A player an open in flight brought up meanwhile goes too.
Future<void> disposeAll({Duration each = const Duration(milliseconds: 1500)}) async {
Future<void> bounded(Future<void> f) => f.timeout(each).catchError((Object _) {});
for (var round = 0; round < 2; round++) {
final pre = _pre;
_pre = null;
await Future.wait([
if (pre != null) bounded(_disposePreload(pre)),
bounded(_retiring),
bounded(dispose()),
]);
if (_player == null && _pre == null) return;
}
}
// Two frames: one may already be mid-build when [alive] flips. Then // Two frames: one may already be mid-build when [alive] flips. Then
// the length of a route pop (the fullscreen route holds its own `Video` // the length of a route pop (the fullscreen route holds its own `Video`
// and leaves animated). Bounded, since a hidden window renders no // and leaves animated). Bounded, since a hidden window renders no
@@ -4,6 +4,8 @@ import 'dart:io';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:sync_relay_core/core.dart'; import 'package:sync_relay_core/core.dart';
import '../platform/ports/link_resolver.dart';
/// mpv's `ytdl-format` for links every player opens itself. /// mpv's `ytdl-format` for links every player opens itself.
const ytdlFormat = 'bv*[height<=1080]+ba/b'; const ytdlFormat = 'bv*[height<=1080]+ba/b';
@@ -16,13 +18,14 @@ const ytdlSubLangs = 'all,-danmaku';
/// The app's yt-dlp (see [YtDlp]): in the app-support folder, shared with /// The app's yt-dlp (see [YtDlp]): in the app-support folder, shared with
/// the hosted server. [probe] remembers what pasted links are for a while, /// the hosted server. [probe] remembers what pasted links are for a while,
/// so the Link preview and the swap ask once. /// so the Link preview and the swap ask once.
class AppYtDlp { class AppYtDlp implements LinkResolver {
static final instance = AppYtDlp(); static final instance = AppYtDlp();
/// [ytDlp]: a given one (tests); default in the app-support folder. /// [ytDlp]: a given one (tests); default in the app-support folder.
AppYtDlp([YtDlp? ytDlp]) : _ytDlp = ytDlp == null ? null : Future.value(ytDlp); AppYtDlp([YtDlp? ytDlp]) : _ytDlp = ytDlp == null ? null : Future.value(ytDlp);
Future<YtDlp>? _ytDlp; Future<YtDlp>? _ytDlp;
@override
Future<YtDlp> get ytDlp => _ytDlp ??= () async { Future<YtDlp> get ytDlp => _ytDlp ??= () async {
final base = await getApplicationSupportDirectory(); final base = await getApplicationSupportDirectory();
return YtDlp(dir(base.path)); return YtDlp(dir(base.path));
@@ -32,10 +35,12 @@ class AppYtDlp {
/// Gets yt-dlp ready in the background (download on first run, a daily /// Gets yt-dlp ready in the background (download on first run, a daily
/// update check afterwards). /// update check afterwards).
@override
void warmUp() => unawaited(ytDlp.then((y) => y.ensure()).catchError((_) => null)); void warmUp() => unawaited(ytDlp.then((y) => y.ensure()).catchError((_) => null));
/// mpv's `ytdl-raw-options`: [ytdlSubLangs] and the cookies in the /// mpv's `ytdl-raw-options`: [ytdlSubLangs] and the cookies in the
/// yt-dlp folder (see [YtDlpCookies]). /// yt-dlp folder (see [YtDlpCookies]).
@override
Future<String> mpvRawOptions() async { Future<String> mpvRawOptions() async {
const subs = 'sub-langs=%${ytdlSubLangs.length}%$ytdlSubLangs'; const subs = 'sub-langs=%${ytdlSubLangs.length}%$ytdlSubLangs';
try { try {
@@ -54,6 +59,7 @@ class AppYtDlp {
/// is probed again before that runs out). [fresh] probes again: the /// is probed again before that runs out). [fresh] probes again: the
/// remembered result stays until the new one is in, and stays when the /// remembered result stays until the new one is in, and stays when the
/// new one fails. /// new one fails.
@override
Future<LinkProbe> probe(String url, {Duration keep = _probeTtl, bool fresh = false}) { Future<LinkProbe> probe(String url, {Duration keep = _probeTtl, bool fresh = false}) {
final now = DateTime.now(); final now = DateTime.now();
_probes.removeWhere((_, v) => now.isAfter(v.until)); _probes.removeWhere((_, v) => now.isAfter(v.until));
@@ -80,6 +86,7 @@ class AppYtDlp {
} }
/// [url] no longer needs keeping longer: back to the usual 10 minutes. /// [url] no longer needs keeping longer: back to the usual 10 minutes.
@override
void release(String url) { void release(String url) {
final hit = _probes[url]; final hit = _probes[url];
if (hit == null) return; if (hit == null) return;
@@ -13,6 +13,7 @@ import '../jellyfin/accounts.dart';
import '../jellyfin/format.dart' show pickFor; import '../jellyfin/format.dart' show pickFor;
import '../jellyfin/session_media.dart'; import '../jellyfin/session_media.dart';
import '../screens/home/picked_media.dart'; import '../screens/home/picked_media.dart';
import '../session/app_events.dart' show AddedToUpNext;
import '../session/media_art.dart'; import '../session/media_art.dart';
import '../session/session_coordinator.dart'; import '../session/session_coordinator.dart';
import '../state/settings.dart'; import '../state/settings.dart';
@@ -42,7 +43,7 @@ class QueueController {
QueueController(this.coord); QueueController(this.coord);
/// Queued items' pictures by hash, from `/i/`. /// Queued items' pictures by hash, from `/i/`.
late final PictureCache thumbs = PictureCache(coord.sync, late final PictureCache thumbs = PictureCache(coord.connection.client,
route: 'i', keep: RelayConstants.maxQueueItems + 8, live: (s) => [for (final q in s.queue) q.thumb]); route: 'i', keep: RelayConstants.maxQueueItems + 8, live: (s) => [for (final q in s.queue) q.thumb]);
/// Up next, in order (empty when nothing is queued or not in a server). /// Up next, in order (empty when nothing is queued or not in a server).
@@ -76,13 +77,12 @@ class QueueController {
String _continuationFor = ''; String _continuationFor = '';
final _random = Random.secure(); final _random = Random.secure();
String get _me => coord.sync.memberId; String get _me => coord.connection.memberId;
void attach() { void attach() {
if (_attached) return; if (_attached) return;
_attached = true; _attached = true;
coord.sync.states.listen(_onState); coord.connection.states.listen(_onState);
coord.sync.feeds.listen(_onFeed);
effect(() { effect(() {
final next = coord.nextEpisode.value; final next = coord.nextEpisode.value;
final plan = coord.jellyfinPlan.value; final plan = coord.jellyfinPlan.value;
@@ -93,7 +93,7 @@ class QueueController {
effect(() { effect(() {
shownContinuation.value; shownContinuation.value;
coord.settledMediaId.value; coord.settledMediaId.value;
coord.selfOut.value; coord.selfAway.value;
final r = coord.serverState.value; final r = coord.serverState.value;
untracked(() => _warm(r)); untracked(() => _warm(r));
}); });
@@ -107,6 +107,7 @@ class QueueController {
Future<String?> add(PickedMedia pick, {bool next = false}) async { Future<String?> add(PickedMedia pick, {bool next = false}) async {
coord.markInput(); coord.markInput();
if (coord.serverState.peek() == null) return 'not in a server'; if (coord.serverState.peek() == null) return 'not in a server';
if (coord.stateStale.peek()) return 'reconnecting — add it again once back';
if (items.peek().length >= RelayConstants.maxQueueItems) { if (items.peek().length >= RelayConstants.maxQueueItems) {
return 'Up next is full (${RelayConstants.maxQueueItems} items)'; return 'Up next is full (${RelayConstants.maxQueueItems} items)';
} }
@@ -121,8 +122,8 @@ class QueueController {
final item = built.item; final item = built.item;
if (item.key.isNotEmpty) _local[item.key] = (pick: pick, at: DateTime.now()); if (item.key.isNotEmpty) _local[item.key] = (pick: pick, at: DateTime.now());
final thumb = built.thumb; final thumb = built.thumb;
coord.sync.sendQueue(QueueIntent.add(item, next: next, thumb: thumb == null ? '' : base64Encode(thumb))); coord.connection.sendQueue(QueueIntent.add(item, next: next, thumb: thumb == null ? '' : base64Encode(thumb)));
coord.showToast('Added ${item.title} to Up next'); coord.notify(AddedToUpNext(item.title));
return null; return null;
} }
@@ -288,12 +289,12 @@ class QueueController {
void move(String id, int to) { void move(String id, int to) {
coord.markInput(); coord.markInput();
coord.sync.sendQueue(QueueIntent.move(id, to)); coord.connection.sendQueue(QueueIntent.move(id, to));
} }
void remove(String id) { void remove(String id) {
coord.markInput(); coord.markInput();
coord.sync.sendQueue(QueueIntent.remove(id)); coord.connection.sendQueue(QueueIntent.remove(id));
} }
/// Plays [id] now: this app puts it up when it is the one that can, /// Plays [id] now: this app puts it up when it is the one that can,
@@ -306,7 +307,7 @@ class QueueController {
if (queuePerformer(item, r.members).id == _me) { if (queuePerformer(item, r.members).id == _me) {
_perform(item); _perform(item);
} else { } else {
coord.sync.sendQueue(QueueIntent.play(id)); coord.connection.sendQueue(QueueIntent.play(id));
} }
} }
@@ -369,12 +370,6 @@ class QueueController {
_warm(r); _warm(r);
} }
void _onFeed(FeedMessage f) {
if (f.action == 'queue-skip') {
coord.showToast('Skipped ${f.target ?? 'an item'}${f.reason.isEmpty ? '' : ': ${f.reason}'}');
}
}
/// Forgets what no longer matters: performing marks after a minute (a /// Forgets what no longer matters: performing marks after a minute (a
/// swap that never landed may be tried again), local picks no longer /// swap that never landed may be tried again), local picks no longer
/// queued. /// queued.
@@ -394,7 +389,7 @@ class QueueController {
if (perf.id == null) { if (perf.id == null) {
final why = perf.why; final why = perf.why;
if (why != null && queueJanitor(r.members) == _me && _skipped.add(item.id)) { if (why != null && queueJanitor(r.members) == _me && _skipped.add(item.id)) {
coord.sync.sendQueue(QueueIntent.remove(item.id, why: why)); coord.connection.sendQueue(QueueIntent.remove(item.id, why: why));
} }
return; return;
} }
@@ -467,7 +462,7 @@ class QueueController {
} }
if (error != null) { if (error != null) {
debugPrint('queue: ${item.id} cannot go up: $error'); debugPrint('queue: ${item.id} cannot go up: $error');
coord.sync.sendQueue(QueueIntent.remove(item.id, why: "couldn't play it ($error)")); coord.connection.sendQueue(QueueIntent.remove(item.id, why: "couldn't play it ($error)"));
} }
}()); }());
} }
@@ -496,7 +491,7 @@ class QueueController {
if (local == null) return "it isn't on this computer any more"; if (local == null) return "it isn't on this computer any more";
// Only this app can pass it on, and a member sitting out has // Only this app can pass it on, and a member sitting out has
// nothing open: back first. // nothing open: back first.
if (coord.selfOut.peek()) coord.joinBack(); if (coord.selfAway.peek()) coord.joinBack();
return coord.swapPicked(local.pick, queueId: item.id); return coord.swapPicked(local.pick, queueId: item.id);
} }
} }
@@ -534,7 +529,7 @@ class QueueController {
/// puts up (its swap reads it). Null: nothing. /// puts up (its swap reads it). Null: nothing.
_WarmTarget? _warmTarget(StateBroadcast? r) { _WarmTarget? _warmTarget(StateBroadcast? r) {
final t = _warmTargetHere(r); final t = _warmTargetHere(r);
if (t == null || !coord.selfOut.peek()) return t; if (t == null || !coord.selfAway.peek()) return t;
return t.probe == null ? null : _WarmTarget(t.id, probe: t.probe); return t.probe == null ? null : _WarmTarget(t.id, probe: t.probe);
} }
@@ -621,7 +616,7 @@ class QueueController {
final head = r?.queue.firstOrNull; final head = r?.queue.firstOrNull;
final relay = head?.media.relay; final relay = head?.media.relay;
if (r == null || head == null || relay == null || relay.fetch) return; if (r == null || head == null || relay == null || relay.fetch) return;
final key = '${head.id}|${coord.sync.token}'; final key = '${head.id}|${coord.connection.token}';
if (key == _fedWarm) return; if (key == _fedWarm) return;
switch (head.how) { switch (head.how) {
case QueueHow.through: case QueueHow.through:
@@ -724,7 +719,7 @@ class QueueController {
final seen = _lastState; final seen = _lastState;
if (r == null || (seen != null && seen.mediaId > r.mediaId)) r = seen; if (r == null || (seen != null && seen.mediaId > r.mediaId)) r = seen;
if (r == null) return; if (r == null) return;
final out = coord.selfOut.peek(); final out = coord.selfAway.peek();
if (!out && r.media != null && coord.settledMediaId.peek() != r.mediaId) return; if (!out && r.media != null && coord.settledMediaId.peek() != r.mediaId) return;
final want = out ? null : _preloadWant; final want = out ? null : _preloadWant;
final playback = coord.playback; final playback = coord.playback;
+11 -7
View File
@@ -1,6 +1,7 @@
import 'package:sync_relay_core/core.dart'; import 'package:sync_relay_core/core.dart';
import '../jellyfin/session_media.dart' show compareMemberIds; import '../jellyfin/session_media.dart' show compareMemberIds;
import '../session/party.dart' show isPresent;
// Who puts an Up next item up. Pure: only what every client sees in // Who puts an Up next item up. Pure: only what every client sees in
// `state` decides, so all clients agree without a wire message (like the // `state` decides, so all clients agree without a wire message (like the
@@ -12,20 +13,22 @@ import '../jellyfin/session_media.dart' show compareMemberIds;
typedef QueuePerformer = ({String? id, String? why}); typedef QueuePerformer = ({String? id, String? why});
/// - A link passed on by its adder, or the adder's file: only the adder /// - A link passed on by its adder, or the adder's file: only the adder
/// (their app has the file, the cookies). Gone: skipped. /// (their app has the file, the cookies). Gone (disconnected, in its
/// grace): it waits for them; left for good: skipped.
/// - Jellyfin: the adder when it plays that server with its own account, /// - Jellyfin: the adder when it plays that server with its own account,
/// else the lowest member id that does (`jf[serverId] == true`, not /// else the lowest member id that does (`jf[serverId] == true`,
/// stale, not sitting out). Nobody: skipped. /// present: `in` or joining). Nobody: skipped.
/// - A link everyone opens, or one the server fetches: the adder when /// - A link everyone opens, or one the server fetches: the adder when
/// present (not stale or sitting out), else the lowest such member id. /// present, else the lowest present member id.
QueuePerformer queuePerformer(QueueItem item, List<MemberView> members) { QueuePerformer queuePerformer(QueueItem item, List<MemberView> members) {
MemberView? adder; MemberView? adder;
for (final m in members) { for (final m in members) {
if (m.id == item.by) adder = m; if (m.id == item.by) adder = m;
} }
bool active(MemberView m) => !m.stale && !m.out; bool active(MemberView m) => isPresent(m);
if (item.how.adderOnly) { if (item.how.adderOnly) {
if (adder == null) return (id: null, why: '${_name(item)} left'); if (adder == null) return (id: null, why: '${_name(item)} left');
if (adder.presence == Presence.gone) return (id: null, why: null);
return (id: adder.id, why: null); return (id: adder.id, why: null);
} }
final ref = item.media.jellyfin; final ref = item.media.jellyfin;
@@ -48,8 +51,9 @@ String? autoAdvanceLowest(Iterable<MemberView> members) {
} }
/// Who takes an item nobody can put up off the queue (`remove` with why): /// Who takes an item nobody can put up off the queue (`remove` with why):
/// the lowest member id that is not stale. /// the lowest member id that is connected (not gone).
String? queueJanitor(List<MemberView> members) => autoAdvanceLowest(members.where((m) => !m.stale)); String? queueJanitor(List<MemberView> members) =>
autoAdvanceLowest(members.where((m) => m.presence != Presence.gone));
/// How [item] plays, in a few words for its row: "through Foxyz" (you: /// How [item] plays, in a few words for its row: "through Foxyz" (you:
/// "through you"), "server fetches", "everyone fetches", "Jellyfin", /// "through you"), "server fetches", "everyone fetches", "Jellyfin",
+28 -45
View File
@@ -3,15 +3,12 @@ import 'dart:math' as math;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:fox_utils/fox_utils.dart' show ClipboardX, DesktopDropX, DropTarget;
import 'package:leaf_widgets/leaf_widgets.dart' show LeafPopover, LeafPopoverController; import 'package:leaf_widgets/leaf_widgets.dart' show LeafPopover, LeafPopoverController;
import 'package:signals/signals_flutter.dart'; import 'package:signals/signals_flutter.dart';
import 'package:sync_relay_core/core.dart'; import 'package:sync_relay_core/core.dart';
import '../controls/controls_probe.dart' show ControlsHold; import '../controls/controls_probe.dart' show ControlsHold;
import '../controls/uosc/buttons.dart' show UoscButton; import '../controls/minimal/style.dart';
import '../controls/uosc/controls.dart' show UoscControls;
import '../controls/uosc/style.dart';
import '../files/scanner.dart' show FileKind, kindOf; import '../files/scanner.dart' show FileKind, kindOf;
import '../jellyfin/images.dart'; import '../jellyfin/images.dart';
import '../jellyfin/session_media.dart' show EpisodeLink; import '../jellyfin/session_media.dart' show EpisodeLink;
@@ -28,16 +25,16 @@ import '../widgets/text_focus.dart' show KeyboardClaim;
import 'queue_controller.dart'; import 'queue_controller.dart';
import 'queue_logic.dart'; import 'queue_logic.dart';
// Up next in the player: its control in the top bar (with chat and // Up next in the player: its control in the top bar (with the window
// fullscreen), the popover it opens (the queue, the continuation, a field // controls), the popover it opens (the queue, the continuation, a field
// and a drop zone to add), and the end-of-media card for the first item. // and a drop zone to add), and the end-of-media card for the first item.
// ---------------------------------------------------------------- button // ---------------------------------------------------------------- button
/// Which controls design the Up next control sits in: it takes the look /// Which controls skin the Up next control sits in: it takes the look
/// of that bar's buttons. /// of that bar's buttons.
enum UpNextLook { standard, uosc } enum UpNextLook { standard, minimal }
/// The next item's picture, "UP NEXT" over its title, a +n count and ⏭ /// The next item's picture, "UP NEXT" over its title, a +n count and ⏭
/// (play it now) in the player's top bar, one flat control among its /// (play it now) in the player's top bar, one flat control among its
@@ -66,7 +63,7 @@ class _UpNextButtonState extends State<UpNextButton> {
bool _hoverTitle = false; bool _hoverTitle = false;
QueueController get _q => widget.queue; QueueController get _q => widget.queue;
bool get _uosc => widget.look == UpNextLook.uosc; bool get _minimal => widget.look == UpNextLook.minimal;
@override @override
void didUpdateWidget(UpNextButton oldWidget) { void didUpdateWidget(UpNextButton oldWidget) {
@@ -135,16 +132,16 @@ class _UpNextButtonState extends State<UpNextButton> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
// The bar's own metrics: uosc's flat 40 px boxes, or media_kit's // The bar's own metrics: the Minimal skin's rounded squares, or the
// round 44 px icon buttons. // Standard skin's round 44 px icon buttons.
final height = _uosc ? UoscControls.buttonRowHeight : 44.0; final height = _minimal ? MinimalStyle.button + 2 * MinimalStyle.capsulePad : 44.0;
final radius = _uosc ? UoscStyle.radius : height / 2; final radius = _minimal ? MinimalStyle.radius + MinimalStyle.capsulePad : height / 2;
final hover = _uosc ? UoscStyle.hover : Colors.white10; final hover = _minimal ? MinimalStyle.hover : Colors.white10;
final fontSize = _uosc ? UoscStyle.fontSize : 14.0; final fontSize = _minimal ? MinimalStyle.fontSize : 14.0;
final dim = _uosc ? UoscStyle.dim : Colors.white70; final dim = _minimal ? MinimalStyle.dim : Colors.white70;
// The picture fills the button's height but for a 7 px margin. // The picture fills the button's height but for a 7 px margin.
final thumbWidth = (height - 14) * 16 / 9; final thumbWidth = (height - 14) * 16 / 9;
final thumbRadius = _uosc ? UoscStyle.radius : 6.0; final thumbRadius = _minimal ? MinimalStyle.radius - 4 : 6.0;
return SignalBuilder(builder: (context) { return SignalBuilder(builder: (context) {
final items = _q.items.value; final items = _q.items.value;
final cont = _q.shownContinuation.value; final cont = _q.shownContinuation.value;
@@ -197,7 +194,7 @@ class _UpNextButtonState extends State<UpNextButton> {
duration: const Duration(milliseconds: 150), duration: const Duration(milliseconds: 150),
height: height, height: height,
// The picture clears a round button's curve. // The picture clears a round button's curve.
padding: EdgeInsets.only(left: _uosc ? 7 : 12, right: 6), padding: EdgeInsets.only(left: _minimal ? 7 : 12, right: 6),
decoration: BoxDecoration( decoration: BoxDecoration(
color: _hoverTitle || open ? hover : Colors.transparent, color: _hoverTitle || open ? hover : Colors.transparent,
borderRadius: BorderRadius.circular(radius), borderRadius: BorderRadius.circular(radius),
@@ -234,7 +231,7 @@ class _UpNextButtonState extends State<UpNextButton> {
const SizedBox(width: 8), const SizedBox(width: 8),
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1), padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
decoration: BoxDecoration(color: UoscStyle.hover, borderRadius: BorderRadius.circular(999)), decoration: BoxDecoration(color: MinimalStyle.hover, borderRadius: BorderRadius.circular(999)),
child: Text('+$more', style: TextStyle(color: dim, fontSize: 11)), child: Text('+$more', style: TextStyle(color: dim, fontSize: 11)),
), ),
], ],
@@ -254,8 +251,8 @@ class _UpNextButtonState extends State<UpNextButton> {
? 'Play ${item!.title} now ($playNextKeys)' ? 'Play ${item!.title} now ($playNextKeys)'
: 'Play ${episode == null ? '' : episode.label} now ($playNextKeys)'; : 'Play ${episode == null ? '' : episode.label} now ($playNextKeys)';
final VoidCallback? play = queued ? _q.skip : (episode != null ? widget.onPlayNextEpisode : null); final VoidCallback? play = queued ? _q.skip : (episode != null ? widget.onPlayNextEpisode : null);
final playButton = _uosc final playButton = _minimal
? UoscButton(key: const ValueKey('up-next-play'), icon: Icons.skip_next_rounded, tooltip: playTip, onPressed: play) ? MinimalButton(key: const ValueKey('up-next-play'), icon: Icons.skip_next_rounded, tooltip: playTip, onPressed: play)
: Tooltip( : Tooltip(
message: playTip, message: playTip,
waitDuration: const Duration(milliseconds: 500), waitDuration: const Duration(milliseconds: 500),
@@ -347,7 +344,7 @@ class UpNextPreview extends StatelessWidget {
const picture = _width - 2 * _pad; const picture = _width - 2 * _pad;
if (item != null) { if (item != null) {
final r = queue.coord.serverState.value; final r = queue.coord.serverState.value;
final selfId = queue.coord.sync.memberId; final selfId = queue.coord.connection.memberId;
final more = queue.items.value.length - 1; final more = queue.items.value.length - 1;
final ready = warm?.id == item.id ? warm!.state : null; final ready = warm?.id == item.id ? warm!.state : null;
return Column( return Column(
@@ -452,7 +449,7 @@ class _KeyCap extends StatelessWidget {
String? performerNote(QueueController queue, QueueItem item, StateBroadcast? r) { String? performerNote(QueueController queue, QueueItem item, StateBroadcast? r) {
if (r == null) return null; if (r == null) return null;
final id = queuePerformer(item, r.members).id; final id = queuePerformer(item, r.members).id;
if (id == null || id == queue.coord.sync.memberId) return null; if (id == null || id == queue.coord.connection.memberId) return null;
final name = r.members.where((m) => m.id == id).firstOrNull?.name; final name = r.members.where((m) => m.id == id).firstOrNull?.name;
return name == null ? null : '$name puts it up'; return name == null ? null : '$name puts it up';
} }
@@ -642,23 +639,9 @@ class _QueuePanelState extends State<QueuePanel> {
return KeyEventResult.ignored; return KeyEventResult.ignored;
} }
Future<({List<String> files, String? text})> _readClipboard() async {
var files = const <String>[];
String? text;
try {
files = await ClipboardX.readFiles();
} catch (_) {}
if (files.isEmpty) {
try {
text = await ClipboardX.readText();
} catch (_) {}
}
return (files: files, text: text);
}
/// Ctrl+V: the clipboard's video files, else the links in its text. /// Ctrl+V: the clipboard's video files, else the links in its text.
Future<void> _paste() async { Future<void> _paste() async {
final clip = await (widget.readClipboard ?? _readClipboard)(); final clip = await (widget.readClipboard ?? SessionState.instance.ports.shell.readPaste)();
final picks = _picksFrom([...clip.files, ...?clip.text?.split(RegExp(r'\s+'))]); final picks = _picksFrom([...clip.files, ...?clip.text?.split(RegExp(r'\s+'))]);
if (!mounted) return; if (!mounted) return;
if (picks.isEmpty) { if (picks.isEmpty) {
@@ -831,12 +814,12 @@ class _QueuePanelState extends State<QueuePanel> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final height = MediaQuery.sizeOf(context).height; final height = MediaQuery.sizeOf(context).height;
final drop = DropTarget( final drop = SessionState.instance.ports.shell.dropTarget(
onDragEntered: (_) => setState(() => _dragging = true), onEnter: () => setState(() => _dragging = true),
onDragExited: (_) => setState(() => _dragging = false), onExit: () => setState(() => _dragging = false),
onDragDone: (d) { onDrop: (paths, _) {
setState(() => _dragging = false); setState(() => _dragging = false);
_dropped(DesktopDropX.pathsOf(d.files)); _dropped(paths);
}, },
// Esc: the popover's own (it fades out). // Esc: the popover's own (it fades out).
child: Builder( child: Builder(
@@ -858,7 +841,7 @@ class _QueuePanelState extends State<QueuePanel> {
final cont = _q.shownContinuation.value; final cont = _q.shownContinuation.value;
final warm = _q.warm.value; final warm = _q.warm.value;
final r = _q.coord.serverState.value; final r = _q.coord.serverState.value;
final selfId = _q.coord.sync.memberId; final selfId = _q.coord.connection.memberId;
return Column( return Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
@@ -1575,7 +1558,7 @@ class UpNextCard extends StatelessWidget {
final item = c.item; final item = c.item;
final left = c.secondsLeft; final left = c.secondsLeft;
final r = queue.coord.serverState.value; final r = queue.coord.serverState.value;
final selfId = queue.coord.sync.memberId; final selfId = queue.coord.connection.memberId;
final waiting = left == null ? performerNote(queue, item, r) : null; final waiting = left == null ? performerNote(queue, item, r) : null;
return AppGlass( return AppGlass(
key: const ValueKey('up-next-card'), key: const ValueKey('up-next-card'),
+1 -1
View File
@@ -158,7 +158,7 @@ class AniListClient {
_cacheDir = cacheDir ?? _defaultCacheDir, _cacheDir = cacheDir ?? _defaultCacheDir,
_delay = delay ?? Future<void>.delayed, _delay = delay ?? Future<void>.delayed,
_now = now ?? DateTime.now, _now = now ?? DateTime.now,
userAgent = userAgent ?? 'watch-together/$appVersion'; userAgent = userAgent ?? 'Starsync/$appVersion';
final Uri url; final Uri url;
final Duration memoryTtl; final Duration memoryTtl;
+1 -1
View File
@@ -474,7 +474,7 @@ class AnimeFlowClient {
_ownsHttp = http == null, _ownsHttp = http == null,
_delay = delay ?? Future<void>.delayed, _delay = delay ?? Future<void>.delayed,
_now = now ?? DateTime.now, _now = now ?? DateTime.now,
userAgent = userAgent ?? 'watch-together/$appVersion'; userAgent = userAgent ?? 'Starsync/$appVersion';
/// Authenticated with the linked Jellyfin account on the same domain /// Authenticated with the linked Jellyfin account on the same domain
/// (see [tokenFor]), looked up on every call so a re-link applies. /// (see [tokenFor]), looked up on every call so a re-link applies.
@@ -12,6 +12,10 @@ import 'settings_page.dart';
import 'ui.dart'; import 'ui.dart';
import 'update_indicator.dart'; import 'update_indicator.dart';
/// The Minimal player skin's credit (README says the same).
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 → /// Name, version, update state, the source link and the logs (Settings →
/// About). /// About).
class AboutPanel extends StatelessWidget { class AboutPanel extends StatelessWidget {
@@ -48,6 +52,8 @@ class AboutPanel extends StatelessWidget {
), ),
], ],
), ),
const SizedBox(height: 4),
Text(uoscCredit, key: const ValueKey('uosc-credit'), style: t.bodySmall?.copyWith(color: Colors.grey)),
const SizedBox(height: kGap), const SizedBox(height: kGap),
Text('Something went wrong? Copy diagnostics and send them to whoever helps you: ' Text('Something went wrong? Copy diagnostics and send them to whoever helps you: '
'versions and the last hours of the log, passwords, tokens and cookies left out. ' 'versions and the last hours of the log, passwords, tokens and cookies left out. '
@@ -113,8 +119,7 @@ class AboutPanel extends StatelessWidget {
static Future<void> _openLogs() async { static Future<void> _openLogs() async {
final dir = appLogDir; final dir = appLogDir;
if (dir == null) return; if (dir == null) return;
// The file manager opens a folder path the way a browser opens a link. if (!await SessionState.instance.ports.folders.reveal(dir)) {
if (!await openInBrowser(Uri.directory(dir).toString())) {
await copyToClipboard(dir, done: "Couldn't open it — folder path copied"); await copyToClipboard(dir, done: "Couldn't open it — folder path copied");
} }
} }
@@ -1,8 +1,10 @@
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import '../../session/connection.dart' show connectErrorText;
import '../../session/identity.dart';
import '../../session/session_coordinator.dart'; import '../../session/session_coordinator.dart';
import '../../state/session_state.dart'; import '../../state/session_state.dart';
import 'profile.dart'; import '../../sync/sync_client.dart' show HelloRefused;
/// What a card shows under its fields after a failed attempt. /// What a card shows under its fields after a failed attempt.
class CardError { class CardError {
@@ -107,7 +109,9 @@ class ConnectFlow extends ChangeNotifier {
} catch (e) { } catch (e) {
if (myAttempt != _attempt) return; if (myAttempt != _attempt) return;
busy = null; busy = null;
errors[mode] = CardError('Could not connect', '$e'); // A refusal says why in plain words (an older host, a wrong password,
// another window).
errors[mode] = CardError('Could not connect', e is HelloRefused ? connectErrorText(e) : '$e');
_notify(); _notify();
return; return;
} }
@@ -1,7 +1,7 @@
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:signals/signals_flutter.dart'; import 'package:signals/signals_flutter.dart';
import '../../state/session_state.dart';
import '../../state/settings.dart'; import '../../state/settings.dart';
import '../../update/update_controller.dart'; import '../../update/update_controller.dart';
import 'ui.dart'; import 'ui.dart';
@@ -89,12 +89,11 @@ class CustomShaderFiles extends StatelessWidget {
const CustomShaderFiles({super.key}); const CustomShaderFiles({super.key});
Future<void> _addFiles() async { Future<void> _addFiles() async {
final List<PlatformFile> files; final List<String> files;
try { try {
files = await FilePicker.pickFiles( files = await SessionState.instance.ports.folders.pickFiles(
dialogTitle: 'Pick mpv GLSL shaders', dialogTitle: 'Pick mpv GLSL shaders',
type: FileType.custom, extensions: const ['glsl', 'hook'],
allowedExtensions: const ['glsl', 'hook'],
); );
} catch (_) { } catch (_) {
return; return;
@@ -102,8 +101,8 @@ class CustomShaderFiles extends StatelessWidget {
final s = AppSettings.instance; final s = AppSettings.instance;
final current = s.customShaders.peek(); final current = s.customShaders.peek();
final added = [ final added = [
for (final f in files) for (final p in files)
if (f.path case final p? when !current.contains(p)) p, if (!current.contains(p)) p,
]; ];
if (added.isNotEmpty) s.setCustomShaders([...current, ...added]); if (added.isNotEmpty) s.setCustomShaders([...current, ...added]);
} }
@@ -198,8 +197,8 @@ class UpdateSettings extends StatelessWidget {
builder: (context) => SwitchListTile( builder: (context) => SwitchListTile(
key: const ValueKey('auto-check-updates'), key: const ValueKey('auto-check-updates'),
contentPadding: EdgeInsets.zero, contentPadding: EdgeInsets.zero,
title: const Text('Check for updates on start'), title: const Text('Check for updates automatically'),
subtitle: const Text('Looks for a new version when the app opens; nothing installs without asking'), subtitle: const Text('When the app opens and every few hours; nothing installs without asking'),
value: s.autoCheckUpdates.value, value: s.autoCheckUpdates.value,
onChanged: s.setAutoCheckUpdates, onChanged: s.setAutoCheckUpdates,
), ),
@@ -22,6 +22,10 @@ String displayVersion({String version = appVersion, String pubspec = _pubspecVer
return isDevBuild(version) && commit.isNotEmpty ? '$version · $commit' : version; return isDevBuild(version) && commit.isNotEmpty ? '$version · $commit' : version;
} }
/// The version in a few characters, always on screen (under the strip's
/// 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;
const kRadius = 12.0; const kRadius = 12.0;
const kGap = 16.0; const kGap = 16.0;
@@ -1,14 +1,17 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:sync_relay_core/core.dart' show isDevBuild;
import '../../update/update_controller.dart'; import '../../update/update_controller.dart';
import '../../widgets/update_banner.dart'; import '../../widgets/update_banner.dart';
import 'ui.dart'; import 'ui.dart';
/// Compact "Update vX" pill for the party strip; hidden until there is /// The band across the top of the window while an update is up for
/// something to install. Click opens the full [UpdateBanner]. /// grabs: "Starsync 1.2.1 is ready", its notes, Update and restart and
class UpdateIndicator extends StatelessWidget { /// 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; final UpdateController? controller;
const UpdateIndicator({super.key, this.controller}); const UpdateBar({super.key, this.controller});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -21,34 +24,102 @@ class UpdateIndicator extends StatelessWidget {
return const SizedBox.shrink(); return const SizedBox.shrink();
} }
final scheme = Theme.of(context).colorScheme; final scheme = Theme.of(context).colorScheme;
final alert = m.isRequired || s.phase == UpdatePhase.failed;
final fg = alert ? scheme.onErrorContainer : scheme.onPrimaryContainer;
final current = c.currentVersion;
// A release (not a test build) that is behind: say which it runs.
final behind = isDevBuild(current) ? null : 'You are running $current';
final String title;
String detail;
switch (s.phase) {
case UpdatePhase.downloading:
title = 'Downloading $appName ${m.version}… ${((s.progress ?? 0) * 100).round()}%';
detail = '';
case UpdatePhase.applying:
title = 'Installing $appName ${m.version}…';
detail = '';
case UpdatePhase.restarting:
title = 'Restarting into $appName ${m.version}…';
detail = '';
case UpdatePhase.failed:
title = 'Update to $appName ${m.version} failed';
detail = '${s.error}';
case UpdatePhase.available || UpdatePhase.idle:
if (!s.canInstall) {
title = 'This copy of $appName is older than the latest release (${m.version})';
detail = 'Download the new release and replace this folder by hand';
} else if (m.isRequired) {
title = '$appName ${m.version} is required to join servers on it';
detail = m.notes;
} else {
title = '$appName ${m.version} is ready';
detail = m.notes;
}
detail = [?behind, detail].where((t) => t.isNotEmpty).join(' · ');
}
final busy = s.phase == UpdatePhase.downloading || final busy = s.phase == UpdatePhase.downloading ||
s.phase == UpdatePhase.applying || s.phase == UpdatePhase.applying ||
s.phase == UpdatePhase.restarting; s.phase == UpdatePhase.restarting;
final label = switch (s.phase) { return Material(
UpdatePhase.downloading => key: const ValueKey('update-bar'),
'Updating ${((s.progress ?? 0) * 100).round()}%', color: alert ? scheme.errorContainer : scheme.primaryContainer,
UpdatePhase.applying => 'Installing…', child: Padding(
UpdatePhase.restarting => 'Restarting…', padding: const EdgeInsets.fromLTRB(16, 8, 12, 8),
UpdatePhase.failed => 'Update failed', child: Row(
_ => m.isRequired ? 'Required update' : 'Update v${m.version}', children: [
}; if (busy)
return Tooltip( SizedBox.square(
message: 'Show update', dimension: 18,
child: FilledButton.tonalIcon( child: CircularProgressIndicator(
style: FilledButton.styleFrom( strokeWidth: 2,
backgroundColor: m.isRequired || s.phase == UpdatePhase.failed color: fg,
? scheme.errorContainer value: s.phase == UpdatePhase.downloading ? s.progress : null,
: null, ),
visualDensity: VisualDensity.compact, )
else
Icon(Icons.system_update_alt, size: 20, color: fg),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: fg, fontWeight: FontWeight.w600)),
if (detail.isNotEmpty)
Tooltip(
message: detail,
waitDuration: const Duration(milliseconds: 600),
child: Text(detail,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: fg.withValues(alpha: 0.8), fontSize: 12)),
),
],
),
),
if (!busy) ...[
const SizedBox(width: 12),
if (!m.isRequired)
TextButton(
key: const ValueKey('update-later'),
style: TextButton.styleFrom(foregroundColor: fg),
onPressed: c.dismiss,
child: const Text('Later'),
),
if (s.canInstall) ...[
const SizedBox(width: 4),
FilledButton(
key: const ValueKey('update-install'),
onPressed: c.install,
child: Text(s.phase == UpdatePhase.failed ? 'Retry' : 'Update and restart'),
),
],
],
],
), ),
onPressed: () => showUpdateDialog(context, controller: c),
icon: busy
? const SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(strokeWidth: 2))
: const Icon(Icons.system_update_alt, size: 18),
label: Text(label),
), ),
); );
}, },
@@ -85,6 +156,9 @@ Future<void> showUpdateDialog(BuildContext context, {UpdateController? controlle
); );
} }
/// How often a running app looks again (auto-check on).
const updateCheckEvery = Duration(hours: 4);
enum UpdateCheck { notRun, checking, done } enum UpdateCheck { notRun, checking, done }
/// Progress of this run's update check, for Settings/About wording. /// Progress of this run's update check, for Settings/About wording.
@@ -8,35 +8,6 @@ import 'player_controls.dart' show FloatingPill;
// Jellyfin pieces of the session player. Plain widgets fed by the // Jellyfin pieces of the session player. Plain widgets fed by the
// coordinator's signals, so tests can pump them without the player. // coordinator's signals, so tests can pump them without the player.
/// Previous / next episode in the video controls. Nothing when there is
/// no such episode or this viewer cannot put it up (no account).
class EpisodeSkipButton extends StatelessWidget {
final EpisodeLink? episode;
final bool next;
final VoidCallback onPressed;
final FocusNode? focusNode;
const EpisodeSkipButton(
{super.key, required this.episode, required this.next, required this.onPressed, this.focusNode});
@override
Widget build(BuildContext context) {
final e = episode;
if (e == null) return const SizedBox.shrink();
return Tooltip(
message: '${next ? 'Next' : 'Previous'}: ${e.label} (${next ? '> or Shift+N' : '< or Shift+P'})',
child: IconButton(
key: ValueKey(next ? 'episode-next' : 'episode-previous'),
focusNode: focusNode,
color: Colors.white,
iconSize: 22,
visualDensity: VisualDensity.compact,
icon: Icon(next ? Icons.skip_next_rounded : Icons.skip_previous_rounded),
onPressed: onPressed,
),
);
}
}
/// Floating "Next episode ›" from the ED (or the last two minutes), while /// Floating "Next episode ›" from the ED (or the last two minutes), while
/// the controls are up (`PillGate`): faint at rest, lit on hover. /// the controls are up (`PillGate`): faint at rest, lit on hover.
class NextEpisodePill extends StatelessWidget { class NextEpisodePill extends StatelessWidget {
@@ -1,530 +1,11 @@
import 'dart:async';
import 'dart:math' as math;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:media_kit/media_kit.dart';
import 'package:sync_relay_core/core.dart' show JellyfinStreamType;
import '../../jellyfin/track_prefs.dart' show MpvTrack;
import '../../playback/media_marks.dart'; import '../../playback/media_marks.dart';
import '../../widgets/app_menu.dart' show AppGlass; import '../../widgets/app_menu.dart' show AppGlass;
// Session control-bar pieces built on the playback signals (chapters, // The player's floating action pills (Skip OP / ED / 1:30, Next episode):
// tracks, skip offers): the seek bar with chapter marks, the chapter and // plain widgets fed by values and callbacks, so tests pump them without a
// track menus, the 1:30 skip button and the floating skip pill. Plain // player. They live in the action area, outside the controls.
// widgets fed by values and callbacks, so tests pump them without a player.
/// Keeps media_kit's desktop controls shown, as a mouse hover would.
/// While a seek bar drag runs no hover events arrive, and the controls
/// would hide (and unmount the bar) mid-drag. A guarded dynamic call on
/// the private controls state; nothing happens outside those controls.
void keepVideoControlsAwake(BuildContext context) {
context.visitAncestorElements((e) {
if (e is StatefulElement &&
e.state.runtimeType.toString() == '_MaterialDesktopVideoControlsState') {
try {
(e.state as dynamic).onHover();
} catch (_) {}
return false;
}
return true;
});
}
/// The seek bar: played / buffered track, a diamond at each chapter start
/// (not the one at 0), and on hover the time with the chapter title above
/// it. Like media_kit's bar it reports a seek on every drag move and on
/// release ([onSeek]); the session routes those through the user-seek path
/// (debounced, everyone follows).
class ChapterSeekBar extends StatefulWidget {
final Duration position;
final Duration duration;
final Duration buffer;
final List<MediaChapter> chapters;
final ValueChanged<Duration> onSeek;
/// Any pointer activity on the bar (keeps the controls up).
final VoidCallback? onActivity;
const ChapterSeekBar({
super.key,
required this.position,
required this.duration,
this.buffer = Duration.zero,
this.chapters = const [],
required this.onSeek,
this.onActivity,
});
static const height = 28.0;
@override
State<ChapterSeekBar> createState() => _ChapterSeekBarState();
}
class _ChapterSeekBarState extends State<ChapterSeekBar> {
double? _hoverX;
bool _dragging = false;
double _dragFraction = 0;
double _fraction(Duration d) {
final total = widget.duration.inMicroseconds;
if (total <= 0) return 0;
return (d.inMicroseconds / total).clamp(0.0, 1.0);
}
Duration _at(double fraction) => widget.duration * fraction.clamp(0.0, 1.0);
void _seekTo(double x, double width) {
if (widget.duration <= Duration.zero || width <= 0) return;
_dragFraction = (x / width).clamp(0.0, 1.0);
widget.onSeek(_at(_dragFraction));
}
@override
Widget build(BuildContext context) {
final primary = Theme.of(context).colorScheme.primary;
return LayoutBuilder(builder: (context, constraints) {
final width = constraints.maxWidth;
final active = _hoverX != null || _dragging;
final barHeight = active ? 5.0 : 3.0;
final played = _dragging ? _dragFraction : _fraction(widget.position);
const mid = ChapterSeekBar.height / 2;
final hoverX = _hoverX;
final hoverAt = hoverX == null || width <= 0 ? null : _at(hoverX / width);
final hoverChapter = hoverAt == null ? null : chapterIndexAt(widget.chapters, hoverAt);
final hoverTitle = hoverChapter == null ? '' : widget.chapters[hoverChapter].title;
return MouseRegion(
cursor: SystemMouseCursors.click,
onEnter: (e) => setState(() => _hoverX = e.localPosition.dx.clamp(0, width)),
onHover: (e) {
setState(() => _hoverX = e.localPosition.dx.clamp(0, width));
widget.onActivity?.call();
},
onExit: (_) => setState(() => _hoverX = null),
child: Listener(
behavior: HitTestBehavior.opaque,
onPointerDown: (e) {
widget.onActivity?.call();
setState(() {
_dragging = true;
_dragFraction = width <= 0 ? 0 : (e.localPosition.dx / width).clamp(0.0, 1.0);
});
},
onPointerMove: (e) {
widget.onActivity?.call();
setState(() => _hoverX = e.localPosition.dx.clamp(0, width));
_seekTo(e.localPosition.dx, width);
},
onPointerUp: (e) {
_seekTo(e.localPosition.dx, width);
setState(() => _dragging = false);
widget.onActivity?.call();
},
onPointerCancel: (_) => setState(() => _dragging = false),
child: SizedBox(
key: const ValueKey('seek-bar'),
width: width,
height: ChapterSeekBar.height,
child: Stack(
clipBehavior: Clip.none,
children: [
Positioned(
left: 0,
right: 0,
top: mid - barHeight / 2,
height: barHeight,
child: Container(color: Colors.white24),
),
Positioned(
left: 0,
top: mid - barHeight / 2,
height: barHeight,
width: width * _fraction(widget.buffer),
child: Container(color: Colors.white24),
),
if (hoverX != null && !_dragging)
Positioned(
left: 0,
top: mid - barHeight / 2,
height: barHeight,
width: hoverX,
child: Container(color: Colors.white24),
),
Positioned(
left: 0,
top: mid - barHeight / 2,
height: barHeight,
width: width * played,
child: Container(color: primary),
),
for (final c in widget.chapters)
if (c.start > Duration.zero && widget.duration > Duration.zero)
Positioned(
key: ValueKey('chapter-mark-${c.index}'),
left: width * _fraction(c.start) - 4,
top: mid - 4,
width: 8,
height: 8,
child: IgnorePointer(
child: Transform.rotate(
angle: math.pi / 4,
child: Container(
margin: const EdgeInsets.all(1),
decoration: BoxDecoration(
color: Colors.white,
border: Border.all(color: Colors.black54, width: 0.8),
),
),
),
),
),
if (active)
Positioned(
left: width * played - 6,
top: mid - 6,
width: 12,
height: 12,
child: IgnorePointer(
child: DecoratedBox(
decoration: BoxDecoration(color: primary, shape: BoxShape.circle),
),
),
),
if (hoverAt != null)
Positioned(
// Kept clear of the window edges (the tip is centred).
left: hoverX!.clamp(math.min(60.0, width / 2), math.max(width - 60, width / 2)),
bottom: ChapterSeekBar.height - 2,
child: IgnorePointer(
child: FractionalTranslation(
translation: const Offset(-0.5, 0),
child: _HoverTip(title: hoverTitle, time: formatClock(hoverAt)),
),
),
),
],
),
),
),
);
});
}
}
class _HoverTip extends StatelessWidget {
final String title;
final String time;
const _HoverTip({required this.title, required this.time});
@override
Widget build(BuildContext context) {
return Container(
constraints: const BoxConstraints(maxWidth: 320),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.8),
borderRadius: BorderRadius.circular(6),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (title.isNotEmpty)
Text(title,
key: const ValueKey('seek-hover-chapter'),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Colors.white, fontSize: 12, fontWeight: FontWeight.w600)),
Text(time,
key: const ValueKey('seek-hover-time'),
style: const TextStyle(color: Colors.white70, fontSize: 11)),
],
),
);
}
}
/// [ChapterSeekBar] on a live player: position, duration and buffer from
/// its streams. Seeks go to `player.seek`, which the session's
/// `SyncPlayer` turns into user seeks.
class PlayerSeekBar extends StatefulWidget {
final Player player;
final List<MediaChapter> chapters;
const PlayerSeekBar({super.key, required this.player, required this.chapters});
@override
State<PlayerSeekBar> createState() => _PlayerSeekBarState();
}
class _PlayerSeekBarState extends State<PlayerSeekBar> {
final List<StreamSubscription<Object>> _subs = [];
late Duration _position, _duration, _buffer;
@override
void initState() {
super.initState();
_listen();
}
@override
void didUpdateWidget(PlayerSeekBar old) {
super.didUpdateWidget(old);
if (!identical(old.player, widget.player)) {
_cancel();
_listen();
}
}
void _listen() {
final p = widget.player;
_position = p.state.position;
_duration = p.state.duration;
_buffer = p.state.buffer;
void set(VoidCallback f) {
if (mounted) setState(f);
}
_subs.addAll([
p.stream.position.listen((d) => set(() => _position = d)),
p.stream.duration.listen((d) => set(() => _duration = d)),
p.stream.buffer.listen((d) => set(() => _buffer = d)),
]);
}
void _cancel() {
for (final s in _subs) {
s.cancel();
}
_subs.clear();
}
@override
void dispose() {
_cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
return ChapterSeekBar(
position: _position,
duration: _duration,
buffer: _buffer,
chapters: widget.chapters,
onSeek: (d) => widget.player.seek(d),
onActivity: () => keepVideoControlsAwake(context),
);
}
}
RelativeRect _menuPosition(BuildContext context) {
final button = context.findRenderObject() as RenderBox;
final overlay = Navigator.of(context).overlay!.context.findRenderObject() as RenderBox;
return RelativeRect.fromRect(
Rect.fromPoints(
button.localToGlobal(Offset.zero, ancestor: overlay),
button.localToGlobal(button.size.bottomRight(Offset.zero), ancestor: overlay),
),
Offset.zero & overlay.size,
);
}
/// Control-bar icon with a small count in the corner.
class _BadgedIcon extends StatelessWidget {
final IconData icon;
final int count;
const _BadgedIcon(this.icon, this.count);
@override
Widget build(BuildContext context) {
return Stack(
clipBehavior: Clip.none,
children: [
Icon(icon),
if (count > 0)
Positioned(
right: -7,
top: -5,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 0.5),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primary,
borderRadius: BorderRadius.circular(999),
),
child: Text('$count',
style: const TextStyle(color: Colors.white, fontSize: 9, fontWeight: FontWeight.bold)),
),
),
],
);
}
}
/// Chapter list for the controls: current chapter checked, a pick seeks
/// to its start. Nothing when the file has no chapters.
class ChaptersControlButton extends StatelessWidget {
final List<MediaChapter> chapters;
final int? current;
final ValueChanged<MediaChapter> onPick;
final FocusNode? focusNode;
final ValueChanged<bool>? onMenuOpen;
const ChaptersControlButton({
super.key,
required this.chapters,
required this.current,
required this.onPick,
this.focusNode,
this.onMenuOpen,
});
@override
Widget build(BuildContext context) {
if (chapters.isEmpty) return const SizedBox.shrink();
return Tooltip(
message: 'Chapters (PgUp / PgDn)',
child: IconButton(
key: const ValueKey('chapters-button'),
focusNode: focusNode,
color: Colors.white,
iconSize: 20,
visualDensity: VisualDensity.compact,
icon: _BadgedIcon(Icons.format_list_bulleted, chapters.length),
onPressed: () => _open(context),
),
);
}
Future<void> _open(BuildContext context) async {
final position = _menuPosition(context);
onMenuOpen?.call(true);
final pick = await showMenu<MediaChapter>(
context: context,
position: position,
popUpAnimationStyle: AnimationStyle.noAnimation,
constraints: const BoxConstraints(minWidth: 240, maxWidth: 420),
items: [
for (final c in chapters)
CheckedPopupMenuItem(
value: c,
checked: c.index == current,
height: 36,
child: Row(
children: [
Expanded(
child: Text(c.title.isEmpty ? 'Chapter ${c.index + 1}' : c.title,
maxLines: 1, overflow: TextOverflow.ellipsis),
),
const SizedBox(width: 12),
Text(formatClock(c.start), style: const TextStyle(fontSize: 12, color: Colors.grey)),
],
),
),
],
);
onMenuOpen?.call(false);
if (pick != null) onPick(pick);
}
}
/// A track pick from [TrackMenuButton]: audio id, subtitle id, or off.
typedef TrackPick = ({int? aid, int? sid, bool subtitlesOff});
/// Audio or subtitle menu for the controls bar (the count of tracks on the
/// icon): "Japanese · E-AC3 2.0 · title", mpv's selection checked,
/// subtitles with Off. The choice is remembered for the next Jellyfin item
/// (per viewer).
class TrackMenuButton extends StatelessWidget {
final JellyfinStreamType type;
/// All tracks; only those of [type] are listed.
final List<MpvTrack> tracks;
final void Function(TrackPick pick) onPick;
final FocusNode? focusNode;
final ValueChanged<bool>? onMenuOpen;
const TrackMenuButton({
super.key,
required this.type,
required this.tracks,
required this.onPick,
this.focusNode,
this.onMenuOpen,
});
bool get _audio => type == JellyfinStreamType.audio;
@override
Widget build(BuildContext context) {
final list = tracks.where((t) => t.type == type).toList();
return Tooltip(
message: _audio ? 'Audio' : 'Subtitles',
child: IconButton(
key: ValueKey(_audio ? 'audio-button' : 'subtitles-button'),
focusNode: focusNode,
color: Colors.white,
iconSize: 20,
visualDensity: VisualDensity.compact,
icon: _BadgedIcon(_audio ? Icons.audiotrack_outlined : Icons.subtitles_outlined, list.length),
onPressed: () => _open(context, list),
),
);
}
Future<void> _open(BuildContext context, List<MpvTrack> list) async {
final position = _menuPosition(context);
onMenuOpen?.call(true);
final pick = await showMenu<TrackPick>(
context: context,
position: position,
popUpAnimationStyle: AnimationStyle.noAnimation,
constraints: const BoxConstraints(minWidth: 280, maxWidth: 460),
items: [
if (!_audio)
CheckedPopupMenuItem(
value: (aid: null, sid: null, subtitlesOff: true),
checked: !list.any((t) => t.selected),
height: 36,
child: const Text('Off'),
),
if (_audio && list.isEmpty)
const PopupMenuItem(enabled: false, height: 32, child: Text('None')),
for (final t in list)
CheckedPopupMenuItem(
value: _audio
? (aid: t.id, sid: null, subtitlesOff: false)
: (aid: null, sid: t.id, subtitlesOff: false),
checked: t.selected,
height: 36,
child: Text(t.label, maxLines: 1, overflow: TextOverflow.ellipsis),
),
],
);
onMenuOpen?.call(false);
if (pick != null) onPick(pick);
}
}
/// "1:30" in the controls: skip forward [fallbackSkip] (Shift+Right).
class SkipForwardButton extends StatelessWidget {
final VoidCallback onPressed;
final FocusNode? focusNode;
const SkipForwardButton({super.key, required this.onPressed, this.focusNode});
@override
Widget build(BuildContext context) {
return Tooltip(
message: 'Skip 1:30 for everyone (Shift+Right)',
child: TextButton.icon(
key: const ValueKey('skip-forward-button'),
focusNode: focusNode,
style: TextButton.styleFrom(
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 8),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
onPressed: onPressed,
icon: const Icon(Icons.fast_forward_rounded, size: 16),
label: const Text('1:30', style: TextStyle(fontSize: 12)),
),
);
}
}
/// The player's action pill (Next episode, Skip OP / ED / 1:30): the /// The player's action pill (Next episode, Skip OP / ED / 1:30): the
/// status pill's glass ([AppGlass.pill]) at [height], faint at rest, lit /// status pill's glass ([AppGlass.pill]) at [height], faint at rest, lit
@@ -0,0 +1,265 @@
import 'package:sync_relay_core/core.dart' show FeedMessage;
// Everything that may become a toast, a sound, an attention request or an
// activity-log line, as one sealed type. Code that has news emits an
// event ([Notifier.notify]); only the notifier decides what the user sees
// and hears. Pure Dart (no Flutter): it can move to core with the policy.
sealed class AppEvent {
const AppEvent();
}
// ---------------------------------------------------------------- feed
/// A server `feed` line (see PROTOCOL.md, `feed.action`); every one lands
/// in the activity log.
sealed class FeedEvent extends AppEvent {
final FeedMessage feed;
const FeedEvent(this.feed);
String get actor => feed.actor;
/// [feed] as its typed event. [selfId]: this member's id (people are
/// matched by `actorId` / `targetId`, never by name); [readyCount]:
/// members ready in the latest state (the pitch of a `ready` sound).
factory FeedEvent.of(FeedMessage f, {String selfId = '', int readyCount = 0}) {
bool isSelf(String id) => selfId.isNotEmpty && id == selfId;
return switch (f.action) {
'join' => MemberJoined(f),
'leave' => MemberLeft(f),
'gone' => MemberGone(f),
'return' => MemberReturned(f),
'away' => MemberSatOut(f,
atYou: isSelf(f.targetId) && !isSelf(f.actorId),
autoSelf: isSelf(f.actorId) && f.targetId.isEmpty && f.reason.isNotEmpty),
'in' => MemberBack(f),
'ready' => ReadyChanged(f, ready: true, readyIndex: readyCount),
'unready' => ReadyChanged(f, ready: false),
'force-ready' => ForcedReady(f, ready: true),
'force-unready' => ForcedReady(f, ready: false),
'force-play' => ForcedTransport(f, play: true),
'force-hold' => ForcedTransport(f, play: false),
'swap' => MediaSwapped(f),
'seek' => Seeked(f),
'pause' => Paused(f),
'resume' => Resumed(f),
'finished' => Finished(f),
'chat' => ChatPosted(f, own: isSelf(f.actorId)),
'rename' => MemberRenamed(f),
'avatar' => AvatarChanged(f),
'queue-add' => QueueAdded(f),
'queue-remove' => QueueRemoved(f),
'queue-skip' => QueueSkipped(f),
'fetch-fail' => FetchFailed(f),
_ => OtherFeed(f),
};
}
}
class MemberJoined extends FeedEvent {
const MemberJoined(super.feed);
}
/// Left for good (`reason` `bye`: the app closed or Leave; `grace`: gone
/// too long).
class MemberLeft extends FeedEvent {
const MemberLeft(super.feed);
}
/// Lost signal: gone for a while, kept in its grace period.
class MemberGone extends FeedEvent {
const MemberGone(super.feed);
}
/// Back within its grace after a [MemberGone].
class MemberReturned extends FeedEvent {
const MemberReturned(super.feed);
}
/// Someone sat out (`away`: the actor itself, or `target` sat out by the
/// actor; `reason` `idle` / `minimised` for an automatic one); [atYou]:
/// someone else sat this member out; [autoSelf]: this app's auto sit-out
/// set this member away.
class MemberSatOut extends FeedEvent {
final bool atYou;
final bool autoSelf;
const MemberSatOut(super.feed, {this.atYou = false, this.autoSelf = false});
}
/// Back from sitting out (`in`).
class MemberBack extends FeedEvent {
const MemberBack(super.feed);
}
class ReadyChanged extends FeedEvent {
final bool ready;
/// Members ready when it came (pitch of the ready sound).
final int readyIndex;
const ReadyChanged(super.feed, {required this.ready, this.readyIndex = 0});
}
class ForcedReady extends FeedEvent {
final bool ready;
const ForcedReady(super.feed, {required this.ready});
}
/// Force play / hold.
class ForcedTransport extends FeedEvent {
final bool play;
const ForcedTransport(super.feed, {required this.play});
}
class MediaSwapped extends FeedEvent {
const MediaSwapped(super.feed);
}
class Seeked extends FeedEvent {
const Seeked(super.feed);
}
class Paused extends FeedEvent {
const Paused(super.feed);
}
class Resumed extends FeedEvent {
const Resumed(super.feed);
}
class Finished extends FeedEvent {
const Finished(super.feed);
}
/// A chat message (`reason` carries the text); [own]: this member sent it.
class ChatPosted extends FeedEvent {
final bool own;
const ChatPosted(super.feed, {this.own = false});
}
class MemberRenamed extends FeedEvent {
const MemberRenamed(super.feed);
}
class AvatarChanged extends FeedEvent {
const AvatarChanged(super.feed);
}
class QueueAdded extends FeedEvent {
const QueueAdded(super.feed);
}
class QueueRemoved extends FeedEvent {
const QueueRemoved(super.feed);
}
/// Up next skipped an item (`target` its title, `reason` why).
class QueueSkipped extends FeedEvent {
const QueueSkipped(super.feed);
}
/// The server could not fetch the current media.
class FetchFailed extends FeedEvent {
const FetchFailed(super.feed);
}
/// An action this app does not know (a newer server).
class OtherFeed extends FeedEvent {
const OtherFeed(super.feed);
}
// -------------------------------------------------------------- session
/// Someone poked this member.
class Nudged extends AppEvent {
/// Their name; '' when unknown.
final String name;
const Nudged(this.name);
}
/// Another member's player cannot open the current media.
class MemberCantPlay extends AppEvent {
final String name;
final String error;
const MemberCantPlay(this.name, this.error);
}
/// This member passes the video on slower than it plays.
class SlowSource extends AppEvent {
final String text;
const SlowSource(this.text);
}
/// This member started passing the video on through the server.
class RelayStarted extends AppEvent {
const RelayStarted();
}
class RelayFailed extends AppEvent {
final String error;
const RelayFailed(this.error);
}
/// The connection to the server dropped and stayed down a few seconds
/// (once per drop).
class ConnectionLost extends AppEvent {
const ConnectionLost();
}
/// It came back after [ConnectionLost].
class ConnectionBack extends AppEvent {
const ConnectionBack();
}
/// Jellyfin progress reports fail (once per media).
class JellyfinProgressFailed extends AppEvent {
final bool signInExpired;
const JellyfinProgressFailed({required this.signInExpired});
}
// ------------------------------------------------------------------ app
/// The player's shader message (a preset change, shaders turned off).
class ShaderMessage extends AppEvent {
final String text;
const ShaderMessage(this.text);
}
/// This app added an item to Up next.
class AddedToUpNext extends AppEvent {
final String title;
const AddedToUpNext(this.title);
}
/// Putting something up or adding it to Up next failed ([text]: why).
class ActionFailed extends AppEvent {
final String text;
const ActionFailed(this.text);
}
/// The Schedule tab's request results and errors.
class ScheduleResult extends AppEvent {
final String text;
const ScheduleResult(this.text);
}
/// "Mark a problem" done; [inSession]: in the server's session log.
class ProblemMarked extends AppEvent {
final bool inSession;
const ProblemMarked({required this.inSession});
}
/// Something went on the clipboard ([text]: the confirmation, e.g.
/// "Link copied").
class Copied extends AppEvent {
final String text;
const Copied(this.text);
}
class CopyFailed extends AppEvent {
const CopyFailed();
}
/// A newer release is out (the update band shows it).
class UpdateAvailable extends AppEvent {
final String version;
const UpdateAvailable(this.version);
}
@@ -0,0 +1,126 @@
import 'package:sync_relay_core/core.dart' show Presence, PresenceReason;
import '../platform/ports/shell_port.dart' show WindowActivity;
/// The auto sit-out timings, in one place.
abstract final class AutoSitOutTimes {
/// Not playing and no input for this long: away (`idle`). Watching
/// counts as activity, so a pause after a long watch starts fresh.
static const idle = Duration(minutes: 5);
/// Minimised (or hidden) for this long, playing or not: away
/// (`minimised`).
static const minimised = Duration(minutes: 10);
}
/// What [AutoSitOut.tick] asks the session to do.
sealed class AutoSitOutStep {
const AutoSitOutStep();
}
/// Nothing to do.
final class AutoStay extends AutoSitOutStep {
const AutoStay();
}
/// Sit this member out (`presence` `away` with [reason]).
final class AutoAway extends AutoSitOutStep {
final PresenceReason reason;
const AutoAway(this.reason);
}
/// Bring this member back `in`: it was set away automatically and the
/// user is back.
final class AutoBack extends AutoSitOutStep {
const AutoBack();
}
/// Auto sit-out: idle for
/// [AutoSitOutTimes.idle] while nothing plays, or minimised for
/// [AutoSitOutTimes.minimised] (playing or not), sets this member away;
/// input or the window's focus brings it back, but only from an away
/// this set ([autoAway]); a manual away (own or someone else's) stays.
/// Never when alone (nobody to hold up or to tell). Pure: the session
/// feeds it input, window changes and a 1 s [tick].
class AutoSitOut {
final Duration idleAfter;
final Duration minimisedAfter;
AutoSitOut(DateTime now,
{this.idleAfter = AutoSitOutTimes.idle, this.minimisedAfter = AutoSitOutTimes.minimised})
: _lastActive = now;
DateTime _lastActive;
DateTime? _minimisedSince;
bool _activitySinceAway = false;
bool _autoAway = false;
// An away sent, not yet seen in a state.
DateTime? _sentAt;
static const _echoWait = Duration(seconds: 10);
/// The member is away because this set it (not by hand, not someone
/// else).
bool get autoAway => _autoAway;
/// Keyboard, mouse or any action in the app.
void input(DateTime now) {
_lastActive = now;
_activitySinceAway = true;
}
/// The window's focus or visibility changed.
void window(WindowActivity a, DateTime now) {
switch (a) {
case WindowActivity.minimised:
case WindowActivity.hidden:
_minimisedSince ??= now;
case WindowActivity.focused:
_minimisedSince = null;
input(now);
case WindowActivity.unfocused:
_minimisedSince = null;
}
}
/// The user sat out (or came back) by hand: an away is no longer ours.
void manual() {
_autoAway = false;
_sentAt = null;
}
/// Once a second. [self]: this member's presence in the latest state
/// (null: not in one); [alone]: nobody else connected ([isAlone]);
/// [enabled]: the setting (and not a solo session).
AutoSitOutStep tick(DateTime now,
{required Presence? self, required bool playing, required bool alone, required bool enabled}) {
if (playing) _lastActive = now;
if (self == null || self == Presence.gone) return const AutoStay();
if (self != Presence.away) {
final sent = _sentAt;
if (sent != null && now.difference(sent) < _echoWait) return const AutoStay();
_sentAt = null;
_autoAway = false;
} else {
_sentAt = null;
if (_autoAway && _activitySinceAway && _minimisedSince == null) {
_autoAway = false;
return const AutoBack();
}
return const AutoStay();
}
if (!enabled || alone) return const AutoStay();
final since = _minimisedSince;
final PresenceReason? reason;
if (since != null && now.difference(since) >= minimisedAfter) {
reason = PresenceReason.minimised;
} else if (!playing && now.difference(_lastActive) >= idleAfter) {
reason = PresenceReason.idle;
} else {
reason = null;
}
if (reason == null) return const AutoStay();
_autoAway = true;
_activitySinceAway = false;
_sentAt = now;
return AutoAway(reason);
}
}
@@ -0,0 +1,708 @@
import 'dart:async';
import 'dart:io';
import 'dart:math';
import 'package:flutter/foundation.dart' show debugPrint;
import 'package:signals/signals_core.dart';
import 'package:sync_relay_core/core.dart';
import '../screens/home/ui.dart' show appName, displayVersion;
import '../state/settings.dart';
import '../sync/sync_client.dart';
import 'app_events.dart';
import 'identity.dart' show ClientIdentity;
/// Thrown when a connect is superseded by cancel/leave before it finished.
class ConnectCancelled implements Exception {
const ConnectCancelled();
@override
String toString() => 'connection cancelled';
}
class ConnectParams {
final String host;
final int port;
final String secret;
final String name;
final String avatar;
final String mediaPath;
final String linkUrl;
/// Sent in hello by the hosting app's own client (see AppConfig.hostKey).
final String hostKey;
/// Put up with a `swap` right after joining (a Jellyfin ref or a link);
/// wins over [linkUrl].
final HelloMedia? media;
const ConnectParams({
required this.host,
required this.port,
required this.secret,
required this.name,
this.avatar = '',
this.mediaPath = '',
this.linkUrl = '',
this.hostKey = '',
this.media,
});
ConnectParams withMedia(HelloMedia? media) => ConnectParams(
host: host,
port: port,
secret: secret,
name: name,
avatar: avatar,
mediaPath: mediaPath,
linkUrl: media == null ? linkUrl : '',
hostKey: hostKey,
media: media,
);
ConnectParams withProfile({String? name, String? avatar}) => ConnectParams(
host: host,
port: port,
secret: secret,
name: name ?? this.name,
avatar: avatar ?? this.avatar,
mediaPath: mediaPath,
linkUrl: linkUrl,
hostKey: hostKey,
media: media,
);
}
/// Where the [Connection] is. Each change is a new value (a repeat is
/// notified too).
sealed class ConnState {
const ConnState();
/// The reconnect loop runs ([ConnLost] or [ConnReconnecting]).
bool get retrying => false;
/// A welcomed socket is up ([ConnOnline] or [ConnUnstable]): what the
/// server says is current.
bool get online => false;
}
/// Not connected and not trying: before the first connect, after leave,
/// cancel or "stop reconnecting".
final class ConnClosed extends ConnState {
const ConnClosed();
@override
String toString() => 'closed';
}
/// The first dial of a session (hello sent, waiting for welcome).
final class ConnConnecting extends ConnState {
const ConnConnecting();
@override
String toString() => 'connecting';
}
/// Welcome received; frames arrive.
final class ConnOnline extends ConnState {
const ConnOnline();
@override
bool get online => true;
@override
String toString() => 'online';
}
/// Still the same socket, but nothing came for [Connection.unstableAfter]
/// (the server sends a state every second): the link may be dying. Back
/// to [ConnOnline] with the next frame, [ConnLost] at
/// [Connection.silenceLimit].
final class ConnUnstable extends ConnState {
const ConnUnstable();
@override
bool get online => true;
@override
String toString() => 'unstable';
}
/// Dropped (the socket closed, or nothing came for
/// [Connection.silenceLimit]); the next attempt is scheduled. The session
/// keeps what it had (the last state, the player, a relay it serves).
final class ConnLost extends ConnState {
const ConnLost();
@override
bool get retrying => true;
@override
String toString() => 'lost';
}
/// Reconnect attempt [attempt] (1 is the first after the drop) is
/// dialling; after it failed it stays until the next one, unless the
/// socket's close said [ConnLost] meanwhile.
final class ConnReconnecting extends ConnState {
final int attempt;
const ConnReconnecting(this.attempt);
@override
bool get retrying => true;
@override
String toString() => 'reconnecting($attempt)';
}
/// The server refused this app; nothing retries. [code]: `version` or
/// `secret` when the server said so, else empty (offline, timeout,
/// anything else on the first dial); [message]: in plain words
/// ([connectErrorText]).
final class ConnRefused extends ConnState {
final String code;
final String message;
const ConnRefused(this.code, this.message);
@override
String toString() => 'refused($code: $message)';
}
/// Another connection of this install holds the member (`err` `replaced`,
/// close 4013, or this hello was `stale`): Starsync was opened in another
/// window. Nothing retries ("Retry" takes the member back).
final class ConnReplaced extends ConnState {
const ConnReplaced();
String get message => replacedText;
@override
String toString() => 'replaced';
}
/// [ConnReplaced] in plain words.
const replacedText = 'Starsync was opened in another window';
/// A version refusal in plain words: [err] is the server's `err`
/// `version` (its range in `min`/`max`), or a proto 15 server's codeless
/// `proto mismatch`, which is always an older host.
String versionText(ErrorFrame? err) {
final max = err?.max, min = err?.min;
if (err?.code == null || (max != null && max < RelayConstants.protoMin)) {
return 'the host runs an older Starsync — they need to update';
}
if (min != null && min > RelayConstants.protoMax) return 'the host runs a newer Starsync — update the app';
return 'the host runs a different Starsync version — update both apps';
}
/// A failed dial in plain words.
String connectErrorText(Object e) {
if (e is HelloRefused) {
final err = e.error;
if (err != null && err.code == null && err.message.contains('proto mismatch')) return versionText(err);
return switch (e.code) {
ErrCode.version => versionText(err),
ErrCode.secret => 'wrong server password (401)',
ErrCode.replaced || ErrCode.stale => replacedText,
_ => 'refused: ${err?.message ?? 'closed (${e.closeCode})'}',
};
}
final msg = '$e';
if (msg.contains('SocketException') || msg.contains('Connection refused')) {
return 'host offline — check address and port';
}
return 'connect failed: $e';
}
/// The terminal state a failed dial leaves, or null when another attempt
/// may work.
ConnState? _terminalFor(Object e, {required bool first}) {
if (e is HelloRefused) {
final err = e.error;
if (err != null && err.code == null && err.message.contains('proto mismatch')) {
return ConnRefused('version', versionText(err));
}
switch (e.code) {
case ErrCode.replaced:
case ErrCode.stale:
return const ConnReplaced();
case ErrCode.version:
return ConnRefused('version', connectErrorText(e));
case ErrCode.secret:
return ConnRefused('secret', connectErrorText(e));
default:
break;
}
}
return first ? ConnRefused('', connectErrorText(e)) : null;
}
/// What a hello says about this install besides the connect form.
typedef HelloIdentity = ({String client, int seq, ClientCaps caps, int? feedAfter});
/// Builds the `hello` for [params]. Injectable so later protocol versions
/// add fields without changing [Connection].
typedef HelloBuilder = HelloMessage Function(ConnectParams params, HelloIdentity id);
/// Protocol 16's hello: this build's version range, the install's client
/// id and connection count, the app version, platform and what it can do;
/// `feedAfter` on a reconnect to the same server.
HelloMessage defaultHello(ConnectParams params, HelloIdentity id) => HelloMessage(
proto: RelayConstants.protoMax,
protoMin: RelayConstants.protoMin,
secret: params.secret,
user: HelloUser(name: params.name, avatar: params.avatar),
hostKey: params.hostKey,
client: id.client,
seq: id.seq,
app: appVersion,
platform: Platform.operatingSystem,
caps: id.caps,
feedAfter: id.feedAfter,
);
/// How a welcome relates to the member this app held before.
typedef Rejoin = ({
/// A reconnect of the running session (not its first dial).
bool reconnect,
/// `welcome.resumed`: the same member, everything kept.
bool resumed,
/// The same server boot as the last welcome (`welcome.instance`).
bool sameServer,
/// The token the session held before this welcome ('' on the first).
String oldToken,
});
/// The session's part of a welcome, called before its state goes out
/// (see [Connection.onWelcome]).
typedef WelcomeHandler = void Function(WelcomeMessage welcome, Rejoin rejoin);
/// The link to the server: the [SyncClient] socket, the first connect,
/// reconnects (backoff with jitter between attempts, epochs that make a
/// cancelled attempt stale), the silence policy ("unstable" after
/// [unstableAfter] without a frame, lost at [silenceLimit]), the 1 s
/// heartbeat (clock ping, which also keeps this member under the
/// server's silence limit, and diagnostics), the connection lost/back
/// events and [state]. The install's identity rides in every hello
/// ([identity]); what a welcome means to the session is its [onWelcome].
class Connection {
final SyncClient client;
/// Where [ConnectionLost] / [ConnectionBack] go (the Notifier).
final void Function(AppEvent event) _notify;
/// Builds each dial's hello.
final HelloBuilder hello;
/// `hello.client` / `hello.seq` (the stored one in the app; a fresh
/// in-memory one by default, so every test connection is its own
/// install).
final ClientIdentity identity;
/// `hello.caps` at each dial (the session sets it).
ClientCaps Function() caps;
/// Wait before the first reconnect attempt; it doubles per failed
/// attempt up to [maxReconnectDelay], each with ±20 % jitter.
final Duration reconnectDelay;
final Duration maxReconnectDelay;
/// No frame for this long: [ConnUnstable].
final Duration unstableAfter;
/// No frame for this long means the connection is lost.
final Duration silenceLimit;
/// The lost sound plays only once a drop lasted this long (a quick
/// reconnect says nothing, and no lost/back pair per flap).
final Duration lostSoundAfter;
/// The clock the silence and the lost sound are measured on (UTC; a
/// test passes a fake one). The heartbeat that checks it stays a real
/// 1 s timer.
final DateTime Function() now;
final Random _random;
Connection(
{void Function(AppEvent event)? notify,
SyncClient? client,
this.hello = defaultHello,
ClientIdentity? identity,
ClientCaps Function()? caps,
this.reconnectDelay = const Duration(seconds: 1),
this.maxReconnectDelay = const Duration(seconds: 15),
this.unstableAfter = const Duration(seconds: 3),
this.silenceLimit = const Duration(seconds: 9),
this.lostSoundAfter = const Duration(seconds: 4),
DateTime Function()? now,
Random? random})
: _notify = notify ?? ((_) {}),
now = now ?? _utcNow,
identity = identity ?? ClientIdentity.ephemeral(),
caps = caps ?? (() => const ClientCaps(source: true)),
_random = random ?? Random(),
client = client ?? SyncClient() {
this.client.onFrame = () => _lastFrameAt = this.now();
_subs = [
this.client.status.listen(_onStatus),
this.client.errors.listen(_onError),
];
}
late final List<StreamSubscription> _subs;
static DateTime _utcNow() => DateTime.now().toUtc();
final _state = signal<ConnState>(const ConnClosed());
/// Every change is notified, a repeat too.
ReadonlySignal<ConnState> get state => _state;
/// Set by the session (see [WelcomeHandler]).
WelcomeHandler? onWelcome;
/// This app's log lines to the server's session log (Settings: "Share
/// diagnostics with the server"), and "Mark a problem". Runs from
/// [begin] to [close].
late final DiagUploader diag = DiagUploader(
target: _diagTarget, version: '$appName ${displayVersion()} on ${Platform.operatingSystem}');
DiagTarget? _diagTarget() {
final uri = client.wsUri;
if (client.token.isEmpty || uri == null || !client.clockSync.hasSample) return null;
return (base: DiagUploader.baseOf(uri), token: client.token, offsetMs: client.clockSync.offsetMs);
}
ConnectParams? _params;
/// The last connect's parameters (with [updateProfile]'s changes); a
/// reconnect dials with them.
ConnectParams? get params => _params;
// Bumped by every connect/leave/cancel/retry: stale attempts no-op.
int _epoch = 0;
Timer? _reconnectTimer;
Timer? _heartbeat;
// Set by close/cancel/a terminal refusal: no reconnect, no lost detection.
bool _closed = false;
bool _everOnline = false;
/// The connection-lost sound played; the reconnected one answers it once.
bool _lostSounded = false;
DateTime? _lastFrameAt;
/// When the current drop began (the last frame for a silent link).
DateTime? _droppedAt;
int _attempt = 0;
/// Non-terminal `err` frames, in plain words.
final _errorTexts = StreamController<String>.broadcast();
// The socket and what the server said, read through.
Stream<StateBroadcast> get states => client.states;
/// Feed entries, chat included; replayed ones after a reconnect.
Stream<FeedMessage> get feeds => client.feeds;
Stream<NudgeNotice> get nudges => client.nudges;
/// `err` frames after the welcome (a `replaced` one ends the connection
/// instead).
Stream<String> get errors => _errorTexts.stream;
String get token => client.token;
String get memberId => client.memberId;
/// This member's `/j/...` URL from `welcome`.
String? get relayUrl => client.relayUrl;
StateBroadcast? get lastState => client.lastState;
/// Now on the server's clock (the ping/pong clock offset).
DateTime serverNow() => client.serverNow();
void sendReady(bool ready, {String source = 'manual'}) => client.sendReady(ready, source: source);
void sendForce(String target, bool ready) => client.sendForce(target, ready);
void sendSeek(double position, {int seq = 0}) => client.sendSeek(position, seq: seq);
void sendSwap(HelloMedia media, {String? queueId}) => client.sendSwap(media, queueId: queueId);
void sendQueue(QueueIntent intent) => client.sendQueue(intent);
void sendReport({
required double position,
required double buffered,
required int eventId,
double duration = 0,
int mediaId = 0,
Map<String, bool>? jf,
String error = '',
}) =>
client.sendReport(
position: position,
buffered: buffered,
eventId: eventId,
duration: duration,
mediaId: mediaId,
jf: jf,
error: error);
void sendChat(String text) => client.sendChat(text);
void sendArt(int mediaId, {String? poster, String? banner}) =>
client.sendArt(mediaId, poster: poster, banner: banner);
void sendPresence(PresenceIntent intent) => client.sendPresence(intent);
void sendNudge(String target) => client.sendNudge(target);
/// Name and/or picture change for everyone now; reconnects send them too.
void updateProfile({String? name, String? avatar}) {
_params = _params?.withProfile(name: name, avatar: avatar);
client.sendProfile(name: name, avatar: avatar);
}
/// A new epoch: attempts in flight become stale, a scheduled reconnect
/// is dropped. Returns it.
int invalidate() {
_epoch++;
_reconnectTimer?.cancel();
_reconnectTimer = null;
return _epoch;
}
/// False once a cancel, retry, leave or new connect followed [epoch].
bool isCurrent(int epoch) => epoch == _epoch;
/// A new session with [params]: forgets the last one's server and
/// starts the diagnostics. [dial] connects.
void begin(ConnectParams params) {
_params = params;
_closed = false;
_everOnline = false;
_lostSounded = false;
_droppedAt = null;
_attempt = 0;
diag
..share = AppSettings.instance.shareDiagnostics.peek()
..start();
}
/// The first dial of the session [begin] started. Throws
/// [ConnectCancelled] when [epoch] went stale meanwhile (whoever bumped
/// it also closed the socket); any other failure leaves [state] refused
/// (or replaced).
Future<WelcomeMessage> dial(int epoch) async {
final params = _params!;
_set(const ConnConnecting());
try {
final welcome = await _connect(params, reconnect: false);
if (epoch != _epoch) throw const ConnectCancelled();
_online();
return welcome;
} catch (e) {
if (epoch != _epoch) throw const ConnectCancelled();
debugPrint('connect failed: $e');
_set(_terminalFor(e, first: true)!);
rethrow;
}
}
Future<WelcomeMessage> _connect(ConnectParams params, {required bool reconnect}) {
final oldToken = client.token;
final before = client.instance;
final id = (
client: identity.clientId,
seq: identity.nextSeq(),
caps: caps(),
feedAfter: reconnect && before.isNotEmpty && client.lastFeedSeq > 0 ? client.lastFeedSeq : null,
);
return client.connectWith(
wsUri: _wsUri(params),
hello: hello(params, id),
onWelcome: (w) {
_lastFrameAt = now();
onWelcome?.call(w, (
reconnect: reconnect,
resumed: w.resumed,
sameServer: before.isNotEmpty && before == w.instance,
oldToken: oldToken,
));
},
);
}
static Uri _wsUri(ConnectParams p) => Uri.parse('ws://${p.host}:${p.port}/sync');
void _online() {
_attempt = 0;
_everOnline = true;
_droppedAt = null;
if (_lostSounded) {
_lostSounded = false;
_notify(const ConnectionBack());
}
_lastFrameAt = now();
_set(const ConnOnline());
_heartbeat ??= Timer.periodic(const Duration(seconds: 1), (_) => _beat());
}
void _set(ConnState s) => _state.set(s, force: true);
void _onStatus(SyncStatus s) {
// Before the first welcome a close is just the dial failing; during a
// reconnect attempt the attempt reports it. A socket error (a reset
// link) may come without a close.
if (s != SyncStatus.closed && s != SyncStatus.error) return;
if (_closed || !_everOnline || !_state.peek().online) return;
switch (s == SyncStatus.closed ? client.closeCode : null) {
case CloseCodes.replaced:
case CloseCodes.stale:
_end(const ConnReplaced());
case CloseCodes.version:
_end(ConnRefused('version', versionText(null)));
case CloseCodes.secret:
_end(const ConnRefused('secret', 'wrong server password (401)'));
default:
_dropped(now());
}
}
void _onError(ErrorFrame e) {
if (e.code == ErrCode.replaced) {
_end(const ConnReplaced());
return;
}
if (!_errorTexts.isClosed) _errorTexts.add(e.message);
}
/// A terminal state: no more attempts, the socket goes.
void _end(ConnState s) {
debugPrint('connection ended: $s');
_halt(s);
unawaited(client.drop());
}
/// Lost since [since]: the socket goes, a reconnect is scheduled (unless
/// one already is or dials).
void _dropped(DateTime since) {
if (_closed || _state.peek().retrying) return;
_droppedAt = since;
unawaited(client.drop());
scheduleReconnect();
}
/// Every second once online: a ping (a clock sample; it also keeps this
/// member under the server's silence limit while nothing else is sent),
/// the diagnostics, the silence check and the delayed lost sound.
void _beat() {
final t = now();
final s = _state.peek();
if (s.online) client.sendPing();
diag
..share = AppSettings.instance.shareDiagnostics.peek()
..tick();
if (_closed) return;
final last = _lastFrameAt;
if (s.online && last != null) {
final quiet = t.difference(last);
if (quiet >= silenceLimit) {
debugPrint('connection: nothing for ${quiet.inSeconds} s; reconnecting');
_dropped(last);
} else if (quiet >= unstableAfter) {
if (s is ConnOnline) _set(const ConnUnstable());
} else if (s is ConnUnstable) {
_set(const ConnOnline());
}
}
final since = _droppedAt;
if (_state.peek().retrying && since != null && !_lostSounded && t.difference(since) >= lostSoundAfter) {
_lostSounded = true;
_notify(const ConnectionLost());
}
}
/// Reconnect after the backoff ([state] lost), unless closed, never
/// online (unless [manual]) or already retrying.
void scheduleReconnect({bool manual = false}) {
if (_closed || _params == null) return;
if (!manual && !_everOnline) return;
if (_state.peek().retrying) return;
_droppedAt ??= now();
_set(const ConnLost());
_armReconnect();
}
/// The wait before attempt [attempt] (1 = the first after a drop).
Duration delayFor(int attempt) {
final base = reconnectDelay.inMilliseconds * pow(2, max(0, attempt - 1));
final capped = min(base.toDouble(), maxReconnectDelay.inMilliseconds.toDouble());
return Duration(milliseconds: (capped * (0.8 + 0.4 * _random.nextDouble())).round());
}
void _armReconnect() {
final epoch = _epoch;
_reconnectTimer?.cancel();
_reconnectTimer = Timer(delayFor(_attempt + 1), () => _reconnect(epoch));
}
/// One reconnect attempt. An attempt from an older epoch must not touch
/// state (it would kill the newer connection).
Future<void> _reconnect(int epoch) async {
_reconnectTimer = null;
bool stale() => epoch != _epoch;
final params = _params;
if (stale() || params == null || _closed) return;
try {
_set(ConnReconnecting(++_attempt));
await _connect(params, reconnect: true);
// Whoever bumped the epoch also closed (or replaced) the socket.
if (stale()) return;
_online();
} catch (e) {
if (stale()) return;
debugPrint('reconnect failed: $e');
final end = _terminalFor(e, first: false);
if (end != null) {
_end(end);
} else {
_retryAfterFailure(e);
}
}
}
void _retryAfterFailure(Object e) {
if (_closed) {
_set(const ConnClosed());
} else if (!_everOnline) {
// Only a manual retry before any welcome gets here.
_set(_terminalFor(e, first: true)!);
} else {
if (!_state.peek().retrying) _set(const ConnLost());
_armReconnect();
}
}
/// "Stop reconnecting": no more attempts; one dialling is aborted.
void cancelReconnect() {
_halt(const ConnClosed());
unawaited(client.drop());
}
/// Cancel during setup: aborts the dial.
Future<void> cancelConnect() async {
_halt(const ConnClosed());
try {
await client.drop();
} catch (_) {}
}
void _halt(ConnState s) {
_closed = true;
invalidate();
_heartbeat?.cancel();
_heartbeat = null;
_set(s);
}
/// "Retry": a reconnect after the first backoff whatever the state
/// (after [ConnReplaced] it takes the member back from the other
/// window). False without a session to retry.
bool retry() {
if (_params == null) return false;
invalidate();
_closed = false;
_attempt = 0;
_set(const ConnClosed());
scheduleReconnect(manual: true);
return true;
}
/// No more reconnects or silence checks, [state] closed; the socket
/// stays until [close] (the session's last words still go out).
void stop() {
_closed = true;
_reconnectTimer?.cancel();
_reconnectTimer = null;
_heartbeat?.cancel();
_heartbeat = null;
_set(const ConnClosed());
}
/// [stop], the last diagnostics (before the token goes), with [bye] the
/// member's `bye` (flushed: the server's close is awaited briefly), then
/// the socket.
Future<void> close({bool bye = false}) async {
stop();
await diag.stop();
// A welcomed socket still open (a dead one costs the short wait).
if (bye && client.isOpen && client.token.isNotEmpty) await client.sendBye();
try {
await client.disconnect();
} catch (_) {}
}
Future<void> dispose() async {
stop();
for (final s in _subs) {
await s.cancel();
}
await _errorTexts.close();
await client.dispose();
}
}
+4 -3
View File
@@ -1,6 +1,7 @@
import 'package:sync_relay_core/core.dart'; import 'package:sync_relay_core/core.dart';
import '../jellyfin/session_media.dart'; import '../jellyfin/session_media.dart';
import 'party.dart';
// Who fetches the current media and how it spreads, from the state alone: // Who fetches the current media and how it spreads, from the state alone:
// the members card's source line and each member's route and buffer. // the members card's source line and each member's route and buffer.
@@ -217,7 +218,7 @@ Delivery? deliveryOf(StateBroadcast r, {required String selfId, MemberNames? nam
final source = r.members.where((m) => m.id == sourceId).firstOrNull; final source = r.members.where((m) => m.id == sourceId).firstOrNull;
var via = 0, direct = 0; var via = 0, direct = 0;
for (final m in r.members) { for (final m in r.members) {
if (m.out || m.id == sourceId) continue; if (!isPresent(m) || m.id == sourceId) continue;
switch (routeOf(m, kind, sourceId, ref, r.relay)) { switch (routeOf(m, kind, sourceId, ref, r.relay)) {
case DeliveryRoute.server: case DeliveryRoute.server:
via++; via++;
@@ -277,7 +278,7 @@ DeliveryRoute? routeOf(MemberView m, DeliveryKind kind, String sourceId, Jellyfi
/// [m]'s route and buffer for [d] (from [deliveryOf] on [r]). /// [m]'s route and buffer for [d] (from [deliveryOf] on [r]).
MemberDelivery memberDeliveryOf(MemberView m, StateBroadcast r, Delivery d) { MemberDelivery memberDeliveryOf(MemberView m, StateBroadcast r, Delivery d) {
final counts = !m.out && !m.stale && m.error.isEmpty; final counts = countsForReady(m);
final need = r.bufferNeed; final need = r.bufferNeed;
final ref = refOfStateMedia(r.media); final ref = refOfStateMedia(r.media);
final health = !counts final health = !counts
@@ -288,7 +289,7 @@ MemberDelivery memberDeliveryOf(MemberView m, StateBroadcast r, Delivery d) {
? BufferHealth.starving ? BufferHealth.starving
: BufferHealth.low; : BufferHealth.low;
return MemberDelivery( return MemberDelivery(
route: m.out ? null : routeOf(m, d.kind, d.sourceId, ref, r.relay), route: m.presence == Presence.away ? null : routeOf(m, d.kind, d.sourceId, ref, r.relay),
kind: d.kind, kind: d.kind,
jellyfin: ref != null, jellyfin: ref != null,
rate: m.rate, rate: m.rate,
@@ -1,13 +1,83 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:math';
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hive_ce/hive.dart';
import 'package:signals/signals_flutter.dart'; import 'package:signals/signals_flutter.dart';
import 'package:sync_relay_core/core.dart' show validClientId;
import '../../state/connect_prefs.dart'; import '../state/connect_prefs.dart';
import '../../state/session_state.dart'; import '../state/session_state.dart';
import '../../widgets/avatar_pipeline.dart'; import '../state/settings.dart' show settingsBoxName;
import '../widgets/avatar_pipeline.dart';
// Who this app is: to the server (the per-install client id and its
// rising connection count, [ClientIdentity]) and to people (name, picture
// and colour, [Profile]).
/// `hello.client` and `hello.seq` (PROTOCOL.md "Presence"): a random id
/// per install, kept in the settings box (so per data folder, and per
/// `STARSYNC_PROFILE`), and a number that rises with every connection this
/// install makes. A server maps the id to one lasting member; the higher
/// `seq` wins when two windows of the same install connect.
class ClientIdentity {
static const _keyClient = 'clientId';
static const _keySeq = 'clientSeq';
final Box? _box;
ClientIdentity._(this._box, this.clientId, this._seq);
/// The id: 22 characters of base64url (16 random bytes). Private: never
/// shown, never logged.
final String clientId;
int _seq;
/// The last `seq` handed out.
int get seq => _seq;
/// The stored identity (the settings box, once open); one is made and
/// stored on first use. Without the box (tests, no store): [ephemeral].
factory ClientIdentity.stored() {
final box = Hive.isBoxOpen(settingsBoxName) ? Hive.box(settingsBoxName) : null;
if (box == null) return ClientIdentity.ephemeral();
var id = box.get(_keyClient);
if (id is! String || !validClientId(id)) {
id = newClientId();
unawaited(_put(box, _keyClient, id));
}
final seq = box.get(_keySeq);
return ClientIdentity._(box, id, seq is int && seq > 0 ? seq : 0);
}
/// A fresh id kept in memory only (each test coordinator is its own
/// install).
factory ClientIdentity.ephemeral() => ClientIdentity._(null, newClientId(), 0);
/// The `seq` for the next hello: one higher than any before, stored at
/// once.
int nextSeq() {
_seq++;
final box = _box;
if (box != null) unawaited(_put(box, _keySeq, _seq));
return _seq;
}
static Future<void> _put(Box box, String key, Object value) async {
try {
await box.put(key, value);
} catch (e) {
debugPrint('save $key failed: $e');
}
}
/// 16 random bytes as 22 characters of base64url.
static String newClientId() {
final r = Random.secure();
return base64Url.encode([for (var i = 0; i < 16; i++) r.nextInt(256)]).replaceAll('=', '');
}
}
/// Colours offered in the profile card; the first is the default. /// Colours offered in the profile card; the first is the default.
const profileColors = <Color>[ const profileColors = <Color>[
-43
View File
@@ -1,43 +0,0 @@
enum IdleEvent { none, warn, unready }
/// Idle auto-unready clock. Idle time counts from the later of the user's
/// last input and the last moment playback was running: watching is
/// activity, so a long watch followed by a pause starts a fresh 10 min.
/// The unready also always trails its warning by the full grace period.
class IdleTracker {
static const warnAfter = Duration(minutes: 8);
static const unreadyAfter = Duration(minutes: 10);
DateTime _lastActive;
DateTime? _warnedAt;
IdleTracker(DateTime now) : _lastActive = now;
void activity(DateTime now) {
_lastActive = now;
_warnedAt = null;
}
/// Called once per tick. Warn and unready only matter while self is
/// ready; while playing the ready flag gates nothing.
IdleEvent tick(DateTime now, {required bool playing, required bool selfReady}) {
if (playing) {
activity(now);
return IdleEvent.none;
}
if (!selfReady) return IdleEvent.none;
final warnedAt = _warnedAt;
if (warnedAt != null) {
if (now.difference(warnedAt) >= unreadyAfter - warnAfter) {
activity(now);
return IdleEvent.unready;
}
return IdleEvent.none;
}
if (now.difference(_lastActive) >= warnAfter) {
_warnedAt = now;
return IdleEvent.warn;
}
return IdleEvent.none;
}
}
+148
View File
@@ -0,0 +1,148 @@
import 'dart:async';
import 'package:signals/signals_core.dart';
import 'package:sync_relay_core/core.dart' show FeedMessage;
import '../sfx/sfx.dart';
import 'app_events.dart';
export 'app_events.dart';
class ToastItem {
final int id;
final String text;
final DateTime at;
/// The member a feed or chat toast is about (their name, as the feed
/// carries it; [text] starts with it); null for the app's own toasts.
final String? actor;
/// [actor]'s member id (feed and chat toasts); '' when unknown.
final String actorId;
const ToastItem({required this.id, required this.text, required this.at, this.actor, this.actorId = ''});
}
/// What one [AppEvent] does: a toast ([toast], about [actor]), a sound,
/// the window asking for attention, an activity-log line.
class Notice {
final String? toast;
final String? actor;
final SfxCue? sound;
final bool attention;
/// The feed line it goes into the activity log as.
final FeedMessage? log;
const Notice({this.toast, this.actor, this.sound, this.attention = false, this.log});
static const none = Notice();
}
/// The policy: what each event does.
Notice noticeFor(AppEvent e) => switch (e) {
// Lines a reconnect replayed (`welcome.feed`): the log only.
FeedEvent(:final feed) when feed.replayed => Notice(log: feed),
// The feed: everything is logged; joins, leaves, swaps and chat
// pop up; the social actions have a sound.
MemberJoined(:final feed) => Notice(
log: feed, toast: '${feed.actor} joined', actor: feed.actor, sound: const SfxCue(SfxKind.join)),
MemberLeft(:final feed) => Notice(
log: feed, toast: '${feed.actor} left', actor: feed.actor, sound: const SfxCue(SfxKind.leave)),
MediaSwapped(:final feed) => Notice(
log: feed, toast: '${feed.actor} changed media', actor: feed.actor, sound: const SfxCue(SfxKind.swap)),
ChatPosted(:final feed, :final own) => Notice(
log: feed,
toast: '${feed.actor}: ${feed.reason}',
actor: feed.actor,
// Your own message needs no sound.
sound: own ? null : const SfxCue(SfxKind.chat)),
MemberSatOut(:final feed, atYou: true) => Notice(
log: feed, toast: '${feed.actor} sat you out', actor: feed.actor, sound: const SfxCue(SfxKind.kick)),
MemberSatOut(:final feed, autoSelf: true) => Notice(
log: feed,
toast: 'You sit out (${feed.reason == 'minimised' ? 'minimised' : 'idle'}); you are back as soon as you return',
sound: const SfxCue(SfxKind.warn)),
ReadyChanged(:final feed, ready: true, :final readyIndex) =>
Notice(log: feed, sound: SfxCue(SfxKind.ready, readyIndex: readyIndex)),
ReadyChanged(:final feed, ready: false) => Notice(log: feed, sound: const SfxCue(SfxKind.unready)),
ForcedReady(:final feed, ready: true) => Notice(log: feed, sound: const SfxCue(SfxKind.forceReady)),
QueueSkipped(:final feed) => Notice(
log: feed, toast: 'Skipped ${feed.target ?? 'an item'}${feed.reason.isEmpty ? '' : ': ${feed.reason}'}'),
FeedEvent(:final feed) => Notice(log: feed),
// The session.
Nudged(:final name) => Notice(
toast: '${name.isEmpty ? 'Someone' : name} nudged you',
actor: name.isEmpty ? null : name,
sound: const SfxCue(SfxKind.nudge),
attention: true),
MemberCantPlay(:final name, :final error) => Notice(toast: "$name can't play this: $error", actor: name),
SlowSource(:final text) => Notice(toast: text),
RelayStarted() =>
const Notice(toast: 'Passing the video on through the server. Keep the app open while it plays.'),
RelayFailed(:final error) => Notice(toast: 'Cannot pass the video on: $error'),
ConnectionLost() => const Notice(sound: SfxCue(SfxKind.lost)),
ConnectionBack() => const Notice(sound: SfxCue(SfxKind.back)),
JellyfinProgressFailed(:final signInExpired) =>
Notice(toast: 'Jellyfin progress not saved: ${signInExpired ? 'sign-in expired' : 'server unreachable'}'),
// The app.
ShaderMessage(:final text) => Notice(toast: text),
AddedToUpNext(:final title) => Notice(toast: 'Added $title to Up next'),
ActionFailed(:final text) => Notice(toast: text),
ScheduleResult(:final text) => Notice(toast: text),
ProblemMarked(:final inSession) =>
Notice(toast: inSession ? 'Marked in the session log' : "Marked in this app's log"),
Copied(:final text) => Notice(toast: text),
CopyFailed() => const Notice(toast: "Couldn't copy"),
// The update band shows it.
UpdateAvailable() => Notice.none,
};
/// The one way news reaches the user: [notify] turns an [AppEvent] into
/// toasts ([toasts]), sounds, an attention request and the activity log
/// ([feedLog]) per [noticeFor].
class Notifier {
final SfxService sfx;
final Future<void> Function() attention;
Notifier({this.sfx = const NoopSfxService(), Future<void> Function()? attention})
: attention = attention ?? _noAttention;
static Future<void> _noAttention() async {}
/// Toasts on screen (the shell shows them; [expire] drops old ones).
final toasts = signal<List<ToastItem>>([]);
/// The activity log: the last 100 feed lines.
final feedLog = signal<List<FeedMessage>>([]);
int _toastId = 0;
void notify(AppEvent e) {
final n = noticeFor(e);
final log = n.log;
if (log != null) {
final all = [...feedLog.peek(), log];
feedLog.value = all.length > 100 ? all.sublist(all.length - 100) : all;
}
final text = n.toast;
if (text != null) {
_toastId++;
toasts.value = [
...toasts.peek(),
ToastItem(
id: _toastId,
text: text,
at: DateTime.now().toUtc(),
actor: n.actor,
actorId: n.actor == null ? '' : n.log?.actorId ?? ''),
];
}
final sound = n.sound;
if (sound != null) unawaited(sfx.play(sound));
if (n.attention) unawaited(attention());
}
/// Drops toasts 4 s old or older.
void expire(DateTime now) {
toasts.value = toasts.peek().where((t) => now.difference(t.at).inSeconds < 4).toList();
}
/// A new session: no toasts, an empty log.
void clear() {
feedLog.value = [];
toasts.value = [];
}
}
+27
View File
@@ -0,0 +1,27 @@
import 'package:sync_relay_core/core.dart';
// The app's reading of presence (PROTOCOL.md "Presence"): every count,
// list and wait in the UI goes through these, on top of core's
// [PresencePolicy] and [isAlone].
/// What each presence counts for (the one table, shared with the server).
const presencePolicy = PresencePolicy();
/// Counted in "2/3 ready" and waited for by name: its presence counts for
/// the ready counter and its player can open the media.
bool countsForReady(MemberView m) => m.error.isEmpty && presencePolicy.countsFor(m.presence, Purpose.readyCounter);
/// Connected and watching (`in` or `joining`): can do things for the
/// party now (put up an Up next item, the auto-advance swap).
bool isPresent(MemberView m) => m.presence == Presence.watching || m.presence == Presence.joining;
/// Nobody but [selfId] is connected in [members] (D9, core's [isAlone]).
bool aloneIn(List<MemberView> members, String selfId) =>
isAlone([for (final m in members) if (m.id != selfId) m.presence]);
/// "3 min" / "40 s": how long since [since] (a `goneSince`), at [now].
String sinceText(DateTime since, DateTime now) {
final d = now.difference(since);
if (d.inMinutes >= 1) return '${d.inMinutes} min';
return '${d.inSeconds.clamp(0, 59)} s';
}
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -13,7 +13,7 @@ enum SfxKind {
/// A new video or episode is put up. /// A new video or episode is put up.
swap, swap,
chat, chat,
/// Idle warning: auto-unready soon. /// Auto sit-out set you away (idle, minimised).
warn, warn,
/// The connection to the server dropped / came back. /// The connection to the server dropped / came back.
lost, lost,
+1 -1
View File
@@ -261,7 +261,7 @@ const soundInfo = {
SfxKind.kick: ('Sat out', 'Someone sits you out'), SfxKind.kick: ('Sat out', 'Someone sits you out'),
SfxKind.swap: ('New video', 'A different video or episode is put up'), SfxKind.swap: ('New video', 'A different video or episode is put up'),
SfxKind.chat: ('Message', 'A chat message from someone else'), SfxKind.chat: ('Message', 'A chat message from someone else'),
SfxKind.warn: ('Idle warning', 'Auto-unready soon unless you press Space'), SfxKind.warn: ('Auto sit-out', 'You were idle or minimised and now sit out'),
SfxKind.lost: ('Connection lost', 'The server stopped answering; reconnecting'), SfxKind.lost: ('Connection lost', 'The server stopped answering; reconnecting'),
SfxKind.back: ('Reconnected', 'Back in the server after a drop'), SfxKind.back: ('Reconnected', 'Back in the server after a drop'),
SfxKind.nudge: ('Nudge', 'Someone nudges you; always plays (volume still applies)'), SfxKind.nudge: ('Nudge', 'Someone nudges you; always plays (volume still applies)'),
+33 -61
View File
@@ -1,88 +1,60 @@
import 'dart:async'; import 'dart:async';
import 'dart:io'; import 'dart:math' as math;
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:media_kit/media_kit.dart';
import 'package:path_provider/path_provider.dart';
import '../platform/audio/sound_engine.dart';
import 'sfx.dart'; import 'sfx.dart';
import 'sound_bank.dart'; import 'sound_bank.dart';
import 'synth.dart'; import 'synth.dart';
/// Plays [SoundSpec]s: each is rendered once to a WAV in the temp dir and /// Plays [SoundSpec]s on a [SoundEngine]: each is rendered once (off the
/// played on a small pool of audio-only mpv players (so two sounds can /// UI isolate) to WAV bytes and loaded into memory; sounds overlap.
/// overlap). [volume] (0..1) is read per play and applied on a square /// [volume] (0..1) is read per play and applied as v^6: the square curve
/// curve (ears are log); [enabled] filters cues. /// (ears are log) the app always used, times mpv's own cubic volume it
/// used to play through, so the slider sounds as it did; [enabled]
/// filters cues.
class SynthSfx implements SfxService { class SynthSfx implements SfxService {
final double Function() volume; final double Function() volume;
final bool Function(SfxKind kind) enabled; final bool Function(SfxKind kind) enabled;
/// Which sound set each sound plays from. /// Which sound set each sound plays from.
final SoundPreset Function(SfxKind kind) presetFor; final SoundPreset Function(SfxKind kind) presetFor;
SynthSfx({required this.volume, bool Function(SfxKind kind)? enabled, SoundPreset Function(SfxKind kind)? presetFor}) final SoundEngine engine;
SynthSfx(
{required this.volume,
bool Function(SfxKind kind)? enabled,
SoundPreset Function(SfxKind kind)? presetFor,
SoundEngine? engine})
: enabled = enabled ?? ((_) => true), : enabled = enabled ?? ((_) => true),
presetFor = presetFor ?? ((_) => defaultPreset); presetFor = presetFor ?? ((_) => defaultPreset),
engine = engine ?? SoundEngine();
static const _poolSize = 3;
final List<Player> _pool = [];
var _next = 0;
final Map<String, Future<String>> _files = {};
Directory? _dir;
@override @override
Future<void> play(SfxCue cue) async { Future<void> play(SfxCue cue) {
if (!enabled(cue.kind)) return; if (!enabled(cue.kind)) return Future.value();
final preset = presetFor(cue.kind); return _play(cue.kind, cue.readyIndex, presetFor(cue.kind));
await playSpec('${preset.name}-${cue.kind.name}-${cue.readyIndex}',
soundFor(cue.kind, readyIndex: cue.readyIndex, preset: preset));
} }
/// Plays [kind] even when it is switched off (Settings' preview), always /// Plays [kind] even when it is switched off (Settings' preview), always
/// freshly rendered: an edited spec plays after a hot reload. /// freshly rendered: an edited spec plays after a hot reload.
Future<void> preview(SfxKind kind, {int readyIndex = 1, SoundPreset? preset}) { Future<void> preview(SfxKind kind, {int readyIndex = 1, SoundPreset? preset}) =>
final p = preset ?? presetFor(kind); _play(kind, readyIndex, preset ?? presetFor(kind), fresh: true);
final key = '${p.name}-${kind.name}-$readyIndex';
_files.remove(key);
return playSpec(key, soundFor(kind, readyIndex: readyIndex, preset: p));
}
/// Plays [spec], cached under [key] (same key = same sound). /// One slot per sound and ready step: a new preset replaces it.
Future<void> playSpec(String key, SoundSpec spec) async { Future<void> _play(SfxKind kind, int readyIndex, SoundPreset preset, {bool fresh = false}) {
final v = volume(); final v = volume();
if (v <= 0) return; return engine.play('${kind.name}-$readyIndex', '${preset.name}-${kind.name}-$readyIndex',
try { () => renderWav(soundFor(kind, readyIndex: readyIndex, preset: preset)),
final path = await (_files[key] ??= _write(key, spec)); volume: math.pow(v, 6).toDouble(), fresh: fresh);
final player = await _player();
await player.setVolume(100 * v * v);
await player.open(Media(path));
} catch (e) {
_files.remove(key);
debugPrint('sfx $key: $e');
}
} }
Future<String> _write(String key, SoundSpec spec) async { /// Opens the audio device ahead of the first sound.
_dir ??= await Directory('${(await getTemporaryDirectory()).path}/watch_together_sfx').create(recursive: true); Future<void> warmUp() => engine.warmUp();
final f = File('${_dir!.path}/$key.wav');
await f.writeAsBytes(wav(render(spec))); Future<void> dispose() => engine.dispose();
return f.path;
} }
Future<Player> _player() async { /// [spec] as WAV bytes, rendered on a worker isolate.
if (_pool.length < _poolSize) { Future<Uint8List> renderWav(SoundSpec spec) => compute(_renderWav, spec, debugLabel: 'sfx render');
final p = Player(
configuration: const PlayerConfiguration(vo: 'null', osc: false, libass: false, title: 'sfx'),
);
await (p.platform as NativePlayer).setProperty('audio-display', 'no');
_pool.add(p);
return p;
}
return _pool[_next++ % _poolSize];
}
Future<void> dispose() async { Uint8List _renderWav(SoundSpec spec) => wav(render(spec));
for (final p in _pool) {
await p.dispose();
}
_pool.clear();
}
}
+2 -45
View File
@@ -8,50 +8,6 @@ import 'shader_presets.dart';
/// A shader pick from the menu; a null quality keeps the saved one. /// A shader pick from the menu; a null quality keeps the saved one.
typedef ShaderPick = (ShaderPreset, ShaderQuality?); typedef ShaderPick = (ShaderPreset, ShaderQuality?);
/// Compact shader button for the video controls bar (normal and
/// fullscreen). Opens a small menu: Off, the six Anime4K modes with
/// HQ / Fast, Custom. The menu is a route, so it outlives the controls
/// bar hiding under it; [onPick] runs on the caller's side.
class ShaderControlButton extends StatelessWidget {
final FocusNode focusNode;
final ReadonlySignal<RenderMode> renderMode;
final void Function(ShaderPreset preset, ShaderQuality? quality) onPick;
final ValueChanged<bool>? onMenuOpen;
const ShaderControlButton({
super.key,
required this.focusNode,
required this.renderMode,
required this.onPick,
this.onMenuOpen,
});
@override
Widget build(BuildContext context) {
final s = AppSettings.instance;
return SignalBuilder(builder: (context) {
final preset = s.shaderPreset.value;
final quality = s.shaderQuality.value;
final short = shaderShortLabel(preset, quality) ?? 'Shaders';
return Tooltip(
message: 'Video shaders (Ctrl+1..6, Ctrl+0 off)',
child: TextButton.icon(
focusNode: focusNode,
style: TextButton.styleFrom(
foregroundColor:
preset == ShaderPreset.off ? Colors.white : Colors.amberAccent,
padding: const EdgeInsets.symmetric(horizontal: 8),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
onPressed: () => showShaderMenu(context,
renderMode: renderMode, onPick: onPick, onMenuOpen: onMenuOpen),
icon: const Icon(Icons.auto_awesome, size: 16),
label: Text(short, style: const TextStyle(fontSize: 12)),
),
);
});
}
}
/// "A HQ", "C+A Fast", "Custom"; null when shaders are off. /// "A HQ", "C+A Fast", "Custom"; null when shaders are off.
String? shaderShortLabel(ShaderPreset preset, ShaderQuality quality) => String? shaderShortLabel(ShaderPreset preset, ShaderQuality quality) =>
switch (preset) { switch (preset) {
@@ -61,7 +17,8 @@ String? shaderShortLabel(ShaderPreset preset, ShaderQuality quality) =>
}; };
/// The shader menu, anchored on [context]'s box (the button that opens /// The shader menu, anchored on [context]'s box (the button that opens
/// it): Off, the six Anime4K modes with HQ / Fast, Custom. /// it; the controls' ⚙): Off, the six Anime4K modes with HQ / Fast,
/// Custom. A route, so it outlives the controls hiding under it.
Future<void> showShaderMenu( Future<void> showShaderMenu(
BuildContext context, { BuildContext context, {
required ReadonlySignal<RenderMode> renderMode, required ReadonlySignal<RenderMode> renderMode,
@@ -85,3 +85,11 @@ String shaderListSeparator({bool? windows}) =>
/// The `glsl-shaders` value for [paths]. /// The `glsl-shaders` value for [paths].
String joinShaderPaths(List<String> paths, {bool? windows}) => String joinShaderPaths(List<String> paths, {bool? windows}) =>
paths.join(shaderListSeparator(windows: windows)); paths.join(shaderListSeparator(windows: windows));
/// Whether mpv's read-back [got] of `glsl-shaders` is the list [paths].
/// Recent mpv prints a path list joined with [shaderListSeparator]; older
/// mpv (0.37 and before, the bundled Windows libmpv among them) joins it
/// with ','. Matched against each joined form rather than split: a path
/// may itself hold ':' (a Windows drive letter) or ','.
bool shaderListMatches(List<String> paths, String? got) =>
got != null && const [',', ';', ':'].any((sep) => paths.join(sep) == got);
+61 -29
View File
@@ -3,20 +3,20 @@ import 'dart:io' show File;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:fox_utils/fox_utils.dart' show ClipboardX, DesktopDropX, DropTarget;
import 'package:signals/signals_flutter.dart'; import 'package:signals/signals_flutter.dart';
import 'package:window_manager/window_manager.dart';
import '../controls/frame.dart' show DoubleClickListener;
import '../files/folder_store.dart'; import '../files/folder_store.dart';
import '../files/ui/files_page.dart' show showFilesFolder; import '../files/ui/files_page.dart' show showFilesFolder;
import '../jellyfin/accounts.dart'; import '../jellyfin/accounts.dart';
import '../jellyfin/browse_scope.dart' show BrowseStart; import '../jellyfin/browse_scope.dart' show BrowseStart;
import '../jellyfin/session_media.dart'; import '../jellyfin/session_media.dart';
import '../screens/home/connect_flow.dart'; import '../screens/home/connect_flow.dart';
import '../session/app_events.dart' show ActionFailed, ScheduleResult;
import '../session/session_coordinator.dart' show ConnectParams; import '../session/session_coordinator.dart' show ConnectParams;
import '../screens/home/health_probe.dart'; import '../screens/home/health_probe.dart';
import '../screens/home/media_picker.dart'; import '../screens/home/media_picker.dart';
import '../screens/home/profile.dart'; import '../session/identity.dart';
import '../screens/home/update_indicator.dart'; import '../screens/home/update_indicator.dart';
import '../state/session_state.dart'; import '../state/session_state.dart';
import '../state/settings.dart'; import '../state/settings.dart';
@@ -24,6 +24,7 @@ import '../update/update_controller.dart';
import 'chat_panel.dart'; import 'chat_panel.dart';
import 'connect_cards.dart'; import 'connect_cards.dart';
import 'handoff.dart'; import 'handoff.dart';
import 'library_link.dart' show LinkProber, defaultLinkProber;
import 'library_tab.dart'; import 'library_tab.dart';
import 'motion.dart'; import 'motion.dart';
import 'party_strip.dart'; import 'party_strip.dart';
@@ -57,6 +58,9 @@ class AppShell extends StatefulWidget {
final JellyfinAccounts? jellyfin; final JellyfinAccounts? jellyfin;
/// Stand-in for media_kit's `Video` (tests). /// Stand-in for media_kit's `Video` (tests).
final VideoViewBuilder? videoBuilder; final VideoViewBuilder? videoBuilder;
/// What the Library's Link page asks about a link (tests: a fake, so no
/// network or yt-dlp runs).
final LinkProber linkProbe;
const AppShell({ const AppShell({
super.key, super.key,
this.updates, this.updates,
@@ -65,6 +69,7 @@ class AppShell extends StatefulWidget {
this.healthGet = defaultHealthGet, this.healthGet = defaultHealthGet,
this.jellyfin, this.jellyfin,
this.videoBuilder, this.videoBuilder,
this.linkProbe = defaultLinkProber,
}); });
@override @override
@@ -127,6 +132,14 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
SessionState.instance.route.value == AppRoute.session && SessionState.instance.route.value == AppRoute.session &&
SessionState.instance.coordinator.serverState.value?.media != null); SessionState.instance.coordinator.serverState.value?.media != null);
/// A video is on in the session (not finished): the update bar stays
/// off the full player.
final _watching = computed(() {
final s = SessionState.instance;
final r = s.coordinator.serverState.value;
return s.route.value == AppRoute.session && r?.media != null && r?.transport != 'finished';
});
PlayerMode get mode => _mode; PlayerMode get mode => _mode;
PlayerMode get shownMode => _shown; PlayerMode get shownMode => _shown;
bool get fullscreen => _fullscreen; bool get fullscreen => _fullscreen;
@@ -189,7 +202,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
// plays again). // plays again).
var wasOut = false; var wasOut = false;
_stops.add(effect(() { _stops.add(effect(() {
final out = coord.selfOut.value; final out = coord.selfAway.value;
if (wasOut && !out && session.route.peek() == AppRoute.session) { if (wasOut && !out && session.route.peek() == AppRoute.session) {
_later(() { _later(() {
if (_mode == PlayerMode.none) _show(PlayerMode.mini); if (_mode == PlayerMode.none) _show(PlayerMode.mini);
@@ -225,10 +238,12 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
stop(); stop();
} }
HardwareKeyboard.instance.removeHandler(_onKey); HardwareKeyboard.instance.removeHandler(_onKey);
_updateTimer?.cancel();
_fade.dispose(); _fade.dispose();
_online.dispose(); _online.dispose();
_alone.dispose(); _alone.dispose();
_hasMedia.dispose(); _hasMedia.dispose();
_watching.dispose();
_flow.dispose(); _flow.dispose();
_join.dispose(); _join.dispose();
_host.dispose(); _host.dispose();
@@ -244,9 +259,17 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
if (mounted) f(); if (mounted) f();
}); });
Timer? _updateTimer;
/// At start, then every [updateCheckEvery] while the app runs (when the
/// auto-check setting is on).
Future<void> _autoCheck() async { Future<void> _autoCheck() async {
final s = AppSettings.instance; final s = AppSettings.instance;
await s.ensureLoaded(); await s.ensureLoaded();
if (!mounted) return;
_updateTimer = Timer.periodic(updateCheckEvery, (_) {
if (s.autoCheckUpdates.peek()) unawaited(runUpdateCheck(widget.updates, true));
});
if (s.autoCheckUpdates.peek()) await runUpdateCheck(widget.updates); if (s.autoCheckUpdates.peek()) await runUpdateCheck(widget.updates);
} }
@@ -300,7 +323,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
/// player shows the join-back card). Once out, × closes the player. /// player shows the join-back card). Once out, × closes the player.
void _closePlayer() { void _closePlayer() {
final coord = SessionState.instance.coordinator; final coord = SessionState.instance.coordinator;
if (coord.selfOut.peek()) { if (coord.selfAway.peek()) {
_show(PlayerMode.none); _show(PlayerMode.none);
} else { } else {
coord.sitOut(); coord.sitOut();
@@ -310,7 +333,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
Future<void> _setWindowFullscreen(bool on) async { Future<void> _setWindowFullscreen(bool on) async {
if (!widget.windowControls) return; if (!widget.windowControls) return;
try { try {
await windowManager.setFullScreen(on); await SessionState.instance.ports.shell.setFullScreen(on);
} catch (_) { } catch (_) {
// No window manager. // No window manager.
} }
@@ -424,6 +447,8 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
/// Ctrl+V outside a text field (a focused field pastes on its own). /// Ctrl+V outside a text field (a focused field pastes on its own).
bool _onKey(KeyEvent e) { bool _onKey(KeyEvent e) {
// Any key is activity (auto sit-out).
SessionState.instance.coordinator.markInput();
if (e is! KeyDownEvent || e.logicalKey != LogicalKeyboardKey.keyV) return false; if (e is! KeyDownEvent || e.logicalKey != LogicalKeyboardKey.keyV) return false;
final keys = HardwareKeyboard.instance; final keys = HardwareKeyboard.instance;
if (!(keys.isControlPressed || keys.isMetaPressed) || keys.isShiftPressed || keys.isAltPressed) return false; if (!(keys.isControlPressed || keys.isMetaPressed) || keys.isShiftPressed || keys.isAltPressed) return false;
@@ -438,17 +463,8 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
/// Clipboard files first, then text (a link or absolute paths). A pasted /// Clipboard files first, then text (a link or absolute paths). A pasted
/// image has no taker yet, so the clipboard's image data is not read. /// image has no taker yet, so the clipboard's image data is not read.
Future<void> _paste() async { Future<void> _paste() async {
var files = const <String>[]; final clip = await SessionState.instance.ports.shell.readPaste();
String? text; await _handOff(classifyHandoff(paths: clip.files, text: clip.text), _pointer);
try {
files = await ClipboardX.readFiles();
} catch (_) {}
if (files.isEmpty) {
try {
text = await ClipboardX.readText();
} catch (_) {}
}
await _handOff(classifyHandoff(paths: files, text: text), _pointer);
} }
Future<void> _handOff(Handoff item, Offset? at) async { Future<void> _handOff(Handoff item, Offset? at) async {
@@ -470,7 +486,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
case (HandoffVideos(:final paths), HandoffAction.putUp): case (HandoffVideos(:final paths), HandoffAction.putUp):
final error = await _putUp(FileMedia(paths.first)); final error = await _putUp(FileMedia(paths.first));
if (error != null) { if (error != null) {
SessionState.instance.coordinator.showToast(error); SessionState.instance.coordinator.notify(ActionFailed(error));
} else { } else {
await FolderStore.instance.ensureLoaded(); await FolderStore.instance.ensureLoaded();
FolderStore.instance.addRecent(paths.first); FolderStore.instance.addRecent(paths.first);
@@ -481,7 +497,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
for (final p in next ? paths.reversed : paths) { for (final p in next ? paths.reversed : paths) {
final error = await SessionState.instance.coordinator.queue.add(FileMedia(p), next: next); final error = await SessionState.instance.coordinator.queue.add(FileMedia(p), next: next);
if (error != null) { if (error != null) {
SessionState.instance.coordinator.showToast(error); SessionState.instance.coordinator.notify(ActionFailed(error));
break; break;
} }
} }
@@ -490,7 +506,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
final coord = SessionState.instance.coordinator; final coord = SessionState.instance.coordinator;
final error = await coord.queue final error = await coord.queue
.add(LinkMedia(url, relay: _picker.linkRelay), next: action == HandoffAction.playNext); .add(LinkMedia(url, relay: _picker.linkRelay), next: action == HandoffAction.playNext);
if (error != null) coord.showToast(error); if (error != null) coord.notify(ActionFailed(error));
case (HandoffVideos(:final paths), HandoffAction.addFolder): case (HandoffVideos(:final paths), HandoffAction.addFolder):
await _addFolder(File(paths.first).parent.path); await _addFolder(File(paths.first).parent.path);
case (HandoffFolder(:final path), _): case (HandoffFolder(:final path), _):
@@ -521,13 +537,13 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
} }
// Off on Settings: its own drop targets (cookies) take the files there. // Off on Settings: its own drop targets (cookies) take the files there.
Widget _dropTarget(Widget child) => DropTarget( Widget _dropTarget(Widget child) => SessionState.instance.ports.shell.dropTarget(
enable: _tab != ShellTab.settings, enable: _tab != ShellTab.settings,
onDragEntered: (_) => setState(() => _dragging = _onTop), onEnter: () => setState(() => _dragging = _onTop),
onDragExited: (_) => setState(() => _dragging = false), onExit: () => setState(() => _dragging = false),
onDragDone: (d) { onDrop: (paths, at) {
setState(() => _dragging = false); setState(() => _dragging = false);
_handOff(classifyHandoff(paths: DesktopDropX.pathsOf(d.files)), d.localPosition); _handOff(classifyHandoff(paths: paths), at);
}, },
child: Stack( child: Stack(
fit: StackFit.expand, fit: StackFit.expand,
@@ -615,6 +631,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
final online = _online.value; final online = _online.value;
final alone = _alone.value; final alone = _alone.value;
final hasMedia = _hasMedia.value; final hasMedia = _hasMedia.value;
final onPlayer = _shown == PlayerMode.full && _watching.value;
return Column( return Column(
children: [ children: [
if (!_fullscreen) if (!_fullscreen)
@@ -631,9 +648,14 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
onInvite: () => _connect(ConnectMode.host), onInvite: () => _connect(ConnectMode.host),
onProfile: () => showProfileCard(context, onOpenSettings: _openSettings, updates: widget.updates), onProfile: () => showProfileCard(context, onOpenSettings: _openSettings, updates: widget.updates),
onShowPlayer: hasMedia && _mode == PlayerMode.none ? () => _show(PlayerMode.mini) : null, onShowPlayer: hasMedia && _mode == PlayerMode.none ? () => _show(PlayerMode.mini) : null,
updates: widget.updates,
windowControls: widget.windowControls, windowControls: widget.windowControls,
), ),
AnimatedSize(
duration: easeTime,
curve: ease,
alignment: Alignment.topCenter,
child: _fullscreen || onPlayer ? const SizedBox(width: double.infinity) : UpdateBar(controller: widget.updates),
),
Expanded( Expanded(
key: const ValueKey('body'), key: const ValueKey('body'),
child: Row( child: Row(
@@ -655,9 +677,18 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
}), }),
), ),
); );
// Any pointer use anywhere is activity (auto sit-out).
final coord = SessionState.instance.coordinator;
return Listener( return Listener(
onPointerHover: (e) => _pointer = e.localPosition, onPointerHover: (e) {
onPointerDown: (e) => _pointer = e.localPosition, _pointer = e.localPosition;
coord.markInput();
},
onPointerDown: (e) {
_pointer = e.localPosition;
coord.markInput();
},
onPointerSignal: (_) => coord.markInput(),
child: _dropTarget(shell), child: _dropTarget(shell),
); );
} }
@@ -693,12 +724,13 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
// Up next needs a server (a solo session has one). // Up next needs a server (a solo session has one).
onAddToQueue: online ? coord.queue.add : null, onAddToQueue: online ? coord.queue.add : null,
onOpenIntegrations: () => _openSettings(SettingsSection.integrations), onOpenIntegrations: () => _openSettings(SettingsSection.integrations),
probe: widget.linkProbe,
), ),
ShellTab.schedule => SchedulePage( ShellTab.schedule => SchedulePage(
solo: alone, solo: alone,
onPutUp: _putUp, onPutUp: _putUp,
onAddToQueue: online ? coord.queue.add : null, onAddToQueue: online ? coord.queue.add : null,
onToast: coord.showToast, onToast: (text) => coord.notify(ScheduleResult(text)),
), ),
ShellTab.settings => SettingsTab( ShellTab.settings => SettingsTab(
section: _section, section: _section,
+44 -25
View File
@@ -4,8 +4,9 @@ import 'package:flutter/material.dart';
import 'package:signals/signals_flutter.dart'; import 'package:signals/signals_flutter.dart';
import 'package:sync_relay_core/core.dart'; import 'package:sync_relay_core/core.dart';
import '../screens/home/profile.dart'; import '../session/identity.dart';
import '../session/delivery.dart'; import '../session/delivery.dart';
import '../session/party.dart';
import '../session/session_coordinator.dart'; import '../session/session_coordinator.dart';
import '../state/session_state.dart'; import '../state/session_state.dart';
import 'motion.dart'; import 'motion.dart';
@@ -17,9 +18,12 @@ class AvatarInfo {
final String name; final String name;
final Color color; final Color color;
final ImageProvider? image; final ImageProvider? image;
final bool ready, buffering, relaying, stale, host, self; final bool ready, buffering, relaying, host, self;
/// Sitting out: dimmed, with an "away" mark. /// Where they stand: joining (a little faded), away (dimmed with a moon),
final bool out; /// gone (dimmed, grey ring: reconnecting).
final Presence presence;
/// Since when they are gone; null otherwise.
final DateTime? goneSince;
/// Why their player cannot open the media (`members[].error`): an error /// Why their player cannot open the media (`members[].error`): an error
/// mark; empty when it can. /// mark; empty when it can.
final String error; final String error;
@@ -34,13 +38,19 @@ class AvatarInfo {
this.ready = false, this.ready = false,
this.buffering = false, this.buffering = false,
this.relaying = false, this.relaying = false,
this.stale = false, this.presence = Presence.watching,
this.goneSince,
this.host = false, this.host = false,
this.self = false, this.self = false,
this.out = false,
this.error = '', this.error = '',
this.delivery = '', this.delivery = '',
}); });
/// Sitting out.
bool get away => presence == Presence.away;
/// Disconnected, in their grace period.
bool get gone => presence == Presence.gone;
} }
/// Stable colour for someone else (colours are not on the wire). /// Stable colour for someone else (colours are not on the wire).
@@ -78,7 +88,7 @@ MemoryImage _memo(Object key, Uint8List bytes) {
/// Callers rebuild on [SessionCoordinator.avatars] and [Profile.picture] /// Callers rebuild on [SessionCoordinator.avatars] and [Profile.picture]
/// (see [PartyWatch]). /// (see [PartyWatch]).
ImageProvider? memberAvatarImage(MemberView m, SessionCoordinator coord) { ImageProvider? memberAvatarImage(MemberView m, SessionCoordinator coord) {
if (m.id.isNotEmpty && m.id == coord.sync.memberId) { if (m.id.isNotEmpty && m.id == coord.connection.memberId) {
final own = Profile.instance.picture.value; final own = Profile.instance.picture.value;
if (own != null) return _memo('self', own); if (own != null) return _memo('self', own);
} }
@@ -87,7 +97,8 @@ ImageProvider? memberAvatarImage(MemberView m, SessionCoordinator coord) {
} }
/// Your own avatar from the profile (also offline). /// Your own avatar from the profile (also offline).
AvatarInfo selfAvatarInfo({bool ready = false, bool host = false, bool out = false, String error = ''}) { AvatarInfo selfAvatarInfo(
{bool ready = false, bool host = false, Presence presence = Presence.watching, String error = ''}) {
final p = Profile.instance; final p = Profile.instance;
final png = p.picture.value; final png = p.picture.value;
final name = p.name.value; final name = p.name.value;
@@ -98,31 +109,31 @@ AvatarInfo selfAvatarInfo({bool ready = false, bool host = false, bool out = fal
ready: ready, ready: ready,
host: host, host: host,
self: true, self: true,
out: out, presence: presence,
error: error, error: error,
); );
} }
/// [m] as the party strip, toasts and the chat show it. /// [m] as the party strip, toasts and the chat show it.
AvatarInfo memberAvatarInfo(MemberView m, StateBroadcast? r, SessionCoordinator coord) { AvatarInfo memberAvatarInfo(MemberView m, StateBroadcast? r, SessionCoordinator coord) {
final self = m.id.isNotEmpty && m.id == coord.sync.memberId; final self = m.id.isNotEmpty && m.id == coord.connection.memberId;
final relay = r == null ? null : stateMediaRelay(r); final relay = r == null ? null : stateMediaRelay(r);
final playing = r?.media != null; final playing = r?.media != null;
final delivery = r == null ? null : deliveryOf(r, selfId: coord.sync.memberId, names: coord.memberNames); final delivery = r == null ? null : deliveryOf(r, selfId: coord.connection.memberId, names: coord.memberNames);
return AvatarInfo( return AvatarInfo(
id: m.id, id: m.id,
name: m.name, name: m.name,
color: self ? Profile.instance.color.value : colorForName(m.name), color: self ? Profile.instance.color.value : colorForName(m.name),
image: memberAvatarImage(m, coord), image: memberAvatarImage(m, coord),
ready: m.ready, ready: m.ready,
buffering: !m.stale && m.ready && m.error.isEmpty && playing && m.buffered < r!.bufferNeed, buffering: countsForReady(m) && m.ready && playing && m.buffered < r!.bufferNeed,
relaying: relay != null && relay.source.isNotEmpty && relay.source == m.id, relaying: relay != null && relay.source.isNotEmpty && relay.source == m.id,
stale: m.stale, presence: m.presence,
goneSince: m.goneSince,
// The state marks the host (`members[].host`); your own app also // The state marks the host (`members[].host`); your own app also
// knows before the first state. // knows before the first state.
host: m.host || (self && coord.hostFile != null), host: m.host || (self && coord.hostFile != null),
self: self, self: self,
out: m.out,
error: m.error, error: m.error,
delivery: delivery == null ? '' : memberDeliveryOf(m, r!, delivery).detail, delivery: delivery == null ? '' : memberDeliveryOf(m, r!, delivery).detail,
); );
@@ -150,8 +161,9 @@ class PartyWatch extends StatelessWidget {
/// Circle avatar with a status ring outside it and a gap in the background /// Circle avatar with a status ring outside it and a gap in the background
/// colour, so overlapping neighbours never hide the status. Crown = host, /// colour, so overlapping neighbours never hide the status. Crown = host,
/// tower = passing the video on, spinner = buffering, dimmed = stale, /// tower = passing the video on, spinner = buffering, a little faded =
/// dimmed with a moon = sitting out, red "!" = cannot play the media. /// joining, dimmed with a grey ring = gone (reconnecting), dimmed with a
/// moon = sitting out, red "!" = cannot play the media.
class StatusAvatar extends StatelessWidget { class StatusAvatar extends StatelessWidget {
final AvatarInfo info; final AvatarInfo info;
final double size; final double size;
@@ -168,7 +180,7 @@ class StatusAvatar extends StatelessWidget {
final bg = Theme.of(context).colorScheme.surfaceContainerLowest; final bg = Theme.of(context).colorScheme.surfaceContainerLowest;
final ringColor = !ring final ringColor = !ring
? bg ? bg
: info.stale : info.gone
? Colors.grey.shade700 ? Colors.grey.shade700
: info.buffering : info.buffering
? Colors.amber ? Colors.amber
@@ -195,7 +207,11 @@ class StatusAvatar extends StatelessWidget {
decoration: BoxDecoration(shape: BoxShape.circle, color: bg), decoration: BoxDecoration(shape: BoxShape.circle, color: bg),
padding: EdgeInsets.all(stroke), padding: EdgeInsets.all(stroke),
child: Opacity( child: Opacity(
opacity: info.stale || info.out ? 0.4 : 1, opacity: info.gone || info.away
? 0.4
: info.presence == Presence.joining
? 0.7
: 1,
child: CircleAvatar( child: CircleAvatar(
radius: size / 2, radius: size / 2,
backgroundColor: info.color, backgroundColor: info.color,
@@ -224,7 +240,7 @@ class StatusAvatar extends StatelessWidget {
), ),
), ),
), ),
if (info.out) if (info.away)
Positioned( Positioned(
key: const ValueKey('away-mark'), key: const ValueKey('away-mark'),
left: -3, left: -3,
@@ -281,7 +297,7 @@ class _AvatarStackState extends State<AvatarStack> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final size = widget.size; final size = widget.size;
int rank(AvatarInfo m) => m.host ? 0 : (m.out ? 3 : m.ready ? 1 : 2); int rank(AvatarInfo m) => m.host ? 0 : (m.gone ? 4 : m.away ? 3 : m.ready ? 1 : 2);
final sorted = [...widget.members]..sort((a, b) => rank(a) - rank(b)); final sorted = [...widget.members]..sort((a, b) => rank(a) - rank(b));
final visible = sorted.take(widget.shown).toList(); final visible = sorted.take(widget.shown).toList();
final extra = sorted.length - visible.length; final extra = sorted.length - visible.length;
@@ -356,13 +372,16 @@ class Pill extends StatelessWidget {
); );
} }
/// Someone by display name (feed lines carry names, not ids): from the /// Someone in a feed line or toast: by member [id] when it has one, else
/// latest state when still there, else their initial on a stable colour. /// by display [name]; from the latest state when still there, else their
AvatarInfo avatarForName(String name, [SessionCoordinator? coord]) { /// initial on a stable colour.
AvatarInfo avatarForName(String name, [SessionCoordinator? coord, String id = '']) {
final c = coord ?? SessionState.instance.coordinator; final c = coord ?? SessionState.instance.coordinator;
final r = c.serverState.value; final r = c.serverState.value;
for (final m in r?.members ?? const <MemberView>[]) { final members = r?.members ?? const <MemberView>[];
if (m.name == name) return memberAvatarInfo(m, r, c); // By id when the line has one (names change and repeat), else by name.
for (final m in members) {
if (id.isNotEmpty ? m.id == id : m.name == name) return memberAvatarInfo(m, r, c);
} }
return AvatarInfo(name: name, color: colorForName(name)); return AvatarInfo(name: name, color: colorForName(name));
} }
+9 -6
View File
@@ -188,7 +188,7 @@ class ActivityRow extends StatelessWidget {
child: Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
StatusAvatar(info: avatarForName(item.actor), size: 34, badges: false), StatusAvatar(info: avatarForName(item.actor, null, item.actorId), size: 34, badges: false),
const SizedBox(width: 10), const SizedBox(width: 10),
Expanded( Expanded(
child: Column( child: Column(
@@ -261,7 +261,7 @@ Future<void> _messageMenu(BuildContext context, String text, String? link) async
case 'join': case 'join':
return (Icons.login, Colors.green, 'joined'); return (Icons.login, Colors.green, 'joined');
case 'leave': case 'leave':
final reason = f.reason == 'silent too long' ? ' · timed out' : (f.reason.isEmpty ? '' : ' · ${f.reason}'); final reason = f.reason == 'grace' ? ' · lost signal too long' : '';
return (Icons.logout, Colors.grey, 'left$reason'); return (Icons.logout, Colors.grey, 'left$reason');
case 'ready': case 'ready':
return (Icons.check_circle, Colors.green, 'is ready'); return (Icons.check_circle, Colors.green, 'is ready');
@@ -279,12 +279,15 @@ Future<void> _messageMenu(BuildContext context, String text, String? link) async
return (Icons.fast_forward, scheme.primary, 'seeked'); return (Icons.fast_forward, scheme.primary, 'seeked');
case 'swap': case 'swap':
return (Icons.swap_horiz, scheme.primary, 'changed the media'); return (Icons.swap_horiz, scheme.primary, 'changed the media');
case 'stale': case 'gone':
return (Icons.cloud_off, Colors.orange, 'lost signal'); return (Icons.cloud_off, Colors.orange, 'lost signal');
case 'out': case 'return':
return (Icons.cloud_done, Colors.green, 'is back');
case 'away':
final who = f.target; final who = f.target;
return (Icons.bedtime, Colors.blueGrey, who == null || who.isEmpty ? 'is sitting out' : 'sat $who out'); final why = f.reason.isEmpty ? '' : ' · ${f.reason}';
case 'back': return (Icons.bedtime, Colors.blueGrey, '${who == null || who.isEmpty ? 'is sitting out' : 'sat $who out'}$why');
case 'in':
return (Icons.login, Colors.green, 'joined back'); return (Icons.login, Colors.green, 'joined back');
case 'queue-add': case 'queue-add':
return (Icons.playlist_add_rounded, scheme.primary, 'added ${f.target ?? ''} to Up next'); return (Icons.playlist_add_rounded, scheme.primary, 'added ${f.target ?? ''} to Up next');
+3 -3
View File
@@ -6,11 +6,11 @@ import 'package:flutter/services.dart';
import 'package:sync_relay_core/core.dart' import 'package:sync_relay_core/core.dart'
show LinkProbe, hasMediaExtension, parseJellyfinLink, playErrorText, unreachableText; show LinkProbe, hasMediaExtension, parseJellyfinLink, playErrorText, unreachableText;
import '../playback/yt_dlp_app.dart';
import '../screens/home/media_picker.dart'; import '../screens/home/media_picker.dart';
import '../widgets/app_menu.dart'; import '../widgets/app_menu.dart';
import '../widgets/copy_open.dart'; import '../widgets/copy_open.dart';
import '../state/connect_prefs.dart'; import '../state/connect_prefs.dart';
import '../state/session_state.dart';
import 'motion.dart'; import 'motion.dart';
// The Library's Link source: a big paste field, a preview of what the link // The Library's Link source: a big paste field, a preview of what the link
@@ -236,10 +236,10 @@ class LinkPreviewStore {
} }
} }
/// What a link is (see [AppYtDlp.probe]). /// What a link is (see [LinkResolver.probe]).
typedef LinkProber = Future<LinkProbe> Function(String url); typedef LinkProber = Future<LinkProbe> Function(String url);
Future<LinkProbe> defaultLinkProber(String url) => AppYtDlp.instance.probe(url); Future<LinkProbe> defaultLinkProber(String url) => SessionState.instance.ports.links.probe(url);
String _host(Uri uri) => uri.host.startsWith('www.') ? uri.host.substring(4) : uri.host; String _host(Uri uri) => uri.host.startsWith('www.') ? uri.host.substring(4) : uri.host;
+2 -1
View File
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:sync_relay_core/core.dart' show RelayConstants; import 'package:sync_relay_core/core.dart' show RelayConstants;
import '../session/app_events.dart' show ProblemMarked;
import '../state/session_state.dart'; import '../state/session_state.dart';
import '../widgets/app_menu.dart' show AppGlass; import '../widgets/app_menu.dart' show AppGlass;
import 'motion.dart'; import 'motion.dart';
@@ -19,7 +20,7 @@ Future<void> showMarkProblem(BuildContext context) async {
if (note == null) return; if (note == null) return;
final coord = SessionState.instance.coordinator; final coord = SessionState.instance.coordinator;
final inSession = await coord.markProblem(note); final inSession = await coord.markProblem(note);
coord.showToast(inSession ? 'Marked in the session log' : "Marked in this app's log"); coord.notify(ProblemMarked(inSession: inSession));
} }
/// The small card: a one-line note and Mark. Pops with the note (maybe /// The small card: a one-line note and Mark. Pops with the note (maybe
+57 -20
View File
@@ -3,13 +3,11 @@ import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:signals/signals_flutter.dart'; import 'package:signals/signals_flutter.dart';
import 'package:sync_relay_core/core.dart'; import 'package:sync_relay_core/core.dart';
import 'package:window_manager/window_manager.dart';
import '../screens/home/ui.dart' show appName, displayVersion; import '../screens/home/ui.dart' show appName, displayVersion, shortVersion;
import '../screens/home/update_indicator.dart';
import '../session/delivery.dart'; import '../session/delivery.dart';
import '../session/party.dart';
import '../state/session_state.dart'; import '../state/session_state.dart';
import '../update/update_controller.dart';
import '../widgets/app_keys.dart'; import '../widgets/app_keys.dart';
import '../widgets/app_menu.dart'; import '../widgets/app_menu.dart';
import '../widgets/copy_open.dart'; import '../widgets/copy_open.dart';
@@ -56,7 +54,6 @@ class PartyStrip extends StatelessWidget {
/// Online with something up but the player closed: brings it back. /// Online with something up but the player closed: brings it back.
final VoidCallback? onShowPlayer; final VoidCallback? onShowPlayer;
final UpdateController? updates;
/// Off in tests: the drag area and window buttons need window_manager. /// Off in tests: the drag area and window buttons need window_manager.
final bool windowControls; final bool windowControls;
@@ -73,7 +70,6 @@ class PartyStrip extends StatelessWidget {
required this.onProfile, required this.onProfile,
this.onInvite, this.onInvite,
this.onShowPlayer, this.onShowPlayer,
this.updates,
this.windowControls = true, this.windowControls = true,
}); });
@@ -125,8 +121,10 @@ class PartyStrip extends StatelessWidget {
label: t.label, label: t.label,
selected: t == tab, selected: t == tab,
compact: compact, compact: compact,
// The version, a rest of the mouse away. // The version: always in sight under Settings,
// the full one a rest of the mouse away.
tooltip: t == ShellTab.settings ? '$appName ${displayVersion()}' : null, tooltip: t == ShellTab.settings ? '$appName ${displayVersion()}' : null,
caption: t == ShellTab.settings ? shortVersion() : null,
onTap: () => onTab(t), onTap: () => onTab(t),
), ),
], ],
@@ -135,7 +133,6 @@ class PartyStrip extends StatelessWidget {
), ),
), ),
), ),
UpdateIndicator(controller: updates),
const SizedBox(width: 8), const SizedBox(width: 8),
if (online && onShowPlayer != null) if (online && onShowPlayer != null)
IconButton( IconButton(
@@ -266,7 +263,7 @@ class PartyStrip extends StatelessWidget {
// their own clicks. // their own clicks.
if (windowControls) if (windowControls)
const Positioned.fill( const Positioned.fill(
child: DragToMoveArea(child: SizedBox.expand()), child: WindowDragArea(child: SizedBox.expand()),
), ),
Positioned.fill(child: row), Positioned.fill(child: row),
], ],
@@ -286,8 +283,11 @@ class _ServerLabel extends StatelessWidget {
builder: (context) { builder: (context) {
final coord = SessionState.instance.coordinator; final coord = SessionState.instance.coordinator;
final r = coord.serverState.value; final r = coord.serverState.value;
final members = r?.members ?? const <MemberView>[]; // The ready counter counts who the presence policy counts (not away,
final ready = members.where((m) => m.ready).length; // not gone, able to play it).
final counted = (r?.members ?? const <MemberView>[]).where(countsForReady).toList();
final ready = counted.where((m) => m.ready).length;
final stale = coord.stateStale.value;
return Column( return Column(
key: const ValueKey('server-label'), key: const ValueKey('server-label'),
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
@@ -298,7 +298,11 @@ class _ServerLabel extends StatelessWidget {
style: const TextStyle(fontWeight: FontWeight.w600, height: 1.1), style: const TextStyle(fontWeight: FontWeight.w600, height: 1.1),
), ),
Text( Text(
r == null ? 'connecting…' : 'ready $ready/${members.length}', r == null
? 'connecting…'
: stale
? 'reconnecting…'
: 'ready $ready/${counted.length}',
style: TextStyle( style: TextStyle(
color: scheme.onSurfaceVariant, color: scheme.onSurfaceVariant,
fontSize: 12, fontSize: 12,
@@ -369,6 +373,8 @@ class StripTab extends StatelessWidget {
final VoidCallback onTap; final VoidCallback onTap;
/// Shown after resting on the tab. /// Shown after resting on the tab.
final String? tooltip; final String? tooltip;
/// Small text under the tab (the version under Settings).
final String? caption;
const StripTab({ const StripTab({
super.key, super.key,
required this.label, required this.label,
@@ -376,6 +382,7 @@ class StripTab extends StatelessWidget {
required this.onTap, required this.onTap,
this.compact = false, this.compact = false,
this.tooltip, this.tooltip,
this.caption,
}); });
@override @override
@@ -391,7 +398,11 @@ class StripTab extends StatelessWidget {
padding: EdgeInsets.symmetric(horizontal: compact ? 8 : 12), padding: EdgeInsets.symmetric(horizontal: compact ? 8 : 12),
child: SizedBox( child: SizedBox(
height: partyStripHeight, height: partyStripHeight,
child: Column( child: Stack(
clipBehavior: Clip.none,
alignment: Alignment.center,
children: [
Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
const SizedBox(height: 3), const SizedBox(height: 3),
@@ -415,6 +426,29 @@ class StripTab extends StatelessWidget {
), ),
], ],
), ),
if (caption case final c?)
Positioned(
left: 0,
right: 0,
bottom: 2,
height: 13,
child: OverflowBox(
maxWidth: 160,
child: Text(
c,
key: const ValueKey('strip-version'),
maxLines: 1,
softWrap: false,
style: TextStyle(
fontSize: 10,
height: 1.2,
color: scheme.onSurfaceVariant.withValues(alpha: 0.7),
),
),
),
),
],
),
), ),
), ),
), ),
@@ -490,7 +524,7 @@ class MembersCard extends StatelessWidget {
: '${session.lastForm.host}:${session.lastForm.port}'; : '${session.lastForm.host}:${session.lastForm.port}';
final delivery = r == null final delivery = r == null
? null ? null
: deliveryOf(r, selfId: coord.sync.memberId, names: coord.memberNames); : deliveryOf(r, selfId: coord.connection.memberId, names: coord.memberNames);
return Column( return Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
@@ -560,10 +594,13 @@ class _MemberTile extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final coord = SessionState.instance.coordinator; final coord = SessionState.instance.coordinator;
final m = member; final m = member;
final status = m.stale final since = m.goneSince;
? 'reconnecting…' final status = m.presence == Presence.gone
: m.out ? 'reconnecting…${since == null ? '' : ' (${sinceText(since, DateTime.now().toUtc())})'}'
: m.presence == Presence.away
? 'sitting out' ? 'sitting out'
: m.presence == Presence.joining
? 'joining…'
: m.error.isNotEmpty : m.error.isNotEmpty
? "can't play this: ${m.error}" ? "can't play this: ${m.error}"
: info.buffering : info.buffering
@@ -602,11 +639,11 @@ class _MemberTile extends StatelessWidget {
} }
}, },
items: () => [ items: () => [
if (!m.out) AppMenuItem(value: 'force', label: m.ready ? 'Force unready' : 'Force ready'), if (m.presence != Presence.away) AppMenuItem(value: 'force', label: m.ready ? 'Force unready' : 'Force ready'),
if (!info.self) const AppMenuItem(value: 'nudge', label: 'Nudge'), if (!info.self) const AppMenuItem(value: 'nudge', label: 'Nudge'),
if (info.self && m.out) if (info.self && m.presence == Presence.away)
const AppMenuItem(value: 'back', label: 'Join back') const AppMenuItem(value: 'back', label: 'Join back')
else if (!m.out) else if (m.presence != Presence.away)
const AppMenuItem(value: 'out', label: 'Sit out'), const AppMenuItem(value: 'out', label: 'Sit out'),
], ],
), ),
+12 -5
View File
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
import 'package:sync_relay_core/core.dart'; import 'package:sync_relay_core/core.dart';
import '../session/health.dart'; import '../session/health.dart';
import '../session/party.dart';
import '../session/session_coordinator.dart'; import '../session/session_coordinator.dart';
import '../state/session_state.dart'; import '../state/session_state.dart';
import '../widgets/app_menu.dart' show AppGlass; import '../widgets/app_menu.dart' show AppGlass;
@@ -61,6 +62,7 @@ String _sentence(String s) => s.isEmpty ? s : s[0].toUpperCase() + s.substring(1
StatusView? playerStatusFor({ StatusView? playerStatusFor({
required HealthStatus health, required HealthStatus health,
bool reconnecting = false, bool reconnecting = false,
bool unstable = false,
StateBroadcast? state, StateBroadcast? state,
MemberView? self, MemberView? self,
bool slowConnect = false, bool slowConnect = false,
@@ -70,7 +72,11 @@ StatusView? playerStatusFor({
return const StatusView(StatusKind.reconnecting, 'Reconnecting…', return const StatusView(StatusKind.reconnecting, 'Reconnecting…',
tone: HealthSeverity.warn, action: StatusAction.cancel); tone: HealthSeverity.warn, action: StatusAction.cancel);
} }
if (playerError.isNotEmpty && state?.media != null && !(self?.out ?? false)) { if (unstable) {
return const StatusView(StatusKind.reconnecting, 'Connection unstable…', tone: HealthSeverity.warn);
}
final away = self?.presence == Presence.away;
if (playerError.isNotEmpty && state?.media != null && !away) {
return StatusView(StatusKind.problem, "Can't play this: $playerError", return StatusView(StatusKind.problem, "Can't play this: $playerError",
tone: HealthSeverity.error, action: StatusAction.reload, short: "Can't play this"); tone: HealthSeverity.error, action: StatusAction.reload, short: "Can't play this");
} }
@@ -84,7 +90,7 @@ StatusView? playerStatusFor({
final r = state; final r = state;
if (r == null || r.media == null || r.transport == 'playing') return null; if (r == null || r.media == null || r.transport == 'playing') return null;
// Sitting out: the join-back card says it; nobody waits for you. // Sitting out: the join-back card says it; nobody waits for you.
if (self != null && self.out) return null; if (away) return null;
if (r.transport == 'finished') { if (r.transport == 'finished') {
return const StatusView(StatusKind.finished, 'Finished · seek back or pick the next video', short: 'Finished'); return const StatusView(StatusKind.finished, 'Finished · seek back or pick the next video', short: 'Finished');
} }
@@ -92,7 +98,7 @@ StatusView? playerStatusFor({
// them; say why instead of "waiting for". // them; say why instead of "waiting for".
final failed = [ final failed = [
for (final m in r.members) for (final m in r.members)
if (m.error.isNotEmpty && !m.out && (self == null || m.id != self.id)) m, if (m.error.isNotEmpty && isPresent(m) && (self == null || m.id != self.id)) m,
]; ];
// As the server holds playback: someone in the ready set short of the // As the server holds playback: someone in the ready set short of the
// minimum buffer. // minimum buffer.
@@ -119,8 +125,8 @@ StatusView? playerStatusFor({
return StatusView(StatusKind.you, "Press Space when you're ready", return StatusView(StatusKind.you, "Press Space when you're ready",
who: [self], self: true, action: StatusAction.ready, detail: 'or click here', short: 'Press Space when ready'); who: [self], self: true, action: StatusAction.ready, detail: 'or click here', short: 'Press Space when ready');
} }
// Members sitting out are never waited for nor counted. // Members sitting out or gone are never waited for nor counted.
final active = [for (final m in r.members) if (!m.stale && !m.out && m.error.isEmpty) m]; final active = [for (final m in r.members) if (countsForReady(m)) m];
final others = [ final others = [
for (final m in active) for (final m in active)
if ((self == null || m.id != self.id) && !(m.inSet && m.ready)) m, if ((self == null || m.id != self.id) && !(m.inSet && m.ready)) m,
@@ -137,6 +143,7 @@ StatusView? playerStatusNow({bool slowConnect = false}) {
return playerStatusFor( return playerStatusFor(
health: c.health.value, health: c.health.value,
reconnecting: c.reconnecting.value, reconnecting: c.reconnecting.value,
unstable: c.unstable.value,
state: c.serverState.value, state: c.serverState.value,
self: c.selfMember.value, self: c.selfMember.value,
slowConnect: slowConnect, slowConnect: slowConnect,
+8 -9
View File
@@ -3,13 +3,13 @@ import 'dart:math' as math;
import 'dart:typed_data'; import 'dart:typed_data';
import 'dart:ui' as ui; import 'dart:ui' as ui;
import 'package:file_picker/file_picker.dart';
import 'package:flutter/gestures.dart'; import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:signals/signals_flutter.dart'; import 'package:signals/signals_flutter.dart';
import 'package:sync_relay_core/core.dart' show Presence;
import '../jellyfin/accounts.dart'; import '../jellyfin/accounts.dart';
import '../screens/home/profile.dart'; import '../session/identity.dart';
import '../state/session_state.dart'; import '../state/session_state.dart';
import '../update/update_controller.dart'; import '../update/update_controller.dart';
import 'avatar.dart'; import 'avatar.dart';
@@ -81,8 +81,8 @@ class _ProfileCardState extends State<ProfileCard> {
final hosting = online && !session.solo.value && coord.hostFile != null; final hosting = online && !session.solo.value && coord.hostFile != null;
final color = _p.color.value; final color = _p.color.value;
final png = _p.picture.value; final png = _p.picture.value;
final out = self?.out ?? false; final out = self?.presence == Presence.away;
final me = selfAvatarInfo(ready: self?.ready ?? false, host: hosting, out: out); final me = selfAvatarInfo(ready: self?.ready ?? false, host: hosting, presence: self?.presence ?? Presence.watching);
return Column( return Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
@@ -329,18 +329,17 @@ class _CropPickerState extends State<CropPicker> {
} }
Future<void> _pick() async { Future<void> _pick() async {
final List<PlatformFile> files; final List<String> files;
try { try {
files = await FilePicker.pickFiles( files = await SessionState.instance.ports.folders.pickFiles(
dialogTitle: 'Pick a picture', dialogTitle: 'Pick a picture',
type: FileType.custom, extensions: const ['png', 'jpg', 'jpeg', 'webp', 'gif', 'bmp'],
allowedExtensions: const ['png', 'jpg', 'jpeg', 'webp', 'gif', 'bmp'],
); );
} catch (e) { } catch (e) {
if (mounted) setState(() => _error = 'file dialog unavailable — try again'); if (mounted) setState(() => _error = 'file dialog unavailable — try again');
return; return;
} }
final path = files.isEmpty ? null : files.first.path; final path = files.firstOrNull;
if (path == null) return; if (path == null) return;
try { try {
final codec = await ui.instantiateImageCodec(await File(path).readAsBytes()); final codec = await ui.instantiateImageCodec(await File(path).readAsBytes());
+69 -551
View File
@@ -2,26 +2,21 @@ import 'dart:async';
import 'dart:math' as math; import 'dart:math' as math;
import 'package:flutter/foundation.dart' show ValueListenable; import 'package:flutter/foundation.dart' show ValueListenable;
import 'package:flutter/gestures.dart' show kPrimaryMouseButton;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:media_kit_video/media_kit_video.dart'; import 'package:media_kit_video/media_kit_video.dart';
import 'package:signals/signals_flutter.dart'; import 'package:signals/signals_flutter.dart';
import 'package:sync_relay_core/core.dart';
import '../controls/controls_probe.dart'; import '../controls/controls_probe.dart';
import '../controls/player_settings.dart'; import '../controls/model/controls_model.dart';
import '../controls/uosc/buttons.dart'; import '../controls/session_controls.dart';
import '../controls/uosc/controls.dart';
import '../controls/uosc/timeline.dart';
import '../controls/uosc/volume_flash.dart';
import '../controls/volume.dart'; import '../controls/volume.dart';
import '../queue/queue_ui.dart'; import '../queue/queue_ui.dart';
import '../playback/media_marks.dart' show SkipKind, SkipOffer, formatClock; import '../playback/media_marks.dart' show SkipKind, SkipOffer, formatClock;
import '../screens/session/jellyfin_widgets.dart'; import '../screens/session/jellyfin_widgets.dart';
import '../screens/session/player_controls.dart'; import '../screens/session/player_controls.dart';
import '../session/app_events.dart' show ShaderMessage;
import '../session/session_coordinator.dart'; import '../session/session_coordinator.dart';
import '../shaders/shader_menu.dart';
import '../shaders/shader_presets.dart'; import '../shaders/shader_presets.dart';
import '../state/session_state.dart'; import '../state/session_state.dart';
import '../state/settings.dart'; import '../state/settings.dart';
@@ -29,7 +24,6 @@ import '../widgets/app_keys.dart';
import '../widgets/text_focus.dart'; import '../widgets/text_focus.dart';
import '../widgets/window_buttons.dart'; import '../widgets/window_buttons.dart';
import 'avatar.dart'; import 'avatar.dart';
import 'mark_problem.dart';
import 'motion.dart'; import 'motion.dart';
import 'now_playing_title.dart'; import 'now_playing_title.dart';
import 'player_status.dart'; import 'player_status.dart';
@@ -46,8 +40,8 @@ enum PlayerMode { none, mini, full }
typedef VideoViewBuilder = Widget Function(BuildContext context, VideoControlsBuilder? controls); typedef VideoViewBuilder = Widget Function(BuildContext context, VideoControlsBuilder? controls);
/// The session's player: the one `Video` of the current player and /// The session's player: the one `Video` of the current player and
/// everything over it. Full: our bars on media_kit's controls or the uosc /// everything over it. Full: the controls (one model, the skin picked in
/// set (Settings), the start card, the status pill, Jellyfin overlays and /// Settings), the start card, the status pill, Jellyfin overlays and
/// the player keys. Mini: hover controls and a progress line. The shell keeps this /// 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 /// widget mounted at one place in the tree while the mode changes, so the
/// `Video` is never mounted twice nor rebuilt for a move. /// `Video` is never mounted twice nor rebuilt for a move.
@@ -99,21 +93,29 @@ class _SessionPlayerState extends State<SessionPlayer> {
final _noFocus = FocusNode(skipTraversal: true, canRequestFocus: false); final _noFocus = FocusNode(skipTraversal: true, canRequestFocus: false);
DateTime? _lastKeyAt; DateTime? _lastKeyAt;
late final void Function() _stopShaderWatch; late final void Function() _stopShaderWatch;
bool _shaderMenuOpen = false;
// Own toast ids, negative: the coordinator counts up from 1.
int _toastSeq = 0;
int _shaderNoticeSeen = 0; int _shaderNoticeSeen = 0;
bool _miniHover = false; bool _miniHover = false;
// Whether the controls are up: the standard ones tracked like media_kit // Whether the controls are up (their frame reports it).
// does (shown on mount and on mouse movement, hidden 3 s later or on
// exit), the uosc ones reporting it.
final _controlsShown = ValueNotifier(true); final _controlsShown = ValueNotifier(true);
// The mouse on a floating element, or the Up next popover open, keeps // The mouse on a floating element, or the Up next popover open, keeps
// the controls up. // the controls up.
final _hold = ControlsHold(); final _hold = ControlsHold();
// Ticks on each wheel / key volume step: flashes the volume readout.
final _volumeFlash = ValueNotifier(0);
late final void Function() _stopPopoverWatch; late final void Function() _stopPopoverWatch;
// The shell's state the controls show, as signals for the model.
late final _fullscreen = signal(widget.fullscreen);
late final _chatOpen = signal(widget.chatOpen);
late final _unread = signal(widget.unread);
late final PlayerControlsModel _model = PlayerControlsModel(
coord: _coord,
fullscreen: _fullscreen,
chatOpen: _chatOpen,
unread: _unread,
onToggleFullscreen: () => widget.onToggleFullscreen(),
onToggleChat: () => widget.onToggleChat(),
onChangeVideo: () => widget.onChangeVideo(),
onCollapse: () => widget.onCollapse(),
onLeave: () => widget.onLeave(),
);
SessionCoordinator get _coord => SessionState.instance.coordinator; SessionCoordinator get _coord => SessionState.instance.coordinator;
bool get _full => widget.mode == PlayerMode.full; bool get _full => widget.mode == PlayerMode.full;
@@ -131,13 +133,21 @@ class _SessionPlayerState extends State<SessionPlayer> {
final n = _coord.playback.shaderNotice.value; final n = _coord.playback.shaderNotice.value;
if (n == null || n.id == _shaderNoticeSeen) return; if (n == null || n.id == _shaderNoticeSeen) return;
_shaderNoticeSeen = n.id; _shaderNoticeSeen = n.id;
_toast(n.text); _coord.notify(ShaderMessage(n.text));
}); });
_stopPopoverWatch = effect(() { _stopPopoverWatch = effect(() {
_hold.set(#upNextPopover, _coord.queue.popoverOpen.value); _hold.set(#upNextPopover, _coord.queue.popoverOpen.value);
}); });
} }
@override
void didUpdateWidget(SessionPlayer oldWidget) {
super.didUpdateWidget(oldWidget);
_fullscreen.value = widget.fullscreen;
_chatOpen.value = widget.chatOpen;
_unread.value = widget.unread;
}
@override @override
void dispose() { void dispose() {
HardwareKeyboard.instance.removeHandler(_globalKey); HardwareKeyboard.instance.removeHandler(_globalKey);
@@ -147,38 +157,11 @@ class _SessionPlayerState extends State<SessionPlayer> {
_noFocus.dispose(); _noFocus.dispose();
_controlsShown.dispose(); _controlsShown.dispose();
_hold.dispose(); _hold.dispose();
_volumeFlash.dispose(); _model.dispose();
super.dispose(); super.dispose();
} }
void _reloadVideo() { void _toggleChat() => _model.toggleChat();
_coord.markInput();
_coord.reloadVideo();
}
void _toast(String text) {
_coord.toasts.value = [
..._coord.toasts.peek(),
ToastItem(id: --_toastSeq, text: text, at: DateTime.now().toUtc()),
];
}
/// Menu, Ctrl+0..6: saved as the choice (also used on the next join)
/// and applied live. A null quality keeps the saved one.
void _pickShader(ShaderPreset preset, ShaderQuality? quality) => applyShaderChoice(preset, quality);
/// One volume step up (+1) or down (-1), from the wheel or ↑ / ↓;
/// flashes the design's volume readout.
void _stepVolume(int step) {
final player = _coord.playback.player;
player.setVolume(stepVolume(player.state.volume, step * volumeStep));
_volumeFlash.value++;
}
void _toggleChat() {
_coord.markInput();
widget.onToggleChat();
}
/// The player keys, while the player fills the body and nothing (a /// The player keys, while the player fills the body and nothing (a
/// dialog, a card, a text field) is in front of it. Space and Tab are /// dialog, a card, a text field) is in front of it. Space and Tab are
@@ -196,7 +179,7 @@ class _SessionPlayerState extends State<SessionPlayer> {
final route = ModalRoute.of(context); final route = ModalRoute.of(context);
if (route != null && !route.isCurrent) { if (route != null && !route.isCurrent) {
// A menu of ours is open: Esc closes it. // A menu of ours is open: Esc closes it.
if (_shaderMenuOpen && event.logicalKey == LogicalKeyboardKey.escape) { if (_model.menusOpen > 0 && event.logicalKey == LogicalKeyboardKey.escape) {
Navigator.of(context, rootNavigator: true).maybePop(); Navigator.of(context, rootNavigator: true).maybePop();
return true; return true;
} }
@@ -207,62 +190,55 @@ class _SessionPlayerState extends State<SessionPlayer> {
if (_lastKeyAt != null && now.difference(_lastKeyAt!).inMilliseconds < 350) { if (_lastKeyAt != null && now.difference(_lastKeyAt!).inMilliseconds < 350) {
return false; return false;
} }
final coord = _coord; final model = _model;
final key = event.logicalKey; final key = event.logicalKey;
// Ctrl+0..6: Anime4K's own mpv keys (off, modes A..C+A). // Ctrl+0..6: Anime4K's own mpv keys (off, modes A..C+A).
final digit = _shaderKeys.indexOf(key); final digit = _shaderKeys.indexOf(key);
if (digit >= 0 && HardwareKeyboard.instance.isControlPressed) { if (digit >= 0 && HardwareKeyboard.instance.isControlPressed) {
_lastKeyAt = now; _lastKeyAt = now;
_pickShader(digit == 0 ? ShaderPreset.off : ShaderPreset.modes[digit - 1], null); model.pickShader(digit == 0 ? ShaderPreset.off : ShaderPreset.modes[digit - 1], null);
return true; return true;
} }
if (key == LogicalKeyboardKey.keyR && HardwareKeyboard.instance.isControlPressed) { if (key == LogicalKeyboardKey.keyR && HardwareKeyboard.instance.isControlPressed) {
_lastKeyAt = now; _lastKeyAt = now;
_reloadVideo(); model.reload();
return true; return true;
} }
// mpv's playlist keys (< >) and Shift+P / Shift+N: Jellyfin episodes. // mpv's playlist keys (< >) and Shift+P / Shift+N: Jellyfin episodes.
final shift = HardwareKeyboard.instance.isShiftPressed; final shift = HardwareKeyboard.instance.isShiftPressed;
if (event.character == '>' || (shift && key == LogicalKeyboardKey.keyN)) { if (event.character == '>' || (shift && key == LogicalKeyboardKey.keyN)) {
// Up next first, then the next episode. // Up next first, then the next episode.
if (coord.queue.items.peek().isNotEmpty) { if (!model.next()) return false;
_lastKeyAt = now; _lastKeyAt = now;
coord.queue.skip();
return true;
}
if (coord.nextEpisode.peek() == null) return false;
_lastKeyAt = now;
coord.playNextEpisode();
return true; return true;
} }
if (event.character == '<' || (shift && key == LogicalKeyboardKey.keyP)) { if (event.character == '<' || (shift && key == LogicalKeyboardKey.keyP)) {
if (coord.previousEpisode.peek() == null) return false; if (!model.previous()) return false;
_lastKeyAt = now; _lastKeyAt = now;
coord.playPreviousEpisode();
return true; return true;
} }
// mpv's chapter keys. // mpv's chapter keys.
if (key == LogicalKeyboardKey.pageDown || key == LogicalKeyboardKey.pageUp) { if (key == LogicalKeyboardKey.pageDown || key == LogicalKeyboardKey.pageUp) {
_lastKeyAt = now; _lastKeyAt = now;
return coord.stepChapter(key == LogicalKeyboardKey.pageDown ? 1 : -1); return model.stepChapter(key == LogicalKeyboardKey.pageDown ? 1 : -1);
} }
if (shift && key == LogicalKeyboardKey.arrowRight) { if (shift && key == LogicalKeyboardKey.arrowRight) {
_lastKeyAt = now; _lastKeyAt = now;
coord.skipForward(); model.skipForward();
return true; return true;
} }
if (key == LogicalKeyboardKey.arrowUp || key == LogicalKeyboardKey.arrowDown) { if (key == LogicalKeyboardKey.arrowUp || key == LogicalKeyboardKey.arrowDown) {
_stepVolume(key == LogicalKeyboardKey.arrowUp ? 1 : -1); model.stepVolume(key == LogicalKeyboardKey.arrowUp ? 1 : -1);
return true; return true;
} }
if (key == LogicalKeyboardKey.arrowRight) { if (key == LogicalKeyboardKey.arrowRight) {
_lastKeyAt = now; _lastKeyAt = now;
coord.nudge(10); model.nudge(10);
return true; return true;
} }
if (key == LogicalKeyboardKey.arrowLeft) { if (key == LogicalKeyboardKey.arrowLeft) {
_lastKeyAt = now; _lastKeyAt = now;
coord.nudge(-10); model.nudge(-10);
return true; return true;
} }
if (key == LogicalKeyboardKey.escape) { if (key == LogicalKeyboardKey.escape) {
@@ -312,13 +288,12 @@ class _SessionPlayerState extends State<SessionPlayer> {
// (lists, menus and popovers under the mouse scroll instead). // (lists, menus and popovers under the mouse scroll instead).
return VolumeWheel( return VolumeWheel(
enabled: _full, enabled: _full,
onStep: _stepVolume, onStep: _model.stepVolume,
child: Stack( child: Stack(
fit: StackFit.expand, fit: StackFit.expand,
children: [ children: [
video, video,
if (_full) ...[ if (_full) ...[
_progressLine(),
_blockedCard(), _blockedCard(),
_actionArea(), _actionArea(),
if (widget.fullscreen) ...[if (!SessionState.instance.solo.value) _pausedParty(), _unreadChip()], if (widget.fullscreen) ...[if (!SessionState.instance.solo.value) _pausedParty(), _unreadChip()],
@@ -327,7 +302,7 @@ class _SessionPlayerState extends State<SessionPlayer> {
_miniOverlay(), _miniOverlay(),
// Sitting out: nothing is loaded; the card covers the // Sitting out: nothing is loaded; the card covers the
// empty player (which stays mounted for joining back). // empty player (which stays mounted for joining back).
if (_coord.selfOut.value) _sitOut(), if (_coord.selfAway.value) _sitOut(),
], ],
), ),
); );
@@ -336,24 +311,12 @@ class _SessionPlayerState extends State<SessionPlayer> {
); );
} }
Widget _video(VideoControlsBuilder? controls) { Widget _video(VideoControlsBuilder? controls) => Video(
final theme = _controlsTheme();
return MaterialDesktopVideoControlsTheme(
normal: theme,
fullscreen: theme,
child: Video(
key: _videoKey, key: _videoKey,
controller: _coord.playback.video, controller: _coord.playback.video,
subtitleViewConfiguration: const SubtitleViewConfiguration(visible: false), subtitleViewConfiguration: const SubtitleViewConfiguration(visible: false),
controls: controls, controls: controls,
),
); );
}
static const _barHeight = 56.0;
/// The standard top bar: room for the cover (2:3, 60 px tall).
static const _topBarHeight = 72.0;
static const _controlsHide = Duration(seconds: 3);
/// Fullscreen hides the strip and its window buttons: the top bar /// Fullscreen hides the strip and its window buttons: the top bar
/// carries them while the controls are up (restore leaves fullscreen). /// carries them while the controls are up (restore leaves fullscreen).
@@ -373,337 +336,18 @@ class _SessionPlayerState extends State<SessionPlayer> {
); );
} }
/// The standard design's progress line while its controls are hidden
/// (the uosc timeline collapses into its own).
Widget _progressLine() => Positioned(
left: 0,
right: 0,
bottom: 0,
child: IgnorePointer(
child: SignalBuilder(builder: (context) {
final s = AppSettings.instance;
if (s.mpvControls.value || !s.progressLine.value) return const SizedBox.shrink();
return ValueListenableBuilder<bool>(
valueListenable: _controlsShown,
builder: (context, shown, _) => shown
? const SizedBox.shrink()
: const _MiniSeekBar(key: ValueKey('progress-line-standard'), thin: true),
);
}),
),
);
MaterialDesktopVideoControlsThemeData _controlsTheme() {
final coord = _coord;
return MaterialDesktopVideoControlsThemeData(
playAndPauseOnTap: false,
modifyVolumeOnScroll: false,
// media_kit's double-click pushes its own fullscreen route (a second
// `Video`); ours toggles the window instead (see [DoubleClickListener]).
toggleFullscreenOnDoublePress: false,
keyboardShortcuts: {},
visibleOnMount: true,
hideMouseOnControlsRemoval: true,
controlsHoverDuration: _controlsHide,
// Our seek bar (chapter marks, hover title) sits in the bottom bar,
// which grows by its height; the top bar keeps its row at the top.
displaySeekBar: false,
buttonBarHeight: _barHeight + ChapterSeekBar.height,
topButtonBar: [
Expanded(
child: Align(
alignment: Alignment.topCenter,
child: SizedBox(
height: _topBarHeight,
child: Row(
children: [
const Expanded(child: NowPlayingTitle()),
UpNextButton(
queue: coord.queue,
onPlayNextEpisode: coord.playNextEpisode,
look: UpNextLook.standard,
hold: _hold),
MaterialDesktopCustomButton(
icon: const Icon(Icons.video_library_outlined),
onPressed: widget.onChangeVideo,
),
MaterialDesktopCustomButton(
icon: Badge(
isLabelVisible: widget.unread > 0,
label: Text('${widget.unread}'),
child: Icon(widget.chatOpen ? Icons.chat_bubble : Icons.chat_bubble_outline),
),
onPressed: _toggleChat,
),
MaterialDesktopCustomButton(
icon: Icon(widget.fullscreen ? Icons.fullscreen_exit : Icons.close_fullscreen),
onPressed: widget.fullscreen ? widget.onToggleFullscreen : widget.onCollapse,
),
?_fullscreenWindowButtons(),
],
),
),
),
),
],
bottomButtonBar: [
Expanded(
child: Column(
children: [
SignalBuilder(
builder: (context) => PlayerSeekBar(
player: coord.playback.player,
chapters: coord.playback.chapters.value,
),
),
Expanded(child: Row(children: _bottomButtons())),
],
),
),
],
);
}
List<Widget> _bottomButtons() {
final coord = _coord;
final playback = coord.playback;
void menuOpen(bool open) => _shaderMenuOpen = open;
void pickTrack(TrackPick p) => coord.selectTrack(aid: p.aid, sid: p.sid, subtitlesOff: p.subtitlesOff);
return [
SignalBuilder(
builder: (context) => EpisodeSkipButton(
focusNode: _noFocus,
episode: coord.previousEpisode.value,
next: false,
onPressed: coord.playPreviousEpisode,
),
),
SignalBuilder(
builder: (context) => EpisodeSkipButton(
focusNode: _noFocus,
episode: coord.nextEpisode.value,
next: true,
onPressed: coord.playNextEpisode,
),
),
VolumeButton(player: playback.player, focusNode: _noFocus),
_ReadyControlButton(focusNode: _noFocus),
_BarTextButton(
focusNode: _noFocus,
label: 'Mark everyone ready',
onPressed: coord.forcePlayAll,
),
_BarTextButton(
focusNode: _noFocus,
icon: Icons.refresh,
label: 'Reload video',
tooltip: 'Reload video (Ctrl+R) — fixes a frozen picture',
onPressed: _reloadVideo,
),
const MaterialDesktopPositionIndicator(),
const Spacer(),
SkipForwardButton(focusNode: _noFocus, onPressed: coord.skipForward),
SignalBuilder(
builder: (context) => ChaptersControlButton(
focusNode: _noFocus,
chapters: playback.chapters.value,
current: playback.currentChapter.value,
onPick: (c) => coord.seekTo(c.start.inMicroseconds / 1e6),
onMenuOpen: menuOpen,
),
),
SignalBuilder(
builder: (context) => TrackMenuButton(
focusNode: _noFocus,
type: JellyfinStreamType.audio,
tracks: playback.tracks.value,
onPick: pickTrack,
onMenuOpen: menuOpen,
),
),
SignalBuilder(
builder: (context) => TrackMenuButton(
focusNode: _noFocus,
type: JellyfinStreamType.subtitle,
tracks: playback.tracks.value,
onPick: pickTrack,
onMenuOpen: menuOpen,
),
),
ShaderControlButton(
focusNode: _noFocus,
renderMode: playback.renderMode,
onPick: _pickShader,
onMenuOpen: menuOpen,
),
PlayerSettingsButton(focusNode: _noFocus, renderMode: playback.renderMode),
MaterialDesktopCustomButton(
icon: Icon(widget.fullscreen ? Icons.fullscreen_exit : Icons.fullscreen),
onPressed: widget.onToggleFullscreen,
),
];
}
Widget _sessionControls(VideoState state) { Widget _sessionControls(VideoState state) {
// The start card sits under the controls (they stay usable on it). // The start card sits under the controls (they stay usable on it).
// The "Player design" setting swaps them live. return Stack(
return Stack(fit: StackFit.expand, children: [const StartBanner(), _controlsSwitch(state)]); fit: StackFit.expand,
}
Widget _controlsSwitch(VideoState state) {
return SessionControlsSwitch(
material: (_) => ControlsHoverProbe(
shown: _controlsShown,
hideAfter: _controlsHide,
hold: _hold,
child: DoubleClickListener(
onDoubleClick: widget.onToggleFullscreen,
// Clicks on the bars are button clicks, not the video.
skipTop: _topBarHeight,
skipBottom: _barHeight + ChapterSeekBar.height,
child: MaterialDesktopVideoControls(state),
),
),
mpv: (_) => _mpvControls(),
);
}
/// The uosc controls: same actions as the bars above,
/// all through the coordinator (seeks via `player.seek`, the user path).
Widget _mpvControls() {
final coord = _coord;
final playback = coord.playback;
void menuOpen(bool open) => _shaderMenuOpen = open;
void pickTrack(TrackPick p) => coord.selectTrack(aid: p.aid, sid: p.sid, subtitlesOff: p.subtitlesOff);
Widget episode(bool next) => SignalBuilder(builder: (context) {
final e = next ? coord.nextEpisode.value : coord.previousEpisode.value;
if (e == null) return const SizedBox.shrink();
return UoscButton(
key: ValueKey(next ? 'episode-next' : 'episode-previous'),
icon: next ? Icons.skip_next_rounded : Icons.skip_previous_rounded,
tooltip: '${next ? 'Next' : 'Previous'}: ${e.label} (${next ? '> or Shift+N' : '< or Shift+P'})',
onPressed: next ? coord.playNextEpisode : coord.playPreviousEpisode,
);
});
return UoscControls(
shown: _controlsShown,
holdBy: _hold,
onDoubleTap: widget.onToggleFullscreen,
topLeft: const NowPlayingTitle(),
// Up next is one of the top bar's controls, before chat.
top: Row(
mainAxisSize: MainAxisSize.min,
children: [ children: [
UpNextButton( const StartBanner(),
queue: coord.queue, onPlayNextEpisode: coord.playNextEpisode, look: UpNextLook.uosc, hold: _hold), SessionControls(
UoscButton( model: _model,
key: const ValueKey('uosc-chat'), shown: _controlsShown,
icon: widget.chatOpen ? Icons.chat_bubble_rounded : Icons.chat_bubble_outline_rounded, hold: _hold,
tooltip: 'Chat & activity (Tab)', onDoubleClick: widget.onToggleFullscreen,
badge: widget.unread, windowButtons: _fullscreenWindowButtons(),
onPressed: _toggleChat,
),
UoscButton(
icon: widget.fullscreen ? Icons.fullscreen_exit_rounded : Icons.close_fullscreen_rounded,
tooltip: widget.fullscreen ? 'Exit fullscreen (Esc)' : 'Back to the library (keeps playing)',
onPressed: widget.fullscreen ? widget.onToggleFullscreen : widget.onCollapse,
),
?_fullscreenWindowButtons(),
],
),
timeline: (context, expanded) => SignalBuilder(
builder: (context) => UoscTimeline(
position: playback.position.value,
duration: playback.duration.value,
buffered: playback.buffered.value,
chapters: playback.chapters.value,
expanded: expanded,
onSeek: (d) => playback.player.seek(d),
),
),
left: [
UoscMainMenuButton(
onMenuOpen: menuOpen,
actions: [
(title: 'Change video', hint: null, run: widget.onChangeVideo),
(title: 'Chat & activity', hint: 'Tab', run: _toggleChat),
(title: 'Reload video', hint: 'Ctrl+R', run: _reloadVideo),
(title: 'Mark a problem', hint: 'Ctrl+Shift+B', run: () => showMarkProblem(context)),
(title: 'Mark everyone ready', hint: null, run: coord.forcePlayAll),
(title: 'Fullscreen', hint: 'F', run: widget.onToggleFullscreen),
(title: 'Leave server', hint: 'Esc', run: widget.onLeave),
],
),
SignalBuilder(
builder: (context) => UoscTracksButton(
type: JellyfinStreamType.subtitle,
tracks: playback.tracks.value,
onPick: pickTrack,
onMenuOpen: menuOpen,
),
),
SignalBuilder(
builder: (context) => UoscTracksButton(
type: JellyfinStreamType.audio,
tracks: playback.tracks.value,
onPick: pickTrack,
onMenuOpen: menuOpen,
),
),
UoscShaderButton(renderMode: playback.renderMode, onPick: _pickShader, onMenuOpen: menuOpen),
const SizedBox(width: 12),
SignalBuilder(builder: (context) {
final ready = coord.selfMember.value?.ready ?? false;
return UoscButton(
key: const ValueKey('uosc-ready'),
icon: ready ? Icons.check_circle_rounded : Icons.radio_button_unchecked_rounded,
label: ready ? 'Ready' : 'Not ready',
color: ready ? Colors.greenAccent : null,
tooltip: 'Ready / not ready (Space)',
onPressed: coord.toggleReady,
);
}),
UoscButton(icon: Icons.done_all_rounded, tooltip: 'Mark everyone ready', onPressed: coord.forcePlayAll),
UoscButton(
icon: Icons.refresh_rounded,
tooltip: 'Reload video (Ctrl+R) — fixes a frozen picture',
onPressed: _reloadVideo,
),
],
right: [
UoscButton(
key: const ValueKey('uosc-skip-forward'),
icon: Icons.fast_forward_rounded,
label: '1:30',
tooltip: 'Skip 1:30 for everyone (Shift+Right)',
onPressed: coord.skipForward,
),
SignalBuilder(
builder: (context) => UoscChaptersButton(
chapters: playback.chapters.value,
current: playback.currentChapter.value,
duration: playback.duration.value,
onPick: (c) => coord.seekTo(c.start.inMicroseconds / 1e6),
onMenuOpen: menuOpen,
),
),
episode(false),
episode(true),
const SizedBox(width: 12),
UoscButton(icon: Icons.video_library_outlined, tooltip: 'Change video', onPressed: widget.onChangeVideo),
StreamBuilder<double>(
stream: playback.player.stream.volume,
initialData: playback.player.state.volume,
builder: (context, v) => UoscVolumeButton(
volume: v.data ?? 100,
onChanged: (x) => playback.player.setVolume(x),
),
),
UoscPlayerSettingsButton(renderMode: playback.renderMode),
UoscButton(
icon: widget.fullscreen ? Icons.fullscreen_exit_rounded : Icons.fullscreen_rounded,
tooltip: 'Fullscreen (F)',
onPressed: widget.onToggleFullscreen,
), ),
], ],
); );
@@ -713,64 +357,26 @@ class _SessionPlayerState extends State<SessionPlayer> {
/// the gap between them and the controls. /// the gap between them and the controls.
static const _edge = 16.0; static const _edge = 16.0;
/// The bottom controls' height in the design in use. /// The bottom controls' height in the skin and size in use.
static double _bottomControlsHeight(bool uosc) => uosc static double _bottomControlsHeight() {
? UoscControls.buttonRowHeight + UoscControls.rowBottomPadding + UoscTimeline.height final s = AppSettings.instance;
: _barHeight + ChapterSeekBar.height; return controlsBottomHeight(s.controlsSkin.value, s.controlsSize.value);
/// The volume flash after a wheel or key step, in the design in use:
/// uosc's vertical slider at the right edge (centred between the top
/// buttons and the bottom controls, as uosc places it), else a pill
/// under the top bar.
Widget _volumeReadout() {
final playback = _coord.playback;
Widget withVolume(Widget Function(double v) build) => StreamBuilder<double>(
stream: playback.player.stream.volume,
initialData: playback.player.state.volume,
builder: (context, v) => build(v.data ?? 100),
);
return SignalBuilder(builder: (context) {
if (AppSettings.instance.mpvControls.value) {
const top = 12 + UoscControls.buttonRowHeight;
final bottom = _bottomControlsHeight(true);
return Positioned(
right: UoscVolumeSlider.width / 2,
top: top,
bottom: bottom,
width: UoscVolumeSlider.width,
child: LayoutBuilder(builder: (context, c) {
final h = UoscVolumeSlider.heightFor(c.maxHeight);
if (h <= UoscVolumeSlider.width) return const SizedBox.shrink();
return Align(
// Centred with the mute button's room below, like uosc.
alignment: Alignment.center,
child: Padding(
padding: const EdgeInsets.only(bottom: UoscVolumeSlider.width),
child: SizedBox(
height: h,
child: VolumeFlash(
trigger: _volumeFlash,
builder: (context) => withVolume((v) => UoscVolumeSlider(volume: v)),
),
),
),
);
}),
);
} }
return Positioned(
static double get _topBarHeight => controlsTopHeight(AppSettings.instance.controlsSkin.peek());
/// The volume flash after a wheel or key step: a pill under the top bar.
Widget _volumeReadout() => Positioned(
left: 0, left: 0,
right: 0, right: 0,
top: _topBarHeight + 8, top: _topBarHeight + 8,
child: Center( child: Center(
child: VolumeFlash( child: VolumeFlash(
trigger: _volumeFlash, trigger: _model.volumeFlash,
builder: (context) => withVolume((v) => VolumeReadout(volume: v)), builder: (context) => SignalBuilder(builder: (context) => VolumeReadout(volume: _model.volume.value)),
), ),
), ),
); );
});
}
/// The action area: one band above the bottom controls for everything /// The action area: one band above the bottom controls for everything
/// that floats there. The status pill (the ready prompt among it) /// that floats there. The status pill (the ready prompt among it)
@@ -781,7 +387,7 @@ class _SessionPlayerState extends State<SessionPlayer> {
/// ([ControlsHold]). /// ([ControlsHold]).
Widget _actionArea() { Widget _actionArea() {
return SignalBuilder(builder: (context) { return SignalBuilder(builder: (context) {
final controls = _bottomControlsHeight(AppSettings.instance.mpvControls.value); final controls = _bottomControlsHeight();
return ValueListenableBuilder<bool>( return ValueListenableBuilder<bool>(
valueListenable: _controlsShown, valueListenable: _controlsShown,
builder: (context, shown, child) => AnimatedPositioned( builder: (context, shown, child) => AnimatedPositioned(
@@ -848,7 +454,7 @@ class _SessionPlayerState extends State<SessionPlayer> {
announce: skip.kind != SkipKind.ending, announce: skip.kind != SkipKind.ending,
controlsShown: _controlsShown, controlsShown: _controlsShown,
hold: _hold, hold: _hold,
child: SkipPill(offer: skip, onPressed: () => coord.skip(skip)), child: SkipPill(offer: skip, onPressed: () => _model.skip(skip)),
), ),
if (showNext) if (showNext)
PillGate( PillGate(
@@ -1171,7 +777,7 @@ const _shaderKeys = [
class _MiniSeekBar extends StatelessWidget { class _MiniSeekBar extends StatelessWidget {
final bool thin; final bool thin;
final ValueChanged<Duration>? onSeek; final ValueChanged<Duration>? onSeek;
const _MiniSeekBar({super.key, this.thin = false, this.onSeek}); const _MiniSeekBar({this.thin = false, this.onSeek});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -1199,91 +805,3 @@ class _MiniSeekBar extends StatelessWidget {
}); });
} }
} }
/// Double-click on the picture (not on the bars) without joining the
/// gesture arena, so single clicks on buttons stay instant.
class DoubleClickListener extends StatefulWidget {
final VoidCallback onDoubleClick;
final double skipTop, skipBottom;
final Widget child;
const DoubleClickListener(
{super.key, required this.onDoubleClick, this.skipTop = 0, this.skipBottom = 0, required this.child});
@override
State<DoubleClickListener> createState() => _DoubleClickListenerState();
}
class _DoubleClickListenerState extends State<DoubleClickListener> {
DateTime? _last;
Offset? _lastAt;
void _down(PointerDownEvent e) {
if (e.buttons != kPrimaryMouseButton) return;
final h = context.size?.height ?? 0;
final y = e.localPosition.dy;
if (y < widget.skipTop || y > h - widget.skipBottom) {
_last = null;
return;
}
final now = DateTime.now();
final last = _last, at = _lastAt;
if (last != null &&
at != null &&
now.difference(last) < const Duration(milliseconds: 400) &&
(e.localPosition - at).distance < 24) {
_last = null;
widget.onDoubleClick();
return;
}
_last = now;
_lastAt = e.localPosition;
}
@override
Widget build(BuildContext context) =>
Listener(behavior: HitTestBehavior.translucent, onPointerDown: _down, child: widget.child);
}
class _ReadyControlButton extends StatelessWidget {
final FocusNode focusNode;
const _ReadyControlButton({required this.focusNode});
@override
Widget build(BuildContext context) {
return SignalBuilder(
builder: (context) {
final ready = SessionState.instance.coordinator.selfMember.value?.ready ?? false;
return _BarTextButton(
focusNode: focusNode,
label: ready ? 'Ready ✓' : 'Not ready',
onPressed: SessionState.instance.coordinator.toggleReady,
);
},
);
}
}
class _BarTextButton extends StatelessWidget {
final FocusNode focusNode;
final String label;
final IconData? icon;
final String? tooltip;
final VoidCallback onPressed;
const _BarTextButton(
{required this.focusNode, required this.label, required this.onPressed, this.icon, this.tooltip});
@override
Widget build(BuildContext context) {
final style = TextButton.styleFrom(
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 8),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
);
final text = Text(label, style: const TextStyle(fontSize: 12));
final button = icon == null
? TextButton(focusNode: focusNode, style: style, onPressed: onPressed, child: text)
: TextButton.icon(
focusNode: focusNode, style: style, onPressed: onPressed, icon: Icon(icon, size: 16), label: text);
return tooltip == null ? button : Tooltip(message: tooltip, child: button);
}
}
+27 -28
View File
@@ -2,9 +2,7 @@ import 'dart:async';
import 'dart:io'; import 'dart:io';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:fox_utils/fox_utils.dart' show DesktopDropX, DropTarget;
import 'package:signals/signals_flutter.dart'; import 'package:signals/signals_flutter.dart';
import 'package:sync_relay_core/core.dart' import 'package:sync_relay_core/core.dart'
show CookieBrowser, CookieFile, NetscapeCookie, YtDlp, YtDlpCookies, cookieBrowsers, parseCookies; show CookieBrowser, CookieFile, NetscapeCookie, YtDlp, YtDlpCookies, cookieBrowsers, parseCookies;
@@ -12,13 +10,12 @@ import 'package:sync_relay_core/core.dart'
import '../controls/player_settings.dart'; import '../controls/player_settings.dart';
import '../jellyfin/accounts.dart'; import '../jellyfin/accounts.dart';
import '../jellyfin/accounts_panel.dart'; import '../jellyfin/accounts_panel.dart';
import '../playback/yt_dlp_app.dart';
import '../schedule/animeflow.dart'; import '../schedule/animeflow.dart';
import '../screens/home/about_page.dart'; import '../screens/home/about_page.dart';
import '../screens/home/settings_page.dart'; import '../screens/home/settings_page.dart';
import '../session/auto_sit_out.dart' show AutoSitOutTimes;
import '../sfx/sfx.dart'; import '../sfx/sfx.dart';
import '../sfx/sound_bank.dart'; import '../sfx/sound_bank.dart';
import '../sfx/synth_sfx.dart';
import '../state/session_state.dart'; import '../state/session_state.dart';
import '../state/settings.dart'; import '../state/settings.dart';
import '../update/update_controller.dart'; import '../update/update_controller.dart';
@@ -104,15 +101,17 @@ class SettingsTab extends StatelessWidget {
), ),
), ),
SettingsPanel( SettingsPanel(
title: 'When idle', title: 'Away',
child: SignalBuilder( child: SignalBuilder(
builder: (context) => SwitchListTile( builder: (context) => SwitchListTile(
key: const ValueKey('idle-sits-out'), key: const ValueKey('auto-sit-out'),
contentPadding: EdgeInsets.zero, contentPadding: EdgeInsets.zero,
title: const Text('When idle, sit out instead of just unready'), title: const Text('Auto sit-out'),
subtitle: const Text('After 10 min away while ready and held, your player unloads the video until you join back (Space)'), subtitle: Text('Idle for ${AutoSitOutTimes.idle.inMinutes} min while nothing plays, or minimised '
value: AppSettings.instance.idleSitsOut.value, 'for ${AutoSitOutTimes.minimised.inMinutes} min, sits you out so nobody waits for you; '
onChanged: AppSettings.instance.setIdleSitsOut, "you're back as soon as you return. Never when you're alone"),
value: AppSettings.instance.autoSitOut.value,
onChanged: AppSettings.instance.setAutoSitOut,
), ),
), ),
), ),
@@ -121,12 +120,16 @@ class SettingsTab extends StatelessWidget {
return [ return [
const SettingsHintCard( const SettingsHintCard(
icon: Icons.tune, icon: Icons.tune,
text: "Player settings are also in the player's ⚙, at the right end of its controls.", text: "Player settings are also in the player: ⚙ → Player settings, at the right of its controls.",
), ),
SettingsPanel( SettingsPanel(
title: 'Player', title: 'Player',
child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: playerSettingRows()), child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: playerSettingRows()),
), ),
SettingsPanel(
title: 'Controls',
child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: controlsSettingRows()),
),
const SettingsPanel(title: 'Custom shaders', child: CustomShaderFiles()), const SettingsPanel(title: 'Custom shaders', child: CustomShaderFiles()),
const SettingsPanel(title: 'yt-dlp', child: YtDlpSettings()), const SettingsPanel(title: 'yt-dlp', child: YtDlpSettings()),
]; ];
@@ -377,7 +380,7 @@ class RecentServersPanel extends StatelessWidget {
/// The app's yt-dlp (web page links): its version, "Update now", and the /// The app's yt-dlp (web page links): its version, "Update now", and the
/// last error. /// last error.
class YtDlpSettings extends StatefulWidget { class YtDlpSettings extends StatefulWidget {
/// Defaults to [AppYtDlp.instance]'s. /// Defaults to the app's ([Ports.links]).
final Future<YtDlp>? ytDlp; final Future<YtDlp>? ytDlp;
const YtDlpSettings({super.key, this.ytDlp}); const YtDlpSettings({super.key, this.ytDlp});
@@ -386,7 +389,7 @@ class YtDlpSettings extends StatefulWidget {
} }
class _YtDlpSettingsState extends State<YtDlpSettings> { class _YtDlpSettingsState extends State<YtDlpSettings> {
late final Future<YtDlp> _y = widget.ytDlp ?? AppYtDlp.instance.ytDlp; late final Future<YtDlp> _y = widget.ytDlp ?? SessionState.instance.ports.links.ytDlp;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -469,18 +472,17 @@ class _YtDlpCookieSettingsState extends State<YtDlpCookieSettings> {
}; };
Future<void> _pickFile() async { Future<void> _pickFile() async {
final List<PlatformFile> files; final List<String> files;
try { try {
files = await FilePicker.pickFiles( files = await SessionState.instance.ports.folders.pickFiles(
dialogTitle: 'Pick a cookies.txt or a JSON cookie export', dialogTitle: 'Pick a cookies.txt or a JSON cookie export',
type: FileType.custom, extensions: const ['txt', 'json'],
allowedExtensions: const ['txt', 'json'],
); );
} catch (_) { } catch (_) {
_reload('file dialog unavailable — try again'); _reload('file dialog unavailable — try again');
return; return;
} }
final path = files.firstOrNull?.path; final path = files.firstOrNull;
if (path != null) await _importFile(path); if (path != null) await _importFile(path);
} }
@@ -532,12 +534,12 @@ class _YtDlpCookieSettingsState extends State<YtDlpCookieSettings> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme; final scheme = Theme.of(context).colorScheme;
return DropTarget( return SessionState.instance.ports.shell.dropTarget(
onDragEntered: (_) => setState(() => _dragging = true), onEnter: () => setState(() => _dragging = true),
onDragExited: (_) => setState(() => _dragging = false), onExit: () => setState(() => _dragging = false),
onDragDone: (d) { onDrop: (paths, _) {
setState(() => _dragging = false); setState(() => _dragging = false);
final path = DesktopDropX.pathsOf(d.files).firstOrNull; final path = paths.firstOrNull;
if (path != null) _importFile(path); if (path != null) _importFile(path);
}, },
child: AnimatedContainer( child: AnimatedContainer(
@@ -779,7 +781,7 @@ class NotificationsInfo extends StatelessWidget {
), ),
line('Sat out or nudged by someone', 'Only on your end, with their picture'), line('Sat out or nudged by someone', 'Only on your end, with their picture'),
line('Ready, seeks and pauses', 'Only in Chat & activity (Tab), never as a pop-up'), line('Ready, seeks and pauses', 'Only in Chat & activity (Tab), never as a pop-up'),
line('App notices', 'Shader changes, the idle warning'), line('App notices', 'Shader changes, auto sit-out setting you away'),
], ],
), ),
), ),
@@ -794,10 +796,7 @@ class NotificationsInfo extends StatelessWidget {
class SoundSettings extends StatelessWidget { class SoundSettings extends StatelessWidget {
const SoundSettings({super.key}); const SoundSettings({super.key});
void _preview(SfxKind kind) { void _preview(SfxKind kind) => unawaited(SessionState.instance.sfx.preview(kind, readyIndex: 2));
final sfx = SessionState.instance.sfx;
if (sfx is SynthSfx) unawaited(sfx.preview(kind, readyIndex: 2));
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
+1 -1
View File
@@ -50,7 +50,7 @@ class _SitOutCardState extends State<SitOutCard> {
final now = coord.nowPlaying.value; final now = coord.nowPlaying.value;
final playing = r?.transport == 'playing'; final playing = r?.transport == 'playing';
final duration = (r?.media?['duration'] as num?)?.toDouble() ?? 0; final duration = (r?.media?['duration'] as num?)?.toDouble() ?? 0;
final at = r == null ? 0.0 : serverPositionAt(r, coord.sync.serverNow()); final at = r == null ? 0.0 : serverPositionAt(r, coord.connection.serverNow());
Duration secs(double s) => Duration(milliseconds: (s * 1000).round()); Duration secs(double s) => Duration(milliseconds: (s * 1000).round());
final where = r?.media == null final where = r?.media == null
? 'Nothing is up' ? 'Nothing is up'
+1 -1
View File
@@ -80,7 +80,7 @@ class _PartyToastsState extends State<PartyToasts> {
final playing = coord.serverState.value?.transport == 'playing'; final playing = coord.serverState.value?.transport == 'playing';
final popups = AppSettings.instance.chatPopups.value; final popups = AppSettings.instance.chatPopups.value;
for (final t in fresh) { for (final t in fresh) {
final toast = shellToastFor(t, (n) => avatarForName(n, coord)); final toast = shellToastFor(t, (n) => avatarForName(n, coord, t.actorId));
// Chat per Settings → Notifications (it is in the chat either way). // Chat per Settings → Notifications (it is in the chat either way).
if (toast.chat && !popups.shows(playing: playing)) continue; if (toast.chat && !popups.shows(playing: playing)) continue;
_add(toast); _add(toast);
+24 -8
View File
@@ -11,11 +11,15 @@ import '../jellyfin/session_media.dart';
import '../screens/home/health_probe.dart'; import '../screens/home/health_probe.dart';
import '../screens/home/picked_media.dart'; import '../screens/home/picked_media.dart';
import '../platform/app_log.dart' show appLogDir; import '../platform/app_log.dart' show appLogDir;
import '../playback/yt_dlp_app.dart'; import '../platform/desktop/desktop_ports.dart';
import '../platform/ports/ports.dart';
import '../sfx/sfx.dart'; import '../sfx/sfx.dart';
import '../sfx/sound_bank.dart'; import '../sfx/sound_bank.dart';
import '../sfx/synth_sfx.dart'; import '../sfx/synth_sfx.dart';
import '../session/health.dart'; import '../session/health.dart';
import '../session/notifier.dart';
import '../session/connection.dart' show Connection;
import '../session/identity.dart' show ClientIdentity;
import '../session/session_coordinator.dart'; import '../session/session_coordinator.dart';
import '../widgets/avatar_pipeline.dart'; import '../widgets/avatar_pipeline.dart';
import 'connect_prefs.dart'; import 'connect_prefs.dart';
@@ -160,13 +164,23 @@ class SessionState {
final route = signal(AppRoute.home); final route = signal(AppRoute.home);
final recents = signal<List<RecentServer>>(const []); final recents = signal<List<RecentServer>>(const []);
final status = signal(const HealthStatus(HealthSeverity.info, 'idle')); final status = signal(const HealthStatus(HealthSeverity.info, 'idle'));
final SfxService sfx = SynthSfx( /// Owns the app's audio device ([SynthSfx.dispose] on close).
final SynthSfx sfx = SynthSfx(
volume: () => AppSettings.instance.soundVolume.peek(), volume: () => AppSettings.instance.soundVolume.peek(),
// A nudge is an explicit poke: it ignores its switch (not the volume). // A nudge is an explicit poke: it ignores its switch (not the volume).
enabled: (k) => k == SfxKind.nudge || !AppSettings.instance.soundsOff.peek().contains(k.name), enabled: (k) => k == SfxKind.nudge || !AppSettings.instance.soundsOff.peek().contains(k.name),
presetFor: soundPresetFor, presetFor: soundPresetFor,
); );
late final SessionCoordinator coordinator = SessionCoordinator(sfx: sfx); /// The platform (window, links, folders, yt-dlp, notifications).
final Ports ports = desktopPorts();
/// Every toast, sound and activity-log line goes through it.
late final Notifier notifier = Notifier(sfx: sfx, attention: ports.shell.requestAttention);
/// The link to the server; lost/back go to the notifier.
/// The link to the server, with this install's stored identity
/// (`hello.client` / `seq`); lost/back go to the notifier.
late final Connection connection = Connection(notify: notifier.notify, identity: ClientIdentity.stored());
late final SessionCoordinator coordinator =
SessionCoordinator(notifier: notifier, connection: connection, ytDlp: ports.links);
final lastForm = ConnectForm(); final lastForm = ConnectForm();
JellyfinAccounts get jellyfin => JellyfinAccounts.instance; JellyfinAccounts get jellyfin => JellyfinAccounts.instance;
/// Set when the hosted server died mid-session; Home shows it on the /// Set when the hosted server died mid-session; Home shows it on the
@@ -187,7 +201,7 @@ class SessionState {
/// default) when the app-support folder is not available. /// default) when the app-support folder is not available.
static Future<String> _sharedYtDlpDir() async { static Future<String> _sharedYtDlpDir() async {
try { try {
return (await AppYtDlp.instance.ytDlp).dir; return (await instance.ports.links.ytDlp).dir;
} catch (_) { } catch (_) {
return ''; return '';
} }
@@ -284,7 +298,7 @@ class SessionState {
try { try {
if (carry != null) await _resume(carry); if (carry != null) await _resume(carry);
} catch (e) { } catch (e) {
coordinator.showToast("couldn't put it up again: $e"); coordinator.notify(ActionFailed("couldn't put it up again: $e"));
} }
} finally { } finally {
switching.value = false; switching.value = false;
@@ -341,7 +355,7 @@ class SessionState {
}); });
try { try {
await landed.future.timeout(const Duration(seconds: 20)); await landed.future.timeout(const Duration(seconds: 20));
coordinator.sync.sendSeek(carry.position); coordinator.connection.sendSeek(carry.position);
} on TimeoutException { } on TimeoutException {
debugPrint('resume: the media did not come up; not seeking'); debugPrint('resume: the media did not come up; not seeking');
} finally { } finally {
@@ -502,8 +516,10 @@ class SessionState {
await server?.stop(); await server?.stop();
} }
Future<void> leave() async { /// Out of the session (Leave, the app closing). [bye] false: Update
await coordinator.leave(); /// and restart, the restarted app takes the member back within its grace.
Future<void> leave({bool bye = true}) async {
await coordinator.leave(bye: bye);
await stopServer(); await stopServer();
solo.value = false; solo.value = false;
coordinator.solo = false; coordinator.solo = false;
+83 -24
View File
@@ -1,8 +1,12 @@
import 'dart:io' show Platform;
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:hive_ce/hive.dart'; import 'package:hive_ce/hive.dart';
import 'package:signals/signals_flutter.dart'; import 'package:signals/signals_flutter.dart';
import '../controls/model/controls_prefs.dart';
import '../shaders/shader_presets.dart'; import '../shaders/shader_presets.dart';
import 'settings_schema.dart';
// App preferences (Settings page), one Hive box. Signals start at their // App preferences (Settings page), one Hive box. Signals start at their
// defaults and take the stored values once [AppSettings.ensureLoaded] // defaults and take the stored values once [AppSettings.ensureLoaded]
@@ -17,7 +21,14 @@ const _keyShaderPreset = 'shaderPreset';
const _keyShaderQuality = 'shaderQuality'; const _keyShaderQuality = 'shaderQuality';
const _keyCustomShaders = 'customShaders'; const _keyCustomShaders = 'customShaders';
const _keyAutoAdvance = 'autoAdvance'; const _keyAutoAdvance = 'autoAdvance';
/// Legacy: true was the uosc design (now [ControlsSkin.minimal]); read
/// once when [_keyControlsSkin] is missing.
const _keyMpvControls = 'mpvControls'; const _keyMpvControls = 'mpvControls';
const _keyControlsSkin = 'controlsSkin';
const _keyTimeDisplay = 'timeDisplay';
const _keyControlsSize = 'controlsSize';
const _keyControlsHideSecs = 'controlsHideSecs';
const _keyHiddenControls = 'hiddenControls';
const _keyHideReReleases = 'hideReReleases'; const _keyHideReReleases = 'hideReReleases';
const _keyProgressLine = 'progressLine'; const _keyProgressLine = 'progressLine';
const _keySoundVolume = 'soundVolume'; const _keySoundVolume = 'soundVolume';
@@ -26,12 +37,18 @@ const _keySoundPreset = 'soundPreset';
const _keyWelcomeDismissed = 'welcomeDismissed'; const _keyWelcomeDismissed = 'welcomeDismissed';
const _keySoundPresetFor = 'soundPresetFor'; const _keySoundPresetFor = 'soundPresetFor';
const _keyUnreadyOnSwap = 'unreadyOnSwap'; const _keyUnreadyOnSwap = 'unreadyOnSwap';
const _keyIdleSitsOut = 'idleSitsOut'; const keyAutoSitOut = 'autoSitOut';
const _keyChatPopups = 'chatPopups'; const _keyChatPopups = 'chatPopups';
const _keyShareDiagnostics = 'shareDiagnostics'; const _keyShareDiagnostics = 'shareDiagnostics';
enum HwdecMode { auto, off, custom } enum HwdecMode { auto, off, custom }
/// 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
/// surface there ("Failed to create EGL surface"). Elsewhere 'auto'.
String get autoHwdec => Platform.isWindows ? 'auto-copy' : 'auto';
/// When chat messages pop up as toasts (they always reach the chat). /// When chat messages pop up as toasts (they always reach the chat).
enum ChatPopups { enum ChatPopups {
all('All'), all('All'),
@@ -60,22 +77,29 @@ class AppSettings {
/// Session: send ready=false when the video changes (a swap keeps the /// Session: send ready=false when the video changes (a swap keeps the
/// server's ready flags). /// server's ready flags).
final unreadyOnSwap = signal(false); final unreadyOnSwap = signal(false);
/// Session: the idle auto-unready sits this member out instead. /// Session: auto sit-out (idle while nothing plays, or minimised for a
final idleSitsOut = signal(false); /// while, sets you away; coming back sets you in).
/// Player design: the uosc controls (`controls/uosc/`) instead of the final autoSitOut = signal(true);
/// standard ones (media_kit's with our bars). Applies live. /// The player controls' skin (`controls/`). Applies live.
final mpvControls = signal(false); final controlsSkin = signal(ControlsSkin.standard);
/// The bar's clock: elapsed / total or the time left.
final timeDisplay = signal(TimeDisplay.elapsed);
final controlsSize = signal(ControlsSize.normal);
/// Seconds without mouse movement before the controls hide.
final controlsHideSecs = signal(defaultControlsHideSecs);
/// Optional bar buttons switched off (they stay in ⚙).
final hiddenControls = signal<Set<OptionalControl>>(const {});
/// Library: leave out of "Latest in X" what was added long after it /// Library: leave out of "Latest in X" what was added long after it
/// first aired (re-uploads, old seasons added late). /// first aired (re-uploads, old seasons added late).
final hideReReleases = signal(true); final hideReReleases = signal(true);
/// A 2 px progress line at the bottom of the video while the controls /// A 2 px progress line at the bottom of the video while the controls
/// are hidden (both designs). Applies live. /// are hidden (both skins). Applies live.
final progressLine = signal(true); final progressLine = signal(true);
/// Send this app's log lines to the server's session log while in a /// Send this app's log lines to the server's session log while in a
/// session (never chat). Off: nothing is sent but a note that it is off. /// session (never chat). Off: nothing is sent but a note that it is off.
final shareDiagnostics = signal(true); final shareDiagnostics = signal(true);
/// Chat message toasts (default: only when paused). Applies live. /// Chat message toasts (default: all). Applies live.
final chatPopups = signal(ChatPopups.paused); final chatPopups = signal(ChatPopups.all);
/// App sounds (`sfx/`): 0..1, 0 = silent. /// App sounds (`sfx/`): 0..1, 0 = silent.
final soundVolume = signal(1.0); final soundVolume = signal(1.0);
/// `SfxKind` names switched off one by one. /// `SfxKind` names switched off one by one.
@@ -95,13 +119,13 @@ class AppSettings {
/// .glsl files of [ShaderPreset.custom], in mpv order. /// .glsl files of [ShaderPreset.custom], in mpv order.
final customShaders = signal<List<String>>(const []); final customShaders = signal<List<String>>(const []);
/// mpv `hwdec` value: 'auto', 'no', or the custom value ('auto' when /// mpv `hwdec` value: [autoHwdec], 'no', or the custom value
/// custom is blank). /// ([autoHwdec] when custom is blank).
late final hwdec = computed(() => switch (hwdecMode.value) { late final hwdec = computed(() => switch (hwdecMode.value) {
HwdecMode.auto => 'auto', HwdecMode.auto => autoHwdec,
HwdecMode.off => 'no', HwdecMode.off => 'no',
HwdecMode.custom => HwdecMode.custom =>
hwdecCustom.value.trim().isEmpty ? 'auto' : hwdecCustom.value.trim(), hwdecCustom.value.trim().isEmpty ? autoHwdec : hwdecCustom.value.trim(),
}); });
Future<void>? _load; Future<void>? _load;
@@ -112,6 +136,9 @@ class AppSettings {
Future<void> _loadNow() async { Future<void> _loadNow() async {
try { try {
final box = _box = await Hive.openBox(settingsBoxName); final box = _box = await Hive.openBox(settingsBoxName);
for (final line in await migrateSettings(box)) {
debugPrint(line);
}
final mode = box.get(_keyHwdecMode); final mode = box.get(_keyHwdecMode);
hwdecMode.value = HwdecMode.values hwdecMode.value = HwdecMode.values
.where((m) => m.name == mode) .where((m) => m.name == mode)
@@ -125,10 +152,20 @@ class AppSettings {
if (advance is bool) autoAdvance.value = advance; if (advance is bool) autoAdvance.value = advance;
final unready = box.get(_keyUnreadyOnSwap); final unready = box.get(_keyUnreadyOnSwap);
if (unready is bool) unreadyOnSwap.value = unready; if (unready is bool) unreadyOnSwap.value = unready;
final idleOut = box.get(_keyIdleSitsOut); final autoOut = box.get(keyAutoSitOut);
if (idleOut is bool) idleSitsOut.value = idleOut; if (autoOut is bool) autoSitOut.value = autoOut;
final mpv = box.get(_keyMpvControls); controlsSkin.value =
if (mpv is bool) mpvControls.value = mpv; ControlsSkin.fromStored(box.get(_keyControlsSkin), legacyMpvControls: box.get(_keyMpvControls));
final time = box.get(_keyTimeDisplay);
timeDisplay.value = TimeDisplay.values.where((t) => t.name == time).firstOrNull ?? TimeDisplay.elapsed;
final size = box.get(_keyControlsSize);
controlsSize.value = ControlsSize.values.where((t) => t.name == size).firstOrNull ?? ControlsSize.normal;
final hide = box.get(_keyControlsHideSecs);
if (hide is int && controlsHideChoices.contains(hide)) controlsHideSecs.value = hide;
final hidden = box.get(_keyHiddenControls);
if (hidden is List) {
hiddenControls.value = Set.unmodifiable(OptionalControl.values.where((c) => hidden.contains(c.name)));
}
final hideRe = box.get(_keyHideReReleases); final hideRe = box.get(_keyHideReReleases);
if (hideRe is bool) hideReReleases.value = hideRe; if (hideRe is bool) hideReReleases.value = hideRe;
final line = box.get(_keyProgressLine); final line = box.get(_keyProgressLine);
@@ -136,7 +173,7 @@ class AppSettings {
final share = box.get(_keyShareDiagnostics); final share = box.get(_keyShareDiagnostics);
if (share is bool) shareDiagnostics.value = share; if (share is bool) shareDiagnostics.value = share;
final popups = box.get(_keyChatPopups); final popups = box.get(_keyChatPopups);
chatPopups.value = ChatPopups.values.where((m) => m.name == popups).firstOrNull ?? ChatPopups.paused; chatPopups.value = ChatPopups.values.where((m) => m.name == popups).firstOrNull ?? ChatPopups.all;
final vol = box.get(_keySoundVolume); final vol = box.get(_keySoundVolume);
if (vol is num) soundVolume.value = vol.toDouble().clamp(0, 1); if (vol is num) soundVolume.value = vol.toDouble().clamp(0, 1);
final off = box.get(_keySoundsOff); final off = box.get(_keySoundsOff);
@@ -194,14 +231,36 @@ class AppSettings {
_put(_keyUnreadyOnSwap, on); _put(_keyUnreadyOnSwap, on);
} }
void setIdleSitsOut(bool on) { void setAutoSitOut(bool on) {
idleSitsOut.value = on; autoSitOut.value = on;
_put(_keyIdleSitsOut, on); _put(keyAutoSitOut, on);
} }
void setMpvControls(bool on) { void setControlsSkin(ControlsSkin skin) {
mpvControls.value = on; controlsSkin.value = skin;
_put(_keyMpvControls, on); _put(_keyControlsSkin, skin.name);
}
void setTimeDisplay(TimeDisplay t) {
timeDisplay.value = t;
_put(_keyTimeDisplay, t.name);
}
void setControlsSize(ControlsSize size) {
controlsSize.value = size;
_put(_keyControlsSize, size.name);
}
void setControlsHideSecs(int secs) {
controlsHideSecs.value = secs;
_put(_keyControlsHideSecs, secs);
}
void setControlShown(OptionalControl c, bool on) {
final next = {...hiddenControls.value};
on ? next.remove(c) : next.add(c);
hiddenControls.value = Set.unmodifiable(next);
_put(_keyHiddenControls, [for (final c in next) c.name]);
} }
void setProgressLine(bool on) { void setProgressLine(bool on) {
@@ -0,0 +1,57 @@
import 'package:hive_ce/hive.dart';
// The `settings` box's layout version and the ordered steps between
// versions. A step rewrites stored values for a new layout (renamed keys,
// changed formats); the restructure steps add theirs here.
/// The layout this build reads. 1: the layout of 1.2.x, where versioning
/// starts (a box without a version is at 1). 2: auto sit-out replaces the
/// idle switch.
const settingsSchemaVersion = 2;
/// The box key holding the stored version.
const settingsSchemaKey = 'schemaVersion';
/// One step: brings a box at `to - 1` to [to].
class SettingsMigration {
final int to;
/// For the log ("settings schema 2: ...").
final String what;
final Future<void> Function(Box box) apply;
const SettingsMigration(this.to, this.what, this.apply);
}
/// Every step in order of [SettingsMigration.to] (2, 3, ...).
const settingsMigrations = <SettingsMigration>[
SettingsMigration(2, 'auto sit-out replaces "when idle, sit out instead of just unready"', _autoSitOut),
];
/// 2: `idleSitsOut` only chose between two automatic steps after 10 idle
/// minutes (unready, or sit out); both are auto sit-out now, on unless
/// switched off (`autoSitOut`).
Future<void> _autoSitOut(Box box) async {
if (!box.containsKey('idleSitsOut')) return;
await box.delete('idleSitsOut');
if (!box.containsKey('autoSitOut')) await box.put('autoSitOut', true);
}
/// Runs the [steps] past the box's stored version up to [current], in
/// order, storing the version after each, then stores [current]. A box
/// from a newer build (a downgrade) is left as it is. Returns a log line
/// per step run.
Future<List<String>> migrateSettings(Box box,
{List<SettingsMigration> steps = settingsMigrations, int current = settingsSchemaVersion}) async {
final stored = box.get(settingsSchemaKey);
var at = stored is int ? stored : 1;
if (at > current) return ['settings schema $at is newer than this build ($current): left as is'];
final ran = <String>[];
for (final step in [...steps]..sort((a, b) => a.to.compareTo(b.to))) {
if (step.to <= at || step.to > current) continue;
await step.apply(box);
at = step.to;
await box.put(settingsSchemaKey, at);
ran.add('settings schema ${step.to}: ${step.what}');
}
if (stored != current) await box.put(settingsSchemaKey, current);
return ran;
}
+144 -40
View File
@@ -6,6 +6,31 @@ import 'package:web_socket_channel/web_socket_channel.dart';
enum SyncStatus { idle, connecting, open, closed, error } enum SyncStatus { idle, connecting, open, closed, error }
/// The server refused the hello: its `err` ([error]; a proto 15 server's
/// has no code), or the socket closed before a welcome with [closeCode].
class HelloRefused implements Exception {
final ErrorFrame? error;
final int? closeCode;
const HelloRefused({this.error, this.closeCode});
/// The refusal's code: the frame's, else read from the close code.
ErrCode? get code =>
error?.code ??
switch (closeCode) {
CloseCodes.version => ErrCode.version,
CloseCodes.secret => ErrCode.secret,
CloseCodes.replaced => ErrCode.replaced,
CloseCodes.stale => ErrCode.stale,
_ => null,
};
@override
String toString() => error?.message ?? 'closed before welcome ($closeCode)';
}
/// One `/sync` socket at a time: the hello and welcome, the frames as
/// streams, the sends. [token], [memberId] and [lastState] outlive a
/// dropped socket ([drop]); only [disconnect] forgets them.
class SyncClient { class SyncClient {
/// Local clock; injectable so tests can run a client whose clock is off. /// Local clock; injectable so tests can run a client whose clock is off.
final DateTime Function() _clock; final DateTime Function() _clock;
@@ -21,19 +46,27 @@ class SyncClient {
WebSocketChannel? _channel; WebSocketChannel? _channel;
StreamSubscription? _sub; StreamSubscription? _sub;
Completer<void>? _socketDone;
final StreamController<StateBroadcast> _states = StreamController.broadcast(); final StreamController<StateBroadcast> _states = StreamController.broadcast();
final StreamController<FeedMessage> _feeds = StreamController.broadcast(); final StreamController<FeedMessage> _feeds = StreamController.broadcast();
final StreamController<NudgeNotice> _nudges = StreamController.broadcast(); final StreamController<NudgeNotice> _nudges = StreamController.broadcast();
final StreamController<String> _errors = StreamController.broadcast(); final StreamController<ErrorFrame> _errors = StreamController.broadcast();
final StreamController<SyncStatus> _status = StreamController.broadcast(); final StreamController<SyncStatus> _status = StreamController.broadcast();
Stream<StateBroadcast> get states => _states.stream; Stream<StateBroadcast> get states => _states.stream;
/// Feed lines (chat included, with its text), and after a welcome the
/// lines it replayed (`replayed`).
Stream<FeedMessage> get feeds => _feeds.stream; Stream<FeedMessage> get feeds => _feeds.stream;
/// Someone poked this member (`nudge`). /// Someone poked this member (`nudge`).
Stream<NudgeNotice> get nudges => _nudges.stream; Stream<NudgeNotice> get nudges => _nudges.stream;
Stream<String> get errors => _errors.stream; /// `err` frames after the welcome.
Stream<ErrorFrame> get errors => _errors.stream;
Stream<SyncStatus> get status => _status.stream; Stream<SyncStatus> get status => _status.stream;
/// Called on every frame from the server (the welcome included), before
/// it is handled: the connection's silence clock.
void Function()? onFrame;
/// The `/sync` URL last dialled; its host serves `/a/...` and the media. /// The `/sync` URL last dialled; its host serves `/a/...` and the media.
Uri? wsUri; Uri? wsUri;
String token = ''; String token = '';
@@ -43,24 +76,51 @@ class SyncClient {
/// relays); private like [token]. /// relays); private like [token].
String? relayUrl; String? relayUrl;
StateBroadcast? lastState; StateBroadcast? lastState;
/// `welcome.instance` of the server last joined: a welcome of another
/// one is a restarted (or another) server.
String instance = '';
/// The highest `feed.seq` seen from [instance] (`hello.feedAfter`).
int lastFeedSeq = 0;
/// The close code of the last socket that closed (null: none given).
int? closeCode;
Completer<WelcomeMessage>? _pendingWelcome; Completer<WelcomeMessage>? _pendingWelcome;
/// A plain hello (tools and tests): [client] / [seq] as given.
Future<WelcomeMessage> connect({ Future<WelcomeMessage> connect({
required Uri wsUri, required Uri wsUri,
required String secret, required String secret,
required String name, required String name,
String avatar = '', String avatar = '',
HelloMedia? media,
String hostKey = '', String hostKey = '',
String resume = '', String client = '',
}) async { int seq = 0,
await disconnect(); }) =>
connectWith(
wsUri: wsUri,
hello: HelloMessage(
secret: secret,
user: HelloUser(name: name, avatar: avatar),
hostKey: hostKey,
client: client,
seq: seq,
),
);
/// Dials [wsUri], sends [hello] and waits for the welcome (10 s). A
/// socket still open is dropped first. [onWelcome] runs once the welcome
/// is read, before its state and replayed feed go out on the streams.
/// Throws [HelloRefused] when the server refuses.
Future<WelcomeMessage> connectWith(
{required Uri wsUri, required HelloMessage hello, void Function(WelcomeMessage welcome)? onWelcome}) async {
await drop();
clockSync.reset(); clockSync.reset();
closeCode = null;
this.wsUri = wsUri; this.wsUri = wsUri;
_status.add(SyncStatus.connecting); _status.add(SyncStatus.connecting);
final ch = WebSocketChannel.connect(wsUri); final ch = WebSocketChannel.connect(wsUri);
unawaited(ch.ready.then((_) {}, onError: (_) {})); unawaited(ch.ready.then((_) {}, onError: (_) {}));
_channel = ch; _channel = ch;
final done = _socketDone = Completer<void>();
final welcomeCompleter = Completer<WelcomeMessage>(); final welcomeCompleter = Completer<WelcomeMessage>();
_sub = ch.stream.listen( _sub = ch.stream.listen(
(raw) => _onText('$raw', welcomeCompleter), (raw) => _onText('$raw', welcomeCompleter),
@@ -71,21 +131,17 @@ class SyncClient {
_status.add(SyncStatus.error); _status.add(SyncStatus.error);
}, },
onDone: () { onDone: () {
closeCode = ch.closeCode;
if (!done.isCompleted) done.complete();
if (!welcomeCompleter.isCompleted) { if (!welcomeCompleter.isCompleted) {
welcomeCompleter.completeError(StateError('closed before welcome')); welcomeCompleter.completeError(ch.closeCode == null
? StateError('closed before welcome')
: HelloRefused(closeCode: ch.closeCode));
} }
_status.add(SyncStatus.closed); _status.add(SyncStatus.closed);
}, },
cancelOnError: false, cancelOnError: false,
); );
final hello = HelloMessage(
proto: RelayConstants.protoVersion,
secret: secret,
user: HelloUser(name: name, avatar: avatar),
media: media ?? const HelloMedia(name: '', size: 0, duration: 0),
hostKey: hostKey,
resume: resume,
);
ch.sink.add(jsonEncode(hello.toJson())); ch.sink.add(jsonEncode(hello.toJson()));
_pendingWelcome = welcomeCompleter; _pendingWelcome = welcomeCompleter;
try { try {
@@ -93,28 +149,47 @@ class SyncClient {
const Duration(seconds: 10), const Duration(seconds: 10),
); );
_pendingWelcome = null; _pendingWelcome = null;
final sameServer = instance.isNotEmpty && welcome.instance == instance;
token = welcome.token; token = welcome.token;
memberId = welcome.memberId; memberId = welcome.memberId;
mediaUrl = welcome.mediaUrl; mediaUrl = welcome.mediaUrl;
relayUrl = welcome.relayUrl; relayUrl = welcome.relayUrl;
instance = welcome.instance;
// Another server's lines count from 1 again: its replay is not ours.
if (!sameServer) lastFeedSeq = 0;
onWelcome?.call(welcome);
if (welcome.state != null) { if (welcome.state != null) {
try { try {
lastState = StateBroadcast.fromJson(welcome.state!); lastState = StateBroadcast.fromJson(welcome.state!);
_states.add(lastState!); _states.add(lastState!);
} catch (_) {} } catch (_) {}
} }
if (sameServer) {
for (final f in welcome.feed) {
_addFeed(f.copyWith(replayed: true));
}
}
_status.add(SyncStatus.open); _status.add(SyncStatus.open);
sendPing(); sendPing();
return welcome; return welcome;
} catch (e) { } catch (e) {
_pendingWelcome = null; _pendingWelcome = null;
await disconnect(); await drop();
_status.add(SyncStatus.error); _status.add(SyncStatus.error);
rethrow; rethrow;
} }
} }
void _addFeed(FeedMessage f) {
if (f.seq > lastFeedSeq) lastFeedSeq = f.seq;
// The server delivers every chat twice: a `chat` frame carrying the
// text (turned into a feed line below) and a textless feed line. Keep
// only the former: one send, one row, one toast, one sound.
if (f.action != 'chat') _feeds.add(f);
}
void _onText(String raw, Completer<WelcomeMessage> welcomeCompleter) { void _onText(String raw, Completer<WelcomeMessage> welcomeCompleter) {
onFrame?.call();
Map<String, Object?> decoded; Map<String, Object?> decoded;
try { try {
final parsed = jsonDecode(raw); final parsed = jsonDecode(raw);
@@ -132,9 +207,13 @@ class SyncClient {
welcomeCompleter.completeError(e); welcomeCompleter.completeError(e);
} }
} else if (t == 'err') { } else if (t == 'err') {
welcomeCompleter.completeError( ErrorFrame frame;
ProtocolException('${decoded['message'] ?? 'rejected'}'), try {
); frame = ErrorFrame.fromJson(decoded);
} catch (_) {
frame = const ErrorFrame(code: null, message: 'rejected');
}
welcomeCompleter.completeError(HelloRefused(error: frame));
} }
return; return;
} }
@@ -147,12 +226,7 @@ class SyncClient {
} catch (_) {} } catch (_) {}
case 'feed': case 'feed':
try { try {
final f = FeedMessage.fromJson(decoded); _addFeed(FeedMessage.fromJson(decoded));
// Server delivers every chat twice: a ChatMessage carrying the
// text (converted to a feed entry in `case 'chat'` below) and a
// textless feed entry. Keep only the former: one send, one row,
// one toast, one sfx.
if (f.action != 'chat') _feeds.add(f);
} catch (_) {} } catch (_) {}
case 'nudge': case 'nudge':
try { try {
@@ -167,21 +241,26 @@ class SyncClient {
receivedMs: _clock().millisecondsSinceEpoch); receivedMs: _clock().millisecondsSinceEpoch);
} catch (_) {} } catch (_) {}
case 'err': case 'err':
_errors.add('${decoded['message'] ?? 'error'}');
case 'chat':
try { try {
_errors.add(ErrorFrame.fromJson(decoded));
} catch (_) {}
case 'chat':
_feeds.add( _feeds.add(
FeedMessage( FeedMessage(
actor: '${decoded['from'] ?? '?'}', actor: '${decoded['from'] ?? '?'}',
actorId: decoded['fromId'] is String ? decoded['fromId'] as String : '',
action: 'chat', action: 'chat',
reason: (decoded['text'] as String?) ?? '', reason: (decoded['text'] as String?) ?? '',
at: DateTime.now().toUtc(), at: DateTime.now().toUtc(),
), ),
); );
} catch (_) {}
} }
} }
/// A socket is open (it may be half-open: the connection's silence
/// check tells).
bool get isOpen => _channel != null;
void _send(Map<String, Object?> json) { void _send(Map<String, Object?> json) {
final ch = _channel; final ch = _channel;
if (ch == null) return; if (ch == null) return;
@@ -203,7 +282,8 @@ class SyncClient {
_send(SeekIntent(position: position, seq: seq).toJson()); _send(SeekIntent(position: position, seq: seq).toJson());
} }
/// One clock sample; the coordinator sends one per second. /// One clock sample; the connection sends one per second (it also keeps
/// this member under the server's silence limit).
void sendPing() { void sendPing() {
_send(PingMessage(clientMs: _clock().millisecondsSinceEpoch).toJson()); _send(PingMessage(clientMs: _clock().millisecondsSinceEpoch).toJson());
} }
@@ -259,34 +339,58 @@ class SyncClient {
_send(ArtMessage(mediaId: mediaId, poster: poster, banner: banner).toJson()); _send(ArtMessage(mediaId: mediaId, poster: poster, banner: banner).toJson());
} }
/// Sits the member [target] out, or with [out] false brings this /// Sits someone out, or brings this member back in (see [PresenceIntent]).
/// member back (only oneself can come back). void sendPresence(PresenceIntent intent) {
void sendOut(String target, bool out) { _send(intent.toJson());
_send(OutIntent(target: target, out: out).toJson());
} }
void sendNudge(String target) { void sendNudge(String target) {
_send(NudgeIntent(target: target).toJson()); _send(NudgeIntent(target: target).toJson());
} }
Future<void> disconnect() async { /// `bye`: this member leaves now. Waits (at most [wait]) for the server
/// to close the socket, so the frame went out before ours closes.
Future<void> sendBye({Duration wait = const Duration(milliseconds: 600)}) async {
final done = _socketDone;
if (_channel == null || done == null) return;
_send(const ByeMessage().toJson());
try {
await done.future.timeout(wait);
} catch (_) {}
}
/// Closes the socket (no [SyncStatus] for it) and keeps who this member
/// is ([token], [memberId], [lastState], [instance]): a reconnect takes
/// it over. The close is not waited for (a half-open link never
/// answers it).
Future<void> drop() async {
final pending = _pendingWelcome; final pending = _pendingWelcome;
_pendingWelcome = null; _pendingWelcome = null;
if (pending != null && !pending.isCompleted) { if (pending != null && !pending.isCompleted) {
pending.completeError(StateError('cancelled')); pending.completeError(StateError('cancelled'));
} try { }
await _sub?.cancel(); final sub = _sub;
} catch (_) {}
_sub = null; _sub = null;
try { final ch = _channel;
await _channel?.sink.close();
} catch (_) {}
_channel = null; _channel = null;
_socketDone = null;
try {
await sub?.cancel().timeout(const Duration(milliseconds: 500));
} catch (_) {}
if (ch != null) unawaited(ch.sink.close().timeout(const Duration(seconds: 2), onTimeout: () {}).catchError((_) {}));
}
/// [drop], then forgets the member and the server: the next connect is
/// a new session.
Future<void> disconnect() async {
await drop();
token = ''; token = '';
memberId = ''; memberId = '';
mediaUrl = null; mediaUrl = null;
relayUrl = null; relayUrl = null;
lastState = null; lastState = null;
instance = '';
lastFeedSeq = 0;
} }
Future<void> dispose() async { Future<void> dispose() async {
@@ -1,4 +1,7 @@
@echo off @echo off
rem Update this install in place (same as clicking Update in the app). rem Update this install in place (same as clicking Update in the app).
"%~dp0watch-together-server.exe" --upgrade %* rem The old server name is the fallback for installs from before the rename.
set "SERVER=%~dp0starsync-server.exe"
if not exist "%SERVER%" set "SERVER=%~dp0watch-together-server.exe"
"%SERVER%" --upgrade %*
pause pause
+4 -1
View File
@@ -1,4 +1,7 @@
#!/bin/sh #!/bin/sh
# Update this install in place (same as clicking Update in the app). # Update this install in place (same as clicking Update in the app).
# The server binary runs the updater: it needs no GTK/display (VPS). # The server binary runs the updater: it needs no GTK/display (VPS).
cd "$(dirname "$0")" && exec ./watch-together-server --upgrade "$@" # The old server name is the fallback for installs from before the rename.
cd "$(dirname "$0")" || exit 1
if [ -x ./starsync-server ]; then exec ./starsync-server --upgrade "$@"; fi
exec ./watch-together-server --upgrade "$@"
+4
View File
@@ -0,0 +1,4 @@
#!/bin/sh
# The dedicated server's name up to 1.2.x: systemd units (ExecStart) and
# old update.sh copies run it.
exec "$(dirname "$(readlink -f "$0")")/starsync-server" "$@"
+4
View File
@@ -0,0 +1,4 @@
#!/bin/sh
# Starsync's name up to 1.2.x: their updater restarts it after updating to
# this release, and launchers or scripts may still point at it.
exec "$(dirname "$(readlink -f "$0")")/starsync" "$@"
@@ -4,7 +4,9 @@ import 'package:flutter/foundation.dart';
import 'package:hive_ce_flutter/hive_flutter.dart'; import 'package:hive_ce_flutter/hive_flutter.dart';
import 'package:sync_relay_core/core.dart'; import 'package:sync_relay_core/core.dart';
// In-app update flow behind the Home update indicator. Same code path as import '../platform/app_shutdown.dart';
// In-app update flow behind the update bar (UpdateBar). Same code path as
// `--upgrade` (core's updater, downloadAndApply); this adds state for the // `--upgrade` (core's updater, downloadAndApply); this adds state for the
// UI and the restart. Check failures are logged only — the banner shows up just when // UI and the restart. Check failures are logged only — the banner shows up just when
// there is something to install. // there is something to install.
@@ -28,26 +30,23 @@ class UpdateStatus {
// Process args as main() received them — restart relaunches with these. // Process args as main() received them — restart relaunches with these.
List<String> launchArgs = const []; List<String> launchArgs = const [];
// Relaunch the (now replaced) binary detached with the same args, then
// exit this process.
Future<Never> restartApp(String executable, List<String> args) async {
final keep = args.where((a) => a != '--upgrade').toList();
await Process.start(executable, keep,
mode: ProcessStartMode.detached,
workingDirectory: Directory.current.path);
exit(0);
}
typedef RestartFn = Future<void> Function(String executable, List<String> args); typedef RestartFn = Future<void> Function(String executable, List<String> args);
// Release Hive's box locks before the new instance opens the same boxes. // The orderly close (players, leave), Hive's box locks released before
// the new instance opens the same boxes, the (now replaced) binary
// relaunched detached with the same args, then the window closes.
Future<void> _closeAndRestart(String executable, List<String> args) async { Future<void> _closeAndRestart(String executable, List<String> args) async {
final keep = args.where((a) => a != '--upgrade').toList();
await shutDownApp(restart: true, beforeQuit: () async {
try { try {
await Hive.close(); await Hive.close();
} catch (e) { } catch (e) {
_log.warning('closing hive before restart: $e'); _log.warning('closing hive before restart: $e');
} }
await restartApp(executable, args); await Process.start(executable, keep,
mode: ProcessStartMode.detached,
workingDirectory: Directory.current.path);
});
} }
class UpdateController { class UpdateController {
@@ -115,15 +114,22 @@ class UpdateController {
return; return;
} }
_log.info('update available: ${m.version} (proto ${m.protocol})'); _log.info('update available: ${m.version} (proto ${m.protocol})');
lastCheck.value = 'Update v${m.version} available';
// A re-check never cuts into an install under way.
if (_installing) return;
status.value = UpdateStatus(UpdatePhase.available, status.value = UpdateStatus(UpdatePhase.available,
manifest: m, canInstall: root != null); manifest: m, canInstall: root != null);
lastCheck.value = 'Update v${m.version} available';
} catch (e) { } catch (e) {
_log.warning('update check failed: $e'); _log.warning('update check failed: $e');
lastCheck.value = 'Update check failed: $e'; lastCheck.value = 'Update check failed: $e';
} }
} }
bool get _installing => switch (status.value.phase) {
UpdatePhase.downloading || UpdatePhase.applying || UpdatePhase.restarting => true,
_ => false,
};
void dismiss() { void dismiss() {
final s = status.value; final s = status.value;
if ((s.phase == UpdatePhase.available || s.phase == UpdatePhase.failed) && if ((s.phase == UpdatePhase.available || s.phase == UpdatePhase.failed) &&
@@ -167,6 +173,8 @@ class UpdateController {
_log.info('updated to ${m.version}, restarting'); _log.info('updated to ${m.version}, restarting');
status.value = status.value =
UpdateStatus(UpdatePhase.restarting, manifest: m, canInstall: true); UpdateStatus(UpdatePhase.restarting, manifest: m, canInstall: true);
await restart(_exe, launchArgs); // Started by an old name (1.2.x's restart, an old launcher): the new
// binary next to it.
await restart(relaunchExecutable(_exe), launchArgs);
} }
} }
+3 -4
View File
@@ -4,7 +4,7 @@ import 'dart:ui' show ImageFilter;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import '../controls/uosc/controls.dart'; import '../controls/frame.dart';
import '../shell/motion.dart'; import '../shell/motion.dart';
// The app's one menu: a frosted dark panel anchored under (or over) the // The app's one menu: a frosted dark panel anchored under (or over) the
@@ -158,9 +158,8 @@ Future<int?> _openMenu(BuildContext context, List<AppMenuItem<Object?>> items, d
minWidth: minWidth - shift, minWidth: minWidth - shift,
capturedThemes: InheritedTheme.capture(from: context, to: navigator.context), capturedThemes: InheritedTheme.capture(from: context, to: navigator.context),
)); ));
// Over the uosc controls: keep them up while the menu is open. // Over the player's controls: keep them up while the menu is open.
final controls = UoscControls.maybeOf(context); return ControlsFrame.holdWhile(context, open);
return controls == null ? open() : controls.holdDuring(open);
} }
class _AppMenuRoute extends PopupRoute<int> { class _AppMenuRoute extends PopupRoute<int> {
+7 -26
View File
@@ -1,8 +1,7 @@
import 'dart:io';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import '../session/notifier.dart' show AppEvent, Copied, CopyFailed, noticeFor;
import '../state/session_state.dart'; import '../state/session_state.dart';
// Reusing what the app shows elsewhere: copying a link or a title, opening // Reusing what the app shows elsewhere: copying a link or a title, opening
@@ -22,28 +21,10 @@ abstract final class ReuseText {
@visibleForTesting @visibleForTesting
void Function(String text)? debugReuseToast; void Function(String text)? debugReuseToast;
void _toast(String text) { void _notify(AppEvent event) {
final debug = debugReuseToast; final debug = debugReuseToast;
if (debug != null) return debug(text); if (debug != null) return debug(noticeFor(event).toast ?? '');
SessionState.instance.coordinator.showToast(text); SessionState.instance.coordinator.notify(event);
}
/// Opens [url] in the default browser (no url_launcher dependency); false
/// when no browser could be started.
Future<bool> openInBrowser(String url) async {
try {
final ProcessResult r;
if (Platform.isWindows) {
r = await Process.run('rundll32', ['url.dll,FileProtocolHandler', url]);
} else if (Platform.isMacOS) {
r = await Process.run('open', [url]);
} else {
r = await Process.run('xdg-open', [url]);
}
return r.exitCode == 0;
} catch (_) {
return false;
}
} }
/// Puts [text] on the clipboard and confirms it with a toast ([done]: /// Puts [text] on the clipboard and confirms it with a toast ([done]:
@@ -52,15 +33,15 @@ Future<void> copyToClipboard(String text, {String done = 'Copied'}) async {
try { try {
await Clipboard.setData(ClipboardData(text: text)); await Clipboard.setData(ClipboardData(text: text));
} catch (_) { } catch (_) {
_toast("Couldn't copy"); _notify(const CopyFailed());
return; return;
} }
_toast(done); _notify(Copied(done));
} }
/// Opens [url] in the browser; when none opens, copies it instead and /// Opens [url] in the browser; when none opens, copies it instead and
/// says so. /// says so.
Future<void> openLink(String url) async { Future<void> openLink(String url) async {
if (await openInBrowser(url)) return; if (await SessionState.instance.ports.urls.open(url)) return;
await copyToClipboard(url, done: "Couldn't open a browser — link copied"); await copyToClipboard(url, done: "Couldn't open a browser — link copied");
} }
@@ -3,7 +3,7 @@ import 'package:sync_relay_core/core.dart';
import '../update/update_controller.dart'; import '../update/update_controller.dart';
// "Update available" card (Home update indicator dialog). Kicks off the one // "Update available" card (Settings → Show update dialog). Kicks off the one
// background check on first build; renders nothing until there is an // background check on first build; renders nothing until there is an
// update to offer. Never shown over playback. // update to offer. Never shown over playback.
class UpdateBanner extends StatefulWidget { class UpdateBanner extends StatefulWidget {
@@ -1,5 +1,4 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:window_manager/window_manager.dart';
import '../state/session_state.dart'; import '../state/session_state.dart';
@@ -18,43 +17,37 @@ class WindowButtons extends StatefulWidget {
State<WindowButtons> createState() => _WindowButtonsState(); State<WindowButtons> createState() => _WindowButtonsState();
} }
class _WindowButtonsState extends State<WindowButtons> with WindowListener { class _WindowButtonsState extends State<WindowButtons> {
final _shell = SessionState.instance.ports.shell;
bool _maximized = false; bool _maximized = false;
late final void Function() _stopWatch;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
windowManager.addListener(this); _stopWatch = _shell.watchMaximized((v) {
windowManager.isMaximized().then((v) { if (mounted) setState(() => _maximized = v);
});
_shell.isMaximized().then((v) {
if (mounted) setState(() => _maximized = v); if (mounted) setState(() => _maximized = v);
}); });
} }
@override @override
void dispose() { void dispose() {
windowManager.removeListener(this); _stopWatch();
super.dispose(); super.dispose();
} }
@override
void onWindowMaximize() {
if (mounted) setState(() => _maximized = true);
}
@override
void onWindowUnmaximize() {
if (mounted) setState(() => _maximized = false);
}
Future<void> _toggleMaximize() async { Future<void> _toggleMaximize() async {
if (widget.onToggleFullscreen != null) { if (widget.onToggleFullscreen != null) {
widget.onToggleFullscreen!(); widget.onToggleFullscreen!();
return; return;
} }
if (await windowManager.isMaximized()) { if (await _shell.isMaximized()) {
await windowManager.unmaximize(); await _shell.unmaximize();
} else { } else {
await windowManager.maximize(); await _shell.maximize();
} }
} }
@@ -63,7 +56,7 @@ class _WindowButtonsState extends State<WindowButtons> with WindowListener {
await SessionState.instance.coordinator.leave(); await SessionState.instance.coordinator.leave();
await SessionState.instance.stopServer(); await SessionState.instance.stopServer();
} catch (_) {} } catch (_) {}
await windowManager.close(); await _shell.requestClose();
} }
@override @override
@@ -77,7 +70,7 @@ class _WindowButtonsState extends State<WindowButtons> with WindowListener {
icon: Icons.remove, icon: Icons.remove,
tooltip: 'Minimize', tooltip: 'Minimize',
focusNode: focus, focusNode: focus,
onPressed: () => windowManager.minimize(), onPressed: () => _shell.minimize(),
), ),
_WinBtn( _WinBtn(
icon: restored ? Icons.filter_none : Icons.crop_square, icon: restored ? Icons.filter_none : Icons.crop_square,
@@ -131,3 +124,27 @@ class _WinBtn extends StatelessWidget {
); );
} }
} }
/// The title area: drag moves the window, a double click maximizes or
/// restores it (window_manager's DragToMoveArea, through [ShellPort]).
class WindowDragArea extends StatelessWidget {
final Widget child;
const WindowDragArea({super.key, required this.child});
@override
Widget build(BuildContext context) {
final shell = SessionState.instance.ports.shell;
return GestureDetector(
behavior: HitTestBehavior.translucent,
onPanStart: (_) => shell.startDragging(),
onDoubleTap: () async {
if (await shell.isMaximized()) {
await shell.unmaximize();
} else {
await shell.maximize();
}
},
child: child,
);
}
}
+2 -2
View File
@@ -4,10 +4,10 @@ project(runner LANGUAGES CXX)
# The name of the executable created for the application. Change this to change # The name of the executable created for the application. Change this to change
# the on-disk name of your application. # the on-disk name of your application.
set(BINARY_NAME "watch_together") set(BINARY_NAME "starsync")
# The unique GTK application identifier for this application. See: # The unique GTK application identifier for this application. See:
# https://wiki.gnome.org/HowDoI/ChooseApplicationID # https://wiki.gnome.org/HowDoI/ChooseApplicationID
set(APPLICATION_ID "moe.senkochan.watch_together") set(APPLICATION_ID "dev.foxyz.starsync")
# Explicitly opt in to modern CMake behaviors to avoid warnings with recent # Explicitly opt in to modern CMake behaviors to avoid warnings with recent
# versions of CMake. # versions of CMake.
Loaded 100 of 222 files, more files were not shown because too many files have changed in this diff. Show more