50 Commits
Author SHA1 Message Date
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
foxyz 9c5733719b Release v0.2.2
ci / core (push) Successful in 24s
ci / updater-windows (push) Successful in 45s
ci / server (push) Successful in 36s
release / linux (push) Successful in 3m31s
release / windows (push) Successful in 4m49s
2026-10-08 05:30:59 +03:00
foxyzandClaude Opus 5.5 50bcc2e1a2 CI: Windows build + release on yuuka-windows
ci / core (push) Successful in 32s
ci / server (push) Successful in 44s
ci / updater-windows (push) Successful in 1m25s
tool/package_windows.ps1 builds watch-together-<ver>-windows-x64.zip
(same layout as the Linux tarball, libmpv bundled, update.bat and
watch-together-server.exe). The release workflow gets a windows job;
ci runs the updater tests on Windows. Publishing moves to
tool/release_publish.dart (dart: only, no python/bash on Windows), which
tolerates both jobs racing to create the release and replaces an asset
on re-runs. release.sh writes both asset URLs into the manifest.
Windows jobs check out with plain git (the host's cached
actions/checkout clone was corrupt).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 22:27:38 +03:00
foxyzandClaude Opus 5.5 2c21cfde11 Updater: per-platform manifest assets, Windows apply tested on NTFS
The manifest gains `assets` (linux-x64 / windows-x64 → archive URL);
top-level `url` stays the Linux tarball for 0.2.1 installs. A Windows-
only test applies a bsdtar zip over a running exe (PING.EXE copy) and
checks it is parked as .old and swept next start; Linux-layout tests
(exec bits, symlinks) skip on Windows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 22:27:38 +03:00
212 changed files with 30457 additions and 6058 deletions

No files matched your search

+29
View File
@@ -36,3 +36,32 @@ jobs:
working-directory: packages/server
- run: build/watch-together-server --help
working-directory: packages/server
# The updater's Windows apply path (running exe parked as .old) only
# means something on real NTFS. Runner notes: see release.yml.
updater-windows:
runs-on: windows-latest
steps:
- name: Checkout
shell: powershell
run: |
git init -q .
git remote add origin "$env:GITHUB_SERVER_URL/$env:GITHUB_REPOSITORY.git"
git fetch -q origin "$env:GITHUB_SHA"
if ($LASTEXITCODE) { exit 1 }
git checkout -q --force FETCH_HEAD
- name: Setup Flutter
shell: powershell
run: powershell -NoProfile -ExecutionPolicy Bypass -File tool/setup_flutter_windows.ps1
- name: Updater tests
shell: powershell
working-directory: packages/core
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
+39 -1
View File
@@ -36,4 +36,42 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
run: tool/release_publish.sh "${{ github.ref_name }}"
run: |
VER="${GITHUB_REF_NAME#v}"
dart tool/release_publish.dart "$GITHUB_REF_NAME" "dist/watch-together-$VER-linux-x64.tar.gz"
windows:
# yuuka-windows: host-mode runner (no containers), runs as SYSTEM, has
# git + VS 2022 Build Tools but no bash/python — PowerShell and Dart
# only. Checkout is plain git: that host's cached actions/checkout
# clone was corrupt, and no `uses:` means no action cache to rot.
runs-on: windows-latest
steps:
- name: Checkout
shell: powershell
run: |
git init -q .
git remote add origin "$env:GITHUB_SERVER_URL/$env:GITHUB_REPOSITORY.git"
git fetch -q --tags origin "$env:GITHUB_SHA"
if ($LASTEXITCODE) { exit 1 }
git checkout -q --force FETCH_HEAD
- name: Setup Flutter
shell: powershell
run: powershell -NoProfile -ExecutionPolicy Bypass -File tool/setup_flutter_windows.ps1
- name: Build zip
shell: powershell
run: powershell -NoProfile -ExecutionPolicy Bypass -File tool/package_windows.ps1
- name: Publish release
if: startsWith(github.ref, 'refs/tags/v')
shell: powershell
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
run: |
$Ver = $env:GITHUB_REF_NAME.TrimStart('v')
dart tool/release_publish.dart $env:GITHUB_REF_NAME "dist/watch-together-$Ver-windows-x64.zip"
if ($LASTEXITCODE) { exit 1 }
+230 -66
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.
Pick from your Jellyfin library, paste any direct video link, or pick a file on your own disk;
@@ -11,7 +15,7 @@ Name: `watch-together` is a placeholder and the final name is undecided
(`sync-relay` is the working name). It appears in file names and the
update manifest until that is settled.
- Desktop only (Linux now, Windows planned), always mpv. No web clients.
- Desktop only (Linux and Windows), always mpv. No web clients.
- No transcoding. Everyone plays the original file.
- Friends trust model: no accounts, no roles. A blank password means an open server.
@@ -19,31 +23,134 @@ How it fits together: [docs/HOW-IT-WORKS.md](docs/HOW-IT-WORKS.md).
## Install and update
Download the latest `watch-together-<version>-linux-x64.tar.gz` from the
Releases page of this repo, extract it, run `watch_together`.
From the Releases page of this repo:
- Linux: `watch-together-<version>-linux-x64.tar.gz`. Extract, run
`watch_together`. On start it adds itself to your app launcher (and its
icon), so the window shows its own icon on Wayland. Needs mpv's library installed (`mpv` or `libmpv2`).
- Windows: `watch-together-<version>-windows-x64.zip`. Extract, run
`watch_together.exe`. mpv is bundled. New and not yet tried on a real
Windows desktop; reports welcome.
Update from the app: when a newer version exists, an update indicator
appears in Home's top bar and opens the update card. "Update and restart" downloads,
appears in the strip at the top of the window and opens the update card. "Update and restart" downloads,
verifies, applies in place and restarts; the install folder must be writable
by you. Settings has Check now and an auto-check toggle. From a terminal:
`watch_together --upgrade` or `update.sh` (shipped in the tarball). A
`./watch-together-server --upgrade` (no display needed, so it works on a
server box too), `watch_together --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
must run the same protocol version, and a mismatch is refused with a message
telling you to update.
## Getting started
Three ways in, all at the top of the Library when you are not in a server.
None of them needs Jellyfin or an account.
- **Join a friend** (most people): your friend sends you an address and,
maybe, a password. See [Join](#join).
- **Host**: you have the video and friends watch it with you. See
[Host from the app](#host-from-the-app).
- **Watch solo**: just play something from the Library. It plays for you
alone, with everything a server has (Jellyfin progress, next episode,
skip buttons); nothing is reachable from outside this computer. Invite
friends in the strip turns it into a hosted server (the Host card's port
and password) and the video carries on from where it was; Stop ends it.
## 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. Offline
the party side has Join a friend and Host; watching solo it says Solo,
with Invite friends and Stop; in a server it shows everyone's avatar
with a ring for their state (green ready, amber buffering, grey not ready,
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 (force ready, Nudge, Sit out, "Copy invite address"; when you host it lists this computer's
network addresses with the port, and friends outside your network need
that port forwarded or a server they can all reach, like a VPS). Beside them the server and "ready n/m", the chat button with unread
count, and Leave. 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 (guests get the cover too); before
a video first plays, its banner fills the picture until playback starts.
One pill 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, and its "Activity" toggle adds
joins, ready, seeks and the like between them. Joins, leaves, chat and
other news also pop up bottom left with the person's avatar; click a message to open the chat. Unread chat
shows as a count on the chat buttons and, in fullscreen with the controls
hidden, 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. Settings → Party & network → "When idle,
sit out instead of just unready" does it after 10 idle minutes.
**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).
## Host from the app
Home, Host tab. Pick what to watch (optional): Jellyfin (browse your linked
servers, see below), Link (paste an http(s) URL; Ctrl+V outside a field
pastes it in) or File (path or Browse). In the column beside it set a port and an optional password (blank =
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.
Host in the strip: set a port and an optional password (blank = anyone can
join), then Host. The app starts a server inside itself and joins it; you
are the host (the crown). Nothing is up yet: pick what to
watch in the Library tab (below) and put it up. 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. Ports you hosted on before are one click away in the Host card.
If you pick a local file, the server streams it to everyone from your disk.
Nothing is uploaded or stored anywhere.
If you put up a local file, the server streams it to everyone from your
disk. Nothing is uploaded or stored anywhere.
## 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 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.
- **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.
- **Files**: video folders you add on this computer, grouped by series
(click a series to list its episodes), or any file through Browse. The others stream it from you.
Put it up and the player opens. Not in a server, the buttons say Play
and it plays for you alone ([Watch solo](#getting-started)).
## Dedicated server
@@ -62,64 +169,87 @@ then everyone (you included) uses Join.
| `--name` | `server` | name reported by `/health` |
| `--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 |
| `--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 |
| `--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
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
[deploy/watch-together.service](deploy/watch-together.service) (copy it to
`/etc/systemd/system/`, edit the flags, `systemctl enable --now watch-together`).
## Join
Home, Join tab. Enter the host's address, port, and the password if there
is one, then Enter or Join. Your name and avatar come from your profile (top
bar chip or Settings). Servers you got into appear as up to 6 Recent server
tiles with live status (online and how many are watching, or offline, plus
"needs update" when the server runs another protocol): click one to rejoin,
x to forget it. Ctrl+1 / Ctrl+2 switch between Join and Host.
Join in the strip. Servers you got into are listed first (up to 6), with
live status (online and how many are watching, or offline, plus "needs
update" when the server runs another protocol): click one to rejoin, x to
forget it. Or enter the host's address, port, and the password if there
is one, then Enter or Join. Your name and picture come from your profile
(your avatar at the top left).
## Settings
Profile, hardware decoding (auto, off or custom; the env var
`SYNC_RELAY_HWDEC` overrides it; applies on the next join or Reload video),
"Experimental: mpv-style controls" (below), updates, playing the next
Jellyfin episode automatically, video shaders (see below), and the
keyboard shortcuts (read-only).
Integrations links Jellyfin servers (below). About shows the version.
The Settings tab (Ctrl+,) has a page per topic, each setting with a line
of help: Profile; Party & network (the Host card's default port and
password, recent servers with remove); Player (below); Jellyfin &
integrations (linked Jellyfin servers, below, the AnimeFlow connection:
"connected as <name>" with what you may do, or why not, and playing the
next episode automatically); Notifications & sounds (what pops up; the sound
volume and sound set, and every sound with a preview, its own set and a switch); Updates; Keyboard shortcuts (read-only); About
(the version).
Keys in a session: Space = ready / unready, Tab = side panel (people, log,
chat, change video), Left/Right = seek 10 s, Shift+Right = skip 1:30,
Player settings live in the player: ⚙ at the right end of its controls
(both designs) opens them, and Settings → Player has the same ones (plus
the custom shader files). Player design: Standard or uosc (below);
progress line when hidden (on by default: a 2 px line at the bottom of
the video while the controls are hidden); upscaling shader (see below);
hardware decoding (auto, off or custom; the env var `SYNC_RELAY_HWDEC`
overrides it); in the player also the subtitle and audio track. Every
change applies right away.
Anywhere in a server (unless you are typing): Space = ready / unready,
Tab = chat & activity (Tab never moves between buttons). Keys while the
player fills the window: Left/Right = seek 10 s, Up/Down = volume, Shift+Right = skip 1:30,
PgUp/PgDn = previous / next chapter (as in mpv), F = fullscreen, Esc =
close panel or ask 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.
close 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.
Controls: the seek bar marks chapter starts with a diamond; hovering it
shows the time and the chapter title. The chapters button lists them
(current one checked; click to jump). "1:30" skips ahead 1:30. Audio and
Subtitles buttons (with the track count) list tracks as "Japanese · E-AC3
2.0 · title"; subtitles have Off.
2.0 · title"; subtitles have Off. Volume (hover the speaker, scroll on
it, or Up/Down) goes to 150 % like mpv: past the tick at 100 % the bar
turns amber and mpv amplifies.
Experimental mpv-style controls (Settings → Player, off by default,
The uosc player design (⚙ → Player design, Standard by default,
switches right away): video controls modeled on mpv's uosc theme. A thick
timeline across the bottom shows the played part, the cached part ahead
(hatched), chapter starts as diamonds and the time; hovering it shows the
chapter title and the time there. With the controls hidden it stays as a
thin line. Above it flat buttons: ≡ menu, Subtitles and Audio (with the
thin line (unless "Progress line when hidden" is off). Above it flat buttons: ≡ menu, Subtitles and Audio (with the
track count), the shader badge (the active Anime4K mode, e.g. "A HQ"),
ready, chapters, 1:30, previous / next episode, change video, people,
volume and fullscreen. Keys and skip buttons work the same.
ready, chapters, 1:30, previous / next episode, change video, volume, ⚙
and fullscreen; the title top left, chat and back-to-library top right. Keys and skip buttons work
the same.
Skipping: during the opening a "Skip OP" button floats in the corner (it
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). Any skip button
(also "Skip 1:30") shows for 5 seconds when it comes up, then only while
the controls are up (move the mouse). The opening and
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
(OP, Opening, Intro / ED, Ending, Credits, Outro). Without an opening, on
anything longer than 20 minutes, "Skip 1:30" floats there during the first
6 minutes.
(OP, Opening, Intro / ED, Ending, Credits, Outro). Without an opening or
any chapters, on anything longer than 20 minutes, "Skip 1:30" floats there
during the first 6 minutes.
## Ports
@@ -132,29 +262,27 @@ Only the host needs the port reachable.
## 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) or your username and password. Several servers can be
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
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
grids with sort (name, date added, release date, rating, random) and
Unwatched / Favorites filters; series pages with seasons, episode progress
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
and moves port, password and Host into a bar below it. On wide windows a
side pane shows details of whatever is under the mouse.
movie or episode picks it. On wide windows a side pane shows details of
whatever is under the mouse.
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 =
search.
Host from the library: pick a movie or episode and Host. Only the item
goes to the server (which Jellyfin server and which item), never a link or
your sign-in. Pasting a Jellyfin link (web page or stream link) does the
Putting up a movie or episode sends only the item to the server (which
Jellyfin server and which item), never a link or 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
plain link.
@@ -164,13 +292,16 @@ Watching:
your watched state, Continue watching and resume point follow what you
saw (reported on start, every 10 s, on pause, resume, seek and track
change, and when it stops).
- Friends without an account watch through the host: the server fetches
the file with the host's sign-in and passes it on (only the current item,
only to people in the session). They show "via host" in the member list.
A dedicated server relays when started with `--jellyfin <file>` (see
[docs/ARCHITECTURE.md](docs/ARCHITECTURE.md), Jellyfin relay).
- Without either, the app says "This needs a Jellyfin account on ..." with
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 host" in the member list (click the avatars). When the app hosts,
or a dedicated server has 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 `<` / `>`,
Shift+P / Shift+N) put up the previous or next episode for everyone. From
@@ -187,9 +318,41 @@ 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
next episode. This is per person; nobody else is affected.
"Change video" in the session panel (Tab) opens the same picker as the Host
tab: the library starts on the series you are watching with the current
season, else where you left it. File is offered to the host only.
"Change video" in the player's controls opens the Library tab (the player
goes mini): the library starts on the series you are watching with the
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. For a link you choose how
the others get it:
- Everyone fetches it (default): 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).
- Through me: your computer downloads it and passes it on, for links only
you can open.
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).
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).
The last two 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.
## Video quality and shaders
@@ -201,8 +364,9 @@ C (480p), A+A, B+B, C+A, each as HQ (strong GPUs, GTX 1080 and up) or Fast
Pick one with the ✨ button in the player's control bar (also in
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,
HQ or Fast (used by Ctrl+1..6) and the custom files. Shaders cost GPU time
session starts with it. The player's ⚙ and Settings → Player set it too,
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
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.
@@ -214,7 +378,7 @@ fails to load shows a toast and switches shaders off.
- "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.
- Everyone waits at "waiting for X": X has not buffered 4 seconds yet or has not pressed Space.
- No Windows build yet.
- Windows: the build is new; if something is off there, say so.
For contributors: [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md),
[docs/ARCHITECTURE.md](docs/ARCHITECTURE.md), [docs/PROTOCOL.md](docs/PROTOCOL.md).
+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

+4 -13
View File
@@ -1,28 +1,19 @@
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/session_screen.dart';
import 'src/state/session_state.dart';
import 'src/shell/app_shell.dart';
import 'src/widgets/app_keys.dart';
class WatchTogetherApp extends StatelessWidget {
const WatchTogetherApp({super.key});
@override
Widget build(BuildContext context) {
final session = SessionState.instance;
return MaterialApp(
title: appName,
theme: ThemeData.dark(),
home: SignalBuilder(
builder: (context) {
return switch (session.route.value) {
AppRoute.home => const HomeScreen(),
AppRoute.session => const SessionScreen(),
};
},
),
shortcuts: appShortcuts,
home: const AppKeys(child: AppShell()),
);
}
}
+8 -1
View File
@@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart';
@@ -7,10 +8,12 @@ import 'package:sync_relay_core/core.dart';
import 'package:window_manager/window_manager.dart';
import 'app.dart';
import 'src/playback/yt_dlp_app.dart';
import 'src/platform/desktop_entry.dart';
import 'src/screens/home/ui.dart';
import 'src/state/session_state.dart';
import 'src/state/settings.dart';
import 'src/update/updater.dart';
import 'src/update/update_controller.dart' show launchArgs;
Future<void> main(List<String> args) async {
launchArgs = List.unmodifiable(args);
@@ -60,6 +63,10 @@ Future<void> main(List<String> args) async {
debugPrint('load connect form failed: $e\n$st');
}
MediaKit.ensureInitialized();
// yt-dlp for web page links: downloaded on first run, kept current.
AppYtDlp.instance.warmUp();
// Linux: the launcher entry and icon Wayland needs (release installs).
unawaited(installDesktopEntry());
runApp(const WatchTogetherApp());
}
@@ -0,0 +1,58 @@
import 'dart:async';
import 'package:flutter/material.dart';
/// Follows whether media_kit's desktop controls around [child] are up,
/// by the same rule they use: up on mount and on any mouse movement,
/// down [hideAfter] later or when the mouse leaves. media_kit does not
/// expose its visibility; this lets the player draw a progress line while
/// they are down. Never takes input.
class ControlsHoverProbe extends StatefulWidget {
final ValueNotifier<bool> shown;
final Duration hideAfter;
final Widget child;
const ControlsHoverProbe({super.key, required this.shown, required this.hideAfter, required this.child});
@override
State<ControlsHoverProbe> createState() => _ControlsHoverProbeState();
}
class _ControlsHoverProbeState extends State<ControlsHoverProbe> {
Timer? _timer;
@override
void initState() {
super.initState();
// Not during this build: the line listening to [shown] may be built.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _wake();
});
}
@override
void dispose() {
_timer?.cancel();
super.dispose();
}
void _wake() {
widget.shown.value = true;
_timer?.cancel();
_timer = Timer(widget.hideAfter, _hide);
}
void _hide() {
_timer?.cancel();
if (mounted) widget.shown.value = false;
}
@override
Widget build(BuildContext context) => MouseRegion(
opaque: false,
hitTestBehavior: HitTestBehavior.translucent,
onEnter: (_) => _wake(),
onHover: (_) => _wake(),
onExit: (_) => _hide(),
child: widget.child,
);
}
@@ -0,0 +1,407 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:signals/signals_flutter.dart';
import 'package:sync_relay_core/core.dart' show JellyfinStreamType;
import '../jellyfin/track_prefs.dart' show MpvTrack;
import '../playback/playback_controller.dart' show PlaybackController, RenderMode;
import '../shaders/shader_presets.dart';
import '../state/session_state.dart';
import '../shell/motion.dart';
import '../state/settings.dart';
import 'uosc/buttons.dart';
import 'uosc/controls.dart';
// Everything about the player, defined once: the rows of the ⚙ card in
// both control designs and of Settings → Player. 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,
],
),
);
}
}
DropdownButton<T> _dropdown<T>({
required Key key,
required T value,
required List<(T, String)> items,
required ValueChanged<T> onChanged,
bool expanded = false,
}) =>
DropdownButton<T>(
key: key,
value: value,
isExpanded: expanded,
underline: const SizedBox.shrink(),
borderRadius: BorderRadius.circular(10),
items: [
for (final (v, label) in items)
DropdownMenuItem(value: v, child: Text(label, maxLines: 1, overflow: TextOverflow.ellipsis)),
],
onChanged: (v) {
if (v != null) onChanged(v);
},
);
/// Standard (our bars over media_kit's controls) or uosc. 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: 'uosc is modeled on mpv: a thick timeline with chapters and what is cached',
control: _dropdown<bool>(
key: const ValueKey('player-design'),
value: s.mpvControls.value,
items: const [(false, 'Standard'), (true, 'uosc')],
onChanged: s.setMpvControls,
),
),
);
}
}
/// 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,
),
),
);
}
}
/// 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(ShaderPreset preset, ShaderQuality? quality) {
final s = AppSettings.instance;
if (quality != null) s.setShaderQuality(quality);
s.setShaderPreset(preset);
final coord = SessionState.instance.coordinator;
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 ?? PlaybackController.lastRenderMode).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(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(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() async {
if ((Platform.environment['SYNC_RELAY_HWDEC'] ?? '').isNotEmpty) return;
final playback = SessionState.instance.coordinator.playback;
if (!playback.alive.peek()) return;
try {
final dynamic platform = playback.player.platform;
await platform?.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();
},
),
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(),
),
),
);
});
}
}
/// Audio or subtitle track of the running player (in the player only).
/// The pick is remembered for the next Jellyfin item.
class TrackSetting extends StatelessWidget {
final JellyfinStreamType type;
const TrackSetting({super.key, required this.type});
bool get _audio => type == JellyfinStreamType.audio;
@override
Widget build(BuildContext context) {
final coord = SessionState.instance.coordinator;
return SignalBuilder(builder: (context) {
final list = [
for (final t in coord.playback.tracks.value)
if (t.type == type) t,
];
// -1: subtitles off.
final selected = list.where((t) => t.selected).firstOrNull?.id ?? (_audio ? null : -1);
final items = <(int, String)>[
if (!_audio) (-1, 'Off'),
for (final MpvTrack t in list) (t.id, t.label),
];
return SettingRow(
label: _audio ? 'Audio' : 'Subtitles',
help: 'Remembered for the next episode',
control: SizedBox(
width: 190,
child: items.isEmpty || selected == null
? Text(items.isEmpty ? 'None' : 'Pick one…',
key: ValueKey(_audio ? 'audio-track' : 'subtitle-track'), textAlign: TextAlign.end)
: _dropdown<int>(
key: ValueKey(_audio ? 'audio-track' : 'subtitle-track'),
value: selected,
expanded: true,
items: items,
onChanged: (id) {
coord.markInput();
if (id == -1) {
coord.selectTrack(subtitlesOff: true);
} else if (_audio) {
coord.selectTrack(aid: id);
} else {
coord.selectTrack(sid: id);
}
},
),
),
);
});
}
}
/// The player rows of Settings → Player and the ⚙ card, in order.
List<Widget> playerSettingRows({ReadonlySignal<RenderMode>? renderMode}) => [
const PlayerDesignSetting(),
const ProgressLineSetting(),
ShaderPresetSetting(renderMode: renderMode),
const HwdecSetting(),
];
/// The ⚙ card: every player setting, plus the running player's tracks.
class PlayerSettingsCard extends StatelessWidget {
final ReadonlySignal<RenderMode>? renderMode;
/// Show the audio / subtitle pickers (a player is running).
final bool tracks;
const PlayerSettingsCard({super.key, this.renderMode, this.tracks = true});
@override
Widget build(BuildContext context) {
return Material(
key: const ValueKey('player-settings-card'),
elevation: 12,
borderRadius: BorderRadius.circular(16),
child: SizedBox(
width: 420,
child: Padding(
padding: const EdgeInsets.fromLTRB(18, 14, 18, 10),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Text('Player', style: TextStyle(fontWeight: FontWeight.w700, fontSize: 16)),
const SizedBox(height: 4),
...playerSettingRows(renderMode: renderMode),
if (tracks) ...[
const Divider(),
const TrackSetting(type: JellyfinStreamType.subtitle),
const TrackSetting(type: JellyfinStreamType.audio),
],
const SizedBox(height: 4),
Text(
'Custom shader files: Settings → Player',
style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant, fontSize: 12),
),
],
),
),
),
);
}
}
/// Opens the ⚙ card over the player, near the controls' right end. The
/// uosc controls stay up while it is open.
Future<void> showPlayerSettings(BuildContext context, {ReadonlySignal<RenderMode>? renderMode}) {
Future<void> open() => showPopover<void>(
context,
at: const Alignment(0.96, 0.5),
child: PlayerSettingsCard(renderMode: renderMode),
);
final controls = UoscControls.maybeOf(context);
return controls == null ? open() : controls.holdDuring(open);
}
/// ⚙ in the standard controls' bottom bar.
class PlayerSettingsButton extends StatelessWidget {
final FocusNode? focusNode;
final ReadonlySignal<RenderMode>? renderMode;
const PlayerSettingsButton({super.key, this.focusNode, this.renderMode});
@override
Widget build(BuildContext context) {
return IconButton(
key: const ValueKey('player-settings'),
focusNode: focusNode,
tooltip: 'Player settings',
color: Colors.white,
iconSize: 20,
visualDensity: VisualDensity.compact,
icon: const Icon(Icons.tune),
onPressed: () => showPlayerSettings(context, renderMode: renderMode),
);
}
}
/// ⚙ in the uosc controls' button row.
class UoscPlayerSettingsButton extends StatelessWidget {
final ReadonlySignal<RenderMode>? renderMode;
const UoscPlayerSettingsButton({super.key, this.renderMode});
@override
Widget build(BuildContext context) {
return UoscButton(
key: const ValueKey('uosc-player-settings'),
icon: Icons.tune_rounded,
tooltip: 'Player settings',
onPressed: () => showPlayerSettings(context, renderMode: renderMode),
);
}
}
+46 -18
View File
@@ -10,6 +10,7 @@ import '../../screens/session/player_controls.dart' show TrackPick;
import '../../shaders/shader_menu.dart';
import '../../shaders/shader_presets.dart';
import '../../state/settings.dart';
import '../volume.dart';
import 'controls.dart';
import 'menu.dart';
import 'style.dart';
@@ -166,9 +167,10 @@ class _UoscBadgeButtonState extends State<UoscBadgeButton> {
}
}
/// Volume: click mutes / unmutes, the wheel steps 5 %.
/// Volume: click mutes / unmutes, hovering shows a [VolumeBar] up to
/// [maxVolume], the wheel steps 5 %.
class UoscVolumeButton extends StatefulWidget {
/// 0..100.
/// 0..[maxVolume].
final double volume;
final ValueChanged<double> onChanged;
const UoscVolumeButton({super.key, required this.volume, required this.onChanged});
@@ -179,29 +181,55 @@ class UoscVolumeButton extends StatefulWidget {
class _UoscVolumeButtonState extends State<UoscVolumeButton> {
double _beforeMute = 100;
bool _hover = false;
@override
Widget build(BuildContext context) {
final v = widget.volume;
final icon = v <= 0 ? Icons.volume_off_rounded : (v < 50 ? Icons.volume_down_rounded : Icons.volume_up_rounded);
return 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);
return MouseRegion(
onEnter: (_) => setState(() => _hover = true),
onExit: (_) => setState(() => _hover = false),
child: Listener(
onPointerSignal: (e) {
if (e is PointerScrollEvent && e.scrollDelta.dy != 0) {
widget.onChanged(stepVolume(v, e.scrollDelta.dy < 0 ? 5 : -5));
}
},
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
AnimatedSize(
duration: const Duration(milliseconds: 150),
child: _hover
? Padding(
padding: const EdgeInsets.only(left: 6, right: 2),
child: VolumeBar(
key: const ValueKey('uosc-volume-bar'),
volume: v,
onChanged: widget.onChanged,
color: UoscStyle.text,
trackColor: UoscStyle.dim.withValues(alpha: 0.5),
),
)
: const SizedBox.shrink(),
),
UoscButton(
key: const ValueKey('uosc-volume'),
icon: icon,
color: v > 100 ? boostColor : null,
tooltip: 'Volume ${v.round()} % (scroll to change)',
onPressed: () {
if (v > 0) {
_beforeMute = v;
widget.onChanged(0);
} else {
widget.onChanged(_beforeMute <= 0 ? 100 : _beforeMute);
}
},
),
],
),
),
);
}
@@ -5,12 +5,13 @@ 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.
// The mpv-style (uosc) controls frame: a full-width timeline at the
// bottom with a row of flat buttons above it, the title top left and a
// corner widget top right.
// Mouse movement shows them; after [UoscControls.hideAfter] without any
// they fade out, the timeline shrinks to a 2 px line (gone when "Progress
// line when hidden" is off) and the cursor hides. Our own frame: no
// media_kit controls state to reach into.
class UoscControls extends StatefulWidget {
/// The timeline; [expanded] is false while the controls are hidden.
@@ -18,18 +19,25 @@ class UoscControls extends StatefulWidget {
/// 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.
/// Top-right corner (chat, back to the library), shown with the controls.
final Widget? top;
/// Top left (what is playing), shown with the controls; gets the width
/// [top] leaves.
final Widget? topLeft;
final VoidCallback? onDoubleTap;
final Duration hideAfter;
/// Follows whether the controls are up (for overlays outside them).
final ValueNotifier<bool>? shown;
const UoscControls({
super.key,
required this.timeline,
this.left = const [],
this.right = const [],
this.top,
this.topLeft,
this.onDoubleTap,
this.hideAfter = const Duration(seconds: 3),
this.shown,
});
static const buttonRowHeight = 40.0;
@@ -53,6 +61,10 @@ class UoscControlsState extends State<UoscControls> {
@override
void initState() {
super.initState();
// Not during this build: an overlay listening to [shown] may be built.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) widget.shown?.value = true;
});
_hideLater();
}
@@ -64,7 +76,10 @@ class UoscControlsState extends State<UoscControls> {
/// Show the controls and restart the hide countdown.
void wake() {
if (!_visible && mounted) setState(() => _visible = true);
if (!_visible && mounted) {
setState(() => _visible = true);
widget.shown?.value = true;
}
_hideLater();
}
@@ -93,6 +108,7 @@ class UoscControlsState extends State<UoscControls> {
void _hide() {
if (!mounted || _holds > 0 || !_visible) return;
setState(() => _visible = false);
widget.shown?.value = false;
}
@override
@@ -136,13 +152,54 @@ class UoscControlsState extends State<UoscControls> {
),
),
),
if (widget.top != null)
if (widget.topLeft != null)
Positioned(
left: 0,
right: 0,
top: 0,
height: 120,
child: IgnorePointer(
child: AnimatedOpacity(
duration: fade,
opacity: visible ? 1 : 0,
child: const DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0x8C000000), Color(0x00000000)],
),
),
),
),
),
),
if (widget.top != null || widget.topLeft != null)
Positioned(
top: 0,
left: 0,
right: 0,
child: IgnorePointer(
key: const ValueKey('uosc-top'),
ignoring: !visible,
child: AnimatedOpacity(duration: fade, opacity: visible ? 1 : 0, child: widget.top),
child: AnimatedOpacity(
duration: fade,
opacity: visible ? 1 : 0,
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
child: widget.topLeft == null
? const SizedBox.shrink()
: Align(alignment: AlignmentDirectional.centerStart, child: widget.topLeft),
),
),
?widget.top,
],
),
),
),
),
Positioned(
@@ -168,7 +225,19 @@ class UoscControlsState extends State<UoscControls> {
),
),
),
widget.timeline(context, visible),
// Hidden: the collapsed timeline is the progress line,
// unless the player settings turn it off.
SignalBuilder(
builder: (context) {
final line = AppSettings.instance.progressLine.value;
return AnimatedOpacity(
key: const ValueKey('uosc-timeline-wrap'),
duration: fade,
opacity: visible || line ? 1 : 0,
child: widget.timeline(context, visible),
);
},
),
],
),
),
@@ -178,9 +247,9 @@ class UoscControlsState extends State<UoscControls> {
}
}
/// 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.
/// The session's controls: the standard ones ([material]) or, with the
/// "Player design" setting on uosc, the uosc ones ([mpv]). Switches live
/// when the setting changes.
class SessionControlsSwitch extends StatelessWidget {
final WidgetBuilder material;
final WidgetBuilder mpv;
@@ -59,7 +59,7 @@ class UoscTimeline extends StatefulWidget {
});
static const height = 30.0;
static const collapsedHeight = 3.0;
static const collapsedHeight = 2.0;
@override
State<UoscTimeline> createState() => _UoscTimelineState();
+146
View File
@@ -0,0 +1,146 @@
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:media_kit/media_kit.dart';
import '../playback/playback_controller.dart' show PlaybackController;
/// 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);
/// [volume] moved by [delta] (wheel, keys), kept within 0..[maxVolume].
double stepVolume(double volume, double delta) => (volume + delta).clamp(0.0, maxVolume);
/// 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;
}
/// Volume for the standard bar (instead of media_kit's, which stops at
/// 100): the icon mutes / unmutes, hovering shows a [VolumeBar], the
/// wheel steps 5 %.
class VolumeButton extends StatefulWidget {
final Player player;
final FocusNode? focusNode;
const VolumeButton({super.key, required this.player, this.focusNode});
@override
State<VolumeButton> createState() => _VolumeButtonState();
}
class _VolumeButtonState extends State<VolumeButton> {
bool _hover = false;
double _beforeMute = 100;
@override
Widget build(BuildContext context) {
final player = widget.player;
return StreamBuilder<double>(
stream: player.stream.volume,
initialData: player.state.volume,
builder: (context, snap) {
final v = snap.data ?? 100;
return MouseRegion(
onEnter: (_) => setState(() => _hover = true),
onExit: (_) => setState(() => _hover = false),
child: Listener(
onPointerSignal: (e) {
if (e is PointerScrollEvent && e.scrollDelta.dy != 0) {
player.setVolume(stepVolume(v, e.scrollDelta.dy < 0 ? 5 : -5));
}
},
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(width: 4),
IconButton(
key: const ValueKey('volume-button'),
focusNode: widget.focusNode,
tooltip: 'Volume ${v.round()} %',
iconSize: 22,
color: Colors.white,
onPressed: () {
if (v > 0) {
_beforeMute = v;
player.setVolume(0);
} else {
player.setVolume(_beforeMute <= 0 ? 100 : _beforeMute);
}
},
icon: Icon(v <= 0 ? Icons.volume_off : (v < 50 ? Icons.volume_down : Icons.volume_up)),
),
AnimatedSize(
duration: const Duration(milliseconds: 150),
child: _hover
? Padding(
padding: const EdgeInsets.only(left: 4, right: 12),
child: VolumeBar(volume: v, onChanged: player.setVolume),
)
: const SizedBox.shrink(),
),
],
),
),
);
},
);
}
}
@@ -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');
}
}
}
+196
View File
@@ -0,0 +1,196 @@
// Release file names -> series / season / episode, anitomy-style but small:
// enough to group a downloads folder into shows. Unknown shapes fall back to
// the cleaned name with no episode, never to a wrong number.
/// What a release file name says.
class ParsedName {
/// Title without group, season marker, episode, year or release tags.
final String series;
final int? season;
final int? episode;
/// Release group: leading `[Group]` or scene-style trailing `-GRP`.
final String? group;
/// Release year, taken out of [series] (movies, `Title (2019)`).
final int? year;
/// Non-episode marker (`NCOP1`, `NCED`, `OVA 2`, `Special`): such files
/// carry no [episode] so they never sort among the real ones.
final String? extra;
const ParsedName(
this.series, {
this.season,
this.episode,
this.group,
this.year,
this.extra,
});
/// `S01E03`, `E25`, the [extra] marker, `S02` for a season pack, else ''.
String get label {
final s = season == null ? '' : 'S${season.toString().padLeft(2, '0')}';
if (episode != null) return '${s}E${episode.toString().padLeft(2, '0')}';
return extra ?? s;
}
/// Grouping key: case, punctuation and spacing do not split a series.
String get seriesKey => normalizeSeries(series);
@override
String toString() => 'ParsedName($series, s=$season, e=$episode, '
'group=$group, year=$year, extra=$extra)';
}
/// Lowercase letters and digits of [s], single spaces between words.
String normalizeSeries(String s) => s
.toLowerCase()
.replaceAll(RegExp(r"['’`]"), '')
.replaceAll(RegExp(r'[^\p{L}\p{N}]+', unicode: true), ' ')
.trim();
// Release tags: the title ends where the first of these starts.
final _junk = RegExp(
r'\b(?:2160p|1440p|1080[pi]|720p|576p|480p|4K|UHD|[xh]\.?26[45]|HEVC|AVC|'
r'AV1|VP9|WEB(?:-?DL|-?Rip)?|BD(?:Rip|Remux)?|Blu-?Ray|BRRip|DVD(?:Rip)?|'
r'HDTV|HDR(?:10)?|DV|REMUX|AAC(?:\d\.\d)?|FLAC|Opus|DTS(?:-HD)?|E?-?AC-?3|'
r'DDP?\d?|TrueHD|Atmos|10-?bit|8-?bit|Hi10P?|PROPER|REPACK|Dual[ -]Audio|'
r'Multi(?:ple)?[ -]Subs?(?:titles?)?|AMZN|NF|CR|DSNP|HMAX|ATVP|'
r'Batch|Complete)\b',
caseSensitive: false,
);
final _ext = RegExp(r'\.(?=[a-z0-9]*[a-z])[a-z0-9]{2,4}$', caseSensitive: false);
final _leadingGroup = RegExp(r'^\s*\[([^\]]+)\]');
final _sceneGroup = RegExp(r'-([A-Za-z0-9]+)$');
final _bracketYear = RegExp(r'[(\[]((?:19|20)\d{2})[)\]]');
final _brackets = RegExp(r'\[[^\]]*\]|\([^)]*\)|\{[^}]*\}|【[^】]*】');
// S01E02, S01.E02, S1 E2; a batch `S01E01-E02` / `S01E01E02` keeps the first.
final _sxe = RegExp(r'\bS(\d{1,2})[ .]?E(\d{1,4})(?:v\d)?(?!\d)',
caseSensitive: false);
final _nxn = RegExp(r'\b(\d{1,2})x(\d{2,3})\b');
// Anime: `Title - 25`, `- 03v2`, `- 01-02` (batch keeps the first).
final _dashNumber = RegExp(r'\s[-–]\s(\d{1,4})(?:v\d)?(?=$|[\s\-~.])');
final _episodeWord = RegExp(
r'\b(?:Episode|Ep\.?|E)\s?(\d{1,4})(?:v\d)?\b',
caseSensitive: false,
);
// Extras: unambiguous words anywhere, short ones only right after the dash.
final _extraAnywhere = RegExp(
r'(?:^|\s)(NCOP|NCED|OVA|OAD|ONA)\s?(\d{1,2})?(?:v\d)?(?=$|\s)',
caseSensitive: false,
);
final _extraAfterDash = RegExp(
r'\s[-–]\s(OP|ED|SP|Specials?|Extras?|Recap|Preview|PV|Menu)\s?(\d{1,2})?'
r'(?:v\d)?(?=$|\s)',
caseSensitive: false,
);
final _seasonInTitle = RegExp(
r'\s(?:S(\d{1,2})|Season\s?(\d{1,2})|(\d{1,2})(?:st|nd|rd|th)\s+Season)\s*$',
caseSensitive: false,
);
final _trailingYear = RegExp(r'\s((?:19|20)\d{2})$');
/// Parses a file name (with or without the extension, no directories).
ParsedName parseReleaseName(String fileName) {
var s = fileName.replaceFirst(_ext, '').trim();
final original = s;
String? group = _leadingGroup.firstMatch(s)?.group(1)?.trim();
int? year;
final by = _bracketYear.firstMatch(s);
if (by != null) year = int.parse(by.group(1)!);
s = s.replaceAll(_brackets, ' ');
// Dots and underscores as separators (scene style), unless the name
// already has spaces and only the odd `Dr. Stone` dot.
final dotted = !s.contains(' ') || RegExp(r'\w\.\w').allMatches(s).length > 2;
if (dotted) {
// `...H264-GRP`, but not `...WEB-DL`.
final tags = _junk.allMatches(s);
if (group == null && tags.isNotEmpty && tags.last.end < s.length) {
final g = _sceneGroup.firstMatch(s);
if (g != null) {
group = g.group(1);
s = s.substring(0, g.start);
}
}
s = s.replaceAll('.', ' ');
}
s = s.replaceAll('_', ' ').replaceAll(RegExp(r'\s+'), ' ').trim();
// Release tags end the interesting part.
final junk = _junk.firstMatch(s);
if (junk != null && junk.start > 0) s = s.substring(0, junk.start).trim();
int? season, episode;
String? extra;
var cut = s.length;
final extraMatch =
_extraAfterDash.firstMatch(s) ?? _extraAnywhere.firstMatch(s);
final sxe = _sxe.firstMatch(s);
if (extraMatch != null) {
final word = extraMatch.group(1)!;
final upper = word.length <= 4 ? word.toUpperCase() : _title(word);
// `OVA - 02`: the number may follow the dash instead.
final n = extraMatch.group(2) ??
_dashNumber.firstMatch(s.substring(extraMatch.end))?.group(1);
extra = n == null
? upper
: (upper.startsWith('NC') || upper.length <= 2)
? '$upper${int.parse(n)}'
: '$upper ${int.parse(n)}';
cut = extraMatch.start;
if (sxe != null && sxe.start < cut) {
season = int.parse(sxe.group(1)!);
cut = sxe.start;
}
} else if (sxe != null) {
season = int.parse(sxe.group(1)!);
episode = int.parse(sxe.group(2)!);
cut = sxe.start;
} else {
final nxn = _nxn.firstMatch(s);
if (nxn != null) {
season = int.parse(nxn.group(1)!);
episode = int.parse(nxn.group(2)!);
cut = nxn.start;
} else {
final m = _dashNumber.firstMatch(s) ?? _episodeWord.firstMatch(s);
if (m != null) {
episode = int.parse(m.group(1)!);
cut = m.start;
}
}
}
var series = s.substring(0, cut);
// Season pack or season marker kept in the title: `Title S2`,
// `Title Season 2`, `Title 2nd Season`, `Title.S01.1080p`.
final sIn = _seasonInTitle.firstMatch(series);
if (sIn != null) {
season ??= int.parse(sIn.group(1) ?? sIn.group(2) ?? sIn.group(3)!);
series = series.substring(0, sIn.start);
}
series = series.replaceAll(RegExp(r'[\s\-–:,~]+$'), '').trim();
// A trailing year is the release year, unless it is the whole title or
// too far ahead to be one (`Blade Runner 2049`).
final ty = _trailingYear.firstMatch(series);
if (ty != null) {
final y = int.parse(ty.group(1)!);
if (y <= DateTime.now().year + 1) {
year ??= y;
series = series.substring(0, ty.start).trim();
}
}
series = series.replaceAll(RegExp(r'[\s\-–:,~]+$'), '').trim();
if (series.isEmpty) {
series = original.replaceAll(RegExp(r'[._]+'), ' ').trim();
}
return ParsedName(
series,
season: season,
episode: episode,
group: group,
year: year,
extra: extra,
);
}
String _title(String w) => w[0].toUpperCase() + w.substring(1).toLowerCase();
+257
View File
@@ -0,0 +1,257 @@
import 'dart:io';
import 'dart:isolate';
import 'name_parser.dart';
// Folder scan for the Files source: videos with their parsed names,
// sidecar subtitles, everything else counted and hidden. The walk is
// synchronous I/O, so it runs in its own isolate.
const videoExtensions = {
'mkv', 'mp4', 'webm', 'avi', 'mov', 'm4v', 'ts', 'm2ts', 'wmv', 'flv', //
};
const subtitleExtensions = {'ass', 'ssa', 'srt', 'vtt', 'sup'};
/// 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;
enum FileKind { video, subtitle, other }
FileKind kindOf(String path) {
final ext = _extension(_baseName(path));
if (videoExtensions.contains(ext)) return FileKind.video;
if (subtitleExtensions.contains(ext)) return FileKind.subtitle;
return FileKind.other;
}
class VideoFile {
final String path;
final int size;
final ParsedName parsed;
final DateTime modified;
const VideoFile(this.path, this.size, this.parsed, this.modified);
String get name => _baseName(path);
}
class FolderScan {
final List<VideoFile> 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 = <VideoFile>[];
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(VideoFile(e.path, size, parseReleaseName(name),
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<VideoFile> 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()),
};
}
/// One show: its episodes in season/episode order plus its extras
/// (NCOP, OVA...) in name order.
class SeriesGroup {
/// Display title: the most common spelling among the files.
final String title;
final String key;
final List<VideoFile> episodes;
final List<VideoFile> extras;
const SeriesGroup(this.title, this.key, this.episodes, this.extras);
/// Distinct seasons, ascending; files without one count as season 1.
List<int> get seasons =>
({for (final e in episodes) e.parsed.season ?? 1}.toList()..sort());
}
/// [groupBySeries] result: shows, and videos that are not episodes.
class GroupedVideos {
final List<SeriesGroup> series;
final List<VideoFile> loose;
const GroupedVideos(this.series, this.loose);
}
/// Groups videos with an episode number (or an extra marker) by
/// [ParsedName.seriesKey]; the rest (movies, clips) stay loose, by name.
/// An extra whose series has no episodes in the list is loose too.
GroupedVideos groupBySeries(Iterable<VideoFile> videos) {
final episodes = <String, List<VideoFile>>{};
final extras = <String, List<VideoFile>>{};
final loose = <VideoFile>[];
for (final v in videos) {
final p = v.parsed;
final key = p.seriesKey;
if (p.episode != null && key.isNotEmpty) {
episodes.putIfAbsent(key, () => []).add(v);
} else if (p.extra != null && key.isNotEmpty) {
extras.putIfAbsent(key, () => []).add(v);
} else {
loose.add(v);
}
}
for (final e in extras.entries) {
if (!episodes.containsKey(e.key)) loose.addAll(e.value);
}
int byName(VideoFile a, VideoFile b) =>
a.name.toLowerCase().compareTo(b.name.toLowerCase());
final groups = [
for (final e in episodes.entries)
SeriesGroup(
_commonTitle(e.value),
e.key,
e.value..sort(_byEpisode),
(extras[e.key] ?? [])..sort(byName),
),
]..sort((a, b) => a.title.toLowerCase().compareTo(b.title.toLowerCase()));
return GroupedVideos(groups, loose..sort(byName));
}
int _byEpisode(VideoFile a, VideoFile b) {
final pa = a.parsed, pb = b.parsed;
final s = (pa.season ?? 1).compareTo(pb.season ?? 1);
if (s != 0) return s;
final e = pa.episode!.compareTo(pb.episode!);
if (e != 0) return e;
// Same episode twice (v2, another group): newest release first.
return b.name.compareTo(a.name);
}
String _commonTitle(List<VideoFile> files) {
final counts = <String, int>{};
for (final f in files) {
counts.update(f.parsed.series, (n) => n + 1, ifAbsent: () => 1);
}
return counts.entries
.reduce((a, b) => b.value > a.value ? b : a)
.key;
}
String _baseName(String path) {
final i = path.lastIndexOf(RegExp(r'[/\\]'));
return i < 0 ? path : path.substring(i + 1);
}
String _extension(String name) {
final dot = name.lastIndexOf('.');
return dot <= 0 ? '' : name.substring(dot + 1).toLowerCase();
}
String _stripExtension(String path) {
final dot = path.lastIndexOf('.');
final sep = path.lastIndexOf(RegExp(r'[/\\]'));
return dot > sep + 1 ? path.substring(0, dot) : path;
}
+181
View File
@@ -0,0 +1,181 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:crypto/crypto.dart';
import 'package:flutter/foundation.dart';
import 'package:media_kit/media_kit.dart';
import 'package:path_provider/path_provider.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;
/// 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?>>{};
/// 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}) {
final existing = _inFlight[path];
if (existing != null) return existing;
final f = _thumbnailFor(path, cancelled ?? () => 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 = Player(
configuration: const PlayerConfiguration(
vo: 'null',
muted: true,
osc: false,
libass: false,
title: 'thumbnail',
bufferSize: 4 << 20,
),
);
try {
final mpv = player.platform as NativePlayer;
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(Media(path), play: false);
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 {
await player.dispose();
}
}
@@ -0,0 +1,330 @@
import 'dart:io';
import 'package:flutter/material.dart';
import '../../shell/avatar.dart';
import '../name_parser.dart';
import '../scanner.dart';
import 'thumb.dart';
// The Files page's rows and cards: a series header, 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})';
class SeriesHeader extends StatelessWidget {
final SeriesGroup group;
final ThumbCache thumbs;
/// Its episodes are listed below; [onTap] opens or closes them.
final bool expanded;
final VoidCallback? onTap;
const SeriesHeader(
{super.key, required this.group, required this.thumbs, this.expanded = true, this.onTap});
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final first = group.episodes.first;
final n = group.episodes.length;
final extras = group.extras.length;
final detail = [
'$n ${n == 1 ? 'episode' : 'episodes'}',
if (extras > 0) '$extras ${extras == 1 ? 'extra' : 'extras'}',
?first.parsed.group,
].join(' · ');
return Padding(
padding: const EdgeInsets.only(top: 14),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(10),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: ThumbBuilder(
cache: thumbs,
path: first.path,
builder: (context, t, _) => ThumbImage(t, width: 96, height: 54),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(group.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.titleMedium),
Text(detail,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12)),
],
),
),
if (onTap != null)
AnimatedRotation(
turns: expanded ? 0.5 : 0,
duration: const Duration(milliseconds: 200),
child: Icon(Icons.expand_more, color: scheme.onSurfaceVariant),
),
const SizedBox(width: 8),
],
),
),
),
);
}
}
/// One file as a row: an episode under its series header, or a Recent
/// entry ([title] given, [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 VideoFile 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.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,749 @@
import 'dart:async';
import 'dart:io';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:signals/signals_flutter.dart';
import '../../shell/motion.dart';
import '../folder_store.dart';
import '../name_parser.dart';
import '../scanner.dart';
import 'file_tiles.dart';
import 'thumb.dart';
export 'thumb.dart' show ThumbnailLoader;
// The Files source: watched folders on the left (chips on top when
// narrow), the open one on the right grouped into series and loose
// videos, or Recent. A folder is scanned when first opened and on Rescan;
// scans are kept for the app session. Series start collapsed; the ones
// opened stay open for the app session. Playing goes through [onPlay].
/// Below this width the sidebar turns into chips above the list.
const filesNarrowWidth = 700.0;
/// Scans kept for the app session, by folder.
final _scans = <String, FolderScan>{};
final _running = <String, Future<FolderScan>>{};
String? _lastOpen;
/// Series opened this session, by `folder\nseries key`.
final _expanded = <String>{};
final _showRequests = ValueNotifier(0);
/// Opens [dir] (null: Recent) 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 series and the open folder.
@visibleForTesting
void resetFilesPageSession() {
_scans.clear();
_running.clear();
_expanded.clear();
_lastOpen = null;
}
class FilesPage extends StatefulWidget {
/// Nobody else watches (offline or solo): no relay notice.
final bool solo;
/// 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;
final String? nowPlayingPath;
/// Defaults to [thumbnailFor].
final ThumbnailLoader? thumbnails;
/// Defaults to [scanFolder].
final Future<FolderScan> Function(String dir)? scan;
/// Defaults to [FolderStore.instance].
final FolderStore? store;
/// Default: the system folder / file dialogs.
final Future<String?> Function()? pickFolder;
final Future<String?> Function()? pickVideo;
const FilesPage({
super.key,
this.solo = false,
required this.relayed,
required this.onPlay,
this.nowPlayingPath,
this.thumbnails,
this.scan,
this.store,
this.pickFolder,
this.pickVideo,
});
@override
State<FilesPage> createState() => _FilesPageState();
}
/// A scan grouped for display, made once per scan.
class _Library {
final GroupedVideos grouped;
final int subtitleFiles;
_Library(FolderScan scan)
: grouped = groupBySeries(scan.videos),
subtitleFiles = {for (final l in scan.subtitles.values) ...l}.length;
/// Series rows in order: a header (video null), then, for the series
/// [open] says, its episodes and extras.
List<(SeriesGroup, VideoFile?)> rows(bool Function(SeriesGroup g) open) => [
for (final g in grouped.series) ...[
(g, null),
if (open(g))
for (final v in [...g.episodes, ...g.extras]) (g, v),
],
];
}
final _libraries = Expando<_Library>();
class _FilesPageState extends State<FilesPage> {
late final ThumbCache _thumbs =
widget.thumbnails == null ? ThumbCache.shared : ThumbCache(widget.thumbnails!);
FolderStore get _store => widget.store ?? FolderStore.instance;
/// Null: Recent.
String? _open = _lastOpen;
bool _showHidden = false;
String? _starting;
String? _error;
@override
void initState() {
super.initState();
_showRequests.addListener(_shown);
_store.ensureLoaded().then((_) {
if (!mounted) return;
final open = _open;
if (open != null && !_store.folders.value.contains(open)) {
_select(null);
} else if (open != null) {
_scan(open);
}
});
}
@override
void dispose() {
_showRequests.removeListener(_shown);
super.dispose();
}
void _shown() => _select(_lastOpen);
Future<FolderScan> _runScan(String dir) => (widget.scan ?? scanFolder)(dir);
void _select(String? dir) {
setState(() {
_open = _lastOpen = dir;
_showHidden = false;
});
if (dir != null && !_scans.containsKey(dir)) _scan(dir);
}
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 <VideoFile>[]) 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(() {});
}
Future<void> _addFolder() async {
String? dir;
try {
dir = await (widget.pickFolder ??
() => FilePicker.getDirectoryPath(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);
}
static Future<String?> _pickVideo() async {
final files = await FilePicker.pickFiles(
dialogTitle: 'Pick a video',
type: FileType.custom,
allowedExtensions: videoExtensions.toList(),
);
return files.isEmpty ? null : files.first.path;
}
void _removeFolder(String dir) {
_store.removeFolder(dir);
_scans.remove(dir);
if (_open == dir) _select(null);
}
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);
@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;
final open = _open != null && folders.contains(_open) ? _open : null;
final main = fadeThrough(
KeyedSubtree(
key: ValueKey(open ?? ''),
child: _main(context, narrow, folders, recent, open),
),
);
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));
return ListView(
padding: const EdgeInsets.fromLTRB(20, 20, 8, 20),
children: [
const Text('Folders', style: TextStyle(fontWeight: FontWeight.w600)),
const SizedBox(height: 8),
ListTile(
dense: true,
selected: open == null,
shape: shape,
leading: const Icon(Icons.history, size: 18),
title: const Text('Recent'),
onTap: () => _select(null),
),
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: switch (_folderStatus(f)) {
final s? => Text(s, style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 11)),
null => null,
},
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'}';
}
Widget _main(
BuildContext context,
bool narrow,
List<String> folders,
List<String> recent,
String? open,
) {
final scan = open == null ? null : _scans[open];
final scanning = open != null && _running.containsKey(open);
final top = <Widget>[
_header(context, open),
if (narrow) _chips(folders, open),
if (!widget.solo && 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)),
),
];
final slivers = <Widget>[
SliverList.list(children: top),
if (open == null)
..._recent(context, recent)
else if (scan == null)
const SliverToBoxAdapter(
child: Padding(
padding: EdgeInsets.only(top: 60),
child: Center(child: CircularProgressIndicator()),
),
)
else
..._folder(context, open, scan, 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;
return Padding(
padding: const EdgeInsets.only(bottom: 14),
child: Row(
children: [
Icon(open == null ? Icons.history : Icons.folder, color: scheme.primary),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
open == null ? 'Recent' : baseName(open),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.headlineSmall,
),
if (open != null)
Text(
open,
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('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 _RecentRow(
key: ValueKey(path),
path: path,
state: _stateOf(path),
thumbs: _thumbs,
onTap: _tapOf(path),
onRemove: () => _store.removeRecent(path),
);
},
),
];
}
List<Widget> _folder(BuildContext context, String dir, FolderScan scan, bool scanning) {
final scheme = Theme.of(context).colorScheme;
final lib = _libraries[scan] ??= _Library(scan);
final series = lib.grouped.series.length;
String count(int n, String one, String many) => '$n ${n == 1 ? one : many}';
final summary = [
count(scan.videos.length, 'video', 'videos') + (series > 0 ? ' in $series series' : ''),
if (scan.hiddenCount > 0)
'${count(scan.hiddenCount, 'other file', 'other files')} hidden',
if (lib.subtitleFiles > 0) count(lib.subtitleFiles, 'subtitle file', 'subtitle files'),
if (scan.truncated) 'stopped after $maxScannedFiles files',
].join(' · ');
final loose = lib.grouped.loose;
String seriesId(SeriesGroup g) => '$dir\n${g.key}';
final rows = lib.rows((g) => _expanded.contains(seriesId(g)));
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 (scan.hiddenCount > 0) ...[
const SizedBox(width: 8),
TextButton(
onPressed: () => setState(() => _showHidden = !_showHidden),
child: Text(_showHidden ? 'Hide them' : 'Show some'),
),
],
TextButton.icon(
onPressed: scanning ? null : () => _scan(dir, rescan: true),
icon: scanning
? const SizedBox.square(
dimension: 14, child: CircularProgressIndicator(strokeWidth: 2))
: const Icon(Icons.refresh, size: 16),
label: Text(scanning ? 'Scanning…' : 'Rescan'),
),
],
),
),
SliverToBoxAdapter(
child: AnimatedSize(
duration: easeTime,
curve: ease,
alignment: Alignment.topLeft,
child: !_showHidden
? const SizedBox(width: double.infinity)
: Column(
children: [
for (final f in scan.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 (scan.hiddenCount > scan.hiddenExamples.length)
ListTile(
dense: true,
enabled: false,
title: Text('and ${scan.hiddenCount - scan.hiddenExamples.length} more'),
),
],
),
),
),
if (scan.videos.isEmpty)
SliverToBoxAdapter(
child: Directory(dir).existsSync()
? 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.',
),
),
// Series: files whose names carry an episode number; a header
// click opens or closes its episodes.
SliverList.builder(
itemCount: rows.length,
itemBuilder: (context, i) {
final (group, v) = rows[i];
if (v == null) {
final id = seriesId(group);
return SeriesHeader(
key: ValueKey('series:${group.key}'),
group: group,
thumbs: _thumbs,
expanded: _expanded.contains(id),
onTap: () => setState(() => _expanded.contains(id) ? _expanded.remove(id) : _expanded.add(id)),
);
}
return FileRow(
key: ValueKey(v.path),
path: v.path,
title: v.parsed.label.isEmpty ? v.parsed.series : v.parsed.label,
subtitle: v.name,
size: formatSize(v.size),
state: _stateOf(v.path),
thumbs: _thumbs,
onTap: _tapOf(v.path),
);
},
),
// Everything else (movies, clips) as a thumbnail grid.
if (loose.isNotEmpty) ...[
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.only(top: 22, bottom: 10),
child: Text(
series == 0 ? 'Videos' : 'Other videos',
style: Theme.of(context).textTheme.titleMedium,
),
),
),
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: loose.length,
itemBuilder: (context, i) {
final v = loose[i];
return VideoCard(
key: ValueKey(v.path),
video: v,
state: _stateOf(v.path),
thumbs: _thumbs,
onTap: _tapOf(v.path),
);
},
);
}),
],
];
}
}
/// A Recent entry: `Series · S01E03` over the file name, or the cleaned
/// title over its folder; greyed when the file is gone.
class _RecentRow extends StatefulWidget {
final String path;
final PlayState state;
final ThumbCache thumbs;
final VoidCallback? onTap;
final VoidCallback onRemove;
const _RecentRow({
super.key,
required this.path,
required this.state,
required this.thumbs,
required this.onTap,
required this.onRemove,
});
@override
State<_RecentRow> createState() => _RecentRowState();
}
class _RecentRowState extends State<_RecentRow> {
FileStat? _stat;
@override
void initState() {
super.initState();
File(widget.path).stat().then((s) {
if (mounted) setState(() => _stat = s);
}, onError: (Object _) {});
}
@override
Widget build(BuildContext context) {
final path = widget.path;
final p = parseReleaseName(baseName(path));
final missing = _stat?.type == FileSystemEntityType.notFound;
final episode = p.episode != null || p.extra != null;
return FileRow(
path: path,
title: episode ? '${p.series} · ${p.label}' : cleanTitle(p),
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,
),
],
),
);
}
}
+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);
+36 -4
View File
@@ -8,8 +8,6 @@ import 'package:hive_ce/hive.dart';
import 'package:sync_relay_core/core.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.
//
@@ -24,6 +22,7 @@ const _keyAccounts = 'accounts';
const _keyDefault = 'defaultServer';
const _keyLastPick = 'lastPick';
const _keyBrowsePrefix = 'browse:';
const _keyDismissedPrefix = 'dismissed:';
/// One signed-in user on one server.
class JellyfinAccount {
@@ -207,14 +206,15 @@ class JellyfinAccounts extends ChangeNotifier {
/// Last failure text for an offline account.
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;
/// Stable per install: Jellyfin keys devices and tokens by it.
String get deviceId => _deviceId;
JellyfinClientInfo get clientInfo => JellyfinClientInfo(
client: appName,
// The old name on purpose: Jellyfin lists devices under it.
client: 'Watch Together',
device: deviceName ?? _hostname(),
deviceId: _deviceId.isEmpty ? 'unknown' : _deviceId,
version: appVersion,
@@ -484,6 +484,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
/// Short-lived memo for browse calls, so going back does not refetch.
@@ -74,7 +74,7 @@ class _JellyfinAccountsPanelState extends State<JellyfinAccountsPanel> {
Text('Jellyfin', style: t.titleMedium),
const SizedBox(height: 4),
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.',
style: TextStyle(color: Colors.grey),
),
+183 -42
View File
@@ -1,3 +1,6 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:sync_relay_core/core.dart';
@@ -20,14 +23,17 @@ final class HomeRoute extends BrowseRoute {
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 JellyfinItem folder;
const LibraryRoute(this.folder);
/// A `LibrarySort` name; null = by name.
final String? sort;
const LibraryRoute(this.folder, {this.sort});
@override
String get label => folder.name;
@override
String get key => 'lib:${folder.id}';
String get key => sort == null ? 'lib:${folder.id}' : 'lib:${folder.id}:$sort';
}
final class SeriesRoute extends BrowseRoute {
@@ -41,18 +47,6 @@ final class SeriesRoute extends BrowseRoute {
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
/// ids, names and the fields the pages start from (each page loads the
/// full item itself).
@@ -60,18 +54,17 @@ List<Map<String, Object?>> encodeBrowseStack(List<BrowseRoute> stack) => [
for (final r in stack)
switch (r) {
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) => {
'r': 'series',
..._itemJson(series),
'seasonId': ?seasonId,
},
DetailRoute(:final item) => {'r': 'item', ..._itemJson(item)},
}
];
/// 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) {
if (raw is! List) return null;
final out = <BrowseRoute>[const HomeRoute()];
@@ -79,11 +72,10 @@ List<BrowseRoute>? decodeBrowseStack(Object? raw) {
if (e is! Map) return null;
final item = _itemFrom(e);
final route = switch (e['r']) {
'home' => null,
'lib' when item != null => LibraryRoute(item),
'lib' when item != null =>
LibraryRoute(item, sort: e['sort'] is String ? e['sort'] as String : null),
'series' when item != null =>
SeriesRoute(item, seasonId: e['seasonId'] is String ? e['seasonId'] as String : null),
'item' when item != null => DetailRoute(item),
_ => null,
};
if (route != null && out.every((r) => r.key != route.key)) out.add(route);
@@ -141,46 +133,181 @@ 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});
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;
super.dispose();
}
}
/// What every browser page needs, provided by [JellyfinBrowser].
class BrowseScope extends InheritedWidget {
final JellyfinAccounts accounts;
final JellyfinAccount account;
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 ValueChanged<JellyfinItem> onPick;
final ValueChanged<BrowseRoute> onOpen;
/// Hover / focus: the inspector pane follows it.
final ValueChanged<JellyfinItem> onPeek;
/// Click: select (details panel).
final ValueChanged<JellyfinItem> onSelect;
/// Double-click: put it up (a series: its next episode).
final ValueChanged<JellyfinItem> onPutUp;
/// Home shows the library chips itself (the Library tab has them in its
/// header instead).
final bool libraryStrip;
const BrowseScope({
super.key,
required this.accounts,
required this.account,
required this.client,
required this.controller,
required this.selectedItemId,
required this.onPick,
required this.onOpen,
required this.onPeek,
required this.onSelect,
required this.onPutUp,
this.libraryStrip = true,
required super.child,
});
static BrowseScope of(BuildContext context) =>
context.dependOnInheritedWidgetOfExactType<BrowseScope>()!;
void onOpen(BrowseRoute route) => controller.open(route);
/// Cached browse call on this server.
Future<T> load<T>(String key, Future<T> Function(JellyfinClient c) fn) =>
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) {
if (isPlayable(item)) {
onPick(item);
if (isSelectable(item)) {
onSelect(item);
return;
}
switch (item.kind) {
case JellyfinItemKind.series:
onOpen(SeriesRoute(item));
case JellyfinItemKind.season:
final sid = item.seriesId;
if (sid != null) {
@@ -193,7 +320,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) {
final sid = episode.seriesId;
if (sid == null) return;
@@ -206,23 +333,28 @@ class BrowseScope extends InheritedWidget {
bool updateShouldNotify(BrowseScope old) =>
old.selectedItemId != selectedItemId ||
old.account != account ||
old.client != client;
old.client != client ||
old.libraryStrip != libraryStrip;
}
/// Runs [load] once (again on [retry]) and shows [builder] with the result,
/// a spinner meanwhile, and a compact error with Retry on failure.
/// Runs [load] once (again on [retry] and when [version] changes) and
/// 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 {
final Future<T> Function() load;
final Widget Function(BuildContext context, T data) builder;
final Widget? loading;
/// When false the error collapses to nothing (optional sections).
final bool showError;
final Object? version;
const Loader({
super.key,
required this.load,
required this.builder,
this.loading,
this.showError = true,
this.version,
});
@override
@@ -231,22 +363,31 @@ class Loader<T> extends StatefulWidget {
class _LoaderState<T> extends State<Loader<T>> {
late Future<T> _future = widget.load();
(T,)? _last;
void _retry() => setState(() => _future = widget.load());
@override
void didUpdateWidget(Loader<T> old) {
super.didUpdateWidget(old);
if (old.version != widget.version) _future = widget.load();
}
@override
Widget build(BuildContext context) {
return FutureBuilder<T>(
future: _future,
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 (!widget.showError) return const SizedBox.shrink();
return LoadError(error: snap.error!, onRetry: _retry);
}
if (snap.connectionState == ConnectionState.done) {
return widget.builder(context, snap.data as T);
}
final last = _last;
if (last != null) return widget.builder(context, last.$1);
return widget.loading ??
const Padding(
padding: EdgeInsets.all(32),
+124 -172
View File
@@ -2,52 +2,51 @@ import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:sync_relay_core/core.dart';
import '../screens/home/picked_media.dart';
import 'accounts.dart';
import 'browse_scope.dart';
import 'detail_page.dart';
import 'format.dart';
import 'home_page.dart';
import 'inspector.dart';
import 'library_page.dart';
import 'search_results.dart';
import 'series_page.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,
/// breadcrumbs, search, and a back stack of pages (Home rows, library
/// grids, series, details). Pages below the top stay alive, so going back
/// keeps scroll position and loaded pages.
/// grids, series). Pages below the top stay alive, so going back keeps
/// 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
/// details, Esc / Backspace / Alt+Left go back, / or Ctrl+F searches.
/// A click on a card selects it ([onSelect]; libraries and folders open),
/// 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 {
final JellyfinAccounts accounts;
final JellyfinMedia? selected;
final ValueChanged<JellyfinMedia> onPick;
/// Shown when given: toggles a bigger browser (the host hides its form).
final VoidCallback? onToggleExpanded;
final bool expanded;
final BrowseController? controller;
/// Highlighted card.
final JellyfinSelection? selected;
final ValueChanged<JellyfinSelection> onSelect;
final ValueChanged<JellyfinSelection> onPutUp;
final VoidCallback? onOpenIntegrations;
final Duration searchDebounce;
/// 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;
/// Library chips on Home (off when the Library header shows them).
final bool libraryStrip;
const JellyfinBrowser({
this.start,
super.key,
required this.accounts,
required this.onPick,
required this.onSelect,
required this.onPutUp,
this.controller,
this.selected,
this.onToggleExpanded,
this.expanded = false,
this.onOpenIntegrations,
this.libraryStrip = true,
this.searchDebounce = const Duration(milliseconds: 300),
});
@@ -56,45 +55,23 @@ class JellyfinBrowser extends StatefulWidget {
}
class _JellyfinBrowserState extends State<JellyfinBrowser> {
String? _serverId;
List<BrowseRoute> _stack = const [HomeRoute()];
BrowseController? _own;
final _search = TextEditingController();
final _searchFocus = FocusNode(debugLabel: 'jf-search');
final _body = FocusScopeNode(debugLabel: 'jf-body');
final _peek = ValueNotifier<JellyfinItem?>(null);
String _term = '';
Timer? _debounce;
int _refresh = 0;
JellyfinAccounts get _accounts => widget.accounts;
BrowseController get _c => widget.controller ?? (_own ??= BrowseController(widget.accounts));
@override
void initState() {
super.initState();
final start = widget.start;
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);
}
_c.start(start: widget.start, serverHint: widget.selected?.serverId);
_accounts.addListener(_changed);
}
/// 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)));
_c.addListener(_moved);
}
@override
@@ -104,16 +81,22 @@ class _JellyfinBrowserState extends State<JellyfinBrowser> {
old.accounts.removeListener(_changed);
widget.accounts.addListener(_changed);
}
final was = old.controller ?? _own;
if (was != _c) {
was?.removeListener(_moved);
_c.addListener(_moved);
}
}
@override
void dispose() {
_accounts.removeListener(_changed);
_c.removeListener(_moved);
_own?.dispose();
_debounce?.cancel();
_search.dispose();
_searchFocus.dispose();
_body.dispose();
_peek.dispose();
super.dispose();
}
@@ -121,44 +104,33 @@ class _JellyfinBrowserState extends State<JellyfinBrowser> {
if (mounted) setState(() {});
}
JellyfinAccount? get _account =>
(_serverId == null ? null : _accounts.account(_serverId!)) ?? _accounts.defaultAccount;
void _switchServer(String serverId) {
if (serverId == _account?.serverId) return;
/// Any move (here, from the library chips or a server switch) ends a
/// search.
int _depth = 0;
String? _server;
void _moved() {
if (!mounted) return;
final moved = _c.stack.length != _depth || _c.serverId != _server;
_depth = _c.stack.length;
_server = _c.serverId;
setState(() {
_serverId = serverId;
_stack = _restored(serverId);
_peek.value = null;
_clearSearch();
if (moved) _clearSearch();
});
}
JellyfinAccount? get _account => _c.account;
// ------------------------------------------------------------- navigation
void _open(BrowseRoute route) {
setState(() {
_clearSearch();
if (_stack.last.key != route.key) _stack = [..._stack, route];
});
_remember();
_body.requestFocus();
}
bool _back() {
if (_stack.length <= 1) return false;
setState(() => _stack = _stack.sublist(0, _stack.length - 1));
_remember();
_body.requestFocus();
return true;
final ok = _c.back();
if (ok) _body.requestFocus();
return ok;
}
void _popTo(int index) {
setState(() {
_clearSearch();
_stack = _stack.sublist(0, index + 1);
});
_remember();
setState(_clearSearch);
_c.popTo(index);
_body.requestFocus();
}
@@ -175,12 +147,6 @@ class _JellyfinBrowserState extends State<JellyfinBrowser> {
_term = '';
}
void _pick(JellyfinItem item) {
final account = _account;
if (account == null) return;
widget.onPick(pickFor(account, item));
}
KeyEventResult _onKey(FocusNode node, KeyEvent e) {
if (e is! KeyDownEvent && e is! KeyRepeatEvent) return KeyEventResult.ignored;
final keys = HardwareKeyboard.instance;
@@ -217,10 +183,9 @@ class _JellyfinBrowserState extends State<JellyfinBrowser> {
Widget _page(BrowseRoute r) => switch (r) {
HomeRoute() => const JellyfinHomePage(),
LibraryRoute(:final folder) => JellyfinLibraryPage(folder: folder),
LibraryRoute(:final folder, :final sort) => JellyfinLibraryPage(folder: folder, sort: sort),
SeriesRoute(:final series, :final seasonId) =>
JellyfinSeriesPage(series: series, seasonId: seasonId),
DetailRoute(:final item) => JellyfinDetailPage(item: item),
};
@override
@@ -233,15 +198,16 @@ class _JellyfinBrowserState extends State<JellyfinBrowser> {
final status = _accounts.statusOf(account.serverId);
final sel = widget.selected;
final searching = _term.isNotEmpty;
final stack = _c.stack;
final pages = Stack(
children: [
for (var i = 0; i < _stack.length; i++)
for (var i = 0; i < stack.length; i++)
Visibility(
key: ValueKey('$_refresh:${account.serverId}:$i:${_stack[i].key}'),
visible: !searching && i == _stack.length - 1,
key: ValueKey('$_refresh:${account.serverId}:$i:${stack[i].key}'),
visible: !searching && i == stack.length - 1,
maintainState: true,
child: _page(_stack[i]),
child: _page(stack[i]),
),
if (searching) JellyfinSearchResults(key: ValueKey('$_refresh:$_term'), term: _term),
],
@@ -251,10 +217,11 @@ class _JellyfinBrowserState extends State<JellyfinBrowser> {
accounts: _accounts,
account: account,
client: client,
controller: _c,
selectedItemId: sel != null && sel.serverId == account.serverId ? sel.itemId : null,
onPick: _pick,
onOpen: _open,
onPeek: (item) => _peek.value = item,
onSelect: (item) => widget.onSelect(JellyfinSelection(account.serverId, item.id, item)),
onPutUp: (item) => widget.onPutUp(JellyfinSelection(account.serverId, item.id, item)),
libraryStrip: widget.libraryStrip,
child: Focus(
onKeyEvent: _onKey,
child: Column(
@@ -265,21 +232,7 @@ class _JellyfinBrowserState extends State<JellyfinBrowser> {
if (status == JellyfinAccountStatus.expired || status == JellyfinAccountStatus.offline)
_StatusBanner(accounts: _accounts, account: account, status: status),
const SizedBox(height: 12),
Expanded(
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)),
],
);
}),
),
Expanded(child: FocusScope(node: _body, child: pages)),
],
),
),
@@ -289,9 +242,10 @@ class _JellyfinBrowserState extends State<JellyfinBrowser> {
Widget _header(BuildContext context, JellyfinAccount account) {
final theme = Theme.of(context);
final t = theme.textTheme;
final stack = _c.stack;
final crumbs = <Widget>[];
for (var i = 0; i < _stack.length; i++) {
final last = i == _stack.length - 1 && _term.isEmpty;
for (var i = 0; i < stack.length; i++) {
final last = i == stack.length - 1 && _term.isEmpty;
if (i > 0) {
crumbs.add(const Padding(
padding: EdgeInsets.symmetric(horizontal: 2),
@@ -299,7 +253,7 @@ class _JellyfinBrowserState extends State<JellyfinBrowser> {
));
}
final label = Text(
_stack[i].label,
stack[i].label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: t.titleSmall?.copyWith(
@@ -328,73 +282,68 @@ class _JellyfinBrowserState extends State<JellyfinBrowser> {
style: t.titleSmall?.copyWith(fontWeight: FontWeight.w600))));
}
return Row(
children: [
_ServerSwitcher(
accounts: _accounts,
current: account,
onSelect: _switchServer,
onOpenIntegrations: widget.onOpenIntegrations,
),
const SizedBox(width: 4),
IconButton(
tooltip: 'Back (Esc)',
visualDensity: VisualDensity.compact,
onPressed: _stack.length > 1 ? _back : null,
icon: const Icon(Icons.arrow_back, size: 20),
),
const SizedBox(width: 4),
Expanded(child: Row(children: crumbs)),
const SizedBox(width: 8),
SizedBox(
width: 260,
child: TextField(
key: const ValueKey('jf-search'),
controller: _search,
focusNode: _searchFocus,
onChanged: _onSearchChanged,
onSubmitted: (v) {
_debounce?.cancel();
setState(() => _term = v.trim());
},
decoration: InputDecoration(
isDense: true,
hintText: 'Search ( / )',
prefixIcon: const Icon(Icons.search, size: 18),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
suffixIcon: ListenableBuilder(
listenable: _search,
builder: (context, _) => _search.text.isEmpty
? const SizedBox.shrink()
: IconButton(
tooltip: 'Clear (Esc)',
iconSize: 16,
onPressed: () => setState(_clearSearch),
icon: const Icon(Icons.close),
),
return LayoutBuilder(builder: (context, box) {
final narrow = box.maxWidth < 640;
return Row(
children: [
_ServerSwitcher(
accounts: _accounts,
current: account,
compact: narrow,
onSelect: _c.switchServer,
onOpenIntegrations: widget.onOpenIntegrations,
),
IconButton(
tooltip: 'Back (Esc)',
visualDensity: VisualDensity.compact,
onPressed: stack.length > 1 ? _back : null,
icon: const Icon(Icons.arrow_back, size: 20),
),
const SizedBox(width: 4),
Expanded(child: Row(children: crumbs)),
const SizedBox(width: 8),
SizedBox(
width: narrow ? 170 : 260,
child: TextField(
key: const ValueKey('jf-search'),
controller: _search,
focusNode: _searchFocus,
onChanged: _onSearchChanged,
onSubmitted: (v) {
_debounce?.cancel();
setState(() => _term = v.trim());
},
decoration: InputDecoration(
isDense: true,
hintText: 'Search ( / )',
prefixIcon: const Icon(Icons.search, size: 18),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
suffixIcon: ListenableBuilder(
listenable: _search,
builder: (context, _) => _search.text.isEmpty
? const SizedBox.shrink()
: IconButton(
tooltip: 'Clear (Esc)',
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(
key: const ValueKey('jf-expand'),
tooltip: widget.expanded ? 'Show the host form' : 'Bigger browser',
tooltip: 'Refresh',
visualDensity: VisualDensity.compact,
onPressed: widget.onToggleExpanded,
icon: Icon(widget.expanded ? Icons.close_fullscreen : Icons.open_in_full, size: 18),
onPressed: () {
_accounts.invalidate(account.serverId);
setState(() => _refresh++);
},
icon: const Icon(Icons.refresh, size: 20),
),
],
);
],
);
});
}
}
@@ -404,11 +353,14 @@ class _ServerSwitcher extends StatelessWidget {
final JellyfinAccount current;
final ValueChanged<String> onSelect;
final VoidCallback? onOpenIntegrations;
/// Narrow: the name is shorter.
final bool compact;
const _ServerSwitcher({
required this.accounts,
required this.current,
required this.onSelect,
this.onOpenIntegrations,
this.compact = false,
});
@override
@@ -420,7 +372,7 @@ class _ServerSwitcher extends StatelessWidget {
StatusDot(status: accounts.statusOf(current.serverId)),
const SizedBox(width: 8),
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 200),
constraints: BoxConstraints(maxWidth: compact ? 110 : 200),
child: Text(current.displayName,
maxLines: 1, overflow: TextOverflow.ellipsis, style: t.titleSmall),
),
+118 -120
View File
@@ -2,12 +2,12 @@ import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
import 'package:sync_relay_core/core.dart';
import 'browse_scope.dart';
import 'format.dart';
import 'images.dart';
import '../shell/motion.dart' show HScroll;
enum CardShape {
/// 2:3 poster (movies, series, latest).
@@ -49,20 +49,29 @@ IconData kindIcon(JellyfinItem item) => switch (item.kind) {
_ => 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
/// and one meta line. Click / Enter activates (pick or open), hover and
/// focus feed the inspector, "i" or the hover button opens the details.
/// and one meta line. Click / Enter selects it (details panel; libraries
/// 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 {
final JellyfinItem item;
final JellyfinItem? display;
final String? subtitle;
final CardShape shape;
/// Rows mixing series (Continue watching, Next up): series name on top,
/// "S2E3 · 12 min left" below.
/// Rows mixing series (Continue): series name on top, "S2E3 · 12 min
/// left" below.
final bool seriesFirst;
final bool autofocus;
const MediaCard({
super.key,
required this.item,
required this.shape,
this.display,
this.subtitle,
this.seriesFirst = false,
this.autofocus = false,
});
@@ -74,18 +83,20 @@ class MediaCard extends StatefulWidget {
class _MediaCardState extends State<MediaCard> {
bool _hover = false;
bool _focus = false;
DateTime? _lastTap;
JellyfinItem get item => widget.item;
void _details(BrowseScope scope) => scope.onOpen(DetailRoute(item));
KeyEventResult _onKey(BrowseScope scope, KeyEvent e) {
if (e is! KeyDownEvent) return KeyEventResult.ignored;
if (e.logicalKey == LogicalKeyboardKey.keyI && isPlayable(item)) {
_details(scope);
return KeyEventResult.handled;
void _tap(BrowseScope scope) {
final now = DateTime.now();
final last = _lastTap;
_lastTap = now;
if (last != null && now.difference(last) < doubleClickTime && isSelectable(item)) {
_lastTap = null;
scope.onPutUp(item);
return;
}
return KeyEventResult.ignored;
scope.activate(item);
}
@override
@@ -93,10 +104,11 @@ class _MediaCardState extends State<MediaCard> {
final scope = BrowseScope.of(context);
final theme = Theme.of(context);
final scheme = theme.colorScheme;
final shown = widget.display ?? item;
final selected = scope.selectedItemId == item.id;
final played = item.userData.played;
final progress = progressOf(item);
final unplayed = item.userData.unplayedItemCount;
final unplayed = shown.userData.unplayedItemCount;
final ring = selected
? scheme.primary
: _focus
@@ -106,20 +118,21 @@ class _MediaCardState extends State<MediaCard> {
final image = AspectRatio(
aspectRatio: widget.shape.aspect,
child: AnimatedContainer(
duration: const Duration(milliseconds: 120),
duration: const Duration(milliseconds: 160),
curve: Curves.fastEaseInToSlowEaseOut,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
border: Border.all(color: ring, width: 2),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: ring, width: 3),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(6),
borderRadius: BorderRadius.circular(7),
child: Stack(
fit: StackFit.expand,
children: [
JellyfinImage(
client: scope.client,
image: cardImage(item, widget.shape),
icon: kindIcon(item),
image: cardImage(shown, widget.shape),
icon: kindIcon(shown),
),
if (_hover || _focus)
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))
: played
? 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(
color: scheme.primaryContainer,
child: Text('$unplayed',
@@ -151,78 +164,55 @@ class _MediaCardState extends State<MediaCard> {
color: scheme.onPrimaryContainer)))
: 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 sub = widget.seriesFirst && item.kind == JellyfinItemKind.episode
? [cardSubtitle(item), item.name].where((s) => s.isNotEmpty).join(' · ')
: cardSubtitle(item);
return Focus(
canRequestFocus: false,
skipTraversal: true,
onKeyEvent: (_, e) => _onKey(scope, e),
child: Semantics(
button: true,
selected: selected,
label: title,
child: InkWell(
key: ValueKey('card-${item.id}'),
autofocus: widget.autofocus,
borderRadius: BorderRadius.circular(8),
focusColor: Colors.transparent,
hoverColor: Colors.transparent,
onTap: () => scope.activate(item),
onHover: (h) {
setState(() => _hover = h);
if (h) scope.onPeek(item);
},
onFocusChange: (f) {
setState(() => _focus = f);
if (f) scope.onPeek(item);
},
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
image,
const SizedBox(height: 6),
SizedBox(
height: cardTextHeight - 6,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title,
maxLines: 1,
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)),
],
),
final title = widget.display != null ? shown.name : cardTitle(item, seriesFirst: widget.seriesFirst);
final sub = widget.subtitle ??
(widget.seriesFirst && item.kind == JellyfinItemKind.episode
? [cardSubtitle(item), item.name].where((s) => s.isNotEmpty).join(' · ')
: cardSubtitle(item));
return Semantics(
button: true,
selected: selected,
label: title,
child: InkWell(
key: ValueKey('card-${item.id}'),
autofocus: widget.autofocus,
borderRadius: BorderRadius.circular(10),
focusColor: Colors.transparent,
hoverColor: Colors.transparent,
onTap: () => _tap(scope),
onHover: (h) => setState(() => _hover = h),
onFocusChange: (f) => setState(() => _focus = f),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
image,
const SizedBox(height: 6),
SizedBox(
height: cardTextHeight - 6,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
color: selected ? scheme.primary : null)),
Text(sub,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall?.copyWith(color: Colors.grey)),
],
),
],
),
),
],
),
),
);
@@ -275,18 +265,25 @@ class CardGridDelegate extends SliverGridDelegate {
bool shouldRelayout(CardGridDelegate old) => old.shape != shape || old.minWidth != minWidth;
}
/// A titled horizontal row of cards sized to fill the width; arrows (on
/// hover) page through it. Lazy: only visible cards are built.
/// One card of a [MediaRow]: see [MediaCard].
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 {
final String title;
final List<JellyfinItem> items;
final List<CardEntry> entries;
final CardShape shape;
final bool seriesFirst;
final Widget? trailing;
const MediaRow({
super.key,
required this.title,
required this.items,
required this.entries,
required this.shape,
this.seriesFirst = false,
this.trailing,
@@ -315,44 +312,38 @@ class _MediaRowState extends State<MediaRow> {
@override
Widget build(BuildContext context) {
final t = Theme.of(context).textTheme;
final entries = widget.entries;
return MouseRegion(
onEnter: (_) => setState(() => _hover = true),
onExit: (_) => setState(() => _hover = false),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
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,
],
),
),
SectionHeader(widget.title, count: '${entries.length}', trailing: widget.trailing),
LayoutBuilder(builder: (context, box) {
final fit = fitColumns(box.maxWidth, widget.shape.minWidth);
final h = fit.itemWidth / widget.shape.aspect + cardTextHeight;
final overflow = widget.items.length > fit.cols;
final overflow = entries.length > fit.cols;
return SizedBox(
height: h,
child: Stack(
children: [
ListView.separated(
HScroll(
controller: _scroll,
scrollDirection: Axis.horizontal,
itemCount: widget.items.length,
separatorBuilder: (_, _) => const SizedBox(width: cardGap),
itemBuilder: (context, i) => SizedBox(
width: fit.itemWidth,
child: MediaCard(
item: widget.items[i],
shape: widget.shape,
seriesFirst: widget.seriesFirst,
builder: (c) => ListView.separated(
controller: c,
scrollDirection: Axis.horizontal,
itemCount: entries.length,
separatorBuilder: (_, _) => const SizedBox(width: cardGap),
itemBuilder: (context, i) => SizedBox(
width: fit.itemWidth,
child: MediaCard(
item: entries[i].item,
display: entries[i].display,
subtitle: entries[i].subtitle,
shape: widget.shape,
seriesFirst: widget.seriesFirst,
),
),
),
),
@@ -410,12 +401,19 @@ class SectionHeader extends StatelessWidget {
padding: const EdgeInsets.only(bottom: 8, top: 4),
child: Row(
children: [
Text(title, style: t.titleMedium),
if (count != null) ...[
const SizedBox(width: 8),
Text(count!, style: t.bodySmall?.copyWith(color: Colors.grey)),
],
const Spacer(),
Expanded(
child: Row(
children: [
Flexible(
child: Text(title, maxLines: 1, overflow: TextOverflow.ellipsis, style: t.titleMedium),
),
if (count != null) ...[
const SizedBox(width: 8),
Text(count!, style: t.bodySmall?.copyWith(color: Colors.grey)),
],
],
),
),
?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,514 @@
import 'package:flutter/material.dart';
import 'package:sync_relay_core/core.dart';
import '../shell/motion.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
/// [solo]), 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 watches (offline or solo): the button says Play.
final bool solo;
/// Puts [item] (playable) up; returns an error text, or null when sent.
final Future<String?> Function(JellyfinSelection item) onPutUp;
final VoidCallback onClose;
const JellyfinDetailsPanel({
super.key,
required this.controller,
required this.selection,
required this.onPutUp,
required this.onClose,
this.solo = 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;
});
}
/// 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.solo ? '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)
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 (_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)
DropdownButton<String>(
key: const ValueKey('details-season'),
value: seasonId,
isDense: true,
underline: const SizedBox.shrink(),
borderRadius: BorderRadius.circular(10),
items: [
for (final s in seasons)
DropdownMenuItem(value: s.id, child: Text(s.name, overflow: TextOverflow.ellipsis)),
],
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),
child: InkWell(
key: ValueKey('details-episode-${e.id}'),
borderRadius: BorderRadius.circular(8),
onTap: () => _tapEpisode(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)),
],
),
),
],
);
}
}
@@ -33,6 +33,9 @@ bool isPlayable(JellyfinItem item) => switch (item.kind) {
_ => !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", ...
String cardSubtitle(JellyfinItem item, {bool withSeries = false}) {
final parts = <String>[];
+143 -73
View File
@@ -1,8 +1,11 @@
import 'package:flutter/material.dart';
import 'package:signals/signals_flutter.dart';
import 'package:sync_relay_core/core.dart';
import '../state/settings.dart';
import 'browse_scope.dart';
import 'cards.dart';
import 'home_rows.dart';
/// Libraries that hold videos; music, books, photos and playlists are left
/// out of the browser.
@@ -19,9 +22,15 @@ IconData libraryIcon(JellyfinItem view) => switch (view.collectionType) {
_ => Icons.folder_outlined,
};
/// Start page: library shortcuts, then Continue watching, Next up and the
/// latest additions of each library. Each row loads on its own, so a slow
/// library does not hold up the rest.
/// The video libraries of the scope's server (cached).
Future<List<JellyfinItem>> videoLibraries(BrowseScope scope) =>
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 {
const JellyfinHomePage({super.key});
@@ -30,58 +39,49 @@ class JellyfinHomePage extends StatelessWidget {
final scope = BrowseScope.of(context);
return Loader<List<JellyfinItem>>(
key: ValueKey('views-${scope.account.serverId}'),
load: () => scope.load('views', (c) => c.userViews()),
builder: (context, all) {
final views = all.where(isVideoLibrary).toList();
return ListView(
key: const PageStorageKey('jf-home'),
padding: const EdgeInsets.fromLTRB(0, 4, 0, 24),
children: [
_LibraryStrip(views: views),
const SizedBox(height: 20),
_Section(
key: const ValueKey('row-resume'),
title: 'Continue watching',
shape: CardShape.thumb,
seriesFirst: true,
load: () => scope.load('resume', (c) => c.resume(limit: 30)),
),
_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'),
),
),
],
);
},
load: () => videoLibraries(scope),
builder: (context, views) => ListenableBuilder(
listenable: scope.controller,
builder: (context, _) {
final v = scope.controller.version;
return ListView(
key: const PageStorageKey('jf-home'),
padding: const EdgeInsets.fromLTRB(0, 4, 0, 24),
children: [
if (scope.libraryStrip) ...[
LibraryStrip(views: views),
const SizedBox(height: 20),
] else if (views.isEmpty)
const _NoLibraries(),
_ContinueRow(version: v),
for (final view in views) _LatestRow(key: ValueKey('row-latest-${view.id}'), view: view, version: v),
],
);
},
),
);
}
}
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;
const _LibraryStrip({required this.views});
const LibraryStrip({super.key, required this.views});
@override
Widget build(BuildContext context) {
final scope = BrowseScope.of(context);
if (views.isEmpty) {
return const Text('No video libraries on this server for your user.',
style: TextStyle(color: Colors.grey));
}
if (views.isEmpty) return const _NoLibraries();
return Wrap(
spacing: 8,
runSpacing: 8,
@@ -92,46 +92,116 @@ class _LibraryStrip extends StatelessWidget {
avatar: Icon(libraryIcon(v), size: 18),
label: Text(v.name),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
onPressed: () => scope.onOpen(LibraryRoute(v)),
onPressed: () => scope.controller.openTop(LibraryRoute(v)),
),
],
);
}
}
class _Section extends StatelessWidget {
final String title;
final CardShape shape;
final bool seriesFirst;
final Future<List<JellyfinItem>> Function() load;
final Widget? trailing;
const _Section({
super.key,
required this.title,
required this.shape,
required this.load,
this.seriesFirst = false,
this.trailing,
});
class _ContinueRow extends StatelessWidget {
final int version;
const _ContinueRow({required this.version});
static const title = 'Continue';
Future<List<JellyfinItem>> _load(BrowseScope scope) async {
final lists = await Future.wait([
scope.load('resume', (c) => c.resume(limit: 40)),
scope.load('nextup', (c) => c.nextUp(limit: 40)),
// Only for ordering: a failure leaves the row as it is.
scope
.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
Widget build(BuildContext context) {
final scope = BrowseScope.of(context);
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,
loading: _RowSkeleton(title: title, shape: shape),
builder: (context, items) => items.isEmpty
? const SizedBox.shrink()
: Padding(
padding: const EdgeInsets.only(bottom: 24),
child: MediaRow(
title: title,
items: items,
shape: shape,
seriesFirst: seriesFirst,
trailing: trailing,
),
builder: (context, items) => SignalBuilder(builder: (context) {
final hide = AppSettings.instance.hideReReleases.value;
final cards = latestCards(items, hideReReleases: hide);
if (cards.isEmpty) return const SizedBox.shrink();
return Padding(
padding: const EdgeInsets.only(bottom: 24),
child: MediaRow(
key: ValueKey('latest-${view.id}'),
title: title,
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'),
),
PopupMenuButton<void>(
key: ValueKey('latest-menu-${view.id}'),
tooltip: 'Row options',
icon: const Icon(Icons.more_vert, size: 18),
itemBuilder: (context) => [
CheckedPopupMenuItem(
key: const ValueKey('hide-rereleases'),
checked: hide,
onTap: () => AppSettings.instance.setHideReReleases(!hide),
child: const Text('Hide re-releases in Latest'),
),
],
),
],
),
),
);
}),
);
}
}
@@ -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.
class JellyfinLibraryPage extends StatefulWidget {
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
State<JellyfinLibraryPage> createState() => _JellyfinLibraryPageState();
}
class _JellyfinLibraryPageState extends State<JellyfinLibraryPage> {
LibrarySort _sort = LibrarySort.name;
bool _descending = false;
late LibrarySort _sort =
LibrarySort.values.where((s) => s.name == widget.sort).firstOrNull ?? LibrarySort.name;
late bool _descending = _sort.descending;
bool _unplayed = false;
bool _favorites = false;
@@ -1,31 +1,35 @@
import 'package:flutter/material.dart';
import '../screens/home/picked_media.dart';
import '../screens/home/ui.dart';
import 'accounts.dart';
import 'browse_scope.dart';
import 'browser.dart';
import 'sign_in_panel.dart';
/// The media picker's Jellyfin source: the library browser once a server
/// is linked, else an inline add-server panel.
/// The Library's Jellyfin source: the library browser once a server is
/// linked, else [empty] (default: an inline add-server panel).
class JellyfinPickerSource extends StatefulWidget {
final JellyfinAccounts accounts;
final JellyfinMedia? selected;
final ValueChanged<JellyfinMedia> onPick;
final BrowseController? controller;
final JellyfinSelection? selected;
final ValueChanged<JellyfinSelection> onSelect;
final ValueChanged<JellyfinSelection> onPutUp;
final VoidCallback? onOpenIntegrations;
final VoidCallback? onToggleExpanded;
final bool expanded;
final BrowseStart? start;
final bool libraryStrip;
/// Shown while no server is linked.
final WidgetBuilder? empty;
const JellyfinPickerSource({
super.key,
this.start,
required this.accounts,
required this.onPick,
required this.onSelect,
required this.onPutUp,
this.controller,
this.selected,
this.onOpenIntegrations,
this.onToggleExpanded,
this.expanded = false,
this.libraryStrip = true,
this.empty,
});
@override
@@ -48,16 +52,18 @@ class _JellyfinPickerSourceState extends State<JellyfinPickerSource> {
if (!a.loaded) {
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);
return JellyfinBrowser(
key: const ValueKey('media-jellyfin'),
accounts: a,
controller: widget.controller,
selected: widget.selected,
onPick: widget.onPick,
onSelect: widget.onSelect,
onPutUp: widget.onPutUp,
onOpenIntegrations: widget.onOpenIntegrations,
onToggleExpanded: widget.onToggleExpanded,
expanded: widget.expanded,
start: widget.start,
libraryStrip: widget.libraryStrip,
);
},
);
+112 -116
View File
@@ -1,8 +1,8 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:sync_relay_core/core.dart';
import 'browse_scope.dart';
import 'cards.dart' show doubleClickTime;
import 'format.dart';
import 'images.dart';
@@ -60,6 +60,7 @@ class _JellyfinSeriesPageState extends State<JellyfinSeriesPage> {
final scope = BrowseScope.of(context);
return Loader<_SeriesData>(
key: ValueKey('series-${widget.series.id}-${scope.account.serverId}'),
version: scope.controller.version,
load: () => _load(scope),
builder: (context, d) {
final seasonId = (_seasonId != null && d.seasons.any((s) => s.id == _seasonId))
@@ -99,6 +100,7 @@ class _JellyfinSeriesPageState extends State<JellyfinSeriesPage> {
key: ValueKey('episodes-$seasonId'),
seriesId: widget.series.id,
seasonId: seasonId,
version: scope.controller.version,
),
const SliverToBoxAdapter(child: SizedBox(height: 24)),
],
@@ -218,7 +220,7 @@ class _SeriesHeaderState extends State<_SeriesHeader> {
Wrap(spacing: 8, runSpacing: 8, children: [
FilledButton.icon(
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),
label: Text(nextLabel, overflow: TextOverflow.ellipsis),
),
@@ -242,7 +244,9 @@ class _SeriesHeaderState extends State<_SeriesHeader> {
class _EpisodeList extends StatefulWidget {
final String seriesId;
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
State<_EpisodeList> createState() => _EpisodeListState();
@@ -250,6 +254,13 @@ class _EpisodeList extends StatefulWidget {
class _EpisodeListState extends State<_EpisodeList> {
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}',
(c) => c.episodes(widget.seriesId, seasonId: widget.seasonId));
@@ -265,7 +276,8 @@ class _EpisodeListState extends State<_EpisodeList> {
return SliverToBoxAdapter(
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) {
return const SliverToBoxAdapter(
child: Padding(
@@ -302,7 +314,8 @@ class _EpisodeListState extends State<_EpisodeList> {
const episodeTileHeight = 6 + 176 * 9 / 16 + 6 + 2;
/// 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 {
final JellyfinItem episode;
const EpisodeTile({super.key, required this.episode});
@@ -314,6 +327,19 @@ class EpisodeTile extends StatefulWidget {
class _EpisodeTileState extends State<EpisodeTile> {
bool _focus = 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
Widget build(BuildContext context) {
@@ -332,122 +358,92 @@ class _EpisodeTileState extends State<EpisodeTile> {
if (e.premiereDate != null)
'${e.premiereDate!.year}-${e.premiereDate!.month.toString().padLeft(2, '0')}-${e.premiereDate!.day.toString().padLeft(2, '0')}',
].join(' · ');
return Focus(
canRequestFocus: false,
skipTraversal: true,
onKeyEvent: (_, k) {
if (k is KeyDownEvent && k.logicalKey == LogicalKeyboardKey.keyI) {
scope.onOpen(DetailRoute(e));
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
},
child: Material(
color: selected
? scheme.primaryContainer.withValues(alpha: 0.35)
: _hover || _focus
? scheme.surfaceContainerHigh
: Colors.transparent,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
side: BorderSide(
color: selected ? scheme.primary : _focus ? scheme.onSurface : Colors.transparent,
width: 1.5),
),
clipBehavior: Clip.antiAlias,
child: InkWell(
key: ValueKey('episode-${e.id}'),
focusColor: Colors.transparent,
onTap: () => scope.onPick(e),
onHover: (h) {
setState(() => _hover = h);
if (h) scope.onPeek(e);
},
onFocusChange: (f) {
setState(() => _focus = f);
if (f) scope.onPeek(e);
},
child: Padding(
padding: const EdgeInsets.all(6),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 176,
child: AspectRatio(
aspectRatio: 16 / 9,
child: ClipRRect(
borderRadius: BorderRadius.circular(6),
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,
return Material(
color: selected
? scheme.primaryContainer.withValues(alpha: 0.35)
: _hover || _focus
? scheme.surfaceContainerHigh
: Colors.transparent,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
side: BorderSide(
color: selected ? scheme.primary : _focus ? scheme.onSurface : Colors.transparent,
width: 1.5),
),
clipBehavior: Clip.antiAlias,
child: InkWell(
key: ValueKey('episode-${e.id}'),
focusColor: Colors.transparent,
onTap: () => _tap(scope),
onHover: (h) => setState(() => _hover = h),
onFocusChange: (f) => setState(() => _focus = f),
child: Padding(
padding: const EdgeInsets.all(6),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 176,
child: AspectRatio(
aspectRatio: 16 / 9,
child: ClipRRect(
borderRadius: BorderRadius.circular(6),
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)
Positioned(
left: 0,
right: 0,
bottom: 0,
child: LinearProgressIndicator(
value: progress, minHeight: 3, backgroundColor: Colors.black54),
),
if (progress > 0)
Positioned(
left: 0,
right: 0,
bottom: 0,
child: LinearProgressIndicator(
value: progress, minHeight: 3, backgroundColor: Colors.black54),
if (played || selected)
Positioned(
top: 4,
right: 4,
child: CircleAvatar(
radius: 10,
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,
backgroundColor: selected ? scheme.primary : const Color(0xCC1B5E20),
child: Icon(Icons.check,
size: 13, color: selected ? scheme.onPrimary : Colors.white),
),
),
]),
),
]),
),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
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,
),
),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
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)),
],
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)),
],
),
],
),
],
),
),
],
),
),
),
@@ -201,7 +201,8 @@ final class OwnPlay extends JellyfinPlan {
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 String url;
const RelayPlay(this.url);
@@ -210,7 +211,7 @@ final class RelayPlay extends JellyfinPlan {
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 String serverId;
/// Known only when this install has (had) an account there.
@@ -224,20 +225,22 @@ final class BlockedPlay extends JellyfinPlan {
String get message {
final where = serverName == null || serverName!.isEmpty ? 'that Jellyfin server' : serverName!;
final base = 'This needs a Jellyfin account on $where (or the host can relay)';
return problem == null ? base : '$base — $problem';
final base = 'No Jellyfin account here for $where, and nobody is passing it on';
return problem == null ? base : '$base ($problem)';
}
}
/// Picks how to play [ref]: this install's account first (PlaybackInfo
/// with its own user; a 401 marks the account expired), else the relay
/// when the server offers it ([relay] = `state.relay`, [relayUrl] this
/// member's `/j/...` URL), else [BlockedPlay].
/// with its own user; a 401 marks the account expired), else the server's
/// own Jellyfin login ([relay] = `state.relay`, [relayUrl] this member's
/// `/j/...` URL), else the member who put it up ([memberRelayUrl], the
/// `/r/...` URL when `media.relay` is set), else [BlockedPlay].
Future<JellyfinPlan> resolveJellyfinRef(
JellyfinAccounts accounts,
JellyfinRef ref, {
required bool relay,
required String? relayUrl,
String? memberRelayUrl,
Duration timeout = const Duration(seconds: 15),
}) async {
await accounts.ensureLoaded();
@@ -282,6 +285,7 @@ Future<JellyfinPlan> resolveJellyfinRef(
}
}
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);
}
@@ -328,13 +332,13 @@ Duration nextPromptStart(JellyfinItem? item, Duration runtime, {Duration? ending
/// 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
/// their own account (`jf[serverId] == true`, not stale, not out). 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 (!m.stale && !m.out && m.jf[serverId] == true) m
];
if (eligible.isEmpty) return null;
eligible.sort((a, b) => compareMemberIds(a.id, b.id));
@@ -0,0 +1,19 @@
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');
}
}
@@ -0,0 +1,53 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:sync_relay_core/core.dart' show findInstallRoot;
/// 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 = 'moe.senkochan.watch_together';
const desktopFileName = '$appId.desktop';
/// 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). Never in debug runs; failures only log.
Future<void> installDesktopEntry({String? executable, String? dataHome, String? home}) async {
if (!Platform.isLinux || kDebugMode) 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';
for (final size in const [16, 32, 48, 64, 128, 256, 512]) {
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) return;
await file.parent.create(recursive: true);
await file.writeAsString(entry);
} catch (e) {
debugPrint('desktop entry: $e');
}
}
@visibleForTesting
String desktopEntryText(String exe) => '''[Desktop Entry]
Type=Application
Name=Starsync
Comment=Watch videos in sync with friends
Exec="$exe"
Icon=$appId
StartupWMClass=$appId
Categories=AudioVideo;Video;Player;Network;
Terminal=false
''';
@@ -243,18 +243,20 @@ const _skipTail = Duration(seconds: 1);
/// 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
/// more episode, is the viewer's call). Without any opening info on media
/// longer than [fallbackMinDuration], "Skip 1:30" during the first
/// [fallbackWindow]. Null otherwise.
/// longer than [fallbackMinDuration] and without chapters ([chaptered]: a
/// chaptered file without an OP chapter has none on purpose), "Skip 1:30"
/// during the first [fallbackWindow]. Null otherwise.
SkipOffer? skipOfferAt({
required Duration position,
required Duration duration,
required SkipRange? opening,
required SkipRange? ending,
bool chaptered = false,
}) {
bool inside(SkipRange r) => position >= r.start && position < r.end - _skipTail;
if (opening != null && inside(opening)) return SkipOffer(SkipKind.opening, opening.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;
return SkipOffer(SkipKind.fixed, to < duration ? to : duration);
}
@@ -16,6 +16,7 @@ import '../sync/state_diff.dart';
import '../sync/user_seek.dart';
import 'frame_watchdog.dart';
import 'media_marks.dart';
import 'yt_dlp_app.dart' show AppYtDlp, ytdlFormat;
/// Owns one mpv [Player] (and its [VideoController]) per session: created
/// by [init] on join, released by [dispose] on leave, swapped by
@@ -32,6 +33,9 @@ class PlaybackController {
/// 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;
/// mpv's `volume-max` (its default is 130): the volume controls go
/// this far, past 100 % amplified.
static const maxVolume = 150;
SyncPlayer? _player;
VideoController? _video;
@@ -39,7 +43,7 @@ class PlaybackController {
bool _probing = false;
final FrameWatchdog _watchdog = FrameWatchdog();
StreamSubscription<PlayerLog>? _logSub;
final List<StreamSubscription<Duration>> _timeSubs = [];
final List<StreamSubscription<Object?>> _timeSubs = [];
String _chapterJson = '';
// `glsl-shaders` as last set on the current player ('' = none).
String _shaders = '';
@@ -57,6 +61,36 @@ class PlaybackController {
/// Called once when the watchdog sees a frozen picture.
void Function()? onFrozen;
/// mpv's yt-dlp hook could not open the current link (its error text).
void Function(String error)? onYtdlError;
/// Points mpv's yt-dlp hook at [path] (the app's managed copy) for web
/// page links, at most 1080p, once per player; its cookies
/// (`ytdl-raw-options`) whenever they changed.
Future<void> useYtDlp(String path) async {
final p = _player;
if (p == null) return;
try {
final dynamic platform = p.platform;
if (!identical(p, _ytdlSetOn)) {
await platform?.setProperty('script-opts', 'ytdl_hook-ytdl_path=$path');
await platform?.setProperty('ytdl-format', ytdlFormat);
_ytdlSetOn = p;
_ytdlRaw = '';
}
final raw = await AppYtDlp.instance.mpvRawOptions();
if (raw != _ytdlRaw && identical(p, _player)) {
await platform?.setProperty('ytdl-raw-options', raw);
_ytdlRaw = raw;
}
} catch (e) {
debugPrint('yt-dlp path not set: $e');
}
}
Player? _ytdlSetOn;
String _ytdlRaw = '';
/// Fingerprint of the picture on screen, installed by the video view
/// (`ShownFrameProbe.sample`). Without it the watchdog stays idle.
Future<int?> Function()? shownFrame;
@@ -66,7 +100,7 @@ class PlaybackController {
/// Seeks from the video controls (seek bar) go here instead of to mpv.
void Function(Duration position)? onUserSeek;
/// Latest shader message for the user (the session screen toasts it).
/// Latest shader message for the user (the session player toasts it).
final shaderNotice = signal<ShaderNotice?>(null);
/// How media_kit renders the current player's video (shaders need
@@ -163,6 +197,7 @@ class PlaybackController {
_logSub = _player!.stream.log.listen(_onLog);
_watchMarks(_player!);
await _pinBufferProps();
await _pin('volume-max', '$maxVolume');
await applyShaders();
_watchdog.reset();
_watchTimer = Timer.periodic(const Duration(seconds: 1), (_) => _watch());
@@ -294,6 +329,11 @@ class PlaybackController {
// 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 == 'ytdl_hook' && (log.level == 'error' || log.level == 'fatal')) {
final text = log.text.trim().replaceFirst(RegExp(r'^ERROR:\s*'), '');
if (text.isNotEmpty) onYtdlError?.call(text);
return;
}
if (log.prefix != 'libmpv_render') return;
if (log.text.contains('suspected software renderer')) {
_setRenderMode(RenderMode.softwareGl);
@@ -426,9 +466,18 @@ class PlaybackController {
// track-list observed in mpv (read back as JSON).
void _watchMarks(SyncPlayer p) {
_timeSubs.add(p.stream.position.listen((d) {
// At the end the bar shows full: mpv's last position is the last
// frame's start (and the 250 ms steps round down), short of duration.
if (p.state.completed && duration.value > Duration.zero) {
position.value = duration.value;
return;
}
final ms = d.inMilliseconds;
position.value = Duration(milliseconds: ms - ms % 250);
}));
_timeSubs.add(p.stream.completed.listen((done) {
if (done && duration.value > Duration.zero) position.value = duration.value;
}));
_timeSubs.add(p.stream.buffer.listen((d) {
final ms = d.inMilliseconds;
buffered.value = Duration(milliseconds: ms - ms % 250);
@@ -0,0 +1,57 @@
import 'dart:async';
import 'dart:io';
import 'package:path_provider/path_provider.dart';
import 'package:sync_relay_core/core.dart';
/// mpv's `ytdl-format` for links every player opens itself.
const ytdlFormat = 'bv*[height<=1080]+ba/b';
/// 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 {
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;
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).
void warmUp() => unawaited(ytDlp.then((y) => y.ensure()).catchError((_) => null));
/// mpv's `ytdl-raw-options` for the cookies in the yt-dlp folder ('' for
/// none; see [YtDlpCookies]).
Future<String> mpvRawOptions() async {
try {
return (await ytDlp).cookies.mpvRawOptions();
} catch (_) {
return '';
}
}
final _probes = <String, (DateTime, Future<LinkProbe>)>{};
static const _probeTtl = Duration(minutes: 10);
Future<LinkProbe> probe(String url) {
final now = DateTime.now();
_probes.removeWhere((_, v) => now.difference(v.$1) > _probeTtl);
final hit = _probes[url];
if (hit != null) return hit.$2;
final f = ytDlp.then((y) => probeLink(Uri.parse(url), ytDlp: y));
_probes[url] = (now, f);
f.catchError((Object _) {
_probes.remove(url);
return LinkProbe.file(Uri(), -1);
});
return f;
}
}
+391
View File
@@ -0,0 +1,391 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:ui' show Color;
import 'package:path_provider/path_provider.dart';
import 'package:sync_relay_core/core.dart';
// AniList airing schedule (public GraphQL, no login): every episode airing
// in a time range, with covers, banners and popularity; and a title search
// (cover + banner of a show, for the media art of files and links).
//
// AniList allows ~90 requests/min (sometimes degraded to 30), so the raw
// pages are cached: in memory for 10 min (tab switches) and on disk for an
// hour (restarts), keyed by range start. A 429 waits out Retry-After once;
// if AniList still refuses (or is unreachable), stale cached data is
// returned with its fetch time.
final _log = createLogger('anilist');
const aniListDefaultUrl = 'https://graphql.anilist.co';
/// One episode airing, as AniList announces it.
class Airing {
final int anilistId;
/// English title, else romaji.
final String title;
final String titleRomaji;
final int episode;
/// UTC.
final DateTime airingAt;
final String? cover;
final String? banner;
/// The cover's dominant color, if AniList has one.
final Color? color;
final int popularity;
const Airing({
required this.anilistId,
required this.title,
required this.titleRomaji,
required this.episode,
required this.airingAt,
this.cover,
this.banner,
this.color,
this.popularity = 0,
});
/// Null for adult entries, music videos and malformed rows.
static Airing? fromJson(Object? j) {
if (j is! Map) return null;
final m = j['media'], at = j['airingAt'], ep = j['episode'];
if (m is! Map || at is! int || ep is! int) return null;
final id = m['id'];
if (id is! int || m['isAdult'] == true || m['format'] == 'MUSIC') return null;
final t = m['title'] is Map ? m['title'] as Map : const {};
final romaji = t['romaji'] is String ? t['romaji'] as String : '';
final english = t['english'] is String ? t['english'] as String : '';
final cov = m['coverImage'] is Map ? m['coverImage'] as Map : const {};
return Airing(
anilistId: id,
title: english.isNotEmpty ? english : (romaji.isNotEmpty ? romaji : '?'),
titleRomaji: romaji,
episode: ep,
airingAt: DateTime.fromMillisecondsSinceEpoch(at * 1000, isUtc: true),
cover: cov['large'] is String ? cov['large'] as String : null,
banner: m['bannerImage'] is String ? m['bannerImage'] as String : null,
color: parseHexColor(cov['color']),
popularity: m['popularity'] is int ? m['popularity'] as int : 0,
);
}
@override
String toString() => 'Airing($anilistId "$title" ep $episode @ $airingAt)';
}
/// An anime found by title ([AniListClient.searchMedia]).
class AniListMedia {
final int id;
/// English title, else romaji.
final String title;
/// Cover (portrait) URL: extraLarge, else large.
final String? cover;
/// The smaller cover, for when [cover] is too big to send on.
final String? coverSmall;
final String? banner;
const AniListMedia({required this.id, required this.title, this.cover, this.coverSmall, this.banner});
static AniListMedia? fromJson(Object? m) {
if (m is! Map || m['id'] is! int) return null;
final t = m['title'] is Map ? m['title'] as Map : const {};
final romaji = t['romaji'] is String ? t['romaji'] as String : '';
final english = t['english'] is String ? t['english'] as String : '';
final cov = m['coverImage'] is Map ? m['coverImage'] as Map : const {};
final large = cov['large'] is String ? cov['large'] as String : null;
return AniListMedia(
id: m['id'] as int,
title: english.isNotEmpty ? english : romaji,
cover: cov['extraLarge'] is String ? cov['extraLarge'] as String : large,
coverSmall: large,
banner: m['bannerImage'] is String ? m['bannerImage'] as String : null,
);
}
}
/// "#e4a15d" -> opaque [Color]; anything else -> null.
Color? parseHexColor(Object? s) {
if (s is! String || s.length != 7 || !s.startsWith('#')) return null;
final v = int.tryParse(s.substring(1), radix: 16);
return v == null ? null : Color(0xff000000 | v);
}
class AniListException implements Exception {
final int? statusCode;
final String message;
const AniListException(this.message, {this.statusCode});
bool get isRateLimited => statusCode == 429;
@override
String toString() => 'AniListException: $message';
}
/// The airings of one range. [staleSince] is set when AniList refused and
/// older cached data (fetched then) is returned instead.
class AniListSchedule {
final List<Airing> airings;
final DateTime fetchedAt;
final DateTime? staleSince;
const AniListSchedule(this.airings, this.fetchedAt, {this.staleSince});
bool get isStale => staleSince != null;
}
class _Cached {
final DateTime fetched;
final int from;
final int to;
final List<Object?> raw;
const _Cached(this.fetched, this.from, this.to, this.raw);
}
class AniListClient {
AniListClient({
String url = aniListDefaultUrl,
HttpClient? http,
Future<Directory?> Function()? cacheDir,
this.memoryTtl = const Duration(minutes: 10),
this.diskTtl = const Duration(hours: 1),
Future<void> Function(Duration)? delay,
DateTime Function()? now,
String? userAgent,
}) : url = Uri.parse(url),
_http = http ?? HttpClient(),
_ownsHttp = http == null,
_cacheDir = cacheDir ?? _defaultCacheDir,
_delay = delay ?? Future<void>.delayed,
_now = now ?? DateTime.now,
userAgent = userAgent ?? 'watch-together/$appVersion';
final Uri url;
final Duration memoryTtl;
final Duration diskTtl;
/// AniList 403s some default agents.
final String userAgent;
final HttpClient _http;
final bool _ownsHttp;
final Future<Directory?> Function() _cacheDir;
final Future<void> Function(Duration) _delay;
final DateTime Function() _now;
/// Pages per range at most (50 airings each).
static const maxPages = 40;
static const perPage = 50;
static const query = r'''
query($from:Int,$to:Int,$page:Int){Page(page:$page,perPage:50){pageInfo{hasNextPage}
airingSchedules(airingAt_greater:$from,airingAt_lesser:$to,sort:TIME){airingAt episode
media{id title{romaji english} coverImage{large color} bannerImage isAdult popularity format}}}}''';
final _mem = <int, _Cached>{};
final _inflight = <String, Future<AniListSchedule>>{};
static Future<Directory?> _defaultCacheDir() async {
try {
return await getApplicationCacheDirectory();
} catch (_) {
return null;
}
}
/// Every airing in [from, to), cached; see the file comment. [force]
/// skips the fresh caches (stale data is still the fallback).
Future<AniListSchedule> schedule(DateTime from, DateTime to, {bool force = false}) {
final f = from.millisecondsSinceEpoch ~/ 1000, t = to.millisecondsSinceEpoch ~/ 1000;
final k = '$f-$t-$force';
return _inflight[k] ??= _schedule(f, t, force).whenComplete(() {
_inflight.remove(k);
});
}
Future<AniListSchedule> _schedule(int from, int to, bool force) async {
final now = _now();
final mem = _mem[from];
final memHit = mem != null && mem.to == to ? mem : null;
if (!force && memHit != null && now.difference(memHit.fetched) < memoryTtl) {
return _result(memHit);
}
final disk = await _readDisk(from, to);
if (!force && disk != null && now.difference(disk.fetched) < diskTtl) {
_mem[from] = disk;
return _result(disk);
}
try {
final raw = await _fetch(from, to);
final c = _mem[from] = _Cached(_now(), from, to, raw);
await _writeDisk(c);
return _result(c);
} catch (e) {
final stale = [memHit, disk].whereType<_Cached>().fold<_Cached?>(
null, (a, b) => a == null || b.fetched.isAfter(a.fetched) ? b : a);
if (stale == null) rethrow;
_log.warning('AniList failed, using cache from ${stale.fetched}: $e');
return _result(stale, stale: true);
}
}
AniListSchedule _result(_Cached c, {bool stale = false}) => AniListSchedule(
[for (final r in c.raw) ?Airing.fromJson(r)],
c.fetched,
staleSince: stale ? c.fetched : null,
);
Future<List<Object?>> _fetch(int from, int to) async {
final out = <Object?>[];
for (var page = 1; page <= maxPages; page++) {
final data = await _page(from, to, page);
final rows = data['airingSchedules'];
if (rows is List) out.addAll(rows);
final info = data['pageInfo'];
if (info is! Map || info['hasNextPage'] != true) break;
}
return out;
}
Future<Map> _page(int from, int to, int page) async {
final (data, status, j) = await _graphql(query, {'from': from - 1, 'to': to, 'page': page});
final p = data?['Page'];
if (p is! Map) {
throw AniListException('AniList: bad response${_errorText(j)}', statusCode: status);
}
return p;
}
/// One GraphQL request: `data` (null when missing), status and body. A
/// 429 waits out Retry-After once; other non-200s throw.
Future<(Map?, int, Object?)> _graphql(String query, Map<String, Object?> variables) async {
for (var attempt = 0;; attempt++) {
final (status, body, retryAfter) = await _post(jsonEncode({'query': query, 'variables': variables}));
if (status == 429 && attempt == 0) {
final wait = retryAfter ?? 60;
_log.info('AniList rate limited, retrying in ${wait}s');
await _delay(Duration(seconds: wait.clamp(1, 65)));
continue;
}
Object? j;
try {
j = jsonDecode(body);
} catch (_) {}
if (status != 200) {
throw AniListException('AniList HTTP $status${_errorText(j)}', statusCode: status);
}
final data = j is Map && j['data'] is Map ? j['data'] as Map : null;
return (data, status, j);
}
}
static const searchQuery = r'''
query($search:String){Media(search:$search,type:ANIME,isAdult:false){id title{romaji english}
coverImage{extraLarge large} bannerImage}}''';
final _search = <String, Future<AniListMedia?>>{};
/// AniList's best anime match for [title], or null when there is none.
/// One request per title for this client's life (a failure is not kept,
/// so a later call asks again).
Future<AniListMedia?> searchMedia(String title) {
final key = title.trim();
if (key.isEmpty) return Future.value(null);
return _search[key] ??= _searchMedia(key).catchError((Object e) {
_search.remove(key);
throw e;
});
}
Future<AniListMedia?> _searchMedia(String title) async {
try {
final (data, _, _) = await _graphql(searchQuery, {'search': title});
return AniListMedia.fromJson(data?['Media']);
} on AniListException catch (e) {
// No match is a 404 with "Not Found." in errors.
if (e.statusCode == 404) return null;
rethrow;
}
}
static String _errorText(Object? j) {
final errs = j is Map ? j['errors'] : null;
if (errs is! List || errs.isEmpty) return '';
final m = errs.first is Map ? (errs.first as Map)['message'] : null;
return m is String ? ': $m' : '';
}
Future<(int, String, int?)> _post(String body) async {
try {
final req = await _http.postUrl(url).timeout(const Duration(seconds: 15));
req.headers
..contentType = ContentType.json
..set(HttpHeaders.acceptHeader, 'application/json')
..set(HttpHeaders.userAgentHeader, userAgent);
req.write(body);
final res = await req.close().timeout(const Duration(seconds: 20));
final text = await res.transform(const Utf8Decoder(allowMalformed: true)).join();
return (
res.statusCode,
text,
int.tryParse(res.headers.value(HttpHeaders.retryAfterHeader) ?? ''),
);
} on TimeoutException {
throw const AniListException('AniList timed out');
} on IOException catch (e) {
throw AniListException('AniList unreachable: $e');
}
}
// ---------------------------------------------------------------- disk
Future<File?> _file(int from) async {
final dir = await _cacheDir();
return dir == null ? null : File('${dir.path}/anilist-schedule-$from.json');
}
Future<_Cached?> _readDisk(int from, int to) async {
try {
final f = await _file(from);
if (f == null || !await f.exists()) return null;
final j = jsonDecode(await f.readAsString());
if (j is! Map || j['from'] != from || j['to'] != to) return null;
final fetched = j['fetched'], raw = j['raw'];
if (fetched is! int || raw is! List) return null;
return _Cached(DateTime.fromMillisecondsSinceEpoch(fetched), from, to, raw);
} catch (_) {
return null;
}
}
Future<void> _writeDisk(_Cached c) async {
try {
final f = await _file(c.from);
if (f == null) return;
await f.parent.create(recursive: true);
await f.writeAsString(jsonEncode({
'fetched': c.fetched.millisecondsSinceEpoch,
'from': c.from,
'to': c.to,
'raw': c.raw,
}));
// Older ranges are useless once a day has passed.
await for (final e in f.parent.list()) {
final name = e.uri.pathSegments.last;
if (e is File &&
e.path != f.path &&
name.startsWith('anilist-schedule-') &&
_now().difference(await e.lastModified()) > const Duration(days: 1)) {
await e.delete();
}
}
} catch (e) {
_log.warning('AniList cache write failed: $e');
}
}
/// Drops the memory cache (the disk cache stays as the stale fallback).
void clearMemory() => _mem.clear();
void close() {
if (_ownsHttp) _http.close(force: true);
}
}
@@ -0,0 +1,754 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:sync_relay_core/core.dart';
import '../jellyfin/accounts.dart';
// AnimeFlow app API: what the media server tracks, has, downloads and was
// asked for. Authenticated with the user's own Jellyfin access token
// (X-Emby-Token) on the Jellyfin server next to it.
//
// Errors are JSON {"detail","code"} ([AnimeFlowException]); nginx in front
// may answer 429 with HTML (5 req/s, burst 20), retried with backoff.
// /schedule, /series, /series/{id} and /requests send weak ETags: the last
// body is kept and revalidated with If-None-Match. Times are UTC ISO
// ("2026-10-08T19:36:32Z"). Scopes can change at runtime, so /me is cached
// for a minute at most.
final _log = createLogger('animeflow');
const animeFlowDefaultBase = 'https://anime.senkochan.moe/api/app/v1';
// ---------------------------------------------------------------- errors
enum AnimeFlowErrorCode {
missingToken('missing_token'),
badToken('bad_token'),
revoked('revoked'),
missingScope('missing_scope'),
ownAccount('own_account'),
jellyfinUnreachable('jellyfin_unreachable'),
searchUnavailable('search_unavailable'),
invalidRequest('invalid_request'),
invalidTime('invalid_time'),
invalidRange('invalid_range'),
notFound('not_found'),
seriesNotFound('series_not_found'),
episodeNotFound('episode_not_found'),
requestNotFound('request_not_found'),
userNotFound('user_not_found'),
alreadyTracked('already_tracked'),
alreadyAvailable('already_available'),
alreadyRequested('already_requested'),
invalidState('invalid_state'),
/// nginx 429 (client side, no JSON body).
rateLimited('rate_limited'),
/// No answer: DNS, connect, timeout (client side).
network('network'),
/// Anything not in this list.
unknown('unknown');
const AnimeFlowErrorCode(this.wire);
final String wire;
static AnimeFlowErrorCode parse(Object? s) =>
values.where((c) => c.wire == s).firstOrNull ?? unknown;
/// Best guess from the status alone (body was not JSON).
static AnimeFlowErrorCode forStatus(int status) => switch (status) {
401 => badToken,
403 => missingScope,
404 => notFound,
409 => invalidState,
400 => invalidRequest,
429 => rateLimited,
503 => jellyfinUnreachable,
_ => unknown,
};
}
class AnimeFlowException implements Exception {
/// HTTP status; 0 when no answer came.
final int statusCode;
final AnimeFlowErrorCode code;
final String detail;
const AnimeFlowException(this.statusCode, this.code, this.detail);
/// Sign in again (token missing, wrong or expired).
bool get isUnauthorized => statusCode == 401;
/// Revoked, missing scope or own account.
bool get isForbidden => statusCode == 403;
bool get isNotFound => statusCode == 404;
bool get isConflict => statusCode == 409;
bool get isUnavailable => statusCode == 503 || code == AnimeFlowErrorCode.network;
bool get isRateLimited => code == AnimeFlowErrorCode.rateLimited;
@override
String toString() => 'AnimeFlowException($statusCode ${code.wire}): $detail';
}
// ---------------------------------------------------------------- models
DateTime? _time(Object? v) => v is String ? DateTime.tryParse(v)?.toUtc() : null;
int? _int(Object? v) => v is int ? v : (v is num ? v.toInt() : null);
String? _str(Object? v) => v is String ? v : null;
List<Map> _items(Object? j) =>
j is Map && j['items'] is List ? (j['items'] as List).whereType<Map>().toList() : const [];
/// Formats [d] as AnimeFlow wants it: "2026-10-08T19:36:32Z".
String animeFlowTime(DateTime d) => '${d.toUtc().toIso8601String().split('.').first.replaceAll('Z', '')}Z';
enum AnimeFlowScope {
view,
request,
manage;
static Set<AnimeFlowScope> parse(Object? j) => {
if (j is List)
for (final s in j) ...values.where((v) => v.name == s),
};
}
class AnimeFlowMe {
final String userId;
final String name;
final Set<AnimeFlowScope> scopes;
const AnimeFlowMe({required this.userId, required this.name, required this.scopes});
bool can(AnimeFlowScope s) => scopes.contains(s);
static AnimeFlowMe fromJson(Object? j) {
final m = j is Map ? j : const {};
return AnimeFlowMe(
userId: _str(m['user_id']) ?? '',
name: _str(m['name']) ?? '',
scopes: AnimeFlowScope.parse(m['scopes']),
);
}
}
enum EpisodeStatus {
upcoming,
missing,
downloading,
available,
unmonitored,
failed;
/// Unknown values read as upcoming.
static EpisodeStatus parse(Object? s) => values.where((v) => v.name == s).firstOrNull ?? upcoming;
}
/// A series on Jellyfin (`GET /library`).
class AnimeFlowLibraryItem {
final String jellyfinSeriesId;
final String name;
/// Set when AnimeFlow tracks it.
final int? seriesId;
/// AniList entry per TVDB season (the first, when a season spans more).
final Map<int, int> anilistIds;
/// Every AniList entry per season with its episode run (`anilist_map`):
/// a season AniList splits (one cour as two entries) has several rows.
final List<AniListRun> anilistMap;
const AnimeFlowLibraryItem({
required this.jellyfinSeriesId,
required this.name,
this.seriesId,
this.anilistIds = const {},
this.anilistMap = const [],
});
static AnimeFlowLibraryItem fromJson(Map j) => AnimeFlowLibraryItem(
jellyfinSeriesId: _str(j['jellyfin_series_id']) ?? '',
name: _str(j['name']) ?? '',
seriesId: _int(j['series_id']),
anilistIds: {
for (final e in ((j['anilist_ids'] as Map?) ?? const {}).entries)
if (int.tryParse('${e.key}') case final season? when _int(e.value) != null) season: _int(e.value)!,
},
anilistMap: [
for (final r in (j['anilist_map'] as List?) ?? const [])
if (r is Map && _int(r['season']) != null && _int(r['anilist_id']) != null)
AniListRun(
season: _int(r['season'])!,
anilistId: _int(r['anilist_id'])!,
firstEpisode: _int(r['first_episode']) ?? 1,
offset: _int(r['offset']) ?? 0,
),
],
);
}
/// One AniList entry's run within a TVDB season: TVDB episode e (from
/// [firstEpisode] to the next run of that season) is AniList episode
/// e - [offset].
class AniListRun {
final int season;
final int anilistId;
final int firstEpisode;
final int offset;
const AniListRun({required this.season, required this.anilistId, this.firstEpisode = 1, this.offset = 0});
}
class AnimeFlowEpisode {
/// 0 = specials.
final int season;
final int episode;
final int? absolute;
final String? title;
final DateTime? airDate;
/// The AniList entry of this episode's season (one AnimeFlow series may
/// span several AniList entries).
final int? anilistId;
/// The episode number within [anilistId] when AnimeFlow knows it (split
/// cours, absolute numbering); prefer it over [episode] for matching.
final int? anilistEpisode;
final EpisodeStatus status;
/// 0..1, only while downloading.
final double? progress;
/// Only when failed.
final String? reason;
const AnimeFlowEpisode({
required this.season,
required this.episode,
this.absolute,
this.title,
this.airDate,
this.anilistId,
this.anilistEpisode,
required this.status,
this.progress,
this.reason,
});
static AnimeFlowEpisode fromJson(Map j) => AnimeFlowEpisode(
season: _int(j['season']) ?? 0,
episode: _int(j['episode']) ?? 0,
absolute: _int(j['absolute']),
title: _str(j['episode_title']),
airDate: _time(j['air_date']),
// The episode's own entry (a split season rolls into its sequel),
// else the series' one.
anilistId: _int(j['episode_anilist_id']) ?? _int(j['anilist_id']),
anilistEpisode: _int(j['anilist_episode']),
status: EpisodeStatus.parse(j['status']),
progress: (j['progress'] as num?)?.toDouble(),
reason: _str(j['reason']),
);
}
/// A /schedule row: one episode plus its series.
class AnimeFlowScheduleItem {
final AnimeFlowEpisode episode;
final int seriesId;
final String title;
final int? anidbId;
final int? tvdbId;
final String? jellyfinSeriesId;
final bool followed;
const AnimeFlowScheduleItem({
required this.episode,
required this.seriesId,
required this.title,
this.anidbId,
this.tvdbId,
this.jellyfinSeriesId,
this.followed = false,
});
static AnimeFlowScheduleItem fromJson(Map j) => AnimeFlowScheduleItem(
episode: AnimeFlowEpisode.fromJson(j),
seriesId: _int(j['series_id']) ?? 0,
title: _str(j['title']) ?? '',
anidbId: _int(j['anidb_id']),
tvdbId: _int(j['tvdb_id']),
jellyfinSeriesId: _str(j['jellyfin_series_id']),
followed: j['followed'] == true,
);
}
class AnimeFlowScheduleWindow {
final DateTime? from;
final DateTime? to;
final List<AnimeFlowScheduleItem> items;
const AnimeFlowScheduleWindow({this.from, this.to, required this.items});
}
enum SeriesState {
releasing,
released;
static SeriesState parse(Object? s) => s == 'released' ? released : releasing;
}
class AnimeFlowSeries {
final int seriesId;
final String title;
final int? anidbId;
final int? tvdbId;
/// The series' (first) AniList entry; later seasons may have others, see
/// [AnimeFlowEpisode.anilistId].
final int? anilistId;
final String? jellyfinSeriesId;
final SeriesState state;
final bool monitored;
final bool followed;
final int episodesTotal;
final int episodesAvailable;
final DateTime? nextAirDate;
/// Only from [AnimeFlowClient.seriesDetail].
final List<AnimeFlowEpisode> episodes;
const AnimeFlowSeries({
required this.seriesId,
required this.title,
this.anidbId,
this.tvdbId,
this.anilistId,
this.jellyfinSeriesId,
this.state = SeriesState.releasing,
this.monitored = false,
this.followed = false,
this.episodesTotal = 0,
this.episodesAvailable = 0,
this.nextAirDate,
this.episodes = const [],
});
static AnimeFlowSeries fromJson(Map j) => AnimeFlowSeries(
seriesId: _int(j['series_id']) ?? 0,
title: _str(j['title']) ?? '',
anidbId: _int(j['anidb_id']),
tvdbId: _int(j['tvdb_id']),
anilistId: _int(j['anilist_id']),
jellyfinSeriesId: _str(j['jellyfin_series_id']),
state: SeriesState.parse(j['state']),
monitored: j['monitored'] == true,
followed: j['followed'] == true,
episodesTotal: _int(j['episodes_total']) ?? 0,
episodesAvailable: _int(j['episodes_available']) ?? 0,
nextAirDate: _time(j['next_air_date']),
episodes: [
if (j['episodes'] is List)
for (final e in (j['episodes'] as List).whereType<Map>()) AnimeFlowEpisode.fromJson(e),
],
);
}
class AnimeFlowSearchHit {
final int anidbId;
final String title;
final int? anilistId;
/// Set when AnimeFlow already tracks it.
final int? seriesId;
const AnimeFlowSearchHit({required this.anidbId, required this.title, this.anilistId, this.seriesId});
static AnimeFlowSearchHit fromJson(Map j) => AnimeFlowSearchHit(
anidbId: _int(j['anidb_id']) ?? 0,
title: _str(j['title']) ?? '',
anilistId: _int(j['anilist_id']),
seriesId: _int(j['series_id']),
);
}
enum RequestKind {
series,
season,
episode;
static RequestKind parse(Object? s) => values.where((v) => v.name == s).firstOrNull ?? series;
}
enum RequestStatus {
/// Briefly, before auto-approval or a manager decides.
pending,
approved,
searching,
done,
rejected,
failed;
static RequestStatus parse(Object? s) => values.where((v) => v.name == s).firstOrNull ?? pending;
}
class AnimeFlowRequest {
final int id;
final RequestKind kind;
final RequestStatus status;
final String title;
final int? anidbId;
final int? seriesId;
final int? season;
final int? episode;
final String? reason;
final DateTime? createdAt;
final DateTime? updatedAt;
/// Only with scope=all.
final String? userId;
final String? userName;
const AnimeFlowRequest({
required this.id,
required this.kind,
required this.status,
required this.title,
this.anidbId,
this.seriesId,
this.season,
this.episode,
this.reason,
this.createdAt,
this.updatedAt,
this.userId,
this.userName,
});
static AnimeFlowRequest fromJson(Map j) => AnimeFlowRequest(
id: _int(j['id']) ?? 0,
kind: RequestKind.parse(j['kind']),
status: RequestStatus.parse(j['status']),
title: _str(j['title']) ?? '',
anidbId: _int(j['anidb_id']),
seriesId: _int(j['series_id']),
season: _int(j['season']),
episode: _int(j['episode']),
reason: _str(j['reason']),
createdAt: _time(j['created_at']),
updatedAt: _time(j['updated_at']),
userId: _str(j['user_id']),
userName: _str(j['user_name']),
);
}
class AnimeFlowUser {
/// The Jellyfin user id.
final String userId;
final String name;
final Set<AnimeFlowScope> scopes;
final bool revoked;
final DateTime? lastSeenAt;
const AnimeFlowUser({
required this.userId,
required this.name,
required this.scopes,
this.revoked = false,
this.lastSeenAt,
});
static AnimeFlowUser fromJson(Map j) => AnimeFlowUser(
userId: _str(j['user_id']) ?? '',
name: _str(j['name']) ?? '',
scopes: AnimeFlowScope.parse(j['scopes']),
revoked: j['revoked'] == true,
lastSeenAt: _time(j['last_seen_at']),
);
}
// ---------------------------------------------------------------- client
class _Etagged {
final String etag;
final Object? body;
const _Etagged(this.etag, this.body);
}
class AnimeFlowClient {
/// [token] wins over [tokenProvider]; with neither, every call fails with
/// [AnimeFlowErrorCode.missingToken].
AnimeFlowClient({
String baseUrl = animeFlowDefaultBase,
this._token,
this._tokenProvider,
HttpClient? http,
Future<void> Function(Duration)? delay,
DateTime Function()? now,
this.meTtl = const Duration(seconds: 60),
String? userAgent,
}) : baseUrl = baseUrl.endsWith('/') ? baseUrl.substring(0, baseUrl.length - 1) : baseUrl,
_http = http ?? HttpClient(),
_ownsHttp = http == null,
_delay = delay ?? Future<void>.delayed,
_now = now ?? DateTime.now,
userAgent = userAgent ?? 'watch-together/$appVersion';
/// Authenticated with the linked Jellyfin account on the same domain
/// (see [tokenFor]), looked up on every call so a re-link applies.
factory AnimeFlowClient.fromAccounts(
JellyfinAccounts accounts, {
String baseUrl = animeFlowDefaultBase,
HttpClient? http,
}) =>
AnimeFlowClient(
baseUrl: baseUrl,
http: http,
tokenProvider: () async {
await accounts.ensureLoaded(validate: false);
return tokenFor(accounts.accounts, baseUrl);
},
);
final String baseUrl;
final Duration meTtl;
final String userAgent;
final String? _token;
final Future<String?> Function()? _tokenProvider;
final HttpClient _http;
final bool _ownsHttp;
final Future<void> Function(Duration) _delay;
final DateTime Function() _now;
/// Request bodies above this are refused by the server.
static const maxBodyBytes = 16 * 1024;
/// Retries after an nginx 429 (backoff 1 s, 2 s unless Retry-After).
static const rateLimitRetries = 2;
/// The longest /schedule window.
static const maxScheduleRange = Duration(days: 45);
String? _lastToken;
final _etags = <String, _Etagged>{};
({DateTime at, AnimeFlowMe me})? _me;
/// The token of the linked Jellyfin account whose host shares
/// [animeFlowBase]'s parent domain (anime.example.org -> example.org,
/// jellyfin.example.org, ...).
static String? tokenFor(Iterable<JellyfinAccount> accounts, String animeFlowBase) {
final host = Uri.tryParse(animeFlowBase)?.host.toLowerCase() ?? '';
if (host.isEmpty) return null;
final labels = host.split('.');
final parent = labels.length > 2 ? labels.skip(1).join('.') : host;
bool matches(String url) {
final h = Uri.tryParse(url)?.host.toLowerCase() ?? '';
return h.isNotEmpty && (h == parent || h.endsWith('.$parent'));
}
for (final a in accounts) {
if (a.urls.any(matches)) return a.token;
}
return null;
}
Future<String> _authToken() async {
final t = _token ?? await _tokenProvider?.call();
if (t == null || t.isEmpty) {
throw const AnimeFlowException(
401, AnimeFlowErrorCode.missingToken, 'No linked Jellyfin account for AnimeFlow');
}
if (t != _lastToken) {
// Another user: nothing cached belongs to them.
_lastToken = t;
_etags.clear();
_me = null;
}
return t;
}
Uri _uri(String path, [Map<String, String>? query]) =>
Uri.parse('$baseUrl$path').replace(queryParameters: query);
/// Sends one call; JSON body or null (204). [etag]: revalidate the last
/// body of this URL.
Future<Object?> _call(String method, String path,
{Map<String, String>? query, Object? body, bool etag = false}) async {
final token = await _authToken();
final uri = _uri(path, query);
final key = uri.toString();
List<int>? bytes;
if (body != null) {
bytes = utf8.encode(jsonEncode(body));
if (bytes.length > maxBodyBytes) {
throw const AnimeFlowException(400, AnimeFlowErrorCode.invalidRequest, 'Request body too large');
}
}
for (var attempt = 0;; attempt++) {
final cached = etag ? _etags[key] : null;
final (status, text, retryAfter, tag) = await _send(method, uri, token, bytes, cached?.etag);
if (status == 304 && cached != null) return cached.body;
if (status >= 200 && status < 300) {
final j = text.trim().isEmpty ? null : _decode(text, status);
if (etag && tag != null && tag.isNotEmpty) _etags[key] = _Etagged(tag, j);
return j;
}
final err = _error(status, text);
if (err.isRateLimited && attempt < rateLimitRetries) {
final wait = retryAfter != null ? Duration(seconds: retryAfter.clamp(1, 30)) : Duration(seconds: 1 << attempt);
_log.info('AnimeFlow rate limited on $path, retrying in ${wait.inSeconds}s');
await _delay(wait);
continue;
}
throw err;
}
}
static Object? _decode(String text, int status) {
try {
return jsonDecode(text);
} catch (_) {
throw AnimeFlowException(status, AnimeFlowErrorCode.unknown, 'Response is not JSON');
}
}
static AnimeFlowException _error(int status, String text) {
Object? j;
try {
j = jsonDecode(text);
} catch (_) {}
if (j is Map && j['code'] is String) {
final code = AnimeFlowErrorCode.parse(j['code']);
return AnimeFlowException(status, code == AnimeFlowErrorCode.unknown ? AnimeFlowErrorCode.forStatus(status) : code,
_str(j['detail']) ?? 'HTTP $status');
}
// Not ours: nginx (HTML 429/502), a proxy, ...
return AnimeFlowException(status, AnimeFlowErrorCode.forStatus(status),
status == 429 ? 'Too many requests, try again shortly' : 'HTTP $status');
}
Future<(int, String, int?, String?)> _send(
String method, Uri uri, String token, List<int>? body, String? ifNoneMatch) async {
try {
final req = await _http.openUrl(method, uri).timeout(const Duration(seconds: 15));
req.headers
..set(HttpHeaders.acceptHeader, 'application/json')
..set(HttpHeaders.userAgentHeader, userAgent)
..set('X-Emby-Token', token);
if (ifNoneMatch != null) req.headers.set(HttpHeaders.ifNoneMatchHeader, ifNoneMatch);
if (body != null) {
req.headers.contentType = ContentType.json;
req.contentLength = body.length;
req.add(body);
}
final res = await req.close().timeout(const Duration(seconds: 20));
final text = await res.transform(const Utf8Decoder(allowMalformed: true)).join();
return (
res.statusCode,
text,
int.tryParse(res.headers.value(HttpHeaders.retryAfterHeader) ?? ''),
res.headers.value(HttpHeaders.etagHeader),
);
} on TimeoutException {
throw const AnimeFlowException(0, AnimeFlowErrorCode.network, 'AnimeFlow timed out');
} on IOException catch (e) {
throw AnimeFlowException(0, AnimeFlowErrorCode.network, 'AnimeFlow unreachable: $e');
}
}
// ------------------------------------------------------------ endpoints
/// Who the token belongs to and what they may do. Cached for [meTtl]
/// (scopes change at runtime).
Future<AnimeFlowMe> me({bool force = false}) async {
await _authToken();
final c = _me;
if (!force && c != null && _now().difference(c.at) < meTtl) return c.me;
final me = AnimeFlowMe.fromJson(await _call('GET', '/me'));
_me = (at: _now(), me: me);
return me;
}
/// Episodes airing in [from, to) (at most 45 days) [view].
Future<AnimeFlowScheduleWindow> schedule(DateTime from, DateTime to) async {
if (!from.isBefore(to) || to.difference(from) > maxScheduleRange) {
throw const AnimeFlowException(400, AnimeFlowErrorCode.invalidRange, 'from must be before to, at most 45 days');
}
final j = await _call('GET', '/schedule',
query: {'from': animeFlowTime(from), 'to': animeFlowTime(to)}, etag: true);
final m = j is Map ? j : const {};
return AnimeFlowScheduleWindow(
from: _time(m['from']),
to: _time(m['to']),
items: [for (final e in _items(j)) AnimeFlowScheduleItem.fromJson(e)],
);
}
/// Every tracked series [view].
Future<List<AnimeFlowSeries>> series() async =>
[for (final s in _items(await _call('GET', '/series', etag: true))) AnimeFlowSeries.fromJson(s)];
/// Every series on Jellyfin (as the caller sees it), tracked by
/// AnimeFlow or not [view]. Empty on servers without the endpoint.
Future<List<AnimeFlowLibraryItem>> library() async {
try {
return [for (final s in _items(await _call('GET', '/library', etag: true))) AnimeFlowLibraryItem.fromJson(s)];
} on AnimeFlowException catch (e) {
if (e.isNotFound) return const [];
rethrow;
}
}
/// One series with all its episodes [view].
Future<AnimeFlowSeries> seriesDetail(int seriesId) async {
final j = await _call('GET', '/series/$seriesId', etag: true);
return AnimeFlowSeries.fromJson(j is Map ? j : const {});
}
/// Follow (or unfollow) a series [view].
Future<void> setFollowed(int seriesId, bool followed) =>
_call(followed ? 'PUT' : 'DELETE', '/series/$seriesId/follow');
/// Titles that can be requested [request].
Future<List<AnimeFlowSearchHit>> search(String q) async => [
for (final h in _items(await _call('GET', '/search', query: {'q': q}))) AnimeFlowSearchHit.fromJson(h),
];
/// Asks for a whole new series by its AniDB id [request].
Future<AnimeFlowRequest> requestSeries(int anidbId) => _request({'anidb_id': anidbId});
/// Asks for one season of a tracked series [request].
Future<AnimeFlowRequest> requestSeason(int seriesId, int season) =>
_request({'series_id': seriesId, 'season': season});
/// Asks for one episode of a tracked series [request].
Future<AnimeFlowRequest> requestEpisode(int seriesId, int season, int episode) =>
_request({'series_id': seriesId, 'season': season, 'episode': episode});
Future<AnimeFlowRequest> _request(Map<String, Object> body) async {
final j = await _call('POST', '/requests', body: body);
return AnimeFlowRequest.fromJson(j is Map ? j : const {});
}
/// Own requests, newest first [view]; [all] = everyone's, with the user
/// [manage].
Future<List<AnimeFlowRequest>> requests({bool all = false}) async => [
for (final r in _items(await _call('GET', '/requests', query: all ? {'scope': 'all'} : null, etag: true)))
AnimeFlowRequest.fromJson(r),
];
/// [manage]. Returns the updated request when the server sends one.
Future<AnimeFlowRequest?> approve(int requestId) async =>
_maybeRequest(await _call('POST', '/requests/$requestId/approve'));
/// [manage].
Future<AnimeFlowRequest?> reject(int requestId, {String? reason}) async => _maybeRequest(
await _call('POST', '/requests/$requestId/reject', body: {'reason': ?reason}));
static AnimeFlowRequest? _maybeRequest(Object? j) =>
j is Map && j['id'] != null ? AnimeFlowRequest.fromJson(j) : null;
/// [manage].
Future<List<AnimeFlowUser>> users() async =>
[for (final u in _items(await _call('GET', '/users'))) AnimeFlowUser.fromJson(u)];
/// Sets a user's scopes (and revocation) [manage]. Returns the updated
/// user when the server sends one.
Future<AnimeFlowUser?> setScopes(String jellyfinUserId, Set<AnimeFlowScope> scopes, {bool? revoked}) async {
final j = await _call('PUT', '/users/${Uri.encodeComponent(jellyfinUserId)}/scopes', body: {
'scopes': [for (final s in AnimeFlowScope.values) if (scopes.contains(s)) s.name],
'revoked': ?revoked,
});
_me = null; // could be our own
return j is Map && j['user_id'] != null ? AnimeFlowUser.fromJson(j) : null;
}
/// Forgets cached bodies and /me (e.g. on a manual refresh).
void clearCache() {
_etags.clear();
_me = null;
}
void close() {
if (_ownsHttp) _http.close(force: true);
}
}
@@ -0,0 +1,296 @@
import 'dart:async';
import 'anilist.dart';
import 'animeflow.dart';
// The Schedule tab's data: AniList says what airs when, AnimeFlow says
// what the media server has of it. [matchSchedule] merges the two and
// [ScheduleRepository] loads a window of both, degrading to AniList alone
// when AnimeFlow is unreachable or not linked.
enum ScheduleStatus {
available,
downloading,
upcoming,
missing,
failed,
unmonitored,
/// AnimeFlow does not track this series (or is unreachable).
notTracked,
/// Not tracked by AnimeFlow, but the series is on Jellyfin (`/library`):
/// the episode may be there; Put it up finds out.
inLibrary;
static ScheduleStatus of(EpisodeStatus s) => switch (s) {
EpisodeStatus.available => available,
EpisodeStatus.downloading => downloading,
EpisodeStatus.upcoming => upcoming,
EpisodeStatus.missing => missing,
EpisodeStatus.failed => failed,
EpisodeStatus.unmonitored => unmonitored,
};
}
/// One airing with what AnimeFlow knows about it.
class ScheduleEntry {
final Airing airing;
final ScheduleStatus status;
/// 0..1 while downloading.
final double? progress;
/// Why it failed.
final String? reason;
/// AnimeFlow tracks the series.
final bool tracked;
final bool followed;
/// AnimeFlow's series id (follow, requests).
final int? seriesId;
/// The AnimeFlow season of the matched episode (requests).
final int? season;
/// Play target once available.
final String? jellyfinSeriesId;
/// The series is on Jellyfin (tracked or not).
bool get onJellyfin => jellyfinSeriesId != null;
const ScheduleEntry({
required this.airing,
required this.status,
this.progress,
this.reason,
this.tracked = false,
this.followed = false,
this.seriesId,
this.season,
this.jellyfinSeriesId,
});
@override
String toString() => 'ScheduleEntry(${airing.anilistId}#${airing.episode} $status)';
}
/// Without AnimeFlow: future airings are upcoming, past ones unknown.
ScheduleEntry untrackedEntry(Airing a, DateTime now) => ScheduleEntry(
airing: a,
status: a.airingAt.isAfter(now) ? ScheduleStatus.upcoming : ScheduleStatus.notTracked,
);
String _day(DateTime d) {
final l = d.toLocal();
return '${l.year}-${l.month}-${l.day}';
}
class _SeriesRef {
final int seriesId;
final String? jellyfinSeriesId;
bool followed;
_SeriesRef(this.seriesId, this.jellyfinSeriesId, this.followed);
}
/// Merges AniList [airings] with AnimeFlow's [schedule] rows and tracked
/// [series]. Per airing: the episode with the same AniList id (that
/// episode's season's entry) and episode number, else absolute number,
/// else the same local air day; unmatched airings of a tracked AniList id
/// are missing once aired, upcoming before; the rest are not tracked.
List<ScheduleEntry> matchSchedule(
List<Airing> airings, {
required List<AnimeFlowScheduleItem> schedule,
List<AnimeFlowSeries> series = const [],
List<AnimeFlowLibraryItem> library = const [],
DateTime? now,
}) {
final at = now ?? DateTime.now();
// AniList id -> (Jellyfin series, its TVDB season) from /library.
final lib = <int, (String, int)>{};
for (final item in library) {
if (item.jellyfinSeriesId.isEmpty) continue;
for (final r in item.anilistMap) {
lib[r.anilistId] ??= (item.jellyfinSeriesId, r.season);
}
for (final e in item.anilistIds.entries) {
lib[e.value] ??= (item.jellyfinSeriesId, e.key);
}
}
final refs = <int, _SeriesRef>{};
for (final s in series) {
final id = s.anilistId;
if (id != null) refs[id] ??= _SeriesRef(s.seriesId, s.jellyfinSeriesId, s.followed);
}
final byEp = <String, AnimeFlowScheduleItem>{};
final byAbs = <String, AnimeFlowScheduleItem>{};
final byDay = <String, AnimeFlowScheduleItem>{};
for (final it in schedule) {
final e = it.episode, id = e.anilistId;
if (id == null) continue;
final ref = refs[id] ??= _SeriesRef(it.seriesId, it.jellyfinSeriesId, it.followed);
if (it.followed) ref.followed = true;
// AnimeFlow's AniList episode number first: TVDB numbering can run on
// across split cours.
if (e.anilistEpisode != null) byEp.putIfAbsent('$id#${e.anilistEpisode}', () => it);
byEp.putIfAbsent('$id#${e.episode}', () => it);
if (e.absolute != null) byAbs.putIfAbsent('$id#${e.absolute}', () => it);
if (e.airDate != null) byDay.putIfAbsent('$id@${_day(e.airDate!)}', () => it);
}
return [
for (final a in airings)
() {
final id = a.anilistId;
final ref = refs[id];
final it = byEp['$id#${a.episode}'] ?? byAbs['$id#${a.episode}'] ?? byDay['$id@${_day(a.airingAt)}'];
if (it != null) {
final e = it.episode;
return ScheduleEntry(
airing: a,
status: ScheduleStatus.of(e.status),
progress: e.status == EpisodeStatus.downloading ? e.progress : null,
reason: e.status == EpisodeStatus.failed ? e.reason : null,
tracked: true,
followed: it.followed || (ref?.followed ?? false),
seriesId: it.seriesId,
season: e.season,
jellyfinSeriesId: it.jellyfinSeriesId ?? ref?.jellyfinSeriesId ?? lib[id]?.$1,
);
}
if (ref == null) {
final onJf = lib[id];
if (onJf == null) return ScheduleEntry(airing: a, status: ScheduleStatus.notTracked);
return ScheduleEntry(
airing: a,
status: a.airingAt.isAfter(at) ? ScheduleStatus.upcoming : ScheduleStatus.inLibrary,
season: onJf.$2,
jellyfinSeriesId: onJf.$1,
);
}
return ScheduleEntry(
airing: a,
status: a.airingAt.isAfter(at) ? ScheduleStatus.upcoming : ScheduleStatus.missing,
tracked: true,
followed: ref.followed,
seriesId: ref.seriesId,
jellyfinSeriesId: ref.jellyfinSeriesId ?? lib[id]?.$1,
);
}(),
];
}
/// One loaded window. Errors are display text; [entries] is empty only
/// when AniList failed with nothing cached.
class ScheduleSnapshot {
final DateTime from;
final DateTime to;
/// Sorted by air time.
final List<ScheduleEntry> entries;
/// AniList failed and nothing was cached.
final String? aniListError;
/// AniList failed; [entries] come from the cache fetched then.
final DateTime? aniListStaleSince;
/// AnimeFlow is unreachable, refused or not linked: statuses are
/// AniList-only (upcoming / not tracked).
final String? animeFlowError;
/// The structured AnimeFlow failure behind [animeFlowError].
final AnimeFlowException? animeFlowException;
/// Who we are on AnimeFlow (null when it failed).
final AnimeFlowMe? me;
const ScheduleSnapshot({
required this.from,
required this.to,
required this.entries,
this.aniListError,
this.aniListStaleSince,
this.animeFlowError,
this.animeFlowException,
this.me,
});
bool get hasAnimeFlow => me != null;
}
/// Loads AniList and AnimeFlow for a window and merges them.
class ScheduleRepository {
ScheduleRepository({required this.aniList, this.animeFlow, DateTime Function()? now})
: _now = now ?? DateTime.now;
final AniListClient aniList;
/// Null = not configured (AniList only).
final AnimeFlowClient? animeFlow;
final DateTime Function() _now;
/// Last week's Monday 00:00 local .. 4 weeks later.
static ({DateTime from, DateTime to}) defaultWindow(DateTime now) {
final l = now.toLocal();
final from = DateTime(l.year, l.month, l.day - (l.weekday - 1) - 7);
return (from: from, to: DateTime(from.year, from.month, from.day + 28));
}
Future<ScheduleSnapshot> load({DateTime? from, DateTime? to, bool force = false}) async {
final w = defaultWindow(_now());
final start = from ?? w.from, end = to ?? w.to;
final flowF = _animeFlow(start, end, force);
final AniListSchedule ani;
try {
ani = await aniList.schedule(start, end, force: force);
} catch (e) {
unawaited(flowF.then((_) {}, onError: (_) {}));
return ScheduleSnapshot(from: start, to: end, entries: const [], aniListError: _text(e));
}
final now = _now();
final flow = await flowF;
final airings = [...ani.airings]..sort((a, b) => a.airingAt.compareTo(b.airingAt));
final entries = flow.error == null
? matchSchedule(airings, schedule: flow.schedule, series: flow.series, library: flow.library, now: now)
: [for (final a in airings) untrackedEntry(a, now)];
return ScheduleSnapshot(
from: start,
to: end,
entries: entries,
aniListStaleSince: ani.staleSince,
animeFlowError: flow.error == null ? null : _text(flow.error!),
animeFlowException: flow.error is AnimeFlowException ? flow.error as AnimeFlowException : null,
me: flow.me,
);
}
Future<({AnimeFlowMe? me, List<AnimeFlowScheduleItem> schedule, List<AnimeFlowSeries> series, List<AnimeFlowLibraryItem> library, Object? error})>
_animeFlow(DateTime from, DateTime to, bool force) async {
final c = animeFlow;
if (c == null) {
return (me: null, schedule: const <AnimeFlowScheduleItem>[], series: const <AnimeFlowSeries>[],
library: const <AnimeFlowLibraryItem>[],
error: 'AnimeFlow is not configured');
}
try {
if (force) c.clearCache();
final me = await c.me();
final (sched, series) = await (c.schedule(from, to), c.series()).wait;
// The library only adds "on Jellyfin" for untracked shows: its
// failure never fails the schedule.
List<AnimeFlowLibraryItem> library = const [];
try {
library = await c.library();
} catch (_) {}
return (me: me, schedule: sched.items, series: series, library: library, error: null);
} on ParallelWaitError catch (e) {
final errs = e.errors as (AsyncError?, AsyncError?);
return (
me: null,
schedule: const <AnimeFlowScheduleItem>[],
series: const <AnimeFlowSeries>[],
library: const <AnimeFlowLibraryItem>[],
error: (errs.$1 ?? errs.$2)!.error,
);
} catch (e) {
return (me: null, schedule: const <AnimeFlowScheduleItem>[], series: const <AnimeFlowSeries>[], library: const <AnimeFlowLibraryItem>[], error: e);
}
}
static String _text(Object e) => switch (e) {
AnimeFlowException(:final code, :final detail) => switch (code) {
AnimeFlowErrorCode.missingToken => 'Link the Jellyfin server to see AnimeFlow statuses',
AnimeFlowErrorCode.badToken => 'AnimeFlow: sign in to Jellyfin again',
AnimeFlowErrorCode.revoked => 'AnimeFlow: access revoked',
AnimeFlowErrorCode.rateLimited => 'AnimeFlow: too many requests, try again shortly',
_ => 'AnimeFlow: $detail',
},
AniListException(:final message) => message,
_ => '$e',
};
}
@@ -0,0 +1,175 @@
import 'package:flutter/material.dart';
import '../../shell/avatar.dart';
import '../animeflow.dart';
// The Schedule page's side sheet: AnimeFlow requests (everyone's for the
// owner, who can retry failed ones and decline the rest; own otherwise).
Color requestColor(RequestStatus s) => switch (s) {
RequestStatus.pending || RequestStatus.searching => Colors.amber,
RequestStatus.approved => Colors.lightBlueAccent,
RequestStatus.rejected || RequestStatus.failed => Colors.redAccent,
RequestStatus.done => Colors.greenAccent,
};
String requestWhat(AnimeFlowRequest r) => switch (r.kind) {
RequestKind.series => r.title,
RequestKind.season => '${r.title} · season ${r.season ?? '?'}',
RequestKind.episode => '${r.title} · S${r.season ?? '?'}E${r.episode ?? '?'}',
};
class RequestsSheet extends StatelessWidget {
/// Scope manage: everyone's requests, Retry and Decline.
final bool owner;
/// Null while loading.
final List<AnimeFlowRequest>? requests;
final String? error;
/// Our own name, for requests without a user name.
final String selfName;
/// Requests with a call in flight.
final Set<int> busy;
final void Function(AnimeFlowRequest r) onRetry;
final void Function(AnimeFlowRequest r) onDecline;
final VoidCallback onClose;
const RequestsSheet({
super.key,
required this.owner,
required this.requests,
required this.selfName,
required this.onRetry,
required this.onDecline,
required this.onClose,
this.error,
this.busy = const {},
});
Widget _tile(BuildContext context, AnimeFlowRequest r) {
final scheme = Theme.of(context).colorScheme;
final who = r.userName ?? (selfName.isEmpty ? 'you' : selfName);
final canRetry = owner && r.status == RequestStatus.failed;
final canDecline =
owner &&
(r.status == RequestStatus.pending ||
r.status == RequestStatus.approved ||
r.status == RequestStatus.searching);
final inFlight = busy.contains(r.id);
return Padding(
key: ValueKey('request-${r.id}'),
padding: const EdgeInsets.symmetric(vertical: 6),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
StatusAvatar(
info: AvatarInfo(name: who, color: colorForName(who)),
size: 32,
ring: false,
badges: false,
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(requestWhat(r), style: const TextStyle(fontWeight: FontWeight.w600)),
const SizedBox(height: 2),
Wrap(
spacing: 8,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
Text(who, style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12)),
Pill(r.status.name, requestColor(r.status)),
],
),
if (r.reason != null && r.reason!.isNotEmpty)
Text(r.reason!, style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 11)),
if (canRetry || canDecline)
Padding(
padding: const EdgeInsets.only(top: 6),
child: Row(
children: [
if (canRetry)
FilledButton.tonal(
key: ValueKey('retry-${r.id}'),
onPressed: inFlight ? null : () => onRetry(r),
child: const Text('Retry'),
),
if (canDecline)
TextButton(
key: ValueKey('decline-${r.id}'),
onPressed: inFlight ? null : () => onDecline(r),
child: const Text('Decline'),
),
if (inFlight) ...[
const SizedBox(width: 8),
const SizedBox(width: 14, height: 14, child: CircularProgressIndicator(strokeWidth: 2)),
],
],
),
),
],
),
),
],
),
);
}
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final reqs = requests;
return Material(
color: scheme.surfaceContainerLow,
child: ListView(
padding: const EdgeInsets.fromLTRB(16, 10, 8, 16),
children: [
Row(
children: [
Text(
owner ? 'Requests' : 'My requests',
style: const TextStyle(fontWeight: FontWeight.w700, fontSize: 16),
),
const Spacer(),
IconButton(
key: const ValueKey('requests-close'),
onPressed: onClose,
tooltip: 'Close',
icon: const Icon(Icons.close, size: 18),
),
],
),
Text(
owner
? 'Everyone\'s requests. AnimeFlow approves them automatically; you can decline or retry.'
: 'AnimeFlow downloads requests automatically; they appear on Jellyfin when done.',
style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12),
),
const SizedBox(height: 12),
if (error != null)
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Text(error!, style: TextStyle(color: scheme.error, fontSize: 12)),
),
if (reqs == null && error == null)
const Padding(
padding: EdgeInsets.only(top: 24),
child: Center(child: SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2))),
)
else if (reqs != null && reqs.isEmpty)
Padding(
padding: const EdgeInsets.only(top: 24),
child: Center(
child: Text('No requests yet', style: TextStyle(color: scheme.onSurfaceVariant)),
),
),
for (final r in reqs ?? const <AnimeFlowRequest>[]) _tile(context, r),
],
),
);
}
}
@@ -0,0 +1,143 @@
import 'package:sync_relay_core/core.dart';
import '../../jellyfin/accounts.dart';
import '../../jellyfin/format.dart' show pickFor;
import '../../screens/home/picked_media.dart';
import '../anilist.dart';
import '../animeflow.dart';
import '../schedule_model.dart';
// What the Schedule page's buttons do beyond one client call: friendly
// AnimeFlow errors, finding AnimeFlow's own numbering of an airing, the
// AniDB id of an untracked AniList entry, and the Jellyfin episode to put
// up.
/// A failure meant for the user as is.
class ScheduleActionException implements Exception {
final String message;
const ScheduleActionException(this.message);
@override
String toString() => message;
}
/// [e] as one short sentence for a toast.
String friendlyError(Object e) => switch (e) {
ScheduleActionException(:final message) => message,
AnimeFlowException(:final code, :final detail) => switch (code) {
AnimeFlowErrorCode.alreadyRequested => 'Already requested',
AnimeFlowErrorCode.alreadyTracked => 'AnimeFlow already tracks this series',
AnimeFlowErrorCode.alreadyAvailable => 'Already on Jellyfin',
AnimeFlowErrorCode.missingScope => 'The owner has not allowed you to do that on AnimeFlow',
AnimeFlowErrorCode.revoked => 'Your AnimeFlow access was revoked',
AnimeFlowErrorCode.missingToken => 'Link the Jellyfin server to use AnimeFlow',
AnimeFlowErrorCode.badToken => 'AnimeFlow: sign in to Jellyfin again',
AnimeFlowErrorCode.ownAccount => 'You cannot change your own account',
AnimeFlowErrorCode.searchUnavailable => 'AniDB search is unavailable right now',
AnimeFlowErrorCode.seriesNotFound => 'AnimeFlow does not know this series',
AnimeFlowErrorCode.episodeNotFound => 'AnimeFlow does not know this episode',
AnimeFlowErrorCode.requestNotFound => 'That request is gone',
AnimeFlowErrorCode.notFound => 'AnimeFlow: not found',
AnimeFlowErrorCode.invalidState => 'That cannot be done in its current state',
AnimeFlowErrorCode.rateLimited => 'AnimeFlow is busy, try again shortly',
AnimeFlowErrorCode.network => 'AnimeFlow is unreachable',
AnimeFlowErrorCode.jellyfinUnreachable => 'AnimeFlow cannot reach Jellyfin right now',
_ => 'AnimeFlow: $detail',
},
JellyfinException(:final message) => 'Jellyfin: $message',
_ => '$e',
};
bool _sameDay(DateTime a, DateTime b) {
final x = a.toLocal(), y = b.toLocal();
return x.year == y.year && x.month == y.month && x.day == y.day;
}
/// AnimeFlow's episode for [e] (its season and numbering), from the
/// series detail: same AniList id and episode, else absolute number, else
/// the same air day. Null when the entry is not tracked or nothing fits.
Future<AnimeFlowEpisode?> flowEpisodeFor(AnimeFlowClient client, ScheduleEntry e) async {
final id = e.seriesId;
if (id == null) return null;
final eps = (await client.seriesDetail(id)).episodes;
final a = e.airing;
final mine = eps.where((x) => x.anilistId == null || x.anilistId == a.anilistId).toList();
return mine.where((x) => x.anilistId == a.anilistId && x.episode == a.episode).firstOrNull ??
mine.where((x) => x.absolute == a.episode).firstOrNull ??
mine.where((x) => x.airDate != null && _sameDay(x.airDate!, a.airingAt)).firstOrNull;
}
/// The (season, episode) AnimeFlow uses for [e]; falls back to the
/// matched season and AniList's number when the detail has nothing.
Future<({int season, int episode})> flowNumbering(AnimeFlowClient? client, ScheduleEntry e) async {
AnimeFlowEpisode? ep;
if (client != null) {
try {
ep = await flowEpisodeFor(client, e);
} on AnimeFlowException catch (err) {
if (err.isUnauthorized || err.isForbidden) rethrow;
}
}
if (ep != null) return (season: ep.season, episode: ep.episode);
return (season: e.season ?? 1, episode: e.airing.episode);
}
/// The AniDB id of an untracked AniList entry, from AnimeFlow's search:
/// the hit with the same AniList id, searched by English then romaji
/// title. Otherwise every hit seen, for the user to choose from.
Future<({AnimeFlowSearchHit? match, List<AnimeFlowSearchHit> hits})> findOnAniDb(
AnimeFlowClient client,
Airing a,
) async {
final hits = <AnimeFlowSearchHit>[];
final queries = {a.title, if (a.titleRomaji.isNotEmpty) a.titleRomaji};
for (final q in queries) {
final found = await client.search(q);
final match = found.where((h) => h.anilistId == a.anilistId).firstOrNull;
if (match != null) return (match: match, hits: found);
for (final h in found) {
if (hits.every((x) => x.anidbId != h.anidbId)) hits.add(h);
}
}
return (match: null, hits: hits);
}
/// The linked Jellyfin account AnimeFlow's series ids belong to: the one
/// AnimeFlow authenticates with, else the default.
JellyfinAccount? jellyfinAccountFor(JellyfinAccounts accounts, AnimeFlowClient? flow) {
if (flow != null) {
final token = AnimeFlowClient.tokenFor(accounts.accounts, flow.baseUrl);
final a = accounts.accounts.where((a) => token != null && a.token == token).firstOrNull;
if (a != null) return a;
}
return accounts.defaultAccount;
}
/// The Jellyfin episode of an available [e] as a media pick. Throws
/// [ScheduleActionException] (or a client error) when it cannot be found.
Future<JellyfinMedia> resolveJellyfinEpisode(JellyfinAccounts accounts, AnimeFlowClient? flow, ScheduleEntry e) async {
final seriesId = e.jellyfinSeriesId;
if (seriesId == null) throw const ScheduleActionException('This series is not on Jellyfin yet');
await accounts.ensureLoaded(validate: false);
final account = jellyfinAccountFor(accounts, flow);
final client = account == null ? null : accounts.clientFor(account.serverId);
if (account == null || client == null) {
throw const ScheduleActionException('Link the Jellyfin server first (Settings)');
}
final n = await flowNumbering(flow, e);
final List<JellyfinItem> eps;
try {
eps = await client.episodes(seriesId);
} on JellyfinException catch (err) {
if (err.isUnauthorized) accounts.markExpired(account.serverId);
rethrow;
}
final a = e.airing;
final item =
eps.where((x) => x.parentIndexNumber == n.season && x.indexNumber == n.episode).firstOrNull ??
eps.where((x) => x.premiereDate != null && _sameDay(x.premiereDate!, a.airingAt)).singleOrNull ??
eps.where((x) => x.indexNumber == a.episode).singleOrNull;
if (item == null) {
throw ScheduleActionException('Episode ${a.episode} of ${a.title} is not on Jellyfin yet');
}
return pickFor(account, item);
}
@@ -0,0 +1,389 @@
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import '../schedule_model.dart';
// One day of the Schedule board and its airing cards: cover poster on the
// left (air time on it, download progress under it), title, episode and
// status on the right, one full-width action below. Compact mode is a
// slim row.
/// What a card's action does, decided by the page.
enum CardAction {
none,
putUp,
/// Available, nobody else watching (offline or solo): plays it here.
play,
request,
requestSeries,
requested,
}
String _two(int n) => n.toString().padLeft(2, '0');
String clockText(DateTime t) {
final l = t.toLocal();
return '${_two(l.hour)}:${_two(l.minute)}';
}
String countdownText(DateTime at, DateTime now) {
final d = at.difference(now);
if (d.isNegative) return 'aired';
if (d.inDays > 0) return 'in ${d.inDays}d ${d.inHours % 24}h';
if (d.inHours > 0) return 'in ${d.inHours}h ${d.inMinutes % 60}m';
return 'in ${d.inMinutes}m';
}
/// Status line: text, colour, icon.
(String, Color, IconData) statusBadge(ScheduleEntry e, DateTime now) => switch (e.status) {
ScheduleStatus.available => ('on Jellyfin', Colors.greenAccent, Icons.check_circle),
ScheduleStatus.downloading => (
e.progress == null ? 'downloading' : 'downloading ${(e.progress! * 100).round()}%',
Colors.lightBlueAccent,
Icons.downloading,
),
ScheduleStatus.upcoming => (countdownText(e.airing.airingAt, now), Colors.amber, Icons.schedule),
ScheduleStatus.missing => ('missing', Colors.orangeAccent, Icons.report_gmailerrorred),
ScheduleStatus.failed => ('failed${e.reason == null ? '' : ' · ${e.reason}'}', Colors.redAccent, Icons.error_outline),
ScheduleStatus.unmonitored => ('not monitored', Colors.grey, Icons.visibility_off_outlined),
ScheduleStatus.notTracked => ('not in AnimeFlow', Colors.grey, Icons.public),
ScheduleStatus.inLibrary => ('on Jellyfin', Colors.greenAccent, Icons.video_library_outlined),
};
class DayColumn extends StatelessWidget {
final DateTime day;
final bool today, past;
final List<Widget> cards;
final bool compact;
const DayColumn({
super.key,
required this.day,
required this.today,
required this.past,
required this.cards,
required this.compact,
});
static const _wd = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return Container(
decoration: BoxDecoration(
color: today ? scheme.primary.withValues(alpha: 0.06) : scheme.surfaceContainerLow.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(14),
border: today ? Border.all(color: scheme.primary.withValues(alpha: 0.5)) : null,
),
child: Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(12, 10, 12, 8),
child: Row(
children: [
Text(
today ? 'Today' : _wd[day.weekday - 1],
style: TextStyle(fontWeight: FontWeight.w700, color: today ? scheme.primary : null),
),
const SizedBox(width: 6),
Text('${day.day}.${day.month}.', style: TextStyle(color: scheme.onSurfaceVariant)),
const Spacer(),
if (cards.isNotEmpty)
Text('${cards.length}', style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12)),
],
),
),
Expanded(
child: Opacity(
opacity: past ? 0.85 : 1,
child: cards.isEmpty
? Center(
child: Text('—', style: TextStyle(color: scheme.onSurfaceVariant)),
)
// The board's mouse drag is sideways only: a column scrolls by
// wheel (or touch), never by dragging the mouse.
: ScrollConfiguration(
behavior: ScrollConfiguration.of(context)
.copyWith(dragDevices: {...PointerDeviceKind.values}..remove(PointerDeviceKind.mouse)),
child: ListView.separated(
padding: const EdgeInsets.fromLTRB(8, 0, 8, 10),
itemCount: cards.length,
separatorBuilder: (_, _) => SizedBox(height: compact ? 6 : 10),
itemBuilder: (context, i) => cards[i],
),
),
),
),
],
),
);
}
}
class AiringCard extends StatelessWidget {
final ScheduleEntry entry;
final DateTime now;
final bool compact;
final bool followed;
final CardAction action;
/// Why [action] is disabled (no request scope, not on Jellyfin, ...);
/// null = enabled.
final String? disabledHint;
/// A call for this card is in flight.
final bool busy;
final VoidCallback onAction;
/// Null hides the bookmark (untracked series).
final VoidCallback? onFollow;
const AiringCard({
super.key,
required this.entry,
required this.now,
required this.compact,
required this.followed,
required this.action,
required this.onAction,
this.disabledHint,
this.busy = false,
this.onFollow,
});
String get _id => '${entry.airing.anilistId}-${entry.airing.episode}';
Widget _image(String? url, Widget fallback) =>
url == null ? fallback : Image.network(url, fit: BoxFit.cover, errorBuilder: (_, _, _) => fallback);
Widget? _button(BuildContext context) {
final enabled = disabledHint == null && !busy;
final spinner = const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2));
Widget? b = switch (action) {
CardAction.none => null,
CardAction.putUp || CardAction.play => FilledButton.tonalIcon(
onPressed: enabled ? onAction : null,
icon: busy ? spinner : const Icon(Icons.play_arrow, size: 18),
label: Text(action == CardAction.play ? 'Play' : 'Put it up'),
),
CardAction.request || CardAction.requestSeries => OutlinedButton.icon(
onPressed: enabled ? onAction : null,
icon: busy ? spinner : const Icon(Icons.download, size: 18),
label: Text(action == CardAction.request ? 'Request' : 'Request series'),
),
CardAction.requested => const OutlinedButton(onPressed: null, child: Text('Requested')),
};
if (b == null) return null;
b = KeyedSubtree(key: ValueKey('action-$_id'), child: b);
return disabledHint == null ? b : Tooltip(message: disabledHint!, child: b);
}
Widget? _follow(BuildContext context, double size) {
final f = onFollow;
if (f == null) return null;
return IconButton(
key: ValueKey('follow-$_id'),
onPressed: f,
tooltip: followed ? 'Stop following' : 'Follow',
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
constraints: BoxConstraints.tightFor(width: size + 10, height: size + 10),
iconSize: size,
color: followed ? Theme.of(context).colorScheme.primary : null,
icon: Icon(followed ? Icons.bookmark : Icons.bookmark_outline),
);
}
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final a = entry.airing;
final (label, color, icon) = statusBadge(entry, now);
final time = clockText(a.airingAt);
final tintColor = a.color ?? Colors.blueGrey;
final tint = DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [tintColor.withValues(alpha: 0.85), tintColor.withValues(alpha: 0.35)],
),
),
);
final tap = (action == CardAction.putUp || action == CardAction.play) && disabledHint == null && !busy ? onAction : null;
if (compact) {
final requestable = action == CardAction.request || action == CardAction.requestSeries;
return Material(
key: ValueKey('card-$_id'),
color: scheme.surfaceContainer,
borderRadius: BorderRadius.circular(10),
child: InkWell(
borderRadius: BorderRadius.circular(10),
onTap: tap,
child: Padding(
padding: const EdgeInsets.all(6),
child: Row(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(6),
child: SizedBox(width: 34, height: 48, child: _image(a.cover, tint)),
),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
a.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 13),
),
Text('$time · E${a.episode}', style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 11)),
],
),
),
if (followed && onFollow == null) Icon(Icons.bookmark, size: 14, color: scheme.primary),
if (requestable)
Tooltip(
message: disabledHint ?? (action == CardAction.request ? 'Request' : 'Request series'),
child: IconButton(
key: ValueKey('action-$_id'),
onPressed: disabledHint == null && !busy ? onAction : null,
visualDensity: VisualDensity.compact,
iconSize: 18,
icon: Icon(Icons.download, color: color),
),
)
else
Tooltip(
message: label,
child: Icon(icon, size: 16, color: color),
),
],
),
),
),
);
}
final button = _button(context);
final follow = _follow(context, 16);
// The cover poster leads: it is what people recognise at a glance.
return Material(
key: ValueKey('card-$_id'),
color: scheme.surfaceContainer,
borderRadius: BorderRadius.circular(12),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: tap,
child: Padding(
padding: const EdgeInsets.all(8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: SizedBox(
width: 84,
height: 120,
child: Stack(
fit: StackFit.expand,
children: [
_image(a.cover, tint),
Positioned(
left: 4,
top: 4,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.7),
borderRadius: BorderRadius.circular(5),
),
child: Text(
time,
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w700, fontSize: 12),
),
),
),
if (entry.status == ScheduleStatus.downloading)
Positioned(
left: 0,
right: 0,
bottom: 0,
child: LinearProgressIndicator(
value: entry.progress,
minHeight: 3,
color: Colors.lightBlueAccent,
),
),
],
),
),
),
const SizedBox(width: 10),
Expanded(
child: SizedBox(
height: 120,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
a.title,
maxLines: 3,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontWeight: FontWeight.w600, height: 1.25),
),
const SizedBox(height: 2),
Row(
children: [
Flexible(
child: Text(
'Episode ${a.episode}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12),
),
),
const SizedBox(width: 2),
if (follow != null)
follow
else if (followed)
Padding(
padding: const EdgeInsets.only(left: 4),
child: Icon(Icons.bookmark, size: 14, color: scheme.primary),
),
],
),
const Spacer(),
Row(
children: [
Icon(icon, size: 14, color: color),
const SizedBox(width: 4),
Expanded(
child: Text(
label,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: color, fontSize: 12),
),
),
],
),
],
),
),
),
],
),
if (button != null) ...[const SizedBox(height: 8), SizedBox(width: double.infinity, child: button)],
],
),
),
),
);
}
}
@@ -0,0 +1,587 @@
import 'dart:async';
import 'package:flutter/material.dart';
import '../../jellyfin/accounts.dart';
import '../../screens/home/picked_media.dart';
import '../../shell/avatar.dart';
import '../../shell/motion.dart';
import '../anilist.dart';
import '../animeflow.dart';
import '../schedule_model.dart';
import 'requests_sheet.dart';
import 'schedule_actions.dart';
import 'schedule_card.dart';
// The Schedule tab: one column per day from last week's Monday to four
// weeks out (AniList), each airing with what AnimeFlow has of it, and the
// one thing to do about it (put it up, request it). Refreshes every
// minute while visible; ETags keep that cheap.
enum ScheduleFilter { jellyfin, following, all }
class SchedulePage extends StatefulWidget {
/// Nobody else watches (offline or solo): available episodes say Play
/// (offline that starts a solo session).
final bool solo;
/// Puts a Jellyfin episode up in the server; returns an error to show.
final Future<String?> Function(JellyfinMedia pick) onPutUp;
final void Function(String message) onToast;
/// Null = the page makes (and closes) its own.
final ScheduleRepository? repository;
/// Linked Jellyfin servers for Put it up; null = [JellyfinAccounts.instance].
final JellyfinAccounts? accounts;
const SchedulePage({
super.key,
this.solo = false,
required this.onPutUp,
required this.onToast,
this.repository,
this.accounts,
});
static const columnWidth = 252.0;
static const columnGap = 12.0;
static const refreshEvery = Duration(seconds: 60);
/// "All airing" hides entries below this AniList popularity unless
/// "Niche too" is on (tracked ones always show).
static const nicheBelow = 5000;
static const sheetWidth = 360.0;
@override
State<SchedulePage> createState() => _SchedulePageState();
}
class _SchedulePageState extends State<SchedulePage> {
late final ScheduleRepository _repo;
late final bool _ownsRepo;
ScheduleSnapshot? _snap;
bool _refreshing = false;
int _gen = 0;
Timer? _timer;
final _scroll = ScrollController();
bool _jumped = false;
ScheduleFilter _filter = ScheduleFilter.jellyfin;
bool _compact = false;
bool _niche = false;
bool _sheet = false;
/// The sheet stays built while it slides out.
bool _sheetBuilt = false;
/// Follow toggles not yet confirmed by a reload (series id -> followed).
final _followOverride = <int, bool>{};
/// Requested in this run: "anilistId#episode" or "series:anilistId".
final _requested = <String>{};
/// Cards with a call in flight (same keys as [_requested], "play:...").
final _busy = <String>{};
List<AnimeFlowRequest>? _requests;
String? _requestsError;
final _reqBusy = <int>{};
JellyfinAccounts get _accounts => widget.accounts ?? JellyfinAccounts.instance;
AnimeFlowClient? get _flow => _repo.animeFlow;
AnimeFlowMe? get _me => _snap?.me;
bool get _owner => _me?.can(AnimeFlowScope.manage) ?? false;
@override
void initState() {
super.initState();
_ownsRepo = widget.repository == null;
_repo =
widget.repository ??
ScheduleRepository(
aniList: AniListClient(),
animeFlow: AnimeFlowClient.fromAccounts(JellyfinAccounts.instance),
);
_load();
_timer = Timer.periodic(SchedulePage.refreshEvery, (_) {
if (mounted && !_refreshing && TickerMode.valuesOf(context).enabled) _load();
});
}
@override
void dispose() {
_timer?.cancel();
_scroll.dispose();
if (_ownsRepo) {
_repo.aniList.close();
_repo.animeFlow?.close();
}
super.dispose();
}
void _toast(String s) {
if (mounted) widget.onToast(s);
}
Future<void> _load({bool force = false}) async {
final gen = ++_gen;
if (_snap != null) setState(() => _refreshing = true);
final snap = await _repo.load(force: force);
if (!mounted || gen != _gen) return;
final first = _snap == null;
setState(() {
_snap = snap;
_refreshing = false;
// Without AnimeFlow nothing is "on Jellyfin": start on everything.
if (first && !snap.hasAnimeFlow) _filter = ScheduleFilter.all;
});
if (!_jumped && snap.entries.isNotEmpty) {
_jumped = true;
WidgetsBinding.instance.addPostFrameCallback((_) => _jumpToday());
}
await _loadRequests();
}
Future<void> _loadRequests() async {
final c = _flow, me = _me;
if (c == null || me == null) {
if (_requests != null || _requestsError != null) {
setState(() {
_requests = null;
_requestsError = null;
});
}
return;
}
try {
final reqs = await c.requests(all: me.can(AnimeFlowScope.manage));
if (mounted) {
setState(() {
_requests = reqs;
_requestsError = null;
});
}
} catch (e) {
if (mounted) setState(() => _requestsError = friendlyError(e));
}
}
// ------------------------------------------------------------- layout
int _todayIndex(DateTime from) {
final now = DateTime.now();
final today = DateTime(now.year, now.month, now.day);
final f = DateTime(from.year, from.month, from.day);
return (today.difference(f).inHours / 24).round();
}
void _jumpToday({bool animate = false}) {
final snap = _snap;
if (!_scroll.hasClients || snap == null) return;
final i = _todayIndex(snap.from.toLocal());
final x = (i * (SchedulePage.columnWidth + SchedulePage.columnGap))
.clamp(0.0, _scroll.position.maxScrollExtent)
.toDouble();
animate ? _scroll.animateTo(x, duration: easeTime, curve: ease) : _scroll.jumpTo(x);
}
bool _followed(ScheduleEntry e) => (e.seriesId == null ? null : _followOverride[e.seriesId]) ?? e.followed;
bool _shows(ScheduleEntry e) => switch (_filter) {
ScheduleFilter.jellyfin => e.tracked || e.onJellyfin,
ScheduleFilter.following => _followed(e),
ScheduleFilter.all => _niche || e.tracked || e.airing.popularity >= SchedulePage.nicheBelow,
};
static String _key(ScheduleEntry e) =>
e.tracked ? '${e.airing.anilistId}#${e.airing.episode}' : 'series:${e.airing.anilistId}';
static const _active = {RequestStatus.pending, RequestStatus.approved, RequestStatus.searching};
bool _isRequested(ScheduleEntry e) {
if (_requested.contains(_key(e))) return true;
final a = e.airing;
for (final r in _requests ?? const <AnimeFlowRequest>[]) {
if (!_active.contains(r.status)) continue;
if (e.tracked) {
if (r.kind == RequestKind.episode && r.seriesId == e.seriesId && r.episode == a.episode) return true;
if (r.kind == RequestKind.season && r.seriesId == e.seriesId && r.season == e.season) return true;
} else if (r.kind == RequestKind.series && (r.title == a.title || r.title == a.titleRomaji)) {
return true;
}
}
return false;
}
(CardAction, String?) _actionFor(ScheduleEntry e) {
if (e.status == ScheduleStatus.available || e.status == ScheduleStatus.inLibrary) {
final put = widget.solo ? CardAction.play : CardAction.putUp;
if (e.jellyfinSeriesId == null) return (put, 'Not linked to a Jellyfin series yet');
return (put, null);
}
final me = _me;
final CardAction kind;
if (e.tracked) {
kind = e.status == ScheduleStatus.missing || e.status == ScheduleStatus.failed
? CardAction.request
: CardAction.none;
} else {
// On Jellyfin already (owner added it by hand): nothing to request.
kind = me != null && !e.onJellyfin ? CardAction.requestSeries : CardAction.none;
}
if (kind == CardAction.none) return (kind, null);
if (_isRequested(e)) return (CardAction.requested, null);
if (me == null || !me.can(AnimeFlowScope.request)) {
return (kind, 'Requests need the owner to allow them for you on AnimeFlow');
}
return (kind, null);
}
// ------------------------------------------------------------- actions
Future<void> _onAction(ScheduleEntry e, CardAction action) async {
switch (action) {
case CardAction.putUp:
case CardAction.play:
return _putUp(e);
case CardAction.request:
case CardAction.requestSeries:
return _request(e);
default:
return;
}
}
Future<void> _putUp(ScheduleEntry e) async {
final key = 'play:${_key(e)}';
if (_busy.contains(key)) return;
setState(() => _busy.add(key));
try {
final pick = await resolveJellyfinEpisode(_accounts, _flow, e);
final err = await widget.onPutUp(pick);
if (err != null) _toast(err);
} catch (err) {
_toast(friendlyError(err));
} finally {
if (mounted) setState(() => _busy.remove(key));
}
}
Future<void> _request(ScheduleEntry e) async {
final c = _flow;
if (c == null) return;
final key = _key(e);
if (_busy.contains(key)) return;
setState(() => _busy.add(key));
final a = e.airing;
try {
final seriesId = e.seriesId;
if (e.tracked && seriesId != null) {
final n = await flowNumbering(c, e);
await c.requestEpisode(seriesId, n.season, n.episode);
_requested.add(key);
_toast('Requested ${a.title}, episode ${a.episode}');
} else {
final found = await findOnAniDb(c, a);
final hit = found.match ?? (mounted ? await _chooseHit(a, found.hits) : null);
if (hit == null) return;
await c.requestSeries(hit.anidbId);
_requested.add(key);
_toast('Requested ${hit.title}');
}
unawaited(_loadRequests());
} on AnimeFlowException catch (err) {
if (err.code == AnimeFlowErrorCode.alreadyRequested) _requested.add(key);
_toast(friendlyError(err));
} catch (err) {
_toast(friendlyError(err));
} finally {
if (mounted) setState(() => _busy.remove(key));
}
}
/// No search hit has [a]'s AniList id: let the user pick one (or learn
/// that AniDB does not know it).
Future<AnimeFlowSearchHit?> _chooseHit(Airing a, List<AnimeFlowSearchHit> hits) => showDialog<AnimeFlowSearchHit>(
context: context,
builder: (context) {
final scheme = Theme.of(context).colorScheme;
return AlertDialog(
key: const ValueKey('anidb-choose'),
title: Text(hits.isEmpty ? 'Not found on AniDB' : 'Which one is "${a.title}"?'),
content: SizedBox(
width: 420,
child: hits.isEmpty
? Text(
'AnimeFlow can only add series AniDB knows, and its search found nothing for this title.',
style: TextStyle(color: scheme.onSurfaceVariant),
)
: ListView(
shrinkWrap: true,
children: [
for (final h in hits)
ListTile(
key: ValueKey('anidb-${h.anidbId}'),
title: Text(h.title),
subtitle: Text('AniDB ${h.anidbId}${h.seriesId != null ? ' · already on AnimeFlow' : ''}'),
onTap: () => Navigator.of(context).pop(h),
),
],
),
),
actions: [
TextButton(onPressed: () => Navigator.of(context).pop(), child: Text(hits.isEmpty ? 'OK' : 'Cancel')),
],
);
},
);
Future<void> _toggleFollow(ScheduleEntry e) async {
final c = _flow, id = e.seriesId;
if (c == null || id == null) return;
final next = !_followed(e);
setState(() => _followOverride[id] = next);
try {
await c.setFollowed(id, next);
await _load();
} catch (err) {
_toast(friendlyError(err));
} finally {
if (mounted) setState(() => _followOverride.remove(id));
}
}
Future<void> _decide(AnimeFlowRequest r, {required bool approve}) async {
final c = _flow;
if (c == null || _reqBusy.contains(r.id)) return;
setState(() => _reqBusy.add(r.id));
try {
approve ? await c.approve(r.id) : await c.reject(r.id);
_toast(approve ? 'Retrying ${r.title}' : 'Declined ${r.title}');
await _loadRequests();
} catch (err) {
_toast(friendlyError(err));
} finally {
if (mounted) setState(() => _reqBusy.remove(r.id));
}
}
// ------------------------------------------------------------- build
Widget _header(BuildContext context) {
final snap = _snap;
final me = _me;
final stale = snap?.aniListStaleSince;
return Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 8),
child: Wrap(
spacing: 12,
runSpacing: 8,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
SegmentedButton<ScheduleFilter>(
key: const ValueKey('schedule-filter'),
showSelectedIcon: false,
segments: const [
ButtonSegment(
value: ScheduleFilter.jellyfin,
icon: Icon(Icons.video_library_outlined),
label: Text('On Jellyfin'),
),
ButtonSegment(
value: ScheduleFilter.following,
icon: Icon(Icons.bookmark_outline),
label: Text('Following'),
),
ButtonSegment(value: ScheduleFilter.all, icon: Icon(Icons.public), label: Text('All airing')),
],
selected: {_filter},
onSelectionChanged: (s) => setState(() => _filter = s.first),
),
FilterChip(
key: const ValueKey('schedule-compact'),
label: const Text('Compact'),
selected: _compact,
onSelected: (v) => setState(() => _compact = v),
),
if (_filter == ScheduleFilter.all)
FilterChip(
key: const ValueKey('schedule-niche'),
label: const Text('Niche too'),
selected: _niche,
onSelected: (v) => setState(() => _niche = v),
),
TextButton.icon(
key: const ValueKey('schedule-today'),
onPressed: () => _jumpToday(animate: true),
icon: const Icon(Icons.today, size: 18),
label: const Text('Today'),
),
IconButton(
key: const ValueKey('schedule-refresh'),
tooltip: 'Refresh',
onPressed: _refreshing || snap == null ? null : () => _load(force: true),
icon: _refreshing
? const SizedBox(width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2))
: const Icon(Icons.refresh, size: 20),
),
Tooltip(
message: me == null ? (snap?.animeFlowError ?? 'AnimeFlow is not available') : '',
child: OutlinedButton.icon(
key: const ValueKey('schedule-requests'),
onPressed: me == null
? null
: () => setState(() {
_sheet = !_sheet;
if (_sheet) _sheetBuilt = true;
}),
icon: const Icon(Icons.inbox_outlined, size: 18),
label: Text(_owner ? 'Requests' : 'My requests'),
),
),
if (stale != null)
Pill(
key: const ValueKey('schedule-stale'),
'AniList busy · showing data from ${clockText(stale)}',
Colors.amber,
),
if (me != null)
Pill(
key: const ValueKey('schedule-animeflow'),
'AnimeFlow · ${me.name} · ${[for (final s in AnimeFlowScope.values)
if (me.can(s)) s.name].join(', ')}',
Colors.greenAccent.shade400,
)
else if (snap?.animeFlowError != null)
Pill(key: const ValueKey('schedule-animeflow'), snap!.animeFlowError!, Colors.orangeAccent),
],
),
);
}
Widget _board(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final snap = _snap;
if (snap == null) return const Center(child: CircularProgressIndicator());
if (snap.aniListError != null && snap.entries.isEmpty) {
return Center(
key: const ValueKey('schedule-error'),
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.cloud_off, color: scheme.error),
const SizedBox(height: 8),
Text(
'Could not load the schedule: ${snap.aniListError}',
textAlign: TextAlign.center,
style: TextStyle(color: scheme.error),
),
const SizedBox(height: 12),
FilledButton.tonal(
onPressed: _refreshing ? null : () => _load(force: true),
child: const Text('Try again'),
),
],
),
),
);
}
final now = DateTime.now();
final today = DateTime(now.year, now.month, now.day);
final from = snap.from.toLocal();
final start = DateTime(from.year, from.month, from.day);
final to = snap.to.toLocal();
final count = (DateTime(to.year, to.month, to.day).difference(start).inHours / 24).round().clamp(1, 60);
final days = [for (var d = 0; d < count; d++) DateTime(start.year, start.month, start.day + d)];
final byDay = <DateTime, List<ScheduleEntry>>{};
for (final e in snap.entries) {
if (!_shows(e)) continue;
final l = e.airing.airingAt.toLocal();
(byDay[DateTime(l.year, l.month, l.day)] ??= []).add(e);
}
return HScroll(
controller: _scroll,
builder: (c) => ListView.separated(
key: const ValueKey('schedule-board'),
controller: c,
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.fromLTRB(20, 4, 20, 16),
itemCount: days.length,
separatorBuilder: (_, _) => const SizedBox(width: SchedulePage.columnGap),
itemBuilder: (context, i) {
final d = days[i];
final items = byDay[d] ?? const <ScheduleEntry>[];
return SizedBox(
key: ValueKey('day-${d.year}-${d.month}-${d.day}'),
width: SchedulePage.columnWidth,
child: DayColumn(
day: d,
today: d == today,
past: d.isBefore(today),
compact: _compact,
cards: [for (final e in items) _card(e, now)],
),
);
},
),
);
}
Widget _card(ScheduleEntry e, DateTime now) {
final (action, hint) = _actionFor(e);
final busy = _busy.contains(action == CardAction.putUp || action == CardAction.play ? 'play:${_key(e)}' : _key(e));
return AiringCard(
entry: e,
now: now,
compact: _compact,
followed: _followed(e),
action: action,
disabledHint: hint,
busy: busy,
onAction: () => _onAction(e, action),
onFollow: e.tracked && e.seriesId != null && _flow != null ? () => _toggleFollow(e) : null,
);
}
@override
Widget build(BuildContext context) => Row(
children: [
Expanded(
child: Column(
children: [
_header(context),
Expanded(child: _board(context)),
],
),
),
ClipRect(
child: AnimatedContainer(
duration: easeTime,
curve: ease,
width: _sheet ? SchedulePage.sheetWidth : 0,
child: OverflowBox(
alignment: Alignment.centerLeft,
minWidth: SchedulePage.sheetWidth,
maxWidth: SchedulePage.sheetWidth,
child: _sheetBuilt && _me != null
? RequestsSheet(
key: const ValueKey('requests-sheet'),
owner: _owner,
requests: _requests,
error: _requestsError,
selfName: _me?.name ?? '',
busy: _reqBusy,
onRetry: (r) => _decide(r, approve: true),
onDecline: (r) => _decide(r, approve: false),
onClose: () => setState(() => _sheet = false),
)
: const SizedBox.shrink(),
),
),
),
],
);
}
@@ -2,9 +2,10 @@ import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:sync_relay_core/core.dart';
import '../../update/update_controller.dart';
import '../../update/updater.dart';
import 'settings_page.dart';
import 'ui.dart';
import 'update_indicator.dart';
@@ -25,9 +26,10 @@ Future<bool> openInBrowser(String url) async {
}
}
class AboutPage extends StatelessWidget {
/// Name, version, update state and the source link (Settings → About).
class AboutPanel extends StatelessWidget {
final UpdateController? updates;
const AboutPage({super.key, this.updates});
const AboutPanel({super.key, this.updates});
Future<void> _open(BuildContext context) async {
final messenger = ScaffoldMessenger.maybeOf(context);
@@ -40,11 +42,8 @@ class AboutPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
final t = Theme.of(context).textTheme;
return PageScroll(
maxWidth: 680,
children: [
const PageTitle('About'),
Panel(
return SettingsPanel(
title: 'About',
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -73,8 +72,6 @@ class AboutPage extends StatelessWidget {
),
],
),
),
],
);
);
}
}
@@ -1,12 +1,8 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:sync_relay_core/core.dart' show HelloMedia;
import '../../session/session_coordinator.dart';
import '../../state/session_state.dart';
import '../../widgets/avatar_pipeline.dart';
import '../../widgets/media_input.dart';
import 'profile.dart';
/// What a card shows under its fields after a failed attempt.
class CardError {
@@ -15,7 +11,7 @@ class CardError {
const CardError(this.title, this.message);
}
/// Host/Join submit state for the two Home cards. One attempt at a time;
/// Host/Join submit state for the strip's two connect cards. One attempt at a time;
/// [cancel] abandons it via [SessionState.cancelPending].
class ConnectFlow extends ChangeNotifier {
ConnectFlow({SessionState? session}) : _session = session ?? SessionState.instance;
@@ -27,9 +23,6 @@ class ConnectFlow extends ChangeNotifier {
int _attempt = 0;
bool _disposed = false;
/// Set when an attempt ends in a kick, so Home shows the removal dialog.
void Function(String reason)? onKicked;
@override
void dispose() {
_disposed = true;
@@ -54,61 +47,38 @@ class ConnectFlow extends ChangeNotifier {
}
/// Validates and builds the params; throws [FormatException] with a
/// user-facing message.
static Future<ConnectParams> buildParams(ConnectMode mode, ConnectForm f) async {
/// user-facing message. [avatar] is the base64 picture (the profile's).
/// Hosting puts nothing up: what to watch is picked in the library.
static ConnectParams buildParams(ConnectMode mode, ConnectForm f, {String avatar = ''}) {
final rawPort = mode == ConnectMode.host ? f.hostPort : f.port;
final port = int.tryParse(rawPort.trim());
if (port == null || port <= 0 || port > 65535) {
throw const FormatException('port must be 1–65535');
}
if (f.name.trim().isEmpty) {
throw const FormatException('set your display name first (click your profile at the top)');
throw const FormatException('set your display name first (click your avatar at the top left)');
}
if (mode == ConnectMode.join && f.host.trim().isEmpty) {
throw const FormatException('enter the server address');
}
String avatar = '';
try {
avatar = await encodeAvatar(f.avatarPath);
} on FormatException catch (e) {
throw FormatException(e.message);
} catch (_) {
throw const FormatException('could not process that avatar — pick a PNG, JPG or WebP photo');
}
if (mode == ConnectMode.host &&
f.mediaPath.isNotEmpty &&
!await File(f.mediaPath).exists()) {
throw const FormatException('media file not found — check the path or pick again with Browse');
}
return ConnectParams(
host: f.host.trim(),
port: port,
secret: mode == ConnectMode.host ? f.hostSecret : f.secret,
name: f.name.trim(),
avatar: avatar,
mediaPath: mode == ConnectMode.host ? f.mediaPath : '',
linkUrl: mode == ConnectMode.host ? f.linkUrl : '',
);
}
/// [edit] fills the card's fields into a copy of the last form, so the
/// other card's saved values survive. [media] (host only) is put up
/// instead of the form's link or file (a Jellyfin ref; the pick itself is
/// remembered in the Jellyfin box, not the form).
Future<void> submit(ConnectMode mode, void Function(ConnectForm f) edit,
{HelloMedia? media}) async {
/// other card's saved values survive.
Future<void> submit(ConnectMode mode, void Function(ConnectForm f) edit) async {
if (busy != null) return;
final myAttempt = ++_attempt;
final form = _session.lastForm.copy();
edit(form);
form.name = form.name.trim();
form.host = form.host.trim();
if (mode == ConnectMode.host) {
final parsed = parseMediaInput(form.linkUrl.isNotEmpty ? form.linkUrl : form.mediaPath);
form
..mediaPath = parsed.mediaPath
..linkUrl = parsed.linkUrl;
}
busy = mode;
errors[mode] = null;
notices[mode] = null;
@@ -116,8 +86,9 @@ class ConnectFlow extends ChangeNotifier {
ConnectParams params;
try {
params = await buildParams(mode, form);
if (media != null && mode == ConnectMode.host) params = params.withMedia(media);
final profile = Profile.instance;
await profile.ensureLoaded();
params = buildParams(mode, form, avatar: profile.avatarBase64());
} catch (e) {
if (myAttempt != _attempt) return;
busy = null;
@@ -126,6 +97,7 @@ class ConnectFlow extends ChangeNotifier {
_notify();
return;
}
final fromSolo = mode == ConnectMode.host && _session.solo.value;
try {
if (mode == ConnectMode.host) {
await _session.host(params, form);
@@ -135,20 +107,19 @@ class ConnectFlow extends ChangeNotifier {
} catch (e) {
if (myAttempt != _attempt) return;
busy = null;
final kick = _session.coordinator.kickReason.peek();
if (kick != null && kick.isNotEmpty) {
onKicked?.call(kick);
} else {
errors[mode] = CardError('Could not connect', '$e');
}
errors[mode] = CardError('Could not connect', '$e');
_notify();
return;
}
if (myAttempt != _attempt) {
// Cancelled after it got through: back out.
// Cancelled after it got through: back out (an Invite: back to solo).
try {
await _session.leave();
await _session.stopServer();
if (fromSolo) {
await _session.cancelInvite();
} else {
await _session.leave();
await _session.stopServer();
}
} catch (_) {}
return;
}
@@ -1,175 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:signals/signals_flutter.dart';
import '../../jellyfin/accounts.dart';
import '../../state/session_state.dart';
import '../../state/settings.dart';
import '../../update/update_controller.dart';
import 'about_page.dart';
import 'connect_flow.dart';
import 'health_probe.dart';
import 'host_tab.dart';
import 'integrations_page.dart';
import 'join_tab.dart';
import 'profile.dart';
import 'settings_page.dart';
import 'top_bar.dart';
import 'update_indicator.dart';
/// Everything outside a session: one top bar (profile, Join/Host tabs,
/// Settings/Integrations/About, window buttons) over a full-width body.
class HomeScreen extends StatelessWidget {
const HomeScreen({super.key});
@override
Widget build(BuildContext context) {
return const Scaffold(body: HomeShell(windowControls: true));
}
}
/// [HomeScreen]'s content. Tests pump it with [windowControls] off (the
/// drag area and window buttons need window_manager) and a fake [healthGet].
class HomeShell extends StatefulWidget {
final UpdateController? updates;
final bool autoUpdateCheck;
final bool windowControls;
final HealthGetter healthGet;
/// Linked Jellyfin servers; defaults to [JellyfinAccounts.instance].
final JellyfinAccounts? jellyfin;
const HomeShell({
super.key,
this.updates,
this.autoUpdateCheck = true,
this.windowControls = false,
this.healthGet = defaultHealthGet,
this.jellyfin,
});
@override
State<HomeShell> createState() => _HomeShellState();
}
class _HomeShellState extends State<HomeShell> {
HomeView _view = HomeView.join;
final _flow = ConnectFlow();
late final _join = JoinFields(SessionState.instance.lastForm);
late final _jellyfin = widget.jellyfin ?? JellyfinAccounts.instance;
late final _host = HostFields(SessionState.instance.lastForm, jellyfin: _jellyfin);
late final void Function() _stopKickWatch;
late final void Function() _stopCrashWatch;
bool _dialogOpen = false;
String? _lastKickShown;
@override
void initState() {
super.initState();
Profile.instance.ensureLoaded();
// Reads linked servers and checks their tokens in the background.
_jellyfin.ensureLoaded();
if (widget.autoUpdateCheck) _autoCheck();
_flow.onKicked = _showKickedDialog;
// Back from a session that ended in a kick: say so.
_stopKickWatch = effect(() {
final kick = SessionState.instance.coordinator.kickReason.value;
if (kick == null || kick.isEmpty) {
_lastKickShown = null;
return;
}
WidgetsBinding.instance.addPostFrameCallback((_) => _showKickedDialog(kick));
});
// Back from a session whose hosted server crashed: say so on the Host tab.
_stopCrashWatch = effect(() {
final crash = SessionState.instance.serverCrash.value;
if (crash == null) return;
WidgetsBinding.instance.addPostFrameCallback((_) {
SessionState.instance.serverCrash.value = null;
if (!mounted) return;
_flow.showError(ConnectMode.host, CardError('Server crashed', crash));
setState(() => _view = HomeView.host);
});
});
}
@override
void dispose() {
_stopKickWatch();
_stopCrashWatch();
_flow.dispose();
_join.dispose();
_host.dispose();
super.dispose();
}
Future<void> _autoCheck() async {
final s = AppSettings.instance;
await s.ensureLoaded();
if (s.autoCheckUpdates.peek()) await runUpdateCheck(widget.updates);
}
Future<void> _showKickedDialog(String reason) async {
if (!mounted || _dialogOpen || reason == _lastKickShown) return;
_lastKickShown = reason;
_dialogOpen = true;
try {
await showDialog<void>(
context: context,
builder: (context) => AlertDialog(
title: const Text('You were removed'),
content: Text(reason.isEmpty ? 'The host removed you from the server.' : reason),
actions: [TextButton(onPressed: () => Navigator.pop(context), child: const Text('OK'))],
),
);
} finally {
_dialogOpen = false;
}
}
void _select(HomeView v) => setState(() => _view = v);
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
// Only the visible view is mounted: the Join tab polls /health only
// while shown. Form state lives here, so nothing is lost on switch.
final body = switch (_view) {
HomeView.join =>
JoinTab(flow: _flow, fields: _join, healthGet: widget.healthGet),
HomeView.host => HostTab(
flow: _flow,
fields: _host,
onOpenIntegrations: () => _select(HomeView.integrations),
),
HomeView.settings => SettingsPage(updates: widget.updates),
HomeView.integrations => IntegrationsPage(jellyfin: _jellyfin),
HomeView.about => AboutPage(updates: widget.updates),
};
return CallbackShortcuts(
bindings: {
const SingleActivator(LogicalKeyboardKey.digit1, control: true): () =>
_select(HomeView.join),
const SingleActivator(LogicalKeyboardKey.digit2, control: true): () =>
_select(HomeView.host),
const SingleActivator(LogicalKeyboardKey.comma, control: true): () =>
_select(HomeView.settings),
},
child: FocusScope(
autofocus: true,
child: Column(
children: [
HomeTopBar(
view: _view,
onSelect: _select,
updates: widget.updates,
windowControls: widget.windowControls,
),
Divider(height: 1, thickness: 1, color: scheme.outlineVariant.withValues(alpha: 0.4)),
Expanded(
child: KeyedSubtree(key: ValueKey(_view), child: body),
),
],
),
),
);
}
}
@@ -1,392 +0,0 @@
import 'package:flutter/material.dart';
import 'package:signals/signals_flutter.dart';
import 'package:sync_relay_core/core.dart' show HelloMedia;
import '../../jellyfin/accounts.dart';
import '../../jellyfin/session_media.dart';
import '../../state/session_state.dart';
import 'connect_flow.dart';
import 'connect_status.dart';
import 'media_picker.dart';
import 'ui.dart';
/// Host form state, owned by the Home shell so it survives tab switches.
class HostFields {
HostFields(ConnectForm f, {JellyfinAccounts? jellyfin})
: jellyfin = jellyfin ?? JellyfinAccounts.instance,
port = TextEditingController(text: f.hostPort),
secret = TextEditingController(text: f.hostSecret),
media = MediaPickerController(mediaPath: f.mediaPath, linkUrl: f.linkUrl) {
// The last Jellyfin pick lives in the Jellyfin box (the connect form
// only knows links and files); restore it once that box is read.
final noMedia = f.mediaPath.isEmpty && f.linkUrl.isEmpty;
this.jellyfin.ensureLoaded().then((_) {
final pick = this.jellyfin.lastPick;
if (_disposed || pick == null || media.jellyfin != null) return;
if (this.jellyfin.account(pick.serverId) == null) return;
if (noMedia) {
media.pickJellyfin(pick);
} else {
final source = media.source;
media.pickJellyfin(pick);
media.source = source;
}
});
}
final JellyfinAccounts jellyfin;
final TextEditingController port;
final TextEditingController secret;
final MediaPickerController media;
/// Jellyfin browser enlarged: the server column shrinks to a bar.
final browserExpanded = ValueNotifier(false);
bool _disposed = false;
void dispose() {
_disposed = true;
port.dispose();
secret.dispose();
media.dispose();
browserExpanded.dispose();
}
}
/// What hosting a picked Jellyfin item sends: its ref (no URL, no token).
/// Throws [FormatException] with a user-facing message when the server is
/// not linked any more or its sign-in expired.
HelloMedia hostJellyfinMedia(JellyfinAccounts accounts, JellyfinMedia media) {
if (accounts.account(media.serverId) == null) {
throw const FormatException('that Jellyfin server is not linked any more — pick again');
}
if (accounts.statusOf(media.serverId) == JellyfinAccountStatus.expired) {
throw const FormatException('Jellyfin sign-in expired — reconnect it in Integrations');
}
return mediaForPick(media);
}
/// Below this width the server column moves under the media picker.
const hostStackBelow = 860.0;
const _columnWidth = 340.0;
/// Media picker as the main area; port/password/Host in a narrow column.
class HostTab extends StatelessWidget {
final ConnectFlow flow;
final HostFields fields;
final VoidCallback? onOpenIntegrations;
const HostTab(
{super.key, required this.flow, required this.fields, this.onOpenIntegrations});
void _host() {
if (flow.busy != null) return;
final media = fields.media.selection;
HelloMedia? jellyfinMedia;
if (media is JellyfinMedia) {
try {
jellyfinMedia = hostJellyfinMedia(fields.jellyfin, media);
} on FormatException catch (e) {
flow.showError(ConnectMode.host, CardError('Check the form', e.message));
return;
}
fields.jellyfin.rememberPick(media);
}
flow.submit(
ConnectMode.host,
(f) => f
..hostPort = fields.port.text.trim()
..hostSecret = fields.secret.text
..mediaPath = media is FileMedia ? media.path : ''
..linkUrl = media is LinkMedia ? media.url : '',
media: jellyfinMedia,
);
}
@override
Widget build(BuildContext context) {
final t = Theme.of(context).textTheme;
return ListenableBuilder(
listenable: Listenable.merge([flow, fields.media, fields.browserExpanded]),
builder: (context, _) {
final busy = flow.busy == ConnectMode.host;
final jellyfin = fields.media.source == MediaKind.jellyfin;
final expanded = jellyfin && fields.browserExpanded.value;
Widget picker({required bool fill}) {
final p = MediaPicker(
controller: fields.media,
enabled: !busy,
autofocus: true,
onSubmit: _host,
onOpenIntegrations: onOpenIntegrations,
jellyfin: fields.jellyfin,
expanded: expanded,
onToggleExpanded: () => fields.browserExpanded.value = !fields.browserExpanded.value,
);
return Column(
key: const ValueKey('host-picker'),
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('What to watch', style: t.titleMedium),
const SizedBox(height: 12),
fill ? Expanded(child: p) : p,
],
);
}
final column = _ServerColumn(flow: flow, fields: fields, onHost: _host);
return LayoutBuilder(builder: (context, box) {
final stacked = box.maxWidth < hostStackBelow;
// Jellyfin browser: fills the height (lazy grids need a bounded
// viewport); the form sits beside it or, enlarged, in a bar below.
if (expanded) {
return Padding(
padding: kPagePadding,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(child: picker(fill: true)),
const SizedBox(height: 12),
_HostBar(flow: flow, fields: fields, onHost: _host),
],
),
);
}
if (jellyfin && !stacked) {
return Padding(
padding: kPagePadding,
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(child: picker(fill: true)),
const SizedBox(width: 24),
SizedBox(
width: _columnWidth,
child: SingleChildScrollView(child: column),
),
],
),
);
}
return SingleChildScrollView(
padding: kPagePadding,
child: stacked
? Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
picker(fill: false),
const SizedBox(height: 24),
Align(
alignment: Alignment.topLeft,
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: kFieldMaxWidth),
child: column,
),
),
],
)
: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(child: picker(fill: false)),
const SizedBox(width: 24),
SizedBox(width: _columnWidth, child: column),
],
),
);
});
},
);
}
}
class _ServerColumn extends StatelessWidget {
final ConnectFlow flow;
final HostFields fields;
final VoidCallback onHost;
const _ServerColumn({required this.flow, required this.fields, required this.onHost});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final t = theme.textTheme;
return Panel(
key: const ValueKey('host-column'),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Icon(Icons.cast, size: 20, color: theme.colorScheme.primary),
const SizedBox(width: 10),
Text('Your server', style: t.titleMedium),
],
),
const SizedBox(height: 4),
Text('Runs on this computer. Friends need this port reachable.',
style: t.bodySmall?.copyWith(color: Colors.grey)),
const SizedBox(height: kGap),
AbsorbPointer(
absorbing: flow.busy == ConnectMode.host,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
spacing: 12,
children: [
Align(
alignment: Alignment.centerLeft,
child: SizedBox(
width: 120,
child: TextField(
key: const ValueKey('host-port'),
controller: fields.port,
decoration: fieldDecoration('Port'),
keyboardType: TextInputType.number,
onChanged: (_) => flow.clearError(ConnectMode.host),
onSubmitted: (_) => onHost(),
),
),
),
ListenableBuilder(
listenable: fields.secret,
builder: (context, _) => TextField(
key: const ValueKey('host-password'),
controller: fields.secret,
obscureText: true,
decoration: fieldDecoration('Password').copyWith(
helperText:
fields.secret.text.isEmpty ? 'No password — anyone can join' : null,
),
onSubmitted: (_) => onHost(),
),
),
],
),
),
ListenableBuilder(
listenable: fields.media,
builder: (context, _) => Padding(
padding: const EdgeInsets.only(top: 12),
child: Text(mediaSummary(fields.media.selection),
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: t.bodySmall?.copyWith(color: Colors.grey)),
),
),
ConnectFeedback(flow: flow, mode: ConnectMode.host),
const SizedBox(height: kGap),
ConnectButton(
flow: flow, mode: ConnectMode.host, label: 'Host', onSubmit: onHost, expand: true),
_HostedRecents(fields: fields, enabled: flow.busy == null),
],
),
);
}
}
String mediaSummary(PickedMedia? s) =>
s == null ? 'No media selected (optional).' : 'Playing: ${s.label}';
/// Enlarged-browser mode: what is picked, port, password and Host in one
/// row under the browser.
class _HostBar extends StatelessWidget {
final ConnectFlow flow;
final HostFields fields;
final VoidCallback onHost;
const _HostBar({required this.flow, required this.fields, required this.onHost});
@override
Widget build(BuildContext context) {
final t = Theme.of(context).textTheme;
return Panel(
key: const ValueKey('host-bar'),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
Icon(Icons.cast, size: 20, color: Theme.of(context).colorScheme.primary),
const SizedBox(width: 12),
Expanded(
child: Text(mediaSummary(fields.media.selection),
maxLines: 1, overflow: TextOverflow.ellipsis, style: t.bodyMedium),
),
const SizedBox(width: 12),
AbsorbPointer(
absorbing: flow.busy == ConnectMode.host,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: 100,
child: TextField(
controller: fields.port,
decoration: fieldDecoration('Port'),
keyboardType: TextInputType.number,
onChanged: (_) => flow.clearError(ConnectMode.host),
onSubmitted: (_) => onHost(),
),
),
const SizedBox(width: 8),
SizedBox(
width: 200,
child: TextField(
controller: fields.secret,
obscureText: true,
decoration: fieldDecoration('Password', hint: 'none = open'),
onSubmitted: (_) => onHost(),
),
),
],
),
),
const SizedBox(width: 12),
ConnectButton(flow: flow, mode: ConnectMode.host, label: 'Host', onSubmit: onHost),
],
),
ConnectFeedback(flow: flow, mode: ConnectMode.host),
],
),
);
}
}
/// Ports hosted before: one click refills port and password.
class _HostedRecents extends StatelessWidget {
final HostFields fields;
final bool enabled;
const _HostedRecents({required this.fields, required this.enabled});
@override
Widget build(BuildContext context) {
return SignalBuilder(builder: (context) {
final list = [
for (final r in SessionState.instance.recents.value)
if (r.hosted) r
];
if (list.isEmpty) return const SizedBox.shrink();
return Padding(
padding: const EdgeInsets.only(top: kGap),
child: Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final r in list)
InputChip(
key: ValueKey('recent-${r.key}'),
avatar: Icon(r.secret.isEmpty ? Icons.history : Icons.lock_outline, size: 16),
label: Text('Port ${r.port}'),
tooltip: 'Use port ${r.port} again',
isEnabled: enabled,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
onPressed: () {
fields.port.text = r.port;
fields.secret.text = r.secret;
},
deleteButtonTooltipMessage: 'Forget',
onDeleted: () => SessionState.instance.forgetServer(r),
),
],
),
);
});
}
}
@@ -1,23 +0,0 @@
import 'package:flutter/material.dart';
import '../../jellyfin/accounts.dart';
import '../../jellyfin/accounts_panel.dart';
import 'ui.dart';
class IntegrationsPage extends StatelessWidget {
/// Defaults to [JellyfinAccounts.instance].
final JellyfinAccounts? jellyfin;
const IntegrationsPage({super.key, this.jellyfin});
@override
Widget build(BuildContext context) {
return PageScroll(
maxWidth: 820,
children: [
const PageTitle('Integrations',
subtitle: 'Connect media libraries to pick what to watch.'),
JellyfinAccountsPanel(accounts: jellyfin ?? JellyfinAccounts.instance),
],
);
}
}
@@ -1,388 +0,0 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:signals/signals_flutter.dart';
import 'package:sync_relay_core/core.dart';
import '../../state/session_state.dart';
import 'connect_flow.dart';
import 'connect_status.dart';
import 'health_probe.dart';
import 'ui.dart';
/// Join form text, owned by the Home shell so it survives tab switches.
class JoinFields {
JoinFields(ConnectForm f)
: host = TextEditingController(text: f.host),
port = TextEditingController(text: f.port),
secret = TextEditingController(text: f.secret);
final TextEditingController host;
final TextEditingController port;
final TextEditingController secret;
void dispose() {
host.dispose();
port.dispose();
secret.dispose();
}
}
/// Smallest recent-server tile; the grid adds columns as the window widens.
const _minTileWidth = 300.0;
/// One compact address/port/password row, then recent servers with live
/// status, as a grid that fills the width.
class JoinTab extends StatelessWidget {
final ConnectFlow flow;
final JoinFields fields;
final HealthGetter healthGet;
const JoinTab(
{super.key, required this.flow, required this.fields, required this.healthGet});
void _join() => flow.submit(ConnectMode.join, (f) {
f
..host = fields.host.text
..port = fields.port.text.trim()
..secret = fields.secret.text;
});
void _joinRecent(RecentServer r) {
fields.host.text = r.host;
fields.port.text = r.port;
fields.secret.text = r.secret;
_join();
}
@override
Widget build(BuildContext context) {
final t = Theme.of(context).textTheme;
return ListenableBuilder(
listenable: flow,
builder: (context, _) => SingleChildScrollView(
padding: kPagePadding,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('Join a friend’s server', style: t.titleMedium),
const SizedBox(height: 12),
AbsorbPointer(
absorbing: flow.busy == ConnectMode.join,
child: _joinRow(),
),
Align(
alignment: Alignment.centerLeft,
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: kFieldMaxWidth),
child: ConnectFeedback(flow: flow, mode: ConnectMode.join),
),
),
const SizedBox(height: 32),
Text('Recent servers', style: t.titleMedium),
const SizedBox(height: 12),
SignalBuilder(builder: (context) {
final list = [
for (final r in SessionState.instance.recents.value)
if (!r.hosted) r
];
return RecentServersGrid(
servers: list,
enabled: flow.busy == null,
healthGet: healthGet,
onJoin: _joinRecent,
onForget: SessionState.instance.forgetServer,
);
}),
],
),
),
);
}
Widget _joinRow() {
return Wrap(
key: const ValueKey('join-row'),
spacing: 12,
runSpacing: 12,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
Row(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: 280,
child: TextField(
key: const ValueKey('join-address'),
controller: fields.host,
decoration: fieldDecoration('Address', hint: 'IP or hostname'),
onChanged: (_) => flow.clearError(ConnectMode.join),
onSubmitted: (_) => _join(),
),
),
const Padding(
padding: EdgeInsets.symmetric(horizontal: 6),
child: Text(':', style: TextStyle(fontSize: 18, color: Colors.grey)),
),
SizedBox(
width: 96,
child: TextField(
key: const ValueKey('join-port'),
controller: fields.port,
decoration: fieldDecoration('Port'),
keyboardType: TextInputType.number,
onSubmitted: (_) => _join(),
),
),
],
),
SizedBox(
width: 220,
child: TextField(
key: const ValueKey('join-password'),
controller: fields.secret,
obscureText: true,
decoration: fieldDecoration('Password', hint: 'If the server has one'),
onSubmitted: (_) => _join(),
),
),
ConnectButton(flow: flow, mode: ConnectMode.join, label: 'Join', onSubmit: _join),
],
);
}
}
/// Recent servers as tiles; polls each one's `/health` every
/// [healthPollEvery] while mounted (i.e. while the Join tab is visible).
class RecentServersGrid extends StatefulWidget {
final List<RecentServer> servers;
final bool enabled;
final HealthGetter healthGet;
final ValueChanged<RecentServer> onJoin;
final ValueChanged<RecentServer> onForget;
const RecentServersGrid({
super.key,
required this.servers,
required this.enabled,
required this.healthGet,
required this.onJoin,
required this.onForget,
});
@override
State<RecentServersGrid> createState() => _RecentServersGridState();
}
class _RecentServersGridState extends State<RecentServersGrid> {
final _health = <String, ServerHealth>{};
final _inFlight = <String>{};
Timer? _timer;
@override
void initState() {
super.initState();
_pollAll();
_timer = Timer.periodic(healthPollEvery, (_) => _pollAll());
}
@override
void didUpdateWidget(RecentServersGrid old) {
super.didUpdateWidget(old);
for (final r in widget.servers) {
if (!_health.containsKey(r.key)) _poll(r);
}
}
@override
void dispose() {
_timer?.cancel();
super.dispose();
}
void _pollAll() {
for (final r in widget.servers) {
_poll(r);
}
}
Future<void> _poll(RecentServer r) async {
if (!_inFlight.add(r.key)) return;
try {
final h = await probeHealth(r.host, r.port, widget.healthGet);
if (mounted) setState(() => _health[r.key] = h);
} finally {
_inFlight.remove(r.key);
}
}
@override
Widget build(BuildContext context) {
if (widget.servers.isEmpty) return const _EmptyRecents();
return LayoutBuilder(builder: (context, box) {
final cols = ((box.maxWidth + kGap) / (_minTileWidth + kGap)).floor().clamp(1, 64);
final width = (box.maxWidth - kGap * (cols - 1)) / cols;
return Wrap(
spacing: kGap,
runSpacing: kGap,
children: [
for (final r in widget.servers)
SizedBox(
width: width,
child: _RecentTile(
key: ValueKey('recent-${r.key}'),
server: r,
health: _health[r.key],
enabled: widget.enabled,
onJoin: () => widget.onJoin(r),
onForget: () => widget.onForget(r),
),
),
],
);
});
}
}
class _RecentTile extends StatelessWidget {
final RecentServer server;
/// Null until the first probe answers.
final ServerHealth? health;
final bool enabled;
final VoidCallback onJoin;
final VoidCallback onForget;
const _RecentTile({
super.key,
required this.server,
required this.health,
required this.enabled,
required this.onJoin,
required this.onForget,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final scheme = theme.colorScheme;
final t = theme.textTheme;
final h = health;
final address = '${server.host}:${server.port}';
final title = h != null && h.name.isNotEmpty ? h.name : server.host;
final (status, statusColor) = switch (h) {
null => ('Checking…', Colors.grey),
ServerHealth(online: true) => (
'● online · ${h.viewers} watching',
Colors.greenAccent.shade400
),
_ => ('○ offline', Colors.grey),
};
return Material(
color: scheme.surfaceContainer,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(kRadius),
side: BorderSide(color: scheme.outlineVariant.withValues(alpha: 0.5)),
),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: enabled ? onJoin : null,
child: Tooltip(
message: 'Join $address',
waitDuration: const Duration(milliseconds: 600),
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 10, 6, 14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.dns_outlined, size: 20, color: scheme.primary),
const SizedBox(width: 10),
Expanded(
child: Text(title,
maxLines: 1, overflow: TextOverflow.ellipsis, style: t.titleMedium),
),
if (server.secret.isNotEmpty)
const Tooltip(
message: 'Password saved',
child: Icon(Icons.lock_outline, size: 16, color: Colors.grey),
),
IconButton(
tooltip: 'Forget',
visualDensity: VisualDensity.compact,
iconSize: 18,
onPressed: onForget,
icon: const Icon(Icons.close),
),
],
),
Padding(
padding: const EdgeInsets.only(left: 30, right: 10),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(address,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: t.bodySmall?.copyWith(color: Colors.grey)),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: Text(status,
key: const ValueKey('recent-status'),
style: t.bodyMedium?.copyWith(color: statusColor)),
),
if (h != null && h.needsUpdate)
Tooltip(
message: 'Server protocol ${h.proto}, this app '
'${RelayConstants.protoVersion} — update so both match',
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: scheme.errorContainer,
borderRadius: BorderRadius.circular(8),
),
child: Text('needs update',
style: t.labelSmall
?.copyWith(color: scheme.onErrorContainer)),
),
),
],
),
],
),
),
],
),
),
),
),
);
}
}
class _EmptyRecents extends StatelessWidget {
const _EmptyRecents();
@override
Widget build(BuildContext context) {
final t = Theme.of(context).textTheme;
return Panel(
key: const ValueKey('recents-empty'),
child: Row(
children: [
const Icon(Icons.history, size: 28, color: Colors.grey),
const SizedBox(width: kGap),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('No recent servers yet', style: t.titleSmall),
const SizedBox(height: 2),
Text('Servers you join show up here with live status; click one to rejoin.',
style: t.bodySmall?.copyWith(color: Colors.grey)),
],
),
),
],
),
);
}
}
@@ -1,34 +1,25 @@
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../../jellyfin/accounts.dart';
import '../../jellyfin/browse_scope.dart' show BrowseStart;
import '../../jellyfin/picker_source.dart';
import '../../jellyfin/browse_scope.dart' show JellyfinSelection;
import '../../widgets/media_input.dart';
import 'picked_media.dart';
import 'ui.dart';
export 'picked_media.dart';
const videoExtensions = ['mp4', 'mkv', 'webm', 'avi', 'mov', 'm4v', 'ts'];
/// The Library's sources.
enum MediaKind { jellyfin, link, file }
/// State of a [MediaPicker], owned by whoever hosts it (Home's Host tab,
/// later the session's swap panel) so it survives rebuilds.
/// State of the Library tab, owned by the shell so it survives rebuilds
/// and tab switches: the source shown, the link field and its relay mode,
/// the selected Jellyfin card (details panel) and the last Jellyfin pick.
class MediaPickerController extends ChangeNotifier {
MediaPickerController(
{String mediaPath = '', String linkUrl = '', JellyfinMedia? jellyfin, MediaKind? source})
: link = TextEditingController(text: linkUrl),
file = TextEditingController(text: mediaPath),
_jellyfin = jellyfin,
_source = source ??
(linkUrl.isNotEmpty
? MediaKind.link
: mediaPath.isEmpty && jellyfin != null
? MediaKind.jellyfin
: MediaKind.file) {
_selected = jellyfin == null ? null : JellyfinSelection(jellyfin.serverId, jellyfin.itemId),
_source = source ?? MediaKind.jellyfin {
link.addListener(notifyListeners);
file.addListener(notifyListeners);
}
@@ -36,18 +27,33 @@ class MediaPickerController extends ChangeNotifier {
final TextEditingController link;
final TextEditingController file;
MediaKind _source;
String? _notice;
LinkRelay _linkRelay = LinkRelay.direct;
JellyfinMedia? _jellyfin;
JellyfinSelection? _selected;
/// The Jellyfin item picked in the browser (kept while another source
/// is shown).
LinkRelay get linkRelay => _linkRelay;
set linkRelay(LinkRelay v) {
if (v == _linkRelay) return;
_linkRelay = v;
notifyListeners();
}
/// The last Jellyfin item picked (restored from the last put-up).
JellyfinMedia? get jellyfin => _jellyfin;
/// Picks a Jellyfin item and switches to the Jellyfin source.
/// Picks a Jellyfin item: selects its card and switches to Jellyfin.
void pickJellyfin(JellyfinMedia? media) {
_jellyfin = media;
_selected = media == null ? null : JellyfinSelection(media.serverId, media.itemId);
_source = MediaKind.jellyfin;
_notice = null;
notifyListeners();
}
/// The selected Jellyfin card (details panel open), or null.
JellyfinSelection? get selected => _selected;
void select(JellyfinSelection? s) {
if (s == _selected && s?.item == _selected?.item) return;
_selected = s;
notifyListeners();
}
@@ -55,23 +61,15 @@ class MediaPickerController extends ChangeNotifier {
set source(MediaKind v) {
if (v == _source) return;
_source = v;
_notice = null;
notifyListeners();
}
/// One-line hint under the fields (e.g. the file dialog failed).
String? get notice => _notice;
set notice(String? v) {
_notice = v;
notifyListeners();
}
/// What the visible source has selected; null = nothing.
/// What the Link or File source holds; for Jellyfin the last pick.
PickedMedia? get selection {
switch (_source) {
case MediaKind.link:
final url = link.text.trim();
return url.isEmpty ? null : LinkMedia(url);
return url.isEmpty ? null : LinkMedia(url, relay: _linkRelay);
case MediaKind.file:
final path = file.text.trim();
return path.isEmpty ? null : FileMedia(path);
@@ -99,258 +97,3 @@ class MediaPickerController extends ChangeNotifier {
super.dispose();
}
}
/// Source segments (Jellyfin / Link / File) and the matching input.
/// Ctrl+V anywhere in the picker outside a text field pastes a link or path.
class MediaPicker extends StatefulWidget {
final MediaPickerController controller;
final ValueChanged<PickedMedia?>? onChanged;
/// Enter in a field.
final VoidCallback? onSubmit;
final VoidCallback? onOpenIntegrations;
final bool enabled;
final bool autofocus;
/// Defaults to [JellyfinAccounts.instance].
final JellyfinAccounts? jellyfin;
/// Jellyfin source only: the browser's "bigger" toggle.
final VoidCallback? onToggleExpanded;
final bool expanded;
/// Where the Jellyfin browser opens instead of its remembered location.
final BrowseStart? jellyfinStart;
/// False hides the File source (only the host can serve a file).
final bool allowFile;
/// Shown under the Link field instead of the default help.
final String? linkHelp;
const MediaPicker({
super.key,
this.jellyfinStart,
this.allowFile = true,
this.linkHelp,
required this.controller,
this.onChanged,
this.onSubmit,
this.onOpenIntegrations,
this.enabled = true,
this.autofocus = false,
this.jellyfin,
this.onToggleExpanded,
this.expanded = false,
});
@override
State<MediaPicker> createState() => _MediaPickerState();
}
class _MediaPickerState extends State<MediaPicker> {
MediaPickerController get _c => widget.controller;
@override
void initState() {
super.initState();
_c.addListener(_changed);
}
@override
void didUpdateWidget(MediaPicker old) {
super.didUpdateWidget(old);
if (old.controller != widget.controller) {
old.controller.removeListener(_changed);
widget.controller.addListener(_changed);
}
}
@override
void dispose() {
_c.removeListener(_changed);
super.dispose();
}
void _changed() {
widget.onChanged?.call(_c.selection);
if (mounted) setState(() {});
}
Future<String?> _clipboard() async {
try {
return (await Clipboard.getData(Clipboard.kTextPlain))?.text?.trim();
} catch (_) {
return null;
}
}
Future<void> _pasteAnywhere() async {
final text = await _clipboard();
if (text != null && text.isNotEmpty) _c.pasteText(text);
}
Future<void> _pasteLink() async {
final text = await _clipboard();
if (text != null && text.isNotEmpty) _c.link.text = text;
}
Future<void> _browse() async {
try {
final files = await FilePicker.pickFiles(
dialogTitle: 'Pick media file',
type: FileType.custom,
allowedExtensions: videoExtensions,
);
final path = files.isEmpty ? null : files.first.path;
if (path != null) {
_c.file.text = path;
_c.notice = null;
}
} catch (e) {
_c.notice = 'File dialog unavailable (${e.runtimeType}) — paste the path instead';
}
}
KeyEventResult _onKey(FocusNode node, KeyEvent e) {
final ctrl = HardwareKeyboard.instance.isControlPressed ||
HardwareKeyboard.instance.isMetaPressed;
if (!widget.enabled || e is! KeyDownEvent || !ctrl || e.logicalKey != LogicalKeyboardKey.keyV) {
return KeyEventResult.ignored;
}
// A focused text field handles its own paste.
final inField = FocusManager.instance.primaryFocus?.context
?.findAncestorStateOfType<EditableTextState>() !=
null;
if (inField) return KeyEventResult.ignored;
_pasteAnywhere();
return KeyEventResult.handled;
}
@override
Widget build(BuildContext context) {
final source = _c.source;
final Widget content = switch (source) {
MediaKind.jellyfin => JellyfinPickerSource(
accounts: widget.jellyfin ?? JellyfinAccounts.instance,
selected: _c.jellyfin,
onPick: _c.pickJellyfin,
onOpenIntegrations: widget.onOpenIntegrations,
onToggleExpanded: widget.onToggleExpanded,
expanded: widget.expanded,
start: widget.jellyfinStart,
),
MediaKind.link => _FieldRow(
key: const ValueKey('media-link'),
field: TextField(
controller: _c.link,
autofocus: widget.autofocus,
decoration: fieldDecoration('Video link', hint: 'https://…/movie.mkv'),
keyboardType: TextInputType.url,
onSubmitted: (_) => widget.onSubmit?.call(),
),
button: OutlinedButton.icon(
onPressed: _pasteLink,
icon: const Icon(Icons.content_paste, size: 18),
label: const Text('Paste'),
),
help: widget.linkHelp ??
'A direct http(s) link to a video file; everyone streams it from there. '
'Jellyfin links are shared as the item, without your sign-in.',
notice: _c.notice,
),
MediaKind.file => _FieldRow(
key: const ValueKey('media-file'),
field: TextField(
controller: _c.file,
decoration: fieldDecoration('Video file', hint: 'Path to a video on this computer'),
onSubmitted: (_) => widget.onSubmit?.call(),
),
button: OutlinedButton.icon(
onPressed: _browse,
icon: const Icon(Icons.folder_open, size: 18),
label: const Text('Browse'),
),
help: 'Friends stream it from you while you watch. Optional — you can host without media.',
notice: _c.notice,
),
};
final segments = Align(
alignment: Alignment.centerLeft,
child: SegmentedButton<MediaKind>(
key: const ValueKey('media-source'),
showSelectedIcon: false,
segments: [
const ButtonSegment(
value: MediaKind.jellyfin,
icon: Icon(Icons.video_library_outlined),
label: Text('Jellyfin')),
const ButtonSegment(value: MediaKind.link, icon: Icon(Icons.link), label: Text('Link')),
const ButtonSegment(
value: MediaKind.file,
icon: Icon(Icons.insert_drive_file_outlined),
label: Text('File')),
].where((s) => widget.allowFile || s.value != MediaKind.file).toList(),
selected: {source},
onSelectionChanged: (s) => _c.source = s.first,
),
);
return Focus(
autofocus: widget.autofocus,
onKeyEvent: _onKey,
child: AbsorbPointer(
absorbing: !widget.enabled,
// The browser fills the height it is given; in an unbounded
// (scrolling) parent it gets a fixed one.
child: LayoutBuilder(builder: (context, box) {
final fill = source == MediaKind.jellyfin;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: fill && box.hasBoundedHeight ? MainAxisSize.max : MainAxisSize.min,
children: [
segments,
const SizedBox(height: kGap),
if (!fill)
content
else if (box.hasBoundedHeight)
Expanded(child: content)
else
SizedBox(height: jellyfinPickerHeight, child: content),
],
);
}),
),
);
}
}
/// Height of the Jellyfin browser when the picker sits in a scroll view.
const jellyfinPickerHeight = 640.0;
class _FieldRow extends StatelessWidget {
final Widget field;
final Widget button;
final String help;
final String? notice;
const _FieldRow(
{super.key, required this.field, required this.button, required this.help, this.notice});
@override
Widget build(BuildContext context) {
final small = Theme.of(context).textTheme.bodySmall?.copyWith(color: Colors.grey);
return Align(
alignment: Alignment.topLeft,
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: kFieldMaxWidth + 140),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
Expanded(child: field),
const SizedBox(width: 12),
button,
],
),
const SizedBox(height: 8),
Text(notice ?? help, style: small),
],
),
),
);
}
}
@@ -7,10 +7,16 @@ sealed class PickedMedia {
String get label;
}
/// A direct http(s) link everyone streams from.
/// How the others get a link: each fetches it ([direct]), the server
/// fetches it once for everyone ([server]), or this computer does and
/// passes it on through the server ([me]; for links only you can reach).
enum LinkRelay { direct, server, me }
/// An http(s) link.
final class LinkMedia extends PickedMedia {
final String url;
const LinkMedia(this.url);
final LinkRelay relay;
const LinkMedia(this.url, {this.relay = LinkRelay.direct});
@override
String get label {
@@ -25,14 +31,15 @@ final class LinkMedia extends PickedMedia {
}
@override
bool operator ==(Object other) => other is LinkMedia && other.url == url;
bool operator ==(Object other) => other is LinkMedia && other.url == url && other.relay == relay;
@override
int get hashCode => url.hashCode;
int get hashCode => Object.hash(url, relay);
@override
String toString() => 'LinkMedia($url)';
}
/// A file on this computer; friends stream it from the host.
/// A file on this computer; the others stream it from here (the host's
/// app serves it itself, anyone else's goes through the server's relay).
final class FileMedia extends PickedMedia {
final String path;
const FileMedia(this.path);
+87 -241
View File
@@ -1,285 +1,131 @@
import 'dart:async';
import 'dart:convert';
import 'dart:typed_data';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:signals/signals_flutter.dart';
import '../../state/connect_prefs.dart';
import '../../state/session_state.dart';
import '../../widgets/avatar_pipeline.dart';
import 'ui.dart';
/// Display name + avatar, shared by the Home profile row and Settings.
/// Backed by [SessionState.lastForm]; every change is persisted. The
/// avatar goes through [encodeAvatar] (the same shrink the server gets)
/// before its path is stored, and the encoded bytes are the preview.
/// Colours offered in the profile card; the first is the default.
const profileColors = <Color>[
Colors.deepPurple,
Colors.indigo,
Colors.teal,
Colors.pink,
Colors.orange,
Colors.blueGrey,
];
const _keyPicture = 'avatarPng';
const _keyColor = 'profileColor';
/// Sends a mid-session name/picture change (null = unchanged, empty
/// avatar = removed). Swappable so tests can watch it.
typedef ProfilePush = void Function({String? name, String? avatar});
/// The one place a profile change reaches the server.
void pushProfileToSession({String? name, String? avatar}) {
final session = SessionState.instance;
if (session.route.peek() != AppRoute.session) return;
session.coordinator.updateProfile(name: name, avatar: avatar);
}
/// Display name, picture and colour of this app's user. Backed by
/// [SessionState.lastForm] (name) and the connect-form box (the cropped
/// 256 px PNG and the colour); every change is persisted, and while in a
/// server it is pushed through [push] so everyone sees it at once.
class Profile {
static final Profile instance = Profile._();
Profile._();
final name = signal('');
final preview = signal<Uint8List?>(null);
/// The cropped PNG; null shows the initial on [color].
final picture = signal<Uint8List?>(null);
final color = signal<Color>(profileColors.first);
final error = signal<String?>(null);
final picking = signal(false);
ProfilePush push = pushProfileToSession;
bool _loaded = false;
Timer? _nameTimer;
void ensureLoaded() {
/// Reads the stored profile once (the app shell starts it). Old installs
/// stored a picture path: it is shrunk through [encodeAvatar] once and
/// kept as bytes. A caller during the first read does not wait for it
/// (no shared Future: tests run each in its own zone).
Future<void> ensureLoaded() async {
if (_loaded) return;
_loaded = true;
final f = SessionState.instance.lastForm;
name.value = f.name;
_refreshPreview(f.avatarPath);
await _load();
}
Future<void> _refreshPreview(String path) async {
Future<void> _load() async {
final form = SessionState.instance.lastForm;
name.value = form.name;
try {
final b64 = await encodeAvatar(path);
preview.value = b64.isEmpty ? null : base64Decode(b64);
} catch (_) {
preview.value = null;
final box = connectFormBoxIfOpen() ?? await openConnectFormBox();
final png = box.get(_keyPicture);
final c = box.get(_keyColor);
if (c is int) color.value = Color(c);
if (png is Uint8List && png.isNotEmpty) {
picture.value = png;
} else if (form.avatarPath.isNotEmpty) {
final b64 = await encodeAvatar(form.avatarPath);
if (b64.isNotEmpty) picture.value = base64Decode(b64);
form.avatarPath = '';
await _save();
}
} catch (e) {
// No store (tests) or an unreadable old picture: start without.
debugPrint('profile load: $e');
}
}
/// The picture as sent in hello and profile updates; '' when none.
String avatarBase64() {
final png = picture.peek();
return png == null ? '' : avatarWireBase64(png);
}
void setName(String value) {
name.value = value.trim();
SessionState.instance.lastForm.name = value.trim();
_save();
final v = value.trim();
if (v.isEmpty || v == name.peek()) return;
name.value = v;
SessionState.instance.lastForm.name = v;
unawaited(_save());
// Typing renames once it settles, not on every key.
_nameTimer?.cancel();
_nameTimer = Timer(const Duration(milliseconds: 600), () => push(name: name.peek()));
}
Future<void> pickAvatar() async {
if (picking.peek()) return;
picking.value = true;
Future<void> setPicture(Uint8List? png) async {
picture.value = png;
error.value = null;
await _save();
try {
final List<PlatformFile> files;
try {
files = await FilePicker.pickFiles(
dialogTitle: 'Pick avatar image',
type: FileType.custom,
allowedExtensions: const ['png', 'jpg', 'jpeg', 'webp'],
);
} catch (_) {
error.value = 'file dialog unavailable — try again';
return;
}
final path = files.isEmpty ? null : files.first.path;
if (path == null) return;
final String b64;
try {
b64 = await encodeAvatar(path);
} on FormatException catch (e) {
error.value = e.message;
return;
} catch (_) {
error.value = 'could not read that image — pick a PNG, JPG or WebP photo';
return;
}
SessionState.instance.lastForm.avatarPath = path;
preview.value = b64.isEmpty ? null : base64Decode(b64);
await _save();
} finally {
picking.value = false;
push(avatar: png == null ? '' : avatarWireBase64(png));
} on FormatException catch (e) {
error.value = e.message;
}
}
Future<void> clearAvatar() async {
SessionState.instance.lastForm.avatarPath = '';
preview.value = null;
error.value = null;
Future<void> setColor(Color c) async {
color.value = c;
await _save();
}
Future<void> _save() async {
try {
await SessionState.instance.saveForm();
final box = connectFormBoxIfOpen() ?? await openConnectFormBox();
final png = picture.peek();
await box.putAll({
_keyPicture: png ?? Uint8List(0),
_keyColor: color.peek().toARGB32(),
});
} catch (_) {
error.value = 'could not save profile — try again';
}
}
}
/// Avatar circle with the initial as fallback.
class ProfileAvatar extends StatelessWidget {
final double radius;
const ProfileAvatar({super.key, this.radius = 20});
@override
Widget build(BuildContext context) {
final p = Profile.instance;
return SignalBuilder(builder: (context) {
final img = p.preview.value;
final n = p.name.value;
return CircleAvatar(
radius: radius,
backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest,
backgroundImage: img != null ? MemoryImage(img) : null,
child: img != null
? null
: p.picking.value
? SizedBox(
width: radius * 0.8,
height: radius * 0.8,
child: const CircularProgressIndicator(strokeWidth: 2))
: Text(n.isEmpty ? '?' : n[0].toUpperCase(),
style: TextStyle(fontSize: radius * 0.8)),
);
});
}
}
/// Avatar (tap to change) + name field. Used inline in Settings and in
/// the Home profile dialog.
class ProfileEditor extends StatefulWidget {
final bool autofocus;
final VoidCallback? onSubmitted;
const ProfileEditor({super.key, this.autofocus = false, this.onSubmitted});
@override
State<ProfileEditor> createState() => _ProfileEditorState();
}
class _ProfileEditorState extends State<ProfileEditor> {
late final _name = TextEditingController(text: Profile.instance.name.peek());
@override
void dispose() {
_name.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final p = Profile.instance;
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Tooltip(
message: 'Change avatar',
child: InkWell(
customBorder: const CircleBorder(),
onTap: p.pickAvatar,
child: const ProfileAvatar(radius: 36),
),
),
const SizedBox(width: kGap),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: _name,
autofocus: widget.autofocus,
decoration: fieldDecoration('Display name'),
onChanged: p.setName,
onSubmitted: (_) => widget.onSubmitted?.call(),
),
const SizedBox(height: 4),
SignalBuilder(builder: (context) {
final err = p.error.value;
return Wrap(
crossAxisAlignment: WrapCrossAlignment.center,
children: [
TextButton(
onPressed: p.pickAvatar,
child: const Text('Change avatar')),
if (p.preview.value != null)
TextButton(
onPressed: p.clearAvatar,
child: const Text('Remove avatar')),
if (err != null)
Padding(
padding: const EdgeInsets.only(left: 8),
child: Text(err,
style: TextStyle(
fontSize: 12,
color: Theme.of(context).colorScheme.error)),
),
],
);
}),
],
),
),
],
);
}
}
Future<void> showProfileDialog(BuildContext context) {
return showDialog<void>(
context: context,
builder: (context) => AlertDialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(kRadius)),
title: const Text('Profile'),
content: SizedBox(
width: 380,
child: ProfileEditor(
autofocus: true,
onSubmitted: () => Navigator.pop(context),
),
),
actions: [
FilledButton(
onPressed: () => Navigator.pop(context), child: const Text('Done')),
],
),
);
}
/// Home's top-left: avatar + name, click to edit.
class ProfileChip extends StatelessWidget {
/// One line and a small avatar, for the Home top bar.
final bool dense;
/// Just the avatar, for very narrow windows.
final bool avatarOnly;
const ProfileChip({super.key, this.dense = false, this.avatarOnly = false});
@override
Widget build(BuildContext context) {
final p = Profile.instance;
return Tooltip(
message: 'Edit profile',
child: InkWell(
borderRadius: BorderRadius.circular(kRadius),
onTap: () => showProfileDialog(context),
child: avatarOnly
? const Padding(padding: EdgeInsets.all(4), child: ProfileAvatar(radius: 14))
: Padding(
padding: dense
? const EdgeInsets.fromLTRB(4, 4, 10, 4)
: const EdgeInsets.fromLTRB(6, 6, 14, 6),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
ProfileAvatar(radius: dense ? 14 : 20),
SizedBox(width: dense ? 8 : 12),
SignalBuilder(builder: (context) {
final n = p.name.value;
final t = Theme.of(context).textTheme;
final name = Text(n.isEmpty ? 'Set your name' : n,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: (dense ? t.titleSmall : t.titleMedium)
?.copyWith(color: n.isEmpty ? Colors.grey : null));
if (dense) {
return ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 200), child: name);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
name,
Text('Edit profile',
style: t.bodySmall?.copyWith(color: Colors.grey)),
],
);
}),
],
),
),
),
);
}
}
@@ -2,136 +2,90 @@ import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:signals/signals_flutter.dart';
import '../../playback/playback_controller.dart';
import '../../shaders/shader_presets.dart';
import '../../state/settings.dart';
import '../../update/update_controller.dart';
import 'profile.dart';
import 'ui.dart';
import 'update_indicator.dart';
/// In-session keys, as handled by the session screen's global key handler.
/// Session keys: Space and Tab anywhere in a server (`AppKeys`), the
/// rest by the session player's key handler (while it fills the window).
const sessionShortcuts = [
('Space', 'Toggle ready'),
('← / →', 'Seek back / forward 10 s'),
('↑ / ↓', 'Volume up / down (up to 150 %)'),
('Shift+→', 'Skip 1:30 (for everyone)'),
('PgUp / PgDn', 'Previous / next chapter'),
('F', 'Toggle fullscreen'),
('Tab', 'Open / close the people & chat panel'),
('Tab', 'Open / close chat & activity'),
('Ctrl+R', 'Reload the video'),
('> or Shift+N', 'Put up the next Jellyfin episode'),
('< or Shift+P', 'Put up the previous Jellyfin episode'),
('Ctrl+1…6', 'Shaders: Anime4K mode A, B, C, A+A, B+B, C+A'),
('Ctrl+0', 'Shaders off'),
('Esc', 'Close panel or fullscreen, else ask to leave'),
('Esc', 'Close the panel or fullscreen, shrink the player, then ask to leave'),
];
class SettingsPage extends StatelessWidget {
final UpdateController? updates;
const SettingsPage({super.key, this.updates});
@override
Widget build(BuildContext context) {
return PageScroll(
maxWidth: 680,
children: [
const PageTitle('Settings'),
const _Section(title: 'Profile', child: ProfileEditor()),
const _Section(
title: 'Player',
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [_HwdecSetting(), SizedBox(height: 12), _MpvControlsSetting()],
),
),
const _Section(title: 'Jellyfin', child: _AutoAdvanceSetting()),
_Section(title: 'Updates', child: _UpdateSettings(controller: updates)),
const _Section(title: 'Keyboard shortcuts', child: _ShortcutList()),
const _Section(title: 'Video quality & shaders', child: _ShaderSettings()),
],
);
}
}
class _Section extends StatelessWidget {
/// One titled card of the Settings tab (rounded Material, so list tiles
/// paint their ripple on it).
class SettingsPanel extends StatelessWidget {
final String title;
final Widget child;
const _Section({required this.title, required this.child});
const SettingsPanel({super.key, required this.title, required this.child});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: kGap),
child: Panel(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [SectionLabel(title), child],
child: Material(
color: Theme.of(context).colorScheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(16),
clipBehavior: Clip.antiAlias,
child: Padding(
padding: const EdgeInsets.fromLTRB(18, 14, 18, 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(title, style: const TextStyle(fontWeight: FontWeight.w700)),
const SizedBox(height: 6),
child,
],
),
),
),
);
}
}
class _HwdecSetting extends StatefulWidget {
const _HwdecSetting();
@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();
}
/// A tinted hint card at the top of a Settings page.
class SettingsHintCard extends StatelessWidget {
final IconData icon;
final String text;
const SettingsHintCard({super.key, required this.icon, required this.text});
@override
Widget build(BuildContext context) {
return SignalBuilder(builder: (context) {
final mode = _s.hwdecMode.value;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
final scheme = Theme.of(context).colorScheme;
return Container(
margin: const EdgeInsets.only(bottom: kGap),
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: scheme.primary.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(16),
),
child: Row(
children: [
const Text('Hardware decoding'),
const SizedBox(height: 4),
const Text(
'Applies on the next join or Reload video (Ctrl+R). Turn it off if video '
'shows glitches or a black picture.',
style: TextStyle(fontSize: 12, color: Colors.grey),
),
const SizedBox(height: 12),
SegmentedButton<HwdecMode>(
showSelectedIcon: false,
segments: const [
ButtonSegment(value: HwdecMode.auto, label: Text('Auto')),
ButtonSegment(value: HwdecMode.off, label: Text('Off')),
ButtonSegment(value: HwdecMode.custom, label: Text('Custom')),
],
selected: {mode},
onSelectionChanged: (s) => _s.setHwdecMode(s.first),
),
if (mode == HwdecMode.custom)
Padding(
padding: const EdgeInsets.only(top: 12),
child: TextField(
controller: _custom,
decoration: fieldDecoration('mpv hwdec value',
hint: 'e.g. vaapi, nvdec, d3d11va, auto-safe'),
onChanged: _s.setHwdecCustom,
),
),
Icon(icon, color: scheme.primary),
const SizedBox(width: 12),
Expanded(child: Text(text)),
],
);
});
),
);
}
}
class _ShaderSettings extends StatelessWidget {
const _ShaderSettings();
/// The .glsl files of the "Custom shaders" preset, in mpv order.
class CustomShaderFiles extends StatelessWidget {
const CustomShaderFiles({super.key});
Future<void> _addFiles() async {
final List<PlatformFile> files;
@@ -156,65 +110,18 @@ class _ShaderSettings extends StatelessWidget {
@override
Widget build(BuildContext context) {
final s = AppSettings.instance;
final muted = TextStyle(fontSize: 12, color: Theme.of(context).colorScheme.onSurfaceVariant);
return SignalBuilder(builder: (context) {
final preset = s.shaderPreset.value;
final quality = s.shaderQuality.value;
final custom = s.customShaders.value;
final mode = PlaybackController.lastRenderMode.value;
const hint = TextStyle(fontSize: 12, color: Colors.grey);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Anime4K upscales and sharpens anime on your GPU, for you only. '
'Mode A suits 1080p sources, B 720p, C 480p; A+A, B+B and C+A add '
'a second pass. HQ is for strong GPUs (GTX 1080 and up), Fast for '
'weaker ones. Costs GPU time every frame and only works with '
'hardware video rendering. In the player: the ✨ button or '
'Ctrl+1…6, Ctrl+0 for off; changes apply live and are saved here.',
style: hint,
),
if (mode == RenderMode.software || mode == RenderMode.softwareGl)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(
mode == RenderMode.software
? 'The last session used software rendering: shaders had no effect there.'
: 'The last session rendered OpenGL on the CPU: shaders will be slow.',
style: const TextStyle(fontSize: 12, color: Colors.orange),
),
),
const SizedBox(height: 12),
Row(
children: [
const Text('Shaders'),
const SizedBox(width: 16),
DropdownButton<ShaderPreset>(
value: preset,
onChanged: (p) {
if (p != null) s.setShaderPreset(p);
},
items: [
for (final p in ShaderPreset.values)
DropdownMenuItem(value: p, child: Text(p.label)),
],
Expanded(
child: Text('Your own mpv shaders for the "Custom shaders" choice; they run in this order.',
style: muted),
),
const Spacer(),
SegmentedButton<ShaderQuality>(
showSelectedIcon: false,
segments: [
for (final q in ShaderQuality.values)
ButtonSegment(value: q, label: Text(q.label)),
],
selected: {quality},
onSelectionChanged: (q) => s.setShaderQuality(q.first),
),
],
),
const SizedBox(height: 12),
Row(
children: [
const Expanded(child: Text('Custom shaders (run in this order)')),
TextButton.icon(
onPressed: _addFiles,
icon: const Icon(Icons.add, size: 18),
@@ -223,7 +130,7 @@ class _ShaderSettings extends StatelessWidget {
],
),
if (custom.isEmpty)
const Text('None picked.', style: hint)
Text('None picked.', style: muted)
else
for (final (i, path) in custom.indexed)
Row(
@@ -231,15 +138,13 @@ class _ShaderSettings extends StatelessWidget {
Expanded(
child: Tooltip(
message: path,
child: Text(path.split(RegExp(r'[/\\]')).last,
overflow: TextOverflow.ellipsis),
child: Text(path.split(RegExp(r'[/\\]')).last, overflow: TextOverflow.ellipsis),
),
),
IconButton(
tooltip: 'Remove',
icon: const Icon(Icons.close, size: 18),
onPressed: () =>
s.setCustomShaders([...custom]..removeAt(i)),
onPressed: () => s.setCustomShaders([...custom]..removeAt(i)),
),
],
),
@@ -249,9 +154,9 @@ class _ShaderSettings extends StatelessWidget {
}
}
class _UpdateSettings extends StatelessWidget {
class UpdateSettings extends StatelessWidget {
final UpdateController? controller;
const _UpdateSettings({this.controller});
const UpdateSettings({super.key, this.controller});
@override
Widget build(BuildContext context) {
@@ -290,8 +195,10 @@ class _UpdateSettings extends StatelessWidget {
const SizedBox(height: 8),
SignalBuilder(
builder: (context) => SwitchListTile(
key: const ValueKey('auto-check-updates'),
contentPadding: EdgeInsets.zero,
title: const Text('Check automatically on start'),
title: const Text('Check for updates on start'),
subtitle: const Text('Looks for a new version when the app opens; nothing installs without asking'),
value: s.autoCheckUpdates.value,
onChanged: s.setAutoCheckUpdates,
),
@@ -301,28 +208,8 @@ class _UpdateSettings extends StatelessWidget {
}
}
class _MpvControlsSetting extends StatelessWidget {
const _MpvControlsSetting();
@override
Widget build(BuildContext context) {
final s = AppSettings.instance;
return SignalBuilder(
builder: (context) => SwitchListTile(
key: const ValueKey('mpv-controls'),
contentPadding: EdgeInsets.zero,
title: const Text('Experimental: mpv-style controls'),
subtitle: const Text('Video controls modeled on mpv\'s uosc theme: a thick timeline with '
'chapters and the cached range, flat buttons and menus. Applies right away.'),
value: s.mpvControls.value,
onChanged: s.setMpvControls,
),
);
}
}
class _AutoAdvanceSetting extends StatelessWidget {
const _AutoAdvanceSetting();
class AutoAdvanceSetting extends StatelessWidget {
const AutoAdvanceSetting({super.key});
@override
Widget build(BuildContext context) {
@@ -332,8 +219,8 @@ class _AutoAdvanceSetting extends StatelessWidget {
key: const ValueKey('auto-advance'),
contentPadding: EdgeInsets.zero,
title: const Text('Play the next episode automatically'),
subtitle: const Text('When an episode ends, a 10 s countdown puts up the next one; '
'anyone can cancel it on their screen.'),
subtitle: const Text('When a Jellyfin episode ends, a 10 s countdown puts up the next one; '
'anyone can cancel it'),
value: s.autoAdvance.value,
onChanged: s.setAutoAdvance,
),
@@ -341,8 +228,28 @@ class _AutoAdvanceSetting extends StatelessWidget {
}
}
class _ShortcutList extends StatelessWidget {
const _ShortcutList();
class HideReReleasesSetting extends StatelessWidget {
const HideReReleasesSetting({super.key});
@override
Widget build(BuildContext context) {
final s = AppSettings.instance;
return SignalBuilder(
builder: (context) => SwitchListTile(
key: const ValueKey('hide-re-releases'),
contentPadding: EdgeInsets.zero,
title: const Text('Hide re-releases in Latest'),
subtitle: const Text('Skips episodes added more than 30 days after they aired '
'(re-uploads, upgrades); also in each Latest row\'s ⋮ menu'),
value: s.hideReReleases.value,
onChanged: s.setHideReReleases,
),
);
}
}
class ShortcutList extends StatelessWidget {
const ShortcutList({super.key});
@override
Widget build(BuildContext context) {
@@ -1,151 +0,0 @@
import 'package:flutter/material.dart';
import 'package:window_manager/window_manager.dart';
import '../../update/update_controller.dart';
import '../../widgets/window_buttons.dart';
import 'profile.dart';
import 'update_indicator.dart';
/// What the Home body shows: the two tabs, or one of the side pages.
enum HomeView { join, host, settings, integrations, about }
const homeTopBarHeight = 48.0;
/// Below this width the bar drops the profile name and tab icons.
const _compactBelow = 760.0;
/// Home's single top bar: profile, Join/Host tabs, update pill, page
/// buttons and (in the real window) the drag area and window buttons.
class HomeTopBar extends StatelessWidget {
final HomeView view;
final ValueChanged<HomeView> onSelect;
final UpdateController? updates;
/// Off in tests: the drag area and window buttons need window_manager.
final bool windowControls;
const HomeTopBar({
super.key,
required this.view,
required this.onSelect,
this.updates,
this.windowControls = false,
});
static const _pages = [
(HomeView.settings, Icons.settings_outlined, Icons.settings, 'Settings'),
(HomeView.integrations, Icons.extension_outlined, Icons.extension, 'Integrations'),
(HomeView.about, Icons.info_outline, Icons.info, 'About'),
];
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, box) => _bar(context, compact: box.maxWidth < _compactBelow));
}
Widget _bar(BuildContext context, {required bool compact}) {
final scheme = Theme.of(context).colorScheme;
final row = Row(
children: [
const SizedBox(width: 12),
ProfileChip(dense: true, avatarOnly: compact),
SizedBox(width: compact ? 8 : 20),
_Tab(
key: const ValueKey('tab-join'),
label: 'Join',
icon: compact ? null : Icons.login,
selected: view == HomeView.join,
onTap: () => onSelect(HomeView.join),
),
_Tab(
key: const ValueKey('tab-host'),
label: 'Host',
icon: compact ? null : Icons.cast,
selected: view == HomeView.host,
onTap: () => onSelect(HomeView.host),
),
const Spacer(),
UpdateIndicator(controller: updates),
const SizedBox(width: 8),
for (final (v, icon, selectedIcon, label) in _pages)
IconButton(
tooltip: label,
isSelected: view == v,
icon: Icon(icon, size: 20),
selectedIcon: Icon(selectedIcon, size: 20, color: scheme.primary),
onPressed: () => onSelect(v),
),
if (windowControls) ...[
const SizedBox(width: 4),
SizedBox(
height: 20,
child: VerticalDivider(width: 12, color: scheme.outlineVariant),
),
const WindowButtons(),
] else
const SizedBox(width: 8),
],
);
final bar = SizedBox(height: homeTopBarHeight, child: row);
return ColoredBox(
color: scheme.surfaceContainerLowest,
// Empty bar space drags the window; the controls on top take their
// own clicks without waiting out the drag area's double-tap.
child: windowControls
? Stack(children: [
const Positioned.fill(child: DragToMoveArea(child: SizedBox.expand())),
bar,
])
: bar,
);
}
}
class _Tab extends StatelessWidget {
final String label;
final IconData? icon;
final bool selected;
final VoidCallback onTap;
const _Tab(
{super.key,
required this.label,
this.icon,
required this.selected,
required this.onTap});
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final color = selected ? scheme.primary : scheme.onSurfaceVariant;
return Semantics(
selected: selected,
button: true,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(8),
child: Container(
height: homeTopBarHeight,
padding: const EdgeInsets.symmetric(horizontal: 16),
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
color: selected ? scheme.primary : Colors.transparent, width: 2),
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (icon != null) ...[
Icon(icon, size: 18, color: color),
const SizedBox(width: 8),
],
Text(label,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: color, fontWeight: selected ? FontWeight.w600 : null)),
],
),
),
),
);
}
}
+3 -56
View File
@@ -1,15 +1,14 @@
import 'package:flutter/material.dart';
/// Product name (working title). Change it here only.
const appName = 'Watch Together';
const appName = 'Starsync';
const repoUrl = 'https://git.senkochan.moe/Foxyz/sync_replay_project';
const kRadius = 12.0;
const kGap = 16.0;
const kPagePadding = EdgeInsets.fromLTRB(28, 20, 28, 28);
/// Rounded panel used by every Home/Settings section.
/// Rounded panel used by every Settings section.
class Panel extends StatelessWidget {
final Widget child;
final EdgeInsetsGeometry padding;
@@ -38,33 +37,6 @@ class Panel extends StatelessWidget {
}
}
/// Page heading + optional subtitle, same spacing everywhere.
class PageTitle extends StatelessWidget {
final String title;
final String? subtitle;
const PageTitle(this.title, {super.key, this.subtitle});
@override
Widget build(BuildContext context) {
final t = Theme.of(context).textTheme;
return Padding(
padding: const EdgeInsets.only(bottom: kGap),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: t.headlineSmall),
if (subtitle != null)
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(subtitle!,
style: t.bodyMedium?.copyWith(color: Colors.grey)),
),
],
),
);
}
}
/// Small grey heading inside a panel.
class SectionLabel extends StatelessWidget {
final String text;
@@ -87,7 +59,7 @@ class SectionLabel extends StatelessWidget {
}
}
/// Text field style shared by the connect cards.
/// Outlined text field style (settings, Jellyfin sign-in).
InputDecoration fieldDecoration(String label, {String? hint, Widget? suffix}) =>
InputDecoration(
labelText: label,
@@ -97,31 +69,6 @@ InputDecoration fieldDecoration(String label, {String? hint, Widget? suffix}) =>
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
);
/// Single-item scrollable page body, width-capped so it doesn't sprawl
/// on wide windows.
class PageScroll extends StatelessWidget {
final List<Widget> children;
final double maxWidth;
const PageScroll({super.key, required this.children, this.maxWidth = 920});
@override
Widget build(BuildContext context) {
return SingleChildScrollView(
padding: kPagePadding,
child: Align(
alignment: Alignment.topLeft,
child: ConstrainedBox(
constraints: BoxConstraints(maxWidth: maxWidth),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: children,
),
),
),
);
}
}
/// Widest a single text field gets, however wide the window.
const kFieldMaxWidth = 720.0;
@@ -4,7 +4,7 @@ import '../../update/update_controller.dart';
import '../../widgets/update_banner.dart';
import 'ui.dart';
/// Compact "Update vX" pill for Home's top-right; hidden until there is
/// Compact "Update vX" pill for the party strip; hidden until there is
/// something to install. Click opens the full [UpdateBanner].
class UpdateIndicator extends StatelessWidget {
final UpdateController? controller;
@@ -4,7 +4,7 @@ import '../../jellyfin/session_media.dart';
import '../../session/session_coordinator.dart' show AutoAdvance, autoAdvanceSecs;
import 'player_controls.dart' show FloatingPill;
// Jellyfin pieces of the session screen. Plain widgets fed by the
// Jellyfin pieces of the session player. Plain widgets fed by the
// coordinator's signals, so tests can pump them without the player.
/// Previous / next episode in the video controls. Nothing when there is
@@ -144,7 +144,9 @@ class JellyfinBlockedCard extends StatelessWidget {
const SizedBox(height: 10),
Text(blocked.message, textAlign: TextAlign.center, style: t.titleSmall),
const SizedBox(height: 6),
Text('Link your own account in Integrations, or ask the host to relay it.',
Text(
'Whoever put it up passes it on automatically while their app is open; '
'if they left, ask them to put it up again. Or link your own account in Integrations.',
textAlign: TextAlign.center, style: t.bodySmall?.copyWith(color: Colors.grey)),
const SizedBox(height: 14),
Wrap(
@@ -1,136 +0,0 @@
import 'package:flutter/material.dart';
import '../../jellyfin/accounts.dart';
import '../../jellyfin/browse_scope.dart' show BrowseStart;
import '../home/media_picker.dart';
/// "Change video" in a session: the same [MediaPicker] as Home's Host tab
/// (Jellyfin browser, Link, File) and a button that puts the pick up for
/// everyone. File is offered to the host only (the file streams from the
/// host's disk). [onSwap] returns an error text, or null when sent.
class SwapPanel extends StatefulWidget {
final MediaPickerController controller;
final JellyfinAccounts accounts;
final bool isHost;
final BrowseStart? jellyfinStart;
final Future<String?> Function(PickedMedia media) onSwap;
final VoidCallback onClose;
final VoidCallback? onOpenIntegrations;
const SwapPanel({
super.key,
required this.controller,
required this.accounts,
required this.isHost,
required this.onSwap,
required this.onClose,
this.jellyfinStart,
this.onOpenIntegrations,
});
@override
State<SwapPanel> createState() => _SwapPanelState();
}
class _SwapPanelState extends State<SwapPanel> {
bool _busy = false;
String? _error;
@override
void initState() {
super.initState();
final c = widget.controller;
if (!widget.isHost && c.source == MediaKind.file) c.source = MediaKind.link;
}
Future<void> _swap() async {
final media = widget.controller.selection;
if (media == null || _busy) return;
setState(() {
_busy = true;
_error = null;
});
String? error;
try {
error = await widget.onSwap(media);
} catch (e) {
error = '$e';
}
if (!mounted) return;
setState(() {
_busy = false;
_error = error;
});
if (error == null) widget.onClose();
}
@override
Widget build(BuildContext context) {
final t = Theme.of(context).textTheme;
return Material(
key: const ValueKey('swap-panel'),
elevation: 12,
borderRadius: BorderRadius.circular(16),
clipBehavior: Clip.antiAlias,
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 12, 12, 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Expanded(child: Text('Change video', style: t.titleLarge)),
IconButton(
tooltip: 'Close (Esc)', icon: const Icon(Icons.close), onPressed: widget.onClose),
],
),
const SizedBox(height: 8),
Expanded(
child: Padding(
padding: const EdgeInsets.only(right: 8),
child: MediaPicker(
controller: widget.controller,
jellyfin: widget.accounts,
enabled: !_busy,
allowFile: widget.isHost,
jellyfinStart: widget.jellyfinStart,
onOpenIntegrations: widget.onOpenIntegrations,
onSubmit: _swap,
),
),
),
const SizedBox(height: 12),
ListenableBuilder(
listenable: widget.controller,
builder: (context, _) {
final sel = widget.controller.selection;
return Row(
children: [
Expanded(
child: Text(
_error ?? (sel == null ? 'Pick something to watch' : 'Put up: ${sel.label}'),
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: t.bodyMedium?.copyWith(
color: _error != null ? Theme.of(context).colorScheme.error : null),
),
),
const SizedBox(width: 12),
FilledButton.icon(
key: const ValueKey('swap-go'),
onPressed: sel == null || _busy ? null : _swap,
icon: _busy
? const SizedBox(
width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2))
: const Icon(Icons.swap_horiz),
label: const Text('Put it up for everyone'),
),
],
);
},
),
],
),
),
);
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,96 @@
import 'dart:async';
import 'dart:io';
import 'dart:typed_data';
import 'package:flutter/foundation.dart';
import 'package:sync_relay_core/core.dart';
import '../files/name_parser.dart';
import '../schedule/anilist.dart';
// Art for media this client swapped in (`art`, see PROTOCOL.md "Media
// art"): Jellyfin images with this install's account, else an AniList
// title search. Everything here fails quietly to "no art".
/// Poster and banner bytes; either may be null.
typedef ArtBytes = ({Uint8List? poster, Uint8List? banner});
/// Series poster (the item's own for movies) and series/item backdrop,
/// else its Banner image, scaled by Jellyfin to fit the caps.
Future<ArtBytes> jellyfinArt(JellyfinClient client, String itemId,
{Future<Uint8List?> Function(Uri url, Map<String, String> headers, int maxBytes) fetch = fetchImage}) async {
final item = await client.item(itemId).timeout(const Duration(seconds: 10));
final seriesPoster = item.seriesId != null && item.seriesPrimaryImageTag != null
? JellyfinImageRef(item.seriesId!, JellyfinImageType.primary, item.seriesPrimaryImageTag!)
: null;
final poster = seriesPoster ?? item.image(JellyfinImageType.primary);
var banner = item.image(JellyfinImageType.backdrop);
if (banner == null) {
var owner = item;
if (item.seriesId != null && item.ownImage(JellyfinImageType.banner) == null) {
try {
owner = await client.item(item.seriesId!).timeout(const Duration(seconds: 10));
} catch (_) {}
}
banner = owner.ownImage(JellyfinImageType.banner);
}
final headers = client.authHeaders;
final (p, b) = await (
poster == null
? Future<Uint8List?>.value()
: fetch(client.imageUrl(poster, maxHeight: 450, quality: 85), headers, RelayConstants.maxPosterBytes),
banner == null
? Future<Uint8List?>.value()
: fetch(client.imageUrl(banner, maxWidth: 1280, quality: 85), headers, RelayConstants.maxBannerBytes),
).wait;
return (poster: p, banner: b);
}
/// The series of release name [name] on AniList: cover and banner; null
/// when the name says nothing or AniList knows no match.
Future<ArtBytes?> aniListArt(AniListClient aniList, String name,
{Future<Uint8List?> Function(Uri url, Map<String, String> headers, int maxBytes) fetch = fetchImage}) async {
final series = parseReleaseName(name).series.trim();
// "1", "2026-10-09": nothing a show could be called.
if (!RegExp(r'\p{L}', unicode: true).hasMatch(series)) return null;
final match = await aniList.searchMedia(series);
if (match == null) return null;
Future<Uint8List?> get(String? url, int maxBytes) async {
final uri = url == null ? null : Uri.tryParse(url);
return uri == null ? null : fetch(uri, const {}, maxBytes);
}
final (cover, banner) = await (
get(match.cover, RelayConstants.maxPosterBytes),
get(match.banner, RelayConstants.maxBannerBytes),
).wait;
final poster = cover ?? (match.coverSmall == match.cover ? null : await get(match.coverSmall, RelayConstants.maxPosterBytes));
return (poster: poster, banner: banner);
}
/// GET [url]: the bytes when 200, at most [maxBytes] and a known image
/// type (see `avatarImageType`); else null.
Future<Uint8List?> fetchImage(Uri url, Map<String, String> headers, int maxBytes) async {
final client = HttpClient()..connectionTimeout = const Duration(seconds: 10);
try {
final req = await client.getUrl(url);
headers.forEach(req.headers.set);
final resp = await req.close().timeout(const Duration(seconds: 15));
if (resp.statusCode != 200 || resp.contentLength > maxBytes) {
await resp.drain<void>().catchError((_) {});
return null;
}
final builder = BytesBuilder(copy: false);
await for (final chunk in resp.timeout(const Duration(seconds: 15))) {
builder.add(chunk);
if (builder.length > maxBytes) return null;
}
final bytes = builder.takeBytes();
return avatarImageType(bytes) == null ? null : bytes;
} catch (e) {
debugPrint('art: fetch failed: $e');
return null;
} finally {
client.close(force: true);
}
}
@@ -0,0 +1,24 @@
/// What the player shows about the current media: a title, an episode line
/// and art. Art is by content hash (`state.media.art`), fetched by everyone
/// (guests included) from the server they dialled; '' when there is none.
class NowPlaying {
/// Series name for episodes, else the movie, file or link name.
final String title;
/// "S2 E4 · Episode title", "E12", or null (movies, links).
final String? episode;
/// Portrait cover (2:3) and wide banner; '' when none.
final String posterHash;
final String bannerHash;
const NowPlaying({required this.title, this.episode, this.posterHash = '', this.bannerHash = ''});
@override
bool operator ==(Object other) =>
other is NowPlaying &&
other.title == title &&
other.episode == episode &&
other.posterHash == posterHash &&
other.bannerHash == bannerHash;
@override
int get hashCode => Object.hash(title, episode, posterHash, bannerHash);
}
@@ -0,0 +1,56 @@
import '../files/name_parser.dart';
import '../jellyfin/session_media.dart';
import 'now_playing.dart';
// Title and episode line of `state.media` (pure; see [NowPlaying]).
// "S1E6" (pickFor) or "S01E02".
final _episodeLabel = RegExp(r'^S(\d{1,3})\s?E(\d{1,4})$', caseSensitive: false);
final _year = RegExp(r'^(?:19|20)\d{2}$');
final _ext = RegExp(r'\.(?=[a-z0-9]*[a-z])[a-z0-9]{2,4}$', caseSensitive: false);
/// What the player shows for [media] (`state.media`); null with nothing up.
NowPlaying? nowPlayingOf(Map<String, Object?>? media) {
if (media == null) return null;
final name = media['name'] is String ? media['name'] as String : '';
final art = media['art'] is Map ? media['art'] as Map : const {};
final poster = art['poster'] is String ? art['poster'] as String : '';
final banner = art['banner'] is String ? art['banner'] as String : '';
final ref = refOfStateMedia(media);
final (title, episode) = ref != null
? jellyfinTitle(ref.title.isNotEmpty ? ref.title : name, ref.subtitle)
: fileTitle(name);
return NowPlaying(title: title, episode: episode, posterHash: poster, bannerHash: banner);
}
/// Jellyfin: [name] is the item's, [subtitle] "Series · S1E6" (or
/// "Series - S01E02") for episodes, the year for movies. Episodes give the
/// series and "S1 E6 · Name"; anything else the name alone.
(String, String?) jellyfinTitle(String name, String? subtitle) {
final sub = subtitle?.trim() ?? '';
if (sub.isEmpty || _year.hasMatch(sub)) return (name, null);
// The label is after the last separator (or the whole subtitle).
final cut = RegExp(r' [·-] ').allMatches(sub).lastOrNull;
final m = _episodeLabel.firstMatch(cut == null ? sub : sub.substring(cut.end).trim());
final series = m == null ? sub : (cut == null ? '' : sub.substring(0, cut.start).trim());
final label = m == null ? null : 'S${int.parse(m.group(1)!)} E${int.parse(m.group(2)!)}';
if (series.isEmpty) return (name, label);
final episode = [?label, if (name.isNotEmpty) name].join(' · ');
return (series, episode.isEmpty ? null : episode);
}
/// Files and links: the release name's series and "S1 E3", "E25" or its
/// extra marker (NCOP1, OVA 2); the name minus extension when it says
/// nothing better.
(String, String?) fileTitle(String name) {
final p = parseReleaseName(name);
final series = p.series.trim();
if (series.isEmpty) return (name.replaceFirst(_ext, ''), null);
final String? episode;
if (p.episode != null) {
episode = p.season != null ? 'S${p.season} E${p.episode}' : 'E${p.episode}';
} else {
episode = p.extra;
}
return (series, episode);
}
File diff suppressed because it is too large. Load diff
+23 -44
View File
@@ -1,20 +1,25 @@
/// The app's sounds. Only events that carry news get one: no sound for
/// what the video itself makes obvious (start, pause, end, seeks).
enum SfxKind {
/// Someone readies up; pitch climbs with [SfxCue.readyIndex].
ready,
unready,
/// Someone marks everyone ready.
forceReady,
forceUnready,
forcePlay,
forceHold,
kick,
join,
leave,
stale,
/// Someone sat you out (played by the session, not from a feed action).
kick,
/// A new video or episode is put up.
swap,
seek,
pause,
resume,
chat,
/// Idle warning: auto-unready soon.
warn,
/// The connection to the server dropped / came back.
lost,
back,
/// Someone pokes you for attention; plays only on your end.
nudge,
}
class SfxCue {
@@ -23,42 +28,16 @@ class SfxCue {
const SfxCue(this.kind, {this.readyIndex = 0});
}
SfxKind? sfxForFeedAction(String action) {
switch (action) {
case 'ready':
return SfxKind.ready;
case 'unready':
return SfxKind.unready;
case 'force-ready':
return SfxKind.forceReady;
case 'force-unready':
return SfxKind.forceUnready;
case 'force-play':
return SfxKind.forcePlay;
case 'force-hold':
return SfxKind.forceHold;
case 'kick':
return SfxKind.kick;
case 'join':
return SfxKind.join;
case 'leave':
return SfxKind.leave;
case 'stale':
return SfxKind.stale;
case 'swap':
return SfxKind.swap;
case 'seek':
return SfxKind.seek;
case 'pause':
return SfxKind.pause;
case 'resume':
return SfxKind.resume;
case 'chat':
return SfxKind.chat;
default:
return null;
}
}
SfxKind? sfxForFeedAction(String action) => switch (action) {
'ready' => SfxKind.ready,
'unready' => SfxKind.unready,
'force-ready' => SfxKind.forceReady,
'join' => SfxKind.join,
'leave' => SfxKind.leave,
'swap' => SfxKind.swap,
'chat' => SfxKind.chat,
_ => null,
};
abstract class SfxService {
Future<void> play(SfxCue cue);
+268
View File
@@ -0,0 +1,268 @@
import 'sfx.dart';
import 'synth.dart';
// The app's sounds come in presets; each action can take its sound from any
// of them. Every preset is quiet, short and on D, so sounds from different
// presets still agree when they overlap.
// - Cosmic: star bells, stardust, bubbles, soft pads, a space tail.
// - Abyss: deep space and its dread. Far beacons, nebula washes, slow
// beating clusters and tritone sags, on D phrygian, with a vast tail.
// - Wood & glass: plucked strings, glass bells, wood knocks, a short room.
enum SoundPreset { cosmic, abyss, woodAndGlass }
/// Shown in Settings: name and feel.
const presetInfo = {
SoundPreset.cosmic: ('Cosmic', 'Star bells, stardust and a soft space tail'),
SoundPreset.abyss: ('Abyss', 'Deep space: far beacons, nebula, a vast dark tail'),
SoundPreset.woodAndGlass: ('Wood & glass', 'Plucked strings, glass bells and wood, close and dry'),
};
/// The preset used when nothing else is chosen.
const defaultPreset = SoundPreset.abyss;
const _d3 = -19, _ab3 = -13;
const _d4 = -7, _eb4 = -6, _ab4 = -1, _a4 = 0, _b4 = 2;
const _d5 = 5, _eb5 = 6, _e5 = 7, _f5 = 8, _fs5 = 9, _g5 = 10, _a5 = 12, _bb5 = 13, _b5 = 14;
const _c6 = 15, _cs6 = 16, _d6 = 17, _e6 = 19, _f6 = 20, _fs6 = 21, _g6 = 22, _a6 = 24, _b6 = 26;
double _p(int s) => semi(s);
/// The ready sound's answer climbs with each person ready.
const _cosmicLadder = [_a5, _b5, _d6, _e6, _fs6, _a6];
const _abyssLadder = [_f5, _g5, _a5, _c6, _d6, _f6];
const _woodLadder = [_g5, _a5, _b5, _d6, _e6, _g6];
SoundSpec readySound(int readyIndex, {SoundPreset preset = defaultPreset}) =>
soundFor(SfxKind.ready, readyIndex: readyIndex, preset: preset);
SoundSpec soundFor(SfxKind kind, {int readyIndex = 1, SoundPreset preset = defaultPreset}) {
final i = (readyIndex - 1).clamp(0, 5);
return switch (preset) {
SoundPreset.cosmic => _cosmic(kind, i),
SoundPreset.abyss => _abyss(kind, i),
SoundPreset.woodAndGlass => _wood(kind, i),
};
}
SoundSpec _cosmic(SfxKind kind, int i) => switch (kind) {
SfxKind.ready => SoundSpec([
Note(0, _p(_d4), 1.0, voice: Voice.pad, gain: 0.3),
Note(0, _p(_d5), 1.2, voice: Voice.star, gain: 0.8, bright: 0.4),
Note(0.09, _p(_cosmicLadder[i]), 1.5, voice: Voice.star, bright: 0.5),
], peakDb: -17, room: 0.3),
// The same bells, dimmed and stepping down.
SfxKind.unready => SoundSpec([
Note(0, _p(_fs5), 0.6, voice: Voice.star, gain: 0.8, bright: 0.15),
Note(0.1, _p(_d5), 0.8, voice: Voice.star, bright: 0.1),
], cutoff: 3500, peakDb: -20, room: 0.25),
// Just the bell, left hanging in space: playback is the rest of the cue.
SfxKind.forceReady => SoundSpec([
Note(0, _p(_d6), 1.8, voice: Voice.star, bright: 0.7),
], cutoff: 7000, peakDb: -18, room: 0.35, tail: 1.6),
// A star arriving: dust rising into a bell.
SfxKind.join => SoundSpec([
Note(0, _p(_d6), 1.0, voice: Voice.dust, to: _p(_a6), gain: 0.5),
Note(0.15, _p(_a5), 1.4, voice: Voice.star, bright: 0.35, gain: 0.6),
], cutoff: 7000, peakDb: -19, room: 0.3),
// Dust drifting down, a low bell going out.
SfxKind.leave => SoundSpec([
Note(0, _p(_a6), 0.9, voice: Voice.dust, to: _p(_d6), gain: 0.5),
Note(0.1, _p(_d5), 1.3, voice: Voice.star, bright: 0.2, gain: 0.5),
], cutoff: 5000, peakDb: -22, room: 0.3),
// Falling into the dark: a deep tone sinking an octave under a sagging pad.
SfxKind.kick => SoundSpec([
Note(0, _p(_d4), 1.0, voice: Voice.sub, to: _p(_d3)),
Note(0, _p(_a4), 0.9, voice: Voice.pad, to: _p(_d4), gain: 0.4),
], cutoff: 3000, peakDb: -19, room: 0.25),
// Something new opens up: a rush of air, then a wide chord blooms.
SfxKind.swap => SoundSpec([
Note(0, 300, 0.6, voice: Voice.slide, to: 2400, gain: 0.35),
Note(0.35, _p(_d4), 1.4, voice: Voice.pad, gain: 0.5, bright: 0.3),
Note(0.35, _p(_a4), 1.4, voice: Voice.pad, gain: 0.5, bright: 0.3),
Note(0.35, _p(_e5), 1.4, voice: Voice.pad, gain: 0.4, bright: 0.3),
Note(0.38, _p(_fs6), 1.2, voice: Voice.star, bright: 0.3, gain: 0.3),
], peakDb: -18, room: 0.3),
// Two bubbles rising: bloop-bloop, the second higher.
SfxKind.chat => SoundSpec([
Note(0, _p(_a5), 0.18, voice: Voice.drop, gain: 0.8),
Note(0.08, _p(_e6), 0.22, voice: Voice.drop),
], peakDb: -19, room: 0.15, tail: 1.0),
// Something huge drifting past: a slow, deep swell.
SfxKind.warn => SoundSpec([
Note(0, _p(_d4), 2.0, voice: Voice.pad),
Note(0, _p(_a4), 2.0, voice: Voice.pad, gain: 0.5),
Note(0.3, _p(_d3), 1.7, voice: Voice.sub, gain: 0.4),
], cutoff: 3000, peakDb: -20, room: 0.2, tail: 1.0),
// The signal fading: a chord sagging down and dimming.
SfxKind.lost => SoundSpec([
Note(0, _p(_a4), 1.2, voice: Voice.pad, to: _p(_d4), gain: 0.7),
Note(0, _p(_e5), 1.2, voice: Voice.pad, to: _p(_a4), gain: 0.4),
], cutoff: 2500, peakDb: -19, room: 0.3, tail: 1.2),
// Re-acquired: a bell gliding up into tune, landing with dust.
SfxKind.back => SoundSpec([
Note(0, _p(_a4), 0.6, voice: Voice.star, to: _p(_d5), bright: 0.3, gain: 0.5),
Note(0.22, _p(_a5), 1.2, voice: Voice.star, bright: 0.5),
Note(0.25, _p(_d6), 0.6, voice: Voice.dust, to: _p(_a6), gain: 0.3),
], peakDb: -18, room: 0.25),
// A poke: two quick bright bubbles flicking up, a glint on the second.
// High and dry so it cuts through a video and stays a patter when spammed.
SfxKind.nudge => SoundSpec([
Note(0, _p(_d6), 0.12, voice: Voice.drop, to: _p(_fs6), gain: 0.8),
Note(0.09, _p(_a6), 0.16, voice: Voice.drop),
Note(0.09, _p(_cs6) * 2, 0.3, voice: Voice.star, bright: 0.6, gain: 0.2),
], cutoff: 7000, peakDb: -18, room: 0.05, tail: 0.6),
};
SoundSpec _abyss(SfxKind kind, int i) => switch (kind) {
// A beacon pinging far out, the void humming under it.
SfxKind.ready => SoundSpec([
Note(0, _p(_d3), 1.2, voice: Voice.pad, gain: 0.35),
Note(0, _p(_d5), 1.4, voice: Voice.star, gain: 0.8, bright: 0.25),
Note(0.12, _p(_abyssLadder[i]), 1.6, voice: Voice.star, bright: 0.3),
], cutoff: 5000, peakDb: -18, room: 0.4, tail: 1.8),
// The beacon answering itself a tritone down, dimmer.
SfxKind.unready => SoundSpec([
Note(0, _p(_a4), 0.7, voice: Voice.star, gain: 0.8, bright: 0.1),
Note(0.12, _p(_eb4), 0.9, voice: Voice.star, bright: 0.1),
], cutoff: 2500, peakDb: -21, room: 0.35, tail: 1.6),
// One bell, huge and very far away.
SfxKind.forceReady => SoundSpec([
Note(0, _p(_d5), 1.7, voice: Voice.star, bright: 0.5),
], cutoff: 5000, peakDb: -18, room: 0.45, tail: 1.8),
// Something emerging from the nebula: a dark wash brightening into a bell.
SfxKind.join => SoundSpec([
Note(0, 150, 1.2, voice: Voice.slide, to: 900, gain: 0.4),
Note(0.05, _p(_d4), 1.2, voice: Voice.pad, gain: 0.3),
Note(0.3, _p(_a5), 1.3, voice: Voice.star, bright: 0.3, gain: 0.5),
], cutoff: 4500, peakDb: -19, room: 0.4, tail: 1.6),
// Swallowed back into it: the wash darkening over a fading bell.
SfxKind.leave => SoundSpec([
Note(0, 900, 1.1, voice: Voice.slide, to: 150, gain: 0.4),
Note(0.1, _p(_d5), 1.2, voice: Voice.star, bright: 0.15, gain: 0.4),
], cutoff: 3500, peakDb: -22, room: 0.4, tail: 1.6),
// Pulled under: a deep fall with a tritone sagging over it.
SfxKind.kick => SoundSpec([
Note(0, _p(_d4), 1.3, voice: Voice.sub, to: _p(_d3)),
Note(0, _p(_ab4), 1.2, voice: Voice.pad, to: _p(_d4), gain: 0.35),
], cutoff: 2000, peakDb: -19, room: 0.35, tail: 1.6),
// A door into the dark: low air, then an unresolved chord opens.
SfxKind.swap => SoundSpec([
Note(0, 120, 0.8, voice: Voice.slide, to: 1200, gain: 0.35),
Note(0.45, _p(_d4), 1.3, voice: Voice.pad, gain: 0.5, bright: 0.2),
Note(0.45, _p(_a4), 1.3, voice: Voice.pad, gain: 0.45, bright: 0.2),
Note(0.45, _p(_eb5), 1.3, voice: Voice.pad, gain: 0.3, bright: 0.2),
Note(0.5, _p(_bb5), 1.1, voice: Voice.star, bright: 0.2, gain: 0.25),
], cutoff: 4000, peakDb: -19, room: 0.35, tail: 1.4),
// Bubbles rising from deep water.
SfxKind.chat => SoundSpec([
Note(0, _p(_d5), 0.25, voice: Voice.drop, gain: 0.8),
Note(0.1, _p(_a5), 0.3, voice: Voice.drop),
], cutoff: 4500, peakDb: -19, room: 0.3, tail: 1.4),
// Something vast drawing near: a slow swell of two notes rubbing.
SfxKind.warn => SoundSpec([
Note(0, _p(_d4), 2.0, voice: Voice.pad),
Note(0, _p(_eb4), 2.0, voice: Voice.pad, gain: 0.35),
Note(0.3, _p(_d3), 1.7, voice: Voice.sub, gain: 0.45),
], cutoff: 2200, peakDb: -20, room: 0.25, tail: 1.0),
// The signal drowning: a chord sagging by a tritone into static.
SfxKind.lost => SoundSpec([
Note(0, _p(_a4), 1.3, voice: Voice.pad, to: _p(_eb4), gain: 0.7),
Note(0, _p(_d4), 1.3, voice: Voice.pad, to: _p(_ab3), gain: 0.5),
Note(0.2, 600, 1.0, voice: Voice.slide, to: 200, gain: 0.2),
], cutoff: 2000, peakDb: -19, room: 0.35, tail: 1.4),
// Found again: a bell climbing out of the tritone into tune.
SfxKind.back => SoundSpec([
Note(0, _p(_ab4), 0.6, voice: Voice.star, to: _p(_d5), bright: 0.25, gain: 0.5),
Note(0.25, _p(_a5), 1.3, voice: Voice.star, bright: 0.4),
Note(0.28, _p(_d5), 0.6, voice: Voice.dust, to: _p(_a5), gain: 0.25),
], cutoff: 5000, peakDb: -18, room: 0.35, tail: 1.6),
// A poke from the dark: two quick bubbles flicking up, a pale glint.
// Still high and fairly dry so it cuts through a video when spammed.
SfxKind.nudge => SoundSpec([
Note(0, _p(_a5), 0.13, voice: Voice.drop, to: _p(_c6), gain: 0.8),
Note(0.1, _p(_d6), 0.18, voice: Voice.drop),
Note(0.1, _p(_a6), 0.35, voice: Voice.star, bright: 0.5, gain: 0.2),
], cutoff: 6000, peakDb: -18, room: 0.1, tail: 1.0),
};
SoundSpec _wood(SfxKind kind, int i) => switch (kind) {
SfxKind.ready => SoundSpec([
Note(0, _p(_d5), 0.9, gain: 0.75, bright: 0.55),
Note(0.085, _p(_woodLadder[i]), 1.1, bright: 0.65),
], peakDb: -17, room: 0.14, tail: 0.5),
// The same strings, muted and stepping down.
SfxKind.unready => SoundSpec([
Note(0, _p(_a5), 0.35, gain: 0.8, bright: 0.2),
Note(0.09, _p(_g5), 0.45, bright: 0.15),
], cutoff: 3800, peakDb: -20, room: 0.1, tail: 0.5),
// Just the bell: playback starting is the rest of the cue.
SfxKind.forceReady => SoundSpec([
Note(0, _p(_d6) * 2, 1.6, voice: Voice.chime),
], cutoff: 7000, peakDb: -18, room: 0.18, tail: 0.5),
// Wind through glass: three uneven tinkles.
SfxKind.join => SoundSpec([
Note(0, _p(_a6), 1.3, voice: Voice.chime, gain: 0.8),
Note(0.11, _p(_d6) * 2, 1.2, voice: Voice.chime, gain: 0.55),
Note(0.26, _p(_e6) * 2, 1.4, voice: Voice.chime, gain: 0.45),
], cutoff: 7000, peakDb: -19, room: 0.18, tail: 0.5),
// The wind settling: two softer, lower tinkles.
SfxKind.leave => SoundSpec([
Note(0, _p(_b6), 1.0, voice: Voice.chime, gain: 0.6),
Note(0.17, _p(_e6), 1.3, voice: Voice.chime, gain: 0.5),
], cutoff: 5000, peakDb: -22, room: 0.18, tail: 0.5),
// Two hardwood clacks, then a low muted string.
SfxKind.kick => SoundSpec([
Note(0, 1900, 0.09, voice: Voice.clack),
Note(0.16, 1750, 0.11, voice: Voice.clack, gain: 0.9),
Note(0.3, _p(_d4), 0.7, bright: 0.15, gain: 0.7),
], cutoff: 5000, peakDb: -19, room: 0.12, tail: 0.5),
// The door slides open on something new: air, then a fifth.
SfxKind.swap => SoundSpec([
Note(0, 500, 0.42, voice: Voice.slide, to: 1700, gain: 0.5),
Note(0.33, _p(_d5), 1.1, bright: 0.6, gain: 0.8),
Note(0.36, _p(_a5), 1.1, bright: 0.6, gain: 0.7),
], peakDb: -18, tail: 0.5),
// A hollow wooden tok-tok, the second higher.
SfxKind.chat => SoundSpec([
Note(0, _p(_g5), 0.13, voice: Voice.bamboo, gain: 0.85),
Note(0.075, _p(_d6), 0.16, voice: Voice.bamboo),
], cutoff: 5500, peakDb: -19, room: 0.1, tail: 0.5),
// A bowl: one soft strike that hums.
SfxKind.warn => SoundSpec([
Note(0, _p(_d5), 2.6, voice: Voice.rin),
], cutoff: 4500, peakDb: -20, room: 0.1, tail: 0.5),
// A string going slack: plucked, its pitch sagging away.
SfxKind.lost => SoundSpec([
Note(0, _p(_a5), 1.1, to: _p(_d5), bright: 0.4),
Note(0.05, _p(_d5), 1.0, to: _p(_a4), bright: 0.3, gain: 0.6),
], cutoff: 3500, peakDb: -19, tail: 0.5),
// Re-tuned: a quick bend up that lands, and a tinkle.
SfxKind.back => SoundSpec([
Note(0, _p(_b4), 0.9, to: _p(_d5), bright: 0.5),
Note(0.2, _p(_a5), 1.0, bright: 0.6, gain: 0.7),
Note(0.3, _p(_d6) * 2, 1.0, voice: Voice.chime, gain: 0.2),
], peakDb: -18, tail: 0.5),
// Two quick wooden taps, the second flicking up, with a glint.
SfxKind.nudge => SoundSpec([
Note(0, _p(_a5), 0.12, voice: Voice.bamboo, gain: 0.8),
Note(0.09, _p(_e6), 0.16, voice: Voice.bamboo),
Note(0.09, _p(_b6), 0.35, voice: Voice.chime, gain: 0.3),
], cutoff: 6500, peakDb: -18, room: 0.04, tail: 0.5),
};
/// Shown in Settings → Notifications & sounds: name and when it plays.
const soundInfo = {
SfxKind.ready: ('Ready', 'Someone presses Space; climbs with each ready person'),
SfxKind.unready: ('Not ready', 'Someone takes their ready back'),
SfxKind.forceReady: ('Everyone ready', 'Someone marks everyone ready'),
SfxKind.join: ('Joined', 'Someone joins the server'),
SfxKind.leave: ('Left', 'Someone leaves'),
SfxKind.kick: ('Sat out', 'Someone sits you out'),
SfxKind.swap: ('New video', 'A different video or episode is put up'),
SfxKind.chat: ('Message', 'A chat message from someone else'),
SfxKind.warn: ('Idle warning', 'Auto-unready soon unless you press Space'),
SfxKind.lost: ('Connection lost', 'The server stopped answering; reconnecting'),
SfxKind.back: ('Reconnected', 'Back in the server after a drop'),
SfxKind.nudge: ('Nudge', 'Someone nudges you; always plays (volume still applies)'),
};
+473
View File
@@ -0,0 +1,473 @@
import 'dart:math' as math;
import 'dart:typed_data';
// A tiny offline synthesizer for UI sounds. The current palette is
// spacious: FM star bells, bubble drops, slow pads, a sub that falls away,
// stardust and a long dark space tail. Older voices (plucked string, glass
// and bowl bells, wood) stay available. Renders to 16-bit mono WAV bytes.
// Pure Dart.
const sampleRate = 44100;
enum Voice {
/// Plucked string; [Note.to] bends it (the delay line stretches).
koto,
/// Small glass bell: inharmonic partials, a slow shimmer.
chime,
/// Singing bowl: a soft strike and a long beating hum.
rin,
/// Hollow bamboo knock, pitched.
bamboo,
/// Two hardwood blocks struck together.
clack,
/// Breathy air sweeping up (a sliding door); [Note.hz]→[Note.to] is the
/// sweep of its band.
slide,
/// FM bell: strikes glassy, settles to a pure hanging tone; [Note.bright]
/// scales the strike, [Note.to] glides it.
star,
/// A bubble: the pitch rises into the note fast; [Note.to] flicks it on.
drop,
/// Slow detuned swell (in over a third of its length, then out);
/// [Note.bright] adds overtones, [Note.to] glides it.
pad,
/// Deep round tone that falls from [Note.hz] to [Note.to].
sub,
/// A scatter of tiny pings on the scale above [Note.hz], drifting
/// towards [Note.to].
dust,
}
class Note {
final double at;
final double hz;
final double? to;
/// Length (s) to about -40 dB.
final double len;
final double gain;
final Voice voice;
/// Koto only: 0 = dark/muted pluck, 1 = bright.
final double bright;
const Note(this.at, this.hz, this.len,
{this.to, this.gain = 1, this.voice = Voice.koto, this.bright = 0.6});
}
class SoundSpec {
final List<Note> notes;
/// Gentle low-pass cutoff (Hz) over the mix.
final double cutoff;
/// Peak level after normalising, dBFS (quiet by design).
final double peakDb;
/// Space tail mix (0..1).
final double room;
/// Space tail decay to -60 dB (s); the sound keeps about half of it.
final double tail;
const SoundSpec(this.notes,
{this.cutoff = 6000, this.peakDb = -17, this.room = 0.14, this.tail = 1.4});
double get length =>
notes.fold<double>(0, (m, n) => math.max(m, n.at + n.len)) + (room > 0 ? tail * 0.55 : 0.02);
}
/// Semitones from A4 (440 Hz) to Hz.
double semi(num n) => 440 * math.pow(2, n / 12).toDouble();
Float64List render(SoundSpec spec, {int seed = 7}) {
final total = (spec.length * sampleRate).ceil();
final out = Float64List(total);
final rnd = math.Random(seed);
for (final n in spec.notes) {
final start = (n.at * sampleRate).round();
if (start >= total) continue;
final s = switch (n.voice) {
Voice.koto => _koto(n, rnd),
Voice.chime => _bell(n, const [(1.0, 1.0, 1.0), (2.76, 0.45, 1.9), (5.40, 0.2, 3.2), (8.93, 0.08, 4.5)], 1.6, 0.003),
Voice.rin => _bell(n, const [(1.0, 1.0, 1.0), (2.71, 0.35, 1.5), (5.15, 0.12, 2.4)], 2.4, 0.012),
Voice.bamboo => _bamboo(n, rnd),
Voice.clack => _clack(n, rnd),
Voice.slide => _slide(n, rnd),
Voice.star => _star(n),
Voice.drop => _drop(n),
Voice.pad => _pad(n, rnd),
Voice.sub => _sub(n),
Voice.dust => _dust(n, rnd),
};
for (var i = 0; i < s.length && start + i < total; i++) {
out[start + i] += n.gain * s[i];
}
}
_lowpass(out, spec.cutoff);
if (spec.room > 0) _space(out, spec.room, spec.tail);
// 1 ms in, 10 ms out: no clicks at either end.
final fadeIn = (0.001 * sampleRate).round();
for (var i = 0; i < fadeIn && i < total; i++) {
out[i] *= i / fadeIn;
}
final fade = (0.01 * sampleRate).round();
for (var i = 0; i < fade && i < total; i++) {
out[total - 1 - i] *= i / fade;
}
var peak = 0.0;
for (final v in out) {
peak = math.max(peak, v.abs());
}
if (peak > 0) {
final g = math.pow(10, spec.peakDb / 20) / peak;
for (var i = 0; i < total; i++) {
out[i] *= g;
}
}
return out;
}
int _n(double secs) => (secs * sampleRate).round();
double _smooth(double t) => t * t * (3 - 2 * t);
/// Karplus-Strong with a fractional, movable delay: a noise burst in a
/// loop that loses a little of its highs each pass.
Float64List _koto(Note n, math.Random rnd) {
final len = _n(n.len);
final out = Float64List(len);
final maxPeriod = sampleRate / math.min(n.hz, n.to ?? n.hz) + 4;
final buf = Float64List(maxPeriod.ceil() + 4);
final size = buf.length;
// Excitation: noise, smoothed more for a darker pluck.
final period0 = sampleRate / n.hz;
var prev = 0.0;
final smooth = 1 - (0.25 + 0.7 * n.bright);
for (var i = 0; i < period0.ceil(); i++) {
final x = rnd.nextDouble() * 2 - 1;
prev = smooth * prev + (1 - smooth) * x;
buf[i % size] = prev;
}
// Loop gain so the note reaches -40 dB at len.
final loss = math.pow(0.01, 1 / (n.len * n.hz)).toDouble();
var w = period0.ceil();
var last = 0.0;
for (var i = 0; i < len; i++) {
final t = i / len;
final hz = n.to == null ? n.hz : n.hz * math.pow(n.to! / n.hz, _smooth(math.min(1, t * 1.6)));
// The two-point average below delays half a sample: take it off.
final period = sampleRate / hz - 0.5;
final r = w - period;
final ri = r.floor();
final f = r - ri;
final a = buf[(ri % size + size) % size];
final b = buf[((ri + 1) % size + size) % size];
final y = a + (b - a) * f;
// Two-point average = the string's damping; the 0.5 mix keeps it bright.
final v = loss * (0.5 * y + 0.5 * last);
last = y;
buf[w % size] = v;
w++;
// A 3 ms attack takes the click off the pluck.
out[i] = v * (i < 132 ? i / 132 : 1);
}
return out;
}
/// Inharmonic partials (ratio, amplitude, decay multiplier), each doubled
/// with a slightly detuned twin so the tail shimmers.
Float64List _bell(Note n, List<(double, double, double)> partials, double beatHz, double attackSecs) {
final len = _n(n.len);
final out = Float64List(len);
final k = math.log(100) / len;
final attack = _n(attackSecs);
for (final (ratio, amp, decay) in partials) {
final f1 = n.hz * ratio, f2 = f1 + beatHz * ratio;
for (var i = 0; i < len; i++) {
final env = math.exp(-k * decay * i) * (i < attack ? _smooth(i / attack) : 1);
final ph = 2 * math.pi * i / sampleRate;
out[i] += amp * env * 0.5 * (math.sin(ph * f1) + math.sin(ph * f2));
}
}
return out;
}
/// A hollow tube: a pitched thump that drops into its note, an overtone
/// that dies fast, and a short click of air.
Float64List _bamboo(Note n, math.Random rnd) {
final len = _n(n.len);
final out = Float64List(len);
final k = math.log(100) / len;
var ph = 0.0, ph2 = 0.0;
for (var i = 0; i < len; i++) {
final drop = math.exp(-i / _n(0.012));
final hz = n.hz * (1 + 0.5 * drop);
ph += 2 * math.pi * hz / sampleRate;
ph2 += 2 * math.pi * hz * 2.31 / sampleRate;
out[i] = math.exp(-k * i) * (math.sin(ph) + 0.35 * math.sin(ph2) * math.exp(-k * 3 * i));
}
final click = _bandNoise(_n(0.006), n.hz * 3, 2.0, rnd);
for (var i = 0; i < click.length && i < len; i++) {
out[i] += 0.5 * click[i] * (1 - i / click.length);
}
return out;
}
/// Hardwood: two resonances excited by a very short noise burst.
Float64List _clack(Note n, math.Random rnd) {
final len = _n(n.len);
final out = Float64List(len);
final burst = Float64List(len);
for (var i = 0; i < _n(0.002) && i < len; i++) {
burst[i] = rnd.nextDouble() * 2 - 1;
}
for (final (f, q, g) in [(n.hz, 18.0, 1.0), (n.hz * 1.47, 14.0, 0.6), (n.hz * 2.6, 10.0, 0.25)]) {
final r = _biquadBand(burst, f, q);
for (var i = 0; i < len; i++) {
out[i] += g * r[i];
}
}
final k = math.log(100) / len;
for (var i = 0; i < len; i++) {
out[i] *= math.exp(-k * i);
}
return out;
}
/// Air through a band that sweeps from [Note.hz] to [Note.to], swelling in
/// and out (a paper door sliding along its track).
Float64List _slide(Note n, math.Random rnd) {
final len = _n(n.len);
final noise = Float64List(len);
for (var i = 0; i < len; i++) {
noise[i] = rnd.nextDouble() * 2 - 1;
}
final out = Float64List(len);
// Sweep in short blocks (a fixed band per block is plenty).
const block = 256;
var z1 = 0.0, z2 = 0.0;
for (var b0 = 0; b0 < len; b0 += block) {
final t = b0 / len;
final f = n.hz * math.pow((n.to ?? n.hz) / n.hz, t);
final c = _bandCoeffs(f.toDouble(), 2.5);
for (var i = b0; i < math.min(len, b0 + block); i++) {
final y = c.$1 * noise[i] + z1;
z1 = c.$2 * noise[i] - c.$4 * y + z2;
z2 = c.$3 * noise[i] - c.$5 * y;
final tt = i / len;
// Rises, holds, eases off: sin^2 swell with a tail.
final env = math.pow(math.sin(math.pi * math.min(1, tt * 1.25)), 2).toDouble();
// The track's grain: a slow flutter.
final grain = 1 + 0.25 * math.sin(2 * math.pi * 23 * i / sampleRate);
out[i] = y * env * grain;
}
}
return out;
}
Float64List _bandNoise(int len, double f, double q, math.Random rnd) {
final x = Float64List(len);
for (var i = 0; i < len; i++) {
x[i] = rnd.nextDouble() * 2 - 1;
}
return _biquadBand(x, f, q);
}
/// RBJ band-pass (constant 0 dB peak): (b0, b1, b2, a1, a2), b1 = 0.
(double, double, double, double, double) _bandCoeffs(double f, double q) {
final w = 2 * math.pi * math.min(f, sampleRate * 0.45) / sampleRate;
final alpha = math.sin(w) / (2 * q);
final a0 = 1 + alpha;
return (alpha / a0, 0, -alpha / a0, -2 * math.cos(w) / a0, (1 - alpha) / a0);
}
Float64List _biquadBand(Float64List x, double f, double q) {
final c = _bandCoeffs(f, q);
final y = Float64List(x.length);
var z1 = 0.0, z2 = 0.0;
for (var i = 0; i < x.length; i++) {
final v = c.$1 * x[i] + z1;
z1 = c.$2 * x[i] - c.$4 * v + z2;
z2 = c.$3 * x[i] - c.$5 * v;
y[i] = v;
}
return y;
}
/// One pole, twice: a soft 12 dB/oct roll-off.
void _lowpass(Float64List out, double cutoff) {
final a = math.exp(-2 * math.pi * cutoff / sampleRate);
for (var pass = 0; pass < 2; pass++) {
var y = 0.0;
for (var i = 0; i < out.length; i++) {
y = (1 - a) * out[i] + a * y;
out[i] = y;
}
}
}
/// Space: four damped feedback combs and two allpasses (a dark, smooth
/// tail decaying to -60 dB in [tail] s) after a short pre-delay.
void _space(Float64List out, double mix, double tail) {
const combs = [0.0297, 0.0371, 0.0411, 0.0437];
const damp = 0.35;
final len = out.length;
final pre = _n(0.018);
final dry = Float64List(len);
for (var i = pre; i < len; i++) {
dry[i] = out[i - pre];
}
final wet = Float64List(len);
for (final d in combs) {
final delay = _n(d);
final g = math.pow(0.001, d / tail).toDouble();
final y = Float64List(len);
var lp = 0.0;
for (var i = 0; i < len; i++) {
final back = i >= delay ? y[i - delay] : 0.0;
lp = back * (1 - damp) + lp * damp;
y[i] = dry[i] + g * lp;
wet[i] += y[i] / combs.length;
}
}
for (final d in const [0.005, 0.0017]) {
final delay = _n(d);
final x = Float64List.fromList(wet);
for (var i = 0; i < len; i++) {
final xd = i >= delay ? x[i - delay] : 0.0;
final yd = i >= delay ? wet[i - delay] : 0.0;
wet[i] = -0.5 * x[i] + xd + 0.5 * yd;
}
}
for (var i = 0; i < len; i++) {
out[i] += mix * wet[i];
}
}
/// [Note.hz] gliding to [Note.to] over the first ~60% of the note.
double _glide(Note n, double t) =>
n.to == null ? n.hz : n.hz * math.pow(n.to! / n.hz, _smooth(math.min(1, t * 1.6))).toDouble();
Float64List _star(Note n) {
final len = _n(n.len);
final out = Float64List(len);
final k = math.log(100) / len;
final attack = _n(0.004);
final index0 = 0.3 + 3.0 * n.bright;
final idxTau = _n(0.09 + 0.12 * n.bright);
const ratio = 3.5;
var c1 = 0.0, c2 = 0.0, m1 = 0.0, m2 = 0.0;
for (var i = 0; i < len; i++) {
final hz = _glide(n, i / len);
final w = 2 * math.pi * hz / sampleRate;
c1 += w;
c2 += w * 1.0017;
m1 += w * ratio;
m2 += w * ratio * 1.0017;
final idx = index0 * math.exp(-i / idxTau);
final env = math.exp(-k * i) * (i < attack ? _smooth(i / attack) : 1);
out[i] = env * 0.5 * (math.sin(c1 + idx * math.sin(m1)) + math.sin(c2 + idx * math.sin(m2)));
}
return out;
}
Float64List _drop(Note n) {
final len = _n(n.len);
final out = Float64List(len);
final k = math.log(100) / len;
final attack = _n(0.002);
final riseTau = _n(0.016);
var ph = 0.0;
for (var i = 0; i < len; i++) {
final t = i / len;
final base = n.to == null ? n.hz : n.hz * math.pow(n.to! / n.hz, _smooth(math.min(1, t * 2.5)));
final hz = base * (0.62 + 0.38 * (1 - math.exp(-i / riseTau)));
ph += 2 * math.pi * hz / sampleRate;
final env = math.exp(-k * i) * (i < attack ? _smooth(i / attack) : 1);
out[i] = env * (math.sin(ph) + 0.15 * math.sin(2 * ph));
}
return out;
}
Float64List _pad(Note n, math.Random rnd) {
final len = _n(n.len);
final out = Float64List(len);
const detune = [-9.0, -3.0, 4.0, 10.0];
final ph = [for (var j = 0; j < detune.length + 1; j++) rnd.nextDouble() * 2 * math.pi];
final ratios = [for (final c in detune) math.pow(2, c / 1200).toDouble(), 0.5];
final amps = [for (final _ in detune) 1.0, 0.45];
const a = 0.33;
for (var i = 0; i < len; i++) {
final t = i / len;
final env = t < a ? _smooth(t / a) : math.pow(1 - _smooth((t - a) / (1 - a)), 2).toDouble();
final vib = 1 + 0.002 * math.sin(2 * math.pi * 0.3 * i / sampleRate);
final hz = _glide(n, t) * vib;
var v = 0.0;
for (var j = 0; j < ratios.length; j++) {
ph[j] += 2 * math.pi * hz * ratios[j] / sampleRate;
v += amps[j] * (math.sin(ph[j]) + n.bright * (0.25 * math.sin(2 * ph[j]) + 0.1 * math.sin(3 * ph[j])));
}
out[i] = env * v / ratios.length;
}
return out;
}
Float64List _sub(Note n) {
final len = _n(n.len);
final out = Float64List(len);
final k = math.log(100) / len;
final attack = _n(0.025);
var ph = 0.0;
for (var i = 0; i < len; i++) {
final t = i / len;
final hz = n.to == null ? n.hz : n.hz * math.pow(n.to! / n.hz, _smooth(t));
ph += 2 * math.pi * hz / sampleRate;
final env = math.exp(-k * i) * (i < attack ? _smooth(i / attack) : 1);
// Overtones so it still reads on small speakers.
out[i] = env * (math.sin(ph) + 0.35 * math.sin(2 * ph) + 0.12 * math.sin(3 * ph));
}
return out;
}
Float64List _dust(Note n, math.Random rnd) {
final len = _n(n.len);
final out = Float64List(len);
const steps = [0, 2, 4, 7, 9, 12, 14];
final count = math.max(4, (n.len * 14).round());
for (var g = 0; g < count; g++) {
final tg = (g + rnd.nextDouble() * 0.6) / count * 0.75;
final start = _n(tg * n.len);
final centre = n.to == null ? n.hz : n.hz * math.pow(n.to! / n.hz, tg);
final f = centre * math.pow(2, steps[rnd.nextInt(steps.length)] / 12);
final glen = math.min(_n(0.1 + 0.15 * rnd.nextDouble()), len - start);
if (glen <= 0) continue;
final amp = (1 - tg) * (0.5 + 0.5 * rnd.nextDouble());
final k = math.log(100) / glen;
final w = 2 * math.pi * f / sampleRate;
for (var i = 0; i < glen; i++) {
out[start + i] += amp * math.exp(-k * i) * (i < 88 ? i / 88 : 1) * math.sin(w * i);
}
}
return out;
}
/// 16-bit PCM mono WAV.
Uint8List wav(Float64List samples) {
final data = samples.length * 2;
final b = ByteData(44 + data);
void str(int o, String s) {
for (var i = 0; i < s.length; i++) {
b.setUint8(o + i, s.codeUnitAt(i));
}
}
str(0, 'RIFF');
b.setUint32(4, 36 + data, Endian.little);
str(8, 'WAVE');
str(12, 'fmt ');
b.setUint32(16, 16, Endian.little);
b.setUint16(20, 1, Endian.little);
b.setUint16(22, 1, Endian.little);
b.setUint32(24, sampleRate, Endian.little);
b.setUint32(28, sampleRate * 2, Endian.little);
b.setUint16(32, 2, Endian.little);
b.setUint16(34, 16, Endian.little);
str(36, 'data');
b.setUint32(40, data, Endian.little);
for (var i = 0; i < samples.length; i++) {
b.setInt16(44 + i * 2, (samples[i].clamp(-1.0, 1.0) * 32767).round(), Endian.little);
}
return b.buffer.asUint8List();
}
+88
View File
@@ -0,0 +1,88 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:media_kit/media_kit.dart';
import 'package:path_provider/path_provider.dart';
import 'sfx.dart';
import 'sound_bank.dart';
import 'synth.dart';
/// Plays [SoundSpec]s: each is rendered once to a WAV in the temp dir and
/// played on a small pool of audio-only mpv players (so two sounds can
/// overlap). [volume] (0..1) is read per play and applied on a square
/// curve (ears are log); [enabled] filters cues.
class SynthSfx implements SfxService {
final double Function() volume;
final bool Function(SfxKind kind) enabled;
/// Which sound set each sound plays from.
final SoundPreset Function(SfxKind kind) presetFor;
SynthSfx({required this.volume, bool Function(SfxKind kind)? enabled, SoundPreset Function(SfxKind kind)? presetFor})
: enabled = enabled ?? ((_) => true),
presetFor = presetFor ?? ((_) => defaultPreset);
static const _poolSize = 3;
final List<Player> _pool = [];
var _next = 0;
final Map<String, Future<String>> _files = {};
Directory? _dir;
@override
Future<void> play(SfxCue cue) async {
if (!enabled(cue.kind)) return;
final preset = presetFor(cue.kind);
await playSpec('${preset.name}-${cue.kind.name}-${cue.readyIndex}',
soundFor(cue.kind, readyIndex: cue.readyIndex, preset: preset));
}
/// Plays [kind] even when it is switched off (Settings' preview), always
/// freshly rendered: an edited spec plays after a hot reload.
Future<void> preview(SfxKind kind, {int readyIndex = 1, SoundPreset? preset}) {
final p = preset ?? presetFor(kind);
final key = '${p.name}-${kind.name}-$readyIndex';
_files.remove(key);
return playSpec(key, soundFor(kind, readyIndex: readyIndex, preset: p));
}
/// Plays [spec], cached under [key] (same key = same sound).
Future<void> playSpec(String key, SoundSpec spec) async {
final v = volume();
if (v <= 0) return;
try {
final path = await (_files[key] ??= _write(key, spec));
final player = await _player();
await player.setVolume(100 * v * v);
await player.open(Media(path));
} catch (e) {
_files.remove(key);
debugPrint('sfx $key: $e');
}
}
Future<String> _write(String key, SoundSpec spec) async {
_dir ??= await Directory('${(await getTemporaryDirectory()).path}/watch_together_sfx').create(recursive: true);
final f = File('${_dir!.path}/$key.wav');
await f.writeAsBytes(wav(render(spec)));
return f.path;
}
Future<Player> _player() async {
if (_pool.length < _poolSize) {
final p = Player(
configuration: const PlayerConfiguration(vo: 'null', osc: false, libass: false, title: 'sfx'),
);
await (p.platform as NativePlayer).setProperty('audio-display', 'no');
_pool.add(p);
return p;
}
return _pool[_next++ % _poolSize];
}
Future<void> dispose() async {
for (final p in _pool) {
await p.dispose();
}
_pool.clear();
}
}
+845
View File
@@ -0,0 +1,845 @@
import 'dart:async';
import 'dart:io' show File;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:fox_utils/fox_utils.dart' show ClipboardX, DesktopDropX, DropTarget;
import 'package:signals/signals_flutter.dart';
import 'package:window_manager/window_manager.dart';
import '../files/folder_store.dart';
import '../files/ui/files_page.dart' show showFilesFolder;
import '../jellyfin/accounts.dart';
import '../jellyfin/browse_scope.dart' show BrowseStart;
import '../jellyfin/session_media.dart';
import '../screens/home/connect_flow.dart';
import '../session/session_coordinator.dart' show ConnectParams;
import '../screens/home/health_probe.dart';
import '../screens/home/media_picker.dart';
import '../screens/home/profile.dart';
import '../screens/home/update_indicator.dart';
import '../state/session_state.dart';
import '../state/settings.dart';
import '../update/update_controller.dart';
import 'chat_panel.dart';
import 'connect_cards.dart';
import 'handoff.dart';
import 'library_tab.dart';
import 'motion.dart';
import 'party_strip.dart';
import 'profile_card.dart';
import '../schedule/ui/schedule_page.dart';
import 'session_player.dart';
import 'settings_tab.dart';
import 'toasts.dart';
import 'welcome.dart';
/// The one window: the party strip over a body that fades through between
/// the tabs and the full player, the floating mini player, the chat panel
/// and the toasts. Online/offline follows [SessionState.route].
///
/// The player ([SessionPlayer], the only `Video`) lives in one layer of
/// the body: full it fills the body (fullscreen: the window, strip
/// hidden), mini it is a 384x216 box snapped to a corner. A mode change
/// fades the layer out, moves it while invisible and fades it back in, so
/// the video is never mounted twice nor remounted.
///
/// A drop anywhere on the window or Ctrl+V outside a text field is a
/// [Handoff]: a card near the pointer asks how to open it.
class AppShell extends StatefulWidget {
final UpdateController? updates;
final bool autoUpdateCheck;
/// Off in tests: the drag area, window buttons and fullscreen need
/// window_manager.
final bool windowControls;
final HealthGetter healthGet;
/// Linked Jellyfin servers; defaults to [JellyfinAccounts.instance].
final JellyfinAccounts? jellyfin;
/// Stand-in for media_kit's `Video` (tests).
final VideoViewBuilder? videoBuilder;
const AppShell({
super.key,
this.updates,
this.autoUpdateCheck = true,
this.windowControls = true,
this.healthGet = defaultHealthGet,
this.jellyfin,
this.videoBuilder,
});
@override
State<AppShell> createState() => AppShellState();
}
class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin {
static const miniSize = Size(384, 216);
static const _miniMargin = 16.0;
ShellTab _tab = ShellTab.library;
SettingsSection _section = SettingsSection.profile;
/// Where the player is going ([_mode]) and where it is drawn ([_shown]):
/// they differ for the first part of a change, while it fades out.
PlayerMode _mode = PlayerMode.none;
PlayerMode _shown = PlayerMode.none;
PlayerMode _from = PlayerMode.none;
int _modeSeq = 0;
late final AnimationController _fade = AnimationController(vsync: this, value: 1);
bool _fullscreen = false;
bool _chatOpen = false;
int _unread = 0;
int _feedSeen = 0;
final _chat = TextEditingController();
final _chatFocus = FocusNode();
final _playerKey = GlobalKey();
Alignment _corner = Alignment.bottomRight;
Offset? _drag; // mini top-left while dragging
bool _snapping = false;
/// Last pointer position in the window (where a paste's card opens).
Offset? _pointer;
bool _dragging = false;
bool _handoffOpen = false;
final _flow = ConnectFlow();
late final _join = JoinFields(SessionState.instance.lastForm);
late final _host = HostFields(SessionState.instance.lastForm);
late final JellyfinAccounts _jellyfin = widget.jellyfin ?? JellyfinAccounts.instance;
late MediaPickerController _picker = _newPicker();
BrowseStart? _libraryStart;
int _libraryGen = 0;
/// The next media to come up opens full (just joined, or put up here).
bool _autoFull = false;
int _lastMediaId = -1;
bool _dialogOpen = false;
final _stops = <void Function()>[];
// Rebuild the shell on these flips, not on every state broadcast.
final _online = computed(() => SessionState.instance.route.value == AppRoute.session);
/// Nobody else watches: offline, solo, or solo becoming a server.
final _alone = computed(() {
final s = SessionState.instance;
return s.route.value != AppRoute.session || s.solo.value || s.switching.value;
});
final _hasMedia = computed(() =>
SessionState.instance.route.value == AppRoute.session &&
SessionState.instance.coordinator.serverState.value?.media != null);
PlayerMode get mode => _mode;
PlayerMode get shownMode => _shown;
bool get fullscreen => _fullscreen;
MediaPickerController _newPicker({JellyfinMedia? jellyfin, MediaKind? source}) {
final f = SessionState.instance.lastForm;
final c = MediaPickerController(mediaPath: f.mediaPath, linkUrl: f.linkUrl, jellyfin: jellyfin, source: source);
if (jellyfin == null) unawaited(restoreLastPick(c, _jellyfin, () => mounted && identical(c, _picker)));
return c;
}
@override
void initState() {
super.initState();
unawaited(Profile.instance.ensureLoaded());
// Reads linked servers and checks their tokens in the background.
_jellyfin.ensureLoaded();
if (widget.autoUpdateCheck) _autoCheck();
HardwareKeyboard.instance.addHandler(_onKey);
final session = SessionState.instance;
final coord = session.coordinator;
_feedSeen = coord.feedLog.peek().length;
_stops.add(effect(() {
final online = session.route.value == AppRoute.session;
// Set now, not later: the media effect below may run first in this
// same flush, and its scheduled show reads these.
_autoFull = online;
if (!online) _lastMediaId = -1;
untracked(() => _later(() => online ? _wentOnline() : _wentOffline()));
}));
// Something comes up while the player is closed: show it (full right
// after joining or a put-up of ours, else as the mini player).
_stops.add(effect(() {
final r = coord.serverState.value;
final online = session.route.value == AppRoute.session;
if (!online || r == null || r.media == null) return;
if (r.mediaId == _lastMediaId) return;
_lastMediaId = r.mediaId;
_later(() {
if (_mode == PlayerMode.none) {
_show(_autoFull ? PlayerMode.full : PlayerMode.mini);
} else if (_autoFull) {
_show(PlayerMode.full);
}
_autoFull = false;
});
}));
// Chat that arrives while the panel is closed.
_stops.add(effect(() {
final log = coord.feedLog.value;
final fresh = log.length < _feedSeen ? log : log.sublist(_feedSeen);
_feedSeen = log.length;
final chats = fresh.where((f) => f.action == 'chat').length;
if (chats == 0) return;
untracked(() => _later(() {
if (!_chatOpen) setState(() => _unread += chats);
}));
}));
// Back from sitting out with the player closed: show it (the media
// plays again).
var wasOut = false;
_stops.add(effect(() {
final out = coord.selfOut.value;
if (wasOut && !out && session.route.peek() == AppRoute.session) {
_later(() {
if (_mode == PlayerMode.none) _show(PlayerMode.mini);
});
}
wasOut = out;
}));
// Solo becoming a hosted server: the player closes while the media
// moves over and comes back the way it was once it is up again.
_stops.add(effect(() {
if (!session.switching.value) return;
_later(() {
_lastMediaId = -1;
_autoFull = _mode == PlayerMode.full;
if (_fullscreen) _toggleFullscreen();
_show(PlayerMode.none);
});
}));
// The hosted server died mid-session (SessionState leaves): say so.
_stops.add(effect(() {
final crash = session.serverCrash.value;
if (crash == null) return;
_later(() {
session.serverCrash.value = null;
_alert('Server crashed', crash);
});
}));
}
@override
void dispose() {
for (final stop in _stops) {
stop();
}
HardwareKeyboard.instance.removeHandler(_onKey);
_fade.dispose();
_online.dispose();
_alone.dispose();
_hasMedia.dispose();
_flow.dispose();
_join.dispose();
_host.dispose();
_picker.dispose();
_chat.dispose();
_chatFocus.dispose();
if (_fullscreen) _setWindowFullscreen(false);
super.dispose();
}
/// Runs [f] after the current signal notification / build.
void _later(VoidCallback f) => scheduleMicrotask(() {
if (mounted) f();
});
Future<void> _autoCheck() async {
final s = AppSettings.instance;
await s.ensureLoaded();
if (s.autoCheckUpdates.peek()) await runUpdateCheck(widget.updates);
}
void _wentOnline() {
_unread = 0;
_feedSeen = SessionState.instance.coordinator.feedLog.peek().length;
// Hosting starts with nothing up: the library is where to pick it.
setState(() => _tab = ShellTab.library);
}
void _wentOffline() {
if (_fullscreen) _toggleFullscreen();
setState(() {
_chatOpen = false;
_unread = 0;
});
_show(PlayerMode.none);
}
// ----------------------------------------------------------- player mode
/// Fade through to [m]: the player layer (and, when full is involved,
/// the tabs) fades out, moves while invisible, then fades in.
Future<void> _show(PlayerMode m) async {
if (m == _mode) return;
final seq = ++_modeSeq;
setState(() {
_from = _shown;
_mode = m;
});
if (m == PlayerMode.full) FocusManager.instance.primaryFocus?.unfocus();
if (_shown != PlayerMode.none || m == PlayerMode.full) {
await _fade.animateTo(0,
duration: fadeThroughTime * fadeOutPart, curve: Curves.easeIn);
} else {
_fade.value = 0;
}
if (!mounted || seq != _modeSeq) return;
setState(() => _shown = m);
await _fade.animateTo(1, duration: fadeThroughTime * (1 - fadeOutPart), curve: ease);
}
void _expand() => _show(PlayerMode.full);
void _collapse() {
if (_fullscreen) _toggleFullscreen();
_show(PlayerMode.mini);
}
/// The player's ×: sit out (stay in the server, nothing loaded; the
/// player shows the join-back card). Once out, × closes the player.
void _closePlayer() {
final coord = SessionState.instance.coordinator;
if (coord.selfOut.peek()) {
_show(PlayerMode.none);
} else {
coord.sitOut();
}
}
Future<void> _setWindowFullscreen(bool on) async {
if (!widget.windowControls) return;
try {
await windowManager.setFullScreen(on);
} catch (_) {
// No window manager.
}
}
void _toggleFullscreen() {
final on = !_fullscreen;
setState(() => _fullscreen = on);
if (on && _mode != PlayerMode.full) _show(PlayerMode.full);
_setWindowFullscreen(on);
}
void _selectTab(ShellTab t) {
if (_fullscreen) _toggleFullscreen();
setState(() => _tab = t);
if (_mode == PlayerMode.full) _show(PlayerMode.mini);
}
void _openSettings(SettingsSection s) {
setState(() => _section = s);
_selectTab(ShellTab.settings);
}
void _toggleChat() => setState(() {
_chatOpen = !_chatOpen;
_unread = 0;
if (!_chatOpen) _chatFocus.unfocus();
});
// --------------------------------------------------------------- library
/// "Change video": the library, opened on the series of the episode
/// playing (season selected, episode highlighted), else where it was.
void _changeVideo() {
final coord = SessionState.instance.coordinator;
coord.markInput();
final ref = coord.currentRef.peek();
final pick = ref == null ? null : pickForRef(_jellyfin, ref);
final plan = coord.jellyfinPlan.peek();
final old = _picker;
_picker = pick == null
? _newPicker()
: _newPicker(jellyfin: pick, source: MediaKind.jellyfin);
old.dispose();
_libraryStart = ref != null && plan is OwnPlay && plan.item != null
? BrowseStart.seriesOf(ref.serverId, plan.item!)
: null;
_libraryGen++;
_selectTab(ShellTab.library);
}
/// Library → "Put it up for everyone"; the player opens full. Offline
/// it starts a solo session first (just you, on this computer).
Future<String?> _putUp(PickedMedia media) async {
final session = SessionState.instance;
if (media is JellyfinMedia) {
try {
putUpJellyfinMedia(_jellyfin, media);
} on FormatException catch (e) {
return e.message;
}
unawaited(_jellyfin.rememberPick(media));
}
if (!_online.peek()) {
final error = await _startSolo();
if (error != null || !mounted) return error;
}
// Alone, a link plays direct: there is nobody to pass it on to.
if (media is LinkMedia && session.solo.peek()) media = LinkMedia(media.url);
final error = await session.coordinator.swapPicked(media);
if (error != null || !mounted) return error;
final f = session.lastForm;
f
..linkUrl = media is LinkMedia ? media.url : f.linkUrl
..mediaPath = media is FileMedia ? media.path : f.mediaPath;
unawaited(session.saveForm().catchError((Object _) {}));
_autoFull = true;
_show(PlayerMode.full);
return null;
}
/// Starts a solo session; returns an error text, or null.
Future<String?> _startSolo() async {
final profile = Profile.instance;
try {
await profile.ensureLoaded();
final name = profile.name.peek().trim();
await SessionState.instance.startSolo(ConnectParams(
host: '127.0.0.1',
port: 0,
secret: '',
name: name.isEmpty ? 'Me' : name,
avatar: profile.avatarBase64(),
));
return null;
} catch (e) {
return "couldn't start playing: $e";
}
}
/// Library's "Watch solo": somewhere to pick from (Jellyfin when linked,
/// else Files).
void _watchSolo() => _openLibrary(_jellyfin.isEmpty ? MediaKind.file : MediaKind.jellyfin);
// --------------------------------------------------------------- handoff
/// Nothing (a dialog, a popover) is over the shell.
bool get _onTop => !_handoffOpen && (ModalRoute.of(context)?.isCurrent ?? true);
/// Ctrl+V outside a text field (a focused field pastes on its own).
bool _onKey(KeyEvent e) {
if (e is! KeyDownEvent || e.logicalKey != LogicalKeyboardKey.keyV) return false;
final keys = HardwareKeyboard.instance;
if (!(keys.isControlPressed || keys.isMetaPressed) || keys.isShiftPressed || keys.isAltPressed) return false;
if (!mounted || !_onTop) return false;
final inField =
FocusManager.instance.primaryFocus?.context?.findAncestorStateOfType<EditableTextState>() != null;
if (inField) return false;
unawaited(_paste());
return true;
}
/// Clipboard files first, then text (a link or absolute paths). A pasted
/// image has no taker yet, so the clipboard's image data is not read.
Future<void> _paste() async {
var files = const <String>[];
String? text;
try {
files = await ClipboardX.readFiles();
} catch (_) {}
if (files.isEmpty) {
try {
text = await ClipboardX.readText();
} catch (_) {}
}
await _handOff(classifyHandoff(paths: files, text: text), _pointer);
}
Future<void> _handOff(Handoff item, Offset? at) async {
if (!mounted || !_onTop || item is HandoffNothing || item is HandoffImage) return;
final size = MediaQuery.sizeOf(context);
final align = at == null || size.isEmpty
? Alignment.center
: Alignment((at.dx / size.width * 2 - 1).clamp(-1, 1), (at.dy / size.height * 2 - 1).clamp(-1, 1));
_handoffOpen = true;
final HandoffAction? action;
try {
action = await showPopover<HandoffAction>(context, at: align, child: HandoffCard(item: item, solo: _alone.peek()));
} finally {
_handoffOpen = false;
}
if (action == null || !mounted) return;
switch ((item, action)) {
case (HandoffVideos(:final paths), HandoffAction.putUp):
final error = await _putUp(FileMedia(paths.first));
if (error != null) {
SessionState.instance.coordinator.showToast(error);
} else {
await FolderStore.instance.ensureLoaded();
FolderStore.instance.addRecent(paths.first);
}
case (HandoffVideos(:final paths), HandoffAction.addFolder):
await _addFolder(File(paths.first).parent.path);
case (HandoffFolder(:final path), _):
await _addFolder(path);
case (HandoffLink(:final url), _):
_picker.link.text = url;
_openLibrary(MediaKind.link);
default:
}
}
Future<void> _addFolder(String dir) async {
await FolderStore.instance.ensureLoaded();
FolderStore.instance.addFolder(dir);
_openFiles(dir);
}
/// Library → Files on [dir] (null: Recent).
void _openFiles(String? dir) {
showFilesFolder(dir);
_openLibrary(MediaKind.file);
}
void _openLibrary(MediaKind source) {
if (!mounted) return;
_picker.source = source;
_selectTab(ShellTab.library);
}
// Off on Settings: its own drop targets (cookies) take the files there.
Widget _dropTarget(Widget child) => DropTarget(
enable: _tab != ShellTab.settings,
onDragEntered: (_) => setState(() => _dragging = _onTop),
onDragExited: (_) => setState(() => _dragging = false),
onDragDone: (d) {
setState(() => _dragging = false);
_handOff(classifyHandoff(paths: DesktopDropX.pathsOf(d.files)), d.localPosition);
},
child: Stack(
fit: StackFit.expand,
children: [
child,
Positioned.fill(
child: IgnorePointer(
child: AnimatedOpacity(
duration: easeTime,
curve: ease,
opacity: _dragging ? 1 : 0,
child: const _DropOverlay(),
),
),
),
],
),
);
// --------------------------------------------------------------- dialogs
/// Leave asks first; a solo session just stops.
Future<void> _confirmLeave() async {
final session = SessionState.instance;
if (session.solo.peek()) {
await session.leave();
return;
}
final ok = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Leave server?'),
content: const Text('You can rejoin right away.'),
actions: [
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Cancel')),
TextButton(
key: const ValueKey('confirm-leave'),
onPressed: () => Navigator.pop(context, true),
child: const Text('Leave'),
),
],
),
);
if (ok == true) await SessionState.instance.leave();
}
Future<void> _alert(String title, String text) async {
if (!mounted || _dialogOpen) return;
_dialogOpen = true;
try {
await showDialog<void>(
context: context,
builder: (context) => AlertDialog(
title: Text(title),
content: Text(text),
actions: [TextButton(onPressed: () => Navigator.pop(context), child: const Text('OK'))],
),
);
} finally {
_dialogOpen = false;
}
}
void _connect(ConnectMode mode) => showConnectCard(
context,
mode: mode,
flow: _flow,
join: _join,
host: _host,
healthGet: widget.healthGet,
);
// ----------------------------------------------------------------- build
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final shell = CallbackShortcuts(
bindings: {
const SingleActivator(LogicalKeyboardKey.comma, control: true): () => _selectTab(ShellTab.settings),
},
child: Scaffold(
backgroundColor: _fullscreen ? Colors.black : scheme.surface,
body: SignalBuilder(builder: (context) {
final online = _online.value;
final alone = _alone.value;
final hasMedia = _hasMedia.value;
return Column(
children: [
if (!_fullscreen)
PartyStrip(
key: const ValueKey('strip'),
tab: _tab,
onTab: _selectTab,
chatOpen: _chatOpen,
unread: _unread,
onChat: _toggleChat,
onJoin: () => _connect(ConnectMode.join),
onHost: () => _connect(ConnectMode.host),
onLeave: _confirmLeave,
onInvite: () => _connect(ConnectMode.host),
onProfile: () => showProfileCard(context, onOpenSettings: _openSettings, updates: widget.updates),
onShowPlayer: hasMedia && _mode == PlayerMode.none ? () => _show(PlayerMode.mini) : null,
updates: widget.updates,
windowControls: widget.windowControls,
),
Expanded(
key: const ValueKey('body'),
child: Row(
children: [
Expanded(child: _body(online, alone)),
AnimatedSize(
duration: easeTime,
curve: ease,
alignment: Alignment.centerLeft,
child: _chatOpen && online
? ChatPanel(chat: _chat, chatFocus: _chatFocus, onClose: _toggleChat)
: const SizedBox(height: double.infinity),
),
],
),
),
],
);
}),
),
);
return Listener(
onPointerHover: (e) => _pointer = e.localPosition,
onPointerDown: (e) => _pointer = e.localPosition,
child: _dropTarget(shell),
);
}
/// No Jellyfin account, no server joined or hosted yet, band not
/// dismissed: the welcome band explains the three ways in.
bool get _newcomer =>
!AppSettings.instance.welcomeDismissed.value &&
_jellyfin.isEmpty &&
SessionState.instance.recents.value.isEmpty;
Widget _body(bool online, bool alone) {
final coord = SessionState.instance.coordinator;
final tabBody = switch (_tab) {
ShellTab.library => LibraryTab(
key: ValueKey('library-$_libraryGen'),
controller: _picker,
accounts: _jellyfin,
solo: alone,
// Offline, a put-up starts a solo session, which hosts.
isHost: !online || coord.hostFile != null,
// Only for newcomers: the strip has Join and Host for everyone else.
banner: online || !_newcomer
? null
: WelcomeBand(
onJoin: () => _connect(ConnectMode.join),
onHost: () => _connect(ConnectMode.host),
onSolo: _watchSolo,
onDismiss: () => setState(AppSettings.instance.dismissWelcome),
),
jellyfinStart: _libraryStart,
onPutUp: _putUp,
onOpenIntegrations: () => _openSettings(SettingsSection.integrations),
),
ShellTab.schedule => SchedulePage(
solo: alone,
onPutUp: _putUp,
onToast: coord.showToast,
),
ShellTab.settings => SettingsTab(
section: _section,
onSection: (s) => setState(() => _section = s),
onEditProfile: () => showProfileCard(context, onOpenSettings: _openSettings, updates: widget.updates),
updates: widget.updates,
jellyfin: _jellyfin,
),
};
final full = _shown == PlayerMode.full;
final fullInvolved = _mode == PlayerMode.full || _from == PlayerMode.full;
return LayoutBuilder(builder: (context, box) {
final area = box.biggest;
return Stack(
children: [
Positioned.fill(
child: AnimatedBuilder(
animation: _fade,
builder: (context, child) {
// The tabs take part in the fade only when the full player
// comes or goes; a mini player appearing leaves them be.
final v = full ? 0.0 : (fullInvolved ? _fade.value : 1.0);
return Opacity(
opacity: v,
child: Transform.scale(scale: 0.985 + 0.015 * v, child: child),
);
},
child: IgnorePointer(
ignoring: full,
child: ExcludeFocus(
excluding: full,
child: TickerMode(
enabled: !full,
child: fadeThrough(KeyedSubtree(key: ValueKey(_tab), child: tabBody)),
),
),
),
),
),
if (online && _shown != PlayerMode.none) _playerLayer(area),
Positioned(left: 16, bottom: _fullscreen ? 90 : 16, child: PartyToasts(
onOpenChat: () {
if (!_chatOpen) _toggleChat();
},
)),
],
);
});
}
Offset _cornerPos(Size area, Alignment a, Size size) => Offset(
a.x < 0 ? _miniMargin : (area.width - size.width - _miniMargin).clamp(0, double.infinity),
a.y < 0 ? _miniMargin : (area.height - size.height - _miniMargin).clamp(0, double.infinity),
);
Widget _playerLayer(Size area) {
final mini = _shown == PlayerMode.mini;
final size = mini
? Size(miniSize.width.clamp(0, area.width), miniSize.height.clamp(0, area.height))
: area;
final pos = mini ? (_drag ?? _cornerPos(area, _corner, size)) : Offset.zero;
return AnimatedPositioned(
key: const ValueKey('player-layer'),
duration: _snapping ? easeTime : Duration.zero,
curve: ease,
left: pos.dx,
top: pos.dy,
width: size.width,
height: size.height,
onEnd: () => _snapping = false,
child: AnimatedBuilder(
animation: _fade,
builder: (context, child) {
final v = _fade.value;
return IgnorePointer(
ignoring: v < 0.5 || _shown != _mode,
child: Opacity(
opacity: v,
child: Transform.scale(scale: mini ? 0.97 + 0.03 * v : 0.985 + 0.015 * v, child: child),
),
);
},
// Same widgets in both modes (only their settings change), so the
// player below never moves in the element tree.
child: GestureDetector(
onPanStart: mini ? (_) => setState(() => _drag = pos) : null,
onPanUpdate: mini ? (d) => setState(() => _drag = _drag! + d.delta) : null,
onPanEnd: mini
? (_) => setState(() {
final c = _drag! + Offset(size.width / 2, size.height / 2);
_corner = Alignment(c.dx < area.width / 2 ? -1 : 1, c.dy < area.height / 2 ? -1 : 1);
_drag = null;
_snapping = true;
})
: null,
child: DoubleClickListener(
onDoubleClick: mini ? _expand : () {},
skipTop: 40,
skipBottom: 60,
child: Material(
elevation: mini ? 16 : 0,
color: Colors.black,
borderRadius: BorderRadius.circular(mini ? 14 : 0),
clipBehavior: Clip.antiAlias,
child: KeyedSubtree(
key: _playerKey,
child: SessionPlayer(
mode: _shown,
fullscreen: _fullscreen,
chatOpen: _chatOpen,
unread: _unread,
onExpand: _expand,
onCollapse: _collapse,
onClose: _closePlayer,
onToggleFullscreen: _toggleFullscreen,
onToggleChat: _toggleChat,
onChangeVideo: _changeVideo,
onOpenIntegrations: () => _openSettings(SettingsSection.integrations),
onLeave: _confirmLeave,
videoBuilder: widget.videoBuilder,
windowControls: widget.windowControls,
),
),
),
),
),
),
);
}
}
/// Over the window while something is dragged over it.
class _DropOverlay extends StatelessWidget {
const _DropOverlay();
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return Container(
key: const ValueKey('drop-overlay'),
margin: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: scheme.surface.withValues(alpha: 0.55),
borderRadius: BorderRadius.circular(18),
border: Border.all(color: scheme.primary.withValues(alpha: 0.7), width: 2),
),
alignment: Alignment.center,
child: Material(
color: scheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(14),
elevation: 8,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 12),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.file_download_outlined, color: scheme.primary),
const SizedBox(width: 10),
Text('Drop to open', style: Theme.of(context).textTheme.titleMedium),
],
),
),
),
);
}
}
+339
View File
@@ -0,0 +1,339 @@
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:signals/signals_flutter.dart';
import 'package:sync_relay_core/core.dart';
import '../screens/home/profile.dart';
import '../session/session_coordinator.dart';
import '../state/session_state.dart';
import 'motion.dart';
/// What an avatar shows: who, their picture or initial on [color], and
/// the status the ring and badges carry.
class AvatarInfo {
final String id;
final String name;
final Color color;
final ImageProvider? image;
final bool ready, buffering, relaying, stale, host, self;
/// Sitting out: dimmed, with an "away" mark.
final bool out;
const AvatarInfo({
this.id = '',
required this.name,
required this.color,
this.image,
this.ready = false,
this.buffering = false,
this.relaying = false,
this.stale = false,
this.host = false,
this.self = false,
this.out = false,
});
}
/// Stable colour for someone else (colours are not on the wire).
Color colorForName(String name) {
const palette = [
Colors.teal,
Colors.orange,
Colors.pink,
Colors.blue,
Colors.green,
Colors.indigo,
Colors.amber,
Colors.cyan,
Colors.red,
Colors.deepPurple,
];
var h = 0;
for (final c in name.codeUnits) {
h = (h * 31 + c) & 0x7fffffff;
}
return palette[h % palette.length];
}
final _images = <Object, MemoryImage>{};
MemoryImage _memo(Object key, Uint8List bytes) {
final hit = _images[key];
if (hit != null && identical(hit.bytes, bytes)) return hit;
if (_images.length > 64) _images.clear();
return _images[key] = MemoryImage(bytes);
}
/// The one lookup for a member's picture: your own from the profile (no
/// round trip), anyone else's from the session's avatar cache by hash.
/// Callers rebuild on [SessionCoordinator.avatars] and [Profile.picture]
/// (see [PartyWatch]).
ImageProvider? memberAvatarImage(MemberView m, SessionCoordinator coord) {
if (m.id.isNotEmpty && m.id == coord.sync.memberId) {
final own = Profile.instance.picture.value;
if (own != null) return _memo('self', own);
}
final bytes = coord.avatars.bytesFor(m.avatarHash);
return bytes == null ? null : _memo(m.avatarHash, bytes);
}
/// Your own avatar from the profile (also offline).
AvatarInfo selfAvatarInfo({bool ready = false, bool host = false, bool out = false}) {
final p = Profile.instance;
final png = p.picture.value;
final name = p.name.value;
return AvatarInfo(
name: name.isEmpty ? '?' : name,
color: p.color.value,
image: png == null ? null : _memo('self', png),
ready: ready,
host: host,
self: true,
out: out,
);
}
/// [m] as the party strip, toasts and the chat show it.
AvatarInfo memberAvatarInfo(MemberView m, StateBroadcast? r, SessionCoordinator coord) {
final self = m.id.isNotEmpty && m.id == coord.sync.memberId;
final relay = r == null ? null : stateMediaRelay(r);
final playing = r?.media != null;
return AvatarInfo(
id: m.id,
name: m.name,
color: self ? Profile.instance.color.value : colorForName(m.name),
image: memberAvatarImage(m, coord),
ready: m.ready,
buffering: !m.stale && m.ready && playing && m.buffered < r!.bufferNeed,
relaying: relay != null && relay.source.isNotEmpty && relay.source == m.id,
stale: m.stale,
// The state marks the host (`members[].host`); your own app also
// knows before the first state.
host: m.host || (self && coord.hostFile != null),
self: self,
out: m.out,
);
}
/// Everyone in the server, from the latest state.
List<AvatarInfo> partyAvatars([SessionCoordinator? coord]) {
final c = coord ?? SessionState.instance.coordinator;
final r = c.serverState.value;
return [for (final m in r?.members ?? const <MemberView>[]) memberAvatarInfo(m, r, c)];
}
/// Rebuilds [builder] when the party state, your profile or a member's
/// picture changes.
class PartyWatch extends StatelessWidget {
final WidgetBuilder builder;
const PartyWatch({super.key, required this.builder});
@override
Widget build(BuildContext context) => ListenableBuilder(
listenable: SessionState.instance.coordinator.avatars,
builder: (context, _) => SignalBuilder(builder: (context) => builder(context)),
);
}
/// Circle avatar with a status ring outside it and a gap in the background
/// colour, so overlapping neighbours never hide the status. Crown = host,
/// tower = passing the video on, spinner = buffering, dimmed = stale,
/// dimmed with a moon = sitting out.
class StatusAvatar extends StatelessWidget {
final AvatarInfo info;
final double size;
/// Host crown and relay tower; off where the context already says it.
final bool badges;
/// Ring colour when not ready (the profile shows none at all).
final bool ring;
const StatusAvatar({super.key, required this.info, required this.size, this.badges = true, this.ring = true});
@override
Widget build(BuildContext context) {
final bg = Theme.of(context).colorScheme.surfaceContainerLowest;
final ringColor = !ring
? bg
: info.stale
? Colors.grey.shade700
: info.buffering
? Colors.amber
: info.ready
? Colors.greenAccent.shade400
: Colors.grey;
final badge = size * 0.42;
// Ring and gap scale with the avatar (2 px at strip size).
final stroke = (size * 0.055).clamp(2.0, 4.0);
final initial = info.name.isEmpty ? '?' : info.name.characters.first.toUpperCase();
return SizedBox(
width: size,
height: size,
child: Stack(
clipBehavior: Clip.none,
children: [
// Fills the box: a non-positioned Stack child only gets loose
// constraints, and CircleAvatar then falls back to 40 px.
Positioned.fill(
child: Container(
decoration: BoxDecoration(shape: BoxShape.circle, color: ringColor),
padding: EdgeInsets.all(stroke),
child: Container(
decoration: BoxDecoration(shape: BoxShape.circle, color: bg),
padding: EdgeInsets.all(stroke),
child: Opacity(
opacity: info.stale || info.out ? 0.4 : 1,
child: CircleAvatar(
radius: size / 2,
backgroundColor: info.color,
backgroundImage: info.image,
child: info.image != null
? null
: Text(initial, style: TextStyle(fontSize: size * 0.38, color: Colors.white)),
),
),
),
),
),
if (info.buffering)
Positioned.fill(child: CircularProgressIndicator(strokeWidth: stroke, color: ringColor)),
if (badges && info.host)
Positioned(
left: -4,
top: -6,
child: Transform.rotate(
angle: -0.4,
child: Icon(
Icons.workspace_premium,
size: badge,
color: Colors.amber,
shadows: const [Shadow(blurRadius: 3, color: Colors.black)],
),
),
),
if (info.out)
Positioned(
key: const ValueKey('away-mark'),
left: -3,
bottom: -3,
child: Container(
padding: const EdgeInsets.all(2),
decoration: BoxDecoration(shape: BoxShape.circle, color: bg),
child: Icon(Icons.bedtime, size: (badge * 0.8).clamp(10.0, 24.0), color: Colors.blueGrey.shade200),
),
),
if (badges && info.relaying)
Positioned(
right: -3,
bottom: -3,
child: Container(
padding: const EdgeInsets.all(2),
decoration: BoxDecoration(shape: BoxShape.circle, color: bg),
child: Icon(Icons.cell_tower, size: badge * 0.8, color: Colors.lightBlueAccent),
),
),
],
),
);
}
}
/// Overlapping avatars, host first then ready (the stack itself reads as
/// progress); they spread apart on hover. "+N" past [shown].
class AvatarStack extends StatefulWidget {
final List<AvatarInfo> members;
final VoidCallback? onTap;
final int shown;
final double size;
const AvatarStack({super.key, required this.members, this.onTap, this.shown = 5, this.size = 42});
@override
State<AvatarStack> createState() => _AvatarStackState();
}
class _AvatarStackState extends State<AvatarStack> {
bool _hover = false;
@override
Widget build(BuildContext context) {
final size = widget.size;
int rank(AvatarInfo m) => m.host ? 0 : (m.out ? 3 : m.ready ? 1 : 2);
final sorted = [...widget.members]..sort((a, b) => rank(a) - rank(b));
final visible = sorted.take(widget.shown).toList();
final extra = sorted.length - visible.length;
final step = _hover ? size + 6 : size * 0.62;
final count = visible.length + (extra > 0 ? 1 : 0);
final width = count == 0 ? 0.0 : step * (count - 1) + size;
return MouseRegion(
onEnter: (_) => setState(() => _hover = true),
onExit: (_) => setState(() => _hover = false),
cursor: widget.onTap == null ? MouseCursor.defer : SystemMouseCursors.click,
child: GestureDetector(
onTap: widget.onTap,
child: AnimatedContainer(
key: const ValueKey('avatar-stack'),
duration: easeTime,
curve: ease,
width: width,
height: size,
child: Stack(
clipBehavior: Clip.none,
children: [
for (var i = visible.length - 1; i >= 0; i--)
AnimatedPositioned(
key: ValueKey('stack-${visible[i].id}-${visible[i].name}'),
duration: easeTime,
curve: ease,
left: i * step,
top: 0,
child: Tooltip(
message: visible[i].name,
child: StatusAvatar(info: visible[i], size: size),
),
),
if (extra > 0)
AnimatedPositioned(
duration: easeTime,
curve: ease,
left: visible.length * step,
top: 0,
child: CircleAvatar(
radius: size / 2,
child: Text('+$extra', style: const TextStyle(fontSize: 12)),
),
),
],
),
),
),
);
}
}
/// Small coloured label ("host", "passing the video on").
class Pill extends StatelessWidget {
final String text;
final Color color;
const Pill(this.text, this.color, {super.key});
@override
Widget build(BuildContext context) => Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(6),
),
child: Text(text, style: TextStyle(color: color, fontSize: 10, fontWeight: FontWeight.w600)),
);
}
/// Someone by display name (feed lines carry names, not ids): from the
/// latest state when still there, else their initial on a stable colour.
AvatarInfo avatarForName(String name, [SessionCoordinator? coord]) {
final c = coord ?? SessionState.instance.coordinator;
final r = c.serverState.value;
for (final m in r?.members ?? const <MemberView>[]) {
if (m.name == name) return memberAvatarInfo(m, r, c);
}
return AvatarInfo(name: name, color: colorForName(name));
}
+256
View File
@@ -0,0 +1,256 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:sync_relay_core/core.dart';
import '../state/session_state.dart';
import '../widgets/text_focus.dart';
import 'avatar.dart';
/// Activity lines (joins, ready, seeks...) between the messages: off by
/// default, kept for the app's run.
final showActivity = ValueNotifier(false);
/// Side panel: chat with avatars, other activity as quiet lines (behind
/// the "Activity" toggle), and the message field. Width is fixed; the
/// shell slides it in and out. The field takes focus on open; Tab and Esc
/// in it close the panel.
class ChatPanel extends StatefulWidget {
static const width = 340.0;
final TextEditingController chat;
final FocusNode chatFocus;
final VoidCallback onClose;
const ChatPanel({super.key, required this.chat, required this.chatFocus, required this.onClose});
@override
State<ChatPanel> createState() => _ChatPanelState();
}
class _ChatPanelState extends State<ChatPanel> {
// Where focus was before the field took it; it goes back there on close
// (else a field behind could take it, and Space with it).
FocusNode? _before;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
final before = FocusManager.instance.primaryFocus;
if (before != widget.chatFocus) _before = before;
widget.chatFocus.requestFocus();
});
}
@override
void dispose() {
final before = _before;
if (before != null && before.context != null && before.canRequestFocus && !textInputFocused(before)) {
before.requestFocus();
} else if (textInputFocused()) {
FocusManager.instance.primaryFocus?.unfocus();
}
super.dispose();
}
void _send() {
SessionState.instance.coordinator.sendChat(widget.chat.text);
widget.chat.clear();
widget.chatFocus.requestFocus();
}
KeyEventResult _fieldKey(FocusNode node, KeyEvent e) {
if (e is! KeyDownEvent) return KeyEventResult.ignored;
if (e.logicalKey != LogicalKeyboardKey.tab && e.logicalKey != LogicalKeyboardKey.escape) {
return KeyEventResult.ignored;
}
widget.onClose();
return KeyEventResult.handled;
}
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return Container(
key: const ValueKey('chat-panel'),
width: ChatPanel.width,
color: scheme.surfaceContainerLow,
child: Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 4, 0),
child: Row(
children: [
const Text('Chat', style: TextStyle(fontWeight: FontWeight.w600)),
const Spacer(),
ValueListenableBuilder<bool>(
valueListenable: showActivity,
builder: (context, on, _) => FilterChip(
key: const ValueKey('chat-activity'),
label: const Text('Activity', style: TextStyle(fontSize: 12)),
tooltip: 'Show joins, ready, seeks and other activity',
selected: on,
showCheckmark: false,
visualDensity: VisualDensity.compact,
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
onSelected: (v) => showActivity.value = v,
),
),
IconButton(tooltip: 'Close (Tab)', onPressed: widget.onClose, icon: const Icon(Icons.close, size: 18)),
],
),
),
Expanded(
child: ValueListenableBuilder<bool>(
valueListenable: showActivity,
builder: (context, activity, _) => PartyWatch(builder: (context) {
final all = SessionState.instance.coordinator.feedLog.value;
final log = activity ? all : [for (final f in all) if (f.action == 'chat') f];
if (log.isEmpty) {
return Center(
child: Text(activity ? 'Nothing yet' : 'No messages yet',
style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12)),
);
}
return ListView.builder(
reverse: true,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
itemCount: log.length,
itemBuilder: (context, i) => ActivityRow(item: log[log.length - 1 - i]),
);
}),
),
),
Padding(
padding: const EdgeInsets.all(12),
child: Focus(
canRequestFocus: false,
skipTraversal: true,
onKeyEvent: _fieldKey,
child: TextField(
key: const ValueKey('chat-field'),
controller: widget.chat,
focusNode: widget.chatFocus,
onSubmitted: (_) => _send(),
decoration: InputDecoration(
hintText: 'Message (Enter)',
isDense: true,
filled: true,
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide.none),
suffixIcon: IconButton(tooltip: 'Send', onPressed: _send, icon: const Icon(Icons.send, size: 18)),
),
),
),
),
],
),
);
}
}
/// One feed line: a chat message with the sender's avatar, anything else
/// as an icon and "who did what".
class ActivityRow extends StatelessWidget {
final FeedMessage item;
const ActivityRow({super.key, required this.item});
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final time = _clock(item.at);
if (item.action == 'chat') {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
StatusAvatar(info: avatarForName(item.actor), size: 34, badges: false),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Flexible(
child: Text(item.actor,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontWeight: FontWeight.w700)),
),
const SizedBox(width: 6),
Text(time, style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 11)),
],
),
SelectableText(item.reason),
],
),
),
],
),
);
}
final (icon, color, text) = item.actor == 'server' ? serverFeedStyle(item) : feedStyle(item, scheme);
final line = item.actor == 'server' ? text : '${item.actor} $text';
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
children: [
const SizedBox(width: 8),
Icon(icon, size: 14, color: color),
const SizedBox(width: 8),
Expanded(child: Text(line, style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12))),
Text(time, style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 11)),
],
),
);
}
}
/// Icon, colour and wording of a member's feed action.
(IconData, Color, String) feedStyle(FeedMessage f, ColorScheme scheme) {
switch (f.action) {
case 'join':
return (Icons.login, Colors.green, 'joined');
case 'leave':
final reason = f.reason == 'silent too long' ? ' · timed out' : (f.reason.isEmpty ? '' : ' · ${f.reason}');
return (Icons.logout, Colors.grey, 'left$reason');
case 'ready':
return (Icons.check_circle, Colors.green, 'is ready');
case 'unready':
return (Icons.radio_button_unchecked, Colors.grey, "isn't ready");
case 'force-ready':
return (Icons.how_to_reg, Colors.orange, 'marked ${f.target ?? ''} ready');
case 'force-unready':
return (Icons.how_to_reg, Colors.orange, 'marked ${f.target ?? ''} not ready');
case 'force-play':
return (Icons.play_arrow, Colors.green, 'started playback for everyone');
case 'force-hold':
return (Icons.pause, Colors.orange, 'held playback for everyone');
case 'seek':
return (Icons.fast_forward, scheme.primary, 'seeked');
case 'swap':
return (Icons.swap_horiz, scheme.primary, 'changed the media');
case 'stale':
return (Icons.cloud_off, Colors.orange, 'lost signal');
case 'out':
final who = f.target;
return (Icons.bedtime, Colors.blueGrey, who == null || who.isEmpty ? 'is sitting out' : 'sat $who out');
case 'back':
return (Icons.login, Colors.green, 'joined back');
default:
final target = f.target == null || f.target!.isEmpty ? '' : ' ${f.target}';
return (Icons.info_outline, Colors.grey, '${f.action}$target');
}
}
/// The server's own lines (pause / resume).
(IconData, Color, String) serverFeedStyle(FeedMessage f) => switch (f.action) {
'resume' => (Icons.play_arrow, Colors.green, 'Playback resumed'),
_ => (Icons.pause, Colors.orange, 'Playback paused · waiting for readiness'),
};
String _clock(DateTime at) {
final l = at.toLocal();
String two(int n) => n.toString().padLeft(2, '0');
return '${two(l.hour)}:${two(l.minute)}';
}
@@ -0,0 +1,505 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:signals/signals_flutter.dart';
import 'package:sync_relay_core/core.dart' show RelayConstants;
import '../screens/home/connect_flow.dart';
import '../screens/home/connect_status.dart';
import '../screens/home/health_probe.dart';
import '../state/session_state.dart';
import 'motion.dart';
/// Join card text, owned by the shell so it survives the card closing.
class JoinFields {
JoinFields(ConnectForm f)
: host = TextEditingController(text: f.host),
port = TextEditingController(text: f.port),
secret = TextEditingController(text: f.secret);
final TextEditingController host;
final TextEditingController port;
final TextEditingController secret;
void dispose() {
host.dispose();
port.dispose();
secret.dispose();
}
}
/// Host card text, owned by the shell so it survives the card closing.
class HostFields {
HostFields(ConnectForm f)
: port = TextEditingController(text: f.hostPort),
secret = TextEditingController(text: f.hostSecret);
final TextEditingController port;
final TextEditingController secret;
void dispose() {
port.dispose();
secret.dispose();
}
}
/// The strip's Join / Host buttons (and solo's Invite friends, the Host
/// card): a card over the window. It closes itself once the attempt got
/// in; a failed one shows its error inside.
Future<void> showConnectCard(
BuildContext context, {
required ConnectMode mode,
required ConnectFlow flow,
required JoinFields join,
required HostFields host,
HealthGetter healthGet = defaultHealthGet,
}) =>
showPopover<void>(
context,
dim: true,
at: const Alignment(0.8, -0.75),
child: _CloseWhenOnline(
child: mode == ConnectMode.host
? HostCard(flow: flow, fields: host)
: JoinCard(flow: flow, fields: join, healthGet: healthGet),
),
);
class _CloseWhenOnline extends StatefulWidget {
final Widget child;
const _CloseWhenOnline({required this.child});
@override
State<_CloseWhenOnline> createState() => _CloseWhenOnlineState();
}
class _CloseWhenOnlineState extends State<_CloseWhenOnline> {
late final void Function() _stop;
@override
void initState() {
super.initState();
_stop = effect(() {
final s = SessionState.instance;
// Solo is in a session too: wait until it is a hosted server.
if (s.route.value != AppRoute.session || s.solo.value || s.switching.value) return;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) Navigator.of(context).maybePop();
});
});
}
@override
void dispose() {
_stop();
super.dispose();
}
@override
Widget build(BuildContext context) => widget.child;
}
/// Card frame shared by Join and Host.
class _Card extends StatelessWidget {
final String title;
final List<Widget> children;
const _Card({required this.title, required this.children});
@override
Widget build(BuildContext context) => Material(
elevation: 12,
borderRadius: BorderRadius.circular(14),
child: SizedBox(
width: 360,
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(title, style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 12),
...children,
],
),
),
),
);
}
const _dense = InputDecoration(isDense: true);
/// Recent servers as one-click rows (with live status), then address and
/// password.
class JoinCard extends StatelessWidget {
final ConnectFlow flow;
final JoinFields fields;
final HealthGetter healthGet;
const JoinCard({super.key, required this.flow, required this.fields, this.healthGet = defaultHealthGet});
void _join() => flow.submit(ConnectMode.join, (f) {
f
..host = fields.host.text
..port = fields.port.text.trim()
..secret = fields.secret.text;
});
void _joinRecent(RecentServer r) {
fields.host.text = r.host;
fields.port.text = r.port;
fields.secret.text = r.secret;
_join();
}
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: flow,
builder: (context, _) {
final busy = flow.busy == ConnectMode.join;
return _Card(
title: 'Join a friend',
children: [
SignalBuilder(builder: (context) {
final list = [
for (final r in SessionState.instance.recents.value)
if (!r.hosted) r
];
if (list.isEmpty) return const SizedBox.shrink();
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Text('Recent', style: TextStyle(color: Colors.grey)),
RecentServerRows(
servers: list,
enabled: flow.busy == null,
healthGet: healthGet,
onJoin: _joinRecent,
onForget: SessionState.instance.forgetServer,
),
const Divider(),
],
);
}),
AbsorbPointer(
absorbing: busy,
child: Column(
key: const ValueKey('join-row'),
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Expanded(
child: TextField(
key: const ValueKey('join-address'),
controller: fields.host,
autofocus: true,
decoration: _dense.copyWith(labelText: 'Address', hintText: 'IP or hostname'),
onChanged: (_) => flow.clearError(ConnectMode.join),
onSubmitted: (_) => _join(),
),
),
const SizedBox(width: 12),
SizedBox(
width: 72,
child: TextField(
key: const ValueKey('join-port'),
controller: fields.port,
decoration: _dense.copyWith(labelText: 'Port'),
keyboardType: TextInputType.number,
onSubmitted: (_) => _join(),
),
),
],
),
const SizedBox(height: 8),
TextField(
key: const ValueKey('join-password'),
controller: fields.secret,
obscureText: true,
decoration: _dense.copyWith(labelText: 'Password (optional)'),
onSubmitted: (_) => _join(),
),
],
),
),
const SizedBox(height: 16),
ConnectButton(flow: flow, mode: ConnectMode.join, label: 'Join', onSubmit: _join, expand: true),
ConnectFeedback(flow: flow, mode: ConnectMode.join),
const Padding(
padding: EdgeInsets.only(top: 8),
child: Text(
'Your friend gives you the address and password. No account or Jellyfin needed.',
style: TextStyle(color: Colors.grey, fontSize: 12),
),
),
],
);
},
);
}
}
/// Port and password; what to watch is picked afterwards, in the library.
class HostCard extends StatelessWidget {
final ConnectFlow flow;
final HostFields fields;
const HostCard({super.key, required this.flow, required this.fields});
void _host() => flow.submit(
ConnectMode.host,
(f) => f
..hostPort = fields.port.text.trim()
..hostSecret = fields.secret.text,
);
@override
Widget build(BuildContext context) {
// From a solo session this is "Invite friends": same server settings,
// and what plays carries over.
final solo = SessionState.instance.solo.peek();
return ListenableBuilder(
listenable: flow,
builder: (context, _) => _Card(
title: solo ? 'Invite friends' : 'Host a server',
children: [
AbsorbPointer(
absorbing: flow.busy == ConnectMode.host,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextField(
key: const ValueKey('host-port'),
controller: fields.port,
autofocus: true,
decoration: _dense.copyWith(labelText: 'Port', hintText: '8080'),
keyboardType: TextInputType.number,
onChanged: (_) => flow.clearError(ConnectMode.host),
onSubmitted: (_) => _host(),
),
const SizedBox(height: 8),
ListenableBuilder(
listenable: fields.secret,
builder: (context, _) => TextField(
key: const ValueKey('host-password'),
controller: fields.secret,
obscureText: true,
decoration: _dense.copyWith(
labelText: 'Password (optional)',
helperText: fields.secret.text.isEmpty ? 'No password — anyone can join' : null,
),
onSubmitted: (_) => _host(),
),
),
_HostedRecents(fields: fields, enabled: flow.busy == null),
],
),
),
const SizedBox(height: 16),
ConnectButton(
flow: flow, mode: ConnectMode.host, label: solo ? 'Start hosting' : 'Host', onSubmit: _host, expand: true),
ConnectFeedback(flow: flow, mode: ConnectMode.host),
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(
solo
? 'Your video keeps playing from where it is; friends join with your address.'
: 'You pick what to watch afterwards, in the library.',
style: const TextStyle(color: Colors.grey, fontSize: 12),
),
),
],
),
);
}
}
/// Ports hosted before: one click refills port and password.
class _HostedRecents extends StatelessWidget {
final HostFields fields;
final bool enabled;
const _HostedRecents({required this.fields, required this.enabled});
@override
Widget build(BuildContext context) {
return SignalBuilder(builder: (context) {
final list = [
for (final r in SessionState.instance.recents.value)
if (r.hosted) r
];
if (list.isEmpty) return const SizedBox.shrink();
return Padding(
padding: const EdgeInsets.only(top: 12),
child: Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final r in list)
InputChip(
key: ValueKey('recent-${r.key}'),
avatar: Icon(r.secret.isEmpty ? Icons.history : Icons.lock_outline, size: 16),
label: Text('Port ${r.port}'),
tooltip: 'Use port ${r.port} again',
isEnabled: enabled,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
onPressed: () {
fields.port.text = r.port;
fields.secret.text = r.secret;
},
deleteButtonTooltipMessage: 'Forget',
onDeleted: () => SessionState.instance.forgetServer(r),
),
],
),
);
});
}
}
/// Recent servers as rows; polls each one's `/health` every
/// [healthPollEvery] while mounted (i.e. while the Join card is open).
class RecentServerRows extends StatefulWidget {
final List<RecentServer> servers;
final bool enabled;
final HealthGetter healthGet;
final ValueChanged<RecentServer> onJoin;
final ValueChanged<RecentServer> onForget;
const RecentServerRows({
super.key,
required this.servers,
required this.enabled,
required this.healthGet,
required this.onJoin,
required this.onForget,
});
@override
State<RecentServerRows> createState() => _RecentServerRowsState();
}
class _RecentServerRowsState extends State<RecentServerRows> {
final _health = <String, ServerHealth>{};
final _inFlight = <String>{};
Timer? _timer;
@override
void initState() {
super.initState();
_pollAll();
_timer = Timer.periodic(healthPollEvery, (_) => _pollAll());
}
@override
void didUpdateWidget(RecentServerRows old) {
super.didUpdateWidget(old);
for (final r in widget.servers) {
if (!_health.containsKey(r.key)) _poll(r);
}
}
@override
void dispose() {
_timer?.cancel();
super.dispose();
}
void _pollAll() {
for (final r in widget.servers) {
_poll(r);
}
}
Future<void> _poll(RecentServer r) async {
if (!_inFlight.add(r.key)) return;
try {
final h = await probeHealth(r.host, r.port, widget.healthGet);
if (mounted) setState(() => _health[r.key] = h);
} finally {
_inFlight.remove(r.key);
}
}
@override
Widget build(BuildContext context) {
return Column(
children: [
for (final r in widget.servers)
_RecentRow(
key: ValueKey('recent-${r.key}'),
server: r,
health: _health[r.key],
enabled: widget.enabled,
onJoin: () => widget.onJoin(r),
onForget: () => widget.onForget(r),
),
],
);
}
}
class _RecentRow extends StatelessWidget {
final RecentServer server;
/// Null until the first probe answers.
final ServerHealth? health;
final bool enabled;
final VoidCallback onJoin;
final VoidCallback onForget;
const _RecentRow({
super.key,
required this.server,
required this.health,
required this.enabled,
required this.onJoin,
required this.onForget,
});
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final h = health;
final address = '${server.host}:${server.port}';
final title = h != null && h.name.isNotEmpty ? h.name : server.host;
final (status, statusColor) = switch (h) {
null => ('checking…', Colors.grey),
ServerHealth(online: true) => ('● online · ${h.viewers} watching', Colors.greenAccent.shade400),
_ => ('○ offline', Colors.grey),
};
return ListTile(
dense: true,
contentPadding: const EdgeInsets.only(left: 4),
enabled: enabled,
leading: Icon(server.secret.isEmpty ? Icons.history : Icons.lock_outline, size: 18),
title: Text(title, maxLines: 1, overflow: TextOverflow.ellipsis),
subtitle: Wrap(
spacing: 6,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
Text(address, style: const TextStyle(color: Colors.grey)),
Text(status, key: const ValueKey('recent-status'), style: TextStyle(color: statusColor)),
if (h != null && h.needsUpdate)
Tooltip(
message: 'Server protocol ${h.proto}, this app '
'${RelayConstants.protoVersion} — update so both match',
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
decoration: BoxDecoration(color: scheme.errorContainer, borderRadius: BorderRadius.circular(6)),
child: Text('needs update', style: TextStyle(fontSize: 11, color: scheme.onErrorContainer)),
),
),
],
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
tooltip: 'Forget',
visualDensity: VisualDensity.compact,
iconSize: 16,
onPressed: onForget,
icon: const Icon(Icons.close),
),
const Icon(Icons.login, size: 18),
],
),
onTap: onJoin,
);
}
}
+192
View File
@@ -0,0 +1,192 @@
import 'dart:io';
import 'package:flutter/material.dart';
import '../files/scanner.dart' show FileKind, kindOf;
import '../files/ui/file_tiles.dart' show baseName;
// Something handed to the app, by a drop or by Ctrl+V outside a text
// field: what it is ([classifyHandoff], pure) and the small card that asks
// how to open it ([HandoffCard]). The shell wires both.
/// What was dropped or pasted.
sealed class Handoff {
const Handoff();
}
/// Video files (by the Files page's extensions), in the order given.
final class HandoffVideos extends Handoff {
final List<String> paths;
const HandoffVideos(this.paths);
}
final class HandoffFolder extends Handoff {
final String path;
const HandoffFolder(this.path);
}
/// An http(s) link.
final class HandoffLink extends Handoff {
final String url;
const HandoffLink(this.url);
}
/// An image file. Nothing takes one yet; the shell ignores it.
final class HandoffImage extends Handoff {
final String path;
const HandoffImage(this.path);
}
final class HandoffNothing extends Handoff {
const HandoffNothing();
}
const _imageExtensions = {'png', 'jpg', 'jpeg', 'webp', 'gif', 'bmp'};
final _link = RegExp(r'^https?://\S+$', caseSensitive: false);
final _windowsAbsolute = RegExp(r'^([A-Za-z]:[\\/]|\\\\)');
bool _absolute(String p) => p.startsWith('/') || _windowsAbsolute.hasMatch(p);
/// A `file://` URI as a path; anything else as is.
String _pathOf(String p) {
if (!p.startsWith('file://')) return p;
try {
return Uri.parse(p).toFilePath();
} catch (_) {
return p;
}
}
/// Classifies dropped or pasted [paths] (files first) or, when there are
/// none, pasted [text]: a link, or one absolute path (or `file://` URI) per
/// line. Videos win over folders, folders over images; paths that do not
/// exist ([typeOf], defaults to the file system) count for nothing.
Handoff classifyHandoff({
List<String> paths = const [],
String? text,
FileSystemEntityType Function(String path)? typeOf,
}) {
final type = typeOf ?? (p) => FileSystemEntity.typeSync(p);
var list = [for (final p in paths) if (p.trim().isNotEmpty) _pathOf(p.trim())];
if (list.isEmpty && text != null) {
final t = text.trim();
if (_link.hasMatch(t)) return HandoffLink(t);
final lines = [for (final l in t.split(RegExp(r'[\r\n]+'))) if (l.trim().isNotEmpty) _pathOf(l.trim())];
if (lines.isEmpty || !lines.every(_absolute)) return const HandoffNothing();
list = lines;
}
final types = {for (final p in list) p: type(p)};
bool file(String p) => types[p] == FileSystemEntityType.file;
final videos = [for (final p in list) if (file(p) && kindOf(p) == FileKind.video) p];
if (videos.isNotEmpty) return HandoffVideos(videos);
for (final p in list) {
if (types[p] == FileSystemEntityType.directory) return HandoffFolder(p);
}
for (final p in list) {
final dot = p.lastIndexOf('.');
if (file(p) && dot >= 0 && _imageExtensions.contains(p.substring(dot + 1).toLowerCase())) {
return HandoffImage(p);
}
}
return const HandoffNothing();
}
/// What the card's buttons ask the shell to do.
enum HandoffAction {
/// Put the (first) video up (offline: watch it solo).
putUp,
/// Add the video's folder (or the folder) to Files and open it.
addFolder,
/// Open Library → Link with the URL filled.
openLink,
}
/// The card asking how to open a [Handoff] (videos, a folder or a link);
/// pops the [HandoffAction] chosen.
class HandoffCard extends StatelessWidget {
final Handoff item;
/// Nobody else watches (offline or solo): videos play, no relay talk.
final bool solo;
const HandoffCard({super.key, required this.item, required this.solo});
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
void pick(HandoffAction a) => Navigator.of(context).pop(a);
Widget button(HandoffAction a, IconData icon, String label, {bool primary = false}) => primary
? FilledButton.icon(
key: ValueKey('handoff-${a.name}'), onPressed: () => pick(a), icon: Icon(icon, size: 18), label: Text(label))
: OutlinedButton.icon(
key: ValueKey('handoff-${a.name}'), onPressed: () => pick(a), icon: Icon(icon, size: 18), label: Text(label));
final (IconData icon, String title, String detail, List<Widget> buttons) = switch (item) {
HandoffVideos(:final paths) => (
Icons.movie_outlined,
baseName(paths.first),
paths.length > 1 ? 'and ${paths.length - 1} more · the first one goes up' : 'A video on this computer',
[
button(HandoffAction.putUp, Icons.play_arrow, solo ? 'Play' : 'Put it up', primary: true),
button(HandoffAction.addFolder, Icons.create_new_folder_outlined, 'Add its folder to Files'),
],
),
HandoffFolder(:final path) => (
Icons.folder_outlined,
baseName(path),
path,
[button(HandoffAction.addFolder, Icons.create_new_folder_outlined, 'Add to Files', primary: true)],
),
HandoffLink(:final url) => (
Icons.link,
url,
solo ? 'Opens it on the Link page' : 'Pick how the others get it on the Link page',
[
button(HandoffAction.openLink, Icons.link, solo ? 'Open in Link' : 'Put it up as a link…',
primary: true),
],
),
HandoffImage() || HandoffNothing() => (Icons.help_outline, '', '', const <Widget>[]),
};
return Material(
key: const ValueKey('handoff-card'),
elevation: 12,
color: scheme.surfaceContainer,
borderRadius: BorderRadius.circular(18),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 380),
child: Padding(
padding: const EdgeInsets.fromLTRB(18, 16, 18, 16),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(icon, color: scheme.primary),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.titleSmall),
Text(detail,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12)),
],
),
),
],
),
const SizedBox(height: 14),
Wrap(spacing: 8, runSpacing: 8, children: buttons),
],
),
),
),
);
}
}
@@ -0,0 +1,626 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:sync_relay_core/core.dart' show LinkProbe, hasMediaExtension, notAVideoText;
import '../playback/yt_dlp_app.dart';
import '../screens/home/media_picker.dart';
import '../state/connect_prefs.dart';
import 'motion.dart';
// The Library's Link source: a big paste field, a preview of what the link
// is, how the others get it (in a server), and the recent links.
const maxRecentLinks = 12;
const _keyRecentLinks = 'recentLinks';
/// A link put up before, newest first in [RecentLinks].
class RecentLink {
final String url;
final String title;
final String? thumbnail;
final DateTime at;
const RecentLink({required this.url, required this.title, this.thumbnail, required this.at});
Map<String, Object?> toJson() =>
{'url': url, 'title': title, 'thumb': ?thumbnail, 'at': at.millisecondsSinceEpoch};
static RecentLink? fromJson(Object? j) {
if (j is! Map) return null;
final url = j['url'], title = j['title'], at = j['at'], thumb = j['thumb'];
if (url is! String || url.isEmpty) return null;
return RecentLink(
url: url,
title: title is String && title.isNotEmpty ? title : LinkMedia(url).label,
thumbnail: thumb is String && thumb.isNotEmpty ? thumb : null,
at: DateTime.fromMillisecondsSinceEpoch(at is int ? at : 0),
);
}
}
/// [link] first, its older copy dropped, at most [maxRecentLinks].
List<RecentLink> pushRecentLink(List<RecentLink> list, RecentLink link) =>
[link, ...list.where((l) => l.url != link.url)].take(maxRecentLinks).toList();
String encodeRecentLinks(List<RecentLink> list) => jsonEncode([for (final l in list) l.toJson()]);
List<RecentLink> decodeRecentLinks(Object? raw) {
try {
final j = raw is String ? jsonDecode(raw) : raw;
if (j is! List) return const [];
return [for (final e in j) ?RecentLink.fromJson(e)].take(maxRecentLinks).toList();
} catch (_) {
return const [];
}
}
/// The recent links, kept in the `connect_form` box next to the last link.
class RecentLinks {
static final RecentLinks instance = RecentLinks();
RecentLinks({Future<Object?> Function()? read, Future<void> Function(String value)? write})
: _read = read ?? _hiveRead,
_write = write ?? _hiveWrite;
final Future<Object?> Function() _read;
final Future<void> Function(String value) _write;
final links = ValueNotifier<List<RecentLink>>(const []);
Future<void>? _load;
static Future<Object?> _hiveRead() async =>
(connectFormBoxIfOpen() ?? await openConnectFormBox()).get(_keyRecentLinks);
static Future<void> _hiveWrite(String value) async =>
(connectFormBoxIfOpen() ?? await openConnectFormBox()).put(_keyRecentLinks, value);
Future<void> ensureLoaded() => _load ??= () async {
try {
final read = decodeRecentLinks(await _read());
// Something added meanwhile stays on top.
var all = read;
for (final l in links.value.reversed) {
all = pushRecentLink(all, l);
}
links.value = all;
} catch (e) {
debugPrint('load recent links failed: $e');
}
}();
Future<void> add(RecentLink link) async {
links.value = pushRecentLink(links.value, link);
try {
await _write(encodeRecentLinks(links.value));
} catch (e) {
debugPrint('save recent links failed: $e');
}
}
}
// ------------------------------------------------------------- preview
/// What a pasted link is, for the preview card and the recent list.
class LinkPreview {
final String title;
/// "youtube.com · Channel", or the host.
final String subtitle;
final String? thumbnail;
const LinkPreview({required this.title, required this.subtitle, this.thumbnail});
}
/// What a link is (see [AppYtDlp.probe]).
typedef LinkProber = Future<LinkProbe> Function(String url);
Future<LinkProbe> defaultLinkProber(String url) => AppYtDlp.instance.probe(url);
String _host(Uri uri) => uri.host.startsWith('www.') ? uri.host.substring(4) : uri.host;
/// "3:07", "1:02:03".
String clockText(double seconds) {
final s = seconds.round();
final h = s ~/ 3600, m = s % 3600 ~/ 60, sec = (s % 60).toString().padLeft(2, '0');
return h > 0 ? '$h:${m.toString().padLeft(2, '0')}:$sec' : '$m:$sec';
}
/// The preview of [url]: a web page shows yt-dlp's title, uploader,
/// duration and thumbnail (or why it is not a video); a media file (by
/// name or by probe) its file name and host. Null for text that is not an http(s) link.
Future<LinkPreview?> previewLink(String url, {LinkProber probe = defaultLinkProber}) async {
final uri = Uri.tryParse(url.trim());
if (uri == null || !(uri.scheme == 'http' || uri.scheme == 'https') || uri.host.isEmpty) return null;
final host = _host(uri);
// A media file name needs no lookup.
if (hasMediaExtension(uri)) return LinkPreview(title: LinkMedia(url.trim()).label, subtitle: host);
final LinkProbe p;
try {
p = await probe(url.trim());
} catch (e) {
return LinkPreview(title: LinkMedia(url.trim()).label, subtitle: '$host · $e');
}
final info = p.info;
if (!p.isPage) return LinkPreview(title: LinkMedia(url.trim()).label, subtitle: host);
if (info == null) {
return LinkPreview(title: p.title ?? host, subtitle: '$host · ${notAVideoText(p.ytDlpError)}');
}
return LinkPreview(
title: p.title ?? host,
subtitle: [
host,
if (info.uploader.isNotEmpty) info.uploader,
if (info.duration case final d? when d > 0) clockText(d),
].join(' · '),
thumbnail: info.thumbnail,
);
}
/// "today", "yesterday", "3 days ago", "last week", "5 weeks ago", ...
String agoText(DateTime at, DateTime now) {
final d = now.difference(at).inDays;
if (d < 1) return 'today';
if (d < 2) return 'yesterday';
if (d < 7) return '$d days ago';
if (d < 14) return 'last week';
if (d < 60) return '${d ~/ 7} weeks ago';
if (d < 365) return '${d ~/ 30} months ago';
return 'over a year ago';
}
// -------------------------------------------------------------- widget
const _modes = [
(LinkRelay.direct, Icons.groups_outlined, 'Everyone fetches it', 'Each app plays the link itself.'),
(
LinkRelay.server,
Icons.dns_outlined,
'Server fetches it',
'Downloaded once by the server. For links slow or blocked for some.'
),
(LinkRelay.me, Icons.cell_tower, 'Through me', 'Your computer passes it on. For links only you can open.'),
];
String linkRelayHelp(LinkRelay r) => switch (r) {
LinkRelay.direct => 'Everyone streams the link themselves. Jellyfin links are shared as the item, '
'without your sign-in.',
LinkRelay.server => 'The server downloads it once and streams it to everyone. It must be able to '
'reach the link; the link is not shown to anyone.',
LinkRelay.me => 'This computer downloads it and passes it on through the server. Keep the app open '
'while it plays; the link is not shown to anyone.',
};
/// Under Play when nobody else watches.
const soloPlayHint = 'Plays just for you. Invite friends any time; it keeps playing.';
/// The Link source. [solo]: nobody else watches (offline or solo), so the
/// button says Play and there are no relay modes (the link plays direct).
class LibraryLinkSource extends StatefulWidget {
final MediaPickerController controller;
final bool solo;
/// Puts the link up; returns an error text, or null when sent.
final Future<String?> Function(LinkMedia media) onPutUp;
final RecentLinks recents;
final LinkProber probe;
const LibraryLinkSource({
super.key,
required this.controller,
required this.onPutUp,
required this.recents,
this.solo = false,
this.probe = defaultLinkProber,
});
@override
State<LibraryLinkSource> createState() => _LibraryLinkSourceState();
}
class _LibraryLinkSourceState extends State<LibraryLinkSource> {
MediaPickerController get _c => widget.controller;
final _previews = <String, LinkPreview?>{};
String _previewUrl = '';
Timer? _debounce;
bool _busy = false;
String? _error;
@override
void initState() {
super.initState();
widget.recents.ensureLoaded();
_c.link.addListener(_typed);
_typed();
}
@override
void dispose() {
_debounce?.cancel();
_c.link.removeListener(_typed);
super.dispose();
}
void _typed() {
final url = _c.link.text.trim();
if (url == _previewUrl) return;
_debounce?.cancel();
_debounce = Timer(const Duration(milliseconds: 350), () => _preview(url));
if (mounted) setState(() => _error = null);
}
Future<void> _preview(String url) async {
_previewUrl = url;
if (url.isEmpty || _previews.containsKey(url)) {
if (mounted) setState(() {});
return;
}
final p = await previewLink(url, probe: widget.probe);
_previews[url] = p;
if (mounted) setState(() {});
}
Future<void> _paste() async {
try {
final text = (await Clipboard.getData(Clipboard.kTextPlain))?.text?.trim();
if (text != null && text.isNotEmpty) _c.link.text = text;
} catch (_) {}
}
Future<void> _go() async {
final url = _c.link.text.trim();
if (url.isEmpty || _busy) return;
setState(() {
_busy = true;
_error = null;
});
String? error;
try {
error = await widget.onPutUp(LinkMedia(url, relay: widget.solo ? LinkRelay.direct : _c.linkRelay));
} catch (e) {
error = '$e';
}
if (error == null) {
final p = _previews[url] ?? await previewLink(url, probe: widget.probe);
unawaited(widget.recents.add(RecentLink(
url: url,
title: p?.title ?? LinkMedia(url).label,
thumbnail: p?.thumbnail,
at: DateTime.now(),
)));
}
if (!mounted) return;
setState(() {
_busy = false;
_error = error;
});
}
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final t = Theme.of(context).textTheme;
final solo = widget.solo;
final url = _c.link.text.trim();
final preview = url.isEmpty ? null : _previews[url];
final loading = url.isNotEmpty && !_previews.containsKey(url);
final field = TextField(
key: const ValueKey('link-field'),
controller: _c.link,
style: const TextStyle(fontSize: 16),
keyboardType: TextInputType.url,
onSubmitted: (_) => _go(),
decoration: InputDecoration(
hintText: 'https://…',
prefixIcon: const Icon(Icons.link),
suffixIcon: IconButton(onPressed: _paste, tooltip: 'Paste', icon: const Icon(Icons.content_paste)),
filled: true,
border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
),
);
final button = FilledButton.icon(
key: const ValueKey('link-put-up'),
onPressed: url.isEmpty || _busy ? null : _go,
icon: _busy
? const SizedBox.square(dimension: 16, child: CircularProgressIndicator(strokeWidth: 2))
: const Icon(Icons.play_arrow),
label: Text(solo ? 'Play' : 'Put it up'),
style: FilledButton.styleFrom(
minimumSize: const Size(0, 56),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
),
);
return SingleChildScrollView(
key: const ValueKey('media-link'),
padding: const EdgeInsets.fromLTRB(24, 32, 24, 32),
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 780),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Center(
child: Container(
width: 64,
height: 64,
decoration: BoxDecoration(
color: scheme.primary.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(18),
),
child: Icon(Icons.link, size: 32, color: scheme.primary),
),
),
const SizedBox(height: 14),
Text('Watch from a link', textAlign: TextAlign.center, style: t.headlineSmall),
const SizedBox(height: 6),
Text(
'A video file, a page (YouTube, X, …) or a Jellyfin link. Ctrl+V or drop it anywhere to bring it here.',
textAlign: TextAlign.center,
style: TextStyle(color: scheme.onSurfaceVariant),
),
const SizedBox(height: 24),
LayoutBuilder(
builder: (context, box) => box.maxWidth < 460
? Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [
field,
const SizedBox(height: 10),
button,
])
: Row(children: [Expanded(child: field), const SizedBox(width: 12), button]),
),
if (solo || _error != null) ...[
const SizedBox(height: 8),
Text(
_error ?? soloPlayHint,
key: const ValueKey('link-hint'),
textAlign: TextAlign.center,
style: t.bodySmall?.copyWith(color: _error != null ? scheme.error : Colors.grey),
),
],
AnimatedSize(
duration: easeTime,
curve: ease,
child: url.isEmpty
? const SizedBox(width: double.infinity)
: Padding(
padding: const EdgeInsets.only(top: 14),
child: _PreviewCard(preview: preview, loading: loading),
),
),
if (!solo) ...[
const SizedBox(height: 28),
Text('How the others get it', style: t.titleSmall),
const SizedBox(height: 10),
LayoutBuilder(builder: (context, box) {
final cards = [
for (final m in _modes)
_ModeCard(
key: ValueKey('link-mode-${m.$1.name}'),
icon: m.$2,
title: m.$3,
sub: m.$4,
selected: _c.linkRelay == m.$1,
onTap: () => setState(() => _c.linkRelay = m.$1),
),
];
if (box.maxWidth < 560) {
return Column(children: [
for (final (i, c) in cards.indexed) ...[if (i > 0) const SizedBox(height: 8), c],
]);
}
return IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (final (i, c) in cards.indexed) ...[
if (i > 0) const SizedBox(width: 10),
Expanded(child: c),
],
],
),
);
}),
const SizedBox(height: 8),
Text(linkRelayHelp(_c.linkRelay), style: t.bodySmall?.copyWith(color: Colors.grey)),
],
ValueListenableBuilder<List<RecentLink>>(
valueListenable: widget.recents.links,
builder: (context, recents, _) => recents.isEmpty
? const SizedBox.shrink()
: Padding(
padding: const EdgeInsets.only(top: 32),
child: _RecentGrid(recents: recents, onPick: (l) => _c.link.text = l.url),
),
),
],
),
),
),
);
}
}
class _PreviewCard extends StatelessWidget {
final LinkPreview? preview;
final bool loading;
const _PreviewCard({required this.preview, required this.loading});
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final p = preview;
return Container(
key: const ValueKey('link-preview'),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(color: scheme.surfaceContainerLow, borderRadius: BorderRadius.circular(16)),
child: Row(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(10),
child: SizedBox(width: 160, height: 90, child: _Thumb(url: p?.thumbnail)),
),
const SizedBox(width: 16),
Expanded(
child: loading
? const Align(
alignment: Alignment.centerLeft,
child: SizedBox.square(dimension: 18, child: CircularProgressIndicator(strokeWidth: 2)),
)
: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
p?.title ?? 'Not a link',
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 16),
),
const SizedBox(height: 4),
Text(
p?.subtitle ?? 'Paste an http(s) address',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Colors.grey),
),
],
),
),
],
),
);
}
}
class _Thumb extends StatelessWidget {
final String? url;
const _Thumb({required this.url});
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final fallback = ColoredBox(
color: scheme.surfaceContainerHighest,
child: const Center(child: Icon(Icons.movie_outlined, color: Colors.grey)),
);
final u = url;
if (u == null) return fallback;
return Image.network(u, fit: BoxFit.cover, errorBuilder: (_, _, _) => fallback);
}
}
class _ModeCard extends StatelessWidget {
final IconData icon;
final String title, sub;
final bool selected;
final VoidCallback onTap;
const _ModeCard({
super.key,
required this.icon,
required this.title,
required this.sub,
required this.selected,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return Semantics(
selected: selected,
button: true,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(14),
child: AnimatedContainer(
duration: easeTime,
curve: ease,
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: selected ? scheme.primary.withValues(alpha: 0.10) : scheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: selected ? scheme.primary : Colors.transparent, width: 2),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(icon, color: selected ? scheme.primary : scheme.onSurfaceVariant),
const SizedBox(height: 8),
Text(title, style: TextStyle(fontWeight: FontWeight.w600, color: selected ? scheme.primary : null)),
const SizedBox(height: 2),
Text(sub,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Colors.grey, fontSize: 12)),
],
),
),
),
);
}
}
class _RecentGrid extends StatelessWidget {
final List<RecentLink> recents;
final ValueChanged<RecentLink> onPick;
const _RecentGrid({required this.recents, required this.onPick});
@override
Widget build(BuildContext context) {
final t = Theme.of(context).textTheme;
final now = DateTime.now();
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('Recent', style: t.titleSmall),
const SizedBox(height: 10),
LayoutBuilder(builder: (context, box) {
const gap = 12.0;
final cols = box.maxWidth > 600 ? 3 : 2;
final w = (box.maxWidth - gap * (cols - 1)) / cols;
return Wrap(
spacing: gap,
runSpacing: gap,
children: [
for (final l in recents)
SizedBox(
width: w,
child: Tooltip(
message: l.url,
waitDuration: const Duration(milliseconds: 600),
child: InkWell(
key: ValueKey('recent-link-${l.url}'),
onTap: () => onPick(l),
borderRadius: BorderRadius.circular(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AspectRatio(
aspectRatio: 16 / 9,
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: _Thumb(url: l.thumbnail),
),
),
const SizedBox(height: 6),
Text(l.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontWeight: FontWeight.w600)),
Text(
[_host(Uri.tryParse(l.url) ?? Uri()), agoText(l.at, now)]
.where((s) => s.isNotEmpty)
.join(' · '),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Colors.grey, fontSize: 12),
),
],
),
),
),
),
],
);
}),
],
);
}
}
+408
View File
@@ -0,0 +1,408 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:signals/signals_flutter.dart';
import 'package:sync_relay_core/core.dart' show HelloMedia, JellyfinException, JellyfinItem;
import '../files/ui/files_page.dart';
import '../jellyfin/accounts.dart';
import '../jellyfin/browse_scope.dart';
import '../jellyfin/details_panel.dart';
import '../jellyfin/format.dart' show pickFor;
import '../jellyfin/home_page.dart' show libraryIcon, isVideoLibrary;
import '../jellyfin/picker_source.dart';
import '../jellyfin/session_media.dart';
import '../screens/home/media_picker.dart';
import '../state/session_state.dart';
import 'library_link.dart';
import 'motion.dart';
import 'welcome.dart';
/// What putting up a Jellyfin pick sends: its ref (no URL, no token).
/// Throws [FormatException] with a user-facing message when the server is
/// not linked any more or its sign-in expired.
HelloMedia putUpJellyfinMedia(JellyfinAccounts accounts, JellyfinMedia media) {
if (accounts.account(media.serverId) == null) {
throw const FormatException('that Jellyfin server is not linked any more — pick again');
}
if (accounts.statusOf(media.serverId) == JellyfinAccountStatus.expired) {
throw const FormatException('Jellyfin sign-in expired — reconnect it in Settings → Jellyfin & integrations');
}
return mediaForPick(media);
}
/// Brings back the last Jellyfin pick (kept in the Jellyfin box) once the
/// accounts are read, unless something else was picked meanwhile or
/// [alive] says the picker is gone.
Future<void> restoreLastPick(
MediaPickerController c, JellyfinAccounts accounts, bool Function() alive) async {
final hadMedia = c.selection != null;
try {
await accounts.ensureLoaded();
} catch (_) {
return;
}
final pick = accounts.lastPick;
if (!alive() || pick == null || c.jellyfin != null) return;
if (accounts.account(pick.serverId) == null) return;
final source = c.source;
c.pickJellyfin(pick);
if (hadMedia) c.source = source;
}
/// The file playing now, when it is the one put up from here last: the
/// server's current media (state JSON) is a plain file (no link, no
/// Jellyfin ref) named like [lastPath].
String? nowPlayingFile(Map<String, Object?>? media, String lastPath) {
if (media == null || media['url'] != null || media['jellyfin'] != null || lastPath.isEmpty) return null;
final name = lastPath.split(RegExp(r'[/\\]')).lastWhere((p) => p.isNotEmpty, orElse: () => lastPath);
return name == media['name'] ? lastPath : null;
}
/// Window width under which the details panel floats over the library.
const detailsDockFrom = 1000.0;
/// The Library tab: a source switch (Jellyfin / Link / Files, fading
/// through), the Jellyfin library chips beside it, and the source below.
/// Jellyfin cards select on click and open their details in a panel at
/// the right (docked from [detailsDockFrom], floating over the library
/// below it); a double-click or the panel's button puts it up. With
/// nobody else watching ([solo]: offline or a solo session) the buttons
/// say Play and putting something up starts (or uses) a solo session.
class LibraryTab extends StatefulWidget {
final MediaPickerController controller;
final JellyfinAccounts accounts;
final bool solo;
/// This app hosts the server: its files stream from here directly.
final bool isHost;
final BrowseStart? jellyfinStart;
/// Puts the pick up; returns an error text, or null when sent.
final Future<String?> Function(PickedMedia media) onPutUp;
final VoidCallback? onPutUpDone;
final VoidCallback? onOpenIntegrations;
/// Defaults to [RecentLinks.instance].
final RecentLinks? recentLinks;
final LinkProber probe;
/// The Files source (tests inject one); defaults to [FilesPage].
final Widget Function(BuildContext context, {required bool solo, required bool relayed,
required Future<String?> Function(String path) onPlay, String? nowPlayingPath})? filesBuilder;
/// Clock for the Jellyfin Continue row (tests pin it).
final DateTime Function() now;
/// Over the source switch (the shell's welcome band while offline).
final Widget? banner;
const LibraryTab({
super.key,
required this.controller,
required this.accounts,
required this.solo,
required this.isHost,
required this.onPutUp,
this.onPutUpDone,
this.jellyfinStart,
this.onOpenIntegrations,
this.recentLinks,
this.probe = defaultLinkProber,
this.filesBuilder,
this.now = DateTime.now,
this.banner,
});
@override
State<LibraryTab> createState() => _LibraryTabState();
}
class _LibraryTabState extends State<LibraryTab> {
late final BrowseController _browse = BrowseController(widget.accounts, now: widget.now);
/// What the closing panel keeps showing while it slides out.
JellyfinSelection? _shown;
MediaPickerController get _c => widget.controller;
@override
void initState() {
super.initState();
_c.addListener(_changed);
_browse.addListener(_changed);
_shown = _c.selected;
}
@override
void didUpdateWidget(LibraryTab old) {
super.didUpdateWidget(old);
if (old.controller != widget.controller) {
old.controller.removeListener(_changed);
widget.controller.addListener(_changed);
}
}
@override
void dispose() {
_c.removeListener(_changed);
_browse.dispose();
super.dispose();
}
void _changed() {
if (!mounted) return;
setState(() {
if (_c.selected != null) _shown = _c.selected;
});
}
// ------------------------------------------------------------- put up
Future<String?> _put(PickedMedia media) async {
String? error;
try {
error = await widget.onPutUp(media);
} catch (e) {
error = '$e';
}
if (error == null) widget.onPutUpDone?.call();
return error;
}
/// A Jellyfin card or episode: a series goes up as its next episode.
Future<String?> _putUpJellyfin(JellyfinSelection sel) async {
final account = widget.accounts.account(sel.serverId);
final client = widget.accounts.clientFor(sel.serverId);
if (account == null || client == null) return 'that Jellyfin server is not linked any more';
JellyfinItem? item = sel.item;
try {
item ??= await client.item(sel.itemId);
item = await playableFor(client, item);
} on JellyfinException catch (e) {
return e.message;
}
if (item == null) return 'nothing to play in there';
return _put(pickFor(account, item));
}
/// Double-click on a card: put it up, errors as a snack bar.
Future<void> _doublePutUp(JellyfinSelection sel) async {
_c.select(sel);
final error = await _putUpJellyfin(sel);
if (error != null && mounted) {
ScaffoldMessenger.maybeOf(context)?.showSnackBar(SnackBar(content: Text(error)));
}
}
// -------------------------------------------------------------- build
@override
Widget build(BuildContext context) {
final source = _c.source;
final header = Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 0),
child: LayoutBuilder(builder: (context, box) {
// Narrow: icons only, so the chips keep some room.
final labels = box.maxWidth >= 620;
ButtonSegment<MediaKind> seg(MediaKind k, IconData icon, String label) => ButtonSegment(
value: k, icon: Icon(icon), label: labels ? Text(label) : null, tooltip: labels ? null : label);
return Row(
children: [
SegmentedButton<MediaKind>(
key: const ValueKey('media-source'),
showSelectedIcon: false,
segments: [
seg(MediaKind.jellyfin, Icons.video_library_outlined, 'Jellyfin'),
seg(MediaKind.link, Icons.link, 'Link'),
seg(MediaKind.file, Icons.folder_outlined, 'Files'),
],
selected: {source},
onSelectionChanged: (s) => _c.source = s.first,
),
const SizedBox(width: 16),
Expanded(
child: AnimatedSwitcher(
duration: easeTime,
child: source == MediaKind.jellyfin
? _LibraryChips(key: const ValueKey('chips'), controller: _browse)
: const SizedBox(height: 40),
),
),
],
);
}),
);
final Widget content = switch (source) {
MediaKind.jellyfin => Padding(
padding: const EdgeInsets.fromLTRB(20, 12, 20, 0),
child: JellyfinPickerSource(
accounts: widget.accounts,
controller: _browse,
selected: _c.selected,
onSelect: _c.select,
onPutUp: _doublePutUp,
onOpenIntegrations: widget.onOpenIntegrations,
start: widget.jellyfinStart,
libraryStrip: false,
empty: (context) => NoJellyfinPanel(
accounts: widget.accounts,
onFiles: () => _c.source = MediaKind.file,
onLink: () => _c.source = MediaKind.link,
),
),
),
MediaKind.link => LibraryLinkSource(
controller: _c,
solo: widget.solo,
recents: widget.recentLinks ?? RecentLinks.instance,
probe: widget.probe,
onPutUp: _put,
),
MediaKind.file => _files(context),
};
final main = Column(
children: [
AnimatedSize(
duration: easeTime,
curve: ease,
alignment: Alignment.topCenter,
child: AnimatedSwitcher(
duration: easeTime,
child: widget.banner ?? const SizedBox(width: double.infinity),
),
),
header,
Expanded(child: fadeThrough(KeyedSubtree(key: ValueKey(source), child: content))),
],
);
final open = source == MediaKind.jellyfin && _c.selected != null;
final shown = _shown;
final details = shown == null
? const SizedBox.shrink()
: fadeThrough(KeyedSubtree(
key: ValueKey('${shown.serverId}/${shown.itemId}'),
child: JellyfinDetailsPanel(
controller: _browse,
selection: shown,
solo: widget.solo,
onPutUp: _putUpJellyfin,
onClose: () => _c.select(null),
),
));
Widget slide(Widget child) => AnimatedSlide(
duration: easeTime,
curve: ease,
offset: open ? Offset.zero : const Offset(1, 0),
child: AnimatedOpacity(duration: easeTime, curve: ease, opacity: open ? 1 : 0, child: child),
);
// Holds focus so the shell's shortcuts (Ctrl+,) work.
return Focus(
key: const ValueKey('library-tab'),
autofocus: true,
child: LayoutBuilder(builder: (context, box) {
if (box.maxWidth >= detailsDockFrom) {
return Row(
children: [
Expanded(child: main),
ClipRect(
child: AnimatedContainer(
duration: easeTime,
curve: ease,
width: open ? detailsPanelWidth : 0,
child: OverflowBox(
alignment: Alignment.centerLeft,
minWidth: detailsPanelWidth,
maxWidth: detailsPanelWidth,
child: slide(details),
),
),
),
],
);
}
return Stack(
children: [
Positioned.fill(child: main),
Positioned(
top: 0,
bottom: 0,
right: 0,
width: detailsPanelWidth,
child: IgnorePointer(
ignoring: !open,
child: slide(Material(elevation: 16, child: details)),
),
),
],
);
}),
);
}
Widget _files(BuildContext context) {
final relayed = !widget.isHost;
Future<String?> play(String path) => _put(FileMedia(path));
return SignalBuilder(builder: (context) {
final session = SessionState.instance;
final now = session.route.value == AppRoute.session
? nowPlayingFile(session.coordinator.serverState.value?.media, session.lastForm.mediaPath)
: null;
final b = widget.filesBuilder;
if (b != null) {
return b(context, solo: widget.solo, relayed: relayed, onPlay: play, nowPlayingPath: now);
}
return FilesPage(solo: widget.solo, relayed: relayed, onPlay: play, nowPlayingPath: now);
});
}
}
/// The current server's video libraries as chips in the Library header; a
/// click opens one (the chip of the library open is selected).
class _LibraryChips extends StatelessWidget {
final BrowseController controller;
const _LibraryChips({super.key, required this.controller});
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: Listenable.merge([controller, controller.accounts]),
builder: (context, _) {
final accounts = controller.accounts;
final account = accounts.loaded ? controller.account : null;
final client = account == null ? null : accounts.clientFor(account.serverId);
if (account == null || client == null) return const SizedBox(height: 40);
final stack = controller.stack;
final openId = stack.length > 1 && stack[1] is LibraryRoute ? (stack[1] as LibraryRoute).folder.id : null;
return FutureBuilder<List<JellyfinItem>>(
future: accounts.cached(account.serverId, 'views', client.userViews),
builder: (context, snap) {
final views = (snap.data ?? const <JellyfinItem>[]).where(isVideoLibrary).toList();
return SizedBox(
height: 40,
child: HScroll(
builder: (c) => ListView.separated(
controller: c,
scrollDirection: Axis.horizontal,
itemCount: views.length,
separatorBuilder: (_, _) => const SizedBox(width: 8),
itemBuilder: (context, i) {
final v = views[i];
return Center(
child: ChoiceChip(
key: ValueKey('library-${v.id}'),
avatar: Icon(libraryIcon(v), size: 16),
showCheckmark: false,
label: Text(v.name),
selected: v.id == openId,
onSelected: (_) => v.id == openId
? controller.popTo(0)
: controller.openTop(LibraryRoute(v)),
),
);
},
),
),
);
},
);
},
);
}
}
+137
View File
@@ -0,0 +1,137 @@
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
// The shell's shared motion: one curve and one duration for everything
// that moves, a fade-through for views that replace each other, popovers
// that grow from where they open, and mouse-friendly horizontal lists.
const ease = Curves.fastEaseInToSlowEaseOut;
const easeTime = Duration(milliseconds: 320);
/// Fade-through length; the old view is gone after [fadeOutPart] of it.
const fadeThroughTime = Duration(milliseconds: 360);
const fadeOutPart = 0.35;
/// Fade through (Material motion): the old view is gone after the first
/// 35%, then the new one fades and scales in; they never overlap. Key
/// [child] by what it shows so a change of key runs the transition.
/// [expand] false: sized by the views (at [alignment]) instead of filling
/// the box, for unbounded places (a row, a pill).
Widget fadeThrough(Widget child,
{Key? key, bool expand = true, AlignmentGeometry alignment = AlignmentDirectional.topStart}) =>
AnimatedSwitcher(
key: key,
duration: fadeThroughTime,
layoutBuilder: (current, previous) => Stack(
fit: expand ? StackFit.expand : StackFit.loose,
alignment: alignment,
children: [...previous, ?current],
),
transitionBuilder: (c, anim) {
if (c.key == child.key) {
// Incoming (anim 0 -> 1): wait, then fade and scale up.
final a = CurvedAnimation(
parent: anim,
curve: const Interval(fadeOutPart, 1, curve: ease),
);
return FadeTransition(
opacity: a,
child: ScaleTransition(scale: Tween(begin: 0.985, end: 1.0).animate(a), child: c),
);
}
// Outgoing (anim 1 -> 0): fully faded by the time it reaches 0.65.
// Same widget shape as the incoming branch, or the old view would
// remount (and lose its state) while it fades.
return FadeTransition(
opacity: CurvedAnimation(
parent: anim,
curve: const Interval(1 - fadeOutPart, 1, curve: Curves.easeIn),
),
child: ScaleTransition(scale: const AlwaysStoppedAnimation(1.0), child: c),
);
},
child: child,
);
/// A card over the window that fades and scales in from [at] (window
/// alignment) instead of popping; a click outside or Esc closes it. Short
/// windows: the card scrolls instead of overflowing.
Future<T?> showPopover<T>(
BuildContext context, {
required Alignment at,
required Widget child,
bool dim = false,
}) =>
showGeneralDialog<T>(
context: context,
barrierDismissible: true,
barrierLabel: 'close',
barrierColor: dim ? Colors.black26 : Colors.transparent,
transitionDuration: easeTime,
pageBuilder: (context, _, _) => SafeArea(
child: Align(
alignment: at,
child: ConstrainedBox(
constraints: BoxConstraints(maxHeight: MediaQuery.sizeOf(context).height - 32),
child: SingleChildScrollView(child: child),
),
),
),
transitionBuilder: (context, anim, _, child) {
final a = CurvedAnimation(parent: anim, curve: ease, reverseCurve: Curves.easeIn);
return FadeTransition(
opacity: a,
child: ScaleTransition(
scale: Tween(begin: 0.96, end: 1.0).animate(a),
alignment: at,
child: child,
),
);
},
);
/// Horizontal lists for mouse users: drag with the mouse or Shift+wheel;
/// the plain wheel stays vertical (the page scrolls under the cursor, not
/// the row).
class HScroll extends StatefulWidget {
final ScrollController? controller;
final Widget Function(ScrollController controller) builder;
/// Also map the plain (vertical) wheel to sideways scrolling (at either
/// end the wheel goes to the page). Off by default: one wheel doing two
/// things was confusing.
final bool wheel;
const HScroll({super.key, this.controller, required this.builder, this.wheel = false});
@override
State<HScroll> createState() => _HScrollState();
}
class _HScrollState extends State<HScroll> {
ScrollController? _own;
ScrollController get _c => widget.controller ?? (_own ??= ScrollController());
@override
void dispose() {
_own?.dispose();
super.dispose();
}
void _wheel(PointerSignalEvent e) {
if (e is! PointerScrollEvent) return;
final dy = e.scrollDelta.dy;
if (dy == 0 || e.scrollDelta.dx != 0 || !_c.hasClients) return;
final p = _c.position;
final target = (p.pixels + dy * 1.5).clamp(p.minScrollExtent, p.maxScrollExtent);
if (target == p.pixels) return; // at the end: let the page scroll
GestureBinding.instance.pointerSignalResolver.register(e, (_) {
_c.animateTo(target, duration: const Duration(milliseconds: 140), curve: Curves.easeOutCubic);
});
}
@override
Widget build(BuildContext context) => ScrollConfiguration(
behavior: ScrollConfiguration.of(context)
.copyWith(dragDevices: PointerDeviceKind.values.toSet()),
child: widget.wheel ? Listener(onPointerSignal: _wheel, child: widget.builder(_c)) : widget.builder(_c),
);
}
@@ -0,0 +1,171 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:signals/signals_flutter.dart';
import '../session/now_playing.dart';
import '../state/session_state.dart';
import 'motion.dart';
/// Tests: art bytes by hash instead of the session's cache.
@visibleForTesting
Uint8List? Function(String hash)? debugArtBytes;
/// The one lookup for media art (cover, banner) by content hash: null
/// when there is none or it has not arrived yet (listeners of
/// [artChanges] hear when it lands).
Uint8List? artBytes(String hash) {
if (hash.isEmpty) return null;
final debug = debugArtBytes;
if (debug != null) return debug(hash);
return SessionState.instance.coordinator.art.bytesFor(hash);
}
/// Notifies when art arrives.
Listenable artChanges() => SessionState.instance.coordinator.art;
final _images = <String, MemoryImage>{};
/// [hash]'s art as an image, the same provider while the bytes are the
/// same (so a rebuild never decodes again).
ImageProvider? artImage(String hash) {
final bytes = artBytes(hash);
if (bytes == null) return null;
final hit = _images[hash];
if (hit != null && identical(hit.bytes, bytes)) return hit;
if (_images.length > 16) _images.clear();
return _images[hash] = MemoryImage(bytes);
}
/// [image] fading in (ease) once decoded; nothing before.
Widget fadeInImage(ImageProvider image, {BoxFit fit = BoxFit.cover}) => Image(
image: image,
fit: fit,
gaplessPlayback: true,
frameBuilder: (context, child, frame, sync) => sync
? child
: AnimatedOpacity(opacity: frame == null ? 0 : 1, duration: easeTime, curve: ease, child: child),
);
/// The portrait cover (2:3) of the current media, a quiet tile with a
/// film icon until (or unless) its art arrives.
class ArtCover extends StatelessWidget {
final String hash;
final double width;
const ArtCover({super.key, required this.hash, this.width = 40});
@override
Widget build(BuildContext context) {
final image = artImage(hash);
final r = BorderRadius.circular(width >= 80 ? 10 : 6);
return Container(
width: width,
height: width * 1.5,
decoration: BoxDecoration(
borderRadius: r,
boxShadow: const [BoxShadow(color: Color(0x66000000), blurRadius: 10, offset: Offset(0, 3))],
),
child: ClipRRect(
borderRadius: r,
child: Stack(
fit: StackFit.expand,
children: [
DecoratedBox(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFF2A2D35), Color(0xFF17191E)],
),
),
child: Icon(Icons.movie_outlined, color: Colors.white38, size: width * 0.45),
),
if (image != null) fadeInImage(image),
DecoratedBox(
decoration: BoxDecoration(
borderRadius: r,
border: Border.all(color: Colors.white12),
),
),
],
),
),
);
}
}
const _shadow = [Shadow(color: Color(0x99000000), blurRadius: 8)];
/// Title and episode line of [now] (with its cover unless [cover] is
/// off), ellipsized; cross-fades when the media changes.
class NowPlayingBlock extends StatelessWidget {
final NowPlaying? now;
final bool cover;
/// Smaller type (the mini player).
final bool compact;
const NowPlayingBlock({super.key, required this.now, this.cover = true, this.compact = false});
@override
Widget build(BuildContext context) {
final n = now;
final Widget child;
if (n == null) {
child = const SizedBox.shrink(key: ValueKey('now-playing-none'));
} else {
final episode = n.episode;
child = Row(
key: ValueKey('now-playing:${n.title}\n${episode ?? ''}'),
mainAxisSize: MainAxisSize.min,
children: [
if (cover) ...[ArtCover(hash: n.posterHash), const SizedBox(width: 12)],
Flexible(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
n.title,
key: const ValueKey('now-playing-title'),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.w700,
fontSize: compact ? 13 : 16,
shadows: _shadow),
),
if (episode != null && episode.isNotEmpty)
Text(
episode,
key: const ValueKey('now-playing-episode'),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: Colors.white70, fontSize: compact ? 11 : 13, shadows: _shadow),
),
],
),
),
],
);
}
return fadeThrough(child, expand: false, alignment: AlignmentDirectional.centerStart);
}
}
/// [NowPlayingBlock] of the session's current media.
class NowPlayingTitle extends StatelessWidget {
final bool cover;
final bool compact;
const NowPlayingTitle({super.key, this.cover = true, this.compact = false});
@override
Widget build(BuildContext context) => ListenableBuilder(
listenable: artChanges(),
builder: (context, _) => SignalBuilder(
builder: (context) => NowPlayingBlock(
now: SessionState.instance.coordinator.nowPlaying.value,
cover: cover,
compact: compact,
),
),
);
}
+683
View File
@@ -0,0 +1,683 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:signals/signals_flutter.dart';
import 'package:sync_relay_core/core.dart';
import 'package:window_manager/window_manager.dart';
import '../screens/home/update_indicator.dart';
import '../screens/session/jellyfin_widgets.dart' show ViaHostBadge;
import '../state/session_state.dart';
import '../update/update_controller.dart';
import '../widgets/app_keys.dart';
import '../widgets/window_buttons.dart';
import 'avatar.dart';
import 'motion.dart';
/// The shell's tabs, in strip order.
enum ShellTab {
library('Library'),
schedule('Schedule'),
settings('Settings');
final String label;
const ShellTab(this.label);
}
const partyStripHeight = 60.0;
/// Every avatar in the strip, yours and the party's.
const stripAvatarSize = 42.0;
/// Below this window width the strip shows 3 avatars and no server text,
/// so the tabs keep their room.
const stripCompactBelow = 1100.0;
/// The member plays the current Jellyfin item through the host's relay.
bool viaHostOf(MemberView m, JellyfinRef? ref) =>
ref != null && m.jf[ref.serverId] == false;
/// The 60 px strip over everything: your avatar (profile), the tabs, and
/// the party: offline Join a friend / Host, solo a "Solo" chip, Invite
/// friends and Stop, online the avatars (members card), server and
/// readiness, chat and Leave. Empty space drags the window.
class PartyStrip extends StatelessWidget {
final ShellTab tab;
final ValueChanged<ShellTab> onTab;
final bool chatOpen;
final int unread;
final VoidCallback onChat;
final VoidCallback onJoin;
final VoidCallback onHost;
final VoidCallback onLeave;
final VoidCallback onProfile;
/// Solo: host from here, keeping what plays.
final VoidCallback? onInvite;
/// Online with something up but the player closed: brings it back.
final VoidCallback? onShowPlayer;
final UpdateController? updates;
/// Off in tests: the drag area and window buttons need window_manager.
final bool windowControls;
const PartyStrip({
super.key,
required this.tab,
required this.onTab,
required this.chatOpen,
required this.unread,
required this.onChat,
required this.onJoin,
required this.onHost,
required this.onLeave,
required this.onProfile,
this.onInvite,
this.onShowPlayer,
this.updates,
this.windowControls = true,
});
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final compact = MediaQuery.sizeOf(context).width < stripCompactBelow;
final row = SignalBuilder(
builder: (context) {
final session = SessionState.instance;
final coord = session.coordinator;
final online = session.route.value == AppRoute.session;
final solo = online && (session.solo.value || session.switching.value);
final self = online ? coord.selfMember.value : null;
return Row(
children: [
const SizedBox(width: 16),
Tooltip(
message: 'Your profile',
child: InkWell(
key: const ValueKey('strip-profile'),
canRequestFocus: false,
customBorder: const CircleBorder(),
onTap: onProfile,
child: StatusAvatar(
info: selfAvatarInfo(
ready: self?.ready ?? false,
host: online && !solo && coord.hostFile != null,
),
size: stripAvatarSize,
ring: online,
),
),
),
const SizedBox(width: 12),
Expanded(
child: Align(
alignment: Alignment.centerLeft,
child: HScroll(
builder: (c) => SingleChildScrollView(
controller: c,
scrollDirection: Axis.horizontal,
child: Row(
children: [
for (final t in ShellTab.values)
StripTab(
key: ValueKey('tab-${t.name}'),
label: t.label,
selected: t == tab,
compact: compact,
onTap: () => onTab(t),
),
],
),
),
),
),
),
UpdateIndicator(controller: updates),
const SizedBox(width: 8),
if (online && onShowPlayer != null)
IconButton(
key: const ValueKey('show-player'),
tooltip: 'Show the player',
onPressed: onShowPlayer,
icon: const Icon(Icons.smart_display_outlined, size: 20),
),
if (solo) ...[
_SoloChip(compact: compact, busy: session.switching.value),
const SizedBox(width: 10),
if (compact)
FilledButton(
key: const ValueKey('strip-invite'),
onPressed: session.switching.value ? null : onInvite,
child: const Text('Invite'),
)
else
FilledButton.icon(
key: const ValueKey('strip-invite'),
onPressed: session.switching.value ? null : onInvite,
icon: const Icon(Icons.group_add_outlined, size: 18),
label: const Text('Invite friends'),
),
const SizedBox(width: 8),
OutlinedButton(
key: const ValueKey('strip-leave'),
onPressed: session.switching.value ? null : onLeave,
child: const Text('Stop'),
),
] else if (online) ...[
PartyWatch(
builder: (context) => AvatarStack(
key: const ValueKey('party-avatars'),
members: partyAvatars(coord),
shown: compact ? 3 : 5,
size: stripAvatarSize,
onTap: () => showMembersCard(context),
),
),
const SizedBox(width: 10),
if (!compact) _ServerLabel(scheme: scheme),
const SizedBox(width: 6),
// Tab toggles the chat while the strip is shown.
ChatKey(
onToggle: onChat,
child: Badge(
isLabelVisible: unread > 0,
label: Text('$unread'),
child: IconButton(
key: const ValueKey('strip-chat'),
tooltip: 'Chat & activity (Tab)',
isSelected: chatOpen,
onPressed: onChat,
icon: const Icon(Icons.chat_bubble_outline, size: 20),
selectedIcon: Icon(
Icons.chat_bubble,
size: 20,
color: scheme.primary,
),
),
),
),
const SizedBox(width: 4),
OutlinedButton(
key: const ValueKey('strip-leave'),
onPressed: onLeave,
child: const Text('Leave'),
),
] else ...[
if (!compact) ...[
Container(
width: 7,
height: 7,
decoration: BoxDecoration(color: scheme.outline, shape: BoxShape.circle),
),
const SizedBox(width: 6),
Text('Offline', style: TextStyle(color: scheme.onSurfaceVariant)),
const SizedBox(width: 12),
],
if (compact) ...[
FilledButton(key: const ValueKey('strip-join'), onPressed: onJoin, child: const Text('Join')),
const SizedBox(width: 8),
FilledButton.tonal(key: const ValueKey('strip-host'), onPressed: onHost, child: const Text('Host')),
] else ...[
FilledButton.icon(
key: const ValueKey('strip-join'),
onPressed: onJoin,
icon: const Icon(Icons.login_rounded, size: 18),
label: const Text('Join a friend'),
),
const SizedBox(width: 8),
FilledButton.tonalIcon(
key: const ValueKey('strip-host'),
onPressed: onHost,
icon: const Icon(Icons.podcasts_rounded, size: 18),
label: const Text('Host'),
),
],
],
const SizedBox(width: 8),
if (windowControls) ...[
SizedBox(
height: 20,
child: VerticalDivider(width: 12, color: scheme.outlineVariant),
),
const WindowButtons(),
] else
const SizedBox(width: 8),
],
);
},
);
return DecoratedBox(
decoration: BoxDecoration(
color: scheme.surfaceContainerLowest,
border: Border(
bottom: BorderSide(
color: scheme.outlineVariant.withValues(alpha: 0.4),
),
),
),
child: SizedBox(
height: partyStripHeight,
child: Stack(
children: [
// Empty strip space drags the window; the controls on top take
// their own clicks.
if (windowControls)
const Positioned.fill(
child: DragToMoveArea(child: SizedBox.expand()),
),
Positioned.fill(child: row),
],
),
),
);
}
}
/// Server name and "ready n/m".
class _ServerLabel extends StatelessWidget {
final ColorScheme scheme;
const _ServerLabel({required this.scheme});
@override
Widget build(BuildContext context) => SignalBuilder(
builder: (context) {
final coord = SessionState.instance.coordinator;
final r = coord.serverState.value;
final members = r?.members ?? const <MemberView>[];
final ready = members.where((m) => m.ready).length;
return Column(
key: const ValueKey('server-label'),
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
serverName(),
style: const TextStyle(fontWeight: FontWeight.w600, height: 1.1),
),
Text(
r == null ? 'connecting…' : 'ready $ready/${members.length}',
style: TextStyle(
color: scheme.onSurfaceVariant,
fontSize: 12,
height: 1.2,
),
),
],
);
},
);
}
/// "Solo" alone, "Your server" when hosting, else the joined server's
/// name (its `--name`), or its address while unknown.
String serverName() {
final session = SessionState.instance;
if (session.solo.value) return 'Solo';
if (session.coordinator.hostFile != null) return 'Your server';
final label = session.serverLabel.value;
return label.isNotEmpty ? label : session.lastForm.host;
}
/// Solo: just you, on your own; a spinner while it becomes a server.
class _SoloChip extends StatelessWidget {
final bool compact;
final bool busy;
const _SoloChip({required this.compact, required this.busy});
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return Tooltip(
message: busy
? 'Starting your server…'
: 'Watching on your own. Nobody can join until you invite friends.',
child: Container(
key: const ValueKey('solo-chip'),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: scheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(20),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (busy)
const SizedBox.square(dimension: 14, child: CircularProgressIndicator(strokeWidth: 2))
else
Icon(Icons.person_outline_rounded, size: 16, color: scheme.primary),
const SizedBox(width: 6),
Text(busy ? 'Inviting…' : 'Solo', style: const TextStyle(fontWeight: FontWeight.w600)),
if (!compact && !busy) ...[
const SizedBox(width: 6),
Text('only you', style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12)),
],
],
),
),
);
}
}
/// One strip tab: label and an underline that grows in when selected.
class StripTab extends StatelessWidget {
final String label;
final bool selected;
final bool compact;
final VoidCallback onTap;
const StripTab({
super.key,
required this.label,
required this.selected,
required this.onTap,
this.compact = false,
});
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return Semantics(
selected: selected,
button: true,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: EdgeInsets.symmetric(horizontal: compact ? 8 : 12),
child: SizedBox(
height: partyStripHeight,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const SizedBox(height: 3),
Text(
label,
style: TextStyle(
fontWeight: selected ? FontWeight.w700 : FontWeight.w500,
color: selected ? scheme.primary : scheme.onSurfaceVariant,
),
),
const SizedBox(height: 4),
AnimatedContainer(
duration: easeTime,
curve: ease,
height: 3,
width: selected ? 22 : 0,
decoration: BoxDecoration(
color: scheme.primary,
borderRadius: BorderRadius.circular(2),
),
),
],
),
),
),
),
);
}
}
/// Who is in the server, from the avatars: status, host / relay pills,
/// per member force ready, nudge and sit out, and the address to invite
/// friends.
Future<void> showMembersCard(BuildContext context) => showPopover<void>(
context,
at: const Alignment(0.7, -0.84),
child: const MembersCard(),
);
/// "ip:port" for each of [addresses] friends could dial: IPv4, neither
/// loopback nor link-local, private (LAN) ranges first, no repeats.
List<String> inviteAddresses(Iterable<InternetAddress> addresses, String port) {
bool private(List<int> b) =>
b[0] == 10 || (b[0] == 192 && b[1] == 168) || (b[0] == 172 && b[1] >= 16 && b[1] < 32);
final usable = [
for (final a in addresses)
if (a.type == InternetAddressType.IPv4 &&
!a.isLoopback &&
!a.isLinkLocal &&
!(a.rawAddress[0] == 169 && a.rawAddress[1] == 254))
a,
];
final ordered = [
...usable.where((a) => private(a.rawAddress)),
...usable.where((a) => !private(a.rawAddress)),
];
return {for (final a in ordered) '${a.address}:$port'}.toList();
}
/// This machine's [inviteAddresses] (empty when the interfaces can't be
/// listed).
Future<List<String>> lanInviteAddresses(String port) async {
try {
final interfaces = await NetworkInterface.list(type: InternetAddressType.IPv4);
return inviteAddresses([for (final i in interfaces) ...i.addresses], port);
} catch (e) {
debugPrint('list network interfaces failed: $e');
return const [];
}
}
class MembersCard extends StatelessWidget {
/// This machine's invite addresses for a port (tests pass fixed ones).
final Future<List<String>> Function(String port) lanAddresses;
const MembersCard({super.key, this.lanAddresses = lanInviteAddresses});
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return Material(
key: const ValueKey('members-card'),
elevation: 12,
borderRadius: BorderRadius.circular(14),
child: SizedBox(
width: 340,
child: PartyWatch(
builder: (context) {
final session = SessionState.instance;
final coord = session.coordinator;
final r = coord.serverState.value;
final hosting = coord.hostFile != null;
final address = hosting
? null
: '${session.lastForm.host}:${session.lastForm.port}';
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 8),
child: Text(
['In the server', ?address].join(' · '),
style: const TextStyle(fontWeight: FontWeight.w600),
),
),
for (final m in r?.members ?? const <MemberView>[])
_MemberTile(
member: m,
info: memberAvatarInfo(m, r, coord),
viaHost: viaHostOf(m, coord.currentRef.value),
),
if (r == null)
Padding(
padding: const EdgeInsets.all(20),
child: Text(
'connecting…',
style: TextStyle(color: scheme.onSurfaceVariant),
),
),
const Divider(),
if (address != null)
Padding(
padding: const EdgeInsets.fromLTRB(12, 0, 12, 8),
child: Align(
alignment: Alignment.centerLeft,
child: TextButton.icon(
key: const ValueKey('copy-invite'),
onPressed: () =>
Clipboard.setData(ClipboardData(text: address)),
icon: const Icon(Icons.copy, size: 16),
label: const Text('Copy invite address'),
),
),
)
else
_HostInvite(
port: session.lastForm.hostPort,
lanAddresses: lanAddresses,
),
],
);
},
),
),
);
}
}
class _MemberTile extends StatelessWidget {
final MemberView member;
final AvatarInfo info;
final bool viaHost;
const _MemberTile({
required this.member,
required this.info,
required this.viaHost,
});
@override
Widget build(BuildContext context) {
final coord = SessionState.instance.coordinator;
final m = member;
final status = m.stale
? 'reconnecting…'
: m.out
? 'sitting out'
: info.buffering
? 'buffering ${m.buffered.toStringAsFixed(1)} s'
: m.ready
? 'ready'
: 'not ready';
return ListTile(
key: ValueKey('member-${m.id}'),
dense: true,
contentPadding: const EdgeInsets.only(left: 20, right: 8),
leading: StatusAvatar(info: info, size: 36, badges: false),
title: Wrap(
spacing: 6,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
Text(info.self ? '${m.name} (you)' : m.name),
if (info.host) const Pill('host', Colors.amber),
if (info.relaying)
const Pill('passing the video on', Colors.lightBlueAccent),
if (viaHost) const ViaHostBadge(),
],
),
subtitle: Text(status),
trailing: PopupMenuButton<String>(
tooltip: 'More',
icon: const Icon(Icons.more_vert, size: 18),
onSelected: (v) {
switch (v) {
case 'force':
coord.forceTarget(m.id, !m.ready);
case 'nudge':
coord.nudgeMember(m.id);
case 'out':
coord.sitOut(m.id);
case 'back':
coord.joinBack();
}
},
itemBuilder: (_) => [
if (!m.out)
PopupMenuItem(
value: 'force',
child: Text(m.ready ? 'Force unready' : 'Force ready'),
),
if (!info.self)
const PopupMenuItem(value: 'nudge', child: Text('Nudge')),
if (info.self && m.out)
const PopupMenuItem(value: 'back', child: Text('Join back'))
else if (!m.out)
const PopupMenuItem(value: 'out', child: Text('Sit out')),
],
),
);
}
}
/// Hosting: the addresses friends on this network can join, the first
/// one behind "Copy invite address", and what friends elsewhere need.
class _HostInvite extends StatefulWidget {
final String port;
final Future<List<String>> Function(String port) lanAddresses;
const _HostInvite({required this.port, required this.lanAddresses});
@override
State<_HostInvite> createState() => _HostInviteState();
}
class _HostInviteState extends State<_HostInvite> {
late final Future<List<String>> _addresses = widget.lanAddresses(widget.port);
@override
Widget build(BuildContext context) {
final muted = Theme.of(context).colorScheme.onSurfaceVariant;
return FutureBuilder<List<String>>(
future: _addresses,
builder: (context, snap) {
final list = snap.data ?? const <String>[];
void copy(String a) => Clipboard.setData(ClipboardData(text: a));
return Padding(
padding: const EdgeInsets.fromLTRB(12, 0, 12, 12),
child: Column(
key: const ValueKey('host-invite'),
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextButton.icon(
key: const ValueKey('copy-invite'),
onPressed: list.isEmpty ? null : () => copy(list.first),
icon: const Icon(Icons.copy, size: 16),
label: const Text('Copy invite address'),
),
if (snap.connectionState != ConnectionState.done)
const SizedBox.shrink()
else if (list.isEmpty)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Text('No network address found', style: TextStyle(color: muted)),
)
else
for (final a in list)
InkWell(
key: ValueKey('invite-$a'),
borderRadius: BorderRadius.circular(8),
onTap: () => copy(a),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: Row(
children: [
Expanded(child: Text(a, style: const TextStyle(fontFamily: 'monospace'))),
Icon(Icons.copy, size: 14, color: muted),
],
),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(8, 6, 8, 0),
child: Text(
'Works for friends on your network. Friends elsewhere need port '
'${widget.port} forwarded on your router, or a server they can '
'all reach (like a VPS).',
style: TextStyle(color: muted, fontSize: 12),
),
),
],
),
);
},
);
}
}
@@ -0,0 +1,383 @@
import 'dart:async';
import 'dart:ui' show ImageFilter;
import 'package:flutter/material.dart';
import 'package:sync_relay_core/core.dart';
import '../session/health.dart';
import '../session/session_coordinator.dart';
import '../state/session_state.dart';
import 'avatar.dart';
import 'motion.dart';
/// What the player's status pill says, most pressing first.
enum StatusKind { reconnecting, problem, connecting, finished, loading, you, waiting }
/// The pill's button: stop reconnecting, retry, or mark yourself ready
/// (the whole pill).
enum StatusAction { cancel, retry, ready }
/// One status line: [text], a muted [detail], a coloured [tone] for
/// problems (warn, error), the members it is about ([who]; [self] for you)
/// and a loading [progress] (0..1).
class StatusView {
final StatusKind kind;
final String text;
final String? detail;
final HealthSeverity? tone;
final List<MemberView> who;
final bool self;
final double? progress;
final StatusAction? action;
/// The mini player's shorter line.
final String short;
const StatusView(
this.kind,
this.text, {
this.detail,
this.tone,
this.who = const [],
this.self = false,
this.progress,
this.action,
String? short,
}) : short = short ?? text;
}
/// "Bob", "Bob and Mika", "Bob, Mika and Cleo", "Bob, Mika and 3 others".
String nameList(List<String> names) {
if (names.length <= 1) return names.join();
if (names.length <= 3) return '${names.sublist(0, names.length - 1).join(', ')} and ${names.last}';
return '${names[0]}, ${names[1]} and ${names.length - 2} others';
}
String _sentence(String s) => s.isEmpty ? s : s[0].toUpperCase() + s.substring(1);
/// The player's status from the session's signals; null when there is
/// nothing to say (playing, connected, everyone ready). [slowConnect]:
/// connecting has taken over 2 s.
StatusView? playerStatusFor({
required HealthStatus health,
bool reconnecting = false,
StateBroadcast? state,
MemberView? self,
bool slowConnect = false,
}) {
if (reconnecting) {
return const StatusView(StatusKind.reconnecting, 'Reconnecting…',
tone: HealthSeverity.warn, action: StatusAction.cancel);
}
if (health.severity == HealthSeverity.warn || health.severity == HealthSeverity.error) {
return StatusView(StatusKind.problem, _sentence(health.message),
tone: health.severity, action: StatusAction.retry);
}
if (slowConnect && health.severity == HealthSeverity.info && health.message.startsWith('connecting')) {
return const StatusView(StatusKind.connecting, 'Connecting…');
}
final r = state;
if (r == null || r.media == null || r.transport == 'playing') return null;
// Sitting out: the join-back card says it; nobody waits for you.
if (self != null && self.out) return null;
if (r.transport == 'finished') {
return const StatusView(StatusKind.finished, 'Finished · seek back or pick the next video', short: 'Finished');
}
// As the server holds playback: someone in the ready set short of the
// minimum buffer.
final need = r.bufferNeed;
for (final m in r.members) {
if (m.inSet && m.buffered < need) {
final you = self != null && m.id == self.id;
final p = (m.buffered / need).clamp(0.0, 1.0);
return StatusView(StatusKind.loading, you ? 'Loading the video…' : '${m.name} is loading…',
who: [m], self: you, progress: p, detail: '${(p * 100).round()}%');
}
}
if (self != null && !self.ready) {
return StatusView(StatusKind.you, "Press Space when you're ready",
who: [self], self: true, action: StatusAction.ready, detail: 'or click here', short: 'Press Space when ready');
}
// Members sitting out are never waited for nor counted.
final active = [for (final m in r.members) if (!m.stale && !m.out) m];
final others = [
for (final m in active)
if ((self == null || m.id != self.id) && !(m.inSet && m.ready)) m,
];
if (others.isEmpty) return null;
final ready = active.where((m) => m.ready).length;
return StatusView(StatusKind.waiting, 'Waiting for ${nameList([for (final m in others) m.name])}',
who: others, detail: '$ready/${active.length} ready');
}
/// [playerStatusFor] from the session (tracked: call inside a builder).
StatusView? playerStatusNow({bool slowConnect = false}) {
final c = SessionState.instance.coordinator;
return playerStatusFor(
health: c.health.value,
reconnecting: c.reconnecting.value,
state: c.serverState.value,
self: c.selfMember.value,
slowConnect: slowConnect,
);
}
/// The player's one status pill (bottom centre): frosted dark glass with
/// one short line, the members it is about and at most one button. Fades
/// in and out; a different kind of status fades through, the pill resizing
/// around it.
class PlayerStatus extends StatefulWidget {
const PlayerStatus({super.key});
static const connectGrace = Duration(seconds: 2);
@override
State<PlayerStatus> createState() => _PlayerStatusState();
}
class _PlayerStatusState extends State<PlayerStatus> {
Timer? _connectTimer;
bool _slow = false;
StatusView? _last;
@override
void dispose() {
_connectTimer?.cancel();
super.dispose();
}
// "Connecting…" only once it takes a while.
void _trackConnecting(HealthStatus h) {
final connecting = h.severity == HealthSeverity.info && h.message.startsWith('connecting');
if (!connecting) {
_connectTimer?.cancel();
_connectTimer = null;
_slow = false;
} else if (_connectTimer == null && !_slow) {
_connectTimer = Timer(PlayerStatus.connectGrace, () {
if (mounted) setState(() => _slow = true);
});
}
}
void _act(SessionCoordinator coord, StatusAction a) {
switch (a) {
case StatusAction.cancel:
coord.cancelReconnect();
case StatusAction.retry:
coord.retry();
case StatusAction.ready:
coord.toggleReady();
}
}
@override
Widget build(BuildContext context) {
return PartyWatch(builder: (context) {
final coord = SessionState.instance.coordinator;
_trackConnecting(coord.health.value);
final view = playerStatusNow(slowConnect: _slow);
if (view != null) _last = view;
final shown = _last;
final ready = view?.action == StatusAction.ready;
return AnimatedOpacity(
duration: easeTime,
curve: ease,
opacity: view == null ? 0 : 1,
child: IgnorePointer(
ignoring: view == null,
child: shown == null
? const SizedBox.shrink()
: MouseRegion(
cursor: ready ? SystemMouseCursors.click : MouseCursor.defer,
child: GestureDetector(
key: const ValueKey('player-status'),
onTap: ready ? coord.toggleReady : null,
child: PlayerGlass(
child: AnimatedSize(
duration: easeTime,
curve: ease,
child: fadeThrough(
_StatusLine(
key: ValueKey(shown.kind),
view: shown,
coord: coord,
onAction: (a) => _act(coord, a),
),
expand: false,
alignment: Alignment.center,
),
),
),
),
),
),
);
});
}
}
/// Frosted dark glass in a pill: the status pill, the unread chip.
class PlayerGlass extends StatelessWidget {
final Widget child;
const PlayerGlass({super.key, required this.child});
@override
Widget build(BuildContext context) {
final r = BorderRadius.circular(999);
return ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 560),
child: ClipRRect(
borderRadius: r,
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 12, sigmaY: 12),
child: DecoratedBox(
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.55),
borderRadius: r,
border: Border.all(color: Colors.white12, width: 0.5),
),
child: child,
),
),
),
);
}
}
class _StatusLine extends StatelessWidget {
final StatusView view;
final SessionCoordinator coord;
final ValueChanged<StatusAction> onAction;
const _StatusLine({super.key, required this.view, required this.coord, required this.onAction});
static const _avatar = 34.0;
Widget? _lead() {
final v = view;
switch (v.kind) {
case StatusKind.problem:
final error = v.tone == HealthSeverity.error;
return Icon(error ? Icons.error_outline_rounded : Icons.warning_amber_rounded,
size: 20, color: error ? Colors.redAccent.shade100 : Colors.amber);
case StatusKind.reconnecting:
return const _Spinner(color: Colors.amber);
case StatusKind.connecting:
return const _Spinner(color: Colors.white70);
case StatusKind.finished:
return null;
case StatusKind.loading:
case StatusKind.you:
case StatusKind.waiting:
final r = coord.serverState.value;
final infos = [
for (final m in v.who)
v.self ? selfAvatarInfo(ready: m.ready, host: m.host) : memberAvatarInfo(m, r, coord),
];
if (infos.isEmpty) return null;
final avatar = infos.length == 1
? StatusAvatar(info: infos.first, size: _avatar, badges: false)
: AvatarStack(members: infos, shown: 4, size: _avatar);
if (v.progress == null) return avatar;
// Loading: the progress runs round the avatar.
return Stack(
alignment: Alignment.center,
children: [
avatar,
SizedBox.square(
dimension: _avatar + 6,
child: CircularProgressIndicator(
value: v.progress,
strokeWidth: 2.5,
color: Colors.white,
backgroundColor: Colors.white24,
),
),
],
);
}
}
@override
Widget build(BuildContext context) {
final v = view;
final lead = _lead();
final avatars = v.who.isNotEmpty;
final action = v.action == StatusAction.ready ? null : v.action;
return Padding(
padding: EdgeInsets.fromLTRB(lead == null ? 18 : (avatars ? 5 : 14), 5, action == null ? 18 : 6, 5),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (lead != null) ...[
SizedBox(height: _avatar + 6, child: Center(child: lead)),
SizedBox(width: avatars ? 10 : 8),
],
Flexible(
child: ConstrainedBox(
constraints: const BoxConstraints(minHeight: _avatar),
child: Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
v.text,
key: const ValueKey('player-status-text'),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.w600, height: 1.2),
),
if (v.detail != null)
Text(
v.detail!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Colors.white60, fontSize: 12, fontWeight: FontWeight.w500, height: 1.2),
),
],
),
),
),
if (action != null) ...[
const SizedBox(width: 12),
_PillButton(
label: action == StatusAction.cancel ? 'Cancel' : 'Retry',
onPressed: () => onAction(action),
),
],
],
),
);
}
}
class _Spinner extends StatelessWidget {
final Color color;
const _Spinner({required this.color});
@override
Widget build(BuildContext context) =>
SizedBox.square(dimension: 14, child: CircularProgressIndicator(strokeWidth: 2, color: color));
}
class _PillButton extends StatelessWidget {
final String label;
final VoidCallback onPressed;
const _PillButton({required this.label, required this.onPressed});
@override
Widget build(BuildContext context) => TextButton(
key: ValueKey('player-status-$label'),
onPressed: onPressed,
style: TextButton.styleFrom(
foregroundColor: Colors.white,
backgroundColor: Colors.white12,
shape: const StadiumBorder(),
minimumSize: const Size(0, 26),
padding: const EdgeInsets.symmetric(horizontal: 12),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
visualDensity: VisualDensity.compact,
textStyle: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600),
),
child: Text(label),
);
}
@@ -0,0 +1,562 @@
import 'dart:io';
import 'dart:math' as math;
import 'dart:typed_data';
import 'dart:ui' as ui;
import 'package:file_picker/file_picker.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:signals/signals_flutter.dart';
import '../jellyfin/accounts.dart';
import '../screens/home/profile.dart';
import '../state/session_state.dart';
import '../update/update_controller.dart';
import 'avatar.dart';
import 'motion.dart';
import 'settings_tab.dart' show SettingsSection;
/// Your profile, from your avatar in the strip: name and picture apply
/// live (pushed to the server while in one), colour is yours only; links
/// to the app-wide pages.
Future<void> showProfileCard(
BuildContext context, {
required ValueChanged<SettingsSection> onOpenSettings,
UpdateController? updates,
}) =>
showPopover<void>(
context,
at: const Alignment(-0.97, -0.84),
child: ProfileCard(onOpenSettings: onOpenSettings, updates: updates),
);
class ProfileCard extends StatefulWidget {
final ValueChanged<SettingsSection> onOpenSettings;
final UpdateController? updates;
const ProfileCard({super.key, required this.onOpenSettings, this.updates});
@override
State<ProfileCard> createState() => _ProfileCardState();
}
class _ProfileCardState extends State<ProfileCard> {
final _p = Profile.instance;
late final _name = TextEditingController(text: _p.name.peek());
bool _hoverPic = false;
@override
void dispose() {
_name.dispose();
super.dispose();
}
void _open(SettingsSection s) {
Navigator.of(context).maybePop();
widget.onOpenSettings(s);
}
Future<void> _changePicture() => showPopover<void>(
context,
at: Alignment.center,
dim: true,
child: CropPicker(onUse: _p.setPicture),
);
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final small = TextStyle(color: scheme.onSurfaceVariant, fontSize: 12);
return Material(
elevation: 12,
color: scheme.surfaceContainer,
borderRadius: BorderRadius.circular(18),
clipBehavior: Clip.antiAlias,
child: SizedBox(
width: 360,
child: SignalBuilder(builder: (context) {
final session = SessionState.instance;
final coord = session.coordinator;
final online = session.route.value == AppRoute.session;
final self = online ? coord.selfMember.value : null;
final hosting = online && !session.solo.value && coord.hostFile != null;
final color = _p.color.value;
final png = _p.picture.value;
final out = self?.out ?? false;
final me = selfAvatarInfo(ready: self?.ready ?? false, host: hosting, out: out);
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Header tinted with your colour.
AnimatedContainer(
duration: easeTime,
curve: ease,
padding: const EdgeInsets.fromLTRB(20, 22, 20, 18),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [color.withValues(alpha: 0.55), color.withValues(alpha: 0.08)],
),
),
child: Row(
children: [
MouseRegion(
cursor: SystemMouseCursors.click,
onEnter: (_) => setState(() => _hoverPic = true),
onExit: (_) => setState(() => _hoverPic = false),
child: GestureDetector(
key: const ValueKey('profile-picture'),
onTap: _changePicture,
child: SizedBox(
width: 88,
height: 88,
child: Stack(
children: [
StatusAvatar(info: me, size: 88, badges: false, ring: online),
Positioned.fill(
child: AnimatedOpacity(
duration: easeTime,
curve: ease,
opacity: _hoverPic ? 1 : 0,
child: Container(
margin: const EdgeInsets.all(4),
decoration: const BoxDecoration(shape: BoxShape.circle, color: Colors.black54),
child: const Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.photo_camera_outlined, color: Colors.white, size: 22),
Text('Change', style: TextStyle(color: Colors.white, fontSize: 11)),
],
),
),
),
),
],
),
),
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextField(
key: const ValueKey('profile-name'),
controller: _name,
maxLength: 32,
style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w700),
decoration: const InputDecoration(
isDense: true,
counterText: '',
border: InputBorder.none,
hintText: 'Your name',
suffixIcon: Icon(Icons.edit_outlined, size: 16),
suffixIconConstraints: BoxConstraints(minWidth: 20),
),
onChanged: _p.setName,
onSubmitted: (_) => Navigator.of(context).maybePop(),
),
const SizedBox(height: 4),
Wrap(
spacing: 6,
runSpacing: 4,
children: [
if (hosting) const Pill('host', Colors.amber),
Pill(online ? 'in a server' : 'offline',
online ? Colors.greenAccent : Colors.grey),
if (online)
Pill(out ? 'sitting out' : (self?.ready ?? false) ? 'ready' : 'not ready',
Colors.lightBlueAccent),
],
),
],
),
),
],
),
),
Padding(
padding: const EdgeInsets.fromLTRB(20, 14, 20, 4),
child: Row(
children: [
Text('Colour', style: small),
const Spacer(),
for (final c in profileColors)
Padding(
padding: const EdgeInsets.only(left: 8),
child: InkWell(
customBorder: const CircleBorder(),
onTap: () => _p.setColor(c),
child: AnimatedContainer(
duration: easeTime,
curve: ease,
width: 24,
height: 24,
decoration: BoxDecoration(
color: c,
shape: BoxShape.circle,
border: Border.all(
color: color.toARGB32() == c.toARGB32() ? scheme.onSurface : Colors.transparent,
width: 2,
),
),
),
),
),
],
),
),
if (png != null)
Padding(
padding: const EdgeInsets.fromLTRB(20, 4, 8, 0),
child: Row(
children: [
Icon(Icons.image_outlined, size: 16, color: scheme.onSurfaceVariant),
const SizedBox(width: 6),
Text('PNG · ${(png.length / 1024).toStringAsFixed(0)} KB', style: small),
const Spacer(),
TextButton(onPressed: () => _p.setPicture(null), child: const Text('Remove picture')),
],
),
),
if (_p.error.value case final err?)
Padding(
padding: const EdgeInsets.fromLTRB(20, 4, 20, 0),
child: Text(err, style: TextStyle(color: scheme.error, fontSize: 12)),
),
Padding(
padding: const EdgeInsets.fromLTRB(20, 6, 20, 0),
child: Text(
online
? 'Name and picture update for everyone in the server right away. Colour is yours only.'
: 'Others see your name and picture when you join. Colour is yours only.',
style: small,
),
),
const Padding(padding: EdgeInsets.fromLTRB(12, 12, 12, 0), child: Divider(height: 1)),
Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Column(children: [
if (self != null)
_link(
out ? Icons.login_rounded : Icons.event_seat_outlined,
out ? 'Join back' : 'Sit out',
trailing: Text(out ? 'Space' : 'stay in the server', style: small),
onTap: () {
out ? coord.joinBack() : coord.sitOut();
Navigator.of(context).maybePop();
},
),
_link(
Icons.extension_outlined,
'Jellyfin & integrations',
trailing: ListenableBuilder(
listenable: JellyfinAccounts.instance,
builder: (context, _) {
final n = JellyfinAccounts.instance.accounts.length;
return Text(n == 0 ? 'none' : '$n linked', style: small);
},
),
onTap: () => _open(SettingsSection.integrations),
),
_link(Icons.settings_outlined, 'Settings', onTap: () => _open(SettingsSection.profile)),
ValueListenableBuilder(
valueListenable: (widget.updates ?? UpdateController.instance).status,
builder: (context, s, _) {
final m = s.manifest;
final available = s.phase != UpdatePhase.idle && m != null;
return _link(
Icons.system_update_alt,
available ? 'Update to ${m.version}' : 'Updates',
trailing: available ? Pill('new', Colors.greenAccent.shade400) : null,
onTap: () => _open(SettingsSection.updates),
);
},
),
_link(Icons.info_outline, 'About', onTap: () => _open(SettingsSection.about)),
]),
),
],
);
}),
),
);
}
Widget _link(IconData icon, String label, {Widget? trailing, required VoidCallback onTap}) => ListTile(
dense: true,
contentPadding: const EdgeInsets.symmetric(horizontal: 20),
leading: Icon(icon, size: 20),
title: Text(label),
trailing: trailing,
onTap: onTap,
);
}
/// Avatar crop: pick a file, drag to move, scroll or slide to zoom; live
/// previews at the sizes it is shown. "Use this" renders a 256 px PNG.
class CropPicker extends StatefulWidget {
final Future<void> Function(Uint8List png) onUse;
/// Opens the file dialog right away (that is why the picker opened).
final bool autoPick;
const CropPicker({super.key, required this.onUse, this.autoPick = true});
@override
State<CropPicker> createState() => _CropPickerState();
}
class _CropPickerState extends State<CropPicker> {
static const _view = 300.0, _out = 256;
ui.Image? _img;
double _zoom = 1; // 1 = the image just covers the circle
Offset _pan = Offset.zero; // in viewport pixels
String? _error;
bool _busy = false;
@override
void initState() {
super.initState();
if (widget.autoPick) WidgetsBinding.instance.addPostFrameCallback((_) => _pick());
}
@override
void dispose() {
_img?.dispose();
super.dispose();
}
Future<void> _pick() async {
final List<PlatformFile> files;
try {
files = await FilePicker.pickFiles(
dialogTitle: 'Pick a picture',
type: FileType.custom,
allowedExtensions: const ['png', 'jpg', 'jpeg', 'webp', 'gif', 'bmp'],
);
} catch (e) {
if (mounted) setState(() => _error = 'file dialog unavailable — try again');
return;
}
final path = files.isEmpty ? null : files.first.path;
if (path == null) return;
try {
final codec = await ui.instantiateImageCodec(await File(path).readAsBytes());
final frame = await codec.getNextFrame();
if (!mounted) return;
_img?.dispose();
setState(() {
_img = frame.image;
_zoom = 1;
_pan = Offset.zero;
_error = null;
});
} catch (e) {
if (mounted) setState(() => _error = 'could not read that image');
}
}
double get _scale {
final img = _img!;
return math.max(_view / img.width, _view / img.height) * _zoom;
}
/// Keeps the circle covered: the image edge never shows inside it.
Offset _clamp(Offset p) {
final img = _img!;
final mx = (img.width * _scale - _view) / 2, my = (img.height * _scale - _view) / 2;
return Offset(p.dx.clamp(-mx, mx), p.dy.clamp(-my, my));
}
void _setZoom(double z) => setState(() {
_zoom = z.clamp(1.0, 6.0);
_pan = _clamp(_pan);
});
Future<void> _use() async {
final img = _img;
if (img == null) return;
setState(() => _busy = true);
final rec = ui.PictureRecorder();
final canvas = Canvas(rec);
canvas.scale(_out / _view);
CropPainter(img, _scale, _pan).paint(canvas, const Size(_view, _view));
final out = await rec.endRecording().toImage(_out, _out);
final png = await out.toByteData(format: ui.ImageByteFormat.png);
out.dispose();
if (!mounted || png == null) return;
await widget.onUse(png.buffer.asUint8List());
if (mounted) Navigator.of(context).maybePop();
}
Widget _preview(double size) => ClipOval(
child: SizedBox(
width: size,
height: size,
child: CustomPaint(painter: CropPainter(_img!, _scale * size / _view, _pan * (size / _view))),
),
);
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final img = _img;
const caption = TextStyle(fontSize: 11, color: Colors.grey);
final editor = img == null
? InkWell(
customBorder: const CircleBorder(),
onTap: _pick,
child: Container(
width: _view,
height: _view,
decoration: BoxDecoration(shape: BoxShape.circle, color: scheme.surfaceContainerHighest),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.add_photo_alternate_outlined, size: 48, color: scheme.onSurfaceVariant),
const SizedBox(height: 8),
Text(_error ?? 'Choose a picture', style: TextStyle(color: scheme.onSurfaceVariant)),
],
),
),
)
: Listener(
onPointerSignal: (e) {
if (e is PointerScrollEvent) _setZoom(_zoom * (e.scrollDelta.dy > 0 ? 0.93 : 1.07));
},
child: GestureDetector(
onPanUpdate: (d) => setState(() => _pan = _clamp(_pan + d.delta)),
child: MouseRegion(
cursor: SystemMouseCursors.move,
// The dimmed surroundings stay inside this rounded square.
child: ClipRRect(
borderRadius: BorderRadius.circular(18),
child: SizedBox(
width: _view,
height: _view,
child: Stack(
children: [
// The whole image, dimmed outside the circle.
Positioned.fill(
child: Opacity(
opacity: 0.3,
child: CustomPaint(painter: CropPainter(img, _scale, _pan, clipToView: false)),
),
),
ClipOval(
child: CustomPaint(size: const Size(_view, _view), painter: CropPainter(img, _scale, _pan)),
),
IgnorePointer(
child: Container(
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(color: scheme.primary, width: 2),
),
),
),
],
),
),
),
),
),
);
return Material(
elevation: 16,
borderRadius: BorderRadius.circular(18),
child: SizedBox(
width: 560,
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text('Your picture', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 4),
Text(
img == null ? 'PNG, JPG, WebP, GIF or BMP' : 'Drag to move · scroll to zoom',
style: const TextStyle(color: Colors.grey, fontSize: 12),
),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
editor,
if (img != null) ...[
const SizedBox(width: 24),
Column(
children: [
_preview(88),
const SizedBox(height: 4),
const Text('profile', style: caption),
const SizedBox(height: 14),
_preview(42),
const SizedBox(height: 4),
const Text('party', style: caption),
const SizedBox(height: 14),
_preview(24),
const SizedBox(height: 4),
const Text('chat', style: caption),
],
),
],
],
),
if (img != null)
Row(
children: [
const Icon(Icons.zoom_out, size: 18),
Expanded(child: Slider(value: _zoom, min: 1, max: 6, onChanged: _setZoom)),
const Icon(Icons.zoom_in, size: 18),
],
)
else
const SizedBox(height: 16),
Row(
children: [
TextButton.icon(
onPressed: _pick,
icon: const Icon(Icons.folder_open, size: 18),
label: Text(img == null ? 'Choose…' : 'Other file…'),
),
const Spacer(),
TextButton(onPressed: () => Navigator.of(context).maybePop(), child: const Text('Cancel')),
const SizedBox(width: 8),
FilledButton(onPressed: img == null || _busy ? null : _use, child: const Text('Use this')),
],
),
],
),
),
),
);
}
}
/// Draws [img] scaled by [scale], centred in the box and moved by [pan].
class CropPainter extends CustomPainter {
final ui.Image img;
final double scale;
final Offset pan;
final bool clipToView;
CropPainter(this.img, this.scale, this.pan, {this.clipToView = true});
@override
void paint(Canvas canvas, Size size) {
if (clipToView) canvas.clipRect(Offset.zero & size);
final w = img.width * scale, h = img.height * scale;
final dst = Rect.fromLTWH((size.width - w) / 2 + pan.dx, (size.height - h) / 2 + pan.dy, w, h);
canvas.drawImageRect(
img,
Rect.fromLTWH(0, 0, img.width.toDouble(), img.height.toDouble()),
dst,
Paint()..filterQuality = FilterQuality.high,
);
}
@override
bool shouldRepaint(CropPainter old) => old.img != img || old.scale != scale || old.pan != pan;
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,874 @@
import 'dart:async';
import 'dart:io';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:fox_utils/fox_utils.dart' show DesktopDropX, DropTarget;
import 'package:signals/signals_flutter.dart';
import 'package:sync_relay_core/core.dart'
show CookieBrowser, CookieFile, NetscapeCookie, YtDlp, YtDlpCookies, cookieBrowsers, parseCookies;
import '../controls/player_settings.dart';
import '../jellyfin/accounts.dart';
import '../jellyfin/accounts_panel.dart';
import '../playback/yt_dlp_app.dart';
import '../schedule/animeflow.dart';
import '../screens/home/about_page.dart';
import '../screens/home/settings_page.dart';
import '../sfx/sfx.dart';
import '../sfx/sound_bank.dart';
import '../sfx/synth_sfx.dart';
import '../state/session_state.dart';
import '../state/settings.dart';
import '../update/update_controller.dart';
import 'avatar.dart';
import 'motion.dart';
/// The Settings tab's pages, in nav order.
enum SettingsSection {
profile(Icons.person_outline, 'Profile'),
network(Icons.hub_outlined, 'Party & network'),
player(Icons.play_circle_outline, 'Player'),
integrations(Icons.extension_outlined, 'Jellyfin & integrations'),
notifications(Icons.notifications_none, 'Notifications & sounds'),
updates(Icons.system_update_alt, 'Updates'),
shortcuts(Icons.keyboard_outlined, 'Keyboard shortcuts'),
about(Icons.info_outline, 'About');
final IconData icon;
final String label;
const SettingsSection(this.icon, this.label);
}
/// App-wide settings under a left nav (chips on top when narrow), each
/// page a column of cards; the shell owns [section] so the profile card
/// can open a page.
class SettingsTab extends StatelessWidget {
final SettingsSection section;
final ValueChanged<SettingsSection> onSection;
final VoidCallback? onEditProfile;
final UpdateController? updates;
/// Defaults to [JellyfinAccounts.instance].
final JellyfinAccounts? jellyfin;
/// AnimeFlow's `/me` for the integrations page (tests pass a fake).
final Future<AnimeFlowMe> Function()? animeFlowMe;
const SettingsTab({
super.key,
required this.section,
required this.onSection,
this.onEditProfile,
this.updates,
this.jellyfin,
this.animeFlowMe,
});
List<Widget> _page(BuildContext context) {
switch (section) {
case SettingsSection.profile:
return [
SettingsPanel(
title: 'You',
child: SignalBuilder(
builder: (context) {
final me = selfAvatarInfo();
return ListTile(
contentPadding: EdgeInsets.zero,
leading: StatusAvatar(info: me, size: 48, badges: false, ring: false),
title: Text(me.name == '?' ? 'No name yet' : me.name),
subtitle: const Text('Name, picture and colour: click your avatar in the top left'),
trailing: onEditProfile == null
? null
: TextButton(onPressed: onEditProfile, child: const Text('Edit')),
);
},
),
),
];
case SettingsSection.network:
return [
const HostDefaultsPanel(),
const RecentServersPanel(),
SettingsPanel(
title: 'Watching',
child: SignalBuilder(
builder: (context) => SwitchListTile(
key: const ValueKey('unready-on-swap'),
contentPadding: EdgeInsets.zero,
title: const Text('Unready when the video changes'),
subtitle: const Text('Otherwise you stay ready and the next video starts once everyone ready has buffered'),
value: AppSettings.instance.unreadyOnSwap.value,
onChanged: AppSettings.instance.setUnreadyOnSwap,
),
),
),
SettingsPanel(
title: 'When idle',
child: SignalBuilder(
builder: (context) => SwitchListTile(
key: const ValueKey('idle-sits-out'),
contentPadding: EdgeInsets.zero,
title: const Text('When idle, sit out instead of just unready'),
subtitle: const Text('After 10 min away while ready and held, your player unloads the video until you join back (Space)'),
value: AppSettings.instance.idleSitsOut.value,
onChanged: AppSettings.instance.setIdleSitsOut,
),
),
),
];
case SettingsSection.player:
return [
const SettingsHintCard(
icon: Icons.tune,
text: "Player settings are also in the player's ⚙, at the right end of its controls.",
),
SettingsPanel(
title: 'Player',
child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: playerSettingRows()),
),
const SettingsPanel(title: 'Custom shaders', child: CustomShaderFiles()),
const SettingsPanel(title: 'yt-dlp', child: YtDlpSettings()),
];
case SettingsSection.integrations:
final accounts = jellyfin ?? JellyfinAccounts.instance;
return [
Padding(
padding: const EdgeInsets.only(bottom: 16),
child: JellyfinAccountsPanel(accounts: accounts),
),
AnimeFlowCard(
me: animeFlowMe ??
() async {
final client = AnimeFlowClient.fromAccounts(accounts);
try {
return await client.me();
} finally {
client.close();
}
},
),
const SettingsPanel(title: 'Jellyfin playback', child: AutoAdvanceSetting()),
const SettingsPanel(title: 'Jellyfin library', child: HideReReleasesSetting()),
];
case SettingsSection.notifications:
return const [NotificationsInfo()];
case SettingsSection.updates:
return [SettingsPanel(title: 'Updates', child: UpdateSettings(controller: updates))];
case SettingsSection.shortcuts:
return const [SettingsPanel(title: 'Keyboard shortcuts', child: ShortcutList())];
case SettingsSection.about:
return [AboutPanel(updates: updates)];
}
}
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, box) {
final narrow = box.maxWidth < 760;
final nav = narrow
? SizedBox(
height: 52,
child: HScroll(
builder: (c) => ListView(
controller: c,
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.fromLTRB(20, 8, 20, 4),
children: [
for (final s in SettingsSection.values)
Padding(
padding: const EdgeInsets.only(right: 8),
child: ChoiceChip(
key: ValueKey('settings-${s.name}'),
avatar: Icon(s.icon, size: 18),
showCheckmark: false,
label: Text(s.label),
selected: section == s,
onSelected: (_) => onSection(s),
),
),
],
),
),
)
: SizedBox(
width: 250,
child: ListView(
padding: const EdgeInsets.fromLTRB(16, 20, 8, 20),
children: [
for (final s in SettingsSection.values)
ListTile(
key: ValueKey('settings-${s.name}'),
selected: section == s,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
leading: Icon(s.icon, size: 20),
title: Text(s.label),
onTap: () => onSection(s),
),
],
),
);
final page = fadeThrough(
KeyedSubtree(
key: ValueKey(section),
child: ListView(
padding: const EdgeInsets.fromLTRB(20, 20, 24, 24),
children: [
Align(
alignment: Alignment.topLeft,
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 760),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(section.label, style: Theme.of(context).textTheme.headlineSmall),
const SizedBox(height: 14),
..._page(context),
],
),
),
),
],
),
),
);
return narrow
? Column(children: [nav, Expanded(child: page)])
: Row(children: [nav, Expanded(child: page)]);
},
);
}
}
/// Party & network: what the Host card starts with.
class HostDefaultsPanel extends StatefulWidget {
const HostDefaultsPanel({super.key});
@override
State<HostDefaultsPanel> createState() => _HostDefaultsPanelState();
}
class _HostDefaultsPanelState extends State<HostDefaultsPanel> {
final _form = SessionState.instance.lastForm;
late final _port = TextEditingController(text: _form.hostPort);
late final _secret = TextEditingController(text: _form.hostSecret);
bool _show = false;
@override
void dispose() {
_port.dispose();
_secret.dispose();
super.dispose();
}
static String? portProblem(String v) {
final n = int.tryParse(v.trim());
return n == null || n < 1 || n > 65535 ? 'A number from 1 to 65535' : null;
}
Future<void> _save() async {
try {
await SessionState.instance.saveForm();
} catch (e) {
debugPrint('save host defaults failed: $e');
}
}
@override
Widget build(BuildContext context) {
return SettingsPanel(
title: 'Hosting',
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SettingRow(
label: 'Default port',
help: 'Where your server listens when you host; friends dial it',
control: SizedBox(
width: 120,
child: TextField(
key: const ValueKey('host-port'),
controller: _port,
keyboardType: TextInputType.number,
decoration: InputDecoration(isDense: true, errorText: portProblem(_port.text)),
onChanged: (v) {
setState(() {});
if (portProblem(v) != null) return;
_form.hostPort = v.trim();
_save();
},
),
),
),
SettingRow(
label: 'Password',
help: 'Asked of everyone joining your server; blank lets in anyone who reaches the port',
control: SizedBox(
width: 220,
child: TextField(
key: const ValueKey('host-secret'),
controller: _secret,
obscureText: !_show,
decoration: InputDecoration(
isDense: true,
hintText: 'none',
suffixIcon: IconButton(
tooltip: _show ? 'Hide' : 'Show',
icon: Icon(_show ? Icons.visibility_off : Icons.visibility, size: 18),
onPressed: () => setState(() => _show = !_show),
),
),
onChanged: (v) {
_form.hostSecret = v;
_save();
},
),
),
),
],
),
);
}
}
/// Party & network: the servers Join offers in one click.
class RecentServersPanel extends StatelessWidget {
const RecentServersPanel({super.key});
@override
Widget build(BuildContext context) {
final session = SessionState.instance;
final muted = Theme.of(context).colorScheme.onSurfaceVariant;
return SettingsPanel(
title: 'Recent servers',
child: SignalBuilder(builder: (context) {
final list = session.recents.value;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('Join and Host offer these in one click; remove the ones you no longer use',
style: TextStyle(color: muted, fontSize: 12)),
if (list.isEmpty)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text('None yet.', style: TextStyle(color: muted)),
),
for (final r in list)
ListTile(
key: ValueKey('recent-${r.key}'),
contentPadding: EdgeInsets.zero,
leading: Icon(r.hosted ? Icons.cast_outlined : Icons.dns_outlined),
title: Text(r.hosted ? 'Hosted on port ${r.port}' : '${r.host}:${r.port}'),
subtitle: r.secret.isEmpty ? null : const Text('password saved'),
trailing: IconButton(
tooltip: 'Remove',
icon: const Icon(Icons.close, size: 18),
onPressed: () => session.forgetServer(r),
),
),
],
);
}),
);
}
}
/// The app's yt-dlp (web page links): its version, "Update now", and the
/// last error.
class YtDlpSettings extends StatefulWidget {
/// Defaults to [AppYtDlp.instance]'s.
final Future<YtDlp>? ytDlp;
const YtDlpSettings({super.key, this.ytDlp});
@override
State<YtDlpSettings> createState() => _YtDlpSettingsState();
}
class _YtDlpSettingsState extends State<YtDlpSettings> {
late final Future<YtDlp> _y = widget.ytDlp ?? AppYtDlp.instance.ytDlp;
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return FutureBuilder<YtDlp>(
future: _y,
builder: (context, snap) {
final y = snap.data;
if (y == null) return const SizedBox.shrink();
return StreamBuilder<void>(
stream: y.changes,
builder: (context, _) {
final err = y.lastError;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.travel_explore),
title: Text(y.busy
? 'Downloading…'
: y.version == null
? 'Not downloaded yet'
: 'Version ${y.version}'),
subtitle: err == null
? null
: Text(err, key: const ValueKey('ytdlp-error'), style: TextStyle(color: scheme.error)),
trailing: TextButton(
onPressed: y.busy
? null
: () => y.path == null ? y.ensure() : y.update(force: true),
child: const Text('Update now'),
),
),
Text(
'Plays links to web pages (YouTube, X, …) in all three link modes. '
'Downloaded and kept up to date by the app; checked once a day.',
style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12),
),
const SizedBox(height: 12),
YtDlpCookieSettings(cookies: y.cookies),
],
);
},
);
},
);
}
}
/// yt-dlp's cookies (sites that refuse guests): from a browser, or a
/// cookies.txt / JSON export picked or dropped, filtered by site into the
/// private copy in the yt-dlp folder (see [YtDlpCookies]).
class YtDlpCookieSettings extends StatefulWidget {
final YtDlpCookies cookies;
const YtDlpCookieSettings({super.key, required this.cookies});
@override
State<YtDlpCookieSettings> createState() => _YtDlpCookieSettingsState();
}
class _YtDlpCookieSettingsState extends State<YtDlpCookieSettings> {
late var _source = widget.cookies.read();
String? _error;
bool _dragging = false;
void _reload([String? error]) {
if (!mounted) return;
setState(() {
_source = widget.cookies.read();
_error = error;
});
}
String get _summary => switch (_source) {
CookieFile f => '${f.count} cookie${f.count == 1 ? '' : 's'} for '
'${f.sites.length <= 3 ? f.sites.join(', ') : '${f.sites.length} sites'}',
CookieBrowser b => 'From ${b.browser}${b.profile.isEmpty ? '' : ' (profile ${b.profile})'}',
_ => 'No cookies: yt-dlp visits sites as a guest',
};
Future<void> _pickFile() async {
final List<PlatformFile> files;
try {
files = await FilePicker.pickFiles(
dialogTitle: 'Pick a cookies.txt or a JSON cookie export',
type: FileType.custom,
allowedExtensions: const ['txt', 'json'],
);
} catch (_) {
_reload('file dialog unavailable — try again');
return;
}
final path = files.firstOrNull?.path;
if (path != null) await _importFile(path);
}
Future<void> _importFile(String path) async {
final List<NetscapeCookie> all;
try {
all = parseCookies(await File(path).readAsString());
} on FormatException catch (e) {
_reload("Can't read that file: ${e.message}");
return;
} on FileSystemException catch (e) {
_reload("Can't read that file: ${e.message}");
return;
}
if (!mounted) return;
// Every site in the file: an export is "my logins", not a pick list.
try {
await widget.cookies.saveFile(all);
_reload();
} catch (e) {
_reload('Could not keep the cookies: $e');
}
}
Future<void> _pickBrowser() async {
final current = _source;
final picked = await showDialog<CookieBrowser>(
context: context,
builder: (_) => _CookieBrowserDialog(initial: current is CookieBrowser ? current : null),
);
if (picked == null) return;
try {
await widget.cookies.saveBrowser(picked.browser, picked.profile);
_reload();
} catch (e) {
_reload('Could not save: $e');
}
}
Future<void> _remove() async {
try {
await widget.cookies.remove();
_reload();
} catch (e) {
_reload('Could not remove the cookies: $e');
}
}
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return DropTarget(
onDragEntered: (_) => setState(() => _dragging = true),
onDragExited: (_) => setState(() => _dragging = false),
onDragDone: (d) {
setState(() => _dragging = false);
final path = DesktopDropX.pathsOf(d.files).firstOrNull;
if (path != null) _importFile(path);
},
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
border: Border.all(color: _dragging ? scheme.primary : scheme.outlineVariant),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.cookie_outlined),
title: const Text('Cookies'),
subtitle: Text(_summary, key: const ValueKey('ytdlp-cookies-summary')),
),
if (_error != null)
Text(_error!, key: const ValueKey('ytdlp-cookies-error'), style: TextStyle(color: scheme.error)),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
OutlinedButton.icon(
onPressed: _pickBrowser,
icon: const Icon(Icons.public),
label: const Text('From a browser…'),
),
OutlinedButton.icon(
onPressed: _pickFile,
icon: const Icon(Icons.file_open_outlined),
label: const Text('From a file…'),
),
if (_source != null)
TextButton(
key: const ValueKey('ytdlp-cookies-remove'),
onPressed: _remove,
child: const Text('Remove cookies'),
),
],
),
const SizedBox(height: 8),
Text(
'For sites that refuse guests (Bilibili, YouTube asking to sign in). Drop a cookies.txt or a '
'cookie-extension JSON export here, or pick one; all of it is kept, on this computer only. '
'Cookies log yt-dlp in as you on those sites. Keep the file private; for YouTube consider a '
'throwaway account (Google may flag accounts used with yt-dlp).',
style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12),
),
],
),
),
);
}
}
class _CookieBrowserDialog extends StatefulWidget {
final CookieBrowser? initial;
const _CookieBrowserDialog({this.initial});
@override
State<_CookieBrowserDialog> createState() => _CookieBrowserDialogState();
}
class _CookieBrowserDialogState extends State<_CookieBrowserDialog> {
late var _browser = widget.initial?.browser ?? cookieBrowsers.first;
late final _profile = TextEditingController(text: widget.initial?.profile ?? '');
@override
void dispose() {
_profile.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('Cookies from a browser'),
content: SizedBox(
width: 380,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
DropdownButtonFormField<String>(
initialValue: _browser,
decoration: const InputDecoration(labelText: 'Browser'),
items: [for (final b in cookieBrowsers) DropdownMenuItem(value: b, child: Text(b))],
onChanged: (v) => setState(() => _browser = v ?? _browser),
),
const SizedBox(height: 12),
TextField(
controller: _profile,
decoration: const InputDecoration(labelText: 'Profile (optional)', hintText: 'the default profile'),
),
const SizedBox(height: 12),
Text(
'yt-dlp reads them each time it opens a link (the browser may need to be closed on Windows).',
style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant, fontSize: 12),
),
],
),
),
actions: [
TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel')),
FilledButton(
onPressed: () => Navigator.pop(context, CookieBrowser(_browser, _profile.text.trim())),
child: const Text('Use'),
),
],
);
}
}
/// AnimeFlow (the Schedule tab's source): connected as whom, with which
/// rights, or why not.
class AnimeFlowCard extends StatefulWidget {
final Future<AnimeFlowMe> Function() me;
const AnimeFlowCard({super.key, required this.me});
@override
State<AnimeFlowCard> createState() => _AnimeFlowCardState();
}
class _AnimeFlowCardState extends State<AnimeFlowCard> {
late Future<AnimeFlowMe> _me = widget.me();
static String scopeText(Set<AnimeFlowScope> scopes) => [
if (scopes.contains(AnimeFlowScope.view)) 'can view',
if (scopes.contains(AnimeFlowScope.request)) 'request',
if (scopes.contains(AnimeFlowScope.manage)) 'approve',
].join(', ');
@override
Widget build(BuildContext context) {
final muted = Theme.of(context).colorScheme.onSurfaceVariant;
return SettingsPanel(
title: 'AnimeFlow',
child: FutureBuilder<AnimeFlowMe>(
future: _me,
builder: (context, snap) {
final String status;
final Color color;
if (snap.connectionState != ConnectionState.done) {
status = 'checking…';
color = muted;
} else if (snap.hasError) {
final e = snap.error;
status = e is AnimeFlowException ? e.detail : '$e';
color = Theme.of(context).colorScheme.error;
} else {
final me = snap.data!;
final scopes = scopeText(me.scopes);
status = ['connected as ${me.name.isEmpty ? me.userId : me.name}', if (scopes.isNotEmpty) scopes]
.join(' · ');
color = Colors.greenAccent.shade400;
}
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.downloading_outlined),
title: Text(Uri.parse(animeFlowDefaultBase).host),
subtitle: Text(status, key: const ValueKey('animeflow-status'), style: TextStyle(color: color)),
trailing: TextButton(
onPressed: snap.connectionState == ConnectionState.done
? () => setState(() => _me = widget.me())
: null,
child: const Text('Check again'),
),
),
Text(
'Powers the Schedule tab: what is downloaded, downloading or missing, and requests. '
'Signs in with your Jellyfin account on the same domain.',
style: TextStyle(color: muted, fontSize: 12),
),
],
);
},
),
);
}
}
/// What pops up and what makes a sound. No switches: there is nothing to
/// choose yet.
class NotificationsInfo extends StatelessWidget {
const NotificationsInfo({super.key});
@override
Widget build(BuildContext context) {
final muted = TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant, fontSize: 12);
Widget line(String title, String help) => Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [Text(title), Text(help, style: muted)],
),
);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SettingsPanel(
title: 'Pop-ups',
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
line('Joins, leaves, video changes and chat',
'Pop up bottom left for 5 s, with the person\'s picture'),
line('Sat out or nudged by someone', 'Only on your end, with their picture'),
line('Ready, seeks and pauses', 'Only in Chat & activity (Tab), never as a pop-up'),
line('App notices', 'Shader changes, a frozen video reloaded, the idle warning'),
],
),
),
const SettingsPanel(title: 'Sounds', child: SoundSettings()),
],
);
}
}
/// Volume and the sound set, then every sound with a preview button, its
/// own set (or the overall one) and its own switch.
class SoundSettings extends StatelessWidget {
const SoundSettings({super.key});
void _preview(SfxKind kind) {
final sfx = SessionState.instance.sfx;
if (sfx is SynthSfx) unawaited(sfx.preview(kind, readyIndex: 2));
}
@override
Widget build(BuildContext context) {
final s = AppSettings.instance;
final scheme = Theme.of(context).colorScheme;
final muted = TextStyle(color: scheme.onSurfaceVariant, fontSize: 12);
return SignalBuilder(builder: (context) {
final volume = s.soundVolume.value;
final off = s.soundsOff.value;
final overall = s.soundPreset.value;
final per = s.soundPresetFor.value;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Icon(volume == 0 ? Icons.volume_off_outlined : Icons.volume_down_outlined,
color: scheme.onSurfaceVariant),
Expanded(
child: Slider(
key: const ValueKey('sound-volume'),
value: volume,
onChanged: s.setSoundVolume,
onChangeEnd: (_) => _preview(SfxKind.ready),
),
),
SizedBox(width: 44, child: Text('${(volume * 100).round()}%', textAlign: TextAlign.end)),
],
),
Text('Soft cues for people and readiness; the video itself covers play, pause and the end. '
'Letting go of the slider plays the ready sound.', style: muted),
const SizedBox(height: 12),
Row(
children: [
const Expanded(child: Text('Sound set')),
DropdownButton<String>(
key: const ValueKey('sound-preset'),
value: overall,
onChanged: (v) {
if (v == null) return;
s.setSoundPreset(v);
_preview(SfxKind.ready);
},
items: [
for (final MapEntry(key: p, value: (name, _)) in presetInfo.entries)
DropdownMenuItem(value: p.name, child: Text(name)),
],
),
],
),
Text(presetInfo.entries.where((e) => e.key.name == overall).firstOrNull?.value.$2 ?? '', style: muted),
const SizedBox(height: 8),
for (final MapEntry(key: kind, value: (name, help)) in soundInfo.entries)
Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Row(
children: [
IconButton(
key: ValueKey('sound-play-${kind.name}'),
tooltip: 'Play',
onPressed: volume == 0 ? null : () => _preview(kind),
icon: const Icon(Icons.play_arrow_rounded),
),
const SizedBox(width: 4),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [Text(name), Text(help, style: muted)],
),
),
DropdownButton<String?>(
key: ValueKey('sound-preset-${kind.name}'),
value: per[kind.name],
underline: const SizedBox.shrink(),
style: muted,
onChanged: (v) {
s.setSoundPresetFor(kind.name, v);
if (volume > 0) _preview(kind);
},
items: [
const DropdownMenuItem<String?>(value: null, child: Text('Same as set')),
for (final MapEntry(key: p, value: (name, _)) in presetInfo.entries)
DropdownMenuItem<String?>(value: p.name, child: Text(name)),
],
),
// A nudge always plays (an explicit poke): no switch,
// its place kept so the rows line up.
Visibility.maintain(
visible: kind != SfxKind.nudge,
child: Switch(
key: ValueKey('sound-on-${kind.name}'),
value: !off.contains(kind.name),
onChanged: volume == 0 || kind == SfxKind.nudge ? null : (on) => s.setSoundOn(kind.name, on),
),
),
],
),
),
],
);
});
}
}
@@ -0,0 +1,106 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:signals/signals_flutter.dart';
import '../playback/media_marks.dart' show formatClock;
import '../state/session_state.dart';
import '../sync/state_diff.dart' show serverPositionAt;
import 'now_playing_title.dart';
import 'start_banner.dart';
/// In place of the video while self sits out: the media's start card
/// (cover, title, episode), where the others are ("12:34 / 24:00",
/// playing or paused) and "Join back" (or Space). [compact]: the mini
/// player.
class SitOutCard extends StatefulWidget {
final bool compact;
const SitOutCard({super.key, this.compact = false});
@override
State<SitOutCard> createState() => _SitOutCardState();
}
class _SitOutCardState extends State<SitOutCard> {
// The position moves on while playing: redraw each second.
late final Timer _tick;
@override
void initState() {
super.initState();
_tick = Timer.periodic(const Duration(seconds: 1), (_) {
if (mounted) setState(() {});
});
}
@override
void dispose() {
_tick.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
final coord = SessionState.instance.coordinator;
final small = widget.compact;
return ListenableBuilder(
listenable: artChanges(),
builder: (context, _) => SignalBuilder(builder: (context) {
final r = coord.serverState.value;
final now = coord.nowPlaying.value;
final playing = r?.transport == 'playing';
final duration = (r?.media?['duration'] as num?)?.toDouble() ?? 0;
final at = r == null ? 0.0 : serverPositionAt(r, coord.sync.serverNow());
Duration secs(double s) => Duration(milliseconds: (s * 1000).round());
final where = r?.media == null
? 'Nothing is up'
: duration > 0
? '${formatClock(secs(at.clamp(0, duration)))} / ${formatClock(secs(duration))}'
: formatClock(secs(at));
final text = TextStyle(color: Colors.white, fontSize: small ? 12 : 15);
final panel = Column(
mainAxisSize: MainAxisSize.min,
children: [
Text("You're sitting out", style: text.copyWith(fontWeight: FontWeight.w700, fontSize: small ? 13 : 20)),
SizedBox(height: small ? 4 : 8),
Row(
mainAxisSize: MainAxisSize.min,
children: [
if (r?.media != null) ...[
Icon(playing ? Icons.play_arrow_rounded : Icons.pause_rounded,
color: Colors.white70, size: small ? 14 : 18),
const SizedBox(width: 4),
],
Text(where, key: const ValueKey('sit-out-position'), style: text.copyWith(color: Colors.white70)),
],
),
SizedBox(height: small ? 8 : 16),
FilledButton.icon(
key: const ValueKey('join-back'),
onPressed: coord.joinBack,
icon: Icon(Icons.login_rounded, size: small ? 16 : 20),
label: Text(small ? 'Join back' : 'Join back (Space)'),
style: small
? FilledButton.styleFrom(
visualDensity: VisualDensity.compact,
padding: const EdgeInsets.symmetric(horizontal: 12),
)
: null,
),
],
);
return Stack(
fit: StackFit.expand,
children: [
if (now != null) StartCard(now: now) else const ColoredBox(color: Color(0xFF0C0D11)),
// Small: the card's title sits at the bottom, so the panel goes up top.
Align(
alignment: small ? const Alignment(0, -0.35) : Alignment.center,
child: panel,
),
],
);
}),
);
}
}
@@ -0,0 +1,180 @@
import 'dart:ui' show ImageFilter;
import 'package:flutter/material.dart';
import 'package:signals/signals_flutter.dart';
import '../session/now_playing.dart';
import '../state/session_state.dart';
import 'motion.dart';
import 'now_playing_title.dart';
/// Space kept free at the bottom of the start card for the controls and
/// the status pill.
const startCardBottom = 176.0;
/// The start card of [now]: its banner (cover-fit, darkened), else a soft
/// blur of the cover, else dark; the cover, a big title and the episode
/// line bottom left, above the controls.
class StartCard extends StatelessWidget {
final NowPlaying now;
const StartCard({super.key, required this.now});
@override
Widget build(BuildContext context) {
final banner = artImage(now.bannerHash);
final cover = banner == null ? artImage(now.posterHash) : null;
final episode = now.episode;
return LayoutBuilder(builder: (context, box) {
final h = box.maxHeight;
// Small (the mini player): no cover, a smaller title near the bottom.
final small = h < 320;
final coverWidth = (h * 0.26).clamp(84.0, 240.0) / 1.5;
final titleSize = small ? (h * 0.08).clamp(13.0, 18.0) : (h * 0.05).clamp(22.0, 44.0);
final side = small ? 12.0 : (box.maxWidth * 0.05).clamp(20.0, 72.0);
return Stack(
fit: StackFit.expand,
children: [
const ColoredBox(color: Color(0xFF0C0D11)),
if (banner != null)
fadeInImage(banner)
else if (cover != null)
Opacity(
opacity: 0.5,
child: ImageFiltered(
imageFilter: ImageFilter.blur(sigmaX: 48, sigmaY: 48, tileMode: TileMode.decal),
child: Transform.scale(scale: 1.3, child: fadeInImage(cover)),
),
)
else
const DecoratedBox(
decoration: BoxDecoration(
gradient: RadialGradient(
center: Alignment(-0.7, 0.5),
radius: 1.2,
colors: [Color(0xFF23262E), Color(0xFF0C0D11)],
),
),
),
const DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
stops: [0, 0.35, 1],
colors: [Color(0x55000000), Color(0x22000000), Color(0xE6000000)],
),
),
),
const DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.centerLeft,
end: Alignment.centerRight,
stops: [0, 0.65],
colors: [Color(0x99000000), Color(0x00000000)],
),
),
),
Positioned(
left: side,
right: side,
bottom: small ? 12 : startCardBottom,
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
if (!small) ...[
ArtCover(hash: now.posterHash, width: coverWidth),
const SizedBox(width: 24),
],
Expanded(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
now.title,
key: const ValueKey('start-card-title'),
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: Colors.white,
fontSize: titleSize,
fontWeight: FontWeight.w800,
height: 1.1,
shadows: const [Shadow(color: Color(0x99000000), blurRadius: 16)],
),
),
if (episode != null && episode.isNotEmpty) ...[
const SizedBox(height: 6),
Text(
episode,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: Colors.white70, fontSize: titleSize * 0.42),
),
],
],
),
),
],
),
),
],
);
});
}
}
/// [StartCard] over the picture until playback starts on this media
/// (the server plays, or its position passed 1 s); then it fades out and
/// stays gone for that media, whatever happens later. Never takes input.
class StartBanner extends StatefulWidget {
const StartBanner({super.key});
@override
State<StartBanner> createState() => _StartBannerState();
}
class _StartBannerState extends State<StartBanner> {
int? _mediaId;
bool _started = false;
// Faded out: the card leaves the tree (and its images with it).
bool _gone = true;
NowPlaying? _last;
@override
Widget build(BuildContext context) {
final coord = SessionState.instance.coordinator;
return IgnorePointer(
child: ListenableBuilder(
listenable: artChanges(),
builder: (context, _) => SignalBuilder(builder: (context) {
final r = coord.serverState.value;
final now = coord.nowPlaying.value;
if (r != null && r.mediaId != _mediaId) {
_mediaId = r.mediaId;
_started = false;
}
if (r != null && (r.transport == 'playing' || r.position >= 1)) _started = true;
final show = now != null && r?.media != null && !_started;
if (show) {
_last = now;
_gone = false;
}
final last = _last;
if (last == null || _gone) return const SizedBox.shrink();
return AnimatedOpacity(
key: const ValueKey('start-banner'),
opacity: show ? 1 : 0,
duration: easeTime,
curve: ease,
onEnd: () {
if (!show && mounted) setState(() => _gone = true);
},
child: StartCard(now: last),
);
}),
),
);
}
}
+248
View File
@@ -0,0 +1,248 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:signals/signals_flutter.dart';
import '../session/session_coordinator.dart' show ToastItem;
import '../state/session_state.dart';
import 'avatar.dart';
import 'motion.dart';
/// One toast on screen: who (null = the app itself), what, and whether it
/// is on its way out.
class ShellToast {
final int id;
final AvatarInfo? who;
final IconData? icon;
final String text;
final bool chat;
bool leaving = false;
ShellToast({required this.id, required this.text, this.who, this.icon, this.chat = false});
}
/// Splits a coordinator toast into who and what: feed and chat toasts
/// name their [ToastItem.actor] and start with it ("Bob joined", "Bob:
/// hi"); the rest are the app's.
ShellToast shellToastFor(ToastItem t, AvatarInfo Function(String name) avatar) {
final n = t.actor;
if (n == null || n.isEmpty) return ShellToast(id: t.id, text: t.text, icon: Icons.info_outline);
if (t.text.startsWith('$n: ')) {
return ShellToast(id: t.id, who: avatar(n), text: t.text.substring(n.length + 2), chat: true);
}
final rest = t.text.startsWith('$n ') ? t.text.substring(n.length + 1) : t.text;
final icon = rest.startsWith('joined')
? Icons.login
: rest.startsWith('left')
? Icons.logout
: rest.startsWith('sat you out')
? Icons.bedtime
: rest.startsWith('nudged')
? Icons.notifications_active_outlined
: rest.startsWith('changed')
? Icons.swap_horiz
: Icons.info_outline;
return ShellToast(id: t.id, who: avatar(n), text: rest, icon: icon);
}
/// The coordinator's toasts as cards with avatars, bottom-left: each
/// slides in, stays [life], then slides out and collapses its gap. A click
/// on a chat toast opens the chat ([onOpenChat]); the rest let clicks
/// through.
class PartyToasts extends StatefulWidget {
final Duration life;
final VoidCallback? onOpenChat;
const PartyToasts({super.key, this.life = const Duration(seconds: 5), this.onOpenChat});
@override
State<PartyToasts> createState() => _PartyToastsState();
}
class _PartyToastsState extends State<PartyToasts> {
final _shown = <ShellToast>[];
final _timers = <Timer>[];
Set<int> _seen = {};
late final void Function() _stop;
@override
void initState() {
super.initState();
final coord = SessionState.instance.coordinator;
// What was there before this mounted is old news.
_seen = {for (final t in coord.toasts.peek()) t.id};
_stop = effect(() {
final items = coord.toasts.value;
final fresh = [for (final t in items) if (!_seen.contains(t.id)) t];
_seen = {for (final t in items) t.id};
if (fresh.isEmpty) return;
// Only new toasts re-run this, not the party they mention.
untracked(() {
for (final t in fresh) {
_add(shellToastFor(t, (n) => avatarForName(n, coord)));
}
});
});
}
void _add(ShellToast t) {
// Not from inside the effect's build-time notification.
scheduleMicrotask(() {
if (!mounted) return;
setState(() => _shown.add(t));
_timers.add(Timer(widget.life, () {
if (mounted) setState(() => t.leaving = true);
}));
_timers.add(Timer(widget.life + easeTime, () {
if (mounted) setState(() => _shown.remove(t));
}));
});
}
@override
void dispose() {
_stop();
for (final t in _timers) {
t.cancel();
}
super.dispose();
}
void _open(ShellToast t) {
widget.onOpenChat?.call();
setState(() => t.leaving = true);
}
@override
Widget build(BuildContext context) => ToastColumn(
toasts: _shown.reversed.take(6).toList().reversed.toList(),
onTap: widget.onOpenChat == null ? null : _open,
);
}
class ToastColumn extends StatelessWidget {
final List<ShellToast> toasts;
/// Clicks on chat toasts; null (or any other toast): clicks pass through.
final ValueChanged<ShellToast>? onTap;
const ToastColumn({super.key, required this.toasts, this.onTap});
Widget _card(ShellToast t) {
final card = ToastCard(toast: t);
final tap = onTap;
if (!t.chat || tap == null || t.leaving) return IgnorePointer(child: card);
return MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
key: ValueKey('toast-open-chat-${t.id}'),
onTap: () => tap(t),
child: card,
),
);
}
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (final t in toasts)
TweenAnimationBuilder<double>(
key: ValueKey(t.id),
tween: Tween(begin: 0, end: 1),
duration: easeTime,
curve: ease,
builder: (context, v, child) => Opacity(
opacity: v,
child: Transform.translate(offset: Offset(-24 * (1 - v), 0), child: child),
),
// Leaving: slide back out, fade, and collapse the gap it leaves.
child: AnimatedAlign(
duration: easeTime,
curve: ease,
alignment: Alignment.topLeft,
heightFactor: t.leaving ? 0 : 1,
child: AnimatedSlide(
duration: easeTime,
curve: ease,
offset: t.leaving ? const Offset(-0.25, 0) : Offset.zero,
child: AnimatedOpacity(
duration: easeTime,
curve: ease,
opacity: t.leaving ? 0 : 1,
child: Padding(padding: const EdgeInsets.only(top: 8), child: _card(t)),
),
),
),
),
],
);
}
}
class ToastCard extends StatelessWidget {
final ShellToast toast;
const ToastCard({super.key, required this.toast});
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final who = toast.who;
return Material(
elevation: 8,
color: scheme.surfaceContainerHigh.withValues(alpha: 0.95),
borderRadius: BorderRadius.circular(14),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 380),
child: Padding(
padding: const EdgeInsets.fromLTRB(10, 10, 16, 10),
child: Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: who == null ? CrossAxisAlignment.center : CrossAxisAlignment.start,
children: [
if (who != null)
StatusAvatar(info: who, size: 38, badges: false)
else
CircleAvatar(
radius: 19,
backgroundColor: scheme.primary.withValues(alpha: 0.15),
child: Icon(toast.icon ?? Icons.info_outline, size: 18, color: scheme.primary),
),
const SizedBox(width: 12),
Flexible(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (who != null) ...[
Row(
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: Text(who.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontWeight: FontWeight.w700, fontSize: 15)),
),
if (toast.icon != null) ...[
const SizedBox(width: 6),
Icon(toast.icon, size: 16, color: scheme.primary),
],
],
),
const SizedBox(height: 2),
],
Text(
toast.text,
style: TextStyle(
fontSize: toast.chat ? 15 : 14,
color: toast.chat || who == null ? scheme.onSurface : scheme.onSurfaceVariant,
),
),
],
),
),
],
),
),
),
);
}
}
+347
View File
@@ -0,0 +1,347 @@
import 'package:flutter/material.dart';
import '../jellyfin/accounts.dart';
import '../jellyfin/sign_in_panel.dart';
import '../screens/home/ui.dart' show appName;
import 'motion.dart';
/// One line under the app name.
const appTagline = 'Watch together, in sync';
/// Window width from which the welcome band puts the three ways beside
/// the app name (else under it).
const welcomeWideFrom = 980.0;
/// Top of the Library while not in a server: the app's name and the three
/// ways in, Join a friend first (what most people came for), then Host and
/// Watch solo. None of them needs Jellyfin.
class WelcomeBand extends StatelessWidget {
final VoidCallback onJoin;
final VoidCallback onHost;
final VoidCallback onSolo;
final VoidCallback? onDismiss;
const WelcomeBand({super.key, required this.onJoin, required this.onHost, required this.onSolo, this.onDismiss});
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final t = Theme.of(context).textTheme;
final hero = Row(
mainAxisSize: MainAxisSize.min,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(14),
child: Image.asset('assets/icon/icon_256.png', width: 52, height: 52, filterQuality: FilterQuality.medium),
),
const SizedBox(width: 14),
Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(appName, style: t.titleLarge?.copyWith(fontWeight: FontWeight.w700, height: 1.1)),
const SizedBox(height: 2),
Text(appTagline, style: TextStyle(color: scheme.onSurfaceVariant)),
if (onDismiss != null)
TextButton(
key: const ValueKey('welcome-dismiss'),
onPressed: onDismiss,
style: TextButton.styleFrom(
padding: EdgeInsets.zero,
minimumSize: const Size(0, 24),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
foregroundColor: scheme.onSurfaceVariant,
textStyle: const TextStyle(fontSize: 12),
),
child: const Text("Don't show again"),
),
],
),
],
);
final ways = [
WayCard(
key: const ValueKey('way-join'),
icon: Icons.login_rounded,
title: 'Join a friend',
caption: 'Got an address from a friend? Join with it. Nothing to set up.',
primary: true,
onTap: onJoin,
),
WayCard(
key: const ValueKey('way-host'),
icon: Icons.podcasts_rounded,
title: 'Host',
caption: 'You have the video. Friends join you and watch it with you.',
onTap: onHost,
),
WayCard(
key: const ValueKey('way-solo'),
icon: Icons.person_outline_rounded,
title: 'Watch solo',
caption: 'Just you: play anything below. Invite friends any time.',
onTap: onSolo,
),
];
Widget row(List<Widget> cards) => IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (final (i, c) in cards.indexed) ...[if (i > 0) const SizedBox(width: 10), Expanded(child: c)],
],
),
);
final band = Container(
key: const ValueKey('welcome-band'),
margin: const EdgeInsets.fromLTRB(20, 16, 20, 0),
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(18),
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [scheme.primary.withValues(alpha: 0.16), scheme.primary.withValues(alpha: 0.03)],
),
border: Border.all(color: scheme.outlineVariant.withValues(alpha: 0.4)),
),
child: LayoutBuilder(builder: (context, box) {
if (box.maxWidth >= welcomeWideFrom - 40) {
return Row(
children: [
Padding(padding: const EdgeInsets.only(left: 4, right: 20), child: hero),
Expanded(child: row(ways)),
],
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(padding: const EdgeInsets.only(left: 4, bottom: 12), child: hero),
if (box.maxWidth >= 520)
row(ways)
else
for (final (i, c) in ways.indexed) ...[if (i > 0) const SizedBox(height: 8), c],
],
);
}),
);
return band;
}
}
/// One way in: a tinted icon, a title and a one-line caption.
class WayCard extends StatefulWidget {
final IconData icon;
final String title;
final String caption;
final bool primary;
final VoidCallback onTap;
const WayCard({
super.key,
required this.icon,
required this.title,
required this.caption,
required this.onTap,
this.primary = false,
});
@override
State<WayCard> createState() => _WayCardState();
}
class _WayCardState extends State<WayCard> {
bool _hover = false;
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final primary = widget.primary;
final bg = primary ? scheme.primaryContainer : scheme.surfaceContainerHigh;
final fg = primary ? scheme.onPrimaryContainer : scheme.onSurface;
return MouseRegion(
onEnter: (_) => setState(() => _hover = true),
onExit: (_) => setState(() => _hover = false),
child: AnimatedScale(
duration: easeTime,
curve: ease,
scale: _hover ? 1.015 : 1,
child: Material(
color: bg,
borderRadius: BorderRadius.circular(14),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: widget.onTap,
child: Padding(
padding: const EdgeInsets.fromLTRB(12, 12, 14, 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: primary ? scheme.primary : scheme.primary.withValues(alpha: 0.14),
borderRadius: BorderRadius.circular(10),
),
child: Icon(widget.icon, size: 20, color: primary ? scheme.onPrimary : scheme.primary),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(widget.title, style: TextStyle(fontWeight: FontWeight.w700, color: fg)),
const SizedBox(height: 2),
Text(widget.caption,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: 12, height: 1.3, color: fg.withValues(alpha: 0.72))),
],
),
),
],
),
),
),
),
),
);
}
}
/// The Library's Jellyfin source with no server linked: what plays with
/// nothing set up (Files, Link), then Jellyfin as an optional extra that
/// guests never need. The sign-in opens in place.
class NoJellyfinPanel extends StatefulWidget {
final JellyfinAccounts accounts;
final VoidCallback onFiles;
final VoidCallback onLink;
const NoJellyfinPanel({super.key, required this.accounts, required this.onFiles, required this.onLink});
@override
State<NoJellyfinPanel> createState() => _NoJellyfinPanelState();
}
class _NoJellyfinPanelState extends State<NoJellyfinPanel> {
bool _signIn = false;
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final t = Theme.of(context).textTheme;
final muted = TextStyle(color: scheme.onSurfaceVariant);
final sources = [
WayCard(
key: const ValueKey('source-files'),
icon: Icons.folder_outlined,
title: 'Files',
caption: 'Videos on this computer. Drop a file or folder anywhere.',
onTap: widget.onFiles,
),
WayCard(
key: const ValueKey('source-link'),
icon: Icons.link,
title: 'Link',
caption: 'YouTube, X, or any video address. Ctrl+V anywhere.',
onTap: widget.onLink,
),
];
return SingleChildScrollView(
key: const ValueKey('media-jellyfin'),
padding: const EdgeInsets.only(bottom: 24),
child: Align(
alignment: Alignment.topLeft,
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 640),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('Pick something to watch', style: t.titleMedium),
const SizedBox(height: 4),
Text('No account needed: these work for everyone.', style: muted),
const SizedBox(height: 12),
LayoutBuilder(
builder: (context, box) => box.maxWidth < 460
? Column(children: [sources[0], const SizedBox(height: 8), sources[1]])
: IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(child: sources[0]),
const SizedBox(width: 10),
Expanded(child: sources[1]),
],
),
),
),
const SizedBox(height: 20),
Material(
key: const ValueKey('jellyfin-optional'),
color: scheme.surfaceContainer,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
side: BorderSide(color: scheme.outlineVariant.withValues(alpha: 0.5)),
),
clipBehavior: Clip.antiAlias,
child: AnimatedSize(
duration: easeTime,
curve: ease,
alignment: Alignment.topCenter,
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Icon(Icons.video_library_outlined, color: scheme.onSurfaceVariant),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Wrap(spacing: 8, crossAxisAlignment: WrapCrossAlignment.center, children: [
const Text('Jellyfin', style: TextStyle(fontWeight: FontWeight.w600)),
Container(
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 1),
decoration: BoxDecoration(
color: scheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(6),
),
child: Text('optional', style: TextStyle(fontSize: 11, color: scheme.onSurfaceVariant)),
),
]),
Text(
'Only if you have a Jellyfin server: browse it here, '
'with progress and next episodes. Joining a friend never needs it.',
style: muted.copyWith(fontSize: 12),
),
],
),
),
],
),
if (!_signIn)
Align(
alignment: Alignment.centerRight,
child: TextButton(
key: const ValueKey('jellyfin-link'),
onPressed: () => setState(() => _signIn = true),
child: const Text('Link a server'),
),
),
if (_signIn) ...[
const SizedBox(height: 14),
JellyfinSignInPanel(accounts: widget.accounts, onCancel: () => setState(() => _signIn = false)),
],
],
),
),
),
),
],
),
),
),
);
}
}
@@ -33,8 +33,10 @@ class ServerIsolate {
/// Spawns the server and waits until it listens. Throws a
/// [StateError] with the server's message if it cannot start.
/// [loopbackOnly]: 127.0.0.1 only (the solo session).
static Future<ServerIsolate> start(
AppConfig config, {
bool loopbackOnly = false,
required void Function(String line) onLog,
required void Function(String error) onCrash,
Duration timeout = const Duration(seconds: 15),
@@ -83,7 +85,7 @@ class ServerIsolate {
final Isolate isolate;
try {
isolate = await Isolate.spawn(
_serverMain, (config.toMap(), events.sendPort),
_serverMain, ({...config.toMap(), if (loopbackOnly) 'loopbackOnly': true}, events.sendPort),
errorsAreFatal: false,
onError: errors.sendPort,
onExit: exit.sendPort,
+156 -23
View File
@@ -8,12 +8,18 @@ import 'package:sync_relay_core/core.dart';
import '../jellyfin/accounts.dart';
import '../jellyfin/session_media.dart';
import '../screens/home/health_probe.dart';
import '../screens/home/picked_media.dart';
import '../playback/yt_dlp_app.dart';
import '../sfx/sfx.dart';
import '../sfx/sound_bank.dart';
import '../sfx/synth_sfx.dart';
import '../session/health.dart';
import '../session/session_coordinator.dart';
import '../widgets/avatar_pipeline.dart';
import 'connect_prefs.dart';
import 'server_isolate.dart';
import 'settings.dart';
enum AppRoute {
home,
@@ -140,7 +146,7 @@ class ServerCrashed implements Exception {
}
/// A fresh per-hosting key: the app's own client sends it in hello to be
/// recognised as the (unkickable) host.
/// recognised as the host (`members[].host`).
String newHostKey() {
final r = Random.secure();
return base64Url.encode([for (var i = 0; i < 24; i++) r.nextInt(256)]);
@@ -153,19 +159,43 @@ class SessionState {
final route = signal(AppRoute.home);
final recents = signal<List<RecentServer>>(const []);
final status = signal(const HealthStatus(HealthSeverity.info, 'idle'));
final SfxService sfx = const NoopSfxService();
final SfxService sfx = SynthSfx(
volume: () => AppSettings.instance.soundVolume.peek(),
// A nudge is an explicit poke: it ignores its switch (not the volume).
enabled: (k) => k == SfxKind.nudge || !AppSettings.instance.soundsOff.peek().contains(k.name),
presetFor: soundPresetFor,
);
late final SessionCoordinator coordinator = SessionCoordinator(sfx: sfx);
final lastForm = ConnectForm();
JellyfinAccounts get jellyfin => JellyfinAccounts.instance;
/// Set when the hosted server died mid-session; Home shows it on the
/// Host card and clears it.
final serverCrash = signal<String?>(null);
/// In a solo session: this app's own server on 127.0.0.1, only you in
/// it. Hosting from it ([host]) keeps what plays and where.
final solo = signal(false);
/// The joined server's own name (`/health`), '' until known or when it
/// only has the default name.
final serverLabel = signal('');
/// A solo session is becoming a hosted one: the player has nothing open
/// until the media is up again on the new server.
final switching = signal(false);
ConnectParams? _soloParams;
/// The app's yt-dlp folder for the hosted server; '' (the server's own
/// default) when the app-support folder is not available.
static Future<String> _sharedYtDlpDir() async {
try {
return (await AppYtDlp.instance.ytDlp).dir;
} catch (_) {
return '';
}
}
ServerIsolate? _server;
String? _setupCrash;
@visibleForTesting
ServerIsolate? get hostedServer => _server;
void Function()? _stopKickWatch;
// Bumped by host/join/cancelPending: a cancelled setup notices at its
// next step and unwinds instead of connecting.
int _op = 0;
@@ -225,9 +255,102 @@ class SessionState {
await _saveFormQuietly();
}
/// Hosts a server friends can join. From a solo session it keeps the
/// media and position: the solo server goes, the new one gets the same
/// pick, seeked to where it was; if hosting fails, solo comes back.
Future<void> host(ConnectParams params, ConnectForm form) async {
if (!solo.peek()) return _host(params, form: form);
final carry = _invited = _carry();
switching.value = true;
try {
await coordinator.leave();
await stopServer();
solo.value = false;
coordinator.solo = false;
try {
await _host(params, form: form);
} catch (_) {
// Not hosted (failed or cancelled): back to solo, same media.
try {
await _host(_soloParams ?? params, soloSession: true);
if (carry != null) await _resume(carry);
} catch (e) {
debugPrint('back to solo failed: $e');
await leave();
}
rethrow;
}
try {
if (carry != null) await _resume(carry);
} catch (e) {
coordinator.showToast("couldn't put it up again: $e");
}
} finally {
switching.value = false;
}
}
/// What an Invite from solo carried over, for [cancelInvite].
({PickedMedia pick, double position})? _invited;
/// Invite cancelled after the hosted server already came up: back to
/// solo with the same media, not out of the session.
Future<void> cancelInvite() async {
final carry = _invited;
switching.value = true;
try {
await coordinator.leave();
await stopServer();
await _host(_soloParams!, soloSession: true);
if (carry != null) await _resume(carry);
} catch (e) {
debugPrint('back to solo failed: $e');
await leave();
} finally {
switching.value = false;
}
}
/// Watch alone: the same hosted path, on 127.0.0.1 and a free port, no
/// password, nothing put up yet. Nobody else can reach it.
Future<void> startSolo(ConnectParams params) async {
if (route.peek() == AppRoute.session) return;
_soloParams = params;
await _host(params, soloSession: true);
}
({PickedMedia pick, double position})? _carry() {
final pick = coordinator.lastPick;
if (pick == null || coordinator.serverState.peek()?.media == null) return null;
final pos = coordinator.playback.positionSecs;
return (pick: pick, position: pos.isFinite && pos > 0 ? pos : 0.0);
}
/// Puts [carry] up on the server just joined and seeks to its position
/// once the swap shows in a state.
Future<void> _resume(({PickedMedia pick, double position}) carry) async {
final before = coordinator.serverState.peek()?.mediaId ?? 0;
final error = await coordinator.swapPicked(carry.pick);
if (error != null) throw StateError(error);
if (carry.position < 1) return;
final landed = Completer<void>();
final stop = effect(() {
final r = coordinator.serverState.value;
if (r != null && r.media != null && r.mediaId > before && !landed.isCompleted) landed.complete();
});
try {
await landed.future.timeout(const Duration(seconds: 20));
coordinator.sync.sendSeek(carry.position);
} on TimeoutException {
debugPrint('resume: the media did not come up; not seeking');
} finally {
stop();
}
}
Future<void> _host(ConnectParams params, {ConnectForm? form, bool soloSession = false}) async {
final op = ++_op;
_rememberForm(form);
if (form != null) _rememberForm(form);
try {
status.value =
const HealthStatus(HealthSeverity.info, 'starting server…');
@@ -247,13 +370,15 @@ class SessionState {
try {
server = await ServerIsolate.start(
AppConfig(
port: params.port,
secret: params.secret,
port: soloSession ? 0 : params.port,
secret: soloSession ? '' : params.secret,
name: 'server',
mediaPath: params.mediaPath,
hostKey: hostKey,
jellyfin: relay,
ytDlpDir: await _sharedYtDlpDir(),
),
loopbackOnly: soloSession,
onLog: (line) => debugPrint('[server] $line'),
onCrash: (cause) => _onServerCrash(server, cause),
);
@@ -270,13 +395,13 @@ class SessionState {
_server = server;
coordinator.hostFile = (path) async =>
identical(_server, server) ? server.setFile(path) : null;
status.value =
HealthStatus(HealthSeverity.ok, 'hosting on port ${server.port}');
status.value = HealthStatus(
HealthSeverity.ok, soloSession ? 'watching solo' : 'hosting on port ${server.port}');
try {
await coordinator.connect(ConnectParams(
host: '127.0.0.1',
port: server.port,
secret: params.secret,
secret: soloSession ? '' : params.secret,
name: params.name,
avatar: params.avatar,
hostKey: hostKey,
@@ -290,10 +415,11 @@ class SessionState {
if (crash != null && e is! ConnectCancelled) throw ServerCrashed(crash);
rethrow;
}
_watchKick();
solo.value = soloSession;
coordinator.solo = soloSession;
route.value = AppRoute.session;
} finally {
await _saveFormQuietly();
if (form != null) await _saveFormQuietly();
}
}
@@ -319,6 +445,11 @@ class SessionState {
await coordinator.connect(params);
if (op != _op) throw const ConnectCancelled();
route.value = AppRoute.session;
serverLabel.value = '';
unawaited(probeHealth(params.host, '${params.port}', defaultHealthGet).then((h) {
// 'server' is the dedicated server's default --name: not a name.
if (op == _op && h.online && h.name.isNotEmpty && h.name != 'server') serverLabel.value = h.name;
}).catchError((Object _) {}));
} finally {
await _saveFormQuietly();
}
@@ -331,16 +462,9 @@ class SessionState {
try {
await coordinator.cancelConnect();
} catch (_) {}
await stopServer();
}
/// A kicked host must not keep serving.
void _watchKick() {
_stopKickWatch?.call();
_stopKickWatch = effect(() {
// Microtask: stopServer disposes this effect, not from inside it.
if (coordinator.kickReason.value != null) scheduleMicrotask(stopServer);
});
// Solo becoming hosted: [host] stops what it started and goes back
// to solo itself.
if (!switching.peek()) await stopServer();
}
/// Saved whatever the outcome: a failed join is exactly when the user
@@ -368,8 +492,6 @@ class SessionState {
Future<void> stopServer() async {
coordinator.hostFile = null;
_stopKickWatch?.call();
_stopKickWatch = null;
final server = _server;
_server = null;
await server?.stop();
@@ -378,7 +500,18 @@ class SessionState {
Future<void> leave() async {
await coordinator.leave();
await stopServer();
solo.value = false;
coordinator.solo = false;
serverLabel.value = '';
status.value = const HealthStatus(HealthSeverity.info, 'idle');
route.value = AppRoute.home;
}
}
/// 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;
}
+104 -2
View File
@@ -18,6 +18,15 @@ const _keyShaderQuality = 'shaderQuality';
const _keyCustomShaders = 'customShaders';
const _keyAutoAdvance = 'autoAdvance';
const _keyMpvControls = 'mpvControls';
const _keyHideReReleases = 'hideReReleases';
const _keyProgressLine = 'progressLine';
const _keySoundVolume = 'soundVolume';
const _keySoundsOff = 'soundsOff';
const _keySoundPreset = 'soundPreset';
const _keyWelcomeDismissed = 'welcomeDismissed';
const _keySoundPresetFor = 'soundPresetFor';
const _keyUnreadyOnSwap = 'unreadyOnSwap';
const _keyIdleSitsOut = 'idleSitsOut';
enum HwdecMode { auto, off, custom }
@@ -29,9 +38,31 @@ class AppSettings {
final autoCheckUpdates = signal(true);
/// Jellyfin episodes: at the end, count down 10 s and put up the next.
final autoAdvance = signal(true);
/// Experimental: the session uses the mpv-style (uosc) controls
/// (`controls/uosc/`) instead of media_kit's. Applies live.
/// Session: send ready=false when the video changes (a swap keeps the
/// server's ready flags).
final unreadyOnSwap = signal(false);
/// Session: the idle auto-unready sits this member out instead.
final idleSitsOut = signal(false);
/// Player design: the uosc controls (`controls/uosc/`) instead of the
/// standard ones (media_kit's with our bars). Applies live.
final mpvControls = signal(false);
/// Library: leave out of "Latest in X" what was added long after it
/// first aired (re-uploads, old seasons added late).
final hideReReleases = signal(true);
/// A 2 px progress line at the bottom of the video while the controls
/// are hidden (both designs). Applies live.
final progressLine = signal(true);
/// App sounds (`sfx/`): 0..1, 0 = silent.
final soundVolume = signal(1.0);
/// `SfxKind` names switched off one by one.
final soundsOff = signal<Set<String>>(const {});
/// The sound set (`SoundPreset` name) every sound uses unless
/// [soundPresetFor] picks another one for it.
final soundPreset = signal('cosmic');
/// The offline welcome band's × was pressed: never show it again.
final welcomeDismissed = signal(false);
/// `SfxKind` name -> `SoundPreset` name, per-sound overrides.
final soundPresetFor = signal<Map<String, String>>(const {});
/// Video shaders: one choice, used on join and changed both here and
/// in the session (Ctrl+0..6, the shader button).
@@ -68,8 +99,31 @@ class AppSettings {
if (auto is bool) autoCheckUpdates.value = auto;
final advance = box.get(_keyAutoAdvance);
if (advance is bool) autoAdvance.value = advance;
final unready = box.get(_keyUnreadyOnSwap);
if (unready is bool) unreadyOnSwap.value = unready;
final idleOut = box.get(_keyIdleSitsOut);
if (idleOut is bool) idleSitsOut.value = idleOut;
final mpv = box.get(_keyMpvControls);
if (mpv is bool) mpvControls.value = mpv;
final hideRe = box.get(_keyHideReReleases);
if (hideRe is bool) hideReReleases.value = hideRe;
final line = box.get(_keyProgressLine);
if (line is bool) progressLine.value = line;
final vol = box.get(_keySoundVolume);
if (vol is num) soundVolume.value = vol.toDouble().clamp(0, 1);
final off = box.get(_keySoundsOff);
if (off is List) soundsOff.value = Set.unmodifiable(off.whereType<String>());
final welcomeOff = box.get(_keyWelcomeDismissed);
if (welcomeOff is bool) welcomeDismissed.value = welcomeOff;
final soundSet = box.get(_keySoundPreset);
if (soundSet is String) soundPreset.value = soundSet;
final per = box.get(_keySoundPresetFor);
if (per is Map) {
soundPresetFor.value = Map.unmodifiable({
for (final e in per.entries)
if (e.key is String && e.value is String) e.key as String: e.value as String,
});
}
final preset = box.get(_keyShaderPreset);
shaderPreset.value =
ShaderPreset.values.where((p) => p.name == preset).firstOrNull ??
@@ -107,11 +161,59 @@ class AppSettings {
_put(_keyAutoAdvance, on);
}
void setUnreadyOnSwap(bool on) {
unreadyOnSwap.value = on;
_put(_keyUnreadyOnSwap, on);
}
void setIdleSitsOut(bool on) {
idleSitsOut.value = on;
_put(_keyIdleSitsOut, on);
}
void setMpvControls(bool on) {
mpvControls.value = on;
_put(_keyMpvControls, on);
}
void setProgressLine(bool on) {
progressLine.value = on;
_put(_keyProgressLine, on);
}
void setSoundVolume(double v) {
soundVolume.value = v.clamp(0, 1);
_put(_keySoundVolume, soundVolume.value);
}
void setSoundOn(String kind, bool on) {
soundsOff.value = Set.unmodifiable(on ? ({...soundsOff.value}..remove(kind)) : {...soundsOff.value, kind});
_put(_keySoundsOff, soundsOff.value.toList());
}
void dismissWelcome() {
welcomeDismissed.value = true;
_put(_keyWelcomeDismissed, true);
}
void setSoundPreset(String preset) {
soundPreset.value = preset;
_put(_keySoundPreset, preset);
}
/// [preset] null: the sound follows [soundPreset] again.
void setSoundPresetFor(String kind, String? preset) {
final m = {...soundPresetFor.value};
preset == null ? m.remove(kind) : m[kind] = preset;
soundPresetFor.value = Map.unmodifiable(m);
_put(_keySoundPresetFor, m);
}
void setHideReReleases(bool on) {
hideReReleases.value = on;
_put(_keyHideReReleases, on);
}
void setShaderPreset(ShaderPreset preset) {
shaderPreset.value = preset;
_put(_keyShaderPreset, preset.name);
+122
View File
@@ -0,0 +1,122 @@
import 'dart:async';
import 'dart:io';
import 'dart:typed_data';
import 'package:flutter/foundation.dart';
import 'package:sync_relay_core/core.dart';
import 'sync_client.dart';
/// Member pictures by content hash (`state.members[].avatarHash`), from `/a/`.
class AvatarCache extends PictureCache {
AvatarCache(super.sync, {super.fetch})
: super(route: 'a', keep: 32, live: (s) => [for (final m in s.members) m.avatarHash]);
}
/// The current media's art by content hash (`state.media.art`), from `/i/`.
class ArtCache extends PictureCache {
ArtCache(super.sync, {super.fetch}) : super(route: 'i', keep: 8, live: artHashesOf);
/// Poster and banner hashes of [s]'s media.
static List<String> artHashesOf(StateBroadcast s) {
final art = s.media?['art'];
if (art is! Map) return const [];
return [for (final v in art.values) if (v is String) v];
}
}
/// Pictures by content hash, fetched once from `/<route>/<sid>/<token>/<hash>`
/// on the host the client dialled and kept in memory. A hash names fixed
/// bytes, so entries never go stale; listeners are notified when one
/// arrives. Hashes in each state ([live]) are fetched ahead of use.
class PictureCache extends ChangeNotifier {
final SyncClient sync;
/// First path segment of the picture route: 'a' (avatars), 'i' (art).
final String route;
/// Above this many pictures, ones not in the latest state go.
final int keep;
/// Fetches one picture; null on any failure. Injectable for tests.
final Future<Uint8List?> Function(Uri url) _fetch;
final Map<String, Uint8List> _bytes = {};
final Set<String> _inFlight = {};
final Map<String, DateTime> _failedAt = {};
StreamSubscription? _sub;
bool _disposed = false;
static const _retryAfter = Duration(seconds: 10);
PictureCache(this.sync,
{required this.route,
required this.keep,
required Iterable<String> Function(StateBroadcast state) live,
Future<Uint8List?> Function(Uri url)? fetch})
: _fetch = fetch ?? _httpGet {
_sub = sync.states.listen((s) {
final hashes = {for (final h in live(s)) if (h.isNotEmpty) h};
if (_bytes.length > keep) _bytes.removeWhere((h, _) => !hashes.contains(h));
hashes.forEach(_want);
});
}
/// The picture for [hash], or null (none, or not here yet: it is fetched
/// and listeners hear when it lands).
Uint8List? bytesFor(String hash) {
if (hash.isEmpty) return null;
final hit = _bytes[hash];
if (hit == null) _want(hash);
return hit;
}
void _want(String hash) {
if (_bytes.containsKey(hash) || _inFlight.contains(hash)) return;
final failed = _failedAt[hash];
if (failed != null && DateTime.now().difference(failed) < _retryAfter) return;
final ws = sync.wsUri;
final token = sync.token;
if (ws == null || token.isEmpty) return;
final url = Uri(
scheme: ws.scheme == 'wss' ? 'https' : 'http',
host: ws.host,
port: ws.port,
pathSegments: [route, 'default', token, hash]);
_inFlight.add(hash);
unawaited(_fetch(url).then((bytes) {
_inFlight.remove(hash);
if (_disposed) return;
if (bytes == null || bytes.isEmpty) {
_failedAt[hash] = DateTime.now();
return;
}
_failedAt.remove(hash);
_bytes[hash] = bytes;
notifyListeners();
}));
}
static Future<Uint8List?> _httpGet(Uri url) async {
final client = HttpClient()..connectionTimeout = const Duration(seconds: 10);
try {
final resp = await (await client.getUrl(url)).close().timeout(const Duration(seconds: 15));
if (resp.statusCode != 200) {
await resp.drain<void>();
return null;
}
final builder = BytesBuilder(copy: false);
await for (final chunk in resp.timeout(const Duration(seconds: 15))) {
builder.add(chunk);
}
return builder.takeBytes();
} catch (_) {
return null;
} finally {
client.close(force: true);
}
}
@override
void dispose() {
_disposed = true;
unawaited(_sub?.cancel());
super.dispose();
}
}
+10 -2
View File
@@ -43,6 +43,10 @@ bool localPlayAllowed(StateBroadcast state, String memberId) {
return false;
}
/// At `finished`, a player at most this far behind the end keeps playing
/// to it.
const finishSlackSecs = 3.0;
class StateDiff {
static const debounceWindow = Duration(seconds: 2);
@@ -73,7 +77,9 @@ class StateDiff {
}
// End of media: everyone stops where they are (at or just before the
// end). No seek to the very end and no drift correction.
// end). No seek to the very end and no drift correction. A player a
// little behind the server's clock plays its last moments out (mpv
// stops at the end by itself) instead of being cut short.
if (state.transport == 'finished') {
final first = _lastEventId == -1;
final changed = first ||
@@ -87,7 +93,9 @@ class StateDiff {
if (!changed) return out;
out.add(const RateAction(1.0));
if (first) out.add(SeekAction(serverPos > 1 ? serverPos - 1 : 0));
out.add(const TransportAction(false));
if (first || state.position - ownPosition > finishSlackSecs) {
out.add(const TransportAction(false));
}
return out;
}
Loaded 100 of 212 files, more files were not shown because too many files have changed in this diff. Show more