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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 02:04:55 +03:00
foxyz 8aa1186d29 Revert "Sounds: survive an audio device that crashes the app on open"
This reverts commit c91d57633f.
2026-10-17 02:04:33 +03:00
foxyz 49fee1ca19 Release v1.3.1
ci / updater-windows (push) Skipped
ci / core (push) Successful in 29s
ci / server (push) Failing after 40s
release / linux (push) Successful in 4m17s
release / windows (push) Successful in 6m18s
2026-10-17 00:56:17 +03:00
foxyzandClaude Opus 5.5 c91d57633f Sounds: survive an audio device that crashes the app on open
A Windows friend's 1.3.0 dies about a second in on every start, right
around the 2 s audio warm-up (no Dart error, no close lines). Opening the
device is now logged and leaves audio-init.pending until it returns; a
start that finds it skips sounds instead of crashing again, and a sound
preview in Settings tries once more.

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 22:52:52 +03:00
foxyzandClaude Opus 5.5 629be0d9ed Restructure step 2: Status as one computed level, the Notifier's real policy, system notifications
ci / core (push) Successful in 28s
ci / server (push) Successful in 1m11s
ci / updater-windows (push) Failing after 6m16s
- Status: computed from connection, presence, hold, relay, player facts;
  no setters (health writes and HealthStatus gone); pill, strip and
  source line read it; transitions go to the Notifier.
- Notifier: noticeFor(event, context) with self presence, alone, window
  activity, playing and prefs; toasts for joins/leaves/returns/swaps/
  Up next adds (with picture)/problems/chat, the rest logged; while
  sitting out only chat, nudges, starting, Up next (+ joins if chosen);
  alone no social notices; replayed lines logged; owns its toast timer.
- System notifications (flutter_local_notifications: D-Bus on Linux,
  WinRT on Windows) when the window isn't focused; a click focuses it.
- Toasts sit above the control bar and move with it.
- Versions: each member's in the members card and avatar tooltip,
  the server's in the card header; the strip no longer shows it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 22:11:08 +03:00
foxyzandClaude Opus 5.5 130c934b9b release scripts: read protoMax (protoVersion is no longer a literal)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 21:45:45 +03:00
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
foxyz bcc6d4bac8 Release v1.2.0
ci / core (push) Successful in 31s
ci / server (push) Successful in 1m4s
release / linux (push) Successful in 3m44s
ci / updater-windows (push) Successful in 11m38s
release / windows (push) Successful in 5m24s
2026-10-13 01:29:32 +03:00
foxyzandClaude Opus 5.5 4526fe86d9 release.sh: notes via git log -n (head broke the pipe under pipefail past 20 commits)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 01:29:32 +03:00
foxyzandClaude Opus 5.5 8e82a1f7be server: lock file catches up with core's version
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 01:29:14 +03:00
foxyzandClaude Opus 5.5 4d74cee67d leaf_widgets from Gitea (v0.1.0) instead of a local path, so CI can build
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 01:29:08 +03:00
foxyzandClaude Opus 5.5 d4952db497 dev_release.sh: --minor / --major start a new dev line, later runs follow it
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 01:21:46 +03:00
foxyzandClaude Opus 5.5 50b2b000a4 Links: yt-dlp's "No video formats found" reads as a sign-in/cookies hint
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 01:18:00 +03:00
foxyzandClaude Opus 5.5 197fe113a1 Jellyfin: a watched episode leaves Continue (resume point cleared); watched at 70 %, or reaching the ending or last 15 % after 40 %
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 01:11:35 +03:00
foxyzandClaude Opus 5.5 7487b4deb1 Protocol 15: one shared session log on the server, chat never logged, Mark a problem
Every app streams its redacted diagnostic lines (server time, batched
every 10 s, gzip, buffered while offline) to POST /l/; the server merges
them with its own into session-YYYY-MM-DD.log in time order, tagged by
who. Opt-out in Settings > About; Mark a problem (Ctrl+Shift+B, the
player menus, About) writes a marker with an optional note. Copy
diagnostics can include the server's session log. Chat text never
reaches any log.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 19:41:06 +03:00
foxyzandClaude Opus 5.5 4ab37c55cf Sessions leave redacted logs; viewers wait for a member relay's source before opening it
Logs: one redaction pass (query strings to names, member tokens,
passwords, cookies, auth headers) for every line; daily files with size
and age caps in the app (hosted server included) and the dedicated
server (--log-dir); swaps, relay channels, per-member /r /m /j outcomes,
server fetches, mpv load errors, yt-dlp runs. Settings > About: Copy
diagnostics, Open logs folder.

Bilibili through someone else: the viewer opened /r/ before the source
dialled in; the server holds that request and mpv gives up after 5 s
("could not be opened"). Viewers now open a member relay once it is
live, the putter starts its source before its own player, and track
sizes are read in parallel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 19:05:32 +03:00
foxyzandClaude Opus 5.5 572104f49e Schedule: "waiting · searched N min ago" and "release rejected" (with AnimeFlow's reason) from its new episode fields
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 18:55:56 +03:00
foxyzandClaude Opus 5.5 a407d3cbb0 Schedule: watched marks and progress on episodes, "waiting for release" instead of a Request for missing ones, air dates matched as Japan days
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 18:46:41 +03:00
foxyzandClaude Opus 5.5 1ec4fc8b13 Side-app use: copy/open links and titles everywhere, version on the Settings tab, known links preview instantly, Ctrl+V adds to Up next
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 20:50:49 +03:00
foxyzandClaude Opus 5.5 9572e8457d uosc timeline ported from uosc (hatching on the uncached part, line marker, remaining time, buffered label, diamonds); mouse wheel sets the volume anywhere on the video, with a volume readout
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 20:50:49 +03:00
foxyzandClaude Opus 5.5 d8585fb78e Protocol 14: a warm relay preloads relayed Up next items for everyone
The server keeps one small relay (32 MiB ahead, 8 behind) for Up next's
first relayed item, keyed by its queue id at a URL that stays the same
after the swap: fetched by the server for server-fetch links, fed by
the adder's app for through-me links and files, by a member with an
account for member-passed Jellyfin items. Every app preloads from it;
the swap promotes it with its buffer. Dropped when the item stops
being first or its adder leaves. Measured switch: ~1.6 s cold, 20-40 ms
preloaded. ROADMAP gains the research item on server-side fetching of
member-resolved URLs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 17:53:29 +03:00
foxyzandClaude Opus 5.5 e52a7633a0 A fresh player for every media change (the frozen frame after a swap needed Ctrl+R); promotion and fresh players share one hand-over
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 17:08:34 +03:00
foxyzandClaude Opus 5.5 c3fe8b682d dev_release.sh: retry the upload up to 3 times
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 16:30:00 +03:00
foxyzandClaude Opus 5.5 1508d2f804 Jellyfin rows reload after watching: the Stopped report clears the server's cached lists
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 16:24:38 +03:00
foxyzandClaude Opus 5.5 86eb30f498 Dev publish: new archive before deleting the old, manifest last (a failed upload no longer empties the dev release)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 16:21:05 +03:00
foxyzandClaude Opus 5.5 ddd7a21195 Protocol 13: Up next preloads, so switching to it is instant
Every app gets the first Up next item ready itself (own Jellyfin plan,
direct links, guests via /j when state.relayNext says the server's
login can pass it on) and opens it in a hidden, muted, paused second
player; the swap promotes that player instead of opening cold, so its
buffer counts at once. Reports go out on open and when the buffer
reaches the gate's need. SwitchTrace times each phase. Measured in the
e2e harness: 1.5-3 s before, 35-103 ms with a preload. Relayed items
still open cold for everyone but their source (ROADMAP).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 16:15:16 +03:00
foxyzandClaude Opus 5.5 4275abe470 Up next control: the next item's picture and title in the bar, a preview card on hover
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 15:42:28 +03:00
foxyzandClaude Opus 5.5 cf2805d3bd Menus: one weight, balanced padding, focus shown only for keys, picker text lines up with its button
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 15:36:51 +03:00
foxyzandClaude Opus 5.5 52f1c481a6 Window comes back at its last size, position and maximized state
Saved on resize, move, maximize and close; on start (and hot restart)
the normal size and maximized state come back everywhere, the position
on Windows and X11 when it still lands on a display (Wayland places
windows itself).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 15:29:23 +03:00
foxyzandClaude Opus 5.5 771b961b32 Player overlay as one piece, Up next always warm and on every source, Jellyfin never unmarks a watched episode
- Overlay: one layout for both designs (top bar with Up next as one of
  its buttons; one action band above the controls for the status pill,
  skips, next episode and end cards), one glass recipe (AppGlass) and
  one pill (FloatingPill); hovering any of them, or the open queue,
  holds the controls up.
- Up next: the first item (or the first continuation episode) is kept
  resolved on the app that puts it up, refreshed before it expires;
  Schedule and drop/paste can queue too.
- Jellyfin: WatchSession (core) counts real watched coverage; an
  episode is watched at 80 % or at its ending after half; a played one
  is re-marked after Playing (10.11 unmarks on open) and never gets a
  position; a peek leaves the old resume point; part-way resume points
  stay under 85 %.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 07:53:42 +03:00
foxyzandClaude Opus 5.5 6b10cae4e6 Protocol 12: Up next (a shared queue) and where the video comes from
Up next: a server-side queue anyone edits by item id (add at end or
next, move, remove, play now, skip); each item says who added it and
how it plays. At the real end the first item swaps in after the end
card's countdown, put up by whoever can (its adder for through-me links
and files, someone with access for Jellyfin, else anyone); items that
can't play are skipped with the reason. The next item's link is
resolved ahead. Greyed "Continues with" episodes below. A chip with the
controls opens it as a popover (leaf_widgets, local path dependency for
now); "Add to queue" next to Put it up everywhere.

Delivery: the server measures the source's rate, the media's bitrate
and each member's read rate; the members card shows a source line
(who passes it on, through what, keeping up or not, source left) and
each member's route and buffer. A slow source is told once per media.
A failed server fetch is announced to everyone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 06:40:26 +03:00
foxyzandClaude Opus 5.5 95cd668cfb One app menu style for every picker; links go Through me by default
- AppSelect / AppMenuButton (widgets/app_menu.dart): glass menu anchored
  to its button, check on the current item, keyboard and Esc; replaces
  the Material dropdowns in Settings, player settings, Jellyfin and the
  member menu. Material menus elsewhere get the same surface by theme.
- Link page: Through me is the main choice (you can't know which sites
  need cookies); Everyone / Server fetches it sit under "Other ways".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 05:58:18 +03:00
foxyzandClaude Opus 5.5 2a7b553597 Pills only with the controls, web links in their final layout at once, chat pop-ups when paused by default
- Next episode (known ending, else the last two minutes) and Skip ED
  show only while the controls are up, like Skip 1:30 (PillGate).
- A web link's name and wide cover slot show from the first state, so
  nothing flips when its thumbnail arrives.
- Chat pop-ups default to Only when paused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 05:13:50 +03:00
foxyzandClaude Opus 5.5 27b4ec2c96 Swap shows only the new media, no guessed endings, quieter screen
- A swap stops the previous media at once; buffer counts only for the
  media the player holds (client) and the current mediaId (server), so
  the old video can no longer start the new one.
- The automatic frozen-picture reload is gone (false alarms on still
  scenes); Reload video stays.
- The "Next episode" pill shows only from a known ending.
- A web link's thumbnail is its cover, under the video's own name.
- While playing, the unread chip stays off the picture; chat pop-ups
  can be All / Only when paused / Off.
- Server fetch failures say when the server has no cookies.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 04:31:14 +03:00
foxyzandClaude Opus 5.5 ab133dd82f Test builds: tool/dev_release.sh builds Linux here and publishes to a dev prerelease that dev builds update from
X.Y.Z-dev.N builds read the dev prerelease's manifest as well as the
stable one and take the newer, so a tester gets each test build through
the normal update and lands back on stable at the release.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 02:29:40 +03:00
foxyzandClaude Opus 5.5 1b0a3db439 Player: leave Bilibili's danmaku out of yt-dlp subtitles (it stalled playback)
The danmaku XML is served raw-deflate; ffmpeg fails to inflate it on
every read, logging a warning each time (millions, drained on the UI
thread) while the video waits on the track: a frame every few seconds.
mpv's hook now asks yt-dlp for sub-langs all,-danmaku.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 01:53:36 +03:00
foxyzandClaude Opus 5.5 6d388483ef CI: run on branch pushes only, not on release tags (release.yml builds those)
ci / core (push) Successful in 30s
ci / server (push) Successful in 57s
ci / updater-windows (push) Successful in 4m46s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 22:00:41 +03:00
foxyz bbecdcc4d8 Release v1.1.2
ci / core (push) Successful in 33s
release / linux (push) Successful in 4m28s
ci / updater-windows (push) Successful in 4m49s
ci / server (push) Successful in 58s
release / windows (push) Successful in 4m30s
2026-10-10 21:45:57 +03:00
foxyzandClaude Opus 5.5 f1ccf88b06 Roadmap: drop the link-token item (what a user pastes is theirs)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 21:45:57 +03:00
foxyzandClaude Opus 5.5 d3be13ecda Errors: refuse links the probe can't play (with Put it up anyway), plain error texts, and others see who can't play
report.error / state.members[].error (optional, no protocol bump): an
errored member is out of the ready gate until its player opens the media
or the media changes; others see "Bob can't play this: ..." and a mark
on the avatar; your own pill offers Retry (reload).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 21:29:41 +03:00
foxyzandClaude Opus 5.5 1b04e002c2 Player: mpv logs at warn (v only while probing the render mode), 4xx fails at once, load errors show
The session player asked libmpv for every v-level message all session and
media_kit drains them on the UI thread: a retry storm could freeze the
window. ffmpeg reconnects only on 5xx, bounded. A load that fails before
a duration is known reports once ('can't play this: HTTP error 403 ...').

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 21:12:00 +03:00
foxyzandClaude Opus 5.5 f23cc7b311 Schedule: remove the per-user follow (bookmark, Following filter, AnimeFlow follow calls)
Personal tracking will come from the user's AniList lists; AnimeFlow keeps
downloads, availability and requests only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 20:51:42 +03:00
foxyzandClaude Opus 5.5 473c82ec0a Schedule: AnimeFlow's AniList episode number wins outright (JoJo S6E4 no longer claims next week's ep 4)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 20:42:28 +03:00
foxyzandClaude Opus 5.5 e621b392af Dependencies: Flutter upgrade, file_picker 13, flutter_lints 6
ci / core (push) Successful in 28s
ci / server (push) Successful in 57s
ci / updater-windows (push) Successful in 6m20s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 20:16:45 +03:00
foxyzandClaude Opus 5.5 884f208330 Schedule: On Jellyfin only when the episode itself is there; Aired · not on Jellyfin yet otherwise
For shows AnimeFlow doesn't track, the app now looks the episode up in the
linked Jellyfin (one /Shows/{id}/Episodes per series, 3 min memo; TVDB
number via anilist_map offsets). No linked account: a neutral Aired.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 20:15:18 +03:00
foxyz cc45b86271 Release v1.1.1
ci / core (push) Successful in 28s
ci / server (push) Successful in 56s
release / windows (push) Successful in 4m33s
release / linux (push) Successful in 3m44s
ci / updater-windows (push) Successful in 4m25s
2026-10-10 01:01:28 +03:00
foxyzandClaude Opus 5.5 84c5cda668 CI (Windows): fetch git dependencies fresh; the host's cached fox_utils clone was stale
ci / core (push) Successful in 42s
ci / server (push) Successful in 1m17s
ci / updater-windows (push) Successful in 4m26s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 00:41:17 +03:00
foxyzandClaude Opus 5.5 bc96cb13d9 fox_utils 0.1.9: fixes the Windows build (broken since 1.0.1)
ci / core (push) Successful in 28s
ci / updater-windows (push) Failing after 43s
ci / server (push) Successful in 1m10s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 00:32:29 +03:00
foxyzandClaude Opus 5.5 f7f2f0cddd CI: show the verbose Windows build's error lines and tail
ci / core (push) Successful in 28s
ci / server (push) Successful in 55s
ci / updater-windows (push) Failing after 3m55s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 00:23:11 +03:00
foxyzandClaude Opus 5.5 05b06b1fc1 CI: capture the verbose Windows build through cmd (PowerShell 'Stop' aborted on stderr)
ci / core (push) Successful in 28s
ci / server (push) Successful in 58s
ci / updater-windows (push) Failing after 4m5s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 00:13:23 +03:00
foxyzandClaude Opus 5.5 748d58f036 CI: build Windows on every push; a failed Windows build prints the compiler errors
ci / core (push) Successful in 28s
ci / server (push) Successful in 57s
ci / updater-windows (push) Failing after 4m50s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 00:01:02 +03:00
foxyz 1fd4081461 Release v1.1.0
ci / updater-windows (push) Successful in 32s
ci / core (push) Successful in 27s
release / windows (push) Failing after 20s
ci / server (push) Successful in 54s
release / linux (push) Successful in 3m46s
2026-10-09 23:53:53 +03:00
foxyzandClaude Opus 5.5 2d39018a73 One updater for app and server: updater in core; watch-together-server --upgrade / --check-update; update.sh uses the server
The server's version is stamped like the app's (APP_VERSION) when packaged,
so it can compare against the manifest; --upgrade swaps the whole install
folder (app and server) and asks for a restart. update.sh/.bat no longer
need GTK.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 23:53:52 +03:00
foxyzandClaude Opus 5.5 059b7dd306 Solo: no idle unready, no party corner in fullscreen; a cancelled Invite returns to solo
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 23:39:48 +03:00
foxyzandClaude Opus 5.5 0115d54ab8 Strip shows the joined server's name (its --name via /health), the address until known
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 23:36:30 +03:00
foxyzandClaude Opus 5.5 fe5d6c3b3f Relay HLS/DASH-only pages as pieces tracks (protocol 11)
A track can be served as pieces: the server writes a VOD playlist whose
entries are its own piece URLs (segments, init maps, AES-128 keys; byte
ranges folded in; yt-dlp's DASH fragments become the same list). A piece
buffer per track fills like the byte buffer. Picks a single file, then a
split byte pair, then pieces. DRM, live, master playlists are refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 23:35:31 +03:00
foxyzandClaude Opus 5.5 05f9e794a9 Cookies: keep every site of an imported file (no site picker); only the cookies box takes drops on Settings
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 23:26:55 +03:00
foxyzandClaude Opus 5.5 75065f90cd Welcome band only for newcomers, with Don't show again
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 23:13:34 +03:00
foxyzandClaude Opus 5.5 e0db5cc655 Solo watching and a clearer offline start
Putting anything up offline starts a solo session: an in-app server bound
to 127.0.0.1 only, joined like hosting, so tracking, next episode and the
player all work. Invite friends re-hosts on the Host card's port and
password with the same media and position; Stop needs no confirmation.
Offline, the Library opens with a welcome band (Join a friend, Host, Watch
solo); without a Jellyfin account it leads with Files and Link, Jellyfin
is an optional card. Play buttons work offline.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 23:08:11 +03:00
foxyzandClaude Opus 5.5 8186fbb5d3 yt-dlp cookies: from a browser or a file (cookies.txt or extension JSON), per site, private; server --yt-dlp-cookies
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 22:59:10 +03:00
foxyzandClaude Opus 5.5 8b81da4069 Protocol 11: sit out replaces kicking; nudge
Anyone can sit anyone out (host included); only you bring yourself back.
Out members stay in chat and the party, never block or count, and load no
media; a card with the live position and Join back (Space) replaces the
video. The mini player's x sits you out; optionally idle does too. Nudge:
the server forwards it to the target only (3/s, 20/min per sender); the
target hears the nudge sound, gets a toast and a taskbar attention
request. Kick, its messages, errors and dialog are gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 21:05:35 +03:00
foxyzandClaude Opus 5.5 dbfd351ee4 Relay picks the most compact video codec (HEVC, AV1, VP9, then h264)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 20:44:14 +03:00
foxyzandClaude Opus 5.5 a4be24a2a5 Sounds: three presets (Cosmic, Abyss, Wood & glass), chosen overall or per sound; nudge sound
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 20:21:43 +03:00
foxyzandClaude Opus 5.5 b038706dff Protocol 11: split-track relay (YouTube video+audio), chunked upstream reads
Relayed media can carry 2-4 byte tracks (video, audio): yt-dlp picks a
split pair when no single-file format exists (h264 first), the source
channel serves each track, the server keeps a buffer per track (budget
split by size, 8 MiB floor) and viewers open an mpv edl:// of the track
URLs. Expired URLs re-resolve once for all tracks. Upstream GETs are cut
into <= http_chunk_size (10 MiB) ranges: YouTube throttles larger ones.
Live streams and DRM are refused; HLS/DASH pieces are a follow-up.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 19:59:41 +03:00
foxyzandClaude Opus 5.5 b2b77a6b3f Hot-restart guard comes from fox_utils 0.1.8 (shared with other media_kit apps)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 18:58:08 +03:00
foxyzandClaude Opus 5.5 d002ff6941 Linux debug: hot restart no longer crashes with live mpv players
Hot restart frees the old isolate's NativeCallables while mpv handles stay
alive; media_kit then quits them on the next start and mpv calls the dead
wakeup callback (SIGSEGV on the mpv 'core' thread). A debug-only runner
hook clears every live handle's wakeup callback on the engine's
on-pre-engine-restart signal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 18:53:07 +03:00
foxyzandClaude Opus 5.5 151ec95289 Show the app as Starsync (window titles, launcher entry, About, README); internals unchanged
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 18:47:35 +03:00
foxyzandClaude Opus 5.5 745717a4a0 yt-dlp for page links in every Link mode; app icon and Linux launcher entry
yt-dlp: a managed, checksum-verified, daily-updated yt-dlp in the app and
the dedicated server (--[no-]yt-dlp, --yt-dlp-dir). Pasted links are
probed: direct files stay links, pages are resolved for title, uploader,
duration and thumbnail. Everyone fetches it: mpv's ytdl hook on the
managed copy. I pass it on / the server fetches it: the best single-file
format, re-resolved once when its URL expires. Relay names never split
the /r/ path; viewers of server-fetched pages wait for the relay to go
live. No wire change.

Icon: two stars orbiting a play button (assets/icon/icon.svg, PNGs, the
Windows .ico). GTK app id moe.senkochan.watch_together; release builds
install their .desktop file and icons into ~/.local/share so Wayland
shows the icon; the window icon is set for X11. README shows the icon.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 18:21:22 +03:00
foxyzandClaude Opus 5.5 9ddd85de90 Esc steps out one level: chat, fullscreen, big player to mini, then ask to leave
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 17:41:38 +03:00
foxyzandClaude Opus 5.5 24fc137e4f No buffering rings at the end of short videos; links never guess an AniList show
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 17:23:43 +03:00
foxyzandClaude Opus 5.5 9a4b04672b Schedule: AniList entries split within a season (anilist_map, per-episode AniList id) count as on Jellyfin
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 17:12:43 +03:00
foxyz ff51d52fbb Release v1.0.1
ci / updater-windows (push) Successful in 47s
ci / core (push) Successful in 27s
ci / server (push) Successful in 45s
release / linux (push) Successful in 3m49s
release / windows (push) Failing after 5m53s
2026-10-09 08:18:29 +03:00
foxyzandClaude Opus 5.5 f2272d7980 Space never presses a focused button; chat toasts open the chat; plain wheel always scrolls the page
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 07:52:48 +03:00
foxyzandClaude Opus 5.5 cf80bb2167 End of video plays out, no false 'loading' near/after the end; bigger status pill avatars; start card fits the mini
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 07:31:03 +03:00
foxyzandClaude Opus 5.5 8effa573a0 Protocol 10 presence gate, drop/paste anywhere, quick UI fixes
Ready gate: whoever is in the server while it is not playing counts (joining
while playing does not pause anyone); a swap counts everyone present and
keeps ready flags; the quorum rule is gone. Optional 'Unready when the
video changes'. No 'Skip 1:30' guess when the media has chapters.

Drag & drop and Ctrl+V anywhere (fox_utils): files, folders and links get a
small card asking how to open them. Files series start collapsed.

UI: Tab never moves focus and Space is always ready outside text fields;
chat focuses its field; unread badge on uosc and a chip in fullscreen;
people-only chat with an Activity toggle; skip pills show 5 s then with
the controls; volume up to 150%; own avatar same size as the party.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 07:06:48 +03:00
foxyzandClaude Opus 5.5 5f22be6c20 Roadmap: wishlist placed (ready gate, quick UI, drop/paste, Files, yt-dlp, AniList, ops)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 06:54:08 +03:00
foxyzandClaude Opus 5.5 740273745b Desktop: depend on fox_utils v0.1.7 (git) for drag & drop and clipboard
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 06:32:22 +03:00
foxyzandClaude Opus 5.5 27b4e1dc0c Sounds: full volume by default; everyone ready is the bell alone
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 06:28:53 +03:00
foxyzandClaude Opus 5.5 fce392e425 Sounds: Japanese-house sound set in the app, layout demo removed
Eleven sounds for events that carry news (ready, unready, everyone ready,
join, leave, kick, new video, others' chat, idle warning, connection
lost/back); none for what the video already says. New voices: koto
(Karplus-Strong, bendable), furin chime, rin bowl, bamboo, hyoshigi,
shoji slide, in the Yo scale on D. Played by SynthSfx at the Settings
volume with per-sound switches and previews. The debug layout mock is
gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 06:23:22 +03:00
foxyzandClaude Opus 5.5 d8fd464e33 Sounds: synthesized sound set and a debug sound board
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 05:45:14 +03:00
foxyz 0c38528520 Release v1.0.0
ci / core (push) Successful in 25s
ci / updater-windows (push) Successful in 48s
ci / server (push) Successful in 49s
release / linux (push) Successful in 3m34s
release / windows (push) Successful in 4m38s
2026-10-09 01:39:35 +03:00
foxyzandClaude Opus 5.5 b534c8c7ea Player: now-playing title with cover, start banner, one status pill; media art for everyone
The swapper sends the media's poster and banner once (`art`, Jellyfin
images or AniList by parsed name); state carries their hashes and every
member fetches them from /i/ like avatars. state.media.swapBy names who
put the media up. uosc and the standard bar show cover, title and
episode; a banner card shows before playback starts. The top-left health
pill and the waiting pill become one frosted status pill in plain words.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 01:19:34 +03:00
foxyzandClaude Opus 5.5 d092ea371a 1.0 library + player settings: Jellyfin/Link/Files library with details panel, player gear, settings cards, host flag
Library: source switch, selectable cards with a details panel, one merged
Continue row, Latest grouped per series without re-releases, Link page with
previews and recent links, Files embedded. Player: gear popover in both
designs, progress line while controls hide, live hwdec. Settings rebuilt as
cards. State marks the host member (proto stays 9, unreleased); hosting
shows LAN invite addresses; window buttons in fullscreen. fadeThrough keeps
the outgoing view's state.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 00:58:12 +03:00
foxyzandClaude Opus 5.5 99cda7ed04 Schedule: mouse drag scrolls the board sideways only; wheel stays vertical
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 00:31:58 +03:00
foxyzandClaude Opus 5.5 d29c12e4f0 1.0 shell: one window with a party strip; protocol 9 profile + avatars by hash
The shell (lib/src/shell/) replaces the Home and session screens:
- Party strip: profile, Library / Schedule / Settings tabs, Join and
  Host cards (hosting no longer takes media), status-ring avatar stack
  with the member list, chat & activity panel, Leave.
- The real session player is mounted once and shown full, as a
  draggable corner-snapping mini player, or fullscreen (window_manager,
  party and toasts overlaid); the Video is never mounted twice.
- Profile popover with inline rename, colour and a crop picker (256 px
  PNG), pushed live to the session; avatar toasts; waiting pill.
- Motion kit (fastEaseInToSlowEaseOut, fade-through, popovers,
  mouse-drag/wheel horizontal scrolling).
- Library = the existing picker for now (restyled next round); Schedule
  = the new SchedulePage; Settings = left nav over the existing panels.

Protocol 9 (packages/core, packages/server, sync client):
- {"t":"profile"} renames / changes the picture mid-session ("rename",
  "avatar" feed actions); names are cleaned the same way at hello.
- state.members carry avatarHash; pictures are served once from
  /a/<sid>/<token>/<hash> (PNG/JPEG/WebP/GIF by magic bytes only) and
  cached by hash in the app (AvatarCache).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 00:23:46 +03:00
foxyzandClaude Opus 5.5 f790470935 Files page: folders, video-only listing grouped by series, lazy mpv thumbnails
Standalone FilesPage (plugged into the shell later): watched folders
(sidebar, chips with remove when narrow), a header saying where you are,
series sections and a 16:9 grid for loose videos, thumbnails requested
only for tiles that stay visible and cancelled when scrolled away,
hidden-files count with examples, rescan, Recent with missing-file
state, play errors in a banner, offline browsing with play disabled.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 00:13:28 +03:00
foxyzandClaude Opus 5.5 2eb6840ec4 Schedule: "on Jellyfin" for shows AnimeFlow doesn't track, AniList episode numbers
From real use: a show on Jellyfin read "not in AnimeFlow". AnimeFlow's
new GET /library lists every Jellyfin series with AniList ids per TVDB
season; matchSchedule uses it for untracked shows (status inLibrary,
Put it up, no Request) and to fill jellyfinSeriesId. A failing or
missing /library never fails the schedule. Episodes may carry
anilist_episode (split cours, absolute numbering), matched first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 00:01:28 +03:00
foxyzandClaude Opus 5.5 677ece81c4 Schedule page: day columns on AniList + AnimeFlow, real requests and owner inbox
Standalone SchedulePage (plugged into the shell later): cover-first
cards in day columns from last week's Monday, filters (On Jellyfin /
Following / All airing, niche toggle, compact), requests that really
call AnimeFlow (episode with AnimeFlow's own numbering, series via
AniDB search by AniList id), follow, Put it up resolving the Jellyfin
episode, the requests sheet with the owner's Retry/Decline, refresh
every 60 s while visible, stale-AniList and AnimeFlow-error states.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 23:59:22 +03:00
foxyzandClaude Opus 5.5 caa0765890 Schedule backend: AniList client, AnimeFlow client, schedule matching
lib/src/schedule/ for the 1.0 Schedule tab (UI and docs come with the
schedule round):
- AniListClient: airingSchedules in a window, 50 per page, adult/music
  dropped; memory 10 min + disk 1 h cache, one Retry-After wait on 429,
  stale cache on failure; real User-Agent (AniList 403s some defaults).
- AnimeFlowClient: the full app API (me, schedule, series, follow,
  search, requests incl. approve/reject, users/scopes) with the user's
  Jellyfin token, only for a Jellyfin on AnimeFlow's parent domain;
  ETag/304, typed error codes, backoff on nginx's HTML 429, /me cached
  at most 60 s so scope changes show.
- matchSchedule + ScheduleRepository: AniList airings with AnimeFlow
  status by (episode anilist_id, episode), then absolute number, then
  air day; degrades to AniList-only when AnimeFlow fails.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 23:47:33 +03:00
foxyzandClaude Opus 5.5 c76fee70dc Files backend: release-name parser, video-only scanner, folder store, mpv thumbnails
lib/src/files/ for the 1.0 Files source (UI comes with the library
round; ARCHITECTURE gets its section then):
- parseReleaseName: series/season/episode/group/year/extras from release
  names (fansub, scene, batch, NCOP/OVA markers); seriesKey for grouping.
- scanFolder (isolate): videos only, samples/trailers and other files
  counted as hidden, sidecar subtitles paired by name (+ language/flag
  suffixes), 20 000-file cap; groupBySeries -> series + loose videos.
- FolderStore: watched folders and recent files in Hive, as signals.
- thumbnailFor: a 480 px JPEG at 25% via a hidden mpv player
  (screenshot-to-file, vo=null), cached by path+size+mtime, 2 at a time,
  cancellable, never throws. No ffmpeg needed (Windows ships only libmpv).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 23:46:01 +03:00
foxyzandClaude Opus 5.5 6f136bfc4d Debug-only layout demo of the 1.0 shell (reference for the redesign)
Behind a bug button in Home's top bar, debug builds only: single window
with a party strip, Library (Jellyfin / Link / Files), Schedule on real
AniList data with AnimeFlow statuses (read-only), Settings, profile with
live rename and avatar crop, floating mini player, full and fullscreen
player with a real media_kit player, chat panel and avatar toasts. Not
wired to sessions; the real implementation replaces it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 23:34:02 +03:00
foxyzandClaude Opus 5.5 e7f33b5dc3 Relay: a member's media through the server's buffer (proto 8)
ci / core (push) Successful in 28s
ci / updater-windows (push) Successful in 49s
ci / server (push) Successful in 49s
Viewers who cannot reach the media get it through the server: a file on
any member's computer, a link passed on by a member ("Through me") or
fetched by the server ("Server fetches it", password-protected servers
only), and Jellyfin items for guests without an account, sourced by
whoever put them up with their own sign-in (or a pasted link's key),
which never leaves their machine. Fixes guests on a dedicated server
without a Jellyfin login being blocked.

The source dials /r/.../source (works behind NAT) and answers pulls with
an adaptive in-flight window (2 x rate x min RTT, 256 KiB..8 MiB). The
server keeps a memory-only buffer of 1 MiB slices sized like a viewer's
mpv buffer (pinned head/tail, 64 MiB back, 256 MiB ahead), fills it with
one sequential pull at a time (blocked viewers first, newest request
only), fetches nothing while nobody watches through it, and drops it on
the next swap or stop. Measured with mpv through shaped links
(tool/relay_e2e.dart): 400 Mbit/s seeks ~0.03 s; 40 Mbit/s no stalls,
3 viewers cost the source ~1x the bytes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 20:41:01 +03:00
532 changed files with 92580 additions and 11905 deletions

No files matched your search

+28 -5
View File
@@ -1,6 +1,9 @@
name: ci name: ci
on: on:
# Branches only: a release tag points at a commit CI already checked on
# main, and release.yml builds it.
push: push:
branches: ['**']
pull_request: pull_request:
jobs: jobs:
@@ -32,14 +35,18 @@ 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, and the close smoke run needs the real
# build. Runner notes: see release.yml.
updater-windows: updater-windows:
# Not for a "Release v…" commit: release.yml builds it on the same
# (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
@@ -55,5 +62,21 @@ jobs:
run: powershell -NoProfile -ExecutionPolicy Bypass -File tool/setup_flutter_windows.ps1 run: powershell -NoProfile -ExecutionPolicy Bypass -File tool/setup_flutter_windows.ps1
- name: Updater tests - name: Updater tests
shell: powershell shell: powershell
working-directory: apps/desktop working-directory: packages/core
run: flutter test test/updater_test.dart run: |
dart pub get
if ($LASTEXITCODE) { exit 1 }
dart test test/updater_test.dart
# The release build, without publishing: a broken Windows build shows
# here, not only on a tag.
- name: Windows build
shell: powershell
run: powershell -NoProfile -ExecutionPolicy Bypass -File tool/package_windows.ps1
# The close smoke run on that build (P32): start, open a short clip in
# a player with a video output, close; it must exit within 45 s and
# leave no process. A runner that cannot show a window skips with a
# warning. Here rather than in a job of its own: one build, one runner.
- name: Smoke (start, open, close)
shell: powershell
timeout-minutes: 3
run: powershell -NoProfile -ExecutionPolicy Bypass -File tool/smoke_windows.ps1 -Limit 45
+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 }
+1
View File
@@ -17,6 +17,7 @@ coverage/
# Build artifacts that should never be committed # Build artifacts that should never be committed
*.tar.gz *.tar.gz
*.zip *.zip
/dist/
app.*.symbols app.*.symbols
app.*.map.json app.*.map.json
+99
View File
@@ -0,0 +1,99 @@
# Changelog
What changed in each Starsync version, newest first, in plain words. The
app shows a version's section when it offers the update and once after it
(`What's new`); the release page shows it too. How entries are added and
cut into a release: [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md#changelog).
## Unreleased
### Added
### Changed
### Fixed
## 1.4.0
Links you put up are downloaded once for everyone, quick chat and danmaku in the player, plus What's new and fixes for Windows, the relay and chat.
### Added
- What's new: the update band shows what changed, and after an update the app shows the full list once (also in Settings → About)
- Update channel in Settings → Updates: Stable, or Dev for test builds; Dev stays on test builds after a release and still gets the newer release when there is one
- Sharper subtitles and picture: the video and its subtitles are drawn at your screen's resolution instead of the video's (Settings → Player → Sharp video and subtitles, on by default)
- Invite links: Invite copies just a starsync:// link, password included, so a friend just clicks it (or copies it and switches to Starsync) and the app asks "Join Bob's server?"
- Invite finds your internet address on its own and says when your router needs the port forwarded; Other addresses picks your home-network or VPN address instead
- Starsync runs once: a link clicked while it is open goes to the open window
- Quick chat: press Enter in the big player to type a message in a small box at the bottom, without opening the chat panel and without the mouse pointer or the controls popping up; it shares its draft with the chat panel, closes by itself when you stop typing, and subtitles move up out of its way
- Danmaku notifications: chat, joins, leaves and Up next can fly across the top of the picture instead of popping up (Settings → Notifications & sounds → Notifications: Danmaku, off by default), with speed, how many at once, where, opacity, size, boldness, outline and avatars all adjustable and a live preview
- Player panel: ⚙ → Player settings opens a panel at the right of the player that stays open while you watch, with tracks, subtitle size, picture, volume and notification settings
- Subtitles can move up above the controls while they show (Settings → Player → Subtitles above the controls, off by default), and their size can be changed
### Changed
- A link you put up is downloaded once: you and everyone else watch the same stream, so nobody drifts ahead or stalls alone, and sites like Bilibili are far less likely to block it
### Fixed
- Upscaling shaders (Anime4K, the HQ modes) no longer shift the colours or draw a grid over the picture, which showed most in a large window or fullscreen
- Sharp video: resizing the window or opening the chat panel no longer shrinks the picture for a moment before it jumps back
- Joining while something plays on Windows: the video opens again by itself when it was too early
- Relay: players no longer get stuck after a slow or stalled source
- Relay: X (Twitter) clips play through the relay
- Relay: gentler retries, so Bilibili stops blocking the server
- Relay: a source that refuses the old link (412) gets a fresh address and keeps playing
- Chat pop-ups stay away while the chat panel is open (the sound still plays)
- Bilibili and other sites no longer stop a video halfway when you watch alone or with "Everyone fetches it": a cut connection picks up where it left off
## 1.3.2
Fixes Windows closing by itself about 3 seconds after every start.
### Fixed
- Windows: the app no longer closes by itself about 3 seconds after starting (1.3.0 and 1.3.1)
## 1.3.1
Joining another server no longer opens an empty player, a cleaner mini player and a softer Minimal progress line.
### Changed
- A cleaner mini player: just the picture, play and a plain progress bar
- Minimal controls: the progress line shown while the controls hide is softer and easier on the eyes
### Fixed
- Joining another server no longer opens an empty player
## 1.3.0
The app is now called Starsync and was rebuilt inside for stability: a shared countdown to what plays next, honest watched marks, faster relay seeks and many fixes. Everyone must update to keep watching together.
### Added
- A shared countdown to what plays next, and anyone can cancel it
- The next episode of your local files continues by itself ("Continues with")
- System notifications while the window is in the background
- Your own server is always running: Invite your friends straight in
- See the server's version and everyone's app version
- The library matches your shows with AniList
- A new sound engine and new sounds
### Changed
- The app is now called Starsync (your settings and data come along)
- "Watched" counts only the time you actually watched (Jellyfin too)
- Joining no longer pauses everyone
- A dropped connection comes back as the same member, not a new one
- A server restart no longer loses what was playing
- Faster seeks over the relay
### Fixed
- The app no longer freezes on exit
- Shaders on Windows
- X11: video renders properly again (also with software rendering)
- Black video when joining mid-play on X11
- Many stability fixes from the rebuild
+520 -100
View File
@@ -1,4 +1,8 @@
# watch-together <p align="center">
<img src="apps/desktop/assets/icon/icon_256.png" width="128" height="128" alt="Starsync icon">
</p>
<h1 align="center">Starsync</h1>
Watch a video with friends, in sync, with the quality of a real mpv player. Watch a video with friends, in sync, with the quality of a real mpv player.
Pick from your Jellyfin library, paste any direct video link, or pick a file on your own disk; Pick from your Jellyfin library, paste any direct video link, or pick a file on your own disk;
@@ -7,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.
@@ -21,43 +25,314 @@ 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`. Needs mpv's library installed (`mpv` or `libmpv2`). `starsync`. On start it adds itself to your app launcher (and its
- Windows: `watch-together-<version>-windows-x64.zip`. Extract, run icon), so the window shows its own icon on Wayland. Needs mpv's library installed (`mpv` or `libmpv2`).
`watch_together.exe`. mpv is bundled. New and not yet tried on a real - Windows: `starsync-<version>-windows-x64.zip`. Extract, run
`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 Home's top bar 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 --upgrade`, or the shipped `update.sh` / `update.bat`. A
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), with its first changes. "What's new" lists
them all ([CHANGELOG.md](CHANGELOG.md)); the first start after an update
shows that list once, and Settings → About has it again. "Update and
restart" downloads, verifies, applies in place and restarts; "Later" hides
it until the next check; the install folder must be writable by you. The version you run is
at the bottom of every Settings page (and in the Settings tab's tooltip). Settings has Check now and an auto-check toggle. From a terminal:
`./starsync-server --upgrade` (no display needed, so it works on a
server box too), `./starsync --upgrade`, or the shipped `update.sh` /
`update.bat` (which run the first). Each updates the whole folder, app and
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
must run the same protocol version, and a mismatch is refused with a message must run the same protocol version, and a mismatch is refused with a message
telling you to update. telling you to update.
## Host from the app ## Getting started
Home, Host tab. Pick what to watch (optional): Jellyfin (browse your linked The app always runs its own server, quietly: your server. When it starts
servers, see below), Link (paste an http(s) URL; Ctrl+V outside a field you are on it, alone, and it is a plain player. Three ways in, at the top
pastes it in) or File (path or Browse). In the column beside it set a port and an optional password (blank = of the Library the first time; none needs Jellyfin or an account.
anyone can join), then Host. Your name and avatar come from your profile
(the chip at the top left). The app starts a
server inside itself and joins it; you are the host and nobody can kick you.
Leaving or closing the app stops the server. Friends need to reach your machine: open
the TCP port on your router (or use a LAN or VPN address). Friends open nothing.
If you pick a local file, the server streams it to everyone from your disk. - **Join a friend** (most people): your friend sends you an invite (a
Nothing is uploaded or stored anywhere. `starsync://` link) and, maybe, a password. Click the link, or just
copy it: the app asks "Join <their server>?". See [Join](#join).
- **Invite friends**: you have the video and friends watch it with you.
See [Your server and Invite](#your-server-and-invite).
- **Watch on your own**: just play something from the Library, with
everything a server has (Jellyfin progress, next episode, skip buttons).
Nobody else can reach your server until you invite them; inviting never
interrupts what plays.
## The window
One window. Along the top, the strip: your avatar (click it for your
profile: name, picture with a crop editor, colour, and links to the
settings), the Library / Schedule / Settings tabs, and the party. Alone
on your server it has just Join a friend and Invite (and "Sharing" while
your server is open to friends); with others there it shows everyone's avatar
with a ring for their state (green ready, amber buffering, grey not ready,
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
member list (each member's app version, amber when it differs from yours,
and the server's at the top; force ready, Nudge, Sit out; on your
server where friends can reach it, Copy invite link and Stop sharing,
on a friend's "Copy invite link"). Beside them the server and "ready n/m" (who is sitting out or
reconnecting does not count), the chat button with unread
count, and Invite on your server or Leave on a friend's. Empty strip
space drags the window.
The player fills the window under the strip. Pick a tab and it shrinks to
a mini player in a corner: drag it anywhere and it snaps to the nearest
corner; double-click or ⤢ makes it big again, × sits you out (below) and
× again closes it; the strip's screen button brings it back. F or
double-click on the picture goes fullscreen (the strip hides, the party
shows in a corner while paused, the window buttons sit in the player's top
bar while its controls are up), Esc leaves it. The controls show the
show's cover, title and episode top left (a web video: its thumbnail and
name; guests get the art too; right-click to copy the title, or the link
when you have it, or open it in the browser or Jellyfin); before
a video first plays, its banner fills the picture until playback starts.
One pill bottom centre, just above the controls, says what playback waits for ("Press Space
when you're ready", "Waiting for Bob and Mika · 2/3 ready", "Bob is
loading…") or what went wrong, with Retry or Cancel when it helps. Chat and activity open in a panel on the right (chat button or
Tab), ready to type; it shows messages (select text to copy it, or
right-click for Copy message and, with a link in it, Copy link / Open in
browser), and its "Activity" toggle adds
joins, ready, seeks and the like between them. Joins, leaves, someone
losing connection and coming back, video changes, additions to Up next
(the title, its picture and who added it), chat and problems also pop up
bottom left with the person's avatar, above the player's controls (never
on them); ready, seeks and pauses stay in the activity. Click a message
to open the chat (Settings → Notifications & sounds → Chat messages: All
by default, or Only when paused, or Off; messages always reach the chat,
with their sound). Watching alone, nothing pops up or sounds about the
party (there is nobody); the app's own notices still show.
**Quick chat.** Enter in the big player opens a small chat box at the
bottom centre without opening the chat panel, also while the video
plays: type (the player's keys wait meanwhile), Enter sends, Esc or an
empty Enter closes it, and it closes by itself after a few seconds
without typing. It is the same draft as the chat panel's field, so a
message started in one is there in the other. What you sent shows for a
moment where the box was (or flies as your own line with danmaku, below);
bottom subtitles move up out of its way and back, top signs stay. The
mouse pointer and the controls stay hidden: typing is not moving the
mouse.
**Danmaku.** Settings → Notifications & sounds → Over the player →
Notifications: Toasts (default) or Danmaku. With Danmaku, over the big
player chat, joins, leaves, Up next and "starting now" fly right to left
across the top of the picture instead of popping up (problems and errors
still pop up, and everything does while the player is small). The look
is yours to tune there, with a live preview: crossing time, how many at
once, which band of the picture they use, opacity, size, boldness,
outline, avatars, and a frame round your own lines.
**While sitting out or in the background.** Sitting out, only chat,
nudges, "starting now" (the video starts for the others) and Up next
reach you; joins and leaves too if you turn on Settings → Notifications &
sounds → "Joins and leaves too". With the window in the background or
minimised, the same ones also come as desktop notifications (Linux's
notification center, Windows' toasts); clicking one brings the window
up, and a chat one opens the chat. Unread chat shows as a count on the chat
buttons and, in fullscreen with the controls hidden and the video paused,
as a small chip top right.
Your name and picture change for everyone in the server right away; the
colour is yours only.
**Sitting out.** Step away without leaving: Sit out (your profile, your
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
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)
loads the video there and readies you, in the background while the others
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:
only they hear it (a sound, a pop-up, and the taskbar entry asks for
attention when the window is in the background).
## Your server and Invite
Your server starts with the app and stops when it closes. Only this app
can reach it until you press **Invite** (in the strip, or in the member
list): that opens it on your network at the port from Settings → Party &
network (default 8080, with the password set there; blank = anyone can
join) and copies an invite link (`starsync://203.0.113.7:8080/?name=…`,
just the link) to send to friends. Nothing restarts: what plays and Up next carry on, and you are
the host (the crown).
The link carries your internet address when the app can find it (it asks
a "what is my IP" service such as ipify over HTTPS when you share), else
this computer's home-network address (not a proxy one, nor a Docker or VM
bridge). In the member list, **Copy invite link** copies it again;
**Other addresses** there lets you pick another: **Internet** for friends
elsewhere, **Home network** for friends on the same Wi-Fi or router,
**VPN** (Tailscale and the like). Behind a router, friends elsewhere get
in only when the TCP port is forwarded on your router to this computer
(the card says so); the app does not open ports itself. The link
includes the password when one is set, so friends just click it: share
it only with them. Friends open nothing. **Stop sharing** (in the member list)
closes it again:
everyone else leaves (their app goes back to their own server and says
why), you and Up next stay.
If you put up a local file, your server streams it to everyone from your
disk. Nothing is uploaded or stored anywhere.
Joining a friend leaves your server for theirs; it stays up meanwhile,
and leaving theirs brings you back to it as you left it.
## Library
What to watch, from three sources (the switch at the top). Drop a video,
a folder or a link anywhere on the window, or press Ctrl+V outside a
text field, and a small card asks how to open it (put it up, add it to
Up next, add the folder to Files, or fill Link).
- **Jellyfin**: your linked servers (see below); with none linked it
points to Files and Link and offers Jellyfin as optional. Library chips beside the
switch, then one **Continue** row (what you were watching and what comes
next, one card per series, the one you watched most recently first;
series not touched for two months drop out) and **Latest in** each
library (one card per series: "S1E1" or "3 new"; Show all opens the
library newest first). Re-uploads (added long after they aired) are
hidden from Latest; the row's ⋮ menu turns that off. Search (/), library
grids (sort, unwatched, favourites) and series pages sit behind them.
Click a card to select it: its
details open on the right with "Put it up · S1E3", mark watched /
unwatched, remove from Continue and the episode list (click an episode
to choose it). Double-click a card or an episode to put it up at once.
Right-click a card or an episode to copy its title or open it in
Jellyfin's web page (the details panel has that button too).
- **Link**: paste an http(s) link: a video file, or a web page with a
video (YouTube, X, and whatever else yt-dlp knows). The preview shows
what it is (a page's title, uploader, length and thumbnail, else the
file name); in a server, pick how the
others get it (everyone fetches it, the server fetches it, or through
you). Your last 12 links are listed under it (right-click one to copy
or open it). A link seen before shows its preview at once, without
asking the site again. A link that does not
open here (needs cookies, 403, 404, not a video) says why and goes up
only with "Put it up anyway"; in the player, whoever cannot play it is
named ("Bob can't play this: Not allowed (403)") and nobody waits for them.
- **Files**: your own library from the video folders you add on this
computer (the **Library** shows them all together, each folder on its
own too), or any file through Browse. Files are sorted into shows and
movies by their names and folders: one show split over several folders
is one show, OP/ED and specials go under its **Extras**, a folder like
"Show (Season 3 - Alicization)" gives the season and its name. Shows
are looked up on AniList for their proper name, cover and banner. A
show's page has its seasons and episodes and a button for the next one
after what you last watched here; when episodes are missing on the way
it says so ("66–107 aren't in your folders — skip to 108?"). An
episode you put up continues with the next one at its end (see Up
next). Matched
the wrong show? **Wrong show?** searches AniList and pins the right one
for the show everywhere or just one folder; **Episode numbers…** says
which episode the first file is when the files count differently. Both
stay through rescans. The others stream what you put up from you, and
see its proper name too.
Put it up and the player opens. With nobody else in the server, the
buttons say Play and a link plays straight from the site.
### Up next (the queue)
Anything can wait its turn instead of going up
now: **Add to queue** on the Link page (in the way you picked), or
right-click a Jellyfin card or episode, a video in Files, a recent link
or an episode on the Schedule for **Add to queue** / **Play next**; the
Jellyfin details panel has a queue button (right-click it to play next),
and the card for a dropped or pasted video or link has both. A link is
checked first, like Put it up. Every app keeps the next item ready,
its first seconds already loaded, so skipping to it starts at once.
For a link passed on, a member's file or a link the server fetches,
the server starts getting the next item while the current one plays
(from the app of whoever added it, or by fetching the link itself), so
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
you added stays when you leave.
In the player, Up next sits in the top bar, right of the title: the
next item's picture and title (or what continues the episode). Rest the mouse on
it for a larger preview (who added it, its length, whether it is ready
to start); ⏭ plays it now, a click opens the queue: drag rows to reorder, hover a
row to play it now (▶) or remove it (×), right-click it for more (copy its
title or link, open it in the browser or in Jellyfin). To add, press
Ctrl+V with the queue open (a copied link or video file goes in at once,
its title and picture showing while it is checked), type one after
**+ Add link**, or drop a link or a video on it. When a video ends, a card shows
what is up next with a 10 s countdown everyone sees, and it plays;
Cancel stops it for everyone, and so does seeking back. `>` or Shift+N
plays it at once. Under the queue, **Continues with** shows what
continues the episode playing: its next episode (a Jellyfin episode's,
or a file's next one in your Files library, saying which numbers it
skips), added by whoever put the episode up, and greyed because it
plays only once Up next is empty; **Clear** drops it for everyone. The
further episodes of a Jellyfin series show under it; **+** adds one.
A link you pass on ("through me") or a file of yours can only be played
by your app: if you left when its turn comes, it is skipped ("Skipped …:
Foxyz left"). If you are only gone for a moment, the countdown waits for
you. A Jellyfin item needs someone with an account on that server. Up
next is kept by the server while it runs. If the server restarts, the
apps put back what was playing, where it was, and Up next with it (a
link the server fetched has to be added again).
## Schedule
The week's airing anime, one column per day (AniList), each episode
marked with what the Jellyfin server has of it when AnimeFlow is
connected (Settings, Jellyfin & integrations): **on Jellyfin** (Put it
up, right-click to queue), **downloading**, **waiting for release**
(aired in Japan, AnimeFlow downloads it by itself once a release
appears; with when AnimeFlow last searched, or **release rejected** when
it found releases it won't take, the reason on hover), **failed**, **not monitored** (Request asks AnimeFlow for it),
or the time left until it airs. A show AnimeFlow does not follow can be asked for with **Request
series**. Episodes you already watched on Jellyfin carry a check on the
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 |
@@ -67,64 +342,149 @@ then everyone (you included) uses Join.
| `--name` | `server` | name reported by `/health` | | `--name` | `server` | name reported by `/health` |
| `--media` | none | local file to serve as the starting video | | `--media` | none | local file to serve as the starting video |
| `--advertise-host` | none | host put in file URLs instead of the one each viewer dialled | | `--advertise-host` | none | host put in file URLs instead of the one each viewer dialled |
| `--config` | none | YAML or JSON file with the same settings (`port`, `secret`, `name`, `mediaPath`, `advertiseHost`); replaces the flags | | `--[no-]yt-dlp` | on | read web pages (YouTube, X, …) with yt-dlp when the server fetches a link |
| `--yt-dlp-dir` | next to the binary | writable folder for the server's yt-dlp (systemd: the unit's `StateDirectory`) |
| `--yt-dlp-cookies` | none | cookies.txt or cookie-extension JSON export for that yt-dlp (sites that refuse guests); copied into the yt-dlp folder at start, removed from it when not given. **An account login**: keep it private, use a throwaway account for YouTube |
| `--log-dir` | `logs` next to `--config`, else next to the binary (systemd: the unit's `StateDirectory`) | folder for the log files (`server-YYYY-MM-DD.log` and the session log `session-YYYY-MM-DD.log`, kept 7 days; passwords, tokens and cookies left out) |
| `--config` | none | YAML or JSON file with the same settings (`port`, `secret`, `name`, `mediaPath`, `advertiseHost`, `ytDlp`, `ytDlpDir`, `ytDlpCookies`); replaces the flags |
| `--upgrade` | | update the install folder to the latest release ([Install and update](#install-and-update)), then exit |
| `--check-update` | | print whether a newer release exists, then exit |
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: anyone can kick anyone. 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
app's recent log (passwords, tokens and cookies left out) on the
clipboard: paste it to whoever helps you, from both sides if you can.
While you are in a server, the app also sends its log to that server, which
merges everyone's lines and its own into one session log
(`session-YYYY-MM-DD.log` next to the server's logs; your own server
keeps it in the app's logs folder), so a problem can be read afterwards from every
side. Chat is never logged anywhere, nor passwords, tokens and cookies.
When something goes wrong, press **Mark a problem** (Ctrl+Shift+B, also in
the player's ⚙ and Settings → About) and add a few words if you like: a
marker with your name goes in the session log at that moment. In a server,
Copy diagnostics can add the session log of the last 6 hours (any member
may read it). Don't want to share? Settings → About → **Share diagnostics
with the server** off: your log stays on your machine (the server only
notes that you don't share; a problem you mark still goes in).
## Join ## Join
Home, Join tab. Enter the host's address, port, and the password if there Join a friend in the strip. Servers you got into are listed first (up
is one, then Enter or Join. Your name and avatar come from your profile (top to 6), with live status (online and how many are watching, or offline,
bar chip or Settings). Servers you got into appear as up to 6 Recent server plus "needs update" when its version and yours do not match): click one
tiles with live status (online and how many are watching, or offline, plus to rejoin, x to forget it. Or paste the invite (or the address and port)
"needs update" when the server runs another protocol): click one to rejoin, and the password if there is one, then Enter or Join.
x to forget it. Ctrl+1 / Ctrl+2 switch between Join and Host.
Faster: click a friend's `starsync://` link, or copy their invite and
switch to Starsync. The app asks "Join Bob's server (203.0.113.7:8080)?"
with the server's version and who is watching; Join joins (and adds it
to your recent servers), Not now leaves it (a copied invite is then not
offered again). The clipboard is only looked at for an invite when the
window comes to the front, and never saved or logged. The app runs once:
a link clicked while it runs goes to the open window. Before connecting
the app checks the server's version: when they do not match it says who
has to update ("Bob's server runs Starsync 1.2.1 — they need to
update"), and offers the update when it is you. Your name and picture
come from your profile (your avatar at the top left). Leave goes back to
your own server.
## Settings ## Settings
Profile, hardware decoding (auto, off or custom; the env var The Settings tab (Ctrl+,) has a page per topic, each setting with a line
`SYNC_RELAY_HWDEC` overrides it; applies on the next join or Reload video), of help: Profile; Party & network (your server's port and password for
"Experimental: mpv-style controls" (below), updates, playing the next Invite, recent servers with remove); Player (below); Jellyfin &
Jellyfin episode automatically, video shaders (see below), and the integrations (linked Jellyfin servers, below, the AnimeFlow connection:
keyboard shortcuts (read-only). "connected as <name>" with what you may do, or why not, and playing the
Integrations links Jellyfin servers (below). About shows the version. next episode automatically); Notifications & sounds (what pops up, chat
pop-ups, what reaches you while sitting out or in the background; the sound
volume and sound set, and every sound with a preview, its own set and a switch); Updates; Keyboard shortcuts (read-only); About
(the version, also at the bottom of every Settings page and in the
Settings tab's tooltip; and the credits, e.g. that the Minimal player design is inspired by uosc).
Keys in a session: Space = ready / unready, Tab = side panel (people, log, Player settings live in the player too: ⚙ → Player settings opens a
chat, change video), Left/Right = seek 10 s, Shift+Right = skip 1:30, narrow panel at the right edge of the player that stays open while you
PgUp/PgDn = previous / next chapter (as in mpv), F = fullscreen, Esc = watch (the controls hide and show as usual) until its × or Esc. Its
close panel or ask to leave, Ctrl+R = reload video, `<` / `>` or Shift+P / sections fold: tracks (subtitles, audio), subtitles (size, above the
Shift+N = previous / next Jellyfin episode, Ctrl+1..6 = Anime4K mode A, B, controls), picture (upscaling shader, sharp video, hardware decoding),
C, A+A, B+B, C+A, Ctrl+0 = shaders off. Every seek, skip and chapter jump sound (volume), notifications (toasts or danmaku and the danmaku look)
moves everyone. and controls (design, progress line). Settings → Player has the same
ones (plus the controls' layout and the custom shader files). Player
design: Standard or Minimal (below); progress line when hidden (on by
default: a thin line at the bottom of the video while the controls are
hidden); upscaling shader (see below); hardware decoding (auto, off or
custom; auto is mpv's `auto-copy` on Windows, `auto` elsewhere; the env
var `SYNC_RELAY_HWDEC` overrides it); subtitle size; subtitles above the
controls (off by default: bottom lines move up while the control bar
shows; the quick chat box always moves them). Settings → Player → Controls: time display (elapsed / total
or remaining), controls size (normal or large), hide the controls after
2, 3 or 5 s, and which optional buttons sit in the bar (chapter buttons,
subtitles, audio, chat; hidden ones are still in ⚙). Every change
applies right away.
Controls: the seek bar marks chapter starts with a diamond; hovering it Anywhere in a server (unless you are typing): Space = ready / unready,
shows the time and the chapter title. The chapters button lists them Tab = chat & activity (Tab never moves between buttons). Anywhere:
(current one checked; click to jump). "1:30" skips ahead 1:30. Audio and Ctrl+Shift+B = mark a problem in the session log. Keys while the
Subtitles buttons (with the track count) list tracks as "Japanese · E-AC3 player fills the window: Left/Right = seek 10 s, Up/Down = volume, Shift+Right = skip 1:30,
2.0 · title"; subtitles have Off. PgUp/PgDn = previous / next chapter (as in mpv), F = fullscreen, Enter =
quick chat, Esc = close the player panel, then
the chat, then fullscreen, then shrink the player to mini; from the
mini it asks to leave, Ctrl+R = reload video,
`<` / `>` or Shift+P / Shift+N = previous / next Jellyfin episode,
Ctrl+1..6 = Anime4K mode A, B, C, A+A, B+B, C+A, Ctrl+0 = shaders off.
Every seek, skip and chapter jump moves everyone.
Experimental mpv-style controls (Settings → Player, off by default, Controls: both designs have the same buttons in the same places, only
switches right away): video controls modeled on mpv's uosc theme. A thick the look differs. Bottom left: play / pause first (it is your ready, like
timeline across the bottom shows the played part, the cached part ahead Space: ✓ while you are ready and the others are not), previous / next
(hatched), chapter starts as diamonds and the time; hovering it shows the episode (when there is one; next plays Up next first), previous / next
chapter title and the time there. With the controls hidden it stays as a chapter (when the video has chapters), volume and the time (click it to
thin line. Above it flat buttons: ≡ menu, Subtitles and Audio (with the switch between elapsed / total and the time left). Bottom right:
track count), the shader badge (the active Anime4K mode, e.g. "A HQ"), Subtitles and Audio (with the track count; tracks listed as "Japanese ·
ready, chapters, 1:30, previous / next episode, change video, people, E-AC3 2.0 · title", subtitles with Off), ⚙, chat and fullscreen last.
volume and fullscreen. Keys and skip buttons work the same. 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.
Skipping: during the opening a "Skip OP" button floats in the corner (it Standard (the default): a seek bar that marks chapter starts with a
diamond (hovering it shows the time and the chapter title) over a row
of round buttons. Minimal (⚙ → Player settings → Player design, switches
right away): a translucent timeline across the bottom tints what has
played, stripes the part not downloaded yet, marks chapter starts with
dots (click one to jump), shows the elapsed time left and the total or
the time left right (click it to switch) and, while less than a minute
is buffered, the buffered seconds next to the position; hovering it shows
the time there and the chapter title. With the controls hidden it stays
as a thin line (unless "Progress line when hidden" is off). Its buttons
sit in two glass capsules above it. Minimal's timeline and controls are
inspired by [uosc](https://github.com/tomasklaen/uosc) by tomasklaen
(LGPL-2.1).
Skipping: during the opening a "Skip OP" button shows bottom right, just
above the controls (it
jumps to the end of the opening), during the ending "Skip ED" (to the end jumps to the end of the opening), during the ending "Skip ED" (to the end
of the ending only; what comes after is yours to skip). The opening and of the ending only; what comes after is yours to skip). "Skip OP" and
"Skip 1:30" show for 5 seconds when they come up, then only while the
controls are up (move the mouse); "Skip ED" and "Next episode" show only
while the controls are up. The opening and
ending come from Jellyfin's intro / outro segments when your Jellyfin has ending come from Jellyfin's intro / outro segments when your Jellyfin has
them (needs a segment plugin, e.g. Intro Skipper), else from chapter names them (needs a segment plugin, e.g. Intro Skipper), else from chapter names
(OP, Opening, Intro / ED, Ending, Credits, Outro). Without an opening, on (OP, Opening, Intro / ED, Ending, Credits, Outro). Without an opening or
anything longer than 20 minutes, "Skip 1:30" floats there during the first any chapters, on anything longer than 20 minutes, "Skip 1:30" floats there
6 minutes. during the first 6 minutes.
## Ports ## Ports
@@ -133,33 +493,32 @@ anything longer than 20 minutes, "Skip 1:30" floats there during the first
| Sync WebSocket and `/health` | TCP 8080 | plain `ws://`, no TLS | | Sync WebSocket and `/health` | TCP 8080 | plain `ws://`, no TLS |
| Local file streaming | same port | path `/m/...`, needs a session token | | Local file streaming | same port | path `/m/...`, needs a session token |
Only the host needs the port reachable. Only the one who invites needs the port reachable; until Invite your
server listens on 127.0.0.1 and a free port only.
## Jellyfin ## Jellyfin
Integrations > Jellyfin > Add server: type the address, then sign in with Settings > Jellyfin & integrations > Add server: type the address, then sign in with
Quick Connect (the app shows a code; approve it in Jellyfin under Profile > Quick Connect (the app shows a code; approve it in Jellyfin under Profile >
Quick Connect) or your username and password. Several servers can be Quick Connect) or your username and password. Several servers can be
linked; each shows its status, and a star picks the default. Sign out linked; each shows its status, and a star picks the default. Sign out
revokes the app's sign-in on the server. If a sign-in expires the server revokes the app's sign-in on the server. If a sign-in expires the server
shows "Reconnect" instead of disappearing. shows "Reconnect" instead of disappearing.
The Host tab's Jellyfin source is a library browser: Continue watching (with The Library's Jellyfin source is a library browser: Continue watching (with
time left), Next up and the latest per library on the start page; library time left), Next up and the latest per library on the start page; library
grids with sort (name, date added, release date, rating, random) and grids with sort (name, date added, release date, rating, random) and
Unwatched / Favorites filters; series pages with seasons, episode progress Unwatched / Favorites filters; series pages with seasons, episode progress
and a "Next up" button; details with the file's tracks; search. Clicking a and a "Next up" button; details with the file's tracks; search. Clicking a
movie or episode picks it; the button in the top right enlarges the browser movie or episode picks it. On wide windows a side pane shows details of
and moves port, password and Host into a bar below it. On wide windows a whatever is under the mouse.
side pane shows details of whatever is under the mouse.
Keys in the browser: arrows move, Enter picks or opens, I = details, Keys in the browser: arrows move, Enter picks or opens, I = details,
Esc / Backspace / Alt+Left = back (Esc first clears a search), / or Ctrl+F = Esc / Backspace / Alt+Left = back (Esc first clears a search), / or Ctrl+F =
search. search.
Host from the library: pick a movie or episode and Host. Only the item Putting up a movie or episode sends only the item to the server (which
goes to the server (which Jellyfin server and which item), never a link or Jellyfin server and which item), never a link or your sign-in. Pasting a Jellyfin link (web page or stream link) does the
your sign-in. Pasting a Jellyfin link (web page or stream link) does the
same when the app can reach that Jellyfin; otherwise it is shared as a same when the app can reach that Jellyfin; otherwise it is shared as a
plain link. plain link.
@@ -168,33 +527,93 @@ Watching:
directly with their own sign-in. Jellyfin tracks progress per account: directly with their own sign-in. Jellyfin tracks progress per account:
your watched state, Continue watching and resume point follow what you your watched state, Continue watching and resume point follow what you
saw (reported on start, every 10 s, on pause, resume, seek and track saw (reported on start, every 10 s, on pause, resume, seek and track
change, and when it stops). change, and when it stops). Watched means you were there while most
- Friends without an account watch through the host: the server fetches of it played (or while half of it played and it reached the ending),
the file with the host's sign-in and passes it on (only the current item, not where it stopped: time you sat out does not count, skipping around
only to people in the session). They show "via host" in the member list. does not count, opening a watched episode to check a moment keeps it
A dedicated server relays when started with `--jellyfin <file>` (see watched, and a quick peek leaves no resume point. Files and links you
[docs/ARCHITECTURE.md](docs/ARCHITECTURE.md), Jellyfin relay). watch are remembered the same way on your computer (a show's next
- Without either, the app says "This needs a Jellyfin account on ..." with episode in Files follows them).
a button to link one; it plays as soon as you are signed in. - Friends without an account watch through the server: whoever put the
item up passes it on automatically, with their own sign-in, which never
leaves their computer (only the current item, only to people in the
session). They show "via server" in the member list (click the avatars). On your
own server (with your sign-in on that item's Jellyfin, whichever of
your linked servers it is), or a dedicated server with its own login (`--jellyfin <file>`, see
[docs/ARCHITECTURE.md](docs/ARCHITECTURE.md), Jellyfin relay), the server
fetches it itself instead.
- If nobody can pass it on (the person who put it up left), the app says
"No Jellyfin account here for ..." with a button to link one; it plays as
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. When an minutes) a "Next episode" button floats in the corner while the controls
episode ends, a card shows what is next with Play next; with "Play the are up. With "Play the next episode automatically" (Settings, on by
next episode automatically" (Settings, on by default) it counts down 10 s default), the episode you put up continues with the next one: when it
first, and Cancel stops it on your screen (seeking back stops it for ends, everyone sees a 10 s countdown, and anyone can cancel it (see Up
everyone). Anyone with an account can put the next one up; friends next). Anyone with an account can put the next one up; friends watching
watching via the host cannot. via the host cannot.
Audio and subtitles: the Audio and Subtitles buttons in the controls pick tracks. Audio and subtitles: the Audio and Subtitles buttons in the controls pick tracks.
Your choice is remembered per series (and as a default) and the closest Your choice is remembered per series (and as a default) and the closest
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 session panel (Tab) opens the same picker as the Host ⚙ → Change video in the player opens the Library tab (the player
tab: the library starts on the series you are watching with the current goes mini): the library starts on the series you are watching with the
season, else where you left it. File is offered to the host only. current season, else where you left it.
Anyone can put up a file from their own computer, also on a dedicated
server: it streams from your computer through the server to everyone (the
server keeps a few minutes in memory, so seeks and skipping the opening
are quick). Keep the app open while it plays. A link goes up "Through
me": your computer opens it and passes it on, so it plays for everyone
whenever it plays for you (you can't tell ahead which sites want cookies,
and YouTube may start asking any day). Under "Other ways" there are two more:
- Everyone fetches it: each app plays the link itself.
- Server fetches it: the server downloads it once for everyone, for links
that are slow or blocked for some. The server must be able to reach it,
and it must have a password (an open server refuses: anyone could make
it fetch addresses only it can reach).
Web pages work in all three: the app and the dedicated server each
download [yt-dlp](https://github.com/yt-dlp/yt-dlp) by themselves (checked
against the release's checksums, updated once a day; Settings → Player
shows its version and has "Update now"), so there is nothing to install.
Sites that refuse guests (Bilibili answers "HTTP Error 412", YouTube may
ask to sign in) need cookies: Settings → Player → yt-dlp → Cookies reads
them from a browser, or from a cookies.txt or a cookie extension's JSON
export (picked or dropped; every site in it is kept). They stay on this
computer and are used by its yt-dlp only (your own playback, "through
me", and a server you host). Cookies log yt-dlp in as you on those
sites: keep the file private, and for YouTube consider a throwaway
account (Google may flag accounts used with yt-dlp). When a page shows
no video, the message says which cookies were used: none, your file
(and whether it has any for that site), or your browser's (and how many
it has for that site), so you know whether to sign in there or add
cookies.
YouTube works in all three too, and so do pages and links that only
offer streaming playlists (HLS `.m3u8`, DASH `.mpd`, the HLS of X videos).
Through me and Server fetches it never show the link to anyone; they can't pass on live
streams or DRM-protected video (use "Everyone fetches it" for live).
Your upload has to beat the video's bitrate: a 25-minute 2 GB episode is
about 11 Mbit/s.
The member list (click the avatars) shows where the video comes from on
top: who passes it on, or that the server fetches it, everyone plays the
link, or everyone streams it from Jellyfin, with how many watch through
the server and, for a relay, how fast it arrives against what the video
needs (the bar's mark; past it is keeping up). It turns amber when the
source is too slow or its connection dropped ("Bob's connection dropped —
playing what the server has (2 min)"), red when it left (the others play
out what the server already has; the player's pill says the same and a
pop-up tells you once), and whoever passes it on is told once when their
upload is too slow. Each member's line says how they get it
("via server" with their speed, or "direct") after a dot for their
buffer: green enough to play on, amber low, red empty. A red dot on one
member while the source keeps up is their own connection.
## Video quality and shaders ## Video quality and shaders
@@ -204,10 +623,11 @@ 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. Settings → Video quality & shaders sets the default, session starts with it. ⚙ → Player settings and Settings → Player set it too,
HQ or Fast (used by Ctrl+1..6) and the custom files. Shaders cost GPU time with HQ or Fast (used by Ctrl+1..6); Settings → Player holds the custom
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
fallback they do nothing, and the app says so. A shader that is missing or fallback they do nothing, and the app says so. A shader that is missing or
fails to load shows a toast and switches shaders off. fails to load shows a toast and switches shaders off.
@@ -217,7 +637,7 @@ fails to load shows a toast and switches shaders off.
- "host offline - check address and port": wrong address, or the port is not open. - "host offline - check address and port": wrong address, or the port is not open.
- "wrong server password": password mismatch. - "wrong server password": password mismatch.
- "host runs a different version": update the app on both sides. - "host runs a different version": update the app on both sides.
- Picture frozen but sound fine: Ctrl+R reloads the video; the app also does it automatically. - Picture frozen but sound fine: Ctrl+R reloads the video.
- Everyone waits at "waiting for X": X has not buffered 4 seconds yet or has not pressed Space. - Everyone waits at "waiting for X": X has not buffered 4 seconds yet or has not pressed Space.
- Windows: the build is new; if something is off there, say so. - Windows: the build is new; if something is off there, say so.
+9
View File
@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256">
<defs><linearGradient id="bg" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#2d2547"/><stop offset="1" stop-color="#14111d"/></linearGradient><linearGradient id="t1" gradientUnits="userSpaceOnUse" x1="50.7" y1="148.7" x2="184.6" y2="71.4"><stop offset="0" stop-color="#f0795a" stop-opacity="0"/><stop offset="0.45" stop-color="#f0795a" stop-opacity="0.85"/><stop offset="1" stop-color="#ffc56b"/></linearGradient><linearGradient id="t2" gradientUnits="userSpaceOnUse" x1="205.3" y1="107.3" x2="71.4" y2="184.6"><stop offset="0" stop-color="#ffc56b" stop-opacity="0"/><stop offset="0.45" stop-color="#ffc56b" stop-opacity="0.85"/><stop offset="1" stop-color="#f0795a"/></linearGradient></defs>
<rect x="8" y="8" width="240" height="240" rx="56" fill="url(#bg)"/>
<circle cx="128" cy="128" r="80" fill="none" stroke="#ffffff" stroke-opacity="0.10" stroke-width="6"/>
<path d="M205.27 107.29 A80 80 0 0 1 71.43 184.57" fill="none" stroke="url(#t2)" stroke-width="7" stroke-linecap="butt"/><path d="M50.73 148.71 A80 80 0 0 1 184.57 71.43" fill="none" stroke="url(#t1)" stroke-width="7" stroke-linecap="butt"/>
<path d="M110 96 L110 160 L162 128 Z" fill="#fff1d6" stroke="#fff1d6" stroke-width="16" stroke-linejoin="round"/>
<path d="M71.4 161.6 Q74.7 181.3 94.4 184.6 Q74.7 187.8 71.4 207.6 Q68.2 187.8 48.4 184.6 Q68.2 181.3 71.4 161.6 Z" fill="#f0795a"/>
<path d="M184.6 40.4 Q188.9 67.1 215.6 71.4 Q188.9 75.8 184.6 102.4 Q180.2 75.8 153.6 71.4 Q180.2 67.1 184.6 40.4 Z" fill="#ffc56b"/>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 732 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

+8 -14
View File
@@ -1,28 +1,22 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:signals/signals_flutter.dart';
import 'src/screens/home/home_screen.dart';
import 'src/screens/home/ui.dart'; import 'src/screens/home/ui.dart';
import 'src/screens/session_screen.dart'; import 'src/shell/app_shell.dart';
import 'src/state/session_state.dart'; import 'src/widgets/app_keys.dart';
import 'src/widgets/app_menu.dart';
class WatchTogetherApp extends StatelessWidget { class WatchTogetherApp extends StatelessWidget {
const WatchTogetherApp({super.key}); const WatchTogetherApp({super.key});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final session = SessionState.instance;
return MaterialApp( return MaterialApp(
title: appName, title: appName,
theme: ThemeData.dark(), // Material's own menus (the player's track menus, sort) wear the
home: SignalBuilder( // app menu's surface.
builder: (context) { theme: AppMenuStyle.theme(ThemeData.dark()),
return switch (session.route.value) { shortcuts: appShortcuts,
AppRoute.home => const HomeScreen(), home: const AppKeys(child: AppShell()),
AppRoute.session => const SessionScreen(),
};
},
),
); );
} }
} }
+90 -40
View File
@@ -1,19 +1,35 @@
import 'dart:async';
import 'dart:io'; import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hive_ce_flutter/hive_flutter.dart'; import 'package:hive_ce_flutter/hive_flutter.dart';
import 'package:media_kit/media_kit.dart';
import 'package:sync_relay_core/core.dart'; import 'package:sync_relay_core/core.dart';
import 'package:window_manager/window_manager.dart';
import 'app.dart'; import 'app.dart';
import 'src/app/app_graph.dart';
import 'src/files/thumbnails.dart' show thumbnailPlayers;
import 'src/platform/app_log.dart';
import 'src/platform/app_shutdown.dart' show closeAppWith;
import 'src/platform/desktop/attention.dart' show exitNow, windowLaunchToken;
import 'src/platform/desktop/desktop_app_dirs.dart';
import 'src/platform/desktop/windows_api.dart' show allowSetForegroundWindow;
import 'src/platform/desktop_entry.dart';
import 'src/platform/ports/window_activity.dart';
import 'src/platform/single_instance.dart';
import 'src/platform/url_scheme.dart';
import 'src/platform/smoke.dart';
import 'src/screens/home/ui.dart'; import 'src/screens/home/ui.dart';
import 'src/state/session_state.dart'; import 'src/session/app_events.dart' show UpdateAvailable;
import 'src/session/invite.dart' show InviteLink;
import 'src/state/settings.dart'; import 'src/state/settings.dart';
import 'src/update/updater.dart'; import 'src/update/update_controller.dart' show UpdatePhase, launchArgs;
Future<void> main(List<String> args) async { Future<void> main(List<String> args) async {
launchArgs = List.unmodifiable(args); // An invite link is handled once: Update and restart does not pass it on.
launchArgs = List.unmodifiable([
for (final a in args)
if (InviteLink.parse(a) == null) a,
]);
if (args.contains('--upgrade')) { if (args.contains('--upgrade')) {
exitCode = await runUpgrade(); exitCode = await runUpgrade();
exit(exitCode); exit(exitCode);
@@ -37,47 +53,81 @@ Future<void> main(List<String> args) async {
), ),
); );
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
await windowManager.ensureInitialized(); final dirs = await const DesktopAppDirs().resolve();
await windowManager.setPreventClose(true); // One app per profile: a second start (a clicked starsync:// link) hands
windowManager.addListener(_CloseGuard()); // its arguments to the running one and ends before its window shows.
const windowOptions = WindowOptions( final instance = await InstanceLock.claim(
size: Size(1280, 720), Directory(dirs.hive).parent.path,
minimumSize: Size(160, 90), args: args,
center: true, activation: await windowLaunchToken(),
backgroundColor: Color(0xFF000000), beforeSend: Platform.isWindows ? allowSetForegroundWindow : null,
titleBarStyle: TitleBarStyle.hidden,
title: appName,
); );
windowManager.waitUntilReadyToShow(windowOptions, () async { if (instance == null) await exitNow();
await windowManager.show(); await startAppLogging(dirs);
await windowManager.focus(); Hive.init(dirs.hive);
});
await Hive.initFlutter();
await AppSettings.instance.ensureLoaded(); await AppSettings.instance.ensureLoaded();
// The composition root: every system, made once.
final graph = AppGraph.build();
final ports = graph.ports;
// Close button / Alt+F4: the orderly close ([shutDownApp]), so others
// see "left", your server shuts down and no player outlives the engine.
final close = closeAppWith(graph, instance: instance);
await ports.shell.start(
title: dirs.profile == null ? appName : '$appName [${dirs.profile}]', onCloseRequest: () => close());
// Focus, minimise and restore feed auto sit-out and the Notifier (in
// the background, notices go out as system notifications).
// Coming to the front also looks for an invite on the clipboard (the
// join prompt; nothing of it is logged).
ports.shell.watchActivity((a) {
graph.coordinator.windowActivity(a);
graph.notifier.window = a;
if (a == WindowActivity.focused) {
unawaited(ports.shell.readClipboardText().then(graph.joinOffers.fromClipboard));
}
});
try { try {
await SessionState.instance.loadForm(); await graph.forms.load();
} catch (e, st) { } catch (e, st) {
debugPrint('load connect form failed: $e\n$st'); debugPrint('load connect form failed: $e\n$st');
} }
MediaKit.ensureInitialized(); // The library's matches, pins and offsets, and the watch history
runApp(const WatchTogetherApp()); // (device-owned).
} unawaited(graph.library.ensureLoaded());
unawaited(graph.watch.history.ensureLoaded());
/// Close button / Alt+F4: leave gracefully first (others see "left", a ports.players.init();
/// hosted server shuts down), bounded so a hung socket can't keep the thumbnailPlayers = ports.players;
/// window open. // yt-dlp for web page links: downloaded on first run, kept current.
class _CloseGuard with WindowListener { ports.links.warmUp();
bool _closing = false; // Linux: the launcher entry and icon Wayland needs (release installs).
unawaited(installDesktopEntry());
@override // Windows: starsync:// links start this install (HKCU, release builds).
Future<void> onWindowClose() async { unawaited(registerWindowsScheme());
if (_closing) return; // A found update is an event too (the band shows it).
_closing = true; final updates = graph.updates;
try { updates.status.addListener(() {
await SessionState.instance.leave().timeout(const Duration(seconds: 2)); final s = updates.status.value;
} catch (e) { final version = s.manifest?.version;
debugPrint('leave on close failed: $e'); if (s.phase == UpdatePhase.available && version != null) {
graph.notifier.notify(UpdateAvailable(version));
} }
await windowManager.destroy(); });
runApp(AppScope(graph: graph, child: const WatchTogetherApp()));
// CI's close smoke run: open a file, play, close (see smoke.dart).
final smoke = smokeMedia;
if (smoke != null) {
unawaited(WidgetsBinding.instance.endOfFrame.then((_) => runSmoke(graph, smoke)));
} }
// Home: your own server, quietly (only this app can reach it until
// Invite).
unawaited(graph.navigation.start());
// A clicked invite link: the one this app started with, then each one a
// later start hands over (the window comes to the front for it).
graph.joinOffers.fromArgs(args);
instance.handoffs.listen((h) {
unawaited(ports.shell.focus(activation: h.activation));
graph.joinOffers.fromArgs(h.args);
});
unawaited(ports.shell.readClipboardText().then(graph.joinOffers.fromClipboard));
// The sound device opens once the window is up, not on the first sound.
Future.delayed(const Duration(seconds: 2), graph.sfx.warmUp);
} }
+220
View File
@@ -0,0 +1,220 @@
import 'package:flutter/widgets.dart';
import '../jellyfin/accounts.dart';
import '../library/library_store.dart';
import '../library/local_next.dart';
import '../library/show_matcher.dart';
import '../platform/app_log.dart' show appLogDir;
import '../platform/desktop/desktop_ports.dart';
import '../platform/ports/ports.dart';
import '../schedule/anilist.dart';
import '../schedule/animeflow.dart';
import '../screens/home/health_probe.dart';
import '../session/connection.dart' show Connection;
import '../session/identity.dart' show ClientIdentity, Profile;
import '../session/join_offers.dart';
import '../session/navigation.dart';
import '../session/notifier.dart';
import '../session/session_coordinator.dart';
import '../session/your_server.dart';
import '../sfx/sfx.dart';
import '../sfx/sound_bank.dart';
import '../sfx/synth_sfx.dart';
import '../state/connect_prefs.dart';
import '../state/settings.dart';
import '../update/update_controller.dart';
import '../update/whats_new.dart';
import '../watch/watch_history.dart';
import '../watch/watch_tracker.dart';
/// The composition root: builds the app's systems once (in `main`), wires
/// them through their constructors and disposes them. No logic and no
/// signals of its own. Widgets reach it through [AppScope].
class AppGraph {
/// The platform (window, links, folders, yt-dlp, notifications).
final Ports ports;
/// Owns the app's audio device ([SynthSfx.dispose] on close).
final SynthSfx sfx;
/// Every toast, sound, system notification and activity-log line.
final Notifier notifier;
/// The link to whichever server this app is in.
final Connection connection;
final SessionCoordinator coordinator;
/// The connect form and recent servers.
final ConnectStore forms;
/// This app's own server, alive for the whole run.
final YourServer yourServer;
/// Home (your server) or a friend's.
final Navigation navigation;
/// The library's matches (L2): local shows to AniList, pins, offsets.
final ShowMatcher library;
/// Watch tracking and the device's watch history (D3).
final WatchTracker watch;
/// The app's updater (checks, the update band, install and restart).
final UpdateController updates;
/// What's new once after an update; keeps the notes the updater saves.
final WhatsNew whatsNew;
/// The join prompt for a clicked invite link or one on the clipboard.
final JoinOffers joinOffers;
AppGraph._({
required this.ports,
required this.sfx,
required this.notifier,
required this.connection,
required this.coordinator,
required this.forms,
required this.yourServer,
required this.navigation,
required this.library,
required this.watch,
required this.updates,
required this.whatsNew,
required this.joinOffers,
});
/// The desktop app's graph. Tests pass their own [ports], [identity]
/// (default: the install's stored one), [healthGet], server starter and
/// internet address lookup ([publicAddress]).
factory AppGraph.build({
Ports? ports,
ClientIdentity? identity,
HealthGetter healthGet = defaultHealthGet,
ServerStarter? serverStarter,
JellyfinAccounts? jellyfin,
ShowMatcher? library,
WatchHistory? history,
Future<String?> Function()? publicAddress,
}) {
final p = ports ?? desktopPorts();
final settings = AppSettings.instance;
final sfx = SynthSfx(
volume: () => settings.soundVolume.peek(),
// A nudge is an explicit poke: it ignores its switch (not the volume).
enabled: (k) => k == SfxKind.nudge || !settings.soundsOff.peek().contains(k.name),
presetFor: soundPresetFor,
);
final notifier = Notifier(
sfx: sfx,
attention: p.shell.requestAttention,
system: p.notifications,
focusWindow: p.shell.focus,
prefs: () => settings.notifyPrefs,
);
final connection = Connection(notify: notifier.notify, identity: identity ?? ClientIdentity.stored());
// Device-owned: the watch history box.
final watch = WatchTracker(history: history ?? WatchHistory());
final coordinator = SessionCoordinator(
notifier: notifier,
connection: connection,
ytDlp: p.links,
jellyfin: jellyfin,
players: p.players,
settings: settings,
watch: watch);
// Device-owned: the library box; AnimeFlow's runs when signed in.
final matcher = library ??
ShowMatcher(
store: HiveLibraryStore(),
aniList: AniListClient(),
flow: AnimeFlowLookup(AnimeFlowClient.fromAccounts(coordinator.jellyfin)),
);
coordinator
..fileRef = matcher.refForPath
..nextFile = LocalNextFinder(matcher: matcher, scan: p.folders.scan).nextAfter;
final forms = ConnectStore();
final yourServer = YourServer(
forms: forms,
jellyfin: jellyfin,
// The app's yt-dlp folder; '' (the server's own default) when the
// app-support folder is not available.
ytDlpDir: () async {
try {
return (await p.links.ytDlp).dir;
} catch (_) {
return '';
}
},
logDir: () => appLogDir,
starter: serverStarter,
publicAddress: publicAddress,
);
final navigation = Navigation(
coordinator: coordinator,
yourServer: yourServer,
forms: forms,
healthGet: healthGet,
);
// The profile keeps its name in the connect form and pushes changes
// to whichever server this app is in.
Profile.instance
..forms = forms
..push = ({String? name, String? avatar}) {
if (navigation.online.peek()) coordinator.updateProfile(name: name, avatar: avatar);
};
final updates = UpdateController.instance;
final whatsNew = WhatsNew(store: SettingsWhatsNewStore(settings));
updates.beforeRestart ??= whatsNew.installing;
updates.channelOf ??= () => settings.updateChannel.peek();
return AppGraph._(
ports: p,
sfx: sfx,
notifier: notifier,
connection: connection,
coordinator: coordinator,
forms: forms,
yourServer: yourServer,
navigation: navigation,
library: matcher,
watch: watch,
updates: updates,
whatsNew: whatsNew,
joinOffers: JoinOffers(navigation: navigation, yourServer: yourServer, forms: forms),
);
}
Future<void> dispose() async {
navigation.dispose();
await coordinator.dispose();
await watch.dispose();
await yourServer.stop();
await sfx.dispose();
}
}
/// The sound set [kind] plays from: its own pick in Settings, else the
/// overall one.
SoundPreset soundPresetFor(SfxKind kind) {
final s = AppSettings.instance;
final name = s.soundPresetFor.peek()[kind.name] ?? s.soundPreset.peek();
return SoundPreset.values.where((p) => p.name == name).firstOrNull ?? defaultPreset;
}
/// Hands the [AppGraph] to every widget below: `AppScope.of(context)`.
/// The graph never changes, so reading it registers no dependency (safe
/// in `initState`).
class AppScope extends InheritedWidget {
final AppGraph graph;
const AppScope({super.key, required this.graph, required super.child});
static AppGraph of(BuildContext context) {
final scope = context.getInheritedWidgetOfExactType<AppScope>();
assert(scope != null, 'no AppScope above this widget');
return scope!.graph;
}
@override
bool updateShouldNotify(AppScope oldWidget) => !identical(graph, oldWidget.graph);
}
@@ -0,0 +1,135 @@
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';
// 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(context, 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:
model.togglePanel();
case ControlAction.leave:
model.leave();
}
}
@@ -0,0 +1,75 @@
import 'dart:async';
import 'package:flutter/material.dart';
/// What keeps the player's controls up besides the mouse on them: the
/// mouse on a floating element over the picture (a pill, a card, the
/// status pill: [ControlsHoldRegion]) or an open popover. While anything
/// holds, the controls stay up; on release they hide on their usual
/// countdown. Both skins follow it (through `ControlsFrame`).
class ControlsHold extends ChangeNotifier {
final _by = <Object>{};
bool _told = false;
bool _queued = false;
bool _disposed = false;
bool get held => _told;
/// [who] holds ([on]) or lets go. Listeners hear it in a microtask, never
/// during a build or a dispose.
void set(Object who, bool on) {
if (on ? !_by.add(who) : !_by.remove(who)) return;
if (_queued) return;
_queued = true;
scheduleMicrotask(() {
_queued = false;
if (_disposed) return;
final now = _by.isNotEmpty;
if (now == _told) return;
_told = now;
notifyListeners();
});
}
@override
void dispose() {
_disposed = true;
super.dispose();
}
}
/// The mouse on [child] counts as the mouse on the controls: it holds
/// [hold] while it is there.
class ControlsHoldRegion extends StatefulWidget {
final ControlsHold? hold;
final Widget child;
const ControlsHoldRegion({super.key, required this.hold, required this.child});
@override
State<ControlsHoldRegion> createState() => _ControlsHoldRegionState();
}
class _ControlsHoldRegionState extends State<ControlsHoldRegion> {
@override
void didUpdateWidget(ControlsHoldRegion oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.hold != widget.hold) oldWidget.hold?.set(this, false);
}
@override
void dispose() {
widget.hold?.set(this, false);
super.dispose();
}
@override
Widget build(BuildContext context) {
final hold = widget.hold;
if (hold == null) return widget.child;
return MouseRegion(
onEnter: (_) => hold.set(this, true),
onExit: (_) => hold.set(this, false),
child: widget.child,
);
}
}
+253
View File
@@ -0,0 +1,253 @@
import 'dart:async';
import 'package:flutter/foundation.dart' show ValueListenable;
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). Only real mouse movement
// wakes them: a widget appearing or going under a still pointer (the
// quick chat box) does not, nor does typing. [ControlsFrame.suppress]
// hides them while something takes their place (the quick chat box).
class ControlsFrame extends StatefulWidget {
/// Draws the controls; [visible] false while they are hidden.
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;
/// While true the controls are drawn hidden and [shown] says so; the
/// cursor still follows the mouse. They come back as they would have
/// been once it is false again.
final ValueListenable<bool>? suppress;
const ControlsFrame({
super.key,
required this.builder,
this.onDoubleClick,
this.barTop = 0,
this.barBottom = 0,
this.hideAfter = const Duration(seconds: 3),
this.shown,
this.holdBy,
this.suppress,
});
/// The frame around [context], for widgets that keep it up (a drag, a
/// 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;
// Where the mouse was last seen, to tell real movement from a widget
// appearing or going under it.
Offset? _lastPos;
bool get _suppressed => widget.suppress?.value ?? false;
/// The controls are drawn (up and not suppressed).
bool get visible => _visible && !_suppressed;
@override
void initState() {
super.initState();
widget.holdBy?.addListener(_holdByChanged);
widget.suppress?.addListener(_suppressChanged);
// Not during this build: an overlay listening to [shown] may be built.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) widget.shown?.value = visible;
});
_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.suppress != widget.suppress) {
oldWidget.suppress?.removeListener(_suppressChanged);
widget.suppress?.addListener(_suppressChanged);
}
if (oldWidget.hideAfter != widget.hideAfter && _visible) _hideLater();
}
@override
void dispose() {
widget.holdBy?.removeListener(_holdByChanged);
widget.suppress?.removeListener(_suppressChanged);
_timer?.cancel();
super.dispose();
}
void _holdByChanged() {
final on = widget.holdBy?.held ?? false;
if (on == _heldBy || !mounted) return;
_heldBy = on;
hold(on);
}
void _suppressChanged() {
if (!mounted) return;
setState(() {});
widget.shown?.value = visible;
}
/// Show the controls and restart the hide countdown.
void wake() {
if (!_visible && mounted) {
setState(() => _visible = true);
widget.shown?.value = visible;
}
_hideLater();
}
/// A hover or enter at [pos]: wakes only when the mouse really moved.
void _moved(Offset pos) {
final last = _lastPos;
_lastPos = pos;
if (last != null && (pos - last).distance < 0.5) return;
wake();
}
/// Keep the controls up while [on] (paired calls).
void hold(bool on) {
_holds = on ? _holds + 1 : (_holds - 1).clamp(0, 1 << 20);
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: (e) => _moved(e.position),
onHover: (e) => _moved(e.position),
onExit: (e) {
_lastPos = e.position;
_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, 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,579 @@
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 played part's accent tint.
static const played = 0.4;
/// 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 same colours shown and hidden: hidden, it is the band shrunk to
// a line, so growing and shrinking never switches colour.
canvas.drawRect(band, Paint()..color = MinimalStyle.band);
canvas.drawRect(Rect.fromLTRB(0, 0, x, h), Paint()..color = accent.withValues(alpha: MinimalTimelineSpec.played));
_stripes(canvas, w, h);
_chapters(canvas, w, h);
if (visibility > 0) {
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()
// Stronger on the line, where only a few pixels of each show.
..color = Colors.white.withValues(alpha: 0.12 + 0.22 * (1 - visibility))
..strokeWidth = 1.5 * scale;
for (var c = (ax / period).floorToDouble() * period - h; c <= w; c += period) {
canvas.drawLine(Offset(c, h), Offset(c + h, 0), paint);
}
canvas.restore();
}
void _chapters(Canvas canvas, double w, double h) {
final cut = Paint()..color = Colors.black.withValues(alpha: 0.3);
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 alone;
/// 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.alone = 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.alone,
_ => 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.alone ? '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,223 @@
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/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 queued = queue.items.value.firstOrNull;
final continues = queue.continues.value;
final next = coord.nextEpisode.value;
return ControlsSnapshot(
play: play.value,
previousLabel: coord.previousEpisode.value?.label,
// Up next's first item, else the next episode, else what continues
// this (a file's next episode).
nextLabel: queued?.title ?? next?.label ?? continues?.title,
nextIsUpNext: queued != null,
hasChapters: chapters.value.isNotEmpty,
hasDuration: duration.value > Duration.zero,
alone: coord.alone.value,
// 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;
/// The player panel docked at the right (⚙ → Player settings; Esc or
/// its × closes it). Stays open while the controls hide and show.
final panelOpen = signal(false);
void togglePanel() => panelOpen.value = !panelOpen.peek();
void dispose() => volumeFlash.dispose();
// -------------------------------------------------------------- actions
/// 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.userSeek(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);
/// What plays next (Up next's first item, else the continuation), else
/// the next episode. False when neither.
bool next() {
if (queue.head.peek() != null) {
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.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(coord, 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;
@@ -0,0 +1,454 @@
import 'package:flutter/material.dart';
import 'package:signals/signals_flutter.dart';
import 'package:sync_relay_core/core.dart' show JellyfinStreamType;
import '../app/app_graph.dart';
import '../danmaku/danmaku_settings.dart' show DanmakuKnobs, NotifyStyleSelect;
import '../playback/playback_controller.dart' show RenderMode;
import '../shaders/shader_presets.dart';
import '../shell/mark_problem.dart' show MarkProblemButton;
import '../shell/motion.dart';
import '../state/settings.dart';
import '../widgets/app_menu.dart';
import 'model/controls_prefs.dart';
import 'player_settings.dart' show applyHwdecLive, applyShaderChoice;
import 'volume.dart' as vol;
// The player panel (⚙ → Player settings, both skins): a narrow glass
// column docked at the right edge of the full player that stays open
// while the controls hide and show, until its × or Esc. What is useful
// while watching, in collapsible sections: tracks, subtitles, picture,
// sound, notifications (toasts or danmaku, the danmaku look) and the
// controls. Everything applies live and is saved; the rest of Settings →
// Player stays there.
/// Sections folded by the user, kept for the app's run.
final _folded = <String>{'Notifications', 'Controls'};
class PlayerPanel extends StatelessWidget {
static const width = 312.0;
final VoidCallback onClose;
/// The running player's render mode (shader and sharp video notes).
final ReadonlySignal<RenderMode>? renderMode;
/// The player's volume (0..[vol.maxVolume]) and how to set it; the
/// Sound section only with both.
final ReadonlySignal<double>? volume;
final ValueChanged<double>? onVolume;
const PlayerPanel({super.key, required this.onClose, this.renderMode, this.volume, this.onVolume});
@override
Widget build(BuildContext context) {
final graph = AppScope.of(context);
final s = graph.coordinator.settings;
return AppGlass(
key: const ValueKey('player-panel'),
child: SizedBox(
width: width,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 6, 2),
child: Row(
children: [
const Icon(Icons.tune_rounded, size: 18, color: Colors.white70),
const SizedBox(width: 10),
const Expanded(
child: Text('Player', style: TextStyle(fontWeight: FontWeight.w600, fontSize: 14)),
),
IconButton(
key: const ValueKey('player-panel-close'),
tooltip: 'Close (Esc)',
visualDensity: VisualDensity.compact,
iconSize: 18,
onPressed: onClose,
icon: const Icon(Icons.close_rounded),
),
],
),
),
Flexible(
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 10),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const _Section(title: 'Tracks', children: [
_TrackRow(type: JellyfinStreamType.subtitle),
_TrackRow(type: JellyfinStreamType.audio),
]),
_Section(title: 'Subtitles', children: [
SignalBuilder(
builder: (context) => _SliderRow(
key: const ValueKey('panel-sub-scale'),
label: 'Size',
value: s.subScale.value,
min: 0.5,
max: 2,
divisions: 30,
readout: '${(s.subScale.value * 100).round()} %',
onChanged: s.setSubScale,
),
),
SignalBuilder(
builder: (context) => _SwitchRow(
key: const ValueKey('panel-lift'),
label: 'Above the controls',
help: 'Bottom lines move up while the control bar shows (the chat box always lifts them)',
value: s.liftSubtitles.value,
onChanged: s.setLiftSubtitles,
),
),
]),
_Section(title: 'Picture', children: [
_ShaderRow(renderMode: renderMode),
SignalBuilder(builder: (context) {
final m = renderMode?.value;
final software = m == RenderMode.software || m == RenderMode.softwareGl;
return _SwitchRow(
key: const ValueKey('panel-sharp'),
label: 'Sharp video and subtitles',
help: software ? 'No effect: software rendering' : "At your screen's resolution",
value: s.screenResVideo.value,
onChanged: s.setScreenResVideo,
);
}),
SignalBuilder(
builder: (context) => _Row(
label: 'Hardware decoding',
control: AppSelect<HwdecMode>(
key: const ValueKey('panel-hwdec'),
value: s.hwdecMode.value,
items: const [(HwdecMode.auto, 'Auto'), (HwdecMode.off, 'Off'), (HwdecMode.custom, 'Custom')],
onChanged: (m) {
s.setHwdecMode(m);
applyHwdecLive(graph.coordinator.playback);
},
),
),
),
]),
if (volume case final v?)
_Section(title: 'Sound', children: [
SignalBuilder(
builder: (context) => _SliderRow(
key: const ValueKey('panel-volume'),
label: 'Volume',
value: v.value,
min: 0,
max: vol.maxVolume,
readout: '${v.value.round()} %',
onChanged: onVolume,
),
),
]),
_Section(title: 'Notifications', children: [
_Row(label: 'Show as', control: NotifyStyleSelect(settings: s)),
SignalBuilder(
builder: (context) => AnimatedSize(
duration: easeTime,
curve: ease,
alignment: Alignment.topCenter,
child: s.notifyStyle.value == NotifyStyle.danmaku
? DanmakuKnobs(settings: s, dense: true)
: const SizedBox(width: double.infinity),
),
),
]),
_Section(title: 'Controls', children: [
SignalBuilder(
builder: (context) => _Row(
label: 'Design',
control: AppSelect<ControlsSkin>(
key: const ValueKey('panel-design'),
value: s.controlsSkin.value,
items: [for (final k in ControlsSkin.values) (k, k.label)],
onChanged: s.setControlsSkin,
),
),
),
SignalBuilder(
builder: (context) => _SwitchRow(
key: const ValueKey('panel-progress-line'),
label: 'Progress line when hidden',
value: s.progressLine.value,
onChanged: s.setProgressLine,
),
),
]),
const SizedBox(height: 6),
Row(
children: [
Expanded(
child: Text(
'More in Settings → Player',
style: TextStyle(color: Colors.white.withValues(alpha: 0.5), fontSize: 12),
),
),
const MarkProblemButton(),
],
),
],
),
),
),
],
),
),
);
}
}
/// A titled group that folds (its title is the toggle).
class _Section extends StatefulWidget {
final String title;
final List<Widget> children;
const _Section({required this.title, required this.children});
@override
State<_Section> createState() => _SectionState();
}
class _SectionState extends State<_Section> {
late bool _open = !_folded.contains(widget.title);
void _toggle() {
setState(() => _open = !_open);
_open ? _folded.remove(widget.title) : _folded.add(widget.title);
}
@override
Widget build(BuildContext context) {
final muted = Colors.white.withValues(alpha: 0.55);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
InkWell(
key: ValueKey('panel-section-${widget.title}'),
borderRadius: BorderRadius.circular(AppMenuStyle.rowRadius),
onTap: _toggle,
child: Padding(
padding: const EdgeInsets.only(top: 10, bottom: 4),
child: Row(
children: [
Expanded(
child: Text(widget.title.toUpperCase(),
style: TextStyle(fontSize: 11, letterSpacing: 0.8, fontWeight: FontWeight.w600, color: muted)),
),
AnimatedRotation(
turns: _open ? 0 : -0.25,
duration: easeTime,
curve: ease,
child: Icon(Icons.expand_more_rounded, size: 18, color: muted),
),
],
),
),
),
AnimatedSize(
duration: easeTime,
curve: ease,
alignment: Alignment.topCenter,
child: _open
? Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: widget.children)
: const SizedBox(width: double.infinity),
),
],
);
}
}
class _Row extends StatelessWidget {
final String label;
final Widget control;
const _Row({required this.label, required this.control});
@override
Widget build(BuildContext context) => SizedBox(
height: 40,
child: Row(
children: [
Expanded(child: Text(label, style: const TextStyle(fontSize: 13))),
Flexible(flex: 2, child: Align(alignment: Alignment.centerRight, child: control)),
],
),
);
}
class _SwitchRow extends StatelessWidget {
final String label;
final String? help;
final bool value;
final ValueChanged<bool> onChanged;
const _SwitchRow({super.key, required this.label, this.help, required this.value, required this.onChanged});
@override
Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: const TextStyle(fontSize: 13)),
if (help != null)
Text(help!, style: TextStyle(fontSize: 11.5, color: Colors.white.withValues(alpha: 0.5))),
],
),
),
Transform.scale(
scale: 0.8,
alignment: Alignment.centerRight,
child: Switch(value: value, onChanged: onChanged),
),
],
),
);
}
class _SliderRow extends StatelessWidget {
final String label;
final double value, min, max;
final int? divisions;
final String readout;
final ValueChanged<double>? onChanged;
const _SliderRow({
super.key,
required this.label,
required this.value,
required this.min,
required this.max,
this.divisions,
required this.readout,
required this.onChanged,
});
@override
Widget build(BuildContext context) => SizedBox(
height: 38,
child: Row(
children: [
SizedBox(width: 84, child: Text(label, style: const TextStyle(fontSize: 13))),
Expanded(
child: SliderTheme(
data: SliderTheme.of(context).copyWith(
trackHeight: 3,
overlayShape: SliderComponentShape.noOverlay,
thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 7),
tickMarkShape: SliderTickMarkShape.noTickMark,
),
child: Slider(value: value.clamp(min, max), min: min, max: max, divisions: divisions, onChanged: onChanged),
),
),
SizedBox(
width: 52,
child: Text(readout,
textAlign: TextAlign.right,
style: TextStyle(
fontSize: 12,
color: Colors.white.withValues(alpha: 0.7),
fontFeatures: const [FontFeature.tabularFigures()],
)),
),
],
),
);
}
/// Subtitle or audio track of the running player; remembered for the
/// next episode.
class _TrackRow extends StatelessWidget {
final JellyfinStreamType type;
const _TrackRow({required this.type});
bool get _audio => type == JellyfinStreamType.audio;
@override
Widget build(BuildContext context) {
final coord = AppScope.of(context).coordinator;
return SignalBuilder(builder: (context) {
final list = [for (final t in coord.playback.tracks.value) if (t.type == type) t];
final selected = list.where((t) => t.selected).firstOrNull?.id ?? (_audio ? null : -1);
final items = <(int, String)>[
if (!_audio) (-1, 'Off'),
for (final t in list) (t.id, t.label),
];
final key = ValueKey(_audio ? 'panel-audio' : 'panel-subtitles');
return _Row(
label: _audio ? 'Audio' : 'Subtitles',
control: items.isEmpty || selected == null
? Text(items.isEmpty ? 'None' : 'Pick one…', key: key, style: const TextStyle(fontSize: 13))
: AppSelect<int>(
key: key,
value: selected,
expanded: true,
items: items,
onChanged: (id) {
coord.markInput();
if (id == -1) {
coord.selectTrack(subtitlesOff: true);
} else if (_audio) {
coord.selectTrack(aid: id);
} else {
coord.selectTrack(sid: id);
}
},
),
);
});
}
}
/// The upscaling shader: Anime4K mode (or custom files) and HQ / Fast.
class _ShaderRow extends StatelessWidget {
final ReadonlySignal<RenderMode>? renderMode;
const _ShaderRow({this.renderMode});
@override
Widget build(BuildContext context) {
final coord = AppScope.of(context).coordinator;
final s = coord.settings;
return SignalBuilder(builder: (context) {
final preset = s.shaderPreset.value;
final hasCustom = s.customShaders.value.isNotEmpty;
final software = switch (renderMode?.value) {
RenderMode.software || RenderMode.softwareGl => true,
_ => false,
};
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_Row(
label: 'Upscaling',
control: AppSelect<ShaderPreset>(
key: const ValueKey('panel-shader'),
value: preset,
items: [
for (final p in ShaderPreset.values)
if (p != ShaderPreset.custom || hasCustom || preset == p) (p, p.label),
],
onChanged: (p) => applyShaderChoice(coord, p, null),
),
),
if (preset.isAnime4k)
_Row(
label: 'Shader quality',
control: AppSelect<ShaderQuality>(
key: const ValueKey('panel-shader-quality'),
value: s.shaderQuality.value,
items: [for (final q in ShaderQuality.values) (q, q.label)],
onChanged: (q) => applyShaderChoice(coord, preset, q),
),
),
if (software && preset != ShaderPreset.off)
const Text('Software rendering: shaders have little or no effect',
style: TextStyle(fontSize: 11.5, color: Colors.orange)),
],
);
});
}
}
@@ -0,0 +1,433 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:signals/signals_flutter.dart';
import '../playback/playback_controller.dart' show PlaybackController, RenderMode;
import '../session/session_coordinator.dart' show SessionCoordinator;
import '../shaders/shader_presets.dart';
import '../app/app_graph.dart';
import '../state/settings.dart';
import '../widgets/app_menu.dart';
import 'model/controls_prefs.dart';
// Everything about the player, defined once: the rows of Settings →
// Player (the player panel, `player_panel.dart`, has compact rows for the
// same settings). Each row reads and writes [AppSettings] and applies to
// a running player right away.
/// One setting: a label, one line of help under it, the control at the
/// right ([below]: an extra line under the row, e.g. a text field).
class SettingRow extends StatelessWidget {
final String label;
final String? help;
final Widget control;
final Widget? below;
const SettingRow({super.key, required this.label, this.help, required this.control, this.below});
@override
Widget build(BuildContext context) {
final muted = Theme.of(context).colorScheme.onSurfaceVariant;
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label),
if (help != null) Text(help!, style: TextStyle(color: muted, fontSize: 12)),
],
),
),
const SizedBox(width: 12),
control,
],
),
?below,
],
),
);
}
}
AppSelect<T> _dropdown<T>({
required Key key,
required T value,
required List<(T, String)> items,
required ValueChanged<T> onChanged,
bool expanded = false,
}) =>
AppSelect<T>(key: key, value: value, items: items, onChanged: onChanged, expanded: expanded);
/// The controls' skin: Standard or Minimal. Live.
class PlayerDesignSetting extends StatelessWidget {
const PlayerDesignSetting({super.key});
@override
Widget build(BuildContext context) {
final s = AppSettings.instance;
return SignalBuilder(
builder: (context) => SettingRow(
label: 'Player design',
help: 'Minimal has a thick timeline with chapters and what is downloaded yet',
control: _dropdown<ControlsSkin>(
key: const ValueKey('player-design'),
value: s.controlsSkin.value,
items: [for (final k in ControlsSkin.values) (k, k.label)],
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),
),
),
);
}
}
/// The 2 px line at the bottom while the controls are hidden. Live.
class ProgressLineSetting extends StatelessWidget {
const ProgressLineSetting({super.key});
@override
Widget build(BuildContext context) {
final s = AppSettings.instance;
return SignalBuilder(
builder: (context) => SettingRow(
label: 'Progress line when hidden',
help: 'A thin bar at the bottom of the video while the controls are hidden',
control: Switch(
key: const ValueKey('progress-line'),
value: s.progressLine.value,
onChanged: s.setProgressLine,
),
),
);
}
}
/// The video output at the player's size on screen ([AppSettings.screenResVideo]).
class ScreenResSetting extends StatelessWidget {
final ReadonlySignal<RenderMode>? renderMode;
const ScreenResSetting({super.key, this.renderMode});
@override
Widget build(BuildContext context) {
final s = AppScope.of(context).coordinator.settings;
return SignalBuilder(builder: (context) {
final software = switch (renderMode?.value) {
RenderMode.software || RenderMode.softwareGl => true,
_ => false,
};
return SettingRow(
label: 'Sharp video and subtitles',
help: software
? 'No effect: video uses software rendering'
: "Scale the picture and draw subtitles at your screen's resolution, not the video's. "
'Off: lighter on a weak GPU',
control: Switch(
key: const ValueKey('screen-res-video'),
value: s.screenResVideo.value,
onChanged: s.setScreenResVideo,
),
);
});
}
}
/// Saves a shader choice (also used on the next join) and puts it on the
/// running player, if any; the player announces the result.
void applyShaderChoice(SessionCoordinator coord, ShaderPreset preset, ShaderQuality? quality) {
final s = AppSettings.instance;
if (quality != null) s.setShaderQuality(quality);
s.setShaderPreset(preset);
coord.markInput();
coord.playback.applyShaders(announce: true);
}
/// Anime4K preset (or custom files) and HQ / Fast. Live.
class ShaderPresetSetting extends StatelessWidget {
/// The running player's render mode (in the player); else the last one.
final ReadonlySignal<RenderMode>? renderMode;
const ShaderPresetSetting({super.key, this.renderMode});
@override
Widget build(BuildContext context) {
final s = AppSettings.instance;
return SignalBuilder(builder: (context) {
final preset = s.shaderPreset.value;
final quality = s.shaderQuality.value;
final hasCustom = s.customShaders.value.isNotEmpty;
final mode = (renderMode ??
context.getInheritedWidgetOfExactType<AppScope>()?.graph.ports.players.renderMode)
?.value;
final warning = switch (mode) {
RenderMode.software => 'Software rendering: shaders have no effect',
RenderMode.softwareGl => 'OpenGL on the CPU: shaders will be slow',
_ => null,
};
return SettingRow(
label: 'Upscaling shader',
help: 'Anime4K sharpens anime on your GPU, only you see it. '
'A for 1080p, B for 720p, C for 480p · Ctrl+1…6, Ctrl+0 off',
control: Row(
mainAxisSize: MainAxisSize.min,
children: [
_dropdown<ShaderPreset>(
key: const ValueKey('shader-preset'),
value: preset,
items: [
for (final p in ShaderPreset.values)
if (p != ShaderPreset.custom || hasCustom || preset == p) (p, p.label),
],
onChanged: (p) => applyShaderChoice(AppScope.of(context).coordinator, p, null),
),
if (preset.isAnime4k) ...[
const SizedBox(width: 8),
_dropdown<ShaderQuality>(
key: const ValueKey('shader-quality'),
value: quality,
items: [for (final q in ShaderQuality.values) (q, q.label)],
onChanged: (q) => applyShaderChoice(AppScope.of(context).coordinator, preset, q),
),
],
],
),
below: warning == null
? null
: Text(warning, style: const TextStyle(fontSize: 12, color: Colors.orange)),
);
});
}
}
/// Puts the hwdec setting on the running player (mpv switches decoders
/// on the fly). `SYNC_RELAY_HWDEC` wins, as at player start.
Future<void> applyHwdecLive(PlaybackController playback) async {
if ((Platform.environment['SYNC_RELAY_HWDEC'] ?? '').isNotEmpty) return;
final p = playback.current;
if (p == null) return;
try {
await p.setProperty('hwdec', AppSettings.instance.hwdec.peek());
} catch (e) {
debugPrint('live hwdec failed: $e');
}
}
/// mpv's `hwdec`: auto, off, or a custom value. Live.
class HwdecSetting extends StatefulWidget {
const HwdecSetting({super.key});
@override
State<HwdecSetting> createState() => _HwdecSettingState();
}
class _HwdecSettingState extends State<HwdecSetting> {
final _s = AppSettings.instance;
late final _custom = TextEditingController(text: _s.hwdecCustom.peek());
@override
void dispose() {
_custom.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return SignalBuilder(builder: (context) {
final mode = _s.hwdecMode.value;
return SettingRow(
label: 'Hardware decoding',
help: 'Decodes video on the GPU. Turn it off if the picture glitches or stays black',
control: _dropdown<HwdecMode>(
key: const ValueKey('hwdec'),
value: mode,
items: const [(HwdecMode.auto, 'Auto'), (HwdecMode.off, 'Off'), (HwdecMode.custom, 'Custom')],
onChanged: (m) {
_s.setHwdecMode(m);
applyHwdecLive(AppScope.of(context).coordinator.playback);
},
),
below: mode != HwdecMode.custom
? null
: Padding(
padding: const EdgeInsets.only(top: 8),
child: TextField(
controller: _custom,
decoration: const InputDecoration(
isDense: true,
labelText: 'mpv hwdec value (Enter applies)',
hintText: 'e.g. vaapi, nvdec, d3d11va, auto-safe',
border: OutlineInputBorder(),
),
onChanged: _s.setHwdecCustom,
onSubmitted: (_) => applyHwdecLive(AppScope.of(context).coordinator.playback),
),
),
);
});
}
}
/// 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, in order (the player panel has
/// its own compact rows for the same settings).
List<Widget> playerSettingRows({ReadonlySignal<RenderMode>? renderMode}) => [
const PlayerDesignSetting(),
const ProgressLineSetting(),
ShaderPresetSetting(renderMode: renderMode),
ScreenResSetting(renderMode: renderMode),
const HwdecSetting(),
const SubScaleSetting(),
const LiftSubtitlesSetting(),
];
/// Subtitle size (mpv `sub-scale`, [AppSettings.subScale]). Live.
class SubScaleSetting extends StatelessWidget {
const SubScaleSetting({super.key});
@override
Widget build(BuildContext context) {
final s = AppScope.of(context).coordinator.settings;
return SignalBuilder(
builder: (context) => SettingRow(
label: 'Subtitle size',
help: '${(s.subScale.value * 100).round()} % of what the file sets',
control: SizedBox(
width: 220,
child: Slider(
key: const ValueKey('sub-scale'),
value: s.subScale.value,
min: 0.5,
max: 2,
divisions: 30,
onChanged: s.setSubScale,
),
),
),
);
}
}
/// Bottom subtitles above the control bar while it is up
/// ([AppSettings.liftSubtitles]). Live.
class LiftSubtitlesSetting extends StatelessWidget {
const LiftSubtitlesSetting({super.key});
@override
Widget build(BuildContext context) {
final s = AppScope.of(context).coordinator.settings;
return SignalBuilder(
builder: (context) => SettingRow(
label: 'Subtitles above the controls',
help: 'Bottom lines move up while the control bar shows; signs at the top stay. '
'The chat box always moves them',
control: Switch(
key: const ValueKey('lift-subtitles'),
value: s.liftSubtitles.value,
onChanged: s.setLiftSubtitles,
),
),
);
}
}
@@ -0,0 +1,89 @@
import 'package:flutter/foundation.dart' show ValueListenable;
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;
/// Hides them while something takes their place ([ControlsFrame.suppress]).
final ValueListenable<bool>? suppress;
const SessionControls({
super.key,
required this.model,
required this.shown,
required this.hold,
required this.onDoubleClick,
this.windowButtons,
this.suppress,
});
@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,
suppress: suppress,
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, 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,334 +0,0 @@
import 'package:flutter/gestures.dart';
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 '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, the wheel steps 5 %.
class UoscVolumeButton extends StatefulWidget {
/// 0..100.
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> {
double _beforeMute = 100;
@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 Listener(
onPointerSignal: (e) {
if (e is PointerScrollEvent && e.scrollDelta.dy != 0) {
widget.onChanged((v + (e.scrollDelta.dy < 0 ? 5 : -5)).clamp(0.0, 100.0));
}
},
child: UoscButton(
key: const ValueKey('uosc-volume'),
icon: icon,
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,196 +0,0 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:signals/signals_flutter.dart';
import '../../state/settings.dart';
// The experimental mpv-style (uosc) controls frame: a full-width timeline
// at the bottom with a row of flat buttons above it, window buttons top
// right. Mouse movement shows them; after [UoscControls.hideAfter] without
// any they fade out, the timeline shrinks to a thin line and the cursor
// hides. Everything lives here because media_kit's fullscreen route
// renders only the video and its controls.
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 (window buttons), shown with the controls.
final Widget? top;
final VoidCallback? onDoubleTap;
final Duration hideAfter;
const UoscControls({
super.key,
required this.timeline,
this.left = const [],
this.right = const [],
this.top,
this.onDoubleTap,
this.hideAfter = const Duration(seconds: 3),
});
static const buttonRowHeight = 40.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;
Timer? _timer;
bool get visible => _visible;
@override
void initState() {
super.initState();
_hideLater();
}
@override
void dispose() {
_timer?.cancel();
super.dispose();
}
/// Show the controls and restart the hide countdown.
void wake() {
if (!_visible && mounted) setState(() => _visible = 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);
}
@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.top != null)
Positioned(
top: 0,
right: 0,
child: IgnorePointer(
ignoring: !visible,
child: AnimatedOpacity(duration: fade, opacity: visible ? 1 : 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, 4),
child: SizedBox(
height: UoscControls.buttonRowHeight,
child: Row(children: [...widget.left, const Spacer(), ...widget.right]),
),
),
),
),
widget.timeline(context, visible),
],
),
),
],
),
);
}
}
/// The session's controls: media_kit's ([material]) or, with Settings →
/// "Experimental: mpv-style controls", 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,79 +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 {
/// Timeline background and the boxes on it.
static const bar = Color(0xCC000000);
/// Played part of the timeline; also 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 times, the
/// hover 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,287 +0,0 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import '../../playback/media_marks.dart';
import 'controls.dart';
import 'style.dart';
/// 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);
}
/// The cached-ahead part of a timeline of [size]: from the playing
/// position to the end of mpv's cache. Null when nothing is cached ahead.
Rect? cacheRect(Size size, {required Duration position, required Duration buffered, required Duration duration}) {
final from = timelineFraction(position, duration) * size.width;
final to = timelineFraction(buffered, duration) * size.width;
if (to - from < 0.5) return null;
return Rect.fromLTRB(from, 0, to, size.height);
}
/// The hatching of [r]: 45° strokes from bottom-left to top-right,
/// [spacing] apart along x, across all of [r] (the painter clips them).
/// The first starts half a step in, so none just grazes the corner.
List<(Offset, Offset)> hatchLines(Rect r, {double spacing = 6}) {
if (r.isEmpty || spacing <= 0) return const [];
final h = r.height;
return [
for (var x = r.left - h + spacing / 2; x < r.right; x += spacing) (Offset(x, r.bottom), Offset(x + h, r.top)),
];
}
/// The uosc timeline: full width, elapsed part filled, the cached range
/// ahead hatched, chapter starts as white diamonds on the top edge, the
/// current time in a box at the left and the duration at the right.
/// Hover shows the chapter title over the hover time. When the controls
/// hide ([expanded] false) it shrinks to a thin line with chapter ticks.
/// Like the media_kit bar it reports a seek on every drag move and on
/// release ([onSeek]); 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 = 30.0;
static const collapsedHeight = 3.0;
@override
State<UoscTimeline> createState() => _UoscTimelineState();
}
class _UoscTimelineState extends State<UoscTimeline> {
double? _hoverX;
bool _dragging = false;
double _dragFraction = 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));
}
void _drag(bool on) {
if (_dragging == on) return;
setState(() => _dragging = on);
UoscControls.maybeOf(context)?.hold(on);
}
@override
void didUpdateWidget(UoscTimeline old) {
super.didUpdateWidget(old);
if (!widget.expanded && old.expanded) _hoverX = null;
}
@override
void dispose() {
// A drag cut short by a rebuild must not keep the controls up.
if (_dragging) UoscControls.maybeOf(context)?.hold(false);
super.dispose();
}
@override
Widget build(BuildContext context) {
return LayoutBuilder(builder: (context, constraints) {
final width = constraints.maxWidth;
final expanded = widget.expanded;
final h = expanded ? UoscTimeline.height : UoscTimeline.collapsedHeight;
final played = _dragging ? _dragFraction : timelineFraction(widget.position, widget.duration);
final hoverX = expanded ? _hoverX : null;
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;
final withHours = widget.duration >= const Duration(hours: 1);
final marks = [
for (final c in widget.chapters)
if (c.start > Duration.zero && widget.duration > Duration.zero) c,
];
final bar = SizedBox(
key: const ValueKey('uosc-timeline'),
width: width,
height: h,
child: Stack(
clipBehavior: Clip.none,
children: [
Positioned.fill(
child: CustomPaint(
painter: UoscTimelinePainter(
played: played,
cache: (size) => cacheRect(size,
position: widget.position, buffered: widget.buffered, duration: widget.duration),
cacheKey: (widget.position, widget.buffered, widget.duration),
hoverX: _dragging ? null : hoverX,
hatched: expanded,
),
),
),
for (final c in marks)
if (expanded)
Positioned(
key: ValueKey('uosc-chapter-${c.index}'),
left: width * timelineFraction(c.start, widget.duration) - 4,
top: -4,
width: 8,
height: 8,
child: IgnorePointer(
child: Transform.rotate(
angle: math.pi / 4,
child: Container(
margin: const EdgeInsets.all(1.2),
decoration: BoxDecoration(
color: Colors.white,
border: Border.all(color: Colors.black54, width: 0.6),
),
),
),
),
)
else
Positioned(
key: ValueKey('uosc-chapter-tick-${c.index}'),
left: width * timelineFraction(c.start, widget.duration) - 1,
top: -2,
width: 2,
height: h + 2,
child: const IgnorePointer(child: ColoredBox(color: Colors.white70)),
),
if (expanded) ...[
Positioned(
left: 4,
top: 0,
bottom: 0,
child: IgnorePointer(
child: Center(
child: UoscBox(formatUoscTime(_at(played), withHours: withHours),
key: const ValueKey('uosc-time'), color: UoscStyle.bar),
),
),
),
Positioned(
right: 4,
top: 0,
bottom: 0,
child: IgnorePointer(
child: Center(
child: UoscBox(formatUoscTime(widget.duration, withHours: withHours),
key: const ValueKey('uosc-duration'), color: UoscStyle.bar),
),
),
),
],
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: h + 8,
child: IgnorePointer(
child: FractionalTranslation(
translation: const Offset(-0.5, 0),
child: Column(
mainAxisSize: MainAxisSize.min,
spacing: 4,
children: [
if (hoverTitle.isNotEmpty) UoscBox(hoverTitle, key: const ValueKey('uosc-hover-chapter')),
UoscBox(formatUoscTime(hoverAt, withHours: withHours),
key: const ValueKey('uosc-hover-time')),
],
),
),
),
),
],
),
);
if (!expanded) return IgnorePointer(child: bar);
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: bar,
),
);
});
}
}
/// Background, cached range (hatched, or a faint band when collapsed),
/// the played part and the hover line.
class UoscTimelinePainter extends CustomPainter {
final double played;
final Rect? Function(Size size) cache;
/// What [cache] depends on (repaint when it changes).
final Object cacheKey;
final double? hoverX;
final bool hatched;
const UoscTimelinePainter({
required this.played,
required this.cache,
required this.cacheKey,
this.hoverX,
this.hatched = true,
});
@override
void paint(Canvas canvas, Size size) {
final all = Offset.zero & size;
canvas.drawRect(all, Paint()..color = UoscStyle.bar);
final c = cache(size);
if (c != null) {
if (hatched) {
canvas.save();
canvas.clipRect(c);
final stroke = Paint()
..color = const Color(0x40FFFFFF)
..strokeWidth = 1.5;
for (final (a, b) in hatchLines(c)) {
canvas.drawLine(a, b, stroke);
}
canvas.restore();
} else {
canvas.drawRect(c, Paint()..color = const Color(0x40FFFFFF));
}
}
canvas.drawRect(Rect.fromLTWH(0, 0, size.width * played, size.height), Paint()..color = UoscStyle.fill);
final x = hoverX;
if (x != null) {
canvas.drawRect(Rect.fromLTWH(x - 0.5, 0, 1, size.height), Paint()..color = Colors.white70);
}
}
@override
bool shouldRepaint(UoscTimelinePainter old) =>
old.played != played || old.cacheKey != cacheKey || old.hoverX != hoverX || old.hatched != hatched;
}
+328
View File
@@ -0,0 +1,328 @@
import 'dart:async';
import 'package:flutter/foundation.dart' show ValueListenable;
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import '../playback/playback_controller.dart' show PlaybackController;
import '../widgets/app_menu.dart' show AppGlass;
/// The player's top volume (mpv's `volume-max`): past 100 % mpv amplifies.
const maxVolume = PlaybackController.maxVolume * 1.0;
/// The part of the volume bar above 100 %.
const boostColor = Color(0xFFFFB74D);
/// One wheel notch or arrow key.
const volumeStep = 5.0;
/// [volume] moved by [delta] (wheel, keys), kept within 0..[maxVolume].
double stepVolume(double volume, double delta) => (volume + delta).clamp(0.0, maxVolume);
/// Turns wheel deltas into volume steps: a mouse notch (a big delta) is
/// one step, small smooth-scroll deltas add up to one. Mostly horizontal
/// scrolls (Shift+wheel, sideways pans) are no steps.
class WheelSteps {
// Flutter makes a notch 53 px on Linux, 60 on Windows (3 lines).
static const _notch = 50.0;
double _sum = 0;
/// Whether [e] is a vertical scroll (the only kind that steps).
static bool vertical(PointerSignalEvent e) =>
e is PointerScrollEvent && e.scrollDelta.dy != 0 && e.scrollDelta.dy.abs() > e.scrollDelta.dx.abs();
/// +1 louder (wheel up), -1 quieter, 0 not a step yet.
int take(PointerScrollEvent e) {
final dy = e.scrollDelta.dy;
if (dy.abs() >= _notch) {
_sum = 0;
return dy < 0 ? 1 : -1;
}
if (_sum != 0 && _sum.sign != dy.sign) _sum = 0;
_sum += dy;
if (_sum.abs() < _notch) return 0;
final step = _sum < 0 ? 1 : -1;
_sum = 0;
return step;
}
/// Claims [e] if it is a vertical scroll that nothing under the mouse
/// claimed first (a list, a menu), then calls [onStep] per step.
void handle(PointerSignalEvent e, ValueChanged<int> onStep) {
if (!vertical(e)) return;
GestureBinding.instance.pointerSignalResolver.register(e, (event) {
final step = take(event as PointerScrollEvent);
if (step != 0) onStep(step);
});
}
}
/// The wheel anywhere over [child] steps the volume ([onStep]: +1 / -1),
/// unless something under the mouse scrolls (it claims the event first)
/// or [enabled] is off.
class VolumeWheel extends StatefulWidget {
final ValueChanged<int> onStep;
final bool enabled;
final Widget child;
const VolumeWheel({super.key, required this.onStep, this.enabled = true, required this.child});
@override
State<VolumeWheel> createState() => _VolumeWheelState();
}
class _VolumeWheelState extends State<VolumeWheel> {
final _steps = WheelSteps();
@override
Widget build(BuildContext context) => Listener(
onPointerSignal: (e) {
if (widget.enabled) _steps.handle(e, widget.onStep);
},
child: widget.child,
);
}
/// Shows [builder] for a second after each [trigger] tick (a wheel or
/// key volume step), fading in and out; ignores the mouse.
class VolumeFlash extends StatefulWidget {
final ValueListenable<int> trigger;
final WidgetBuilder builder;
const VolumeFlash({super.key, required this.trigger, required this.builder});
/// How long the readout stays after the last step.
static const hold = Duration(seconds: 1);
@override
State<VolumeFlash> createState() => _VolumeFlashState();
}
class _VolumeFlashState extends State<VolumeFlash> {
bool _on = false;
Timer? _timer;
@override
void initState() {
super.initState();
widget.trigger.addListener(_flash);
}
@override
void didUpdateWidget(VolumeFlash old) {
super.didUpdateWidget(old);
if (old.trigger != widget.trigger) {
old.trigger.removeListener(_flash);
widget.trigger.addListener(_flash);
}
}
@override
void dispose() {
widget.trigger.removeListener(_flash);
_timer?.cancel();
super.dispose();
}
void _flash() {
if (!mounted) return;
setState(() => _on = true);
_timer?.cancel();
_timer = Timer(VolumeFlash.hold, () {
if (mounted) setState(() => _on = false);
});
}
@override
Widget build(BuildContext context) => IgnorePointer(
child: AnimatedOpacity(
duration: const Duration(milliseconds: 150),
opacity: _on ? 1 : 0,
child: _on || _timer != null ? widget.builder(context) : const SizedBox.shrink(),
),
);
}
/// The volume readout for [VolumeFlash] (both skins): a glass pill
/// (the player's pill look) with the icon, a [VolumeBar] and the
/// percentage.
class VolumeReadout extends StatelessWidget {
final double volume;
const VolumeReadout({super.key, required this.volume});
@override
Widget build(BuildContext context) {
final v = volume.clamp(0.0, maxVolume);
return AppGlass.pill(
key: const ValueKey('volume-readout'),
child: Padding(
padding: const EdgeInsets.fromLTRB(12, 4, 14, 4),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(volumeIcon(v), size: 20, color: v > 100 ? boostColor : Colors.white),
const SizedBox(width: 8),
VolumeBar(volume: v, onChanged: (_) {}, width: 96),
const SizedBox(width: 10),
SizedBox(
width: 44,
child: Text(
'${v.round()} %',
textAlign: TextAlign.right,
style: const TextStyle(color: Colors.white, fontSize: 13, fontFeatures: [FontFeature.tabularFigures()]),
),
),
],
),
),
);
}
}
/// A thin volume track over 0..[maxVolume]: filled up to the volume, the
/// part above 100 % in [boostColor], a tick at 100 %. Click or drag sets
/// it.
class VolumeBar extends StatelessWidget {
final double volume;
final ValueChanged<double> onChanged;
final double width;
final Color color;
final Color trackColor;
const VolumeBar({
super.key,
required this.volume,
required this.onChanged,
this.width = 64,
this.color = Colors.white,
this.trackColor = Colors.white30,
});
@override
Widget build(BuildContext context) {
void at(double x) => onChanged((x / width * maxVolume).clamp(0.0, maxVolume).roundToDouble());
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTapDown: (e) => at(e.localPosition.dx),
onHorizontalDragUpdate: (e) => at(e.localPosition.dx),
child: SizedBox(
width: width,
height: 24,
child: CustomPaint(painter: _VolumePainter(volume.clamp(0.0, maxVolume), color, trackColor)),
),
);
}
}
class _VolumePainter extends CustomPainter {
final double volume;
final Color color, trackColor;
_VolumePainter(this.volume, this.color, this.trackColor);
@override
void paint(Canvas canvas, Size size) {
final y = size.height / 2;
double x(double v) => size.width * v / maxVolume;
final line = Paint()
..strokeWidth = 3
..strokeCap = StrokeCap.round;
canvas.drawLine(Offset(0, y), Offset(size.width, y), line..color = trackColor);
final full = x(100);
canvas.drawLine(
Offset(full, y - 5),
Offset(full, y + 5),
Paint()
..color = trackColor
..strokeWidth = 1.5,
);
if (volume > 0) canvas.drawLine(Offset(0, y), Offset(x(volume.clamp(0, 100)), y), line..color = color);
if (volume > 100) canvas.drawLine(Offset(full, y), Offset(x(volume), y), line..color = boostColor);
canvas.drawCircle(Offset(x(volume), y), 5, Paint()..color = volume > 100 ? boostColor : color);
}
@override
bool shouldRepaint(_VolumePainter old) => old.volume != volume || old.color != color || old.trackColor != trackColor;
}
/// The speaker icon for [volume].
IconData volumeIcon(double volume) =>
volume <= 0 ? Icons.volume_off_rounded : (volume < 50 ? Icons.volume_down_rounded : Icons.volume_up_rounded);
/// Builds a skin's button for the [VolumeControl].
typedef VolumeButtonBuilder = Widget Function(
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
State<VolumeControl> createState() => _VolumeControlState();
}
class _VolumeControlState extends State<VolumeControl> {
final _steps = WheelSteps();
bool _hover = false;
@override
Widget build(BuildContext context) {
final v = widget.volume;
return MouseRegion(
onEnter: (_) => setState(() => _hover = true),
onExit: (_) => setState(() => _hover = false),
child: Listener(
onPointerSignal: (e) => _steps.handle(e, (step) => widget.onChanged(stepVolume(v, step * volumeStep))),
child: Row(
key: const ValueKey('volume-control'),
mainAxisSize: MainAxisSize.min,
children: [
widget.button(
context,
volumeIcon(v),
v > 100 ? boostColor : null,
'Volume ${v.round()} % (scroll, ↑ / ↓)',
widget.onToggleMute,
),
AnimatedSize(
duration: const Duration(milliseconds: 150),
child: _hover
? Padding(
padding: const EdgeInsets.only(left: 4, right: 8),
child: VolumeBar(
key: const ValueKey('volume-bar'),
volume: v,
onChanged: widget.onChanged,
width: widget.sliderWidth,
color: widget.sliderColor,
trackColor: widget.trackColor,
),
)
: const SizedBox.shrink(),
),
],
),
),
);
}
}
+582
View File
@@ -0,0 +1,582 @@
import 'dart:collection';
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import '../shell/avatar.dart' show AvatarInfo, StatusAvatar;
// Danmaku (Bilibili / niconico style): short lines that fly right to left
// across the upper part of the picture. Each line takes a free lane (no
// two ever touch), at most [DanmakuStyle.maxOnScreen] fly at once and the
// rest wait their turn; every line crosses in the same time, so a long
// one moves faster. The clock is the layer's own: lines keep flying while
// the video is paused. The session's notifications use it when the user
// picks "Notifications as danmaku" (`danmaku_notices.dart`); the dev
// preview tries it out (DEVELOPMENT.md "Dev tools").
/// What a line is: a chat message or a short notice.
enum DanmakuKind { chat, join, leave, upNext, starting }
/// One line: [sender] (a coloured prefix, [senderColor]) and [text];
/// [own] lines (this member's) get a hairline frame. [avatar]: the
/// sender's picture (or initial) before it; the app's own notices have
/// none.
@immutable
class DanmakuItem {
final DanmakuKind kind;
final String? sender;
final Color? senderColor;
final String text;
final bool own;
final AvatarInfo? avatar;
const DanmakuItem(
{required this.kind, required this.text, this.sender, this.senderColor, this.own = false, this.avatar});
}
/// How lines look and move. Persisted as [toJson] (Settings →
/// Notifications & sounds); [DanmakuStyle.fromJson] clamps every value to
/// its range ([limits]), so a hand-edited or older box never breaks it.
@immutable
class DanmakuStyle {
/// Seconds a line takes from the right edge until it has left on the
/// left, whatever its length.
final double crossSecs;
/// Lines on screen at once; more wait.
final int maxOnScreen;
/// The band the lanes fill, as fractions of the height from the top:
/// below where top signs sit, never down into the dialogue.
final double bandTop;
final double bandBottom;
final double opacity;
/// Text size relative to the picture's height ([fontSizeFor]).
final double size;
/// Font weight of the text, 400 (regular) to 900 (black); the sender's
/// name is a step bolder.
final int weight;
/// Width of the dark outline round the white text, logical px.
final double outline;
final bool highlightOwn;
/// The sender's avatar before the line.
final bool avatars;
const DanmakuStyle({
this.crossSecs = 8,
this.maxOnScreen = 12,
this.bandTop = 0.10,
this.bandBottom = 0.45,
this.opacity = 0.92,
this.size = 1,
this.weight = 600,
this.outline = 1.4,
this.highlightOwn = true,
this.avatars = true,
});
/// The range of each knob (the settings' sliders use them too).
static const limits = (
crossSecs: (4.0, 16.0),
maxOnScreen: (1, 40),
band: (0.0, 0.9),
minBand: 0.05,
opacity: (0.3, 1.0),
size: (0.6, 1.8),
weight: (400, 900),
outline: (0.0, 4.0),
);
DanmakuStyle copyWith({
double? crossSecs,
int? maxOnScreen,
double? bandTop,
double? bandBottom,
double? opacity,
double? size,
int? weight,
double? outline,
bool? highlightOwn,
bool? avatars,
}) =>
DanmakuStyle(
crossSecs: crossSecs ?? this.crossSecs,
maxOnScreen: maxOnScreen ?? this.maxOnScreen,
bandTop: bandTop ?? this.bandTop,
bandBottom: bandBottom ?? this.bandBottom,
opacity: opacity ?? this.opacity,
size: size ?? this.size,
weight: weight ?? this.weight,
outline: outline ?? this.outline,
highlightOwn: highlightOwn ?? this.highlightOwn,
avatars: avatars ?? this.avatars,
);
Map<String, Object> toJson() => {
'crossSecs': crossSecs,
'maxOnScreen': maxOnScreen,
'bandTop': bandTop,
'bandBottom': bandBottom,
'opacity': opacity,
'size': size,
'weight': weight,
'outline': outline,
'highlightOwn': highlightOwn,
'avatars': avatars,
};
/// [json] as stored ([toJson]); missing or wrong values take the
/// default, out-of-range ones the nearest end of their range.
factory DanmakuStyle.fromJson(Map<Object?, Object?> json) {
const d = DanmakuStyle();
const l = limits;
double num_(String k, double def, (double, double) r) {
final v = json[k];
return v is num && v.isFinite ? v.toDouble().clamp(r.$1, r.$2) : def;
}
int int_(String k, int def, (int, int) r) {
final v = json[k];
return v is num && v.isFinite ? v.round().clamp(r.$1, r.$2) : def;
}
bool bool_(String k, bool def) {
final v = json[k];
return v is bool ? v : def;
}
var top = num_('bandTop', d.bandTop, l.band);
var bottom = num_('bandBottom', d.bandBottom, l.band);
if (bottom - top < l.minBand) (top, bottom) = (d.bandTop, d.bandBottom);
return DanmakuStyle(
crossSecs: num_('crossSecs', d.crossSecs, l.crossSecs),
maxOnScreen: int_('maxOnScreen', d.maxOnScreen, l.maxOnScreen),
bandTop: top,
bandBottom: bottom,
opacity: num_('opacity', d.opacity, l.opacity),
size: num_('size', d.size, l.size),
weight: (int_('weight', d.weight, l.weight) / 100).round() * 100,
outline: num_('outline', d.outline, l.outline),
highlightOwn: bool_('highlightOwn', d.highlightOwn),
avatars: bool_('avatars', d.avatars),
);
}
/// [weight] as a [FontWeight] (400..900 in steps of 100).
FontWeight get fontWeight => FontWeight.values[((weight.clamp(100, 900) / 100).round() - 1)];
/// The sender's name: a step bolder than the text.
FontWeight get nameWeight =>
FontWeight.values[math.min(FontWeight.values.length - 1, (weight.clamp(100, 900) / 100).round())];
/// The text size in a picture [height] px tall: 4.2 % of it (about 30 px
/// on a 720 px player), 13 to 44 px, times [size].
double fontSizeFor(double height) => (height * 0.042).clamp(13.0, 44.0) * size;
/// A lane: the line's height plus a little air.
double laneHeightFor(double height) => fontSizeFor(height) * 1.5;
/// Lanes in the band of a picture [height] px tall (at least one).
int lanesFor(double height) {
final band = (bandBottom - bandTop).clamp(0.0, 1.0) * height;
return math.max(1, (band / laneHeightFor(height)).floor());
}
@override
bool operator ==(Object other) =>
other is DanmakuStyle &&
other.crossSecs == crossSecs &&
other.maxOnScreen == maxOnScreen &&
other.bandTop == bandTop &&
other.bandBottom == bandBottom &&
other.opacity == opacity &&
other.size == size &&
other.weight == weight &&
other.outline == outline &&
other.highlightOwn == highlightOwn &&
other.avatars == avatars;
@override
int get hashCode =>
Object.hash(crossSecs, maxOnScreen, bandTop, bandBottom, opacity, size, weight, outline, highlightOwn, avatars);
}
/// One line in flight in [lane], from [start] (seconds on the layer's
/// clock), [width] px wide, crossing in [crossSecs].
class DanmakuFlight<T> {
final T item;
final int lane;
final double start;
final double width;
final double crossSecs;
DanmakuFlight({required this.item, required this.lane, required this.start, required this.width, required this.crossSecs});
/// Its left edge at [t] in a picture [w] px wide: [w] at the start,
/// -[width] at the end.
double x(double t, double w) => w - (w + width) * ((t - start) / crossSecs);
double end() => start + crossSecs;
bool doneAt(double t) => t >= end();
}
/// Lane assignment, pure (no widgets, a clock passed in): lines queue in
/// order of arrival; [tick] lets the waiting ones in while a lane is free
/// and fewer than the cap fly. A lane is free for a new line when the last
/// line in it has fully entered with [gap] behind it and the new one, at
/// its own speed, cannot catch up with it before it has left.
class DanmakuLanes<T> {
/// Space kept between two lines in a lane, px.
final double gap;
/// A line waiting longer than this is dropped (a burst on a crowded
/// picture would otherwise fly long after it was said).
final double maxWaitSecs;
/// Lines kept waiting at most; older ones go first when it is full.
final int maxWaiting;
DanmakuLanes({this.gap = 24, this.maxWaitSecs = 15, this.maxWaiting = 60});
final List<DanmakuFlight<T>> flights = [];
final Queue<(T, double)> _waiting = Queue();
int dropped = 0;
int get waiting => _waiting.length;
/// Everything flying and waiting goes; returns it.
List<T> clear() {
final all = [for (final f in flights) f.item, for (final w in _waiting) w.$1];
flights.clear();
_waiting.clear();
return all;
}
/// [item] arrived at [t]; it flies on the next [tick] that has room.
void add(T item, double t) {
_waiting.add((item, t));
while (_waiting.length > maxWaiting) {
_waiting.removeFirst();
dropped++;
}
}
/// Advances to [t] in a picture [w] px wide with [lanes] lanes: drops
/// what has left and what waited too long, then places waiting lines in
/// order (the topmost free lane each) until the cap or no lane is free.
/// [measure] gives a line's width. Returns the lines placed.
List<DanmakuFlight<T>> tick(double t,
{required double w, required int lanes, required int cap, required double crossSecs, required double Function(T) measure}) {
flights.removeWhere((f) => f.doneAt(t));
while (_waiting.isNotEmpty && t - _waiting.first.$2 > maxWaitSecs) {
_waiting.removeFirst();
dropped++;
}
final placed = <DanmakuFlight<T>>[];
while (_waiting.isNotEmpty && flights.length < cap) {
final item = _waiting.first.$1;
final width = measure(item);
final lane = freeLane(t, w: w, lanes: lanes, width: width, crossSecs: crossSecs);
if (lane == null) break;
_waiting.removeFirst();
final f = DanmakuFlight(item: item, lane: lane, start: t, width: width, crossSecs: crossSecs);
flights.add(f);
placed.add(f);
}
return placed;
}
/// The topmost lane a line [width] px wide can start in at [t], or null.
int? freeLane(double t, {required double w, required int lanes, required double width, required double crossSecs}) {
final last = <int, DanmakuFlight<T>>{};
for (final f in flights) {
final l = last[f.lane];
if (l == null || f.start > l.start) last[f.lane] = f;
}
for (var lane = 0; lane < lanes; lane++) {
final prev = last[lane];
if (prev == null || fits(prev, t, w: w, width: width, crossSecs: crossSecs, gap: gap)) return lane;
}
return null;
}
/// A new line ([width], [crossSecs]) may follow [prev] in its lane at [t].
/// Both move linearly, so checking now and the moment [prev] has left is
/// enough.
static bool fits(DanmakuFlight<Object?> prev, double t,
{required double w, required double width, required double crossSecs, required double gap}) {
// Fully in, with the gap behind it.
if (prev.x(t, w) + prev.width + gap > w) return false;
final leaves = prev.end();
if (leaves <= t) return true;
// Where the new line's head is when prev's tail reaches the left edge.
final head = w - (w + width) * ((leaves - t) / crossSecs);
return head >= gap;
}
}
/// Feeds a [DanmakuLayer]; [onScreen] / [waiting] follow what the layer
/// does with the lines (for a status readout).
class DanmakuController extends ChangeNotifier {
final _inbox = <DanmakuItem>[];
final onScreen = ValueNotifier(0);
final waiting = ValueNotifier(0);
final dropped = ValueNotifier(0);
void add(DanmakuItem item) {
_inbox.add(item);
notifyListeners();
}
/// Everything in flight and waiting goes.
void clear() {
_clear = true;
_inbox.clear();
notifyListeners();
}
bool _clear = false;
List<DanmakuItem> _take() {
final items = List.of(_inbox);
_inbox.clear();
return items;
}
@override
void dispose() {
onScreen.dispose();
waiting.dispose();
dropped.dispose();
super.dispose();
}
}
/// The lines over a picture: fills its box, lets every click through.
/// Each line is a widget (avatar, outlined text, own frame) moved by a
/// [Flow] on every frame: only paint runs per frame, a build only when a
/// line comes or goes.
class DanmakuLayer extends StatefulWidget {
final DanmakuController controller;
final DanmakuStyle style;
/// The accent of [DanmakuItem.own] lines' frame and of "starting now".
final Color accent;
const DanmakuLayer({super.key, required this.controller, this.style = const DanmakuStyle(), required this.accent});
@override
State<DanmakuLayer> createState() => _DanmakuLayerState();
}
/// A line measured once (its width decides its speed and its lane) and
/// its widget, built once.
class _Line {
final DanmakuItem item;
final double width;
final double height;
final Widget widget;
_Line(this.item, this.width, this.height, this.widget);
}
class _DanmakuLayerState extends State<DanmakuLayer> with SingleTickerProviderStateMixin {
late final Ticker _ticker = createTicker(_onTick);
final _lanes = DanmakuLanes<_Line>();
final _repaint = ValueNotifier(0);
// The layer's clock: [_base] from earlier runs of the ticker, which
// only runs while lines fly or wait (an idle layer asks for no frames).
Duration _base = Duration.zero;
Duration _elapsed = Duration.zero;
Size _size = Size.zero;
var _pending = <DanmakuItem>[];
var _shown = <DanmakuFlight<_Line>>[];
double get _t => (_base + _elapsed).inMicroseconds / 1e6;
@override
void initState() {
super.initState();
widget.controller.addListener(_onAdd);
_onAdd();
}
@override
void didUpdateWidget(DanmakuLayer old) {
super.didUpdateWidget(old);
if (old.controller != widget.controller) {
old.controller.removeListener(_onAdd);
widget.controller.addListener(_onAdd);
}
}
@override
void dispose() {
widget.controller.removeListener(_onAdd);
_ticker.dispose();
_repaint.dispose();
super.dispose();
}
void _onAdd() {
final c = widget.controller;
if (c._clear) {
c._clear = false;
_lanes.clear();
_pending = [];
}
_pending.addAll(c._take());
if (!_ticker.isActive) _ticker.start();
}
_Line _build(DanmakuItem item) {
final s = widget.style;
final size = s.fontSizeFor(_size.height);
final base = TextStyle(
fontSize: size,
height: 1.2,
fontWeight: s.fontWeight,
color: Colors.white,
letterSpacing: 0.2,
shadows: const [],
);
InlineSpan span({Paint? stroke}) {
final name = item.sender;
final accent = item.kind == DanmakuKind.starting && stroke == null ? widget.accent : null;
return TextSpan(style: stroke == null ? base : base.copyWith(foreground: stroke, color: null), children: [
if (name != null)
TextSpan(
text: item.kind == DanmakuKind.chat ? '$name ' : '$name ',
style: TextStyle(
fontWeight: s.nameWeight,
color: stroke == null ? (item.senderColor ?? Colors.white) : null,
),
),
TextSpan(text: item.text, style: accent == null ? null : TextStyle(color: accent)),
]);
}
final measure = TextPainter(text: span(), textDirection: TextDirection.ltr, maxLines: 1)..layout();
final textW = measure.width, textH = measure.height;
measure.dispose();
final avatar = s.avatars ? item.avatar : null;
final avatarSize = size * 1.15;
final avatarGap = size * 0.3;
final frame = item.own && s.highlightOwn;
final padX = frame ? size * 0.4 : 0.0;
final width = textW + padX * 2 + (avatar == null ? 0 : avatarSize + avatarGap);
Widget text = Text.rich(span(), maxLines: 1, softWrap: false);
if (s.outline > 0) {
final stroke = Paint()
..style = PaintingStyle.stroke
..strokeWidth = s.outline * 2
..strokeJoin = StrokeJoin.round
..color = const Color(0xE6000000);
text = Stack(children: [Text.rich(span(stroke: stroke), maxLines: 1, softWrap: false), text]);
}
Widget line = Row(
mainAxisSize: MainAxisSize.min,
children: [
if (avatar != null) ...[
StatusAvatar(info: avatar, size: avatarSize, badges: false, ring: false),
SizedBox(width: avatarGap),
],
text,
],
);
if (frame) {
line = DecoratedBox(
decoration: BoxDecoration(
color: widget.accent.withValues(alpha: 0.14),
borderRadius: BorderRadius.circular(999),
border: Border.all(color: widget.accent.withValues(alpha: 0.85), width: 1.2),
),
child: Padding(padding: EdgeInsets.symmetric(horizontal: padX), child: line),
);
}
final height = math.max(textH, avatar == null ? 0.0 : avatarSize);
return _Line(item, width, height, SizedBox(width: width, height: height, child: Center(child: line)));
}
void _onTick(Duration elapsed) {
_elapsed = elapsed;
if (_size.isEmpty) return;
final t = _t;
for (final item in _pending) {
_lanes.add(_build(item), t);
}
_pending = [];
final s = widget.style;
_lanes.tick(t,
w: _size.width,
lanes: s.lanesFor(_size.height),
cap: s.maxOnScreen,
crossSecs: s.crossSecs,
measure: (l) => l.width);
final c = widget.controller;
c.onScreen.value = _lanes.flights.length;
c.waiting.value = _lanes.waiting;
c.dropped.value = _lanes.dropped;
final now = List.of(_lanes.flights);
if (now.length != _shown.length || !_sameFlights(now, _shown)) {
setState(() => _shown = now);
} else if (now.isNotEmpty) {
_repaint.value++;
}
if (now.isEmpty && _lanes.waiting == 0 && _pending.isEmpty) {
_base += _elapsed;
_elapsed = Duration.zero;
_ticker.stop();
}
}
static bool _sameFlights(List<DanmakuFlight<_Line>> a, List<DanmakuFlight<_Line>> b) {
for (var i = 0; i < a.length; i++) {
if (!identical(a[i], b[i])) return false;
}
return true;
}
@override
Widget build(BuildContext context) {
return IgnorePointer(
child: LayoutBuilder(builder: (context, box) {
_size = box.biggest;
return Opacity(
opacity: widget.style.opacity.clamp(0.0, 1.0),
child: Flow(
delegate: _DanmakuFlow(this, _repaint),
children: [for (final f in _shown) KeyedSubtree(key: ObjectKey(f), child: f.item.widget)],
),
);
}),
);
}
}
class _DanmakuFlow extends FlowDelegate {
final _DanmakuLayerState state;
_DanmakuFlow(this.state, Listenable repaint) : super(repaint: repaint);
@override
BoxConstraints getConstraintsForChild(int i, BoxConstraints constraints) => const BoxConstraints();
@override
void paintChildren(FlowPaintingContext context) {
final s = state.widget.style;
final size = context.size;
final t = state._t;
final top = s.bandTop * size.height;
final lane = s.laneHeightFor(size.height);
final flights = state._shown;
for (var i = 0; i < flights.length && i < context.childCount; i++) {
final f = flights[i];
final x = f.x(t, size.width);
final y = top + f.lane * lane + (lane - f.item.height) / 2;
context.paintChild(i, transform: Matrix4.translationValues(x, y, 0));
}
}
@override
bool shouldRepaint(_DanmakuFlow old) => true;
}
@@ -0,0 +1,111 @@
import 'package:flutter/material.dart';
import 'package:signals/signals_flutter.dart';
import '../session/notifier.dart' show NoticeCategory, ToastItem;
import '../shell/avatar.dart' show AvatarInfo, colorForName;
import 'danmaku.dart';
// "Notifications: Danmaku" (Settings → Notifications & sounds): over the
// full player, chat and short notices fly across the picture instead of
// popping up as toasts. Problems, errors and the app's own notices stay
// toasts, and so does everything while the player is small or closed.
/// Whether [t] flies as a line (when danmaku is the style in use): chat,
/// joins / leaves / lost connection / back, "starting now", and Up next
/// news someone caused (added, changed the video). Nudges, problems, your
/// own state and the app's notices stay toasts.
bool fliesAsDanmaku(ToastItem t) => switch (t.category) {
NoticeCategory.chat || NoticeCategory.people || NoticeCategory.start => true,
NoticeCategory.upNext => t.actor != null && t.actor!.isNotEmpty,
_ => false,
};
/// [t] as a line: the sender (their avatar from [avatar], their colour)
/// before what they said or did; "starting now" without one. Null for
/// what does not fly ([fliesAsDanmaku]).
DanmakuItem? danmakuForToast(ToastItem t, {AvatarInfo Function(String name, String id)? avatar}) {
if (!fliesAsDanmaku(t)) return null;
final who = t.actor;
if (who == null || who.isEmpty) {
return DanmakuItem(kind: DanmakuKind.starting, text: '▶ ${t.text}');
}
final pic = avatar?.call(who, t.actorId) ?? AvatarInfo(name: who, color: colorForName(who));
final color = pic.color;
if (t.chat && t.text.startsWith('$who: ')) {
return DanmakuItem(
kind: DanmakuKind.chat,
sender: who,
senderColor: color,
text: t.text.substring(who.length + 2),
own: t.own,
avatar: pic);
}
final rest = t.text.startsWith('$who ') ? t.text.substring(who.length + 1) : t.text;
final kind = switch (t.category) {
NoticeCategory.upNext => DanmakuKind.upNext,
_ when rest.startsWith('left') || rest.startsWith('lost') => DanmakuKind.leave,
_ => DanmakuKind.join,
};
return DanmakuItem(kind: kind, sender: who, senderColor: color, text: rest, avatar: pic);
}
/// The session's lines over the picture: fresh toasts that fly
/// ([fliesAsDanmaku], and not [skip]) go to [controller] while this is
/// mounted (the player mounts it only while danmaku is the style and the
/// full player is up); what was there before it mounted is old news.
class DanmakuNotices extends StatefulWidget {
final ReadonlySignal<List<ToastItem>> toasts;
final ReadonlySignal<DanmakuStyle> style;
final DanmakuController controller;
final AvatarInfo Function(String name, String id)? avatar;
/// Toasts shown another way already (your own quick chat echo).
final bool Function(ToastItem t)? skip;
const DanmakuNotices({
super.key,
required this.toasts,
required this.style,
required this.controller,
this.avatar,
this.skip,
});
@override
State<DanmakuNotices> createState() => _DanmakuNoticesState();
}
class _DanmakuNoticesState extends State<DanmakuNotices> {
late Set<int> _seen = {for (final t in widget.toasts.peek()) t.id};
late final void Function() _stop;
@override
void initState() {
super.initState();
_stop = effect(() {
final items = widget.toasts.value;
untracked(() {
for (final t in items) {
if (_seen.contains(t.id) || (widget.skip?.call(t) ?? false)) continue;
final line = danmakuForToast(t, avatar: widget.avatar);
if (line != null) widget.controller.add(line);
}
_seen = {for (final t in items) t.id};
});
});
}
@override
void dispose() {
_stop();
super.dispose();
}
@override
Widget build(BuildContext context) => SignalBuilder(
builder: (context) => DanmakuLayer(
controller: widget.controller,
style: widget.style.value,
accent: Theme.of(context).colorScheme.primary,
),
);
}
@@ -0,0 +1,851 @@
import 'dart:async';
import 'dart:math' as math;
import 'package:flutter/foundation.dart' show ValueListenable;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:signals/signals_flutter.dart';
import '../controls/minimal/style.dart';
import '../controls/session_controls.dart' show controlsBottomHeight;
import '../session/notifier.dart' show ToastItem;
import '../session/session_coordinator.dart' show SessionCoordinator;
import '../shell/avatar.dart' show AvatarInfo, avatarForName, colorForName, memberAvatarInfo, selfAvatarInfo;
import '../shell/motion.dart';
import '../shell/quick_chat.dart';
import '../shell/video_view.dart' show SubtitleLift;
import '../app/app_graph.dart' show AppScope;
import '../widgets/app_menu.dart' show AppGlass, AppMenuStyle;
import 'danmaku.dart';
import 'danmaku_notices.dart' show danmakuForToast;
// Dev builds only (Settings → About → Developer): try the danmaku look on
// the real player (when something is loaded) or on a stand-in picture,
// fed with samples, a random stream or this session's own toasts, with
// every knob of [DanmakuStyle] live and mock subtitles to judge overlap.
/// The preview's state, owned by the shell while the app runs.
class DanmakuPreview {
final controller = DanmakuController();
/// Shown over the full session player.
final active = signal(false);
final Signal<DanmakuStyle> style;
/// A random stream: lines per minute, lengths from one word to a long
/// sentence, CJK and emoji among them.
final auto = signal(false);
final autoRate = signal(40.0);
/// Mock subtitles: a top sign and bottom dialogue.
final mockTop = signal(false);
final mockBottom = signal(false);
/// The stand-in picture's control band (the real player has its own).
final controlsBand = signal(true);
/// This session's chat and notice toasts fly too (session only).
final live = signal(false);
/// In a session: the party's real avatars for the samples and the
/// session's toasts (set while the session panel is up).
SessionCoordinator? coord;
final _rng = math.Random();
Timer? _autoTimer;
late final void Function() _stopAuto;
/// Starts from [style] (the user's look, Settings → Notifications &
/// sounds); the preview's knobs change only the preview.
DanmakuPreview({DanmakuStyle style = const DanmakuStyle()}) : style = signal(style) {
_stopAuto = effect(() {
final on = auto.value;
autoRate.value;
untracked(() {
_autoTimer?.cancel();
if (on) _scheduleAuto();
});
});
}
void dispose() {
_stopAuto();
_autoTimer?.cancel();
controller.dispose();
}
void _scheduleAuto() {
// Poisson arrivals: bursts and lulls like a real chat.
final perSec = autoRate.peek() / 60;
final wait = -math.log(1 - _rng.nextDouble()) / perSec;
_autoTimer = Timer(Duration(milliseconds: (wait * 1000).round().clamp(30, 60000)), () {
if (!auto.peek()) return;
controller.add(randomSample());
_scheduleAuto();
});
}
/// One sample line of [kind]; [own]: as if this member wrote it.
/// Senders come from the party in a session (their pictures), else
/// from [sampleNames] (initials).
DanmakuItem sample(DanmakuKind kind, {bool own = false}) {
final a = own ? selfAvatarInfo() : _sampleSender();
final who = a.name;
final color = own ? a.color : colorForName(who);
return switch (kind) {
DanmakuKind.chat =>
DanmakuItem(kind: kind, sender: who, senderColor: color, text: _pick(sampleChat), own: own, avatar: a),
DanmakuKind.join => DanmakuItem(kind: kind, sender: who, senderColor: color, text: 'joined', avatar: a),
DanmakuKind.leave => DanmakuItem(kind: kind, sender: who, senderColor: color, text: 'left', avatar: a),
DanmakuKind.upNext => DanmakuItem(
kind: kind, sender: who, senderColor: color, text: 'added ${_pick(sampleTitles)} to Up next', avatar: a),
DanmakuKind.starting => const DanmakuItem(kind: DanmakuKind.starting, text: '▶ Starting now'),
};
}
/// [text] as this member's own chat line.
DanmakuItem own(String text) {
final a = selfAvatarInfo();
return DanmakuItem(kind: DanmakuKind.chat, sender: a.name, senderColor: a.color, text: text, own: true, avatar: a);
}
AvatarInfo _sampleSender() {
final c = coord;
final r = c?.serverState.peek();
final others = [
if (c != null && r != null)
for (final m in r.members)
if (m.id != c.connection.memberId) memberAvatarInfo(m, r, c),
];
// The party's real ones half the time, so initials show up too.
if (others.isNotEmpty && _rng.nextBool()) return _pick(others);
final name = _pick(sampleNames);
return AvatarInfo(name: name, color: colorForName(name));
}
/// What the random stream sends: mostly chat, some of it yours, now and
/// then a notice.
DanmakuItem randomSample() {
final r = _rng.nextDouble();
if (r < 0.70) return sample(DanmakuKind.chat);
if (r < 0.80) return sample(DanmakuKind.chat, own: true);
if (r < 0.88) return sample(DanmakuKind.join);
if (r < 0.94) return sample(DanmakuKind.leave);
return sample(DanmakuKind.upNext);
}
/// A toast of this session as a line, as "Notifications: Danmaku" maps
/// it ([danmakuForToast]); the rest (problems) stay toasts.
DanmakuItem? fromToast(ToastItem t) {
final c = coord;
return danmakuForToast(t, avatar: c == null ? null : (name, id) => avatarForName(name, c, id));
}
/// Follows [toasts] while [live] is on; returns the stop.
void Function() watchToasts(ReadonlySignal<List<ToastItem>> toasts) {
var seen = {for (final t in toasts.peek()) t.id};
return effect(() {
final items = toasts.value;
final on = live.value;
untracked(() {
for (final t in items) {
if (!seen.add(t.id) || !on) continue;
final line = fromToast(t);
if (line != null) controller.add(line);
}
seen = {for (final t in items) t.id};
});
});
}
T _pick<T>(List<T> from) => from[_rng.nextInt(from.length)];
static const sampleNames = ['Alice', 'Bob', 'Chiyo', '小雪', 'Kenji', 'Mara'];
static const sampleTitles = ['Frieren · 12', 'Bocchi the Rock! · 5', 'Dungeon Meshi · 20', 'Mob Psycho 100 · 3'];
static const sampleChat = [
'lol',
'www',
'草',
'8888888',
'NICE',
'it\'s so over',
'she\'s back!!',
'wait, who is that?',
'op skip pls 🙏',
'the animation in this scene is insane',
'I can\'t believe they actually adapted this arc, the manga panel looked exactly like this, chills honestly',
'brb getting snacks, don\'t wait for me (actually do wait)',
'前方高能预警!!!',
'このシーン最高すぎる',
'神回確定',
'画质好清晰啊',
'🔥🔥🔥',
'😭😭 my heart',
'🎉 finally',
'Himmel would have done the same 🥲',
];
}
/// The danmaku with this preview's style and mock subtitles over a
/// picture; lets clicks through.
class DanmakuPreviewLayers extends StatelessWidget {
final DanmakuPreview preview;
/// The covered bottom the mock dialogue moves above, as the real
/// subtitles do ([SubtitleLift]).
final ValueListenable<double>? lift;
const DanmakuPreviewLayers({super.key, required this.preview, this.lift});
@override
Widget build(BuildContext context) {
final accent = Theme.of(context).colorScheme.primary;
return SignalBuilder(
builder: (context) => Stack(
fit: StackFit.expand,
children: [
MockSubtitles(top: preview.mockTop.value, bottom: preview.mockBottom.value, lift: lift),
DanmakuLayer(controller: preview.controller, style: preview.style.value, accent: accent),
],
),
);
}
}
/// Stand-ins for subtitles where real ones sit: an ASS sign at the top
/// (`\an8`, yellow serif) and two lines of dialogue at the bottom. The
/// dialogue moves up by [lift] (px covered at the bottom) in the same
/// steps as mpv's `sub-pos` moves the real ones; the sign stays.
class MockSubtitles extends StatelessWidget {
final bool top;
final bool bottom;
final ValueListenable<double>? lift;
const MockSubtitles({super.key, required this.top, required this.bottom, this.lift});
static Widget _outlined(String text, TextStyle style, double outline) => Stack(
children: [
Text(text,
textAlign: TextAlign.center,
style: style.copyWith(
color: null,
foreground: Paint()
..style = PaintingStyle.stroke
..strokeWidth = outline
..strokeJoin = StrokeJoin.round
..color = const Color(0xFF1A1206),
)),
Text(text, textAlign: TextAlign.center, style: style),
],
);
@override
Widget build(BuildContext context) {
if (!top && !bottom) return const SizedBox.shrink();
return IgnorePointer(
child: LayoutBuilder(builder: (context, box) {
final h = box.maxHeight;
final dialogue = (h * 0.05).clamp(12.0, 52.0);
return Stack(
children: [
if (top)
Positioned(
left: 0,
right: 0,
top: h * 0.035,
child: Center(
child: _outlined(
'TOP SIGN · 第3新東京市',
TextStyle(
fontSize: dialogue * 0.85,
fontWeight: FontWeight.w800,
color: const Color(0xFFFFE14D),
),
dialogue * 0.14,
),
),
),
if (bottom)
ValueListenableBuilder<double>(
valueListenable: lift ?? const AlwaysStoppedAnimation(0.0),
builder: (context, up, child) => Positioned(
key: const ValueKey('mock-dialogue'),
left: 0,
right: 0,
bottom: h * 0.05 + up,
child: child!,
),
child: Center(
child: _outlined(
'I told you the controls would cover this line.\nThey never do now — or do they?',
TextStyle(fontSize: dialogue, height: 1.15, color: Colors.white, fontWeight: FontWeight.w500),
dialogue * 0.12,
),
),
),
],
);
}),
);
}
}
/// A moving picture for the preview when nothing is loaded: slow colour
/// fields drifting over a dusk gradient, so motion and contrast behind
/// the lines are realistic.
class DanmakuStandIn extends StatefulWidget {
/// False: a still picture (the settings' strip while it rests).
final bool animate;
const DanmakuStandIn({super.key, this.animate = true});
@override
State<DanmakuStandIn> createState() => _DanmakuStandInState();
}
class _DanmakuStandInState extends State<DanmakuStandIn> with SingleTickerProviderStateMixin {
late final AnimationController _clock = AnimationController(vsync: this, duration: const Duration(seconds: 60));
@override
void initState() {
super.initState();
if (widget.animate) _clock.repeat();
}
@override
void didUpdateWidget(DanmakuStandIn old) {
super.didUpdateWidget(old);
if (widget.animate && !_clock.isAnimating) _clock.repeat();
if (!widget.animate && _clock.isAnimating) _clock.stop();
}
@override
void dispose() {
_clock.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) => RepaintBoundary(
child: CustomPaint(painter: _StandInPainter(_clock), child: const SizedBox.expand()),
);
}
class _StandInPainter extends CustomPainter {
final Animation<double> clock;
_StandInPainter(this.clock) : super(repaint: clock);
static const _blobs = [
(Color(0xFFFF7A59), 0.9, 0.0, 0.42),
(Color(0xFF7C5CFF), 1.3, 1.7, 0.5),
(Color(0xFF2EC5CE), 0.7, 3.1, 0.38),
(Color(0xFFFFC857), 1.1, 4.4, 0.3),
(Color(0xFFE85D9E), 0.5, 5.2, 0.45),
];
@override
void paint(Canvas canvas, Size size) {
final rect = Offset.zero & size;
canvas.drawRect(
rect,
Paint()
..shader = const LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0xFF0B1030), Color(0xFF2A1B4A), Color(0xFF5A2F4F), Color(0xFF14141C)],
stops: [0, 0.45, 0.72, 1],
).createShader(rect),
);
final t = clock.value * 2 * math.pi;
final r = size.shortestSide;
for (final (color, speed, phase, radius) in _blobs) {
final c = Offset(
size.width * (0.5 + 0.38 * math.sin(t * speed + phase)),
size.height * (0.5 + 0.32 * math.cos(t * speed * 0.8 + phase * 1.3)),
);
canvas.drawCircle(
c,
r * radius,
Paint()
..shader = RadialGradient(colors: [color.withValues(alpha: 0.55), color.withValues(alpha: 0)])
.createShader(Rect.fromCircle(center: c, radius: r * radius)),
);
}
// A bright horizon line and some stars: hard edges behind the text.
final horizon = size.height * 0.68;
canvas.drawRect(
Rect.fromLTWH(0, horizon, size.width, size.height - horizon),
Paint()..color = const Color(0xCC0D0D14),
);
canvas.drawLine(Offset(0, horizon), Offset(size.width, horizon),
Paint()
..color = const Color(0x88FFD7A8)
..strokeWidth = 1.5);
final stars = math.Random(7);
for (var i = 0; i < 90; i++) {
final x = stars.nextDouble() * size.width;
final y = stars.nextDouble() * horizon;
final twinkle = 0.4 + 0.6 * (0.5 + 0.5 * math.sin(t * 8 + i));
canvas.drawCircle(Offset(x, y), 0.6 + stars.nextDouble() * 1.2,
Paint()..color = Colors.white.withValues(alpha: 0.5 * twinkle));
}
}
@override
bool shouldRepaint(_StandInPainter old) => false;
}
/// The preview's knobs on the app's glass: fire samples, the random
/// stream, the look, mock subtitles. [onClose] ends the preview; it
/// collapses to its title bar.
class DanmakuPanel extends StatefulWidget {
final DanmakuPreview preview;
final VoidCallback onClose;
/// In a session: offer this session's own toasts as a source.
final bool session;
const DanmakuPanel({super.key, required this.preview, required this.onClose, this.session = false});
@override
State<DanmakuPanel> createState() => _DanmakuPanelState();
}
class _DanmakuPanelState extends State<DanmakuPanel> {
bool _open = true;
DanmakuPreview get p => widget.preview;
void _style(DanmakuStyle Function(DanmakuStyle s) f) => p.style.value = f(p.style.peek());
@override
Widget build(BuildContext context) {
final muted = Colors.white.withValues(alpha: 0.6);
return ExcludeFocus(
child: AppGlass(
child: SizedBox(
width: 300,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(14, 8, 6, 4),
child: Row(
children: [
const Expanded(
child: Text('Danmaku preview', style: TextStyle(fontWeight: FontWeight.w600, fontSize: 14)),
),
_Counts(controller: p.controller),
IconButton(
tooltip: _open ? 'Collapse' : 'Expand',
visualDensity: VisualDensity.compact,
iconSize: 18,
onPressed: () => setState(() => _open = !_open),
icon: AnimatedRotation(
turns: _open ? 0 : 0.5,
duration: easeTime,
curve: ease,
child: const Icon(Icons.expand_less),
),
),
IconButton(
tooltip: 'Close the preview',
visualDensity: VisualDensity.compact,
iconSize: 18,
onPressed: widget.onClose,
icon: const Icon(Icons.close),
),
],
),
),
AnimatedSize(
duration: easeTime,
curve: ease,
alignment: Alignment.topCenter,
child: !_open
? const SizedBox(width: double.infinity)
: ConstrainedBox(
constraints: BoxConstraints(maxHeight: MediaQuery.sizeOf(context).height * 0.7),
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(14, 0, 14, 12),
child: SignalBuilder(builder: (context) => _body(muted)),
),
),
),
],
),
),
),
);
}
Widget _body(Color muted) {
final s = p.style.value;
Widget fire(IconData icon, String label, DanmakuItem Function() item) => MinimalButton(
icon: icon,
label: label,
tooltip: '',
scale: 0.8,
onPressed: () => p.controller.add(item()),
);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_Head('Send', muted),
Wrap(
spacing: 4,
runSpacing: 4,
children: [
fire(Icons.chat_bubble_outline, 'Chat', () => p.sample(DanmakuKind.chat)),
fire(Icons.person_outline, 'Mine', () => p.sample(DanmakuKind.chat, own: true)),
fire(Icons.login, 'Join', () => p.sample(DanmakuKind.join)),
fire(Icons.logout, 'Leave', () => p.sample(DanmakuKind.leave)),
fire(Icons.playlist_add_rounded, 'Up next', () => p.sample(DanmakuKind.upNext)),
fire(Icons.play_arrow_rounded, 'Starting', () => p.sample(DanmakuKind.starting)),
fire(Icons.bolt, 'Burst ×12', () {
for (var i = 0; i < 11; i++) {
p.controller.add(p.randomSample());
}
return p.randomSample();
}),
MinimalButton(
icon: Icons.clear_all,
label: 'Clear',
tooltip: '',
scale: 0.8,
onPressed: p.controller.clear,
),
],
),
_Toggle('Random stream', p.auto.value, (v) => p.auto.value = v),
_Slider('Rate', p.autoRate.value, 5, 240, '${p.autoRate.value.round()}/min', (v) => p.autoRate.value = v,
enabled: p.auto.value),
if (widget.session) _Toggle("This session's chat and notices", p.live.value, (v) => p.live.value = v),
_Head('Look', muted),
_Slider('Crossing', s.crossSecs, 4, 16, '${s.crossSecs.toStringAsFixed(1)} s',
(v) => _style((s) => s.copyWith(crossSecs: v))),
_Slider('At most', s.maxOnScreen.toDouble(), 1, 40, '${s.maxOnScreen} lines',
(v) => _style((s) => s.copyWith(maxOnScreen: v.round()))),
_Range('Lanes', s.bandTop, s.bandBottom, '${(s.bandTop * 100).round()}–${(s.bandBottom * 100).round()} %',
(a, b) => _style((s) => s.copyWith(bandTop: a, bandBottom: b))),
_Slider('Opacity', s.opacity, 0.3, 1, '${(s.opacity * 100).round()} %',
(v) => _style((s) => s.copyWith(opacity: v))),
_Slider('Size', s.size, 0.6, 1.8, '×${s.size.toStringAsFixed(2)}', (v) => _style((s) => s.copyWith(size: v))),
_Slider('Bold', s.weight.toDouble(), 400, 900, '${s.weight}',
(v) => _style((s) => s.copyWith(weight: (v / 100).round() * 100))),
_Slider('Outline', s.outline, 0, 4, '${s.outline.toStringAsFixed(1)} px',
(v) => _style((s) => s.copyWith(outline: v))),
_Toggle('Avatars', s.avatars, (v) => _style((s) => s.copyWith(avatars: v))),
_Toggle('Frame my own lines', s.highlightOwn, (v) => _style((s) => s.copyWith(highlightOwn: v))),
_Head('Subtitles (mock)', muted),
_Toggle('Top sign', p.mockTop.value, (v) => p.mockTop.value = v),
_Toggle('Bottom dialogue', p.mockBottom.value, (v) => p.mockBottom.value = v),
if (!widget.session) _Toggle('Control bar', p.controlsBand.value, (v) => p.controlsBand.value = v),
],
);
}
}
class _Counts extends StatelessWidget {
final DanmakuController controller;
const _Counts({required this.controller});
@override
Widget build(BuildContext context) => ListenableBuilder(
listenable: Listenable.merge([controller.onScreen, controller.waiting]),
builder: (context, _) => Tooltip(
message: 'Flying · waiting for a lane',
child: Text(
'${controller.onScreen.value} · ${controller.waiting.value}',
style: TextStyle(
fontSize: 12,
color: Colors.white.withValues(alpha: 0.6),
fontFeatures: const [FontFeature.tabularFigures()],
),
),
),
);
}
class _Head extends StatelessWidget {
final String text;
final Color color;
const _Head(this.text, this.color);
@override
Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.only(top: 10, bottom: 4),
child: Text(text.toUpperCase(),
style: TextStyle(fontSize: 11, letterSpacing: 0.8, fontWeight: FontWeight.w600, color: color)),
);
}
class _Toggle extends StatelessWidget {
final String label;
final bool value;
final ValueChanged<bool> onChanged;
const _Toggle(this.label, this.value, this.onChanged);
@override
Widget build(BuildContext context) => SizedBox(
height: 34,
child: Row(
children: [
Expanded(child: Text(label, style: const TextStyle(fontSize: 13))),
Transform.scale(scale: 0.75, alignment: Alignment.centerRight, child: Switch(value: value, onChanged: onChanged)),
],
),
);
}
class _Slider extends StatelessWidget {
final String label;
final double value, min, max;
final String readout;
final ValueChanged<double> onChanged;
final bool enabled;
const _Slider(this.label, this.value, this.min, this.max, this.readout, this.onChanged, {this.enabled = true});
@override
Widget build(BuildContext context) => _SliderFrame(
label: label,
readout: readout,
child: Slider(value: value.clamp(min, max), min: min, max: max, onChanged: enabled ? onChanged : null),
);
}
class _Range extends StatelessWidget {
final String label;
final double a, b;
final String readout;
final void Function(double a, double b) onChanged;
const _Range(this.label, this.a, this.b, this.readout, this.onChanged);
@override
Widget build(BuildContext context) => _SliderFrame(
label: label,
readout: readout,
child: RangeSlider(
values: RangeValues(a, b),
max: 0.9,
onChanged: (v) {
if (v.end - v.start >= 0.05) onChanged(v.start, v.end);
},
),
);
}
class _SliderFrame extends StatelessWidget {
final String label;
final String readout;
final Widget child;
const _SliderFrame({required this.label, required this.readout, required this.child});
@override
Widget build(BuildContext context) => SizedBox(
height: 34,
child: Row(
children: [
SizedBox(width: 64, child: Text(label, style: const TextStyle(fontSize: 13))),
Expanded(
child: SliderTheme(
data: SliderTheme.of(context).copyWith(
trackHeight: 3,
overlayShape: SliderComponentShape.noOverlay,
thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 6),
rangeThumbShape: const RoundRangeSliderThumbShape(enabledThumbRadius: 6),
),
child: child,
),
),
SizedBox(
width: 76,
child: Text(
readout,
textAlign: TextAlign.right,
style: TextStyle(
fontSize: 12,
color: Colors.white.withValues(alpha: 0.7),
fontFeatures: const [FontFeature.tabularFigures()],
),
),
),
],
),
);
}
/// The preview without a session player: the stand-in picture, a mock
/// control bar of the skin in use, the lines and the panel, over the
/// whole window (Esc or × closes it).
Future<void> showDanmakuPreviewPage(BuildContext context, DanmakuPreview preview) => showGeneralDialog<void>(
context: context,
barrierDismissible: false,
barrierLabel: 'Danmaku preview',
barrierColor: Colors.black,
transitionDuration: easeTime,
pageBuilder: (context, _, _) => DanmakuPreviewPage(preview: preview),
transitionBuilder: (context, anim, _, child) => FadeTransition(
opacity: CurvedAnimation(parent: anim, curve: ease, reverseCurve: Curves.easeIn),
child: child,
),
);
class DanmakuPreviewPage extends StatefulWidget {
final DanmakuPreview preview;
const DanmakuPreviewPage({super.key, required this.preview});
@override
State<DanmakuPreviewPage> createState() => _DanmakuPreviewPageState();
}
/// The page also has the quick chat box (Enter): what you send flies as
/// your own line, and the mock dialogue moves above the box in the lift's
/// steps, as the real subtitles do over the player.
class _DanmakuPreviewPageState extends State<DanmakuPreviewPage> {
final _chat = QuickChat();
final _lift = SubtitleLift();
final _pageFocus = FocusNode(debugLabel: 'danmaku preview');
double _boxH = QuickChatBox.height;
DanmakuPreview get preview => widget.preview;
@override
void initState() {
super.initState();
_chat.open.addListener(_retarget);
WidgetsBinding.instance.addPostFrameCallback((_) => _pageFocus.requestFocus());
}
@override
void dispose() {
_chat.open.removeListener(_retarget);
_chat.dispose();
_lift.dispose();
_pageFocus.dispose();
super.dispose();
}
void _retarget() {
final s = AppScope.of(context).coordinator.settings;
final bar = preview.controlsBand.peek() && s.liftSubtitles.peek()
? controlsBottomHeight(s.controlsSkin.peek(), s.controlsSize.peek()) + 8
: 0.0;
final box = _chat.open.value ? 16 + _boxH + 8 : 0.0;
_lift.animateTo(box > bar ? box : bar);
if (!_chat.open.value) _pageFocus.requestFocus();
}
KeyEventResult _key(FocusNode node, KeyEvent e) {
if (e is! KeyDownEvent) return KeyEventResult.ignored;
if (e.logicalKey == LogicalKeyboardKey.enter || e.logicalKey == LogicalKeyboardKey.numpadEnter) {
_chat.show();
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
}
@override
Widget build(BuildContext context) {
return Focus(
focusNode: _pageFocus,
onKeyEvent: _key,
child: Material(
color: Colors.black,
child: Stack(
fit: StackFit.expand,
children: [
const DanmakuStandIn(),
DanmakuPreviewLayers(preview: preview, lift: _lift),
SignalBuilder(builder: (context) {
if (!preview.controlsBand.value) return const SizedBox.shrink();
final s = AppScope.of(context).coordinator.settings;
final h = controlsBottomHeight(s.controlsSkin.value, s.controlsSize.value);
return Align(
alignment: Alignment.bottomCenter,
child: IgnorePointer(
child: ValueListenableBuilder<bool>(
valueListenable: _chat.open,
builder: (context, open, child) =>
AnimatedOpacity(opacity: open ? 0 : 1, duration: easeTime, curve: ease, child: child),
child: Container(
height: h,
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0x00000000), MinimalStyle.band],
),
border: Border(top: BorderSide(color: AppMenuStyle.border, width: 0.5)),
),
alignment: Alignment.center,
child: Text('control bar',
style: TextStyle(color: Colors.white.withValues(alpha: 0.35), fontSize: 12)),
),
),
),
);
}),
Positioned(
left: 16,
right: 16,
bottom: 16,
child: QuickChatBox(
chat: _chat,
onSend: (text) => preview.controller.add(preview.own(text)),
onHeight: (h) {
_boxH = h;
if (_chat.open.value) _retarget();
},
),
),
Positioned(
left: 16,
top: 14,
child: Text('Stand-in picture: load something to preview on the real player · Enter: chat box',
style: TextStyle(color: Colors.white.withValues(alpha: 0.55), fontSize: 12)),
),
Positioned(
right: 16,
top: 16,
child: DanmakuPanel(preview: preview, onClose: () => Navigator.of(context).pop()),
),
],
),
),
);
}
}
/// The panel over the session player; while mounted, this session's
/// toasts reach the preview when its "chat and notices" switch is on.
class DanmakuSessionPanel extends StatefulWidget {
final DanmakuPreview preview;
final SessionCoordinator coord;
const DanmakuSessionPanel({super.key, required this.preview, required this.coord});
@override
State<DanmakuSessionPanel> createState() => _DanmakuSessionPanelState();
}
class _DanmakuSessionPanelState extends State<DanmakuSessionPanel> {
late final void Function() _stop = widget.preview.watchToasts(widget.coord.toasts);
@override
void initState() {
super.initState();
widget.preview.coord = widget.coord;
_stop;
}
@override
void dispose() {
_stop();
widget.preview.coord = null;
super.dispose();
}
@override
Widget build(BuildContext context) => DanmakuPanel(
preview: widget.preview,
session: true,
onClose: () => widget.preview.active.value = false,
);
}
@@ -0,0 +1,342 @@
import 'dart:async';
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:signals/signals_flutter.dart';
import '../shell/avatar.dart' show AvatarInfo, colorForName, selfAvatarInfo;
import '../state/settings.dart';
import '../widgets/app_menu.dart' show AppSelect;
import 'danmaku.dart';
import 'danmaku_preview.dart' show DanmakuPreview, DanmakuStandIn, MockSubtitles;
// Settings → Notifications & sounds: how notifications show over the full
// player (Toasts or Danmaku) and every knob of the danmaku look, with a
// live preview strip. The player's side panel shows the style and the
// knobs too ([DanmakuKnobs]).
/// Names of the weights the boldness slider steps through.
const danmakuWeightNames = {
400: 'Regular',
500: 'Medium',
600: 'Semibold',
700: 'Bold',
800: 'Extra bold',
900: 'Black',
};
/// "Notifications: Toasts / Danmaku" ([AppSettings.notifyStyle]).
class NotifyStyleSelect extends StatelessWidget {
final AppSettings settings;
const NotifyStyleSelect({super.key, required this.settings});
@override
Widget build(BuildContext context) => SignalBuilder(
builder: (context) => AppSelect<NotifyStyle>(
key: const ValueKey('notify-style'),
value: settings.notifyStyle.value,
onChanged: settings.setNotifyStyle,
items: [for (final m in NotifyStyle.values) (m, m.label)],
),
);
}
/// The card: the style picker, the preview strip and the knobs.
class DanmakuSettingsPanel extends StatelessWidget {
final AppSettings settings;
const DanmakuSettingsPanel({super.key, required this.settings});
@override
Widget build(BuildContext context) {
final muted = TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant, fontSize: 12);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Notifications'),
Text(
'Danmaku: over the full player, chat, joins, leaves, Up next and "starting now" fly across '
'the top of the picture instead of popping up. Problems and errors stay pop-ups, and so does '
'everything with the player small',
style: muted),
],
),
),
const SizedBox(width: 12),
NotifyStyleSelect(settings: settings),
],
),
const SizedBox(height: 12),
DanmakuPreviewStrip(settings: settings),
const SizedBox(height: 6),
DanmakuKnobs(settings: settings),
],
);
}
}
/// A 16:9-ish strip with a moving picture, a top sign and bottom
/// dialogue, and sample lines in the current look, a few a second.
class DanmakuPreviewStrip extends StatefulWidget {
final AppSettings settings;
final double height;
const DanmakuPreviewStrip({super.key, required this.settings, this.height = 190});
@override
State<DanmakuPreviewStrip> createState() => _DanmakuPreviewStripState();
}
class _DanmakuPreviewStripState extends State<DanmakuPreviewStrip> {
/// The strip plays this long after it opens or a knob moves, then
/// rests (a click plays it again): a settings page left open costs
/// nothing.
static const playFor = Duration(seconds: 16);
final _controller = DanmakuController();
final _rng = math.Random(3);
Timer? _timer;
Timer? _rest;
bool _playing = false;
int _n = 0;
late final void Function() _stop;
@override
void initState() {
super.initState();
_stop = effect(() {
widget.settings.danmakuStyle.value;
untracked(_play);
});
}
@override
void dispose() {
_stop();
_timer?.cancel();
_rest?.cancel();
_controller.dispose();
super.dispose();
}
void _play() {
_rest?.cancel();
_rest = Timer(playFor, () {
_timer?.cancel();
_timer = null;
if (mounted) setState(() => _playing = false);
});
if (_timer != null) return;
_send();
_timer = Timer.periodic(const Duration(milliseconds: 1100), (_) => _send());
if (_playing) return;
_playing = true;
if (_built && mounted) setState(() {});
}
bool _built = false;
void _send() {
final i = _n++;
if (i % 7 == 3) {
final me = selfAvatarInfo();
_controller.add(DanmakuItem(
kind: DanmakuKind.chat, sender: me.name, senderColor: me.color, text: 'this is my line', own: true, avatar: me));
return;
}
final name = DanmakuPreview.sampleNames[_rng.nextInt(DanmakuPreview.sampleNames.length)];
final who = AvatarInfo(name: name, color: colorForName(name));
if (i % 9 == 5) {
_controller.add(DanmakuItem(kind: DanmakuKind.join, sender: name, senderColor: who.color, text: 'joined', avatar: who));
return;
}
// Short and medium lines: the long ones crowd a small strip.
final pool = [for (final t in DanmakuPreview.sampleChat) if (t.length < 48) t];
_controller.add(DanmakuItem(
kind: DanmakuKind.chat,
sender: name,
senderColor: who.color,
text: pool[_rng.nextInt(pool.length)],
avatar: who));
}
@override
Widget build(BuildContext context) {
_built = true;
final accent = Theme.of(context).colorScheme.primary;
return ClipRRect(
key: const ValueKey('danmaku-preview-strip'),
borderRadius: BorderRadius.circular(12),
child: SizedBox(
height: widget.height,
child: Stack(
fit: StackFit.expand,
children: [
DanmakuStandIn(animate: _playing),
const MockSubtitles(top: true, bottom: true),
SignalBuilder(
builder: (context) =>
DanmakuLayer(controller: _controller, style: widget.settings.danmakuStyle.value, accent: accent),
),
Center(
child: AnimatedOpacity(
opacity: _playing ? 0 : 1,
duration: const Duration(milliseconds: 200),
child: IgnorePointer(
ignoring: _playing,
child: FilledButton.tonalIcon(
key: const ValueKey('danmaku-preview-play'),
onPressed: _play,
icon: const Icon(Icons.play_arrow_rounded, size: 18),
label: const Text('Play preview'),
),
),
),
),
],
),
),
);
}
}
/// Every knob of the danmaku look, live and saved: crossing time, how
/// many at once, the lane band, opacity, size, boldness, outline,
/// avatars, your own lines framed; and a reset.
class DanmakuKnobs extends StatelessWidget {
final AppSettings settings;
/// Narrow (the player's side panel): shorter labels column.
final bool dense;
const DanmakuKnobs({super.key, required this.settings, this.dense = false});
void _set(DanmakuStyle Function(DanmakuStyle s) f) => settings.setDanmakuStyle(f(settings.danmakuStyle.peek()));
@override
Widget build(BuildContext context) {
const l = DanmakuStyle.limits;
return SignalBuilder(builder: (context) {
final s = settings.danmakuStyle.value;
final labelW = dense ? 84.0 : 150.0;
Widget slider(String key, String label, double v, (num, num) r, String readout, ValueChanged<double> f,
{int? divisions}) =>
_Knob(
label: label,
labelWidth: labelW,
readout: readout,
child: Slider(
key: ValueKey('danmaku-$key'),
value: v.clamp(r.$1.toDouble(), r.$2.toDouble()),
min: r.$1.toDouble(),
max: r.$2.toDouble(),
divisions: divisions,
onChanged: f,
),
);
Widget toggle(String key, String label, bool v, ValueChanged<bool> f) => _Knob(
label: label,
labelWidth: double.infinity,
child: Switch(key: ValueKey('danmaku-$key'), value: v, onChanged: f),
);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
slider('cross', dense ? 'Crossing' : 'Crossing time', s.crossSecs, l.crossSecs,
'${s.crossSecs.toStringAsFixed(1)} s', (v) => _set((s) => s.copyWith(crossSecs: v))),
slider('cap', dense ? 'At most' : 'At most on screen', s.maxOnScreen.toDouble(), l.maxOnScreen,
'${s.maxOnScreen} lines', (v) => _set((s) => s.copyWith(maxOnScreen: v.round())),
divisions: l.maxOnScreen.$2 - l.maxOnScreen.$1),
_Knob(
label: dense ? 'Lanes' : 'Lanes (from the top)',
labelWidth: labelW,
readout: '${(s.bandTop * 100).round()}–${(s.bandBottom * 100).round()} %',
child: RangeSlider(
key: const ValueKey('danmaku-band'),
values: RangeValues(s.bandTop, s.bandBottom),
min: l.band.$1,
max: l.band.$2,
onChanged: (v) {
if (v.end - v.start >= l.minBand) _set((s) => s.copyWith(bandTop: v.start, bandBottom: v.end));
},
),
),
slider('opacity', 'Opacity', s.opacity, l.opacity, '${(s.opacity * 100).round()} %',
(v) => _set((s) => s.copyWith(opacity: v))),
slider('size', 'Size', s.size, l.size, '×${s.size.toStringAsFixed(2)}', (v) => _set((s) => s.copyWith(size: v))),
slider('weight', 'Boldness', s.weight.toDouble(), l.weight, danmakuWeightNames[s.weight] ?? '${s.weight}',
(v) => _set((s) => s.copyWith(weight: (v / 100).round() * 100)),
divisions: (l.weight.$2 - l.weight.$1) ~/ 100),
slider('outline', 'Outline', s.outline, l.outline,
s.outline < 0.05 ? 'none' : '${s.outline.toStringAsFixed(1)} px', (v) => _set((s) => s.copyWith(outline: v))),
toggle('avatars', 'Avatars before the lines', s.avatars, (v) => _set((s) => s.copyWith(avatars: v))),
toggle('own', 'Frame my own lines', s.highlightOwn, (v) => _set((s) => s.copyWith(highlightOwn: v))),
Align(
alignment: Alignment.centerRight,
child: TextButton.icon(
key: const ValueKey('danmaku-reset'),
onPressed: s == const DanmakuStyle() ? null : () => settings.setDanmakuStyle(const DanmakuStyle()),
icon: const Icon(Icons.restart_alt, size: 18),
label: const Text('Reset look'),
),
),
],
);
});
}
}
class _Knob extends StatelessWidget {
final String label;
final double labelWidth;
final String? readout;
final Widget child;
const _Knob({required this.label, required this.labelWidth, this.readout, required this.child});
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final text = Text(label, style: const TextStyle(fontSize: 13));
if (labelWidth == double.infinity) {
return SizedBox(
height: 38,
child: Row(children: [Expanded(child: text), Transform.scale(scale: 0.8, alignment: Alignment.centerRight, child: child)]),
);
}
return SizedBox(
height: 36,
child: Row(
children: [
SizedBox(width: labelWidth, child: text),
Expanded(
child: SliderTheme(
data: SliderTheme.of(context).copyWith(
trackHeight: 3,
overlayShape: SliderComponentShape.noOverlay,
thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 7),
rangeThumbShape: const RoundRangeSliderThumbShape(enabledThumbRadius: 7),
showValueIndicator: ShowValueIndicator.never,
tickMarkShape: SliderTickMarkShape.noTickMark,
),
child: child,
),
),
SizedBox(
width: 78,
child: Text(
readout ?? '',
textAlign: TextAlign.right,
style: TextStyle(
fontSize: 12,
color: scheme.onSurfaceVariant,
fontFeatures: const [FontFeature.tabularFigures()],
),
),
),
],
),
);
}
}
@@ -0,0 +1,84 @@
import 'package:flutter/foundation.dart';
import 'package:hive_ce/hive.dart';
import 'package:signals/signals_flutter.dart';
// Files source state, one Hive box: the folders the user added and the
// local files played last. Same shape as AppSettings: signals start empty,
// take the stored values once [FolderStore.ensureLoaded] finishes, and
// mutators write through.
const filesBoxName = 'files';
const _keyFolders = 'folders';
const _keyRecent = 'recent';
/// Recent files kept, most recent first.
const maxRecentFiles = 30;
class FolderStore {
static final FolderStore instance = FolderStore();
/// Watched folders, in the order they were added.
final folders = signal<List<String>>(const []);
/// Local files played, most recent first, no duplicates.
final recent = signal<List<String>>(const []);
Future<void>? _load;
Box? _box;
Future<void> ensureLoaded() => _load ??= _loadNow();
Future<void> _loadNow() async {
try {
final box = _box = await Hive.openBox(filesBoxName);
final f = box.get(_keyFolders);
if (f is List) folders.value = List.unmodifiable(f.whereType<String>());
final r = box.get(_keyRecent);
if (r is List) {
recent.value = List.unmodifiable(
r.whereType<String>().toSet().take(maxRecentFiles));
}
} catch (e, st) {
debugPrint('load files store failed: $e\n$st');
}
}
/// False when [dir] is already watched.
bool addFolder(String dir) {
if (folders.value.contains(dir)) return false;
_setFolders([...folders.value, dir]);
return true;
}
void removeFolder(String dir) =>
_setFolders([...folders.value.where((d) => d != dir)]);
/// Moves [path] to the top of [recent].
void addRecent(String path) => _setRecent(
[path, ...recent.value.where((p) => p != path)].take(maxRecentFiles));
void removeRecent(String path) =>
_setRecent(recent.value.where((p) => p != path));
void clearRecent() => _setRecent(const []);
void _setFolders(List<String> list) {
folders.value = List.unmodifiable(list);
_put(_keyFolders, [...list]);
}
void _setRecent(Iterable<String> list) {
recent.value = List.unmodifiable(list);
_put(_keyRecent, [...recent.value]);
}
Future<void> _put(String key, Object value) async {
try {
final box = _box ??
(Hive.isBoxOpen(filesBoxName) ? Hive.box(filesBoxName) : null);
await box?.put(key, value);
} catch (e, st) {
debugPrint('save files store $key failed: $e\n$st');
}
}
}
+152
View File
@@ -0,0 +1,152 @@
import 'dart:io';
import 'dart:isolate';
import 'package:sync_relay_core/core.dart';
export 'package:sync_relay_core/core.dart' show FileKind, LocalFile, kindOf, subtitleExtensions, videoExtensions;
// Folder scan for the Files source: the videos (grouped into shows by
// core's `groupLocal`, L1), sidecar subtitles, everything else counted
// and hidden. The walk is synchronous I/O, so it runs in its own isolate.
/// Files looked at per scan; past this the result is [FolderScan.truncated].
const maxScannedFiles = 20000;
/// Samples and trailers under this size are hidden next to the real thing.
const _sampleMaxBytes = 300 << 20;
class FolderScan {
final List<LocalFile> videos;
/// Non-video files and samples (subtitles and dot-entries not counted).
final int hiddenCount;
/// Up to 6 of the hidden file names, for "and N other files" hints.
final List<String> hiddenExamples;
/// Video path -> subtitle files next to it with the same base name
/// (`Ep 01.mkv` -> `Ep 01.srt`, `Ep 01.en.ass`).
final Map<String, List<String>> subtitles;
/// Stopped after [maxScannedFiles] files.
final bool truncated;
const FolderScan({
required this.videos,
required this.hiddenCount,
required this.hiddenExamples,
required this.subtitles,
required this.truncated,
});
static const empty = FolderScan(
videos: [],
hiddenCount: 0,
hiddenExamples: [],
subtitles: {},
truncated: false,
);
}
/// Scans [dir] recursively off the UI isolate. Symlinks are not followed,
/// hidden (dot) files and folders are skipped, unreadable subfolders are
/// left out; never throws (a missing folder is [FolderScan.empty]).
Future<FolderScan> scanFolder(String dir, {int maxFiles = maxScannedFiles}) =>
Isolate.run(() => scanFolderSync(dir, maxFiles: maxFiles))
.catchError((Object _) => FolderScan.empty);
/// The walk behind [scanFolder]; blocks, so call it from an isolate.
FolderScan scanFolderSync(String dir, {int maxFiles = maxScannedFiles}) {
final videos = <LocalFile>[];
final subs = <String>[];
final examples = <String>[];
var hidden = 0, seen = 0;
var truncated = false;
final pending = [Directory(dir)];
walk:
while (pending.isNotEmpty) {
final List<FileSystemEntity> entries;
try {
entries = pending.removeLast().listSync(followLinks: false);
} catch (_) {
continue;
}
entries.sort((a, b) => a.path.compareTo(b.path));
for (final e in entries) {
final name = _baseName(e.path);
if (name.startsWith('.')) continue;
if (e is Directory) {
pending.add(e);
continue;
}
if (e is! File) continue; // links
if (++seen > maxFiles) {
truncated = true;
break walk;
}
final kind = kindOf(name);
if (kind == FileKind.subtitle) {
subs.add(e.path);
continue;
}
FileStat? stat;
if (kind == FileKind.video) {
try {
stat = e.statSync();
} catch (_) {}
}
final size = stat?.size ?? 0;
final lower = name.toLowerCase();
final sample = kind == FileKind.video &&
(lower.contains('sample') || lower.contains('trailer')) &&
size < _sampleMaxBytes;
if (kind != FileKind.video || sample) {
hidden++;
if (examples.length < 6) examples.add(name);
continue;
}
videos.add(LocalFile(e.path, size, stat?.modified ?? DateTime.fromMillisecondsSinceEpoch(0)));
}
}
return FolderScan(
videos: videos,
hiddenCount: hidden,
hiddenExamples: examples,
subtitles: _sidecars(videos, subs),
truncated: truncated,
);
}
/// Subtitles in the same folder whose name is the video's base name,
/// optionally followed by `.lang` / `.forced` style suffixes.
Map<String, List<String>> _sidecars(List<LocalFile> videos, List<String> subs) {
final byStem = <String, List<String>>{};
for (final s in subs) {
final stem = _stripExtension(s);
byStem.putIfAbsent(stem, () => []).add(s);
// `Ep 01.en.ass`, `Ep 01.en.forced.ass`: also index the shorter stems.
var shorter = stem;
for (var i = 0; i < 2; i++) {
final dot = shorter.lastIndexOf('.');
if (dot <= shorter.lastIndexOf(RegExp(r'[/\\]')) + 1) break;
final suffix = shorter.substring(dot + 1);
// Language / flag tags only (en, eng, pt-BR, forced, sdh): a suffix
// with digits is part of the name (`Show.S01E01.srt` is not `Show`'s).
if (!RegExp(r'^[A-Za-z]{2,8}(-[A-Za-z]{2,4})?$').hasMatch(suffix)) break;
shorter = shorter.substring(0, dot);
byStem.putIfAbsent(shorter, () => []).add(s);
}
}
return {
for (final v in videos)
if (byStem[_stripExtension(v.path)] case final list?)
v.path: (list.toSet().toList()..sort()),
};
}
String _baseName(String path) {
final i = path.lastIndexOf(RegExp(r'[/\\]'));
return i < 0 ? path : path.substring(i + 1);
}
String _stripExtension(String path) {
final dot = path.lastIndexOf('.');
final sep = path.lastIndexOf(RegExp(r'[/\\]'));
return dot > sep + 1 ? path.substring(0, dot) : path;
}
+200
View File
@@ -0,0 +1,200 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:crypto/crypto.dart';
import 'package:flutter/foundation.dart';
import 'package:path_provider/path_provider.dart';
import '../platform/desktop/media_kit_host.dart' show MediaKitHost;
import '../platform/ports/player_host.dart';
// Poster frames for the Files source, made by mpv itself (no ffmpeg on
// users' machines; Windows ships only libmpv). A hidden player per file
// with `vo=null`: mpv still keeps the last decoded frame, so
// `screenshot-to-file` encodes it with mpv's own JPEG writer. `start=25%`
// makes the first decoded frame the one we want, `vf=scale` keeps it
// small. Results (and failures) are cached on disk by path+size+mtime.
class Thumbnail {
/// JPEG on disk, at most [thumbnailWidth] wide.
final String imagePath;
final Duration duration;
const Thumbnail(this.imagePath, this.duration);
}
const thumbnailWidth = 480;
/// The players thumbnails are made with (the app's host, set in `main`).
PlayerHost thumbnailPlayers = MediaKitHost()..init();
/// Where thumbnails are cached; swapped in tests.
Future<Directory> Function() thumbnailCacheRoot = getApplicationCacheDirectory;
/// Generations running at once: each is a full decoder.
const _maxConcurrent = 2;
const _timeout = Duration(seconds: 20);
int _running = 0;
final _waiting = <Completer<void>>[];
final _inFlight = <String, Future<Thumbnail?>>{};
final _players = <HostedPlayer>{};
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 thumbnailPlayers.dispose(p).timeout(deadline);
} catch (_) {}
}
}
/// 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).
/// Never throws. [cancelled] is checked while queued and before the
/// expensive steps: a scrolled-away tile returns null without decoding.
Future<Thumbnail?> thumbnailFor(String path, {bool Function()? cancelled}) {
if (_closed) return Future.value(null);
final existing = _inFlight[path];
if (existing != null) return existing;
final f = _thumbnailFor(path, () => _closed || (cancelled?.call() ?? false)).catchError((
Object e,
StackTrace st,
) {
debugPrint('thumbnail $path failed: $e\n$st');
return null;
});
_inFlight[path] = f;
f.whenComplete(() => _inFlight.remove(path));
return f;
}
/// Cache key: the same file at the same size and mtime reuses its frame.
String thumbnailKey(String path, int size, DateTime modified) => sha1
.convert(utf8.encode('$path|$size|${modified.millisecondsSinceEpoch}'))
.toString();
Future<Thumbnail?> _thumbnailFor(String path, bool Function() cancelled) async {
final stat = await File(path).stat();
if (stat.type != FileSystemEntityType.file) return null;
final dir = Directory(
'${(await thumbnailCacheRoot()).path}${Platform.pathSeparator}thumbnails');
await dir.create(recursive: true);
final base =
'${dir.path}${Platform.pathSeparator}${thumbnailKey(path, stat.size, stat.modified)}';
final cached = await _readCache(base);
if (cached != null) return cached.$1;
if (cancelled()) return null;
await _acquire();
try {
if (cancelled()) return null;
final made = await _generate(path, '$base.jpg', cancelled)
.timeout(_timeout, onTimeout: () => null);
if (made == null) {
// Remember broken files, not cancellations.
if (!cancelled()) await File('$base.json').writeAsString('{}');
return null;
}
await File('$base.json')
.writeAsString(jsonEncode({'ms': made.duration.inMilliseconds}));
return made;
} finally {
_release();
}
}
/// (thumbnail or null for a remembered failure), or null when not cached.
Future<(Thumbnail?,)?> _readCache(String base) async {
try {
final meta = File('$base.json');
if (!await meta.exists()) return null;
final ms = (jsonDecode(await meta.readAsString()) as Map)['ms'];
if (ms is! int) return (null,);
final jpg = File('$base.jpg');
if (!await jpg.exists()) return null;
return (Thumbnail(jpg.path, Duration(milliseconds: ms)),);
} catch (_) {
return null;
}
}
Future<void> _acquire() async {
if (_running < _maxConcurrent) {
_running++;
return;
}
final c = Completer<void>();
_waiting.add(c);
await c.future; // the releaser hands its slot over
}
void _release() {
if (_waiting.isNotEmpty) {
_waiting.removeAt(0).complete();
} else {
_running--;
}
}
Future<Thumbnail?> _generate(
String path,
String out,
bool Function() cancelled,
) async {
final player = await thumbnailPlayers.create(PlayerRole.thumbnail);
_players.add(player);
try {
final mpv = player;
for (final (k, v) in [
('ao', 'null'),
('aid', 'no'),
('sid', 'no'),
('vid', 'auto'), // media_kit starts with vid=no
('hwdec', 'no'),
('cache', 'no'),
('hr-seek', 'no'), // nearest keyframe is fine for a poster
('start', '25%'),
('vf', 'scale=w=$thumbnailWidth:h=-2'),
('screenshot-format', 'jpg'),
('screenshot-jpeg-quality', '85'),
('screenshot-tag-colorspace', 'no'),
]) {
await mpv.setProperty(k, v);
}
await player.open(path);
final target = File(out);
final deadline = DateTime.now().add(_timeout);
while (DateTime.now().isBefore(deadline)) {
if (cancelled()) return null;
// Unreadable or no video (with aid=no, audio-only stops too): mpv
// drops the file and goes idle at once.
if (await mpv.getProperty('idle-active') == 'yes') return null;
// A frame is up once the video has a size and the seek is done.
final w = await mpv.getProperty('video-params/w');
final seeking = await mpv.getProperty('seeking');
if (w.isNotEmpty && w != '0' && seeking != 'yes') {
await mpv.command(['screenshot-to-file', out, 'video']);
if (await target.exists() && await target.length() > 0) {
final d = double.tryParse(await mpv.getProperty('duration')) ?? 0;
return Thumbnail(out, Duration(milliseconds: (d * 1000).round()));
}
}
await Future.delayed(const Duration(milliseconds: 50));
}
return null;
} finally {
// [cancelAllThumbnails] may have taken it already.
if (_players.remove(player)) await thumbnailPlayers.dispose(player);
}
}
@@ -0,0 +1,262 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:sync_relay_core/core.dart';
import '../../shell/avatar.dart';
import 'thumb.dart';
// The Files page's rows and cards: an episode row (also used for Recent),
// a loose-video card, and the empty states.
/// What a play control is doing right now.
enum PlayState { idle, starting, playing }
/// Last path segment, ignoring trailing separators (`/media/anime/`).
String baseName(String path) {
final parts = path.split(RegExp(r'[/\\]')).where((p) => p.isNotEmpty).toList();
return parts.isEmpty ? path : parts.last;
}
/// Name of the folder [path] is in.
String parentName(String path) => baseName(File(path).parent.path);
/// Cleaned title of a loose video: `Title (2019)`.
String cleanTitle(ParsedName p) => p.year == null ? p.series : '${p.series} (${p.year})';
/// One file as a row: an episode on a show's page, or a Recent entry
/// ([indent] off).
class FileRow extends StatelessWidget {
final String path;
final String title;
final String subtitle;
/// Size text; null while unknown.
final String? size;
final PlayState state;
final ThumbCache thumbs;
/// Null disables the row.
final VoidCallback? onTap;
final bool indent;
final Widget? trailing;
const FileRow({
super.key,
required this.path,
required this.title,
required this.subtitle,
required this.size,
required this.state,
required this.thumbs,
required this.onTap,
this.indent = true,
this.trailing,
});
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final playing = state == PlayState.playing;
final enabled = onTap != null;
final muted = scheme.onSurfaceVariant;
final icon = switch (state) {
PlayState.starting => const SizedBox.square(
dimension: 16, child: CircularProgressIndicator(strokeWidth: 2)),
PlayState.playing => Icon(Icons.graphic_eq, size: 20, color: scheme.primary),
PlayState.idle => Icon(Icons.play_circle_outline,
size: 20, color: enabled ? scheme.primary : scheme.onSurface.withValues(alpha: 0.38)),
};
return ThumbBuilder(
cache: thumbs,
path: path,
builder: (context, t, _) {
final duration = durationText(t);
final meta = [?duration, ?size].join(' · ');
return Material(
color: playing ? scheme.primary.withValues(alpha: 0.08) : Colors.transparent,
borderRadius: BorderRadius.circular(10),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(10),
child: Padding(
padding: EdgeInsets.fromLTRB(indent ? 20 : 4, 5, 8, 5),
child: Row(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(6),
child: ThumbImage(t, width: 80, height: 45),
),
const SizedBox(width: 10),
SizedBox(width: 20, child: Center(child: icon)),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Flexible(
child: Text(
title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontWeight: FontWeight.w600,
color: playing ? scheme.primary : null,
),
),
),
if (playing) ...[
const SizedBox(width: 8),
Pill('now playing', scheme.primary),
],
],
),
Text(subtitle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: muted, fontSize: 12)),
],
),
),
if (meta.isNotEmpty) ...[
const SizedBox(width: 12),
Text(meta, style: TextStyle(color: muted, fontSize: 12)),
],
?trailing,
],
),
),
),
);
},
);
}
}
/// Text under a [VideoCard]'s 16:9 frame (title up to two lines, size).
const videoCardTextHeight = 66.0;
/// A loose video (movie, clip): 16:9 frame with its duration, then the
/// cleaned title and size.
class VideoCard extends StatelessWidget {
final LocalEpisode video;
final PlayState state;
final ThumbCache thumbs;
final VoidCallback? onTap;
const VideoCard({
super.key,
required this.video,
required this.state,
required this.thumbs,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final playing = state == PlayState.playing;
return ThumbBuilder(
cache: thumbs,
path: video.path,
builder: (context, t, _) {
final duration = durationText(t);
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AspectRatio(
aspectRatio: 16 / 9,
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: playing ? scheme.primary : Colors.transparent,
width: 2,
),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(10),
child: Stack(
fit: StackFit.expand,
children: [
ThumbImage(t),
if (duration != null)
Positioned(right: 6, bottom: 6, child: _Badge(duration)),
if (state != PlayState.idle)
Container(
color: Colors.black38,
alignment: Alignment.center,
child: state == PlayState.playing
? const Icon(Icons.graphic_eq, color: Colors.white, size: 32)
: const SizedBox.square(
dimension: 28,
child: CircularProgressIndicator(
strokeWidth: 3, color: Colors.white)),
),
],
),
),
),
),
const SizedBox(height: 6),
Flexible(
child: Text(
cleanTitle(video.parsed),
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontWeight: FontWeight.w600,
fontSize: 13,
color: playing ? scheme.primary : null,
),
),
),
Text(formatSize(video.file.size),
style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12)),
],
),
);
},
);
}
}
class _Badge extends StatelessWidget {
final String text;
const _Badge(this.text);
@override
Widget build(BuildContext context) => Container(
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1),
decoration: BoxDecoration(color: Colors.black87, borderRadius: BorderRadius.circular(4)),
child: Text(text, style: const TextStyle(color: Colors.white, fontSize: 11)),
);
}
class FilesEmpty extends StatelessWidget {
final IconData icon;
final String title;
final String detail;
final Widget? action;
const FilesEmpty(this.icon, this.title, this.detail, {super.key, this.action});
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return Padding(
padding: const EdgeInsets.only(top: 60, bottom: 20),
child: Column(
children: [
Icon(icon, size: 44, color: scheme.onSurfaceVariant),
const SizedBox(height: 10),
Text(title, style: Theme.of(context).textTheme.titleMedium, textAlign: TextAlign.center),
const SizedBox(height: 4),
Text(detail, style: TextStyle(color: scheme.onSurfaceVariant), textAlign: TextAlign.center),
if (action != null) ...[const SizedBox(height: 14), action!],
],
),
);
}
}
@@ -0,0 +1,978 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:signals/signals_flutter.dart';
import 'package:sync_relay_core/core.dart';
import '../../library/show_matcher.dart';
import '../../shell/motion.dart';
import '../../platform/ports/folder_access.dart';
import '../../app/app_graph.dart';
import '../../watch/watch_history.dart';
import '../../widgets/app_menu.dart';
import '../folder_store.dart';
import '../scanner.dart';
import 'file_tiles.dart';
import 'library_view.dart';
import 'thumb.dart';
export 'thumb.dart' show ThumbnailLoader;
// The Files source as a library: the watched folders on the left (chips
// on top when narrow): the Library (every folder together), Recent, then
// each folder. A library is shows and movies as poster cards, grouped by
// the library's L1 and named by its L2 match, plus the videos that are
// neither; a show opens its own page. Folders are scanned when first
// needed and on Rescan; scans, the open place and the open show are kept
// for the app session. Playing goes through [onPlay].
/// Below this width the sidebar turns into chips above the list.
const filesNarrowWidth = 700.0;
/// The place that shows every watched folder together.
const libraryPlace = '\u0000library';
/// Scans kept for the app session, by folder.
final _scans = <String, FolderScan>{};
final _running = <String, Future<FolderScan>>{};
String? _lastOpen = libraryPlace;
/// The show open in each place (by show key) for the app session.
final _openShows = <String, String>{};
final _showRequests = ValueNotifier(0);
/// Opens [dir] (null: Recent; [libraryPlace]: the library) in the Files
/// page: now if one is showing, else when it next opens.
void showFilesFolder(String? dir) {
_lastOpen = dir;
_showRequests.value++;
}
/// Forgets the session's scans, open shows and the open place.
@visibleForTesting
void resetFilesPageSession() {
_scans.clear();
_running.clear();
_openShows.clear();
_lastOpen = libraryPlace;
}
class FilesPage extends StatefulWidget {
/// Nobody else is in the server: no relay notice.
final bool alone;
/// The file will stream from this computer through the server.
final bool relayed;
/// Plays [path] in the session; returns an error text, or null.
final Future<String?> Function(String path) onPlay;
/// Adds [path] to Up next (right-click on a video); null: no server.
final Future<String?> Function(String path, {bool next})? onAddToQueue;
final String? nowPlayingPath;
/// Defaults to [thumbnailFor].
final ThumbnailLoader? thumbnails;
/// Defaults to [FolderAccess.scan].
final Future<FolderScan> Function(String dir)? scan;
/// Defaults to [FolderStore.instance].
final FolderStore? store;
/// Defaults to the app's (`AppGraph.library`).
final ShowMatcher? matcher;
/// Default: the system folder / file dialogs.
final Future<String?> Function()? pickFolder;
final Future<String?> Function()? pickVideo;
const FilesPage({
super.key,
this.alone = false,
required this.relayed,
required this.onPlay,
this.onAddToQueue,
this.nowPlayingPath,
this.thumbnails,
this.scan,
this.store,
this.matcher,
this.pickFolder,
this.pickVideo,
});
@override
State<FilesPage> createState() => _FilesPageState();
}
/// A place's scans grouped (L1), made once per set of scans.
class _Library {
final List<FolderScan> scans;
final LocalLibrary lib;
final Map<String, LocalEpisode> byPath;
_Library(this.scans, List<String> roots)
: lib = groupLocal([for (final s in scans) ...s.videos], roots: roots),
byPath = {} {
for (final e in lib.files) {
byPath[e.path] = e;
}
}
int get videos => scans.fold(0, (n, s) => n + s.videos.length);
int get hidden => scans.fold(0, (n, s) => n + s.hiddenCount);
int get subtitleFiles => {
for (final s in scans)
for (final l in s.subtitles.values) ...l
}.length;
bool get truncated => scans.any((s) => s.truncated);
}
class _FilesPageState extends State<FilesPage> {
FolderAccess get _folders => AppScope.of(context).ports.folders;
ShowMatcher get _matcher => widget.matcher ?? AppScope.of(context).library;
late final ThumbCache _thumbs =
widget.thumbnails == null ? ThumbCache.shared : ThumbCache(widget.thumbnails!);
FolderStore get _store => widget.store ?? FolderStore.instance;
WatchHistory get _history => AppScope.of(context).watch.history;
/// Null: Recent; [libraryPlace]: every folder.
String? _open = _lastOpen;
bool _showHidden = false;
String? _starting;
String? _error;
final _libs = <String, _Library>{};
@override
void initState() {
super.initState();
_showRequests.addListener(_shown);
unawaited(_matcher.ensureLoaded());
_store.ensureLoaded().then((_) {
if (!mounted) return;
final open = _open;
if (open != null && open != libraryPlace && !_store.folders.value.contains(open)) {
_select(libraryPlace);
} else {
_scanFor(open);
}
});
}
@override
void dispose() {
_showRequests.removeListener(_shown);
super.dispose();
}
void _shown() => _select(_lastOpen);
Future<FolderScan> _runScan(String dir) => (widget.scan ?? _folders.scan)(dir);
/// The folders a place needs scanned.
List<String> _foldersOf(String? place) => place == null
? const []
: place == libraryPlace
? _store.folders.peek()
: [place];
void _scanFor(String? place) {
for (final f in _foldersOf(place)) {
if (!_scans.containsKey(f) && !_running.containsKey(f)) _scan(f);
}
}
void _select(String? place) {
setState(() {
_open = _lastOpen = place;
_showHidden = false;
});
_scanFor(place);
}
Future<void> _scan(String dir, {bool rescan = false}) async {
var f = _running[dir];
if (f == null || rescan) {
f = _running[dir] = _runScan(dir);
if (rescan) _thumbs.forget([for (final v in _scans[dir]?.videos ?? const <LocalFile>[]) v.path]);
}
if (mounted) setState(() {});
FolderScan result;
try {
result = await f;
} catch (_) {
result = FolderScan.empty;
}
if (_running[dir] == f) {
_running.remove(dir);
_scans[dir] = result;
}
if (mounted) setState(() {});
}
/// The grouped library of [place], once its folders are scanned (the
/// library: those scanned so far).
_Library? _libraryOf(String place) {
final folders = _foldersOf(place);
final scans = [for (final f in folders) ?_scans[f]];
if (place != libraryPlace && scans.isEmpty) return null;
final known = _libs[place];
if (known != null && known.scans.length == scans.length && _same(known.scans, scans)) return known;
final lib = _libs[place] = _Library(scans, folders);
_matcher.remember(lib.lib);
return lib;
}
static bool _same(List<FolderScan> a, List<FolderScan> b) {
for (var i = 0; i < a.length; i++) {
if (!identical(a[i], b[i])) return false;
}
return true;
}
Future<void> _addFolder() async {
String? dir;
try {
dir = await (widget.pickFolder ??
() => _folders.pickFolder(dialogTitle: 'Add a folder with videos'))();
} catch (e) {
_showError('Folder dialog unavailable (${e.runtimeType})');
}
if (dir == null || !mounted) return;
_store.addFolder(dir);
_select(dir);
}
Future<void> _browse() async {
String? path;
try {
path = await (widget.pickVideo ?? _pickVideo)();
} catch (e) {
_showError('File dialog unavailable (${e.runtimeType})');
}
if (path != null && mounted) await _play(path);
}
Future<String?> _pickVideo() async {
final files = await _folders.pickFiles(dialogTitle: 'Pick a video', extensions: videoExtensions.toList());
return files.firstOrNull;
}
void _removeFolder(String dir) {
_store.removeFolder(dir);
_scans.remove(dir);
if (_open == dir) _select(libraryPlace);
setState(() {});
}
Future<void> _play(String path) async {
if (_starting != null) return;
setState(() {
_starting = path;
_error = null;
});
String? error;
try {
error = await widget.onPlay(path);
} catch (e) {
error = '$e';
}
if (error == null) _store.addRecent(path);
if (!mounted) return;
setState(() {
_starting = null;
_error = error;
});
}
void _showError(String text) {
if (mounted) setState(() => _error = text);
}
PlayState _stateOf(String path) => path == _starting
? PlayState.starting
: path == widget.nowPlayingPath
? PlayState.playing
: PlayState.idle;
VoidCallback _tapOf(String path) => () => _play(path);
/// Right-click on a video ([context]: its tile): Put it up, Add to
/// queue, Play next.
Future<void> _menu(BuildContext context, String path) async {
final add = widget.onAddToQueue;
if (add == null) return;
final pick = await showAppMenu<int>(context, items: [
AppMenuItem(value: 0, label: widget.alone ? 'Play' : 'Put it up'),
const AppMenuItem(value: 1, label: 'Add to queue'),
const AppMenuItem(value: 2, label: 'Play next'),
]);
if (pick == null || !mounted) return;
if (pick == 0) return _play(path);
String? error;
try {
error = await add(path, next: pick == 2);
} catch (e) {
error = '$e';
}
if (error != null) _showError(error);
}
/// [child] with the right-click menu (when there is a server).
Widget _menuable(String path, Widget child) => widget.onAddToQueue == null
? child
: KeyedSubtree(
key: ValueKey('menu:$path'),
child: Builder(
builder: (context) => GestureDetector(onSecondaryTap: () => _menu(context, path), child: child)));
late final _actions = FilesActions(
play: (p) => unawaited(_play(p)),
stateOf: _stateOf,
menuable: _menuable,
thumbs: _thumbs,
);
/// The last episode of [show] watched here (the watch history: the
/// newest one watched to the end), else the last one opened (Recent), by
/// key.
String? _lastPlayed(_Library lib, LocalShow show) {
for (final (key, entry) in _history.entries) {
if (!entry.played || !key.value.startsWith('file:')) continue;
final e = lib.byPath[key.value.substring(5)];
if (e != null && e.showKey == show.key && e.kind == LocalKind.episode) return e.key;
}
for (final p in _store.recent.value) {
final e = lib.byPath[p];
if (e != null && e.showKey == show.key && e.kind == LocalKind.episode) return e.key;
}
return null;
}
@override
Widget build(BuildContext context) => LayoutBuilder(
builder: (context, box) {
final narrow = box.maxWidth < filesNarrowWidth;
return SignalBuilder(builder: (context) {
final folders = _store.folders.value;
final recent = _store.recent.value;
_matcher.revision.value;
_history.revision.value;
final open = _open == null || _open == libraryPlace || folders.contains(_open) ? _open : libraryPlace;
final showKey = open == null ? null : _openShows[open];
final main = fadeThrough(
KeyedSubtree(
key: ValueKey('${open ?? ''}\n${showKey ?? ''}'),
child: _main(context, narrow, folders, recent, open, showKey),
),
);
if (narrow) return main;
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(width: 260, child: _sidebar(context, folders, open)),
Expanded(child: main),
],
);
});
},
);
Widget _browseButton(Widget Function(VoidCallback? onPressed) build) => build(_browse);
Widget _sidebar(BuildContext context, List<String> folders, String? open) {
final scheme = Theme.of(context).colorScheme;
final shape = RoundedRectangleBorder(borderRadius: BorderRadius.circular(10));
Widget? status(String? s) =>
s == null ? null : Text(s, style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 11));
return ListView(
padding: const EdgeInsets.fromLTRB(20, 20, 8, 20),
children: [
ListTile(
key: const ValueKey('place:library'),
dense: true,
selected: open == libraryPlace,
shape: shape,
leading: const Icon(Icons.video_library_outlined, size: 18),
title: const Text('Library'),
subtitle: status(_libraryStatus(folders)),
onTap: () => _select(libraryPlace),
),
ListTile(
key: const ValueKey('place:recent'),
dense: true,
selected: open == null,
shape: shape,
leading: const Icon(Icons.history, size: 18),
title: const Text('Recent'),
onTap: () => _select(null),
),
const SizedBox(height: 12),
const Text('Folders', style: TextStyle(fontWeight: FontWeight.w600)),
const SizedBox(height: 8),
for (final f in folders)
ListTile(
key: ValueKey('folder:$f'),
dense: true,
selected: open == f,
shape: shape,
leading: const Icon(Icons.folder_outlined, size: 18),
title: Text(baseName(f), maxLines: 1, overflow: TextOverflow.ellipsis),
subtitle: status(_folderStatus(f)),
trailing: IconButton(
tooltip: 'Remove folder',
icon: const Icon(Icons.close, size: 16),
onPressed: () => _removeFolder(f),
),
onTap: () => _select(f),
),
Align(
alignment: Alignment.centerLeft,
child: TextButton.icon(
onPressed: _addFolder,
icon: const Icon(Icons.add, size: 18),
label: const Text('Add folder'),
),
),
const Divider(),
_browseButton((onPressed) => OutlinedButton.icon(
onPressed: onPressed,
icon: const Icon(Icons.folder_open, size: 18),
label: const Text('Browse…'),
)),
const SizedBox(height: 6),
Text(
'The system file picker, for anything outside your folders.',
style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 11),
),
],
);
}
/// "scanning…", "N videos", or null before the first scan.
String? _folderStatus(String dir) {
if (_running.containsKey(dir)) return 'scanning…';
final n = _scans[dir]?.videos.length;
return n == null ? null : '$n ${n == 1 ? 'video' : 'videos'}';
}
String? _libraryStatus(List<String> folders) {
if (folders.any(_running.containsKey)) return 'scanning…';
if (folders.isEmpty || folders.any((f) => !_scans.containsKey(f))) return null;
final n = _libraryOf(libraryPlace)?.lib.shows.length ?? 0;
return '$n ${n == 1 ? 'show' : 'shows'}';
}
List<Widget> _notices() => [
if (!widget.alone && widget.relayed) const _RelayNotice(),
AnimatedSize(
duration: easeTime,
curve: ease,
alignment: Alignment.topLeft,
child: _error == null
? const SizedBox(width: double.infinity)
: _ErrorBanner(_error!, onClose: () => setState(() => _error = null)),
),
];
Widget _main(
BuildContext context,
bool narrow,
List<String> folders,
List<String> recent,
String? open,
String? showKey,
) {
final lib = open == null ? null : _libraryOf(open);
final show = showKey == null ? null : lib?.lib.show(showKey);
if (open != null && lib != null && show != null) {
return ShowPage(
key: ValueKey('show:$open:${show.key}'),
show: show,
matcher: _matcher,
actions: _actions,
lastPlayed: _lastPlayed(lib, show),
backLabel: open == libraryPlace ? 'Library' : baseName(open),
onBack: () => setState(() => _openShows.remove(open)),
top: [if (narrow) _chips(folders, open), ..._notices()],
);
}
final scanning = _foldersOf(open).any(_running.containsKey);
// A folder added elsewhere (a drop, the handoff) joins the library.
if (open == libraryPlace && folders.any((f) => !_scans.containsKey(f) && !_running.containsKey(f))) {
scheduleMicrotask(() {
if (mounted) _scanFor(libraryPlace);
});
}
final top = <Widget>[
_header(context, open),
if (narrow) _chips(folders, open),
..._notices(),
];
final slivers = <Widget>[
SliverList.list(children: top),
if (open == null)
..._recent(context, recent)
else if (open == libraryPlace && folders.isEmpty)
SliverToBoxAdapter(
child: FilesEmpty(
Icons.video_library_outlined,
'Your library is empty',
'Add a folder with your videos: shows, seasons and movies are found by their names.',
action: FilledButton.icon(
onPressed: _addFolder,
icon: const Icon(Icons.create_new_folder_outlined),
label: const Text('Add folder'),
),
),
)
else if (lib == null)
const SliverToBoxAdapter(
child: Padding(
padding: EdgeInsets.only(top: 60),
child: Center(child: CircularProgressIndicator()),
),
)
else
..._library(context, open, lib, scanning),
];
return CustomScrollView(
slivers: [
SliverPadding(
padding: EdgeInsets.fromLTRB(narrow ? 20 : 12, 20, 20, 20),
sliver: SliverMainAxisGroup(slivers: slivers),
),
],
);
}
Widget _header(BuildContext context, String? open) {
final scheme = Theme.of(context).colorScheme;
final (icon, title, sub) = switch (open) {
null => (Icons.history, 'Recent', null),
libraryPlace => (Icons.video_library, 'Library', null),
final dir => (Icons.folder, baseName(dir), dir),
};
return Padding(
padding: const EdgeInsets.only(bottom: 14),
child: Row(
children: [
Icon(icon, color: scheme.primary),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.headlineSmall,
),
if (sub != null)
Text(
sub,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12),
),
],
),
),
],
),
);
}
Widget _chips(List<String> folders, String? open) => Padding(
padding: const EdgeInsets.only(bottom: 14),
child: Wrap(
spacing: 8,
runSpacing: 8,
children: [
ChoiceChip(
label: const Text('Library'),
selected: open == libraryPlace,
onSelected: (_) => _select(libraryPlace),
),
ChoiceChip(
label: const Text('Recent'),
selected: open == null,
onSelected: (_) => _select(null),
),
for (final f in folders)
// InputChip: selectable like the others, plus the remove x
// the sidebar has.
InputChip(
key: ValueKey('chip:$f'),
label: Text(baseName(f)),
selected: open == f,
onSelected: (_) => _select(f),
onDeleted: () => _removeFolder(f),
deleteButtonTooltipMessage: 'Remove folder',
),
ActionChip(
avatar: const Icon(Icons.add, size: 16),
label: const Text('Folder'),
onPressed: _addFolder,
),
_browseButton((onPressed) => ActionChip(
avatar: const Icon(Icons.folder_open, size: 16),
label: const Text('Browse…'),
onPressed: onPressed,
)),
],
),
);
List<Widget> _recent(BuildContext context, List<String> recent) {
if (recent.isEmpty) {
return [
SliverToBoxAdapter(
child: FilesEmpty(
Icons.video_library_outlined,
'Nothing played yet',
'Add a folder with your videos, or Browse for a single file.',
action: FilledButton.icon(
onPressed: _addFolder,
icon: const Icon(Icons.create_new_folder_outlined),
label: const Text('Add folder'),
),
),
),
];
}
return [
SliverList.builder(
itemCount: recent.length,
itemBuilder: (context, i) {
final path = recent[i];
return _menuable(
path,
_RecentRow(
key: ValueKey(path),
path: path,
matcher: _matcher,
state: _stateOf(path),
thumbs: _thumbs,
onTap: _tapOf(path),
onRemove: () => _store.removeRecent(path),
));
},
),
];
}
Widget _section(BuildContext context, String title, {String? count}) => Padding(
padding: const EdgeInsets.only(top: 22, bottom: 10),
child: Row(children: [
Text(title, style: Theme.of(context).textTheme.titleMedium),
if (count != null) ...[
const SizedBox(width: 8),
Text(count, style: Theme.of(context).textTheme.bodySmall?.copyWith(color: Colors.grey)),
],
]),
);
List<Widget> _library(BuildContext context, String place, _Library lib, bool scanning) {
final scheme = Theme.of(context).colorScheme;
final l = lib.lib;
final folderScan = place == libraryPlace ? null : _scans[place];
String count(int n, String one, String many) => '$n ${n == 1 ? one : many}';
final summary = [
count(lib.videos, 'video', 'videos') +
(l.shows.isEmpty ? '' : ' in ${count(l.shows.length, 'show', 'shows')}'),
if (lib.hidden > 0) '${count(lib.hidden, 'other file', 'other files')} hidden',
if (lib.subtitleFiles > 0) count(lib.subtitleFiles, 'subtitle file', 'subtitle files'),
if (lib.truncated) 'stopped after $maxScannedFiles files',
].join(' · ');
final folders = _foldersOf(place);
final movies = l.movies;
final other = l.other;
return [
SliverToBoxAdapter(
child: Wrap(
crossAxisAlignment: WrapCrossAlignment.center,
children: [
Icon(Icons.filter_alt_outlined, size: 16, color: scheme.onSurfaceVariant),
const SizedBox(width: 6),
Text(summary, style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12)),
if (folderScan != null && folderScan.hiddenCount > 0) ...[
const SizedBox(width: 8),
TextButton(
onPressed: () => setState(() => _showHidden = !_showHidden),
child: Text(_showHidden ? 'Hide them' : 'Show some'),
),
],
TextButton.icon(
onPressed: scanning
? null
: () {
for (final f in folders) {
_scan(f, rescan: true);
}
},
icon: scanning
? const SizedBox.square(dimension: 14, child: CircularProgressIndicator(strokeWidth: 2))
: const Icon(Icons.refresh, size: 16),
label: Text(scanning ? 'Scanning…' : 'Rescan'),
),
],
),
),
if (folderScan != null)
SliverToBoxAdapter(
child: AnimatedSize(
duration: easeTime,
curve: ease,
alignment: Alignment.topLeft,
child: !_showHidden
? const SizedBox(width: double.infinity)
: Column(
children: [
for (final f in folderScan.hiddenExamples)
ListTile(
dense: true,
enabled: false,
leading: const Icon(Icons.insert_drive_file_outlined, size: 18),
title: Text(f, maxLines: 1, overflow: TextOverflow.ellipsis),
trailing: Text(
kindOf(f) == FileKind.video ? 'sample' : 'not a video',
style: const TextStyle(fontSize: 12),
),
),
if (folderScan.hiddenCount > folderScan.hiddenExamples.length)
ListTile(
dense: true,
enabled: false,
title: Text('and ${folderScan.hiddenCount - folderScan.hiddenExamples.length} more'),
),
],
),
),
),
if (lib.videos == 0)
SliverToBoxAdapter(
child: place == libraryPlace || _folders.folderExists(place)
? const FilesEmpty(
Icons.search_off,
'No videos here',
'Only video files are listed (mkv, mp4, webm…).',
)
: const FilesEmpty(
Icons.folder_off_outlined,
'Folder not found',
'It may have been moved, renamed or be on a drive that is not connected.',
),
),
if (l.shows.isNotEmpty) ...[
SliverToBoxAdapter(child: _section(context, 'Shows', count: '${l.shows.length}')),
SliverGrid.builder(
gridDelegate: posterGrid(),
itemCount: l.shows.length,
itemBuilder: (context, i) => _showCard(place, lib, l.shows[i]),
),
],
if (movies.isNotEmpty) ...[
SliverToBoxAdapter(child: _section(context, 'Movies', count: '${movies.length}')),
SliverGrid.builder(
gridDelegate: posterGrid(),
itemCount: movies.length,
itemBuilder: (context, i) => _movieCard(movies[i]),
),
],
if (other.isNotEmpty) ...[
SliverToBoxAdapter(
child: _section(context, l.shows.isEmpty && movies.isEmpty ? 'Videos' : 'Other videos'),
),
SliverLayoutBuilder(builder: (context, c) {
const gap = 12.0;
final w = c.crossAxisExtent;
final cols = (w / 240).floor().clamp(2, 8);
final cardW = (w - gap * (cols - 1)) / cols;
return SliverGrid.builder(
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: cols,
mainAxisSpacing: gap,
crossAxisSpacing: gap,
mainAxisExtent: cardW * 9 / 16 + videoCardTextHeight,
),
itemCount: other.length,
itemBuilder: (context, i) {
final v = other[i];
return _menuable(
v.path,
VideoCard(
key: ValueKey(v.path),
video: v,
state: _stateOf(v.path),
thumbs: _thumbs,
onTap: _tapOf(v.path),
));
},
);
}),
],
];
}
Widget _showCard(String place, _Library lib, LocalShow show) {
final m = _matcher.showMatch(show);
final last = _lastPlayed(lib, show);
final String subtitle;
if (last == null) {
final n = show.episodeCount;
subtitle = '$n ${n == 1 ? 'episode' : 'episodes'}';
} else {
final next = show.nextAfter(last);
subtitle = next == null ? 'Watched to the end' : 'Next: ${episodeNumber(next.episode)}';
}
final playing = widget.nowPlayingPath != null && show.files.any((e) => e.path == widget.nowPlayingPath);
return PosterCard(
key: ValueKey('showcard:${show.key}'),
title: m?.entry.title ?? show.title,
subtitle: subtitle,
coverUrl: m?.entry.cover,
framePath: show.episodes.firstOrNull?.path,
thumbs: _thumbs,
playing: playing,
onShown: () => _matcher.want(show),
onTap: () => setState(() => _openShows[place] = show.key),
);
}
Widget _movieCard(LocalEpisode movie) {
final m = _matcher.seasonMatch(movie);
return _menuable(
movie.path,
PosterCard(
key: ValueKey('moviecard:${movie.path}'),
title: m?.entry.title ?? (movie.parsed.year == null ? movie.title : '${movie.title} (${movie.parsed.year})'),
subtitle: [
if (movie.movieNumber != null) movie.label,
if (m?.entry.year != null) '${m!.entry.year}' else if (movie.parsed.year != null) '${movie.parsed.year}',
formatSize(movie.file.size),
].join(' · '),
coverUrl: m?.entry.cover,
framePath: movie.path,
thumbs: _thumbs,
playing: _stateOf(movie.path) == PlayState.playing,
onShown: () => _matcher.wantMovie(movie),
onTap: _tapOf(movie.path),
));
}
}
/// A Recent entry: `Show · E02` over the file name (the library's names
/// when it knows the show), or the cleaned title over its folder; greyed
/// when the file is gone.
class _RecentRow extends StatefulWidget {
final String path;
final ShowMatcher matcher;
final PlayState state;
final ThumbCache thumbs;
final VoidCallback? onTap;
final VoidCallback onRemove;
const _RecentRow({
super.key,
required this.path,
required this.matcher,
required this.state,
required this.thumbs,
required this.onTap,
required this.onRemove,
});
@override
State<_RecentRow> createState() => _RecentRowState();
}
class _RecentRowState extends State<_RecentRow> {
FolderAccess get _folders => AppScope.of(context).ports.folders;
FileStat? _stat;
@override
void initState() {
super.initState();
_folders.stat(widget.path).then((s) {
if (mounted) setState(() => _stat = s);
}, onError: (Object _) {});
}
@override
Widget build(BuildContext context) {
final path = widget.path;
final e = widget.matcher.episodeAt(path) ??
readLocalFile(LocalFile(path, 0, DateTime.fromMillisecondsSinceEpoch(0)));
final missing = _stat?.type == FileSystemEntityType.notFound;
final episode = e.kind == LocalKind.episode || e.kind == LocalKind.extra;
final name = widget.matcher.seasonMatch(e)?.entry.title ?? e.title;
return FileRow(
path: path,
title: episode ? '$name · ${e.label}' : (e.kind == LocalKind.other ? cleanTitle(e.parsed) : name),
subtitle: missing ? 'File not found' : (episode ? baseName(path) : parentName(path)),
size: _stat == null || missing ? null : formatSize(_stat!.size),
state: widget.state,
thumbs: widget.thumbs,
onTap: missing ? null : widget.onTap,
indent: false,
trailing: IconButton(
tooltip: 'Remove from recent',
icon: const Icon(Icons.close, size: 16),
onPressed: widget.onRemove,
),
);
}
}
class _RelayNotice extends StatelessWidget {
const _RelayNotice();
@override
Widget build(BuildContext context) => Container(
padding: const EdgeInsets.all(10),
margin: const EdgeInsets.only(bottom: 16),
decoration: BoxDecoration(
color: Colors.lightBlueAccent.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(10),
),
child: const Row(
children: [
Icon(Icons.cell_tower, size: 18, color: Colors.lightBlueAccent),
SizedBox(width: 10),
Expanded(
child: Text(
'Files stream from this computer through the server. '
'Keep the app open while it plays.',
),
),
],
),
);
}
class _ErrorBanner extends StatelessWidget {
final String text;
final VoidCallback onClose;
const _ErrorBanner(this.text, {required this.onClose});
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return Container(
padding: const EdgeInsets.fromLTRB(10, 4, 4, 4),
margin: const EdgeInsets.only(bottom: 16),
decoration: BoxDecoration(
color: scheme.errorContainer,
borderRadius: BorderRadius.circular(10),
),
child: Row(
children: [
Icon(Icons.error_outline, size: 18, color: scheme.onErrorContainer),
const SizedBox(width: 10),
Expanded(child: Text(text, style: TextStyle(color: scheme.onErrorContainer))),
IconButton(
tooltip: 'Dismiss',
icon: Icon(Icons.close, size: 16, color: scheme.onErrorContainer),
onPressed: onClose,
),
],
),
);
}
}
@@ -0,0 +1,712 @@
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:signals/signals_flutter.dart';
import 'package:sync_relay_core/core.dart';
import '../../library/show_matcher.dart';
import '../../schedule/anilist.dart' show AniListEntry;
import '../../shell/avatar.dart' show Pill;
import 'file_tiles.dart';
import 'thumb.dart';
// The Files library: shows and movies as poster cards (like the Jellyfin
// browser's), a show's own page (banner, the next episode with what is
// missing on the way, seasons, extras, movies) and the corrections
// ("Wrong show?", "Episode numbers…"). Names and art come from the
// library's AniList match when there is one, else from the files.
/// What the page hands its views: playing, the right-click menu, the
/// thumbnails, what plays now.
class FilesActions {
final void Function(String path) play;
final PlayState Function(String path) stateOf;
final Widget Function(String path, Widget child) menuable;
final ThumbCache thumbs;
const FilesActions({required this.play, required this.stateOf, required this.menuable, required this.thumbs});
}
/// "66–107 aren't in your folders", "66 isn't in your folders".
String missingText(EpisodeGap gap) =>
gap.first == gap.last ? '${gap.label} isn\'t in your folders' : '${gap.label} aren\'t in your folders';
/// The next-episode prompt: "66–107 aren't in your folders — skip to
/// 108?", or at the start "1–55 … — start at 56?"; null when nothing is
/// skipped.
String? skipPrompt(LocalNext next, {required bool started}) {
final gap = next.skipped;
if (gap == null) return null;
final n = next.episode.episode;
return started ? '${missingText(gap)} — skip to $n?' : '${missingText(gap)} — start at $n?';
}
/// A show's label for an episode: `56`, `S3 E1`.
String episodeNumber(LocalEpisode e) =>
e.season == null ? '${e.episode}' : 'S${e.season} E${e.episode}';
/// The 2:3 picture of a show or movie: its AniList cover, else a frame of
/// [framePath].
class PosterImage extends StatelessWidget {
final String? url;
final String? framePath;
final ThumbCache thumbs;
const PosterImage({super.key, this.url, this.framePath, required this.thumbs});
@override
Widget build(BuildContext context) {
final frame = framePath == null
? const ThumbImage(null)
: ThumbBuilder(cache: thumbs, path: framePath!, builder: (context, t, _) => ThumbImage(t));
final u = url;
if (u == null) return frame;
return Image.network(
u,
fit: BoxFit.cover,
gaplessPlayback: true,
errorBuilder: (_, _, _) => frame,
loadingBuilder: (context, child, progress) => progress == null ? child : frame,
);
}
}
/// A show (or movie) as a poster card: picture, title, one line under it.
class PosterCard extends StatefulWidget {
final String title;
final String subtitle;
final String? coverUrl;
final String? framePath;
final ThumbCache thumbs;
final VoidCallback onTap;
final bool playing;
/// Called once when the card is first built (the library matches what
/// is on screen).
final VoidCallback? onShown;
const PosterCard({
super.key,
required this.title,
required this.subtitle,
required this.thumbs,
required this.onTap,
this.coverUrl,
this.framePath,
this.playing = false,
this.onShown,
});
@override
State<PosterCard> createState() => _PosterCardState();
}
class _PosterCardState extends State<PosterCard> {
bool _hover = false;
@override
void initState() {
super.initState();
widget.onShown?.call();
}
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final t = Theme.of(context).textTheme;
return InkWell(
onTap: widget.onTap,
onHover: (h) => setState(() => _hover = h),
borderRadius: BorderRadius.circular(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: widget.playing ? scheme.primary : (_hover ? scheme.onSurfaceVariant : Colors.transparent),
width: 2,
),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(10),
child: PosterImage(url: widget.coverUrl, framePath: widget.framePath, thumbs: widget.thumbs),
),
),
),
const SizedBox(height: 6),
Text(widget.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: t.titleSmall?.copyWith(color: widget.playing ? scheme.primary : null)),
Text(widget.subtitle,
maxLines: 1, overflow: TextOverflow.ellipsis, style: t.bodySmall?.copyWith(color: Colors.grey)),
],
),
);
}
}
/// Title and meta lines under a [PosterCard]'s picture.
const posterTextHeight = 46.0;
/// Poster cards in a grid: about 170 px wide each.
SliverGridDelegate posterGrid() => const _PosterGrid();
class _PosterGrid extends SliverGridDelegate {
const _PosterGrid();
@override
SliverGridLayout getLayout(SliverConstraints constraints) {
const gap = 12.0;
final w = constraints.crossAxisExtent;
final cols = (w / 170).floor().clamp(2, 12);
final cardW = (w - gap * (cols - 1)) / cols;
return SliverGridRegularTileLayout(
crossAxisCount: cols,
mainAxisStride: cardW * 1.5 + posterTextHeight + gap,
crossAxisStride: cardW + gap,
childMainAxisExtent: cardW * 1.5 + posterTextHeight,
childCrossAxisExtent: cardW,
reverseCrossAxis: false,
);
}
@override
bool shouldRelayout(covariant SliverGridDelegate oldDelegate) => false;
}
/// One show's page: header (banner, proper name, what the files are,
/// the match), the next episode with what is missing on the way, the
/// season's episodes with their holes, extras and the show's movies.
class ShowPage extends StatefulWidget {
final LocalShow show;
final ShowMatcher matcher;
final FilesActions actions;
/// The last episode of this show played here ([LocalEpisode.key]).
final String? lastPlayed;
final String backLabel;
final VoidCallback onBack;
/// Notices above the page (the relay notice, errors).
final List<Widget> top;
const ShowPage({
super.key,
required this.show,
required this.matcher,
required this.actions,
required this.lastPlayed,
required this.backLabel,
required this.onBack,
this.top = const [],
});
@override
State<ShowPage> createState() => _ShowPageState();
}
class _ShowPageState extends State<ShowPage> {
int? _season;
bool _seasonPicked = false;
@override
void initState() {
super.initState();
widget.matcher.want(widget.show);
}
LocalShow get show => widget.show;
@override
Widget build(BuildContext context) => SignalBuilder(builder: (context) {
widget.matcher.revision.value;
final busy = widget.matcher.busy.value.contains(show.key);
final m = widget.matcher.showMatch(show);
final next = show.nextAfter(widget.lastPlayed);
final seasons = show.seasons;
if (!_seasonPicked) _season = next?.episode.season ?? seasons.firstOrNull?.number;
final season = show.season(_season) ?? seasons.firstOrNull;
return CustomScrollView(
key: PageStorageKey('show:${show.key}'),
slivers: [
SliverPadding(
padding: const EdgeInsets.fromLTRB(12, 12, 20, 24),
sliver: SliverMainAxisGroup(slivers: [
SliverToBoxAdapter(
child: Align(
alignment: Alignment.centerLeft,
child: TextButton.icon(
onPressed: widget.onBack,
icon: const Icon(Icons.arrow_back, size: 18),
label: Text(widget.backLabel),
),
),
),
SliverList.list(children: widget.top),
SliverToBoxAdapter(child: _header(context, m, next, busy)),
if (seasons.length > 1)
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.only(top: 14),
child: Wrap(spacing: 8, runSpacing: 8, children: [
for (final s in seasons)
ChoiceChip(
key: ValueKey('season:${s.number}'),
label: Text(s.title),
selected: identical(s, season),
onSelected: (_) => setState(() {
_season = s.number;
_seasonPicked = true;
}),
),
]),
),
),
if (season != null) ..._episodes(context, season),
if (show.extras.isNotEmpty) ...[
_section(context, 'Extras', show.extras.length),
SliverList.list(children: [for (final e in show.extras) _row(e, e.label, e.name)]),
],
if (show.movies.isNotEmpty) ...[
_section(context, show.movies.length == 1 ? 'Movie' : 'Movies', show.movies.length),
SliverList.list(children: [for (final e in show.movies) _row(e, e.label, e.name)]),
],
]),
),
],
);
});
Widget _section(BuildContext context, String title, int n) => SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.only(top: 22, bottom: 6),
child: Row(children: [
Text(title, style: Theme.of(context).textTheme.titleMedium),
const SizedBox(width: 8),
Text('$n', style: Theme.of(context).textTheme.bodySmall?.copyWith(color: Colors.grey)),
]),
),
);
Widget _row(LocalEpisode e, String title, String subtitle) => widget.actions.menuable(
e.path,
FileRow(
key: ValueKey(e.path),
path: e.path,
title: title,
subtitle: subtitle,
size: formatSize(e.file.size),
state: widget.actions.stateOf(e.path),
thumbs: widget.actions.thumbs,
onTap: () => widget.actions.play(e.path),
));
List<Widget> _episodes(BuildContext context, LocalSeason season) {
final rows = <Widget>[];
var expect = 1;
for (final e in season.episodes) {
final n = e.episode!;
final gap = season.gaps.where((g) => g.first >= expect && g.last < n).firstOrNull;
if (gap != null) rows.add(_GapRow(missingText(gap)));
if (n >= expect) expect = n + 1;
final offset = widget.matcher.offset(show.key, season.number);
final shown = n + offset;
final name = widget.matcher.episodeTitle(e);
// Like the Jellyfin browser's rows: "12. Name", else "Episode 12".
final title = name == null ? 'Episode $shown' : '$shown. $name';
rows.add(_row(e, title, e.name));
}
final tail = season.gaps.where((g) => g.first >= expect).firstOrNull;
if (tail != null) rows.add(_GapRow(missingText(tail)));
return [
_section(context, season.title, season.distinct.length),
SliverList.list(children: rows),
];
}
Widget _header(BuildContext context, SeasonMatch? m, LocalNext? next, bool busy) {
final theme = Theme.of(context);
final t = theme.textTheme;
final scheme = theme.colorScheme;
final entry = m?.entry;
final title = entry?.title ?? show.title;
final count = show.episodeCount;
final meta = [
if (show.seasons.length > 1) '${show.seasons.length} seasons',
'$count ${count == 1 ? 'episode' : 'episodes'}',
if (show.extras.isNotEmpty) '${show.extras.length} ${show.extras.length == 1 ? 'extra' : 'extras'}',
if (show.movies.isNotEmpty) '${show.movies.length} ${show.movies.length == 1 ? 'movie' : 'movies'}',
?show.group,
if (show.folders.length > 1) 'in ${show.folders.length} folders',
].join(' · ');
final String matchLine;
if (entry != null) {
final what = [
if (entry.format.isNotEmpty) entry.format.replaceAll('_', ' '),
if (entry.year != null) '${entry.year}',
if (entry.episodes != null) '${entry.episodes} eps',
].join(', ');
matchLine = [
'AniList: ${entry.romaji.isNotEmpty && entry.romaji != title ? entry.romaji : entry.title}'
'${what.isEmpty ? '' : ' ($what)'}',
if (title != show.title) 'files say "${show.title}"',
].join(' · ');
} else {
matchLine = busy ? 'Looking it up on AniList…' : 'Not matched on AniList';
}
final started = widget.lastPlayed != null;
final prompt = next == null ? null : skipPrompt(next, started: started);
final nextLabel = next == null
? 'Watched to the end'
: next.skipped != null
? (started ? 'Skip to ${next.episode.episode}' : 'Start at ${next.episode.episode}')
: '${started ? 'Next' : 'Play'}: ${episodeNumber(next.episode)}';
return LayoutBuilder(builder: (context, box) {
final h = (box.maxWidth * 0.28).clamp(220.0, 340.0);
final cover = entry?.cover;
return ClipRRect(
borderRadius: BorderRadius.circular(12),
child: SizedBox(
height: h,
child: Stack(fit: StackFit.expand, children: [
PosterImage(
url: entry?.banner ?? entry?.cover,
framePath: show.episodes.firstOrNull?.path,
thumbs: widget.actions.thumbs),
DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.centerLeft,
end: Alignment.centerRight,
colors: [
scheme.surface.withValues(alpha: 0.96),
scheme.surface.withValues(alpha: 0.8),
scheme.surface.withValues(alpha: 0.15),
],
stops: const [0, 0.5, 1],
),
),
),
Padding(
padding: const EdgeInsets.all(20),
child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
if (cover != null && box.maxWidth > 560) ...[
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: SizedBox(
width: (h - 40) / 1.5,
child: PosterImage(url: cover, thumbs: widget.actions.thumbs),
),
),
const SizedBox(width: 18),
],
Expanded(
child: SingleChildScrollView(
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(children: [
Flexible(
child: Text(title,
maxLines: 2, overflow: TextOverflow.ellipsis, style: t.headlineMedium),
),
if (m?.pinned != null) ...[
const SizedBox(width: 8),
Pill(m!.pinned == PinScope.folder ? 'pinned for this folder' : 'pinned', scheme.primary),
],
]),
const SizedBox(height: 6),
Text(meta, style: t.bodyMedium?.copyWith(color: Colors.grey)),
const SizedBox(height: 2),
Text(matchLine,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: t.bodySmall?.copyWith(color: scheme.onSurfaceVariant)),
if (prompt != null) ...[
const SizedBox(height: 10),
Row(children: [
Icon(Icons.info_outline, size: 16, color: scheme.tertiary),
const SizedBox(width: 6),
Flexible(child: Text(prompt, style: t.bodyMedium?.copyWith(color: scheme.tertiary))),
]),
],
const SizedBox(height: 12),
Wrap(spacing: 8, runSpacing: 8, children: [
FilledButton.icon(
key: const ValueKey('show-next'),
onPressed: next == null ? null : () => widget.actions.play(next.episode.path),
icon: const Icon(Icons.play_arrow),
label: Text(nextLabel),
),
OutlinedButton.icon(
onPressed: () => showWrongShowDialog(context, show, widget.matcher),
icon: const Icon(Icons.manage_search, size: 18),
label: const Text('Wrong show?'),
),
TextButton(
onPressed: () => showEpisodeNumbersDialog(context, show, widget.matcher),
child: const Text('Episode numbers…'),
),
]),
]),
),
),
]),
),
]),
),
);
});
}
}
class _GapRow extends StatelessWidget {
final String text;
const _GapRow(this.text);
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return Padding(
padding: const EdgeInsets.fromLTRB(20, 6, 8, 6),
child: Row(children: [
Icon(Icons.more_vert, size: 18, color: scheme.onSurfaceVariant),
const SizedBox(width: 10),
Expanded(
child: Text(text,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: scheme.onSurfaceVariant, fontStyle: FontStyle.italic, fontSize: 13)),
),
]),
);
}
}
/// "Wrong show?": search AniList, pick the right entry, pin it for the
/// show everywhere or for one of its folders.
Future<void> showWrongShowDialog(BuildContext context, LocalShow show, ShowMatcher matcher) =>
showDialog<void>(context: context, builder: (context) => WrongShowDialog(show: show, matcher: matcher));
class WrongShowDialog extends StatefulWidget {
final LocalShow show;
final ShowMatcher matcher;
const WrongShowDialog({super.key, required this.show, required this.matcher});
@override
State<WrongShowDialog> createState() => _WrongShowDialogState();
}
class _WrongShowDialogState extends State<WrongShowDialog> {
late final _query = TextEditingController(text: widget.show.title.replaceAll(RegExp(r'[._]+'), ' '));
List<AniListEntry>? _hits;
String? _error;
bool _searching = false;
/// Null: the whole show; else one of its folders.
String? _folder;
@override
void initState() {
super.initState();
_search();
}
@override
void dispose() {
_query.dispose();
super.dispose();
}
Future<void> _search() async {
setState(() {
_searching = true;
_error = null;
});
try {
final hits = await widget.matcher.search(_query.text);
if (mounted) setState(() => _hits = hits);
} catch (e) {
if (mounted) setState(() => _error = 'AniList did not answer ($e)');
} finally {
if (mounted) setState(() => _searching = false);
}
}
void _pin(AniListEntry e) {
final folder = _folder;
folder == null ? widget.matcher.pinShow(widget.show.key, e) : widget.matcher.pinFolder(folder, e);
Navigator.of(context).pop();
}
@override
Widget build(BuildContext context) {
final show = widget.show;
final m = widget.matcher;
final showPin = m.showPin(show.key);
final folderPins = [for (final f in show.folders) if (m.folderPin(f)?.folder == f) f];
final scheme = Theme.of(context).colorScheme;
return AlertDialog(
title: const Text('Wrong show?'),
content: SizedBox(
width: 520,
child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
Text('Search AniList and pick what "${show.title}" really is. '
'A pin wins over the automatic match and survives rescans.',
style: TextStyle(color: scheme.onSurfaceVariant)),
const SizedBox(height: 12),
TextField(
controller: _query,
autofocus: true,
decoration: InputDecoration(
isDense: true,
border: const OutlineInputBorder(),
hintText: 'Title on AniList',
suffixIcon: IconButton(tooltip: 'Search', icon: const Icon(Icons.search), onPressed: _search),
),
onSubmitted: (_) => _search(),
),
if (show.folders.length > 1 || folderPins.isNotEmpty) ...[
const SizedBox(height: 10),
DropdownButton<String?>(
value: _folder,
isExpanded: true,
items: [
const DropdownMenuItem(value: null, child: Text('For this show everywhere')),
for (final f in show.folders)
DropdownMenuItem(value: f, child: Text('Only the folder "${baseName(f)}"', overflow: TextOverflow.ellipsis)),
],
onChanged: (v) => setState(() => _folder = v),
),
],
const SizedBox(height: 8),
ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 340),
child: _searching && _hits == null
? const Padding(padding: EdgeInsets.all(24), child: Center(child: CircularProgressIndicator()))
: _error != null
? Text(_error!, style: TextStyle(color: scheme.error))
: (_hits ?? const []).isEmpty
? const Padding(padding: EdgeInsets.all(12), child: Text('Nothing found'))
: ListView(shrinkWrap: true, children: [
for (final e in _hits!)
ListTile(
key: ValueKey('hit:${e.id}'),
dense: true,
leading: SizedBox(
width: 32,
height: 48,
child: ClipRRect(
borderRadius: BorderRadius.circular(4),
child: e.coverSmall == null
? const ColoredBox(color: Colors.black26)
: Image.network(e.coverSmall!,
fit: BoxFit.cover,
errorBuilder: (_, _, _) => const ColoredBox(color: Colors.black26)),
),
),
title: Text(e.title, maxLines: 1, overflow: TextOverflow.ellipsis),
subtitle: Text([
if (e.romaji.isNotEmpty && e.romaji != e.title) e.romaji,
if (e.format.isNotEmpty) e.format.replaceAll('_', ' '),
if (e.year != null) '${e.year}',
if (e.episodes != null) '${e.episodes} eps',
].join(' · '), maxLines: 1, overflow: TextOverflow.ellipsis),
onTap: () => _pin(e),
),
]),
),
]),
),
actions: [
if (showPin != null)
TextButton(
onPressed: () {
m.unpinShow(show.key);
Navigator.of(context).pop();
},
child: const Text('Remove the pin'),
),
for (final f in folderPins)
TextButton(
onPressed: () {
m.unpinFolder(f);
Navigator.of(context).pop();
},
child: Text('Unpin "${baseName(f)}"'),
),
TextButton(onPressed: () => Navigator.of(context).pop(), child: const Text('Cancel')),
],
);
}
}
/// "Episode numbers…": which episode the first file of each season is
/// ("these files are episodes N..M").
Future<void> showEpisodeNumbersDialog(BuildContext context, LocalShow show, ShowMatcher matcher) =>
showDialog<void>(context: context, builder: (context) => _EpisodeNumbersDialog(show: show, matcher: matcher));
class _EpisodeNumbersDialog extends StatefulWidget {
final LocalShow show;
final ShowMatcher matcher;
const _EpisodeNumbersDialog({required this.show, required this.matcher});
@override
State<_EpisodeNumbersDialog> createState() => _EpisodeNumbersDialogState();
}
class _EpisodeNumbersDialogState extends State<_EpisodeNumbersDialog> {
late final _fields = {
for (final s in widget.show.seasons)
s.number: TextEditingController(
text: '${s.distinct.first.episode! + widget.matcher.offset(widget.show.key, s.number)}'),
};
@override
void dispose() {
for (final c in _fields.values) {
c.dispose();
}
super.dispose();
}
void _save() {
for (final s in widget.show.seasons) {
final first = s.distinct.first.episode!;
final want = int.tryParse(_fields[s.number]!.text.trim());
if (want != null && want > 0) widget.matcher.setOffset(widget.show.key, s.number, want - first);
}
Navigator.of(context).pop();
}
@override
Widget build(BuildContext context) => AlertDialog(
title: const Text('Episode numbers'),
content: SizedBox(
width: 420,
child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
const Text('When the files count differently from AniList, say which episode the first file is; '
'the others follow.'),
const SizedBox(height: 12),
for (final s in widget.show.seasons)
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(children: [
Expanded(
child: Text('${s.title}: the file numbered ${s.distinct.first.episode} is episode',
maxLines: 2),
),
const SizedBox(width: 8),
SizedBox(
width: 72,
child: TextField(
key: ValueKey('offset:${s.number}'),
controller: _fields[s.number],
keyboardType: TextInputType.number,
decoration: const InputDecoration(isDense: true, border: OutlineInputBorder()),
),
),
]),
),
]),
),
actions: [
TextButton(onPressed: () => Navigator.of(context).pop(), child: const Text('Cancel')),
FilledButton(onPressed: _save, child: const Text('Save')),
],
);
}
+213
View File
@@ -0,0 +1,213 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart';
import '../../shell/motion.dart';
import '../thumbnails.dart';
// Lazy poster frames for the Files page. A tile asks for its frame only
// after it has stayed on screen for a moment, so a fast scroll requests
// nothing; tiles sharing a file share one request, and the request is
// cancelled once no tile on screen wants it any more.
/// [thumbnailFor]'s shape; swapped in tests.
typedef ThumbnailLoader = Future<Thumbnail?> Function(String path, {bool Function()? cancelled});
/// A tile waits this long on screen before asking for its frame.
const thumbRequestDelay = Duration(milliseconds: 120);
/// Results of a [ThumbnailLoader] and who is waiting for them.
class ThumbCache {
final ThumbnailLoader load;
ThumbCache(this.load);
final _done = <String, Thumbnail?>{};
final _pending = <String, Future<Thumbnail?>>{};
final _waiting = <String, int>{};
/// Shared by every page using [thumbnailFor].
static final shared = ThumbCache(thumbnailFor);
bool has(String path) => _done.containsKey(path);
Thumbnail? cached(String path) => _done[path];
/// Pair every call with one [release].
Future<Thumbnail?> request(String path) {
_waiting.update(path, (n) => n + 1, ifAbsent: () => 1);
if (_done.containsKey(path)) return Future.value(_done[path]);
return _pending[path] ??= _start(path);
}
void release(String path) {
final n = (_waiting[path] ?? 0) - 1;
if (n > 0) {
_waiting[path] = n;
} else {
_waiting.remove(path);
}
}
/// Drops results, so the next request asks the loader again (rescan).
void forget(Iterable<String> paths) {
for (final p in paths) {
_done.remove(p);
}
}
Future<Thumbnail?> _start(String path) {
var gaveUp = false;
return load(path, cancelled: () => gaveUp = !_waiting.containsKey(path))
.catchError((Object _) => null)
.then((t) {
if (t == null && gaveUp) {
// Cancelled: not a result. Someone may have come back meanwhile.
if (_waiting.containsKey(path)) return _start(path);
_pending.remove(path);
return null;
}
_pending.remove(path);
_done[path] = t;
return t;
});
}
}
/// Builds with the frame of [path] (null while loading or when there is
/// none); [loaded] is false until the request finished.
class ThumbBuilder extends StatefulWidget {
final ThumbCache cache;
final String path;
final Widget Function(BuildContext context, Thumbnail? thumb, bool loaded) builder;
const ThumbBuilder({super.key, required this.cache, required this.path, required this.builder});
@override
State<ThumbBuilder> createState() => _ThumbBuilderState();
}
class _ThumbBuilderState extends State<ThumbBuilder> {
Thumbnail? _thumb;
bool _loaded = false;
Timer? _delay;
// The request held right now: its path, and a token so a late answer to
// an older request is ignored.
String? _holding;
Object? _token;
@override
void initState() {
super.initState();
_begin();
}
@override
void didUpdateWidget(ThumbBuilder old) {
super.didUpdateWidget(old);
if (old.path != widget.path || old.cache != widget.cache) {
_end(old.cache);
_begin();
}
}
@override
void dispose() {
_end(widget.cache);
super.dispose();
}
void _begin() {
final cache = widget.cache, path = widget.path;
_loaded = cache.has(path);
_thumb = cache.cached(path);
if (_loaded) return;
_delay = Timer(thumbRequestDelay, () {
_delay = null;
_holding = path;
final token = _token = Object();
cache.request(path).then((t) {
if (_token != token) return;
_holding = _token = null;
cache.release(path);
if (!mounted) return;
setState(() {
_thumb = t;
_loaded = true;
});
});
});
}
void _end(ThumbCache cache) {
_delay?.cancel();
_delay = null;
final held = _holding;
_holding = _token = null;
if (held != null) cache.release(held);
}
@override
Widget build(BuildContext context) => widget.builder(context, _thumb, _loaded);
}
/// The frame, fading in over a placeholder.
class ThumbImage extends StatelessWidget {
final Thumbnail? thumb;
final double? width, height;
const ThumbImage(this.thumb, {super.key, this.width, this.height});
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final placeholder = Container(
width: width,
height: height,
color: scheme.surfaceContainerHighest,
alignment: Alignment.center,
child: Icon(Icons.movie_outlined, color: scheme.onSurfaceVariant, size: 20),
);
final t = thumb;
if (t == null) return placeholder;
final dpr = MediaQuery.devicePixelRatioOf(context);
return Stack(
fit: width == null ? StackFit.expand : StackFit.loose,
children: [
placeholder,
Image.file(
File(t.imagePath),
width: width,
height: height,
fit: BoxFit.cover,
cacheWidth: width == null ? null : (width! * dpr).round(),
gaplessPlayback: true,
errorBuilder: (_, _, _) => const SizedBox.shrink(),
frameBuilder: (context, child, frame, sync) => sync
? child
: AnimatedOpacity(
opacity: frame == null ? 0 : 1,
duration: easeTime,
curve: ease,
child: child,
),
),
],
);
}
}
/// `1:02:03` / `24:05`.
String formatDuration(Duration d) {
final h = d.inHours, m = d.inMinutes % 60, s = d.inSeconds % 60;
String two(int n) => n.toString().padLeft(2, '0');
return h > 0 ? '$h:${two(m)}:${two(s)}' : '${two(m)}:${two(s)}';
}
/// `1.4 GB` / `350 MB` / `12 KB`.
String formatSize(int bytes) {
if (bytes >= 1 << 30) return '${(bytes / (1 << 30)).toStringAsFixed(1)} GB';
if (bytes >= 1 << 20) return '${(bytes / (1 << 20)).toStringAsFixed(0)} MB';
return '${(bytes / 1024).ceil()} KB';
}
/// The frame's duration, or null when unknown (no frame, 0 length).
String? durationText(Thumbnail? t) =>
t == null || t.duration <= Duration.zero ? null : formatDuration(t.duration);
+54 -4
View File
@@ -8,8 +8,6 @@ import 'package:hive_ce/hive.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 '../screens/home/ui.dart' show appName;
import '../update/updater.dart' show appVersion;
// Linked Jellyfin servers, one account per ServerId, in their own Hive box. // Linked Jellyfin servers, one account per ServerId, in their own Hive box.
// //
@@ -24,6 +22,7 @@ const _keyAccounts = 'accounts';
const _keyDefault = 'defaultServer'; const _keyDefault = 'defaultServer';
const _keyLastPick = 'lastPick'; const _keyLastPick = 'lastPick';
const _keyBrowsePrefix = 'browse:'; const _keyBrowsePrefix = 'browse:';
const _keyDismissedPrefix = 'dismissed:';
/// One signed-in user on one server. /// One signed-in user on one server.
class JellyfinAccount { class JellyfinAccount {
@@ -195,6 +194,13 @@ class JellyfinAccounts extends ChangeNotifier {
JellyfinAccount? account(String serverId) => JellyfinAccount? account(String serverId) =>
_accounts.where((a) => a.serverId == serverId).firstOrNull; _accounts.where((a) => a.serverId == serverId).firstOrNull;
/// [itemId]'s page in the web UI of linked server [serverId] ("Open in
/// Jellyfin"); null when that server is not linked here.
String? webUrlOf(String serverId, String itemId) {
final a = account(serverId);
return a == null ? null : jellyfinWebUrl(a.baseUrl, itemId, serverId: serverId);
}
/// The chosen default, else the first linked server. /// The chosen default, else the first linked server.
JellyfinAccount? get defaultAccount => JellyfinAccount? get defaultAccount =>
(_defaultServerId == null ? null : account(_defaultServerId!)) ?? _accounts.firstOrNull; (_defaultServerId == null ? null : account(_defaultServerId!)) ?? _accounts.firstOrNull;
@@ -207,14 +213,15 @@ class JellyfinAccounts extends ChangeNotifier {
/// Last failure text for an offline account. /// Last failure text for an offline account.
String? errorOf(String serverId) => _errors[serverId]; String? errorOf(String serverId) => _errors[serverId];
/// The Host tab's last Jellyfin pick, restored on the next start. /// The last Jellyfin pick put up, restored in the Library on the next start.
JellyfinMedia? get lastPick => _lastPick; JellyfinMedia? get lastPick => _lastPick;
/// Stable per install: Jellyfin keys devices and tokens by it. /// Stable per install: Jellyfin keys devices and tokens by it.
String get deviceId => _deviceId; String get deviceId => _deviceId;
JellyfinClientInfo get clientInfo => JellyfinClientInfo( JellyfinClientInfo get clientInfo => JellyfinClientInfo(
client: appName, // The old name on purpose: Jellyfin lists devices under it.
client: 'Watch Together',
device: deviceName ?? _hostname(), device: deviceName ?? _hostname(),
deviceId: _deviceId.isEmpty ? 'unknown' : _deviceId, deviceId: _deviceId.isEmpty ? 'unknown' : _deviceId,
version: appVersion, version: appVersion,
@@ -484,6 +491,38 @@ class JellyfinAccounts extends ChangeNotifier {
} }
} }
/// Continue-row entries removed by hand on [serverId] (key -> when), so a
/// next-up series (which Jellyfin cannot drop) stays out until it is
/// played again. Keys are `continueKey`s.
Map<String, DateTime> continueDismissed(String serverId) {
try {
final v = _store.get('$_keyDismissedPrefix$serverId');
final j = v is String ? _tryJson(v) : null;
if (j is! Map) return const {};
return {
for (final e in j.entries)
if (e.key is String && e.value is int)
e.key as String: DateTime.fromMillisecondsSinceEpoch(e.value as int, isUtc: true),
};
} catch (_) {
return const {};
}
}
/// Keeps [key] out of the Continue row on [serverId] until it is played
/// after [at] (default now). The newest 200 are kept.
Future<void> dismissContinue(String serverId, String key, {DateTime? at}) async {
final all = {...continueDismissed(serverId), key: (at ?? DateTime.now()).toUtc()};
final newest = all.entries.toList()..sort((a, b) => b.value.compareTo(a.value));
try {
await _store.put('$_keyDismissedPrefix$serverId', jsonEncode({
for (final e in newest.take(200)) e.key: e.value.millisecondsSinceEpoch,
}));
} catch (e) {
debugPrint('save dismissed continue failed: $e');
}
}
// --------------------------------------------------------------- cache // --------------------------------------------------------------- cache
/// Short-lived memo for browse calls, so going back does not refetch. /// Short-lived memo for browse calls, so going back does not refetch.
@@ -511,6 +550,17 @@ class JellyfinAccounts extends ChangeNotifier {
/// Drops cached browse results (refresh button, after a pick). /// Drops cached browse results (refresh button, after a pick).
void invalidate(String serverId) => _clearMemo(serverId); void invalidate(String serverId) => _clearMemo(serverId);
final _watched = StreamController<String>.broadcast();
/// Server ids whose watched state this app just changed (a stop report):
/// their cached lists are gone, and browse views reload.
Stream<String> get watched => _watched.stream;
void watchedChanged(String serverId) {
_clearMemo(serverId);
_watched.add(serverId);
}
@override @override
void dispose() { void dispose() {
_disposed = true; _disposed = true;
@@ -74,7 +74,7 @@ class _JellyfinAccountsPanelState extends State<JellyfinAccountsPanel> {
Text('Jellyfin', style: t.titleMedium), Text('Jellyfin', style: t.titleMedium),
const SizedBox(height: 4), const SizedBox(height: 4),
const Text( const Text(
'Browse your libraries in the Host tab and put up movies and episodes. ' 'Browse your libraries in the Library tab and put up movies and episodes. '
'Sign in with Quick Connect or your password; several servers are fine.', 'Sign in with Quick Connect or your password; several servers are fine.',
style: TextStyle(color: Colors.grey), style: TextStyle(color: Colors.grey),
), ),
+232 -42
View File
@@ -1,8 +1,13 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:sync_relay_core/core.dart'; import 'package:sync_relay_core/core.dart';
import 'accounts.dart'; import 'accounts.dart';
import 'format.dart'; import 'format.dart';
import '../widgets/app_menu.dart';
import '../widgets/copy_open.dart';
/// One level of the library browser's back stack. /// One level of the library browser's back stack.
sealed class BrowseRoute { sealed class BrowseRoute {
@@ -20,14 +25,17 @@ final class HomeRoute extends BrowseRoute {
String get key => 'home'; String get key => 'home';
} }
/// A library or folder grid. /// A library or folder grid, optionally opened with a sort ("Show all"
/// next to Latest opens it newest first).
final class LibraryRoute extends BrowseRoute { final class LibraryRoute extends BrowseRoute {
final JellyfinItem folder; final JellyfinItem folder;
const LibraryRoute(this.folder); /// A `LibrarySort` name; null = by name.
final String? sort;
const LibraryRoute(this.folder, {this.sort});
@override @override
String get label => folder.name; String get label => folder.name;
@override @override
String get key => 'lib:${folder.id}'; String get key => sort == null ? 'lib:${folder.id}' : 'lib:${folder.id}:$sort';
} }
final class SeriesRoute extends BrowseRoute { final class SeriesRoute extends BrowseRoute {
@@ -41,18 +49,6 @@ final class SeriesRoute extends BrowseRoute {
String get key => 'series:${series.id}'; String get key => 'series:${series.id}';
} }
/// Movie / episode / video detail.
final class DetailRoute extends BrowseRoute {
final JellyfinItem item;
const DetailRoute(this.item);
@override
String get label => item.kind == JellyfinItemKind.episode
? (item.episodeLabel ?? item.name)
: item.name;
@override
String get key => 'item:${item.id}';
}
/// A route stack as JSON for [JellyfinAccounts.saveBrowseLocation]: only /// A route stack as JSON for [JellyfinAccounts.saveBrowseLocation]: only
/// ids, names and the fields the pages start from (each page loads the /// ids, names and the fields the pages start from (each page loads the
/// full item itself). /// full item itself).
@@ -60,18 +56,17 @@ List<Map<String, Object?>> encodeBrowseStack(List<BrowseRoute> stack) => [
for (final r in stack) for (final r in stack)
switch (r) { switch (r) {
HomeRoute() => {'r': 'home'}, HomeRoute() => {'r': 'home'},
LibraryRoute(:final folder) => {'r': 'lib', ..._itemJson(folder)}, LibraryRoute(:final folder, :final sort) => {'r': 'lib', ..._itemJson(folder), 'sort': ?sort},
SeriesRoute(:final series, :final seasonId) => { SeriesRoute(:final series, :final seasonId) => {
'r': 'series', 'r': 'series',
..._itemJson(series), ..._itemJson(series),
'seasonId': ?seasonId, 'seasonId': ?seasonId,
}, },
DetailRoute(:final item) => {'r': 'item', ..._itemJson(item)},
} }
]; ];
/// Inverse of [encodeBrowseStack]; always starts with Home. Null when /// Inverse of [encodeBrowseStack]; always starts with Home. Null when
/// [raw] is not a stack. /// [raw] is not a stack. Entries it no longer knows are skipped.
List<BrowseRoute>? decodeBrowseStack(Object? raw) { List<BrowseRoute>? decodeBrowseStack(Object? raw) {
if (raw is! List) return null; if (raw is! List) return null;
final out = <BrowseRoute>[const HomeRoute()]; final out = <BrowseRoute>[const HomeRoute()];
@@ -79,11 +74,10 @@ List<BrowseRoute>? decodeBrowseStack(Object? raw) {
if (e is! Map) return null; if (e is! Map) return null;
final item = _itemFrom(e); final item = _itemFrom(e);
final route = switch (e['r']) { final route = switch (e['r']) {
'home' => null, 'lib' when item != null =>
'lib' when item != null => LibraryRoute(item), LibraryRoute(item, sort: e['sort'] is String ? e['sort'] as String : null),
'series' when item != null => 'series' when item != null =>
SeriesRoute(item, seasonId: e['seasonId'] is String ? e['seasonId'] as String : null), SeriesRoute(item, seasonId: e['seasonId'] is String ? e['seasonId'] as String : null),
'item' when item != null => DetailRoute(item),
_ => null, _ => null,
}; };
if (route != null && out.every((r) => r.key != route.key)) out.add(route); if (route != null && out.every((r) => r.key != route.key)) out.add(route);
@@ -141,46 +135,228 @@ class BrowseStart {
} }
} }
/// The card selected in the library (details panel): an item on a server.
/// [item] is what the card had, when known (the panel loads it in full).
class JellyfinSelection {
final String serverId;
final String itemId;
final JellyfinItem? item;
const JellyfinSelection(this.serverId, this.itemId, [this.item]);
@override
bool operator ==(Object other) =>
other is JellyfinSelection && other.serverId == serverId && other.itemId == itemId;
@override
int get hashCode => Object.hash(serverId, itemId);
}
/// Browser location shared by the browser, the library chips in the
/// Library header and the details panel: the server shown, its route
/// stack (saved per server on every move), a data version that bumps after
/// watched-state changes (rows reload), and the keys in the Continue row.
class BrowseController extends ChangeNotifier {
final JellyfinAccounts accounts;
/// Clock for the Continue row's age cut-off (tests pin it).
final DateTime Function() now;
BrowseController(this.accounts, {this.now = DateTime.now}) {
// Back from watching: the rows show what was just watched.
_watchedSub = accounts.watched.listen((id) {
if (!_disposed && id == serverId) dataChanged();
});
}
late final StreamSubscription<String> _watchedSub;
String? _serverId;
List<BrowseRoute> _stack = const [HomeRoute()];
bool _started = false;
int _version = 0;
Set<String> _continueKeys = const {};
List<BrowseRoute> get stack => _stack;
int get version => _version;
/// [continueKey]s of the cards in the Continue row now.
Set<String> get continueKeys => _continueKeys;
JellyfinAccount? get account =>
(_serverId == null ? null : accounts.account(_serverId!)) ?? accounts.defaultAccount;
String? get serverId => account?.serverId;
/// First use (accounts loaded): [start], else the server of [serverHint]
/// at its remembered location, else the default server's.
void start({BrowseStart? start, String? serverHint}) {
if (_started) return;
_started = true;
if (start != null && accounts.account(start.serverId) != null) {
_serverId = start.serverId;
_stack = start.stack.isEmpty ? const [HomeRoute()] : start.stack;
_remember();
} else {
_serverId = serverHint != null && accounts.account(serverHint) != null
? serverHint
: accounts.defaultServerId;
_stack = _restored(_serverId);
}
}
List<BrowseRoute> _restored(String? serverId) =>
(serverId == null ? null : decodeBrowseStack(accounts.browseLocation(serverId))) ??
const [HomeRoute()];
void _remember() {
final id = serverId;
if (id != null) unawaited(accounts.saveBrowseLocation(id, encodeBrowseStack(_stack)));
}
void switchServer(String serverId) {
if (serverId == this.serverId) return;
_serverId = serverId;
_stack = _restored(serverId);
_continueKeys = const {};
notifyListeners();
}
void open(BrowseRoute route) {
if (_stack.last.key == route.key) return;
_stack = [..._stack, route];
_remember();
notifyListeners();
}
/// Home, then [route] (library chips: a library is a top-level place).
void openTop(BrowseRoute route) {
_stack = [const HomeRoute(), route];
_remember();
notifyListeners();
}
bool back() {
if (_stack.length <= 1) return false;
_stack = _stack.sublist(0, _stack.length - 1);
_remember();
notifyListeners();
return true;
}
void popTo(int index) {
_stack = _stack.sublist(0, index + 1);
_remember();
notifyListeners();
}
/// Watched state changed on the server: drop its cached lists and let
/// the rows reload.
void dataChanged() {
final id = serverId;
if (id != null) accounts.invalidate(id);
_version++;
notifyListeners();
}
set continueKeys(Set<String> keys) {
if (setEquals(keys, _continueKeys)) return;
_continueKeys = keys;
// Set while building a row: tell listeners after the frame.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!_disposed) notifyListeners();
});
}
bool _disposed = false;
@override
void dispose() {
_disposed = true;
unawaited(_watchedSub.cancel());
super.dispose();
}
}
/// An item's title to copy: an episode with its series ("Show · Name").
String jellyfinCopyTitle(JellyfinItem item) {
final series = item.seriesName;
return item.kind == JellyfinItemKind.episode && series != null && series.isNotEmpty
? '$series · ${item.name}'
: item.name;
}
/// What every browser page needs, provided by [JellyfinBrowser]. /// What every browser page needs, provided by [JellyfinBrowser].
class BrowseScope extends InheritedWidget { class BrowseScope extends InheritedWidget {
final JellyfinAccounts accounts; final JellyfinAccounts accounts;
final JellyfinAccount account; final JellyfinAccount account;
final JellyfinClient client; final JellyfinClient client;
/// Item id of the current pick on this server, highlighted everywhere. final BrowseController controller;
/// Item id of the selected card on this server, highlighted everywhere.
final String? selectedItemId; final String? selectedItemId;
final ValueChanged<JellyfinItem> onPick; /// Click: select (details panel).
final ValueChanged<BrowseRoute> onOpen; final ValueChanged<JellyfinItem> onSelect;
/// Hover / focus: the inspector pane follows it. /// Double-click: put it up (a series: its next episode).
final ValueChanged<JellyfinItem> onPeek; final ValueChanged<JellyfinItem> onPutUp;
/// Adds it to Up next (with next: right after what plays); null: no
/// server (the cards' menu then only copies and opens).
final void Function(JellyfinItem item, {bool next})? onAddToQueue;
/// Home shows the library chips itself (the Library tab has them in its
/// header instead).
final bool libraryStrip;
const BrowseScope({ const BrowseScope({
super.key, super.key,
required this.accounts, required this.accounts,
required this.account, required this.account,
required this.client, required this.client,
required this.controller,
required this.selectedItemId, required this.selectedItemId,
required this.onPick, required this.onSelect,
required this.onOpen, required this.onPutUp,
required this.onPeek, this.onAddToQueue,
this.libraryStrip = true,
required super.child, required super.child,
}); });
static BrowseScope of(BuildContext context) => static BrowseScope of(BuildContext context) =>
context.dependOnInheritedWidgetOfExactType<BrowseScope>()!; context.dependOnInheritedWidgetOfExactType<BrowseScope>()!;
void onOpen(BrowseRoute route) => controller.open(route);
/// Right-click on a card ([context]: the card): Put it up, Add to queue,
/// Play next (playable, in a server), Copy title, Open in Jellyfin.
Future<void> cardMenu(BuildContext context, JellyfinItem item) async {
final queue = isSelectable(item) ? onAddToQueue : null;
final pick = await showAppMenu<int>(context, items: [
if (queue != null) ...const [
AppMenuItem(value: 0, label: 'Put it up'),
AppMenuItem(value: 1, label: 'Add to queue'),
AppMenuItem(value: 2, label: 'Play next'),
],
const AppMenuItem(value: 3, label: ReuseText.copyTitle),
const AppMenuItem(value: 4, label: ReuseText.openInJellyfin),
]);
switch (pick) {
case 0:
onPutUp(item);
case 1:
queue!(item);
case 2:
queue!(item, next: true);
case 3:
if (context.mounted) await copyToClipboard(context, jellyfinCopyTitle(item), done: 'Title copied');
case 4:
if (context.mounted) await openLink(context, jellyfinWebUrl(client.baseUrl, item.id, serverId: account.serverId));
}
}
/// Cached browse call on this server. /// Cached browse call on this server.
Future<T> load<T>(String key, Future<T> Function(JellyfinClient c) fn) => Future<T> load<T>(String key, Future<T> Function(JellyfinClient c) fn) =>
accounts.cached(account.serverId, key, () => fn(client)); accounts.cached(account.serverId, key, () => fn(client));
/// Click / Enter on a card: playable items are picked, containers open. /// Click / Enter on a card: series, movies and episodes are selected,
/// seasons open their series, libraries and folders open.
void activate(JellyfinItem item) { void activate(JellyfinItem item) {
if (isPlayable(item)) { if (isSelectable(item)) {
onPick(item); onSelect(item);
return; return;
} }
switch (item.kind) { switch (item.kind) {
case JellyfinItemKind.series:
onOpen(SeriesRoute(item));
case JellyfinItemKind.season: case JellyfinItemKind.season:
final sid = item.seriesId; final sid = item.seriesId;
if (sid != null) { if (sid != null) {
@@ -193,7 +369,7 @@ class BrowseScope extends InheritedWidget {
} }
} }
/// Opens the series of an episode (or the item itself if it has none). /// Opens the series of an episode.
void openSeriesOf(JellyfinItem episode) { void openSeriesOf(JellyfinItem episode) {
final sid = episode.seriesId; final sid = episode.seriesId;
if (sid == null) return; if (sid == null) return;
@@ -206,23 +382,28 @@ class BrowseScope extends InheritedWidget {
bool updateShouldNotify(BrowseScope old) => bool updateShouldNotify(BrowseScope old) =>
old.selectedItemId != selectedItemId || old.selectedItemId != selectedItemId ||
old.account != account || old.account != account ||
old.client != client; old.client != client ||
old.libraryStrip != libraryStrip;
} }
/// Runs [load] once (again on [retry]) and shows [builder] with the result, /// Runs [load] once (again on [retry] and when [version] changes) and
/// a spinner meanwhile, and a compact error with Retry on failure. /// shows [builder] with the result, a spinner meanwhile, and a compact
/// error with Retry on failure. A reload for a new [version] keeps the old
/// result on screen until the new one arrives.
class Loader<T> extends StatefulWidget { class Loader<T> extends StatefulWidget {
final Future<T> Function() load; final Future<T> Function() load;
final Widget Function(BuildContext context, T data) builder; final Widget Function(BuildContext context, T data) builder;
final Widget? loading; final Widget? loading;
/// When false the error collapses to nothing (optional sections). /// When false the error collapses to nothing (optional sections).
final bool showError; final bool showError;
final Object? version;
const Loader({ const Loader({
super.key, super.key,
required this.load, required this.load,
required this.builder, required this.builder,
this.loading, this.loading,
this.showError = true, this.showError = true,
this.version,
}); });
@override @override
@@ -231,22 +412,31 @@ class Loader<T> extends StatefulWidget {
class _LoaderState<T> extends State<Loader<T>> { class _LoaderState<T> extends State<Loader<T>> {
late Future<T> _future = widget.load(); late Future<T> _future = widget.load();
(T,)? _last;
void _retry() => setState(() => _future = widget.load()); void _retry() => setState(() => _future = widget.load());
@override
void didUpdateWidget(Loader<T> old) {
super.didUpdateWidget(old);
if (old.version != widget.version) _future = widget.load();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return FutureBuilder<T>( return FutureBuilder<T>(
future: _future, future: _future,
builder: (context, snap) { builder: (context, snap) {
if (snap.hasData) return widget.builder(context, snap.data as T); if (snap.hasData || (snap.connectionState == ConnectionState.done && !snap.hasError)) {
_last = (snap.data as T,);
return widget.builder(context, snap.data as T);
}
if (snap.hasError) { if (snap.hasError) {
if (!widget.showError) return const SizedBox.shrink(); if (!widget.showError) return const SizedBox.shrink();
return LoadError(error: snap.error!, onRetry: _retry); return LoadError(error: snap.error!, onRetry: _retry);
} }
if (snap.connectionState == ConnectionState.done) { final last = _last;
return widget.builder(context, snap.data as T); if (last != null) return widget.builder(context, last.$1);
}
return widget.loading ?? return widget.loading ??
const Padding( const Padding(
padding: EdgeInsets.all(32), padding: EdgeInsets.all(32),
+131 -172
View File
@@ -2,52 +2,54 @@ import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:sync_relay_core/core.dart';
import '../screens/home/picked_media.dart';
import 'accounts.dart'; import 'accounts.dart';
import 'browse_scope.dart'; import 'browse_scope.dart';
import 'detail_page.dart';
import 'format.dart';
import 'home_page.dart'; import 'home_page.dart';
import 'inspector.dart';
import 'library_page.dart'; import 'library_page.dart';
import 'search_results.dart'; import 'search_results.dart';
import 'series_page.dart'; import 'series_page.dart';
import 'sign_in_panel.dart'; import 'sign_in_panel.dart';
/// Browser width from which the inspector pane shows on the right.
const inspectorFrom = 1400.0;
const inspectorWidth = 340.0;
/// Library browser for the linked Jellyfin servers: server switcher, /// Library browser for the linked Jellyfin servers: server switcher,
/// breadcrumbs, search, and a back stack of pages (Home rows, library /// breadcrumbs, search, and a back stack of pages (Home rows, library
/// grids, series, details). Pages below the top stay alive, so going back /// grids, series). Pages below the top stay alive, so going back keeps
/// keeps scroll position and loaded pages. /// scroll position and loaded pages. Where it is lives in a
/// [BrowseController] (shared with the Library header's library chips and
/// the details panel; one of its own when none is given).
/// ///
/// Keyboard: arrows move between cards, Enter picks / opens, I opens /// A click on a card selects it ([onSelect]; libraries and folders open),
/// details, Esc / Backspace / Alt+Left go back, / or Ctrl+F searches. /// a double-click puts it up ([onPutUp]).
///
/// Keyboard: arrows move between cards, Enter selects / opens, Esc /
/// Backspace / Alt+Left go back, / or Ctrl+F searches.
class JellyfinBrowser extends StatefulWidget { class JellyfinBrowser extends StatefulWidget {
final JellyfinAccounts accounts; final JellyfinAccounts accounts;
final JellyfinMedia? selected; final BrowseController? controller;
final ValueChanged<JellyfinMedia> onPick; /// Highlighted card.
/// Shown when given: toggles a bigger browser (the host hides its form). final JellyfinSelection? selected;
final VoidCallback? onToggleExpanded; final ValueChanged<JellyfinSelection> onSelect;
final bool expanded; final ValueChanged<JellyfinSelection> onPutUp;
/// Right-click on a card: Add to queue / Play next; null: no server.
final void Function(JellyfinSelection item, {bool next})? onAddToQueue;
final VoidCallback? onOpenIntegrations; final VoidCallback? onOpenIntegrations;
final Duration searchDebounce; final Duration searchDebounce;
/// Opens here instead of the remembered location (the series of the /// Opens here instead of the remembered location (the series of the
/// episode playing, in the session's swap panel). /// episode playing, after "Change video").
final BrowseStart? start; final BrowseStart? start;
/// Library chips on Home (off when the Library header shows them).
final bool libraryStrip;
const JellyfinBrowser({ const JellyfinBrowser({
this.start, this.start,
super.key, super.key,
required this.accounts, required this.accounts,
required this.onPick, required this.onSelect,
required this.onPutUp,
this.onAddToQueue,
this.controller,
this.selected, this.selected,
this.onToggleExpanded,
this.expanded = false,
this.onOpenIntegrations, this.onOpenIntegrations,
this.libraryStrip = true,
this.searchDebounce = const Duration(milliseconds: 300), this.searchDebounce = const Duration(milliseconds: 300),
}); });
@@ -56,45 +58,23 @@ class JellyfinBrowser extends StatefulWidget {
} }
class _JellyfinBrowserState extends State<JellyfinBrowser> { class _JellyfinBrowserState extends State<JellyfinBrowser> {
String? _serverId; BrowseController? _own;
List<BrowseRoute> _stack = const [HomeRoute()];
final _search = TextEditingController(); final _search = TextEditingController();
final _searchFocus = FocusNode(debugLabel: 'jf-search'); final _searchFocus = FocusNode(debugLabel: 'jf-search');
final _body = FocusScopeNode(debugLabel: 'jf-body'); final _body = FocusScopeNode(debugLabel: 'jf-body');
final _peek = ValueNotifier<JellyfinItem?>(null);
String _term = ''; String _term = '';
Timer? _debounce; Timer? _debounce;
int _refresh = 0; int _refresh = 0;
JellyfinAccounts get _accounts => widget.accounts; JellyfinAccounts get _accounts => widget.accounts;
BrowseController get _c => widget.controller ?? (_own ??= BrowseController(widget.accounts));
@override @override
void initState() { void initState() {
super.initState(); super.initState();
final start = widget.start; _c.start(start: widget.start, serverHint: widget.selected?.serverId);
if (start != null && _accounts.account(start.serverId) != null) {
_serverId = start.serverId;
_stack = start.stack.isEmpty ? const [HomeRoute()] : start.stack;
_remember();
} else {
_serverId = widget.selected != null && _accounts.account(widget.selected!.serverId) != null
? widget.selected!.serverId
: _accounts.defaultServerId;
_stack = _restored(_serverId);
}
_accounts.addListener(_changed); _accounts.addListener(_changed);
} _c.addListener(_moved);
/// The remembered location on [serverId], else Home.
List<BrowseRoute> _restored(String? serverId) =>
(serverId == null ? null : decodeBrowseStack(_accounts.browseLocation(serverId))) ??
const [HomeRoute()];
/// Every navigation is saved per server (Hive), so the next browser
/// (Host tab, swap panel, next start) opens where this one was.
void _remember() {
final id = _account?.serverId;
if (id != null) unawaited(_accounts.saveBrowseLocation(id, encodeBrowseStack(_stack)));
} }
@override @override
@@ -104,16 +84,22 @@ class _JellyfinBrowserState extends State<JellyfinBrowser> {
old.accounts.removeListener(_changed); old.accounts.removeListener(_changed);
widget.accounts.addListener(_changed); widget.accounts.addListener(_changed);
} }
final was = old.controller ?? _own;
if (was != _c) {
was?.removeListener(_moved);
_c.addListener(_moved);
}
} }
@override @override
void dispose() { void dispose() {
_accounts.removeListener(_changed); _accounts.removeListener(_changed);
_c.removeListener(_moved);
_own?.dispose();
_debounce?.cancel(); _debounce?.cancel();
_search.dispose(); _search.dispose();
_searchFocus.dispose(); _searchFocus.dispose();
_body.dispose(); _body.dispose();
_peek.dispose();
super.dispose(); super.dispose();
} }
@@ -121,44 +107,33 @@ class _JellyfinBrowserState extends State<JellyfinBrowser> {
if (mounted) setState(() {}); if (mounted) setState(() {});
} }
JellyfinAccount? get _account => /// Any move (here, from the library chips or a server switch) ends a
(_serverId == null ? null : _accounts.account(_serverId!)) ?? _accounts.defaultAccount; /// search.
int _depth = 0;
void _switchServer(String serverId) { String? _server;
if (serverId == _account?.serverId) return; void _moved() {
if (!mounted) return;
final moved = _c.stack.length != _depth || _c.serverId != _server;
_depth = _c.stack.length;
_server = _c.serverId;
setState(() { setState(() {
_serverId = serverId; if (moved) _clearSearch();
_stack = _restored(serverId);
_peek.value = null;
_clearSearch();
}); });
} }
JellyfinAccount? get _account => _c.account;
// ------------------------------------------------------------- navigation // ------------------------------------------------------------- navigation
void _open(BrowseRoute route) {
setState(() {
_clearSearch();
if (_stack.last.key != route.key) _stack = [..._stack, route];
});
_remember();
_body.requestFocus();
}
bool _back() { bool _back() {
if (_stack.length <= 1) return false; final ok = _c.back();
setState(() => _stack = _stack.sublist(0, _stack.length - 1)); if (ok) _body.requestFocus();
_remember(); return ok;
_body.requestFocus();
return true;
} }
void _popTo(int index) { void _popTo(int index) {
setState(() { setState(_clearSearch);
_clearSearch(); _c.popTo(index);
_stack = _stack.sublist(0, index + 1);
});
_remember();
_body.requestFocus(); _body.requestFocus();
} }
@@ -175,12 +150,6 @@ class _JellyfinBrowserState extends State<JellyfinBrowser> {
_term = ''; _term = '';
} }
void _pick(JellyfinItem item) {
final account = _account;
if (account == null) return;
widget.onPick(pickFor(account, item));
}
KeyEventResult _onKey(FocusNode node, KeyEvent e) { KeyEventResult _onKey(FocusNode node, KeyEvent e) {
if (e is! KeyDownEvent && e is! KeyRepeatEvent) return KeyEventResult.ignored; if (e is! KeyDownEvent && e is! KeyRepeatEvent) return KeyEventResult.ignored;
final keys = HardwareKeyboard.instance; final keys = HardwareKeyboard.instance;
@@ -217,10 +186,9 @@ class _JellyfinBrowserState extends State<JellyfinBrowser> {
Widget _page(BrowseRoute r) => switch (r) { Widget _page(BrowseRoute r) => switch (r) {
HomeRoute() => const JellyfinHomePage(), HomeRoute() => const JellyfinHomePage(),
LibraryRoute(:final folder) => JellyfinLibraryPage(folder: folder), LibraryRoute(:final folder, :final sort) => JellyfinLibraryPage(folder: folder, sort: sort),
SeriesRoute(:final series, :final seasonId) => SeriesRoute(:final series, :final seasonId) =>
JellyfinSeriesPage(series: series, seasonId: seasonId), JellyfinSeriesPage(series: series, seasonId: seasonId),
DetailRoute(:final item) => JellyfinDetailPage(item: item),
}; };
@override @override
@@ -233,15 +201,16 @@ class _JellyfinBrowserState extends State<JellyfinBrowser> {
final status = _accounts.statusOf(account.serverId); final status = _accounts.statusOf(account.serverId);
final sel = widget.selected; final sel = widget.selected;
final searching = _term.isNotEmpty; final searching = _term.isNotEmpty;
final stack = _c.stack;
final pages = Stack( final pages = Stack(
children: [ children: [
for (var i = 0; i < _stack.length; i++) for (var i = 0; i < stack.length; i++)
Visibility( Visibility(
key: ValueKey('$_refresh:${account.serverId}:$i:${_stack[i].key}'), key: ValueKey('$_refresh:${account.serverId}:$i:${stack[i].key}'),
visible: !searching && i == _stack.length - 1, visible: !searching && i == stack.length - 1,
maintainState: true, maintainState: true,
child: _page(_stack[i]), child: _page(stack[i]),
), ),
if (searching) JellyfinSearchResults(key: ValueKey('$_refresh:$_term'), term: _term), if (searching) JellyfinSearchResults(key: ValueKey('$_refresh:$_term'), term: _term),
], ],
@@ -251,10 +220,15 @@ class _JellyfinBrowserState extends State<JellyfinBrowser> {
accounts: _accounts, accounts: _accounts,
account: account, account: account,
client: client, client: client,
controller: _c,
selectedItemId: sel != null && sel.serverId == account.serverId ? sel.itemId : null, selectedItemId: sel != null && sel.serverId == account.serverId ? sel.itemId : null,
onPick: _pick, onSelect: (item) => widget.onSelect(JellyfinSelection(account.serverId, item.id, item)),
onOpen: _open, onPutUp: (item) => widget.onPutUp(JellyfinSelection(account.serverId, item.id, item)),
onPeek: (item) => _peek.value = item, onAddToQueue: widget.onAddToQueue == null
? null
: (item, {next = false}) =>
widget.onAddToQueue!(JellyfinSelection(account.serverId, item.id, item), next: next),
libraryStrip: widget.libraryStrip,
child: Focus( child: Focus(
onKeyEvent: _onKey, onKeyEvent: _onKey,
child: Column( child: Column(
@@ -265,21 +239,7 @@ class _JellyfinBrowserState extends State<JellyfinBrowser> {
if (status == JellyfinAccountStatus.expired || status == JellyfinAccountStatus.offline) if (status == JellyfinAccountStatus.expired || status == JellyfinAccountStatus.offline)
_StatusBanner(accounts: _accounts, account: account, status: status), _StatusBanner(accounts: _accounts, account: account, status: status),
const SizedBox(height: 12), const SizedBox(height: 12),
Expanded( Expanded(child: FocusScope(node: _body, child: pages)),
child: LayoutBuilder(builder: (context, box) {
final inspector = box.maxWidth >= inspectorFrom;
final body = FocusScope(node: _body, child: pages);
if (!inspector) return body;
return Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(child: body),
const SizedBox(width: 16),
SizedBox(width: inspectorWidth, child: JellyfinInspector(item: _peek)),
],
);
}),
),
], ],
), ),
), ),
@@ -289,9 +249,10 @@ class _JellyfinBrowserState extends State<JellyfinBrowser> {
Widget _header(BuildContext context, JellyfinAccount account) { Widget _header(BuildContext context, JellyfinAccount account) {
final theme = Theme.of(context); final theme = Theme.of(context);
final t = theme.textTheme; final t = theme.textTheme;
final stack = _c.stack;
final crumbs = <Widget>[]; final crumbs = <Widget>[];
for (var i = 0; i < _stack.length; i++) { for (var i = 0; i < stack.length; i++) {
final last = i == _stack.length - 1 && _term.isEmpty; final last = i == stack.length - 1 && _term.isEmpty;
if (i > 0) { if (i > 0) {
crumbs.add(const Padding( crumbs.add(const Padding(
padding: EdgeInsets.symmetric(horizontal: 2), padding: EdgeInsets.symmetric(horizontal: 2),
@@ -299,7 +260,7 @@ class _JellyfinBrowserState extends State<JellyfinBrowser> {
)); ));
} }
final label = Text( final label = Text(
_stack[i].label, stack[i].label,
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: t.titleSmall?.copyWith( style: t.titleSmall?.copyWith(
@@ -328,73 +289,68 @@ class _JellyfinBrowserState extends State<JellyfinBrowser> {
style: t.titleSmall?.copyWith(fontWeight: FontWeight.w600)))); style: t.titleSmall?.copyWith(fontWeight: FontWeight.w600))));
} }
return Row( return LayoutBuilder(builder: (context, box) {
children: [ final narrow = box.maxWidth < 640;
_ServerSwitcher( return Row(
accounts: _accounts, children: [
current: account, _ServerSwitcher(
onSelect: _switchServer, accounts: _accounts,
onOpenIntegrations: widget.onOpenIntegrations, current: account,
), compact: narrow,
const SizedBox(width: 4), onSelect: _c.switchServer,
IconButton( onOpenIntegrations: widget.onOpenIntegrations,
tooltip: 'Back (Esc)', ),
visualDensity: VisualDensity.compact, IconButton(
onPressed: _stack.length > 1 ? _back : null, tooltip: 'Back (Esc)',
icon: const Icon(Icons.arrow_back, size: 20), visualDensity: VisualDensity.compact,
), onPressed: stack.length > 1 ? _back : null,
const SizedBox(width: 4), icon: const Icon(Icons.arrow_back, size: 20),
Expanded(child: Row(children: crumbs)), ),
const SizedBox(width: 8), const SizedBox(width: 4),
SizedBox( Expanded(child: Row(children: crumbs)),
width: 260, const SizedBox(width: 8),
child: TextField( SizedBox(
key: const ValueKey('jf-search'), width: narrow ? 170 : 260,
controller: _search, child: TextField(
focusNode: _searchFocus, key: const ValueKey('jf-search'),
onChanged: _onSearchChanged, controller: _search,
onSubmitted: (v) { focusNode: _searchFocus,
_debounce?.cancel(); onChanged: _onSearchChanged,
setState(() => _term = v.trim()); onSubmitted: (v) {
}, _debounce?.cancel();
decoration: InputDecoration( setState(() => _term = v.trim());
isDense: true, },
hintText: 'Search ( / )', decoration: InputDecoration(
prefixIcon: const Icon(Icons.search, size: 18), isDense: true,
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)), hintText: 'Search ( / )',
suffixIcon: ListenableBuilder( prefixIcon: const Icon(Icons.search, size: 18),
listenable: _search, border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
builder: (context, _) => _search.text.isEmpty suffixIcon: ListenableBuilder(
? const SizedBox.shrink() listenable: _search,
: IconButton( builder: (context, _) => _search.text.isEmpty
tooltip: 'Clear (Esc)', ? const SizedBox.shrink()
iconSize: 16, : IconButton(
onPressed: () => setState(_clearSearch), tooltip: 'Clear (Esc)',
icon: const Icon(Icons.close), iconSize: 16,
), onPressed: () => setState(_clearSearch),
icon: const Icon(Icons.close),
),
),
), ),
), ),
), ),
),
IconButton(
tooltip: 'Refresh',
visualDensity: VisualDensity.compact,
onPressed: () {
_accounts.invalidate(account.serverId);
setState(() => _refresh++);
},
icon: const Icon(Icons.refresh, size: 20),
),
if (widget.onToggleExpanded != null)
IconButton( IconButton(
key: const ValueKey('jf-expand'), tooltip: 'Refresh',
tooltip: widget.expanded ? 'Show the host form' : 'Bigger browser',
visualDensity: VisualDensity.compact, visualDensity: VisualDensity.compact,
onPressed: widget.onToggleExpanded, onPressed: () {
icon: Icon(widget.expanded ? Icons.close_fullscreen : Icons.open_in_full, size: 18), _accounts.invalidate(account.serverId);
setState(() => _refresh++);
},
icon: const Icon(Icons.refresh, size: 20),
), ),
], ],
); );
});
} }
} }
@@ -404,11 +360,14 @@ class _ServerSwitcher extends StatelessWidget {
final JellyfinAccount current; final JellyfinAccount current;
final ValueChanged<String> onSelect; final ValueChanged<String> onSelect;
final VoidCallback? onOpenIntegrations; final VoidCallback? onOpenIntegrations;
/// Narrow: the name is shorter.
final bool compact;
const _ServerSwitcher({ const _ServerSwitcher({
required this.accounts, required this.accounts,
required this.current, required this.current,
required this.onSelect, required this.onSelect,
this.onOpenIntegrations, this.onOpenIntegrations,
this.compact = false,
}); });
@override @override
@@ -420,7 +379,7 @@ class _ServerSwitcher extends StatelessWidget {
StatusDot(status: accounts.statusOf(current.serverId)), StatusDot(status: accounts.statusOf(current.serverId)),
const SizedBox(width: 8), const SizedBox(width: 8),
ConstrainedBox( ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 200), constraints: BoxConstraints(maxWidth: compact ? 110 : 200),
child: Text(current.displayName, child: Text(current.displayName,
maxLines: 1, overflow: TextOverflow.ellipsis, style: t.titleSmall), maxLines: 1, overflow: TextOverflow.ellipsis, style: t.titleSmall),
), ),
+119 -120
View File
@@ -2,12 +2,12 @@ import 'dart:math' as math;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart'; import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
import 'package:sync_relay_core/core.dart'; import 'package:sync_relay_core/core.dart';
import 'browse_scope.dart'; import 'browse_scope.dart';
import 'format.dart'; import 'format.dart';
import 'images.dart'; import 'images.dart';
import '../shell/motion.dart' show HScroll;
enum CardShape { enum CardShape {
/// 2:3 poster (movies, series, latest). /// 2:3 poster (movies, series, latest).
@@ -49,20 +49,29 @@ IconData kindIcon(JellyfinItem item) => switch (item.kind) {
_ => Icons.movie_outlined, _ => Icons.movie_outlined,
}; };
/// Two clicks on the same card within this time put it up.
const doubleClickTime = Duration(milliseconds: 350);
/// Poster or thumb card: image with progress bar and state badges, title /// Poster or thumb card: image with progress bar and state badges, title
/// and one meta line. Click / Enter activates (pick or open), hover and /// and one meta line. Click / Enter selects it (details panel; libraries
/// focus feed the inspector, "i" or the hover button opens the details. /// and folders open), a second click soon after puts it up. [display]
/// gives the picture and title when they are not [item]'s own (a series
/// card for a new episode), [subtitle] replaces the meta line.
class MediaCard extends StatefulWidget { class MediaCard extends StatefulWidget {
final JellyfinItem item; final JellyfinItem item;
final JellyfinItem? display;
final String? subtitle;
final CardShape shape; final CardShape shape;
/// Rows mixing series (Continue watching, Next up): series name on top, /// Rows mixing series (Continue): series name on top, "S2E3 · 12 min
/// "S2E3 · 12 min left" below. /// left" below.
final bool seriesFirst; final bool seriesFirst;
final bool autofocus; final bool autofocus;
const MediaCard({ const MediaCard({
super.key, super.key,
required this.item, required this.item,
required this.shape, required this.shape,
this.display,
this.subtitle,
this.seriesFirst = false, this.seriesFirst = false,
this.autofocus = false, this.autofocus = false,
}); });
@@ -74,18 +83,20 @@ class MediaCard extends StatefulWidget {
class _MediaCardState extends State<MediaCard> { class _MediaCardState extends State<MediaCard> {
bool _hover = false; bool _hover = false;
bool _focus = false; bool _focus = false;
DateTime? _lastTap;
JellyfinItem get item => widget.item; JellyfinItem get item => widget.item;
void _details(BrowseScope scope) => scope.onOpen(DetailRoute(item)); void _tap(BrowseScope scope) {
final now = DateTime.now();
KeyEventResult _onKey(BrowseScope scope, KeyEvent e) { final last = _lastTap;
if (e is! KeyDownEvent) return KeyEventResult.ignored; _lastTap = now;
if (e.logicalKey == LogicalKeyboardKey.keyI && isPlayable(item)) { if (last != null && now.difference(last) < doubleClickTime && isSelectable(item)) {
_details(scope); _lastTap = null;
return KeyEventResult.handled; scope.onPutUp(item);
return;
} }
return KeyEventResult.ignored; scope.activate(item);
} }
@override @override
@@ -93,10 +104,11 @@ class _MediaCardState extends State<MediaCard> {
final scope = BrowseScope.of(context); final scope = BrowseScope.of(context);
final theme = Theme.of(context); final theme = Theme.of(context);
final scheme = theme.colorScheme; final scheme = theme.colorScheme;
final shown = widget.display ?? item;
final selected = scope.selectedItemId == item.id; final selected = scope.selectedItemId == item.id;
final played = item.userData.played; final played = item.userData.played;
final progress = progressOf(item); final progress = progressOf(item);
final unplayed = item.userData.unplayedItemCount; final unplayed = shown.userData.unplayedItemCount;
final ring = selected final ring = selected
? scheme.primary ? scheme.primary
: _focus : _focus
@@ -106,20 +118,21 @@ class _MediaCardState extends State<MediaCard> {
final image = AspectRatio( final image = AspectRatio(
aspectRatio: widget.shape.aspect, aspectRatio: widget.shape.aspect,
child: AnimatedContainer( child: AnimatedContainer(
duration: const Duration(milliseconds: 120), duration: const Duration(milliseconds: 160),
curve: Curves.fastEaseInToSlowEaseOut,
decoration: BoxDecoration( decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(10),
border: Border.all(color: ring, width: 2), border: Border.all(color: ring, width: 3),
), ),
child: ClipRRect( child: ClipRRect(
borderRadius: BorderRadius.circular(6), borderRadius: BorderRadius.circular(7),
child: Stack( child: Stack(
fit: StackFit.expand, fit: StackFit.expand,
children: [ children: [
JellyfinImage( JellyfinImage(
client: scope.client, client: scope.client,
image: cardImage(item, widget.shape), image: cardImage(shown, widget.shape),
icon: kindIcon(item), icon: kindIcon(shown),
), ),
if (_hover || _focus) if (_hover || _focus)
const ColoredBox(color: Color(0x22FFFFFF)), const ColoredBox(color: Color(0x22FFFFFF)),
@@ -141,7 +154,7 @@ class _MediaCardState extends State<MediaCard> {
? _Badge(color: scheme.primary, child: Icon(Icons.check, size: 14, color: scheme.onPrimary)) ? _Badge(color: scheme.primary, child: Icon(Icons.check, size: 14, color: scheme.onPrimary))
: played : played
? const _Badge(color: Color(0xCC1B5E20), child: Icon(Icons.check, size: 14, color: Colors.white)) ? const _Badge(color: Color(0xCC1B5E20), child: Icon(Icons.check, size: 14, color: Colors.white))
: unplayed != null && unplayed > 0 && !isPlayable(item) : unplayed != null && unplayed > 0 && !isPlayable(shown) && widget.subtitle == null
? _Badge( ? _Badge(
color: scheme.primaryContainer, color: scheme.primaryContainer,
child: Text('$unplayed', child: Text('$unplayed',
@@ -151,78 +164,56 @@ class _MediaCardState extends State<MediaCard> {
color: scheme.onPrimaryContainer))) color: scheme.onPrimaryContainer)))
: const SizedBox.shrink(), : const SizedBox.shrink(),
), ),
if (_hover && isPlayable(item))
Positioned(
top: 4,
left: 4,
child: IconButton.filledTonal(
key: ValueKey('card-info-${item.id}'),
tooltip: 'Details (I)',
visualDensity: VisualDensity.compact,
iconSize: 16,
onPressed: () => _details(scope),
icon: const Icon(Icons.info_outline),
),
),
], ],
), ),
), ),
), ),
); );
final title = cardTitle(item, seriesFirst: widget.seriesFirst); final title = widget.display != null ? shown.name : cardTitle(item, seriesFirst: widget.seriesFirst);
final sub = widget.seriesFirst && item.kind == JellyfinItemKind.episode final sub = widget.subtitle ??
? [cardSubtitle(item), item.name].where((s) => s.isNotEmpty).join(' · ') (widget.seriesFirst && item.kind == JellyfinItemKind.episode
: cardSubtitle(item); ? [cardSubtitle(item), item.name].where((s) => s.isNotEmpty).join(' · ')
return Focus( : cardSubtitle(item));
canRequestFocus: false, return Semantics(
skipTraversal: true, button: true,
onKeyEvent: (_, e) => _onKey(scope, e), selected: selected,
child: Semantics( label: title,
button: true, child: InkWell(
selected: selected, key: ValueKey('card-${item.id}'),
label: title, autofocus: widget.autofocus,
child: InkWell( borderRadius: BorderRadius.circular(10),
key: ValueKey('card-${item.id}'), focusColor: Colors.transparent,
autofocus: widget.autofocus, hoverColor: Colors.transparent,
borderRadius: BorderRadius.circular(8), onTap: () => _tap(scope),
focusColor: Colors.transparent, onSecondaryTap: () => scope.cardMenu(context, item),
hoverColor: Colors.transparent, onHover: (h) => setState(() => _hover = h),
onTap: () => scope.activate(item), onFocusChange: (f) => setState(() => _focus = f),
onHover: (h) { child: Column(
setState(() => _hover = h); crossAxisAlignment: CrossAxisAlignment.stretch,
if (h) scope.onPeek(item); mainAxisSize: MainAxisSize.min,
}, children: [
onFocusChange: (f) { image,
setState(() => _focus = f); const SizedBox(height: 6),
if (f) scope.onPeek(item); SizedBox(
}, height: cardTextHeight - 6,
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min, children: [
children: [ Text(title,
image, maxLines: 1,
const SizedBox(height: 6), overflow: TextOverflow.ellipsis,
SizedBox( style: theme.textTheme.bodyMedium?.copyWith(
height: cardTextHeight - 6, fontWeight: FontWeight.w600,
child: Column( color: selected ? scheme.primary : null)),
crossAxisAlignment: CrossAxisAlignment.start, Text(sub,
children: [ maxLines: 1,
Text(title, overflow: TextOverflow.ellipsis,
maxLines: 1, style: theme.textTheme.bodySmall?.copyWith(color: Colors.grey)),
overflow: TextOverflow.ellipsis, ],
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w500,
color: selected ? scheme.primary : null)),
Text(sub,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall?.copyWith(color: Colors.grey)),
],
),
), ),
], ),
), ],
), ),
), ),
); );
@@ -275,18 +266,25 @@ class CardGridDelegate extends SliverGridDelegate {
bool shouldRelayout(CardGridDelegate old) => old.shape != shape || old.minWidth != minWidth; bool shouldRelayout(CardGridDelegate old) => old.shape != shape || old.minWidth != minWidth;
} }
/// A titled horizontal row of cards sized to fill the width; arrows (on /// One card of a [MediaRow]: see [MediaCard].
/// hover) page through it. Lazy: only visible cards are built. typedef CardEntry = ({JellyfinItem item, JellyfinItem? display, String? subtitle});
List<CardEntry> cardEntries(Iterable<JellyfinItem> items) =>
[for (final i in items) (item: i, display: null, subtitle: null)];
/// A titled horizontal row of cards sized to fill the width; mouse drag
/// and the wheel move it ([HScroll]), arrows (on hover) page through it.
/// Lazy: only visible cards are built.
class MediaRow extends StatefulWidget { class MediaRow extends StatefulWidget {
final String title; final String title;
final List<JellyfinItem> items; final List<CardEntry> entries;
final CardShape shape; final CardShape shape;
final bool seriesFirst; final bool seriesFirst;
final Widget? trailing; final Widget? trailing;
const MediaRow({ const MediaRow({
super.key, super.key,
required this.title, required this.title,
required this.items, required this.entries,
required this.shape, required this.shape,
this.seriesFirst = false, this.seriesFirst = false,
this.trailing, this.trailing,
@@ -315,44 +313,38 @@ class _MediaRowState extends State<MediaRow> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final t = Theme.of(context).textTheme; final entries = widget.entries;
return MouseRegion( return MouseRegion(
onEnter: (_) => setState(() => _hover = true), onEnter: (_) => setState(() => _hover = true),
onExit: (_) => setState(() => _hover = false), onExit: (_) => setState(() => _hover = false),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
Padding( SectionHeader(widget.title, count: '${entries.length}', trailing: widget.trailing),
padding: const EdgeInsets.only(bottom: 8),
child: Row(
children: [
Text(widget.title, style: t.titleMedium),
const SizedBox(width: 8),
Text('${widget.items.length}', style: t.bodySmall?.copyWith(color: Colors.grey)),
const Spacer(),
?widget.trailing,
],
),
),
LayoutBuilder(builder: (context, box) { LayoutBuilder(builder: (context, box) {
final fit = fitColumns(box.maxWidth, widget.shape.minWidth); final fit = fitColumns(box.maxWidth, widget.shape.minWidth);
final h = fit.itemWidth / widget.shape.aspect + cardTextHeight; final h = fit.itemWidth / widget.shape.aspect + cardTextHeight;
final overflow = widget.items.length > fit.cols; final overflow = entries.length > fit.cols;
return SizedBox( return SizedBox(
height: h, height: h,
child: Stack( child: Stack(
children: [ children: [
ListView.separated( HScroll(
controller: _scroll, controller: _scroll,
scrollDirection: Axis.horizontal, builder: (c) => ListView.separated(
itemCount: widget.items.length, controller: c,
separatorBuilder: (_, _) => const SizedBox(width: cardGap), scrollDirection: Axis.horizontal,
itemBuilder: (context, i) => SizedBox( itemCount: entries.length,
width: fit.itemWidth, separatorBuilder: (_, _) => const SizedBox(width: cardGap),
child: MediaCard( itemBuilder: (context, i) => SizedBox(
item: widget.items[i], width: fit.itemWidth,
shape: widget.shape, child: MediaCard(
seriesFirst: widget.seriesFirst, item: entries[i].item,
display: entries[i].display,
subtitle: entries[i].subtitle,
shape: widget.shape,
seriesFirst: widget.seriesFirst,
),
), ),
), ),
), ),
@@ -410,12 +402,19 @@ class SectionHeader extends StatelessWidget {
padding: const EdgeInsets.only(bottom: 8, top: 4), padding: const EdgeInsets.only(bottom: 8, top: 4),
child: Row( child: Row(
children: [ children: [
Text(title, style: t.titleMedium), Expanded(
if (count != null) ...[ child: Row(
const SizedBox(width: 8), children: [
Text(count!, style: t.bodySmall?.copyWith(color: Colors.grey)), Flexible(
], child: Text(title, maxLines: 1, overflow: TextOverflow.ellipsis, style: t.titleMedium),
const Spacer(), ),
if (count != null) ...[
const SizedBox(width: 8),
Text(count!, style: t.bodySmall?.copyWith(color: Colors.grey)),
],
],
),
),
?trailing, ?trailing,
], ],
), ),
@@ -1,180 +0,0 @@
import 'package:flutter/material.dart';
import 'package:sync_relay_core/core.dart';
import 'browse_scope.dart';
import 'format.dart';
import 'images.dart';
/// Movie / episode detail: art, title, meta, overview, the pick button and
/// what is in the file (video, audio and subtitle tracks).
class JellyfinDetailPage extends StatelessWidget {
final JellyfinItem item;
const JellyfinDetailPage({super.key, required this.item});
@override
Widget build(BuildContext context) {
final scope = BrowseScope.of(context);
return Loader<JellyfinItem>(
key: ValueKey('detail-${item.id}-${scope.account.serverId}'),
load: () => scope.load('item:${item.id}', (c) => c.item(item.id)),
builder: (context, full) => _Detail(item: full),
);
}
}
class _Detail extends StatelessWidget {
final JellyfinItem item;
const _Detail({required this.item});
@override
Widget build(BuildContext context) {
final scope = BrowseScope.of(context);
final theme = Theme.of(context);
final t = theme.textTheme;
final episode = item.kind == JellyfinItemKind.episode;
final selected = scope.selectedItemId == item.id;
final meta = [
?item.episodeLabel,
if (item.productionYear != null) '${item.productionYear}',
if (item.runTime != null) formatRuntime(item.runTime!),
?timeLeft(item),
if (item.officialRating != null) item.officialRating!,
if (item.communityRating != null) '★ ${item.communityRating!.toStringAsFixed(1)}',
if (item.userData.played) 'Watched',
].join(' · ');
final art = episode
? AspectRatio(
aspectRatio: 16 / 9,
child: ClipRRect(
borderRadius: BorderRadius.circular(10),
child: JellyfinImage(
client: scope.client,
image: item.ownImage(JellyfinImageType.primary) ??
item.image(JellyfinImageType.backdrop),
icon: Icons.tv_outlined),
),
)
: AspectRatio(
aspectRatio: 2 / 3,
child: ClipRRect(
borderRadius: BorderRadius.circular(10),
child: JellyfinImage(
client: scope.client, image: item.image(JellyfinImageType.primary)),
),
);
final info = Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (episode && item.seriesName != null)
TextButton.icon(
key: const ValueKey('detail-series'),
style: TextButton.styleFrom(padding: EdgeInsets.zero),
onPressed: () => scope.openSeriesOf(item),
icon: const Icon(Icons.live_tv_outlined, size: 18),
label: Text(item.seriesName!, style: t.titleMedium),
),
SelectableText(item.name, style: t.headlineSmall),
const SizedBox(height: 6),
Text(meta, style: t.bodyMedium?.copyWith(color: Colors.grey)),
if (progressOf(item) > 0) ...[
const SizedBox(height: 8),
SizedBox(
width: 240,
child: LinearProgressIndicator(value: progressOf(item), minHeight: 4)),
],
const SizedBox(height: 16),
FilledButton.icon(
key: const ValueKey('detail-pick'),
onPressed: () => scope.onPick(item),
icon: Icon(selected ? Icons.check : Icons.play_arrow),
label: Text(selected ? 'Picked' : 'Pick to watch'),
),
if (item.overview != null) ...[
const SizedBox(height: 16),
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 760),
child: SelectableText(item.overview!, style: t.bodyMedium),
),
],
const SizedBox(height: 16),
_Tracks(item: item),
],
);
return SingleChildScrollView(
padding: const EdgeInsets.only(bottom: 24),
child: LayoutBuilder(builder: (context, box) {
final wide = box.maxWidth >= 760;
final artWidth = episode ? (box.maxWidth * 0.32).clamp(280.0, 480.0) : 240.0;
if (!wide) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Align(
alignment: Alignment.topLeft,
child: SizedBox(width: episode ? 320 : 180, child: art)),
const SizedBox(height: 16),
info,
],
);
}
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(width: artWidth, child: art),
const SizedBox(width: 24),
Expanded(child: info),
],
);
}),
);
}
}
/// What the file holds, one line per track kind.
class _Tracks extends StatelessWidget {
final JellyfinItem item;
const _Tracks({required this.item});
@override
Widget build(BuildContext context) {
final t = Theme.of(context).textTheme;
final source = item.mediaSources.firstOrNull;
final streams = source?.mediaStreams.isNotEmpty == true ? source!.mediaStreams : item.mediaStreams;
if (streams.isEmpty) return const SizedBox.shrink();
String names(JellyfinStreamType type) => streams
.where((s) => s.type == type)
.map((s) => s.displayTitle ?? [s.language, s.codec].whereType<String>().join(' '))
.join(' · ');
final rows = [
('Video', names(JellyfinStreamType.video)),
('Audio', names(JellyfinStreamType.audio)),
('Subtitles', names(JellyfinStreamType.subtitle)),
if (source?.container != null || source?.size != null)
('File', [
?source?.container,
if (source?.size != null) '${(source!.size! / (1 << 30)).toStringAsFixed(2)} GB',
].join(' · ')),
].where((r) => r.$2.isNotEmpty);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (final (label, value) in rows)
Padding(
padding: const EdgeInsets.only(bottom: 6),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 84,
child: Text(label, style: t.bodySmall?.copyWith(color: Colors.grey))),
Expanded(child: Text(value, style: t.bodySmall)),
],
),
),
],
);
}
}
@@ -0,0 +1,584 @@
import 'package:flutter/material.dart';
import 'package:sync_relay_core/core.dart';
import '../shell/motion.dart';
import '../widgets/app_menu.dart';
import '../widgets/copy_open.dart';
import 'accounts.dart';
import 'browse_scope.dart';
import 'cards.dart' show doubleClickTime, kindIcon;
import 'format.dart';
import 'home_rows.dart' show continueKey;
import 'images.dart';
/// Width of the details panel (docked or floating).
const detailsPanelWidth = 400.0;
/// What putting up [item] plays: itself when playable; for a series its
/// next episode (resume point or next up), else its first unwatched, else
/// its first episode. Null when there is nothing to play.
Future<JellyfinItem?> playableFor(JellyfinClient c, JellyfinItem item) async {
if (isPlayable(item)) return item;
if (item.kind != JellyfinItemKind.series) return null;
final next = await c.nextUp(seriesId: item.id, limit: 1, enableResumable: true);
if (next.isNotEmpty) return next.first;
final eps = await c.episodes(item.id);
return eps.where((e) => !e.userData.played).firstOrNull ?? eps.firstOrNull;
}
typedef _Data = ({
JellyfinItem item,
JellyfinItem? series,
List<JellyfinItem> seasons,
JellyfinItem? next,
});
/// The selected card's details, at the right of the library: banner,
/// title, meta, "Put it up · S1E3" ("Play · S1E3" when watching
/// [alone]), mark watched / unwatched, remove from Continue, overview and,
/// for a series or an episode, the episode list (click picks which
/// episode goes up, double-click puts it up). Reloads when the
/// [controller]'s data version moves.
class JellyfinDetailsPanel extends StatefulWidget {
final BrowseController controller;
final JellyfinSelection selection;
/// Nobody else is in the server: the button says Play.
final bool alone;
/// Puts [item] (playable) up; returns an error text, or null when sent.
final Future<String?> Function(JellyfinSelection item) onPutUp;
/// Adds [item] to Up next (with next: right after what plays); null: no
/// server, no queue actions.
final Future<String?> Function(JellyfinSelection item, {bool next})? onAddToQueue;
final VoidCallback onClose;
const JellyfinDetailsPanel({
super.key,
required this.controller,
required this.selection,
required this.onPutUp,
this.onAddToQueue,
required this.onClose,
this.alone = false,
});
@override
State<JellyfinDetailsPanel> createState() => _JellyfinDetailsPanelState();
}
class _JellyfinDetailsPanelState extends State<JellyfinDetailsPanel> {
String? _seasonId;
String? _targetId;
bool _busy = false;
String? _error;
DateTime? _lastTap;
String? _lastTapId;
BrowseController get _c => widget.controller;
JellyfinAccounts get _accounts => _c.accounts;
String get _serverId => widget.selection.serverId;
JellyfinClient? get _client => _accounts.clientFor(_serverId);
Future<T> _cached<T>(String key, Future<T> Function(JellyfinClient c) fn) {
final c = _client;
if (c == null) return Future.error(const JellyfinException('that Jellyfin server is not linked any more'));
return _accounts.cached(_serverId, key, () => fn(c));
}
Future<_Data> _load() async {
await _accounts.ensureLoaded();
final id = widget.selection.itemId;
final item = await _cached('item:$id', (c) => c.item(id));
final sid = item.kind == JellyfinItemKind.series ? item.id : item.seriesId;
if (sid == null || (item.kind != JellyfinItemKind.series && item.kind != JellyfinItemKind.episode)) {
return (item: item, series: null, seasons: const <JellyfinItem>[], next: null);
}
final r = await Future.wait<Object?>([
item.kind == JellyfinItemKind.series ? Future.value(item) : _cached('item:$sid', (c) => c.item(sid)),
_cached('seasons:$sid', (c) => c.seasons(sid)),
_cached('next:$sid', (c) => c.nextUp(seriesId: sid, limit: 1, enableResumable: true))
.then<List<JellyfinItem>?>((v) => v, onError: (_) => null),
]);
final next = r[2] as List<JellyfinItem>?;
return (
item: item,
series: r[0] as JellyfinItem,
seasons: r[1] as List<JellyfinItem>,
next: next == null || next.isEmpty ? null : next.first,
);
}
Future<List<JellyfinItem>> _episodes(String seriesId, String seasonId) =>
_cached('episodes:$seriesId:$seasonId', (c) => c.episodes(seriesId, seasonId: seasonId));
Future<void> _putUp(JellyfinItem target) async {
if (_busy) return;
setState(() {
_busy = true;
_error = null;
});
String? error;
try {
error = await widget.onPutUp(JellyfinSelection(_serverId, target.id, target));
} catch (e) {
error = '$e';
}
if (!mounted) return;
setState(() {
_busy = false;
_error = error;
});
}
Future<void> _queue(JellyfinItem target, {bool next = false}) async {
final add = widget.onAddToQueue;
if (add == null || _busy) return;
setState(() {
_busy = true;
_error = null;
});
String? error;
try {
error = await add(JellyfinSelection(_serverId, target.id, target), next: next);
} catch (e) {
error = '$e';
}
if (!mounted) return;
setState(() {
_busy = false;
_error = error;
});
}
/// Right-click on an episode ([context]: its row): Put it up, Add to
/// queue, Play next (in a server), Copy title, Open in Jellyfin.
Future<void> _episodeMenu(BuildContext context, JellyfinItem e) async {
final queue = widget.onAddToQueue != null;
if (queue) setState(() => _targetId = e.id);
final client = _client;
final pick = await showAppMenu<int>(context, items: [
if (queue) ...[
AppMenuItem(value: 0, label: widget.alone ? 'Play' : 'Put it up'),
const AppMenuItem(value: 1, label: 'Add to queue'),
const AppMenuItem(value: 2, label: 'Play next'),
],
const AppMenuItem(value: 3, label: ReuseText.copyTitle),
if (client != null) const AppMenuItem(value: 4, label: ReuseText.openInJellyfin),
]);
switch (pick) {
case 0:
await _putUp(e);
case 1:
await _queue(e);
case 2:
await _queue(e, next: true);
case 3:
if (context.mounted) await copyToClipboard(context, jellyfinCopyTitle(e), done: 'Title copied');
case 4:
if (context.mounted) await openLink(context, jellyfinWebUrl(client!.baseUrl, e.id, serverId: _serverId));
}
}
/// Runs a watched-state change, then lets every row reload.
Future<void> _change(Future<void> Function(JellyfinClient c) action) async {
final c = _client;
if (c == null || _busy) return;
setState(() {
_busy = true;
_error = null;
});
String? error;
try {
await action(c);
} on JellyfinException catch (e) {
if (e.isUnauthorized) _accounts.markExpired(_serverId);
error = e.isUnauthorized ? 'Jellyfin sign-in expired' : e.message;
} catch (e) {
error = '$e';
}
_c.dataChanged();
if (!mounted) return;
setState(() {
_busy = false;
_error = error;
});
}
void _toggleWatched(JellyfinItem t) =>
_change((c) => t.userData.played ? c.markUnplayed(t.id) : c.markPlayed(t.id));
void _removeFromContinue(JellyfinItem t) => _change((c) async {
if (t.userData.isResumable) await c.clearResumePosition(t.id);
await _accounts.dismissContinue(_serverId, continueKey(t));
});
void _tapEpisode(JellyfinItem e) {
final now = DateTime.now();
final again = _lastTapId == e.id && _lastTap != null && now.difference(_lastTap!) < doubleClickTime;
_lastTap = again ? null : now;
_lastTapId = e.id;
setState(() => _targetId = e.id);
if (again) _putUp(e);
}
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return Material(
key: const ValueKey('jf-details'),
color: scheme.surfaceContainerLow,
child: SizedBox(
width: detailsPanelWidth,
child: ListenableBuilder(
listenable: _c,
builder: (context, _) => Loader<_Data>(
key: ValueKey('details-${widget.selection.serverId}-${widget.selection.itemId}'),
version: _c.version,
load: _load,
loading: Stack(children: [
const Center(child: CircularProgressIndicator(strokeWidth: 2)),
Positioned(right: 4, top: 4, child: _closeButton()),
]),
builder: (context, d) => _body(context, d),
),
),
),
);
}
Widget _closeButton() => IconButton(
key: const ValueKey('details-close'),
tooltip: 'Close',
onPressed: widget.onClose,
icon: const Icon(Icons.close),
);
Widget _body(BuildContext context, _Data d) {
final series = d.series;
if (series == null) return _view(context, d, null, null);
final preferred = d.item.kind == JellyfinItemKind.episode ? d.item : null;
final baseTarget = preferred ?? d.next;
final seasons = d.seasons;
final seasonId = (_seasonId != null && seasons.any((s) => s.id == _seasonId))
? _seasonId
: seasons.any((s) => s.id == baseTarget?.seasonId)
? baseTarget!.seasonId
: (seasons.where((s) => !s.userData.played).firstOrNull ?? seasons.firstOrNull)?.id;
if (seasonId == null) return _view(context, d, baseTarget, const []);
return Loader<List<JellyfinItem>>(
key: ValueKey('details-episodes-$seasonId'),
version: _c.version,
load: () => _episodes(series.id, seasonId),
loading: _view(context, d, baseTarget, null),
builder: (context, eps) {
final chosen = _targetId == null
? null
: eps.where((e) => e.id == _targetId).firstOrNull ??
(baseTarget?.id == _targetId ? baseTarget : null);
final fresh = baseTarget == null ? null : eps.where((e) => e.id == baseTarget.id).firstOrNull;
final target = chosen ??
fresh ??
baseTarget ??
eps.where((e) => !e.userData.played).firstOrNull ??
eps.firstOrNull;
return _view(context, d, target, eps, seasonId: seasonId);
},
);
}
/// [episodes] null = loading, empty = none (or not a series).
Widget _view(BuildContext context, _Data d, JellyfinItem? target, List<JellyfinItem>? episodes,
{String? seasonId}) {
final theme = Theme.of(context);
final t = theme.textTheme;
final scheme = theme.colorScheme;
final client = _client;
final shown = d.series ?? d.item;
final isSeries = d.series != null;
final playable = isSeries ? target : (isPlayable(d.item) ? d.item : null);
final banner = shown.image(JellyfinImageType.backdrop) ??
shown.image(JellyfinImageType.thumb) ??
(d.item.kind == JellyfinItemKind.episode ? d.item.ownImage(JellyfinImageType.primary) : null) ??
shown.image(JellyfinImageType.primary);
final eps = shown.recursiveItemCount;
final meta = [
if (shown.productionYear != null) '${shown.productionYear}',
if (isSeries && d.seasons.length > 1) '${d.seasons.length} seasons',
if (isSeries && eps != null && eps > 0) '$eps episodes',
if (!isSeries && shown.runTime != null) formatRuntime(shown.runTime!),
?shown.officialRating,
if (shown.communityRating != null) '★ ${shown.communityRating!.toStringAsFixed(1)}',
if (!isSeries && shown.userData.played) 'Watched',
].join(' · ');
final verb = widget.alone ? 'Play' : 'Put it up';
final label = playable != null && isSeries ? '$verb · ${playable.episodeLabel ?? playable.name}' : verb;
final inContinue = playable != null &&
(playable.userData.isResumable || _c.continueKeys.contains(continueKey(playable)));
final left = playable == null ? null : timeLeft(playable);
return ListView(
key: const ValueKey('details-list'),
padding: EdgeInsets.zero,
children: [
SizedBox(
height: 180,
child: Stack(
fit: StackFit.expand,
children: [
if (client != null)
JellyfinImage(client: client, image: banner, alignment: Alignment.topCenter, icon: kindIcon(shown))
else
ColoredBox(color: scheme.surfaceContainerHighest),
DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Colors.transparent, scheme.surfaceContainerLow],
stops: const [0.55, 1],
),
),
),
Positioned(right: 4, top: 4, child: _closeButton()),
],
),
),
Padding(
padding: const EdgeInsets.fromLTRB(16, 4, 16, 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SelectableText(shown.name, style: t.titleLarge),
if (meta.isNotEmpty) Text(meta, style: t.bodyMedium?.copyWith(color: Colors.grey)),
const SizedBox(height: 12),
Wrap(
spacing: 4,
runSpacing: 4,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
FilledButton.icon(
key: const ValueKey('details-put-up'),
onPressed: playable == null || _busy ? null : () => _putUp(playable),
icon: _busy
? const SizedBox.square(dimension: 16, child: CircularProgressIndicator(strokeWidth: 2))
: const Icon(Icons.play_arrow),
label: Text(label, overflow: TextOverflow.ellipsis),
),
if (playable != null && widget.onAddToQueue != null)
GestureDetector(
onSecondaryTap: _busy ? null : () => _queue(playable, next: true),
child: IconButton(
key: const ValueKey('details-add-queue'),
onPressed: _busy ? null : () => _queue(playable),
tooltip: 'Add to queue (right-click: play next)',
icon: const Icon(Icons.playlist_add_rounded),
),
),
if (playable != null)
IconButton(
key: const ValueKey('details-watched'),
onPressed: _busy ? null : () => _toggleWatched(playable),
tooltip: playable.userData.played ? 'Mark unwatched' : 'Mark watched',
icon: Icon(playable.userData.played ? Icons.check_circle : Icons.check_circle_outline),
),
if (inContinue)
IconButton(
key: const ValueKey('details-remove-continue'),
onPressed: _busy ? null : () => _removeFromContinue(playable),
tooltip: 'Remove from Continue',
icon: const Icon(Icons.remove_circle_outline),
),
if (isSeries)
IconButton(
key: const ValueKey('details-open-series'),
tooltip: 'Open the series page',
onPressed: () => _c.open(SeriesRoute(d.series!, seasonId: seasonId)),
icon: const Icon(Icons.open_in_new),
),
if (client != null)
IconButton(
key: const ValueKey('details-open-jellyfin'),
tooltip: ReuseText.openInJellyfin,
onPressed: () => openLink(context, jellyfinWebUrl(client.baseUrl, shown.id, serverId: _serverId)),
icon: const Icon(Icons.open_in_browser_rounded),
),
],
),
if (_error != null) ...[
const SizedBox(height: 6),
Text(
_error!,
key: const ValueKey('details-hint'),
style: t.bodySmall?.copyWith(color: scheme.error),
),
],
if (isSeries && playable != null) ...[
const SizedBox(height: 10),
Text(
[playable.episodeLabel ?? '', playable.name].where((s) => s.isNotEmpty).join(' · '),
style: t.titleSmall,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
if (left != null) ...[
const SizedBox(height: 6),
Row(children: [
SizedBox(width: 160, child: LinearProgressIndicator(value: progressOf(playable!), minHeight: 3)),
const SizedBox(width: 8),
Text(left, style: t.bodySmall?.copyWith(color: Colors.grey)),
]),
],
if (shown.overview != null) ...[
const SizedBox(height: 12),
Text(shown.overview!, style: t.bodyMedium),
],
if (!isSeries) ...[
const SizedBox(height: 12),
_Tracks(item: d.item),
],
if (isSeries) ...[
const SizedBox(height: 16),
_episodesHeader(context, d, seasonId),
if (episodes == null)
const Padding(
padding: EdgeInsets.all(24),
child: Center(child: CircularProgressIndicator(strokeWidth: 2)),
)
else if (episodes.isEmpty)
const Padding(
padding: EdgeInsets.symmetric(vertical: 12),
child: Text('No episodes', style: TextStyle(color: Colors.grey)),
)
else
for (final e in episodes) _episodeRow(context, e, target?.id == e.id),
],
],
),
),
],
);
}
Widget _episodesHeader(BuildContext context, _Data d, String? seasonId) {
final seasons = d.seasons;
return Row(
children: [
const Expanded(child: Text('Episodes', style: TextStyle(fontWeight: FontWeight.w600))),
if (seasons.length > 1)
Flexible(
child: AppSelect<String?>(
key: const ValueKey('details-season'),
value: seasonId,
items: [for (final s in seasons) (s.id, s.name)],
onChanged: (id) => setState(() => _seasonId = id),
),
),
],
);
}
Widget _episodeRow(BuildContext context, JellyfinItem e, bool isTarget) {
final scheme = Theme.of(context).colorScheme;
final t = Theme.of(context).textTheme;
final client = _client;
final sub = e.userData.played ? 'watched' : timeLeft(e) ?? (e.runTime == null ? '' : formatRuntime(e.runTime!));
return Material(
color: isTarget ? scheme.primary.withValues(alpha: 0.10) : Colors.transparent,
borderRadius: BorderRadius.circular(8),
// The row's own context: its menu opens at the row.
child: Builder(builder: (context) => InkWell(
key: ValueKey('details-episode-${e.id}'),
borderRadius: BorderRadius.circular(8),
onTap: () => _tapEpisode(e),
onSecondaryTap: () => _episodeMenu(context, e),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 5, horizontal: 4),
child: Row(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: SizedBox(
width: 64,
height: 36,
child: client == null
? ColoredBox(color: scheme.surfaceContainerHighest)
: JellyfinImage(
client: client,
image: e.ownImage(JellyfinImageType.primary) ?? e.image(JellyfinImageType.thumb),
icon: Icons.tv_outlined,
),
),
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
e.indexNumber == null ? e.name : 'E${e.indexNumber} · ${e.name}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: t.bodyMedium?.copyWith(
color: isTarget ? scheme.primary : e.userData.played ? Colors.grey : null,
fontWeight: isTarget ? FontWeight.w600 : null,
),
),
if (sub.isNotEmpty) Text(sub, style: t.bodySmall?.copyWith(color: Colors.grey)),
],
),
),
AnimatedOpacity(
opacity: isTarget ? 1 : 0,
duration: easeTime,
child: Icon(Icons.play_circle, color: scheme.primary),
),
],
),
),
)),
);
}
}
/// What the file holds, one line per track kind.
class _Tracks extends StatelessWidget {
final JellyfinItem item;
const _Tracks({required this.item});
@override
Widget build(BuildContext context) {
final t = Theme.of(context).textTheme;
final source = item.mediaSources.firstOrNull;
final streams = source?.mediaStreams.isNotEmpty == true ? source!.mediaStreams : item.mediaStreams;
if (streams.isEmpty) return const SizedBox.shrink();
String names(JellyfinStreamType type) => streams
.where((s) => s.type == type)
.map((s) => s.displayTitle ?? [s.language, s.codec].whereType<String>().join(' '))
.join(' · ');
final rows = [
('Video', names(JellyfinStreamType.video)),
('Audio', names(JellyfinStreamType.audio)),
('Subtitles', names(JellyfinStreamType.subtitle)),
if (source?.container != null || source?.size != null)
('File', [
?source?.container,
if (source?.size != null) '${(source!.size! / (1 << 30)).toStringAsFixed(2)} GB',
].join(' · ')),
].where((r) => r.$2.isNotEmpty);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (final (label, value) in rows)
Padding(
padding: const EdgeInsets.only(bottom: 6),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(width: 76, child: Text(label, style: t.bodySmall?.copyWith(color: Colors.grey))),
Expanded(child: Text(value, style: t.bodySmall)),
],
),
),
],
);
}
}
@@ -1,5 +1,6 @@
import 'package:sync_relay_core/core.dart'; import 'package:sync_relay_core/core.dart';
import '../library/media_refs.dart';
import '../screens/home/picked_media.dart'; import '../screens/home/picked_media.dart';
import 'accounts.dart'; import 'accounts.dart';
@@ -33,6 +34,9 @@ bool isPlayable(JellyfinItem item) => switch (item.kind) {
_ => !item.isFolder && item.mediaType == 'Video', _ => !item.isFolder && item.mediaType == 'Video',
}; };
/// Cards that select (details panel) on click: playable items and series.
bool isSelectable(JellyfinItem item) => isPlayable(item) || item.kind == JellyfinItemKind.series;
/// Line under a card title: "S2E3 · 12 min left", "2019 · 24 eps", ... /// Line under a card title: "S2E3 · 12 min left", "2019 · 24 eps", ...
String cardSubtitle(JellyfinItem item, {bool withSeries = false}) { String cardSubtitle(JellyfinItem item, {bool withSeries = false}) {
final parts = <String>[]; final parts = <String>[];
@@ -86,5 +90,6 @@ JellyfinMedia pickFor(JellyfinAccount account, JellyfinItem item) {
title: item.name, title: item.name,
subtitle: subtitle, subtitle: subtitle,
runtime: item.runTime, runtime: item.runTime,
ref: jellyfinMediaRef(item),
); );
} }
+144 -73
View File
@@ -1,8 +1,12 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:signals/signals_flutter.dart';
import 'package:sync_relay_core/core.dart'; import 'package:sync_relay_core/core.dart';
import '../state/settings.dart';
import '../widgets/app_menu.dart';
import 'browse_scope.dart'; import 'browse_scope.dart';
import 'cards.dart'; import 'cards.dart';
import 'home_rows.dart';
/// Libraries that hold videos; music, books, photos and playlists are left /// Libraries that hold videos; music, books, photos and playlists are left
/// out of the browser. /// out of the browser.
@@ -19,9 +23,15 @@ IconData libraryIcon(JellyfinItem view) => switch (view.collectionType) {
_ => Icons.folder_outlined, _ => Icons.folder_outlined,
}; };
/// Start page: library shortcuts, then Continue watching, Next up and the /// The video libraries of the scope's server (cached).
/// latest additions of each library. Each row loads on its own, so a slow Future<List<JellyfinItem>> videoLibraries(BrowseScope scope) =>
/// library does not hold up the rest. scope.load('views', (c) => c.userViews()).then((all) => all.where(isVideoLibrary).toList());
/// Start page: (library shortcuts,) one Continue row (resume points and
/// next up merged per series, see [mergeContinue]), then the newest
/// additions of each library as series cards ([latestCards]). Each row
/// loads on its own, so a slow library does not hold up the rest; rows
/// reload when watched state changes ([BrowseController.dataChanged]).
class JellyfinHomePage extends StatelessWidget { class JellyfinHomePage extends StatelessWidget {
const JellyfinHomePage({super.key}); const JellyfinHomePage({super.key});
@@ -30,58 +40,49 @@ class JellyfinHomePage extends StatelessWidget {
final scope = BrowseScope.of(context); final scope = BrowseScope.of(context);
return Loader<List<JellyfinItem>>( return Loader<List<JellyfinItem>>(
key: ValueKey('views-${scope.account.serverId}'), key: ValueKey('views-${scope.account.serverId}'),
load: () => scope.load('views', (c) => c.userViews()), load: () => videoLibraries(scope),
builder: (context, all) { builder: (context, views) => ListenableBuilder(
final views = all.where(isVideoLibrary).toList(); listenable: scope.controller,
return ListView( builder: (context, _) {
key: const PageStorageKey('jf-home'), final v = scope.controller.version;
padding: const EdgeInsets.fromLTRB(0, 4, 0, 24), return ListView(
children: [ key: const PageStorageKey('jf-home'),
_LibraryStrip(views: views), padding: const EdgeInsets.fromLTRB(0, 4, 0, 24),
const SizedBox(height: 20), children: [
_Section( if (scope.libraryStrip) ...[
key: const ValueKey('row-resume'), LibraryStrip(views: views),
title: 'Continue watching', const SizedBox(height: 20),
shape: CardShape.thumb, ] else if (views.isEmpty)
seriesFirst: true, const _NoLibraries(),
load: () => scope.load('resume', (c) => c.resume(limit: 30)), _ContinueRow(version: v),
), for (final view in views) _LatestRow(key: ValueKey('row-latest-${view.id}'), view: view, version: v),
_Section( ],
key: const ValueKey('row-nextup'), );
title: 'Next up', },
shape: CardShape.thumb, ),
seriesFirst: true,
load: () => scope.load('nextup', (c) => c.nextUp(limit: 30)),
),
for (final v in views)
_Section(
key: ValueKey('row-latest-${v.id}'),
title: 'Latest in ${v.name}',
shape: v.collectionType == 'homevideos' ? CardShape.thumb : CardShape.poster,
load: () => scope.load('latest:${v.id}', (c) => c.latest(v.id, limit: 30)),
trailing: TextButton(
onPressed: () => scope.onOpen(LibraryRoute(v)),
child: const Text('Show all'),
),
),
],
);
},
); );
} }
} }
class _LibraryStrip extends StatelessWidget { class _NoLibraries extends StatelessWidget {
const _NoLibraries();
@override
Widget build(BuildContext context) => const Padding(
padding: EdgeInsets.only(bottom: 20),
child: Text('No video libraries on this server for your user.', style: TextStyle(color: Colors.grey)),
);
}
/// The libraries as chips; a click opens one.
class LibraryStrip extends StatelessWidget {
final List<JellyfinItem> views; final List<JellyfinItem> views;
const _LibraryStrip({required this.views}); const LibraryStrip({super.key, required this.views});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final scope = BrowseScope.of(context); final scope = BrowseScope.of(context);
if (views.isEmpty) { if (views.isEmpty) return const _NoLibraries();
return const Text('No video libraries on this server for your user.',
style: TextStyle(color: Colors.grey));
}
return Wrap( return Wrap(
spacing: 8, spacing: 8,
runSpacing: 8, runSpacing: 8,
@@ -92,46 +93,116 @@ class _LibraryStrip extends StatelessWidget {
avatar: Icon(libraryIcon(v), size: 18), avatar: Icon(libraryIcon(v), size: 18),
label: Text(v.name), label: Text(v.name),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
onPressed: () => scope.onOpen(LibraryRoute(v)), onPressed: () => scope.controller.openTop(LibraryRoute(v)),
), ),
], ],
); );
} }
} }
class _Section extends StatelessWidget { class _ContinueRow extends StatelessWidget {
final String title; final int version;
final CardShape shape; const _ContinueRow({required this.version});
final bool seriesFirst;
final Future<List<JellyfinItem>> Function() load; static const title = 'Continue';
final Widget? trailing;
const _Section({ Future<List<JellyfinItem>> _load(BrowseScope scope) async {
super.key, final lists = await Future.wait([
required this.title, scope.load('resume', (c) => c.resume(limit: 40)),
required this.shape, scope.load('nextup', (c) => c.nextUp(limit: 40)),
required this.load, // Only for ordering: a failure leaves the row as it is.
this.seriesFirst = false, scope
this.trailing, .load('played', (c) => c.recentlyPlayed(limit: 150))
}); .catchError((Object _) => const <JellyfinItem>[]),
]);
return mergeContinue(
resume: lists[0],
nextUp: lists[1],
recentlyPlayed: lists[2],
dismissed: scope.accounts.continueDismissed(scope.account.serverId),
now: scope.controller.now(),
);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final scope = BrowseScope.of(context);
return Loader<List<JellyfinItem>>( return Loader<List<JellyfinItem>>(
load: load, version: version,
load: () => _load(scope),
showError: false,
loading: const _RowSkeleton(title: title, shape: CardShape.thumb),
builder: (context, items) {
scope.controller.continueKeys = {for (final i in items) continueKey(i)};
if (items.isEmpty) return const SizedBox.shrink();
return Padding(
key: const ValueKey('row-continue'),
padding: const EdgeInsets.only(bottom: 24),
child: MediaRow(
title: title,
entries: cardEntries(items),
shape: CardShape.thumb,
seriesFirst: true,
),
);
},
);
}
}
class _LatestRow extends StatelessWidget {
final JellyfinItem view;
final int version;
const _LatestRow({super.key, required this.view, required this.version});
@override
Widget build(BuildContext context) {
final scope = BrowseScope.of(context);
final title = 'Latest in ${view.name}';
final shape = view.collectionType == 'homevideos' ? CardShape.thumb : CardShape.poster;
return Loader<List<JellyfinItem>>(
version: version,
load: () => scope.load('latest:${view.id}', (c) => c.latest(view.id, limit: 100, groupItems: false)),
showError: false, showError: false,
loading: _RowSkeleton(title: title, shape: shape), loading: _RowSkeleton(title: title, shape: shape),
builder: (context, items) => items.isEmpty builder: (context, items) => SignalBuilder(builder: (context) {
? const SizedBox.shrink() final hide = AppSettings.instance.hideReReleases.value;
: Padding( final cards = latestCards(items, hideReReleases: hide);
padding: const EdgeInsets.only(bottom: 24), if (cards.isEmpty) return const SizedBox.shrink();
child: MediaRow( return Padding(
title: title, padding: const EdgeInsets.only(bottom: 24),
items: items, child: MediaRow(
shape: shape, key: ValueKey('latest-${view.id}'),
seriesFirst: seriesFirst, title: title,
trailing: trailing, entries: [for (final c in cards) (item: c.item, display: c.display, subtitle: c.subtitle)],
), shape: shape,
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
TextButton(
key: ValueKey('show-all-${view.id}'),
// Newest first, like the row.
onPressed: () => scope.onOpen(LibraryRoute(view, sort: 'added')),
child: const Text('Show all'),
),
AppMenuButton<bool>(
key: ValueKey('latest-menu-${view.id}'),
tooltip: 'Row options',
items: () => [
AppMenuItem(
key: const ValueKey('hide-rereleases'),
value: !hide,
checked: hide,
label: 'Hide re-releases in Latest',
),
],
onSelected: AppSettings.instance.setHideReReleases,
),
],
), ),
),
);
}),
); );
} }
} }
@@ -0,0 +1,163 @@
import 'package:sync_relay_core/core.dart';
import 'format.dart';
// What the Jellyfin home rows show, as pure functions over the API lists
// (tested without widgets).
/// Series not watched for longer than this leave the Continue row, unless
/// nothing newer is left.
const continueMaxAge = Duration(days: 60);
/// Added this long after it first aired: a re-release ([isReRelease]).
const reReleaseAge = Duration(days: 30);
/// One Continue card per series: an episode's series, else the item itself.
String continueKey(JellyfinItem item) => item.seriesId ?? item.id;
DateTime? _later(DateTime? a, DateTime? b) =>
a == null ? b : b == null || !b.isAfter(a) ? a : b;
/// The Continue row: Continue watching ([resume]) and Next up ([nextUp])
/// merged into one card per series, the series watched most recently first.
/// A series shows its resume item when that was the last thing played in
/// it, else its next-up episode. When a series was last played comes from
/// the resume points and from [recentlyPlayed] (finished episodes, which
/// next-up items do not carry). Series untouched for [maxAge] are left out
/// unless nothing else is left; [dismissed] (key -> when) hides a series
/// until it is played again after that.
List<JellyfinItem> mergeContinue({
required List<JellyfinItem> resume,
required List<JellyfinItem> nextUp,
List<JellyfinItem> recentlyPlayed = const [],
Map<String, DateTime> dismissed = const {},
required DateTime now,
Duration maxAge = continueMaxAge,
}) {
final finished = <String, DateTime>{};
for (final p in recentlyPlayed) {
final at = p.userData.lastPlayedDate;
if (at != null) finished[continueKey(p)] = _later(finished[continueKey(p)], at)!;
}
final resumeBy = <String, JellyfinItem>{};
final nextBy = <String, JellyfinItem>{};
final order = <String>[];
for (final r in resume) {
final k = continueKey(r);
final had = resumeBy[k];
if (had == null) order.add(k);
final at = r.userData.lastPlayedDate;
if (had == null || (at != null && at.isAfter(had.userData.lastPlayedDate ?? DateTime(0)))) {
resumeBy[k] = r;
}
}
for (final n in nextUp) {
final k = continueKey(n);
if (nextBy.containsKey(k)) continue;
nextBy[k] = n;
if (!resumeBy.containsKey(k)) order.add(k);
}
final cards = <({JellyfinItem item, DateTime? at, int index})>[];
for (final (i, k) in order.indexed) {
final r = resumeBy[k], n = nextBy[k], done = finished[k];
final resumedAt = r?.userData.lastPlayedDate;
final JellyfinItem item;
if (r != null && (n == null || (resumedAt != null && (done == null || !resumedAt.isBefore(done))))) {
item = r;
} else {
item = n!;
}
final at = _later(resumedAt, done);
final hiddenAt = dismissed[k];
if (hiddenAt != null && (at == null || !at.isAfter(hiddenAt))) continue;
cards.add((item: item, at: at, index: i));
}
cards.sort((a, b) {
if (a.at == null || b.at == null) {
return a.at == b.at ? a.index.compareTo(b.index) : (a.at == null ? 1 : -1);
}
final c = b.at!.compareTo(a.at!);
return c != 0 ? c : a.index.compareTo(b.index);
});
final fresh = [for (final c in cards) if (c.at != null && now.difference(c.at!) <= maxAge) c.item];
return fresh.isNotEmpty ? fresh : [for (final c in cards) c.item];
}
/// Added to the library long ([age]) after it first aired: a re-upload or
/// an old season added late, not news. Unknown dates are never re-releases.
bool isReRelease(JellyfinItem item, {Duration age = reReleaseAge}) {
final aired = item.premiereDate, added = item.dateCreated;
return aired != null && added != null && added.difference(aired) > age;
}
/// One card of a "Latest in X" row: [item] is what selecting or putting it
/// up acts on, [display] gives the picture and title, [subtitle] the line
/// under it.
class LatestCard {
final JellyfinItem item;
final JellyfinItem display;
final String subtitle;
const LatestCard(this.item, this.display, this.subtitle);
}
/// A series to show for [episode]: its id, name and poster.
JellyfinItem seriesOf(JellyfinItem episode) => JellyfinItem(
id: episode.seriesId!,
name: episode.seriesName ?? episode.name,
type: JellyfinItemType.series,
isFolder: true,
imageTags: {
if (episode.seriesPrimaryImageTag != null)
JellyfinImageType.primary.apiName: episode.seriesPrimaryImageTag!,
},
parentBackdropItemId: episode.parentBackdropItemId,
parentBackdropImageTags: episode.parentBackdropImageTags,
);
/// "Latest in X" from the library's newest items (ungrouped, newest
/// first): episodes become one card per series (its poster and name,
/// "S1E1" for a single new episode, else "N new"), in order of the newest
/// episode; movies and other items stay as they are. With [hideReReleases]
/// items that [isReRelease] are left out first.
List<LatestCard> latestCards(List<JellyfinItem> items, {bool hideReReleases = true}) {
final kept = hideReReleases ? items.where((i) => !isReRelease(i)) : items;
final groups = <String, List<JellyfinItem>>{};
final out = <Object>[]; // a JellyfinItem, or a series id (group)
for (final i in kept) {
final sid = i.seriesId;
if (i.kind == JellyfinItemKind.episode && sid != null) {
final g = groups[sid];
if (g == null) {
groups[sid] = [i];
out.add(sid);
} else {
g.add(i);
}
} else {
out.add(i);
}
}
return [
for (final o in out)
if (o is JellyfinItem)
LatestCard(
o,
o,
o.kind == JellyfinItemKind.series && (o.childCount ?? 0) > 1
? '${o.childCount} new'
: cardSubtitle(o),
)
else
_seriesCard(groups[o]!),
];
}
LatestCard _seriesCard(List<JellyfinItem> eps) {
final series = seriesOf(eps.first);
if (eps.length == 1) {
final e = eps.single;
return LatestCard(e, series, e.episodeLabel ?? e.name);
}
return LatestCard(series, series, '${eps.length} new');
}
@@ -1,128 +0,0 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:sync_relay_core/core.dart';
import 'browse_scope.dart';
import 'format.dart';
import 'images.dart';
/// Side pane on wide windows: details of whatever card is hovered or
/// focused, so browsing needs no extra clicks. Pick / Open act on it.
class JellyfinInspector extends StatelessWidget {
final ValueListenable<JellyfinItem?> item;
const JellyfinInspector({super.key, required this.item});
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return DecoratedBox(
key: const ValueKey('jf-inspector'),
decoration: BoxDecoration(
color: scheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: scheme.outlineVariant.withValues(alpha: 0.4)),
),
child: ValueListenableBuilder<JellyfinItem?>(
valueListenable: item,
builder: (context, it, _) => it == null
? const Center(
child: Padding(
padding: EdgeInsets.all(24),
child: Text('Hover or focus a title to see its details here.',
textAlign: TextAlign.center, style: TextStyle(color: Colors.grey)),
),
)
: _Body(item: it),
),
);
}
}
class _Body extends StatelessWidget {
final JellyfinItem item;
const _Body({required this.item});
@override
Widget build(BuildContext context) {
final scope = BrowseScope.of(context);
final t = Theme.of(context).textTheme;
final playable = isPlayable(item);
final selected = scope.selectedItemId == item.id;
final episode = item.kind == JellyfinItemKind.episode;
final art = episode
? item.ownImage(JellyfinImageType.primary) ?? item.image(JellyfinImageType.backdrop)
: item.image(JellyfinImageType.backdrop) ?? item.image(JellyfinImageType.thumb);
final meta = [
if (item.productionYear != null) '${item.productionYear}',
if (playable && item.runTime != null) formatRuntime(item.runTime!),
if (item.officialRating != null) item.officialRating!,
if (item.communityRating != null) '★ ${item.communityRating!.toStringAsFixed(1)}',
if (!playable && (item.userData.unplayedItemCount ?? 0) > 0)
'${item.userData.unplayedItemCount} unwatched',
if (item.userData.played) 'Watched',
].join(' · ');
return ListView(
padding: EdgeInsets.zero,
children: [
ClipRRect(
borderRadius: const BorderRadius.vertical(top: Radius.circular(12)),
child: AspectRatio(
aspectRatio: 16 / 9,
child: JellyfinImage(client: scope.client, image: art),
),
),
Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (episode && item.seriesName != null)
Text(item.seriesName!, style: t.labelLarge?.copyWith(color: Colors.grey)),
Text(
episode && item.episodeLabel != null ? '${item.episodeLabel} ${item.name}' : item.name,
style: t.titleMedium,
),
const SizedBox(height: 4),
Text(meta, style: t.bodySmall?.copyWith(color: Colors.grey)),
if (timeLeft(item) != null) ...[
const SizedBox(height: 8),
LinearProgressIndicator(value: progressOf(item), minHeight: 3),
const SizedBox(height: 4),
Text(timeLeft(item)!, style: t.bodySmall),
],
const SizedBox(height: 12),
Wrap(spacing: 8, runSpacing: 8, children: [
if (playable)
FilledButton.icon(
onPressed: () => scope.onPick(item),
icon: Icon(selected ? Icons.check : Icons.play_arrow, size: 18),
label: Text(selected ? 'Picked' : 'Pick'),
)
else
FilledButton.tonalIcon(
onPressed: () => scope.activate(item),
icon: const Icon(Icons.arrow_forward, size: 18),
label: const Text('Open'),
),
if (playable)
OutlinedButton(
onPressed: () => scope.onOpen(DetailRoute(item)),
child: const Text('Details'),
),
if (episode && item.seriesId != null)
OutlinedButton(
onPressed: () => scope.openSeriesOf(item),
child: const Text('Series'),
),
]),
if (item.overview != null) ...[
const SizedBox(height: 12),
Text(item.overview!, style: t.bodyMedium),
],
],
),
),
],
);
}
}
@@ -39,15 +39,18 @@ const libraryPageSize = 100;
/// pages load as they come near the viewport. /// pages load as they come near the viewport.
class JellyfinLibraryPage extends StatefulWidget { class JellyfinLibraryPage extends StatefulWidget {
final JellyfinItem folder; final JellyfinItem folder;
const JellyfinLibraryPage({super.key, required this.folder}); /// A [LibrarySort] name to start with (in its natural direction).
final String? sort;
const JellyfinLibraryPage({super.key, required this.folder, this.sort});
@override @override
State<JellyfinLibraryPage> createState() => _JellyfinLibraryPageState(); State<JellyfinLibraryPage> createState() => _JellyfinLibraryPageState();
} }
class _JellyfinLibraryPageState extends State<JellyfinLibraryPage> { class _JellyfinLibraryPageState extends State<JellyfinLibraryPage> {
LibrarySort _sort = LibrarySort.name; late LibrarySort _sort =
bool _descending = false; LibrarySort.values.where((s) => s.name == widget.sort).firstOrNull ?? LibrarySort.name;
late bool _descending = _sort.descending;
bool _unplayed = false; bool _unplayed = false;
bool _favorites = false; bool _favorites = false;
@@ -1,31 +1,37 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../screens/home/picked_media.dart';
import '../screens/home/ui.dart'; import '../screens/home/ui.dart';
import 'accounts.dart'; import 'accounts.dart';
import 'browse_scope.dart'; import 'browse_scope.dart';
import 'browser.dart'; import 'browser.dart';
import 'sign_in_panel.dart'; import 'sign_in_panel.dart';
/// The media picker's Jellyfin source: the library browser once a server /// The Library's Jellyfin source: the library browser once a server is
/// is linked, else an inline add-server panel. /// linked, else [empty] (default: an inline add-server panel).
class JellyfinPickerSource extends StatefulWidget { class JellyfinPickerSource extends StatefulWidget {
final JellyfinAccounts accounts; final JellyfinAccounts accounts;
final JellyfinMedia? selected; final BrowseController? controller;
final ValueChanged<JellyfinMedia> onPick; final JellyfinSelection? selected;
final ValueChanged<JellyfinSelection> onSelect;
final ValueChanged<JellyfinSelection> onPutUp;
final void Function(JellyfinSelection item, {bool next})? onAddToQueue;
final VoidCallback? onOpenIntegrations; final VoidCallback? onOpenIntegrations;
final VoidCallback? onToggleExpanded;
final bool expanded;
final BrowseStart? start; final BrowseStart? start;
final bool libraryStrip;
/// Shown while no server is linked.
final WidgetBuilder? empty;
const JellyfinPickerSource({ const JellyfinPickerSource({
super.key, super.key,
this.start, this.start,
required this.accounts, required this.accounts,
required this.onPick, required this.onSelect,
required this.onPutUp,
this.onAddToQueue,
this.controller,
this.selected, this.selected,
this.onOpenIntegrations, this.onOpenIntegrations,
this.onToggleExpanded, this.libraryStrip = true,
this.expanded = false, this.empty,
}); });
@override @override
@@ -48,16 +54,19 @@ class _JellyfinPickerSourceState extends State<JellyfinPickerSource> {
if (!a.loaded) { if (!a.loaded) {
return const Center(child: CircularProgressIndicator(strokeWidth: 2)); return const Center(child: CircularProgressIndicator(strokeWidth: 2));
} }
if (a.isEmpty && widget.empty != null) return widget.empty!(context);
if (a.isEmpty) return _LinkFirst(accounts: a, onOpenIntegrations: widget.onOpenIntegrations); if (a.isEmpty) return _LinkFirst(accounts: a, onOpenIntegrations: widget.onOpenIntegrations);
return JellyfinBrowser( return JellyfinBrowser(
key: const ValueKey('media-jellyfin'), key: const ValueKey('media-jellyfin'),
accounts: a, accounts: a,
controller: widget.controller,
selected: widget.selected, selected: widget.selected,
onPick: widget.onPick, onSelect: widget.onSelect,
onPutUp: widget.onPutUp,
onAddToQueue: widget.onAddToQueue,
onOpenIntegrations: widget.onOpenIntegrations, onOpenIntegrations: widget.onOpenIntegrations,
onToggleExpanded: widget.onToggleExpanded,
expanded: widget.expanded,
start: widget.start, start: widget.start,
libraryStrip: widget.libraryStrip,
); );
}, },
); );
+89 -8
View File
@@ -1,4 +1,5 @@
import 'dart:async'; import 'dart:async';
import 'dart:math' as math;
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:sync_relay_core/core.dart'; import 'package:sync_relay_core/core.dart';
@@ -24,6 +25,15 @@ class PlayerSnapshot {
/// go out one after another; a failure is logged and handed to [onError] /// go out one after another; a failure is logged and handed to [onError]
/// (the session shows one quiet toast) and never touches playback. A 401 /// (the session shows one quiet toast) and never touches playback. A 401
/// calls [onUnauthorized] and ends reporting. /// calls [onUnauthorized] and ends reporting.
///
/// Watched or not is [WatchSession]'s rule (time In, D3), not Jellyfin's
/// position percentages (see ARCHITECTURE.md "Watch tracking"): the
/// session's watch tracker feeds [watch] and this reporter only reads its
/// verdict. An item played before ([playedBefore]) stays played
/// (re-marked after Playing, Progress without a position, Stopped without
/// one, then marked played again); otherwise Progress leaves the position
/// out past [_noPositionFrom] of the runtime so Jellyfin cannot mark it
/// played on its own, and Stopped carries what the rule says.
class JellyfinProgressReporter { class JellyfinProgressReporter {
final JellyfinClient client; final JellyfinClient client;
final String itemId; final String itemId;
@@ -34,20 +44,44 @@ class JellyfinProgressReporter {
final void Function(Object error)? onError; final void Function(Object error)? onError;
final VoidCallback? onUnauthorized; final VoidCallback? onUnauthorized;
/// What this viewer watched of the item while it was open here (fed by
/// the watch tracker, not by this reporter).
final WatchSession watch;
JellyfinProgressReporter({ JellyfinProgressReporter({
required this.client, required this.client,
required this.itemId, required this.itemId,
required this.source, required this.source,
required this.playSessionId, required this.playSessionId,
bool playedBefore = false,
int resumeTicksBefore = 0,
this.interval = const Duration(seconds: 10), this.interval = const Duration(seconds: 10),
this.onError, this.onError,
this.onUnauthorized, this.onUnauthorized,
this.onStopped,
WatchSession? watch,
DateTime Function()? clock, DateTime Function()? clock,
}) : _clock = clock ?? DateTime.now; }) : _clock = clock ?? DateTime.now,
_keepPlayed = playedBefore,
_runtime = (source.runTimeTicks ?? 0) / jellyfinTicksPerSecond,
watch = (watch ?? WatchSession())
..playedBefore = playedBefore
..resumeBefore = resumeTicksBefore / jellyfinTicksPerSecond;
/// Past this share of the runtime Progress carries no position: Jellyfin
/// marks an item played past its "max resume" share (90 % by default).
static const _noPositionFrom = 0.85;
/// After Stopped (and a mark played) went out: the item's watched
/// state on the server may differ from what lists show.
final void Function()? onStopped;
Future<void> _chain = Future.value(); Future<void> _chain = Future.value();
bool _playing = false; bool _playing = false;
bool _dead = false; bool _dead = false;
bool _keepPlayed;
double _runtime;
double? _endingStart;
PlayerSnapshot? _last; PlayerSnapshot? _last;
DateTime? _lastAt; DateTime? _lastAt;
DateTime? _lastSentAt; DateTime? _lastSentAt;
@@ -55,13 +89,23 @@ class JellyfinProgressReporter {
/// Between [start] and [stop]. /// Between [start] and [stop].
bool get active => _playing && !_dead; bool get active => _playing && !_dead;
/// The item counts as played here (before, or by the rule at a stop).
bool get keepsPlayed => _keepPlayed;
/// The player's duration (used when Jellyfin gave no runtime) and where
/// the ending starts (Outro segment / ED chapter), seconds.
void timeline({double duration = 0, double? endingStart}) {
if ((source.runTimeTicks ?? 0) <= 0 && duration > 0) _runtime = duration;
_endingStart = endingStart;
}
/// The report body for [s]: ticks, pause, and mpv's aid/sid mapped to /// The report body for [s]: ticks, pause, and mpv's aid/sid mapped to
/// Jellyfin stream indices (-1 = subtitles off). /// Jellyfin stream indices (-1 = subtitles off).
JellyfinPlaybackReport reportFor(PlayerSnapshot s) => JellyfinPlaybackReport( JellyfinPlaybackReport reportFor(PlayerSnapshot s, {bool withPosition = true}) => JellyfinPlaybackReport(
itemId: itemId, itemId: itemId,
mediaSourceId: source.id, mediaSourceId: source.id,
playSessionId: playSessionId, playSessionId: playSessionId,
positionTicks: (s.position * jellyfinTicksPerSecond).round(), positionTicks: withPosition ? _ticks(s.position) : null,
isPaused: s.paused, isPaused: s.paused,
audioStreamIndex: s.aid == null audioStreamIndex: s.aid == null
? null ? null
@@ -71,13 +115,23 @@ class JellyfinProgressReporter {
: source.jellyfinIndexForMpvTrack(JellyfinStreamType.subtitle, s.sid!), : source.jellyfinIndexForMpvTrack(JellyfinStreamType.subtitle, s.sid!),
); );
static int _ticks(double secs) => (secs * jellyfinTicksPerSecond).round();
/// Progress may carry the position: not for a played item, not without
/// a runtime and not near the end (either would let Jellyfin decide).
bool _progressPosition(double position) =>
!_keepPlayed && _runtime > 0 && position < _noPositionFrom * _runtime;
/// Playing: once the media is open. Again after [stop] (played to the /// Playing: once the media is open. Again after [stop] (played to the
/// end, then someone seeked back) starts reporting anew. /// end, then someone seeked back) starts reporting anew. A played item
/// is marked played again right after (Jellyfin up to 10.11 clears it
/// on Playing).
void start(PlayerSnapshot s) { void start(PlayerSnapshot s) {
if (_playing || _dead) return; if (_playing || _dead) return;
_playing = true; _playing = true;
_mark(s); _mark(s);
_send('Playing', () => client.reportPlaybackStart(reportFor(s))); _send('Playing', () => client.reportPlaybackStart(reportFor(s)));
if (_keepPlayed) _send('played', () => client.markPlayed(itemId));
} }
/// Progress when due (see the class doc). /// Progress when due (see the class doc).
@@ -99,16 +153,43 @@ class JellyfinProgressReporter {
_lastAt = now; _lastAt = now;
if (!due) return; if (!due) return;
_lastSentAt = now; _lastSentAt = now;
_send('Progress', () => client.reportPlaybackProgress(reportFor(s))); final report = reportFor(s, withPosition: _progressPosition(s.position));
_send('Progress', () => client.reportPlaybackProgress(report));
} }
/// Stopped (swap, leave, kick, window close, played to the end). /// Stopped (swap, leave, kick, window close, played to the end), as
/// [WatchSession.verdict] says: played (or kept played) -> no position,
/// then marked played; a peek -> the resume point from before; part way
/// -> the position, held under [_noPositionFrom] of the runtime.
/// Completes when it and everything before it went out. /// Completes when it and everything before it went out.
Future<void> stop(PlayerSnapshot s) { Future<void> stop(PlayerSnapshot s) {
if (!_playing || _dead) return _chain; if (!_playing || _dead) return _chain;
_playing = false; _playing = false;
_send('Stopped', () => client.reportPlaybackStopped(reportFor(s))); final v = _keepPlayed
return _chain; ? const WatchVerdict(WatchOutcome.keepPlayed)
: watch.verdict(runtime: _runtime, endingStart: _endingStart);
debugPrint('jellyfin $itemId: $v (${watch.credited.round()} s In of ${_runtime.round()} s)');
final int? ticks;
switch (v.outcome) {
case WatchOutcome.played || WatchOutcome.keepPlayed:
_keepPlayed = true;
ticks = null;
case WatchOutcome.peek:
ticks = _ticks(watch.resumeBefore);
case WatchOutcome.partial:
final at = v.resumeAt ?? s.position;
ticks = _ticks(_runtime > 0 ? math.min(at, _noPositionFrom * _runtime) : at);
}
final report = JellyfinPlaybackReport(
itemId: itemId, mediaSourceId: source.id, playSessionId: playSessionId, positionTicks: ticks);
_send('Stopped', () => client.reportPlaybackStopped(report));
if (ticks == null) {
_send('played', () => client.markPlayed(itemId));
// Marking played keeps a resume point Progress left (Continue would
// still list it, "3 min left"): clear it.
_send('resume cleared', () => client.clearResumePosition(itemId));
}
return _chain = _chain.then((_) => onStopped?.call());
} }
void _mark(PlayerSnapshot s) { void _mark(PlayerSnapshot s) {
+113 -116
View File
@@ -1,8 +1,8 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:sync_relay_core/core.dart'; import 'package:sync_relay_core/core.dart';
import 'browse_scope.dart'; import 'browse_scope.dart';
import 'cards.dart' show doubleClickTime;
import 'format.dart'; import 'format.dart';
import 'images.dart'; import 'images.dart';
@@ -60,6 +60,7 @@ class _JellyfinSeriesPageState extends State<JellyfinSeriesPage> {
final scope = BrowseScope.of(context); final scope = BrowseScope.of(context);
return Loader<_SeriesData>( return Loader<_SeriesData>(
key: ValueKey('series-${widget.series.id}-${scope.account.serverId}'), key: ValueKey('series-${widget.series.id}-${scope.account.serverId}'),
version: scope.controller.version,
load: () => _load(scope), load: () => _load(scope),
builder: (context, d) { builder: (context, d) {
final seasonId = (_seasonId != null && d.seasons.any((s) => s.id == _seasonId)) final seasonId = (_seasonId != null && d.seasons.any((s) => s.id == _seasonId))
@@ -99,6 +100,7 @@ class _JellyfinSeriesPageState extends State<JellyfinSeriesPage> {
key: ValueKey('episodes-$seasonId'), key: ValueKey('episodes-$seasonId'),
seriesId: widget.series.id, seriesId: widget.series.id,
seasonId: seasonId, seasonId: seasonId,
version: scope.controller.version,
), ),
const SliverToBoxAdapter(child: SizedBox(height: 24)), const SliverToBoxAdapter(child: SizedBox(height: 24)),
], ],
@@ -218,7 +220,7 @@ class _SeriesHeaderState extends State<_SeriesHeader> {
Wrap(spacing: 8, runSpacing: 8, children: [ Wrap(spacing: 8, runSpacing: 8, children: [
FilledButton.icon( FilledButton.icon(
key: const ValueKey('series-next'), key: const ValueKey('series-next'),
onPressed: next == null ? null : () => scope.onPick(next), onPressed: next == null ? null : () => scope.onSelect(next),
icon: Icon(selectedNext ? Icons.check : Icons.play_arrow), icon: Icon(selectedNext ? Icons.check : Icons.play_arrow),
label: Text(nextLabel, overflow: TextOverflow.ellipsis), label: Text(nextLabel, overflow: TextOverflow.ellipsis),
), ),
@@ -242,7 +244,9 @@ class _SeriesHeaderState extends State<_SeriesHeader> {
class _EpisodeList extends StatefulWidget { class _EpisodeList extends StatefulWidget {
final String seriesId; final String seriesId;
final String seasonId; final String seasonId;
const _EpisodeList({super.key, required this.seriesId, required this.seasonId}); final int version;
const _EpisodeList(
{super.key, required this.seriesId, required this.seasonId, required this.version});
@override @override
State<_EpisodeList> createState() => _EpisodeListState(); State<_EpisodeList> createState() => _EpisodeListState();
@@ -250,6 +254,13 @@ class _EpisodeList extends StatefulWidget {
class _EpisodeListState extends State<_EpisodeList> { class _EpisodeListState extends State<_EpisodeList> {
Future<List<JellyfinItem>>? _future; Future<List<JellyfinItem>>? _future;
List<JellyfinItem>? _last;
@override
void didUpdateWidget(_EpisodeList old) {
super.didUpdateWidget(old);
if (old.version != widget.version) _future = null;
}
void _load(BrowseScope scope) => _future = scope.load('episodes:${widget.seriesId}:${widget.seasonId}', void _load(BrowseScope scope) => _future = scope.load('episodes:${widget.seriesId}:${widget.seasonId}',
(c) => c.episodes(widget.seriesId, seasonId: widget.seasonId)); (c) => c.episodes(widget.seriesId, seasonId: widget.seasonId));
@@ -265,7 +276,8 @@ class _EpisodeListState extends State<_EpisodeList> {
return SliverToBoxAdapter( return SliverToBoxAdapter(
child: LoadError(error: snap.error!, onRetry: () => setState(() => _load(scope)))); child: LoadError(error: snap.error!, onRetry: () => setState(() => _load(scope))));
} }
final eps = snap.data; final eps = snap.data ?? _last;
if (snap.data != null) _last = snap.data;
if (eps == null) { if (eps == null) {
return const SliverToBoxAdapter( return const SliverToBoxAdapter(
child: Padding( child: Padding(
@@ -302,7 +314,8 @@ class _EpisodeListState extends State<_EpisodeList> {
const episodeTileHeight = 6 + 176 * 9 / 16 + 6 + 2; const episodeTileHeight = 6 + 176 * 9 / 16 + 6 + 2;
/// One episode: thumbnail with progress, number + title, runtime / air /// One episode: thumbnail with progress, number + title, runtime / air
/// date, two lines of overview. Click picks it; I opens details. /// date, two lines of overview. Click selects it (details panel), a second
/// click soon after puts it up.
class EpisodeTile extends StatefulWidget { class EpisodeTile extends StatefulWidget {
final JellyfinItem episode; final JellyfinItem episode;
const EpisodeTile({super.key, required this.episode}); const EpisodeTile({super.key, required this.episode});
@@ -314,6 +327,19 @@ class EpisodeTile extends StatefulWidget {
class _EpisodeTileState extends State<EpisodeTile> { class _EpisodeTileState extends State<EpisodeTile> {
bool _focus = false; bool _focus = false;
bool _hover = false; bool _hover = false;
DateTime? _lastTap;
void _tap(BrowseScope scope) {
final now = DateTime.now();
final last = _lastTap;
_lastTap = now;
if (last != null && now.difference(last) < doubleClickTime) {
_lastTap = null;
scope.onPutUp(widget.episode);
} else {
scope.onSelect(widget.episode);
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -332,122 +358,93 @@ class _EpisodeTileState extends State<EpisodeTile> {
if (e.premiereDate != null) if (e.premiereDate != null)
'${e.premiereDate!.year}-${e.premiereDate!.month.toString().padLeft(2, '0')}-${e.premiereDate!.day.toString().padLeft(2, '0')}', '${e.premiereDate!.year}-${e.premiereDate!.month.toString().padLeft(2, '0')}-${e.premiereDate!.day.toString().padLeft(2, '0')}',
].join(' · '); ].join(' · ');
return Focus( return Material(
canRequestFocus: false, color: selected
skipTraversal: true, ? scheme.primaryContainer.withValues(alpha: 0.35)
onKeyEvent: (_, k) { : _hover || _focus
if (k is KeyDownEvent && k.logicalKey == LogicalKeyboardKey.keyI) { ? scheme.surfaceContainerHigh
scope.onOpen(DetailRoute(e)); : Colors.transparent,
return KeyEventResult.handled; shape: RoundedRectangleBorder(
} borderRadius: BorderRadius.circular(10),
return KeyEventResult.ignored; side: BorderSide(
}, color: selected ? scheme.primary : _focus ? scheme.onSurface : Colors.transparent,
child: Material( width: 1.5),
color: selected ),
? scheme.primaryContainer.withValues(alpha: 0.35) clipBehavior: Clip.antiAlias,
: _hover || _focus child: InkWell(
? scheme.surfaceContainerHigh key: ValueKey('episode-${e.id}'),
: Colors.transparent, focusColor: Colors.transparent,
shape: RoundedRectangleBorder( onTap: () => _tap(scope),
borderRadius: BorderRadius.circular(10), onSecondaryTap: () => scope.cardMenu(context, widget.episode),
side: BorderSide( onHover: (h) => setState(() => _hover = h),
color: selected ? scheme.primary : _focus ? scheme.onSurface : Colors.transparent, onFocusChange: (f) => setState(() => _focus = f),
width: 1.5), child: Padding(
), padding: const EdgeInsets.all(6),
clipBehavior: Clip.antiAlias, child: Row(
child: InkWell( crossAxisAlignment: CrossAxisAlignment.start,
key: ValueKey('episode-${e.id}'), children: [
focusColor: Colors.transparent, SizedBox(
onTap: () => scope.onPick(e), width: 176,
onHover: (h) { child: AspectRatio(
setState(() => _hover = h); aspectRatio: 16 / 9,
if (h) scope.onPeek(e); child: ClipRRect(
}, borderRadius: BorderRadius.circular(6),
onFocusChange: (f) { child: Stack(fit: StackFit.expand, children: [
setState(() => _focus = f); JellyfinImage(
if (f) scope.onPeek(e); client: scope.client,
}, image: e.ownImage(JellyfinImageType.primary) ??
child: Padding( e.image(JellyfinImageType.thumb) ??
padding: const EdgeInsets.all(6), e.image(JellyfinImageType.backdrop),
child: Row( icon: Icons.tv_outlined,
crossAxisAlignment: CrossAxisAlignment.start, ),
children: [ if (progress > 0)
SizedBox( Positioned(
width: 176, left: 0,
child: AspectRatio( right: 0,
aspectRatio: 16 / 9, bottom: 0,
child: ClipRRect( child: LinearProgressIndicator(
borderRadius: BorderRadius.circular(6), value: progress, minHeight: 3, backgroundColor: Colors.black54),
child: Stack(fit: StackFit.expand, children: [
JellyfinImage(
client: scope.client,
image: e.ownImage(JellyfinImageType.primary) ??
e.image(JellyfinImageType.thumb) ??
e.image(JellyfinImageType.backdrop),
icon: Icons.tv_outlined,
), ),
if (progress > 0) if (played || selected)
Positioned( Positioned(
left: 0, top: 4,
right: 0, right: 4,
bottom: 0, child: CircleAvatar(
child: LinearProgressIndicator( radius: 10,
value: progress, minHeight: 3, backgroundColor: Colors.black54), backgroundColor: selected ? scheme.primary : const Color(0xCC1B5E20),
child: Icon(Icons.check,
size: 13, color: selected ? scheme.onPrimary : Colors.white),
), ),
if (played || selected) ),
Positioned( ]),
top: 4, ),
right: 4, ),
child: CircleAvatar( ),
radius: 10, const SizedBox(width: 12),
backgroundColor: selected ? scheme.primary : const Color(0xCC1B5E20), Expanded(
child: Icon(Icons.check, child: Column(
size: 13, color: selected ? scheme.onPrimary : Colors.white), crossAxisAlignment: CrossAxisAlignment.start,
), children: [
), Text(
]), e.indexNumber == null ? e.name : '${e.indexNumber}. ${e.name}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: t.titleSmall?.copyWith(
color: selected ? scheme.primary : played ? Colors.grey : null,
),
), ),
), Text(meta, style: t.bodySmall?.copyWith(color: Colors.grey)),
), if (e.overview != null) ...[
const SizedBox(width: 12), const SizedBox(height: 4),
Expanded( Text(e.overview!,
child: Column( maxLines: 2,
crossAxisAlignment: CrossAxisAlignment.start, overflow: TextOverflow.ellipsis,
children: [ style: t.bodySmall?.copyWith(color: scheme.onSurfaceVariant)),
Row(children: [
Expanded(
child: Text(
e.indexNumber == null ? e.name : '${e.indexNumber}. ${e.name}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: t.titleSmall?.copyWith(
color: selected ? scheme.primary : played ? Colors.grey : null,
),
),
),
if (_hover)
IconButton(
tooltip: 'Details (I)',
padding: EdgeInsets.zero,
constraints: const BoxConstraints.tightFor(width: 24, height: 22),
iconSize: 18,
onPressed: () => scope.onOpen(DetailRoute(e)),
icon: const Icon(Icons.info_outline),
),
]),
Text(meta, style: t.bodySmall?.copyWith(color: Colors.grey)),
if (e.overview != null) ...[
const SizedBox(height: 4),
Text(e.overview!,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: t.bodySmall?.copyWith(color: scheme.onSurfaceVariant)),
],
], ],
), ],
), ),
], ),
), ],
), ),
), ),
), ),
@@ -6,6 +6,7 @@ import 'package:sync_relay_core/core.dart';
import '../screens/home/picked_media.dart'; import '../screens/home/picked_media.dart';
import 'accounts.dart'; import 'accounts.dart';
import 'format.dart'; import 'format.dart';
import '../library/media_refs.dart';
// Jellyfin media in a session: turning picks and pasted links into refs // Jellyfin media in a session: turning picks and pasted links into refs
// (the wire carries only `{serverId, itemId, ...}`), the hosted server's // (the wire carries only `{serverId, itemId, ...}`), the hosted server's
@@ -27,7 +28,8 @@ HelloMedia mediaForPick(JellyfinMedia m) => HelloMedia(
name: m.title.isEmpty ? 'Jellyfin item' : m.title, name: m.title.isEmpty ? 'Jellyfin item' : m.title,
size: 0, size: 0,
duration: 0, duration: 0,
jellyfin: refForPick(m)); jellyfin: refForPick(m),
ref: m.ref ?? jellyfinPickRef(m.title, m.subtitle));
/// The pick a ref stands for on a linked server (for highlighting it in /// The pick a ref stands for on a linked server (for highlighting it in
/// the browser); null when this install has no account there. /// the browser); null when this install has no account there.
@@ -77,7 +79,8 @@ final _sourceId = RegExp(r'^[A-Za-z0-9-]{1,64}$');
/// is one. /// is one.
Future<HelloMedia> mediaForLink(JellyfinAccounts accounts, String url, Future<HelloMedia> mediaForLink(JellyfinAccounts accounts, String url,
{Duration timeout = const Duration(seconds: 6)}) async { {Duration timeout = const Duration(seconds: 6)}) async {
final plain = HelloMedia(name: linkName(url), size: 0, duration: 0, url: url); final name = linkName(url);
final plain = HelloMedia(name: name, size: 0, duration: 0, url: url, ref: linkMediaRef(name));
final link = parseJellyfinLink(url); final link = parseJellyfinLink(url);
if (link == null) return plain; if (link == null) return plain;
await accounts.ensureLoaded(); await accounts.ensureLoaded();
@@ -102,9 +105,11 @@ Future<HelloMedia> mediaForLink(JellyfinAccounts accounts, String url,
); );
final account = accounts.account(serverId); final account = accounts.account(serverId);
final client = accounts.clientFor(serverId); final client = accounts.clientFor(serverId);
MediaRef? what;
if (account != null && client != null) { if (account != null && client != null) {
try { try {
final pick = pickFor(account, await client.item(link.itemId).timeout(timeout)); final pick = pickFor(account, await client.item(link.itemId).timeout(timeout));
what = pick.ref;
ref = refForPick(JellyfinMedia( ref = refForPick(JellyfinMedia(
serverId: serverId, serverId: serverId,
baseUrl: account.baseUrl, baseUrl: account.baseUrl,
@@ -119,7 +124,11 @@ Future<HelloMedia> mediaForLink(JellyfinAccounts accounts, String url,
} }
} }
return HelloMedia( return HelloMedia(
name: ref.title.isEmpty ? 'Jellyfin item' : ref.title, size: 0, duration: 0, jellyfin: ref); name: ref.title.isEmpty ? 'Jellyfin item' : ref.title,
size: 0,
duration: 0,
jellyfin: ref,
ref: what ?? jellyfinPickRef(ref.title, ref.subtitle ?? ''));
} }
/// Display name for a link: last path segment, decoded, never the query /// Display name for a link: last path segment, decoded, never the query
@@ -138,27 +147,28 @@ String linkName(String url) {
return uri.host.isNotEmpty ? uri.host : 'link'; return uri.host.isNotEmpty ? uri.host : 'link';
} }
/// The hosted server's relay login: the account for [serverId] (the /// The hosted server's relay logins: one per linked Jellyfin account
/// hosted item's server), else the default account, when its token is not /// whose token is not known to be expired (the default account first),
/// known to be expired. The device fields are exactly the ones this app /// so `/j` relays an item with its own server's login. The device fields
/// sends with that token ([JellyfinAccounts.clientInfo]): Jellyfin rewrites /// are exactly the ones this app sends with those tokens
/// a token's device from the request header, so the relay must not differ. /// ([JellyfinAccounts.clientInfo]): Jellyfin rewrites a token's device
JellyfinCredential? relayCredentialFor(JellyfinAccounts accounts, {String? serverId}) { /// from the request header, so the relay must not differ.
bool usable(JellyfinAccount? a) => List<JellyfinCredential> relayCredentialsFor(JellyfinAccounts accounts) {
a != null && accounts.statusOf(a.serverId) != JellyfinAccountStatus.expired;
var a = serverId == null ? null : accounts.account(serverId);
if (!usable(a)) a = accounts.defaultAccount;
if (!usable(a)) return null;
final info = accounts.clientInfo; final info = accounts.clientInfo;
return JellyfinCredential( final first = accounts.defaultAccount;
baseUrl: a!.baseUrl, return [
serverId: a.serverId, for (final a in [?first, ...accounts.accounts.where((a) => a.serverId != first?.serverId)])
token: a.token, if (accounts.statusOf(a.serverId) != JellyfinAccountStatus.expired)
client: info.client, JellyfinCredential(
device: info.device, baseUrl: a.baseUrl,
deviceId: info.deviceId, serverId: a.serverId,
version: info.version, token: a.token,
); client: info.client,
device: info.device,
deviceId: info.deviceId,
version: info.version,
),
];
} }
/// `/j/...` URL of the current Jellyfin media on the host this client /// `/j/...` URL of the current Jellyfin media on the host this client
@@ -201,7 +211,8 @@ final class OwnPlay extends JellyfinPlan {
bool get jfAccess => true; bool get jfAccess => true;
} }
/// Through the sync server's relay (`/j/...`). /// Through the server: its own Jellyfin login (`/j/...`) or the member
/// who put it up (`/r/...`).
final class RelayPlay extends JellyfinPlan { final class RelayPlay extends JellyfinPlan {
final String url; final String url;
const RelayPlay(this.url); const RelayPlay(this.url);
@@ -210,7 +221,7 @@ final class RelayPlay extends JellyfinPlan {
bool get jfAccess => false; bool get jfAccess => false;
} }
/// Neither: no (usable) account here and the server cannot relay. /// Neither: no (usable) account here and nothing relays it.
final class BlockedPlay extends JellyfinPlan { final class BlockedPlay extends JellyfinPlan {
final String serverId; final String serverId;
/// Known only when this install has (had) an account there. /// Known only when this install has (had) an account there.
@@ -224,20 +235,22 @@ final class BlockedPlay extends JellyfinPlan {
String get message { String get message {
final where = serverName == null || serverName!.isEmpty ? 'that Jellyfin server' : serverName!; final where = serverName == null || serverName!.isEmpty ? 'that Jellyfin server' : serverName!;
final base = 'This needs a Jellyfin account on $where (or the host can relay)'; final base = 'No Jellyfin account here for $where, and nobody is passing it on';
return problem == null ? base : '$base — $problem'; return problem == null ? base : '$base ($problem)';
} }
} }
/// Picks how to play [ref]: this install's account first (PlaybackInfo /// Picks how to play [ref]: this install's account first (PlaybackInfo
/// with its own user; a 401 marks the account expired), else the relay /// with its own user; a 401 marks the account expired), else the server's
/// when the server offers it ([relay] = `state.relay`, [relayUrl] this /// own Jellyfin login ([relay] = `state.relay`, [relayUrl] this member's
/// member's `/j/...` URL), else [BlockedPlay]. /// `/j/...` URL), else the member who put it up ([memberRelayUrl], the
/// `/r/...` URL when `media.relay` is set), else [BlockedPlay].
Future<JellyfinPlan> resolveJellyfinRef( Future<JellyfinPlan> resolveJellyfinRef(
JellyfinAccounts accounts, JellyfinAccounts accounts,
JellyfinRef ref, { JellyfinRef ref, {
required bool relay, required bool relay,
required String? relayUrl, required String? relayUrl,
String? memberRelayUrl,
Duration timeout = const Duration(seconds: 15), Duration timeout = const Duration(seconds: 15),
}) async { }) async {
await accounts.ensureLoaded(); await accounts.ensureLoaded();
@@ -282,6 +295,7 @@ Future<JellyfinPlan> resolveJellyfinRef(
} }
} }
if (relay && relayUrl != null && relayUrl.isNotEmpty) return RelayPlay(relayUrl); if (relay && relayUrl != null && relayUrl.isNotEmpty) return RelayPlay(relayUrl);
if (memberRelayUrl != null && memberRelayUrl.isNotEmpty) return RelayPlay(memberRelayUrl);
return BlockedPlay(serverId: ref.serverId, serverName: account?.displayName, problem: problem); return BlockedPlay(serverId: ref.serverId, serverName: account?.displayName, problem: problem);
} }
@@ -314,10 +328,11 @@ Future<({EpisodeLink? previous, EpisodeLink? next})> findAdjacentEpisodes(OwnPla
} }
} }
/// When the floating "Next episode" pill appears: at [endingStart] (the /// Where the "Next episode" pill's window starts: at [endingStart] (the
/// ED: Jellyfin Outro segment or ED-titled chapter, see `endingRange`) /// ED: Jellyfin Outro segment or ED-titled chapter, see `endingRange`)
/// when one is known inside [runtime], else the last two minutes of /// when one is known inside [runtime], else the last two minutes of
/// [runtime] (all of it for shorter media). /// [runtime] (all of it for shorter media). The pill shows only while the
/// controls are up (`PillGate`), so a guessed window costs nothing.
Duration nextPromptStart(JellyfinItem? item, Duration runtime, {Duration? endingStart}) { Duration nextPromptStart(JellyfinItem? item, Duration runtime, {Duration? endingStart}) {
if (endingStart != null && endingStart > Duration.zero && endingStart < runtime) { if (endingStart != null && endingStart > Duration.zero && endingStart < runtime) {
return endingStart; return endingStart;
@@ -325,26 +340,3 @@ Duration nextPromptStart(JellyfinItem? item, Duration runtime, {Duration? ending
const lead = Duration(minutes: 2); const lead = Duration(minutes: 2);
return runtime > lead ? runtime - lead : Duration.zero; return runtime > lead ? runtime - lead : Duration.zero;
} }
/// Who performs the auto-advance swap, so several counting clients do not
/// swap twice: the lowest member id among members that play the item with
/// their own account (`jf[serverId] == true`, not stale). Only what every
/// client sees in `state` decides (who put the item up is not in it: a
/// late joiner never saw that swap), so all clients agree.
String? autoAdvanceSwapper(List<MemberView> members, String serverId) {
final eligible = [
for (final m in members)
if (!m.stale && m.jf[serverId] == true) m
];
if (eligible.isEmpty) return null;
eligible.sort((a, b) => compareMemberIds(a.id, b.id));
return eligible.first.id;
}
/// "m2" before "m10": numeric suffix order, then plain text.
int compareMemberIds(String a, String b) {
final na = int.tryParse(a.replaceFirst(RegExp(r'^\D+'), ''));
final nb = int.tryParse(b.replaceFirst(RegExp(r'^\D+'), ''));
if (na != null && nb != null && na != nb) return na.compareTo(nb);
return a.compareTo(b);
}
@@ -0,0 +1,81 @@
import 'package:flutter/foundation.dart';
import 'package:hive_ce/hive.dart';
// Where the library's matches live (device-owned, one Hive box): AniList
// entries seen, automatic matches, the user's pins and episode offsets.
// Plain JSON-ish values under string keys; reads are synchronous once
// loaded, writes go through.
const libraryBoxName = 'library';
abstract interface class LibraryStore {
/// Loads the stored values (once).
Future<void> ensureLoaded();
Object? get(String key);
void put(String key, Object value);
void delete(String key);
/// Every key starting with [prefix].
Iterable<String> keys(String prefix);
}
/// For tests and as the fallback when Hive is not available.
class MemoryLibraryStore implements LibraryStore {
final Map<String, Object> values;
MemoryLibraryStore([Map<String, Object>? values]) : values = values ?? {};
@override
Future<void> ensureLoaded() async {}
@override
Object? get(String key) => values[key];
@override
void put(String key, Object value) => values[key] = value;
@override
void delete(String key) => values.remove(key);
@override
Iterable<String> keys(String prefix) => values.keys.where((k) => k.startsWith(prefix)).toList();
}
/// The `library` box; until it opens (or when it cannot) it acts as an
/// empty memory store, so matching still works for the run.
class HiveLibraryStore implements LibraryStore {
Box? _box;
final _memory = MemoryLibraryStore();
Future<void>? _load;
@override
Future<void> ensureLoaded() => _load ??= () async {
try {
final box = _box = await Hive.openBox(libraryBoxName);
// Writes made before the box opened.
for (final e in _memory.values.entries) {
await box.put(e.key, e.value);
}
_memory.values.clear();
} catch (e, st) {
debugPrint('open library store failed: $e\n$st');
}
}();
@override
Object? get(String key) => _box?.get(key) ?? _memory.get(key);
@override
void put(String key, Object value) {
final box = _box;
if (box == null) return _memory.put(key, value);
box.put(key, value).catchError((Object e) => debugPrint('library store put $key: $e'));
}
@override
void delete(String key) {
_memory.delete(key);
_box?.delete(key).catchError((Object e) => debugPrint('library store delete $key: $e'));
}
@override
Iterable<String> keys(String prefix) => [
...?_box?.keys.whereType<String>().where((k) => k.startsWith(prefix)),
..._memory.keys(prefix),
];
}
@@ -0,0 +1,34 @@
import 'dart:io';
import 'package:sync_relay_core/core.dart';
import '../files/scanner.dart' show FolderScan;
import 'show_matcher.dart';
// The next episode of a local show (the library's `nextAfter`, numbers
// skipped on the way included): what continues a file this app put up
// (see ARCHITECTURE.md "What's next").
/// [path]'s next episode: from the library the Files page saw this run
/// ([ShowMatcher.showAt]), else from the file's own folder read now (one
/// [scan], grouped by `groupLocal` with that folder's name as a hint). Null when there is none (the last
/// episode, not an episode, an unreadable folder).
class LocalNextFinder {
final ShowMatcher matcher;
final Future<FolderScan> Function(String dir) scan;
LocalNextFinder({required this.matcher, required this.scan});
Future<LocalNext?> nextAfter(String path) async {
var show = matcher.showAt(path);
if (show == null) {
final dir = File(path).parent.path;
final found = await scan(dir);
// The folder itself is a hint (a bare `01.mkv` in `Bleach`).
final lib = groupLocal(found.videos, roots: [Directory(dir).parent.path]);
show = lib.shows.where((s) => s.files.any((e) => e.path == path)).firstOrNull;
}
final at = show?.files.where((e) => e.path == path).firstOrNull;
if (show == null || at == null || at.kind != LocalKind.episode) return null;
return show.nextAfter(at.key);
}
}
@@ -0,0 +1,80 @@
import 'package:sync_relay_core/core.dart';
// `media.ref` (PROTOCOL.md "Media identity") for each source: what a media
// is, independent of where its bytes come from. Local files get theirs
// from the library (`ShowMatcher.refFor`, L1 + L2); these are the other
// two: Jellyfin items by their provider ids, links best effort from their
// name.
/// A Jellyfin episode, movie or video: kind, series (or movie) title,
/// season and episode as Jellyfin numbers them, and the AniList / TVDB ids
/// its metadata plugins set ([series]' when given: an episode's own ids
/// are the episode's, not the show's). Null for anything that is not
/// played (series, seasons, folders).
MediaRef? jellyfinMediaRef(JellyfinItem item, {JellyfinItem? series}) {
final kind = switch (item.kind) {
JellyfinItemKind.episode => 'episode',
JellyfinItemKind.movie || JellyfinItemKind.video => 'movie',
_ => null,
};
if (kind == null) return null;
final episode = kind == 'episode';
// An episode's Tvdb id names the episode; the show's comes from the
// series item. AniList ids are only ever set on shows and movies.
final owner = episode ? series : item;
final special = episode && item.parentIndexNumber == 0;
return MediaRef(
kind: special ? 'extra' : kind,
title: episode ? (item.seriesName ?? series?.name ?? item.name) : item.name,
anilistId: owner?.providerId('AniList'),
tvdbId: owner?.providerId('Tvdb'),
season: episode && !special ? item.parentIndexNumber : null,
ep: episode && !special ? item.indexNumber : null,
);
}
final _sxe = RegExp(r'S(\d{1,3})\s?E(\d{1,4})$', caseSensitive: false);
/// A picked Jellyfin item from its card's lines alone ([title], and
/// [subtitle] "Series · S1E6" for episodes, the year for movies): kind,
/// title and numbers, no ids.
MediaRef jellyfinPickRef(String title, String subtitle) {
final sub = subtitle.trim();
final cut = sub.lastIndexOf(' · ');
final label = cut < 0 ? sub : sub.substring(cut + 3).trim();
final m = _sxe.firstMatch(label);
if (m == null) return MediaRef(kind: 'movie', title: title);
final season = int.parse(m.group(1)!);
return MediaRef(
kind: season == 0 ? 'extra' : 'episode',
title: cut < 0 ? title : sub.substring(0, cut).trim(),
season: season == 0 ? null : season,
ep: season == 0 ? null : int.parse(m.group(2)!),
);
}
/// A link, best effort: its name read like a release name (`Show - 05`
/// is an episode, `OP1` an extra); anything else a movie of that title.
MediaRef linkMediaRef(String name) {
final p = parseReleaseName(name);
final title = p.series.trim().isEmpty ? name.trim() : p.series.trim();
return switch (p.kind) {
ReleaseKind.episode => MediaRef(
kind: 'episode',
title: title,
season: p.season,
ep: p.season == null ? null : p.episode,
absolute: p.season == null ? p.episode : null),
ReleaseKind.extra => MediaRef(kind: 'extra', title: title, season: p.season),
ReleaseKind.movie || ReleaseKind.other => MediaRef(kind: 'movie', title: title),
};
}
/// The episode line a ref gives (`S3 E13`, `E108`), or null (movies;
/// extras keep the name's own marker).
String? refEpisodeLine(MediaRef ref) {
if (ref.kind != 'episode') return null;
if (ref.season != null && ref.ep != null) return 'S${ref.season} E${ref.ep}';
final n = ref.absolute ?? ref.ep ?? ref.anilistEp;
return n == null ? null : 'E$n';
}
@@ -0,0 +1,666 @@
import 'dart:async';
import 'package:flutter/foundation.dart' show debugPrint, visibleForTesting;
import 'package:signals/signals.dart';
import 'package:sync_relay_core/core.dart';
import '../schedule/anilist.dart';
import '../schedule/animeflow.dart';
import 'library_store.dart';
// L2 of the library (ARCHITECTURE.md "Media identity"): local shows (L1)
// matched to AniList, device-owned and cached. A season of a show is
// searched once (title and synonyms, the season's number or name as
// hints) and the answer kept by show key, season and season name; its
// episodes map onto AniList entries along the run (a season AniList
// splits into cours continues on the SEQUEL entry), or onto AnimeFlow's
// `anilist_map` when signed in. The user's pins (a show everywhere, or one
// folder) and episode offsets override everything and survive rescans.
// Requests are lazy (only for shows on screen), one at a time and spaced
// out for AniList's rate limit; everything answers offline from the cache.
/// Where AnimeFlow places an AniList entry: the TVDB season runs of its
/// series ([AniListRun]) and the series' TVDB id.
class FlowShow {
final List<AniListRun> runs;
final int? tvdbId;
const FlowShow(this.runs, {this.tvdbId});
}
/// AnimeFlow's knowledge of a show, when signed in.
abstract interface class FlowLookup {
/// The series [anilistId] belongs to; null when AnimeFlow has none (or
/// cannot be asked).
Future<FlowShow?> forAniList(int anilistId);
}
/// [FlowLookup] over the AnimeFlow app API (`/library`, `/series`), read
/// once per app run.
class AnimeFlowLookup implements FlowLookup {
final AnimeFlowClient client;
AnimeFlowLookup(this.client);
Future<(List<AnimeFlowLibraryItem>, List<AnimeFlowSeries>)>? _load;
Future<(List<AnimeFlowLibraryItem>, List<AnimeFlowSeries>)> _fetch() async =>
(await client.library(), await client.series());
@override
Future<FlowShow?> forAniList(int anilistId) async {
final load = _load ??= _fetch();
final (List<AnimeFlowLibraryItem>, List<AnimeFlowSeries>) data;
try {
data = await load;
} catch (e) {
_load = null;
return null;
}
final (library, series) = data;
for (final item in library) {
final has = item.anilistMap.any((r) => r.anilistId == anilistId) || item.anilistIds.values.contains(anilistId);
if (!has) continue;
final s = series
.where((s) =>
(item.seriesId != null && s.seriesId == item.seriesId) ||
(s.jellyfinSeriesId != null && s.jellyfinSeriesId == item.jellyfinSeriesId))
.firstOrNull;
return FlowShow(item.anilistMap, tvdbId: s?.tvdbId);
}
return null;
}
}
/// What a pin covers.
enum PinScope { show, folder }
/// The AniList entry a season (or a movie, or an extra's show) is.
class SeasonMatch {
final AniListEntry entry;
/// Set by the user ("Wrong show?"), not by the search.
final PinScope? pinned;
const SeasonMatch(this.entry, {this.pinned});
}
/// An episode on AniList.
class EpisodeTarget {
final int anilistId;
final int? anilistEp;
const EpisodeTarget(this.anilistId, this.anilistEp);
@override
bool operator ==(Object other) => other is EpisodeTarget && other.anilistId == anilistId && other.anilistEp == anilistEp;
@override
int get hashCode => Object.hash(anilistId, anilistEp);
@override
String toString() => 'EpisodeTarget($anilistId, $anilistEp)';
}
/// The library's L2: see the file comment. Made by the composition root.
class ShowMatcher {
ShowMatcher({
required this.store,
required this.aniList,
this.flow,
this.spacing = const Duration(milliseconds: 800),
this.retryNoneAfter = const Duration(days: 3),
DateTime Function()? now,
Future<void> Function(Duration)? delay,
}) : _now = now ?? DateTime.now,
_delay = delay ?? Future<void>.delayed;
final LibraryStore store;
final AniListLookup aniList;
final FlowLookup? flow;
/// Between two AniList requests.
final Duration spacing;
/// A search that found nothing is asked again after this.
final Duration retryNoneAfter;
final DateTime Function() _now;
final Future<void> Function(Duration) _delay;
/// Bumps whenever something new is known (a match, an entry, a pin).
final revision = signal(0);
/// Shows being matched right now, by show key.
final busy = signal<Set<String>>(const {});
// Files seen in scans this run, for refs of files put up.
final _byPath = <String, LocalEpisode>{};
final _showByPath = <String, LocalShow>{};
final _entries = <int, AniListEntry>{};
final _flows = <int, FlowShow?>{};
final _jobs = <String, Future<void> Function()>{};
final _failed = <String, DateTime>{};
bool _pumping = false;
DateTime? _lastRequest;
DateTime? _pausedUntil;
/// Loads the cache (once); reads before it finishes see nothing.
Future<void> ensureLoaded() => store.ensureLoaded().then((_) => _bump());
// ------------------------------------------------------------- keys
static String matchKey(String showKey, int? season, String? seasonName) =>
'$showKey|${season ?? ''}|${normalizeSeries(seasonName ?? '')}';
static String _movieKey(LocalEpisode e) => '${e.showKey}|movie|${e.movieNumber ?? ''}';
String _keyOf(LocalEpisode e) =>
e.kind == LocalKind.movie ? _movieKey(e) : matchKey(e.showKey, e.season, e.seasonName);
// ------------------------------------------------------------- reads
/// Remembers [library]'s files, so [refForPath] and [showAt] know
/// their show.
void remember(LocalLibrary library) {
for (final e in library.files) {
_byPath[e.path] = e;
}
for (final s in library.shows) {
for (final e in s.files) {
_showByPath[e.path] = s;
}
}
}
/// The show the file at [path] belongs to, as seen in a scan this run.
LocalShow? showAt(String path) => _showByPath[path];
AniListEntry? entry(int id) {
final known = _entries[id];
if (known != null) return known;
final raw = store.get('entry:$id');
final e = raw is Map ? AniListEntry.fromJson(raw['e']) : null;
if (e != null) _entries[id] = e;
return e;
}
/// The user's pin for [showKey] (everywhere), if any.
int? showPin(String showKey) => _int(store.get('pin:show:$showKey'));
/// The pin of [folder] or the nearest folder above it, if any.
({String folder, int id})? folderPin(String folder) {
var f = folder;
while (f.isNotEmpty) {
final id = _int(store.get('pin:folder:$f'));
if (id != null) return (folder: f, id: id);
final cut = f.lastIndexOf(RegExp(r'[/\\]'));
if (cut <= 0) break;
f = f.substring(0, cut);
}
return null;
}
/// "These files are episodes N..": added to every file's number of
/// [showKey]'s season [season].
int offset(String showKey, int? season) => _int(store.get('offset:$showKey|${season ?? ''}')) ?? 0;
/// The entry [e]'s season (or movie, or the show of an extra) is: a
/// folder pin, else the show's pin, else the search's answer.
SeasonMatch? seasonMatch(LocalEpisode e) {
if (e.kind == LocalKind.other) return null;
final fp = folderPin(e.folder);
if (fp != null) {
final en = entry(fp.id);
return en == null ? null : SeasonMatch(en, pinned: PinScope.folder);
}
final sp = showPin(e.showKey);
if (sp != null) {
final en = entry(sp);
return en == null ? null : SeasonMatch(en, pinned: PinScope.show);
}
final auto = store.get('auto:${_keyOf(e)}');
final id = auto is Map ? _int(auto['id']) : null;
if (id == null) {
// An extra of no matched season: the show's first one.
if (e.kind == LocalKind.extra && e.season != null) {
return seasonMatch(LocalEpisode(
file: e.file, parsed: e.parsed, kind: LocalKind.extra, showKey: e.showKey, title: e.title, extra: e.extra));
}
return null;
}
final en = entry(id);
return en == null ? null : SeasonMatch(en);
}
/// What the show is as a whole: its first season's match (else a
/// movie's or an extra's).
SeasonMatch? showMatch(LocalShow show) {
for (final e in [
for (final s in show.seasons) s.episodes.first,
...show.movies,
...show.extras,
]) {
final m = seasonMatch(e);
if (m != null) return m;
}
return null;
}
/// The proper name of [show]: its match's title, else as found.
String titleOf(LocalShow show) => showMatch(show)?.entry.title ?? show.title;
/// The AniList entry and episode [e] is; for extras and movies only the
/// entry. Null before a match.
EpisodeTarget? target(LocalEpisode e) {
final m = seasonMatch(e);
if (m == null) return null;
if (e.kind != LocalKind.episode) return EpisodeTarget(m.entry.id, null);
final n = e.episode! + offset(e.showKey, e.season);
if (n < 1) return EpisodeTarget(m.entry.id, null);
final fs = _flowOf(m.entry.id);
if (fs != null && e.season != null) {
final runs = fs.runs.where((r) => r.season == e.season && r.firstEpisode <= n).toList()
..sort((a, b) => b.firstEpisode.compareTo(a.firstEpisode));
if (runs.isNotEmpty) return EpisodeTarget(runs.first.anilistId, n - runs.first.offset);
}
// Along the run: past an entry's episodes, its sequel's.
var cur = m.entry;
var k = n;
for (var hop = 0; hop < 30; hop++) {
final count = cur.episodes;
final next = cur.sequel;
if (count == null || k <= count || next == null) break;
k -= count;
final nx = entry(next);
if (nx == null) {
_wantEntry(next);
return EpisodeTarget(next, k);
}
cur = nx;
}
return EpisodeTarget(cur.id, k);
}
/// AniList's title of [e]'s episode, when it knows one.
String? episodeTitle(LocalEpisode e) {
final t = target(e);
final ep = t?.anilistEp;
if (t == null || ep == null) return null;
return entry(t.anilistId)?.episodeTitles[ep];
}
/// What [e] is on the wire (`media.ref`): the match when there is one,
/// else what L1 read (kind, title, numbers).
MediaRef refFor(LocalEpisode e) {
final m = seasonMatch(e);
final t = target(e);
final n = e.episode == null ? null : e.episode! + offset(e.showKey, e.season);
final tvdb = m == null ? null : _flowOf(m.entry.id)?.tvdbId;
return MediaRef(
kind: switch (e.kind) {
LocalKind.episode => 'episode',
LocalKind.extra => 'extra',
LocalKind.movie || LocalKind.other => 'movie',
},
title: m?.entry.title ?? e.title,
anilistId: t?.anilistId ?? m?.entry.id,
anilistEp: t?.anilistEp,
tvdbId: tvdb,
season: e.kind == LocalKind.episode ? e.season : null,
ep: e.kind == LocalKind.episode && e.season != null ? n : null,
absolute: e.kind == LocalKind.episode && e.season == null ? n : null,
);
}
/// [refFor] the file at [path]: as seen in a scan this run, else read
/// from its name and folder alone.
MediaRef refForPath(String path) =>
refFor(_byPath[path] ?? readLocalFile(LocalFile(path, 0, DateTime.fromMillisecondsSinceEpoch(0))));
/// The library's reading of [path] (null: not seen in a scan this run).
LocalEpisode? episodeAt(String path) => _byPath[path];
// ------------------------------------------------------------- matching
/// Matches [show]'s seasons and movies that have no answer yet, in the
/// background (see the file comment).
void want(LocalShow show) {
final seen = <String>{};
for (final e in [
for (final s in show.seasons) s.episodes.first,
...show.movies,
if (show.seasons.isEmpty) ...show.extras.take(1),
]) {
final key = _keyOf(e);
if (!seen.add(key)) continue;
final pinned = folderPin(e.folder)?.id ?? showPin(e.showKey);
if (pinned != null) {
if (entry(pinned) == null) _wantEntry(pinned);
continue;
}
_wantMatch(key, show, e);
}
final m = showMatch(show);
if (m != null) _wantFlow(m.entry.id);
}
/// [want] for each standalone movie of [library] and each of [shows].
void wantMovie(LocalEpisode movie) {
if (movie.kind != LocalKind.movie) return;
_wantMatch(_movieKey(movie), null, movie);
}
void _wantMatch(String key, LocalShow? show, LocalEpisode e) {
final auto = store.get('auto:$key');
if (auto is Map) {
final id = _int(auto['id']);
if (id != null) {
if (entry(id) == null) _wantEntry(id);
return;
}
final at = _int(auto['at']) ?? 0;
if (_now().difference(DateTime.fromMillisecondsSinceEpoch(at)) < retryNoneAfter) return;
}
if (_recentlyFailed(key)) return;
_enqueue('match:$key', () => _match(key, show?.title ?? e.title, e));
_setBusy(e.showKey, true);
}
void _wantEntry(int id) {
if (_recentlyFailed('entry:$id')) return;
_enqueue('entry:$id', () async {
final en = await _ask(() => aniList.anime(id));
if (en != null) _keepEntry(en);
});
}
void _wantFlow(int id) {
final f = flow;
if (f == null || _flows.containsKey(id)) return;
final stored = store.get('flow:$id');
if (stored is Map) {
_flows[id] = _flowFromJson(stored);
final at = _int(stored['at']) ?? 0;
if (_now().difference(DateTime.fromMillisecondsSinceEpoch(at)) < const Duration(days: 1)) return;
} else {
_flows[id] = null;
}
unawaited(f.forAniList(id).then((fs) {
if (fs == null) return;
_flows[id] = fs;
store.put('flow:$id', {
'at': _now().millisecondsSinceEpoch,
'tvdb': fs.tvdbId,
'runs': [
for (final r in fs.runs) [r.season, r.anilistId, r.firstEpisode, r.offset]
],
});
_bump();
}).catchError((Object e) => debugPrint('library: animeflow lookup $id: $e')));
}
FlowShow? _flowOf(int id) {
if (!_flows.containsKey(id)) {
final stored = store.get('flow:$id');
if (stored is Map) _flows[id] = _flowFromJson(stored);
}
return _flows[id];
}
static FlowShow? _flowFromJson(Map j) {
final runs = <AniListRun>[];
final raw = j['runs'];
if (raw is List) {
for (final r in raw) {
if (r is List && r.length == 4 && r.every((v) => v is int)) {
runs.add(AniListRun(
season: r[0] as int, anilistId: r[1] as int, firstEpisode: r[2] as int, offset: r[3] as int));
}
}
}
return runs.isEmpty ? null : FlowShow(runs, tvdbId: _int(j['tvdb']));
}
/// One search for the season [e] stands for; keeps the best entry (or
/// "none") under [key].
Future<void> _match(String key, String title, LocalEpisode e) async {
try {
final plain = title.replaceAll(RegExp(r'[._]+'), ' ').replaceAll(RegExp(r'\s+'), ' ').trim();
final movie = e.kind == LocalKind.movie;
final season = movie ? null : e.season;
final name = movie ? null : e.seasonName;
final query = [
plain,
if (name != null) name else if (season != null && season > 1) 'season $season',
].join(' ');
var hits = await _ask(() => aniList.searchAnime(query));
var best = pickMatch(hits, title: plain, season: season, seasonName: name, movie: movie);
if (best == null && query != plain) {
hits = await _ask(() => aniList.searchAnime(plain));
best = pickMatch(hits, title: plain, season: season, seasonName: name, movie: movie);
// Found the show but not the season: walk its sequels.
if (best != null && season != null && season > 1 && name == null && seasonMarker(best) != season) {
var cur = best;
for (var hop = 1; hop < season; hop++) {
final next = cur.sequel;
if (next == null) break;
final nx = entry(next) ?? await _ask(() => aniList.anime(next));
if (nx == null) break;
_keepEntry(nx);
cur = nx;
}
best = cur;
}
}
for (final h in hits) {
if (h.id == best?.id) _keepEntry(h);
}
store.put('auto:$key', {'id': best?.id, 'at': _now().millisecondsSinceEpoch});
if (best != null) _wantFlow(best.id);
_bump();
} finally {
_setBusy(e.showKey, false);
}
}
/// "Wrong show?": AniList's answers for [query], best first.
Future<List<AniListEntry>> search(String query) async {
final hits = await _ask(() => aniList.searchAnime(query));
return hits;
}
// ------------------------------------------------------------- corrections
/// Pins [showKey] to [e] everywhere (every folder, every rescan).
void pinShow(String showKey, AniListEntry e) {
_keepEntry(e);
store.put('pin:show:$showKey', e.id);
_wantFlow(e.id);
_bump();
}
/// Pins every video in [folder] (and below) to [e].
void pinFolder(String folder, AniListEntry e) {
_keepEntry(e);
store.put('pin:folder:$folder', e.id);
_wantFlow(e.id);
_bump();
}
void unpinShow(String showKey) {
store.delete('pin:show:$showKey');
_bump();
}
void unpinFolder(String folder) {
store.delete('pin:folder:$folder');
_bump();
}
/// "These files are episodes N..": [offset] is added to the file
/// numbers of [showKey]'s season [season] (0 removes it).
void setOffset(String showKey, int? season, int offset) {
final key = 'offset:$showKey|${season ?? ''}';
offset == 0 ? store.delete(key) : store.put(key, offset);
_bump();
}
/// Forgets the automatic match of [show] so it is searched again.
void rematch(LocalShow show) {
for (final k in store.keys('auto:${show.key}|').toList()) {
store.delete(k);
}
want(show);
_bump();
}
// ------------------------------------------------------------- plumbing
void _keepEntry(AniListEntry e) {
_entries[e.id] = e;
store.put('entry:${e.id}', {'at': _now().millisecondsSinceEpoch, 'e': e.toJson()});
_bump();
}
void _bump() => revision.value++;
void _setBusy(String showKey, bool on) {
final now = {...busy.peek()};
if (on ? now.add(showKey) : now.remove(showKey)) busy.value = now;
}
bool _recentlyFailed(String key) {
final at = _failed[key];
return at != null && _now().difference(at) < const Duration(minutes: 5);
}
void _enqueue(String key, Future<void> Function() job) {
if (_jobs.containsKey(key)) return;
_jobs[key] = () => job().catchError((Object e) {
_failed[key] = _now();
debugPrint('library: $key failed: $e');
if (e is AniListException && e.isRateLimited) _pausedUntil = _now().add(const Duration(minutes: 1));
});
if (!_pumping) unawaited(_pump());
}
Future<void> _pump() async {
_pumping = true;
try {
while (_jobs.isNotEmpty) {
final key = _jobs.keys.first;
final job = _jobs[key]!;
await job();
_jobs.remove(key);
}
} finally {
_pumping = false;
}
}
/// One AniList request, spaced from the last one (and after a rate
/// limit's pause).
Future<T> _ask<T>(Future<T> Function() request) async {
final paused = _pausedUntil;
if (paused != null && paused.isAfter(_now())) await _delay(paused.difference(_now()));
final last = _lastRequest;
if (last != null) {
final wait = spacing - _now().difference(last);
if (wait > Duration.zero) await _delay(wait);
}
_lastRequest = _now();
return request();
}
@visibleForTesting
bool get idle => _jobs.isEmpty && !_pumping;
static int? _int(Object? v) => v is int ? v : null;
}
// ------------------------------------------------------------------ scoring
/// The season number an entry's names carry (`Season 2`, `2nd Season`,
/// `Part 2` is not one); null when none does.
int? seasonMarker(AniListEntry e) {
for (final n in e.names) {
final s = _seasonOf(normalizeSeries(n));
if (s != null) return s;
}
return null;
}
final _seasonWords = RegExp(r'\b(?:season (\d{1,2})|(\d{1,2})(?:st|nd|rd|th) season|s(\d{1,2}))\b');
final _roman = {'ii': 2, 'iii': 3, 'iv': 4, 'v': 5, 'vi': 6};
int? _seasonOf(String normalized) {
final m = _seasonWords.firstMatch(normalized);
if (m != null) return int.parse(m.group(1) ?? m.group(2) ?? m.group(3)!);
final last = normalized.split(' ').last;
return _roman[last];
}
/// How alike two normalized titles are, 0..1.
double titleSimilarity(String a, String b) {
if (a.isEmpty || b.isEmpty) return 0;
if (a == b) return 1;
final short = a.length <= b.length ? a : b;
final long = a.length <= b.length ? b : a;
final ratio = short.length / long.length;
if (long.startsWith('$short ')) return 0.8 + 0.2 * ratio;
if (long.contains(' $short ') || long.endsWith(' $short')) return 0.6 + 0.2 * ratio;
// Dice over letter pairs, spaces ignored.
Set<String> pairs(String s) {
final t = s.replaceAll(' ', '');
return {for (var i = 0; i + 1 < t.length; i++) t.substring(i, i + 2)};
}
final pa = pairs(a), pb = pairs(b);
if (pa.isEmpty || pb.isEmpty) return 0;
return 2 * pa.intersection(pb).length / (pa.length + pb.length);
}
/// Scores [e] as the answer for a show called [title] (its season
/// [season] or its season named [seasonName]; a [movie]); [rank]: its
/// place in AniList's own order.
double scoreMatch(AniListEntry e,
{required String title, int? season, String? seasonName, bool movie = false, int rank = 0}) {
final q = normalizeSeries(title);
final full = seasonName == null ? q : normalizeSeries('$title $seasonName');
var best = 0.0;
for (final n in e.names) {
final nn = normalizeSeries(n);
final s = [titleSimilarity(nn, q), titleSimilarity(nn, full)].reduce((a, b) => a > b ? a : b);
if (s > best) best = s;
}
var score = best;
if (seasonName != null) {
final sub = normalizeSeries(seasonName);
if (e.names.any((n) => normalizeSeries(n).contains(sub))) score += 0.25;
} else if (!movie) {
final wanted = season ?? 1;
final marker = seasonMarker(e);
if (marker != null && marker == wanted) {
score += 0.2;
} else if (marker != null) {
score -= 0.25;
} else if (wanted > 1) {
score -= 0.1;
}
}
if (movie) {
score += e.isMovie ? 0.15 : -0.25;
} else {
score += e.isSeries ? 0.05 : (e.isMovie ? -0.3 : -0.1);
}
return score - rank * 0.02;
}
/// The best of [hits] for a show called [title], if any is good enough.
AniListEntry? pickMatch(List<AniListEntry> hits,
{required String title, int? season, String? seasonName, bool movie = false}) {
AniListEntry? best;
var bestScore = 0.55;
for (var i = 0; i < hits.length; i++) {
final s = scoreMatch(hits[i], title: title, season: season, seasonName: seasonName, movie: movie, rank: i);
if (s > bestScore) {
best = hits[i];
bestScore = s;
}
}
return best;
}
+110
View File
@@ -0,0 +1,110 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:sync_relay_core/core.dart';
import '../screens/home/ui.dart' show appName, displayVersion;
import 'ports/app_dirs.dart';
import '../app/app_graph.dart';
// The app's log on disk (<app support>/logs/app-YYYY-MM-DD.log; core's
// logging.dart writes, rotates and redacts it), debugPrint routed into it,
// and the "Copy diagnostics" text of Settings → About.
final _appLog = createLogger('app');
/// The logs folder, once [startAppLogging] ran.
String? appLogDir;
/// Opens the log files in [dirs]' log folder and routes `debugPrint` and
/// uncaught errors into the log.
Future<void> startAppLogging(AppDirs dirs) async {
try {
if (startLogFiles(dirs.logs) != null) appLogDir = dirs.logs;
} catch (e) {
_appLog.warning('no log files: $e');
}
// Everything the app prints for diagnosis lands in the file too (the
// log's console output replaces the print).
debugPrint = (String? message, {int? wrapWidth}) {
if (message == null || message.isEmpty) return;
final bad = RegExp(r'fail|error|crash|exception', caseSensitive: false).hasMatch(message);
bad ? _appLog.warning(message) : _appLog.info(message);
};
PlatformDispatcher.instance.onError = (error, stack) {
_appLog.severe('uncaught: $error', error, stack);
return false;
};
_appLog.info('$appName ${displayVersion()} on ${Platform.operatingSystem} '
'${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 app log (its hosted server's records included), already redacted;
/// with [sessionLog], the server's session log of the same hours after it
/// (every member's shared lines and the server's, merged).
Future<String> diagnosticsText(AppGraph graph, {Duration since = const Duration(hours: 6), bool sessionLog = false}) async {
final b = StringBuffer()
..writeln('$appName ${displayVersion()}')
..writeln('OS: ${Platform.operatingSystem} ${Platform.operatingSystemVersion}')
..writeln('Dart: ${Platform.version.split(' ').first}');
try {
final mpv = await graph.coordinator.playback.mpvVersion().timeout(const Duration(seconds: 2));
b.writeln('mpv: ${mpv ?? 'no player yet'}');
} catch (_) {
b.writeln('mpv: unknown');
}
try {
final y = await graph.ports.links.ytDlp.timeout(const Duration(seconds: 2));
final cookies = y.cookies.read(count: false);
b.writeln('yt-dlp: ${y.version ?? 'not ready'}${y.lastError == null ? '' : ' (${y.lastError})'}, '
'cookies: ${cookies == null ? 'none' : 'set'}');
} catch (_) {
b.writeln('yt-dlp: unknown');
}
final files = logFiles;
b.writeln('Log: ${files?.currentPath ?? 'none (console only)'}');
b.writeln('--- last ${since.inHours} h of the log ---');
b.write(files?.recent(since) ?? '');
if (sessionLog) {
final server = await graph.coordinator.sessionLog(hours: since.inHours);
b
..writeln()
..writeln("--- last ${since.inHours} h of the server's session log ---")
..write(server ?? "(none: the server keeps no session log, or it couldn't be reached)");
}
return redactLog(b.toString());
}
/// Saves [text] next to the logs as `diagnostics-<time>.txt`; its path,
/// or null when it could not be written.
Future<String?> saveDiagnostics(String text) async {
final dir = appLogDir;
if (dir == null) return null;
final t = DateTime.now();
String two(int n) => n.toString().padLeft(2, '0');
final path = '$dir${Platform.pathSeparator}diagnostics-${t.year}${two(t.month)}${two(t.day)}-'
'${two(t.hour)}${two(t.minute)}${two(t.second)}.txt';
try {
// Older ones go after the log files' week.
for (final e in Directory(dir).listSync()) {
if (e is File &&
e.uri.pathSegments.last.startsWith('diagnostics-') &&
t.difference(e.statSync().modified) > const Duration(days: 7)) {
e.deleteSync();
}
}
await File(path).writeAsString(text);
return path;
} catch (e) {
_appLog.warning('cannot save diagnostics: $e');
return null;
}
}
@@ -0,0 +1,93 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/foundation.dart';
import '../files/thumbnails.dart';
import '../app/app_graph.dart';
import 'single_instance.dart';
// The one way out of the app (close button, Alt+F4, Update and restart).
// Order matters: every mpv player goes before the engine does (a live one
// 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;
/// The systems the close stops; `main` hands them over once.
AppGraph? _graph;
/// This app's claim on its profile folder (one app at a time).
InstanceLock? _instance;
/// Makes [graph] the one [shutDownApp] stops (and [instance] the claim it
/// gives up) and returns the close request handler for the window.
Future<void> Function() closeAppWith(AppGraph graph, {InstanceLock? instance}) {
_graph = graph;
_instance = instance;
return shutDownApp;
}
/// Hides the window, releases every player and the sound device, leaves
/// (the `bye` goes out first, so others see "left" at once; then your
/// 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 {
final graph = _graph;
if (_shuttingDown || graph == null) return;
_shuttingDown = true;
// A start from now on waits for this app to end instead of handing its
// link to it.
_instance?.closing();
final shell = graph.ports.shell;
final total = Stopwatch()..start();
await _step('window save', shell.saveWindow(), const Duration(milliseconds: 500));
await _step('hide', shell.hide(), const Duration(milliseconds: 500));
await Future.wait([
_step('players', graph.coordinator.releasePlayers(), const Duration(seconds: 3)),
_step('thumbnails', cancelAllThumbnails(), const Duration(milliseconds: 1500)),
_step('sounds', graph.sfx.dispose(), const Duration(milliseconds: 500)),
]);
// The host owns the disposal order: anything still alive goes now.
await _step('all players', graph.ports.players.disposeAll(), const Duration(milliseconds: 1500));
// Windows: media_kit_video frees a player's video output on a thread of
// 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 your
// server: 3 s in all.
await _step('leave', graph.navigation.leave(bye: !restart), const Duration(milliseconds: 2500));
await _step('server stop', graph.yourServer.stop(), const Duration(milliseconds: 500));
// Update and restart: the new binary claims the folder at once.
if (restart) await _step('instance', _instance?.release(), const Duration(milliseconds: 500));
if (beforeQuit != null) await _step('before quit', beforeQuit(), const Duration(seconds: 3));
debugPrint('close: ready to quit after ${total.elapsedMilliseconds} ms');
await shell.quit();
// 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,59 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
/// The runner's window channel (linux/runner/my_application.cc,
/// windows/runner/flutter_window.cpp).
const _window = MethodChannel('starsync/window');
/// Asks the desktop to point at the app's window (taskbar flash, urgency
/// hint) when it is not focused; nothing when it is. The runner clears it
/// once the window gets focus. Quietly nothing where the runner lacks it.
Future<void> requestWindowAttention() async {
try {
await _window.invokeMethod<void>('attention');
} on MissingPluginException {
// Tests, other platforms.
} catch (e) {
debugPrint('window attention: $e');
}
}
/// The desktop's activation token this process started with (Linux:
/// `XDG_ACTIVATION_TOKEN` or `DESKTOP_STARTUP_ID`, kept by the runner
/// before GTK used it); '' when none or elsewhere.
Future<String> windowLaunchToken() async {
if (!Platform.isLinux) return '';
try {
return await _window.invokeMethod<String>('launchToken') ?? '';
} catch (_) {
return '';
}
}
/// Linux: brings the window up with another start's activation token
/// ([windowLaunchToken] of the start that handed over). False where the
/// runner lacks it.
Future<bool> presentWindow(String token) async {
if (!Platform.isLinux) return false;
try {
await _window.invokeMethod<void>('present', token);
return true;
} catch (_) {
return false;
}
}
/// Ends this process at once with no exit handlers (a second start that
/// handed over; its window never showed). The runner does it; where it
/// cannot, a kill.
Future<Never> exitNow() async {
try {
await _window.invokeMethod<void>('exit');
} catch (_) {}
Process.killPid(pid, ProcessSignal.sigkill);
// The kill lands before anything else runs.
await Future<void>.delayed(const Duration(days: 1));
throw StateError('unreachable');
}
@@ -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,100 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import '../desktop_entry.dart' show appId;
import '../ports/system_notifications.dart';
/// [SystemNotifications] on the desktop, through flutter_local_notifications'
/// platform plugins (used directly, not through its facade, so the platform
/// is picked by the OS and not by Flutter's target platform):
/// - Linux: `org.freedesktop.Notifications` over D-Bus (pure Dart), with the
/// app's icon (the bundled PNG, else the launcher entry's icon by name);
/// a click is the notification's default action.
/// - Windows: toast notifications (WinRT), the app's name and icon, a click
/// activates the app while it runs (an unpackaged app has no history).
/// Silent: the app plays its own sound for the same notice. Anything that
/// fails (no notification daemon, no session bus) is logged and dropped.
class DesktopNotifications implements SystemNotifications {
DesktopNotifications({this.appName = 'Starsync'});
final String appName;
void Function(String payload)? _onClick;
Future<bool>? _ready;
LinuxFlutterLocalNotificationsPlugin? _linux;
FlutterLocalNotificationsWindows? _windows;
// Tag -> id, so a notification with the same tag replaces the last one.
final _ids = <String, int>{};
int _next = 1;
@override
set onClick(void Function(String payload)? handler) => _onClick = handler;
void _clicked(NotificationResponse r) => _onClick?.call(r.payload ?? '');
/// The bundled 256 px icon next to the executable (a release or a
/// `flutter run` build), else the icon the launcher entry installed.
static LinuxNotificationIcon _linuxIcon() {
final exe = File(Platform.resolvedExecutable).parent.path;
final png = File('$exe/data/flutter_assets/assets/icon/icon_256.png');
return png.existsSync() ? FilePathLinuxIcon(png.path) : ThemeLinuxIcon(appId);
}
Future<bool> _init() => _ready ??= () async {
try {
if (Platform.isLinux) {
final p = _linux = LinuxFlutterLocalNotificationsPlugin();
return await p.initialize(
settings: LinuxInitializationSettings(defaultActionName: 'Open', defaultIcon: _linuxIcon()),
onDidReceiveNotificationResponse: _clicked,
) ??
false;
}
if (Platform.isWindows) {
final exe = File(Platform.resolvedExecutable).parent.path;
final icon = File('$exe/data/flutter_assets/assets/icon/icon_256.png');
final p = _windows = FlutterLocalNotificationsWindows();
return await p.initialize(
settings: WindowsInitializationSettings(
appName: appName,
appUserModelId: 'Foxyz.Starsync',
guid: '6c0f4b4e-3e8a-4b8e-9d0c-5a7f2b1e9c31',
iconPath: icon.existsSync() ? icon.path : null,
),
onDidReceiveNotificationResponse: _clicked,
);
}
} catch (e) {
debugPrint('system notifications unavailable: $e');
}
return false;
}();
@override
Future<void> show({required String title, required String body, String tag = '', String payload = ''}) async {
if (!await _init()) return;
final id = tag.isEmpty ? _next++ : _ids[tag] ??= _next++;
try {
if (_linux case final p?) {
await p.show(
id: id,
title: title,
body: body,
payload: payload,
notificationDetails: const LinuxNotificationDetails(suppressSound: true),
);
} else if (_windows case final p?) {
await p.show(
id: id,
title: title,
body: body,
payload: payload,
notificationDetails: WindowsNotificationDetails(audio: WindowsNotificationAudio.silent()),
);
}
} catch (e) {
debugPrint('system notification failed: $e');
}
}
}
@@ -0,0 +1,20 @@
import '../../playback/yt_dlp_app.dart';
import '../ports/ports.dart';
import 'desktop_folders.dart';
import 'desktop_notifications.dart';
import 'desktop_shell.dart';
import 'desktop_urls.dart';
import 'media_kit_host.dart';
/// The desktop's [Ports].
Ports desktopPorts() {
const urls = DesktopUrls();
return Ports(
shell: DesktopShell(),
urls: urls,
folders: const DesktopFolders(urls),
links: AppYtDlp.instance,
notifications: DesktopNotifications(),
players: MediaKitHost(),
);
}
@@ -0,0 +1,190 @@
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> focus({String activation = ''}) async {
try {
if (await windowManager.isMinimized()) await windowManager.restore();
if (!await windowManager.isVisible()) await windowManager.show();
// Linux: present with the token when there is one (else a
// compositor only marks the window as wanting attention).
if (activation.isNotEmpty && await presentWindow(activation)) return;
await windowManager.focus();
} catch (e) {
debugPrint('window focus failed: $e');
}
}
@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
Future<String?> readClipboardText() async {
try {
return await ClipboardX.readText();
} catch (_) {
return null;
}
}
@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;
}
}
}
@@ -0,0 +1,605 @@
import 'dart:async';
import 'dart:ffi' show Int8;
import 'dart:io';
import 'package:ffi/ffi.dart' show StringUtf8Pointer, malloc;
import 'package:flutter/widgets.dart';
import 'package:media_kit/media_kit.dart';
import 'package:media_kit_video/media_kit_video.dart';
import 'package:signals/signals.dart';
import 'package:sync_relay_core/core.dart' show LogThrottle, createLogger;
import '../../state/settings.dart';
import '../ports/player_host.dart';
// The desktop's players: media_kit over libmpv. Everything mpv-specific
// that is not about one media lives here: the cache and HTTP pins, hwdec,
// mpv's log level (media_kit has no API for it: `mpv_request_log_messages`
// on its handle), the render-mode probe and its diagnostics, mpv's log into
// the app log, and the order players are torn down in.
/// Read-ahead target: about two minutes rides out short outages and makes
/// skipping an intro instant.
const readAheadSecs = 150;
/// Forward demuxer cache of a session player. Holds [readAheadSecs] up to
/// ~14 Mbit/s (a 1.4 GB / 24 min file is ~8); higher bitrates get less
/// time. In RAM: `cache-on-disk=no` (media_kit sets it on without a
/// cache-dir, and mpv warned "Failed to create file cache." per open).
const sessionAheadBytes = 256 * 1024 * 1024;
/// Back buffer (media_kit sets it to bufferSize too): enough for a short
/// seek back without holding another 256 MiB.
const sessionBackBytes = 64 * 1024 * 1024;
/// The preload's forward cache: its first seconds and the container's
/// index, at little memory. Raised to [sessionAheadBytes] when promoted.
const preloadAheadBytes = 48 * 1024 * 1024;
const preloadBackBytes = 8 * 1024 * 1024;
/// mpv's `volume-max` (its default is 130): the volume controls go this
/// far, past 100 % amplified.
const mpvMaxVolume = 150;
/// mpv's log level for session players. media_kit drains mpv's log on the
/// UI thread one line at a time, so a chatty level (ffmpeg retrying a
/// refused request logs every attempt) can starve the UI: warnings and
/// errors only, except during the render-mode probe, which needs mpv's
/// verbose GL init lines.
const sessionLogLevel = 'warn';
/// ffmpeg's HTTP options (mpv `stream-lavf-o`, comma form): reconnect after
/// a dropped connection, also on a streamed (growing) relay response, and a
/// server error (5xx) while connecting; a refused request (4xx) fails at
/// once. At most 3 tries per read, 4 s apart at most.
const streamLavfOptions = 'reconnect_streamed=1,reconnect_on_http_error=5xx,'
'reconnect_delay_max=4,reconnect_max_retries=3';
/// mpv echoes numbers in its own format (`150` reads back `150.000000`).
bool pinMatches(String wanted, String got) {
if (wanted == got) return true;
final a = double.tryParse(wanted), b = double.tryParse(got);
return a != null && b != null && a == b;
}
/// The `VideoController` behind [video], for the `Video` widget (the only
/// media_kit use outside this file).
VideoController videoControllerOf(HostedVideo video) => (video as _MkVideo).controller;
final _mpvLog = createLogger('mpv');
class MediaKitHost implements PlayerHost {
/// The hwdec setting ([AppSettings.hwdec] by default).
final String? Function() _hwdecSetting;
MediaKitHost({String? Function()? hwdecSetting})
: _hwdecSetting = hwdecSetting ?? (() => AppSettings.instance.hwdec.peek());
final _live = <_MkPlayer>{};
final _videos = <_MkVideo>{};
// The size outputs render at ([setOutputSize]); null: the video's own.
(int, int)? _outputSize;
final _renderMode = signal(RenderMode.unknown);
final _http = StreamController<String>.broadcast();
final _throttle = LogThrottle(burst: 5);
Future<void> _retiring = Future.value();
var _closing = false;
@override
final List<String> pinMismatches = [];
@override
void init() => MediaKit.ensureInitialized();
@override
ReadonlySignal<RenderMode> get renderMode => _renderMode;
@override
void setOutputSize(int? width, int? height) {
final size = width != null && height != null && width > 0 && height > 0 ? (width, height) : null;
if (size == _outputSize) return;
_outputSize = size;
for (final v in _videos.toList()) {
unawaited(v.applySize());
}
}
@override
Stream<String> get httpErrors => _http.stream;
@override
int get live => _live.length;
@override
Future<void> get retiring => _retiring;
@override
String? get hwdec {
final env = Platform.environment['SYNC_RELAY_HWDEC'];
if (env != null && env.isNotEmpty) return env;
return _hwdecSetting();
}
@override
Future<HostedPlayer> create(PlayerRole role, {bool muted = false}) async {
if (_closing) throw StateError('closing: no new players');
final mk = _SyncPlayer(
configuration: switch (role) {
PlayerRole.session => PlayerConfiguration(
bufferSize: sessionAheadBytes,
libass: true,
vo: Platform.environment['SYNC_RELAY_VO'],
logLevel: MPVLogLevel.warn),
PlayerRole.preload => PlayerConfiguration(
bufferSize: preloadAheadBytes,
libass: true,
vo: Platform.environment['SYNC_RELAY_VO'],
logLevel: MPVLogLevel.error),
PlayerRole.thumbnail => const PlayerConfiguration(
vo: 'null', muted: true, osc: false, libass: false, title: 'thumbnail', bufferSize: 4 << 20),
});
final p = _MkPlayer(this, mk, role);
_live.add(p);
if (role != PlayerRole.thumbnail) {
p._logSub = p.logs.listen((l) => _route(p, l));
final mismatches = <String>[];
await _pinCache(p, role, mismatches);
if (await p.pin('volume-max', '$mpvMaxVolume') case final m?) mismatches.add(m);
if (muted || role == PlayerRole.preload) {
if (await p.pin('mute', 'yes') case final m?) mismatches.add(m);
}
if (role == PlayerRole.session) {
pinMismatches
..clear()
..addAll(mismatches);
}
}
return p;
}
@override
Future<void> promote(HostedPlayer p) async {
final mk = p as _MkPlayer;
mk._role = PlayerRole.session;
mk.setLogLevel(sessionLogLevel);
final mismatches = <String>[];
if (await p.pin('mute', 'no') case final m?) mismatches.add(m);
await _pinCache(mk, PlayerRole.session, mismatches);
pinMismatches
..clear()
..addAll(mismatches);
}
Future<void> _pinCache(_MkPlayer p, PlayerRole role, List<String> out) async {
final session = role == PlayerRole.session;
for (final (k, v) in [
('demuxer-readahead-secs', '$readAheadSecs'),
('cache-secs', '$readAheadSecs'),
('demuxer-max-bytes', '${session ? sessionAheadBytes : preloadAheadBytes}'),
('demuxer-max-back-bytes', '${session ? sessionBackBytes : preloadBackBytes}'),
('cache-on-disk', 'no'),
if (hwdec case final h?) ('hwdec', h),
('stream-lavf-o', streamLavfOptions),
// Pages are resolved by the app's own yt-dlp (the media pipeline):
// mpv's Lua hook never runs one.
('ytdl', 'no'),
]) {
if (await p.pin(k, v) case final m?) out.add(m);
}
}
@override
HostedVideo video(HostedPlayer player, {bool probe = false}) {
final p = player as _MkPlayer;
if (probe) {
_logRenderEnv();
p.setLogLevel('v'); // back to the session level at the probe's end
}
// media_kit's video output sets hwdec itself on attach ('auto' unless
// configured), which used to override SYNC_RELAY_HWDEC.
final controller = VideoController(p.mk, configuration: VideoControllerConfiguration(hwdec: hwdec));
final v = _MkVideo(p, controller);
_videos.add(v);
p._video = v;
if (_outputSize != null) unawaited(v.applySize());
// mpv gets `vo=libmpv` before the output's render context exists; a
// media opened in between fails its video output ("No render context
// set") and plays black. Opens wait for the output (seen on X11, where
// the context comes up later, when joining with media already up).
p._videoReady = controller.platform.future
.then((_) {})
.timeout(const Duration(seconds: 5), onTimeout: () {})
.catchError((Object _) {});
if (probe) unawaited(_probeRender(p, v));
return v;
}
// ------------------------------------------------------------ the log
// mpv's warnings and errors to the app log, a few per kind at a time (a
// refused request makes ffmpeg retry and warn on every try); ffmpeg's
// HTTP status to [httpErrors]; the GL init lines while probing.
void _route(_MkPlayer p, PlayerLogLine log) {
// Windows: the controller can be "ready" before mpv's render context
// exists, and an open in that gap fails its video output ("No render
// context set"). Open the same media again once, a moment later.
if (log.prefix == 'cplayer' && log.text.contains('initializing the selected video_out')) {
final last = p._lastOpen;
if (last != null && !p._voRetried && !p._retired) {
p._voRetried = true;
_mpvLog.info('render: the video output was not up for the open; opening it again');
unawaited(Future.delayed(const Duration(milliseconds: 500), () async {
if (p._retired || !identical(p._lastOpen, last)) return;
await p.mk.open(Media(last.url, httpHeaders: last.headers), play: last.play);
}).catchError((Object e) => _mpvLog.warning('render: opening again failed: $e')));
}
}
if (log.prefix == 'ffmpeg') {
final http = RegExp(r'HTTP error \d{3}.*').firstMatch(log.text);
if (http != null) _http.add(http[0]!.trim());
}
if (log.prefix == 'libmpv_render') {
if (_glInfo.hasMatch(log.text)) _mpvLog.info('render: [libmpv_render] ${log.text.trim()}');
if (log.text.contains('suspected software renderer')) {
_probeMode = RenderMode.softwareGl;
_renderMode.value = RenderMode.softwareGl;
} else if (log.text.startsWith('GL_VERSION') && p._probing && _probeMode == RenderMode.unknown) {
_probeMode = RenderMode.gpu;
_renderMode.value = RenderMode.gpu;
}
}
final level = log.level;
if (level != 'warn' && !log.isError) return;
final text = log.text.trim();
if (text.isEmpty) return;
final who = p.role == PlayerRole.preload ? 'preload ' : '';
final pass = _throttle.allow('$who${log.prefix} $level ${text.length > 40 ? text.substring(0, 40) : text}');
if (pass == null) return;
final line = '$pass$who[${log.prefix}] $text';
level == 'warn' ? _mpvLog.info(line) : _mpvLog.warning(line);
}
// mpv's GL init lines (`GL_VENDOR='...'`, `Detected desktop OpenGL 4.6.`).
static final _glInfo =
RegExp(r'^\s*(GL_(VENDOR|RENDERER|VERSION|SHADING_LANGUAGE_VERSION)=|Detected (GLES|desktop OpenGL))');
// Once per app start: what decides how media_kit and mpv render (the
// display server, Flutter's and Mesa's overrides). Not secrets.
static bool _renderEnvLogged = false;
static void _logRenderEnv() {
if (_renderEnvLogged || !Platform.isLinux) return;
_renderEnvLogged = true;
const names = [
'XDG_SESSION_TYPE',
'WAYLAND_DISPLAY',
'DISPLAY',
'GDK_BACKEND',
'FLUTTER_LINUX_RENDERER',
'LIBGL_ALWAYS_SOFTWARE',
'__EGL_VENDOR_LIBRARY_FILENAMES',
];
final env = Platform.environment;
_mpvLog.info('render env: ${[for (final n in names) '$n=${env[n] ?? '-'}'].join(' ')}');
}
RenderMode _probeMode = RenderMode.unknown;
// media_kit's Linux/Windows video output creates mpv's render context
// while the controller is created: OpenGL when it can, else the software
// renderer, which never logs GL init (and ignores shaders). No GL line
// within 1.5 s of the output being up means software.
Future<void> _probeRender(_MkPlayer p, _MkVideo v) async {
p._probing = true;
_probeMode = RenderMode.unknown;
try {
await v.controller.platform.future;
await Future.delayed(const Duration(milliseconds: 1500));
} catch (_) {
return;
} finally {
p._probing = false;
if (!p.disposed) p.setLogLevel(sessionLogLevel);
}
if (p.disposed) return;
final mode = _probeMode;
if (mode != RenderMode.unknown) {
_renderMode.value = mode;
_mpvLog.info('render: ${mode.name} (${mode == RenderMode.gpu ? 'OpenGL on the GPU' : 'OpenGL on the CPU'})');
return;
}
_renderMode.value = RenderMode.software;
_mpvLog.info("render: software (no GL init logged: media_kit's own fallback; "
'its reason is on stderr, "media_kit: VideoOutput: ..." when run from a terminal)');
}
// Once per player, after its first frame: the decoder mpv picked; the
// first time per app start also mpv's and ffmpeg's versions.
static bool _versionsLogged = false;
Future<void> _logFirstFrame(_MkPlayer p) async {
try {
final current = await p.getProperty('hwdec-current');
final wanted = await p.getProperty('hwdec');
_mpvLog.info('render: first frame ${p.state.width}x${p.state.height}, '
'hwdec-current=${current.isEmpty ? 'no' : current} (hwdec=$wanted), mode ${_renderMode.peek().name}');
if (!_versionsLogged) {
_versionsLogged = true;
_mpvLog.info('render: ${await versions(p)}');
}
} catch (e) {
debugPrint('first frame info: $e');
}
}
@override
Future<String?> versions(HostedPlayer player) async {
try {
return '${await player.getProperty('mpv-version')}, ffmpeg ${await player.getProperty('ffmpeg-version')}';
} catch (_) {
return null;
}
}
// --------------------------------------------------------- tear-down
@override
Future<void> retire(HostedPlayer player) {
final p = player as _MkPlayer;
p._retired = true;
unawaited(p.mk.pause().catchError((_) {}));
return _retiring = () async {
await _settleUi();
await dispose(p);
}();
}
@override
Future<void> dispose(HostedPlayer player) async {
final p = player as _MkPlayer;
if (!_live.remove(p)) return;
p._retired = true;
if (p._video case final v?) {
_videos.remove(v);
await v._paramsSub?.cancel();
}
await p._logSub?.cancel();
try {
await p.mk.dispose();
} catch (_) {}
}
@override
Future<void> disposeAll({Duration each = const Duration(milliseconds: 1500)}) async {
_closing = true;
await Future.wait([
for (final p in _live.toList()) dispose(p).timeout(each).catchError((Object _) {}),
]);
_closing = false;
}
// Two frames: one may already be mid-build when the view drops the
// output. Then the length of a route pop (the fullscreen route holds its
// own `Video` and leaves animated). Bounded, since a hidden window
// renders no frames; headless tests have no binding and skip it all.
static Future<void> _settleUi() async {
try {
for (var i = 0; i < 2; i++) {
await WidgetsBinding.instance.endOfFrame.timeout(const Duration(milliseconds: 300));
}
await Future.delayed(const Duration(milliseconds: 350));
} catch (_) {}
}
}
/// A [Player] whose public [seek] belongs to the user. media_kit's controls
/// call `player.seek`; with [onUserSeek] set those calls become explicit
/// events for the sync layer instead of moving mpv behind its back.
class _SyncPlayer extends Player {
_SyncPlayer({super.configuration});
void Function(Duration position)? onUserSeek;
@override
Future<void> seek(Duration duration) async {
final hook = onUserSeek;
if (hook == null) return super.seek(duration);
hook(duration);
}
Future<void> syncSeek(Duration duration) => super.seek(duration);
}
class _MkPlayer implements HostedPlayer {
final MediaKitHost host;
final _SyncPlayer mk;
PlayerRole _role;
_MkPlayer(this.host, this.mk, this._role) {
_firstFrame = mk.stream.width.listen((w) {
if (w == null || w <= 0 || _role == PlayerRole.thumbnail) return;
unawaited(_firstFrame?.cancel());
unawaited(host._logFirstFrame(this));
});
}
StreamSubscription<PlayerLogLine>? _logSub;
StreamSubscription<int?>? _firstFrame;
bool _probing = false;
bool _retired = false;
Future<void>? _videoReady; // set once a video output is attached
// The last open, for one retry when mpv's video output was not up yet.
({String url, Map<String, String> headers, bool play})? _lastOpen;
bool _voRetried = false;
_MkVideo? _video;
dynamic get _mpv => mk.platform;
@override
PlayerRole get role => _role;
@override
bool get disposed => _retired;
@override
HostedPlayerState get state {
final s = mk.state;
return HostedPlayerState(
position: s.position,
duration: s.duration,
buffer: s.buffer,
volume: s.volume,
playing: s.playing,
completed: s.completed,
width: s.width,
height: s.height,
);
}
@override
Stream<Duration> get positionStream => mk.stream.position;
@override
Stream<Duration> get durationStream => mk.stream.duration;
@override
Stream<Duration> get bufferStream => mk.stream.buffer;
@override
Stream<double> get volumeStream => mk.stream.volume;
@override
Stream<bool> get completedStream => mk.stream.completed;
@override
Stream<int?> get widthStream => mk.stream.width;
@override
Stream<PlayerLogLine> get logs => mk.stream.log.map((l) => PlayerLogLine(l.prefix, l.level, l.text));
@override
void Function(Duration position)? get onUserSeek => mk.onUserSeek;
@override
set onUserSeek(void Function(Duration position)? f) => mk.onUserSeek = f;
@override
Future<void> open(String url, {Map<String, String> headers = const {}, bool play = false}) async {
if (_videoReady case final ready?) await ready;
_lastOpen = (url: url, headers: headers, play: play);
_voRetried = false;
// An empty map, not null: media_kit caches headers per URI.
await mk.open(Media(url, httpHeaders: headers), play: play);
}
@override
Future<void> play() => mk.play();
@override
Future<void> pause() => mk.pause();
@override
Future<void> stop() => mk.stop();
@override
Future<void> seek(Duration position) => mk.seek(position);
@override
Future<void> syncSeek(Duration position) => mk.syncSeek(position);
@override
Future<void> setRate(double rate) => mk.setRate(rate);
@override
Future<void> setVolume(double volume) => mk.setVolume(volume);
// media_kit has no public property API: a guarded dynamic call on
// `player.platform` (its NativePlayer).
@override
Future<void> setProperty(String name, String value) async => await _mpv?.setProperty(name, value);
@override
Future<String> getProperty(String name) async => '${await _mpv?.getProperty(name)}';
@override
Future<void> observeProperty(String name, Future<void> Function(String value) onChange) async =>
await _mpv?.observeProperty(name, onChange);
@override
Future<void> command(List<String> args) async => await _mpv?.command(args);
@override
Future<String?> pin(String name, String value) async {
try {
await setProperty(name, value);
final got = await getProperty(name);
if (pinMatches(value, got)) return null;
debugPrint('pin $name=$value not applied, mpv has "$got"');
return '$name: $value -> $got';
} catch (e) {
debugPrint('pin $name=$value failed: $e');
return '$name: $value -> error $e';
}
}
// mpv_request_log_messages on media_kit's handle.
@override
void setLogLevel(String level) {
try {
final dynamic platform = mk.platform;
final name = level.toNativeUtf8();
try {
platform.mpv.mpv_request_log_messages(platform.ctx, name.cast<Int8>());
} finally {
malloc.free(name);
}
} catch (e) {
debugPrint('mpv log level $level not set: $e');
}
}
}
class _MkVideo implements HostedVideo {
@override
final _MkPlayer player;
final VideoController controller;
_MkVideo(this.player, this.controller);
@override
Future<void> get ready => controller.platform.future;
// What this output was last set to: null with [_sized] the video's own
// size again; nothing sent yet while [_sized] is false.
(int, int)? _sent;
bool _sized = false;
StreamSubscription<VideoParams>? _paramsSub;
Future<void> _sizeOp = Future.value();
/// Renders at the host's output size (or the video's own when it has
/// none). media_kit's controller sets the output back to the video's
/// size whenever the video's parameters change (a new file, a track
/// with another size); the fixed size is sent again after it, under
/// the controller's own lock so it lands last.
Future<void> applySize({bool again = false}) => _sizeOp = _sizeOp.then((_) async {
if (player.disposed) return;
try {
// NativeVideoController (Linux, Windows): not exported, reached
// through its public fields.
final dynamic native = await controller.platform.future;
final view = player.host._outputSize;
final params = player.mk.state.videoParams;
final dw = params.dw ?? 0, dh = params.dh ?? 0;
final want = view == null ? null : fitOutputSize(view, dw > 0 && dh > 0 ? (dw, dh) : null);
if (_sized && want == _sent && !again) return;
if (want == null) {
if (!_sized) return; // never resized: already the video's size
await native.setSize();
} else {
// setSize skips a size it believes is set; after media_kit's own
// reset it is not.
native.width = null;
await native.setSize(width: want.$1, height: want.$2);
}
_sent = want;
_sized = true;
// The output takes its new size with the next frame mpv draws;
// paused, none comes: a pan too small to see makes mpv redraw.
if (!player.state.playing) {
await player.setProperty('video-pan-x', '0.000001');
await player.setProperty('video-pan-x', '0');
}
_paramsSub ??= player.mk.stream.videoParams.listen((params) async {
if ((params.dw ?? 0) == 0 || _sent == null) return;
try {
await (native.lock.synchronized(() async {}) as Future);
} catch (_) {}
unawaited(applySize(again: true));
});
} catch (e) {
debugPrint('video output size: $e');
}
});
}
@@ -0,0 +1,106 @@
import 'dart:async';
import 'dart:io';
import 'dart:ui';
import 'package:flutter/foundation.dart';
import 'package:hive_ce_flutter/hive_flutter.dart';
import 'package:screen_retriever/screen_retriever.dart';
import 'package:window_manager/window_manager.dart';
/// The window's size, position and maximized state across restarts (and
/// hot restarts), in the settings box. The size and position kept are
/// the normal (unmaximized) ones; fullscreen is never saved or restored.
/// Wayland lets no app place its own window, so there only the size and
/// maximized state come back (the compositor decides the position).
class WindowMemory with WindowListener {
static const _key = 'windowBounds';
static const defaultSize = Size(1280, 720);
// Null when the settings box failed to open: defaults, nothing saved.
final Box? _box;
Timer? _save;
WindowMemory(this._box);
static bool get _canPlace => !(Platform.isLinux && Platform.environment['WAYLAND_DISPLAY'] != null);
Map? get _saved => _box?.get(_key) is Map ? _box!.get(_key) as Map : null;
/// The size to open at: the last normal size, else [defaultSize].
Size get size {
final s = _saved;
final w = s?['w'], h = s?['h'];
return w is num && h is num && w >= 160 && h >= 90 ? Size(w.toDouble(), h.toDouble()) : defaultSize;
}
/// Whether a saved position exists to place the window at (else center).
bool get hasPosition => _canPlace && _saved?['x'] is num && _saved?['y'] is num;
/// After the window shows: back to the saved position when it still
/// lands on a connected display, maximized when it was; then listens.
Future<void> restore() async {
final s = _saved;
try {
if (s != null && hasPosition) {
final rect = Offset((s['x'] as num).toDouble(), (s['y'] as num).toDouble()) & size;
if (await _onSomeDisplay(rect)) {
await windowManager.setPosition(rect.topLeft);
} else {
await windowManager.center();
}
}
if (s?['max'] == true) await windowManager.maximize();
} catch (e) {
debugPrint('window restore failed: $e');
}
windowManager.addListener(this);
}
// At least a 100×50 corner of the title area on a display's visible area.
static Future<bool> _onSomeDisplay(Rect r) async {
final grip = Rect.fromLTWH(r.left, r.top, r.width.clamp(0, 100).toDouble(), 50);
for (final d in await screenRetriever.getAllDisplays()) {
final area = (d.visiblePosition ?? Offset.zero) & (d.visibleSize ?? d.size);
if (area.overlaps(grip)) return true;
}
return false;
}
void _later() {
_save?.cancel();
_save = Timer(const Duration(milliseconds: 400), () => unawaited(save()));
}
/// Writes the current state now (also on close).
Future<void> save() async {
_save?.cancel();
final box = _box;
if (box == null) return;
try {
if (await windowManager.isFullScreen() || await windowManager.isMinimized()) return;
final max = await windowManager.isMaximized();
final s = Map<String, Object?>.of((_saved ?? const {}).cast<String, Object?>());
s['max'] = max;
if (!max) {
final b = await windowManager.getBounds();
s['w'] = b.width;
s['h'] = b.height;
if (_canPlace) {
s['x'] = b.left;
s['y'] = b.top;
}
}
await box.put(_key, s);
} catch (e) {
debugPrint('window save failed: $e');
}
}
@override
void onWindowResized() => _later();
@override
void onWindowMoved() => _later();
@override
void onWindowMaximize() => _later();
@override
void onWindowUnmaximize() => _later();
}
@@ -0,0 +1,60 @@
import 'dart:ffi';
import 'package:ffi/ffi.dart';
// The few Win32 calls the app makes itself (no package wraps them for
// us without a new dependency). Only ever called on Windows.
/// HKEY_CURRENT_USER as the API takes it (sign-extended 0x80000001).
const _hkeyCurrentUser = -0x7FFFFFFF;
const _regSz = 1;
const _rrfRtRegSz = 0x2;
typedef _AllowNative = Int32 Function(Uint32 pid);
typedef _Allow = int Function(int pid);
/// Lets process [pid] take the foreground (a second start does this for
/// the running app before handing over its link: Windows only lets a
/// process that got the user's click bring a window up).
void allowSetForegroundWindow(int pid) {
final user32 = DynamicLibrary.open('user32.dll');
user32.lookupFunction<_AllowNative, _Allow>('AllowSetForegroundWindow')(pid);
}
typedef _SetNative = Int32 Function(
IntPtr key, Pointer<Utf16> subKey, Pointer<Utf16> name, Uint32 type, Pointer<Void> data, Uint32 size);
typedef _Set = int Function(
int key, Pointer<Utf16> subKey, Pointer<Utf16> name, int type, Pointer<Void> data, int size);
typedef _GetNative = Int32 Function(IntPtr key, Pointer<Utf16> subKey, Pointer<Utf16> name, Uint32 flags,
Pointer<Uint32> type, Pointer<Void> data, Pointer<Uint32> size);
typedef _Get = int Function(int key, Pointer<Utf16> subKey, Pointer<Utf16> name, int flags,
Pointer<Uint32> type, Pointer<Void> data, Pointer<Uint32> size);
DynamicLibrary get _advapi => DynamicLibrary.open('advapi32.dll');
/// The string value [name] ('' = the key's default) of
/// HKEY_CURRENT_USER\[subKey]; null when missing.
String? readUserRegistryString(String subKey, String name) {
final get = _advapi.lookupFunction<_GetNative, _Get>('RegGetValueW');
return using((arena) {
final key = subKey.toNativeUtf16(allocator: arena);
final value = name.isEmpty ? nullptr : name.toNativeUtf16(allocator: arena);
final size = arena<Uint32>()..value = 0;
if (get(_hkeyCurrentUser, key, value, _rrfRtRegSz, nullptr, nullptr, size) != 0) return null;
final data = arena<Uint16>(size.value ~/ 2 + 1);
if (get(_hkeyCurrentUser, key, value, _rrfRtRegSz, nullptr, data.cast(), size) != 0) return null;
return data.cast<Utf16>().toDartString();
});
}
/// Sets the string value [name] ('' = the key's default) of
/// HKEY_CURRENT_USER\[subKey], creating the key; false when refused.
bool writeUserRegistryString(String subKey, String name, String data) {
final set = _advapi.lookupFunction<_SetNative, _Set>('RegSetKeyValueW');
return using((arena) {
final key = subKey.toNativeUtf16(allocator: arena);
final value = name.isEmpty ? nullptr : name.toNativeUtf16(allocator: arena);
final bytes = data.toNativeUtf16(allocator: arena);
return set(_hkeyCurrentUser, key, value.cast(), _regSz, bytes.cast(), (data.length + 1) * 2) == 0;
});
}
@@ -0,0 +1,90 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:sync_relay_core/core.dart' show findInstallRoot;
import 'url_scheme.dart';
/// The app's id: GTK's application id, so the Wayland `app_id` the
/// compositor matches to [desktopFileName] for the window's icon.
const appId = 'dev.foxyz.starsync';
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
/// Wayland shows a generic icon. On start: write the `.desktop` file into
/// `~/.local/share/applications` (Exec = this binary) and the icon into
/// the hicolor theme, only when something changed (a moved install, a new
/// icon). The entry and icons of [legacyAppId] go. The entry also takes
/// `starsync://` links ([schemeMimeType], %u) and the app becomes their
/// default handler ([registerScheme], default [registerLinuxScheme]).
/// Never in debug runs (unless [force], for tests); failures only log.
Future<void> installDesktopEntry({
String? executable,
String? dataHome,
String? home,
@visibleForTesting bool force = false,
Future<void> Function(String desktopFile, String applicationsDir)? registerScheme,
}) async {
if (!Platform.isLinux || (kDebugMode && !force)) return;
try {
final exe = executable ?? Platform.resolvedExecutable;
final root = findInstallRoot(exe, windows: false);
if (root == null) return;
final share = dataHome ?? Platform.environment['XDG_DATA_HOME'] ??
'${home ?? Platform.environment['HOME']}/.local/share';
final icons = '${root.path}/data/flutter_assets/assets/icon';
await _removeLegacy(share);
for (final size in _iconSizes) {
final src = File('$icons/icon_$size.png');
if (!src.existsSync()) continue;
final dst = File('$share/icons/hicolor/${size}x$size/apps/$appId.png');
if (dst.existsSync() && dst.lengthSync() == src.lengthSync()) continue;
await dst.parent.create(recursive: true);
await src.copy(dst.path);
}
final entry = desktopEntryText(exe);
final file = File('$share/applications/$desktopFileName');
if (!file.existsSync() || file.readAsStringSync() != entry) {
await file.parent.create(recursive: true);
await file.writeAsString(entry);
}
await (registerScheme ?? registerLinuxScheme)(desktopFileName, file.parent.path);
} catch (e) {
debugPrint('desktop entry: $e');
}
}
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
String desktopEntryText(String exe) => '''[Desktop Entry]
Type=Application
Name=Starsync
Comment=Watch videos in sync with friends
Exec="$exe" %u
Icon=$appId
StartupWMClass=$appId
StartupNotify=true
MimeType=$schemeMimeType;
Categories=AudioVideo;Video;Player;Network;
Terminal=false
''';
@@ -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,23 @@
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();
/// This device opens web pages itself (the media plan's `resolve`
/// capability). Desktop: always (yt-dlp is downloaded when first
/// needed); a device without it gets pages passed on by someone else.
bool get resolvesLinks;
/// What [url] is, remembered for a while ([keep]); [fresh] asks again.
Future<LinkProbe> probe(String url, {Duration keep, bool fresh});
/// [url] no longer needs keeping longer than usual.
void release(String url);
}
@@ -0,0 +1,229 @@
import 'dart:async';
import 'package:signals/signals.dart';
// The media players as the app sees them: mpv instances, their video
// outputs, how the video is rendered, mpv's log and the order players are
// torn down in. Desktop: media_kit over libmpv
// (`platform/desktop/media_kit_host.dart`); nothing else in the app
// touches media_kit except the `Video` widget glue.
/// How the video output renders (shaders need [gpu]).
enum RenderMode {
unknown,
/// OpenGL on a real GPU: shaders work.
gpu,
/// OpenGL on a software rasterizer (llvmpipe): shaders crawl.
softwareGl,
/// The video output's own software fallback: shaders are ignored.
software,
}
/// What a player is for: its cache, log level and log routing.
enum PlayerRole {
/// The session's player (and a fresh one per media): full cache, mpv log
/// at `warn` into the app log.
session,
/// The hidden player holding what plays next: small cache, muted, log
/// at `error`.
preload,
/// A poster frame for Files: no audio or video output, no cache, no log.
thumbnail,
}
/// One line of mpv's log.
class PlayerLogLine {
final String prefix;
final String level;
final String text;
const PlayerLogLine(this.prefix, this.level, this.text);
bool get isError => level == 'error' || level == 'fatal';
}
/// A snapshot of a player's state.
class HostedPlayerState {
final Duration position;
final Duration duration;
/// End of the cached range (mpv `demuxer-cache-time`).
final Duration buffer;
final double volume;
final bool playing;
/// At the end of the file.
final bool completed;
final int? width;
final int? height;
const HostedPlayerState({
this.position = Duration.zero,
this.duration = Duration.zero,
this.buffer = Duration.zero,
this.volume = 100,
this.playing = false,
this.completed = false,
this.width,
this.height,
});
}
/// One mpv player. Each media gets its own (see ARCHITECTURE "Player
/// lifecycle"); [PlayerHost.retire] / [PlayerHost.disposeAll] end it.
abstract interface class HostedPlayer {
PlayerRole get role;
HostedPlayerState get state;
Stream<Duration> get positionStream;
Stream<Duration> get durationStream;
Stream<Duration> get bufferStream;
Stream<double> get volumeStream;
Stream<bool> get completedStream;
Stream<int?> get widthStream;
/// mpv's log lines at the player's level.
Stream<PlayerLogLine> get logs;
/// The player is gone (disposed or being disposed): commands do nothing.
bool get disposed;
/// Seeks from the video widget's own controls land here instead of in
/// mpv when set ([seek] goes through it too); [syncSeek] always moves
/// mpv.
void Function(Duration position)? onUserSeek;
/// Opens [url] (with [headers] on every request), paused unless [play].
Future<void> open(String url, {Map<String, String> headers = const {}, bool play = false});
Future<void> play();
Future<void> pause();
Future<void> stop();
/// A user seek: through [onUserSeek] when set, else [syncSeek].
Future<void> seek(Duration position);
/// Moves mpv (sync corrections, scrub preview).
Future<void> syncSeek(Duration position);
Future<void> setRate(double rate);
Future<void> setVolume(double volume);
Future<void> setProperty(String name, String value);
Future<String> getProperty(String name);
Future<void> observeProperty(String name, Future<void> Function(String value) onChange);
Future<void> command(List<String> args);
/// Sets [name] and reads it back: null when mpv took [value], else
/// `name: value -> got` (mpv ignores invalid values silently).
Future<String?> pin(String name, String value);
/// mpv's log level for this player (`warn`, `v`, ...).
void setLogLevel(String level);
}
/// A player's video output (a texture the `Video` widget shows).
abstract interface class HostedVideo {
HostedPlayer get player;
/// Completes once the output is up (its texture registered).
Future<void> get ready;
}
/// Creates, owns and disposes the app's players. One per app (the
/// composition root's `Ports`); tests make their own.
abstract interface class PlayerHost {
/// Once at start, before any player.
void init();
/// A new player for [role], set up before it is returned to the caller's
/// first await: cache, pins ([pinMismatches]), hwdec, log level.
Future<HostedPlayer> create(PlayerRole role, {bool muted = false});
/// [player] (a preload) becomes a session player: unmuted, the full
/// cache pins, the session's log level.
Future<void> promote(HostedPlayer player);
/// [player]'s video output, with the hwdec policy. [probe]: listen to
/// mpv's render init to learn [renderMode] (the first output of an app
/// start).
HostedVideo video(HostedPlayer player, {bool probe = false});
/// The view's pixel size: every video output renders the picture fitted
/// in it ([fitOutputSize]), so mpv scales the picture and draws
/// subtitles at screen resolution (Settings → Player, "Sharp video").
/// Null: each output at its video's own size. Applies to live outputs
/// and to new ones once they are up.
void setOutputSize(int? width, int? height);
/// How video renders, as last learned (kept after leaving).
ReadonlySignal<RenderMode> get renderMode;
/// mpv `hwdec` for new players: env `SYNC_RELAY_HWDEC` wins over the
/// setting (Windows' auto is `auto-copy`).
String? get hwdec;
/// Pins that did not take on the last session player created.
List<String> get pinMismatches;
/// ffmpeg's HTTP errors (`HTTP error 403 Forbidden`), from whichever
/// player ffmpeg logs through (one per process).
Stream<String> get httpErrors;
/// `mpv-version` and `ffmpeg-version` of [player], for diagnostics.
Future<String?> versions(HostedPlayer player);
/// Ends [player] once the view let go of its texture (two frames and a
/// route pop); at most one retiring player lingers, a new [create] of a
/// session player waits for it ([retiring]).
Future<void> retire(HostedPlayer player);
/// The last retire, until done.
Future<void> get retiring;
/// Disposes [player] at once (no view showed it).
Future<void> dispose(HostedPlayer player);
/// App close: every live player goes, each bounded by [each], before
/// the engine does (a live mpv calls back into a dead isolate).
Future<void> disposeAll({Duration each});
/// Live players, for tests and the close.
int get live;
}
/// [log] says ffmpeg's HTTP stream lost the file's size and so every
/// seek on it fails ("Seek failed (to 3417735, size -38)": -38 is ENOSYS,
/// what ffmpeg answers for the size once a reconnect's or a seek's request
/// failed). Only a fresh open gets it back.
bool mpvStreamLost(PlayerLogLine log) =>
log.prefix == 'ffmpeg' && log.text.contains('Seek failed') && log.text.contains('size -38)');
/// [log] says the media could not be opened: before a [duration] is known,
/// mpv's fatal "nothing to play" or a demuxer error.
bool mpvLoadFailed(PlayerLogLine log, Duration duration) {
return duration == Duration.zero &&
((log.prefix == 'cplayer' && log.level == 'fatal') || (log.prefix == 'demux' && log.level == 'error'));
}
/// The size a video output renders at for a [view] of that many pixels:
/// the [video]'s picture (its display size, aspect included) fitted
/// inside, no letterbox. The texture then has the picture's aspect, so
/// while a resize settles Flutter scales the last one to the new view
/// with no jump (a texture of the whole view would shrink inside its own
/// black bars until the new one came). Drawn 1:1: the fitted side is the
/// view's, the other is rounded down to the view's parity, so the
/// picture sits on whole pixels when centred (a half-pixel offset would
/// blur every line). Unknown video: the view.
(int, int) fitOutputSize((int, int) view, (int, int)? video) {
final (vw, vh) = view;
if (video == null || video.$1 <= 0 || video.$2 <= 0) return view;
final aspect = video.$1 / video.$2;
int under(double x, int full) {
var n = x.floor().clamp(1, full);
if ((full - n).isOdd) n = n > 1 ? n - 1 : n;
return n;
}
if (vw / vh > aspect) return (under(vh * aspect, vw), vh); // pillarbox
return (vw, under(vw / aspect, vh)); // letterbox (or exact)
}
@@ -0,0 +1,34 @@
import 'folder_access.dart';
import 'link_resolver.dart';
import 'player_host.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 'player_host.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 ([AppGraph]) and handed on from there.
class Ports {
final ShellPort shell;
final UrlOpener urls;
final FolderAccess folders;
final LinkResolver links;
final SystemNotifications notifications;
/// The media players (mpv): created, rendered and disposed here.
final PlayerHost players;
const Ports({
required this.shell,
required this.urls,
required this.folders,
required this.links,
required this.notifications,
required this.players,
});
}
@@ -0,0 +1,72 @@
import 'package:flutter/widgets.dart';
import 'window_activity.dart';
export 'window_activity.dart';
/// 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();
/// Brings the window up and gives it the focus (restored when
/// minimised, shown when hidden): a click on a system notification, a
/// link handed over by a second start ([activation]: that start's
/// desktop activation token, which lets the window take the focus).
Future<void> focus({String activation = ''});
/// Points the desktop at the window when it is not focused (taskbar
/// flash, urgency hint); nothing when it is.
Future<void> requestAttention();
/// The clipboard's files, else its text (Ctrl+V outside a text field).
Future<Paste> readPaste();
/// The clipboard's text only (the join prompt looks for an invite in
/// it); null when there is none or it can't be read.
Future<String?> readClipboardText();
/// [child] taking dropped files ([onDrop]: their paths and where they
/// landed); [enable] off lets drops through.
Widget dropTarget({
Key? key,
bool enable = true,
VoidCallback? onEnter,
VoidCallback? onExit,
required void Function(List<String> paths, Offset at) onDrop,
required Widget child,
});
}
@@ -0,0 +1,23 @@
/// Notifications outside the window (the desktop's notification center,
/// Android's shade). The Notifier decides what goes out here (see
/// `noticeFor`); the port only shows them and reports clicks.
abstract interface class SystemNotifications {
/// Shows [title] / [body] with the app's name and icon; one with the
/// same [tag] replaces it. [payload] comes back to [onClick].
Future<void> show({required String title, required String body, String tag = '', String payload = ''});
/// Called with a notification's payload when the user clicks it (set
/// once, by the Notifier).
set onClick(void Function(String payload)? handler);
}
/// Shows nothing (tests, platforms without one).
class NoSystemNotifications implements SystemNotifications {
const NoSystemNotifications();
@override
Future<void> show({required String title, required String body, String tag = '', String payload = ''}) async {}
@override
set onClick(void Function(String payload)? handler) {}
}
@@ -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);
}
@@ -0,0 +1,19 @@
/// Where the app window stands ([ShellPort.watchActivity]): auto sit-out
/// and the Notifier (system notifications while it is not focused) read
/// it. Pure Dart.
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;
/// Not in front of the user: notices go out as system notifications.
bool get background => this != focused;
}
@@ -0,0 +1,205 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:math';
import 'package:flutter/foundation.dart' show debugPrint, visibleForTesting;
/// What a second start hands to the running app: its arguments (a
/// clicked `starsync://` link) and the desktop's activation token for
/// bringing the window up (Linux: `XDG_ACTIVATION_TOKEN` /
/// `DESKTOP_STARTUP_ID`; '' when none).
typedef Handoff = ({List<String> args, String activation});
const _lockName = 'instance.lock';
const _infoName = 'instance.json';
/// Lock paths this process holds: a second claim from the same process
/// (tests) sees them held, as another process would.
final _heldHere = <String>{};
/// One app per profile folder at a time. The first start holds an
/// exclusive lock on `instance.lock` in [dir] (the OS drops it when the
/// process ends, a crash too) and listens on a loopback port written to
/// `instance.json` with a random token. A later start finds the lock
/// held, sends its [Handoff] there and ends; the running app comes to
/// the front and handles it ([handoffs]). Pure dart:io: the same on
/// Linux (Wayland and X11) and Windows.
class InstanceLock {
InstanceLock._(this.dir, this._lock, this._server, this._token);
/// The profile folder (each `STARSYNC_PROFILE` has its own, so each is
/// its own instance).
final String dir;
final RandomAccessFile? _lock;
final ServerSocket? _server;
final String _token;
final _handoffs = StreamController<Handoff>();
bool _closing = false;
bool _released = false;
/// What later starts handed over, in order (buffered until listened to).
Stream<Handoff> get handoffs => _handoffs.stream;
/// False when the lock could not be had nor the running app reached
/// (this app runs anyway, as before single instance).
bool get held => _lock != null;
/// Claims [dir] for this process, or hands [args] to the app that holds
/// it: null when handed over (this start should end now). A holder
/// that is closing (or not listening yet) is retried for [retryFor]
/// ([closingWait] once it said it is closing, so a start during a close
/// or an update's restart takes over after it). [beforeSend] gets the
/// holder's process id first (Windows: allow it to take the
/// foreground).
static Future<InstanceLock?> claim(
String dir, {
List<String> args = const [],
String activation = '',
void Function(int pid)? beforeSend,
Duration retryFor = const Duration(seconds: 5),
Duration closingWait = const Duration(seconds: 20),
Duration retryEvery = const Duration(milliseconds: 200),
}) async {
await Directory(dir).create(recursive: true);
final started = DateTime.now();
var closingSeen = false;
while (true) {
final lock = await _tryLock(dir);
if (lock != null) return _listen(dir, lock);
final answer = await _send(dir, (args: args, activation: activation), beforeSend);
if (answer == _Answer.ok) return null;
if (answer == _Answer.closing) closingSeen = true;
final waited = DateTime.now().difference(started);
if (waited > (closingSeen ? closingWait : retryFor)) {
debugPrint('single instance: no lock and no answer after ${waited.inMilliseconds} ms; starting anyway');
return InstanceLock._(dir, null, null, '');
}
await Future.delayed(retryEvery);
}
}
static Future<RandomAccessFile?> _tryLock(String dir) async {
final path = '$dir${Platform.pathSeparator}$_lockName';
if (_heldHere.contains(path)) return null;
RandomAccessFile? raf;
try {
raf = await File(path).open(mode: FileMode.append);
await raf.lock(FileLock.exclusive);
_heldHere.add(path);
return raf;
} catch (_) {
if (raf != null) await _quiet(raf.close());
return null;
}
}
static Future<InstanceLock> _listen(String dir, RandomAccessFile lock) async {
final token = base64Url.encode([for (var i = 0; i < 18; i++) Random.secure().nextInt(256)]);
ServerSocket? server;
try {
server = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0);
await File('$dir${Platform.pathSeparator}$_infoName')
.writeAsString(jsonEncode({'port': server.port, 'token': token, 'pid': pid}), flush: true);
} catch (e) {
debugPrint('single instance: cannot listen for later starts: $e');
}
final self = InstanceLock._(dir, lock, server, token);
server?.listen(self._serve, onError: (Object e) => debugPrint('single instance: $e'));
return self;
}
Future<void> _serve(Socket socket) async {
try {
final line = await utf8.decoder
.bind(socket)
.transform(const LineSplitter())
.first
.timeout(const Duration(seconds: 3));
if (line.length > 64 * 1024) throw const FormatException('too long');
final j = jsonDecode(line);
if (j is! Map || j['token'] != _token) {
socket.writeln(jsonEncode({'ok': false}));
} else if (_closing) {
socket.writeln(jsonEncode({'closing': true}));
} else {
final args = j['args'];
final activation = j['activation'];
_handoffs.add((
args: [if (args is List) for (final a in args) if (a is String) a],
activation: activation is String ? activation : '',
));
socket.writeln(jsonEncode({'ok': true}));
}
await socket.flush();
} catch (e) {
debugPrint('single instance: a later start sent nothing usable (${e.runtimeType})');
} finally {
await _quiet(socket.close());
}
}
static Future<_Answer> _send(String dir, Handoff h, void Function(int pid)? beforeSend) async {
Socket? socket;
try {
final info = jsonDecode(await File('$dir${Platform.pathSeparator}$_infoName').readAsString());
final port = info is Map ? info['port'] : null;
final token = info is Map ? info['token'] : null;
final holder = info is Map ? info['pid'] : null;
if (port is! int || token is! String) return _Answer.none;
if (holder is int) beforeSend?.call(holder);
socket = await Socket.connect(InternetAddress.loopbackIPv4, port, timeout: const Duration(seconds: 2));
socket.writeln(jsonEncode({'token': token, 'args': h.args, 'activation': h.activation}));
await socket.flush();
final reply = await utf8.decoder
.bind(socket)
.transform(const LineSplitter())
.first
.timeout(const Duration(seconds: 3));
final r = jsonDecode(reply);
if (r is Map && r['ok'] == true) return _Answer.ok;
if (r is Map && r['closing'] == true) return _Answer.closing;
return _Answer.none;
} catch (_) {
// Not written yet, a stale one from a crash, or not listening yet.
return _Answer.none;
} finally {
socket?.destroy();
}
}
/// The app is closing: later starts are told so and wait for the lock
/// instead of handing over to an app on its way out.
void closing() => _closing = true;
/// Gives the folder up before this process ends (Update and restart:
/// the new binary claims it at once).
Future<void> release() async {
if (_released) return;
_released = true;
_closing = true;
await _server?.close();
final lock = _lock;
if (lock == null) return;
try {
await File('$dir${Platform.pathSeparator}$_infoName').delete();
} catch (_) {}
try {
await lock.unlock();
} catch (_) {}
await _quiet(lock.close());
_heldHere.remove('$dir${Platform.pathSeparator}$_lockName');
unawaited(_handoffs.close());
}
@visibleForTesting
int? get port => _server?.port;
}
enum _Answer { ok, closing, none }
Future<void> _quiet(Future<Object?> f) async {
try {
await f;
} catch (_) {}
}
+78
View File
@@ -0,0 +1,78 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/foundation.dart' show debugPrint;
import '../app/app_graph.dart';
import 'app_shutdown.dart';
import 'ports/player_host.dart';
// The close smoke run (CI, `tool/smoke_windows.ps1` / `tool/smoke_linux.sh`):
// env `STARSYNC_SMOKE=<media file>` makes the app, once up, open that file
// in a session player with a video output through the media pipeline,
// play a second and close the normal way ([shutDownApp]). Progress goes to
// `STARSYNC_SMOKE_OUT` (one line per step: `start`, `opened <ms> render=<mode>`,
// `closing`, `quitting`, or `skip <why>` / `fail <why>`), so the script can
// tell a runner that cannot show a window (skip) from a close that hangs or
// leaves a process behind, or a process that ends on its own (fail). It
// stays up [smokeUptime] first: a start that dies by itself (the 1.3.0
// Windows exit timer) ends before `closing`.
/// How long the smoke run keeps the app up before closing it.
const smokeUptime = Duration(seconds: 10);
/// The smoke run's media, or null for a normal start.
String? get smokeMedia {
final m = Platform.environment['STARSYNC_SMOKE'];
return m == null || m.isEmpty ? null : m;
}
Future<void> runSmoke(AppGraph graph, String media) async {
final outPath = Platform.environment['STARSYNC_SMOKE_OUT'];
void step(String line) {
debugPrint('smoke: $line'); // into the app log too
if (outPath == null || outPath.isEmpty) return;
try {
File(outPath).writeAsStringSync('$line\n', mode: FileMode.append, flush: true);
} catch (_) {}
}
step('start');
final sw = Stopwatch()..start();
final pipe = graph.coordinator.media;
try {
if (!await File(media).exists()) {
step('fail no media file at $media');
} else {
await pipe.start();
await pipe.playback.setVolume(0);
// The video output the session view would make: the render path
// (EGL / ANGLE) is what breaks on some machines.
final video = pipe.playback.video;
await video.ready.timeout(const Duration(seconds: 15));
final ok = await pipe.open(media);
final end = DateTime.now().add(const Duration(seconds: 20));
while (ok && pipe.playback.durationSecs <= 0 && DateTime.now().isBefore(end)) {
await Future.delayed(const Duration(milliseconds: 100));
}
if (pipe.playback.durationSecs <= 0) {
step('fail the media did not open in 20 s');
} else {
await pipe.playback.player.play();
await Future.delayed(const Duration(seconds: 1));
final mode = graph.ports.players.renderMode.peek();
step('opened ${sw.elapsedMilliseconds} render=${mode.name}');
if (mode == RenderMode.unknown) await Future.delayed(const Duration(seconds: 2));
}
}
} on TimeoutException {
step('skip the video output did not come up (no display for this runner?)');
} catch (e) {
step('fail $e');
}
final wait = smokeUptime - sw.elapsed;
if (wait > Duration.zero) await Future.delayed(wait);
step('closing');
// Reached only through the app's own close (not a kill on the way).
await shutDownApp(beforeQuit: () async => step('quitting'));
}
@@ -0,0 +1,81 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:sync_relay_core/core.dart' show findInstallRoot;
import '../session/invite.dart' show inviteScheme;
import 'desktop/windows_api.dart';
/// The MIME type desktops look up for a `starsync://` link (Linux).
const schemeMimeType = 'x-scheme-handler/$inviteScheme';
/// HKCU\Software\Classes\starsync: what Windows opens a link with.
const windowsSchemeKey = r'Software\Classes\' + inviteScheme;
/// The binary a link should start: `starsync.exe` next to an old-named
/// copy (`watch_together.exe`, kept for 1.2.x updaters) when it is there,
/// so links always start the current name.
String schemeExecutable(String exe, {bool Function(String path)? exists}) {
final has = exists ?? (p) => File(p).existsSync();
final sep = exe.contains(r'\') ? r'\' : '/';
final cut = exe.lastIndexOf(sep);
final name = exe.substring(cut + 1).toLowerCase();
if (name != 'watch_together.exe' && name != 'watch_together') return exe;
final current = '${exe.substring(0, cut + 1)}starsync${name.endsWith('.exe') ? '.exe' : ''}';
return has(current) ? current : exe;
}
/// The registry values of the Windows link handler for [exe]: (key under
/// HKEY_CURRENT_USER, value name ('' = default), data).
List<(String, String, String)> windowsSchemeValues(String exe) => [
(windowsSchemeKey, '', 'URL:Starsync invite'),
(windowsSchemeKey, 'URL Protocol', ''),
('$windowsSchemeKey\\DefaultIcon', '', '"$exe",0'),
('$windowsSchemeKey\\shell\\open\\command', '', '"$exe" "%1"'),
];
/// Windows: makes `starsync://` links start this install (per user, no
/// admin: HKCU\Software\Classes), only when the command there is not this
/// one already (first start, a moved install, an update from an old
/// name). Release installs only (never a debug run); failures only log.
Future<void> registerWindowsScheme({String? executable}) async {
if (!Platform.isWindows || kDebugMode) return;
try {
final exe = schemeExecutable(executable ?? Platform.resolvedExecutable);
if (findInstallRoot(exe, windows: true) == null) return;
final values = windowsSchemeValues(exe);
final command = values.last;
if (readUserRegistryString(command.$1, command.$2) == command.$3) return;
for (final (key, name, data) in values) {
if (!writeUserRegistryString(key, name, data)) {
debugPrint('url scheme: cannot write HKCU\\$key');
return;
}
}
debugPrint('url scheme: starsync:// links open $exe');
} catch (e) {
debugPrint('url scheme: $e');
}
}
/// Linux: makes this app the default for [schemeMimeType] (xdg-mime,
/// `~/.config/mimeapps.list`) unless it already is. [desktopFile]: the
/// launcher entry's file name, which lists the type.
Future<void> registerLinuxScheme(String desktopFile, String applicationsDir) async {
try {
final q = await Process.run('xdg-mime', ['query', 'default', schemeMimeType]);
if (q.exitCode == 0 && '${q.stdout}'.trim() == desktopFile) return;
final r = await Process.run('xdg-mime', ['default', desktopFile, schemeMimeType]);
if (r.exitCode != 0) {
debugPrint('url scheme: xdg-mime default failed: ${'${r.stderr}'.trim()}');
return;
}
// The MIME cache some desktops read (missing on some systems: fine).
try {
await Process.run('update-desktop-database', ['-q', applicationsDir]);
} catch (_) {}
debugPrint('url scheme: starsync:// links open $desktopFile');
} catch (e) {
debugPrint('url scheme: $e');
}
}
@@ -1,120 +0,0 @@
import 'dart:typed_data';
/// FNV-1a over a 15x15 grid of RGB(A) pixels in the central half of an
/// image. Used on a small capture of what the compositor shows: the
/// centre is video, the edges carry the (changing) control overlays.
int hashCenterPixels(ByteData rgba, int width, int height) {
var x = 0x811c9dc5;
if (width <= 0 || height <= 0) return x;
for (var gy = 1; gy < 16; gy++) {
for (var gx = 1; gx < 16; gx++) {
final px = (width * (0.25 + 0.5 * gx / 16)).floor();
final py = (height * (0.25 + 0.5 * gy / 16)).floor();
final o = (py * width + px) * 4;
if (o + 3 > rgba.lengthInBytes) continue;
for (var c = 0; c < 3; c++) {
x = ((x ^ rgba.getUint8(o + c)) * 0x01000193) & 0xffffffff;
}
}
}
return x;
}
/// FNV-1a over ~4k bytes spread evenly across a raw frame (mpv
/// `screenshot-raw`); dimension-agnostic, enough to tell frames apart.
int hashSpread(Uint8List bytes) {
var x = 0x811c9dc5;
if (bytes.isEmpty) return x;
final step = bytes.length <= 4096 ? 1 : bytes.length ~/ 4096;
for (var i = 0; i < bytes.length; i += step) {
x = ((x ^ bytes[i]) * 0x01000193) & 0xffffffff;
}
return x;
}
/// One sample of player state, taken about once a second.
class FrameSample {
final DateTime at;
final double position;
/// Fingerprint of the picture currently shown on screen; null when it
/// cannot be captured (no view, hidden window, headless).
final int? frames;
/// Playing, not buffering, rate > 0, video track present, window
/// visible. Anything else disarms the watchdog.
final bool eligible;
const FrameSample({
required this.at,
required this.position,
required this.frames,
required this.eligible,
});
}
/// Detects a frozen picture: position advances normally but the shown
/// picture stands still for [stallAfter]. Seeks (position jumps), pauses,
/// buffering and missing captures all reset the stall clock. A hit is a
/// suspicion only — a still scene looks the same — so the caller confirms
/// it against the decoder and calls [dismiss] when it was a still scene.
/// Fires at most once until the picture is seen moving again for
/// [rearmAfter], so a player that stays (or soon gets) frozen after a
/// reload does not reload in a loop. [reset] keeps that latch.
class FrameWatchdog {
static const stallAfter = Duration(seconds: 3);
static const rearmAfter = Duration(seconds: 10);
FrameSample? _last;
DateTime? _stallSince;
DateTime? _healthySince;
bool _fired = false;
void reset() {
_last = null;
_stallSince = null;
_healthySince = null;
}
/// The last hit was a still scene: re-arm, but wait a full
/// [stallAfter] before suspecting again.
void dismiss() {
_fired = false;
_stallSince = null;
}
/// Returns true exactly when a freeze is suspected.
bool feed(FrameSample s) {
final last = _last;
_last = s;
if (!s.eligible || s.frames == null || last == null ||
!last.eligible || last.frames == null) {
_stallSince = null;
_healthySince = null;
return false;
}
if (s.frames != last.frames) {
_stallSince = null;
_healthySince ??= last.at;
if (s.at.difference(_healthySince!) >= rearmAfter) _fired = false;
return false;
}
_healthySince = null;
final wall = s.at.difference(last.at).inMilliseconds / 1000.0;
final moved = s.position - last.position;
// Normal playback only: a jump (seek) or a standstill (stall, the
// position itself stuck) is not a frozen picture.
final normal = wall > 0 && moved > wall * 0.5 && moved < wall * 2 + 0.5;
if (!normal) {
_stallSince = null;
return false;
}
_stallSince ??= last.at;
if (_fired) return false;
if (s.at.difference(_stallSince!) >= stallAfter) {
_fired = true;
_stallSince = null;
return true;
}
return false;
}
}
@@ -243,18 +243,20 @@ const _skipTail = Duration(seconds: 1);
/// The skip pill at [position]: inside the opening "Skip OP" (to its end), /// The skip pill at [position]: inside the opening "Skip OP" (to its end),
/// inside the ending "Skip ED" (to its end only: what follows, preview or /// inside the ending "Skip ED" (to its end only: what follows, preview or
/// more episode, is the viewer's call). Without any opening info on media /// more episode, is the viewer's call). Without any opening info on media
/// longer than [fallbackMinDuration], "Skip 1:30" during the first /// longer than [fallbackMinDuration] and without chapters ([chaptered]: a
/// [fallbackWindow]. Null otherwise. /// chaptered file without an OP chapter has none on purpose), "Skip 1:30"
/// during the first [fallbackWindow]. Null otherwise.
SkipOffer? skipOfferAt({ SkipOffer? skipOfferAt({
required Duration position, required Duration position,
required Duration duration, required Duration duration,
required SkipRange? opening, required SkipRange? opening,
required SkipRange? ending, required SkipRange? ending,
bool chaptered = false,
}) { }) {
bool inside(SkipRange r) => position >= r.start && position < r.end - _skipTail; bool inside(SkipRange r) => position >= r.start && position < r.end - _skipTail;
if (opening != null && inside(opening)) return SkipOffer(SkipKind.opening, opening.end); if (opening != null && inside(opening)) return SkipOffer(SkipKind.opening, opening.end);
if (ending != null && inside(ending)) return SkipOffer(SkipKind.ending, ending.end); if (ending != null && inside(ending)) return SkipOffer(SkipKind.ending, ending.end);
if (opening == null && duration > fallbackMinDuration && position < fallbackWindow) { if (opening == null && !chaptered && duration > fallbackMinDuration && position < fallbackWindow) {
final to = position + fallbackSkip; final to = position + fallbackSkip;
return SkipOffer(SkipKind.fixed, to < duration ? to : duration); return SkipOffer(SkipKind.fixed, to < duration ? to : duration);
} }
@@ -1,45 +1,50 @@
import 'dart:async'; import 'dart:async';
import 'dart:io';
import 'package:flutter/foundation.dart' show listEquals; import 'package:flutter/foundation.dart' show debugPrint, listEquals, visibleForTesting;
import 'package:flutter/widgets.dart';
import 'package:media_kit/media_kit.dart';
import 'package:media_kit_video/media_kit_video.dart';
import 'package:signals/signals_flutter.dart'; import 'package:signals/signals_flutter.dart';
import 'package:sync_relay_core/core.dart' show JellyfinStreamType; import 'package:sync_relay_core/core.dart' show JellyfinStreamType, createLogger;
import '../jellyfin/track_prefs.dart' show MpvTrack; import '../jellyfin/track_prefs.dart' show MpvTrack;
import '../platform/ports/player_host.dart';
import '../shaders/shader_assets.dart'; import '../shaders/shader_assets.dart';
import '../shaders/shader_presets.dart'; import '../shaders/shader_presets.dart';
import '../state/settings.dart'; import '../state/settings.dart';
import '../sync/state_diff.dart'; import '../sync/state_diff.dart';
import '../sync/user_seek.dart';
import 'frame_watchdog.dart';
import 'media_marks.dart'; import 'media_marks.dart';
/// Owns one mpv [Player] (and its [VideoController]) per session: created export '../platform/ports/player_host.dart' show RenderMode, HostedPlayer, HostedVideo;
/// by [init] on join, released by [dispose] on leave, swapped by
/// [recreate] for "Reload video". Never reuse a player across sessions —
/// a GPU-side freeze sticks to the player, not to the widget.
class PlaybackController {
/// Read-ahead target: about two minutes rides out short outages and
/// makes skipping an intro instant.
static const readAheadSecs = 150;
/// 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:
/// media_kit sets cache-on-disk but no cache-dir, so mpv falls back.
static const maxBytes = 256 * 1024 * 1024;
/// Back buffer (media_kit sets it to bufferSize too): enough for a short
/// seek back without holding another 256 MiB.
static const maxBackBytes = 64 * 1024 * 1024;
SyncPlayer? _player; final _log = createLogger('mpv');
VideoController? _video;
Timer? _watchTimer; /// The session player as everyone else sees it: whichever player the media
bool _probing = false; /// pipeline made current ([attach]), its marks as signals (position,
final FrameWatchdog _watchdog = FrameWatchdog(); /// chapters, tracks, ...), the commands sent to it and the shaders on it.
StreamSubscription<PlayerLog>? _logSub; /// It never creates or disposes a player (the pipeline does, through the
final List<StreamSubscription<Duration>> _timeSubs = []; /// [PlayerHost]). Commands that come while a switch is under way (no
/// player, or its media not loaded yet) wait and go to the new player once
/// ([apply]): a seek into a player still opening is what made mpv log
/// "error running command seek".
class PlaybackController {
final PlayerHost host;
PlaybackController(this.host) {
_httpSub = host.httpErrors.listen((e) => _httpError = e);
// The probe found software rendering while shaders are on: say so.
_renderSub = host.renderMode.subscribe((mode) {
if (mode == RenderMode.software && _shaders.isNotEmpty) {
_notice('Shaders have no effect: video uses software rendering');
}
});
}
/// mpv's `volume-max`: the volume controls go this far.
static const maxVolume = 150;
HostedPlayer? _player;
HostedVideo? _video;
StreamSubscription<PlayerLogLine>? _logSub;
StreamSubscription<String>? _httpSub;
void Function()? _renderSub;
final List<StreamSubscription<Object?>> _timeSubs = [];
String _chapterJson = ''; String _chapterJson = '';
// `glsl-shaders` as last set on the current player ('' = none). // `glsl-shaders` as last set on the current player ('' = none).
String _shaders = ''; String _shaders = '';
@@ -47,406 +52,210 @@ class PlaybackController {
int _noticeId = 0; int _noticeId = 0;
/// Bumped for every new player; the video view keys itself on it so a /// Bumped for every new player; the video view keys itself on it so a
/// recreated player gets a fresh `Video` widget. /// new player gets a fresh `Video` widget.
final generation = signal(0); final generation = signal(0);
/// True while a live player exists. The video view shows plain black /// True while a live player is attached. The video view shows plain
/// otherwise, so no texture outlives its player. /// black otherwise, so no texture outlives its player.
final alive = signal(false); final alive = signal(false);
/// Called once when the watchdog sees a frozen picture. /// The current media failed to load (the HTTP error when there was one,
void Function()? onFrozen; /// e.g. a CDN's 403), at most once per [beginOpen].
void Function(String error)? onLoadError;
/// Fingerprint of the picture on screen, installed by the video view /// The current media's HTTP stream lost its size (see [mpvStreamLost]):
/// (`ShownFrameProbe.sample`). Without it the watchdog stays idle. /// at most once per player and media; the coordinator opens it again.
Future<int?> Function()? shownFrame; void Function()? onStreamLost;
Player get player => _player!; /// Seeks from the controls (seek bar, the video widget's own) go here
/// instead of to mpv.
/// Seeks from the video controls (seek bar) go here instead of to mpv.
void Function(Duration position)? onUserSeek; void Function(Duration position)? onUserSeek;
/// Latest shader message for the user (the session screen toasts it). // Per opened media: the last HTTP error ffmpeg logged, and whether a
// load error was reported.
String? _httpError;
bool _loadErrorSent = false;
bool _streamLostSent = false;
/// The current player. Only while [alive].
HostedPlayer get player => _player!;
/// The current player, null between sessions.
HostedPlayer? get current => _player;
/// Latest shader message for the user (the session player toasts it).
final shaderNotice = signal<ShaderNotice?>(null); final shaderNotice = signal<ShaderNotice?>(null);
/// How media_kit renders the current player's video (shaders need /// How video renders (the host's last probe; shaders need gpu).
/// [RenderMode.gpu]); [lastRenderMode] keeps it after leaving. ReadonlySignal<RenderMode> get renderMode => host.renderMode;
final renderMode = signal(RenderMode.unknown);
static final lastRenderMode = signal(RenderMode.unknown);
// ------------------------------------------------- media marks (signals) // ------------------------------------------------- media marks (signals)
// //
// What the open file has, for any controls UI (the session's seek bar, // What the open file has, for any controls UI. Reset when the player goes
// chapter / track menus and skip pills read these; nothing here knows // away; [segments] is owned by the coordinator (it survives Reload video,
// about widgets). Reset when the player goes away; [segments] is owned // the item is the same).
// by the coordinator (it survives Reload video, the item is the same).
/// mpv `chapter-list`: title and start; end = next start, or the /// mpv `chapter-list`: title and start; end = next start, or the
/// duration for the last one. Empty without chapters. /// duration for the last one. Empty without chapters.
final chapters = signal<List<MediaChapter>>(const []); final chapters = signal<List<MediaChapter>>(const []);
/// Jellyfin MediaSegments of the current item (Intro / Outro / Recap / /// Jellyfin MediaSegments of the current item. Only viewers with their
/// Preview / Commercial). Only viewers with their own account fetch /// own account fetch them: empty otherwise.
/// them (relay viewers cannot call Jellyfin): empty otherwise.
final segments = signal<List<MediaSegment>>(const []); final segments = signal<List<MediaSegment>>(const []);
/// Audio and subtitle tracks from mpv's `track-list` (id, language, /// Audio and subtitle tracks from mpv's `track-list`.
/// title, codec, channels, default / forced flags, [MpvTrack.selected]).
final tracks = signal<List<MpvTrack>>(const []); final tracks = signal<List<MpvTrack>>(const []);
/// Playback position, in 250 ms steps (enough for pills and menus; a /// Playback position, in 250 ms steps.
/// seek bar should listen to `player.stream.position`).
final position = signal(Duration.zero); final position = signal(Duration.zero);
/// Duration of the open file (zero until known). /// Duration of the open file (zero until known).
final duration = signal(Duration.zero); final duration = signal(Duration.zero);
/// End of the cached range ahead of the position (mpv /// End of the cached range ahead of the position, 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 opening ([openingRange]: Intro segment, else an OP-titled /// The player's volume, 0..[maxVolume]; kept across players.
/// chapter) and the ending ([endingRange]: Outro, else ED-titled). final volume = signal(100.0);
late final Computed<SkipRange?> opening =
computed(() => openingRange(segments.value, chapters.value)); late final Computed<SkipRange?> opening = computed(() => openingRange(segments.value, chapters.value));
late final Computed<SkipRange?> ending = late final Computed<SkipRange?> ending = computed(() => endingRange(segments.value, chapters.value));
computed(() => endingRange(segments.value, chapters.value));
/// Index into [chapters] of the chapter playing now. /// Index into [chapters] of the chapter playing now.
late final Computed<int?> currentChapter = late final Computed<int?> currentChapter = computed(() => chapterIndexAt(chapters.value, position.value));
computed(() => chapterIndexAt(chapters.value, position.value));
/// Pinned mpv properties whose read-back differs from what was set /// The [HostedVideo] of the current player. Created by the video view
/// (mpv ignores invalid values silently), as `name: wanted -> got`. /// when the pipeline did not make one ahead (it needs a running Flutter
final List<String> pinMismatches = []; /// binding; headless tests never touch it). The first one of an app
bool get _ready => _player != null; /// start probes the render mode.
HostedVideo get video =>
_video ??= host.video(player, probe: host.renderMode.peek() == RenderMode.unknown);
/// The [VideoController] of the current player. Created lazily by the /// The current player has a video output (the view is up).
/// video view (it needs a running Flutter binding; headless tests never bool get hasVideo => _video != null;
/// touch it) and released with the player.
VideoController get video {
final existing = _video;
if (existing != null) return existing;
final hwdec = _hwdecOverride;
// media_kit's video output sets hwdec itself on attach ('auto' unless
// configured), which used to override SYNC_RELAY_HWDEC.
final video = _video = VideoController(
player,
configuration: VideoControllerConfiguration(hwdec: hwdec),
);
unawaited(_detectRenderMode(player, video));
return video;
}
// SYNC_RELAY_HWDEC wins over the Settings choice; read per player, so a // ------------------------------------------------------------- attach
// changed setting applies on the next join or Reload video.
static String? get _hwdecOverride {
final hwdec = Platform.environment['SYNC_RELAY_HWDEC'];
if (hwdec != null && hwdec.isNotEmpty) return hwdec;
return AppSettings.instance.hwdec.peek();
}
Future<void> init() async { /// [p] becomes the session player (the first one, a fresh one per media,
if (_player != null) return; /// a promoted preload, Reload video's): log, marks and track observers
_player = SyncPlayer( /// move over, the view swaps to [video] ([generation]), volume and
configuration: PlayerConfiguration( /// shaders carry over. Returns the previous player for the pipeline to
bufferSize: maxBytes, /// retire. [loaded]: [p] already holds its media (a promoted preload).
libass: true, HostedPlayer? attach(HostedPlayer p, {HostedVideo? video, bool loaded = false}) {
vo: Platform.environment['SYNC_RELAY_VO'], final old = _player;
// Verbose: the render-mode probe needs mpv's GL init lines. _unwatch();
logLevel: MPVLogLevel.v, _player = p;
)) _video = video;
..onUserSeek = (d) => onUserSeek?.call(d); _chapterJson = '';
_httpError = null;
_loadErrorSent = false;
_streamLostSent = false;
_shaders = ''; _shaders = '';
renderMode.value = RenderMode.unknown; _loaded = loaded && p.state.duration > Duration.zero;
shaderNotice.value = null; _opened = loaded;
_logSub = _player!.stream.log.listen(_onLog); p.onUserSeek = (d) => onUserSeek?.call(d);
_watchMarks(_player!); final st = p.state;
await _pinBufferProps(); position.value = _step(st.position);
await applyShaders(); duration.value = st.duration;
_watchdog.reset(); buffered.value = _step(st.buffer);
_watchTimer = Timer.periodic(const Duration(seconds: 1), (_) => _watch()); chapters.value = const [];
tracks.value = const [];
_logSub = p.logs.listen(_onLog);
_watchMarks(p);
generation.value++; generation.value++;
alive.value = true; alive.value = true;
unawaited(p.setVolume(volume.peek()).catchError((Object e) => debugPrint('new player volume: $e')));
unawaited(applyShaders());
unawaited(_refreshTracks());
if (_loaded) unawaited(_flush());
return old;
} }
/// Dispose the current player and start a fresh one (no media loaded). /// The player leaves (leave, Reload video): the view drops its texture
/// Keeps volume. The caller reopens media and resyncs. /// ([alive] false) and the marks clear. Returns it for the pipeline to
Future<void> recreate() async { /// dispose.
final volume = _player?.state.volume; HostedPlayer? detach() {
await dispose();
await init();
if (volume != null) {
try {
await player.setVolume(volume);
} catch (_) {}
}
}
Future<void> _pin(String property, String value) async {
try {
final dynamic platform = player.platform;
await platform?.setProperty(property, value);
final got = '${await platform?.getProperty(property)}';
if (!pinMatches(value, got)) {
pinMismatches.add('$property: $value -> $got');
debugPrint('pin $property=$value not applied, mpv has "$got"');
}
} catch (e, st) {
pinMismatches.add('$property: $value -> error $e');
debugPrint('pin $property=$value failed: $e\n$st');
}
}
Future<void> _pinBufferProps() async {
pinMismatches.clear();
await _pin('demuxer-readahead-secs', '$readAheadSecs');
await _pin('cache-secs', '$readAheadSecs');
await _pin('demuxer-max-bytes', '$maxBytes');
await _pin('demuxer-max-back-bytes', '$maxBackBytes');
final hwdec = _hwdecOverride;
if (hwdec != null) {
await _pin('hwdec', hwdec);
}
await _pin('stream-lavf-o',
'reconnect_streamed=1,reconnect_on_http_error=1,reconnect_delay_max=4');
}
/// Puts the saved shader choice (Settings, Ctrl+0..6, shader button) on
/// the current player, live. A missing file or a value mpv won't take
/// turns shaders off and says why in [shaderNotice]; [announce] also
/// reports success ("Shaders: Mode A (HQ)").
Future<void> applyShaders({bool announce = false}) =>
_shaderOp = _shaderOp.then((_) => _applyShaders(announce));
Future<void> _applyShaders(bool announce) async {
final p = _player; final p = _player;
if (p == null) return; _unwatch();
final s = AppSettings.instance; _player = null;
final preset = s.shaderPreset.peek(); _video = null;
final label = shaderLabel(preset, s.shaderQuality.peek()); _loaded = false;
String? problem; _pending.clear();
var paths = const <String>[]; alive.value = false;
try { _clearMarks();
paths = await shaderPathsFor( return p;
preset, s.shaderQuality.peek(), s.customShaders.peek());
if (preset == ShaderPreset.custom && paths.isEmpty) {
problem = 'no custom shader files picked';
} else {
problem = shaderPathsProblem(paths);
}
} catch (e) {
problem = 'could not unpack the shaders ($e)';
}
if (!identical(p, _player)) return;
if (problem != null) paths = const [];
final value = joinShaderPaths(paths);
if (value != _shaders || problem != null) {
final got = await setShaderValue(value);
if (!identical(p, _player)) return;
if (problem == null && got != value) {
problem = 'mpv did not take them';
await setShaderValue('');
}
}
if (problem != null) {
_turnShadersOff('$label failed: $problem — turned off');
} else if (announce) {
_notice('Shaders: $label${_softwareHint(paths)}');
}
} }
/// Sets mpv `glsl-shaders` on the current player and returns the void _unwatch() {
/// read-back (null on error). unawaited(_logSub?.cancel());
@visibleForTesting _logSub = null;
Future<String?> setShaderValue(String value) async { for (final sub in _timeSubs) {
try { unawaited(sub.cancel());
final dynamic platform = player.platform;
await platform?.setProperty('glsl-shaders', value);
final got = '${await platform?.getProperty('glsl-shaders')}';
_shaders = got;
if (got != value) debugPrint('glsl-shaders=$value not applied: "$got"');
return got;
} catch (e, st) {
debugPrint('glsl-shaders=$value failed: $e\n$st');
return null;
} }
_timeSubs.clear();
} }
void _turnShadersOff(String message) { /// A media is about to be opened in the current player: its marks and
AppSettings.instance.setShaderPreset(ShaderPreset.off); /// errors start over, and commands wait until it is loaded.
_notice(message); void beginOpen() {
_opened = true;
_chapterJson = '';
_httpError = null;
_loadErrorSent = false;
_streamLostSent = false;
_loaded = false;
chapters.value = const [];
tracks.value = const [];
buffered.value = Duration.zero;
} }
void _notice(String text) { static Duration _step(Duration d) {
shaderNotice.value = ShaderNotice(++_noticeId, text); final ms = d.inMilliseconds;
return Duration(milliseconds: ms - ms % 250);
} }
String _softwareHint(List<String> paths) => paths.isEmpty // Position, duration and volume from the player's streams; chapter-list
? '' // and track-list observed in mpv (read back as JSON).
: switch (renderMode.peek()) { void _watchMarks(HostedPlayer p) {
RenderMode.software => ' (no effect: video uses software rendering)', _timeSubs.add(p.volumeStream.listen((v) {
RenderMode.softwareGl => ' (likely slow: OpenGL runs on the CPU)', if (identical(p, _player)) volume.value = v;
_ => '',
};
// mpv logs from the render context: GL init lines mean GPU rendering;
// an error there while shaders are on is a shader that failed to load
// or compile (mpv then renders without it).
void _onLog(PlayerLog log) {
if (log.prefix != 'libmpv_render') return;
if (log.text.contains('suspected software renderer')) {
_setRenderMode(RenderMode.softwareGl);
} else if (log.text.startsWith('GL_VERSION') &&
renderMode.peek() == RenderMode.unknown) {
_setRenderMode(RenderMode.gpu);
} else if ((log.level == 'error' || log.level == 'fatal') &&
_shaders.isNotEmpty) {
debugPrint('shader error: ${log.text}');
final p = _player;
_shaders = '';
_shaderOp = _shaderOp.then((_) async {
if (!identical(p, _player)) return;
await setShaderValue('');
_turnShadersOff('Shaders failed in mpv (${log.text}) — turned off');
});
}
}
void _setRenderMode(RenderMode mode) {
renderMode.value = mode;
lastRenderMode.value = mode;
}
// media_kit's Linux/Windows video output creates mpv's render context
// while the controller is created: OpenGL when it can, else the
// software renderer, which never logs GL init (and ignores shaders).
Future<void> _detectRenderMode(Player p, VideoController video) async {
try {
await video.platform.future;
} catch (_) {
return;
}
await Future.delayed(const Duration(milliseconds: 1500));
if (!identical(p, _player) || renderMode.peek() != RenderMode.unknown) {
return;
}
_setRenderMode(RenderMode.software);
debugPrint('video output: software rendering (no GL init logged)');
if (_shaders.isNotEmpty) {
_notice('Shaders have no effect: video uses software rendering');
}
}
/// Fingerprint of the decoder's current frame (mpv `screenshot-raw`,
/// off-thread); null without video.
Future<int?> _sourceFrame(Player p) async {
try {
final raw = await p.screenshot(format: null);
return raw == null || raw.isEmpty ? null : hashSpread(raw);
} catch (_) {
return null;
}
}
/// A suspected freeze is real only if mpv's own frames still move: a
/// still scene looks frozen on screen too.
Future<bool> _sourceMoving(Player p) async {
final a = await _sourceFrame(p);
await Future.delayed(const Duration(milliseconds: 500));
if (!identical(p, _player) || !p.state.playing) return false;
final b = await _sourceFrame(p);
return a != null && b != null && a != b;
}
static bool get _windowVisible {
try {
final s = WidgetsBinding.instance.lifecycleState;
return s == null ||
s == AppLifecycleState.resumed ||
s == AppLifecycleState.inactive;
} catch (_) {
return false;
}
}
Future<void> _watch() async {
final p = _player;
final probe = shownFrame;
// Only meaningful once the video view is up.
if (p == null || _video == null || probe == null || _probing) return;
_probing = true;
try {
final frames = await probe();
if (!identical(p, _player)) return;
final s = p.state;
final eligible = s.playing &&
!s.buffering &&
!s.completed &&
s.rate > 0 &&
(s.width ?? 0) > 0 &&
_windowVisible;
final fired = _watchdog.feed(FrameSample(
at: DateTime.now(),
position: positionSecs,
frames: frames,
eligible: eligible,
));
if (fired) {
if (await _sourceMoving(p) && identical(p, _player)) {
debugPrint('video watchdog: picture stuck while mpv frames move');
onFrozen?.call();
} else {
_watchdog.dismiss();
}
}
} finally {
_probing = false;
}
}
/// Opens [url] paused. [headers] go with every request for it (mpv
/// `http-header-fields`): a Jellyfin token travels there, not in the URL.
Future<void> openUrl(String url, {Map<String, String>? headers}) async {
if (!_ready) return;
try {
_chapterJson = '';
chapters.value = const [];
tracks.value = const [];
buffered.value = Duration.zero;
// An empty map, not null: media_kit caches headers per URI.
await player.open(Media(url, httpHeaders: headers ?? const {}), play: false);
} catch (e, st) {
debugPrint('open ${Uri.tryParse(url)?.replace(query: '') ?? 'media'} failed: $e\n$st');
rethrow;
}
}
// Position / duration from media_kit's streams; chapter-list and
// track-list observed in mpv (read back as JSON).
void _watchMarks(SyncPlayer p) {
_timeSubs.add(p.stream.position.listen((d) {
final ms = d.inMilliseconds;
position.value = Duration(milliseconds: ms - ms % 250);
})); }));
_timeSubs.add(p.stream.buffer.listen((d) { _timeSubs.add(p.positionStream.listen((d) {
final ms = d.inMilliseconds; // At the end the bar shows full: mpv's last position is the last
buffered.value = Duration(milliseconds: ms - ms % 250); // frame's start (and the 250 ms steps round down), short of duration.
if (p.state.completed && duration.value > Duration.zero) {
position.value = duration.value;
return;
}
position.value = _step(d);
// Media without a known length (a live link) counts as loaded once
// it plays.
if (d > Duration.zero && !_loaded && _opened && identical(p, _player)) {
_loaded = true;
unawaited(_flush());
}
})); }));
_timeSubs.add(p.stream.duration.listen((d) { _timeSubs.add(p.completedStream.listen((done) {
if (done && duration.value > Duration.zero) position.value = duration.value;
}));
_timeSubs.add(p.bufferStream.listen((d) => buffered.value = _step(d)));
_timeSubs.add(p.durationStream.listen((d) {
duration.value = d; duration.value = d;
// The last chapter ends at the duration. // The last chapter ends at the duration.
if (_chapterJson.isNotEmpty) chapters.value = parseChapterList(_chapterJson, d); if (_chapterJson.isNotEmpty) chapters.value = parseChapterList(_chapterJson, d);
if (d > Duration.zero && !_loaded && identical(p, _player)) {
_loaded = true;
unawaited(_flush());
}
})); }));
() async { () async {
try { try {
final dynamic platform = p.platform; await p.observeProperty('chapter-list', (String v) async {
await platform?.observeProperty('chapter-list', (String v) async {
if (!identical(p, _player)) return; if (!identical(p, _player)) return;
_chapterJson = v; _chapterJson = v;
chapters.value = parseChapterList(v, duration.peek()); chapters.value = parseChapterList(v, duration.peek());
}); });
await platform?.observeProperty('track-list', (String v) async { await p.observeProperty('track-list', (String v) async {
if (!identical(p, _player)) return; if (!identical(p, _player)) return;
_setTracks(parseTrackList(v)); _setTracks(parseTrackList(v));
}); });
@@ -460,14 +269,11 @@ class PlaybackController {
if (!listEquals(tracks.peek(), list)) tracks.value = list; if (!listEquals(tracks.peek(), list)) tracks.value = list;
} }
/// Reads `track-list` now (after a selection: [tracks] then shows it at
/// once instead of on mpv's next change notice).
Future<void> _refreshTracks() async { Future<void> _refreshTracks() async {
final p = _player; final p = _player;
if (p == null) return; if (p == null) return;
try { try {
final dynamic platform = p.platform; final v = await p.getProperty('track-list');
final v = '${await platform?.getProperty('track-list')}';
if (identical(p, _player)) _setTracks(parseTrackList(v)); if (identical(p, _player)) _setTracks(parseTrackList(v));
} catch (_) {} } catch (_) {}
} }
@@ -481,9 +287,129 @@ class PlaybackController {
buffered.value = Duration.zero; buffered.value = Duration.zero;
} }
// -------------------------------------------------------------- log
// The current player's log: load failures become player errors; a render
// error while shaders are on is a shader that failed to load or compile
// (mpv then renders without it).
void _onLog(PlayerLogLine log) {
if (log.prefix == 'ffmpeg') {
if (!_streamLostSent && mpvStreamLost(log)) {
_streamLostSent = true;
_log.warning('the media stream lost its size, every seek fails: [ffmpeg] ${log.text.trim()}');
onStreamLost?.call();
}
return;
}
if (!_loadErrorSent && mpvLoadFailed(log, duration.peek())) {
_loadErrorSent = true;
final http = _httpError;
_log.warning('cannot open the media: [${log.prefix}] ${log.text.trim()}'
'${http == null ? ' (no HTTP error logged; a request that timed out after 5 s looks like this)' : ' ($http)'}');
onLoadError?.call(http == null ? 'the video stream could not be opened' : 'the video server answered $http');
return;
}
if (log.prefix == 'libmpv_render' && log.isError && _shaders.isNotEmpty) {
debugPrint('shader error: ${log.text}');
final p = _player;
_shaders = '';
_shaderOp = _shaderOp.then((_) async {
if (!identical(p, _player)) return;
await setShaderPaths(const []);
_turnShadersOff('Shaders failed in mpv (${log.text}) — turned off');
});
}
}
// ---------------------------------------------------------- shaders
/// Puts the saved shader choice on the current player, live. A missing
/// file or a value mpv won't take turns shaders off and says why in
/// [shaderNotice]; [announce] also reports success.
Future<void> applyShaders({bool announce = false}) =>
_shaderOp = _shaderOp.then((_) => _applyShaders(announce));
Future<void> _applyShaders(bool announce) async {
final p = _player;
if (p == null) return;
final s = AppSettings.instance;
final preset = s.shaderPreset.peek();
final label = shaderLabel(preset, s.shaderQuality.peek());
String? problem;
var paths = const <String>[];
try {
paths = await shaderPathsFor(preset, s.shaderQuality.peek(), s.customShaders.peek());
if (preset == ShaderPreset.custom && paths.isEmpty) {
problem = 'no custom shader files picked';
} else {
problem = shaderPathsProblem(paths);
}
} catch (e) {
problem = 'could not unpack the shaders ($e)';
}
if (!identical(p, _player)) return;
if (problem != null) paths = const [];
if (joinShaderPaths(paths) != _shaders || problem != null) {
final took = await setShaderPaths(paths);
if (!identical(p, _player)) return;
if (problem == null && !took) {
problem = 'mpv did not take them';
await setShaderPaths(const []);
}
}
if (problem != null) {
_turnShadersOff('$label failed: $problem — turned off');
} else if (announce) {
_notice('Shaders: $label${_softwareHint(paths)}');
}
}
/// Sets mpv `glsl-shaders` to [paths] on the current player; true when
/// mpv's read-back is that list ([shaderListMatches]: old mpv prints it
/// joined with ',').
@visibleForTesting
Future<bool> setShaderPaths(List<String> paths) async {
final p = _player;
if (p == null) return false;
final value = joinShaderPaths(paths);
try {
await p.setProperty('glsl-shaders', value);
final got = await p.getProperty('glsl-shaders');
final took = shaderListMatches(paths, got);
_shaders = took ? value : got;
if (!took) debugPrint('glsl-shaders=$value not applied: "$got"');
return took;
} catch (e, st) {
debugPrint('glsl-shaders=$value failed: $e\n$st');
return false;
}
}
void _turnShadersOff(String message) {
AppSettings.instance.setShaderPreset(ShaderPreset.off);
_notice(message);
}
void _notice(String text) => shaderNotice.value = ShaderNotice(++_noticeId, text);
String _softwareHint(List<String> paths) => paths.isEmpty
? ''
: switch (renderMode.peek()) {
RenderMode.software => ' (no effect: video uses software rendering)',
RenderMode.softwareGl => ' (likely slow: OpenGL runs on the CPU)',
_ => '',
};
/// `mpv-version` and `ffmpeg-version` of the session player.
Future<String?> mpvVersion() async {
final p = _player;
return p == null ? null : host.versions(p);
}
// ------------------------------------------------------------ tracks
/// Waits (bounded) until the open file's tracks are listed and mpv has /// Waits (bounded) until the open file's tracks are listed and mpv has
/// selected its audio track, so a report or a carried-over choice sees /// selected its audio track.
/// the real selection.
Future<List<MpvTrack>> waitForTracks({Duration timeout = const Duration(seconds: 5)}) async { Future<List<MpvTrack>> waitForTracks({Duration timeout = const Duration(seconds: 5)}) async {
final end = DateTime.now().add(timeout); final end = DateTime.now().add(timeout);
while (_player != null && DateTime.now().isBefore(end)) { while (_player != null && DateTime.now().isBefore(end)) {
@@ -500,9 +426,8 @@ class PlaybackController {
final p = _player; final p = _player;
if (p == null) return (aid: null, sid: null); if (p == null) return (aid: null, sid: null);
try { try {
final dynamic platform = p.platform; final aid = int.tryParse(await p.getProperty('current-tracks/audio/id'));
final aid = int.tryParse('${await platform?.getProperty('current-tracks/audio/id')}'); final sid = int.tryParse(await p.getProperty('current-tracks/sub/id'));
final sid = int.tryParse('${await platform?.getProperty('current-tracks/sub/id')}');
return (aid: aid, sid: sid); return (aid: aid, sid: sid);
} catch (_) { } catch (_) {
return (aid: null, sid: null); return (aid: null, sid: null);
@@ -515,12 +440,11 @@ class PlaybackController {
final p = _player; final p = _player;
if (p == null) return; if (p == null) return;
try { try {
final dynamic platform = p.platform; if (aid != null) await p.setProperty('aid', '$aid');
if (aid != null) await platform?.setProperty('aid', '$aid');
if (subtitlesOff) { if (subtitlesOff) {
await platform?.setProperty('sid', 'no'); await p.setProperty('sid', 'no');
} else if (sid != null) { } else if (sid != null) {
await platform?.setProperty('sid', '$sid'); await p.setProperty('sid', '$sid');
} }
} catch (e) { } catch (e) {
debugPrint('select tracks failed: $e'); debugPrint('select tracks failed: $e');
@@ -528,6 +452,8 @@ class PlaybackController {
await _refreshTracks(); await _refreshTracks();
} }
// ---------------------------------------------------------- position
double get positionSecs { double get positionSecs {
final p = _player; final p = _player;
if (p == null) return 0; if (p == null) return 0;
@@ -537,7 +463,8 @@ class PlaybackController {
double get bufferedSecsAhead { double get bufferedSecsAhead {
final p = _player; final p = _player;
if (p == null) return 0; if (p == null) return 0;
final ahead = (p.state.buffer - p.state.position).inMilliseconds / 1000.0; final st = p.state;
final ahead = (st.buffer - st.position).inMilliseconds / 1000.0;
if (ahead.isNaN || ahead < 0) return 0; if (ahead.isNaN || ahead < 0) return 0;
return ahead; return ahead;
} }
@@ -548,23 +475,62 @@ class PlaybackController {
return p.state.duration.inMilliseconds / 1000.0; return p.state.duration.inMilliseconds / 1000.0;
} }
/// The player is playing (not paused, not waiting for a switch).
bool get playing => _player?.state.playing ?? false;
// ---------------------------------------------------------- commands
// The current player holds its media (a duration is known since
// [beginOpen]); until then commands wait in [_pending].
bool _loaded = false;
// An open was started on the current player since it was attached.
bool _opened = false;
final _pending = <PlayerAction>[];
/// Commands applied to mpv, per kind (tests: a switch applies queued
/// ones once).
@visibleForTesting
int seeksSent = 0;
/// A switch is under way: no player, or its media is not loaded yet.
bool get switching => _player == null || !_loaded;
/// Applies [actions] (from the sync diff, a user seek, the play gate):
/// at once to a loaded player, else queued for the new one, where the
/// latest seek, transport and rate apply once it is loaded.
Future<void> apply(List<PlayerAction> actions) async { Future<void> apply(List<PlayerAction> actions) async {
if (!_ready) return; if (switching) {
for (final a in actions) {
_pending
..removeWhere((p) => p.runtimeType == a.runtimeType)
..add(a);
}
return;
}
await _run(actions);
}
Future<void> _flush() async {
if (_pending.isEmpty) return;
final list = [..._pending];
_pending.clear();
await _run(list);
}
Future<void> _run(List<PlayerAction> actions) async {
final p = _player;
if (p == null) return;
for (final action in actions) { for (final action in actions) {
try { try {
switch (action) { switch (action) {
case SeekAction(:final position): case SeekAction(:final position):
if ((positionSecs - position).abs() < 0.5) break; if ((positionSecs - position).abs() < 0.5) break;
await _player! seeksSent++;
.syncSeek(Duration(milliseconds: (position * 1000).round())); await p.syncSeek(Duration(milliseconds: (position * 1000).round()));
case TransportAction(:final playing): case TransportAction(:final playing):
if (playing) { playing ? await p.play() : await p.pause();
await player.play();
} else {
await player.pause();
}
case RateAction(:final rate): case RateAction(:final rate):
await player.setRate(rate); await p.setRate(rate);
} }
} catch (e, st) { } catch (e, st) {
debugPrint('player action $action failed: $e\n$st'); debugPrint('player action $action failed: $e\n$st');
@@ -572,61 +538,36 @@ class PlaybackController {
} }
} }
/// Release the player. The view drops its texture first ([alive] goes /// The controls' volume: on the player now, and on every next one.
/// false, one frame passes), then mpv is torn down. Future<void> setVolume(double v) async {
Future<void> dispose() async { final clamped = v.clamp(0.0, maxVolume * 1.0);
volume.value = clamped;
final p = _player;
if (p != null) await p.setVolume(clamped);
}
/// A seek from the controls (seek bar, keys): the sync layer's user
/// seek ([onUserSeek]).
void userSeek(Duration to) => onUserSeek?.call(to);
/// Stops what plays (the player stays for the next media).
Future<void> stop() async {
final p = _player; final p = _player;
if (p == null) return; if (p == null) return;
_watchTimer?.cancel(); _pending.clear();
_watchTimer = null; _loaded = false;
await _logSub?.cancel();
_logSub = null;
for (final sub in _timeSubs) {
await sub.cancel();
}
_timeSubs.clear();
_player = null;
_video = null;
alive.value = false;
_clearMarks();
await _settleUi();
try { try {
await p.dispose(); await p.stop();
} catch (_) {} } catch (e) {
} debugPrint('player stop failed: $e');
// 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`
// and leaves animated). Bounded, since a hidden window renders no
// frames; headless tests have no binding and skip it all.
static Future<void> _settleUi() async {
try {
for (var i = 0; i < 2; i++) {
await WidgetsBinding.instance.endOfFrame
.timeout(const Duration(milliseconds: 300));
}
await Future.delayed(const Duration(milliseconds: 350));
} catch (_) {}
}
Future<void> stop() async {
if (!_ready) return;
try {
await player.stop();
} catch (e, st) {
debugPrint('player stop failed: $e\n$st');
} }
} }
}
enum RenderMode { Future<void> dispose() async {
unknown, _unwatch();
/// OpenGL on a real GPU: shaders work. _renderSub?.call();
gpu, await _httpSub?.cancel();
/// OpenGL on a software rasterizer (llvmpipe): shaders crawl. }
softwareGl,
/// media_kit's software fallback: shaders are ignored.
software,
} }
class ShaderNotice { class ShaderNotice {
@@ -634,10 +575,3 @@ class ShaderNotice {
final String text; final String text;
const ShaderNotice(this.id, this.text); const ShaderNotice(this.id, this.text);
} }
/// mpv echoes numbers in its own format (`150` reads back `150.000000`).
bool pinMatches(String wanted, String got) {
if (wanted == got) return true;
final a = double.tryParse(wanted), b = double.tryParse(got);
return a != null && b != null && a == b;
}
@@ -0,0 +1,77 @@
import 'dart:async';
import 'dart:io';
import 'package:path_provider/path_provider.dart';
import 'package:sync_relay_core/core.dart';
import '../platform/ports/link_resolver.dart';
/// 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,
/// so the Link preview and the swap ask once.
class AppYtDlp implements LinkResolver {
static final instance = AppYtDlp();
/// [ytDlp]: a given one (tests); default in the app-support folder.
AppYtDlp([YtDlp? ytDlp]) : _ytDlp = ytDlp == null ? null : Future.value(ytDlp);
Future<YtDlp>? _ytDlp;
@override
Future<YtDlp> get ytDlp => _ytDlp ??= () async {
final base = await getApplicationSupportDirectory();
return YtDlp(dir(base.path));
}();
static String dir(String appSupport) => '$appSupport${Platform.pathSeparator}yt-dlp';
/// Gets yt-dlp ready in the background (download on first run, a daily
/// update check afterwards).
@override
bool get resolvesLinks => true;
@override
void warmUp() => unawaited(ytDlp.then((y) => y.ensure()).catchError((_) => null));
final _probes = <String, ({DateTime at, DateTime until, Future<LinkProbe> f})>{};
static const _probeTtl = Duration(minutes: 10);
/// What [url] is, from memory while it is kept (10 minutes from when it
/// was probed; [keep] keeps it longer, for Up next's first item, which
/// is probed again before that runs out). [fresh] probes again: the
/// remembered result stays until the new one is in, and stays when the
/// new one fails.
@override
Future<LinkProbe> probe(String url, {Duration keep = _probeTtl, bool fresh = false}) {
final now = DateTime.now();
_probes.removeWhere((_, v) => now.isAfter(v.until));
final hit = _probes[url];
if (hit != null && !fresh) {
final longer = hit.at.add(keep);
if (longer.isAfter(hit.until)) _probes[url] = (at: hit.at, until: longer, f: hit.f);
return hit.f;
}
final f = ytDlp.then((y) => probeLink(Uri.parse(url), ytDlp: y));
final until = now.add(keep);
if (hit == null) {
_probes[url] = (at: now, until: until, f: f);
f.catchError((Object _) {
if (_probes[url]?.f == f) _probes.remove(url);
return LinkProbe.file(Uri(), -1);
});
} else {
f.then<void>((_) {
_probes[url] = (at: now, until: until, f: f);
}, onError: (Object _) {});
}
return f;
}
/// [url] no longer needs keeping longer: back to the usual 10 minutes.
@override
void release(String url) {
final hit = _probes[url];
if (hit == null) return;
final usual = hit.at.add(_probeTtl);
if (usual.isBefore(hit.until)) _probes[url] = (at: hit.at, until: usual, f: hit.f);
}
}
@@ -0,0 +1,898 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:math';
import 'package:flutter/foundation.dart';
import 'package:signals/signals_flutter.dart';
import 'package:sync_relay_core/core.dart';
import '../files/thumbnails.dart';
import '../jellyfin/accounts.dart';
import '../jellyfin/format.dart' show pickFor;
import '../jellyfin/session_media.dart';
import '../library/media_refs.dart';
import '../screens/home/picked_media.dart';
import '../session/app_events.dart' show AddedToUpNext;
import '../session/media_art.dart';
import '../session/session_coordinator.dart';
import '../sync/avatar_cache.dart';
// What's next on this app (see ARCHITECTURE.md "What's next" and "Up
// next"): the server's queue as signals (Up next and its continuation),
// adding to it (probed, with a picture), the continuation of what this
// app put up (the next Jellyfin episode, the next file of a local show),
// the server's countdown as the end card shows it, putting an item up
// when the server asks this app (`queuePlay`), and keeping the item that
// plays next ready (the media pipeline prepares it).
/// The end-of-media card for the first item (Up next's, else the
/// continuation). [secondsLeft]: the server's countdown (`state.advance`);
/// null when none runs (cancelled, or nothing counts down), then the card
/// offers Play now only. [waitingFor]: the countdown ran out and the one
/// who puts the item up is not here (its name).
class UpNextCountdown {
final int mediaId;
final QueueItem item;
final int? secondsLeft;
final String? waitingFor;
const UpNextCountdown({required this.mediaId, required this.item, this.secondsLeft, this.waitingFor});
}
/// The first item's readiness on this app.
enum WarmState { idle, warming, ready, failed }
class QueueController {
final SessionCoordinator coord;
QueueController(this.coord);
/// Queued items' pictures by hash, from `/i/`.
late final PictureCache thumbs = PictureCache(coord.connection.client,
route: 'i', keep: RelayConstants.maxQueueItems + 8, live: (s) => [for (final q in s.queue) q.thumb]);
/// Up next, in order, without the continuation (empty when nothing is
/// queued or not in a server).
late final Computed<List<QueueItem>> items = computed(
() => [for (final q in coord.serverState.value?.queue ?? const <QueueItem>[]) if (!q.auto) q]);
/// The continuation of what plays (`auto`: the next episode), shared by
/// everyone; null when none.
late final Computed<QueueItem?> continues =
computed(() => coord.serverState.value?.queue.where((q) => q.auto).firstOrNull);
/// What plays next: Up next's first item, else the continuation.
late final Computed<QueueItem?> head = computed(() => coord.serverState.value?.queue.firstOrNull);
/// The end card while the media has ended with something next (see
/// [UpNextCountdown]).
final countdown = signal<UpNextCountdown?>(null);
/// The first item's id and how ready it is here (null: nothing to get
/// ready on this app).
final warm = signal<({String id, WarmState state})?>(null);
/// The next episodes of the Jellyfin series playing, on an app that
/// plays it with its own account (the first one is what continues it;
/// the rest show greyed under "Continues with", "+" adds one).
final continuation = signal<List<EpisodeLink>>(const []);
/// The mediaId whose continuation was cleared (by anyone: `cancel`).
final _hiddenFor = signal(-1);
/// The queue popover is open: the Up next button holds the controls up.
final popoverOpen = signal(false);
// What adder-only items stand for, by their key (a link passed on from
// here, a file here). Never leaves this app.
final Map<String, ({PickedMedia pick, DateTime at})> _local = {};
final Map<String, DateTime> _performing = {};
Timer? _timer;
bool _attached = false;
// The mediaId whose continuation this app added (or is adding).
int _continuedFor = -1;
// A restarted server waits for what played (that mediaId) to come back.
int _restoreFrom = -1;
String _continuationFor = '';
final _random = Random.secure();
String get _me => coord.connection.memberId;
void attach() {
if (_attached) return;
_attached = true;
coord.connection.states.listen(_onState);
// `cancel` from anyone: no continuation for that media any more.
coord.connection.feeds.listen((f) {
final id = coord.serverState.peek()?.mediaId;
if (f.action == 'advance-cancel' && id != null) _hiddenFor.value = id;
});
effect(() {
final next = coord.nextEpisode.value;
final plan = coord.jellyfinPlan.value;
unawaited(_loadContinuation(next, plan));
});
// What continues this media becomes known (the next episode found, a
// file of ours up): add it.
effect(() {
coord.nextEpisode.value;
coord.ownFile.value;
untracked(() => _continue(coord.serverState.peek()));
});
// The state decides what to get ready; the media playing having
// started (or sitting out) lets the preload go.
effect(() {
coord.settledMediaId.value;
coord.selfAway.value;
final r = coord.serverState.value;
untracked(() => _warm(r));
});
}
// ------------------------------------------------------------- adding
/// Adds [pick] to Up next (at the end, or with [next] right after what
/// plays). A link is probed first and refused like Put it up when it
/// will not play. Returns an error text, or null when sent.
Future<String?> add(PickedMedia pick, {bool next = false}) async {
coord.markInput();
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) {
return 'Up next is full (${RelayConstants.maxQueueItems} items)';
}
final ({QueueItem item, Uint8List? thumb}) built;
try {
final b = await _build(pick);
if (b is String) return b;
built = b as ({QueueItem item, Uint8List? thumb});
} catch (e) {
return "couldn't add it: $e";
}
final item = built.item;
if (item.key.isNotEmpty) _local[item.key] = (pick: pick, at: DateTime.now());
final thumb = built.thumb;
coord.connection.sendQueue(QueueIntent.add(item, next: next, thumb: thumb == null ? '' : base64Encode(thumb)));
coord.notify(AddedToUpNext(item.title));
return null;
}
/// A continuation episode becomes an Up next item.
Future<String?> promote(EpisodeLink e) => add(e.pick);
/// The item, or an error text.
Future<Object> _build(PickedMedia pick) async {
switch (pick) {
case JellyfinMedia():
final accounts = coord.jellyfin;
if (accounts.account(pick.serverId) == null) return 'that Jellyfin server is not linked any more';
if (accounts.statusOf(pick.serverId) == JellyfinAccountStatus.expired) {
return 'Jellyfin sign-in expired — reconnect it in Settings → Jellyfin & integrations';
}
final client = accounts.clientFor(pick.serverId);
final thumb = client == null ? null : await _jellyfinThumb(client, pick.itemId);
return (
item: QueueItem(
media: mediaForPick(pick),
how: QueueHow.jellyfin,
title: pick.title,
sub: pick.subtitle,
duration: (pick.runtime?.inMilliseconds ?? 0) / 1000,
),
thumb: thumb,
);
case FileMedia(:final path):
final file = File(path);
if (!await file.exists()) return 'file not found — check the path';
final name = pick.label;
final parsed = parseReleaseName(name);
final shot = await thumbnailFor(path);
Uint8List? thumb;
if (shot != null) {
try {
final bytes = await File(shot.imagePath).readAsBytes();
if (bytes.length <= RelayConstants.maxPosterBytes) thumb = bytes;
} catch (_) {}
}
// The library's name for it when it knows the show (what friends
// see in Up next), else the release name's.
final what = coord.fileRef?.call(path);
final ep = what != null && what.kind == 'episode'
? (refEpisodeLine(what) ?? '').replaceAll(' ', '')
: [
if (parsed.season != null) 'S${parsed.season}',
if (parsed.episode != null) 'E${parsed.episode}',
if (parsed.episode == null) ?parsed.extra,
].join();
final title = what != null && what.title.isNotEmpty ? what.title : parsed.series.trim();
return (
item: QueueItem(
media: HelloMedia(name: name, size: await file.length(), duration: 0, ref: what),
how: QueueHow.file,
title: title.isEmpty ? name : title,
sub: ep,
duration: (shot?.duration.inMilliseconds ?? 0) / 1000,
key: _newKey(),
),
thumb: thumb,
);
case LinkMedia(:final url, :final anyway):
// Alone, a link plays direct: there is nobody to pass it on to.
final relay = coord.alone.peek() ? LinkRelay.direct : pick.relay;
final linked = await mediaForLink(coord.jellyfin, url);
final ref = linked.jellyfin;
if (ref != null) {
return (
item: QueueItem(
media: linked,
how: QueueHow.jellyfin,
title: ref.title.isEmpty ? linked.name : ref.title,
sub: ref.subtitle ?? '',
duration: ref.runtimeSecs ?? 0),
thumb: null,
);
}
LinkProbe? probe;
String? probeError;
try {
probe = await coord.ytDlp.probe(url).timeout(const Duration(seconds: 45));
} catch (e) {
probeError = '$e';
}
final problem = probe == null ? playErrorText(probeError ?? '') : probe.problem;
if (problem != null && !anyway) return problem;
if (relay == LinkRelay.me) {
if (probe == null) return problem ?? "couldn't reach the site";
final relayProblem = probe.relayProblem;
if (relayProblem != null) return relayProblem;
}
if (relay == LinkRelay.server && !coord.serverHasPassword) return 'server fetch needs a server password';
if (relay == LinkRelay.server && probe != null && probe.isPage && probe.info != null) {
final relayProblem = probe.relayProblem;
if (relayProblem != null) return relayProblem;
}
final uri = Uri.tryParse(url) ?? Uri();
final info = probe?.info;
final title = probe?.title ?? linked.name;
final site = info == null
? uri.host
: (info.uploader.isNotEmpty ? info.uploader : (info.extractor.isNotEmpty ? info.extractor : uri.host));
final thumbUrl = info?.thumbnail == null ? null : Uri.tryParse(info!.thumbnail!);
final thumb = thumbUrl == null ? null : (await thumbnailArt(thumbUrl))?.poster;
final how = switch (relay) {
LinkRelay.direct => QueueHow.direct,
LinkRelay.server => QueueHow.server,
LinkRelay.me => QueueHow.through,
};
return (
item: QueueItem(
media: HelloMedia(
name: title,
size: 0,
duration: 0,
url: how == QueueHow.through ? null : url,
relay: how == QueueHow.server ? const MediaRelay(fetch: true) : null,
ref: linkMediaRef(title)),
how: how,
title: title,
sub: site,
duration: info?.duration ?? 0,
key: how == QueueHow.through ? _newKey() : '',
),
thumb: thumb,
);
}
}
String _newKey() => [for (var i = 0; i < 12; i++) _random.nextInt(256).toRadixString(16).padLeft(2, '0')].join();
/// A wide picture of a Jellyfin item: an episode's own still, else the
/// item's (or series') thumb or backdrop, else its poster.
Future<Uint8List?> _jellyfinThumb(JellyfinClient client, String itemId) async {
try {
final item = await client.item(itemId).timeout(const Duration(seconds: 10));
final ref = (item.kind == JellyfinItemKind.episode ? item.ownImage(JellyfinImageType.primary) : null) ??
item.image(JellyfinImageType.thumb) ??
item.image(JellyfinImageType.backdrop) ??
item.image(JellyfinImageType.primary);
if (ref == null) return null;
return await fetchImage(
client.imageUrl(ref, maxWidth: 480, quality: 80), client.authHeaders, RelayConstants.maxPosterBytes);
} catch (e) {
debugPrint('queue: no Jellyfin picture for $itemId: $e');
return null;
}
}
/// [item]'s link as this app may hand it out (Copy link, Open in
/// browser): a link everyone opens (it is in the state), or a link this
/// app passes on (its adder). Null otherwise: a server-fetched link stays
/// with the server, another member's passed-on link with them.
String? linkOf(QueueItem item) => switch (item.how) {
QueueHow.direct => item.media.url,
QueueHow.through when item.by == _me => switch (_local[item.key]?.pick) {
LinkMedia(:final url) => url,
_ => null,
},
_ => null,
};
/// [item]'s page on its Jellyfin server, when that server is linked here.
String? jellyfinPageOf(QueueItem item) {
final ref = item.media.jellyfin;
return ref == null ? null : coord.jellyfin.webUrlOf(ref.serverId, ref.itemId);
}
// ------------------------------------------------------------ editing
void move(String id, int to) {
coord.markInput();
coord.connection.sendQueue(QueueIntent.move(id, to));
}
void remove(String id) {
coord.markInput();
coord.connection.sendQueue(QueueIntent.remove(id));
}
/// Plays [id] now: this app puts it up when it is the one that can,
/// else it asks the server, and the app that can puts it up.
void playNow(String id) {
coord.markInput();
final r = coord.serverState.peek();
final item = r?.queue.where((q) => q.id == id).firstOrNull;
if (r == null || item == null) return;
if (queuePerformer(item, r.members).id == _me) {
_perform(item);
} else {
coord.connection.sendQueue(QueueIntent.play(id));
}
}
/// Plays what comes next now (Up next's first item, else the
/// continuation).
void skip() {
final first = head.peek();
if (first != null) playNow(first.id);
}
/// Cancel on the end card: the countdown stops for everyone, and the
/// continuation goes (`cancel`).
void cancelCountdown() {
coord.markInput();
coord.connection.sendQueue(const QueueIntent.cancel());
}
/// "Clear" under "Continues with": the continuation of what plays goes
/// for everyone (`cancel`) and does not come back for this media.
void clearContinuation() {
final id = coord.serverState.peek()?.mediaId;
if (id != null) _hiddenFor.value = id;
cancelCountdown();
}
/// The further episodes of [continuation] (after the one that continues
/// what plays), unless it was cleared for the media playing.
late final Computed<List<EpisodeLink>> shownContinuation = computed(() {
final id = coord.serverState.value?.mediaId;
if (id == null || _hiddenFor.value == id) return const <EpisodeLink>[];
final auto = continues.value?.media.jellyfin?.itemId;
return [for (final e in continuation.value) if (e.item.id != auto) e];
});
// --------------------------------------------------------- per state
void _onState(StateBroadcast r) {
_lastState = r;
// What played came back after a restart under a new mediaId: a
// continuation cleared for it stays cleared.
if (_restoreFrom != -1 && r.media != null && _hiddenFor.peek() == _restoreFrom) _hiddenFor.value = r.mediaId;
_prune(r);
// The server asks for an item (a countdown ran out, Play now from
// another app, nothing up yet): the app that puts it up does.
final play = r.queuePlay;
if (play != null) {
final item = r.queue.where((q) => q.id == play).firstOrNull;
if (item != null && queuePerformer(item, r.members).id == _me) _perform(item);
}
_syncCountdown(r);
_continue(r);
_warm(r);
}
/// Forgets what no longer matters: performing marks after a minute (a
/// swap that never landed may be tried again), local picks no longer
/// queued.
void _prune(StateBroadcast r) {
final now = DateTime.now();
_performing.removeWhere((_, at) => now.difference(at) > const Duration(minutes: 1));
final keys = {for (final q in r.queue) q.key};
// Kept while a restarted server waits for what played: Up next comes
// back with it.
if (_restoreFrom == -1) {
_local.removeWhere((k, v) => !keys.contains(k) && now.difference(v.at) > const Duration(minutes: 1));
}
}
/// The end card from the server's state: the media ended with something
/// next; the seconds of `state.advance` move on with a 1 s timer.
void _syncCountdown(StateBroadcast r) {
final head = r.queue.firstOrNull;
if (r.transport != 'finished' || head == null || r.media == null) {
_timer?.cancel();
_timer = null;
if (countdown.peek() != null) countdown.value = null;
return;
}
final a = r.advance != null && r.advance!.id == head.id ? r.advance : null;
int? left;
String? waiting;
if (a != null) {
left = advanceSecondsLeft(a, coord.connection.serverNow());
if (left == 0 && queuePerformer(head, r.members).id == null) {
waiting = r.members.where((m) => m.id == head.by).firstOrNull?.name ?? head.byName;
}
}
countdown.value = UpNextCountdown(mediaId: r.mediaId, item: head, secondsLeft: left, waitingFor: waiting);
if (a == null) {
_timer?.cancel();
_timer = null;
} else {
_timer ??= Timer.periodic(const Duration(seconds: 1), (_) {
final now = coord.serverState.peek();
if (now != null) _syncCountdown(now);
});
}
}
// ------------------------------------------------------------ restart
/// The server restarted without media; what played ([mediaId]) is being
/// offered back (-1: that is over).
void restoreFrom(int mediaId) => _restoreFrom = mediaId;
/// What played is back after a server restart: Up next as [before] had
/// it comes back too. The app whose offer won ([winner]) adds the items
/// anyone can put up (links everyone opens, Jellyfin items); every app
/// its own links passed on and files (only it has them; [self] is its
/// member id then). A server-fetched link's address stayed with the old
/// server, so it is gone; the continuation comes back as usual.
void restoreAfterRestart(StateBroadcast before, String self, {required bool winner}) {
for (final q in before.queue) {
if (q.auto) continue;
final ok = switch (q.how) {
QueueHow.direct || QueueHow.jellyfin => winner,
QueueHow.through || QueueHow.file => self.isNotEmpty && q.by == self && _local.containsKey(q.key),
QueueHow.server => false,
};
if (!ok) continue;
final local = _local[q.key];
if (local != null) _local[q.key] = (pick: local.pick, at: DateTime.now());
final thumb = thumbs.bytesFor(q.thumb);
coord.connection.sendQueue(QueueIntent.add(
QueueItem(media: q.media, how: q.how, title: q.title, sub: q.sub, duration: q.duration, key: q.key),
thumb: thumb == null ? '' : base64Encode(thumb)));
}
_restoreFrom = -1;
}
// ------------------------------------------------------- continuation
/// Adds what continues the media playing (`auto`, PROTOCOL.md "Queue")
/// when this app may ([mayAddContinuation]: it put it up, or that
/// member is not in and this is the first who is), knows what comes
/// next, and it is not there or cancelled yet; with the setting on. A
/// Jellyfin episode played with this app's account continues with the
/// series' next one, a file this app put up with the next episode of its
/// show in the library (numbers skipped on the way said in its line).
void _continue(StateBroadcast? r) {
if (r == null || r.media == null || !coord.settings.autoAdvance.peek()) return;
final id = r.mediaId;
if (_continuedFor == id || _hiddenFor.peek() == id) return;
if (r.queue.any((q) => q.auto && q.forMedia == id)) {
_continuedFor = id;
return;
}
final swapBy = r.media?['swapBy'];
if (!mayAddContinuation(_me, swapBy is String ? swapBy : null, r.members)) return;
final ref = refOfStateMedia(r.media);
final next = coord.nextEpisode.peek();
if (ref != null) {
if (next == null || !sameJellyfinItem(ref, coord.currentRef.peek())) return;
_continuedFor = id;
unawaited(_addContinuation(id, next.pick));
return;
}
final own = coord.ownFile.peek();
final finder = coord.nextFile;
if (own == null || own.$1 != id || finder == null) return;
_continuedFor = id;
unawaited(() async {
try {
final n = await finder(own.$2);
if (n == null) return;
final gap = n.skipped;
await _addContinuation(id, FileMedia(n.episode.path),
note: gap == null ? null : 'skips ${gap.label}');
} catch (e) {
debugPrint('queue: what continues ${own.$2}: $e');
}
}());
}
Future<void> _addContinuation(int mediaId, PickedMedia pick, {String? note}) async {
try {
final b = await _build(pick);
if (b is! ({QueueItem item, Uint8List? thumb})) return;
final r = coord.serverState.peek();
if (r == null || r.mediaId != mediaId || _hiddenFor.peek() == mediaId) return;
final sub = [b.item.sub, ?note].where((s) => s.isNotEmpty).join(' · ');
final item = b.item.copyWith(auto: true, forMedia: mediaId, sub: sub);
if (item.key.isNotEmpty) _local[item.key] = (pick: pick, at: DateTime.now());
final thumb = b.thumb;
coord.connection.sendQueue(QueueIntent.add(item, thumb: thumb == null ? '' : base64Encode(thumb)));
} catch (e) {
debugPrint('queue: adding what continues media $mediaId: $e');
}
}
/// Puts [item] up from here (once per item while it is in flight).
/// When it cannot play after all, it is taken off with the reason.
void _perform(QueueItem item) {
if (_performing.containsKey(item.id)) return;
_performing[item.id] = DateTime.now();
unawaited(() async {
String? error;
try {
error = await _swapTo(item);
} catch (e) {
error = '$e';
}
if (error != null) {
debugPrint('queue: ${item.id} cannot go up: $error');
coord.connection.sendQueue(QueueIntent.remove(item.id, why: "couldn't play it ($error)"));
}
}());
}
Future<String?> _swapTo(QueueItem item) async {
switch (item.how) {
case QueueHow.jellyfin:
final ref = item.media.jellyfin;
final pick = ref == null ? null : pickForRef(coord.jellyfin, ref);
if (pick == null) return 'no Jellyfin account here for it';
coord.swapJellyfin(pick, queueId: item.id);
return null;
case QueueHow.direct:
final url = item.media.url;
if (url == null) return 'no link';
// Each viewer opens it: one computer's probe does not decide.
return coord.swapLink(url, relay: LinkRelay.direct, anyway: true, queueId: item.id);
case QueueHow.server:
coord.swapQueued(HelloMedia(name: item.title, size: 0, duration: 0, relay: const MediaRelay(fetch: true)),
item.id,
art: thumbs.bytesFor(item.thumb));
return null;
case QueueHow.through:
case QueueHow.file:
final local = _local[item.key];
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
// nothing open: back first.
if (coord.selfAway.peek()) coord.joinBack();
return coord.swapPicked(local.pick, queueId: item.id);
}
}
// ------------------------------------------------------------ warming
/// How long a warm result is kept, when it is resolved again, and when a
/// failed one is tried again.
static const _warmKeep = Duration(minutes: 35);
static const _warmRefresh = Duration(minutes: 30);
static const _warmRetry = Duration(minutes: 2);
String _warmKey = '';
String? _warmUrl;
Timer? _warmTimer;
int _warmGen = 0;
// What the preload player should hold once the media playing has
// started (null: nothing); [_preloadFresh]: open it again (a link's
// refresh).
({String url, Map<String, String>? headers})? _preloadWant;
bool _preloadFresh = false;
String _preloadKey = '';
bool _preloadBusy = false;
bool _preloadOk = true;
int _preloadGen = 0;
StateBroadcast? _lastState;
bool _ownAccount(String serverId) =>
coord.jellyfin.account(serverId) != null && coord.jellyfin.statusOf(serverId) != JellyfinAccountStatus.expired;
/// What this app gets ready: the first item of the queue (Up next's,
/// else the continuation). Every app does, each its own copy (see
/// [_WarmTarget]); while sitting out only the probe of a link this app
/// puts up (its swap reads it). Null: nothing.
_WarmTarget? _warmTarget(StateBroadcast? r) {
final t = _warmTargetHere(r);
if (t == null || !coord.selfAway.peek()) return t;
return t.probe == null ? null : _WarmTarget(t.id, probe: t.probe);
}
_WarmTarget? _warmTargetHere(StateBroadcast? r) {
if (r == null) return null;
final head = r.queue.firstOrNull;
if (head != null) {
final performer = queuePerformer(head, r.members).id == _me;
final local = _local[head.key]?.pick;
// The server's warm relay of the item, once bytes can flow: what
// everyone without their own copy preloads.
final relay = head.media.relay;
final warmUrl = relay != null && relay.live ? coord.media.warmRelayUrl(head, relay) : null;
switch (head.how) {
case QueueHow.jellyfin:
final ref = head.media.jellyfin;
if (ref == null) return null;
if (_ownAccount(ref.serverId)) return _WarmTarget(head.id, ref: ref);
// No account here: the server's login passes it on (`/j`), else
// the warm relay a member with an account feeds.
final open = r.relayNext ? coord.jellyfinRelayUrl(ref) : warmUrl;
return open == null ? null : _WarmTarget(head.id, open: open);
case QueueHow.direct:
final url = head.media.url;
if (url == null) return null;
return _WarmTarget(head.id, probe: performer ? url : null, open: url);
case QueueHow.through:
// Only its adder has the link (and feeds the warm relay).
if (!performer) return warmUrl == null ? null : _WarmTarget(head.id, open: warmUrl);
final url = local is LinkMedia ? local.url : null;
return _WarmTarget(head.id, probe: url, open: url);
case QueueHow.file:
if (!performer) return warmUrl == null ? null : _WarmTarget(head.id, open: warmUrl);
// The host's own file plays from its `/m` URL once it is up.
return _WarmTarget(head.id, open: local is FileMedia && coord.hostFile == null ? local.path : null);
case QueueHow.server:
// The server fetches it into its warm relay; everyone preloads that.
if (warmUrl != null) return _WarmTarget(head.id, open: warmUrl);
return performer ? _WarmTarget(head.id) : null;
}
}
return null;
}
/// Keeps the one item that plays next ready on this app, so a skip or
/// the end of what plays starts it at once: a link probed when this app
/// puts it up (`AppYtDlp.probe`, kept while it stays first), a Jellyfin
/// item resolved with this app's account
/// ([SessionCoordinator.warmJellyfin]), and then opened in the hidden
/// preload player ([MediaPipeline.prepare]) once the media
/// playing has started. Done as soon as it is first, again before that
/// runs out, dropped when it is no longer first.
void _warm(StateBroadcast? r) {
_feedWarm(r);
final t = _warmTarget(r);
final key = t?.key ?? '';
if (key != _warmKey) {
_dropWarm(r);
_warmKey = key;
if (t == null) {
if (warm.peek() != null) warm.value = null;
} else if (t.empty) {
warm.value = (id: t.id, state: WarmState.ready);
} else {
warm.value = (id: t.id, state: WarmState.warming);
_resolveWarm(t, fresh: false);
}
}
_syncPreload(r);
}
// The warm relay this app feeds (`<queue id>|<token>`), started once.
String _fedWarm = '';
// The link whose warm reader is being set up: its preload waits for it
// (it then reads that reader, not the site).
String? _feedingLink;
/// Feeds the server's warm relay of Up next's first item from here when
/// it names this member as its source (a link passed on, a file), or a
/// Jellyfin item this app puts up with its own account when the server
/// asks for a source; see [RelayFeeder.feedWarm]. The server
/// drops the relay (and with it the source) once the item is no longer
/// first.
void _feedWarm(StateBroadcast? r) {
final head = r?.queue.firstOrNull;
final relay = head?.media.relay;
if (r == null || head == null || relay == null || relay.fetch) return;
final key = '${head.id}|${coord.connection.token}';
if (key == _fedWarm) return;
switch (head.how) {
case QueueHow.through:
case QueueHow.file:
if (relay.source != _me) return;
final pick = _local[head.key]?.pick;
if (pick is FileMedia) {
_fedWarm = key;
coord.media.feeder.feedWarm(head.id, RelayMedia.single(FileRelayBytes(File(pick.path))));
} else if (pick is LinkMedia) {
_fedWarm = key;
_feedingLink = pick.url;
unawaited(() async {
try {
// The probe [_resolveWarm] keeps: no second yt-dlp run.
final probe = await coord.ytDlp.probe(pick.url, keep: _warmKeep).timeout(const Duration(seconds: 60));
final y = await coord.ytDlp.ytDlp;
if (_fedWarm != key) return;
// Read once for the preload and the warm relay.
await coord.media.feedWarmLink(head.id, pick.url, probe, y);
} catch (e) {
debugPrint('queue: feeding ${head.id}: $e');
} finally {
if (_feedingLink == pick.url) {
_feedingLink = null;
_syncPreload(coord.serverState.peek());
}
}
}());
}
case QueueHow.jellyfin:
final ref = head.media.jellyfin;
if (ref == null || (relay.source.isNotEmpty && relay.source != _me)) return;
if (queuePerformer(head, r.members).id != _me) return;
// Resolved with this app's account by [_resolveWarm].
final w = coord.warmJellyfin;
if (w == null || !sameJellyfinItem(w.ref, ref)) return;
_fedWarm = key;
coord.media.feeder.feedWarm(
head.id, RelayMedia.single(HttpRelayBytes(Uri.parse(w.plan.url), headers: w.plan.headers)));
case QueueHow.direct:
case QueueHow.server:
return;
}
}
void _dropWarm(StateBroadcast? r) {
_warmTimer?.cancel();
_warmTimer = null;
_warmGen++;
_preloadWant = null;
final url = _warmUrl;
_warmUrl = null;
if (url != null) coord.ytDlp.release(url);
// Kept while it is what just went up: that open takes it.
final w = coord.warmJellyfin;
if (w != null && !sameJellyfinItem(w.ref, refOfStateMedia(r?.media))) coord.warmJellyfin = null;
}
void _resolveWarm(_WarmTarget t, {required bool fresh}) {
final gen = ++_warmGen;
unawaited(() async {
var ok = true;
({String url, Map<String, String>? headers})? open;
try {
final probe = t.probe, ref = t.ref, url = t.open;
if (probe != null) {
_warmUrl = probe;
final p = await coord.ytDlp.probe(probe, keep: _warmKeep, fresh: fresh).timeout(const Duration(seconds: 60));
ok = p.problem == null;
}
if (ref != null) {
final plan = await resolveJellyfinRef(coord.jellyfin, ref, relay: false, relayUrl: null);
if (gen != _warmGen) return;
ok = plan is OwnPlay;
if (plan is OwnPlay) {
coord.warmJellyfin = (ref: ref, plan: plan, until: DateTime.now().add(_warmKeep));
open = (url: plan.url, headers: plan.headers);
_feedWarm(coord.serverState.peek());
}
} else if (url != null && ok) {
open = (url: url, headers: null);
}
} catch (e) {
ok = false;
debugPrint('queue: getting ${t.id} ready: $e');
}
if (gen != _warmGen) return;
_preloadWant = ok ? open : null;
// A refresh opens a link again (a page's stream URLs expire); the
// same Jellyfin stream stays as it is.
_preloadFresh = fresh && ok && t.ref == null && open != null;
final waits = ok && open != null && coord.media.preloadEnabled;
warm.value = (id: t.id, state: !ok ? WarmState.failed : (waits ? WarmState.warming : WarmState.ready));
_syncPreload(coord.serverState.peek());
_warmTimer = Timer(ok ? _warmRefresh : _warmRetry, () => _resolveWarm(t, fresh: true));
}());
}
/// Puts [_preloadWant] in the preload player, or drops what it holds;
/// only once the media playing has started (its own open goes first,
/// and a preload a swap is about to take is never replaced before), at
/// once while sitting out (nothing preloads then).
void _syncPreload(StateBroadcast? r) {
// The newest state seen here: the coordinator's lags behind while the
// same `state` is still being handed to it.
final seen = _lastState;
if (r == null || (seen != null && seen.mediaId > r.mediaId)) r = seen;
if (r == null) return;
final out = coord.selfAway.peek();
if (!out && r.media != null && coord.settledMediaId.peek() != r.mediaId) return;
final want = out ? null : _preloadWant;
final media = coord.media;
if (want == null) {
_preloadKey = '';
_preloadGen++;
if (media.preloadUrl != null) unawaited(media.dropPrepared());
return;
}
if (!media.preloadEnabled) return;
if (want.url == _feedingLink) return;
final key = '${want.url}|${want.headers}';
final fresh = _preloadFresh;
// Asked for already: in flight, there, or failed (tried again with the
// next refresh). One that went away (Reload video) is asked again.
if (!fresh && key == _preloadKey && (_preloadBusy || !_preloadOk || media.preloadUrl == want.url)) return;
_preloadFresh = false;
_preloadKey = key;
_preloadBusy = true;
final gen = ++_preloadGen;
final id = warm.peek()?.id;
unawaited(coord.media.prepare(want.url, headers: want.headers, fresh: fresh).then((ok) {
if (gen != _preloadGen) return;
_preloadBusy = false;
_preloadOk = ok;
// A preload that fails leaves the usual open: nothing to say.
if (!ok) debugPrint('queue: preload of $id failed');
final w = warm.peek();
if (w != null && w.id == id && w.state == WarmState.warming) {
warm.value = (id: w.id, state: WarmState.ready);
}
}));
}
// -------------------------------------------------------- continuation
/// The next few episodes after the one playing (own account only).
Future<void> _loadContinuation(EpisodeLink? next, JellyfinPlan? plan) async {
if (next == null || plan is! OwnPlay) {
_continuationFor = '';
if (continuation.peek().isNotEmpty) continuation.value = const [];
return;
}
final key = '${plan.account.serverId}/${next.item.id}';
if (key == _continuationFor) return;
_continuationFor = key;
continuation.value = [next];
final seriesId = next.item.seriesId;
if (seriesId == null) return;
try {
final list = await coord.jellyfin.cached(
plan.account.serverId, 'episodes:$seriesId', () => plan.client.episodes(seriesId));
if (_continuationFor != key) return;
final more = [next];
var at = next.item;
while (more.length < 4) {
final after = episodeAfter(list, at.id);
if (after == null) break;
more.add(EpisodeLink(after, pickFor(plan.account, after)));
at = after;
}
continuation.value = more;
} catch (e) {
debugPrint('queue: continuation of $seriesId: $e');
}
}
}
/// What [QueueController] gets ready for one item on this app: [probe] a
/// link (the app that puts it up), resolve [ref] with this app's own
/// account, and preload [open] (or the resolved stream). None of it:
/// nothing to do here, and it is ready ([empty]).
class _WarmTarget {
final String id;
final String? probe;
final JellyfinRef? ref;
final String? open;
const _WarmTarget(this.id, {this.probe, this.ref, this.open});
bool get empty => probe == null && ref == null && open == null;
String get key => '$id|$probe|$open|${ref?.serverId}/${ref?.itemId}/${ref?.mediaSourceId}';
}
@@ -0,0 +1,29 @@
import 'package:sync_relay_core/core.dart';
// Up next's words for an item. Who puts an item up is core's
// `queuePerformer` (whats_next.dart), read the same by the server and
// every app.
/// How [item] plays, in a few words for its row: "through Foxyz" (you:
/// "through you"), "server fetches", "everyone fetches", "Jellyfin",
/// "Foxyz's file".
String queueHowLabel(QueueItem item, {required String selfId}) {
final mine = item.by == selfId && selfId.isNotEmpty;
final who = mine ? 'you' : (item.byName.isEmpty ? 'who added it' : item.byName);
return switch (item.how) {
QueueHow.through => 'through $who',
QueueHow.file => mine ? 'your file' : "$who's file",
QueueHow.server => 'server fetches',
QueueHow.direct => 'everyone fetches',
QueueHow.jellyfin => 'Jellyfin',
};
}
/// "1:23:45" / "23:45" for [secs]; empty when unknown.
String queueClock(double secs) {
if (secs <= 0) return '';
final s = secs.round();
final h = s ~/ 3600, m = (s % 3600) ~/ 60, r = s % 60;
String two(int v) => v.toString().padLeft(2, '0');
return h > 0 ? '$h:${two(m)}:${two(r)}' : '$m:${two(r)}';
}
Loaded 100 of 532 files, more files were not shown because too many files have changed in this diff. Show more